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:
co-authored by
Claude Fable 5
parent
cbd4d80dea
commit
e388691858
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" : ""}
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
Reference in New Issue
Block a user