diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index d0d86fe..e9315b5 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -1239,6 +1239,12 @@ export function automatonCommand( if (spell) return spell.cmd; const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null; 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") { return { type: "punch", targetId: target.id }; } diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index ed87b61..e450195 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -605,6 +605,8 @@ export type GameEvent = | { type: "handTaken"; from: PlayerId; to: PlayerId; count: number } | { type: "handTakenPrivate"; visibleTo: PlayerId; cards: CardInstance[] } | { 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: "playerEliminated"; player: PlayerId; reason: "killed" | "treasuresLost" } | { type: "cardsDiscarded"; player: PlayerId; cards: CardInstance[] } @@ -632,6 +634,7 @@ export type Command = | { type: "move"; direction: Side; over?: boolean } | { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string } | { type: "punch"; targetId: PlayerId } + | { type: "tearTreasure"; targetId: PlayerId } | { type: "punchWall"; cell: Cell; side: Side } | { type: "testIllusion"; cell: Cell; side: Side } | { 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 "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId); case "punch": return doPunch(state, command.targetId); + case "tearTreasure": return doTearTreasure(state, command.targetId); case "punchWall": return doPunchWall(state, command.cell, command.side); case "testIllusion": return doTestIllusion(state, command.cell, command.side); case "armWard": return doArmWard(); @@ -4325,6 +4329,90 @@ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult 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 { const pre = attackPreconditions(prev); if (pre) return err(pre); diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 2f314f0..f498815 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -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", () => { function rig() { let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] }); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index abf9701..882e32d 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1021,6 +1021,13 @@ ); const carryingTreasure = $derived(me?.carriedTreasureId != null); 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. */ // 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. @@ -2120,6 +2127,11 @@ onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}> Pick up {cardDef(obj.cardId).name} {/each} + {#each tearTargets as t (t.id)} + + {/each} diff --git a/packages/web/src/hints.ts b/packages/web/src/hints.ts index 68b55b9..86fc264 100644 --- a/packages/web/src/hints.ts +++ b/packages/web/src/hints.ts @@ -61,6 +61,8 @@ function describe(view: GameView, cmd: Command): string { return `march the monster ${DIRECTION[cmd.direction]}`; case "creatureAttack": return `set the monster on ${cmd.targetId}`; + case "tearTreasure": + return `wrest the treasure from ${cmd.targetId}'s arms`; case "setAmbush": return `lay an ambush with ${nameOf(view, cmd.instanceId)}`; case "discard": diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index b09d1d1..1b27e55 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -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 "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`; 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" ? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!` : `${e.player} walked into an old TRAP! Lose a turn.`;