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. */