From b99c22f14a69df5c8627c1a68710c1ebe890c4ef Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Fri, 28 Aug 2026 12:35:35 -0400 Subject: [PATCH] Disease makes its caster the carrier (rules rev 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card says it plainly — "You're the carrier! Disease caused by spell does not affect you, only others" — but the engine cast it AT an adjacent victim, making them the plague rat. At rev 7 it is a self-cast on fear's pattern: no target, no counteraction window ("REFLECTIONs have no effect against this"), duration equals the number, and sharing a square bites in both directions — the carrier walking in, or anyone walking onto the carrier. Older ledgers hold targeted disease casts with full stack exchanges, so the legacy attack path survives for rev ≤6 games and all 33 ledgers replay clean. The client offers the plain Cast button for it in rev-7 games. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 37 ++++++++++++++++++- packages/engine/test/casting.test.ts | 55 ++++++++++++++++++++++++++++ packages/web/src/App.svelte | 8 +++- 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index de91415..d06e543 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -234,8 +234,10 @@ export interface CastParams { /** The revision new games are dealt under; GameConfig.deckRev pins it per game. * Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this would - * be an attack") instead of resolving instantly. */ -export const CURRENT_RULES_REV = 6; + * be an attack") instead of resolving instantly. + * Rev 7: DISEASE is a self-cast plague ("You're the carrier!") — the caster + * carries it, sharing a square bites both directions, no counteraction. */ +export const CURRENT_RULES_REV = 7; export interface GameConfig { playerIds: PlayerId[]; @@ -2180,6 +2182,9 @@ const CARD_EFFECTS: Record }, }, disease: { + // Legacy (rev ≤6): cast AT an adjacent victim through the attack + // stack; ledgers from those games replay this path. Rev 7 games + // never reach it — doCast intercepts disease as a self-cast plague. kind: "attack", baseDamage: () => 0, sameSquare: false, @@ -4223,6 +4228,18 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { } checkVictory(state, events); } + // Rev 7: sharing a plague square cuts both ways — walking INTO a + // carrier's square catches the disease's bite just the same. + if ((state.config.deckRev ?? 1) >= 7 && p.alive) { + for (const other of state.players) { + if (!other.alive || other.id === p.id) continue; + if (cellKey(other.position) !== cellKey(p.position)) continue; + if (sustainedOn(state, other.id, "disease").length === 0) continue; + applyDamage(state, events, p, 3, "disease", null, "physical"); + checkVictory(state, events); + break; + } + } if (crossedFirewall) { events.push({ type: "firewallBurned", player: p.id }); @@ -4991,6 +5008,22 @@ function doCast(prev: GameState, cmd: Extract): Comma return null; }; + // DISEASE (rev 7): "You're the carrier!" — a self-cast plague on fear's + // pattern, no target and no counteraction window ("REFLECTIONs have no + // effect against this"). Older games cast it at a victim through the + // stack; their ledgers replay the legacy attack path below. + if (inHand.cardId === "disease" && (state.config.deckRev ?? 1) >= 7) { + consumeCast(state, caster, inHand, mods, false); + state.lastSpellUsed[caster.id] = inHand.cardId; + const events: GameEvent[] = [{ + type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, + numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, + from: caster.position, target: null, targetCell: null, + }]; + attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration); + return { ok: true, state, events }; + } + if (effect.kind === "attack") { const pre = attackPreconditions(state); if (pre) return err(pre); diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index a4cbc0f..7721c22 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -1296,3 +1296,58 @@ describe("a forced drop lands like any drop", () => { if (drop?.type === "treasureDropped") expect(drop.onHomeOf).toBe("def"); }); }); + +describe("disease makes the caster the carrier (rev 7)", () => { + it("self-casts with no target; sharing a square bites both directions", () => { + let { state } = createGame({ playerIds: ["carrier", "mark"], seed: 42, sets: ["basic", "expansion1"] }); + state = toRound2(state); + while (activePlayer(state).id !== "carrier") { + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + } + const carrier = state.players.find((p) => p.id === "carrier")!; + const mark = state.players.find((p) => p.id === "mark")!; + const dz = giveCard(state, "carrier", "disease", "D", 0); + giveCard(state, "carrier", "number-3", "N", 1); + let r = applyCommand(state, "carrier", { + type: "cast", instanceId: dz.instanceId, numberInstanceIds: ["number-3#N"], + }); + if (!r.ok) throw new Error(r.error); + state = r.state; + expect(state.stack).toBeNull(); + expect(state.sustained.some((s) => s.cardId === "disease" && s.targetId === "carrier")).toBe(true); + + // The carrier walks INTO the mark's square: the mark takes 3. + const view = boardView(state); + const carrierNow = state.players.find((p) => p.id === "carrier")!; + const markNow = state.players.find((p) => p.id === "mark")!; + for (const side of SIDES) { + const t = stepTarget(view, carrierNow.position, side); + if (t.kind !== "step") continue; + markNow.position = { ...t.to }; + const before = markNow.life; + r = applyCommand(state, "carrier", { type: "move", direction: side }); + if (!r.ok) throw new Error(r.error); + state = r.state; + expect(state.players.find((p) => p.id === "mark")!.life).toBe(before - 3); + break; + } + state = must(state, "carrier", { type: "endTurn", draw: 0 }); + + // The mark walks INTO the carrier's square: the mark takes 3 again. + const m2 = state.players.find((p) => p.id === "mark")!; + const c2 = state.players.find((p) => p.id === "carrier")!; + const view2 = boardView(state); + for (const side of SIDES) { + const t = stepTarget(view2, c2.position, side); + if (t.kind !== "step") continue; + m2.position = { ...t.to }; + const back = side === "N" ? "S" : side === "S" ? "N" : side === "E" ? "W" : "E"; + const before = m2.life; + r = applyCommand(state, "mark", { type: "move", direction: back as "N" }); + if (!r.ok) throw new Error(r.error); + expect(r.state.players.find((p) => p.id === "mark")!.life).toBe(before - 3); + return; + } + throw new Error("no adjacent step for the return walk"); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index baf788f..db74cd7 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -552,6 +552,12 @@ "shieldstone", "soulstone", "speedstone", "visionstone", "invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline", ]); + /** Cards cast on yourself via the Cast button. DISEASE joined at rev 7 + * ("You're the carrier!"); older games still aim it at a victim. */ + function confirmCastable(cardId: string): boolean { + return CONFIRM_CAST.has(cardId) || + (cardId === "disease" && (view?.deckRev ?? 1) >= 7); + } function playNumberForMovement() { if (!selectedCard || !view) return; @@ -2323,7 +2329,7 @@ {/if} - {#if selectedCard && CONFIRM_CAST.has(selectedCard.cardId)} + {#if selectedCard && confirmCastable(selectedCard.cardId)}