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
+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)!;