Elimination destroys the evidence: a killed wizard's cards pass to the killer, a treasure-lost wizard's go to the discard — so the post-game reveal showed "empty-handed" for exactly the player whose hand everyone wants to see. Both elimination paths now snapshot the hand at the moment of death (finalHand), the finished view serves it, and the gallery marks those rows "as they fell" in script. Because rooms replay from the command log, already-finished games show their fallen hands too. Test kills a wizard holding a Fireball and a 6 and finds them in the reveal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
341 lines
15 KiB
TypeScript
341 lines
15 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
applyCommand,
|
|
activePlayer,
|
|
createGame,
|
|
boardView,
|
|
type Command,
|
|
type GameState,
|
|
type PlayerId,
|
|
} from "../src/game";
|
|
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
|
|
function newGame(seed = 42) {
|
|
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
|
|
}
|
|
|
|
function must(state: GameState, player: PlayerId, command: Command): GameState {
|
|
const result = applyCommand(state, player, command);
|
|
if (!result.ok) throw new Error(`command failed: ${result.error}`);
|
|
return result.state;
|
|
}
|
|
|
|
/** Test surgery: put a specific card into a player's hand (swapping one out). */
|
|
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T"): CardInstance {
|
|
const p = state.players.find((p) => p.id === playerId)!;
|
|
const instance = { instanceId: `${cardId}#${tag}`, cardId };
|
|
p.hand[0] = instance;
|
|
return instance;
|
|
}
|
|
|
|
/** Advance past round 1 (both players just end their turns). */
|
|
function toRound2(state: GameState): GameState {
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
expect(state.turn.round).toBe(2);
|
|
return state;
|
|
}
|
|
|
|
/** Put attacker and defender in mutual LOS (same square works for spells too). */
|
|
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
defender.position = { ...attacker.position };
|
|
return { attacker: attacker.id, defender: defender.id };
|
|
}
|
|
|
|
describe("attack spells", () => {
|
|
it("fireball does 5 flat damage when unopposed", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
expect(state.stack).not.toBeNull();
|
|
state = must(state, defender, { type: "pass" });
|
|
expect(state.stack).toBeNull();
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
|
|
});
|
|
|
|
it("fireball destroys carried magic stones only if damage gets through", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.hand[0] = { instanceId: "powerstone#T", cardId: "powerstone" };
|
|
d.hand[1] = { instanceId: "full-shield#T", cardId: "full-shield" };
|
|
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
// Full shield stops everything -> stones survive.
|
|
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "pass" });
|
|
const after = state.players.find((p) => p.id === defender)!;
|
|
expect(after.life).toBe(15);
|
|
expect(after.hand.some((c) => c.cardId === "powerstone")).toBe(true);
|
|
});
|
|
|
|
it("lightning blast deals number-card damage and stuns", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const lb = giveCard(state, attacker, "lightning-blast");
|
|
const a = state.players.find((p) => p.id === attacker)!;
|
|
a.hand[1] = { instanceId: "number-4#T", cardId: "number-4" };
|
|
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: lb.instanceId, numberInstanceId: "number-4#T",
|
|
target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "pass" });
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
expect(d.life).toBe(11);
|
|
expect(d.lostTurns).toBe(1);
|
|
|
|
// The stunned player's next turn is skipped.
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
expect(activePlayer(state).id).toBe(attacker); // defender was skipped
|
|
});
|
|
|
|
it("blunt halves damage rounding the result up; absorb takes 3 off", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
|
|
d.hand[1] = { instanceId: "absorb#T", cardId: "absorb" };
|
|
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "counteract", instanceId: "absorb#T" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "pass" });
|
|
// 5 -> blunt -> ceil(2.5)=3 -> absorb -> 0
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
|
|
// All damage stopped -> no stones logic, no stun, attack fully spent.
|
|
});
|
|
|
|
it("anti-anti nullifies a counteraction", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const a = state.players.find((p) => p.id === attacker)!;
|
|
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.hand[0] = { instanceId: "full-shield#T", cardId: "full-shield" };
|
|
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
|
|
state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
|
|
state = must(state, defender, { type: "pass" });
|
|
// Shield nullified: full 5 damage lands.
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
|
|
});
|
|
|
|
it("full reflection sends the whole spell back at the caster", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.hand[0] = { instanceId: "full-reflection#T", cardId: "full-reflection" };
|
|
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#T" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
state = must(state, defender, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
|
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
|
|
});
|
|
|
|
it("absorb spell nullifies the attack and takes the card into hand", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.hand[0] = { instanceId: "absorb-spell#T", cardId: "absorb-spell" };
|
|
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
state = must(state, defender, { type: "counteract", instanceId: "absorb-spell#T" });
|
|
const after = state.players.find((p) => p.id === defender)!;
|
|
expect(after.life).toBe(15);
|
|
expect(after.hand.some((c) => c.cardId === "fireball")).toBe(true);
|
|
expect(state.stack).toBeNull();
|
|
});
|
|
|
|
it("waterbolt splits number value between damage and knockback", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
// Stand defender 1 east of attacker with open space behind (find a spot):
|
|
// use same square then nudge — simplest robust arrangement: same square,
|
|
// knockback direction defaults to none, so instead place east if open.
|
|
defender.position = { ...attacker.position };
|
|
const wb = giveCard(state, attacker.id, "waterbolt");
|
|
const a = state.players.find((p) => p.id === attacker.id)!;
|
|
a.hand[1] = { instanceId: "number-4#T", cardId: "number-4" };
|
|
|
|
const bad = applyCommand(state, attacker.id, {
|
|
type: "cast", instanceId: wb.instanceId, numberInstanceId: "number-4#T",
|
|
target: { kind: "player", playerId: defender.id },
|
|
params: { damage: 1, knockback: 1 },
|
|
});
|
|
expect(bad.ok).toBe(false); // must total 4
|
|
|
|
state = must(state, attacker.id, {
|
|
type: "cast", instanceId: wb.instanceId, numberInstanceId: "number-4#T",
|
|
target: { kind: "player", playerId: defender.id },
|
|
params: { damage: 3, knockback: 1 },
|
|
});
|
|
state = must(state, defender.id, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(12);
|
|
});
|
|
|
|
it("requires line of sight for LOS attacks", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
// Find any cell without LOS from the attacker.
|
|
const view = boardView(state);
|
|
let hidden = null;
|
|
for (const key of Object.keys(view.cells)) {
|
|
const [x, y] = key.split(",").map(Number);
|
|
if (!hasLineOfSight(view, attacker.position, { x: x!, y: y! })) { hidden = { x: x!, y: y! }; break; }
|
|
}
|
|
expect(hidden).not.toBeNull();
|
|
defender.position = hidden!;
|
|
const fb = giveCard(state, attacker.id, "fireball");
|
|
const result = applyCommand(state, attacker.id, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("neutral spells", () => {
|
|
it("create wall then destroy wall, with collapse damage", () => {
|
|
let { state } = newGame();
|
|
const caster = activePlayer(state);
|
|
// Find an open edge adjacent to the caster.
|
|
const view = boardView(state);
|
|
let edge: { cell: typeof caster.position; side: Side } | null = null;
|
|
for (const side of ["N", "S", "E", "W"] as Side[]) {
|
|
const n = neighbor(caster.position, side);
|
|
if (view.cells[cellKey(n)] && !(edgeKey(caster.position, side) in view.edges)) {
|
|
edge = { cell: caster.position, side };
|
|
break;
|
|
}
|
|
}
|
|
expect(edge).not.toBeNull();
|
|
|
|
const cw = giveCard(state, caster.id, "create-wall");
|
|
state = must(state, caster.id, {
|
|
type: "cast", instanceId: cw.instanceId,
|
|
target: { kind: "edge", cell: edge!.cell, side: edge!.side },
|
|
});
|
|
expect(boardView(state).edges[edgeKey(edge!.cell, edge!.side)]).toBe("wall");
|
|
|
|
// Destroying it hurts anyone standing beside it — the caster is adjacent.
|
|
const dw = giveCard(state, caster.id, "destroy-wall");
|
|
state = must(state, caster.id, {
|
|
type: "cast", instanceId: dw.instanceId,
|
|
target: { kind: "edge", cell: edge!.cell, side: edge!.side },
|
|
});
|
|
expect(boardView(state).edges[edgeKey(edge!.cell, edge!.side)]).toBe("open");
|
|
expect(state.players.find((p) => p.id === caster.id)!.life).toBe(11);
|
|
});
|
|
|
|
it("neutrals do not consume the attack and multiple can be cast", () => {
|
|
let { state } = newGame();
|
|
const caster = activePlayer(state);
|
|
const s1 = giveCard(state, caster.id, "speed", "T1");
|
|
state = must(state, caster.id, { type: "cast", instanceId: s1.instanceId });
|
|
expect(state.turn.attackUsed).toBe(false);
|
|
const s2 = giveCard(state, caster.id, "speed", "T2");
|
|
state = must(state, caster.id, { type: "cast", instanceId: s2.instanceId });
|
|
expect(state.players.find((p) => p.id === caster.id)!.extraTurns).toBe(2);
|
|
});
|
|
|
|
it("speed grants an extra turn before play passes", () => {
|
|
let { state } = newGame();
|
|
const caster = activePlayer(state);
|
|
const sp = giveCard(state, caster.id, "speed");
|
|
state = must(state, caster.id, { type: "cast", instanceId: sp.instanceId });
|
|
state = must(state, caster.id, { type: "endTurn", draw: 0 });
|
|
expect(activePlayer(state).id).toBe(caster.id); // extra turn, same player
|
|
state = must(state, caster.id, { type: "endTurn", draw: 0 });
|
|
expect(activePlayer(state).id).not.toBe(caster.id);
|
|
});
|
|
});
|
|
|
|
describe("stack discipline", () => {
|
|
it("locks other commands while an attack is unresolved", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
|
|
// Attacker cannot move while awaiting the defender.
|
|
expect(applyCommand(state, attacker, { type: "move", direction: "N" }).ok).toBe(false);
|
|
// Defender cannot move either — only counteract or pass.
|
|
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
|
|
});
|
|
|
|
it("unimplemented cards refuse to cast with a clear error", () => {
|
|
let { state } = newGame();
|
|
const caster = activePlayer(state);
|
|
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/);
|
|
});
|
|
});
|
|
|
|
describe("speedstone", () => {
|
|
it("displaying it grants the extra movement point immediately", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const who = activePlayer(state).id;
|
|
giveCard(state, who, "speedstone");
|
|
expect(state.turn.movementAllowance).toBe(3);
|
|
state = must(state, who, { type: "cast", instanceId: "speedstone#T" });
|
|
expect(state.turn.movementAllowance).toBe(4);
|
|
// And it persists into later turns via the displayed stone.
|
|
state = must(state, who, { type: "endTurn", draw: 0 });
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
expect(activePlayer(state).id).toBe(who);
|
|
expect(state.turn.movementAllowance).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe("the post-game reveal", () => {
|
|
it("shows an eliminated player's hand as they fell, not their emptied one", async () => {
|
|
const { viewFor } = await import("../src/view");
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
defender.position = { ...attacker.position };
|
|
defender.life = 1;
|
|
defender.hand = [
|
|
{ instanceId: "fireball#J", cardId: "fireball" },
|
|
{ instanceId: "number-6#J", cardId: "number-6" },
|
|
];
|
|
state = must(state, attacker.id, { type: "punch", targetId: defender.id });
|
|
state = must(state, defender.id, { type: "pass" });
|
|
expect(state.phase).toBe("finished");
|
|
// The killer took the cards — but the reveal remembers the fallen hand.
|
|
const view = viewFor(state, attacker.id);
|
|
const fallen = view.revealedHands?.[defender.id] ?? [];
|
|
expect(fallen.map((c) => c.cardId).sort()).toEqual(["fireball", "number-6"]);
|
|
});
|
|
});
|