From c9524a37dbc1b07793042de111eaaac746c321a1 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 15 Aug 2026 23:05:47 -0400 Subject: [PATCH] Thumb Of God lands: the 6th edition is 100% implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eric chose the "divine meteor" redesign for the one card that cannot be digitized faithfully (the physical version has you flick the die at the board from six inches). Digital form: aim at a square in sight; the die drifts 0-2 squares in a random direction, then every token in and around the landing square — ground objects, treasures, creatures, and wizards alike — is flung to a random nearby square. Walls mean nothing to falling cardboard; tokens knocked off the board settle at the nearest edge, per the original card; there is no counteraction. With this, all 128 unique cards of the 6th edition game (69 basic + 59 Expansion Set #1) are implemented, tested, and playable online. The "unimplemented card" guard test now points at an Expansion #2 shelf card, which is the only kind left. 119 tests passing. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 100 ++++++++++++++++++ packages/engine/test/casting.test.ts | 2 +- packages/engine/test/expansion-combat.test.ts | 20 ++++ packages/web/src/App.svelte | 2 +- packages/web/src/net.svelte.ts | 2 + 5 files changed, 124 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index b152b94..fd930b3 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -485,6 +485,8 @@ export type GameEvent = | { type: "warpStepped"; player: PlayerId; from: Cell; to: Cell } | { type: "exitsRedirected"; caster: PlayerId } | { type: "outOfTurnWindow"; player: PlayerId; kind: "interrupt" | "opportunity-fire" } + | { type: "thumbOfGod"; caster: PlayerId; aimedAt: Cell; landedAt: Cell } + | { type: "tokenScattered"; what: string; from: Cell; to: Cell } | { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean } | { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string } | { type: "doorsRelocked"; count: number } @@ -2062,6 +2064,104 @@ const CARD_EFFECTS: Record kind: "neutral", resolve: () => "played out of turn — use it during another player's turn", }, + "thumb-of-god": { + kind: "neutral", + // Digital redesign ("divine meteor", chosen by the owner): aim at a + // square; the die drifts 0-2 squares in a random direction, then every + // token in and around the landing square — objects, treasures, creatures, + // even wizards — is flung to a random nearby square. Walls mean nothing + // to falling cardboard. "There is no COUNTERACTION against this spell." + resolve: (state, events, caster, cmd) => { + const pre = attackPreconditions(state); + if (pre) return pre; + if (!cmd.target || cmd.target.kind !== "cell") return "aim the die at a square"; + const aim = cmd.target.cell; + const view = boardView(state); + if (!view.cells[cellKey(aim)]) return "off the board"; + if (!casterLos(state, caster, caster.position, aim, events)) return "no line of sight"; + state.turn.attackUsed = true; + + const clampToBoard = (c: Cell): Cell => { + if (view.cells[cellKey(c)]) return c; + // knocked off the board: settle at the nearest on-board cell + let best: Cell = aim; + let bestD = Infinity; + for (const key of Object.keys(view.cells)) { + const [x, y] = key.split(",").map(Number) as [number, number]; + const d = Math.abs(x - c.x) + Math.abs(y - c.y); + if (d < bestD) { bestD = d; best = { x, y }; } + } + return best; + }; + + // Drift: 1 = dead on; 2-3 = one square off; 4 = two squares off. + let landed = aim; + { + const [d1, r1] = rollDie(state.rng); + state.rng = r1; + const drift = d1 === 1 ? 0 : d1 === 4 ? 2 : 1; + if (drift > 0) { + const [d2, r2] = rollDie(state.rng); + state.rng = r2; + const dir = SIDES[d2 - 1]!; + landed = clampToBoard({ + x: aim.x + (dir === "E" ? drift : dir === "W" ? -drift : 0), + y: aim.y + (dir === "S" ? drift : dir === "N" ? -drift : 0), + }); + } + } + events.push({ type: "thumbOfGod", caster: caster.id, aimedAt: aim, landedAt: landed }); + + const inBlast = (c: Cell) => + Math.abs(c.x - landed.x) <= 1 && Math.abs(c.y - landed.y) <= 1; + const scatterTo = (from: Cell): Cell => { + const [d, rNext] = rollDie(state.rng); + state.rng = rNext; + const dir = SIDES[d - 1]!; + const [d2, rNext2] = rollDie(state.rng); + state.rng = rNext2; + const dist = d2 <= 2 ? 1 : 2; + return clampToBoard({ + x: from.x + (dir === "E" ? dist : dir === "W" ? -dist : 0), + y: from.y + (dir === "S" ? dist : dir === "N" ? -dist : 0), + }); + }; + const safeCell = (c: Cell): Cell => + state.squareContents[cellKey(c)]?.kind === "stone" ? landed : c; + + for (const [key, objs] of Object.entries({ ...state.groundObjects })) { + const [x, y] = key.split(",").map(Number) as [number, number]; + if (!inBlast({ x, y })) continue; + delete state.groundObjects[key]; + for (const o of objs) { + const to = safeCell(scatterTo({ x, y })); + state.groundObjects[cellKey(to)] = [...(state.groundObjects[cellKey(to)] ?? []), o]; + events.push({ type: "tokenScattered", what: o.cardId, from: { x, y }, to }); + } + } + for (const t of state.treasures) { + if (!t.position || !inBlast(t.position)) continue; + const from = t.position; + t.position = safeCell(scatterTo(from)); + events.push({ type: "tokenScattered", what: t.id, from, to: t.position }); + } + for (const c of state.creatures) { + if (!inBlast(c.position)) continue; + const from = c.position; + c.position = safeCell(scatterTo(from)); + events.push({ type: "tokenScattered", what: c.kind, from, to: c.position }); + } + for (const p of state.players) { + if (!p.alive || !inBlast(p.position)) continue; + if (isLockedInPlace(state, p.id)) continue; + const from = p.position; + p.position = safeCell(scatterTo(from)); + events.push({ type: "tokenScattered", what: p.id, from, to: p.position }); + } + checkVictory(state, events); + return null; + }, + }, "swap-home-bases": { kind: "neutral", // "Swap your home base with any other player, as long as you both have an diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 598e131..e8d1941 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -292,7 +292,7 @@ describe("stack discipline", () => { it("unimplemented cards refuse to cast with a clear error", () => { let { state } = newGame(); const caster = activePlayer(state); - const card = giveCard(state, caster.id, "thumb-of-god"); // awaiting digital redesign + const card = giveCard(state, caster.id, "bomb"); // expansion2: historical, never implemented const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toMatch(/not implemented/); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 6665a41..60a0031 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -226,3 +226,23 @@ describe("swap home bases", () => { expect(cellKey(state.players.find((p) => p.id === other.id)!.home)).toBe(cellKey(myHome)); }); }); + +describe("thumb of god (divine meteor)", () => { + it("scatters every token near where the die lands", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state); + // Sprinkle the blast zone: a ground object and the enemy nearby. + const enemy = state.players.find((p) => p.id !== me.id)!; + enemy.position = { ...me.position }; + state.groundObjects[cellKey(me.position)] = [{ instanceId: "dagger#G", cardId: "dagger" }]; + const tog = giveCard(state, me.id, "thumb-of-god"); + state = must(state, me.id, { + type: "cast", instanceId: tog.instanceId, target: { kind: "cell", cell: me.position }, + }); + expect(state.turn.attackUsed).toBe(true); + // The dagger moved somewhere on the board. + const allObjects = Object.values(state.groundObjects).flat(); + expect(allObjects.some((c) => c.cardId === "dagger")).toBe(true); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index a750c57..d181f35 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -55,7 +55,7 @@ "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", + "dimensional-warp", "redirection", "thumb-of-god", ]); const TWO_CELL_CARDS = new Set(["trader", "dimensional-warp", "redirection"]); const CREATURE_TARGET_CARDS = new Set(["mega-monster"]); diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 0dabc36..a15eeef 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -114,6 +114,8 @@ function humanize(e: GameEvent): string | null { 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 "thumbOfGod": return `THE THUMB OF GOD descends! The die crashes down${e.aimedAt.x === e.landedAt.x && e.aimedAt.y === e.landedAt.y ? " dead on target" : " — and drifts"}!`; + case "tokenScattered": return `${e.what} goes flying!`; 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.`;