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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-07 14:38:48 -05:00
co-authored by Claude Fable 5.1
parent e5d82a94af
commit 3f71b04c3b
3 changed files with 92 additions and 42 deletions
+46 -42
View File
@@ -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<typeof gather>) => {
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. */
@@ -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);
});
}
});