Around the Corner bends every spell, hairpins included
Two holes, found when a SAFE cast at a treasure 'around the 180 degree corner' refused with no line of sight (room LZ5R). First: the bend was honored only in the attack path — every neutral resolve (creations, summons, utilities, edge targets) checked straight sight and ignored the attached card, though the card says 'any line of sight spell'. castSight/castSightEdge now thread the bend through all of them, and the chronicle narrates the neutral bend too. Second: the bend itself pivoted only at cell centers, so the card's flagship shot — doubling back up to 180 degrees around a wall's end — never resolved: both legs graze the corner and die. The pivot may now sit in the wall's open gap itself (bentSightThroughGap), which is what rounding a corner physically is. Closed doors still block; two corners still refuse. The client lights bent-reachable squares for real now instead of the old one-step-adjacency guess, creations included. Pure widening, so no rev bump: every one of these casts was refused before, so no ledger holds one. All 26 production ledgers replay clean; the LZ5R moment itself replayed and the exact refused cast now lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
f53f26ace9
commit
3bf3c33fdc
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
|
||||
import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||
import { newGame, newExpansionGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||
|
||||
describe("around the corner", () => {
|
||||
it("bends line of sight past a wall that blocks a straight cast", () => {
|
||||
@@ -44,6 +44,130 @@ describe("around the corner", () => {
|
||||
state = must(state, defender.id, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(10);
|
||||
});
|
||||
|
||||
it("hairpins 180 degrees around a wall's end — the pivot sits in the gap", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const attacker = activePlayer(state);
|
||||
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||
const view0 = boardView(state);
|
||||
// Carve a 2x2 block: attacker at A, defender at C directly north behind a
|
||||
// wall that ends beside the B/D gap. C is sealed on every other side, so
|
||||
// no cell-centered bend reaches it — only the pivot in the gap itself.
|
||||
// +---+---+ C = target D = gap's far cell
|
||||
// # C D # A = caster B = gap's near cell
|
||||
// +###+ +
|
||||
// A B
|
||||
let A: Cell | null = null;
|
||||
for (const k of Object.keys(view0.cells)) {
|
||||
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||
if (view0.cells[`${x + 1},${y}`] && view0.cells[`${x},${y - 1}`] && view0.cells[`${x + 1},${y - 1}`]) {
|
||||
A = { x, y };
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(A).not.toBeNull();
|
||||
const B = { x: A!.x + 1, y: A!.y };
|
||||
const C = { x: A!.x, y: A!.y - 1 };
|
||||
const D = { x: A!.x + 1, y: A!.y - 1 };
|
||||
state.edgeOverrides[edgeKey(A!, "N")] = "wall"; // the wall to double back around
|
||||
state.edgeOverrides[edgeKey(A!, "E")] = "open";
|
||||
state.edgeOverrides[edgeKey(B, "N")] = "open"; // the gap past the wall's end
|
||||
state.edgeOverrides[edgeKey(C, "E")] = "open";
|
||||
state.edgeOverrides[edgeKey(C, "N")] = "wall";
|
||||
state.edgeOverrides[edgeKey(C, "W")] = "wall";
|
||||
state.edgeOverrides[edgeKey(D, "E")] = "wall";
|
||||
attacker.position = { ...A! };
|
||||
defender.position = { ...C };
|
||||
|
||||
const fb = giveCard(state, attacker.id, "fireball", "F", 0);
|
||||
giveCard(state, attacker.id, "around-the-corner", "ATC", 1);
|
||||
const straight = applyCommand(state, attacker.id, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
|
||||
});
|
||||
expect(straight.ok).toBe(false);
|
||||
|
||||
const lifeBefore = defender.life;
|
||||
state = must(state, attacker.id, {
|
||||
type: "cast", instanceId: fb.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
|
||||
target: { kind: "player", playerId: defender.id },
|
||||
});
|
||||
state = must(state, defender.id, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === defender.id)!.life).toBeLessThan(lifeBefore);
|
||||
});
|
||||
|
||||
// A cell out of straight sight but reachable with one bend, clear enough
|
||||
// to create on: no contents, home, wizard, treasure, object, or warp.
|
||||
function hiddenEmptyCell(state: ReturnType<typeof newGame>["state"]): Cell {
|
||||
const caster = activePlayer(state);
|
||||
const view = boardView(state);
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
const cell = { x, y };
|
||||
if (gameLos(state, caster.position, cell, caster.id)) continue;
|
||||
if (state.squareContents[key]) continue;
|
||||
if (view.homes.some((h) => cellKey(h) === key)) continue;
|
||||
if (state.players.some((p) => p.alive && cellKey(p.position) === key)) continue;
|
||||
if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) continue;
|
||||
if ((state.groundObjects[key] ?? []).length > 0) continue;
|
||||
for (const midKey of Object.keys(view.cells)) {
|
||||
const [mx, my] = midKey.split(",").map(Number) as [number, number];
|
||||
const mid = { x: mx, y: my };
|
||||
if (gameLos(state, caster.position, mid, caster.id) && gameLos(state, mid, cell, caster.id)) {
|
||||
return cell;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("no hidden-but-bendable cell on this board");
|
||||
}
|
||||
|
||||
it("bends a neutral spell too — SAFE locks up a treasure around the corner", () => {
|
||||
let { state } = newExpansionGame();
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const hidden = hiddenEmptyCell(state);
|
||||
const gold = state.treasures.find((t) => t.position)!;
|
||||
gold.position = { ...hidden };
|
||||
|
||||
const safe = giveCard(state, caster.id, "safe", "S", 0);
|
||||
giveCard(state, caster.id, "around-the-corner", "ATC", 1);
|
||||
const straight = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: hidden },
|
||||
});
|
||||
expect(straight.ok).toBe(false);
|
||||
if (!straight.ok) expect(straight.error).toBe("no line of sight");
|
||||
|
||||
const bent = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: safe.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
|
||||
target: { kind: "cell", cell: hidden },
|
||||
});
|
||||
if (!bent.ok) throw new Error(`bent SAFE refused: ${bent.error}`);
|
||||
expect(bent.state.squareContents[cellKey(hidden)]?.kind).toBe("safe");
|
||||
expect(bent.events.some((e) => e.type === "castAroundCorner")).toBe(true);
|
||||
// Both cards spent: the bend is not free.
|
||||
const after = bent.state.players.find((p) => p.id === caster.id)!;
|
||||
expect(after.hand.some((c) => c?.cardId === "around-the-corner")).toBe(false);
|
||||
});
|
||||
|
||||
it("bends a creation — THORNBUSH grows on a square out of straight sight", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const hidden = hiddenEmptyCell(state);
|
||||
|
||||
const bush = giveCard(state, caster.id, "thornbush", "TB", 0);
|
||||
giveCard(state, caster.id, "around-the-corner", "ATC", 1);
|
||||
const straight = applyCommand(state, caster.id, {
|
||||
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell: hidden },
|
||||
});
|
||||
expect(straight.ok).toBe(false);
|
||||
|
||||
state = must(state, caster.id, {
|
||||
type: "cast", instanceId: bush.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
|
||||
target: { kind: "cell", cell: hidden },
|
||||
});
|
||||
expect(state.squareContents[cellKey(hidden)]?.kind).toBe("thornbush");
|
||||
});
|
||||
});
|
||||
|
||||
describe("blind", () => {
|
||||
|
||||
Reference in New Issue
Block a user