Rev 10: a total stop ends the exchange, and the modal stops goading

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 19:53:38 -04:00
co-authored by Claude Fable 5
parent ac53773fe4
commit 18d3142f95
5 changed files with 97 additions and 6 deletions
+20 -4
View File
@@ -4695,14 +4695,30 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p
return err("you are not part of this exchange"); 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 { function doPass(prev: GameState, playerId: PlayerId): CommandResult {
const state = clone(prev); const state = clone(prev);
const stack = state.stack!; const stack = state.stack!;
if (playerId === stack.attackerId) {
stack.waitingOn = stack.defenderId;
return { ok: true, state, events: [] };
}
const events: GameEvent[] = []; 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); resolveStack(state, events);
checkVictory(state, events); checkVictory(state, events);
return { ok: true, state, events }; return { ok: true, state, events };
+51
View File
@@ -794,3 +794,54 @@ describe("redirection (rules rev 9)", () => {
expect(cellKey(after(pbCell).to.cell)).toBe(cellKey(paCell)); 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);
});
});
+1 -1
View File
@@ -56,7 +56,7 @@ export interface Room {
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */ /** 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"; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+24
View File
@@ -859,6 +859,22 @@
{:else if view.stack.counters.length > 0} {:else if view.stack.counters.length > 0}
<div class="attack-card"><Card card={view.stack.counters[view.stack.counters.length - 1]!.card} /></div> <div class="attack-card"><Card card={view.stack.counters[view.stack.counters.length - 1]!.card} /></div>
{/if} {/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}
<div class="attack-standing nullified">
Your {broken.map((c) => cardDef(c.card.cardId).name).join(" and ")}
{broken.length === 1 ? "was" : "were"} nullified — the attack comes on unchecked.
</div>
{:else if standing.length > 0}
<div class="attack-standing">
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.
</div>
{/if}
{/if}
<button class="stamp big" onclick={() => (attackNoticeSeen = attackNoticeKey)}>To arms!</button> <button class="stamp big" onclick={() => (attackNoticeSeen = attackNoticeKey)}>To arms!</button>
</div> </div>
</div> </div>
@@ -1999,6 +2015,14 @@
.attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; } .attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; }
.attack-card :global(.card:hover) { transform: scale(1.35); } .attack-card :global(.card:hover) { transform: scale(1.35); }
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; } .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; } .attack-fist { font-size: 1.1rem; color: #43331f; }
.table-talk { .table-talk {
+1 -1
View File
@@ -132,7 +132,7 @@ class LocalGame {
seed, seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"], sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}), ...(colors ? { colors } : {}),
deckRev: 9, deckRev: 10,
}; };
const { state, events } = createGame(config); const { state, events } = createGame(config);
this.config = config; this.config = config;