From 3f71b04c3b7f679acaa819fc6469676dfa0774b0 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 7 Sep 2026 14:38:48 -0500 Subject: [PATCH] Rev 20: a wave names its victims before it pushes (LK97) Kestrel stood against a wall Mad Hywel turned to water, was washed one square back into stone, and was crushed twice. The sweep walked the way it pushed: having shoved him onto the next square, its next probe found him there and shoved him again with the force left. From rev 20 a sweep names every victim along its line first, then pushes each once; older games replay the double shove. A test washes a wizard one square into a wall under both readings. With it, a client fix: a tap on a bare square while holding a card no longer walks there. Kestrel tapped a slime with LIGHTNING BLAST in hand and strode into it. The tap now says the card is aimed at a wizard or creature and leaves the feet alone. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/engine/src/game.ts | 88 ++++++++++--------- .../engine/test/expansion-terrain.test.ts | 39 ++++++++ packages/web/src/App.svelte | 7 ++ 3 files changed, 92 insertions(+), 42 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 78d054c..16e1b87 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -238,7 +238,7 @@ export interface CastParams { } /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 19; +export const CURRENT_RULES_REV = 20; /** 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 @@ -262,6 +262,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [ { rev: 17, note: "A warp mouth on a pit's rim is a way off it like any square: a bare step beside one square and one mouth is a fork the walker names. In every game the mouth may be named, and is taken when no square offers; only an older game's bare step still lands on the square unasked." }, { 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." }, ]; export interface GameConfig { @@ -2876,6 +2877,48 @@ function waveForce(range: number, dist: number): number { return range - dist; } +/** One line of a wave's sweep: the squares it reaches, nearest first, with + * the force left at each. The victims are named before any is pushed + * (rev 20). Before it the sweep, walking the way it pushed, could find a + * victim it had just shoved and shove them again with the force left: a + * wizard washed one square into a wall was crushed twice. Older games + * replay that sweep. */ +function sweepWave( + state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string, +): void { + const gather = ({ cell, force }: { cell: Cell; force: number }) => ({ + force, + players: state.players.filter((p) => p.alive && cellKey(p.position) === cellKey(cell)), + creatures: state.creatures.filter((c) => cellKey(c.position) === cellKey(cell)), + slime: state.squareContents[cellKey(cell)]?.kind === "slime" ? cell : null, + }); + const apply = (hit: ReturnType) => { + for (const p of hit.players) washBackN(state, events, p, dir, hit.force); + for (const c of hit.creatures) { + if (!state.creatures.includes(c)) continue; + if (c.kind === "fire-imp") destroyCreature(state, events, c, reason); + else washBackCreature(state, events, c, dir, hit.force); + } + if (hit.slime) { + delete state.squareContents[cellKey(hit.slime)]; + delete state.slimeTraps[cellKey(hit.slime)]; + events.push({ type: "slimeWashed", cell: hit.slime }); + } + }; + if ((state.config.deckRev ?? 1) >= 20) line.map(gather).forEach(apply); + else for (const step of line) apply(gather(step)); +} + +function waveLine(start: Cell, dir: Side, range: number): { cell: Cell; force: number }[] { + const line: { cell: Cell; force: number }[] = []; + let probe = start; + for (let dist = 0; dist < range; dist++) { + line.push({ cell: probe, force: waveForce(range, dist) }); + probe = neighbor(probe, dir); + } + return line; +} + /** A collapsing waterwall wave from an edge: wash players back `range`. */ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void { // A warp mouth's "far side" is the opposite rim: the collapse washes both @@ -2890,51 +2933,12 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S { start: cell, dir: opposite(side) }, { start: neighbor(cell, side), dir: side }, ]; - for (const { start, dir } of pushes) { - let probe = start; - for (let dist = 0; dist < range; dist++) { - const force = waveForce(range, dist); - for (const p of state.players) { - if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force); - } - for (const c of [...state.creatures]) { - if (cellKey(c.position) !== cellKey(probe)) continue; - if (c.kind === "fire-imp") { - destroyCreature(state, events, c, reason); - } else { - washBackCreature(state, events, c, dir, force); - } - } - if (state.squareContents[cellKey(probe)]?.kind === "slime") { - delete state.squareContents[cellKey(probe)]; - delete state.slimeTraps[cellKey(probe)]; - events.push({ type: "slimeWashed", cell: probe }); - } - probe = neighbor(probe, dir); - } - } + for (const { start, dir } of pushes) sweepWave(state, events, waveLine(start, dir, range), dir, reason); } /** A wave bursting outward from a cell in all four directions. */ function waveFromCell(state: GameState, events: GameEvent[], center: Cell, range: number): void { - for (const dir of SIDES) { - let probe = center; - for (let dist = 0; dist < range; dist++) { - probe = neighbor(probe, dir); - const force = waveForce(range, dist); - for (const p of state.players) { - if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force); - } - for (const c of [...state.creatures]) { - if (cellKey(c.position) !== cellKey(probe)) continue; - if (c.kind === "fire-imp") { - destroyCreature(state, events, c, "rushing water"); - } else { - washBackCreature(state, events, c, dir, force); - } - } - } - } + for (const dir of SIDES) sweepWave(state, events, waveLine(neighbor(center, dir), dir, range), dir, "rushing water"); } /** A monster summon: ATTACK-typed, uses your attack, appears in your LOS. */ diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index c14b03d..9c0ef9d 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -636,3 +636,42 @@ describe("a pit on the board's rim (M4Q7)", () => { } }); }); + +describe("a wave names its victims before it pushes (LK97)", () => { + /** A wall between two squares, a wizard on its near side with one open + * square behind them and a wall beyond it, and a caster's square beside. */ + function site(state: GameState) { + const view = boardView(state); + for (const k of Object.keys(view.cells)) { + const [x, y] = k.split(",").map(Number) as [number, number]; + const a = { x, y }; + if (state.squareContents[k] || view.homes.some((h) => cellKey(h) === k)) continue; + if ((view.edges[edgeKey(a, "S")] ?? "open") !== "wall" || !view.cells[cellKey(neighbor(a, "S"))]) continue; + const back = stepTarget(view, a, "N"); + if (back.kind !== "step" || state.squareContents[cellKey(back.to)] || view.homes.some((h) => cellKey(h) === cellKey(back.to))) continue; + if (stepTarget(view, back.to, "N").kind !== "blocked") continue; + const beside = stepTarget(view, a, "E"); + if (beside.kind !== "step" || state.squareContents[cellKey(beside.to)]) continue; + return { a, back: back.to, beside: beside.to }; + } + throw new Error("no such wall on this board"); + } + for (const [deckRev, crush] of [[20, 1], [19, 2]] as const) { + it(`washed one square into a wall: rev ${deckRev} costs ${crush}`, () => { + let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev }); + const caster = activePlayer(state); + const victim = state.players.find((p) => p.id !== caster.id)!; + const s = site(state); + caster.position = { ...s.beside }; + victim.position = { ...s.a }; + const card = giveCard(state, caster.id, "stone-to-water"); + const r = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId, target: { kind: "edge", cell: s.a, side: "S" } }); + expect(r.ok).toBe(true); + if (!r.ok) return; + const v = r.state.players.find((p) => p.id === victim.id)!; + expect(cellKey(v.position)).toBe(cellKey(s.back)); + expect(v.life).toBe(15 - crush); + expect(r.events.filter((e) => e.type === "washedBack" && e.player === victim.id).length).toBe(crush === 1 ? 1 : 2); + }); + } +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 184950a..2aba538 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -912,6 +912,13 @@ return; } const me = view.players.find((p) => p.id === view.you)!; + // A card in hand is aimed, not walked with: a tap on a bare square + // while holding one is a miss, never a stride into whatever is there. + if (selectedCard) { + net.error = `${cardDef(selectedCard.cardId).name} is aimed by tapping a wizard or creature — put the card down to walk`; + setTimeout(() => { if (net.error?.startsWith(cardDef(selectedCard!.cardId).name)) net.error = null; }, 5000); + return; + } // A cell click is a move if the cell is one legal step away (the server // also lets doors/walls pass when unlocked/misted — try the direction). for (const side of SIDES) {