From 5193b12b8e1212adcadbcb40a797db2a0b51af53 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 22 Sep 2026 12:19:15 -0400 Subject: [PATCH] =?UTF-8?q?Rev=2025:=20a=20bush=20or=20the=20ooze=20is=20a?= =?UTF-8?q?ttacked=20like=20a=20wall=20=E2=80=94=20POWER=20ATTACK=20counts?= =?UTF-8?q?,=20thrown=20weapons=20land=20at=20its=20foot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Older games took neither; both sides pinned. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/engine/src/game.ts | 22 +++++++++++-- .../engine/test/expansion-terrain.test.ts | 33 ++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 24ebfd2..c938f18 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -247,7 +247,7 @@ export interface CastParams { } /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 24; +export const CURRENT_RULES_REV = 25; /** Every rulings revision since the baseline, newest last — the entries a * game's deckRev freezes it before or after. Shown to players as the house @@ -276,6 +276,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [ { rev: 22, note: "TELEPORT ignores the maze's outer edge as it ignores any wall: a teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on. Older games crossed the edge only at the lettered openings." }, { rev: 23, note: "POWER DRAIN drains the number played: the caster gains it whether the blow is BLUNTed or ABSORBed (FAQ: the counter blunts the damage done, not the drain), and a wall drained for its points gives them up too. Older games gave the caster only what the opponent lost, and nothing from a wall." }, { rev: 24, note: "A REFLECTION against a permanent curse afflicts both wizards: WALKING DEAD and IDIOT say so on the card, and SLOW DEATH follows REFLECTION's own rule that a spell works for both parties. Older games left the caster untouched." }, + { rev: 25, note: "An attack on a bush or the ooze is an attack on an inanimate object like any wall: POWER ATTACK's bought points count, and a thrown DAGGER or LARGE ROCK lands at the foot of the bush instead of vanishing into the discard. Older games took neither." }, ]; export interface GameConfig { @@ -5551,13 +5552,22 @@ function doCast(prev: GameState, cmd: Extract): Comma if (content.kind === "ooze" && inHand.cardId !== "fireball") { return err("only fire hurts the ooze"); } - const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length); + // Rev 25: an inanimate object like any wall — POWER ATTACK's bought + // points count, and thrown weapons land at its foot. + const likeAWall = (state.config.deckRev ?? 1) >= 25; + const dmg = + effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length) + + (likeAWall ? mods.powerAttackPoints : 0); if (dmg <= 0) return err(`that spell would not singe the ${what}`); const wandEvents: GameEvent[] = []; { const werr = spendWandCharge(state, caster, wandEvents); if (werr) return err(werr); } + if (likeAWall && mods.powerAttackPoints > 0) { + caster.life -= mods.powerAttackPoints; + wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance }); + } consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); if (state.turn.attackUsed) state.turn.secondAttackUsed = true; state.turn.attackUsed = true; @@ -5573,6 +5583,14 @@ function doCast(prev: GameState, cmd: Extract): Comma delete state.squareContents[cellKey(cell)]; events.push({ type: "squareContentDestroyed", attacker: caster.id, cell: { ...cell }, kind: content.kind }); } + if (likeAWall && (inHand.cardId === "dagger" || inHand.cardId === "large-rock")) { + const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId); + if (di !== -1) { + const [card] = state.discard.splice(di, 1); + state.groundObjects[cellKey(cell)] = [...(state.groundObjects[cellKey(cell)] ?? []), card!]; + events.push({ type: "objectThrown", attacker: caster.id, cardId: inHand.cardId, landedAt: cell }); + } + } return { ok: true, state, events }; } } diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index 5549404..709488a 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -727,11 +727,42 @@ describe("attacks aimed at a bush or the ooze", () => { expect(r.state.turn.attackUsed).toBe(true); }); - it("a thrown dagger scratches a rosebush, and the scratch stays", () => { + it("a thrown dagger scratches a rosebush, the scratch stays, and the dagger lies at its foot", () => { const { state, me, cell } = withContent("rosebush"); const dagger = giveCard(state, me.id, "dagger"); const next = must(state, me.id, { type: "cast", instanceId: dagger.instanceId, target: { kind: "cell", cell } }); expect(next.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "rosebush", damage: 3 })); + expect(next.groundObjects[cellKey(cell)]).toEqual([expect.objectContaining({ cardId: "dagger" })]); + expect(next.discard.some((c) => c.instanceId === dagger.instanceId)).toBe(false); + }); + + it("POWER ATTACK's bought points count against a bush, at the caster's cost", () => { + const { state, me, cell } = withContent("thornbush"); + const rock = giveCard(state, me.id, "large-rock"); + giveCard(state, me.id, "power-attack", "PA", 1); + const next = must(state, me.id, { + type: "cast", instanceId: rock.instanceId, target: { kind: "cell", cell }, + powerAttackInstanceId: "power-attack#PA", powerAttackPoints: 2, + }); + expect(next.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "thornbush", damage: 4 })); + expect(next.players.find((p) => p.id === me.id)!.life).toBe(13); + }); + + it("older games let a thrown weapon vanish and POWER ATTACK count for nothing against a bush", () => { + const state = toRound2(createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 }).state); + const me = activePlayer(state); + const { cell } = emptyNeighborCell(state, me.position); + state.squareContents[cellKey(cell)] = { kind: "rosebush", damage: 0, createdBy: "bob" }; + const rock = giveCard(state, me.id, "large-rock"); + giveCard(state, me.id, "power-attack", "PA", 1); + const next = must(state, me.id, { + type: "cast", instanceId: rock.instanceId, target: { kind: "cell", cell }, + powerAttackInstanceId: "power-attack#PA", powerAttackPoints: 2, + }); + expect(next.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "rosebush", damage: 2 })); + expect(next.players.find((p) => p.id === me.id)!.life).toBe(15); + expect(next.groundObjects[cellKey(cell)]).toBeUndefined(); + expect(next.discard.some((c) => c.instanceId === rock.instanceId)).toBe(true); }); it("only fire hurts the ooze", () => {