Rev 22: the self-stack resolves — one wizard cannot bounce to himself

A democratic monster touching its own creator opens a stack where one
player wears both hats, and doPass matched the attacker first: every
pass "bounced the exchange to the defender" — the same wizard —
forever. Room 928D livelocked there at seq 83; once the boot kick
revived its pump, the automaton politely passed 389 times. At rev 22
a self-stack pass RESOLVES (the claw lands, the game moves on);
older ledgers hold their recorded bounces and replay so. 928D itself
was repaired by truncating the no-op run and lifting it to rev 22,
verified clean and unstuck on a copy before installation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-17 18:14:18 -04:00
co-authored by Claude Fable 5
parent 4a75370de5
commit 7372bfc5e8
4 changed files with 46 additions and 3 deletions
+7 -1
View File
@@ -5071,7 +5071,13 @@ function doPass(prev: GameState, playerId: PlayerId): CommandResult {
const state = clone(prev);
const stack = state.stack!;
const events: GameEvent[] = [];
if (playerId === stack.attackerId) {
// A self-stack (a democratic monster touching its own creator) has one
// party wearing both hats: their pass RESOLVES — bouncing it "to the
// defender" would hand the exchange back to themselves forever
// (rules rev 22; older ledgers hold those no-op bounces and replay so).
const selfStack =
stack.attackerId === stack.defenderId && (state.config.deckRev ?? 1) >= 22;
if (playerId === stack.attackerId && !selfStack) {
// The attacker declining to answer bounces the exchange back so the
// defender may stack further counters — but not past a total stop:
// re-prompting then reads as "your shield failed" and goads the table
+37
View File
@@ -683,3 +683,40 @@ describe("collapsing walls crush monsters too (rules rev 21)", () => {
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 });
state = toRound2(state);
const creator = activePlayer(state);
state.creatures.push({
id: "dm1", kind: "democratic-monster", controllerId: creator.id,
position: { x: creator.position.x - 1, y: creator.position.y },
damage: 0, maxDamage: 5, movesPerTurn: 3, movementUsed: 0,
attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const r = applyCommand(state, creator.id, { type: "moveCreature", creatureId: "dm1", direction: "E" });
if (!r.ok) throw new Error(r.error);
return { state: r.state, creator: creator.id };
}
it("rev 22: passing your own monster's touch takes the claw and moves on", () => {
const { state, creator } = selfTouch(22);
if (!state.stack) return; // a wall between: the touch never opened
expect(state.stack.attackerId).toBe(creator);
expect(state.stack.defenderId).toBe(creator);
const r = applyCommand(state, creator, { type: "pass" });
if (!r.ok) throw new Error(r.error);
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();
});
});
+1 -1
View File
@@ -54,7 +54,7 @@ export interface Room {
const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */
const RULES_REV = 21;
const RULES_REV = 22;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+1 -1
View File
@@ -136,7 +136,7 @@ class LocalGame {
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}),
deckRev: 21,
deckRev: 22,
};
const { state, events } = createGame(config);
for (const e of events) {