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;