From 85690f0e23527dd2139fe7a4755136072a1420ee Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 15 Sep 2026 23:57:38 -0400 Subject: [PATCH] Rev 21: LIFESAVER saves, and FORCE FIELD stands for the turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of every card in the deck, prompted by the Alter Ego, found two more whose cast worked but whose promise was never kept. LIFESAVER — "immune to the effects of losing both of your treasures" — was cast and remembered and never asked where a wizard is eliminated for exactly that; twenty-three recorded casts never mattered. FORCE FIELD stopped the spell and vanished, though the card keeps it standing until the opponent's turn ends, barring them from entering its caster's square or casting on or past them. Both are honored from rev 21, the field on every side where the card says one. Older games replay as they were played. Tests pin both readings. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/engine/src/game.ts | 38 ++++++++++++- packages/engine/test/expansion-combat.test.ts | 54 ++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index ffd8600..a52844c 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -241,7 +241,7 @@ export interface CastParams { } /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 20; +export const CURRENT_RULES_REV = 21; /** Every rulings revision since the baseline, newest last — the entries a * game's deckRev freezes it before or after. Shown to players as the house @@ -266,6 +266,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [ { rev: 18, note: "REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window — an ABSORB or a BLUNT meets it as it would any blow. Before, the half landed the instant the spell resolved." }, { rev: 19, note: "DUST CLOUD blinds whoever stands in it: no LOS spell may be cast from inside a cloud, nor at anyone standing in one, and VISIONSTONE does not see through it. Spells cast on oneself still work. Before, the cloud blocked only sight lines passing through it." }, { rev: 20, note: "A waterwall's wave names its victims before it pushes any of them. Before, a wave walking the way it pushed could catch a wizard it had just shoved and shove them again with the force left — one square into a wall cost two points instead of one." }, + { rev: 21, note: "Two cards keep their whole promise. LIFESAVER's holder is not eliminated for losing both treasures. FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them — on every side, where the card says one." }, ]; export interface GameConfig { @@ -453,10 +454,24 @@ function dustAtEnd(state: GameState, from: Cell, to: Cell): boolean { return state.squareContents[cellKey(from)]?.kind === "dust" || state.squareContents[cellKey(to)]?.kind === "dust"; } +/** A FORCE FIELD raised against `id` on the wizard standing at `cell`, + * if any (rev 21): they may not enter it, nor cast on or past it. */ +function fieldAgainst(state: GameState, id: PlayerId, cell: Cell): boolean { + if ((state.config.deckRev ?? 1) < 21) return false; + const idx = state.players.findIndex((q) => q.id === id); + return state.players.some((h) => h.alive && h.id !== id && cellKey(h.position) === cellKey(cell) && + sustainedOn(state, h.id, "force-field").some((f) => f.data.against === idx)); +} + function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean { if (dustAtEnd(state, from, to)) return false; const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); const blockers = losBlockers(state); + if (viewerId) { + for (const h of state.players) { + if (h.alive && h.id !== viewerId && fieldAgainst(state, viewerId, h.position)) blockers[cellKey(h.position)] = true; + } + } if (sightBetween(board, from, to, blockers)) return true; if (!stone) return false; const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined; @@ -4346,6 +4361,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho // Square contents at the destination. let content = state.squareContents[cellKey(p.position)]; if (content?.kind === "stone") return err("that square is solid stone"); + if (fieldAgainst(state, p.id, p.position)) return err("a force field bars the way"); // BIG MAN: nobody enters his square — except a SHRUNK wizard, small // enough to slip between the giant's boots (table ruling). if (sustainedOn(state, p.id, "shrink").length === 0) { @@ -5563,6 +5579,7 @@ function doCast(prev: GameState, cmd: Extract): Comma } const statusBlock = attackBlockedByStatus(state, caster, target); if (statusBlock) return err(statusBlock); + if (fieldAgainst(state, caster.id, target.position)) return err("their force field turns your spell aside"); const preEvents: GameEvent[] = []; if (effect.requiresLos) { const sighted = mods.aroundCorner @@ -6053,6 +6070,14 @@ function doCounteract( // any, will be swapped" — the reflector's choice rides the counter. ...(card.cardId === "full-reflection" && params?.cardId ? { cardId: params.cardId } : {}), }); + // Rev 21: FORCE FIELD stands after it stops the spell — "preventing an + // opponent from entering the space you occupy or casting spells on or + // past you. Lasts only until the end of the opponent's turn." The + // field faces every side, not one: this table's simplification. + if (card.cardId === "force-field" && (state.config.deckRev ?? 1) >= 21) { + const against = state.players.findIndex((q) => q.id === stack.attackerId); + attachSustained(state, [], "force-field", playerId, playerId, PERMANENT_TURNS, { against }); + } stack.waitingOn = stack.attackerId; return { ok: true, @@ -6818,7 +6843,11 @@ function checkVictory(state: GameState, events: GameEvent[]): void { const ownerPlayer = owner ? state.players.find((q) => q.id === owner) : null; return owner !== null && owner !== p.id && ownerPlayer?.alive === true; }); - if (lost) { + // Rev 21: LIFESAVER — "immune to the effects of losing both of your + // treasures to other players' home bases." The card was cast and + // remembered but never asked here; older games eliminated its holder. + const saved = (state.config.deckRev ?? 1) >= 21 && sustainedOn(state, p.id, "lifesaver").length > 0; + if (lost && !saved) { p.alive = false; p.finalHand = [...p.hand]; state.discard.push(...p.hand.splice(0)); @@ -7148,6 +7177,11 @@ function doEndTurn(prev: GameState, draw: number): CommandResult { } state.openSafes = []; + // A FORCE FIELD raised against this wizard lasts only until their turn ends. + { + const idx = state.players.findIndex((q) => q.id === p.id); + state.sustained = state.sustained.filter((f) => !(f.cardId === "force-field" && f.data.against === idx)); + } events.push({ type: "turnEnded", player: p.id }); if (p.extraTurns > 0) { diff --git a/packages/engine/test/expansion-combat.test.ts b/packages/engine/test/expansion-combat.test.ts index 0468730..f59c7b0 100644 --- a/packages/engine/test/expansion-combat.test.ts +++ b/packages/engine/test/expansion-combat.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, walkingDistance, type GameState } from "../src/game"; -import { cellKey, edgeKey, stepTarget, type Cell } from "../src/board"; +import { cellKey, edgeKey, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import type { CardInstance } from "../src/cards"; import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers"; @@ -843,3 +843,55 @@ describe("butt-head's charge reaches as far as its legs (K4T3)", () => { }); } }); + +describe("cards that keep their whole promise (rev 21)", () => { + it("LIFESAVER's holder survives losing both treasures", () => { + for (const [deckRev, alive] of [[21, true], [20, false]] as const) { + let { state } = createGame({ playerIds: ["alice", "bob", "carol"], seed: 42, sets: ["basic", "expansion1"], deckRev }); + const me = activePlayer(state); + const others = state.players.filter((p) => p.id !== me.id); + const card = giveCard(state, me.id, "lifesaver"); + state = must(state, me.id, { type: "cast", instanceId: card.instanceId }); + // Both of the holder's treasures rest on two different enemy homes: nobody wins, the holder is done for. + const mine = state.treasures.filter((t) => t.owner === me.id); + mine[0]!.position = { ...others[0]!.home }; + mine[1]!.position = { ...others[1]!.home }; + state = must(state, me.id, { type: "endTurn", draw: 0 }); + expect(state.players.find((p) => p.id === me.id)!.alive).toBe(alive); + } + }); + + it("FORCE FIELD stands until the opponent's turn ends: no entering, no casting on its caster", () => { + for (const deckRev of [21, 20]) { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const a = state.players.find((p) => p.id === attacker)!; + const d = state.players.find((p) => p.id === defender)!; + // Stand the attacker beside the defender, an open edge between. + const view = boardView(state); + const side = SIDES.find((s: Side) => { const t = stepTarget(view, d.position, s); return t.kind === "step" && !state.squareContents[cellKey(t.to)]; })!; + const beside = stepTarget(view, d.position, side); + if (beside.kind === "blocked") throw new Error("no open side"); + a.position = { ...beside.to }; + const fb = giveCard(state, attacker, "fireball"); + d.hand[0] = { instanceId: "force-field#T", cardId: "force-field" }; + state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } }); + state = must(state, defender, { type: "counteract", instanceId: "force-field#T" }); + state = must(state, attacker, { type: "pass" }); + if (state.stack) state = must(state, defender, { type: "pass" }); + expect(state.players.find((p) => p.id === defender)!.life).toBe(15); + const step = applyCommand(state, attacker, { type: "move", direction: opposite(side) }); + if (deckRev >= 21) { + expect(step.ok).toBe(false); + if (!step.ok) expect(step.error).toMatch(/force field/); + expect(state.sustained.some((f) => f.cardId === "force-field")).toBe(true); + const after = must(state, attacker, { type: "endTurn", draw: 0 }); + expect(after.sustained.some((f) => f.cardId === "force-field")).toBe(false); + } else { + expect(step.ok).toBe(true); + expect(state.sustained.some((f) => f.cardId === "force-field")).toBe(false); + } + } + }); +});