The tear waits for counteractions (rules rev 6)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-27 22:40:12 -04:00
co-authored by Claude Fable 5
parent cbd4d80dea
commit e388691858
6 changed files with 144 additions and 8 deletions
+10
View File
@@ -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 =
+53 -5
View File
@@ -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,