Credibility pass: sweep the workshop floor

Scoped to everything since the last pass (695307d). The residue of
fast iteration, removed: a reduced-motion media query that had
swallowed a full copy of the .faq-seal rules; doc comments orphaned
from their functions by inserted methods; the FAQ scrape's seams
(section headings run into ruling bodies under the wrong topics, a
next-page heading shipped as a ruling, an amputated "h", and rulings
filed under alphabetically-nearest strangers — Large Rock/Dagger now
lives on those cards; Book of Spells and Torquemada describe no card
in this set and are gone); the write-only aisle flag left behind by
the reverted rev 7; a linter-silenced dead destructure; a duplicated
median lookup; dead casts; and the fallback path that ignored the
apprentice's one-card draw.

Tests now typecheck (tsconfig includes test/), which surfaced the
missing type imports and a drifted creature literal hiding under an
as-cast. Also: a no-op self-assignment, mid-file imports hoisted, a
dynamic-import habit made static, a hedge comment replaced with a
loud setup failure, the fallback-retries-itself dead rung removed
from both bot drivers, and the twin peek scrims merged.

Deliberately kept: the TIERS lookup guard (ledger JSON is untrusted),
the wand-cost stanzas (they differ on the power-attack trade — a
rules question, not a dedup), and rollDie's coexistence with rollD4
(migrating changes visible logs; future work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 20:20:37 -04:00
co-authored by Claude Fable 5
parent 73150a89b5
commit 3308850bb6
18 changed files with 136 additions and 142 deletions
+25 -26
View File
@@ -179,8 +179,7 @@
"text": "Reduces any damage done to you by 1/2. Round fractions up. Works on point-based or duration-based spells, or physical damage.",
"quantity": 2,
"faqRulings": [
"If you play two in a row, they act one after the other, they do not cancel the damage out entirely! Thus, against a 5 point Fireball, the first would reduce damage to 3 points (2.5 rounded up), and the second would reduce damage to 2 points (1.5 rounded up).",
"Book of Spells This cannot hold items or objects, just spells and NUMBER cards. Discard them if you draw objects, redrawing to fill to 5 cards. These may be used in any combination you like with the cards in your hand. REFLECTED spells will blast you, not the BOOK."
"If you play two in a row, they act one after the other, they do not cancel the damage out entirely! Thus, against a 5 point Fireball, the first would reduce damage to 3 points (2.5 rounded up), and the second would reduce damage to 2 points (1.5 rounded up)."
],
"verification": {
"status": "verbatim",
@@ -268,6 +267,7 @@
"text": "You may throw it. Does three points physical damage. You cannot stab with it. Retrievable by anyone after it is thrown.",
"quantity": 1,
"faqRulings": [
"These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!",
"These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!"
],
"verification": {
@@ -500,6 +500,7 @@
"text": "You may throw it. Does two points physical damage. Once thrown, it may be picked up and thrown again by any player.",
"quantity": 1,
"faqRulings": [
"These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!",
"These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!"
],
"verification": {
@@ -612,13 +613,13 @@
},
{
"id": "number-2",
"name": "Number \u201c2\u201d",
"name": "Number “2”",
"set": "basic",
"cardType": "number",
"value": 2,
"subtypes": [],
"los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 12,
"faqRulings": [],
"verification": {
@@ -628,13 +629,13 @@
},
{
"id": "number-3",
"name": "Number \u201c3\u201d",
"name": "Number “3”",
"set": "basic",
"cardType": "number",
"value": 3,
"subtypes": [],
"los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 10,
"faqRulings": [],
"verification": {
@@ -644,13 +645,13 @@
},
{
"id": "number-4",
"name": "Number \u201c4\u201d",
"name": "Number “4”",
"set": "basic",
"cardType": "number",
"value": 4,
"subtypes": [],
"los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 7,
"faqRulings": [],
"verification": {
@@ -660,13 +661,13 @@
},
{
"id": "number-5",
"name": "Number \u201c5\u201d",
"name": "Number “5”",
"set": "basic",
"cardType": "number",
"value": 5,
"subtypes": [],
"los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 4,
"faqRulings": [],
"verification": {
@@ -676,13 +677,13 @@
},
{
"id": "number-6",
"name": "Number \u201c6\u201d",
"name": "Number “6”",
"set": "basic",
"cardType": "number",
"value": 6,
"subtypes": [],
"los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 1,
"faqRulings": [],
"verification": {
@@ -1529,13 +1530,13 @@
},
{
"id": "exp1-number-2",
"name": "Number \u201c2\u201d (Expansion #1)",
"name": "Number “2” (Expansion #1)",
"set": "expansion1",
"cardType": "number",
"value": 2,
"subtypes": [],
"los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 5,
"faqRulings": [],
"verification": {
@@ -1545,13 +1546,13 @@
},
{
"id": "exp1-number-3",
"name": "Number \u201c3\u201d (Expansion #1)",
"name": "Number “3” (Expansion #1)",
"set": "expansion1",
"cardType": "number",
"value": 3,
"subtypes": [],
"los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 4,
"faqRulings": [],
"verification": {
@@ -1561,13 +1562,13 @@
},
{
"id": "exp1-number-4",
"name": "Number \u201c4\u201d (Expansion #1)",
"name": "Number “4” (Expansion #1)",
"set": "expansion1",
"cardType": "number",
"value": 4,
"subtypes": [],
"los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 3,
"faqRulings": [],
"verification": {
@@ -1577,13 +1578,13 @@
},
{
"id": "exp1-number-5",
"name": "Number \u201c5\u201d (Expansion #1)",
"name": "Number “5” (Expansion #1)",
"set": "expansion1",
"cardType": "number",
"value": 5,
"subtypes": [],
"los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.",
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 2,
"faqRulings": [],
"verification": {
@@ -1805,8 +1806,7 @@
"text": "Covers the surface of one square of the floor with acidic ooze. Any character or monster entering the square takes 1 point of damage, and on a roll of 1 or 2 falls down, drops treasure, takes 2 more points of physical damage, and is unable to move for the rest of the turn. Any following turns that he tries to stand and leave must be rolled again as stated. Only 5 points of fire damage will destroy the ooze.",
"quantity": 1,
"faqRulings": [
"This is a MONSTER. No, it does not block LOS, just lays in a layer on the floor, and is not moveable. Only fire, and no other type of attack, will hurt the ooze (except MONSTER BANE for 5 points).",
"Large Rock/Dagger These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!"
"This is a MONSTER. No, it does not block LOS, just lays in a layer on the floor, and is not moveable. Only fire, and no other type of attack, will hurt the ooze (except MONSTER BANE for 5 points)."
],
"verification": {
"status": "verbatim",
@@ -2051,7 +2051,7 @@
"text": "Wand covers enemy with webs, reducing movement by 3 (enemy can still use NUMBER cards for additional movement). Lasts one turn. Any fire damage done to player in webs causes two extra points of damage due to webs burning. The wand has a limited number of charges determined by NUMBER card played when first used. Discard the wand when last charge is gone.",
"quantity": 1,
"faqRulings": [
"Webs, if burned, disappear immediately. h"
"Webs, if burned, disappear immediately."
],
"verification": {
"status": "verbatim",
@@ -3568,8 +3568,7 @@
"text": "This spell lets you swap any two items (uncarried), people, monsters, or immovable created object, or any combination of the same, as long as both are in your LOS! In the case of swapping a person for a created wall, the person could end up in either square adjacent to where the wall was, and vice-versa. Both objects, once swapped, must still be in your LOS afterwards.",
"quantity": null,
"faqRulings": [
"Though not intended to be used this way, it is possible to use TINY SWAP to move yourself, since you are in your own LOS. Yes, this is a much more powerful version of TRADER. No, you may not put immovable objects on a player's home base.",
"Torquemada If your opponent has run out of fingers and teeth, then he can probably no longer deal cards anyway, so any player with functional eyes left will have to read all the players' cards to them. Ha! Just kidding. Not a real card (Not yet, anyway)."
"Though not intended to be used this way, it is possible to use TINY SWAP to move yourself, since you are in your own LOS. Yes, this is a much more powerful version of TRADER. No, you may not put immovable objects on a player's home base."
],
"verification": {
"status": "verbatim",
@@ -3669,4 +3668,4 @@
}
}
]
}
}
+6 -7
View File
@@ -465,12 +465,11 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
return { type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: myFloorTreasure.position! } };
}
const glue = inHand(view, "glue");
const midN = numbers[Math.floor(numbers.length / 2)];
if (glue && midN) {
if (glue && mid) {
return {
type: "cast", instanceId: glue.instanceId,
target: { kind: "cell", cell: myFloorTreasure.position! },
numberInstanceIds: [midN.instanceId],
numberInstanceIds: [mid.instanceId],
};
}
}
@@ -504,7 +503,7 @@ export function automatonCommand(
style: AutomatonStyle = "hunter",
tierName: AutomatonTier = "archmage",
): Command | null {
const you = view.you as PlayerId;
const you = view.you;
const tier = TIERS[tierName] ?? TIERS.archmage;
if (view.phase !== "playing") return null;
@@ -653,11 +652,11 @@ export function automatonCommand(
}
/** The safe fallback when the automaton's choice was refused. */
export function automatonFallback(view: GameView): Command {
const you = view.you as PlayerId;
export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command {
const you = view.you;
if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
}
if (view.stack || view.chaosPending || view.outOfTurnWindow) return { type: "pass" };
return { type: "endTurn", draw: 2 };
return { type: "endTurn", draw: (TIERS[tierName] ?? TIERS.archmage).draw };
}
+4 -7
View File
@@ -27,8 +27,6 @@ export interface Warp {
from: { cell: Cell; side: Side };
/** ...you arrive at this cell (entering through `side`). */
to: { cell: Cell; side: Side };
/** An aisle (AUTO) warp joins two edges that meet at a corner. */
aisle?: boolean;
}
export interface AssembledBoard {
@@ -121,7 +119,7 @@ export interface AssemblyOptions {
* openings pair straight across the map correct for the 4p square; the
* standard 2p column crosses its side openings and must pass pairs.
*/
warpPairs?: ([OpeningRef, OpeningRef] | [OpeningRef, OpeningRef, "aisle"])[];
warpPairs?: [OpeningRef, OpeningRef][];
}
/**
@@ -206,12 +204,11 @@ export function assembleBoard(
};
if (options.warpPairs) {
for (const [a, b, kind] of options.warpPairs) {
for (const [a, b] of options.warpPairs) {
const ca = openingCell(a.sector, a.side);
const cb = openingCell(b.sector, b.side);
const aisle = kind === "aisle" ? { aisle: true as const } : {};
warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side }, ...aisle });
warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side }, ...aisle });
warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side } });
warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side } });
}
} else {
for (let i = 0; i < placements.length; i++) {
+3 -4
View File
@@ -218,9 +218,8 @@ export interface GameConfig {
* BIG MAN pushes occupants ahead, steps over floor hazards for 2 points,
* and bars monsters from his square. Rev 6: ANTI-ANTI cannot pin a
* teleport escape ("does not work against escape" the card face).
* Rev 7 briefly restricted warp sight to aisle corners; the table's
* reading prevailed and all board openings carry sight in every revision.
* DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the
* Rev 7: no behavioral change every board opening carries warp sight
* in every revision. DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the
* warp." the card face). Rev 8: nothing can be created on a warp token,
* and a SPEED bonus turn burns a turn of durations on the hastened wizard.
*/
@@ -4219,7 +4218,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const view = boardView(state);
const current = view.edges[edgeKey(cell, side)] ?? "open";
if (current !== "wall" && current !== "door") {
return err("only walls, doors, and thornbushes can be attacked");
return err("there is no wall or door on that edge to attack");
}
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) {
return err("you must stand beside the wall");
+5 -5
View File
@@ -68,7 +68,7 @@ export function setupBoard(playerCount: number, rng: RngState): SetupResult {
];
const board = assembleBoard(placements, {
warpPairs: [
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }, "aisle"], // AUTO WARP corner
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }], // AUTO WARP corner
[{ sector: 0, side: "N" }, { sector: 2, side: "S" }],
[{ sector: 1, side: "W" }, { sector: 2, side: "E" }],
[{ sector: 0, side: "E" }, { sector: 1, side: "S" }],
@@ -106,10 +106,10 @@ export function setupBoard(playerCount: number, rng: RngState): SetupResult {
warpPairs: [
[{ sector: 0, side: "N" }, { sector: 4, side: "S" }], // tip to tip
[{ sector: 1, side: "W" }, { sector: 3, side: "E" }],
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }, "aisle"], // aisle-warp corners
[{ sector: 0, side: "E" }, { sector: 3, side: "N" }, "aisle"],
[{ sector: 4, side: "W" }, { sector: 1, side: "S" }, "aisle"],
[{ sector: 4, side: "E" }, { sector: 3, side: "S" }, "aisle"],
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }], // aisle-warp corners
[{ sector: 0, side: "E" }, { sector: 3, side: "N" }],
[{ sector: 4, side: "W" }, { sector: 1, side: "S" }],
[{ sector: 4, side: "E" }, { sector: 3, side: "S" }],
],
});
return { board, rng: rngN };
-2
View File
@@ -212,7 +212,6 @@ export function eligibleCellsFor(view: GameView, cardId: string): Set<string> |
const out = new Set<string>();
for (const k of cells) {
if (view.squareContents[k]) continue;
const [x, y] = k.split(",").map(Number) as [number, number];
if (view.board.homes.some((h) => key(h.x, h.y) === k)) continue;
if (view.players.some((p) => p.alive && key(p.position.x, p.position.y) === k)) continue;
if (view.treasures.some((t) => t.position && key(t.position.x, t.position.y) === k)) continue;
@@ -220,7 +219,6 @@ export function eligibleCellsFor(view: GameView, cardId: string): Set<string> |
if (view.dimWarps.some((w) => key(w.a.x, w.a.y) === k || key(w.b.x, w.b.y) === k)) continue;
if (!sighted.has(k)) continue;
out.add(k);
void x; void y;
}
return out;
}
+7 -5
View File
@@ -39,11 +39,14 @@ function playOut(
while (state.phase === "playing" && commands < CAP) {
const seat = actingSeat(state);
const view = viewFor(state, seat);
const cmd = automatonCommand(view, styleOf.get(seat), tierOf.get(seat)) ?? automatonFallback(view);
const tier = tierOf.get(seat);
const chosen = automatonCommand(view, styleOf.get(seat), tier);
const cmd = chosen ?? automatonFallback(view, tier);
let r = applyCommand(state, seat, cmd);
if (!r.ok) {
const fb = automatonFallback(view);
r = applyCommand(state, seat, fb);
// Retry with the fallback only if the fallback was not what just failed.
const fb = automatonFallback(view, tier);
if (chosen) r = applyCommand(state, seat, fb);
if (!r.ok) {
stuck++;
if (stuck > 3) {
@@ -68,10 +71,9 @@ function playOut(
describe("automaton vs automaton", () => {
it("two clockwork wizards fight a game to its end", () => {
const { state, commands } = playOut(11, 2);
const { state } = playOut(11, 2);
expect(state.phase).toBe("finished");
expect(state.winner).not.toBeNull();
expect(commands).toBeLessThan(4000);
});
it("holds up across many seeds without wedging", () => {
+13 -16
View File
@@ -9,6 +9,10 @@ import {
type SectorPlacement,
} from "../src/board";
import { buildDeck } from "../src/cards";
import { activePlayer, applyCommand, createGame } from "../src/game";
import { createRng } from "../src/rng";
import { setupBoard } from "../src/setups";
import { giveCard } from "./helpers";
describe("card data", () => {
it("basic deck is exactly 125 cards (official 6e rulebook)", () => {
@@ -109,9 +113,7 @@ describe("board assembly", () => {
});
describe("player counts 2-6", () => {
it("assembles a coherent board for every supported count", async () => {
const { setupBoard } = await import("../src/setups");
const { createRng } = await import("../src/rng");
it("assembles a coherent board for every supported count", () => {
for (const n of [2, 3, 4, 5, 6]) {
const { board } = setupBoard(n, createRng(n * 7));
expect(board.homes.length).toBe(n);
@@ -127,8 +129,7 @@ describe("player counts 2-6", () => {
}
});
it("full games start at every count", async () => {
const { createGame } = await import("../src/game");
it("full games start at every count", () => {
for (const n of [3, 5, 6]) {
const ids = Array.from({ length: n }, (_, i) => `w${i}`);
const { state } = createGame({ playerIds: ids, seed: 99 + n, sets: ["basic", "expansion1"] });
@@ -158,12 +159,7 @@ describe("sight and the wraparound openings", () => {
});
});
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;
@@ -203,17 +199,16 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
}
});
it("relocation discards the aisle warp: only opposite edges connect after", async () => {
const { createGame, applyCommand, activePlayer } = await import("../src/game");
it("relocation discards the aisle warp: only opposite edges connect after", () => {
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" };
const rel = giveCard(state, me.id, "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",
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: { x: 0, y: 0 } }, params: { cell: { x: 7, y: 2 } },
});
if (!r.ok) throw new Error(r.error);
@@ -223,8 +218,10 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
describe("the aisle warp treats its corner as adjacent — sight included", () => {
it("sight passes through the AUTO WARP, diagonals and all", () => {
const { board } = setupBoardForPins(3, createRng(7));
const aisle = board.warps.find((w) => w.aisle)!;
const { board } = setupBoard(3, createRng(7));
const aisle = board.warps.find(
(w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y,
)!;
expect(aisle).toBeDefined();
// From the aisle mouth, squares beyond the corner are visible — including
// at least one OFF the straight axis (the adjacency promise).
+7 -8
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES, stepTarget } from "../src/board";
import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards";
import { viewFor } from "../src/view";
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
/** Test surgery: put a specific card into a player's hand (swapping one out). */
@@ -277,8 +278,7 @@ describe("speedstone", () => {
});
describe("the post-game reveal", () => {
it("shows an eliminated player's hand as they fell, not their emptied one", async () => {
const { viewFor } = await import("../src/view");
it("shows an eliminated player's hand as they fell, not their emptied one", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
@@ -350,7 +350,7 @@ describe("attacking walls and doors", () => {
expect(applyCommand(state, me.id, { type: "punchWall", cell, side }).ok).toBe(false);
});
it("a powered fireball brings a wall down at 20 accumulated damage", () => {
it("repeated fireballs bring a wall down at 20 accumulated damage", () => {
let { state } = newGame();
state = toRound2(state);
let me = activePlayer(state);
@@ -388,7 +388,7 @@ describe("attacking walls and doors", () => {
});
});
describe("rules revision 3", () => {
describe("ward arming and chaos shields (rules rev 3)", () => {
function rev3Game(seed = 42) {
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 });
}
@@ -409,7 +409,6 @@ describe("rules revision 3", () => {
expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true);
// Armed (on the owner's own turn): the trap bites for 3.
state.players[state.turn.activeIndex] = state.players[state.turn.activeIndex]!;
const ownerTurnState = (() => {
let s = state;
while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 });
@@ -735,7 +734,7 @@ describe("answering counteractions (FAQ rulings)", () => {
});
});
describe("rules revision 8", () => {
describe("speed and warp-token creation (rules rev 8)", () => {
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 });
state = toRound2(state);
+2 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
import { cellKey, SIDES, stepTarget, type Side } from "../src/board";
import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
@@ -402,8 +402,7 @@ describe("big man (rules rev 5)", () => {
expect(r.ok).toBe(false);
return;
}
// Seed offered no walled pocket beside the home; that is fine — the
// pushable case above pins the mechanism.
throw new Error("setup: seed offered no walled pocket beside the home");
});
it("monsters cannot enter the giant's square", () => {
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, gameLos } from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell } from "../src/board";
import type { CardInstance } from "../src/cards";
import { eligibleCellsFor, viewFor } from "../src/view";
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers";
describe("expansion terrain", () => {
@@ -163,8 +164,7 @@ describe("expansion terrain", () => {
});
describe("eligibility dimming mirrors the engine", () => {
it("boobytrap tokens go anywhere but solid stone, sight be damned", async () => {
const { viewFor, eligibleCellsFor } = await import("../src/view");
it("boobytrap tokens go anywhere but solid stone, sight be damned", () => {
let { state } = newGame();
const caster = activePlayer(state).id;
const view = viewFor(state, caster);
@@ -174,8 +174,7 @@ describe("eligibility dimming mirrors the engine", () => {
expect(lit.size).toBe(Object.keys(view.board.cells).length);
});
it("glue lights only sighted squares that hold something", async () => {
const { viewFor, eligibleCellsFor } = await import("../src/view");
it("glue lights only sighted squares that hold something", () => {
let { state } = newGame();
const caster = activePlayer(state);
// Drop a dagger at the caster's feet — the one guaranteed-sighted object.
@@ -184,8 +183,8 @@ describe("eligibility dimming mirrors the engine", () => {
const lit = eligibleCellsFor(viewFor(state, caster.id), "glue")!;
expect(lit.has(cellKey(here))).toBe(true);
// Empty squares stay dim — glue needs something to glue down.
const view = viewFor(state, caster.id);
for (const k of lit) {
const view = viewFor(state, caster.id);
const held =
(view.groundObjects[k] ?? []).length > 0 ||
view.treasures.some((t) => t.position && cellKey(t.position) === k);
@@ -409,7 +409,8 @@ describe("relocation past the origin (rules rev 11)", () => {
id: "c1", kind: "troll", controllerId: me.id, position: { ...spot },
damage: 0, maxDamage: 5, movesPerTurn: 3, movementUsed: 0,
attackUsed: false, justCreated: false,
} as (typeof state.creatures)[number]);
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
state.gluedCells[cellKey(spot)] = true;
state.dimWarps.push({ a: { ...spot }, b: { x: otherOrigin.x + 1, y: otherOrigin.y + 1 } });
state.boobytraps.push({ casterId: me.id, cells: [{ ...spot }], realKey: cellKey(spot) });
@@ -424,10 +425,8 @@ describe("relocation past the origin (rules rev 11)", () => {
const finalMine = state.board.placements[idx]!.origin;
const finalOther = state.board.placements[idx === 0 ? 1 : 0]!.origin;
const moved = { x: spot.x + finalMine.x - myOrigin.x, y: spot.y + finalMine.y - myOrigin.y };
const staticShifted = {
x: otherOrigin.x + 1 + finalOther.x - otherOrigin.x,
y: otherOrigin.y + 1 + finalOther.y - otherOrigin.y,
};
// The warp's far token sat one square inside the static sector.
const staticShifted = { x: finalOther.x + 1, y: finalOther.y + 1 };
expect(state.creatures[0]!.position).toEqual(moved);
expect(state.gluedCells[cellKey(moved)]).toBe(true);
expect(state.dimWarps[0]!.a).toEqual(moved);
+1 -1
View File
@@ -3,5 +3,5 @@
"compilerOptions": {
"noEmit": true
},
"include": ["src"]
"include": ["src", "test"]
}