From 31bfcf7bc0e752c6a781d344cbab25f234a57acd Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 23 Sep 2026 08:16:30 -0400 Subject: [PATCH] Rev 26: BUTT-HEAD's ram is priced into the blow, so STRENGTH, AMPLIFY, and the counters weigh it (KWUZ) Ramses cast STRENGTH, then an amplified BUTT-HEAD three squares off, and the goat dealt three: the ram was applied in onResolved, past the pipe that doubles, soaks, and adds. Older games keep the bare distance. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/engine/src/game.ts | 19 +++++++- packages/engine/test/expansion-combat.test.ts | 44 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index c938f18..a2b817c 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 = 25; +export const CURRENT_RULES_REV = 26; /** 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 @@ -277,6 +277,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [ { 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." }, + { rev: 26, note: "BUTT-HEAD's ram is physical damage like any other: STRENGTH doubles it, AMPLIFY doubles it, WEAKNESS doubles it, POWER ATTACK adds to it, and ABSORB or BLUNT soak it. Older games dealt the bare distance, whatever rode the cast." }, ]; export interface GameConfig { @@ -2452,7 +2453,10 @@ const CARD_EFFECTS: Record ctx.state.turn.movementUsed += d; ctx.attacker.position = { ...ctx.defender.position }; ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d }); - resolvedBlow(ctx, d, "goat ram", "physical"); + // Rev 26: the ram was priced into the blow itself (ramDistance), + // so STRENGTH, AMPLIFY, and the counters have already weighed it. + // Older games dealt the bare distance here, past all of them. + if ((ctx.state.config.deckRev ?? 1) < 26) resolvedBlow(ctx, d, "goat ram", "physical"); return; } const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) + @@ -6035,6 +6039,14 @@ function checkAmbushes( /** Damage dealt inside an attack's own resolution hook still honors * EMPATHY — "Any attack done in ANY form against you acts against both you * and the caster". */ +/** How far the goat charges: the corridor distance to its mark within + * this turn's legs, or zero when the mark is underfoot or out of reach. */ +function ramDistance(state: GameState, attacker: PlayerState, defender: PlayerState): number { + const legs = state.turn.movementAllowance - state.turn.movementUsed; + const d = walkingDistance(state, attacker.position, defender.position, chargeReach(state, legs)); + return d > legs ? 0 : d; +} + function resolvedBlow( ctx: ResolutionContext, dmg: number, source: string, kind?: "physical", ): void { @@ -6371,6 +6383,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void { isMagicStone(c.cardId) && defender.displayed.includes(c.instanceId)); base = (stack.numberValue ?? 1) * inPlay.length; } + // Rev 26: BUTT-HEAD's ram is priced here, as the physical blow it is, + // so everything below weighs it like any other. + if (attackId === "butt-head" && (state.config.deckRev ?? 1) >= 26) base = ramDistance(state, attacker, defender); base *= stack.amplifyFactor; base += stack.powerAttackPoints; // SWARTHMORE'S ENCHANTMENT: an enchanted thrown object bites one deeper. diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index b6f0f4c..5c1ff4f 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -895,3 +895,47 @@ describe("cards that keep their whole promise (rev 21)", () => { } }); }); + +describe("the goat's ram is a physical blow like any other (rev 26)", () => { + /** A goat and its mark a charge apart, the goat holding STRENGTH, AMPLIFY, and the ram. */ + function charged(deckRev?: number) { + let { state } = createGame({ playerIds: ["goat", "mark"], seed: 42, sets: ["basic", "expansion1"], ...(deckRev ? { deckRev } : {}) }); + state = toRound2(state); + const goat = activePlayer(state); + const mark = state.players.find((p) => p.id !== goat.id)!; + const bh = giveCard(state, goat.id, "butt-head"); + giveCard(state, goat.id, "amplify", "A", 1); + giveCard(state, goat.id, "strength", "S", 2); + giveCard(state, goat.id, "number-2", "N", 3); + state = must(state, goat.id, { type: "cast", instanceId: "strength#S", numberInstanceIds: ["number-2#N"] }); + // Any square the charge reaches at two or more steps will do. + for (const key of Object.keys(boardView(state).cells)) { + if (key === cellKey(goat.position)) continue; + const [x, y] = key.split(",").map(Number) as [number, number]; + const trial = structuredClone(state); + trial.players.find((p) => p.id === mark.id)!.position = { x, y }; + const r = applyCommand(trial, goat.id, { + type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: mark.id }, amplifyInstanceIds: ["amplify#A"], + }); + if (!r.ok) continue; + const done = applyCommand(r.state, mark.id, { type: "pass" }); + if (!done.ok) continue; + const ram = done.events.find((e) => e.type === "rammed"); + if (!ram || ram.type !== "rammed" || ram.distance < 2) continue; + return { events: done.events, distance: ram.distance, state: done.state, goat: goat.id, mark: mark.id }; + } + throw new Error("no square a charge of two or more reaches on this board"); + } + + it("STRENGTH and AMPLIFY each double the ram", () => { + const { events, distance, state, mark } = charged(); + expect(events).toContainEqual(expect.objectContaining({ type: "damaged", player: mark, amount: distance * 4 })); + expect(state.players.find((p) => p.id === mark)!.life).toBe(15 - distance * 4); + }); + + it("older games dealt the bare distance, whatever rode the cast", () => { + const { events, distance, state, mark } = charged(25); + expect(events).toContainEqual(expect.objectContaining({ type: "damaged", player: mark, amount: distance })); + expect(state.players.find((p) => p.id === mark)!.life).toBe(15 - distance); + }); +});