Credibility pass: the accretion sanded smooth

Blind reviews over d2b40ec..HEAD (engine / web / server+deploy). Orphaned
doc comments rejoined their functions; the swap-meet tradables collapsed
from four pasted deriveds to one (killing the "the's treasure" label);
FEAR's aura and banner now share the engine's own dreadDistance; the
long-press peek got one home; rulebook.ts stopped wearing JSON quotes;
process-named tests and war-story comments now state the constraints they
pin; the protocol doc caught up to its handlers; verify-ledgers.sh can
actually count failures; the runbook learned about the determinism gate
and the nightly backups. No behavior changes — 255 tests and all 48
production ledgers replay identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-21 10:58:08 -04:00
co-authored by Claude Fable 5
parent 3445d12206
commit 6adcbe23b5
17 changed files with 209 additions and 204 deletions
+15 -1
View File
@@ -23,6 +23,14 @@ research), installs dependencies on the droplet, and restarts the service.
Games in progress survive: state lives in room files, and clients reconnect
automatically.
Before any deploy that touches the engine, run the determinism gate:
deploy/verify-ledgers.sh 104.236.96.198
It fetches every production ledger and strictly replays it against the local
engine; a single refused command fails the check. A room whose ledger no
longer replays becomes unreachable after restart, so this is not optional.
## New droplet from scratch
1. `doctl compute droplet create wizwar --region nyc3 --size s-1vcpu-1gb \
@@ -35,4 +43,10 @@ automatically.
- Logs: `ssh root@<ip> journalctl -u wizwar -f`
- Restart: `ssh root@<ip> systemctl restart wizwar`
- Backup games: `scp -r root@<ip>:/var/lib/wizwar/rooms ./rooms-backup`
- Nightly backups: `wizwar-backup.sh` runs from root's crontab at 07:17 UTC,
pushing /var/lib/wizwar to the `kestrel-wizwar-backups` Space (nyc3) via
rclone. It is installed at /usr/local/bin/wizwar-backup.sh on the droplet;
`setup-droplet.sh` does NOT install it — on a fresh droplet, copy the
script, configure rclone (the script refuses to run while the config still
holds its CHANGE_ME placeholder), and add the cron entry by hand.
- One-off backup: `scp -r root@<ip>:/var/lib/wizwar/rooms ./rooms-backup`
+4 -2
View File
@@ -9,9 +9,11 @@ trap 'rm -rf "$TMP"' EXIT
scp -q "root@$HOST:/var/lib/wizwar/rooms/*.jsonl" "$TMP/"
fails=0
for f in "$TMP"/*.jsonl; do
out=$(npx tsx deploy/replay-verify.mjs "$f" 2>&1 | tail -1)
# `|| true`: a failing replay must feed the tally, not abort the loop.
out=$(npx tsx deploy/replay-verify.mjs "$f" 2>&1 | tail -1) || true
case "$out" in
FAIL*) echo "$out"; fails=$((fails+1));;
OK*) ;;
*) echo "${out:-$(basename "$f"): replay crashed with no output}"; fails=$((fails+1));;
esac
done
count=$(ls "$TMP" | wc -l | tr -d ' ')
+10 -10
View File
@@ -256,6 +256,12 @@ function bestWallCrossing(
return best;
}
/** Square-filling nuisances for path denial, best blocker first. */
const DENIAL_FILLERS = [
"fill-square-with-stone", "thornbush", "rosebush", "create-pit",
"fill-square-with-slime", "killer-ooze", "handful-of-tacks", "dust-cloud",
];
/**
* Path denial: when an enemy's march threatens something dear — they carry
* one of the clockwork's treasures home, or close on its gold lying on the
@@ -265,12 +271,6 @@ function bestWallCrossing(
* would accept are offered (sighted, empty, off homes and warp tokens), and
* only when the detour costs the enemy 3+ extra steps.
*/
/** Square-filling nuisances for path denial, best blocker first. */
const DENIAL_FILLERS = [
"fill-square-with-stone", "thornbush", "rosebush", "create-pit",
"fill-square-with-slime", "killer-ooze", "handful-of-tacks", "dust-cloud",
];
function pathDenial(view: GameView): Command | null {
const wall = inHand(view, "create-wall") ?? inHand(view, "illusion-wall");
const jam = inHand(view, "jam-lock");
@@ -531,7 +531,8 @@ function roadworkPlan(
}
}
}
return best !== null ? (best as { cmd: Command; total: number }).cmd : null;
if (best !== null) return (best as { cmd: Command; total: number }).cmd;
return null;
}
/**
@@ -1308,14 +1309,14 @@ export function automatonCommand(
view.sustained.some((e) => e.cardId === "fear" && e.targetId === p.id) &&
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3);
for (const source of dreadful) {
const here = Math.abs(source.position.x - self.position.x) + Math.abs(source.position.y - self.position.y);
const distNow = Math.abs(source.position.x - self.position.x) + Math.abs(source.position.y - self.position.y);
let best: { dir: Side; d: number } | null = null;
for (const dir of SIDES) {
const t = stepTarget(view.board, self.position, dir);
if (t.kind === "blocked") continue;
if (view.squareContents[cellKey(t.to)]?.kind === "stone") continue;
const d = Math.abs(source.position.x - t.to.x) + Math.abs(source.position.y - t.to.y);
if (d > here && (best === null || d > best.d)) best = { dir, d };
if (d > distNow && (best === null || d > best.d)) best = { dir, d };
}
if (best) return { type: "move", direction: best.dir };
// Dead end: the card excuses what cannot be done; march on normally.
@@ -1391,7 +1392,6 @@ export function automatonCommand(
}
// Standing on a warp token whose far side is closer to the goal: step in.
{
const here = cellKey(self.position);
const pair = view.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here);
if (pair) {
const dest = cellKey(pair.a) === here ? pair.b : pair.a;
+12 -12
View File
@@ -958,7 +958,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive) return;
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params?.cell;
if (!to) return; // an old ambush sprang it with no destination: fizzle
if (!to) return; // pre-rev-35 ambushes carry no destination: fizzle
const from = ctx.defender.position;
ctx.defender.position = to;
ctx.events.push({
@@ -2077,7 +2077,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive) return;
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params?.cell;
if (!to) return; // an old ambush sprang it with no destination: fizzle
if (!to) return; // pre-rev-35 ambushes carry no destination: fizzle
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return;
const from = ctx.defender.position;
ctx.defender.position = to;
@@ -2586,9 +2586,8 @@ function waveForce(state: GameState, range: number, dist: number): number {
/** A collapsing waterwall wave from an edge: wash players back `range`. */
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E");
const pushes: { start: Cell; dir: Side }[] = [
{ start: cell, dir: away(side) },
{ start: cell, dir: opposite(side) },
{ start: neighbor(cell, side), dir: side },
];
for (const { start, dir } of pushes) {
@@ -3734,23 +3733,24 @@ function takeFromHand(p: PlayerState, instanceId: string): CardInstance | null {
// --- Movement ---------------------------------------------------------------
/**
* FEAR: "no player or monster will move to within 3 spaces of you." True
* for every WILLING move walking, warp steps, self-teleports, commanded
* monsters (rules rev 32 widened it beyond walking wizards; forced movement
* such as knockback or a wave is not willing and passes).
*/
/**
* FEAR's measure FAQ: "as a Wizard walks (if he could walk through
* walls), not diagonally." Orthogonal steps, walls ignored and the maze
* wraps, so stepping off one rim continues from the opposite one.
* Exported so the client draws the aura by the same yardstick.
*/
function dreadDistance(board: AssembledBoard, a: Cell, b: Cell): number {
export function dreadDistance(board: AssembledBoard, a: Cell, b: Cell): number {
const dx = Math.abs(a.x - b.x);
const dy = Math.abs(a.y - b.y);
return Math.min(dx, board.width - dx) + Math.min(dy, board.height - dy);
}
/**
* FEAR: "no player or monster will move to within 3 spaces of you." True
* for every WILLING move walking, warp steps, self-teleports, commanded
* monsters (rules rev 32 widened it beyond walking wizards; forced movement
* such as knockback or a wave is not willing and passes).
*/
function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to: Cell): boolean {
// The wrap joined the measure at rev 32; older ledgers were recorded
// under the flat diamond and replay so.
@@ -4271,7 +4271,6 @@ function touchesEdge(position: Cell, cell: Cell, side: Side): boolean {
return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side));
}
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
/** The owner's answer to a hanging grab: spring the WARD, or let them go. */
function doWardChoice(prev: GameState, play: boolean): CommandResult {
const state = clone(prev);
@@ -4293,6 +4292,7 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
return { ok: true, state, events };
}
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
function doArmWard(prev: GameState, armed: boolean): CommandResult {
// Rev 31 reads the card literally: the Ward is played in the moment of
// the grab, never set ahead. Older games keep their arming and replay so.
+31 -31
View File
@@ -118,36 +118,36 @@ describe("automaton vs automaton", () => {
});
});
function underAttackShared(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
// burn round 1
for (let i = 0; i < 2; i++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(`setup: ${r.error}`);
state = r.state;
}
while (actingSeat(state) !== "human") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(`setup: ${r.error}`);
state = r.state;
}
const human = state.players.find((p) => p.id === "human")!;
const bot = state.players.find((p) => p.id === "bot")!;
bot.position = { ...human.position };
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
rig?.(state, "bot");
const r = applyCommand(state, "human", {
type: "cast", instanceId: `${attackId}#A`, target: { kind: "player", playerId: "bot" },
...(attackId === "drop-object" ? { params: { cardId: "dagger" } } : {}),
});
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
// burn round 1
for (let i = 0; i < 2; i++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(`setup: ${r.error}`);
return r.state;
state = r.state;
}
while (actingSeat(state) !== "human") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(`setup: ${r.error}`);
state = r.state;
}
const human = state.players.find((p) => p.id === "human")!;
const bot = state.players.find((p) => p.id === "bot")!;
bot.position = { ...human.position };
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
rig?.(state, "bot");
const r = applyCommand(state, "human", {
type: "cast", instanceId: `${attackId}#A`, target: { kind: "player", playerId: "bot" },
...(attackId === "drop-object" ? { params: { cardId: "dagger" } } : {}),
});
if (!r.ok) throw new Error(`setup: ${r.error}`);
return r.state;
}
describe("the clockwork does not waste counters on pointless targets", () => {
it("passes on drop-object rather than absorbing nothing", () => {
const state = underAttackShared("drop-object", [
const state = underAttack("drop-object", [
{ instanceId: "absorb#T", cardId: "absorb" },
{ instanceId: "blunt#T", cardId: "blunt" },
{ instanceId: "dagger#T", cardId: "dagger" },
@@ -157,7 +157,7 @@ describe("the clockwork does not waste counters on pointless targets", () => {
});
it("shields a drop-object aimed at its carried treasure", () => {
const state = underAttackShared("drop-object", [
const state = underAttack("drop-object", [
{ instanceId: "full-shield#T", cardId: "full-shield" },
{ instanceId: "dagger#T", cardId: "dagger" },
], (s, defender) => {
@@ -171,8 +171,8 @@ describe("the clockwork does not waste counters on pointless targets", () => {
});
describe("the clockwork guards gold only within sight", () => {
// Game X2XN: the bot blind-cast SAFE at a treasure it could not see, the
// engine refused it, and the fallback burned the whole turn.
// A refused cast forfeits the bot's whole turn (the fallback is endTurn),
// so SAFE must only ever be offered where the engine's sight rule allows it.
function goldOnTheFloor() {
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"], deckRev: 36 });
while (actingSeat(state) !== "bot") {
@@ -260,7 +260,7 @@ describe("a clogged hand gets shed, not hoarded", () => {
describe("the clockwork honors absorb's fine print", () => {
it("answers NO SPELL with blunt, never absorb — durations soak no points", () => {
const state = underAttackShared("no-spell", [
const state = underAttack("no-spell", [
{ instanceId: "absorb#T", cardId: "absorb" },
{ instanceId: "blunt#T", cardId: "blunt" },
]);
@@ -428,7 +428,7 @@ describe("the widened spellbook", () => {
});
});
describe("round two of the owner's tactics", () => {
describe("finishing tactics: exile and adrenaline", () => {
it("exiles a thief carrying its gold to the far end of nowhere", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
// burn round 1 and reach the bot's turn
@@ -536,7 +536,7 @@ describe("a pact once signed is honored", () => {
// The pact already stands; a fireball waits in hand as temptation.
state.sustained.push({
id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other",
turnsLeft: 1000, edge: undefined,
remainingTurns: 1000, data: {},
} as never);
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
+2 -2
View File
@@ -1101,8 +1101,8 @@ describe("stone dead counts only the stones in play (rules rev 34)", () => {
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
// 3 x 1 displayed = 3 damage... minus the displayed bloodstone? It is
// hidden, so no soak either: 15 - 3 = 12.
// 3 x 1 displayed stone = 3; the bloodstone is hidden, so it soaks
// nothing: 15 - 3 = 12.
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
});
});
+1 -1
View File
@@ -529,7 +529,7 @@ describe("creatures walk open doorways", () => {
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
if (edge !== "door") continue;
const [kind, coords] = key.split(",").length ? key.split(":") as [string, string] : ["", ""];
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
state.doorStates[key] = "removed";
+3 -2
View File
@@ -339,8 +339,9 @@ describe("an ambushed teleport carries its destination (rules rev 35)", () => {
});
if (!r.ok) throw new Error(r.error);
state = r.state;
state = applyCommand(state, "trapper", { type: "endTurn", draw: 0 }).ok
? (applyCommand(state, "trapper", { type: "endTurn", draw: 0 }) as { state: typeof state }).state : state;
r = applyCommand(state, "trapper", { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
// The prey walks adjacent; the trap springs; the prey passes; they land
// at dest. (Re-find both: applyCommand clones made the handles stale.)
const trapperNow = state.players.find((p) => p.id === "trapper")!;
+9 -5
View File
@@ -9,6 +9,8 @@
// {type:"claimTransfer", code} claim a seat on a new device
// {type:"catchUp", sinceSeq} replay of moves missed while away
// {type:"chat", text} table talk to the room
// {type:"rollDie"} the tabletop D4, published as talk
// {type:"addBot", style?, tier?} host seats an automaton
// {type:"watch", roomId} join the Peanut Gallery: nameless, read-only
// {type:"leave"} detach this socket from table or gallery
// {type:"myGames", seats} summaries for held seats
@@ -17,8 +19,8 @@
// server -> client:
// {type:"welcome"} on connect
// {type:"seat", playerId, token} your seat secret — keep it
// {type:"room", roomId, players, hostId, started, colors}
// {type:"events", events} redacted for this recipient
// {type:"room", roomId, players, hostId, started, audience, colors, bots}
// {type:"events", events, replayed?} redacted for this recipient
// {type:"state", view, seq} redacted full view (after every change)
// {type:"chat", player, text, at} one line of table talk
// {type:"watching", roomId} you are seated in the gallery
@@ -81,7 +83,7 @@ loadPersistedRooms();
// current actor is an automaton waits forever for a human to poke it.
setTimeout(() => {
for (const room of runningRooms()) runBots(room);
}, 2000);
}, 2000); // a beat for clients to reconnect before the clockwork stirs
// One process serves both the built client and the websocket, so production
// needs only a TLS proxy in front (or nothing, on a LAN).
@@ -150,7 +152,8 @@ interface Session {
socket: WebSocket;
playerId: PlayerId | null;
roomId: string | null;
/** In the Peanut Gallery: nameless, read-only, counted but never listed. */
/** In the Peanut Gallery: spectator implies playerId === null, so every
* handler that requires a seat refuses this session by construction. */
spectator: boolean;
/** The raw seat token this connection authenticated with (memory only). */
token: string | null;
@@ -380,7 +383,7 @@ wss.on("connection", (socket) => {
case "watch": {
// The Peanut Gallery: no name, no seat, no ledger line — a pure
// reader of the public broadcast, counted but never identified.
const roomId = String(msg.roomId ?? "").trim().toUpperCase().slice(0, 8);
const roomId = String(msg.roomId ?? "").trim().slice(0, 8);
if (!roomId) return send(socket, { type: "error", message: "roomId required" });
const room = getRoom(roomId);
if (!room) return send(socket, { type: "error", message: "no such room" });
@@ -444,6 +447,7 @@ wss.on("connection", (socket) => {
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
// The game's end unmasks the mystery machines in the roster.
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
botRemark(room, session.playerId, result.events as { type: string }[]);
runBots(room);
+34 -54
View File
@@ -12,7 +12,7 @@
import { prefs, savePrefs } from "./prefs.svelte";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, Side } from "@wizwar/engine";
net.connect();
@@ -899,32 +899,10 @@
// Reflecting a SWAP MEET: the reflector picks the trade — or none at all.
let reflectSwapCard = $state<CardInstance | null>(null);
let reflectSwapMine = $state<string | null>(null);
const reflectMyTradables = $derived.by(() => {
if (!view || !reflectSwapCard) return [];
const opts = new Map<string, string>();
for (const c of view.yourHand) {
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
}
if (me?.carriedTreasureId) {
const t = view.treasures.find((t) => t.id === me.carriedTreasureId);
opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`);
}
return [...opts].map(([key, label]) => ({ key, label }));
});
const reflectTheirTradables = $derived.by(() => {
if (!view || !reflectSwapCard || !view.stack) return [];
const them = view.players.find((p) => p.id === view.stack!.attackerId);
if (!them) return [];
const opts = new Map<string, string>();
for (const c of them.displayed) {
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
}
if (them.carriedTreasureId && (reflectSwapMine === "treasure" || !me?.carriedTreasureId)) {
const t = view.treasures.find((t) => t.id === them.carriedTreasureId);
opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`);
}
return [...opts].map(([key, label]) => ({ key, label }));
});
const reflectMyTradables = $derived(reflectSwapCard ? tradablesOf(null, null) : []);
const reflectTheirTradables = $derived(
reflectSwapCard && view?.stack ? tradablesOf(view.stack.attackerId, reflectSwapMine) : [],
);
function castReflectSwap(pair: string) {
if (!reflectSwapCard) return;
dispatch({
@@ -940,32 +918,38 @@
if (!view) return false;
return view.deckRev >= 26 ? isMovableObject(cardId) : cardDef(cardId).cardType === "object";
}
const myTradables = $derived.by(() => {
/** What one side can put on a trade table: hand (yours) or displayed
* cards (theirs), plus a carried treasure. Their treasure is claimable
* only when yours is going the other way or your arms are free —
* `pairedPick` is what you have already staked. */
function tradablesOf(themId: string | null, pairedPick: string | null): { key: string; label: string }[] {
if (!view) return [];
const opts = new Map<string, string>();
for (const c of view.yourHand) {
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
}
if (me?.carriedTreasureId) {
const t = view.treasures.find((t) => t.id === me.carriedTreasureId);
opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`);
const treasureEntry = (treasureId: string) => {
const t = view!.treasures.find((t) => t.id === treasureId);
opts.set("treasure", `${t?.owner ?? "someone"}'s treasure (carried)`);
};
if (themId === null) {
for (const c of view.yourHand) {
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
}
if (me?.carriedTreasureId) treasureEntry(me.carriedTreasureId);
} else {
const them = view.players.find((p) => p.id === themId);
if (!them) return [];
for (const c of them.displayed) {
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
}
if (them.carriedTreasureId && (pairedPick === "treasure" || !me?.carriedTreasureId)) {
treasureEntry(them.carriedTreasureId);
}
}
return [...opts].map(([key, label]) => ({ key, label }));
});
}
const myTradables = $derived(tradablesOf(null, null));
const theirTradables = $derived.by(() => {
if (!view || !swapMeetTarget) return [];
const them = view.players.find((p) => p.id === swapMeetTarget);
if (!them) return [];
const opts = new Map<string, string>();
for (const c of them.displayed) {
if (tradableHere(c.cardId)) opts.set(c.cardId, cardDef(c.cardId).name);
}
// Their treasure is claimable unless your arms are already full.
if (them.carriedTreasureId && (swapMine === "treasure" || !me?.carriedTreasureId)) {
const t = view.treasures.find((t) => t.id === them.carriedTreasureId);
opts.set("treasure", `${t?.owner ?? "the"}'s treasure (carried)`);
}
return [...opts].map(([key, label]) => ({ key, label }));
if (!swapMeetTarget) return [];
return tradablesOf(swapMeetTarget, swapMine);
});
function castSwapMeet(theirs: string) {
if (!selectedCard || !swapMeetTarget || !swapMine) return;
@@ -1041,11 +1025,7 @@
: [],
);
const carryingTreasure = $derived(me?.carriedTreasureId != null);
const treasureHere = $derived(
view != null && me != null &&
view.treasures.some((t) => t.position && t.position.x === me.position.x &&
t.position.y === me.position.y && !t.carriedBy),
);
const treasureHere = $derived(treasuresHere.length > 0);
/** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */
// While an LOS attack sits on the stack, draw the line it traveled — the
// answer to "how can he even see me?" when sight ran through a warp mouth.
@@ -1818,7 +1798,7 @@
{/if}
{#if isYourTurn && view.players.some((p) => p.alive && p.id !== view.you &&
view.sustained.some((e) => e.cardId === "fear" && e.targetId === p.id) && me &&
Math.abs(p.position.x - me.position.x) + Math.abs(p.position.y - me.position.y) <= 3)}
dreadDistance(view.board, p.position, me.position) <= 3)}
<div class="slip urgent">
😱 An unnatural dread grips you — FEAR requires you to move away
this turn if you can, even if your only escape is a card
+9 -14
View File
@@ -2,10 +2,11 @@
import type { Component } from "svelte";
import type { GameView, SightTrace } from "@wizwar/engine";
import type { Side } from "@wizwar/engine";
import { dreadDistance } from "@wizwar/engine";
import type { BoardFx } from "./fx";
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import EdgeGlyph from "./EdgeGlyph.svelte";
import EdgeGlyph, { type LockState } from "./EdgeGlyph.svelte";
import FirewallEdge from "./FirewallEdge.svelte";
import IllusionShimmer from "./IllusionShimmer.svelte";
import SightTraceOverlay from "./SightTraceOverlay.svelte";
@@ -182,22 +183,16 @@
// FEAR's dread: three spaces in every direction, straight through walls
// ("even if walls separate you"), never diagonally — and the maze wraps,
// so the diamond continues from the opposite rim. Same measure the
// engine's refusal takes.
// so the diamond continues from the opposite rim. The engine's own
// yardstick, so the aura and the refusal can never disagree.
const fearCells = $derived.by(() => {
const out = new Set<string>();
const W = view.board.width;
const H = view.board.height;
for (const fp of view.players) {
if (!fp.alive) continue;
if (!view.sustained.some((e) => e.cardId === "fear" && e.targetId === fp.id)) continue;
for (let dx = -3; dx <= 3; dx++) {
for (let dy = -3 + Math.abs(dx); dy <= 3 - Math.abs(dx); dy++) {
const wx = ((fp.position.x + dx) % W + W) % W;
const wy = ((fp.position.y + dy) % H + H) % H;
const k = `${wx},${wy}`;
if (view.board.cells[k]) out.add(k);
}
for (const k of Object.keys(view.board.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
if (dreadDistance(view.board, fp.position, { x, y }) <= 3) out.add(k);
}
}
return out;
@@ -421,7 +416,7 @@
: e.lock === "ajar" ? "unlocked until end of turn" : null}
<EdgeGlyph x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"}
state={e.state === "door" ? "door" : "wall"}
lock={(e.lock ?? null) as "removed" | "jammed" | "held" | "ajar" | null}
lock={(e.lock ?? null) as LockState | null}
title={lockTitle} damage={view.wallDamage[`${e.kind}:${e.x},${e.y}`] ?? 0}
onpeek={onEdgePeek} />
{/if}
@@ -656,7 +651,7 @@
{/if}
{/each}
{/if}
<!-- FEAR's bubble: every square within three WALKED spaces (warps count) -->
<!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim -->
{#each fearCells as key (key)}
{@const bx = Number(key.split(",")[0])}
{@const by = Number(key.split(",")[1])}
+8 -11
View File
@@ -1,11 +1,17 @@
<script lang="ts" module>
/** The color states a door can wear (a wall wears none). */
export type LockState = "removed" | "jammed" | "held" | "ajar";
</script>
<script lang="ts">
import { holdToPeek } from "./peek";
// A wall or door on an edge, exactly as the board draws it. Wall-segment
// effects (locks picked, jammed, removed; doors held) are color states of
// this one glyph — the game has never used tokens for them.
let { x, y, kind, state, lock = null, title = null, damage = 0, onpeek }: {
x: number; y: number; kind: "V" | "H";
state: "wall" | "door";
lock?: "removed" | "jammed" | "held" | "ajar" | null;
lock?: LockState | null;
title?: string | null;
/** Battle damage taken so far (walls fall at 20, doors at 15). */
damage?: number;
@@ -23,16 +29,7 @@
: `${needed} points to destroy`);
const tip = $derived(`${title ?? (state === "door" ? "a locked door" : "a wall")} — ${hp}`);
// Press-and-hold peeks the segment, mirroring the board's cell gesture.
let timer: ReturnType<typeof setTimeout> | null = null;
function press() {
if (!onpeek) return;
timer = setTimeout(() => onpeek?.(tip), 450);
}
function release() {
if (timer) clearTimeout(timer);
timer = null;
}
const { press, release } = holdToPeek(() => onpeek?.(tip));
</script>
{#if kind === "V"}
+3 -10
View File
@@ -2,21 +2,14 @@
// A standing WALL OF FIRE: board furniture, not a flourish — it burns for
// as long as the spell holds, so the animation is pure CSS on a handful
// of shapes (no turbulence filters; those are budgeted for one-shot fx).
import { holdToPeek } from "./peek";
let { x, y, kind, onpeek }: {
x: number; y: number; kind: "V" | "H";
/** Press-and-hold (touch has no hover): report what this fire is. */
onpeek?: (tip: string) => void;
} = $props();
const TIP = "a wall of fire — passing through burns for 4";
let peekTimer: ReturnType<typeof setTimeout> | null = null;
function press() {
if (!onpeek) return;
peekTimer = setTimeout(() => onpeek?.(TIP), 450);
}
function release() {
if (peekTimer) clearTimeout(peekTimer);
peekTimer = null;
}
const { press, release } = holdToPeek(() => onpeek?.(TIP));
const uid = $props.id();
const CELL = 48;
const WALL = 7;
@@ -40,7 +33,7 @@
</script>
<g transform={`translate(${cx} ${cy})`} class="fw">
<title>a wall of fire — passing through burns for 4</title>
<title>{TIP}</title>
<defs>
<linearGradient id={`fw-bar-${uid}`} x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#7c1a14" />
+6 -6
View File
@@ -4,6 +4,9 @@
// with /?tokens under `npm run dev`; edits to public/tokens-svg/*.svg
// show on the next refresh. Board size and close-up for each.
import TokenArt from "./TokenArt.svelte";
import EdgeGlyph, { type LockState } from "./EdgeGlyph.svelte";
import FirewallEdge from "./FirewallEdge.svelte";
import IllusionShimmer from "./IllusionShimmer.svelte";
const GROUPS: { title: string; files: string[] }[] = [
{ title: "Wizards", files: ["wizard-0", "wizard-1", "wizard-2", "wizard-3", "wizard-4", "wizard-5"] },
@@ -28,15 +31,12 @@
},
];
// Wall-segment effects have never been tokens: the maze draws them as edge
// treatments. Shown here exactly as the board mounts them.
import EdgeGlyph from "./EdgeGlyph.svelte";
import FirewallEdge from "./FirewallEdge.svelte";
import IllusionShimmer from "./IllusionShimmer.svelte";
// Edge treatments, not tokens — see EdgeGlyph. Shown exactly as the
// board mounts them.
const EDGE_PIECES: {
caption: string;
state?: "wall" | "door";
lock?: "removed" | "jammed" | "held" | "ajar" | null;
lock?: LockState | null;
special?: "firewall" | "shimmer" | "known-illusion";
}[] = [
{ caption: "wall", state: "wall" },
+2 -1
View File
@@ -91,7 +91,8 @@ export function tableHints(view: GameView): TableHint[] {
try {
const cmd = automatonCommand(view, s, "archmage");
advice = cmd ? `I would ${describe(view, cmd)}.` : "Nothing calls for us right now — wait for your moment.";
} catch {
} catch (e) {
console.warn("hint council choked:", e);
advice = "…I have no counsel here.";
}
out.push({ name: v.name, glyph: v.glyph, mood: v.mood, advice });
+18
View File
@@ -0,0 +1,18 @@
// Press-and-hold to peek, mirroring the board's cell gesture. Touch has no
// hover, so every board fixture answers a 450ms hold with its tooltip.
const HOLD_MS = 450;
/** Wire the result's press/release to onpointerdown/-up/-leave. */
export function holdToPeek(fire: () => void) {
let timer: ReturnType<typeof setTimeout> | null = null;
return {
press() {
timer = setTimeout(fire, HOLD_MS);
},
release() {
if (timer) clearTimeout(timer);
timer = null;
},
};
}
+42 -42
View File
@@ -10,28 +10,28 @@ export interface RuleSection {
export const RULEBOOK_BASE: RuleSection[] = [
{
"title": "INTRODUCTION",
"paragraphs": [
title: "INTRODUCTION",
paragraphs: [
"Wiz War is a game for magical combat in a stone labyrinth. Players attempt to bring two of their opponent's treasures back to their home bases while at the same time guarding their own treasures against theft. A game usually takes from fifteen minutes to an hour to play, and can be played with two or more players."
]
},
{
"title": "WINNING",
"paragraphs": [
title: "WINNING",
paragraphs: [
"There are two ways you can win Wiz War. One way is to eliminate all the other players in wizardly battle. The other way is to obtain two \"treasure chests\" from any of the other players (they need not both be from the same player), then return them, one at a time, to your home bases, and drop them there.",
"Alternatively, it is also possible to lose the game by allowing both of the treasures that you protect to be taken to another players' home bases and left there. The moment that both of your treasures sit on other players' home bases, you are out of the game."
]
},
{
"title": "EQUIPMENT",
"paragraphs": [
title: "EQUIPMENT",
paragraphs: [
"A four section playing board, a deck of 124 cards, one sheet of cardboard tokens, a rule booklet, and one die (numbered from 1-4). It may be 8-sided or 4-sided, depending on what game edition you have.",
"*(6E: 125 cards, two sheets of tokens — see Section 1.)*"
]
},
{
"title": "STARTING THE GAME",
"paragraphs": [
title: "STARTING THE GAME",
paragraphs: [
"Each player chooses one of the four sectors, face down, at random. Put these sectors together in any of the setups shown on the last page, depending on the number of players, and turn all the sectors over simultaneously. This is now your playing board.",
"Each player starts his playing piece in the exact center of the sector he chose. This center square is his HOME BASE, to which he will try to bring two of his opponents' treasures.",
"In each player's sector, his two treasure chests are placed on the small circles evident on each sector board. For the rest of the game, the other players will try to steal these, and he will try to protect them. He in turn will try to steal theirs.",
@@ -41,8 +41,8 @@ export const RULEBOOK_BASE: RuleSection[] = [
]
},
{
"title": "THE BOARD",
"paragraphs": [
title: "THE BOARD",
paragraphs: [
"Notice the letters on the setup diagram on the last page of the rules. These boards have open sides; that is, if you leave a board at point \"A\" on either side, you will reenter at point \"A\" on the opposite side. By the same token, if you smash down a wall right next to the \"A\" exit, then you can enter the opposite board edge at the point relative to where you smashed the wall.",
"The apparent double wall at the junction of any two sectors is not double at all; it should be treated as a single wall.",
"The AUTO WARP is only used in the three-player game. It does not count as a space; you go directly from one end to the other. For all purposes, treat the connected board edges as though they are adjacent. It serves to connect the three sectors together in such a way as to prevent any unfair advantages for any particular board. If a sector gets RELOCATED through use of the RELOCATE card, then the AUTO WARP is discarded, and only opposite board edges connect. ROTATION has no effect on the AUTO WARP.",
@@ -53,14 +53,14 @@ export const RULEBOOK_BASE: RuleSection[] = [
]
},
{
"title": "MOVEMENT",
"paragraphs": [
title: "MOVEMENT",
paragraphs: [
"You may move up to three spaces per turn. If you wish, during any turn you may add one NUMBER card to your movement to increase your allowed movement for your turn. If the NUMBER card was \"4\", then you could move up to seven spaces that turn. The die is not used for movement."
]
},
{
"title": "OBJECTS",
"paragraphs": [
title: "OBJECTS",
paragraphs: [
"**Fixed/Movable Objects:** Fixed objects cannot be moved. These include such things as walls, firewalls, illusion walls, thorn bushes, doors, and solid stone squares. Movable objects include magic stones, treasure chests, the DAGGER, the LARGE ROCK, the WIZARDBLADE, and other players. Other players cannot be picked up, of course, but they can be DRAGged with the DRAG spell. Generally speaking, movable objects do not block line of sight, while fixed objects do.",
"**Dropping and Retrieving Objects:** Sometimes you will have objects in your card hand that you do not wish to carry at the time, since you are limited to seven cards in your hand and you may wish to get new cards. In that case, you can drop any or all of them. To do this, you merely state that you are dropping an object, and put an appropriate cardboard token representing that object on the space that your playing piece occupies. You can do this any time during your turn. Anyone else may pick this object up at a later time, if they land on it. The card designating the object, if there is one, is placed next to the sector where the object was dropped. Another player picking this object up must take the card for it into his own hand.",
"If an object is thrown, it is considered to have landed in the targeted square unless some barrier, such as a THORNBUSH, is in the way. In that case the object lands immediately before the barrier.",
@@ -71,8 +71,8 @@ export const RULEBOOK_BASE: RuleSection[] = [
]
},
{
"title": "THE CARDS",
"paragraphs": [
title: "THE CARDS",
paragraphs: [
"There are five different kinds of cards in the game. They are called ATTACK cards, NEUTRAL cards, COUNTER-ACTION cards, NUMBER cards, and MAGIC STONES.",
"**ATTACK** cards say \"Attack\" in the upper left-hand corner of each card. **ONLY ONE ATTACK CAN BE USED DURING EACH PLAYER TURN.** In the upper right-hand corner, the abbreviation \"L.O.S.\" might appear. This means that a spell can only be used if a player has a clear line of sight to the person or object the spell is to be cast upon. Line of sight is defined as the line going from the center of the attacker's square to the center of the target's square. The center is the dot marked on each space. In the case of a CREATE WALL spell, the center would be the center of the wall to be created. If this line of sight is interrupted by any part of a wall, the spell cannot be cast.",
"*(6E: the boards have no printed dots — eyeball the square centers. See Section 1.)*",
@@ -90,16 +90,16 @@ export const RULEBOOK_BASE: RuleSection[] = [
]
},
{
"title": "THE TURN",
"paragraphs": [
title: "THE TURN",
paragraphs: [
"You can do a lot during your turn. You may move, attack once, and use as many NEUTRAL and COUNTERACTION cards as you like. As an example, you play a \"3\" NUMBER card and say \"I'm moving six spaces this turn.\" Then you move two spaces into L.O.S. with another player, cast an attack, move back three spaces, create a wall, shrink yourself, then move back one more space. The order you use doesn't matter, and you can break up your movement while performing other actions. If you pick up an object during your turn, your turn ends the moment you pick it up.",
"Losing a turn means you can do NOTHING for that entire turn, except COUNTERACTION.",
"For the purpose of lost turns, or spell duration, a \"turn\" starts when a spell is cast, and ends at the beginning of one of the caster's turns."
]
},
{
"title": "COMBAT",
"paragraphs": [
title: "COMBAT",
paragraphs: [
"There is no combat during the first round of turns. Combat consists of taking a card from your hand and playing it against your opponent during your turn. If that player has a COUNTERACTION card, he may play it then. If you want to COUNTERACT his COUNTERACTION, and have the card to do so, you may. You may attack only once per turn. Spells automatically hit their target if not COUNTERACTed. If a spell misses for some reason, it dissipates harmlessly, and does NOT hit some object behind the target!",
"Any damage done to either player in the form of damage points is removed from the 15 points on his score sheet. If you get to \"0\" points, you are dead.",
"Damage points, points, damage, and point-based spells all refer to the same thing: things that take away points from you, an opponent, or object (such as a wall or door). They have nothing to do with duration-based spells.",
@@ -114,8 +114,8 @@ export const RULEBOOK_BASE: RuleSection[] = [
]
},
{
"title": "CARDS AND ACTIONS THAT CHANGE THE MAP",
"paragraphs": [
title: "CARDS AND ACTIONS THAT CHANGE THE MAP",
paragraphs: [
"If you add a wall, destroy a wall, create a bush or firewall, etc., then you must take an appropriate cardboard token representing this and place it where the item was created.",
"You CANNOT create an object on a player's home base, or on any space already occupied by a player or object (unless it is a wall, which doesn't actually exist ON a space, but in between them). You cannot, for example, cast a FIREWALL where an ILLUSION WALL already exists, or two THORNBUSHES in the same square.",
"Walls cannot be cast diagonally. They must be cast on the lines between spaces.",
@@ -124,23 +124,23 @@ export const RULEBOOK_BASE: RuleSection[] = [
]
},
{
"title": "GETTING NEW CARDS",
"paragraphs": [
title: "GETTING NEW CARDS",
paragraphs: [
"You start with seven cards. You may draw up to two per turn, but may never have more than seven cards in your hand at one time. If for some reason you do, you must immediately discard enough cards to bring your hand down to the seven card limit. Draw new cards only at the end of YOUR turn.",
"If you want to discard cards from your hand just for the sake of getting new cards, you may. This includes cards which represent permanent objects, which give you the option of dropping the object or discarding it."
]
},
{
"title": "THE DIE",
"paragraphs": [
title: "THE DIE",
paragraphs: [
"This is referred to as a D4 in the game. It is either 8-sided (numbered from 1-4 twice) or 4-sided, depending on which game edition you have. It is NOT used to roll movement.",
"Whenever a random direction is called for in the course of the game, pick a number representing the direction you are trying to go, or trying to cast a spell (if you are BLINKed), and then roll for that number.",
"If you need to roll a 50-50% chance, just call 1,2 as low and 3,4 as high, determining which roll represents what, beforehand."
]
},
{
"title": "DEATH",
"paragraphs": [
title: "DEATH",
paragraphs: [
"A dead player is out of the game and may not win even if two treasures are placed on his home base. His home base is no longer valid for the purpose of eliminating other players. His treasures, though, are still valid to use to win the game. A player who dies while performing an action that would normally win the game is considered to have died first, and is eliminated from the game.",
"---"
]
@@ -149,8 +149,8 @@ export const RULEBOOK_BASE: RuleSection[] = [
export const RULEBOOK_EXPANSION: RuleSection[] = [
{
"title": "MONSTERS",
"paragraphs": [
title: "MONSTERS",
paragraphs: [
"Expansion 1 introduces spells that create monsters. Any monster will follow the commands of its creator (unless otherwise noted on the cards, like the Imp), even when out of line-of-sight. They move any time during the controlling player's turn, so if a player gets an extra turn, or loses a turn, so does the monster (Imp excluded, again). Monsters, unless otherwise noted on the card, may not hurt their creators, carry items, cast spells or use cards from your hand. They are permanent creations, until killed. You may not add NUMBER cards to their movement, but you can cast NEUTRAL spells such as MIST-BODY and SPEED on them.",
"For the few instances where MONSTERS can carry items, they can't use those items at all (unless the card specifically says otherwise).",
"Monsters attack once per turn. They cannot attack the turn they are created, but may move on that turn. Creating a monster counts as your attack only on the turn it is created. You and your monster may both have attacks on later turns. Monsters can attack other monsters. Players may not attack a creature that they control. If a monster attacks a player and kills him, the controlling player DOES NOT get the dead player's cards.",
@@ -163,14 +163,14 @@ export const RULEBOOK_EXPANSION: RuleSection[] = [
]
},
{
"title": "FLYING AND LEVITATING",
"paragraphs": [
title: "FLYING AND LEVITATING",
paragraphs: [
"Things that fly, float, or levitate are immune to the effects of ground-based effects such as TACKS, KILLER OOZE, and WATERWALL."
]
},
{
"title": "MAGIC STICKS",
"paragraphs": [
title: "MAGIC STICKS",
paragraphs: [
"You can recognize a magic stick because the word \"Stick\" is in the name of the card. Each stick carries a number of \"charges\" set by the number card played when the stick is first used. This number tells you how many times the stick can be used before it gets discarded. Place a token on the card when played indicating how many charges the stick has, and leave the stick displayed. Any stick operates a maximum of once per turn. Once it is used up it is useless and may not be recharged, or retrieved from the discard pile. If ownership of a magic stick is taken by an opponent, then whatever charges are left on the stick are also transferred. If the initial number of charges has not been set, then the new owner may do so. AMPLIFY and ADD will both work when setting the initial number of charges.",
"Magic Sticks do not burn. They do, of course, count as a card in your hand.",
"SPEED or ADRENALINE will NOT allow you to use a Magic Stick more than once in your turn. While the spells speed YOU up (and your monsters, whose turns are slaved to you), they do not speed up the limits of the wand. This isolation of the wand from you allows you to use it even with NO SPELL cast on you.",
@@ -178,8 +178,8 @@ export const RULEBOOK_EXPANSION: RuleSection[] = [
]
},
{
"title": "OTHER NOTES FOR THE FIRST EXPANSION",
"paragraphs": [
title: "OTHER NOTES FOR THE FIRST EXPANSION",
paragraphs: [
"There are tokens on the counter sheet marked with a number of Charges. These are to help keep track of how many charges a Magic Stick has left on it (place the token in front of you after you have charged up the stick). You needn't use them if you can keep it all straight in your head.",
"If you don't like a card in your edition of Wiz War, don't use it.",
"The tokens for Magic Sticks and Magic Stones aren't meant to be carried by a Wizard. They are there in case the player drops an item, to show where on the board it was dropped.",
@@ -189,20 +189,20 @@ export const RULEBOOK_EXPANSION: RuleSection[] = [
]
},
{
"title": "TEAM PLAY OPTION (thanks to Jeff Ingalls)",
"paragraphs": [
title: "TEAM PLAY OPTION (thanks to Jeff Ingalls)",
paragraphs: [
"If you have 4 or 6 players, it may be fun to try team play. For 4 players, the 1st and 3rd are one team, the 2nd and 4th the other. For 6 players, use 3 teams of 2. Winning is determined by either killing the other players, or by getting 3 of the opponents' treasures on either of your team's home bases in any combination. You may not attack your teammates (unless a teammate is under someone else's control). Discussion of strategy is allowed, but you may not reveal or trade cards with your teammate unless in L.O.S."
]
},
{
"title": "TWO PLAYER VARIANT",
"paragraphs": [
title: "TWO PLAYER VARIANT",
paragraphs: [
"For better board access for two players, cross the B and C exits on the 2 boards (see the diagram in the basic set). Thus, each player has a B exit and a C exit in his sector. It's a lot more fun this way."
]
},
{
"title": "DOORS VARIANT",
"paragraphs": [
title: "DOORS VARIANT",
paragraphs: [
"You can, if you like, play with the optional rule that within each sector a Wizard can open his doors without a key. Suggestion by Tracy Johnston.",
"---"
]