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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-23 08:16:30 -04:00
co-authored by Claude Fable 5.1
parent 5193b12b8e
commit 31bfcf7bc0
2 changed files with 61 additions and 2 deletions
+17 -2
View File
@@ -247,7 +247,7 @@ export interface CastParams {
} }
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ /** 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 /** 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 * 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: 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: 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: 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 { export interface GameConfig {
@@ -2452,7 +2453,10 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
ctx.state.turn.movementUsed += d; ctx.state.turn.movementUsed += d;
ctx.attacker.position = { ...ctx.defender.position }; ctx.attacker.position = { ...ctx.defender.position };
ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d }); 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; return;
} }
const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) + 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 /** Damage dealt inside an attack's own resolution hook still honors
* EMPATHY — "Any attack done in ANY form against you acts against both you * EMPATHY — "Any attack done in ANY form against you acts against both you
* and the caster". */ * 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( function resolvedBlow(
ctx: ResolutionContext, dmg: number, source: string, kind?: "physical", ctx: ResolutionContext, dmg: number, source: string, kind?: "physical",
): void { ): void {
@@ -6371,6 +6383,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
isMagicStone(c.cardId) && defender.displayed.includes(c.instanceId)); isMagicStone(c.cardId) && defender.displayed.includes(c.instanceId));
base = (stack.numberValue ?? 1) * inPlay.length; 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.amplifyFactor;
base += stack.powerAttackPoints; base += stack.powerAttackPoints;
// SWARTHMORE'S ENCHANTMENT: an enchanted thrown object bites one deeper. // SWARTHMORE'S ENCHANTMENT: an enchanted thrown object bites one deeper.
@@ -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);
});
});