The great simplification: every vintage gate collapses, the count restarts at 1

All 50 rooms were retired (backed up on the droplet and in Spaces), so no
ledger needs an old branch to replay: forty revisions of deckRev gates
fold into one canonical ruleset — the newest behavior everywhere. The
ward's arming, the idiot's steering, the lazy illusion roll, the legacy
redirection swap, and the flat wave all leave with their gates; RULES_REV
restarts at 1 for whenever the rules fork again. Old-vintage test pins go
with them. The opening screen now quietly drops seats whose rooms the
server no longer knows, instead of listing them "unreachable" forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-21 12:45:35 -04:00
co-authored by Claude Fable 5
parent e8df6efcde
commit 8a50ff7f36
19 changed files with 383 additions and 685 deletions
+16 -17
View File
@@ -33,7 +33,6 @@ function playOut(
playerIds: ids,
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
deckRev: 8,
});
let commands = 0;
let stuck = 0;
@@ -120,7 +119,7 @@ describe("automaton vs automaton", () => {
});
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"] });
// burn round 1
for (let i = 0; i < 2; i++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
@@ -175,7 +174,7 @@ describe("the clockwork guards gold only within sight", () => {
// A refused cast forfeits the bot's whole turn (the fallback is endTurn),
// so SAFE must only ever be offered where the engine's sight rule allows it.
function goldOnTheFloor() {
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"], deckRev: 36 });
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
@@ -227,7 +226,7 @@ describe("the clockwork guards gold only within sight", () => {
describe("the clockwork honors IDIOT's march", () => {
// Rev 37 retired the engine's steering, so the duty falls to the brain.
function cursedBot() {
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"], deckRev: 37 });
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
// Past round 1 (no combat) and around to the bot's turn.
for (let i = 0; i < 2; i++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
@@ -285,7 +284,7 @@ describe("the clockwork honors IDIOT's march", () => {
describe("a clogged hand gets shed, not hoarded", () => {
it("the bot discards dead weight so the end-of-turn draw has room", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 });
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
@@ -331,7 +330,7 @@ describe("the clockwork honors absorb's fine print", () => {
describe("the clockwork wields destroy wall", () => {
it("blasts its way out when no road leads to the gold", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
@@ -353,7 +352,7 @@ describe("the clockwork wields destroy wall", () => {
});
it("values destroy wall above the chaff when forced to discard", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
const bot = state.players.find((p) => p.id === "bot")!;
bot.hand = [
{ instanceId: "destroy-wall#T", cardId: "destroy-wall" },
@@ -376,7 +375,7 @@ describe("the clockwork wields destroy wall", () => {
describe("the clockwork wields pass through wall", () => {
it("banks a crossing when bricked in, then steps through the wall", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
@@ -408,7 +407,7 @@ describe("the clockwork wields pass through wall", () => {
describe("the clockwork denies the road", () => {
it("walls a thief's corridor when its treasure is being carried home", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"] });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
@@ -446,7 +445,7 @@ describe("the clockwork denies the road", () => {
describe("the widened spellbook", () => {
it("dispels a conjured wall standing between it and the only road", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
@@ -468,7 +467,7 @@ describe("the widened spellbook", () => {
});
it("offers a buddy pact to the hound at its heels", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
@@ -490,7 +489,7 @@ describe("the widened spellbook", () => {
describe("finishing tactics: exile and adrenaline", () => {
it("exiles a thief carrying its gold to the far end of nowhere", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
// burn round 1 and reach the bot's turn
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
@@ -518,7 +517,7 @@ describe("finishing tactics: exile and adrenaline", () => {
});
it("casts adrenaline when two blows finish what one cannot", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
@@ -543,7 +542,7 @@ describe("finishing tactics: exile and adrenaline", () => {
describe("the fireball-then-buddy lockout", () => {
it("burns the target, then signs the pact so they cannot hit back", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
@@ -584,7 +583,7 @@ describe("the fireball-then-buddy lockout", () => {
describe("a pact once signed is honored", () => {
it("will not attack the wizard it just buddied", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 25 });
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
@@ -610,7 +609,7 @@ describe("a pact once signed is honored", () => {
describe("no number is wasted on a turn that ends at a grab", () => {
it("gold two free steps away: the berserker charges without spending its 5", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 29 });
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"] });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
@@ -636,7 +635,7 @@ describe("no number is wasted on a turn that ends at a grab", () => {
describe("the clockwork flees the dread", () => {
it("caught in FEAR's bubble, it spends its legs moving away", () => {
let { state } = createGame({ playerIds: ["bot", "grim"], seed: 42, sets: ["basic"], deckRev: 32 });
let { state } = createGame({ playerIds: ["bot", "grim"], seed: 42, sets: ["basic"] });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
+3 -3
View File
@@ -201,7 +201,7 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
});
it("relocation discards the aisle warp: only opposite edges connect after", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 });
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"] });
const bent = (warps: typeof state.board.warps) =>
warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y);
expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists
@@ -244,7 +244,7 @@ describe("the aisle warp treats its corner as adjacent — sight included", () =
describe("bricking over a warp mouth", () => {
it("create wall seals a lettered opening: no passage, no sight", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 18 });
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
// round 1 passes
for (let i = 0; i < 2; i++) {
const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
@@ -283,7 +283,7 @@ describe("bricking over a warp mouth", () => {
describe("breaching the rim (rules rev 19)", () => {
it("destroying a perimeter wall opens both sides as a new warp", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 19 });
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
for (let i = 0; i < 2; i++) {
const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
+42 -111
View File
@@ -31,10 +31,10 @@ describe("attack spells", () => {
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.
// Full shield stops everything -> stones survive. (A total stop
// resolves on the attacker's pass; no bounce back.)
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);
@@ -111,7 +111,8 @@ describe("attack spells", () => {
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" });
// The returned spell is a fresh attack on its caster: their window.
state = must(state, attacker, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
});
@@ -308,16 +309,14 @@ describe("stored-log compatibility", () => {
});
describe("deck revisions", () => {
it("revision 2 removes LIFESAVER at two players, and only there", () => {
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
it("two-player games shed LIFESAVER; bigger tables keep it", () => {
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
const inGame = (s: typeof two.state) =>
[...s.deck, ...s.discard, ...s.players.flatMap((p) => p.hand)].some((c) => c.cardId === "lifesaver");
expect(inGame(two.state)).toBe(false);
// Three players keep it; old two-player games (no deckRev) keep it too.
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
// Three players keep it.
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"] });
expect(inGame(three.state)).toBe(true);
const legacy = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
expect(inGame(legacy.state)).toBe(true);
});
});
@@ -380,42 +379,31 @@ describe("attacking walls and doors", () => {
});
});
describe("ward arming and chaos shields (rules rev 3)", () => {
function rev3Game(seed = 42) {
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 });
describe("the ward window and chaos shields", () => {
function threeGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"] });
}
it("ward springs only when its owner armed it", () => {
let { state } = rev3Game();
it("the grab hangs while the ward's owner decides — and arming ahead is refused", () => {
let { state } = threeGame();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const thief = activePlayer(state);
const owner = state.players.find((p) => p.id !== thief.id)!;
giveCard(state, owner.id, "ward", "W", 0);
expect(applyCommand(state, owner.id, { type: "armWard", armed: true }).ok).toBe(false);
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
// Unarmed: the grab goes unpunished.
thief.position = { ...treasure.position! };
let s2 = must(state, thief.id, { type: "pickUpTreasure" });
expect(s2.players.find((p) => p.id === thief.id)!.life).toBe(15);
expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true);
// Armed (on the owner's own turn): the trap bites for 3.
const ownerTurnState = (() => {
let s = state;
while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 });
return s;
})();
let s3 = must(ownerTurnState, owner.id, { type: "armWard", armed: true });
while (activePlayer(s3).id !== thief.id) s3 = must(s3, activePlayer(s3).id, { type: "endTurn", draw: 0 });
s3.players.find((p) => p.id === thief.id)!.position = { ...treasure.position! };
s3 = must(s3, thief.id, { type: "pickUpTreasure" });
expect(s3.players.find((p) => p.id === thief.id)!.life).toBe(12);
expect(s3.wardArmed).not.toContain(owner.id);
state = must(state, thief.id, { type: "pickUpTreasure" });
expect(state.wardPending).toEqual({ ownerId: owner.id, takerId: thief.id });
// Spring it: the thief bleeds 3 and the ward is spent.
state = must(state, owner.id, { type: "wardChoice", play: true });
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
});
it("chaos: bystanders may shield out, reflections are refused", () => {
let { state } = rev3Game();
let { state } = threeGame();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const caster = activePlayer(state);
@@ -442,7 +430,7 @@ describe("ward arming and chaos shields (rules rev 3)", () => {
});
it("chaos: the defender's full shield sits them out without stopping it", () => {
let { state } = rev3Game();
let { state } = threeGame();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const caster = activePlayer(state);
@@ -458,7 +446,6 @@ describe("ward arming and chaos shields (rules rev 3)", () => {
});
state = must(state, defender.id, { type: "counteract", instanceId: "full-shield#S" });
state = must(state, caster.id, { type: "pass" }); // caster declines to anti-anti
state = must(state, defender.id, { type: "pass" }); // defender rests on the shield
// Bystander declines; the scramble happens without the defender.
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
state = must(state, bystander.id, { type: "pass" });
@@ -466,17 +453,6 @@ describe("ward arming and chaos shields (rules rev 3)", () => {
expect(defenderHand()).toEqual(kept);
});
it("legacy games (rev < 3) keep the automatic ward", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const thief = activePlayer(state);
const owner = state.players.find((p) => p.id !== thief.id)!;
giveCard(state, owner.id, "ward", "W", 0);
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
thief.position = { ...treasure.position! };
state = must(state, thief.id, { type: "pickUpTreasure" });
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
});
});
describe("zero-damage utility attacks", () => {
@@ -517,8 +493,8 @@ describe("zero-damage utility attacks", () => {
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
state = must(state, attacker, { type: "pass" });
const result = applyCommand(state, defender, { type: "pass" });
// A total stop settles on the attacker's pass.
const result = applyCommand(state, attacker, { type: "pass" });
if (!result.ok) throw new Error(result.error);
const resolved = result.events.find((e) => e.type === "attackResolved");
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(true);
@@ -539,33 +515,13 @@ describe("teleport as a counteraction", () => {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
// The escape is a total stop: the attacker's pass settles it.
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(cellKey(after.position)).toBe(cellKey(escape));
});
it("ANTI-ANTI pins the boots in pre-rev-6 games (their stored chains replay)", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
const tp = giveCard(state, defender, "teleport", "TP", 0);
const d = state.players.find((p) => p.id === defender)!;
const home = { ...d.position };
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: { x: home.x, y: home.y + 1 } } });
state = must(state, attacker, { type: "counteract", instanceId: aa.instanceId });
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(cellKey(after.position)).toBe(cellKey(home));
expect(after.life).toBe(10);
});
it("reaches at most four spaces", () => {
let { state } = newGame();
state = toRound2(state);
@@ -585,7 +541,7 @@ describe("teleport as a counteraction", () => {
describe("slime holds spells", () => {
function slimeRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const caster = activePlayer(state);
const victim = state.players.find((p) => p.id !== caster.id)!;
@@ -638,7 +594,6 @@ describe("slime holds spells", () => {
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, rig.caster, { type: "pass" });
state = must(state, rig.victim, { type: "pass" });
expect(state.players.find((p) => p.id === rig.caster)!.life).toBe(15);
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(15);
});
@@ -664,7 +619,7 @@ describe("slime holds spells", () => {
describe("answering counteractions (FAQ rulings)", () => {
function rev6() {
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 6 });
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
}
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
@@ -719,7 +674,6 @@ describe("answering counteractions (FAQ rulings)", () => {
const r = applyCommand(state, attacker, { type: "counteract", instanceId: aa.instanceId });
expect(r.ok).toBe(false);
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(cellKey(after.position)).toBe(cellKey(escape));
@@ -728,7 +682,7 @@ describe("answering counteractions (FAQ rulings)", () => {
describe("speed and warp-token creation (rules rev 8)", () => {
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const me = activePlayer(state);
const other = state.players.find((p) => p.id !== me.id)!;
@@ -747,7 +701,7 @@ describe("speed and warp-token creation (rules rev 8)", () => {
});
it("nothing can be created on a dimensional warp token", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 8 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const me = activePlayer(state);
const spot = { x: me.position.x + 1, y: me.position.y };
@@ -763,7 +717,7 @@ describe("speed and warp-token creation (rules rev 8)", () => {
describe("redirection (rules rev 9)", () => {
it("the two chosen exits connect; their old partners pair with each other", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 9 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const me = activePlayer(state);
// Pick two exits that are NOT already partners.
@@ -788,7 +742,7 @@ describe("redirection (rules rev 9)", () => {
describe("total stops end the exchange (rules rev 10)", () => {
function rev10Game() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
return { state, attacker, defender };
@@ -839,7 +793,7 @@ describe("total stops end the exchange (rules rev 10)", () => {
describe("escapes and elemental walls as counteractions", () => {
function rigged(attackId: string, defenderCards: { instanceId: string; cardId: string }[]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const atk = giveCard(state, attacker, attackId);
@@ -901,7 +855,7 @@ describe("escapes and elemental walls as counteractions", () => {
describe("empathy as a counteraction", () => {
function empathyRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 15 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
@@ -942,7 +896,7 @@ describe("empathy as a counteraction", () => {
describe("empathy mirrors resolution-hook blows (rules rev 16)", () => {
it("a believed illusion bites both wizards", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const ill = giveCard(state, attacker, "illusionary-attack");
@@ -963,9 +917,9 @@ describe("empathy mirrors resolution-hook blows (rules rev 16)", () => {
});
});
describe("the reflected-attack window (rules rev 25)", () => {
describe("the reflected-attack window", () => {
function reflectRig() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 25 });
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "sudden-death");
@@ -1007,26 +961,11 @@ describe("the reflected-attack window (rules rev 25)", () => {
expect(state.players.find((p) => p.id === defender)!.life).toBe(5);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(15);
});
it("rev 24 lands the reflected blow instantly, as stored games replay", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 24 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "sudden-death");
giveCard(state, defender, "full-reflection", "FR", 0);
state = must(state, attacker, {
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, attacker, { type: "pass" });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === attacker)!.life).toBe(5);
});
});
describe("a reflected lightning blast ends the caster's turn (rules rev 28)", () => {
function boltRig(deckRev: number) {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev });
describe("a reflected lightning blast ends the caster's turn", () => {
function boltRig() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const lb = giveCard(state, attacker, "lightning-blast");
@@ -1044,7 +983,7 @@ describe("a reflected lightning blast ends the caster's turn (rules rev 28)", ()
}
it("FAQ: 'you lose the rest of your turn and can't draw cards'", () => {
let { state, attacker } = boltRig(28);
let { state, attacker } = boltRig();
expect(state.players.find((p) => p.id === attacker)!.life).toBe(12);
expect(state.turn.actionsEnded).toBe(true);
// No more marching, and the end-of-turn draw comes up empty.
@@ -1056,19 +995,11 @@ describe("a reflected lightning blast ends the caster's turn (rules rev 28)", ()
expect(state.players.find((p) => p.id === attacker)!.lostTurns
+ (activePlayer(state).id === attacker ? 1 : 0)).toBeGreaterThanOrEqual(1);
});
it("rev 27 keeps the old behavior: the caster plays on and draws", () => {
let { state, attacker } = boltRig(27);
expect(state.turn.actionsEnded).toBe(false);
const before = state.players.find((p) => p.id === attacker)!.hand.length;
state = must(state, attacker, { type: "endTurn", draw: 2 });
expect(state.players.find((p) => p.id === attacker)!.hand.length).toBeGreaterThan(before);
});
});
describe("fireball burns only the stones in play (rules rev 33)", () => {
it("a displayed stone dies; a hidden one stays secret and safe", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 33 });
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
@@ -1086,7 +1017,7 @@ describe("fireball burns only the stones in play (rules rev 33)", () => {
describe("stone dead counts only the stones in play (rules rev 34)", () => {
it("hidden stones neither add damage nor betray their count", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 34 });
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "stone-dead");
+27 -55
View File
@@ -17,7 +17,8 @@ function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
describe("expansion deck", () => {
it("basic + expansion1 builds the full 200-card game", () => {
const { state } = newGame();
// Three seats: two-player games shed LIFESAVER from the build.
const { state } = newGame(42, ["a", "b", "c"]);
const total = state.deck.length + state.discard.length +
state.players.reduce((s, p) => s + p.hand.length, 0);
expect(total).toBe(200);
@@ -59,6 +60,8 @@ describe("monsters", () => {
state = must(state, me, {
type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: victim.id,
});
// The blow opens the victim's counteraction window; they take it raw.
state = must(state, victim.id, { type: "pass" });
expect(state.players.find((p) => p.id !== me)!.life).toBe(13);
});
@@ -136,6 +139,8 @@ describe("monsters", () => {
break;
}
}
// The touch opens the victim's counteraction window; they take it raw.
state = must(state, enemy2.id, { type: "pass" });
const bitten = state.players.find((p) => p.id !== me)!;
expect(bitten.life).toBe(13);
expect(bitten.hand.length).toBe(6);
@@ -293,9 +298,9 @@ describe("expansion support cards", () => {
});
});
describe("counteracting a creature's blow (rules rev 4)", () => {
describe("counteracting a creature's blow", () => {
function rev4() {
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 4 });
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
}
function wraithOnVictim(state: GameState) {
state = toRound2(state);
@@ -341,20 +346,11 @@ describe("counteracting a creature's blow (rules rev 4)", () => {
const wraith = state.creatures.find((c) => c.id === "w1")!;
expect(wraith.damage).toBe(2);
});
it("legacy games (rev < 4) keep the instant touch", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
const rig = wraithOnVictim(state);
state = rig.state;
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(13);
});
});
describe("big man (rules rev 5)", () => {
function bigRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const giant = activePlayer(state);
state.sustained.push({
@@ -450,9 +446,9 @@ describe("big man (rules rev 5)", () => {
});
});
describe("monsters roll to hit the hidden (rules rev 13)", () => {
function creatureVsInvisible(deckRev: number) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
describe("monsters roll to hit the hidden", () => {
function creatureVsInvisible() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const me = activePlayer(state).id;
const victim = state.players.find((p) => p.id !== me)!;
@@ -470,29 +466,18 @@ describe("monsters roll to hit the hidden (rules rev 13)", () => {
}
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
let { state, me, victim, skeletonId } = creatureVsInvisible(13);
let { state, me, victim, skeletonId } = creatureVsInvisible();
const rngBefore = JSON.stringify(state.rng);
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
state = must(state, victim.id, { type: "pass" });
// The die was consumed, whichever way it landed.
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
});
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
let { state, me, victim, skeletonId } = creatureVsInvisible(12);
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
const rngBefore = JSON.stringify(state.rng);
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
state = must(state, victim.id, { type: "pass" });
expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore);
// No die was consumed on the way.
expect(JSON.stringify(state.rng)).toBe(rngBefore);
});
});
describe("elimination sweeps the board either way (rules rev 14)", () => {
it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
const victim = state.players.find((p) => p.id === "c")!;
state.creatures.push({
id: "imp1", kind: "fire-imp", controllerId: "c",
@@ -524,7 +509,7 @@ describe("elimination sweeps the board either way (rules rev 14)", () => {
describe("creatures walk open doorways", () => {
it("a skeleton passes a door whose lock was removed", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 });
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
@@ -553,7 +538,7 @@ describe("creatures walk open doorways", () => {
describe("the wave carries monsters (rules rev 17)", () => {
it("a cornered skeleton takes the waterwall crush", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 17 });
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const caster = activePlayer(state);
// A skeleton pinned against solid stone, one square from the wave's edge.
@@ -592,7 +577,7 @@ describe("the wave carries monsters (rules rev 17)", () => {
describe("the democratic monster's claw survives the first wizard's death (rules rev 18)", () => {
it("refreshes each round even with the roll-off winner dead", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 });
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const firstId = state.players[state.turn.firstIndex]!.id;
const dm = {
@@ -619,7 +604,7 @@ describe("the democratic monster's claw survives the first wizard's death (rules
describe("a mid-round democratic monster claws on the next turn (rules rev 18)", () => {
it("creation spends the turn, not the round", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 });
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const creator = activePlayer(state);
const r0 = summon(state, creator.id, "democratic-monster");
@@ -641,9 +626,9 @@ describe("a mid-round democratic monster claws on the next turn (rules rev 18)",
});
});
describe("collapsing walls crush monsters too (rules rev 21)", () => {
function wallRig(deckRev: number) {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
describe("collapsing walls crush monsters too", () => {
function wallRig() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
@@ -672,21 +657,16 @@ describe("collapsing walls crush monsters too (rules rev 21)", () => {
}
it("rev 21: the adjacent troll takes the four points", () => {
const state = wallRig(21);
const state = wallRig();
const troll = state.creatures.find((c) => c.id === "tr1");
// Four points on a six-point troll: hurt but standing (or regenerating).
expect(troll?.damage).toBe(4);
});
it("earlier revisions leave the troll dusty but unharmed", () => {
const state = wallRig(20);
expect(state.creatures.find((c) => c.id === "tr1")!.damage).toBe(0);
});
});
describe("the self-stack resolves on a pass (rules rev 22)", () => {
function selfTouch(deckRev: number) {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
describe("the self-stack resolves on a pass", () => {
function selfTouch() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const creator = activePlayer(state);
state.creatures.push({
@@ -702,7 +682,7 @@ describe("the self-stack resolves on a pass (rules rev 22)", () => {
}
it("rev 22: passing your own monster's touch takes the claw and moves on", () => {
const { state, creator } = selfTouch(22);
const { state, creator } = selfTouch();
if (!state.stack) return; // a wall between: the touch never opened
expect(state.stack.attackerId).toBe(creator);
expect(state.stack.defenderId).toBe(creator);
@@ -711,19 +691,11 @@ describe("the self-stack resolves on a pass (rules rev 22)", () => {
expect(r.state.stack).toBeNull();
expect(r.state.players.find((p) => p.id === creator)!.life).toBe(13);
});
it("rev 21 keeps its recorded bounce, no-op as it was", () => {
const { state, creator } = selfTouch(21);
if (!state.stack) return;
const r = applyCommand(state, creator, { type: "pass" });
if (!r.ok) throw new Error(r.error);
expect(r.state.stack).not.toBeNull();
});
});
describe("fear holds off monsters and unwilling feet alike (rules rev 32)", () => {
it("a commanded monster cannot close within three of the fearsome", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 32 });
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
const a = state.players.find((p) => p.id === "a")!;
const b = state.players.find((p) => p.id === "b")!;
// b radiates fear; a's troll stands exactly four away, aimed straight at b.
@@ -339,8 +339,8 @@ describe("cast modifiers", () => {
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "reverse#R" });
// REVERSE is a total stop of the harm: the attacker's pass settles it.
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
const d = state.players.find((p) => p.id === defender)!;
expect(d.life).toBe(19); // gained 4 instead of losing it
expect(d.lostTurns).toBe(1); // the stun still applies
@@ -349,7 +349,7 @@ describe("cast modifiers", () => {
describe("holding the door open (Pick Lock / Master Key)", () => {
function doorRig() {
let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"], deckRev: 14 });
let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"] });
state = toRound2(state);
// Find a door edge; stand the acting player beside it.
const view = boardView(state);
@@ -410,9 +410,9 @@ describe("holding the door open (Pick Lock / Master Key)", () => {
});
});
describe("a held door is an open doorway to the eye (rules rev 15)", () => {
function sightRig(deckRev: number) {
let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"], deckRev });
describe("a held door is an open doorway to the eye", () => {
function sightRig() {
let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
@@ -431,7 +431,7 @@ describe("a held door is an open doorway to the eye (rules rev 15)", () => {
}
it("rev 15: the holder blasts the pursuer through the doorway", () => {
let { state, cell, side, holder, pursuer } = sightRig(15);
let { state, cell, side, holder, pursuer } = sightRig();
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
type: "cast", instanceId: pick.instanceId,
@@ -447,7 +447,7 @@ describe("a held door is an open doorway to the eye (rules rev 15)", () => {
});
it("an unheld unlocked door still blocks sight", () => {
let { state, cell, side, holder, pursuer } = sightRig(15);
let { state, cell, side, holder, pursuer } = sightRig();
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
type: "cast", instanceId: pick.instanceId,
@@ -459,18 +459,4 @@ describe("a held door is an open doorway to the eye (rules rev 15)", () => {
});
expect(refused.ok).toBe(false);
});
it("earlier revisions keep the blocked sightline, hold or no", () => {
let { state, cell, side, holder, pursuer } = sightRig(14);
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
type: "cast", instanceId: pick.instanceId,
target: { kind: "edge", cell, side }, params: { hold: true },
});
const fb = giveCard(state, holder, "fireball", "FB", 1);
const refused = applyCommand(state, holder, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
});
expect(refused.ok).toBe(false);
});
});
+20 -24
View File
@@ -106,7 +106,7 @@ describe("expansion combat cards", () => {
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
});
it("ward springs when a trapped treasure is grabbed", () => {
it("ward springs in the moment: the grab hangs on the owner's choice", () => {
let { state } = newGame();
const me = activePlayer(state);
const enemy = state.players.find((p) => p.id !== me.id)!;
@@ -114,6 +114,8 @@ describe("expansion combat cards", () => {
const treasure = state.treasures.find((t) => t.owner === enemy.id && t.position)!;
me.position = { ...treasure.position! };
state = must(state, me.id, { type: "pickUpTreasure" });
expect(state.wardPending).toEqual({ ownerId: enemy.id, takerId: me.id });
state = must(state, enemy.id, { type: "wardChoice", play: true });
expect(state.players.find((p) => p.id === me.id)!.life).toBe(12);
expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
});
@@ -164,7 +166,7 @@ describe("expansion combat cards", () => {
it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
state = toRound2(state);
const { attacker, defender } = faceOff(state);
@@ -198,9 +200,9 @@ describe("expansion combat cards", () => {
expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true);
});
it("idiot at rev 37 no longer steers the march — the step goes where asked", () => {
it("idiot never steers the march — the step goes where asked", () => {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 37,
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
state = toRound2(state);
const { attacker, defender } = faceOff(state);
@@ -225,10 +227,10 @@ describe("expansion combat cards", () => {
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(away.to));
});
it("idiot at rev 37 permits DROP OBJECT that frees the victim's own treasure", () => {
for (const deckRev of [36, 37]) {
it("idiot permits DROP OBJECT that frees the victim's own treasure", () => {
{
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
state = toRound2(state);
const { attacker, defender } = faceOff(state);
@@ -252,13 +254,13 @@ describe("expansion combat cards", () => {
type: "cast", instanceId: dr.instanceId,
target: { kind: "player", playerId: attacker }, params: { cardId: "treasure" },
});
expect(r.ok).toBe(deckRev >= 37);
expect(r.ok).toBe(true);
}
});
it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
state = toRound2(state);
const { attacker, defender } = faceOff(state);
@@ -394,9 +396,9 @@ describe("ambushes (async interrupts)", () => {
});
});
describe("swap meet trades carried items (rules rev 26)", () => {
function rig(deckRev: number) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
describe("swap meet trades carried items", () => {
function rig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sm = giveCard(state, attacker, "swap-meet");
@@ -412,21 +414,15 @@ describe("swap meet trades carried items (rules rev 26)", () => {
}
it("a dagger trades for a wand — both are carried items", () => {
const { state, attacker, defender } = rig(26);
const { state, attacker, defender } = rig();
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true);
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
});
it("older revisions matched only object-typed cards and replay so", () => {
const { state, attacker, defender } = rig(25);
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true);
});
});
describe("swap meet trades treasures too", () => {
function treasureRig(theirsToken: string, mineToken: string) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
@@ -459,7 +455,7 @@ describe("swap meet trades treasures too", () => {
it("the trade refuses to overload full arms", () => {
// The attacker also carries a treasure: claiming theirs with a dagger
// would mean two in hand — the swap quietly cannot happen.
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
@@ -481,7 +477,7 @@ describe("swap meet trades treasures too", () => {
});
it("treasure for treasure trades both armfuls", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
@@ -506,7 +502,7 @@ describe("swap meet trades treasures too", () => {
describe("full reflection hands the swap meet choice to the reflector (rev 27)", () => {
function reflectedSwapRig(reflectorChoice?: string) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 27 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
@@ -553,7 +549,7 @@ describe("full reflection hands the swap meet choice to the reflector (rev 27)",
describe("go away routs around walls (rules rev 36)", () => {
it("a victim against a wall bends the line instead of standing still", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 36 });
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
+30 -43
View File
@@ -162,10 +162,10 @@ describe("expansion terrain", () => {
// Wave side effects vary by geometry; the melt itself is the pinned behavior.
});
it("wall of fire takes a rim warp from rev 39 — both mouths burn the crossing", () => {
for (const deckRev of [38, 39]) {
it("wall of fire takes a rim warp — both mouths burn the crossing", () => {
{
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
const me = activePlayer(state);
const warp = state.board.warps[0]!;
@@ -175,8 +175,7 @@ describe("expansion terrain", () => {
type: "cast", instanceId: wof.instanceId,
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
});
expect(r.ok).toBe(deckRev >= 39);
if (!r.ok) continue;
if (!r.ok) throw new Error(r.error);
state = r.state;
const nearKey = edgeKey(warp.from.cell, warp.from.side);
const farKey = edgeKey(warp.to.cell, warp.to.side);
@@ -191,10 +190,10 @@ describe("expansion terrain", () => {
}
});
it("waterwall takes a rim warp from rev 40 — the collapse washes both rims", () => {
for (const deckRev of [39, 40]) {
it("waterwall takes a rim warp — the collapse washes both rims", () => {
{
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
const me = activePlayer(state);
const other = state.players.find((p) => p.id !== me.id)!;
@@ -206,8 +205,7 @@ describe("expansion terrain", () => {
type: "cast", instanceId: ww.instanceId,
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
});
expect(r.ok).toBe(deckRev >= 40);
if (!r.ok) continue;
if (!r.ok) throw new Error(r.error);
// Both wizards stood in the mouths; the collapse washed both inward.
const meAfter = r.state.players.find((p) => p.id === me.id)!;
const otherAfter = r.state.players.find((p) => p.id === other.id)!;
@@ -216,10 +214,10 @@ describe("expansion terrain", () => {
}
});
it("illusion wall hangs on a rim warp mouth from rev 40", () => {
for (const deckRev of [39, 40]) {
it("illusion wall hangs on a rim warp mouth", () => {
{
const { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
const me = activePlayer(state);
const warp = state.board.warps[0]!;
@@ -229,17 +227,17 @@ describe("expansion terrain", () => {
type: "cast", instanceId: il.instanceId,
target: { kind: "edge", cell: warp.from.cell, side: warp.from.side },
});
expect(r.ok).toBe(deckRev >= 40);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.state.illusionWalls[edgeKey(warp.from.cell, warp.from.side)]).toBeDefined();
}
}
});
it("stone to water breaches the outer rim from rev 40, opening a warp", () => {
for (const deckRev of [39, 40]) {
it("stone to water breaches the outer rim, opening a warp", () => {
{
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
const me = activePlayer(state);
const board = boardView(state);
@@ -264,20 +262,15 @@ describe("expansion terrain", () => {
type: "cast", instanceId: stw.instanceId,
target: { kind: "edge", cell: rim.cell, side: rim.side },
});
if (deckRev >= 40) {
// The far rim melted with it and the wraparound now runs.
expect(state.board.warps.length).toBe(warpsBefore + 2);
} else {
// Older vintages left a dangling opening and replay so.
expect(state.board.warps.length).toBe(warpsBefore);
}
// The far rim melted with it and the wraparound now runs.
expect(state.board.warps.length).toBe(warpsBefore + 2);
}
});
it("no doors through the rim from rev 40", () => {
for (const deckRev of [39, 40]) {
it("no doors through the rim", () => {
{
const { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
const me = activePlayer(state);
const board = boardView(state);
@@ -299,15 +292,14 @@ describe("expansion terrain", () => {
type: "cast", instanceId: cd.instanceId,
target: { kind: "edge", cell: rim.cell, side: rim.side },
});
// Rev 39 hung a dead door there (and replays so); rev 40 refuses.
expect(r.ok).toBe(deckRev < 40);
expect(r.ok).toBe(false);
}
});
it("stone to water melts a door from rev 38 — a small entryway in a stone wall", () => {
for (const deckRev of [37, 38]) {
it("stone to water melts a door — a small entryway in a stone wall", () => {
{
const { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev,
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
const me = activePlayer(state);
const board = boardView(state);
@@ -329,7 +321,7 @@ describe("expansion terrain", () => {
type: "cast", instanceId: stw.instanceId,
target: { kind: "edge", cell: doorAt.cell, side: doorAt.side },
});
expect(r.ok).toBe(deckRev >= 38);
expect(r.ok).toBe(true);
if (r.ok) {
const key = edgeKey(doorAt.cell, doorAt.side);
expect(boardView(r.state).edges[key] ?? "open").toBe("open");
@@ -389,11 +381,11 @@ describe("eligibility dimming mirrors the engine", () => {
});
});
describe("a wave's force is spent as it travels (rules rev 24)", () => {
describe("a wave's force is spent as it travels", () => {
/** Caster at B, one cell above A; the target wall is A's south edge, so
* the range-2 wave covers A (dist 0) and B (dist 1). */
function rig(deckRev: number, behind: "open" | "wall") {
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
function rig(behind: "open" | "wall") {
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
const me = activePlayer(state);
const A = { x: 4, y: 5 }, B = { x: 4, y: 4 }, Bn = { x: 4, y: 3 }, Bnn = { x: 4, y: 2 };
for (const c of [A, B, Bn, Bnn]) expect(boardView(state).cells[cellKey(c)]).toBeTruthy();
@@ -412,20 +404,15 @@ describe("a wave's force is spent as it travels (rules rev 24)", () => {
}
it("a victim at the wave's far edge is carried one space, unhurt", () => {
const { me, Bn } = rig(24, "open");
const { me, Bn } = rig("open");
expect(cellKey(me.position)).toBe(cellKey(Bn));
expect(me.life).toBe(15);
});
it("only unspent force crushes: one space of push blocked is one damage", () => {
const { me, B } = rig(24, "wall");
const { me, B } = rig("wall");
expect(cellKey(me.position)).toBe(cellKey(B));
expect(me.life).toBe(14);
});
it("older revisions keep the flat full-range wash for replay fidelity", () => {
const { me, Bnn } = rig(23, "open");
expect(cellKey(me.position)).toBe(cellKey(Bnn));
expect(me.life).toBe(15);
});
});
+4 -4
View File
@@ -229,7 +229,7 @@ describe("treasures and victory", () => {
describe("elimination by lost treasures drops what the fallen carried (rev 30)", () => {
it("the carried treasure lands where the wizard stood", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"], deckRev: 30 });
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"] });
const A = state.players.find((p) => p.id === "a")!;
const B = state.players.find((p) => p.id === "b")!;
const C = state.players.find((p) => p.id === "c")!;
@@ -267,7 +267,7 @@ describe("elimination by lost treasures drops what the fallen carried (rev 30)",
describe("the Ward is played in the moment (rules rev 31)", () => {
function grabRig() {
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"], deckRev: 31 });
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"] });
const thief = state.players.find((p) => p.id === "thief")!;
const owner = state.players.find((p) => p.id === "owner")!;
giveCard(state, "owner", "ward", "W", 0);
@@ -307,7 +307,7 @@ describe("the Ward is played in the moment (rules rev 31)", () => {
});
it("arming is refused in this vintage", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 31 });
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0);
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard", armed: true });
expect(r.ok).toBe(false);
@@ -316,7 +316,7 @@ describe("the Ward is played in the moment (rules rev 31)", () => {
describe("an ambushed teleport carries its destination (rules rev 35)", () => {
it("the trap springs and the victim lands where the trapper said", () => {
let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"], deckRev: 35 });
let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"] });
for (let guard = 0; guard < 10 && !(state.players[state.turn.activeIndex]!.id === "trapper" && state.turn.round > 1); guard++) {
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
@@ -143,13 +143,16 @@ describe("illusion wall", () => {
expect(after.ok).toBe(true);
});
it("opponents test the illusion when they bump it — some see through, some believe", () => {
it("an untested illusion blocks the bump; a tested eye rolls its verdict", () => {
let believed = 0, sawThrough = 0;
for (let seed = 1; seed <= 12; seed++) {
const { state, caster, side } = setupIllusion(seed);
const other = state.players.find((p) => p.id !== caster)!;
other.position = { ...state.players.find((p) => p.id === caster)!.position };
let s = must(state, caster, { type: "endTurn", draw: 0 });
// Bumping blind is refused: eyes must be tested first.
expect(applyCommand(s, other.id, { type: "move", direction: side }).ok).toBe(false);
s = must(s, other.id, { type: "testIllusion", cell: other.position, side });
const result = applyCommand(s, other.id, { type: "move", direction: side });
if (result.ok) sawThrough++;
else believed++;
@@ -223,11 +226,16 @@ describe("sector manipulation", () => {
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: dest }, params: { cell: me.position },
});
// The maze zero-anchors after the move; measure against the sector's
// origin as it settled, not the requested landing.
const after = state.players.find((p) => p.id === me.id)!;
const dx = dest.x - myOrigin.x, dy = dest.y - myOrigin.y;
const newOrigin = state.board.placements[idx]!.origin;
const dx = newOrigin.x - myOrigin.x, dy = newOrigin.y - myOrigin.y;
expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy });
expect(after.home).toEqual({ x: homeBefore.x + dx, y: homeBefore.y + dy });
expect(state.board.placements[idx]!.origin).toEqual(dest);
// My sector sits east of the other, as asked.
const otherAfter = state.board.placements[idx === 0 ? 1 : 0]!.origin;
expect(newOrigin).toEqual({ x: otherAfter.x + 5, y: otherAfter.y });
// The map reassembled: every treasure/wizard cell exists on the new board.
for (const t of state.treasures) {
if (t.position) expect(state.board.cells[cellKey(t.position)]).toBe(true);
@@ -319,8 +327,8 @@ describe("6e card-face corrections", () => {
params: { damage: 4, knockback: 0 },
});
state = must(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF" });
// The fire is a total stop: the attacker's pass settles it.
state = must(state, attacker.id, { type: "pass" });
state = must(state, defender.id, { type: "pass" });
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(15);
// But it cannot counter a fireball.
@@ -337,7 +345,7 @@ describe("6e card-face corrections", () => {
describe("relocation past the origin (rules rev 11)", () => {
function rev11Game() {
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 11 });
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
return state;
}
@@ -438,7 +446,7 @@ describe("relocation past the origin (rules rev 11)", () => {
describe("junction alterations roll for their sector (rules rev 20)", () => {
it("a conjured wall on the seam obeys the die: 1-2 stays, 3-4 travels", () => {
const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 20 });
const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
let state = toRound2(fresh);
// The 2p board is two sectors stacked: the seam runs between them.
const [p0, p1] = state.board.placements;
@@ -503,7 +511,7 @@ describe("sight tracing", () => {
describe("illusions are tested by choice (rules rev 29)", () => {
function shimmerRig() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 29 });
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const caster = activePlayer(state);
const mark = state.players.find((p) => p.id !== caster.id)!;
@@ -280,7 +280,7 @@ describe("control effects", () => {
describe("picking one of two treasures", () => {
it("a named treasure is the one taken", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 16 });
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const p = activePlayer(state);
const [t1, t2] = state.treasures.filter((t) => t.owner !== p.id);
+1 -1
View File
@@ -145,7 +145,7 @@ describe("magic wands", () => {
it("warp wand bores the rim for one turn from rev 40 — a temporary wraparound", () => {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 40,
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
});
const me = activePlayer(state);
const view = boardView(state);