Final wave: complete the 6th edition basic set (69/69 cards)
The hard six: AROUND THE CORNER attaches to any LOS attack and bends the sight line through one intermediate cell. BLIND victims lurch in die-rolled directions (bumping a wall costs the movement point, per the card) and their attacks fly wherever the die says — hitting whoever stands that way, or dissipating. UGLY drives every opponent in sight fleeing along shortest paths (breadth-first, die-broken ties) until they cannot see the caster. ILLUSION WALL is per-player reality: each opponent rolls 50/50 the first time it matters and the wall is real for believers forever — believers' views render it as a wall, the caster and those who saw through it get a ghostly dashed line, and Dispel Creation banishes it. ROTATE SECTOR turns a sector 90 degrees with every wall, door, wizard, treasure, object, firewall and alteration turning in place (the home star, being the exact center, never moves); RELOCATE SECTOR slides a sector anywhere that keeps all sectors adjacent, reassembling the map and recomputing wraparounds. Client: modifier attachment (Amplify/Add/Extend/Around The Corner), two-stage relocate, rotation direction toggle, dashed known-illusions. Every card in the 6th edition basic deck is now implemented. 81 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4ab44cc535
commit
1924114b6d
@@ -292,7 +292,7 @@ describe("stack discipline", () => {
|
||||
it("unimplemented cards refuse to cast with a clear error", () => {
|
||||
let { state } = newGame();
|
||||
const caster = activePlayer(state);
|
||||
const card = giveCard(state, caster.id, "ugly");
|
||||
const card = giveCard(state, caster.id, "chaos"); // expansion1, unimplemented
|
||||
const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toMatch(/not implemented/);
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyCommand,
|
||||
activePlayer,
|
||||
createGame,
|
||||
boardView,
|
||||
gameLos,
|
||||
sustainedOn,
|
||||
viewFor,
|
||||
type Command,
|
||||
type GameState,
|
||||
type PlayerId,
|
||||
} from "../src";
|
||||
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
||||
import type { CardInstance } from "../src/cards";
|
||||
|
||||
function newGame(seed = 42) {
|
||||
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
|
||||
}
|
||||
|
||||
function must(state: GameState, player: PlayerId, command: Command): GameState {
|
||||
const result = applyCommand(state, player, command);
|
||||
if (!result.ok) throw new Error(`command failed: ${result.error}`);
|
||||
return result.state;
|
||||
}
|
||||
|
||||
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
|
||||
const p = state.players.find((p) => p.id === playerId)!;
|
||||
const instance = { instanceId: `${cardId}#${tag}`, cardId };
|
||||
p.hand[slot] = instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
function toRound2(state: GameState): GameState {
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
||||
return state;
|
||||
}
|
||||
|
||||
function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
|
||||
const view = boardView(state);
|
||||
for (const side of SIDES) {
|
||||
const t = stepTarget(view, of, side);
|
||||
if (t.kind !== "step") continue;
|
||||
const key = cellKey(t.to);
|
||||
if (view.homes.some((h) => cellKey(h) === key)) continue;
|
||||
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
|
||||
if (state.players.some((p) => cellKey(p.position) === key)) continue;
|
||||
return { cell: t.to, side };
|
||||
}
|
||||
throw new Error("no empty neighbor");
|
||||
}
|
||||
|
||||
describe("around the corner", () => {
|
||||
it("bends line of sight past a wall that blocks a straight cast", () => {
|
||||
let { state } = newGame();
|
||||
state = toRound2(state);
|
||||
const attacker = activePlayer(state);
|
||||
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||
// Find a hidden cell (no direct LOS) that IS reachable with one bend.
|
||||
const view = boardView(state);
|
||||
let hidden: Cell | null = null;
|
||||
outer: 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, attacker.position, cell)) 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, attacker.position, mid) && gameLos(state, mid, cell)) {
|
||||
hidden = cell;
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hidden).not.toBeNull();
|
||||
defender.position = hidden!;
|
||||
|
||||
const fb = giveCard(state, attacker.id, "fireball", "F", 0);
|
||||
giveCard(state, attacker.id, "around-the-corner", "ATC", 1);
|
||||
// Straight cast: refused.
|
||||
const straight = applyCommand(state, attacker.id, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
|
||||
});
|
||||
expect(straight.ok).toBe(false);
|
||||
// Bent cast: lands.
|
||||
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).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blind", () => {
|
||||
it("blinded movement lurches in rolled directions and bumps cost movement", () => {
|
||||
let { state } = newGame(7);
|
||||
state = toRound2(state);
|
||||
const attacker = activePlayer(state);
|
||||
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||
defender.position = { ...attacker.position };
|
||||
const bl = giveCard(state, attacker.id, "blind");
|
||||
giveCard(state, attacker.id, "number-3", "N", 1);
|
||||
state = must(state, attacker.id, {
|
||||
type: "cast", instanceId: bl.instanceId, numberInstanceIds: ["number-3#N"],
|
||||
target: { kind: "player", playerId: defender.id },
|
||||
});
|
||||
state = must(state, defender.id, { type: "pass" });
|
||||
expect(sustainedOn(state, defender.id, "blind").length).toBe(1);
|
||||
state = must(state, attacker.id, { type: "endTurn", draw: 0 });
|
||||
|
||||
// Every move consumes exactly one movement point whether it lands or bumps.
|
||||
const before = state.turn.movementUsed;
|
||||
state = must(state, defender.id, { type: "move", direction: "N" });
|
||||
expect(state.turn.movementUsed).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("blinded casts fly in a rolled direction and can miss entirely", () => {
|
||||
// Across seeds: a blinded caster aiming at a real target sometimes hits
|
||||
// (roll matches), usually misses (attack dissipates, card still spent).
|
||||
let hits = 0, misses = 0;
|
||||
for (let seed = 1; seed <= 10; seed++) {
|
||||
let { state } = newGame(seed);
|
||||
state = toRound2(state);
|
||||
const attacker = activePlayer(state);
|
||||
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||
// Stand the defender one cell east-ish with LOS.
|
||||
const spot = emptyNeighborCell(state, attacker.position);
|
||||
defender.position = spot.cell;
|
||||
state.sustained.push({
|
||||
id: "fx-test", cardId: "blind", casterId: defender.id,
|
||||
targetId: attacker.id, remainingTurns: 3, data: {},
|
||||
});
|
||||
const fb = giveCard(state, attacker.id, "fireball", "F", 0);
|
||||
const result = applyCommand(state, attacker.id, {
|
||||
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) continue;
|
||||
const dLife = result.state.players.find((p) => p.id === defender.id)!;
|
||||
if (result.state.stack) {
|
||||
hits++; // the roll matched: attack proceeds normally
|
||||
} else {
|
||||
misses++;
|
||||
expect(result.state.turn.attackUsed).toBe(true); // card spent anyway
|
||||
}
|
||||
void dLife;
|
||||
}
|
||||
expect(hits + misses).toBe(10);
|
||||
expect(misses).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ugly", () => {
|
||||
it("drives every visible opponent out of line of sight", () => {
|
||||
let { state } = newGame();
|
||||
const caster = activePlayer(state);
|
||||
const opp = state.players.find((p) => p.id !== caster.id)!;
|
||||
opp.position = { ...caster.position }; // same square: definitely in LOS
|
||||
const ug = giveCard(state, caster.id, "ugly");
|
||||
state = must(state, caster.id, { type: "cast", instanceId: ug.instanceId });
|
||||
const after = state.players.find((p) => p.id === opp.id)!;
|
||||
expect(gameLos(state, state.players.find((p) => p.id === caster.id)!.position, after.position)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("illusion wall", () => {
|
||||
function setupIllusion(seed = 42) {
|
||||
let { state } = newGame(seed);
|
||||
const caster = activePlayer(state);
|
||||
const spot = emptyNeighborCell(state, caster.position);
|
||||
const key = edgeKey(caster.position, spot.side);
|
||||
const iw = giveCard(state, caster.id, "illusion-wall");
|
||||
state = must(state, caster.id, {
|
||||
type: "cast", instanceId: iw.instanceId,
|
||||
target: { kind: "edge", cell: caster.position, side: spot.side },
|
||||
});
|
||||
return { state, caster: caster.id, key, side: spot.side, cell: spot.cell };
|
||||
}
|
||||
|
||||
it("the caster walks through their own illusion; others see a wall", () => {
|
||||
const { state, caster, key, side } = setupIllusion();
|
||||
// Caster's view knows it's fake; the opponent's view shows a wall.
|
||||
const casterView = viewFor(state, caster);
|
||||
expect(casterView.knownIllusionEdges).toContain(key);
|
||||
const other = state.players.find((p) => p.id !== caster)!.id;
|
||||
const otherView = viewFor(state, other);
|
||||
expect(otherView.board.edges[key]).toBe("wall");
|
||||
// And the caster can move through it freely.
|
||||
const after = applyCommand(state, caster, { type: "move", direction: side });
|
||||
expect(after.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("opponents test the illusion when they bump it — some see through, some believe", () => {
|
||||
let believed = 0, sawThrough = 0;
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const { state, caster, side } = setupIllusion(seed);
|
||||
const other = state.players.find((p) => p.id !== caster)!;
|
||||
other.position = { ...state.players.find((p) => p.id === caster)!.position };
|
||||
let s = must(state, caster, { type: "endTurn", draw: 0 });
|
||||
const result = applyCommand(s, other.id, { type: "move", direction: side });
|
||||
if (result.ok) sawThrough++;
|
||||
else believed++;
|
||||
}
|
||||
expect(believed + sawThrough).toBe(12);
|
||||
expect(believed).toBeGreaterThan(0);
|
||||
expect(sawThrough).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("dispel creation removes an illusion wall", () => {
|
||||
let { state, caster, key, side } = setupIllusion();
|
||||
const me = state.players.find((p) => p.id === caster)!;
|
||||
const dc = giveCard(state, caster, "dispel-creation", "DC", 1);
|
||||
state = must(state, caster, {
|
||||
type: "cast", instanceId: dc.instanceId,
|
||||
target: { kind: "edge", cell: me.position, side },
|
||||
});
|
||||
expect(state.illusionWalls[key]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sector manipulation", () => {
|
||||
it("rotate sector turns walls and pieces together; the home stays centered", () => {
|
||||
let { state } = newGame();
|
||||
const me = activePlayer(state);
|
||||
const idx = state.board.placements.findIndex(
|
||||
(p) => me.position.x >= p.origin.x && me.position.x < p.origin.x + 5 &&
|
||||
me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
|
||||
);
|
||||
const wallsBefore = Object.values(boardView(state).edges).filter((e) => e === "wall").length;
|
||||
const homeBefore = { ...me.home };
|
||||
const treasuresBefore = state.treasures.filter((t) => t.owner === me.id).map((t) => ({ ...t.position! }));
|
||||
|
||||
const rs = giveCard(state, me.id, "rotate-sector");
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: rs.instanceId,
|
||||
target: { kind: "cell", cell: me.position }, params: { clockwise: true },
|
||||
});
|
||||
const after = state.players.find((p) => p.id === me.id)!;
|
||||
// Home star is the exact center: rotation cannot move it.
|
||||
expect(cellKey(after.home)).toBe(cellKey(homeBefore));
|
||||
// The wizard stood on the home (center) at setup? They may have been
|
||||
// anywhere; either way they remain inside the same sector.
|
||||
const p = state.board.placements[idx]!;
|
||||
expect(after.position.x).toBeGreaterThanOrEqual(p.origin.x);
|
||||
expect(after.position.x).toBeLessThan(p.origin.x + 5);
|
||||
// Wall count is invariant under rotation.
|
||||
const wallsAfter = Object.values(boardView(state).edges).filter((e) => e === "wall").length;
|
||||
expect(wallsAfter).toBe(wallsBefore);
|
||||
// Treasures rotated with the sector (diagonal flips to the other diagonal
|
||||
// or stays, but they remain on the board inside the sector).
|
||||
for (const t of state.treasures.filter((t) => t.owner === after.id)) {
|
||||
expect(state.board.cells[cellKey(t.position!)]).toBe(true);
|
||||
}
|
||||
void treasuresBefore;
|
||||
});
|
||||
|
||||
it("relocate sector slides everything and keeps adjacency", () => {
|
||||
let { state } = newGame(); // 2 players: 5x10 column, sectors at y=0 and y=5
|
||||
const me = activePlayer(state);
|
||||
const idx = state.board.placements.findIndex(
|
||||
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
|
||||
);
|
||||
const myOrigin = state.board.placements[idx]!.origin;
|
||||
const otherIdx = idx === 0 ? 1 : 0;
|
||||
const otherOrigin = state.board.placements[otherIdx]!.origin;
|
||||
|
||||
// Move my sector to the EAST side of the other sector (still adjacent).
|
||||
const dest = { x: otherOrigin.x + 5, y: otherOrigin.y };
|
||||
const posBefore = { ...me.position };
|
||||
const rel = giveCard(state, me.id, "relocate-sector");
|
||||
state = must(state, me.id, {
|
||||
type: "cast", instanceId: rel.instanceId,
|
||||
target: { kind: "cell", cell: dest }, params: { cell: me.position },
|
||||
});
|
||||
const after = state.players.find((p) => p.id === me.id)!;
|
||||
const dx = dest.x - myOrigin.x, dy = dest.y - myOrigin.y;
|
||||
expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy });
|
||||
expect(cellKey(after.home)).toBe(cellKey({ x: after.home.x, y: after.home.y }));
|
||||
expect(state.board.placements[idx]!.origin).toEqual(dest);
|
||||
// The map reassembled: every treasure/wizard cell exists on the new board.
|
||||
for (const t of state.treasures) {
|
||||
if (t.position) expect(state.board.cells[cellKey(t.position)]).toBe(true);
|
||||
}
|
||||
|
||||
// An island move is refused.
|
||||
const rel2 = giveCard(state, me.id, "relocate-sector", "R2");
|
||||
const refused = applyCommand(state, me.id, {
|
||||
type: "cast", instanceId: rel2.instanceId,
|
||||
target: { kind: "cell", cell: { x: 40, y: 40 } }, params: { cell: after.position },
|
||||
});
|
||||
expect(refused.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user