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.`;