Six answers to the first wave of player feedback

The turn slip says 'turn spent' instead of counting phantom moves once
actions end; the attacking card (or the counter being countered) shows
in the respond slip so counters are chosen with eyes open; the absorb
chronicle line names the card it swallowed — which answers the KTXQ
mystery, where an automaton's ABSORB SPELL took Blind into its hand and
Reuse Spell rightly found nothing to retrieve. POWER ATTACK gains its
missing UI: attach it like any modifier and pick the life to burn.
Hand cards drag to reorder (browser-local arrangement over the
server's authoritative contents), and the left-hand layout scales its
two columns to the space without ever growing a scrollbar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-30 15:47:37 -04:00
co-authored by Claude Fable 5
parent e8a7fa1b73
commit 9aaccf4f30
2 changed files with 94 additions and 6 deletions
+92 -4
View File
@@ -436,7 +436,57 @@
]);
const TWO_CELL_CARDS = new Set(["trader", "dimensional-warp", "redirection"]);
const CREATURE_TARGET_CARDS = new Set(["mega-monster"]);
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]);
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner", "power-attack"]);
/** The hand in the player's own order: drag a card onto another to take
* its place. The order lives only in this browser — the server's hand
* array is authority for contents, this for arrangement. */
let handOrder = $state<string[]>([]);
let draggingId = $state<string | null>(null);
const orderedHand = $derived.by(() => {
const hand = view?.yourHand ?? [];
const rank = new Map(handOrder.map((id, i) => [id, i]));
return [...hand].sort((a, b) =>
(rank.get(a.instanceId) ?? handOrder.length) - (rank.get(b.instanceId) ?? handOrder.length));
});
function dropCardOn(targetId: string) {
if (!draggingId || draggingId === targetId) return;
const ids = orderedHand.map((c) => c.instanceId);
const from = ids.indexOf(draggingId);
const to = ids.indexOf(targetId);
if (from === -1 || to === -1) return;
ids.splice(to, 0, ...ids.splice(from, 1));
handOrder = ids;
draggingId = null;
}
/** Hand-at-left card scale: the two columns grow to fill their column's
* width, capped so the full hand still fits without a scrollbar. */
let handEl = $state<HTMLDivElement | null>(null);
function fitHandCards(el: HTMLDivElement) {
if (!prefs.handLeft) { el.style.removeProperty("--card-zoom"); return; }
const cardW = 8.2 * 16, gap = 0.45 * 16, pad = 0.6 * 16;
const zoomW = (el.clientWidth - pad - gap) / (2 * cardW);
const rows = Math.max(1, Math.ceil((view?.yourHand.length ?? 7) / 2));
const cardH = 1.42 * cardW;
const maxH = window.innerHeight - 5 * 16;
const zoomH = (maxH - pad) / (rows * (cardH + gap));
el.style.setProperty("--card-zoom", String(Math.max(0.8, Math.min(zoomW, zoomH, 1.8)).toFixed(3)));
}
$effect(() => {
const el = handEl;
if (!el) return;
const ro = new ResizeObserver(() => fitHandCards(el));
ro.observe(el);
return () => ro.disconnect();
});
$effect(() => {
void view?.yourHand.length;
void prefs.handLeft;
const el = handEl;
if (el) requestAnimationFrame(() => fitHandCards(el));
});
/** POWER ATTACK: life points burned into the attached attack. */
let paPoints = $state(1);
const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]);
const attackVsWall = $derived(
@@ -486,6 +536,10 @@
else if (m.cardId === "add") cmd.addInstanceId = m.instanceId;
else if (m.cardId === "extend") cmd.extendInstanceId = m.instanceId;
else if (m.cardId === "around-the-corner") cmd.aroundCornerInstanceId = m.instanceId;
else if (m.cardId === "power-attack") {
cmd.powerAttackInstanceId = m.instanceId;
cmd.powerAttackPoints = paPoints;
}
}
}
@@ -2120,8 +2174,17 @@
<button class="hint-cancel" onclick={() => castReflectSwap("none")}>swap nothing</button>
{:else if view.stack?.defenderId === view.you}
Under attack — respond by your hand.
{#if view.stack.attackCard}
<span class="respond-card"><Card card={view.stack.attackCard} onclick={() => {}} /></span>
{/if}
{:else}
Your spell is countered — respond by your hand.
{#if view.stack?.counters.length}
{@const latest = [...view.stack.counters].reverse().find((c) => !c.nullified)}
{#if latest}
<span class="respond-card"><Card card={latest.card} onclick={() => {}} /></span>
{/if}
{/if}
{/if}
</div>
{:else if view.stack}
@@ -2131,7 +2194,11 @@
{:else if isYourTurn}
<div class="slip yours">
Your turn — round {view.turn.round}.
{#if view.turn.actionsEnded}
turn spent — end it when ready
{:else}
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""}
{/if}
</div>
{:else}
<div class="slip">{view.activePlayerId} is taking their turn…</div>
@@ -2386,6 +2453,15 @@
{#if attachedMods.length > 0}
<span class="hint-mods">[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]</span>
{/if}
{#if attachedMods.some((m) => m.cardId === "power-attack")}
<label class="inline">burn
<select class="tier-pick" bind:value={paPoints} aria-label="life points to burn">
{#each [1, 2, 3, 4, 5] as n (n)}
{#if me && n < me.life}<option value={n}>{n}</option>{/if}
{/each}
</select>
life for +{paPoints} damage</label>
{/if}
{#if selectedCard?.cardId === "boobytrap"}
<span> place 4 tokens ({trapCells.length}/4; the first is real)</span>
{/if}
@@ -2589,11 +2665,18 @@
</div>
{/if}
<div class="hand" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
<div class="hand" bind:this={handEl} class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
{#if net.spectating}
<span class="gallery-note">👁 You watch from the Peanut Gallery hands stay secret, even from you.</span>
{/if}
{#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)}
{#each view.phase === "finished" ? [] : orderedHand as card (card.instanceId)}
<div class="hand-slot"
draggable="true"
role="listitem"
ondragstart={(e) => { draggingId = card.instanceId; e.dataTransfer?.setData("text/plain", card.instanceId); }}
ondragover={(e) => e.preventDefault()}
ondrop={(e) => { e.preventDefault(); dropCardOn(card.instanceId); }}
ondragend={() => (draggingId = null)}>
<Card
{card}
selected={selectedCard?.instanceId === card.instanceId ||
@@ -2608,6 +2691,7 @@
onclick={() => selectCard(card)}
onfaq={(id) => (faqCardId = id)}
/>
</div>
{/each}
</div>
</div>
@@ -2697,6 +2781,9 @@
}
.fb-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; }
.fb-thanks { color: #43331f; padding: 0.6rem 0; }
.respond-card { display: inline-block; vertical-align: middle; margin-left: 0.4rem; zoom: 0.72; }
.hand-slot { display: flex; cursor: grab; }
.hand-slot:active { cursor: grabbing; }
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
.mind-read { max-width: min(92vw, 46rem); }
.caution-reason { padding: 0.15rem 0; }
@@ -3121,8 +3208,9 @@
* Phones keep the bottom hand; the one-column flow re-orders anyway. */
@media (min-width: 901px) {
.game.hand-left {
grid-template-columns: minmax(17.4rem, 18.2rem) minmax(340px, 1fr) minmax(250px, 330px);
grid-template-columns: minmax(17.4rem, 24rem) minmax(340px, 1fr) minmax(250px, 330px);
}
.game.hand-left .hand :global(.card) { zoom: var(--card-zoom, 1); }
.game.hand-left .table-edge { display: contents; }
.game.hand-left .table-edge > * { grid-column: 2; min-width: 0; }
.game.hand-left .table-edge > .hand {
+1 -1
View File
@@ -45,7 +45,7 @@ export function humanize(e: GameEvent): string | null {
}
case "counteractionPlayed": return `${e.player} counters with ${cardDef(e.cardId).name}!`;
case "counterNullified": return `${cardDef(e.card.cardId).name} is nullified by Anti-Anti!`;
case "attackAbsorbedIntoHand": return `${e.player} absorbs the spell into their hand!`;
case "attackAbsorbedIntoHand": return `${e.player} absorbs ${cardDef(e.attackCard.cardId).name} into their hand — the card is theirs now!`;
case "attackResolved":
if (e.redirected) return `The spell is reflected back at ${e.attacker}!`;
if (e.fullyStopped) return `The attack is completely stopped.`;