From e38869185839b041f603a968c2ea5ce0ae791f7f Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 27 Aug 2026 22:40:12 -0400 Subject: [PATCH] The tear waits for counteractions (rules rev 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STRENGTH's card says the grab "would be an attack," but the engine resolved it instantly while punches opened counteraction windows — the defender never got their say. At rev 6 the tear rides the stack like any physical attack: the clutch roll waits out the counters, and only an unstopped grab still in arm's reach of a still-laden defender gets its wrestle. A TELEPORT escape now genuinely saves the chest (FULL SHIELD does not — the card stops spell attacks only, and the grab is physical). The automatons counter tears at their carried gold by not being there when the hand closes. Narrowing, so rev-gated: games pinned at rev 5 keep their instant tears and replay unchanged — all 29 ledgers verify. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/automaton.ts | 10 +++ packages/engine/src/game.ts | 58 ++++++++++++++-- packages/engine/test/casting.test.ts | 69 +++++++++++++++++++ packages/engine/test/expansion-combat.test.ts | 11 ++- packages/web/src/App.svelte | 3 +- packages/web/src/net.svelte.ts | 1 + 6 files changed, 144 insertions(+), 8 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index ca2d7be..6020729 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -775,6 +775,16 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma const atk = attackId ? ATTACKS[attackId] : null; const affliction = attackId ? AFFLICTIONS[attackId] != null : false; + // A tear at the treasure in my arms outranks its zero damage: the stake + // is the chest, not the bruise. The grab is PHYSICAL — shields part + // around it; the only real answer is to not be there when it closes. + if (stack.tearTreasure && me(view).carriedTreasureId != null) { + const tp = find("teleport"); + const out = tp ? escapeCell(view, me(view).position) : null; + if (tp && out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } }; + return { type: "pass" }; + } + if (attackId && UTILITY_ATTACKS.has(attackId)) { const stopper = find("full-shield") ?? find("force-field"); const stakes = diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index b274f72..56f6a3b 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -212,6 +212,9 @@ export interface CastStack { creatureTouch?: "wraith" | "claw"; /** A spell freed from slime: counteractions cannot touch its caster. */ trapped?: boolean; + /** STRENGTH's tear: "this would be an attack" — the grab rides the stack + * like a punch, and the clutch roll waits for the counteractions. */ + tearTreasure?: true; } export interface CastParams { @@ -229,8 +232,10 @@ export interface CastParams { hold?: boolean; } -/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 5; +/** The revision new games are dealt under; GameConfig.deckRev pins it per game. + * Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this would + * be an attack") instead of resolving instantly. */ +export const CURRENT_RULES_REV = 6; export interface GameConfig { playerIds: PlayerId[]; @@ -708,6 +713,7 @@ export type GameEvent = | { type: "treasurePickedUp"; player: PlayerId; treasureId: string; owner: PlayerId; at: Cell } | { type: "treasureTorn"; attacker: PlayerId; defender: PlayerId; treasureId: string; toFloor: boolean } | { type: "tearResisted"; attacker: PlayerId; defender: PlayerId } + | { type: "treasureTearAttempted"; attacker: PlayerId; defender: PlayerId } | { type: "treasureDropped"; player: PlayerId; treasureId: string; at: Cell; onHomeOf: PlayerId | null } | { type: "playerEliminated"; player: PlayerId; reason: "killed" | "treasuresLost" } | { type: "cardsDiscarded"; player: PlayerId; cards: CardInstance[] } @@ -4617,10 +4623,42 @@ function doTearTreasure(prev: GameState, targetId: PlayerId): CommandResult { if (state.turn.attackUsed) state.turn.secondAttackUsed = true; state.turn.attackUsed = true; + // Rev 6: "this would be an attack" — the grab opens a counteraction + // window like any punch; the clutch roll waits for the counters. + if ((state.config.deckRev ?? 1) >= 6) { + state.stack = { + attackerId: attacker.id, + defenderId: target.id, + attackCard: null, + numberValue: null, + amplifyFactor: 1, + extendFactor: 1, + powerAttackPoints: 0, + params: null, + kind: "physical", + counters: [], + waitingOn: target.id, + tearTreasure: true, + }; + events.push({ type: "treasureTearAttempted", attacker: attacker.id, defender: target.id }); + return { ok: true, state, events }; + } + + executeTear(state, events, attacker, target); + return { ok: true, state, events }; +} + +/** The clutch roll and the wrestling itself, once nothing stands in the way. */ +function executeTear( + state: GameState, + events: GameEvent[], + attacker: PlayerState, + target: PlayerState, +): void { const roll = rollD4(state, events, target.id, "clutching the treasure — a 1 keeps it"); if (roll === 1) { events.push({ type: "tearResisted", attacker: attacker.id, defender: target.id }); - return { ok: true, state, events }; + return; } const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!; @@ -4650,7 +4688,6 @@ function doTearTreasure(prev: GameState, targetId: PlayerId): CommandResult { } } checkVictory(state, events); - return { ok: true, state, events }; } function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult { @@ -5721,7 +5758,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void { ? effect.baseDamage(stack.numberValue, stack.params) : stack.creatureId ? (stack.params?.damage ?? 1) // a creature's blow - : 1; // a punch + : stack.tearTreasure + ? 0 // the tear wrestles; it does not bruise + : 1; // a punch // Webs burn: "Any fire damage done to player in webs causes two extra points." if (attackId === "fireball" && sustainedOn(state, defender.id, "sticky-web").length > 0) { base += 2; @@ -5951,6 +5990,15 @@ function resolveStack(state: GameState, events: GameEvent[]): void { } } + // The tear's wrestling waits for the counters: only an unstopped grab, + // still in arm's reach of a still-laden defender, gets its clutch roll. + if (stack.tearTreasure && !pipe.fullyStopped && !pipe.redirected && + attacker.alive && defender.alive && + cellKey(attacker.position) === cellKey(defender.position) && + defender.carriedTreasureId != null) { + executeTear(state, events, attacker, defender); + } + events.push({ type: "attackResolved", attacker: attacker.id, diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 1bec1ee..77a56b0 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -1196,3 +1196,72 @@ describe("the bent sight-trace shows AROUND THE CORNER's legs", () => { expect(traced?.bend?.mid).toBeDefined(); }); }); + +describe("the tear is an attack (rev 6)", () => { + function tearRig(deckRev?: number) { + let { state } = createGame({ + playerIds: ["att", "def"], seed: 42, sets: ["basic", "expansion1"], + ...(deckRev != null ? { deckRev } : {}), + }); + state = toRound2(state); + while (activePlayer(state).id !== "att") { + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + } + const att = state.players.find((p) => p.id === "att")!; + const def = state.players.find((p) => p.id === "def")!; + def.position = { ...att.position }; + const gold = state.treasures.find((t) => t.owner === "att" && t.position)!; + gold.position = null; + gold.carriedBy = "def"; + def.carriedTreasureId = gold.id; + state.sustained.push({ + id: "fx-t", cardId: "strength", casterId: "att", targetId: "att", + remainingTurns: 3, data: {}, + }); + return { state, att, def, gold }; + } + + it("opens a counteraction window; a TELEPORT escape keeps the chest", () => { + let { state, gold } = tearRig(); + const r = applyCommand(state, "att", { type: "tearTreasure", targetId: "def" }); + if (!r.ok) throw new Error(r.error); + state = r.state; + expect(state.stack?.tearTreasure).toBe(true); + expect(state.stack?.waitingOn).toBe("def"); + const def = state.players.find((p) => p.id === "def")!; + const view = boardView(state); + const away = SIDES.map((s) => stepTarget(view, def.position, s)) + .find((t) => t.kind === "step")!; + giveCard(state, "def", "teleport", "TP", 0); + state = must(state, "def", { type: "counteract", instanceId: "teleport#TP", params: { cell: (away as { to: Cell }).to } }); + state = must(state, "att", { type: "pass" }); + if (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" }); + expect(state.stack).toBeNull(); + expect(state.players.find((p) => p.id === "def")!.carriedTreasureId).toBe(gold.id); + }); + + it("an uncountered tear wrestles after the window closes", () => { + let { state, gold } = tearRig(); + const r = applyCommand(state, "att", { type: "tearTreasure", targetId: "def" }); + if (!r.ok) throw new Error(r.error); + state = r.state; + const res = applyCommand(state, "def", { type: "pass" }); + if (!res.ok) throw new Error(res.error); + state = res.state; + const rolled = res.events.find((e) => e.type === "dieRolled"); + expect(rolled).toBeTruthy(); + const kept = rolled!.type === "dieRolled" && rolled!.roll === 1; + const att = state.players.find((p) => p.id === "att")!; + const def = state.players.find((p) => p.id === "def")!; + if (kept) expect(def.carriedTreasureId).toBe(gold.id); + else expect(att.carriedTreasureId).toBe(gold.id); + }); + + it("rev 5 games keep the instant tear, no stack", () => { + let { state } = tearRig(5); + const r = applyCommand(state, "att", { type: "tearTreasure", targetId: "def" }); + if (!r.ok) throw new Error(r.error); + expect(r.state.stack).toBeNull(); + expect(r.events.some((e) => e.type === "treasureTorn" || e.type === "tearResisted")).toBe(true); + }); +}); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 1398422..d502ec8 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -417,7 +417,11 @@ describe("strength tears treasures from wizards' arms", () => { let torn = 0, kept = 0; for (let seed = 1; seed <= 12; seed++) { const { state, attacker, defender, treasure } = grip(seed); - const r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender }); + let r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender }); + if (!r.ok) throw new Error(r.error); + // Rev 6: the grab waits out its counteraction window first. + expect(r.state.stack?.tearTreasure).toBe(true); + r = applyCommand(r.state, defender, { type: "pass" }); if (!r.ok) throw new Error(r.error); expect(r.state.turn.attackUsed).toBe(true); // FAQ: tearing is not picking up — the turn's actions continue. @@ -477,7 +481,10 @@ describe("strength tears treasures from wizards' arms", () => { state = must(state, "alice", { type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"], }); - const r = applyCommand(state, "alice", { type: "tearTreasure", targetId: "bob" }); + let r = applyCommand(state, "alice", { type: "tearTreasure", targetId: "bob" }); + if (!r.ok) throw new Error(r.error); + // Rev 6: the grab waits out its counteraction window first. + r = applyCommand(r.state, "bob", { type: "pass" }); if (!r.ok) throw new Error(r.error); const after = r.state.treasures.find((t) => t.id === caras.id)!; if (after.carriedBy === "bob") continue; // Bob rolled his 1 — try again diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index ce5cd8d..287b8d6 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -2195,7 +2195,8 @@ {attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` : view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId ? `an illusionary ${cardDef(view.stack.params.cardId).name}` - : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} — + : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name + : view.stack.tearTreasure ? "a grab at your treasure" : "a punch"} — tap a counteraction card, or {:else} {view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""} diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index ba0f8f4..141ce00 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -66,6 +66,7 @@ export function humanize(e: GameEvent): string | null { ? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!` : `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`; case "tearResisted": return `${e.defender} clutches the treasure with white knuckles — ${e.attacker} comes away empty!`; + case "treasureTearAttempted": return `${e.attacker} grabs for the treasure in ${e.defender}'s arms!`; case "trapSprung": return e.cardId === "gift-from-below" ? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!` : `${e.player} walked into an old TRAP! Lose a turn.`;