From 12ece6065abff61a6f0be3f7faa76717cf98ab15 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 30 Aug 2026 16:25:30 -0400 Subject: [PATCH] Mad Dash doubles everything it promises (rules rev 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Including NUMBER cards and other add-ons' now means it: a number riding the cast fuels the dash — (base + number) × 2 — and numbers or POWER RUN points played under it double as well. The 5BM4 five that vanished into a borrowed 'burns 0 life for speed' line gets its own madDash event and chronicle voice. Older games doubled only the base and replay so, pinned at deckRev 11. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 30 +++++++++++-- packages/engine/test/expansion-combat.test.ts | 43 +++++++++++++++++++ packages/web/src/net.svelte.ts | 1 + 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index ba55c46..c2e25b0 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -176,6 +176,8 @@ export interface TurnState { /** A reflected LIGHTNING BLAST stuns mid-turn: no end-of-turn draw either * (FAQ: "you lose the rest of your turn and can't draw cards"). */ drawForbidden?: boolean; + /** MAD DASH: every movement gain this turn doubles — numbers, POWER RUN. */ + madDash?: boolean; } export interface CastStack { @@ -236,7 +238,7 @@ export interface CastParams { } /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 11; +export const CURRENT_RULES_REV = 12; export interface GameConfig { playerIds: PlayerId[]; @@ -278,6 +280,10 @@ export interface GameConfig { * walked, permanently — and a FULL REFLECTION returns a permanent * curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of * letting it evaporate. + * Rev 12: MAD DASH doubles "NUMBER cards and other add-ons" too — a + * number riding the cast fuels it, and numbers or POWER RUN points + * played under the dash double; older games doubled only the base + * allowance and consumed the rider without effect. */ deckRev?: number; } @@ -738,6 +744,7 @@ export type GameEvent = | { type: "cardDisplayed"; player: PlayerId; card: CardInstance } | { type: "extraTurnGranted"; player: PlayerId } | { type: "lifeTraded"; player: PlayerId; points: number; newAllowance: number } + | { type: "madDash"; player: PlayerId; newAllowance: number } | { type: "trapSprung"; player: PlayerId; cardId?: string } | { type: "died"; player: PlayerId; killedBy: PlayerId | null } | { type: "handTaken"; from: PlayerId; to: PlayerId; count: number } @@ -1411,7 +1418,7 @@ const CARD_EFFECTS: Record if (!Number.isInteger(points) || points < 1) return "choose how many life points to trade"; if (points >= caster.life) return "that trade would kill you"; caster.life -= points; - state.turn.movementAllowance += points; + state.turn.movementAllowance += state.turn.madDash ? points * 2 : points; events.push({ type: "lifeTraded", player: caster.id, points, newAllowance: state.turn.movementAllowance }); return null; }, @@ -1871,8 +1878,23 @@ const CARD_EFFECTS: Record kind: "neutral", // "Doubles your movement (including NUMBER cards and other add-ons) for // one turn. You cannot carry treasures while exerting yourself." - resolve: (state, events, caster) => { + resolve: (state, events, caster, _cmd, magnitude) => { if (caster.carriedTreasureId) return "you cannot mad-dash while carrying a treasure"; + // Rev 12: "including NUMBER cards and other add-ons" — a number + // riding the cast is the turn's movement number, and every movement + // gain after the dash doubles too. Older games doubled only the base + // allowance, ate the rider, and spoke in POWER RUN's voice. + if ((state.config.deckRev ?? 1) >= 12) { + const fuel = magnitude.numberValue ?? 0; + if (fuel > 0) { + if (state.turn.numberPlayedForMovement) return "a number already fuels this turn's movement"; + state.turn.numberPlayedForMovement = true; + } + state.turn.madDash = true; + state.turn.movementAllowance = (state.turn.movementAllowance + fuel) * 2; + events.push({ type: "madDash", player: caster.id, newAllowance: state.turn.movementAllowance }); + return null; + } state.turn.movementAllowance *= 2; events.push({ type: "lifeTraded", player: caster.id, points: 0, newAllowance: state.turn.movementAllowance }); return null; @@ -4494,7 +4516,7 @@ function doPlayNumberForMovement(prev: GameState, instanceId: string, addInstanc const value = numberValue(card.cardId) + (displays(p, "powerstone") ? 1 : 0); state.discard.push(card); - state.turn.movementAllowance += value; + state.turn.movementAllowance += state.turn.madDash ? value * 2 : value; state.turn.numberPlayedForMovement = true; return { ok: true, diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 3e471d4..5a13fd1 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -84,6 +84,49 @@ describe("expansion combat cards", () => { expect(state.players.find((p) => p.id === defender)!.life).toBe(16); }); + it("mad dash doubles the base, the riding number, and later fuel (rev 12)", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state).id; + const md = giveCard(state, me, "mad-dash"); + giveCard(state, me, "exp1-number-5", "N5", 1); + state = must(state, me, { + type: "cast", instanceId: md.instanceId, numberInstanceIds: ["exp1-number-5#N5"], + }); + expect(state.turn.movementAllowance).toBe(16); // (3 + 5) × 2 + // The rider was the turn's movement number: a bare second is refused. + giveCard(state, me, "number-2", "N2", 0); + expect(applyCommand(state, me, { type: "playNumberForMovement", instanceId: "number-2#N2" }).ok).toBe(false); + // POWER RUN points double under the dash too. + const pr = giveCard(state, me, "power-run", "PR", 2); + state = must(state, me, { type: "cast", instanceId: pr.instanceId, params: { points: 2 } }); + expect(state.turn.movementAllowance).toBe(20); // 16 + 2×2 + }); + + it("a number played after a bare mad dash doubles as well (rev 12)", () => { + let { state } = newGame(); + state = toRound2(state); + const me = activePlayer(state).id; + const md = giveCard(state, me, "mad-dash"); + state = must(state, me, { type: "cast", instanceId: md.instanceId }); + expect(state.turn.movementAllowance).toBe(6); // 3 × 2 + giveCard(state, me, "exp1-number-5", "N5", 0); + state = must(state, me, { type: "playNumberForMovement", instanceId: "exp1-number-5#N5" }); + expect(state.turn.movementAllowance).toBe(16); // 6 + 5×2 + }); + + it("older decks double only the base and eat the rider (rev ≤11)", () => { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 11 }); + state = toRound2(state); + const me = activePlayer(state).id; + const md = giveCard(state, me, "mad-dash"); + giveCard(state, me, "exp1-number-5", "N5", 1); + state = must(state, me, { + type: "cast", instanceId: md.instanceId, numberInstanceIds: ["exp1-number-5#N5"], + }); + expect(state.turn.movementAllowance).toBe(6); // 3 × 2, the five wasted + }); + it("mental swap trades entire hands", () => { let { state } = newGame(); state = toRound2(state); diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 96207ba..113de87 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -78,6 +78,7 @@ export function humanize(e: GameEvent): string | null { ? `${e.player}'s bloodstone drinks the whole blow — no damage.` : `${e.player} is stone — the damage has no effect.`; case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`; + case "madDash": return `${e.player} MAD DASHES — every stride doubles: ${e.newAllowance} movement this turn!`; case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`; case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`; case "teleported": return e.by === e.player