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
+8 -29
View File
@@ -227,8 +227,6 @@
const k = `${c.x},${c.y}`;
if (seen.has(k)) continue;
seen.add(k);
// Before rev 11 the grid stopped at zero; newer games renormalize.
if (view!.deckRev < 11 && (c.x < 0 || c.y < 0)) continue;
if (origins.some((o) => o.x === c.x && o.y === c.y)) continue;
const next = origins.map((o, j) => (j === idx ? c : o));
if (next.every((o, j) => next.some((p, m) => m !== j && adjacent(o, p)))) out.push(c);
@@ -255,7 +253,6 @@
});
clearSelection();
}
const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false);
/** A live moment to interrupt: another wizard acts, no attack is pending. */
const canInterruptNow = $derived(
view != null && !isYourTurn && !view.stack && !view.chaosPending &&
@@ -400,7 +397,7 @@
// Fully reflecting a SWAP MEET: "lets the other player decide which
// objects, if any, will be swapped" — the choice rides the counter.
if (youMustRespond && card.cardId === "full-reflection" &&
view.stack?.attackCard?.cardId === "swap-meet" && view.deckRev >= 27) {
view.stack?.attackCard?.cardId === "swap-meet") {
reflectSwapCard = card;
reflectSwapMine = null;
return;
@@ -912,11 +909,8 @@
reflectSwapCard = null;
reflectSwapMine = null;
}
// Older rooms' rules trade only object-typed cards (rev < 26): the picker
// must offer exactly what that room's engine will honor.
function tradableHere(cardId: string): boolean {
if (!view) return false;
return view.deckRev >= 26 ? isMovableObject(cardId) : cardDef(cardId).cardType === "object";
return view != null && isMovableObject(cardId);
}
/** What one side can put on a trade table: hand (yours) or displayed
* cards (theirs), plus a carried treasure. Their treasure is claimable
@@ -1753,9 +1747,6 @@
</div>
{/if}
{#if view.yourWardArmed}
<div class="slip ambush-note">🗡 Your ward is set — a thief who grabs your treasure bleeds for 3.</div>
{/if}
{#if youMustShield}
<div class="slip urgent">
Chaos comes for your hand — tap your Full Shield to sit out, or
@@ -1808,20 +1799,12 @@
{/if}
{#if isYourTurn && view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you)}
<div class="slip urgent">
{#if view.deckRev >= 37}
🥴 "What am I doing here…?" — IDIOT drives you to the nearest
of your own treasures. Spend your movement marching toward it;
you may cast only counteractions, spells that speed the trip,
and Drop Object to shake your own treasure from a thief's
arms. It lifts when you stand on your treasure. (The marching
is yours to honor.)
{:else}
🥴 "What am I doing here…?" IDIOT drives you to the nearest
of your own treasures. Spend your movement marching (the maze
will steer your steps); you may cast only counteractions and
spells that speed the trip. It lifts when you stand on your
treasure.
{/if}
🥴 "What am I doing here…?" — IDIOT drives you to the nearest
of your own treasures. Spend your movement marching toward it;
you may cast only counteractions, spells that speed the trip,
and Drop Object to shake your own treasure from a thief's
arms. It lifts when you stand on your treasure. (The marching
is yours to honor.)
</div>
{/if}
{#if actionsSpent}
@@ -2136,10 +2119,6 @@
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
Pick up {cardDef(obj.cardId).name}</button>
{/each}
{#if holdingWard && view.deckRev < 31}
<button class="stamp" onclick={() => dispatch({ type: "armWard", armed: !view.yourWardArmed })}>
{view.yourWardArmed ? "Stand down the ward" : "Set the ward"}</button>
{/if}
<button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
onclick={() => (punchWallMode = !punchWallMode)}>
{punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button>
-2
View File
@@ -63,8 +63,6 @@ function describe(view: GameView, cmd: Command): string {
return `set the monster on ${cmd.targetId}`;
case "setAmbush":
return `lay an ambush with ${nameOf(view, cmd.instanceId)}`;
case "armWard":
return cmd.armed ? "arm the Ward over the gold" : "stand the Ward down";
case "discard":
return `shed ${cmd.instanceIds.map((id) => nameOf(view, id)).join(", ")} to make room`;
case "endTurn":
-1
View File
@@ -136,7 +136,6 @@ class LocalGame {
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}),
deckRev: 36,
};
const { state, events } = createGame(config);
for (const e of events) {
+12
View File
@@ -418,6 +418,18 @@ class Net {
if (g.yourTurn && !was) this.notifyTurn(g);
this.lastYourTurn.set(key, g.yourTurn);
}
// A seat the server answered for but did not list is gone — the
// room was deleted (or the seat's token voided). Quietly drop it
// from the ledger rather than showing "unreachable" forever.
// (The server checks at most 50 seats per ask; never prune blind.)
if (this.seats.length <= 50) {
const listed = new Set((msg.games as GameSummary[]).map((g) => `${g.roomId}:${g.name}`));
const kept = this.seats.filter((s) => listed.has(`${s.roomId}:${s.name}`));
if (kept.length !== this.seats.length) {
this.seats = kept;
saveSeats(this.seats);
}
}
break;
}
case "error":