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
+28 -28
View File
@@ -2350,7 +2350,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, AttackEffect | NeutralEffect | CounterEffect>
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<string, AttackEffect | NeutralEffect | CounterEffect>
},
"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<Command, { type: "cast" }>): 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<Command, { type: "setAmbush"
if (!spell) return err("the attack to commit is not in your hand");
const fx = CARD_EFFECTS[spell.cardId];
if (!fx || fx.kind !== "attack") return err("commit an attack spell to the ambush");
if (fx.sameSquare) return err("that attack needs to share a square — no good from ambush");
if (fx.sameSquare) return err("that attack needs a shared square, so it cannot be sprung from ambush");
const numbers: CardInstance[] = [];
for (const id of cmd.numberInstanceIds ?? []) {
@@ -5932,7 +5932,7 @@ function doSetAmbush(prev: GameState, cmd: Extract<Command, { type: "setAmbush"
if (state.squareContents[cellKey(cmd.cell)]?.kind === "stone") return err("that destination is solid stone");
}
if (!cmd.trigger || !["los", "near", "treasure"].includes(cmd.trigger.kind)) {
return err("choose a trigger: line of sight, close approach, or treasure");
return err("choose a trigger (line of sight, close approach, or treasure)");
}
// Commit the cards out of the hand; they return if the ambush is cancelled.
@@ -6223,7 +6223,7 @@ function doCounteract(
// duration-based spells, reducing effects by [its] value."
if (isNumberCard(card.cardId)) {
if (!displays(player, "shieldstone")) return err("only a displayed Shieldstone lets you counter with number cards");
if (stack.kind !== "spell") return err("shieldstone counters spells, not physical attacks");
if (stack.kind !== "spell") return err("shieldstone cannot counter a physical attack");
takeFromHand(player, instanceId);
state.discard.push(card);
stack.counters.push({ player: playerId, card, nullified: false });
@@ -6269,7 +6269,7 @@ function doCounteract(
if (!targetCounter) return err("no counteraction to absorb");
const id = targetCounter.card.cardId;
if (id !== "reflection" && id !== "full-reflection") {
return err(`${cardDef(id).name} is not cast at you — it cannot be absorbed`);
return err(`${cardDef(id).name} is not cast at you, so it cannot be absorbed`);
}
takeFromHand(player, instanceId);
state.discard.push(card);
@@ -799,7 +799,7 @@ describe("the goat charges on legs, not wings (rev 10)", () => {
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/);
});
}
});
+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);
+3 -3
View File
@@ -4,19 +4,19 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wiz-War</title>
<meta name="description" content="A game of magical combat in a stone labyrinth. The classic 1993 sixth edition, faithfully reproduced — play free with friends, online or around one device." />
<meta name="description" content="A game of magical combat in a stone labyrinth. The classic 1993 sixth edition, faithfully reproduced. Play free online with friends, or pass one device around." />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Wiz-War" />
<meta property="og:title" content="Wiz-War" />
<meta property="og:description" content="A game of magical combat in a stone labyrinth. The classic 1993 sixth edition, faithfully reproduced — play free with friends, online or around one device." />
<meta property="og:description" content="A game of magical combat in a stone labyrinth. The classic 1993 sixth edition, faithfully reproduced. Play free online with friends, or pass one device around." />
<meta property="og:image" content="https://wizwar.kestrelsnest.social/og.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Wiz-War" />
<meta name="twitter:description" content="A game of magical combat in a stone labyrinth. The classic 1993 sixth edition, faithfully reproduced — play free with friends." />
<meta name="twitter:description" content="A game of magical combat in a stone labyrinth. The classic 1993 sixth edition, faithfully reproduced. Play free online with friends." />
<meta name="twitter:image" content="https://wizwar.kestrelsnest.social/og.png" />
<meta name="theme-color" content="#efe8d4" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -457,10 +457,10 @@
{#if e.state === "firewall"}
<FirewallEdge x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"} onpeek={onEdgePeek} />
{:else}
{@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
: e.lock === "jammed" ? "lock jammed — sealed for good"
: e.lock === "held" ? "held open by a standing wizard"
: e.lock === "ajar" ? "unlocked until end of turn" : null}
{@const lockTitle = e.lock === "removed" ? "a door with its lock removed, swinging free"
: e.lock === "jammed" ? "a door with its lock jammed, sealed for good"
: e.lock === "held" ? "a door held open by a standing wizard"
: e.lock === "ajar" ? "a door unlocked until the end of the 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 LockState | null}
@@ -479,7 +479,7 @@
? edgeScar((wx + 1) * CELL, wy * CELL + 3, (wx + 1) * CELL, (wy + 1) * CELL - 3, frac)
: edgeScar(wx * CELL + 3, (wy + 1) * CELL, (wx + 1) * CELL - 3, (wy + 1) * CELL, frac)}
<path d={scar} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
<path d={scar} class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
<path d={scar} class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred, {dmg} damage taken</title></path>
{/each}
<!-- illusions YOU know are fake: ghostly dashed lines -->
@@ -488,9 +488,9 @@
{@const ix = Number(key.split(":")[1]?.split(",")[0])}
{@const iy = Number(key.split(":")[1]?.split(",")[1])}
{#if kind === "V"}
<path d={edgeWisps((ix+1)*CELL, iy*CELL, (ix+1)*CELL, (iy+1)*CELL)} class="illusion"><title>an illusion — your eyes see through it</title></path>
<path d={edgeWisps((ix+1)*CELL, iy*CELL, (ix+1)*CELL, (iy+1)*CELL)} class="illusion"><title>an illusion your eyes see through</title></path>
{:else}
<path d={edgeWisps(ix*CELL, (iy+1)*CELL, (ix+1)*CELL, (iy+1)*CELL)} class="illusion"><title>an illusion — your eyes see through it</title></path>
<path d={edgeWisps(ix*CELL, (iy+1)*CELL, (ix+1)*CELL, (iy+1)*CELL)} class="illusion"><title>an illusion your eyes see through</title></path>
{/if}
{/each}
@@ -649,7 +649,7 @@
x={-CELL * 0.26} y={-CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
cls={`token-art hit${c.id === selectedCreatureId ? " selected-art" : ""}`}
title={`${c.kind} (${c.controllerId}) — ${c.damage}/${Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg`}
title={`${c.kind} (${c.controllerId}), ${c.damage}/${Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg`}
/>
<rect
x={-CELL * 0.26} y={-CELL * 0.26}
@@ -666,7 +666,7 @@
class:selected={c.id === selectedCreatureId}
stroke={playerColor(c.controllerId)}
>
<title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
<title>{c.kind} ({c.controllerId}), {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</rect>
<text x="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
{/if}
+1 -1
View File
@@ -27,7 +27,7 @@
const hp = $derived(damage > 0
? `${Math.max(0, needed - damage)} of ${needed} points left`
: `${needed} points to destroy`);
const tip = $derived(`${title ?? (state === "door" ? "a locked door" : "a wall")} — ${hp}`);
const tip = $derived(`${title ?? (state === "door" ? "a locked door" : "a wall")} (${hp})`);
const { press, release } = holdToPeek(() => onpeek?.(tip));
</script>
+2 -2
View File
@@ -22,7 +22,7 @@
onkeydown={() => {}}
>
<header class="faq-head">
<span class="faq-title">{def.name} — Official FAQ</span>
<span class="faq-title">Official FAQ on {def.name}</span>
<button class="close" onclick={onclose} aria-label="close">×</button>
</header>
<div class="faq-body">
@@ -31,7 +31,7 @@
{/each}
<p class="colophon">
From Tom Jolly's official Wiz-War Rules FAQ (wizwar.com, September 2002).
Rulings occasionally reference other editions; the cards overrule the rules,
Rulings occasionally reference other editions. The cards overrule the rules,
and the designer overrules the table.
</p>
</div>
+1 -1
View File
@@ -8,7 +8,7 @@
/** 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";
const TIP = "a wall of fire that burns for 4 as you pass through";
const { press, release } = holdToPeek(() => onpeek?.(TIP));
const CELL = 48;
const WALL = 7;
+29 -28
View File
@@ -164,7 +164,7 @@
{typeLabel(entryDef)} · {sightOf(entryDef)} · {setLabel(entryDef)}
</p>
<h3>As printed</h3>
<p class="printed">{entryDef.text ?? (entryDef.cardType === "number" ? `A NUMBER card worth ${entryDef.value}: add it to your movement, or set a spell's duration or power.` : "")}</p>
<p class="printed">{entryDef.text ?? (entryDef.cardType === "number" ? `A NUMBER card worth ${entryDef.value}. Add it to your movement, or use it to set a spell's duration or power.` : "")}</p>
{#if entryDef.faqRulings.length > 0}
<h3>Official FAQ</h3>
{#each entryDef.faqRulings as q, i (i)}<p>{q}</p>{/each}
@@ -198,7 +198,7 @@
<span class="booklet-title">Wiz-War</span>
<nav class="tabs" aria-label="help sections">
<button class:current={tab === "play"} onclick={() => goTab("play")}>How to play</button>
<button class:current={tab === "rules"} onclick={() => goTab("rules")}>The rules</button>
<button class:current={tab === "rules"} onclick={() => goTab("rules")}>Rules</button>
<button class:current={tab === "cards"} onclick={() => goTab("cards")}>Card library</button>
<button class:current={tab === "rulings"} onclick={() => goTab("rulings")}>House rulings</button>
<button class:current={tab === "about"} onclick={() => goTab("about")}>About</button>
@@ -220,7 +220,7 @@
</div>
<h3>The ledgers in detail</h3>
<dl class="tally-list">
<dt>{n(stats.gamesCreated)}</dt><dd>games chronicled — {n(stats.gamesStarted)} begun</dd>
<dt>{n(stats.gamesCreated)}</dt><dd>games chronicled, {n(stats.gamesStarted)} of them begun</dd>
<dt>{n(stats.wizardsSeated)}</dt><dd>distinct wizard names have taken a seat</dd>
<dt>{n(stats.commandsPlayed)}</dt><dd>spells, steps, and punches recorded</dd>
<dt>{n(stats.longestGameCommands)}</dt><dd>actions in the longest game yet played</dd>
@@ -230,9 +230,9 @@
</dl>
<p class="colophon">
{#if stats.firstGameAt}The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}.{/if}
Wizards are counted by name, not by person: one player under two names is two wizards here.
Wizards are counted by name, so one player under two names counts as two wizards.
Table time is estimated from the clock between moves, so a game left open overnight counts only the minutes it was actually played.
Games on one device send only their counts — names and moves stay on the device.
Games on one device send only their counts, and their names and moves stay on the device.
</p>
{:else}
<p>Counting the ledgers…</p>
@@ -243,15 +243,15 @@
<h3>Why it exists</h3>
<p>
In the early nineties, a group of friends played this exact edition
for countless hours — the slammed doors, the ambushes around
corners, the goat charges, the laughter. That box is long out of
for countless hours of slammed doors, ambushes around corners,
goat charges and laughter. That box is long out of
print, and its cardboard is worn soft at the edges. This digital
table was built from that very copy: every card was photographed and
transcribed word for word, every board layout verified wall by wall,
and the tokens on this screen are the actual hand-inked art from
those counter sheets. It is a faithful reproduction of the game as
it was played at that table, made so the same friends — and their
friends — can keep playing it.
it was played at that table, made so the same friends, and their
friends, can keep playing it.
</p>
<h3>What this is</h3>
<p>
@@ -259,16 +259,16 @@
wizards prowl a maze, hurling fireballs, walking through walls,
summoning trolls, and stealing each other's treasure. It was created
by <strong>Tom Jolly</strong> in 1983 and first published under his
own Jolly Games label; the edition reproduced here is the
own Jolly Games label. The edition reproduced here is the
<strong>sixth edition</strong>, published by Chessex in 1993, together
with its one expansion — monsters and magic wands included.
with its one expansion, which brings monsters and magic wands.
</p>
<h3>Whose game it is</h3>
<p>
Wiz-War is Tom Jolly's design, and the rights to it now rest with
<strong>Steve Jackson Games</strong>, who publish the current
edition. This project is an unofficial, non-commercial fan work,
made with love and admiration for the original — nothing here is
made with love and admiration for the original. Nothing here is
endorsed by or affiliated with Tom Jolly, Chessex, or Steve Jackson
Games. If this game delights you, honor it the right way:
<a href="https://www.sjgames.com/wiz-war/" target="_blank" rel="noreferrer">buy
@@ -294,21 +294,21 @@
<span class="tile-note">the maze through a wizard's own eyes</span>
</a>
</div>
<p class="colophon"><a href="/?fpv&demo=1">Watch the demo</a>: two clockwork wizards play a stretch, then the reel replays it.</p>
<p class="colophon"><a href="/?fpv&demo=1">Watch the demo</a>, in which two clockwork wizards play a stretch and the reel replays it.</p>
<h3>What the table remembers</h3>
<p>
An online game is written to this table's ledger move by move, table
talk included, and kept: a finished game can be replayed from above
talk included, and kept. A finished game can be replayed from above
or through a wizard's own eyes, and any turn can be shared by link.
A shared replay shows that turn's moves and the sharing wizard's view
of the board — never anyone's hand. A game passed around one device
stays on that device; only its counts reach the tally. Play under
A shared replay shows that turn's moves as an onlooker would have
seen them, and never anyone's hand. A game passed around one device
stays on that device, and only its counts reach the tally. Play under
whatever name you like, and know that what you say and do at an
online table is part of its record.
</p>
<h3>Send word</h3>
<p>
Feedback, bug reports, and faint praise all welcome — ravens fly to
Feedback, bug reports and faint praise are all welcome. Ravens fly to
<a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>,
<a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky,
or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon.
@@ -324,8 +324,8 @@
</ul>
{/if}
<p class="hosting">
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month; anyone who wants to chip in toward the hosting can at
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Nothing is unlocked by it and nothing is withheld without it.
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Every game stays whole either way.
</p>
<p class="colophon">
Wiz-War © Tom Jolly. Sixth edition published by Chessex, 1993.
@@ -341,10 +341,10 @@
{:else if tab === "rulings"}
<div class="reading">
<p class="colophon">
For players who know the box: how this table resolves what the
cardboard leaves to the players, by card. If the table rules
For players who know the box, here is how this table resolves, card
by card, what the cardboard leaves to the players. If the table rules
against your memory of the rules, the 🐞 report button pins the
moment for review — the reply lands in your lobby.
moment for review, and the reply lands in your lobby.
</p>
<div class="search-row">
<input bind:value={rulingsSearch} placeholder="search the rulings by card or topic…" aria-label="search house rulings" />
@@ -367,11 +367,12 @@
{/each}
{#if !rulingsSearch.trim()}
<h3 id="ruling-the-table">The table itself</h3>
<p>Clockwork wizards — the automatons — play from the same redacted
view a human seat gets and play by the rules the engine enforces on
everyone; a tier changes what they draw and know.</p>
<p>The clockwork wizards, the automatons, play from the same redacted
view a human seat gets and by the same rules the engine enforces on
everyone. Their tier changes how many cards they draw and how many
tricks they know.</p>
<p>A turn need not be taken at once. Games wait on the lobby ledger
for days; the browser holds your seat; a replay of any game can be
for days while the browser holds your seat. A replay of any game can be
watched from above or through a wizard's eyes and shared by link.</p>
{/if}
</div>
@@ -399,7 +400,7 @@
{#each refBySource as group (group.source)}
<h2 class="divider" id="source-{group.source}">{SOURCE_LABELS[group.source]}</h2>
{#if group.source === "summary"}
<p class="colophon">Sixth edition rules, condensed from the original rulebook. © 1985 Jolly Games — this digital adaptation is a fan project.</p>
<p class="colophon">Sixth edition rules, condensed from the original rulebook. © 1985 Jolly Games. This digital adaptation is a fan project.</p>
{:else if group.source === "rulebook"}
<p class="colophon">The full text, word for word, for settling table disputes. {RULEBOOK_COPYRIGHT} Notes marked <span class="ed-note">(6E: …)</span> are this table's, on where the sixth edition differs.</p>
{:else if group.source === "faq"}
+2 -2
View File
@@ -23,9 +23,9 @@
</g>
{#if onclick}
<line {x1} {y1} {x2} {y2} class="illusion-hit"
role="button" tabindex="-1" aria-label="a shimmering wall — test your eyes"
role="button" tabindex="-1" aria-label="a shimmering wall, to test with your eyes"
onclick={(ev) => { ev.stopPropagation(); onclick?.(); }}
onkeydown={() => {}}><title>this wall shimmers oddly — tap to test your eyes</title></line>
onkeydown={() => {}}><title>this wall shimmers oddly, so tap to test your eyes</title></line>
{/if}
<style>
+2 -2
View File
@@ -708,7 +708,7 @@
<div class="replay-scrim">
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
<header class="replay-head">
<span class="replay-title">{moment ? `Instant replay — ${povId}'s turn` : steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
<span class="replay-title">{moment ? `Instant replay of ${povId}'s turn` : steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
<span class="replay-count">move {idx + 1} of {steps.length}</span>
<button class="replay-eyes" class:lit={fp} onclick={() => (fp = !fp)}>
{fp ? "⬒ the board" : "👁 your eyes"}</button>
@@ -770,7 +770,7 @@
<div class="timeline" role="group" aria-label="the moves, a tick each">
{#each ticks as t (t.i)}
<button class="tick" class:current={t.i === idx} class:turn={t.flag === "turn"} class:hit={t.flag === "hit"} class:gold={t.flag === "gold"} class:end={t.flag === "end"}
style:--who={t.color} title={`move ${t.i + 1} — ${t.actor}${t.flag ? ` · ${t.flag === "turn" ? "turn begins" : t.flag === "hit" ? "a blow" : t.flag === "gold" ? "treasure" : "the end"}` : ""}`}
style:--who={t.color} title={`move ${t.i + 1}, ${t.actor}${t.flag ? ` · ${t.flag === "turn" ? "turn begins" : t.flag === "hit" ? "a blow" : t.flag === "gold" ? "treasure" : "the end"}` : ""}`}
onclick={() => { playing = false; idx = t.i; }} aria-label={`jump to move ${t.i + 1}`}></button>
{/each}
</div>
+7 -7
View File
@@ -36,12 +36,12 @@
{#if data}
<div class="share-title">
{#if data.whole}
<span class="share-deed">The whole tale{data.actor ? ` — ${data.actor} triumphant` : ""}</span>
<span class="share-deed">{data.actor ? `The whole tale of ${data.actor}'s triumph` : "The whole tale"}</span>
{:else if data.headline}
<span class="share-deed">{data.headline}</span>
<span class="share-sub">an instant replay of {data.actor}'s turn{data.round ? `, round ${data.round}` : ""}</span>
{:else}
<span class="share-deed">Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}</span>
<span class="share-deed">Instant replay of {data.actor}'s turn{data.round ? `, round ${data.round}` : ""}</span>
{/if}
</div>
{/if}
@@ -49,7 +49,7 @@
{#if failed}
<div class="share-note">
<p>This replay has wandered off — the link may be old, or the game gone.</p>
<p>This replay has wandered off. The link may be old, or the game gone.</p>
<a class="share-cta" href="/">▶ deal yourself into a fresh game</a>
</div>
{:else if !data}
@@ -62,7 +62,7 @@
{/key}
</div>
<div class="share-play">
<a class="share-cta" href="/">▶ play free — two to six wizards enter the maze</a>
<a class="share-cta" href="/">▶ play free, with two to six wizards in the maze</a>
</div>
{/if}
@@ -70,9 +70,9 @@
<p>
<strong>Wiz-War</strong> is Tom Jolly's 1983 game of magical combat in
a stone labyrinth. This digital table is an unofficial, non-commercial
fan re-creation of the sixth edition (Chessex, 1993) — every card
transcribed, every wall verified, replays rebuilt move by move from
the game's own ledger.
fan re-creation of the sixth edition (Chessex, 1993). Every card is
transcribed and every wall verified, and each replay is rebuilt move
by move from the game's own ledger.
</p>
</footer>
</div>
+1 -1
View File
@@ -95,7 +95,7 @@ export function summarizeMissed(steps: readonly ReplayStep[], you: string, now:
});
for (const [who, d] of doings) {
const bits = [d.moves > 0 ? `walked ${d.moves} square${d.moves === 1 ? "" : "s"}` : "", d.casts > 0 ? `cast ${d.casts} spell${d.casts === 1 ? "" : "s"}` : ""].filter(Boolean);
facts.push({ text: `${who} ${bits.length ? bits.join(" and ") : "passed the time"}; nothing touched you.`, step: d.first });
facts.push({ text: bits.length ? `${who} ${bits.join(" and ")}, but nothing touched you.` : `${who} passed the time without troubling you.`, step: d.first });
}
}
if (talk > 0) facts.push({ text: `${talk} line${talk === 1 ? "" : "s"} of table talk.`, step: -1 });
+8 -8
View File
@@ -144,7 +144,7 @@ export const SCREENPLAYS: Screenplay[] = [
...DUEL,
name: "warp-sniper",
title: "The Warp Sniper",
blurb: "Line of sight threads a wormhole: a fireball cast into one mouth arrives through the other.",
blurb: "Line of sight threads a wormhole, so a fireball cast into one mouth arrives through the other.",
pov: "Wanderer",
rig: { Wanderer: ["number-3", "fireball"], Rival: ["number-3"] },
beats: [6, 16],
@@ -176,7 +176,7 @@ export const SCREENPLAYS: Screenplay[] = [
...DUEL,
name: "springing-the-trap",
title: "Springing the Trap",
blurb: "An ambush watches its corridor through a wormhole — and fires the moment a wizard crosses the far sightline.",
blurb: "An ambush watches its corridor through a wormhole and fires the moment a wizard crosses the far sightline.",
pov: "Rival",
rig: { Wanderer: ["opportunity-fire", "fireball"] },
beats: [5, 6],
@@ -197,7 +197,7 @@ export const SCREENPLAYS: Screenplay[] = [
...DUEL,
name: "the-leap",
title: "The Leap",
blurb: "A pit yawns in the corridor — but the die says the intruder sails clean over it, onto the trapper's doorstep.",
blurb: "A pit yawns in the corridor, but the die says the intruder sails clean over it, onto the trapper's doorstep.",
pov: "Rival",
rig: { Wanderer: ["create-pit"] },
beats: [8, 9],
@@ -221,7 +221,7 @@ export const SCREENPLAYS: Screenplay[] = [
...DUEL,
name: "come-here",
title: "The Puppeteer",
blurb: "Mental Force walks the enemy to exactly where you want them — which has its risks.",
blurb: "Mental Force walks the enemy to exactly where you want them, and that has its risks.",
pov: "Wanderer",
rig: { Wanderer: ["mental-force"], Rival: ["number-2"] },
beats: [10, 13],
@@ -243,7 +243,7 @@ export const SCREENPLAYS: Screenplay[] = [
...DUEL,
name: "stone-gaze",
title: "The Stone Gaze",
blurb: "Medusa freezes a wizard solid — and stone, inconveniently, cannot be hurt.",
blurb: "Medusa freezes a wizard solid, and stone, inconveniently, cannot be hurt.",
pov: "Rival",
rig: { Rival: ["number-2", "medusa", "number-5", "fireball"] },
beats: [10, 14],
@@ -279,7 +279,7 @@ export const SCREENPLAYS: Screenplay[] = [
...DUEL,
name: "wall-comes-down",
title: "Knock Knock",
blurb: "Blast the wall between you — the rubble bloodies both sides of it — then step through the breach.",
blurb: "Blast the wall between you, bloodying both sides with its rubble, then step through the breach.",
pov: "Wanderer",
rig: { Wanderer: ["number-2", "destroy-wall"] },
beats: [6, 7],
@@ -320,7 +320,7 @@ export const SCREENPLAYS: Screenplay[] = [
...DUEL,
name: "fire-in-the-webs",
title: "Fire in the Webs",
blurb: "The Sticky Wand binds a wizard in silk — and fire burns twice as hot in a web.",
blurb: "The Sticky Wand binds a wizard in silk, and a fireball bites two points harder in a web.",
pov: "Rival",
rig: { Rival: ["number-2", "sticky-wand", "fireball"] },
beats: [11, 14],
@@ -340,7 +340,7 @@ export const SCREENPLAYS: Screenplay[] = [
...DUEL,
name: "dust-and-light",
title: "Dust and Light",
blurb: "A dust cloud hides her from his spells — so he throws a warp behind her, steps through the light, and she flees blind into her own dust.",
blurb: "A dust cloud hides her from his spells, so he throws a warp behind her, steps through the light, and she flees blind into her own dust.",
pov: "Rival",
rig: { Rival: ["number-2", "dimensional-warp"], Wanderer: ["dust-cloud"] },
beats: [12, 14, 20],
+3 -3
View File
@@ -40,7 +40,7 @@ function describe(view: GameView, cmd: Command): string {
case "counteract":
return `answer with ${nameOf(view, cmd.instanceId)}`;
case "pass":
return "let it resolve — nothing in hand is worth spending on this";
return "let it resolve, since nothing in hand is worth spending on this";
case "punch":
return `punch ${cmd.targetId}`;
case "punchWall":
@@ -56,7 +56,7 @@ function describe(view: GameView, cmd: Command): string {
case "warpStep":
return "step through the warp underfoot";
case "testIllusion":
return "test that shimmering wall — doubting it is free";
return "test that shimmering wall, since doubting it is free";
case "moveCreature":
return `march the monster ${DIRECTION[cmd.direction]}`;
case "creatureAttack":
@@ -90,7 +90,7 @@ export function tableHints(view: GameView): TableHint[] {
let advice: string;
try {
const cmd = automatonCommand(view, s, "archmage");
advice = cmd ? `I would ${describe(view, cmd)}.` : "Nothing calls for us right now — wait for your moment.";
advice = cmd ? `I would ${describe(view, cmd)}.` : "Nothing calls for us right now. Wait for your moment.";
} catch (e) {
console.warn("hint council choked:", e);
advice = "…I have no counsel here.";
+1 -1
View File
@@ -77,7 +77,7 @@ class LocalGame {
rollTableDie(): void {
if (!this.viewerId) return;
const roll = 1 + (crypto.getRandomValues(new Uint32Array(1))[0]! % 4);
this.log = [...this.log, { text: `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`, turn: null, notable: false }];
this.log = [...this.log, { text: `\u{1F3B2} ${this.viewerId} rolls a ${roll}.`, turn: null, notable: false }];
}
/** Rebuild the whole game as replay steps (finished games only). */
+55 -55
View File
@@ -38,7 +38,7 @@ export function humanize(e: GameEvent): string | null {
const rolloff = e.players
.map((p) => `${p} 🎲 ${(e.dieRolls[p] ?? []).join(", ")}`)
.join(" · ");
return `Game started. The roll-off: ${rolloff} — ${e.firstPlayer} goes first.`;
return `Game started. The roll-off: ${rolloff}. ${e.firstPlayer} goes first.`;
}
case "turnStarted": return `— ${e.player}'s turn (round ${e.round}) —`;
case "turnSkipped": return `${e.player} loses a turn.`;
@@ -58,7 +58,7 @@ export function humanize(e: GameEvent): string | null {
return `${e.player} counters ${what} with ${cardDef(e.cardId).name}!`;
}
case "counterNullified": return `${cardDef(e.card.cardId).name} is nullified by Anti-Anti!`;
case "attackAbsorbedIntoHand": return `${e.player} absorbs ${cardDef(e.attackCard.cardId).name} into their hand — the card is theirs now!`;
case "attackAbsorbedIntoHand": return `${e.player} absorbs ${cardDef(e.attackCard.cardId).name} into their hand, and the card is theirs now!`;
case "attackResolved":
if (e.redirected) return `The spell is reflected back at ${e.attacker}!`;
if (e.fullyStopped) return `The attack is completely stopped.`;
@@ -68,42 +68,42 @@ export function humanize(e: GameEvent): string | null {
// blow that follows it has nothing to add.
if (e.amount === 0 && e.source === "shadow upkeep") return null;
const soak = e.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", ");
return `${e.player} takes ${e.amount} damage (${e.source}${soak ? ` — ${soak}` : ""}) — ${e.lifeAfter} life left.`;
return `${e.player} takes ${e.amount} damage (${e.source}${soak ? `, ${soak}` : ""}), ${e.lifeAfter} life left.`;
}
case "stunned": return `${e.player} is stunned and loses a turn!`;
case "knockedBack": return `${e.player} is knocked back ${e.squares} square(s)!`;
case "stonesDestroyed": return `${e.player}'s magic stones are destroyed!`;
case "wallCreated": return `A wall appears!`;
case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`;
case "warpOpened": return `The outer wall breaches clean through — a new warp opens across the maze!`;
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
case "pushWindow": return `${e.giant} bears down on ${e.pushee} at a fork — which way will they go?`;
case "warpOpened": return `The outer wall breaches clean through, and a new warp opens across the maze!`;
case "extraTurnGranted": return `${e.player} speeds up and banks an extra turn.`;
case "wardWindow": return `The grab hangs in the air as ${e.owner} clutches something…`;
case "pushWindow": return `${e.giant} bears down on ${e.pushee} at a fork. Which way will they go?`;
case "pushChosen": return `${e.pushee} breaks ${SIDE_NAMES[e.direction].toLowerCase()}.`;
case "treasureTorn": return e.toFloor
? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!`
? `${e.attacker} TEARS the treasure from ${e.defender}'s arms, and it tumbles to the floor!`
: `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`;
case "tearResisted": return `${e.defender} clutches the treasure with white knuckles — ${e.attacker} comes away empty!`;
case "tearResisted": return `${e.defender} clutches the treasure with white knuckles, and ${e.attacker} comes away empty!`;
case "treasureTearAttempted": return `${e.attacker} grabs for the treasure in ${e.defender}'s arms!`;
case "powerstoneBoost": return `The powerstone hums over the number already played — ${e.player} gains a stride (${e.newAllowance} total).`;
case "powerstoneBoost": return `The powerstone hums over the number already played, and ${e.player} gains a stride (${e.newAllowance} total).`;
case "trapSprung": return e.cardId === "gift-from-below"
? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`
? `${e.player} draws GIFT FROM BELOW. It bites for 3, then deals again!`
: `${e.player} walked into an old TRAP! Lose a turn.`;
case "attackMissed": return e.because === "invisible"
? `The attack passes through empty air — ${e.defender} is invisible!`
: `${e.defender} is too small to hit — the attack misses!`;
? `The attack passes through empty air, for ${e.defender} is invisible!`
: `${e.defender} is too small to hit, and the attack misses!`;
case "damageImmune": return e.because === "bloodstone"
? `${e.player}'s bloodstone drinks the whole blow — no damage.`
: `${e.player} is stone — the damage has no effect.`;
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
case "madDash": return `${e.player} MAD DASHES — every stride doubles: ${e.newAllowance} movement this turn!`;
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`;
case "safeDamaged": return `${e.attacker} batters the safe — ${e.amount} damage (${e.total}/15).`;
? `${e.player}'s bloodstone drinks the whole blow, and no damage lands.`
: `${e.player} is stone, and the damage has no effect.`;
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}), now ${e.lifeAfter}.`;
case "madDash": return `${e.player} MAD DASHES, every stride doubled, for ${e.newAllowance} movement this turn!`;
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open until the turn's end.`;
case "safeDamaged": return `${e.attacker} batters the safe for ${e.amount} damage (${e.total}/15).`;
case "safeSmashed": return `💥 The safe BURSTS open under ${e.attacker}'s assault!`;
case "squareContentDamaged": return `${e.attacker} ${e.kind === "ooze" ? "burns" : "batters"} the ${e.kind} — ${e.amount} damage (${e.total}/${e.needed}).`;
case "squareContentDamaged": return `${e.attacker} ${e.kind === "ooze" ? "burns" : "batters"} the ${e.kind} for ${e.amount} damage (${e.total}/${e.needed}).`;
case "squareContentDestroyed": return e.kind === "ooze" ? `🔥 The ooze burns away under ${e.attacker}'s fire!` : `🌿 The ${e.kind} is torn apart by ${e.attacker}'s attack!`;
case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points} — a counter hovers over the wound…`;
case "slowDeathCountered": return `${e.player}'s ${cardDef(e.cardId).name} blunts the rot — ${e.remaining} point${e.remaining === 1 ? "" : "s"} still coming.`;
case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points}, and a counter hovers over the wound…`;
case "slowDeathCountered": return `${e.player}'s ${cardDef(e.cardId).name} blunts the rot, with ${e.remaining} point${e.remaining === 1 ? "" : "s"} still coming.`;
case "spellSustained": return isPermanentDuration(e.turns)
? `${spellName(e.cardId)} settles over ${e.target} for good.`
: `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
@@ -112,16 +112,16 @@ export function humanize(e: GameEvent): string | null {
? `${e.player} teleports across the maze!`
: `${e.player} is teleported away by ${e.by}!`;
case "positionsSwapped": return `${e.a} and ${e.b} swap places!`;
case "homeBasesSwapped": return `${e.a} and ${e.b} swap home bases — the maze's loyalties shift!`;
case "homeBasesSwapped": return `${e.a} and ${e.b} swap home bases, and the maze's loyalties shift!`;
case "cardErased": return e.found
? `${e.player}'s ${e.cardId ? cardDef(e.cardId).name : "card"} is erased from their mind!`
: `${e.player} wasn't holding that card — the erasure fizzles.`;
: `${e.player} wasn't holding that card, so the erasure fizzles.`;
case "cardsStolen": return `${e.to} steals ${e.count} card(s) from ${e.from}'s thoughts!`;
case "handRevealed": return `${e.to} reads ${e.player}'s mind — their hand is revealed.`;
case "handRevealed": return `${e.to} reads ${e.player}'s mind, and their hand lies revealed.`;
case "cardsDrawnPrivate": return e.cards.length > 0
? `You draw ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`
: null;
case "creationDispelled": return `The ${e.what.replace(/-/g, " ")} unravels — dispelled!`;
case "creationDispelled": return `The ${e.what.replace(/-/g, " ")} unravels, dispelled!`;
case "firewallCreated": return `A wall of fire roars up (${e.turns} turn${e.turns === 1 ? "" : "s"})!`;
case "firewallExpired": return `The wall of fire gutters out.`;
case "firewallBurned": return `${e.player} steps through the flames!`;
@@ -134,7 +134,7 @@ export function humanize(e: GameEvent): string | null {
case "doorReleased": return `The held door swings shut.`;
case "doorsRelocked": return `The door swings shut and relocks.`;
case "washedBack": return `${e.player} is washed back ${e.blockedSpaces > 0 ? "and crushed against the stone" : "by the wave"}!`;
case "enteredThornbush": return `${e.player} pushes into the thorns — and bleeds for it.`;
case "enteredThornbush": return `${e.player} pushes into the thorns and bleeds for it.`;
case "objectDragged": return `The ${e.what.replace(/-/g, " ")} is dragged across the maze.`;
case "cardsDealt": return `${e.player} is dealt ${e.count} card${e.count === 1 ? "" : "s"}.`;
case "cardsStolenPrivate": return e.cards.length > 0
@@ -142,25 +142,25 @@ export function humanize(e: GameEvent): string | null {
: null;
case "handRevealedPrivate": return `${e.player}'s hand lies open to you: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`;
case "creatureWarpStepped": return `The creature slips through the dimensional warp!`;
case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} — and fails to find a path.`;
case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} and fails to find a path.`;
case "doorJammed": return `${e.player} jams a door's lock solid.`;
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
case "lifeTraded": return `${e.player} burns ${e.points} life for speed!`;
case "castAroundCorner": return `The spell bends around the corner!`;
case "moveBumped": return e.why === "pit"
? `${e.player} leaps the pit — nothing to land on beyond it — and teeters back.`
? `${e.player} leaps the pit, finds nothing to land on beyond it, and teeters back.`
: `${e.player} blunders into a wall!`;
case "attackMisdirected": return e.newTarget
? `${e.attacker}'s blind attack veers off — and hits ${e.newTarget}!`
? `${e.attacker}'s blind attack veers off and hits ${e.newTarget}!`
: `${e.attacker}'s blind attack flies off into the darkness.`;
case "retreatedInHorror": return `${e.player} flees the hideous sight!`;
case "illusionWallCreated": return `A wall appears... or does it?`;
case "illusionTested": return e.result === "seesThrough"
? `${e.player} sees right through the illusion!`
: `${e.player} is convinced the wall is real.`;
case "sectorRotated": return `The maze GRINDS — a sector rotates ${e.clockwise ? "clockwise" : "counterclockwise"}!`;
case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`;
case "sectorRotated": return `The maze GRINDS as a sector rotates ${e.clockwise ? "clockwise" : "counterclockwise"}!`;
case "sectorRelocated": return `The maze SHUDDERS as an entire sector slides away!`;
case "creatureCreated": return `${e.controller} summons a ${e.kind.replace(/-/g, " ")}!`;
case "creatureMoved": return null;
case "creatureAttacked":
@@ -172,7 +172,7 @@ export function humanize(e: GameEvent): string | null {
case "trollRegenerated": return `The troll's stony hide knits itself back together.`;
case "shadowUpkeep": return `The shadow drains its master (${e.lifeAfter} life left).`;
case "impScorches": return `The fire imp scorches ${e.player}!`;
case "monsterBoosted": return `The monster GROWS — its ${e.boost} doubles!`;
case "monsterBoosted": return `The monster GROWS, and its ${e.boost} doubles!`;
case "wandCharged": return `${e.player} charges a wand (${e.charges} charges).`;
case "wandUsed": return e.chargesLeft > 0 ? `The wand crackles (${e.chargesLeft} left).` : null;
case "wandExhausted": return `${e.player}'s wand crumbles to dust.`;
@@ -190,59 +190,59 @@ export function humanize(e: GameEvent): string | null {
case "stuckInSlime": return `${e.player} squelches into the slime and sticks fast.`;
case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`;
case "boobytrapSprung": return `SNAP! ${e.player} finds the real boobytrap!`;
case "boobytrapBlank": return `${e.player} flips a face-down token — a blank. The rest still wait.`;
case "boobytrapBlank": return `${e.player} flips a face-down token and finds a blank. The rest still wait.`;
case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`;
case "safeCreated": return `A massive safe slams down around the loot.`;
case "itemsTraded": return `Two items blink and trade places.`;
case "stoneTurnedToWater": return `Stone runs like water — a wave crashes out!`;
case "stoneTurnedToWater": return `Stone runs like water, and a wave crashes out!`;
case "handsSwapped": return `${e.a} and ${e.b} trade entire hands of cards!`;
case "handsScrambled": return `CHAOS! Every hand is thrown in a pile and redealt!`;
case "rammed": return `BAAA! ${e.attacker} turns into a goat and rams ${e.target} (${e.distance} spaces)!`;
case "treasureThrown": return `${e.attacker} HURLS their treasure (${e.distance} spaces)!`;
case "illusionBelieved": return e.believed ? `${e.player} flinches — the illusion feels real!` : `${e.player} laughs off the illusion.`;
case "illusionBelieved": return e.believed ? `${e.player} flinches, for the illusion feels real!` : `${e.player} laughs off the illusion.`;
case "itemStolen": return `${e.to} picks ${e.from}'s pocket.`;
case "itemsSwapped": return `${e.a} and ${e.b} swap items.`;
case "swapFizzled": return `${e.player}'s trade comes to nothing — the named items were not there to swap.`;
case "wardSprung": return `${e.owner}'s treasure was WARDED — it bites ${e.victim}!`;
case "swapFizzled": return `${e.player}'s trade comes to nothing, for the named items were not there to swap.`;
case "wardSprung": return `${e.owner}'s treasure was WARDED, and it bites ${e.victim}!`;
case "curseRemoved": return `${e.caster} lifts a curse from ${e.target}.`;
case "objectEnchanted": return `An object gleams with Swarthmore's enchantment.`;
case "warpTokensPlaced": return `Two dimensional warp tokens hum to life.`;
case "warpStepped": return `${e.player} steps through the dimensional warp!`;
case "exitsRedirected": return `The maze's outer exits twist and reconnect!`;
case "outOfTurnWindow": return `${e.player} interrupts the flow of time (${e.kind === "interrupt" ? "Interrupt" : "Opportunity Fire"})!`;
case "thumbOfGod": return `THE THUMB OF GOD descends! The die crashes down${e.aimedAt.x === e.landedAt.x && e.aimedAt.y === e.landedAt.y ? " dead on target" : " — and drifts"}!`;
case "thumbOfGod": return `THE THUMB OF GOD descends! The die crashes down${e.aimedAt.x === e.landedAt.x && e.aimedAt.y === e.landedAt.y ? " dead on target" : ", and drifts"}!`;
case "tokenScattered": return `${e.what} goes flying!`;
case "ambushSet": return `You commit ${e.spell} to an ambush (${e.via}).`;
case "ambushCancelled": return `You quietly disarm your ambush.`;
case "ambushSprung": return `AMBUSH! ${e.owner}'s hidden ${e.via.replace(/-/g, " ")} springs on ${e.victim}!`;
case "trapRedrawnDuringDeal": return null;
case "died": return `☠ ${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
case "died": return `☠ ${e.player} is dead${e.killedBy ? `, killed by ${e.killedBy}` : ""}.`;
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;
case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`;
case "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`;
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`;
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name}, and their actions are over.`;
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
case "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`;
case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll} — ${e.purpose}.`;
case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll} (${e.purpose}).`;
case "pushed": return e.player
? `${e.by} shoves ${e.player} down the corridor!`
: `${e.by} shoves the beast down the corridor!`;
case "spellTrapped": return `${e.caster} casts ${spellName(e.cardId)} into the slime — it sticks, waiting.`;
case "spellTrapped": return `${e.caster} casts ${spellName(e.cardId)} into the slime, where it sticks and waits.`;
case "slimeTrapSprung": return `The slime releases its ${spellName(e.cardId)} at ${e.victim}!`;
case "slimeWashed": return `The wave washes the slime away.`;
case "wallDamaged": {
const what = e.needed === 15 ? "door" : "wall";
const weapon = e.source === "punch" ? "bare fists" : e.source === "troll" ? "the troll's fist" : cardDef(e.source).name;
return `${e.player} batters the ${what} with ${weapon} — ${e.total}/${e.needed}.`;
return `${e.player} batters the ${what} with ${weapon} (${e.total}/${e.needed}).`;
}
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated, both treasures lost!` : null;
case "cardsDiscarded":
return `${e.player} discards ${e.cards.map((c) => spellName(c.cardId)).join(", ")}.`;
case "cardsDrawn": return e.count > 0 ? `${e.player} draws ${e.count} card(s).` : null;
case "deckReshuffled": return `The discard pile is reshuffled (${e.size} cards).`;
case "turnEnded": return null;
case "gameWon": return `🏆 ${e.player} WINS ${e.reason === "treasures" ? "by treasure!" : "— last wizard standing!"}`;
case "gameWon": return `🏆 ${e.player} WINS ${e.reason === "treasures" ? "by treasure!" : "as the last wizard standing!"}`;
default: return null;
}
}
@@ -329,7 +329,7 @@ export interface GameSummary {
export function attentionLabel(a: GameSummary["attention"]): string {
switch (a) {
case "counteract": return "UNDER ATTACK — respond!";
case "counteract": return "UNDER ATTACK! Respond.";
case "discard": return "DISCARD to your hand limit";
case "interrupt": return "your interruption is waiting";
default: return "YOUR TURN";
@@ -682,8 +682,8 @@ class Net {
this.pendingImage = null;
if (image && typeof msg.id === "string") {
fetch(`${API_URL}/api/feedback-image/${msg.id}`, { method: "POST", headers: { "content-type": image.type }, body: image })
.then((r) => { if (!r.ok) this.flash("The words went through; the picture did not — a PNG or JPEG under 2.5 MB, please"); })
.catch(() => this.flash("The words went through; the picture did not"));
.then((r) => { if (!r.ok) this.flash("The words went through, but the picture did not. A PNG or JPEG under 2.5 MB, please."); })
.catch(() => this.flash("The words went through, but the picture did not."));
}
this.refreshFeedback();
break;
@@ -753,7 +753,7 @@ class Net {
create(name: string): void {
this.you = name;
this.spectating = false;
if (!this.send({ type: "create", name })) this.flash("Still reaching the table — try again in a moment");
if (!this.send({ type: "create", name })) this.flash("Still reaching the table. Try again in a moment.");
}
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
@@ -771,7 +771,7 @@ class Net {
(s) => s.roomId === this.roomIdPending && s.name === name,
);
if (!this.send({ type: "join", roomId, name, token: existing?.token ?? this.token })) {
this.flash("Still reaching the table — try again in a moment");
this.flash("Still reaching the table. Try again in a moment.");
}
}
@@ -813,7 +813,7 @@ class Net {
/** A word back to the desk under one of your reports, proven by the seat it was filed from. */
answerReport(report: { id: string; roomId: string; player?: string }, text: string): boolean {
const seat = this.seats.find((s) => s.roomId === report.roomId && (!report.player || s.name === report.player));
if (!seat) { this.flash("That seat is no longer in this browser, so the desk cannot be sure it is you"); return false; }
if (!seat) { this.flash("That seat is no longer in this browser, so the keeper cannot be sure it is you."); return false; }
return this.send({ type: "feedbackAnswer", reportId: report.id, text, seat: $state.snapshot(seat) });
}
@@ -922,8 +922,8 @@ class Net {
try {
new Notification(
g.attention === "counteract"
? `Wiz-War — you are under attack in ${g.roomId}!`
: `Wiz-War — your turn in ${g.roomId}`,
? `Wiz-War · you are under attack in ${g.roomId}!`
: `Wiz-War · your turn in ${g.roomId}`,
{
body: `${g.players.join(" vs ")} · round ${g.round ?? "?"}`,
tag: `wizwar-${g.roomId}`,
@@ -1055,7 +1055,7 @@ class Net {
const label = describeCommand(command);
if (!this.send({ type: "command", command })) {
this.unsent = label;
this.flash(`${label[0]!.toUpperCase()}${label.slice(1)} was not sent — the table is out of reach`);
this.flash(`${label[0]!.toUpperCase()}${label.slice(1)} was not sent. The table is out of reach.`);
return false;
}
this.unsent = null;
+3 -3
View File
@@ -21,8 +21,8 @@ function nameOf(id: string | null): string {
export function receiptFor(batch: readonly GameEvent[], e: GameEvent): Receipt | null {
if (e.type === "attackMissed") {
const why =
e.because === "invisible" ? `${e.defender} is invisible; the die sent it astray.`
: e.because === "shrink" ? `${e.defender} is shrunk; the die said miss.`
e.because === "invisible" ? `${e.defender} is invisible, and the die sent it astray.`
: e.because === "shrink" ? `${e.defender} is shrunk, and the die said miss.`
: `${e.defender} outran it.`;
return { title: `${nameOf(e.attackCardId)} misses ${e.defender}`, lines: [why] };
}
@@ -91,7 +91,7 @@ export function receiptFor(batch: readonly GameEvent[], e: GameEvent): Receipt |
const soak = x.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", ");
lines.push(`${x.player}: ${x.lifeAfter + x.amount} → ${x.lifeAfter} life${soak ? ` (${soak})` : ""}`);
} else if (x.type === "damageImmune") {
lines.push(`${x.player} takes nothing — ${x.because}`);
lines.push(`${x.player} takes nothing (${x.because})`);
} else if (x.type === "lifeGained") {
lines.push(`${x.player}: ${x.lifeAfter - x.amount} → ${x.lifeAfter} life, reversed`);
} else if (x.type === "stonesDestroyed") {
+18 -18
View File
@@ -188,11 +188,11 @@ export interface HouseRuling {
export const HOUSE_RULINGS: HouseRuling[] = [
{
id: "thumb-of-god", title: "The Thumb of God", cards: ["thumb-of-god"],
body: ["THE THUMB OF GOD is a divine meteor. Aim it at a square; the die drifts up to two squares in a random direction, then every token in and around the landing square — objects, treasures, creatures, even wizards — is flung to a random nearby square. Walls mean nothing to falling cardboard, and there is no counteraction."],
body: ["THE THUMB OF GOD is a divine meteor. Aim it at a square and the die drifts up to two squares in a random direction. Then every token in and around the landing square is flung to a random nearby square, whether object, treasure, creature or wizard. Walls mean nothing to falling cardboard, and there is no counteraction."],
},
{
id: "illusion-wall", title: "Illusion walls", cards: ["illusion-wall"],
body: ["An ILLUSION WALL is real only to those who believe it. Its creator sees through it from the start; everyone else sees stone until they walk into it or see through it, and the maze remembers each wizard's verdict separately. An untested illusion shimmers faintly, and doubting it is free."],
body: ["An ILLUSION WALL is real only to those who believe it. Its creator sees through it from the start. Everyone else sees stone until they walk into it or see through it, and the maze remembers each wizard's verdict separately. An untested illusion shimmers faintly, and doubting it is free."],
},
{
id: "ambush", title: "Ambushes", cards: ["opportunity-fire"],
@@ -201,24 +201,24 @@ export const HOUSE_RULINGS: HouseRuling[] = [
{
id: "butt-head", title: "The goat's ram", cards: ["butt-head", "mad-dash", "power-run"],
body: [
"BUTT-HEAD's ram is movement. The charge is measured as the shortest walk through the corridors from where you cast it to your victim's square, on the legs you have this turn — three, plus any NUMBER played for movement — and it spends them. You cannot pad the blow by taking the long way round, and the goat lands on the victim's square. There is no ceiling on the damage.",
"BUTT-HEAD's ram is movement. The charge is measured as the shortest walk through the corridors from where you cast it to your victim's square, on the legs you have this turn (three, plus any NUMBER played for movement), and it spends them. You cannot pad the blow by taking the long way round, and the goat lands on the victim's square. There is no ceiling on the damage.",
"MAD DASH doubles the whole allowance, NUMBER cards and POWER RUN points included, so a well-placed goat can ram for sixteen.",
],
},
{
id: "slime", title: "Spells cast into slime", cards: ["fill-square-with-slime"],
body: ["Spells cast at a FILL SQUARE WITH SLIME lodge in the gel, and a slime may hold several. They go off one at a time, oldest first: each wizard who pushes in springs one spell, the next wizard the next. The card says only that each spell goes off once; the queue is this table's reading. A slime shows how many it holds, and a peek names them, since every cast into it was seen."],
body: ["Spells cast at a FILL SQUARE WITH SLIME lodge in the gel, and a slime may hold several. They go off one at a time, oldest first, so each wizard who pushes in springs one spell and the next wizard the next. The card says only that each spell goes off once, and the queue is this table's reading. A slime shows how many it holds, and a peek names them, since every cast into it was seen."],
},
{
id: "doors-and-sight", title: "Doors and sight", cards: ["pick-lock", "master-key", "remove-lock"],
body: [
"A wizard beside a door they can open — its lock removed, unlocked this turn, or PICK LOCK or MASTER KEY in hand — sees through the doorway. The hallway behind them still cannot.",
"A wizard beside a door they can open (its lock removed, unlocked this turn, or PICK LOCK or MASTER KEY in hand) sees through the doorway. The hallway behind them still cannot.",
"\"The door will relock behind you\" means it: passing through an unlocked door shuts it at the walker's back unless a hand holds it open. A door unlocked and not passed relocks at the turn's end.",
],
},
{
id: "visionstone", title: "Visionstone", cards: ["visionstone", "dust-cloud"],
body: ["VISIONSTONE pierces its one wall for every sight the game asks of its bearer — creations and utility spells included, not only direct attacks. It does not see through a DUST CLOUD."],
body: ["VISIONSTONE pierces its one wall for every sight the game asks of its bearer, for creations and utility spells as well as direct attacks. It does not see through a DUST CLOUD."],
},
{
id: "dust-cloud", title: "Dust clouds", cards: ["dust-cloud"],
@@ -234,11 +234,11 @@ export const HOUSE_RULINGS: HouseRuling[] = [
},
{
id: "disease", title: "Disease", cards: ["disease"],
body: ["DISEASE is a plague cast on yourself (\"You're the carrier!\"). The caster carries it; sharing a square bites in both directions; there is no counteraction."],
body: ["DISEASE is a plague cast on yourself (\"You're the carrier!\"). The caster carries it, sharing a square bites in both directions, and there is no counteraction."],
},
{
id: "fire-imp", title: "The fire imp's scorch", cards: ["fire-imp"],
body: ["The fire imp's scorch is a spell, as the FAQ has it: magical, and counteractable."],
body: ["The fire imp's scorch is a spell, as the FAQ has it, so it is magical and can be counteracted."],
},
{
id: "soulstone", title: "Soulstone", cards: ["soulstone"],
@@ -246,15 +246,15 @@ export const HOUSE_RULINGS: HouseRuling[] = [
},
{
id: "ward", title: "The ward's bite", cards: ["ward"],
body: ["A WARD's bite is counteractable (\"COUNTERACTIONs ... otherwise work as written\"), though nothing a counter does — a reversal, a reflection — touches the ward's caster."],
body: ["A WARD's bite is counteractable (\"COUNTERACTIONs ... otherwise work as written\"), though nothing a counter does (a reversal, a reflection) touches the ward's caster."],
},
{
id: "curses", title: "Reversing and reflecting a curse", cards: ["reverse", "full-reflection", "reflection", "slow-death", "walking-dead", "idiot"],
body: [
"A REVERSE against SLOW DEATH or WALKING DEAD turns the whole curse, as the FAQ rules: a point gained per card drawn, half a point per space walked, permanently.",
"A FULL REFLECTION returns a permanent curse — SLOW DEATH, WALKING DEAD, IDIOT — onto its caster instead of letting it evaporate.",
"A REVERSE against SLOW DEATH or WALKING DEAD turns the whole curse, as the FAQ rules, into a point gained per card drawn and half a point per space walked, permanently.",
"A FULL REFLECTION returns a permanent curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of letting it evaporate.",
"REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window: an ABSORB or a BLUNT meets it as it would any blow.",
"A REFLECTION against a permanent curse afflicts both wizards. WALKING DEAD and IDIOT say so on the card; SLOW DEATH follows REFLECTION's own rule that a spell works for both parties.",
"A REFLECTION against a permanent curse afflicts both wizards. WALKING DEAD and IDIOT say so on the card, and SLOW DEATH follows REFLECTION's own rule that a spell works for both parties.",
],
},
{
@@ -267,25 +267,25 @@ export const HOUSE_RULINGS: HouseRuling[] = [
},
{
id: "teleport", title: "Teleporting across the maze's edge", cards: ["teleport"],
body: ["TELEPORT ignores the maze's outer edge as it ignores any wall. A teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on — the lettered openings are not needed. Four spaces straight up from two squares below the top edge lands two squares up from the bottom."],
body: ["TELEPORT ignores the maze's outer edge as it ignores any wall. A teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on, with no need for the lettered openings. Four spaces straight up from the second row lands in the third row from the bottom."],
},
{
id: "power-drain", title: "Power Drain", cards: ["power-drain", "blunt", "absorb"],
body: ["POWER DRAIN drains the number played. The caster gains it whether the blow is BLUNTed or ABSORBed — the FAQ has the counter blunting the damage done, not the drain — and a wall drained for its points gives them up as a wizard would. A FULL SHIELD stops the drain with the spell."],
body: ["POWER DRAIN drains the number played. The caster gains it whether the blow is BLUNTed or ABSORBed (the FAQ has the counter blunting the damage done and leaving the drain whole), and a wall drained for its points gives them up as a wizard would. A FULL SHIELD stops the drain with the spell."],
},
{
id: "troll", title: "The troll's fist", cards: ["troll"],
body: ["A commanded TROLL punches a wall line beside it as it punches a wizard: a D4 of damage toward the wall's fall, once a turn."],
body: ["A commanded TROLL punches a wall line beside it as it punches a wizard, for a D4 of damage toward the wall's fall, once a turn."],
},
{
id: "pits", title: "Crossing a pit", cards: ["create-pit"],
body: [
"Crossing a pit on a 2, 3, or 4 means edging around its rim. The walker lands on an open square beside the pit — the only one if there is one, otherwise the one they name by clicking it. A warp mouth on the rim is a way off like any square, and is taken when no square offers. A pit with no way off cannot be entered.",
"Crossing a pit on a 2, 3, or 4 means edging around its rim. The walker lands on an open square beside the pit. If there is more than one, they name it by clicking it. A warp mouth on the rim is a way off like any square, and is taken when no square offers. A pit with no way off cannot be entered.",
],
},
{
id: "big-man", title: "The giant's shove at a fork", cards: ["big-man"],
body: ["BIG MAN at a fork: a pushed wizard leaves by any open side but the way the giant came — the only one if there is one, otherwise the side they choose, the giant's stride hanging until they do. The FAQ gives the choice to the other player; so does this table."],
body: ["When BIG MAN pushes a wizard to a fork, they leave by any open side but the way the giant came. If more than one is open they choose, and the giant's stride hangs until they do. The FAQ gives the choice to the wizard being pushed, and so does this table."],
},
{
id: "waterwall", title: "The waterwall's wave", cards: ["waterwall"],
@@ -293,7 +293,7 @@ export const HOUSE_RULINGS: HouseRuling[] = [
},
{
id: "force-field", title: "Force Field", cards: ["force-field"],
body: ["FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them, on every side — where the card says one side, this table gives all four."],
body: ["FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn. Until then they may not enter its caster's square, or cast on or past them, from any side. The card says one side, and this table gives all four."],
},
];
+3 -3
View File
@@ -26,7 +26,7 @@ export const RULEBOOK_BASE: RuleSection[] = [
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.)*"
"*(6E: 125 cards and two sheets of tokens.)*"
]
},
{
@@ -75,7 +75,7 @@ export const RULEBOOK_BASE: RuleSection[] = [
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.)*",
"*(6E: the boards have no printed dots, so eyeball the square centers.)*",
"**NEUTRAL** cards are non-offensive or non-defensive spells. ANY NUMBER of Neutral spells can be cast during a player's turn. It is not impossible for a player to use his entire hand in one turn.",
"**COUNTERACTION** cards are the only cards that can be used OUT OF TURN. They can be used (but aren't usually) during a normal turn as a NEUTRAL card, but their primary use is as a defense against attacks. They are played immediately following an attack by the player who was attacked. You CANNOT counteract a Neutral card. You can, however, use an ABSORB or BLUNT against indirect damage caused to you (e.g., by a falling DESTROYed WALL).",
"More than one COUNTERACTION card can be used to defend against a single attack, either used sequentially or added together. It is also possible to COUNTERACT a COUNTERACTION card, so an attack may bounce back and forth a few times before coming to rest. In case a COUNTERACTION card only stops half of a spell, always round the resulting damage UP to the nearest whole number. If all damage from a spell is stopped, any secondary effects, such as a lost turn, are also stopped.",
@@ -105,7 +105,7 @@ export const RULEBOOK_BASE: RuleSection[] = [
"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.",
"If a person is eliminated from the game by being killed, then the last attacker gets all the dead person's cards. If a player is eliminated by having lost both his treasures to opponents' home bases, his cards are discarded.",
"You cannot physically attack through any type of wall, firewall, or bush. You cannot attack yourself. You cannot attack on the first round of turns, even if you have the SPEED spell.",
"*(6E: \"You cannot attack yourself\" is stated in the 6E rulebook; FAQ errata: the first-round restriction should read \"no ATTACKS during the first round\", not \"no combat\" — e.g. Destroy Wall next to someone on turn one is legal.)*",
"*(6E: \"You cannot attack yourself\" is stated in the 6E rulebook. The FAQ's errata say the first-round restriction should read \"no ATTACKS during the first round\" rather than \"no combat\", so a Destroy Wall next to someone on turn one is legal.)*",
"ANY CARD MAY BE PLAYED WITHOUT A NUMBER CARD, but its power is only \"1\". There are no \"1\" NUMBER cards in the deck.",
"When all else fails, you may PUNCH your opponent. This requires no card, but does constitute your attack for the turn. A punch does one point of damage, and you must be in the same square as your opponent to do it. If the object to be hit fills an entire square, you may be in an adjacent square to hit it. It is possible, though time-consuming, to punch a wall down. A wall takes 20 points of damage to destroy; a door takes 15. Any attack against an inanimate object counts as your one attack for the turn. The only inanimate objects you can attack are the walls, doors, and thornbushes.",
"It is possible to walk by an opponent or cast a spell past him without affecting him at all, if you want to.",
+9 -9
View File
@@ -94,14 +94,14 @@ export const HOW_TO_PLAY: RulesSection[] = [
{
title: "Winning",
body: [
"Carry two treasures that aren't yours home — one at a time, to your home square, and drop them there — or be the last wizard standing. Lose both of your own treasures to enemy homes and you're out.",
"Move and play cards in any order, then end your turn. You have three steps and one attack a turn — a step, a spell, two more steps is a fine turn. The banner above the board keeps count of your moves and your treasures home.",
"Carry home two treasures that aren't yours, one at a time, and drop them on your home square. Or be the last wizard standing. If both of your own treasures end up on rival homes, you're out.",
"Each turn you have three steps and one attack, to use in any order. You might take a step, cast a spell and take two more steps, then end your turn. The banner above the board keeps count of your moves and the treasures you have carried home.",
],
},
{
title: "Moving",
body: [
"You can move up to three squares on your turn. Click a square next to your wizard to step there; blocked steps are refused with a reason. Standing on a lettered edge opening lights its arrow green and pulses the square it leads to — click the arrow to step through.",
"You can move up to three squares on your turn. Click a square next to your wizard to step there, and a blocked step is refused with the reason. When you stand on a lettered opening at the board's edge, its arrow lights green and the square it leads to pulses. Click the arrow to step through.",
"To move farther, select a number card and press “Play for +N movement”.",
],
},
@@ -115,23 +115,23 @@ export const HOW_TO_PLAY: RulesSection[] = [
{
title: "Fighting",
body: [
"Click an enemy wizard in your square to punch. When you are attacked, the sidebar offers your counteractions — click one to play it, or let the attack resolve.",
"Your creatures are the diamond tokens: click one to select it, click adjacent squares to march it, click a wizard or creature in its square to attack.",
"Click an enemy wizard in your square to punch. When you are attacked, the sidebar offers your counteractions. Click one to play it, or let the attack resolve.",
"Your creatures' tokens are framed in your color. Click one to select it, click the squares beside it to march it, and click a wizard or creature in its square to attack.",
],
},
{
title: "Turns, hands, and games",
body: [
"End your turn with the draw selector set to how many cards you want (up to two). Use “Discard cards…” to throw cards away without playing them.",
"Games are saved move-by-move on the server and survive anything. The lobby lists every game you're in and whose turn it is; the tab title and favicon flip when a turn is yours, and “notify me on my turn” adds browser notifications. “Transfer seat” hands a game to another device with a spoken phrase.",
"Online games are saved on the server move by move, so a closed tab or a dropped connection loses nothing. The lobby lists every game you're in and whose turn it is. The tab title and icon change when a turn is yours, and “notify me on my turn” adds browser notifications. “Transfer seat” hands a game to another device with a short phrase you enter there.",
],
},
{
title: "The automatons",
body: [
"Any empty seat can be filled with an automaton — a clockwork wizard that plays by the same rules and the same information a human seat gets. Add one in the lobby before the game starts, choosing its temperament: a HUNTER marches for treasure, a BERSERKER hunts wizards over gold, a WORRIER keeps its distance and guards what it has. Pick MYSTERY and the maze deals a temperament in secret — you'll have to read its play to guess its mood, and the roster only unmasks it (🎭) when the game ends.",
"The difficulty picker sets how much the clockwork has to work with, never how fairly it plays: an APPRENTICE draws one card a turn and knows a modest spellbook, an ADEPT draws two and defends its gold, and an ARCHMAGE knows every trick in the deck — ambushes, amplifies, walls blasted through, roads denied. Every tier plays its cards correctly; none of them cheats, peeks, or rolls loaded dice.",
"The automatons chat a little, hold grudges not at all, and take exactly one action per second so you can watch them think. If one does something that looks brilliant, it probably learned it from a human at this table.",
"Any empty seat at an online table can be filled with an automaton, a clockwork wizard that plays by the same rules and sees only what a human seat would see. Add one in the lobby before the game starts and choose its temperament. A HUNTER marches for treasure, a BERSERKER hunts wizards over gold, and a WORRIER keeps its distance and guards what it has. Pick MYSTERY and the maze deals a temperament in secret. You'll have to read its play to guess its mood, and the roster unmasks it (🎭) only when the game ends.",
"The difficulty picker sets how much the clockwork has to work with. An APPRENTICE draws one card a turn and knows a modest spellbook, an ADEPT draws two and defends its gold, and an ARCHMAGE knows every trick in the deck: ambushes, amplified spells, walls blasted through, and roads denied. Every tier plays its cards correctly, and none of them cheats, peeks or rolls loaded dice.",
"The automatons chat a little, hold no grudges, and take one action a second so you can watch them think. If one does something that looks brilliant, it probably learned it from a human at this table.",
],
},
];