Disease makes its caster the carrier (rules rev 7)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-28 12:35:35 -04:00
co-authored by Claude Fable 5
parent db3b14fd51
commit b99c22f14a
3 changed files with 97 additions and 3 deletions
+35 -2
View File
@@ -234,8 +234,10 @@ 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.
* Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this would * Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this would
* be an attack") instead of resolving instantly. */ * be an attack") instead of resolving instantly.
export const CURRENT_RULES_REV = 6; * 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 { export interface GameConfig {
playerIds: PlayerId[]; playerIds: PlayerId[];
@@ -2180,6 +2182,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
}, },
}, },
disease: { 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", kind: "attack",
baseDamage: () => 0, baseDamage: () => 0,
sameSquare: false, sameSquare: false,
@@ -4223,6 +4228,18 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
} }
checkVictory(state, events); 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) { if (crossedFirewall) {
events.push({ type: "firewallBurned", player: p.id }); events.push({ type: "firewallBurned", player: p.id });
@@ -4991,6 +5008,22 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
return null; 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") { if (effect.kind === "attack") {
const pre = attackPreconditions(state); const pre = attackPreconditions(state);
if (pre) return err(pre); if (pre) return err(pre);
+55
View File
@@ -1296,3 +1296,58 @@ describe("a forced drop lands like any drop", () => {
if (drop?.type === "treasureDropped") expect(drop.onHomeOf).toBe("def"); 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");
});
});
+7 -1
View File
@@ -552,6 +552,12 @@
"shieldstone", "soulstone", "speedstone", "visionstone", "shieldstone", "soulstone", "speedstone", "visionstone",
"invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline", "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() { function playNumberForMovement() {
if (!selectedCard || !view) return; if (!selectedCard || !view) return;
@@ -2323,7 +2329,7 @@
<label class="inline">life to trade <input class="num-input" type="number" min="1" max="10" bind:value={runPoints} /></label> <label class="inline">life to trade <input class="num-input" type="number" min="1" max="10" bind:value={runPoints} /></label>
<button class="stamp tiny" onclick={castPowerRun}>Run!</button> <button class="stamp tiny" onclick={castPowerRun}>Run!</button>
{/if} {/if}
{#if selectedCard && CONFIRM_CAST.has(selectedCard.cardId)} {#if selectedCard && confirmCastable(selectedCard.cardId)}
<button class="stamp tiny" onclick={castSelfWithNumber}> <button class="stamp tiny" onclick={castSelfWithNumber}>
Cast{attachedNumber ? ` with the ${numberTotal}` : ""} Cast{attachedNumber ? ` with the ${numberTotal}` : ""}
</button> </button>