From c64ff70ed4975ae102dae1d18fbb4b491c99d9b1 Mon Sep 17 00:00:00 2001
From: Eric Wagoner
Date: Sun, 11 Oct 2026 11:29:07 -0400
Subject: [PATCH] The table speaks plainer, and keeps its whimsy
Wording throughout the app, the server's messages and the engine's
refusals trades its stacked dashes for sentences, while the maze still
trusts you to honor its curses, the gallery still listens, and the
clockwork still learns its best tricks from the humans at the table.
Co-Authored-By: Claude Opus 5.5
---
packages/engine/src/game.ts | 56 +--
packages/engine/test/expansion-combat.test.ts | 4 +-
packages/server/src/banter.ts | 16 +-
packages/server/src/clips.ts | 12 +-
packages/server/src/index.ts | 42 +-
packages/server/src/rooms.ts | 12 +-
packages/web/index.html | 6 +-
packages/web/src/App.svelte | 374 +++++++++---------
packages/web/src/Board.svelte | 18 +-
packages/web/src/EdgeGlyph.svelte | 2 +-
packages/web/src/Faq.svelte | 4 +-
packages/web/src/FirewallEdge.svelte | 2 +-
packages/web/src/Help.svelte | 57 +--
packages/web/src/IllusionShimmer.svelte | 4 +-
packages/web/src/Replay.svelte | 4 +-
packages/web/src/SharePage.svelte | 14 +-
packages/web/src/catchup.ts | 2 +-
packages/web/src/fpv/screenplays.ts | 16 +-
packages/web/src/hints.ts | 6 +-
packages/web/src/local.svelte.ts | 2 +-
packages/web/src/net.svelte.ts | 110 +++---
packages/web/src/receipt.ts | 6 +-
packages/web/src/reference.ts | 36 +-
packages/web/src/rulebook.ts | 6 +-
packages/web/src/rules.ts | 18 +-
25 files changed, 415 insertions(+), 414 deletions(-)
diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts
index 59568b5..7b434b8 100644
--- a/packages/engine/src/game.ts
+++ b/packages/engine/src/game.ts
@@ -2350,7 +2350,7 @@ const CARD_EFFECTS: Record
const caster = activePlayer(state);
if (!target) return null;
const d = Math.abs(target.position.x - caster.position.x) + Math.abs(target.position.y - caster.position.y);
- return d <= 1 ? null : "disease spreads by touch — you must be adjacent";
+ return d <= 1 ? null : "disease spreads by touch, so you must be adjacent";
},
sustains: true,
},
@@ -2434,7 +2434,7 @@ const CARD_EFFECTS: Record
validate: (state, _cmd, _caster, target) => {
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
const { d, legs } = charge(state, activePlayer(state), target);
- if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
+ if (d > legs) return "the goat's legs cannot carry the charge that far this turn";
return null;
},
onResolved: (ctx) => {
@@ -2758,11 +2758,11 @@ const CARD_EFFECTS: Record
},
"opportunity-fire": {
kind: "neutral",
- resolve: () => "played out of turn — wait for another player's turn, then use it",
+ resolve: () => "this is played during another player's turn",
},
interrupt: {
kind: "neutral",
- resolve: () => "played out of turn — use it during another player's turn",
+ resolve: () => "this is played during another player's turn",
},
"thumb-of-god": {
kind: "neutral",
@@ -3061,7 +3061,7 @@ function emptySquareTarget(
if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there";
// "You can't create an object on a DIMENSIONAL WARP".
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
- return "the warp shimmers there — nothing can form on it";
+ return "nothing can form where the warp shimmers";
}
if (!castSight(state, caster, cmd, cell)) return "no line of sight";
return cell;
@@ -3417,7 +3417,7 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
if (creature.justCreated) return err("it cannot attack the turn it was created");
if (creature.attackUsed) return err("that creature has already attacked this turn");
if (creature.kind !== "troll" && creature.kind !== "skeleton" && creature.kind !== "shadow") {
- return err("that creature attacks on its own, not on command");
+ return err("that creature picks its own victims and takes no orders to attack");
}
const events: GameEvent[] = [];
@@ -4068,7 +4068,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
if (playerId !== state.stack.waitingOn) return err("waiting for another player's response");
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params, command.numberInstanceIds);
if (command.type === "pass") return doPass(state, playerId);
- return err("an attack is being resolved — counteract or pass");
+ return err("an attack is being resolved, so counteract or pass");
}
if (state.chaosPending) {
@@ -4095,7 +4095,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
finishChaosIfReady(st, events);
return { ok: true, state: st, events };
}
- return err("chaos is coming — shield your hand or pass");
+ return err("chaos is coming, so shield your hand or pass");
}
// INTERRUPT / OPPORTUNITY FIRE: an out-of-turn action window.
@@ -4131,7 +4131,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
if (!fx || fx.kind !== "attack") return err("opportunity fire permits an attack");
}
if (kind === "interrupt" && command.type === "punch") {
- return err("interrupt lets you cast a spell, not brawl");
+ return err("an interruption allows a spell but not a punch");
}
st.outOfTurnWindow = null;
const result = command.type === "cast" ? doCast(st, command) : doPunch(st, command.targetId);
@@ -4362,7 +4362,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
const shimmer = state.illusionWalls[key];
if (shimmer && shimmer.createdBy !== p.id && !shimmer.belief[p.id]) {
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
- return err("the wall shimmers oddly — test your eyes before you can pass");
+ return err("the wall shimmers oddly, and you must test your eyes before you can pass");
}
if (shimmer && illusionBelief(state, p.id, key) === "believes") {
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
@@ -4517,7 +4517,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
}
landing = canShoveTo(exits.includes(direction) ? direction : exits[0]!);
} else {
- if (!exits.includes(chosen)) { p.position = from; return err(`no way to push them ${chosen} — the corridor leads ${exits.join(" or ")}`); }
+ if (!exits.includes(chosen)) { p.position = from; return err(`no way to push them ${chosen}, because the corridor leads ${exits.join(" or ")}`); }
landing = canShoveTo(chosen);
}
} else {
@@ -4568,16 +4568,16 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
if (exits.length === 0) {
p.position = from;
- return err("the pit cannot be crossed from here — nothing to land on beside it");
+ return err("the pit cannot be crossed from here, with nothing to land on beside it");
}
if (exit !== undefined && !exits.includes(exit)) {
p.position = from;
- return err(`no footing that way — the pit's rim leads ${exits.join(" or ")}`);
+ return err(`no footing that way, because the pit's rim leads ${exits.join(" or ")}`);
}
landing = exit ?? (bare.length === 1 ? bare[0]! : null);
if (landing === null) {
p.position = from;
- return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`);
+ return err(`the rim leads ${exits.join(" or ")}, so click the square to land on`);
}
const foot = stepTarget(view, pitCell, landing) as { kind: "step" | "warp"; to: Cell };
landingCell = foot.to;
@@ -4762,7 +4762,7 @@ function doPlayNumberForMovement(prev: GameState, instanceId: string, addInstanc
if (blocked) return err(blocked);
const mover = activePlayer(prev);
if (sustainedOn(prev, mover.id, "slow").length > 0) {
- return err("you are slowed — no number cards for movement");
+ return err("a slowed wizard cannot play number cards for movement");
}
// "You may add two NUMBER cards together for any single action" — an ADD
// permits one extra movement number this turn.
@@ -4815,7 +4815,7 @@ function attackPreconditions(state: GameState): string | null {
}
return "you may attack only once per turn";
}
- if (state.turn.attackForbidden) return "you are slowed — no attack this turn";
+ if (state.turn.attackForbidden) return "a slowed wizard cannot attack this turn";
return null;
}
@@ -4834,7 +4834,7 @@ function castingBlocked(
opts?: { counteraction?: boolean; cardId?: string; targetPlayerId?: PlayerId; pickedCardId?: string },
): string | null {
if (sustainedOn(state, playerId, "medusa").length > 0) return "you are paralyzed by Medusa";
- if (sustainedOn(state, playerId, "no-spell").length > 0) return "No Spell — you cannot cast";
+ if (sustainedOn(state, playerId, "no-spell").length > 0) return "No Spell holds you, and you cannot cast";
// IDIOT: "He can do nothing else but cast COUNTERACTION spells (if
// needed)" — and per the FAQ, spells that aid the march to the treasure.
if (sustainedOn(state, playerId, "idiot").length > 0 &&
@@ -4887,17 +4887,17 @@ function liftIdiotIfSatisfied(state: GameState, events: GameEvent[], p: PlayerSt
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
if (sustainedOn(state, target.id, "big-man").length > 0 &&
cellKey(attacker.position) === cellKey(target.position)) {
- return "he fills the corridor — there is no room to swing";
+ return "he fills the corridor and leaves no room to swing";
}
if (inThornbush(state, attacker)) return "you cannot attack from inside a thornbush";
if (inThornbush(state, target)) return "you cannot attack someone in a thornbush";
- if (isMisted(state, attacker.id)) return "you are mist — you may not attack";
+ if (isMisted(state, attacker.id)) return "as mist, you may not attack";
if (isMisted(state, target.id)) return "your target is mist and cannot be attacked";
// BUDDY: "Opponent will not attack you unless you attack first."
const buddy = state.sustained.find(
(s) => s.cardId === "buddy" && s.casterId === target.id && s.targetId === attacker.id,
);
- if (buddy) return "the Buddy pact holds — you cannot bring yourself to attack them";
+ if (buddy) return "the Buddy pact holds, and you cannot bring yourself to attack them";
return null;
}
@@ -4986,7 +4986,7 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
/** Compat stub for stale clients: the Ward has no arming step. */
function doArmWard(): CommandResult {
- return err("the Ward is played in the moment — you will be asked when your treasure is grabbed");
+ return err("the Ward is played in the moment, and you will be asked when your treasure is grabbed");
}
/** Player ids in seat order, starting after `fromId`. */
@@ -5032,7 +5032,7 @@ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult
const key = edgeKey(cell, side);
const wall = state.illusionWalls[key];
if (!wall) return err("there is no illusion there");
- if (wall.createdBy === p.id) return err("you made it — you know exactly what it is");
+ if (wall.createdBy === p.id) return err("you made it, so you know exactly what it is");
if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
const events: GameEvent[] = [];
const board = doorsAjar(state, p.id, openedDoors(state, perceivedBoard(state, p.id)));
@@ -5368,13 +5368,13 @@ function doCast(prev: GameState, cmd: Extract): Comma
const ego = state.creatures.find((c) => c.id === cmd.via);
if (!ego || ego.kind !== "alter-ego" || ego.controllerId !== caster.id) return err("that is not your double");
if (!cmd.target) return err("your double casts only at a target");
- if (inHand.cardId === "butt-head" || inHand.cardId === "swap") return err(`${def.name} moves your own body — not your double's to cast`);
+ if (inHand.cardId === "butt-head" || inHand.cardId === "swap") return err(`${def.name} moves your own body, so your double cannot cast it`);
origin = ego.position;
}
const effect = CARD_EFFECTS[inHand.cardId];
if (!effect) return err(`${def.name} is not implemented yet`);
if (effect.kind === "counter") {
- return err(`${def.name} is a counteraction — play it in response to an attack`);
+ return err(`${def.name} is a counteraction, played in response to an attack`);
}
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
return err(`${def.name} is already displayed`);
@@ -5914,7 +5914,7 @@ function doSetAmbush(prev: GameState, cmd: Extract {
type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: defender.id },
});
if (!r.ok) {
- if (/legs, not wings/.test(r.error)) refusals += 1;
+ if (/cannot carry the charge that far/.test(r.error)) refusals += 1;
continue;
}
const st = drain(r.state);
@@ -839,7 +839,7 @@ describe("butt-head's charge reaches as far as its legs (rev 16)", () => {
const card = giveCard(state, goat.id, "butt-head");
const r = applyCommand(state, goat.id, { type: "cast", instanceId: card.instanceId, target: { kind: "player", playerId: mark.id } });
expect(r.ok).toBe(ok);
- if (!r.ok) expect(r.error).toMatch(/legs, not wings/);
+ if (!r.ok) expect(r.error).toMatch(/cannot carry the charge that far/);
});
}
});
diff --git a/packages/server/src/banter.ts b/packages/server/src/banter.ts
index 07a797b..ba4bf0b 100644
--- a/packages/server/src/banter.ts
+++ b/packages/server/src/banter.ts
@@ -97,7 +97,7 @@ export const BOT_LINES: RecordWiz-War — clips from the labyrinth`,
+ `Wiz-War clips from the labyrinth`,
``,
``,
- ``,
- ``,
+ ``,
+ ``,
``,
...(clips[0] ? cardImageMetas(clips[0], base) : []),
``,
@@ -115,13 +115,13 @@ export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
Each clip has a first-person take and a board take of the same moves.
${cards}
Wiz-War is Tom Jolly's classic of magical combat.
- This is a fan-built table — deal yourself in.
`;
+ This is a fan-built table, and you can deal yourself in.`;
return shell(metas, body);
}
export function clipPageHtml(clip: ClipMeta, base: string): string {
const metas = [
- `${esc(clip.title)} — a Wiz-War clip`,
+ `${esc(clip.title)} · Wiz-War clip`,
``,
``,
``,
@@ -152,7 +152,7 @@ export function clipPageHtml(clip: ClipMeta, base: string): string {
-
Played by the real rules engine — every move on screen is a legal move.
+
Played by the real rules engine, so every move on screen is a legal move.
Deal yourself in.
`;
return shell(metas, body);
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 71c72c5..8098580 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -257,7 +257,7 @@ function ogPage(metas: string[]): string {
/** Host and proto arrive from request headers — attacker-writable text
* that must never reach an HTML attribute raw. */
const noSuchClip = (res: ServerResponse) =>
- res.writeHead(404, { "content-type": "text/plain" }).end("no such clip — see /clips");
+ res.writeHead(404, { "content-type": "text/plain" }).end("no such clip (see /clips)");
/** The absolute origin a request came in on, as the proxy saw it. */
function requestBase(req: IncomingMessage): string {
@@ -282,9 +282,9 @@ function headlineOf(data: ShareData): string | null {
for (const step of data.steps) {
for (const e of step.events) {
switch (e.type) {
- case "gameWon": offer(9, e.reason === "treasures" ? `${e.player} wins with two treasures home` : `${e.player} is the last wizard standing`); break;
+ case "gameWon": offer(9, e.reason === "treasures" ? `${e.player} wins with two stolen treasures home` : `${e.player} is the last wizard standing`); break;
case "playerEliminated": offer(8, e.reason === "killed" ? `${e.player} falls in the maze` : `${e.player} loses both treasures`); break;
- case "treasureDropped": if (e.onHomeOf === e.player) offer(7, `${e.player} carries a stolen treasure home`); break;
+ case "treasureDropped": if (e.onHomeOf === e.player) offer(7, `${e.player} carries a treasure home`); break;
case "treasurePickedUp": if (e.owner !== e.player) offer(5, `${e.player} snatches ${e.owner}'s treasure`); break;
case "attackResolved": if (e.damageDealt > 0) offer(6, `${e.attacker} hits ${e.defender} with ${name(e.attackCardId)} for ${e.damageDealt}`); else if (e.fullyStopped) offer(4, `${e.defender} stops ${e.attacker}'s ${name(e.attackCardId)} cold`); break;
case "punched": offer(3, `${e.attacker} punches ${e.target}`); break;
@@ -306,12 +306,12 @@ function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: strin
const base = safeBase(rawHost, rawProto);
const headline = data.whole ? null : headlineOf(data);
const title = data.whole
- ? "The whole tale — a game of Wiz-War, replayed"
+ ? "A whole game of Wiz-War, replayed"
: headline
- ? `${escapeHtml(headline)} — a Wiz-War instant replay`
- : `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
+ ? `${escapeHtml(headline)} · Wiz-War instant replay`
+ : `${escapeHtml(data.actor)}'s turn · Wiz-War instant replay`;
const desc = data.whole
- ? `A full game of Wiz-War, magical combat in a stone labyrinth — every turn through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.`
+ ? `A full game of Wiz-War, magical combat in a stone labyrinth, with every turn seen through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.`
: `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
`Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`;
const metas = [
@@ -336,11 +336,11 @@ function inviteHtml(room: Room, rawHost: string, rawProto: string): string {
const base = safeBase(rawHost, rawProto);
const seats = room.players.length;
const wizards = `${seats} wizard${seats === 1 ? "" : "s"}`;
- const title = `You're summoned — Wiz-War room ${room.id}`;
+ const title = `You're summoned to Wiz-War room ${room.id}`;
const desc = room.state?.phase === "finished"
- ? `The tale is told — ${wizards} fought in this labyrinth. Follow the link to see how it ended.`
+ ? `The tale is told. ${wizards} fought in this labyrinth. Follow the link to see how it ended.`
: room.state
- ? `The duel is underway, ${wizards} in the labyrinth. Follow the link to watch it live from the Peanut Gallery.`
+ ? `The game is underway, with ${wizards} in the labyrinth. Follow the link to watch it live from the Peanut Gallery.`
: `${wizards} at the table, waiting to flip the boards. Follow the link, pick a name, and take a seat.`;
const metas = [
`${title}`,
@@ -727,7 +727,7 @@ function broadcastRoomState(room: Room): void {
wss.on("connection", (socket, req) => {
if (sessions.size >= MAX_SOCKETS) {
- send(socket, { type: "error", message: "the tavern is packed — try again shortly" });
+ send(socket, { type: "error", message: "the tavern is packed; try again shortly" });
socket.close();
return;
}
@@ -773,10 +773,10 @@ wss.on("connection", (socket, req) => {
const name = cleanName(msg.name);
if (!name) return send(socket, { type: "error", message: "name required" });
if (session.roomsCreated >= MAX_ROOMS_PER_CONN || roomCount() >= MAX_ROOMS) {
- return send(socket, { type: "error", message: "no new rooms right now — try again later" });
+ return send(socket, { type: "error", message: "no new rooms are opening right now, so try again later" });
}
if (!roomsPerAddress.allow(session.address)) {
- return send(socket, { type: "error", message: "that's a lot of new tables from one place — try again in an hour" });
+ return send(socket, { type: "error", message: "this address can open more tables in an hour" });
}
session.roomsCreated++;
leaveGallery(session);
@@ -847,7 +847,7 @@ wss.on("connection", (socket, req) => {
const room = getRoom(roomId);
if (!room) return send(socket, { type: "error", message: "no such room" });
if (audienceCount(room) >= MAX_AUDIENCE) {
- return send(socket, { type: "error", message: "the gallery is packed — try again later" });
+ return send(socket, { type: "error", message: "the gallery is full for now; try again later" });
}
leaveGallery(session); // switching galleries updates the old room's count
session.playerId = null;
@@ -1014,7 +1014,7 @@ wss.on("connection", (socket, req) => {
}
case "claimTransfer": {
if (session.claimFails >= 5) {
- return send(socket, { type: "error", message: "too many attempts on this connection — reconnect and mint a fresh phrase" });
+ return send(socket, { type: "error", message: "too many attempts on this connection, so reconnect and mint a fresh phrase" });
}
const result = claimTransferCode(String(msg.code ?? ""));
if ("error" in result) {
@@ -1031,7 +1031,7 @@ wss.on("connection", (socket, req) => {
// A catch-up replays the whole game server-side; one at a time, please.
const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
- return send(socket, { type: "error", message: "catching up already — one moment" });
+ return send(socket, { type: "error", message: "already catching up, so give it a moment" });
}
session.lastCatchUpAt = now;
const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0), msg.full === true);
@@ -1047,7 +1047,7 @@ wss.on("connection", (socket, req) => {
if (!Number.isInteger(turn) || turn < -1) return send(socket, { type: "error", message: "no such turn" });
const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
- return send(socket, { type: "error", message: "catching up already — one moment" });
+ return send(socket, { type: "error", message: "already catching up, so give it a moment" });
}
session.lastCatchUpAt = now;
// turn -1 shares the whole finished game; anything else, one turn.
@@ -1065,7 +1065,7 @@ wss.on("connection", (socket, req) => {
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
- return send(socket, { type: "error", message: "catching up already — one moment" });
+ return send(socket, { type: "error", message: "already catching up, so give it a moment" });
}
session.lastCatchUpAt = now;
const reel = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
@@ -1083,13 +1083,13 @@ wss.on("connection", (socket, req) => {
}
case "feedback": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
- if (!room) return send(socket, { type: "error", message: "join a room first — a report rides its ledger" });
+ if (!room) return send(socket, { type: "error", message: "join a room first, since every report is filed with its game" });
const clean = (raw: unknown) =>
String(raw ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000);
const happened = clean(msg.happened);
if (!happened) return send(socket, { type: "error", message: "say what happened" });
if (!reportsPerAddress.allow(session.address)) {
- return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
+ return send(socket, { type: "error", message: "the desk has plenty from you for now; write again in an hour" });
}
const reportId = randomBytes(4).toString("hex");
appendFeedback({
@@ -1117,7 +1117,7 @@ wss.on("connection", (socket, req) => {
// seat is proven as the ledger proves it, and the report must be
// theirs. Each answer rings the desk's bell.
if (!reportsPerAddress.allow(session.address)) {
- return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
+ return send(socket, { type: "error", message: "the desk has plenty from you for now; write again in an hour" });
}
const seat = (msg.seat ?? {}) as Record;
const roomId = String(seat.roomId ?? "").toUpperCase();
diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts
index 33bdaec..8a33665 100644
--- a/packages/server/src/rooms.ts
+++ b/packages/server/src/rooms.ts
@@ -319,8 +319,8 @@ export function joinRoom(
* once the deal happens, every seat is a player in the game's record. */
export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | null {
if (byId !== room.hostId) return "only the host may clear a seat";
- if (room.state) return "the game has started — seats are settled";
- if (name === room.hostId) return "the host cannot kick themselves — abandon the room instead";
+ if (room.state) return "the game has started and its seats are settled";
+ if (name === room.hostId) return "the host cannot clear their own seat, but can abandon the room";
if (!room.players.includes(name)) return "no such seat";
room.players = room.players.filter((p) => p !== name);
room.tokens.delete(name);
@@ -361,7 +361,7 @@ export function resolveColors(room: Room): number[] {
}
export function pickColor(room: Room, playerId: PlayerId, color: number): string | null {
- if (room.state) return "the game has started — your robes are dyed";
+ if (room.state) return "the game has started and your robes are already dyed";
if (!room.players.includes(playerId)) return "you hold no seat in this room";
if (!Number.isInteger(color) || color < 0 || color > 5) return "no such wizard";
for (const [other, c] of room.colorChoices) {
@@ -373,7 +373,7 @@ export function pickColor(room: Room, playerId: PlayerId, color: number): string
function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRev?: number): { events: GameEvent[] } | { error: string } {
const n = room.players.length;
- if (n < 2 || n > 6) return { error: "supported player counts: 2 to 6" };
+ if (n < 2 || n > 6) return { error: "a table seats 2 to 6 wizards" };
const { state, events } = createGame({
playerIds: room.players,
seed: room.seed,
@@ -764,7 +764,7 @@ export function makeTransferCode(
for (const [code, t] of transfers) {
if (t.expiresAt < now) transfers.delete(code);
}
- if (transfers.size >= 200) return { error: "too many transfers pending — try again in a few minutes" };
+ if (transfers.size >= 200) return { error: "too many seat transfers are waiting; try again in a few minutes" };
let code: string;
do {
code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-");
@@ -788,7 +788,7 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI
failedClaims = 0;
}
if (failedClaims >= FAIL_LIMIT) {
- return { error: "too many failed claims — transfers are cooling off, mint a fresh phrase" };
+ return { error: "too many failed claims have cleared every phrase, so wait a few minutes and mint a fresh one" };
}
const normalized = code.trim().toLowerCase().replace(/\s+/g, "-");
const t = transfers.get(normalized);
diff --git a/packages/web/index.html b/packages/web/index.html
index c01963c..8e634c7 100644
--- a/packages/web/index.html
+++ b/packages/web/index.html
@@ -4,19 +4,19 @@
Wiz-War
-
+
-
+
-
+
diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte
index 93e5f30..41c8752 100644
--- a/packages/web/src/App.svelte
+++ b/packages/web/src/App.svelte
@@ -167,8 +167,8 @@
if (view && me && me.carriedTreasureId && cellKey(me.position) === cellKey(me.home)) {
const t = view.treasures.find((t) => t.id === me!.carriedTreasureId);
reasons.push(t && t.owner !== view.you
- ? `you stand on your home with ${t.owner}'s treasure in your arms — drop it first and it scores`
- : "you stand on your home with your own treasure in your arms — drop it first and it is safe");
+ ? `you stand on your home with ${t.owner}'s treasure in your arms, and dropping it first scores it`
+ : "you stand on your home with your own treasure in your arms, and dropping it first keeps it safe");
}
if (view && me) {
const misted = view.sustained.some((e) => e.cardId === "mist-body" && e.targetId === view.you);
@@ -180,8 +180,8 @@
const bite: Record = {
thornbush: "the thorns end your turn, cost you the NEXT one, and prick for 1",
tacks: "the tacks bite for 3",
- pit: "the pit: roll a 1 and you fall in, hurt and stuck",
- ooze: "the ooze burns 1 — and may drop you flat, treasure and all",
+ pit: "the pit takes you on a roll of 1, hurt and stuck",
+ ooze: "the ooze burns 1 and may drop you flat, treasure and all",
};
if (bite[ground]) reasons.push(bite[ground]);
}
@@ -205,7 +205,7 @@
if (e.type === "handRevealedPrivate" && e.visibleTo === view.you) {
cardReveal = {
title: `${e.player}'s mind, laid bare`,
- note: "Commit them to memory — the glimpse does not linger.",
+ note: "Commit them to memory, for the glimpse does not linger.",
cards: e.cards,
};
}
@@ -213,7 +213,7 @@
const pub = events.find((s) => s.type === "cardsStolen" && s.to === view!.you);
cardReveal = {
title: pub?.type === "cardsStolen" ? `Plucked from ${pub.from}'s thoughts` : "Plucked from their thoughts",
- note: "Yours now — already in your hand.",
+ note: "Yours now, already in your hand.",
cards: e.cards,
};
}
@@ -221,7 +221,7 @@
const pub = events.find((s) => s.type === "handTaken" && s.to === view!.you);
cardReveal = {
title: pub?.type === "handTaken" ? `The spoils of ${pub.from}` : "The spoils",
- note: "The fallen wizard's hand — already in yours.",
+ note: "The fallen wizard's hand, already in yours.",
cards: e.cards,
};
}
@@ -293,7 +293,7 @@
hunter: "A hunter goes for the treasure and carries it home.",
berserker: "A berserker goes for blood, and worries about treasure later.",
worrier: "A worrier keeps to the shadows and guards what is theirs.",
- mystery: "Mystery deals one of the three at random; you learn which when the game ends.",
+ mystery: "Mystery deals one of the three at random, and you learn which when the game ends.",
} as const;
/** Card whose official FAQ rulings are open. */
let faqCardId = $state(null);
@@ -522,20 +522,20 @@
const WAND_IDS = new Set(["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"]);
/** What the edge click actually is, per card — "wall line" fits few. */
const EDGE_HINTS: Record = {
- "create-wall": "click an open corridor line to wall it",
- "destroy-wall": "click a wall or door to destroy it",
- "illusion-wall": "click an open corridor line for the false wall",
- "wall-of-fire": "click a corridor line for the fire",
- "waterwall": "click a corridor line, or a slime to wash it away — the wave collapses at once",
- "pick-lock": "click a locked door",
- "master-key": "click a locked door — or Display it once and walk through every lock",
- "jam-lock": "click a door to jam its lock solid",
- "remove-lock": "click a door to strip its lock for good",
- "create-door": "click a wall for the new door",
- "warp-wand": "click a wall to warp it open",
- "stone-to-water": "click a stone wall or door, or a stone-filled square",
- "thumb-of-god": "aim the die at a square in sight — it may drift on impact",
- "dispel-creation": "click the creation — a square, a creature, or a conjured wall",
+ "create-wall": "Click an open corridor line to wall it.",
+ "destroy-wall": "Click a wall or door to destroy it.",
+ "illusion-wall": "Click an open corridor line for the false wall.",
+ "wall-of-fire": "Click a corridor line for the fire.",
+ "waterwall": "Click a corridor line, or a slime to wash it away. The wave collapses at once.",
+ "pick-lock": "Click a locked door.",
+ "master-key": "Click a locked door, or Display it once and walk through every lock.",
+ "jam-lock": "Click a door to jam its lock solid.",
+ "remove-lock": "Click a door to strip its lock for good.",
+ "create-door": "Click a wall for the new door.",
+ "warp-wand": "Click a wall to warp it open.",
+ "stone-to-water": "Click a stone wall or door, or a stone-filled square.",
+ "thumb-of-god": "Aim the die at a square in sight. It may drift on impact.",
+ "dispel-creation": "Click the creation (a square, a creature, or a conjured wall).",
};
const EDGE_CARDS = new Set([
"create-wall", "destroy-wall", "illusion-wall", "wall-of-fire", "waterwall",
@@ -1082,8 +1082,8 @@
if (selectedCard) {
const name = cardDef(selectedCard.cardId).name;
net.flash(EDGE_CARDS.has(selectedCard.cardId)
- ? `${name} is aimed at a line between squares: ${EDGE_HINTS[selectedCard.cardId] ?? "tap a lit line"}`
- : `${name} is aimed by tapping a wizard, a creature, a bush, or a slime — put the card down to walk`);
+ ? `${name} is aimed at a line between squares. ${EDGE_HINTS[selectedCard.cardId] ?? "Tap a lit line."}`
+ : `${name} is aimed by tapping a wizard, a creature, a bush, or a slime. Put the card down to walk.`);
return;
}
// A cell click is a move if the cell is one legal step away (the server
@@ -1373,14 +1373,14 @@
const room = limit - view.yourHand.length;
if (drawCount > room) {
reasons.push(room <= 0
- ? "your hand is full — the draw will bring nothing"
+ ? "your hand is full, so the draw will bring nothing"
: `your hand only has room for ${room} of the ${drawCount}-card draw`);
}
if (me.carriedTreasureId && cellKey(me.position) === cellKey(me.home)) {
const t = view.treasures.find((t) => t.id === me!.carriedTreasureId);
reasons.push(t && t.owner !== view.you
- ? `${t.owner}'s treasure is still in your arms — drop it here and it scores`
- : "your own treasure is still in your arms — drop it here and it is safe");
+ ? `${t.owner}'s treasure is still in your arms, and dropping it here scores it`
+ : "your own treasure is still in your arms, and dropping it here keeps it safe");
}
// Treasure underfoot un-grabbed — unless the auto-grab preference (or
// this caution's own "do it anyway") is about to handle it, or the
@@ -1399,7 +1399,7 @@
// Auto-grab about to pocket your OWN chest: carrying it does not
// score and bares it to capture, so that walk-off asks first.
if (autoWillGrab && !grabRefused && treasuresHere[0]!.owner === view.you) {
- reasons.push("your OWN treasure lies underfoot — ending the turn grabs it and carries it along");
+ reasons.push("your OWN treasure lies underfoot, and ending the turn grabs it and carries it along");
}
// The draw picker is sticky: a 0 or 1 chosen turns ago quietly starves.
const drawRoom = Math.min(2, room);
@@ -1494,22 +1494,22 @@
/** What each ongoing spell does, in a sentence. */
const EFFECT_GLOSS: Record = {
slow: "Moves one square a turn, no numbers or speed spells, and attacks only every other turn.",
- "no-spell": "Cannot cast any spell; a magic stone's power still works, and cards may be discarded.",
- medusa: "Cannot move or cast, counteractions included — and takes no damage from anything.",
- "lock-in-place": "Cannot move or be moved in any way, teleportation included; spells may still be cast.",
- blind: "Movement, thrown objects, attacks, and spells go in a direction the die decides; self-cast spells work.",
+ "no-spell": "Cannot cast any spell. A magic stone's power still works, and cards may be discarded.",
+ medusa: "Cannot move or cast, counteractions included, and takes no damage from anything.",
+ "lock-in-place": "Cannot move or be moved in any way, teleportation included, but spells may still be cast.",
+ blind: "Movement, thrown objects, attacks, and spells go in a direction the die decides, though self-cast spells work.",
invisible: "An attacker must roll the die for a random direction to see whether the blow finds them.",
shrink: "Half as likely to be hit by any attack, and walks only two squares a turn.",
adrenaline: "May make two attacks in one turn.",
- "big-man": "Fills the corridor: nobody moves, casts, or punches past them, or enters their square; they can push what stands in their way.",
- "mist-body": "Passes through anything but stone; neither attacks nor can be attacked; walls of fire still burn.",
- disease: "Carries the plague: sharing a square with another wizard costs each of them three points.",
+ "big-man": "Fills the corridor, so nobody moves, casts, or punches past them, or enters their square. They can push what stands in their way.",
+ "mist-body": "Passes through anything but stone, and neither attacks nor can be attacked. Walls of fire still burn them.",
+ disease: "Carries the plague, so sharing a square with another wizard costs each of them three points.",
empathy: "Any attack against them lands on the attacker as well.",
- fear: "Nobody comes within three squares, walls or no walls; those in range must move away.",
+ fear: "Nobody comes within three squares, walls or no walls, and those in range must move away.",
strength: "Physical damage they deal is doubled, and they may tear a treasure from a wizard sharing their square.",
buddy: "Will not attack the wizard who cast it, until attacked first.",
lifesaver: "Not eliminated for losing both treasures to enemy homes.",
- "force-field": "Stops the spell it answered, then stands: the opponent may not enter this square, nor cast on or past this wizard, on any side.",
+ "force-field": "Stops the spell it answered, then stands. The opponent may not enter this square, nor cast on or past this wizard, on any side.",
"around-the-corner": "May cast line-of-sight spells around one corner.",
};
function effectNote(e: Sustained): string {
@@ -1518,7 +1518,7 @@
const ends =
e.cardId === "force-field" ? "Stands until the end of the opponent's turn."
: e.cardId === "buddy" ? "Holds until the pact is broken by an attack."
- : isPermanentDuration(e.remainingTurns) ? "Permanent — until dispelled, or the game ends."
+ : isPermanentDuration(e.remainingTurns) ? "Permanent, until dispelled or the game ends."
: e.remainingTurns === 1 ? `Ends at the start of ${e.casterId}'s next turn.`
: `Ends at the start of ${e.casterId}'s turn, ${e.remainingTurns} of their turns from now.`;
return `${gloss} ${who} ${ends}`;
@@ -1557,7 +1557,7 @@
const lost = net.chatUnsent;
if (!lost) return;
if (!chatDraft.trim()) chatDraft = lost;
- net.flash("Your message was not sent — it is back in the box");
+ net.flash("Your message was not sent. It is back in the box.");
net.chatUnsent = null;
});
@@ -1578,7 +1578,7 @@
function pickImage(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0] ?? null;
if (fbPreview) URL.revokeObjectURL(fbPreview);
- if (file && file.size > 2_500_000) { net.flash("That picture is over 2.5 MB — a smaller one, please"); fbImage = null; fbPreview = null; return; }
+ if (file && file.size > 2_500_000) { net.flash("That picture is over 2.5 MB. A smaller one, please."); fbImage = null; fbPreview = null; return; }
fbImage = file;
fbPreview = file ? URL.createObjectURL(file) : null;
}
@@ -1896,7 +1896,7 @@
const FAVICON_IDLE = "/favicon.png";
const FAVICON_TURN = "/favicon-turn.png";
$effect(() => {
- document.title = anyTurnWaiting ? "● Your turn — Wiz-War" : "Wiz-War";
+ document.title = anyTurnWaiting ? "● Your turn · Wiz-War" : "Wiz-War";
let link = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
if (!link) {
link = document.createElement("link");
@@ -1926,7 +1926,7 @@
const t = chatDraft.trim();
if (!t) return;
if (net.sendChat(t)) chatDraft = "";
- else net.flash("Not sent — the table is out of reach; your words wait in the box");
+ else net.flash("Not sent. The table is out of reach, and your words wait in the box.");
}}>
{#if net.chatPending}sending…{/if}
{#if net.chatPending}sending…{/if}
@@ -1990,13 +1990,13 @@
{:else if net.roomId}
-
{#if net.you === net.hostId && net.roomId && (net.audience > 0 || net.galleryTalk)}
{:else if net.audience > 0}
👁 {net.audience}{net.galleryTalk ? " 💬" : ""}
@@ -2005,7 +2005,7 @@
{/if}
-
{/if}