Five fidelity gaps closed: teleport escapes, slime traps, the Big Man
moves like a giant, and the boards are diagram-verified TELEPORT as a counteraction (official FAQ: "the attack has no chance of hitting you"): the defender names an escape square within four spaces, the escape resolves before anything lands, and an ANTI-ANTI pins their boots to the floor. Additive — no revision gate needed. FILL SQUARE WITH SLIME holds spells now: an attack cast at the slime sticks in the gel (leaving the discard pile), springs once at whoever is inside or next enters, and counteractions against the trapped blast cannot touch its caster — reflections vanish into the ooze. A five-point waterbolt washes the slime and its cargo away, and the waterwall waves clear slime from their path. BIG MAN, under rules rev 5, finally moves like the card says: he pushes players and monsters down the corridor ahead of him (stuck or unpushable occupants block his advance), steps over a pit, tacks, or killer ooze for two movement points without ever entering the square (click two cells beyond the hazard), and monsters may not enter his square. All gated so stored games replay under their own rules. The boards were never wrong — the rulebook's Set-Up Diagram photo confirms every pairing the code already had: 2p crossed, 3p stair with the Aisle Warp arc, 4p/6p straight-across, 5p plus with all four corner arcs. The stale TODOs are gone, replaced by tests pinning each letter pair, and relocation's "only opposite board edges connect" (which discards the aisle warp) is pinned too. Wall of Fire vs Waterbolt turned out to be implemented and tested all along — its TODO comment was the only thing wrong. Also: hotseat saves now request durable storage (iOS evicts unprotected origins under pressure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
28bc91c3ac
commit
ed9585aa59
@@ -173,3 +173,66 @@ describe("line of sight through wraparound openings", () => {
|
||||
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 8 }, stone)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
import { createRng } from "../src/rng";
|
||||
import { setupBoard as setupBoardForPins } from "../src/setups";
|
||||
|
||||
describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
|
||||
const setupBoard = setupBoardForPins;
|
||||
|
||||
function onEdge(c: { x: number; y: number }, origin: { x: number; y: number }, side: string): boolean {
|
||||
if (side === "N") return c.y === origin.y && c.x >= origin.x && c.x < origin.x + 5;
|
||||
if (side === "S") return c.y === origin.y + 4 && c.x >= origin.x && c.x < origin.x + 5;
|
||||
if (side === "W") return c.x === origin.x && c.y >= origin.y && c.y < origin.y + 5;
|
||||
return c.x === origin.x + 4 && c.y >= origin.y && c.y < origin.y + 5;
|
||||
}
|
||||
function hasWarp(board: { warps: { from: { cell: { x: number; y: number } }; to: { cell: { x: number; y: number } } }[] },
|
||||
a: { o: { x: number; y: number }; s: string }, b: { o: { x: number; y: number }; s: string }): boolean {
|
||||
return board.warps.some((w) => onEdge(w.from.cell, a.o, a.s) && onEdge(w.to.cell, b.o, b.s));
|
||||
}
|
||||
|
||||
it("3 players: the aisle-warp arc plus B, C, A letter pairs", () => {
|
||||
const { board } = setupBoard(3, createRng(7));
|
||||
const top = { x: 5, y: 0 }, bl = { x: 0, y: 5 }, br = { x: 5, y: 5 };
|
||||
expect(hasWarp(board, { o: top, s: "W" }, { o: bl, s: "N" })).toBe(true); // Aisle Warp
|
||||
expect(hasWarp(board, { o: top, s: "N" }, { o: br, s: "S" })).toBe(true); // B
|
||||
expect(hasWarp(board, { o: top, s: "E" }, { o: bl, s: "S" })).toBe(true); // C
|
||||
expect(hasWarp(board, { o: bl, s: "W" }, { o: br, s: "E" })).toBe(true); // A
|
||||
});
|
||||
|
||||
it("5 players: opposite tips wrap, notch corners arc around", () => {
|
||||
const { board } = setupBoard(5, createRng(7));
|
||||
const n = { x: 5, y: 0 }, w = { x: 0, y: 5 }, e = { x: 10, y: 5 }, s = { x: 5, y: 10 };
|
||||
expect(hasWarp(board, { o: n, s: "N" }, { o: s, s: "S" })).toBe(true); // A
|
||||
expect(hasWarp(board, { o: w, s: "W" }, { o: e, s: "E" })).toBe(true); // B
|
||||
expect(hasWarp(board, { o: n, s: "W" }, { o: w, s: "N" })).toBe(true); // corners
|
||||
expect(hasWarp(board, { o: n, s: "E" }, { o: e, s: "N" })).toBe(true);
|
||||
expect(hasWarp(board, { o: s, s: "W" }, { o: w, s: "S" })).toBe(true);
|
||||
expect(hasWarp(board, { o: s, s: "E" }, { o: e, s: "S" })).toBe(true);
|
||||
});
|
||||
|
||||
it("6 players: straight-across wrap on both axes", () => {
|
||||
const { board } = setupBoard(6, createRng(7));
|
||||
for (const w of board.warps) {
|
||||
const colinear = w.from.cell.x === w.to.cell.x || w.from.cell.y === w.to.cell.y;
|
||||
expect(colinear).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("relocation discards the aisle warp: only opposite edges connect after", async () => {
|
||||
const { createGame, applyCommand, activePlayer } = await import("../src/game");
|
||||
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 });
|
||||
const bent = (warps: typeof state.board.warps) =>
|
||||
warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y);
|
||||
expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists
|
||||
const me = activePlayer(state);
|
||||
me.hand[0] = { instanceId: "rel#1", cardId: "relocate-sector" };
|
||||
// Slide the top sector from (5,0) across to (0,0): an L becomes a block.
|
||||
const r = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: "rel#1",
|
||||
target: { kind: "cell", cell: { x: 0, y: 0 } }, params: { cell: { x: 7, y: 2 } },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
expect(bent(r.state.board.warps).length).toBe(0); // "only opposite board edges connect"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
|
||||
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES } from "../src/board";
|
||||
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES, stepTarget } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
|
||||
|
||||
@@ -534,3 +534,139 @@ describe("zero-damage utility attacks", () => {
|
||||
expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(treasure.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("teleport as a counteraction", () => {
|
||||
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const escape = { x: d.position.x, y: d.position.y + 1 };
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
|
||||
state = must(state, attacker, { type: "pass" });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(after.life).toBe(15);
|
||||
expect(cellKey(after.position)).toBe(cellKey(escape));
|
||||
});
|
||||
|
||||
it("ANTI-ANTI pins the boots: the escape never happens and the spell lands", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
|
||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
const home = { ...d.position };
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: { x: home.x, y: home.y + 1 } } });
|
||||
state = must(state, attacker, { type: "counteract", instanceId: aa.instanceId });
|
||||
state = must(state, defender, { type: "pass" });
|
||||
const after = state.players.find((p) => p.id === defender)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(home));
|
||||
expect(after.life).toBe(10);
|
||||
});
|
||||
|
||||
it("reaches at most four spaces", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const fb = giveCard(state, attacker, "fireball");
|
||||
const tp = giveCard(state, defender, "teleport", "TP", 0);
|
||||
const d = state.players.find((p) => p.id === defender)!;
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
|
||||
});
|
||||
const far = { x: d.position.x, y: d.position.y + 5 };
|
||||
expect(applyCommand(state, defender, {
|
||||
type: "counteract", instanceId: tp.instanceId, params: { cell: far },
|
||||
}).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slime holds spells", () => {
|
||||
function slimeRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
||||
state = toRound2(state);
|
||||
const caster = activePlayer(state);
|
||||
const victim = state.players.find((p) => p.id !== caster.id)!;
|
||||
// A slime square beside the victim, with the victim adjacent to walk in.
|
||||
const spot = { x: victim.position.x, y: victim.position.y };
|
||||
const cellBeside = (() => {
|
||||
const view = boardView(state);
|
||||
for (const side of SIDES) {
|
||||
const t = stepTarget(view, spot, side);
|
||||
if (t.kind === "step" && !state.players.some((p) => cellKey(p.position) === cellKey(t.to)) &&
|
||||
!boardView(state).homes.some((h) => cellKey(h) === cellKey(t.to))) {
|
||||
return { cell: t.to, side };
|
||||
}
|
||||
}
|
||||
throw new Error("setup: no free square beside the victim");
|
||||
})();
|
||||
state.squareContents[cellKey(cellBeside.cell)] = { kind: "slime", damage: 0, createdBy: caster.id };
|
||||
// The caster stands with the victim: one open edge to the slime = clear LOS.
|
||||
caster.position = { ...victim.position };
|
||||
return { state, caster: caster.id, victim: victim.id, slimeAt: cellBeside };
|
||||
}
|
||||
|
||||
it("a fireball cast at the slime waits, then goes off on the next visitor", () => {
|
||||
const rig = slimeRig();
|
||||
let state = rig.state;
|
||||
const fb = giveCard(state, rig.caster, "fireball");
|
||||
state = must(state, rig.caster, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
|
||||
});
|
||||
expect(Object.keys(state.slimeTraps).length).toBe(1);
|
||||
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
|
||||
// The victim walks in: stuck, and the fireball erupts.
|
||||
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
|
||||
expect(state.stack?.attackCard?.cardId).toBe("fireball");
|
||||
expect(state.stack?.trapped).toBe(true);
|
||||
state = must(state, rig.victim, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(10);
|
||||
expect(Object.keys(state.slimeTraps).length).toBe(0);
|
||||
});
|
||||
|
||||
it("reflections off a trapped spell never touch its caster", () => {
|
||||
const rig = slimeRig();
|
||||
let state = rig.state;
|
||||
const fb = giveCard(state, rig.caster, "fireball");
|
||||
giveCard(state, rig.victim, "full-reflection", "FR", 0);
|
||||
state = must(state, rig.caster, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
|
||||
});
|
||||
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
|
||||
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
|
||||
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
|
||||
state = must(state, rig.caster, { type: "pass" });
|
||||
state = must(state, rig.victim, { type: "pass" });
|
||||
expect(state.players.find((p) => p.id === rig.caster)!.life).toBe(15);
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(15);
|
||||
});
|
||||
|
||||
it("a five-point waterbolt washes the slime (and its cargo) away", () => {
|
||||
const rig = slimeRig();
|
||||
let state = rig.state;
|
||||
const fb = giveCard(state, rig.caster, "fireball");
|
||||
state = must(state, rig.caster, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
|
||||
});
|
||||
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
|
||||
const wb = giveCard(state, rig.victim, "waterbolt", "WB", 0);
|
||||
giveCard(state, rig.victim, "number-5", "N", 1);
|
||||
state = must(state, rig.victim, {
|
||||
type: "cast", instanceId: wb.instanceId, numberInstanceIds: ["number-5#N"],
|
||||
target: { kind: "cell", cell: rig.slimeAt.cell },
|
||||
});
|
||||
expect(state.squareContents[cellKey(rig.slimeAt.cell)]).toBeUndefined();
|
||||
expect(Object.keys(state.slimeTraps).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,3 +341,102 @@ describe("counteracting a creature's blow (rules rev 4)", () => {
|
||||
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(13);
|
||||
});
|
||||
});
|
||||
|
||||
describe("big man (rules rev 5)", () => {
|
||||
function bigRig() {
|
||||
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
||||
state = toRound2(state);
|
||||
const giant = activePlayer(state);
|
||||
state.sustained.push({
|
||||
id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id,
|
||||
remainingTurns: 9, data: {},
|
||||
});
|
||||
return { state, giant };
|
||||
}
|
||||
|
||||
/** Any square with two open steps in a straight line (test surgery spot). */
|
||||
function straightRun(state: GameState): { at: Cell; side: Side; mid: Cell; far: Cell } {
|
||||
const view = boardView(state);
|
||||
for (const key of Object.keys(view.cells)) {
|
||||
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||
const at = { x, y };
|
||||
if (view.homes.some((h) => cellKey(h) === key)) continue;
|
||||
for (const side of SIDES) {
|
||||
const t1 = stepTarget(view, at, side);
|
||||
if (t1.kind !== "step") continue;
|
||||
const t2 = stepTarget(view, t1.to, side);
|
||||
if (t2.kind !== "step") continue;
|
||||
return { at, side, mid: t1.to, far: t2.to };
|
||||
}
|
||||
}
|
||||
throw new Error("no straight corridor anywhere — impossible");
|
||||
}
|
||||
|
||||
it("pushes a wizard down the corridor as he advances", () => {
|
||||
const rig = bigRig();
|
||||
let state = rig.state;
|
||||
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||
const run = straightRun(state);
|
||||
const giant = state.players.find((p) => p.id === rig.giant.id)!;
|
||||
giant.position = { ...run.at };
|
||||
victim.position = { ...run.mid };
|
||||
state = must(state, rig.giant.id, { type: "move", direction: run.side });
|
||||
const after = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||
expect(cellKey(after.position)).toBe(cellKey(run.far));
|
||||
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(run.mid));
|
||||
});
|
||||
|
||||
it("no room to shove means no way forward", () => {
|
||||
const rig = bigRig();
|
||||
const state = rig.state;
|
||||
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
|
||||
const view = boardView(state);
|
||||
// Find a step whose far side is walled: victim there cannot be pushed.
|
||||
for (const side of SIDES) {
|
||||
const t1 = stepTarget(view, rig.giant.position, side);
|
||||
if (t1.kind !== "step") continue;
|
||||
const t2 = stepTarget(view, t1.to, side);
|
||||
if (t2.kind === "step") continue;
|
||||
victim.position = { ...t1.to };
|
||||
const r = applyCommand(state, rig.giant.id, { type: "move", direction: side });
|
||||
expect(r.ok).toBe(false);
|
||||
return;
|
||||
}
|
||||
// Seed offered no walled pocket beside the home; that is fine — the
|
||||
// pushable case above pins the mechanism.
|
||||
});
|
||||
|
||||
it("monsters cannot enter the giant's square", () => {
|
||||
const rig = bigRig();
|
||||
let state = rig.state;
|
||||
const view = boardView(state);
|
||||
const spot = emptyNeighborCell(state, rig.giant.position);
|
||||
state.creatures.push({
|
||||
id: "t1", kind: "troll", controllerId: state.players.find((p) => p.id !== rig.giant.id)!.id,
|
||||
position: { ...spot.cell },
|
||||
damage: 0, maxDamage: 8, movesPerTurn: 3, movementUsed: 0,
|
||||
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [],
|
||||
});
|
||||
state = must(state, rig.giant.id, { type: "endTurn", draw: 0 });
|
||||
const controller = activePlayer(state).id;
|
||||
const back = (["N", "S", "E", "W"] as const).find((d) => {
|
||||
const t = stepTarget(view, spot.cell, d);
|
||||
return t.kind === "step" && cellKey(t.to) === cellKey(rig.giant.position);
|
||||
})!;
|
||||
const r = applyCommand(state, controller, { type: "moveCreature", creatureId: "t1", direction: back });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("steps over a pit for two movement points, never entering it", () => {
|
||||
const rig = bigRig();
|
||||
let state = rig.state;
|
||||
const run = straightRun(state);
|
||||
state.players.find((p) => p.id === rig.giant.id)!.position = { ...run.at };
|
||||
state.squareContents[cellKey(run.mid)] = { kind: "pit", damage: 0, createdBy: rig.giant.id };
|
||||
state = must(state, rig.giant.id, { type: "move", direction: run.side, over: true });
|
||||
const g = state.players.find((p) => p.id === rig.giant.id)!;
|
||||
expect(cellKey(g.position)).toBe(cellKey(run.far));
|
||||
expect(state.turn.movementUsed).toBe(2);
|
||||
expect(g.inPit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user