Rules rev 3: Ward is a choice, Chaos honors the shield
WARD's text says "you may play at that time" — under rules revision 3 the choice is made by setting the trap: an armWard command toggles it on your own turn (your secret; a private wardSet event and a rail note), and the spring fires only while set, consuming the card and the arming together. Async games keep their agency without an interrupt window, exactly as the ambush system solved this before. CHAOS gains its printed interactions: "FULL SHIELD removes a player from participation" — a defender's shield sits them out instead of stopping the spell, and after the counter chain every bystander gets a shield window in seat order (chaosPending; queue head owes a response in lobby summaries, the rail says whose hand hangs in the balance) before the pile forms among the unshielded. "REFLECTIONS have no effect" — both reflections are refused as counters. ABSORB SPELL still eats the whole thing through the existing absorb path. Both changes are frozen behind deckRev 3 so every stored game — and the live one — replays byte-for-byte under its own rules. Five new tests cover armed/unarmed wards, shielded defenders and bystanders, the reflection refusal, and the legacy auto-ward. Not deployed — a live game is in progress. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
41c384274d
commit
7e3e00bf97
+123
-21
@@ -180,6 +180,8 @@ export interface CastStack {
|
||||
kind: "spell" | "physical";
|
||||
counters: { player: PlayerId; card: CardInstance; nullified: boolean }[];
|
||||
waitingOn: PlayerId;
|
||||
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
|
||||
defenderShielded?: boolean;
|
||||
}
|
||||
|
||||
export interface CastParams {
|
||||
@@ -202,9 +204,10 @@ export interface GameConfig {
|
||||
* seat order. */
|
||||
colors?: number[];
|
||||
/**
|
||||
* Deck revision. Absent = the original build, which stored games replay
|
||||
* against forever. Revision 2 removes LIFESAVER from two-player decks
|
||||
* ("Not applicable in a 2-player game." — the card face).
|
||||
* Rules revision, frozen per game so stored games replay unchanged.
|
||||
* Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not
|
||||
* applicable in a 2-player game."). Rev 3: WARD springs only when armed,
|
||||
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS.
|
||||
*/
|
||||
deckRev?: number;
|
||||
}
|
||||
@@ -217,6 +220,10 @@ export interface GameState {
|
||||
edgeOverrides: Record<string, EdgeState>;
|
||||
/** Accumulated attack damage per edge: a wall falls at 20, a door at 15. */
|
||||
wallDamage: Record<string, number>;
|
||||
/** Players whose WARD is set to spring (rules rev 3+; their secret). */
|
||||
wardArmed: PlayerId[];
|
||||
/** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */
|
||||
chaosPending: { casterId: PlayerId; excluded: PlayerId[]; queue: PlayerId[] } | null;
|
||||
/** Permanent door-lock changes, by edge key. */
|
||||
doorStates: Record<string, "jammed" | "removed">;
|
||||
/** Door edges unlocked until the end of the current turn. */
|
||||
@@ -522,6 +529,8 @@ export type GameEvent =
|
||||
| { type: "itemStolen"; from: PlayerId; to: PlayerId; cardId: string }
|
||||
| { type: "itemsSwapped"; a: PlayerId; b: PlayerId }
|
||||
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
|
||||
| { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId }
|
||||
| { type: "chaosShielded"; player: PlayerId }
|
||||
| { type: "curseRemoved"; caster: PlayerId; target: PlayerId; cardId: string }
|
||||
| { type: "objectEnchanted"; caster: PlayerId; cardId: string }
|
||||
| { type: "warpTokensPlaced"; caster: PlayerId; a: Cell; b: Cell }
|
||||
@@ -575,6 +584,7 @@ export type Command =
|
||||
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
|
||||
| { type: "punch"; targetId: PlayerId }
|
||||
| { type: "punchWall"; cell: Cell; side: Side }
|
||||
| { type: "armWard"; armed: boolean }
|
||||
| { type: "warpStep" }
|
||||
| { type: "moveCreature"; creatureId: string; direction: Side }
|
||||
| { type: "creatureAttack"; creatureId: string; targetId: string }
|
||||
@@ -1902,22 +1912,22 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
kind: "attack",
|
||||
baseDamage: () => 0,
|
||||
// Everyone's hands into one pile, shuffled, dealt back in equal counts.
|
||||
// (Simplification: the FULL SHIELD opt-out and ABSORB SPELL interactions
|
||||
// are not modeled — chaos resolves for all living players at once.)
|
||||
// "FULL SHIELD removes a player from participation": under rules rev 3
|
||||
// every bystander gets a shield window before the pile forms; earlier
|
||||
// revisions scramble at once so stored games replay unchanged.
|
||||
onResolved: (ctx) => {
|
||||
const players = ctx.state.players.filter((p) => p.alive);
|
||||
const counts = players.map((p) => p.hand.length);
|
||||
const pile = players.flatMap((p) => p.hand.splice(0));
|
||||
for (const p of players) p.displayed = [];
|
||||
const [shuffled, rngNext] = shuffle(ctx.state.rng, pile);
|
||||
ctx.state.rng = rngNext;
|
||||
let i = 0;
|
||||
players.forEach((p, pi) => {
|
||||
p.hand = shuffled.slice(i, i + counts[pi]!);
|
||||
i += counts[pi]!;
|
||||
ctx.events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] });
|
||||
});
|
||||
ctx.events.push({ type: "handsScrambled", caster: ctx.attacker.id });
|
||||
if (ctx.fullyStopped) return;
|
||||
const excluded = ctx.stack.defenderShielded ? [ctx.defender.id] : [];
|
||||
if ((ctx.state.config.deckRev ?? 1) >= 3) {
|
||||
const order = turnOrderFrom(ctx.state, ctx.attacker.id);
|
||||
const queue = order.filter((id) =>
|
||||
id !== ctx.attacker.id && id !== ctx.defender.id &&
|
||||
ctx.state.players.find((p) => p.id === id)!.alive);
|
||||
ctx.state.chaosPending = { casterId: ctx.attacker.id, excluded, queue };
|
||||
finishChaosIfReady(ctx.state, ctx.events);
|
||||
return;
|
||||
}
|
||||
scrambleHands(ctx.state, ctx.events, ctx.attacker.id, excluded);
|
||||
},
|
||||
},
|
||||
"illusionary-attack": {
|
||||
@@ -2953,6 +2963,8 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
|
||||
board,
|
||||
edgeOverrides: {},
|
||||
wallDamage: {},
|
||||
wardArmed: [],
|
||||
chaosPending: null,
|
||||
doorStates: {},
|
||||
openDoorEdges: [],
|
||||
createdEdges: {},
|
||||
@@ -3030,6 +3042,33 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
|
||||
return err("an attack is being resolved — counteract or pass");
|
||||
}
|
||||
|
||||
if (state.chaosPending) {
|
||||
const head = state.chaosPending.queue[0];
|
||||
if (playerId !== head) return err("waiting for another player to face the chaos");
|
||||
if (command.type === "counteract") {
|
||||
const st = clone(state);
|
||||
const p = st.players.find((q) => q.id === playerId)!;
|
||||
const card = p.hand.find((c) => c.instanceId === command.instanceId);
|
||||
if (!card) return err("card not in hand");
|
||||
if (card.cardId !== "full-shield") return err("only FULL SHIELD keeps your hand out of the chaos");
|
||||
takeFromHand(p, command.instanceId);
|
||||
st.discard.push(card);
|
||||
st.chaosPending!.excluded.push(playerId);
|
||||
st.chaosPending!.queue.shift();
|
||||
const events: GameEvent[] = [{ type: "chaosShielded", player: playerId }];
|
||||
finishChaosIfReady(st, events);
|
||||
return { ok: true, state: st, events };
|
||||
}
|
||||
if (command.type === "pass") {
|
||||
const st = clone(state);
|
||||
st.chaosPending!.queue.shift();
|
||||
const events: GameEvent[] = [];
|
||||
finishChaosIfReady(st, events);
|
||||
return { ok: true, state: st, events };
|
||||
}
|
||||
return err("chaos is coming — shield your hand or pass");
|
||||
}
|
||||
|
||||
// INTERRUPT / OPPORTUNITY FIRE: an out-of-turn action window.
|
||||
if (state.outOfTurnWindow) {
|
||||
if (playerId !== state.outOfTurnWindow.playerId) {
|
||||
@@ -3106,6 +3145,7 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
|
||||
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
|
||||
case "punch": return doPunch(state, command.targetId);
|
||||
case "punchWall": return doPunchWall(state, command.cell, command.side);
|
||||
case "armWard": return doArmWard(state, command.armed);
|
||||
case "warpStep": return doWarpStep(state);
|
||||
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
|
||||
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
|
||||
@@ -3565,6 +3605,52 @@ function touchesEdge(position: Cell, cell: Cell, side: Side): boolean {
|
||||
return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side));
|
||||
}
|
||||
|
||||
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
|
||||
function doArmWard(prev: GameState, armed: boolean): CommandResult {
|
||||
const state = clone(prev);
|
||||
const p = activePlayer(state);
|
||||
if (!p.hand.some((c) => c.cardId === "ward")) return err("you hold no WARD");
|
||||
const already = state.wardArmed.includes(p.id);
|
||||
if (armed === already) return err(armed ? "your ward is already set" : "your ward is not set");
|
||||
state.wardArmed = armed ? [...state.wardArmed, p.id] : state.wardArmed.filter((id) => id !== p.id);
|
||||
return {
|
||||
ok: true,
|
||||
state,
|
||||
events: [{ type: "wardSet", player: p.id, armed, visibleTo: p.id }],
|
||||
};
|
||||
}
|
||||
|
||||
/** Player ids in seat order, starting after `fromId`. */
|
||||
function turnOrderFrom(state: GameState, fromId: PlayerId): PlayerId[] {
|
||||
const ids = state.players.map((p) => p.id);
|
||||
const at = ids.indexOf(fromId);
|
||||
return [...ids.slice(at + 1), ...ids.slice(0, at + 1)];
|
||||
}
|
||||
|
||||
/** "Everyone tosses them in a pile" — except those FULL SHIELD sat out. */
|
||||
function scrambleHands(state: GameState, events: GameEvent[], casterId: PlayerId, excluded: PlayerId[]): void {
|
||||
const players = state.players.filter((p) => p.alive && !excluded.includes(p.id));
|
||||
const counts = players.map((p) => p.hand.length);
|
||||
const pile = players.flatMap((p) => p.hand.splice(0));
|
||||
for (const p of players) p.displayed = [];
|
||||
const [shuffled, rngNext] = shuffle(state.rng, pile);
|
||||
state.rng = rngNext;
|
||||
let i = 0;
|
||||
players.forEach((p, pi) => {
|
||||
p.hand = shuffled.slice(i, i + counts[pi]!);
|
||||
i += counts[pi]!;
|
||||
events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] });
|
||||
});
|
||||
events.push({ type: "handsScrambled", caster: casterId });
|
||||
}
|
||||
|
||||
function finishChaosIfReady(state: GameState, events: GameEvent[]): void {
|
||||
const pending = state.chaosPending;
|
||||
if (!pending || pending.queue.length > 0) return;
|
||||
state.chaosPending = null;
|
||||
scrambleHands(state, events, pending.casterId, pending.excluded);
|
||||
}
|
||||
|
||||
function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
|
||||
const pre = attackPreconditions(prev);
|
||||
if (pre) return err(pre);
|
||||
@@ -4239,6 +4325,12 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
|
||||
const castBlock = castingBlocked(state, playerId);
|
||||
if (castBlock) return err(castBlock);
|
||||
|
||||
// "REFLECTIONS have no effect" against CHAOS (rules rev 3).
|
||||
if (stack.attackCard?.cardId === "chaos" && (state.config.deckRev ?? 1) >= 3 &&
|
||||
(card.cardId === "reflection" || card.cardId === "full-reflection")) {
|
||||
return err("REFLECTIONS have no effect against CHAOS");
|
||||
}
|
||||
|
||||
if (playerId === stack.defenderId) {
|
||||
if (card.cardId === "absorb-spell") {
|
||||
if (stack.kind !== "spell") return err("absorb spell only works against spells");
|
||||
@@ -4415,6 +4507,13 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
reversed: false,
|
||||
kind: stack.kind,
|
||||
};
|
||||
if (attackId === "chaos" && (state.config.deckRev ?? 1) >= 3) {
|
||||
const shielded = stack.counters.some((c) => !c.nullified && c.card.cardId === "full-shield");
|
||||
if (shielded) {
|
||||
stack.defenderShielded = true;
|
||||
stack.counters = stack.counters.filter((c) => c.nullified || c.card.cardId !== "full-shield");
|
||||
}
|
||||
}
|
||||
for (const counter of stack.counters) {
|
||||
if (counter.nullified) continue;
|
||||
if (isNumberCard(counter.card.cardId)) {
|
||||
@@ -4637,15 +4736,18 @@ function doPickUpTreasure(prev: GameState): CommandResult {
|
||||
const events: GameEvent[] = [
|
||||
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
|
||||
];
|
||||
// WARD: the treasure's owner may have trapped it. (Simplification: springs
|
||||
// automatically whenever the owner holds the card.)
|
||||
// WARD: "you may play at that time (out of turn) this card on him" — the
|
||||
// choice is made ahead of time by arming it (rev 3); earlier revisions
|
||||
// spring automatically so stored games replay unchanged.
|
||||
const owner = state.players.find((q) => q.id === t.owner);
|
||||
if (owner && owner.alive && owner.id !== p.id) {
|
||||
const wardSet = (state.config.deckRev ?? 1) >= 3 ? state.wardArmed.includes(owner?.id ?? "") : true;
|
||||
if (owner && owner.alive && owner.id !== p.id && wardSet) {
|
||||
const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward");
|
||||
if (wardIdx !== -1) {
|
||||
const [card] = owner.hand.splice(wardIdx, 1);
|
||||
owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId);
|
||||
state.discard.push(card!);
|
||||
state.wardArmed = state.wardArmed.filter((id) => id !== owner.id);
|
||||
events.push({ type: "wardSprung", owner: owner.id, victim: p.id });
|
||||
applyDamage(state, events, p, 3, "warded treasure", null);
|
||||
checkVictory(state, events);
|
||||
|
||||
@@ -67,6 +67,10 @@ export interface GameView {
|
||||
boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[];
|
||||
dimWarps: { a: { x: number; y: number }; b: { x: number; y: number } }[];
|
||||
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
|
||||
/** CHAOS shield windows in progress (public: everyone sees it coming). */
|
||||
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
|
||||
/** Whether YOUR ward is set to spring. */
|
||||
yourWardArmed: boolean;
|
||||
/** YOUR armed ambushes. Other players' ambushes are invisible. */
|
||||
yourAmbushes: AmbushState[];
|
||||
/** Once the game is finished, every hand goes face-up on the table. */
|
||||
@@ -132,6 +136,10 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
|
||||
wandCharges: { ...state.wandCharges },
|
||||
dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })),
|
||||
outOfTurnWindow: state.outOfTurnWindow ? { ...state.outOfTurnWindow } : null,
|
||||
chaosPending: state.chaosPending
|
||||
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
|
||||
: null,
|
||||
yourWardArmed: state.wardArmed.includes(playerId),
|
||||
yourAmbushes: state.ambushes
|
||||
.filter((a) => a.ownerId === playerId)
|
||||
.map((a) => ({ ...a, numbers: [...a.numbers] })),
|
||||
|
||||
Reference in New Issue
Block a user