diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 5312f1c..7b58085 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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; /** Accumulated attack damage per edge: a wall falls at 20, a door at 15. */ wallDamage: Record; + /** 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; /** 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 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); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 6833d75..7d9626f 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -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] })), diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index d8ce857..78db78c 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -387,3 +387,103 @@ describe("attacking walls and doors", () => { expect(r.ok).toBe(false); }); }); + +describe("rules revision 3", () => { + function rev3Game(seed = 42) { + return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 }); + } + + it("ward springs only when its owner armed it", () => { + let { state } = rev3Game(); + state = toRound2(state); + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + const thief = activePlayer(state); + const owner = state.players.find((p) => p.id !== thief.id)!; + giveCard(state, owner.id, "ward", "W", 0); + const treasure = state.treasures.find((t) => t.owner === owner.id)!; + + // Unarmed: the grab goes unpunished. + thief.position = { ...treasure.position! }; + let s2 = must(state, thief.id, { type: "pickUpTreasure" }); + expect(s2.players.find((p) => p.id === thief.id)!.life).toBe(15); + expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true); + + // Armed (on the owner's own turn): the trap bites for 3. + state.players[state.turn.activeIndex] = state.players[state.turn.activeIndex]!; + const ownerTurnState = (() => { + let s = state; + while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 }); + return s; + })(); + let s3 = must(ownerTurnState, owner.id, { type: "armWard", armed: true }); + while (activePlayer(s3).id !== thief.id) s3 = must(s3, activePlayer(s3).id, { type: "endTurn", draw: 0 }); + s3.players.find((p) => p.id === thief.id)!.position = { ...treasure.position! }; + s3 = must(s3, thief.id, { type: "pickUpTreasure" }); + expect(s3.players.find((p) => p.id === thief.id)!.life).toBe(12); + expect(s3.wardArmed).not.toContain(owner.id); + }); + + it("chaos: bystanders may shield out, reflections are refused", () => { + let { state } = rev3Game(); + state = toRound2(state); + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + const caster = activePlayer(state); + const defender = state.players.find((p) => p.id !== caster.id)!; + const bystander = state.players.find((p) => p.id !== caster.id && p.id !== defender.id)!; + const chaos = giveCard(state, caster.id, "chaos", "C", 0); + giveCard(state, defender.id, "full-reflection", "R", 0); + giveCard(state, bystander.id, "full-shield", "S", 0); + const bystanderHand = bystander.hand.map((c) => c.instanceId).sort(); + + state = must(state, caster.id, { + type: "cast", instanceId: chaos.instanceId, target: { kind: "player", playerId: defender.id }, + }); + // "REFLECTIONS have no effect." + expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "full-reflection#R" }).ok).toBe(false); + state = must(state, defender.id, { type: "pass" }); + // Now the bystander's window: they shield out and keep their hand. + expect(state.chaosPending?.queue[0]).toBe(bystander.id); + state = must(state, bystander.id, { type: "counteract", instanceId: "full-shield#S" }); + expect(state.chaosPending).toBeNull(); + const after = state.players.find((p) => p.id === bystander.id)!; + expect(after.hand.map((c) => c.instanceId).sort()).toEqual( + bystanderHand.filter((id) => id !== "full-shield#S").sort()); + }); + + it("chaos: the defender's full shield sits them out without stopping it", () => { + let { state } = rev3Game(); + state = toRound2(state); + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + const caster = activePlayer(state); + const defender = state.players.find((p) => p.id !== caster.id)!; + const bystander = state.players.find((p) => p.id !== caster.id && p.id !== defender.id)!; + const chaos = giveCard(state, caster.id, "chaos", "C", 0); + giveCard(state, defender.id, "full-shield", "S", 0); + const defenderHand = () => state.players.find((p) => p.id === defender.id)!.hand.map((c) => c.instanceId).sort(); + const kept = defenderHand().filter((id) => id !== "full-shield#S").sort(); + + state = must(state, caster.id, { + type: "cast", instanceId: chaos.instanceId, target: { kind: "player", playerId: defender.id }, + }); + state = must(state, defender.id, { type: "counteract", instanceId: "full-shield#S" }); + state = must(state, caster.id, { type: "pass" }); // caster declines to anti-anti + state = must(state, defender.id, { type: "pass" }); // defender rests on the shield + // Bystander declines; the scramble happens without the defender. + expect(state.chaosPending?.queue[0]).toBe(bystander.id); + state = must(state, bystander.id, { type: "pass" }); + expect(state.chaosPending).toBeNull(); + expect(defenderHand()).toEqual(kept); + }); + + it("legacy games (rev < 3) keep the automatic ward", () => { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] }); + state = toRound2(state); + const thief = activePlayer(state); + const owner = state.players.find((p) => p.id !== thief.id)!; + giveCard(state, owner.id, "ward", "W", 0); + const treasure = state.treasures.find((t) => t.owner === owner.id)!; + thief.position = { ...treasure.position! }; + state = must(state, thief.id, { type: "pickUpTreasure" }); + expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 9c02a8f..7e84e66 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -43,6 +43,9 @@ export interface Room { const rooms = new Map(); +/** Rules revision new games are dealt under (stored games keep their own). */ +const RULES_REV = 3; + const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; /** Tokens live hashed at rest (memory and disk); clients hold the raw form. */ @@ -176,9 +179,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRe export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } { if (room.state) return { error: "already started" }; const colors = resolveColors(room); - const result = startInMemory(room, expansion, colors, 2); + const result = startInMemory(room, expansion, colors, RULES_REV); if ("error" in result) return result; - appendLine(room.id, { kind: "start", expansion, colors, deckRev: 2 }); + appendLine(room.id, { kind: "start", expansion, colors, deckRev: RULES_REV }); recordRoom(room); return result; } @@ -224,12 +227,13 @@ export interface GameSummary { export function summarize(room: Room, playerId: PlayerId): GameSummary { const s = room.state; const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null; - const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.outOfTurnWindow?.playerId ?? null; + const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ?? s?.outOfTurnWindow?.playerId ?? null; const turnHolder = waitingOn ?? active; let attention: "turn" | "counteract" | "discard" | "interrupt" | null = null; if (s?.phase === "playing" && turnHolder === playerId) { attention = s.stack?.waitingOn === playerId ? "counteract" + : s.chaosPending?.queue[0] === playerId ? "counteract" : s.pendingDiscard === playerId ? "discard" : s.outOfTurnWindow?.playerId === playerId ? "interrupt" : "turn"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 1fd07d9..62a019d 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -87,6 +87,8 @@ : null, ); const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you); + const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null); + const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false); const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null); const EDGE_CARDS = new Set([ @@ -181,7 +183,7 @@ if (!view) return; peekCard = null; peekCreatureId = null; - if (youMustRespond) { + if (youMustRespond || youMustShield) { dispatch({ type: "counteract", instanceId: card.instanceId }); return; } @@ -1003,6 +1005,17 @@
{view.activePlayerId} is taking their turn…
{/if} + {#if view.yourWardArmed} +
🗡 Your ward is set — a thief who grabs your treasure bleeds for 3.
+ {/if} + {#if youMustShield} +
+ Chaos comes for your hand — tap your Full Shield to sit out, or + +
+ {:else if view.chaosPending} +
Chaos gathers — waiting on {view.chaosPending.queue[0]}…
+ {/if} {#each idleCreatures as c (c.id)} {@const moves = c.movesPerTurn - c.movementUsed}
@@ -1197,6 +1210,10 @@ onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}> Pick up {cardDef(obj.cardId).name} {/each} + {#if holdingWard} + + {/if} diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index bf17d27..9aac436 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -101,7 +101,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 2, + deckRev: 3, }; const { state, events } = createGame(config); this.config = config; diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 6291964..f76ecd0 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -129,6 +129,8 @@ export function humanize(e: GameEvent): string | null { case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`; case "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`; case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`; + case "wardSet": return e.armed ? "Your ward is set — the next thief bleeds." : "Your ward stands down."; + case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`; case "wallDamaged": { const what = e.needed === 15 ? "door" : "wall"; return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name} — ${e.total}/${e.needed}.`;