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:
co-authored by
Claude Fable 5
parent
db3b14fd51
commit
b99c22f14a
@@ -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<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
},
|
||||
},
|
||||
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<Command, { type: "cast" }>): 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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user