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"]
}
+13 -11
View File
@@ -6,9 +6,15 @@
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
import {
applyCommand,
automatonCommand,
automatonFallback,
AUTOMATON_STYLES,
AUTOMATON_TIERS,
createGame,
redactEvent,
viewFor,
type AutomatonStyle,
type AutomatonTier,
type Command,
type GameEvent,
type GameState,
@@ -17,14 +23,6 @@ import {
} from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { recordRoom } from "./stats";
import {
automatonCommand,
automatonFallback,
AUTOMATON_STYLES,
AUTOMATON_TIERS,
type AutomatonStyle,
type AutomatonTier,
} from "@wizwar/engine";
export interface LoggedCommand {
seq: number;
@@ -289,10 +287,12 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat);
const bot = room.bots.get(seat);
const cmd = automatonCommand(view, bot?.style, bot?.tier) ?? automatonFallback(view);
let r = runCommand(room, seat, cmd);
const chosen = automatonCommand(view, bot?.style, bot?.tier);
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
if ("error" in r) {
r = runCommand(room, seat, automatonFallback(view));
// A refused choice retries with the fallback — unless the fallback IS
// what just failed — then burns down the ladder to endTurn and pass.
if (chosen) r = runCommand(room, seat, automatonFallback(view, bot?.tier));
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
if ("error" in r) {
@@ -509,6 +509,8 @@ export function loadPersistedRooms(): void {
room.bots.set(line.name, {
style: (line.style as AutomatonStyle) ?? "hunter",
secret: line.secret === true,
// Tier-less join lines predate tiers, when every automaton
// played the full repertoire — not the "adept" lobby default.
tier: (line.tier as AutomatonTier) ?? "archmage",
});
} else {
+3 -12
View File
@@ -833,25 +833,16 @@
</div>
{/if}
{#if peekCard}
{#if peekCard || discardPeek}
<div class="big-peek-scrim" role="button" tabindex="-1"
onclick={() => { peekCard = null; peekCreatureId = null; }} onkeydown={() => {}}>
onclick={() => { peekCard = null; peekCreatureId = null; discardPeek = null; }} onkeydown={() => {}}>
<div class="big-peek">
<Card card={peekCard} onfaq={(id) => (faqCardId = id)} />
<Card card={(peekCard ?? discardPeek)!} onfaq={(id) => (faqCardId = id)} />
{#if peekNote}<div class="peek-note big-peek-note">{peekNote}</div>{/if}
</div>
</div>
{/if}
{#if discardPeek}
<div class="big-peek-scrim" role="button" tabindex="-1"
onclick={() => (discardPeek = null)} onkeydown={() => {}}>
<div class="big-peek">
<Card card={discardPeek} onfaq={(id) => (faqCardId = id)} />
</div>
</div>
{/if}
{#if faqCardId}
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
{/if}
+1 -16
View File
@@ -236,22 +236,7 @@
50% { box-shadow: 0 0 0 2px #c9a72a, 0 0 6px 1px rgba(201, 167, 42, 0.3); }
}
@media (prefers-reduced-motion: reduce) {
.faq-seal {
position: absolute;
bottom: 0.15rem;
right: 0.2rem;
background: #2b2218;
color: #e8dfc6;
font-family: "Oswald", sans-serif;
font-size: 0.42rem;
letter-spacing: 0.12em;
padding: 0.1rem 0.25rem;
border-radius: 2px;
cursor: pointer;
opacity: 0.85;
}
.faq-seal:hover { opacity: 1; }
.card.glowing { animation: none; }
.card.glowing { animation: none; }
.card { transition: none; }
.card:hover, .card.selected, .card.attached { transform: none; }
}
+36 -7
View File
@@ -25,7 +25,12 @@ export const FAQ_GENERAL: { title: string; body: string[] }[] = [
title: "Death",
body: [
"The second-hand killing of a Wizard, say by falling walls or controlled MONSTERS, does not get you his cards, nor does a MONSTER get cards by killing a player!",
"Dumping Cards and Physical Objects This is explained pretty well in the basic rules, but everybody seems to forget you can do it. If you want to discard a physical object, it is NOT necessary to dump it on the board, you can just discard it to the discard pile. The board would get very crowded otherwise. This includes ARTIFACTS, if you think someone is about to take one from you and there's nothing you can do about it. This can only be done during your own turn (this is not in the rules, but will be next printing).",
],
},
{
title: "Dumping Cards and Physical Objects",
body: [
"This is explained pretty well in the basic rules, but everybody seems to forget you can do it. If you want to discard a physical object, it is NOT necessary to dump it on the board, you can just discard it to the discard pile. The board would get very crowded otherwise. This includes ARTIFACTS, if you think someone is about to take one from you and there's nothing you can do about it. This can only be done during your own turn (this is not in the rules, but will be next printing).",
],
},
{
@@ -38,8 +43,18 @@ export const FAQ_GENERAL: { title: string; body: string[] }[] = [
title: "Lost Turns",
body: [
"These will accumulate if you get hit by a \"lost turns\" type spell while under the effect of another \"lost turns\" spell. Also, if you lose a turn while under a duration spell, this still uses up a turn of the duration spell. If you happen to get hit by a reflected attack (yours), and you \"lose a turn\", this would be THAT turn, not your next turn. This could happen with Lightning Bolt or Hypnosis, for example. As the rules say, if you Lose a Turn, you can't do anything but play Counteractions until your next turn. This also means you can't draw cards!",
"Magic Sticks These don't have to be displayed. When you first charge it with a NUMBER card, the NUMBER card gets discarded, so it no longer counts as part of your hand.",
"Magic Stones They may be discarded as cards even after used and displayed. Nothing makes an object so permanent that it cannot be discarded. This is always a player option. MAGIC STONES are not \"created\" by the card, the card merely represents the physical object. It may be displayed and left displayed whenever you use it first, even if it is not your turn.",
],
},
{
title: "Magic Sticks",
body: [
"These don't have to be displayed. When you first charge it with a NUMBER card, the NUMBER card gets discarded, so it no longer counts as part of your hand.",
],
},
{
title: "Magic Stones",
body: [
"They may be discarded as cards even after used and displayed. Nothing makes an object so permanent that it cannot be discarded. This is always a player option. MAGIC STONES are not \"created\" by the card, the card merely represents the physical object. It may be displayed and left displayed whenever you use it first, even if it is not your turn.",
],
},
{
@@ -52,16 +67,30 @@ export const FAQ_GENERAL: { title: string; body: string[] }[] = [
title: "Picking Up Objects",
body: [
"No, you can't pick up 2 objects at the same time, even if they're in the same space.",
"Players as Objects There is some confusion in the rules which will probably not be resolved until next edition as to whether Wizards and MONSTERS can be treated as objects or not, for purposes of spells such as DRAG and a few others. Unfortunately, this is not well defined anywhere. The basic rules state that players are NOT objects, so spells such as TELEPORT OBJECT and ANIMATE OBJECT would have no effect on Wizards. Use common sense and house rules. You will rarely run into this as a problem, but you could. Obviously, if a card says a MONSTER can carry objects, it is NOT referring to other Wizards!",
"Spell Priority There is some question as to whether Counteractions or Attacks work first, especially in a case like SHIELDSTONE vs FIREBALL. Counteractions always work first. Thus, if someone was hit with a FIREBALL while they were carrying a BLOODSTONE, the BLOODSTONE would reduce the damage done by a point before it was destroyed. If you have 2 Counteractions you want to use, you decide for yourself which one activates first. If a spell gets counteracted, the number card (if any) that accompanied it is also discarded.",
"Throwing Objects The basic rules imply, without explicitly stating, that you can throw objects, but not treasures. That's true! You could take a Magic Stone and throw it inot a PIT 3 spaces away, if you wanted. Throwing objects at other players does not do any damage, unless a card, such as HEAVE-HO or DAGGER says so.",
],
},
{
title: "Players as Objects",
body: [
"There is some confusion in the rules which will probably not be resolved until next edition as to whether Wizards and MONSTERS can be treated as objects or not, for purposes of spells such as DRAG and a few others. Unfortunately, this is not well defined anywhere. The basic rules state that players are NOT objects, so spells such as TELEPORT OBJECT and ANIMATE OBJECT would have no effect on Wizards. Use common sense and house rules. You will rarely run into this as a problem, but you could. Obviously, if a card says a MONSTER can carry objects, it is NOT referring to other Wizards!",
],
},
{
title: "Spell Priority",
body: [
"There is some question as to whether Counteractions or Attacks work first, especially in a case like SHIELDSTONE vs FIREBALL. Counteractions always work first. Thus, if someone was hit with a FIREBALL while they were carrying a BLOODSTONE, the BLOODSTONE would reduce the damage done by a point before it was destroyed. If you have 2 Counteractions you want to use, you decide for yourself which one activates first. If a spell gets counteracted, the number card (if any) that accompanied it is also discarded.",
],
},
{
title: "Throwing Objects",
body: [
"The basic rules imply, without explicitly stating, that you can throw objects, but not treasures. That's true! You could take a Magic Stone and throw it inot a PIT 3 spaces away, if you wanted. Throwing objects at other players does not do any damage, unless a card, such as HEAVE-HO or DAGGER says so.",
],
},
{
title: "Treasures",
body: [
"If a player is eliminated, any treasure left on his home base does NOT count toward eliminating the player to whom that treasure belongs. Also, the 7th edition rules are rather badly worded and imply that you can carry 2 treasures as long as one of them is your own and the other belongs to another player. This is incorrect. You can carry only ONE treasure, and it can be EITHER one of your own or one of the opponent's.",
"Card FAQ's for Wiz-War",
],
},
];
+2 -2
View File
@@ -427,7 +427,6 @@ class Net {
this.send({ type: "claimTransfer", code });
}
/** Ask the server how all our games are doing. */
addBot(style?: string, tier?: string): void {
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
}
@@ -466,6 +465,7 @@ class Net {
this.send({ type: "hotseatReport", ...report });
}
/** Ask the server how all our games are doing. */
refreshGames(): void {
if (this.seats.length > 0) this.send({ type: "myGames", seats: $state.snapshot(this.seats) });
}
@@ -521,12 +521,12 @@ class Net {
this.send({ type: "pickColor", color });
}
/** Ask for the reel of everything since we last watched. */
/** The whole game from the deal, once it is finished. */
requestFullReplay(): void {
this.send({ type: "catchUp", sinceSeq: 0, full: true });
}
/** Ask for the reel of everything since we last watched. */
requestCatchUp(): void {
if (!this.roomId) return;
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });