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:
co-authored by
Claude Fable 5
parent
21dcd532a3
commit
c9524a37db
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user