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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-10-11 11:29:07 -04:00
co-authored by Claude Opus 5.5
parent ad23c83b8a
commit c64ff70ed4
25 changed files with 415 additions and 414 deletions
+8 -8
View File
@@ -97,7 +97,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
"NEW PREMISES. SAME AMBITIONS.",
"PLEASE UPDATE YOUR ADDRESS BOOK.",
"COMMUTING IS FOR PEOPLE WITHOUT SPELLS.",
"A CHANGE OF ADDRESS, NOT OF OWNERSHIP.",
"MY OFFICES REMAIN OPEN AT THE NEW ADDRESS.",
],
springTrap: [
"AN UNFORESEEN LIABILITY.",
@@ -116,7 +116,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
"YOU MAY WISH TO REVISE YOUR PROJECTION.",
],
win: [
"PLEASURE DOING BUSINESS WITH ALL OF YOU.",
"PLEASURE DOING BUSINESS WITH THIS TABLE.",
"THE BOOKS BALANCE. IN MY FAVOR.",
"A SATISFACTORY RETURN ON WIZARDRY.",
"THANK YOU FOR YOUR CONTRIBUTIONS.",
@@ -147,7 +147,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
],
dealPain: [
"YES. AGAIN.",
"PAIN RECEIPT PRINTED.",
"DID THAT STING? GOOD.",
"HOLD STILL. THIS IS THE FUN PART.",
"THE MAZE PROVIDES.",
"THAT IS THE SOUND I CAME FOR.",
@@ -166,7 +166,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
"I LIKE YOUR ATTITUDE. KEEP IT WITHIN REACH.",
],
kill: [
"SCHEDULED DEMISE: DELIVERED.",
"THAT ONE WILL NOT BE GETTING UP.",
"NEXT.",
"THE PILE GROWS.",
"THE MAZE ECHOES BETTER NOW.",
@@ -198,7 +198,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
"A LITTLE SCENERY FOR OUR NEXT ARGUMENT.",
],
escape: [
"A TACTICAL LEAP. NOT A RETREAT.",
"IT WAS A TACTICAL LEAP. WRITE THAT DOWN.",
"A BETTER ANGLE FOR THE NEXT BLOW.",
"I GO WHERE THE TROUBLE NEEDS ME.",
"SAME FEUD. DIFFERENT SQUARE.",
@@ -222,7 +222,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
"ALL THAT FUSS. NO BRUISE.",
],
win: [
"LAST ONE STANDING. AS FORETOLD.",
"VICTORY. AS FORETOLD.",
"A FINE MAZE. LET US DO THAT AGAIN.",
"I WAS JUST GETTING COMFORTABLE.",
"VICTORY! WHO WANTS A REMATCH?",
@@ -242,7 +242,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
"this is going to make me conspicuous.",
],
deliverGold: [
"home. safe. breathe.",
"home. safe. i can breathe again.",
"there. i would like to stop carrying problems now.",
"stay. please be good at staying.",
"down you go. quietly.",
@@ -252,7 +252,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
"good. now i can wring both hands.",
],
dealPain: [
"ow. logged.",
"oh! sorry. was that too hard?",
"unnecessary!",
"that looked like it hurt. it was supposed to. i think.",
"could we consider that a warning and stop?",
+6 -6
View File
@@ -90,11 +90,11 @@ function cardImageMetas(clip: ClipMeta, base: string): string[] {
export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
const metas = [
`<title>Wiz-War — clips from the labyrinth</title>`,
`<title>Wiz-War clips from the labyrinth</title>`,
`<meta property="og:type" content="website"/>`,
`<meta property="og:site_name" content="Wiz-War"/>`,
`<meta property="og:title" content="Wiz-War — clips from the labyrinth"/>`,
`<meta property="og:description" content="Short scenes of magical combat in a stone maze: wormhole heists, warp snipers, ambushes, and the walls coming down — each seen through the wizard's own eyes and from the board above."/>`,
`<meta property="og:title" content="Wiz-War clips from the labyrinth"/>`,
`<meta property="og:description" content="Short scenes of magical combat in a stone maze, from wormhole heists and warp snipers to ambushes and falling walls, each seen through the wizard's own eyes and from the board above."/>`,
`<meta property="og:url" content="${base}/clips"/>`,
...(clips[0] ? cardImageMetas(clips[0], base) : []),
`<meta name="twitter:card" content="summary_large_image"/>`,
@@ -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.</p>
<div class="grid">${cards}</div>
<p class="foot">Wiz-War is Tom Jolly's classic of magical combat.
This is a fan-built table — <a href="/">deal yourself in</a>.</p>`;
This is a fan-built table, and you can <a href="/">deal yourself in</a>.</p>`;
return shell(metas, body);
}
export function clipPageHtml(clip: ClipMeta, base: string): string {
const metas = [
`<title>${esc(clip.title)} — a Wiz-War clip</title>`,
`<title>${esc(clip.title)} · Wiz-War clip</title>`,
`<meta property="og:type" content="video.other"/>`,
`<meta property="og:site_name" content="Wiz-War"/>`,
`<meta property="og:title" content="${esc(clip.title)}"/>`,
@@ -152,7 +152,7 @@ export function clipPageHtml(clip: ClipMeta, base: string): string {
<video src="/clips/${clip.name}-board.mp4" controls playsinline preload="metadata"></video>
</div>
</div>
<p class="foot">Played by the real rules engine — every move on screen is a legal move.
<p class="foot">Played by the real rules engine, so every move on screen is a legal move.
<a href="/">Deal yourself in</a>.</p>
<script>${SHARE_JS}</script>`;
return shell(metas, body);
+21 -21
View File
@@ -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>${title}</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<string, unknown>;
const roomId = String(seat.roomId ?? "").toUpperCase();
+6 -6
View File
@@ -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);