Thumb Of God lands: the 6th edition is 100% implemented

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 23:05:47 -04:00
co-authored by Claude Fable 5
parent 21dcd532a3
commit c9524a37db
5 changed files with 124 additions and 2 deletions
+100
View File
@@ -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<string, AttackEffect | NeutralEffect | CounterEffect>
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