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:
co-authored by
Claude Fable 5.1
parent
e5d82a94af
commit
3f71b04c3b
+46
-42
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user