Expansion wave 1: the creature system and first eight monster cards

Creatures are first-class citizens: TROLL (D4 punches, 6 damage to
kill, regenerates at its creator's turn end), SKELETON (2-point
punches), WRAITH (walks through one wall a turn; its touch deals 2
and steals a random card), FIRE IMP (a stationary turret scorching
anyone in sight once per turn — including its creator — killed only
by Waterbolt or a Waterwall wave), DEMOCRATIC MONSTER (moved three
spaces by EVERY player on their turn, one claw per round), SHADOW (a
second body costing a life point per turn, destroyed by any damage),
and ALTER EGO (a stationary double). Monsters obey their creators,
move on the controller's turn, attack once per turn but never on
their creation turn (summoning IS your attack), refuse to strike
their creators, and vanish when their creator dies. Attacks can
target creatures directly (no counteraction window — monsters don't
counter); Dispel Creation un-creates them. Plus MEGA-MONSTER (double
a monster's toughness or speed), ADRENALINE (two attacks a turn),
MAD DASH, and LIFESAVER. Expansion Set #2 confirmed by Eric as a
5e-era product — marked historical-only in the data; the 6e game is
exactly base + Expansion #1 (200 cards, all verified). Lobby gains an
"include Expansion Set #1" toggle; the client renders creatures as
diamond tokens with select-move-attack interaction. 95 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 22:41:54 -04:00
co-authored by Claude Fable 5
parent e475253fb0
commit df0675c735
10 changed files with 872 additions and 14 deletions
+56 -2
View File
@@ -9,6 +9,9 @@
let name = $state("");
let joinCode = $state("");
let drawCount = $state(2);
let withExpansion = $state(true);
/** Your creature selected for movement/attacks. */
let selectedCreature = $state<string | null>(null);
/** Card selected in hand, pending a target. */
let selectedCard = $state<CardInstance | null>(null);
@@ -44,7 +47,9 @@
const CELL_CARDS = new Set([
"teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag",
"rotate-sector", "relocate-sector",
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
]);
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",
@@ -57,6 +62,7 @@
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
function clearSelection() {
selectedCreature = null;
selectedCard = null;
attachedNumber = null;
attachedMods = [];
@@ -126,7 +132,7 @@
// Instant untargeted spells (and stone displays) cast immediately;
// duration self-spells wait so a number card can be attached.
const INSTANT = new Set([
"speed", "pass-through-wall", "reuse-spell", "ugly",
"speed", "pass-through-wall", "reuse-spell", "ugly", "alter-ego", "lifesaver", "mad-dash",
"bloodstone", "brainstone", "powerstone", "shadowstone",
"shieldstone", "soulstone", "speedstone", "visionstone",
]);
@@ -190,6 +196,21 @@
clearSelection();
return;
}
if (selectedCreature) {
const creature = view.creatures.find((c) => c.id === selectedCreature);
if (creature) {
for (const side of SIDES) {
const n = { x: creature.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: creature.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (cellKey(n) === cellKey(cell)) {
net.command({ type: "moveCreature", creatureId: selectedCreature, direction: side });
return;
}
}
}
selectedCreature = null;
return;
}
const me = view.players.find((p) => p.id === view.you)!;
// A cell click is a move if the cell is one legal step away (the server
// also lets doors/walls pass when unlocked/misted — try the direction).
@@ -222,8 +243,38 @@
clearSelection();
}
function clickCreature(creatureId: string) {
if (!view || !isYourTurn) return;
const creature = view.creatures.find((c) => c.id === creatureId);
if (!creature) return;
if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
net.command({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "creature", creatureId },
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
});
clearSelection();
return;
}
if (selectedCreature && selectedCreature !== creatureId) {
// Your selected creature attacks another creature in its square.
net.command({ type: "creatureAttack", creatureId: selectedCreature, targetId: creatureId });
selectedCreature = null;
return;
}
const mine = creature.controllerId === view.you || creature.kind === "democratic-monster";
if (mine) {
selectedCreature = selectedCreature === creatureId ? null : creatureId;
}
}
function clickPlayer(playerId: string) {
if (!view || !isYourTurn) return;
if (selectedCreature) {
net.command({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
selectedCreature = null;
return;
}
if (!selectedCard) {
// No card selected: same-square click = punch.
const me = view.players.find((p) => p.id === view.you)!;
@@ -307,9 +358,10 @@
{/each}
</ul>
{#if net.you === net.hostId}
<label><input type="checkbox" bind:checked={withExpansion} /> include Expansion Set #1 (monsters &amp; wands)</label>
<button
disabled={net.players.length !== 2 && net.players.length !== 4}
onclick={() => net.start()}
onclick={() => net.start(withExpansion)}
>
Start game ({net.players.length} wizards — need 2 or 4)
</button>
@@ -323,9 +375,11 @@
<Board
{view}
edgeSelectMode={edgeSelectMode && isYourTurn}
selectedCreatureId={selectedCreature}
onCellClick={clickCell}
onEdgeClick={clickEdge}
onPlayerClick={clickPlayer}
onCreatureClick={clickCreature}
/>
</div>
+30
View File
@@ -8,15 +8,19 @@
let {
view,
edgeSelectMode = false,
selectedCreatureId = null,
onCellClick,
onEdgeClick,
onPlayerClick,
onCreatureClick,
}: {
view: GameView;
edgeSelectMode?: boolean;
selectedCreatureId?: string | null;
onCellClick?: (cell: { x: number; y: number }) => void;
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
onPlayerClick?: (playerId: string) => void;
onCreatureClick?: (creatureId: string) => void;
} = $props();
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
@@ -187,6 +191,28 @@
{/each}
{/each}
<!-- creatures -->
{#each view.creatures as c (c.id)}
{@const ccx = c.position.x * CELL + CELL * 0.72}
{@const ccy = c.position.y * CELL + CELL * 0.7}
<g
role="button" tabindex="-1" class="creature"
onclick={(ev) => { ev.stopPropagation(); onCreatureClick?.(c.id); }}
onkeydown={() => {}}
>
<rect
x={ccx - 10} y={ccy - 10} width={20} height={20} rx="3"
transform={`rotate(45 ${ccx} ${ccy})`}
class="creature-body"
class:selected={c.id === selectedCreatureId}
stroke={playerColor(c.controllerId)}
>
<title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</rect>
<text x={ccx} y={ccy + 4} class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
</g>
{/each}
<!-- edge selection hitboxes -->
{#each edgeHitboxes as h (`${h.cell.x},${h.cell.y},${h.side}`)}
<rect
@@ -221,6 +247,10 @@
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
.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; }
.creature-body { fill: #3b3428; stroke-width: 2.5; }
.creature-body.selected { fill: #6b5a34; }
.creature-label { font-size: 11px; font-weight: bold; fill: #f4ead2; text-anchor: middle; pointer-events: none; }
.home { font-size: 26px; text-anchor: middle; dominant-baseline: middle; opacity: 0.85; }
.treasure { stroke: #111; stroke-width: 1.2; }
.warp { font-size: 13px; text-anchor: middle; fill: #6a5f4b; font-weight: bold; }
+12 -2
View File
@@ -68,6 +68,16 @@ function humanize(e: GameEvent): string | null {
: `${e.player} is convinced the wall is real.`;
case "sectorRotated": return `The maze GRINDS — a sector rotates ${e.clockwise ? "clockwise" : "counterclockwise"}!`;
case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`;
case "creatureCreated": return `${e.controller} summons a ${e.kind.replace(/-/g, " ")}!`;
case "creatureMoved": return null;
case "creatureAttacked": return e.dieRoll != null ? `The troll swings (rolled ${e.dieRoll})!` : `The creature strikes!`;
case "creatureTouched": return `The creature falls upon ${e.player}!`;
case "creatureDamaged": return e.amount > 0 ? `The ${e.creatureId} takes ${e.amount} damage.` : `The attack has no effect on it.`;
case "creatureDestroyed": return `The ${e.kind.replace(/-/g, " ")} is destroyed (${e.by})!`;
case "trollRegenerated": return `The troll's stony hide knits itself back together.`;
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 "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.`;
@@ -150,8 +160,8 @@ class Net {
this.send({ type: "join", roomId, name, token: this.token });
}
start(): void {
this.send({ type: "start" });
start(expansion: boolean): void {
this.send({ type: "start", expansion });
}
command(command: Command): void {