Expansion wave 4: 27 combat, curse, and utility cards

Fortune: GIFT FROM ABOVE (+3, no ceiling), GIFT FROM BELOW (a trap in
the deck — 3 damage on the draw, harmless in the opening deal).
Modifiers: POWER ATTACK burns life into any damage spell. Curses:
WEAKNESS (drop your treasure, take double, carry nothing), STRENGTH
(double physical dealt; the two cancel), WALKING DEAD (half a point
per space walked, forever), DISEASE (the victim becomes a carrier who
infects everyone in squares they enter), IDIOT (the victim shambles
toward their nearest own treasure, able only to counteract, until
they stand on it and ask "What am I doing here?"). Defense: EMPATHY
(attacks bite their caster too), FORCE FIELD (spell-stopping
counteraction). Mischief: MENTAL SWAP (trade whole hands), MENTAL
FORCE (march someone three spaces), BUTT-HEAD (become a goat, ram for
the distance charged), HEAVE-HO (throw your carried treasure as a
weapon), THIEF and SWAP-MEET (item larceny), CHAOS (all hands in a
pile, shuffled, redealt), ILLUSIONARY ATTACK (a fake spell that hurts
if believed), WARD (a trapped treasure bites its thief), REMOVE CURSE
(strip any duration spell, rolling to hit the shrunken or invisible),
SWARTHMORE'S ENCHANTMENT (+1 on an enchanted object). Space-time:
DIMENSIONAL WARP (paired step-through tokens), REDIRECTION (swap two
outer exits' wraparounds), BIG MAN (fills the corridor: no entry, no
punches, no casting past him), FEAR (nobody approaches within 3),
and the out-of-turn pair — INTERRUPT and OPPORTUNITY FIRE — which
open a one-action window in another player's turn. Only THUMB OF GOD
remains, awaiting its digital redesign. 117 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 23:01:42 -04:00
co-authored by Claude Fable 5
parent 3a35922a40
commit 06eea72ded
7 changed files with 969 additions and 14 deletions
+22 -5
View File
@@ -55,13 +55,15 @@
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
"killer-ooze", "rosebush", "dust-cloud", "fill-square-with-slime", "create-pit",
"handful-of-tacks", "glue", "safe", "trader", "stone-to-water", "boobytrap",
"dimensional-warp", "redirection",
]);
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",
"invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline",
]);
const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu"]);
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(
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
@@ -142,6 +144,7 @@
// duration self-spells wait so a number card can be attached.
const INSTANT = 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",
]);
@@ -197,7 +200,7 @@
}
return;
}
if (selectedCard?.cardId === "trader") {
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
if (!tradeFrom) { tradeFrom = cell; return; }
net.command({
type: "cast", instanceId: selectedCard.instanceId,
@@ -485,8 +488,11 @@
{#if selectedCard?.cardId === "boobytrap"}
— place 4 tokens ({trapCells.length}/4; the FIRST is the real trap)
{/if}
{#if selectedCard?.cardId === "trader"}
{tradeFrom ? "— now the second item square" : "— click the first item square"}
{#if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)}
{tradeFrom ? "— now the second square" : "— click the first square"}
{/if}
{#if selectedCard?.cardId === "power-attack"}
(select an attack first, then attach Power Attack via number input)
{/if}
{#if selectedCard?.cardId === "relocate-sector"}
{#if pendingSectorFrom}
@@ -543,6 +549,17 @@
{#if discardSelection.size > 0}
<button onclick={doDiscard}>Discard {discardSelection.size} selected</button>
{/if}
{#if isYourTurn && view.dimWarps.some((w) => {
const me = view.players.find((p) => p.id === view.you)!;
return (w.a.x === me.position.x && w.a.y === me.position.y) ||
(w.b.x === me.position.x && w.b.y === me.position.y);
})}
<button onclick={() => net.command({ type: "warpStep" })}>Step through the warp</button>
{/if}
{#if view.outOfTurnWindow?.playerId === view.you}
<div class="banner respond">Your interruption! Cast your spell (or pass).</div>
<button onclick={pass}>Pass (waste it)</button>
{/if}
{#if isYourTurn}
<button onclick={pickUp}>Pick up treasure</button>
<button onclick={drop}>Drop treasure</button>
+9
View File
@@ -128,6 +128,14 @@
{/if}
{/each}
<!-- dimensional warp tokens -->
{#each view.dimWarps as w, wi (wi)}
{#each [w.a, w.b] as tok, i (i)}
<circle cx={tok.x * CELL + CELL * 0.5} cy={tok.y * CELL + CELL * 0.5} r={CELL * 0.3}
class="dimwarp" />
{/each}
{/each}
<!-- boobytrap tokens: face-down for everyone (the caster knows the real one) -->
{#each view.boobytraps as trap, ti (ti)}
{#each trap.cells as tc, i (i)}
@@ -274,6 +282,7 @@
.safe { fill: #7d8894; stroke: #2f3844; stroke-width: 2; }
.trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; }
.trap-real { stroke: #d3352b; stroke-width: 2.5; }
.dimwarp { fill: none; stroke: #5b3f9e; stroke-width: 3.5; stroke-dasharray: 4 3; }
.ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; }
.illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
.creature { cursor: pointer; }
+36
View File
@@ -78,6 +78,42 @@ function humanize(e: GameEvent): string | null {
case "shadowUpkeep": return `The shadow drains its master (${e.lifeAfter} life left).`;
case "impScorches": return `The fire imp scorches ${e.player}!`;
case "monsterBoosted": return `The monster GROWS — its ${e.boost} doubles!`;
case "wandCharged": return `${e.player} charges a wand (${e.charges} charges).`;
case "wandUsed": return e.chargesLeft > 0 ? `The wand crackles (${e.chargesLeft} left).` : null;
case "wandExhausted": return `${e.player}'s wand crumbles to dust.`;
case "wallWarpedOpen": return `A section of wall shimmers out of existence!`;
case "wallsWarpedBack": return `The warped wall snaps back into place.`;
case "shoved": return `${e.player} is shoved bodily by ${e.by}!`;
case "webbed": return `${e.player} is tangled in sticky webs!`;
case "cardRetrieved": return `${e.player} plucks a card from the discard pile.`;
case "slippedInOoze": return `${e.player} slips flat on their face in the ooze!`;
case "struggledInOoze": return e.stood ? `${e.player} staggers upright.` : `${e.player} flounders in the ooze.`;
case "steppedOnTacks": return `${e.player} steps on tacks! OW OW OW.`;
case "jumpedPit": return `${e.player} leaps the pit!`;
case "fellInPit": return `${e.player} misjudges the jump and plummets in!`;
case "climbedFromPit": return e.success ? `${e.player} hauls themselves out of the pit.` : `${e.player} scrabbles at the pit walls in vain.`;
case "stuckInSlime": return `${e.player} squelches into the slime and sticks fast.`;
case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`;
case "boobytrapSprung": return `SNAP! ${e.player} finds the real boobytrap!`;
case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`;
case "safeCreated": return `A massive safe slams down around the loot.`;
case "safeOpened": return null;
case "itemsTraded": return `Two items blink and trade places.`;
case "stoneTurnedToWater": return `Stone runs like water — a wave crashes out!`;
case "handsSwapped": return `${e.a} and ${e.b} trade entire hands of cards!`;
case "handsScrambled": return `CHAOS! Every hand is thrown in a pile and redealt!`;
case "rammed": return `BAAA! ${e.attacker} turns into a goat and rams ${e.target} (${e.distance} spaces)!`;
case "treasureThrown": return `${e.attacker} HURLS their treasure (${e.distance} spaces)!`;
case "illusionBelieved": return e.believed ? `${e.player} flinches — the illusion feels real!` : `${e.player} laughs off the illusion.`;
case "itemStolen": return `${e.to} picks ${e.from}'s pocket.`;
case "itemsSwapped": return `${e.a} and ${e.b} swap items.`;
case "wardSprung": return `${e.owner}'s treasure was WARDED — it bites ${e.victim}!`;
case "curseRemoved": return `${e.caster} lifts a curse from ${e.target}.`;
case "objectEnchanted": return `An object gleams with Swarthmore's enchantment.`;
case "warpTokensPlaced": return `Two dimensional warp tokens hum to life.`;
case "warpStepped": return `${e.player} steps through the dimensional warp!`;
case "exitsRedirected": return `The maze's outer exits twist and reconnect!`;
case "outOfTurnWindow": return `${e.player} interrupts the flow of time (${e.kind === "interrupt" ? "Interrupt" : "Opportunity Fire"})!`;
case "trapRedrawnDuringDeal": return null;
case "died": return `${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;