Duration system: sustained effects expire at the start of the caster's turns; SLOW (movement 1, no number cards, attack every other turn), NO SPELL, MEDUSA (paralysis + damage immunity), INVISIBLE (1-in-4 hit roll), SHRINK (50% miss, movement 2). Doors: PICK LOCK and MASTER KEY (displayed, reusable) unlock adjacent doors until end of turn, REMOVE LOCK is permanent, JAM LOCK seals a door for everyone. Movement: TELEPORT (4 spaces through walls, ends movement), PASS THROUGH WALL charges, POWER RUN (life for spaces), SWAP (consumes movement), GO AWAY (knockback + lost turn), TELEPORT OPPONENT. Card warfare: CARD ERASURE (named), THOUGHT-STEAL (2 random via seeded RNG), TELEPATH (private hand reveal), POWER DRAIN (damage feeds the caster), SUDDEN DEATH, STONE DEAD, WIZARDBLADE (same-square, number-powered, stays displayed). Cast modifiers: AMPLIFY doubles power/duration (stackable x2), ADD permits two number cards, EXTEND doubles duration; REVERSE heals instead of harms but keeps secondary effects. Counters now also halve durations (BLUNT) and split them (REFLECTION). Security (from review findings): room codes and game seeds now come from node:crypto, and every seat gets a secret token — reclaiming a name in a room requires its token, closing the impersonation hole. 29 cards implemented; 51 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
301 lines
14 KiB
TypeScript
301 lines
14 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, "ugly");
|
|
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/);
|
|
});
|
|
});
|