Strength gets its grip: tear a treasure from a wizard's arms
The card's second power was never built. New tearTreasure command: with STRENGTH on you, in the same square as a carrier, the tear spends your attack; the victim keeps the treasure only on a D4 roll of 1. Per the FAQ it is not a pickup (your turn's actions continue), and full or weakened arms tear it loose onto the floor instead. A torn treasure's warded owner gets their window, blind grabs flail like blind punches, and the automatons wrestle too. A new command breaks no stored ledger, so live games gain the grip at once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4a91d56d24
commit
76207c6264
@@ -1239,6 +1239,12 @@ export function automatonCommand(
|
|||||||
if (spell) return spell.cmd;
|
if (spell) return spell.cmd;
|
||||||
const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null;
|
const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null;
|
||||||
if (misery) return misery;
|
if (misery) return misery;
|
||||||
|
// STRENGTH in force and the treasure in reach: wrest it from their
|
||||||
|
// arms — worth the attack even for the timid.
|
||||||
|
if (cellKey(target.position) === here && target.carriedTreasureId &&
|
||||||
|
view.sustained.some((e) => e.cardId === "strength" && e.targetId === view.you)) {
|
||||||
|
return { type: "tearTreasure", targetId: target.id };
|
||||||
|
}
|
||||||
if (cellKey(target.position) === here && style !== "worrier") {
|
if (cellKey(target.position) === here && style !== "worrier") {
|
||||||
return { type: "punch", targetId: target.id };
|
return { type: "punch", targetId: target.id };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -605,6 +605,8 @@ export type GameEvent =
|
|||||||
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
|
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
|
||||||
| { type: "handTakenPrivate"; visibleTo: PlayerId; cards: CardInstance[] }
|
| { type: "handTakenPrivate"; visibleTo: PlayerId; cards: CardInstance[] }
|
||||||
| { type: "treasurePickedUp"; player: PlayerId; treasureId: string; owner: PlayerId; at: Cell }
|
| { 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: "treasureDropped"; player: PlayerId; treasureId: string; at: Cell; onHomeOf: PlayerId | null }
|
| { type: "treasureDropped"; player: PlayerId; treasureId: string; at: Cell; onHomeOf: PlayerId | null }
|
||||||
| { type: "playerEliminated"; player: PlayerId; reason: "killed" | "treasuresLost" }
|
| { type: "playerEliminated"; player: PlayerId; reason: "killed" | "treasuresLost" }
|
||||||
| { type: "cardsDiscarded"; player: PlayerId; cards: CardInstance[] }
|
| { type: "cardsDiscarded"; player: PlayerId; cards: CardInstance[] }
|
||||||
@@ -632,6 +634,7 @@ export type Command =
|
|||||||
| { type: "move"; direction: Side; over?: boolean }
|
| { type: "move"; direction: Side; over?: boolean }
|
||||||
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
|
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
|
||||||
| { type: "punch"; targetId: PlayerId }
|
| { type: "punch"; targetId: PlayerId }
|
||||||
|
| { type: "tearTreasure"; targetId: PlayerId }
|
||||||
| { type: "punchWall"; cell: Cell; side: Side }
|
| { type: "punchWall"; cell: Cell; side: Side }
|
||||||
| { type: "testIllusion"; cell: Cell; side: Side }
|
| { type: "testIllusion"; cell: Cell; side: Side }
|
||||||
| { type: "armWard" }
|
| { type: "armWard" }
|
||||||
@@ -3659,6 +3662,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
|||||||
case "move": return doMove(state, command.direction, command.over === true);
|
case "move": return doMove(state, command.direction, command.over === true);
|
||||||
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
|
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
|
||||||
case "punch": return doPunch(state, command.targetId);
|
case "punch": return doPunch(state, command.targetId);
|
||||||
|
case "tearTreasure": return doTearTreasure(state, command.targetId);
|
||||||
case "punchWall": return doPunchWall(state, command.cell, command.side);
|
case "punchWall": return doPunchWall(state, command.cell, command.side);
|
||||||
case "testIllusion": return doTestIllusion(state, command.cell, command.side);
|
case "testIllusion": return doTestIllusion(state, command.cell, command.side);
|
||||||
case "armWard": return doArmWard();
|
case "armWard": return doArmWard();
|
||||||
@@ -4325,6 +4329,90 @@ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult
|
|||||||
return { ok: true, state, events };
|
return { ok: true, state, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STRENGTH's grip: "physically tear a treasure out of the grasp of another
|
||||||
|
* player if you occupy the same space (this would be an attack). The other
|
||||||
|
* player must roll a 1 on the D4 to retain the treasure." The struggle is
|
||||||
|
* bodily — the defender's D4 is the defense, not a counteraction window.
|
||||||
|
* FAQ: tearing is not picking up an object (the turn's actions continue),
|
||||||
|
* and a wizard whose arms are full — or too weak to carry — tears it loose
|
||||||
|
* onto the floor instead.
|
||||||
|
*/
|
||||||
|
function doTearTreasure(prev: GameState, targetId: PlayerId): CommandResult {
|
||||||
|
const pre = attackPreconditions(prev) ?? idiotBlocked(prev, activePlayer(prev).id);
|
||||||
|
if (pre) return err(pre);
|
||||||
|
|
||||||
|
const state = clone(prev);
|
||||||
|
const attacker = activePlayer(state);
|
||||||
|
if (sustainedOn(state, attacker.id, "strength").length === 0) {
|
||||||
|
return err("only STRENGTH lets you tear a treasure from a wizard's grasp");
|
||||||
|
}
|
||||||
|
if (targetId === attacker.id) return err("you cannot attack yourself");
|
||||||
|
const target = state.players.find((p) => p.id === targetId);
|
||||||
|
if (!target || !target.alive) return err("no such living player");
|
||||||
|
if (cellKey(target.position) !== cellKey(attacker.position)) {
|
||||||
|
return err("you must be in the same square to tear it away");
|
||||||
|
}
|
||||||
|
if (!target.carriedTreasureId) return err("they carry no treasure");
|
||||||
|
const bushOrMist = attackBlockedByStatus(state, attacker, target);
|
||||||
|
if (bushOrMist) return err(bushOrMist);
|
||||||
|
state.sustained = state.sustained.filter(
|
||||||
|
(s) => !(s.cardId === "buddy" && s.casterId === attacker.id && s.targetId === target.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const events: GameEvent[] = [];
|
||||||
|
// BLIND: same convention as a punch — the grab finds its grip on a 1.
|
||||||
|
if (isBlinded(state, attacker)) {
|
||||||
|
const roll = rollD4(state, events, attacker.id, "a blind grab — only a 1 finds the grip");
|
||||||
|
if (roll !== 1) {
|
||||||
|
state.turn.attackUsed = true;
|
||||||
|
events.push({
|
||||||
|
type: "attackMisdirected", attacker: attacker.id, intended: target.id,
|
||||||
|
rolledDirection: SIDES[roll - 1]!, newTarget: null,
|
||||||
|
});
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||||
|
state.turn.attackUsed = true;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!;
|
||||||
|
target.carriedTreasureId = null;
|
||||||
|
const laden = attacker.carriedTreasureId != null ||
|
||||||
|
sustainedOn(state, attacker.id, "weakness").length > 0;
|
||||||
|
if (laden) {
|
||||||
|
t.carriedBy = null;
|
||||||
|
t.position = { ...attacker.position };
|
||||||
|
events.push({ type: "treasureTorn", attacker: attacker.id, defender: target.id, treasureId: t.id, toFloor: true });
|
||||||
|
events.push({
|
||||||
|
type: "treasureDropped", player: attacker.id, treasureId: t.id,
|
||||||
|
at: attacker.position, onHomeOf: homeOwnerAt(state, attacker.position),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
t.carriedBy = attacker.id;
|
||||||
|
t.position = null;
|
||||||
|
attacker.carriedTreasureId = t.id;
|
||||||
|
events.push({ type: "treasureTorn", attacker: attacker.id, defender: target.id, treasureId: t.id, toFloor: false });
|
||||||
|
}
|
||||||
|
// WARD guards the treasure against ANY seizure: its owner's window opens.
|
||||||
|
const owner = state.players.find((q) => q.id === t.owner);
|
||||||
|
if (owner && owner.alive && owner.id !== attacker.id) {
|
||||||
|
if (owner.hand.some((c) => c.cardId === "ward")) {
|
||||||
|
state.wardPending = { ownerId: owner.id, takerId: attacker.id };
|
||||||
|
events.push({ type: "wardWindow", owner: owner.id, victim: attacker.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkVictory(state, events);
|
||||||
|
return { ok: true, state, events };
|
||||||
|
}
|
||||||
|
|
||||||
function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
|
function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
|
||||||
const pre = attackPreconditions(prev);
|
const pre = attackPreconditions(prev);
|
||||||
if (pre) return err(pre);
|
if (pre) return err(pre);
|
||||||
|
|||||||
@@ -394,6 +394,105 @@ describe("ambushes (async interrupts)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("strength tears treasures from wizards' arms", () => {
|
||||||
|
function grip(seed: number) {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const d = state.players.find((p) => p.id === defender)!;
|
||||||
|
// The defender carries the ATTACKER's own treasure (no ward window).
|
||||||
|
const stolen = state.treasures.find((t) => t.owner === attacker)!;
|
||||||
|
stolen.carriedBy = defender;
|
||||||
|
stolen.position = null;
|
||||||
|
d.carriedTreasureId = stolen.id;
|
||||||
|
const st = giveCard(state, attacker, "strength");
|
||||||
|
giveCard(state, attacker, "number-2", "N", 1);
|
||||||
|
state = must(state, attacker, {
|
||||||
|
type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"],
|
||||||
|
});
|
||||||
|
return { state, attacker, defender, treasure: stolen.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("the grip is an attack: the victim keeps the treasure only on a 1", () => {
|
||||||
|
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 });
|
||||||
|
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.
|
||||||
|
expect(r.state.turn.actionsEnded).toBe(false);
|
||||||
|
const a = r.state.players.find((p) => p.id === attacker)!;
|
||||||
|
const d = r.state.players.find((p) => p.id === defender)!;
|
||||||
|
if (a.carriedTreasureId === treasure) {
|
||||||
|
torn++;
|
||||||
|
expect(d.carriedTreasureId).toBeNull();
|
||||||
|
} else {
|
||||||
|
kept++;
|
||||||
|
expect(d.carriedTreasureId).toBe(treasure);
|
||||||
|
}
|
||||||
|
// One attack per turn: a second wrench is refused.
|
||||||
|
expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false);
|
||||||
|
}
|
||||||
|
expect(torn + kept).toBe(12);
|
||||||
|
expect(torn).toBeGreaterThan(0);
|
||||||
|
expect(kept).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("without STRENGTH the grip is refused", () => {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
state = toRound2(state);
|
||||||
|
const { attacker, defender } = faceOff(state);
|
||||||
|
const d = state.players.find((p) => p.id === defender)!;
|
||||||
|
const t = state.treasures.find((t) => t.owner === attacker)!;
|
||||||
|
t.carriedBy = defender;
|
||||||
|
t.position = null;
|
||||||
|
d.carriedTreasureId = t.id;
|
||||||
|
const r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laden arms tear it loose onto the floor — and the ward's owner gets their window", () => {
|
||||||
|
for (let seed = 1; seed <= 12; seed++) {
|
||||||
|
let { state } = createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"] });
|
||||||
|
state = toRound2(state);
|
||||||
|
while (activePlayer(state).id !== "alice") {
|
||||||
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||||
|
}
|
||||||
|
const alice = state.players.find((p) => p.id === "alice")!;
|
||||||
|
const bob = state.players.find((p) => p.id === "bob")!;
|
||||||
|
bob.position = { ...alice.position };
|
||||||
|
// Alice already hauls her own gold; Bob carries CARA's — and Cara
|
||||||
|
// holds a WARD over it.
|
||||||
|
const mine = state.treasures.find((t) => t.owner === "alice")!;
|
||||||
|
mine.carriedBy = "alice";
|
||||||
|
mine.position = null;
|
||||||
|
alice.carriedTreasureId = mine.id;
|
||||||
|
const caras = state.treasures.find((t) => t.owner === "cara")!;
|
||||||
|
caras.carriedBy = "bob";
|
||||||
|
caras.position = null;
|
||||||
|
bob.carriedTreasureId = caras.id;
|
||||||
|
giveCard(state, "cara", "ward", "W", 0);
|
||||||
|
const st = giveCard(state, "alice", "strength");
|
||||||
|
giveCard(state, "alice", "number-2", "N", 1);
|
||||||
|
state = must(state, "alice", {
|
||||||
|
type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"],
|
||||||
|
});
|
||||||
|
const r = applyCommand(state, "alice", { type: "tearTreasure", targetId: "bob" });
|
||||||
|
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
|
||||||
|
// Torn loose: Alice's arms are full, so it lands at her feet…
|
||||||
|
expect(after.position).toEqual(alice.position);
|
||||||
|
expect(r.state.players.find((p) => p.id === "alice")!.carriedTreasureId).toBe(mine.id);
|
||||||
|
// …and the seizure hangs on Cara's ward.
|
||||||
|
expect(r.state.wardPending).toEqual({ ownerId: "cara", takerId: "alice" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("every seed rolled a 1 — the rig is wrong");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("swap meet trades carried items", () => {
|
describe("swap meet trades carried items", () => {
|
||||||
function rig() {
|
function rig() {
|
||||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
||||||
|
|||||||
@@ -1021,6 +1021,13 @@
|
|||||||
);
|
);
|
||||||
const carryingTreasure = $derived(me?.carriedTreasureId != null);
|
const carryingTreasure = $derived(me?.carriedTreasureId != null);
|
||||||
const treasureHere = $derived(treasuresHere.length > 0);
|
const treasureHere = $derived(treasuresHere.length > 0);
|
||||||
|
/** Co-located carriers STRENGTH lets you wrestle (the tear is an attack). */
|
||||||
|
const tearTargets = $derived(
|
||||||
|
view && me && view.sustained.some((e) => e.cardId === "strength" && e.targetId === view.you)
|
||||||
|
? view.players.filter((p) => p.alive && p.id !== view.you && p.carriedTreasureId &&
|
||||||
|
cellKey(p.position) === cellKey(me!.position))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
/** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */
|
/** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */
|
||||||
// While an LOS attack sits on the stack, draw the line it traveled — the
|
// While an LOS attack sits on the stack, draw the line it traveled — the
|
||||||
// answer to "how can he even see me?" when sight ran through a warp mouth.
|
// answer to "how can he even see me?" when sight ran through a warp mouth.
|
||||||
@@ -2120,6 +2127,11 @@
|
|||||||
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
|
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
|
||||||
Pick up {cardDef(obj.cardId).name}</button>
|
Pick up {cardDef(obj.cardId).name}</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#each tearTargets as t (t.id)}
|
||||||
|
<button class="stamp" disabled={view.turn.attackUsed || view.turn.round === 1}
|
||||||
|
onclick={() => dispatch({ type: "tearTreasure", targetId: t.id })}>
|
||||||
|
Tear the treasure from {t.id}'s arms</button>
|
||||||
|
{/each}
|
||||||
<button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
|
<button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
|
||||||
onclick={() => (punchWallMode = !punchWallMode)}>
|
onclick={() => (punchWallMode = !punchWallMode)}>
|
||||||
{punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button>
|
{punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button>
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ function describe(view: GameView, cmd: Command): string {
|
|||||||
return `march the monster ${DIRECTION[cmd.direction]}`;
|
return `march the monster ${DIRECTION[cmd.direction]}`;
|
||||||
case "creatureAttack":
|
case "creatureAttack":
|
||||||
return `set the monster on ${cmd.targetId}`;
|
return `set the monster on ${cmd.targetId}`;
|
||||||
|
case "tearTreasure":
|
||||||
|
return `wrest the treasure from ${cmd.targetId}'s arms`;
|
||||||
case "setAmbush":
|
case "setAmbush":
|
||||||
return `lay an ambush with ${nameOf(view, cmd.instanceId)}`;
|
return `lay an ambush with ${nameOf(view, cmd.instanceId)}`;
|
||||||
case "discard":
|
case "discard":
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export function humanize(e: GameEvent): string | null {
|
|||||||
case "warpOpened": return `The outer wall breaches clean through — a new warp opens across the maze!`;
|
case "warpOpened": return `The outer wall breaches clean through — a new warp opens across the maze!`;
|
||||||
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
|
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
|
||||||
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
|
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
|
||||||
|
case "treasureTorn": return e.toFloor
|
||||||
|
? `${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 "trapSprung": return e.cardId === "gift-from-below"
|
case "trapSprung": return e.cardId === "gift-from-below"
|
||||||
? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`
|
? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`
|
||||||
: `${e.player} walked into an old TRAP! Lose a turn.`;
|
: `${e.player} walked into an old TRAP! Lose a turn.`;
|
||||||
|
|||||||
Reference in New Issue
Block a user