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); + } + } + }); +});