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:
co-authored by
Claude Fable 5
parent
e8df6efcde
commit
8a50ff7f36
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user