Voluntary discards, and no card ever casts on the first click

The rulebook allows discarding cards purely to churn for new ones, but
the client had no way to start it: a "Discard cards..." action now
switches the hand into marking mode (with a "never mind" exit), and
confirming sends the discard. Alongside it, the hair-trigger casts are
gone: selecting a card never plays it anymore — untargeted spells and
stone displays get a Cast button in the hint bar, and a bare number
card gets an explicit "Play for +N movement" button. Every play is
now a two-step, which is the correct cost in a game with no undo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 23:35:02 -04:00
co-authored by Claude Fable 5
parent d7afe3a5ca
commit 42ea9b8f2c
+37 -17
View File
@@ -38,6 +38,8 @@
let rotateCW = $state(true);
/** Cards marked for discard. */
let discardSelection = $state<Set<string>>(new Set());
/** Voluntary discard mode: hand clicks mark cards instead of playing them. */
let discardMode = $state(false);
const view = $derived(net.view);
const isYourTurn = $derived(view !== null && view.activePlayerId === view.you && !view.stack);
@@ -61,9 +63,7 @@
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 SELF_CARDS = new Set([
"invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline",
]);
const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "swap-meet", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]);
const edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId));
const cellSelectMode = $derived(
@@ -111,7 +111,7 @@
net.command({ type: "counteract", instanceId: card.instanceId });
return;
}
if (youMustDiscard || discardSelection.size > 0) {
if (youMustDiscard || discardMode || discardSelection.size > 0) {
const next = new Set(discardSelection);
next.has(card.instanceId) ? next.delete(card.instanceId) : next.add(card.instanceId);
discardSelection = next;
@@ -135,24 +135,32 @@
}
selectedCard = card;
attachedNumber = null;
if (isNumberCard(card.cardId)) {
// A bare number card: play it for movement.
net.command({ type: "playNumberForMovement", instanceId: card.instanceId });
clearSelection();
return;
// Nothing casts on first click: every play is confirmed from the hint
// bar or by choosing a target. Misclicks cost nothing.
}
// Instant untargeted spells (and stone displays) cast immediately;
// duration self-spells wait so a number card can be attached.
const INSTANT = new Set([
/** Untargeted spells confirmed from the hint bar (with optional number). */
const CONFIRM_CAST = new Set([
"speed", "pass-through-wall", "reuse-spell", "ugly", "alter-ego", "lifesaver", "mad-dash",
"gift-from-above", "chaos", "interrupt", "opportunity-fire",
"bloodstone", "brainstone", "powerstone", "shadowstone",
"shieldstone", "soulstone", "speedstone", "visionstone",
"invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline",
]);
if (INSTANT.has(card.cardId)) {
net.command({ type: "cast", instanceId: card.instanceId });
function playNumberForMovement() {
if (!selectedCard) return;
net.command({ type: "playNumberForMovement", instanceId: selectedCard.instanceId });
clearSelection();
}
function startDiscardMode() {
clearSelection();
discardMode = true;
}
function cancelDiscardMode() {
discardMode = false;
discardSelection = new Set();
}
function castSelfWithNumber() {
@@ -351,6 +359,7 @@
function doDiscard() {
net.command({ type: "discard", instanceIds: [...discardSelection] });
discardSelection = new Set();
discardMode = false;
}
function endTurn() {
@@ -519,14 +528,19 @@
</div>
<div class="table-edge">
{#if selectedDef || youMustDiscard || discardSelection.size > 0}
{#if selectedDef || youMustDiscard || discardMode || discardSelection.size > 0}
<div class="hint-strip">
{#if youMustDiscard}
<span class="hint-alert">Hand over the limit — mark cards and discard.</span>
{:else if discardMode && discardSelection.size === 0}
<span>Mark the cards to throw away, then confirm.</span>
{/if}
{#if discardSelection.size > 0}
<button class="stamp tiny" onclick={doDiscard}>Discard {discardSelection.size} marked</button>
{/if}
{#if discardMode || (discardSelection.size > 0 && !youMustDiscard)}
<button class="hint-cancel" onclick={cancelDiscardMode}>never mind</button>
{/if}
{#if selectedDef}
<strong class="hint-name">{selectedDef.name}</strong>
{#if edgeSelectMode}<span>— click a wall line</span>{/if}
@@ -571,9 +585,14 @@
<label class="inline">life to trade <input class="num-input" type="number" min="1" max="10" bind:value={runPoints} /></label>
<button class="stamp tiny" onclick={castPowerRun}>Run!</button>
{/if}
{#if selectedCard && SELF_CARDS.has(selectedCard.cardId)}
{#if selectedCard && CONFIRM_CAST.has(selectedCard.cardId)}
<button class="stamp tiny" onclick={castSelfWithNumber}>
Cast{attachedNumber ? ` with the ${numberTotal}` : " (duration 1)"}
Cast{attachedNumber ? ` with the ${numberTotal}` : ""}
</button>
{/if}
{#if selectedCard && isNumberCard(selectedCard.cardId)}
<button class="stamp tiny" onclick={playNumberForMovement}>
Play for +{cardDef(selectedCard.cardId).value} movement
</button>
{/if}
<button class="hint-cancel" onclick={clearSelection}>cancel</button>
@@ -589,6 +608,7 @@
<button class="stamp" disabled={!treasureHere || carryingTreasure || view.turn.actionsEnded}
onclick={pickUp}>Pick up treasure</button>
<button class="stamp" disabled={!carryingTreasure} onclick={drop}>Drop treasure</button>
<button class="stamp" onclick={startDiscardMode}>Discard cards…</button>
<label class="inline draw-pick">
draw
<select bind:value={drawCount}>