From 18d3142f95875c7ea4f32b49455c384d9f5ba660 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 16 Aug 2026 19:53:38 -0400 Subject: [PATCH] Rev 10: a total stop ends the exchange, and the modal stops goading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slow Death, countered with Force Field — and the battle modal popped right back up as if nothing had happened, goading a second counter out of a hand that had already won the exchange. The engine was bouncing every attacker's pass back to the defender so counters could stack; correct for partial stops (Blunt, Absorb, Reflection numbers), senseless past a total one. Under rules rev 10, when a Full Shield, Force Field, Teleport escape, Full Reflection, or Reverse stands unnullified, the attacker's declined answer resolves the stack on the spot. Older ledgers keep the bounce and replay unchanged. And when the exchange legitimately returns — a partial counter standing, or a shield broken by Anti-Anti — the modal now says so: "Your Blunt stands — only what slips past it will land," or "Your Full Shield was nullified — the attack comes on unchecked," instead of replaying the fanfare of a fresh, unanswered attack. Co-Authored-By: Claude Fable 5 --- packages/engine/src/game.ts | 24 ++++++++++--- packages/engine/test/casting.test.ts | 51 ++++++++++++++++++++++++++++ packages/server/src/rooms.ts | 2 +- packages/web/src/App.svelte | 24 +++++++++++++ packages/web/src/local.svelte.ts | 2 +- 5 files changed, 97 insertions(+), 6 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 457edd3..79f5909 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -4695,14 +4695,30 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p return err("you are not part of this exchange"); } +/** Counters that leave the defender nothing worth adding: the spell is dead, + * escaped, thrown back whole, or drunk as a tonic. */ +const TOTAL_STOP_COUNTERS = new Set([ + "full-shield", "force-field", "teleport", "full-reflection", "reverse", +]); + function doPass(prev: GameState, playerId: PlayerId): CommandResult { const state = clone(prev); const stack = state.stack!; - if (playerId === stack.attackerId) { - stack.waitingOn = stack.defenderId; - return { ok: true, state, events: [] }; - } const events: GameEvent[] = []; + if (playerId === stack.attackerId) { + // 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 + // into burning cards against a spell that is already dead (rules rev 10). + const totalStop = (state.config.deckRev ?? 1) >= 10 && + stack.counters.some((c) => + !c.nullified && TOTAL_STOP_COUNTERS.has(c.card.cardId) && + (stack.kind === "spell" || c.card.cardId === "teleport")); + if (!totalStop) { + stack.waitingOn = stack.defenderId; + return { ok: true, state, events }; + } + } resolveStack(state, events); checkVictory(state, events); return { ok: true, state, events }; diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index e7c3e32..c30cef5 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -794,3 +794,54 @@ describe("redirection (rules rev 9)", () => { expect(cellKey(after(pbCell).to.cell)).toBe(cellKey(paCell)); }); }); + +describe("total stops end the exchange (rules rev 10)", () => { + function rev10Game() { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + return { state, attacker, defender }; + } + + it("a declined answer to Force Field resolves at once — no goading re-prompt", () => { + let { state, attacker, defender } = rev10Game(); + const fb = giveCard(state, attacker, "fireball"); + const d = state.players.find((p) => p.id === defender)!; + const lifeBefore = d.life; + d.hand[0] = { instanceId: "force-field#T", cardId: "force-field" }; + + state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } }); + state = must(state, defender, { type: "counteract", instanceId: "force-field#T" }); + state = must(state, attacker, { type: "pass" }); + expect(state.stack).toBeNull(); + expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore); + }); + + it("a partial counter still invites the defender to stack more", () => { + let { state, attacker, defender } = rev10Game(); + const fb = giveCard(state, attacker, "fireball"); + const d = state.players.find((p) => p.id === defender)!; + d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" }; + + state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } }); + state = must(state, defender, { type: "counteract", instanceId: "blunt#T" }); + state = must(state, attacker, { type: "pass" }); + expect(state.stack).not.toBeNull(); + expect(state.stack!.waitingOn).toBe(defender); + }); + + it("a nullified shield is no stop — the exchange returns to the defender", () => { + let { state, attacker, defender } = rev10Game(); + const fb = giveCard(state, attacker, "fireball"); + const a = state.players.find((p) => p.id === attacker)!; + const d = state.players.find((p) => p.id === defender)!; + a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" }; + d.hand[0] = { instanceId: "full-shield#T", cardId: "full-shield" }; + + state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } }); + state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" }); + state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" }); + state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === defender)!.life).toBeLessThan(15); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 267f99d..bc18d86 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -56,7 +56,7 @@ export interface Room { const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 9; +const RULES_REV = 10; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index cc15c37..0d5be81 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -859,6 +859,22 @@ {:else if view.stack.counters.length > 0}
{/if} + {#if view.stack.defenderId === view.you && view.stack.counters.length > 0} + {@const standing = view.stack.counters.filter((c) => c.player === view.you && !c.nullified)} + {@const broken = view.stack.counters.filter((c) => c.player === view.you && c.nullified)} + {#if broken.length > 0 && standing.length === 0} +
+ Your {broken.map((c) => cardDef(c.card.cardId).name).join(" and ")} + {broken.length === 1 ? "was" : "were"} nullified — the attack comes on unchecked. +
+ {:else if standing.length > 0} +
+ Your {standing.map((c) => cardDef(c.card.cardId).name).join(" and ")} + stand{standing.length === 1 ? "s" : ""} — only what slips past + {standing.length === 1 ? "it" : "them"} will land. Counter again, or let it resolve. +
+ {/if} + {/if} @@ -1999,6 +2015,14 @@ .attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; } .attack-card :global(.card:hover) { transform: scale(1.35); } .attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; } + .attack-standing { + font-family: "Courier Prime", monospace; + font-size: 0.85rem; + color: #3f6b41; + max-width: 17rem; + margin: 0.3rem auto 0; + } + .attack-standing.nullified { color: #b3372b; } .attack-fist { font-size: 1.1rem; color: #43331f; } .table-talk { diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index 7379ea8..81cf7ee 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -132,7 +132,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 9, + deckRev: 10, }; const { state, events } = createGame(config); this.config = config;