The goat charges on legs, not wings (rules rev 10)

Eric's table ruling: BUTT-HEAD's ram IS movement — the charge walks
real corridors on the turn's legal legs (three plus numbers played)
and spends them, with the walked distance as the damage. The engine
allowed an unlimited-range homing goat, and game thirteen ended on an
eight-square ram its own caster ruled illegal. At rev 10 the cast
refuses beyond-reach targets up front, a victim who slips away leaves
the goat winded ("outran"), and the legacy test pins the old behavior
at rev 9 for the ledgers that recorded it. The skill also gains the
passivity audit: a turn that only draws is a lost turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-29 00:17:32 -04:00
co-authored by Claude Fable 5
parent cadba96b43
commit 8ba0a934cc
3 changed files with 96 additions and 5 deletions
+32 -3
View File
@@ -240,8 +240,10 @@ export interface CastParams {
* Rev 8: the fire imp's scorch is a SPELL (FAQ) counteractable, magical
* and SOULSTONE's floor holds even at 3 life or below.
* Rev 9: the WARD's bite is counteractable ("COUNTERACTIONs ... otherwise
* work as written"), though nothing a counter does touches its caster. */
export const CURRENT_RULES_REV = 9;
* work as written"), though nothing a counter does touches its caster.
* Rev 10: BUTT-HEAD's ram is MOVEMENT the charge walks real corridors on
* the turn's legal legs (three plus numbers) and spends them. */
export const CURRENT_RULES_REV = 10;
export interface GameConfig {
playerIds: PlayerId[];
@@ -603,7 +605,7 @@ export type GameEvent =
| { type: "counteractionPlayed"; player: PlayerId; card: CardInstance; cardId: string; against: string }
| { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance }
| { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance }
| { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" }
| { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" | "outran" }
| { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean }
| { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number;
soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] }
@@ -2285,8 +2287,35 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
kind: "attack",
physical: true,
baseDamage: () => 0, // computed at resolution: distance rammed
// Rev 10, per the table: the ram IS movement — the goat charges on
// its legal legs (three plus numbers played), walking real corridors.
// Earlier revisions rammed at any range; their ledgers keep it.
validate: (state, _cmd, _caster, target) => {
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
const caster = activePlayer(state);
const legs = state.turn.movementAllowance - state.turn.movementUsed;
const d = walkingDistance(state, caster.position, target.position);
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
return null;
},
onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
if ((ctx.state.config.deckRev ?? 1) >= 10) {
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position);
if (d === 0) return;
if (d > legs) {
// The target slipped beyond the charge (a counter-teleport, say):
// the goat pulls up short, winded, and the table sees it.
ctx.events.push({ type: "attackMissed", attacker: ctx.attacker.id, defender: ctx.defender.id, attackCardId: "butt-head", because: "outran" });
return;
}
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");
return;
}
const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) +
Math.abs(ctx.defender.position.y - ctx.attacker.position.y);
if (d === 0) return;