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
+7 -12
View File
@@ -784,7 +784,7 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
}
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
const vanish = find("invisible");
if (vanish && (!stack.creatureId || view.deckRev >= 13) && incoming >= 3 - flinch) {
if (vanish && incoming >= 3 - flinch) {
const num = numbersInHand(view)[0];
return {
type: "counteract", instanceId: vanish.instanceId,
@@ -957,11 +957,6 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
// Three or more wizards: LIFESAVER takes elimination off the table.
const lifesaver = inHand(view, "lifesaver");
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
// The ward guards the gold while its owner is away robbing yours
// (arming retired at rev 31: the window asks in the moment instead).
if (view.deckRev < 31 && inHand(view, "ward") && !view.yourWardArmed) {
return { type: "armWard", armed: true };
}
const enemyNear = livingEnemies(view).some(
(p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3,
);
@@ -1323,12 +1318,12 @@ export function automatonCommand(
}
}
// IDIOT's curse: the maze no longer forces the feet (rev 37), so the
// IDIOT's curse: the maze no longer forces the feet, so the
// clockwork honors the march to its own gold itself — and when a thief
// carries that gold, DROP OBJECT (the rev-37 carve-out) shakes it onto
// the floor where it can finally be stood upon.
// carries that gold, DROP OBJECT (an IDIOT aid) shakes it onto the
// floor where it can finally be stood upon.
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
if (cursedIdiot && view.deckRev >= 37 && view.turn.round > 1 && !view.turn.attackUsed) {
if (cursedIdiot && view.turn.round > 1 && !view.turn.attackUsed) {
const dropper = inHand(view, "drop-object");
if (dropper) {
const sighted = sightedCellsFor(view);
@@ -1365,8 +1360,8 @@ export function automatonCommand(
pathToward(view, self.position, objectives, { canUnlock }) ??
pathToward(view, self.position, enemyCells, { canUnlock });
// A shimmering illusion on the best crossing costs nothing to doubt:
// roll the free test before spending any card on that wall (rev 29).
if (view.deckRev >= 29) {
// roll the free test before spending any card on that wall.
{
const crossing = bestWallCrossing(view, self, objectives, canUnlock);
if (crossing && crossing.total < (path?.distance ?? Infinity)) {
const k = edgeKey(crossing.cell, crossing.side);
File diff suppressed because it is too large Load Diff
+11 -16
View File
@@ -42,7 +42,8 @@ export interface GameView {
winReason: "treasures" | "lastStanding" | null;
turn: TurnState;
activePlayerId: PlayerId;
/** The game's rules revision — the client mirrors rev-gated legality. */
/** The game's rules revision (a fresh count from 1; gates return if the
* rules ever fork again while games are live). */
deckRev: number;
/** Board with dynamic wall changes already merged in. */
board: AssembledBoard;
@@ -70,7 +71,7 @@ export interface GameView {
createdEdges: string[];
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
knownIllusionEdges: string[];
/** Every illusion edge and YOUR verdict on it (rules rev 29+): untested
/** Every illusion edge and YOUR verdict on it: untested
* shimmers, believes renders solid, mine/seesThrough are ghosts. */
illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine">;
creatures: CreatureState[];
@@ -82,9 +83,7 @@ export interface GameView {
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
/** CHAOS shield windows in progress (public: everyone sees it coming). */
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
/** Whether YOUR ward is set to spring. */
yourWardArmed: boolean;
/** A grab hangs while the treasure's owner decides their Ward (rev 31). */
/** A grab hangs while the treasure's owner decides their Ward. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[];
@@ -95,20 +94,17 @@ export interface GameView {
export function viewFor(state: GameState, playerId: PlayerId): GameView {
const you = state.players.find((p) => p.id === playerId);
// Illusion walls render as real walls unless this viewer knows better.
// From rules rev 29 their locations are open knowledge (the cast is
// public at a table) — what stays personal is each player's verdict.
// Their locations are open knowledge (the cast is public at a table) —
// what stays personal is each player's verdict.
const base = boardView(state);
const rev29 = (state.config.deckRev ?? 1) >= 29;
const knownIllusionEdges: string[] = [];
const illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine"> = {};
let edges = base.edges;
for (const [key, wall] of Object.entries(state.illusionWalls)) {
const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough";
if (rev29) {
illusionEdges[key] =
wall.createdBy === playerId ? "mine"
: wall.belief[playerId] ?? "untested";
}
illusionEdges[key] =
wall.createdBy === playerId ? "mine"
: wall.belief[playerId] ?? "untested";
if (knows) {
knownIllusionEdges.push(key);
} else {
@@ -169,7 +165,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
chaosPending: state.chaosPending
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
: null,
yourWardArmed: state.wardArmed.includes(playerId),
wardPending: state.wardPending ? { ...state.wardPending } : null,
yourAmbushes: state.ambushes
.filter((a) => a.ownerId === playerId)
@@ -205,9 +200,9 @@ export function sightedCellsFor(view: GameView): Set<string> {
/** The board-as-seen and sight blockers this view's sight rules run against. */
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
// Held-open doors are open doorways to the eye (rules rev 15).
// Held-open doors are open doorways to the eye.
let board = view.board;
if (view.deckRev >= 15 && view.heldDoorEdges.length > 0) {
if (view.heldDoorEdges.length > 0) {
const edges = { ...board.edges };
for (const k of view.heldDoorEdges) delete edges[k];
board = { ...board, edges };