19 Commits
Author SHA1 Message Date
Eric WagonerandClaude Opus 5.5 9500b8062a Safari's masked extension frames stay out of Sentry
Safari reports an extension's scripts as webkit-masked-url://hidden/, so the
extension filter never matched them; WIZWAR-17 was one such failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:17:02 -05:00
Eric WagonerandClaude Opus 5.5 e3681cd703 The family list welcomes Wyrm, and Dip seats as many as its map allows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:57:07 -05:00
Eric WagonerandClaude Opus 5.5 e2194a49e7 Table talk replayed from the chronicle wears its speaker's color
A rejoin rebuilds chat from tableTalk events, whose player was not counted
as the actor, so those lines took the turn owner's mark instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:49:39 -05:00
Eric WagonerandClaude Opus 5.5 b0993032f7 Chaos asks for your answer in a modal, as the other out-of-turn choices do
The shield-or-surrender question was a slip in the sidebar, below the
board on a phone, and a player who missed it left the table waiting.
It now takes the centre of the screen like a Ward or a giant's push:
who cast it, what it does, a Full Shield button when you hold one, and
the way through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:56:32 -04:00
Eric WagonerandClaude Opus 5.5 f372b0a3ca A fire imp asks for no orders, and Chaos asks for no shield you do not hold
The creature slip offered "attack ready" for every creature you control,
but only trolls, skeletons and shadows strike on command; a fire imp
neither moves nor takes orders, so it no longer awaits any. The Chaos
slip told every player to tap a Full Shield; a hand without one is now
told so and offered only the way through.

The duel skill gains game fifteen's lessons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:54:00 -04:00
Eric WagonerandClaude Opus 5.5 3a9f39780b A wallet that lost the seat last sat in takes it back on load
The cap that dropped the newest seat left wallets without the very seat
their browser was sitting in, so a reload landed in the Peanut Gallery.
That seat is also kept on its own; loading the wallet now restores it
from there when it is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 12:07:13 -04:00
Eric WagonerandClaude Opus 5.5 bca4d5eba7 The seat wallet's cap of fifty drops the oldest seats, not the newest
Seats are kept newest first, but the cap kept the tail, so a browser
holding more than fifty threw away the seat it had just taken. Sitting
in that room hid it (each room message remembers the seat again); the
first games poll after moving to another table lost it for good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:13:09 -04:00
Eric WagonerandClaude Opus 5.5 29025f2ccb A rejoin's replayed chronicle replaces the one the table already holds
When a socket dropped and came back, the server sent the whole chronicle
again and the client appended it, so every turn was counted twice and
the instant-replay eyes asked for turns the server had not reached ("no
such turn yet"). The talk count doubled the same way and never reset
between rooms; it now resets with the chronicle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:01:04 -04:00
Eric WagonerandClaude Opus 5.5 0ced49ca6c A wall beyond the door you stand at and have unlocked is in sight for wall-aimed spells
Edge casts judged sight on the bare board, where every door hangs shut,
so the threshold peek (an unlocked, held, removed or pickable door at
your elbow) reached only square-aimed spells. Ramses (BDFS) unlocked a
door with MASTER KEY and could not STONE TO WATER the wall beyond it
until he stepped through. Edge casts now see with the caster's eye.
Widening only: refused casts never entered ledgers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:10:56 -04:00
Eric WagonerandClaude Opus 5.5 8dcdfd2415 Mist shields its wizard from creatures; a glued square turns with its sector; Waterbolt splits the power it is cast with
Three of Ramses's reports from KFJL:
- Rev 28: MIST-BODY's "may not attack or be attacked" holds against
  creatures. A wraith's or democratic monster's touch, a fire imp's
  scorch, and an ordered troll/skeleton/shadow blow all pass the mist by;
  older games keep the strike their ledgers recorded. The automaton no
  longer orders its creatures at a misted wizard.
- ROTATE and RELOCATE SECTOR threw while GLUE was down: GLUE keeps its
  cell's key in the sustained effect's edge slot, and the remap read it
  as an edge. It maps as a cell now (the throw never reached a ledger,
  so this ships ungated).
- WATERBOLT's damage/knockback split was sized by the NUMBER's face; the
  engine weighs POWERSTONE's +1 and AMPLIFY's doubling, so a 5 behind a
  powerstone was refused for not totalling 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:54:43 -04:00
Eric WagonerandClaude Opus 5.5 3368a92cf5 A seat link is cut from a report to the end of its string, so a name with a quote, an ampersand or a hash in it cannot end the cut before the token
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-17 20:14:41 -04:00
Eric WagonerandClaude Opus 5.5 6606853332 A seat link's token is cut from an error report and its breadcrumbs before it goes to Sentry, since the history breadcrumb for taking the link out of the address bar still names it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-17 20:12:49 -04:00
Eric WagonerandClaude Opus 5.5 c984bb4b56 A crash report's strings reach the log on one line: anyone may post one, and a line break could otherwise forge another log line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-16 17:06:24 -04:00
Eric WagonerandClaude Opus 5.5 956c908867 Chromium's crash reports reach the server, which logs each and files it in Sentry as a warning, and the page's own errors name the room and seat and leave extensions' failures out
A tab that crashes takes the page's error reporting down with it, so the
page never says so. Every page now names a default Reporting API endpoint,
/api/reports, where Chromium sends a crash report after the fact; the
server logs the room, the reason when one is given, and the browser, and
files a warning that rings no bell. Other report types are ignored, and an
address may send ten an hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-16 17:03:14 -04:00
Eric WagonerandClaude Opus 5.5 9a1c98ca55 The host's phrase button says it makes a transfer phrase, and its tooltip says who it is for and how the player uses it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-16 15:54:54 -04:00
Eric WagonerandClaude Opus 5.5 4b7e05713d The host can give a stuck player's seat to the clockwork and make a phrase that brings a seat back, and a socket in a background tab no longer drops every minute
A "seats" button beside "transfer seat" opens the host's panel. In a game
under way, "give to the clockwork" lets an automaton play a person's seat
from the next beat, with their hand, treasures and place untouched; a
standIn line on the ledger records it, the chronicle says so, the
stand-in never talks under the person's name, and the person's token
opens nothing after. "Make a phrase" mints the four-word phrase for any
person's seat; claiming it re-seats on a new token, and a seat the
clockwork stood in for goes back to its person mid-turn. A rematch holds
a stood-in seat for its person. The gallery now has its own claim box,
for a player who opens the invite link in a fresh browser.

The client's watchdog pinged and then closed a socket quiet for 45
seconds. A background tab's timers run about once a minute, so the last
pong was always a minute old when it looked, and idle tabs reconnected
every 58 seconds. It now closes only when its own ping went unanswered.
The server pings every socket at the protocol level every 30 seconds and
drops one that left the last ping unanswered, so a crashed browser's
socket no longer lingers until TCP gives up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-16 15:43:24 -04:00
Eric WagonerandClaude Opus 5.5 7ef0c1bc14 On a phone, the table fits the screen and another wizard's turn says whose it is
The masthead's links never wrapped, so a game widened a phone's page to
nearly 500px: the page scrolled sideways and the opening roll-off sat off
its centre. And while an automaton's opening turn played back, the rail
that says "Automaton is taking their turn" sat below the fold, leaving a
board that moved on its own. Of 132 bot-first games, 13 humans never made
a move. Now the links wrap, and a caption over the board names the wizard
whose turn it is, and says when yours is next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:35:39 -04:00
Eric WagonerandClaude Opus 5.5 5ea0d4fdec The keeper can bring back a seat a browser lost: a reseat line on the ledger gives that wizard's seat a new token and the old one opens nothing after, wizwar-reseat.sh on the host asks for it through a keeper door that listens on the loopback alone and opens only to a key the deploy makes once, and the link it prints carries the seat in the fragment, which leaves the address bar at once and is kept only when no seat held in this browser still opens and the server takes it; a gallery watching a game in play is told both ways back, the front page asks before forgetting a game not yet finished, and the server's tests now run with the rest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:33:04 -04:00
Eric WagonerandClaude Opus 5.5 1b9acfd7c4 The family list carries the kit's current pitches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 09:29:58 -04:00
26 changed files with 1184 additions and 58 deletions

No files matched your search

+23
View File
@@ -255,6 +255,29 @@ probing legality is free, so when unsure, try it and read the error.
on WARP LANDING squares hit players who never see the token coming.
That placement doctrine is proven; repeat it.
## Game-fifteen scars (six seats, lost to a two-life walk home)
- **Same victim twice, again — and the kill feeds the carrier.**
Kestrel banked Automaton IV's first chest, his troll killed IV, and
IV's cards passed to him — the hand that kept him alive on two life
long enough to walk IV's second chest home (the game-nine lesson,
taught a second time). A wizard's death is a card windfall for its
killer: when a weak player falls to someone carrying, price the loot
into his survival. When a human has one banked, find the victim whose
LAST floor chest sits nearest him and guard THAT square.
- **The interception window is the turn BEFORE the grab.** Once he held
the chest four steps from home, nothing I could reach could see his
road. Run the sight-and-block scan against his likely grab squares
every turn he has one banked, not after he picks up.
- **CHAOS scrambles your own tools.** I cast it to strip his numbers and
lost both pick-locks my door route needed; my whole plan was a hand
I no longer held. Cast it only when your plan does not depend on the
hand you are giving away.
- Stone and thornbush as blockers worked twice against a bot carrier
(8 -> 11, then 8 -> 22 steps): a bot BFS reroutes rather than wades,
and buys real turns. The block scan (my reach x visible empty squares
x carrier's new distance) is the tool; keep it in the scratchpad.
## The passivity audit (Eric, after game thirteen)
- "You did nothing but draw for several turns" — true, and fatal. A
+16
View File
@@ -52,4 +52,20 @@ longer replays becomes unreachable after restart, so this is not optional.
`setup-droplet.sh` does NOT install it — on a fresh droplet, copy the
script, configure rclone (the script refuses to run while the config still
holds its CHANGE_ME placeholder), and add the cron entry by hand.
- A player whose browser lost their seat (Safari's seven days, an app's own
browser, cleared data) asks the host, whose "seats" button makes a
four-word phrase that re-seats them on a new token. The same panel lets the
host give a seat to the clockwork in a game under way, for a player who
cannot take their turns; a `standIn` line on the ledger records it, and a
later reseat gives the seat back. Failing the host, they write to the
keeper. On the droplet,
`wizwar-reseat.sh ROOM` lists who sits at the table and when each last
moved or spoke; `wizwar-reseat.sh ROOM NAME` gives that wizard's seat a new
token and prints a link carrying it in the fragment. The old token opens
nothing after. The script answers through the game server's keeper door,
a listener of its own on 127.0.0.1:9787 that Caddy never reaches, which
opens only to the key in /etc/wizwar/keeper.key (root and the wizwar user
alone can read it, so the other services on the host cannot use the door).
The first deploy makes the key, and every deploy installs the script at
/usr/local/bin.
- One-off backup: `scp -r root@<ip>:/var/lib/wizwar/rooms ./rooms-backup`
+8
View File
@@ -25,6 +25,14 @@ ssh "root@$HOST" '
install -m 755 /opt/wizwar/deploy/wizwar-backup.sh /usr/local/bin/wizwar-backup.sh
install -m 755 /opt/wizwar/deploy/wizwar-rollup.sh /usr/local/bin/wizwar-rollup.sh
install -m 755 /opt/wizwar/deploy/wizwar-visitors.sh /usr/local/bin/wizwar-visitors.sh
install -m 755 /opt/wizwar/deploy/wizwar-reseat.sh /usr/local/bin/wizwar-reseat.sh
# The key to the keeper door: made once, readable by root (the reseat
# script) and the wizwar user (the server) alone.
if [ ! -s /etc/wizwar/keeper.key ]; then
install -d -m 750 -o root -g wizwar /etc/wizwar
(umask 037; openssl rand -hex 32 > /etc/wizwar/keeper.key)
chown root:wizwar /etc/wizwar/keeper.key
fi
# On a host the hall shares, its hall-nightly.sh runs the rollup and backup
# of every game and checks in with Sentry once for all; a game on a box of
# its own schedules its own.
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# A way back for a player whose browser lost their seat: the seat gets a new
# token, the old one stops working, and the link printed carries the new one.
# Whether the one asking holds that seat is the keeper's call; send the link
# only to them. Runs ON the droplet, through the game server's keeper door,
# which listens on the loopback alone.
# wizwar-reseat.sh ROOM who sits at the table, and when each person last moved or spoke
# wizwar-reseat.sh ROOM NAME re-seat the wizard NAME and print the link
set -euo pipefail
ROOM="${1:?usage: wizwar-reseat.sh ROOM [NAME]}"
DOOR="http://127.0.0.1:${KEEPER_PORT:-9787}/keeper/rooms/${ROOM^^}"
KEY_FILE="${KEEPER_KEY_FILE:-/etc/wizwar/keeper.key}"
[ -s "$KEY_FILE" ] || { echo "No keeper's key at $KEY_FILE; a deploy makes it." >&2; exit 1; }
# The key goes as a header read from a file descriptor, never on a command line another user could see.
KEY="@/dev/fd/3"
exec 3< <(printf 'x-keeper-key: %s\n' "$(cat "$KEY_FILE")")
if [ $# -lt 2 ]; then
curl -s -H "$KEY" "$DOOR" | python3 -c '
import json, sys
r = json.load(sys.stdin)
if "error" in r: sys.exit(r["error"])
print("room %s, %s" % (r["roomId"], "finished" if r["finished"] else "in play" if r["started"] else "not begun"))
for s in r["seats"]:
when = "automaton" if s["bot"] else "never moved or spoke" if s["lastActed"] is None else "last acted " + s["lastActed"][:16].replace("T", " ") + " UTC"
print(" %-24s %-5s %s" % (s["name"], "host" if s["host"] else "", when))
'
else
python3 -c 'import json, sys; print(json.dumps({"seat": sys.argv[1]}))' "$2" \
| curl -s -X POST -H "$KEY" -H 'content-type: application/json' --data-binary @- "$DOOR/reseat" | python3 -c '
import json, sys
r = json.load(sys.stdin)
if "error" in r: sys.exit(r["error"])
print("%s has a new token; their old one opens nothing now. Send them this link:" % r["name"])
print(r["link"])
'
fi
+9 -3
View File
@@ -41,6 +41,7 @@ for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"):
ca = meta.get("createdAt", "")
if not ca: continue
humans = [meta["hostId"]]; bots = []; started = False; cmds = 0; chat = []; last = ca; steps = []
standing = set(); stood = [] # seats the host gave the clockwork, and who and when
for l in lines[1:]:
try: x = json.loads(l)
except Exception: continue
@@ -48,15 +49,19 @@ for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"):
if k == "join":
(bots if x.get("bot") else humans).append(x["name"])
elif k == "start": started = True
elif k == "standIn":
standing.add(x["name"]); stood.append((x.get("at", "")[11:16], x["name"], "to the clockwork"))
elif k == "reseat" and x["name"] in standing:
standing.discard(x["name"]); stood.append((x.get("at", "")[11:16], x["name"], "back to a person"))
elif k == "command":
cmds += 1; last = x.get("at", last)
steps.append((x.get("playerId"), x["command"].get("type"), x.get("at", "")))
steps.append((x.get("playerId"), x["command"].get("type"), x.get("at", ""), x.get("playerId") in standing))
elif k == "chat":
last = x.get("at", last)
if x["player"] in humans: chat.append((x.get("at", "")[11:16], x["player"], x["text"]))
for h in humans:
if h not in first_seen or ca < first_seen[h]: first_seen[h] = ca
rooms.append(dict(id=meta["id"], created=ca, humans=humans, bots=bots, started=started, cmds=cmds, last=last, chat=chat, steps=steps))
rooms.append(dict(id=meta["id"], created=ca, humans=humans, bots=bots, started=started, cmds=cmds, last=last, chat=chat, steps=steps, stood=stood))
today = sorted([r for r in rooms if r["created"].startswith(day)], key=lambda r: r["created"])
names = sorted({h for r in today for h in r["humans"]})
@@ -68,12 +73,13 @@ for r in today:
if r["cmds"]:
t0 = datetime.datetime.fromisoformat(r["created"].replace("Z", "+00:00")); t1 = datetime.datetime.fromisoformat(r["last"].replace("Z", "+00:00"))
span = "%d min" % round((t1 - t0).total_seconds() / 60)
human_cmds = [s for s in r["steps"] if s[0] in r["humans"]]
human_cmds = [s for s in r["steps"] if s[0] in r["humans"] and not s[3]]
last_human = ("last human move: %s %s at %s" % (human_cmds[-1][0], human_cmds[-1][1], human_cmds[-1][2][11:16])) if human_cmds else "no human move"
state = "lobby only" if not r["started"] else ("started, no moves" if not r["cmds"] else "in play")
print(" %s %s %s%s %s %d moves (%d human) %s %s" % (
r["created"][11:16], r["id"], "+".join(r["humans"]), (" vs %d bot%s" % (len(r["bots"]), "" if len(r["bots"]) == 1 else "s")) if r["bots"] else "",
state, r["cmds"], len(human_cmds), span, last_human))
for at, who, what in r["stood"]: print(" %s %s's seat goes %s" % (at, who, what))
for at, who, text in r["chat"]: print(" %s %s: %s" % (at, who, text))
# --- the desk ---
+3
View File
@@ -9,6 +9,9 @@ WorkingDirectory=/opt/wizwar/packages/server
Environment=PORT=8787
# Caddy terminates TLS; the plaintext port must not face the internet.
Environment=HOST=127.0.0.1
# The keeper's door (deploy/wizwar-reseat.sh) binds the loopback whatever HOST says.
Environment=KEEPER_PORT=9787
Environment=KEEPER_KEY_FILE=/etc/wizwar/keeper.key
Environment=WIZWAR_DATA_DIR=/var/lib/wizwar/rooms
Environment=WIZWAR_STATIC_DIR=/opt/wizwar/packages/web/dist
Environment=WIZWAR_KEEPER_TZ=America/New_York
+2 -1
View File
@@ -2759,7 +2759,8 @@
"@types/node": "^22.0.0",
"@types/ws": "^8.5.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
},
"packages/web": {
+3 -1
View File
@@ -1472,7 +1472,9 @@ export function automatonCommand(
// Command the menagerie: creatures march and maul before the wizard moves.
for (const c of view.creatures) {
if (c.controllerId !== you || c.justCreated) continue;
const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position));
const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position) &&
// Rev 28: creatures cannot strike the mist either.
!(view.deckRev >= 28 && view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === p.id)));
if (enemyHere && !c.attackUsed &&
(c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) {
return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id };
+19 -2
View File
@@ -247,7 +247,7 @@ export interface CastParams {
}
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 27;
export const CURRENT_RULES_REV = 28;
/** Every rulings revision since the baseline, newest last — the entries a
* game's deckRev freezes it before or after. Shown to players as the house
@@ -279,6 +279,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
{ rev: 25, note: "An attack on a bush or the ooze is an attack on an inanimate object like any wall: POWER ATTACK's bought points count, and a thrown DAGGER or LARGE ROCK lands at the foot of the bush instead of vanishing into the discard. Older games took neither." },
{ rev: 26, note: "BUTT-HEAD's ram is physical damage like any other: STRENGTH doubles it, AMPLIFY doubles it, WEAKNESS doubles it, POWER ATTACK adds to it, and ABSORB or BLUNT soak it. Older games dealt the bare distance, whatever rode the cast." },
{ rev: 27, note: "An ILLUSIONARY ATTACK is priced as the spell it fakes, so a BLUNT halves it, an ABSORB soaks it, and a shield stops it, exactly as against the real thing; the belief roll then decides whether the weighed blow lands at all. Older games dealt a believed illusion's full points past every counteraction." },
{ rev: 28, note: "MIST-BODY's \"may not be attacked\" holds against creatures too: a wraith's chill or a democratic monster's claw passes through a misted wizard, a fire imp's scorch finds nothing to burn, and no troll, skeleton or shadow may be ordered to strike one. Older games let creatures attack the mist." },
];
export interface GameConfig {
@@ -598,6 +599,9 @@ function castSightEdge(
cell: Cell,
side: Side,
): boolean {
// The caster's own eye, as for square-aimed casts: a held door stands
// open, and the door at their elbow they may pull ajar and look through.
board = doorsAjar(state, caster.id, openedDoors(state, board));
if (losToEdge(board, caster.position, cell, side)) return true;
if (displays(caster, "visionstone") &&
throughOneEdge(board, (b) => losToEdge(b, caster.position, cell, side))) {
@@ -624,6 +628,12 @@ function isMisted(state: GameState, playerId: PlayerId): boolean {
return sustainedOn(state, playerId, "mist-body").length > 0;
}
/** Rev 28: MIST-BODY — "You may not attack or be attacked" — shields its
* wizard from creatures as from wizards. Older games let them strike. */
function mistShieldsFromCreatures(state: GameState, p: PlayerState): boolean {
return (state.config.deckRev ?? 1) >= 28 && isMisted(state, p.id);
}
function isLockedInPlace(state: GameState, playerId: PlayerId): boolean {
return sustainedOn(state, playerId, "lock-in-place").length > 0;
}
@@ -706,6 +716,9 @@ export type GameEvent =
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
| { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string }
| { type: "tableTalk"; player: PlayerId; text: string }
/** The room's word, not the maze's: the host gave a person's seat to the
* clockwork, or the seat went back to a person. */
| { type: "seatChanged"; player: PlayerId; to: "clockwork" | "person"; by?: PlayerId }
| { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell }
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
@@ -3185,6 +3198,7 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
if (onlyPlayer && p.id !== onlyPlayer) continue;
if (imp.justCreated && p.id === imp.controllerId) continue; // not its creator on creation turn
if (imp.scorchedThisTurn.includes(p.id)) continue;
if (mistShieldsFromCreatures(state, p)) continue;
// "Imp cannot see through a FIREWALL!" — gameLos already blocks on
// firewall edges and terrain.
if (!gameLos(state, imp.position, p.position)) continue;
@@ -3218,6 +3232,7 @@ function creatureTouchOnArrival(state: GameState, events: GameEvent[], creature:
for (const p of state.players) {
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
if (mistShieldsFromCreatures(state, p)) continue;
if (creature.kind === "wraith" && !creature.attackUsed) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
@@ -3429,6 +3444,7 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
return err("the creature must share its target's square");
}
if (targetPlayer && targetPlayer.id === creature.controllerId) return err("it will not hurt its creator");
if (targetPlayer && mistShieldsFromCreatures(state, targetPlayer)) return err("your target is mist and cannot be attacked");
creature.attackUsed = true;
let amount: number;
@@ -3568,7 +3584,8 @@ function remapState(
state.slimeTraps = remapRecord(state.slimeTraps, mapCellKey);
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
for (const fx of state.sustained) {
if (fx.edge) fx.edge = mapEdgeKey(fx.edge);
// GLUE keeps its glued cell's key where edge spells keep an edge's.
if (fx.edge) fx.edge = fx.cardId === "glue" ? mapCellKey(fx.edge) : mapEdgeKey(fx.edge);
}
for (const p of state.players) {
if (inSector(p.position)) p.position = mapCell(p.position);
+2 -2
View File
@@ -1021,7 +1021,7 @@ describe("interception, escape, and hazard sense", () => {
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "DC", target: { kind: "cell" } });
const r = applyCommand(state, "bot", cmd!);
expect(r.ok).toBe(true);
if (!r.ok) throw new Error(r.error);
// The box gone, the very next thought is the grab.
const next = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
expect(next).toMatchObject({ type: "pickUpTreasure" });
@@ -1050,7 +1050,7 @@ describe("interception, escape, and hazard sense", () => {
cmd = automatonFallback(view, "archmage");
r = applyCommand(state, seat, cmd);
}
expect(r.ok).toBe(true);
if (!r.ok) throw new Error(`${seat}: ${r.error}`);
state = r.state;
ended = seat === "bot" && cmd.type === "endTurn";
}
+61
View File
@@ -1044,3 +1044,64 @@ describe("the troll's fist on a wall", () => {
expect(again.ok).toBe(false);
});
});
describe("creatures cannot strike the mist (rev 28)", () => {
// MIST-BODY: "You may not attack or be attacked." Rev 28 holds the
// card against creatures as well as wizards; older games let them strike.
function mistRig(deckRev?: number) {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
state = toRound2(state);
const creator = activePlayer(state);
state = summon(state, creator.id, "democratic-monster").state;
state = must(state, creator.id, { type: "endTurn", draw: 0 });
const mover = activePlayer(state);
const victim = state.players.find((p) => p.id !== mover.id)!;
pushSustained(state, { id: "fx-mist", cardId: "mist-body", casterId: victim.id, targetId: victim.id, remainingTurns: 2 });
const dm = state.creatures.find((c) => c.kind === "democratic-monster")!;
const spot = emptyNeighborCell(state, victim.position);
dm.position = { ...spot.cell };
return { state, mover: mover.id, victim: victim.id, dm: dm.id, into: opposite(spot.side) };
}
it("a democratic monster passes through a misted wizard without clawing", () => {
const { state, mover, dm, into } = mistRig();
const r = applyCommand(state, mover, { type: "moveCreature", creatureId: dm, direction: into });
if (!r.ok) throw new Error(r.error);
expect(r.state.stack).toBeNull();
expect(r.events.some((e) => e.type === "creatureTouched")).toBe(false);
expect(r.state.creatures.find((c) => c.id === dm)!.attackUsed).toBe(false);
});
it("older games let the monster claw the mist", () => {
const { state, mover, dm, into } = mistRig(27);
const r = applyCommand(state, mover, { type: "moveCreature", creatureId: dm, direction: into });
if (!r.ok) throw new Error(r.error);
expect(r.state.stack?.creatureId ?? null).toBe(dm);
});
function skeletonRig(deckRev?: number) {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
state = toRound2(state);
const me = activePlayer(state).id;
state = summon(state, me, "skeleton").state;
state = must(state, me, { type: "endTurn", draw: 0 });
const other = activePlayer(state).id;
state = must(state, other, { type: "endTurn", draw: 0 });
const victim = state.players.find((p) => p.id === other)!;
victim.position = { ...state.creatures[0]!.position };
pushSustained(state, { id: "fx-mist", cardId: "mist-body", casterId: other, targetId: other, remainingTurns: 2 });
return { state, me, victim: other, skeleton: state.creatures[0]!.id };
}
it("no skeleton may be ordered to strike a misted wizard", () => {
const { state, me, victim, skeleton } = skeletonRig();
const r = applyCommand(state, me, { type: "creatureAttack", creatureId: skeleton, targetId: victim });
expect(r.ok).toBe(false);
});
it("older games let the order stand", () => {
const { state, me, victim, skeleton } = skeletonRig(27);
const r = applyCommand(state, me, { type: "creatureAttack", creatureId: skeleton, targetId: victim });
expect(r.ok).toBe(true);
});
});
@@ -458,6 +458,26 @@ describe("a held door is an open doorway to the eye", () => {
expect(cast.ok).toBe(true);
});
it("a wall beyond the unlocked door is in sight for wall-aimed spells too (BDFS)", () => {
// Edge casts once judged sight on the bare board, where every door
// hangs shut — the threshold peek reached only square-aimed spells.
let { state, cell, side, holder } = sightRig();
const near = neighbor(cell, side);
const view = boardView(state);
const far = SIDES.find((s) => s !== opposite(side) && view.edges[edgeKey(near, s)] === "wall");
if (!far) throw new Error("setup: no wall on the far side of seed 42's door");
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
type: "cast", instanceId: pick.instanceId,
target: { kind: "edge", cell, side },
});
const dw = giveCard(state, holder, "destroy-wall", "DW", 1);
const cast = applyCommand(state, holder, {
type: "cast", instanceId: dw.instanceId, target: { kind: "edge", cell: near, side: far },
});
expect(cast.ok).toBe(true);
});
it("a lock REMOVED lets any wizard beside the door peek through", () => {
let { state, cell, side, holder, pursuer } = sightRig();
const rm = giveCard(state, holder, "remove-lock");
@@ -331,6 +331,27 @@ describe("sector manipulation", () => {
}
});
it("a glued treasure turns with its sector, and the glue turns with it (KFJL)", () => {
// GLUE keeps its cell's key in the sustained effect's edge slot; the
// rotation once read it as an edge key and threw.
let { state } = newGame();
const me = activePlayer(state);
const chest = state.treasures.find((t) => t.owner === me.id)!;
const key = cellKey(chest.position!);
state.gluedCells[key] = true;
state.sustained.push({
id: "fx-glue", cardId: "glue", casterId: me.id, targetId: me.id, remainingTurns: 4, data: {}, edge: key,
});
const rs = giveCard(state, me.id, "rotate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rs.instanceId,
target: { kind: "cell", cell: me.position }, params: { clockwise: true },
});
const moved = cellKey(state.treasures.find((t) => t.id === chest.id)!.position!);
expect(state.gluedCells).toEqual({ [moved]: true });
expect(state.sustained.find((s) => s.id === "fx-glue")!.edge).toBe(moved);
});
it("relocate sector slides everything and keeps adjacency", () => {
let { state } = newGame(); // 2 players: 5x10 column, sectors at y=0 and y=5
const me = activePlayer(state);
+4 -2
View File
@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@sentry/node": "^10.73.0",
@@ -16,6 +17,7 @@
"@types/node": "^22.0.0",
"@types/ws": "^8.5.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
+135 -4
View File
@@ -64,6 +64,7 @@ import {
GALLERY_MARK,
driveOneAutomaton,
makeTransferCode,
makeSeatPhrase,
redactFor,
roomCount,
runCommand,
@@ -74,6 +75,7 @@ import {
viewForPlayer,
type Room,
kickSeat,
standIn,
abandonRoom,
KEEPER,
MAX_SEATS,
@@ -81,6 +83,7 @@ import {
import { engagementStats, recordHotseat } from "./stats";
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store";
import { clipsIndexHtml, clipPageHtml } from "./clips";
import { openKeeperDoor } from "./keeper";
import { SlidingLimit, clientAddress } from "./ratelimit";
import { getShare, loadShares, mintShare } from "./shares";
import { renderSharePng } from "./ogimage";
@@ -110,6 +113,52 @@ const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
/** Screenshots for the desk: a few per address per hour, like reports. */
const imagesPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
const IMAGE_MAX_BYTES = 2_500_000;
/** Crash reports from browsers: a crashing tab reloads and crashes again, so a few per address per hour say all there is. */
const crashReportsPerAddress = new SlidingLimit(10, 60 * 60 * 1000);
const CRASH_REPORT_MAX_BYTES = 64 * 1024;
/** Anyone may post a report, so its strings reach the log on one line,
* with no control characters to forge another. */
function printable(value: unknown, max: number): string {
return String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, max);
}
/** Chromium sends the Reporting API's crash reports here, after the fact,
* from the browser itself: a tab that crashes takes the page's own error
* reporting down with it. Each crash is logged and filed in Sentry as a
* warning, which rings no bell. Other report types are ignored. */
function receiveBrowserReports(req: IncomingMessage, res: ServerResponse): void {
const address = clientAddress(req.headers, req.socket.remoteAddress);
let raw = "";
let tooBig = false;
req.setEncoding("utf8");
req.on("data", (chunk: string) => {
if (tooBig) return;
raw += chunk;
if (raw.length > CRASH_REPORT_MAX_BYTES) tooBig = true;
});
req.on("end", () => {
res.writeHead(204).end();
if (tooBig) return;
let reports: unknown;
try { reports = JSON.parse(raw); } catch { return; }
if (!Array.isArray(reports)) return;
for (const r of reports as { type?: unknown; url?: unknown; user_agent?: unknown; body?: Record<string, unknown> }[]) {
if (r?.type !== "crash" || !crashReportsPerAddress.allow(address)) continue;
const url = printable(r.url, 200);
const room = url.match(/\/join\/([A-Za-z0-9]{4})/)?.[1]?.toUpperCase() ?? "none";
const reason = typeof r.body?.reason === "string" ? printable(r.body.reason, 40) : "unknown";
const userAgent = printable(r.user_agent, 300);
console.warn(`browser tab crashed: room ${room}, reason ${reason}, ${userAgent}`);
Sentry.captureMessage("A player's browser tab crashed", {
level: "warning",
fingerprint: ["browser-crash", reason],
tags: { room, reason },
extra: { url, userAgent, body: r.body ?? null },
});
}
});
}
/** A picture for a report, POSTed right after the report is filed: one
* per report, within the hour, PNG/JPEG/WebP by its own first bytes, kept
@@ -369,6 +418,7 @@ const httpServer = createServer((req, res) => {
if (req.method === "POST") {
const picture = (req.url ?? "").split("?")[0]!.match(/^\/api\/feedback-image\/([0-9a-f]{8})$/);
if (picture) { receiveFeedbackImage(req, res, picture[1]!); return; }
if ((req.url ?? "").split("?")[0] === "/api/reports") { receiveBrowserReports(req, res); return; }
res.writeHead(404).end();
return;
}
@@ -377,6 +427,8 @@ const httpServer = createServer((req, res) => {
return;
}
res.setHeader("x-content-type-options", "nosniff");
// Where Chromium sends a crash report about a tab that showed this page.
res.setHeader("reporting-endpoints", `default="${requestBase(req)}/api/reports"`);
if (!staticRoot) {
res.writeHead(404).end("client not built");
return;
@@ -535,6 +587,27 @@ const httpServer = createServer((req, res) => {
const wss = new WebSocketServer({ server: httpServer, maxPayload: 64 * 1024 });
httpServer.listen(port, host);
// The keeper's door listens on the loopback alone, on a port of its own, and
// opens only to the key in KEEPER_KEY_FILE (deploy/wizwar-reseat.sh). A
// socket still seated on the old token is closed: its reconnect offers that
// token, and it opens nothing now.
openKeeperDoor({
port: Number(process.env.KEEPER_PORT ?? 9787),
publicUrl: PUBLIC_URL,
keyFile: process.env.KEEPER_KEY_FILE ?? "/etc/wizwar/keeper.key",
onReseat: (room, name, returned) => {
closeSeat(room, name);
if (returned) broadcast(room, () => ({ type: "events", events: [returned] }));
broadcastRoomState(room);
},
});
/** Close every socket still seated as NAME: its reconnect offers the old
* token, which opens nothing now. */
function closeSeat(room: Room, name: PlayerId): void {
for (const s of sessions) if (s.roomId === room.id && s.playerId === name) s.socket.close();
}
interface Session {
socket: WebSocket;
playerId: PlayerId | null;
@@ -554,6 +627,8 @@ interface Session {
roomsCreated: number;
lastCatchUpAt: number;
hotseatReports: number;
/** Answered the last protocol ping. */
alive: boolean;
}
function underRateLimit(s: Session): boolean {
@@ -567,6 +642,21 @@ function underRateLimit(s: Session): boolean {
const sessions = new Set<Session>();
// A browser that crashes or loses its network never closes its socket, and
// TCP may take minutes to notice. Every beat each socket is pinged at the
// protocol level, which browsers answer on their own, and one that left the
// last ping unanswered is dropped.
setInterval(() => {
for (const s of sessions) {
if (!s.alive) {
s.socket.terminate();
continue;
}
s.alive = false;
s.socket.ping();
}
}, Number(process.env.WIZWAR_PING_MS ?? 30_000));
function send(socket: WebSocket, message: unknown): void {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
}
@@ -606,12 +696,15 @@ function roomInfo(room: Room) {
keeperZone: KEEPER_ZONE,
galleryTalk: room.galleryTalk,
colors: Object.fromEntries(room.colorChoices),
/** People's seats the clockwork is playing for now. */
standIns: [...room.bots].filter(([, b]) => b.standIn).map(([name]) => name),
bots: Object.fromEntries(
// A mystery machine keeps its mood only while the game lives: once it
// ends, the hands go face-up and so does the temperament.
[...room.bots].map(([name, b]) => [
name,
room.state?.phase === "finished"
b.standIn ? "standing in"
: room.state?.phase === "finished"
? `${b.tier} ${b.style}${b.secret ? " 🎭" : ""}`
: `${b.tier} ${b.secret ? "mystery" : b.style}`,
]),
@@ -665,6 +758,8 @@ function banterTrigger(
* deeds, bystanders on their suffering. Rarely, and one voice at most. */
function botRemark(room: Room, actor: string, events: { type: string; [k: string]: unknown }[]): void {
for (const [seat, bot] of room.bots) {
// A stand-in plays under a person's name, and does not speak for them.
if (bot.standIn) continue;
const lines = BOT_LINES[bot.style] ?? {};
for (const e of events) {
const trigger = banterTrigger(e, seat, actor);
@@ -706,7 +801,7 @@ function runBots(room: Room): void {
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
// A refused brain-choice becomes a visible stumble, in character —
// an idle bot turn should read as hesitation, never as nothing.
if (step.hesitated) {
if (step.hesitated && !room.bots.get(step.seat)?.standIn) {
const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!);
if (!("error" in said)) {
broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at }));
@@ -735,9 +830,10 @@ wss.on("connection", (socket, req) => {
socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0,
address: clientAddress(req.headers, req.socket.remoteAddress),
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0, alive: true,
};
sessions.add(session);
socket.on("pong", () => { session.alive = true; });
// A socket's own error (a frame over the limit, a malformed one) closes that socket, never the server.
socket.on("error", () => socket.terminate());
send(socket, { type: "welcome", game: "wizwar" });
@@ -890,6 +986,35 @@ wss.on("connection", (socket, req) => {
broadcastRoomState(room);
break;
}
case "standIn": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const name = String(msg.name ?? "");
const result = standIn(room, session.playerId, name);
if ("error" in result) return send(socket, { type: "error", message: result.error });
// The person's live sockets are set adrift: the seat plays itself now.
for (const other of sessions) {
if (other.roomId === room.id && other.playerId === name) {
other.playerId = null;
other.roomId = null;
other.token = null;
send(other.socket, { type: "standingIn", roomId: room.id });
}
}
broadcast(room, () => ({ type: "events", events: [result.event] }));
broadcastRoomState(room);
runBots(room);
break;
}
case "makeSeatPhrase": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const name = String(msg.name ?? "");
const result = makeSeatPhrase(room, session.playerId, name);
if ("error" in result) return send(socket, { type: "error", message: result.error });
send(socket, { type: "seatPhrase", name, code: result.code, expiresAt: result.expiresAt });
break;
}
case "abandonRoom": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
@@ -1022,7 +1147,13 @@ wss.on("connection", (socket, req) => {
return send(socket, { type: "error", message: result.error });
}
session.claimFails = 0;
send(socket, { type: "transferClaimed", seat: result });
const claimed = getRoom(result.roomId);
if (result.reseated && claimed) {
closeSeat(claimed, result.name);
if (result.returned) broadcast(claimed, () => ({ type: "events", events: [result.returned] }));
broadcastRoomState(claimed);
}
send(socket, { type: "transferClaimed", seat: { roomId: result.roomId, name: result.name, token: result.token } });
break;
}
case "catchUp": {
+84
View File
@@ -0,0 +1,84 @@
// The keeper's door: a listener of its own on the loopback, apart from the
// port Caddy forwards to, so it answers only on the host itself, and only to
// the key in its key file, so another service on a shared host cannot use
// it. Its one caller is deploy/wizwar-reseat.sh, which sends the key:
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
import { timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { GameEvent, PlayerId } from "@wizwar/engine";
import { getRoom, reseat, seatsForKeeper, type Room } from "./rooms";
function send(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
}
/** The key is read on each knock, so a key written after the server started is honoured. Absent, the door stays shut. */
function keyGiven(req: IncomingMessage, keyFile: string): boolean {
let key: Buffer;
try {
key = Buffer.from(readFileSync(keyFile, "utf8").trim());
} catch {
return false;
}
const given = Buffer.from(String(req.headers["x-keeper-key"] ?? ""));
return key.length > 0 && given.length === key.length && timingSafeEqual(given, key);
}
async function readSeat(req: IncomingMessage): Promise<string> {
let raw = "";
for await (const chunk of req) {
raw += chunk;
if (raw.length > 4096) return "";
}
try {
const seat = (JSON.parse(raw) as { seat?: unknown }).seat;
return typeof seat === "string" ? seat : "";
} catch {
return "";
}
}
export interface KeeperDoor {
port: number;
publicUrl: string;
keyFile: string;
/** Lets the caller set adrift any socket still seated on the old token,
* and tell the table when the seat comes back from the clockwork. */
onReseat: (room: Room, name: PlayerId, returned?: GameEvent) => void;
}
async function handle(req: IncomingMessage, res: ServerResponse, { publicUrl, keyFile, onReseat }: KeeperDoor): Promise<void> {
const parts = (req.url ?? "/").split("?")[0]!.split("/").filter(Boolean);
// Nothing proxied belongs here: a forwarded request is turned away even if a proxy is someday pointed at this port.
if (req.headers["x-forwarded-for"] || !keyGiven(req, keyFile)) return send(res, 404, { error: "not here" });
if (parts[0] !== "keeper" || parts[1] !== "rooms" || !parts[2]) return send(res, 404, { error: "not here" });
const room = getRoom(parts[2]);
if (!room) return send(res, 404, { error: "no such room" });
if (parts.length === 3 && req.method === "GET") {
return send(res, 200, { roomId: room.id, started: room.state !== null, finished: room.state?.phase === "finished", seats: seatsForKeeper(room) });
}
if (parts.length === 4 && parts[3] === "reseat" && req.method === "POST") {
const name = await readSeat(req);
const result = reseat(room, name);
if ("error" in result) return send(res, 404, result);
onReseat(room, name, result.returned);
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor the server.
return send(res, 200, { name, link: `${publicUrl}/join/${room.id}#seat=${encodeURIComponent(name)}.${result.token}` });
}
return send(res, 404, { error: "not here" });
}
/** Open the door on 127.0.0.1, whatever address the game itself listens on. */
export function openKeeperDoor(door: KeeperDoor): Server {
const server = createServer((req, res) => {
handle(req, res, door).catch((e) => {
console.error("keeper door:", e);
if (!res.headersSent) send(res, 500, { error: "internal error" });
});
});
server.listen(door.port, "127.0.0.1");
return server;
}
+115 -10
View File
@@ -49,8 +49,9 @@ export interface Room {
events: GameEvent[]; // full history (unredacted — redact per recipient)
/** Table talk, persisted with the room (public to all seats). */
chat: { player: PlayerId; text: string; at: string }[];
/** Seats the server itself plays: temperament, tier, and secrecy. */
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
/** Seats the server itself plays: temperament, tier, and secrecy. A
* stand-in plays a person's seat the host handed over mid-game. */
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier; standIn?: true }>;
/** Last time anything looked at this room (memory eviction clock). */
touchedAt?: number;
/** A finished table that called for a rematch: where it went, and who called. */
@@ -173,9 +174,10 @@ export function callRematch(
if (room.state?.phase !== "finished") return { error: "the game is not over yet" };
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "only a wizard of this table may call a rematch" };
if (room.rematch) return { roomId: room.rematch.roomId, token: null, created: false };
const humans = room.players.filter((p) => !room.bots.has(p) && p !== byId);
// A seat the clockwork stood in for is still a person's, and is held for them.
const humans = room.players.filter((p) => personSeat(room, p) && p !== byId);
const { room: next, token } = createRoom(byId, { of: room.id, expected: humans, expansion: room.expansion });
for (const [, b] of room.bots) addAutomaton(next, b.secret ? undefined : b.style, b.tier);
for (const [, b] of room.bots) if (!b.standIn) addAutomaton(next, b.secret ? undefined : b.style, b.tier);
room.rematch = { roomId: next.id, by: byId };
appendLine(room.id, { kind: "rematch", to: next.id, by: byId, at: new Date().toISOString() });
return { roomId: next.id, token, created: true };
@@ -299,6 +301,7 @@ export function joinRoom(
token: string | null,
): { token: string } | { error: string } {
if (room.players.includes(playerId)) {
if (room.bots.get(playerId)?.standIn) return { error: "the host gave this seat to the clockwork; ask them for a phrase to take it back" };
// Reclaiming an existing seat requires that seat's secret.
if (tokenMatches(room, playerId, token)) return { token: token! };
return { error: "that wizard name is taken in this room" };
@@ -331,6 +334,78 @@ export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | n
return null;
}
/** A person's seat gets a new token, for a player whose browser lost the
* old one: the keeper's door asks for it, or a phrase the host made is
* claimed. Whether the one asking holds the seat is their judgment; this
* carries it out. The old token opens nothing after, and a seat the
* clockwork was standing in for goes back to a person. */
export function reseat(room: Room, name: PlayerId): { token: string; returned?: GameEvent } | { error: string } {
if (!personSeat(room, name)) return { error: "no person sits in that seat" };
const token = randomBytes(16).toString("hex");
const returned = seatToPerson(room, name, hashToken(token));
appendLine(room.id, { kind: "reseat", name, tokenHash: hashToken(token), at: new Date().toISOString() });
// A phrase minted with the old token would hand over a seat that no longer answers to it.
for (const [code, t] of transfers) if (t.roomId === room.id && t.name === name) transfers.delete(code);
return { token, ...(returned ? { returned } : {}) };
}
/** A seat answers to a new token; one the clockwork was standing in for
* goes back to its person, and the chronicle says so. */
function seatToPerson(room: Room, name: PlayerId, tokenHash: string): GameEvent | undefined {
const wasStanding = room.bots.get(name)?.standIn === true;
room.bots.delete(name);
room.tokens.set(name, tokenHash);
if (!wasStanding) return undefined;
const event: GameEvent = { type: "seatChanged", player: name, to: "person" };
room.events.push(event);
return event;
}
/** The clockwork takes a person's seat, and the chronicle says so. */
function seatToClockwork(room: Room, name: PlayerId, by: PlayerId, style: AutomatonStyle, tier: AutomatonTier): GameEvent {
room.tokens.delete(name);
room.bots.set(name, { style, secret: true, tier, standIn: true });
const event: GameEvent = { type: "seatChanged", player: name, to: "clockwork", by };
room.events.push(event);
return event;
}
/** A seat a person holds, or held until the host gave it to the clockwork. */
function personSeat(room: Room, name: PlayerId): boolean {
const bot = room.bots.get(name);
return bot ? bot.standIn === true : room.tokens.has(name);
}
/** The host gives a person's seat to the clockwork in a game under way, for
* a player who cannot take their turns: the wizard keeps hand, treasures
* and place, and an automaton plays on from here. The person's token opens
* nothing until a reseat gives the seat back. */
export function standIn(room: Room, byId: PlayerId, name: PlayerId): { event: GameEvent } | { error: string } {
if (byId !== room.hostId) return { error: "only the host may give a seat to the clockwork" };
if (room.state?.phase !== "playing") return { error: "only a game under way needs a stand-in" };
if (name === room.hostId) return { error: "the host keeps their own seat" };
if (!room.tokens.has(name) || room.bots.has(name)) return { error: "no person sits in that seat" };
if (!room.state.players.find((p) => p.id === name)?.alive) return { error: `${name} is out of the game already` };
const style = AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!;
const tier: AutomatonTier = "adept";
const event = seatToClockwork(room, name, byId, style, tier);
for (const [code, t] of transfers) if (t.roomId === room.id && t.name === name) transfers.delete(code);
appendLine(room.id, { kind: "standIn", name, by: byId, style, tier, at: new Date().toISOString() });
return { event };
}
/** The table as the keeper sees it before re-seating: who sits where, and
* when each person last moved or spoke. */
export function seatsForKeeper(room: Room): { name: PlayerId; host: boolean; bot: boolean; lastActed: string | null }[] {
const last = new Map<PlayerId, string>();
for (const c of room.log) last.set(c.playerId, c.at);
for (const c of room.chat) if ((last.get(c.player) ?? "") < c.at) last.set(c.player, c.at);
return room.players.map((name) => {
const bot = room.bots.has(name) && !room.bots.get(name)!.standIn;
return { name, host: name === room.hostId, bot, lastActed: bot ? null : (last.get(name) ?? null) };
});
}
/** The host dissolves the room: a lobby that never dealt, or a finished
* game done being remembered. The ledger is archived, never deleted. */
export function abandonRoom(room: Room, byId: PlayerId): string | null {
@@ -745,7 +820,9 @@ const TRANSFER_WORDS = [
interface PendingTransfer {
roomId: string;
name: PlayerId;
token: string;
/** The seat's own token, from its holder; null for a phrase the host
* made, which re-seats on its claim. */
token: string | null;
expiresAt: number;
}
@@ -758,7 +835,23 @@ export function makeTransferCode(
rawToken: string | null,
): { code: string; expiresAt: number } | { error: string } {
if (!tokenMatches(room, playerId, rawToken)) return { error: "you hold no seat in this room" };
const token = rawToken!;
return mintPhrase(room.id, playerId, rawToken);
}
/** The host makes a phrase for a person's seat, for a player whose browser
* lost it or who wants it back from the clockwork. Claiming it re-seats:
* the seat gets a new token and the old one opens nothing after. */
export function makeSeatPhrase(
room: Room,
byId: PlayerId,
name: PlayerId,
): { code: string; expiresAt: number } | { error: string } {
if (byId !== room.hostId) return { error: "only the host may make a phrase for another seat" };
if (!room.players.includes(name) || !personSeat(room, name)) return { error: "no person sits in that seat" };
return mintPhrase(room.id, name, null);
}
function mintPhrase(roomId: string, name: PlayerId, token: string | null): { code: string; expiresAt: number } | { error: string } {
// Sweep expired codes while we are here.
const now = Date.now();
for (const [code, t] of transfers) {
@@ -770,7 +863,7 @@ export function makeTransferCode(
code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-");
} while (transfers.has(code));
const expiresAt = now + TRANSFER_TTL_MS;
transfers.set(code, { roomId: room.id, name: playerId, token, expiresAt });
transfers.set(code, { roomId, name, token, expiresAt });
return { code, expiresAt };
}
@@ -781,7 +874,9 @@ let failWindowStart = 0;
const FAIL_WINDOW_MS = 10 * 60 * 1000;
const FAIL_LIMIT = 30;
export function claimTransferCode(code: string): { roomId: string; name: PlayerId; token: string } | { error: string } {
export function claimTransferCode(
code: string,
): { roomId: string; name: PlayerId; token: string; reseated: boolean; returned?: GameEvent } | { error: string } {
const now = Date.now();
if (now - failWindowStart > FAIL_WINDOW_MS) {
failWindowStart = now;
@@ -799,8 +894,14 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI
}
transfers.delete(normalized); // one-time
const room = rooms.get(t.roomId);
if (!room || !tokenMatches(room, t.name, t.token)) return { error: "that seat no longer exists" };
return { roomId: t.roomId, name: t.name, token: t.token };
if (!room) return { error: "that seat no longer exists" };
if (t.token === null) {
const fresh = reseat(room, t.name);
if ("error" in fresh) return { error: "that seat no longer exists" };
return { roomId: t.roomId, name: t.name, token: fresh.token, reseated: true, ...(fresh.returned ? { returned: fresh.returned } : {}) };
}
if (!tokenMatches(room, t.name, t.token)) return { error: "that seat no longer exists" };
return { roomId: t.roomId, name: t.name, token: t.token, reseated: false };
}
/** Rebuild one room from its ledger lines — the seed plus the log IS the
@@ -835,6 +936,10 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
room.tokens.delete(line.name);
room.bots.delete(line.name);
room.colorChoices.delete(line.name);
} else if (line.kind === "reseat") {
if (personSeat(room, line.name)) seatToPerson(room, line.name, line.tokenHash);
} else if (line.kind === "standIn") {
seatToClockwork(room, line.name, line.by, line.style as AutomatonStyle, line.tier as AutomatonTier);
} else if (line.kind === "join") {
if (line.bot) {
room.players.push(line.name);
+23 -1
View File
@@ -93,13 +93,35 @@ export interface KickLine {
at: string;
}
/** A person's seat got a new token, from the keeper or from a phrase the
* host made, and the old one opens nothing after. A seat the clockwork was
* standing in for goes back to a person. */
export interface ReseatLine {
kind: "reseat";
name: string;
tokenHash: string;
at: string;
}
/** The host gave a person's seat to the clockwork in a game under way: the
* wizard keeps their hand and place, the automaton plays their turns, and
* the person's token opens nothing until a reseat gives the seat back. */
export interface StandInLine {
kind: "standIn";
name: string;
by: string;
style: string;
tier: string;
at: string;
}
export interface AbandonLine {
kind: "abandon";
by: string;
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine;
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | ReseatLine | StandInLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine;
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
+151
View File
@@ -0,0 +1,151 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { once } from "node:events";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
// The store reads its directory when first imported, so the rooms module is
// loaded only after the ledgers have somewhere of their own to go.
const scratch = mkdtempSync(join(tmpdir(), "wizwar-keeper-"));
process.env.WIZWAR_DATA_DIR = join(scratch, "rooms");
const KEY_FILE = join(scratch, "keeper.key");
const KEY = "a".repeat(64);
let rooms: typeof import("../src/rooms");
let keeper: typeof import("../src/keeper");
beforeAll(async () => {
rooms = await import("../src/rooms");
rooms.loadPersistedRooms();
keeper = await import("../src/keeper");
});
function table() {
const { room, token: ann } = rooms.createRoom("Ann");
const joined = rooms.joinRoom(room, "Bo", null);
if ("error" in joined) throw new Error(joined.error);
const bot = rooms.addAutomaton(room);
if ("error" in bot) throw new Error(bot.error);
return { room, ann, bo: joined.token, bot: bot.name };
}
function token(result: { token: string } | { error: string }): string {
if ("error" in result) throw new Error(result.error);
return result.token;
}
describe("the keeper's reseat", () => {
it("gives a person's seat a new token: the old one is refused and the new one opens the seat", () => {
const { room, bo } = table();
const fresh = token(rooms.reseat(room, "Bo"));
expect(fresh).not.toBe(bo);
expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ error: "that wizard name is taken in this room" });
expect(rooms.joinRoom(room, "Bo", fresh)).toEqual({ token: fresh });
expect(rooms.peekSummary(room.id, "Bo", bo)).toBe("badToken");
});
it("re-seats the host the same way", () => {
const { room, ann } = table();
const fresh = token(rooms.reseat(room, "Ann"));
expect("error" in rooms.joinRoom(room, "Ann", ann)).toBe(true);
expect(rooms.joinRoom(room, "Ann", fresh)).toEqual({ token: fresh });
});
it("voids a transfer phrase minted with the old token", () => {
const { room, bo } = table();
const minted = rooms.makeTransferCode(room, "Bo", bo);
if ("error" in minted) throw new Error(minted.error);
rooms.reseat(room, "Bo");
expect("error" in rooms.claimTransferCode(minted.code)).toBe(true);
});
it("keeps the new token across a reload from the ledger", async () => {
const { room, ann, bo } = table();
const fresh = token(rooms.reseat(room, "Bo"));
vi.resetModules();
const reloaded: typeof import("../src/rooms") = await import("../src/rooms");
const again = reloaded.getRoom(room.id);
expect(again).toBeDefined();
expect(again).not.toBe(room);
expect(reloaded.joinRoom(again!, "Bo", bo)).toEqual({ error: "that wizard name is taken in this room" });
expect(reloaded.joinRoom(again!, "Bo", fresh)).toEqual({ token: fresh });
expect(reloaded.joinRoom(again!, "Ann", ann)).toEqual({ token: ann });
});
it("re-seats only a person who sits at the table", () => {
const { room, bot } = table();
expect(rooms.reseat(room, bot)).toEqual({ error: "no person sits in that seat" });
expect(rooms.reseat(room, "Cy")).toEqual({ error: "no person sits in that seat" });
});
it("tells the keeper who sits where, and when each person last moved or spoke", () => {
const { room, bot } = table();
rooms.addChat(room, "Bo", "good luck");
const seats = rooms.seatsForKeeper(room);
expect(seats.map((s) => [s.name, s.bot, s.host])).toEqual([["Ann", false, true], ["Bo", false, false], [bot, true, false]]);
expect(seats[0]!.lastActed).toBeNull();
expect(seats[1]!.lastActed).toMatch(/^\d{4}-/);
expect(seats[2]!.lastActed).toBeNull();
});
});
describe("the keeper's door", () => {
let door: import("node:http").Server;
let base: string;
const knock = (path: string, init: RequestInit = {}) =>
fetch(`${base}${path}`, { ...init, headers: { "x-keeper-key": KEY, ...(init.headers as Record<string, string>) } });
beforeAll(async () => {
writeFileSync(KEY_FILE, KEY + "\n");
door = keeper.openKeeperDoor({ port: 0, publicUrl: "https://wizwar.example", keyFile: KEY_FILE, onReseat: () => {} });
await once(door, "listening");
const address = door.address() as AddressInfo;
base = `http://127.0.0.1:${address.port}`;
});
afterAll(() => door.close());
it("listens on the loopback alone", () => {
expect((door.address() as AddressInfo).address).toBe("127.0.0.1");
});
it("lists a table's seats and re-seats one as a link carrying the token in the fragment", async () => {
const { room, bo } = table();
const listed = await (await knock(`/keeper/rooms/${room.id}`)).json();
expect(listed.seats.map((s: { name: string }) => s.name)).toContain("Bo");
const res = await knock(`/keeper/rooms/${room.id.toLowerCase()}/reseat`, {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ seat: "Bo" }),
});
expect(res.status).toBe(200);
const { name, link } = await res.json();
expect(name).toBe("Bo");
const m = new RegExp(`^https://wizwar\\.example/join/${room.id}#seat=Bo\\.([0-9a-f]{32})$`).exec(link);
expect(m).not.toBeNull();
expect("error" in rooms.joinRoom(room, "Bo", bo)).toBe(true);
expect(rooms.joinRoom(room, "Bo", m![1]!)).toEqual({ token: m![1] });
});
it("turns away a forwarded request, an unknown room and a bot", async () => {
const { room, bot } = table();
expect((await knock(`/keeper/rooms/${room.id}`, { headers: { "x-forwarded-for": "203.0.113.9" } })).status).toBe(404);
expect((await knock(`/keeper/rooms/${room.id}X`)).status).toBe(404);
const res = await knock(`/keeper/rooms/${room.id}/reseat`, { method: "POST", body: JSON.stringify({ seat: bot }) });
expect(res.status).toBe(404);
expect((await res.json()).error).toBe("no person sits in that seat");
});
it("opens only to the keeper's key, and stays shut while the key file is absent", async () => {
const { room, bo } = table();
const reseatBo = (headers: Record<string, string>) =>
fetch(`${base}/keeper/rooms/${room.id}/reseat`, { method: "POST", headers, body: JSON.stringify({ seat: "Bo" }) });
expect((await reseatBo({})).status).toBe(404);
expect((await reseatBo({ "x-keeper-key": "b".repeat(64) })).status).toBe(404);
expect((await reseatBo({ "x-keeper-key": KEY.slice(1) })).status).toBe(404);
rmSync(KEY_FILE);
try {
expect((await reseatBo({ "x-keeper-key": KEY })).status).toBe(404);
expect((await reseatBo({ "x-keeper-key": "" })).status).toBe(404);
} finally {
writeFileSync(KEY_FILE, KEY + "\n");
}
expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ token: bo });
});
});
+154
View File
@@ -0,0 +1,154 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
// The store reads its directory when first imported, so the rooms module is
// loaded only after the ledgers have somewhere of their own to go.
process.env.WIZWAR_DATA_DIR = join(mkdtempSync(join(tmpdir(), "wizwar-seats-")), "rooms");
let rooms: typeof import("../src/rooms");
beforeAll(async () => {
rooms = await import("../src/rooms");
rooms.loadPersistedRooms();
});
/** Ann hosts, Bo and Cy sit, and the boards have flipped. */
function playing() {
const { room, token: ann } = rooms.createRoom("Ann");
const bo = token(rooms.joinRoom(room, "Bo", null));
const cy = token(rooms.joinRoom(room, "Cy", null));
const started = rooms.startGame(room, false);
if ("error" in started) throw new Error(started.error);
return { room, ann, bo, cy };
}
function token(result: { token: string } | { error: string }): string {
if ("error" in result) throw new Error(result.error);
return result.token;
}
function phrase(result: { code: string } | { error: string }): string {
if ("error" in result) throw new Error(result.error);
return result.code;
}
describe("a stand-in", () => {
it("plays a person's seat the host gives it, and the person's token opens nothing", () => {
const { room, bo } = playing();
expect(rooms.standIn(room, "Ann", "Bo")).toEqual({ event: { type: "seatChanged", player: "Bo", to: "clockwork", by: "Ann" } });
expect(room.bots.get("Bo")).toMatchObject({ standIn: true, secret: true });
expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ error: "the host gave this seat to the clockwork; ask them for a phrase to take it back" });
expect(room.players).toContain("Bo");
});
it("takes the turn when the seat it stands in for holds it, and leaves the host's to the host", () => {
const { room } = playing();
rooms.standIn(room, "Ann", "Bo");
rooms.standIn(room, "Ann", "Cy");
const holder = room.state!.players[room.state!.turn.activeIndex]!.id;
expect(rooms.driveOneAutomaton(room)?.seat ?? null).toBe(holder === "Ann" ? null : holder);
});
it("is the host's call alone, in a game under way, for a person still in it", () => {
const { room } = playing();
expect(rooms.standIn(room, "Bo", "Cy")).toEqual({ error: "only the host may give a seat to the clockwork" });
expect(rooms.standIn(room, "Ann", "Ann")).toEqual({ error: "the host keeps their own seat" });
expect(rooms.standIn(room, "Ann", "Dee")).toEqual({ error: "no person sits in that seat" });
rooms.standIn(room, "Ann", "Bo");
expect(rooms.standIn(room, "Ann", "Bo")).toEqual({ error: "no person sits in that seat" });
const { room: lobby } = rooms.createRoom("Ann");
rooms.joinRoom(lobby, "Bo", null);
expect(rooms.standIn(lobby, "Ann", "Bo")).toEqual({ error: "only a game under way needs a stand-in" });
});
it("is still standing in after a reload from the ledger", async () => {
const { room, bo } = playing();
rooms.standIn(room, "Ann", "Bo");
vi.resetModules();
const reloaded: typeof import("../src/rooms") = await import("../src/rooms");
const again = reloaded.getRoom(room.id)!;
expect(again).not.toBe(room);
expect(again.bots.get("Bo")).toEqual(room.bots.get("Bo"));
expect(again.events.filter((e) => e.type === "seatChanged")).toEqual([{ type: "seatChanged", player: "Bo", to: "clockwork", by: "Ann" }]);
expect("error" in reloaded.joinRoom(again, "Bo", bo)).toBe(true);
});
it("holds the person's seat at a rematch, and brings no automaton for it", () => {
const { room } = playing();
rooms.standIn(room, "Ann", "Bo");
room.state = { ...room.state!, phase: "finished" };
const called = rooms.callRematch(room, "Ann");
if ("error" in called) throw new Error(called.error);
const next = rooms.getRoom(called.roomId)!;
expect(next.expected).toEqual(["Bo", "Cy"]);
expect(next.bots.size).toBe(0);
});
});
describe("a phrase the host makes", () => {
it("re-seats a person whose browser lost the seat: the old token is refused, the new one opens it", () => {
const { room, bo } = playing();
const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")));
if ("error" in claimed) throw new Error(claimed.error);
expect(claimed).toMatchObject({ roomId: room.id, name: "Bo", reseated: true });
expect(claimed.token).not.toBe(bo);
expect("error" in rooms.joinRoom(room, "Bo", bo)).toBe(true);
expect(rooms.joinRoom(room, "Bo", claimed.token)).toEqual({ token: claimed.token });
});
it("takes a seat back from the clockwork", () => {
const { room } = playing();
rooms.standIn(room, "Ann", "Bo");
const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")));
if ("error" in claimed) throw new Error(claimed.error);
expect(room.bots.has("Bo")).toBe(false);
expect(claimed.returned).toEqual({ type: "seatChanged", player: "Bo", to: "person" });
expect(rooms.joinRoom(room, "Bo", claimed.token)).toEqual({ token: claimed.token });
});
it("keeps the seat a person's again after a reload from the ledger", async () => {
const { room } = playing();
rooms.standIn(room, "Ann", "Bo");
const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")));
if ("error" in claimed) throw new Error(claimed.error);
vi.resetModules();
const reloaded: typeof import("../src/rooms") = await import("../src/rooms");
const again = reloaded.getRoom(room.id)!;
expect(again.bots.has("Bo")).toBe(false);
expect(again.events.filter((e) => e.type === "seatChanged").map((e) => (e as { to: string }).to)).toEqual(["clockwork", "person"]);
expect(reloaded.joinRoom(again, "Bo", claimed.token)).toEqual({ token: claimed.token });
});
it("is the host's to make, and only for a person's seat", () => {
const { room } = playing();
expect(rooms.makeSeatPhrase(room, "Bo", "Cy")).toEqual({ error: "only the host may make a phrase for another seat" });
expect(rooms.makeSeatPhrase(room, "Ann", "Dee")).toEqual({ error: "no person sits in that seat" });
const { room: withBot } = rooms.createRoom("Ann");
const bot = rooms.addAutomaton(withBot);
if ("error" in bot) throw new Error(bot.error);
expect(rooms.makeSeatPhrase(withBot, "Ann", bot.name)).toEqual({ error: "no person sits in that seat" });
});
it("works once", () => {
const { room } = playing();
const code = phrase(rooms.makeSeatPhrase(room, "Ann", "Cy"));
expect("error" in rooms.claimTransferCode(code)).toBe(false);
expect(rooms.claimTransferCode(code)).toEqual({ error: "that transfer phrase is unknown or has expired" });
});
it("is voided when the host gives the seat to the clockwork after making it", () => {
const { room } = playing();
const code = phrase(rooms.makeSeatPhrase(room, "Ann", "Bo"));
rooms.standIn(room, "Ann", "Bo");
expect("error" in rooms.claimTransferCode(code)).toBe(true);
});
});
describe("a player's own transfer phrase", () => {
it("still hands over the same token, without re-seating", () => {
const { room, bo } = playing();
const claimed = rooms.claimTransferCode(phrase(rooms.makeTransferCode(room, "Bo", bo)));
expect(claimed).toEqual({ roomId: room.id, name: "Bo", token: bo, reseated: false });
});
});
+1 -1
View File
@@ -4,5 +4,5 @@
"noEmit": true,
"types": ["node"]
},
"include": ["src"]
"include": ["src", "test"]
}
+12 -4
View File
@@ -14,7 +14,7 @@
"slug": "waving-hands",
"name": "Waving Hands",
"url": "https://hands.kestrelsnest.social",
"pitch": "Duelling wizards. Two hands each. The right run of gestures is a spell.",
"pitch": "Wizards duel with their hands, and the right run of gestures is a spell.",
"players": "2 to 12",
"origin": "Richard Bartle's pencil-and-paper duel, 1977"
},
@@ -22,7 +22,7 @@
"slug": "hnefatafl",
"name": "Hnefatafl",
"url": "https://tafl.kestrelsnest.social",
"pitch": "The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.",
"pitch": "The Viking board game of a king breaking for safety and a ring of attackers closing in.",
"players": "2",
"origin": "the Norse tafl game, reconstructed from Linnaeus's 1732 notes; Copenhagen rules or Tablut"
},
@@ -30,9 +30,17 @@
"slug": "dip",
"name": "Dip",
"url": "https://dip.kestrelsnest.social",
"pitch": "Seven powers. One map. Nobody has to keep their word.",
"players": "2 to 7",
"pitch": "Seven powers share one map, and none of them has to keep its word.",
"players": "2 to 10, depending on the map",
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
},
{
"slug": "wyrm",
"name": "Wyrm",
"url": "https://wyrm.kestrelsnest.social",
"pitch": "Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.",
"players": "2",
"origin": "Gary Gygax's Dragonchess, Dragon Magazine No. 100, 1985; an unofficial rebuild"
}
]
}
+168 -18
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { motion } from "./motion";
import { attentionLabel, net, SIDE_NAMES, spellName } from "./net.svelte";
import { attentionLabel, net, seatInLink, SIDE_NAMES, spellName } from "./net.svelte";
import { MediaQuery } from "svelte/reactivity";
import { FISTS } from "./fpv/paintedFx";
import Board from "./Board.svelte";
@@ -32,19 +32,40 @@
const hall = $derived(kin.hall);
let joinCode = $state("");
let claimPhrase = $state("");
/** The host's seats panel: hand a seat to the clockwork, or make a transfer phrase for it. */
let seatsOpen = $state(false);
/** The seat the host asked to give the clockwork, waiting on a second click. */
let standInAsked = $state<string | null>(null);
/** The people's seats besides the host's own: the clockwork the host seated is not among them. */
const peopleSeats = $derived(net.players.filter((p) => p !== net.you && (!net.roomBots[p] || net.standIns.includes(p))));
/** A /join/<CODE> recruiting link. The visitor lands looking at the
* room: a held seat walks them back to it, a waiting room offers a
* seat or the gallery, a started game is watched from the gallery. */
const inviteCode = location.pathname.match(/^\/join\/([A-Za-z0-9]{4})$/)?.[1]?.toUpperCase() ?? null;
if (inviteCode) joinCode = inviteCode;
/** A link from the keeper carries a seat in its fragment. It leaves the
* address bar at once, is tried only when no seat held here still opens,
* and is kept only once the server takes it, so a link someone else
* sends cannot take a working seat away. */
const linkedSeat = inviteCode ? seatInLink(location.hash, inviteCode) : null;
if (location.hash.startsWith("#seat=")) history.replaceState(history.state, "", location.pathname + location.search);
let inviteFollowed = false;
$effect(() => {
if (!inviteCode || inviteFollowed || net.status !== "connected" || net.roomId) return;
inviteFollowed = true;
const seat = net.seats.find((s) => s.roomId === inviteCode);
if (seat) net.resume(seat);
else net.watch(inviteCode);
const code = inviteCode;
const fromLink = linkedSeat && (() => net.resume(linkedSeat, () => {
net.watch(code);
net.flash("The keeper's link no longer opens that seat. Write again for a fresh one.");
}));
const seat = net.seats.find((s) => s.roomId === code);
if (seat) net.resume(seat, fromLink || undefined);
else if (fromLink) fromLink();
else net.watch(code);
});
/** A game not yet finished asks before it is forgotten: this browser may
* be all that holds the seat. */
let forgetting = $state<string | null>(null);
/** The address bar holds the table's invite link while you sit at one,
* so recruiting is a copy away. */
$effect(() => {
@@ -405,6 +426,17 @@
view !== null && view.phase === "playing" && view.activePlayerId === view.you && !view.stack,
);
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
/** Another wizard's turn, seen from a phone: the rail that says so sits
* below the fold, and a board moving on its own with no word reads as a
* game running without you. Names the wizard, and whether you're next. */
const theirTurnCaption = $derived.by(() => {
if (wideScreen || !view || !view.you || net.spectating || local.active || openingRolls) return null;
if (view.phase !== "playing" || view.activePlayerId === view.you) return null;
const alive = view.players.filter((p) => p.alive || p.id === view!.activePlayerId);
const at = alive.findIndex((p) => p.id === view!.activePlayerId);
const next = alive.length ? alive[(at + 1) % alive.length]!.id : null;
return `${view.activePlayerId}'s turn${next === view.you ? " — yours is next" : ""}.`;
});
/** An interruption window (INTERRUPT / OPPORTUNITY FIRE) is your moment:
* casting and targeting open up as if the turn were yours. */
const yourMoment = $derived(
@@ -660,7 +692,14 @@
if (selectedDef.los) return "Choose a target · lit squares are in your sight";
return `${selectedDef.name} · its panel offers the choices`;
});
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
/** The spell's power as the engine weighs it: the NUMBER's face, +1 for
* a displayed POWERSTONE, doubled per AMPLIFY riding along. WATERBOLT's
* split must total exactly this, or the cast is refused. */
const numberTotal = $derived.by(() => {
const stone = view?.players.find((p) => p.id === view.you)?.displayed.some((c) => c.cardId === "powerstone") ? 1 : 0;
const face = attachedNumber ? cardDef(attachedNumber.cardId).value! + stone : 1;
return face * 2 ** attachedMods.filter((m) => m.cardId === "amplify").length;
});
function dispatch(command: Parameters<typeof net.command>[0]) {
if (local.active) local.command(command);
@@ -822,6 +861,7 @@
attachedMods = attachedMods.some((m) => m.instanceId === card.instanceId)
? attachedMods.filter((m) => m.instanceId !== card.instanceId)
: [...attachedMods, card];
wbDamage = numberTotal;
return;
}
if (selectedCard?.instanceId === card.instanceId) {
@@ -1340,7 +1380,8 @@
};
withMods(cmd);
if (selectedCard.cardId === "waterbolt") {
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
const damage = Math.min(Math.max(0, wbDamage), numberTotal);
cmd.params = { damage, knockback: numberTotal - damage };
}
if (NAMED_CARDS.has(selectedCard.cardId)) {
const id = nameToCardId(nameInput);
@@ -1849,12 +1890,17 @@
return bent ? bentSightedCellsFor(view, castingDouble?.position) : sightedCellsFor(view, castingDouble?.position);
});
/** The creatures that strike on command; the rest (a fire imp, a wraith)
* pick their own victims, as the engine's creatureAttack holds. */
const ORDERED_ATTACKERS = new Set(["troll", "skeleton", "shadow"]);
const attackReady = (c: { kind: string; attackUsed: boolean; justCreated?: boolean }) =>
ORDERED_ATTACKERS.has(c.kind) && !c.attackUsed && !c.justCreated;
/** Creatures awaiting your orders this turn (yours + any democratic monster). */
const idleCreatures = $derived(
view != null && isYourTurn
? view.creatures.filter((c) =>
(c.controllerId === view!.you || c.kind === "democratic-monster") &&
(c.movementUsed < c.movesPerTurn || (!c.attackUsed && !c.justCreated)))
(c.movementUsed < c.movesPerTurn || attackReady(c)))
: [],
);
@@ -2004,6 +2050,10 @@
{#if !net.spectating}
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
{/if}
{#if !net.spectating && net.you === net.hostId && peopleSeats.length > 0}
<button class="mast-leave" title="Make a phrase that brings a wizard back to their seat, or let the clockwork play for someone who cannot."
onclick={() => { seatsOpen = !seatsOpen; standInAsked = null; }}>seats</button>
{/if}
<button class="mast-leave" onclick={() => net.leave()}>{net.spectating ? "leave the gallery" : "leave table"}</button>
<button class="mast-leave" title="Something behaved unexpectedly? Tell the keeper."
onclick={() => (feedbackOpen = true)}>🐞 report</button>
@@ -2118,6 +2168,26 @@
{/if}
{/if}
{#if youMustShield && view?.chaosPending}
{@const shield = view.yourHand.find((c) => c.cardId === "full-shield")}
<div class="scrim attack-scrim" role="alertdialog" aria-label="chaos">
<div class="attack-notice">
<div class="attack-title">{view.chaosPending.casterId} casts Chaos!</div>
<div class="attack-power">
Every hand goes into one pile and is dealt back at random, each
wizard getting as many cards as they held.
{shield ? "Your Full Shield keeps your hand out of it." : "You hold no Full Shield to sit it out."}
</div>
<div class="attack-actions">
{#if shield}
<button class="stamp primary" onclick={() => dispatch({ type: "counteract", instanceId: shield.instanceId })}>Raise the Full Shield</button>
{/if}
<button class={shield ? "hint-cancel" : "stamp primary"} onclick={pass}>let it take you</button>
</div>
</div>
</div>
{/if}
{#if megaPrompt && view}
{@const beast = view.creatures.find((c) => c.id === megaPrompt!.creatureId)}
<div class="scrim attack-scrim" role="alertdialog" aria-label="mega-monster">
@@ -2537,6 +2607,41 @@
</div>
{/if}
{#if seatsOpen && net.you === net.hostId && !net.spectating}
<div class="slip seats-slip">
<div class="seats-head">
<span>Seats at your table</span>
<button class="hint-cancel" onclick={() => { seatsOpen = false; standInAsked = null; }}>close</button>
</div>
{#each peopleSeats as p (p)}
{@const standing = net.standIns.includes(p)}
<div class="seats-row">
<span class="seats-name">{p}{#if standing} <span class="seats-note">⚙ the clockwork plays for them</span>{/if}</span>
<button class="stamp tiny"
title={`${standing ? `Brings ${p} back from the clockwork.` : `For when ${p}'s browser lost the seat, or they need to play from another device.`} You get four words to pass to them. Within ten minutes, they enter them on the front page, or in the claim box while watching this table, and sit back down as ${p}. The phrase works once, and the browser they used before stops opening the seat.`}
onclick={() => net.makeSeatPhrase(p)}>{standing ? "transfer phrase to take it back" : "make a transfer phrase"}</button>
{#if !standing && net.started && view?.phase === "playing" && view.players.find((w) => w.id === p)?.alive}
{#if standInAsked === p}
<button class="stamp tiny primary" onclick={() => { net.standIn(p); standInAsked = null; }}>yes, the clockwork plays for {p}</button>
<button class="hint-cancel" onclick={() => (standInAsked = null)}>no</button>
{:else}
<button class="stamp tiny" title="{p} keeps their hand, treasures and place, and an automaton takes their turns until you make them a transfer phrase to take it back."
onclick={() => (standInAsked = p)}>give to the clockwork</button>
{/if}
{/if}
</div>
{/each}
</div>
{/if}
{#if net.seatPhrase}
<div class="slip transfer-slip">
Give {net.seatPhrase.name} this phrase to claim within ten minutes, on the front page or while watching this table. It works once, and their old link stops opening the seat.
<strong class="transfer-phrase">{net.seatPhrase.code}</strong>
<button class="hint-cancel" onclick={() => (net.seatPhrase = null)}>dismiss</button>
</div>
{/if}
{#if local.setup}
<div class="scrim-handoff">
<div class="handoff-card">
@@ -2701,8 +2806,15 @@
<button class="stamp tiny" title="take your seat at the rematch"
onclick={() => { net.you = seat.name; net.spectating = false; net.join(g.rematch!.roomId, seat.name); }}>rematch</button>
{/if}
<button class="ledger-forget" title="forget this game"
onclick={() => net.forgetSeat(seat.roomId)}>×</button>
{#if forgetting === seat.roomId}
<span class="forget-ask">This browser may be all that holds your seat.
<button class="hint-cancel" onclick={() => { forgetting = null; net.forgetSeat(seat.roomId); }}>forget it</button>
<button class="hint-cancel" onclick={() => (forgetting = null)}>keep it</button>
</span>
{:else}
<button class="ledger-forget" title="forget this game"
onclick={() => { if (g?.finished) net.forgetSeat(seat.roomId); else forgetting = seat.roomId; }}>×</button>
{/if}
</div>
{/each}
{#if net.feedbackReports.length > 0}
@@ -3157,6 +3269,11 @@
<span class="step-chip" style:background={playerColor(me.id)}></span>
This is you. Tap a lit square beside your wizard to walk.
</div>
{:else if theirTurnCaption}
<div class="step-caption" aria-live="polite">
<span class="step-chip" style:background={playerColor(view.activePlayerId)}></span>
{theirTurnCaption}
</div>
{/if}
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
{#if prefs.liveFp && view.you && !net.spectating}
@@ -3234,7 +3351,7 @@
{#if view.phase === "finished"}
<div class="slip winner">🏆 {view.winner} wins!
{#if !local.active}
{@const otherHumans = net.players.some((p) => p !== net.you && !net.roomBots[p])}
{@const otherHumans = net.players.some((p) => p !== net.you && (!net.roomBots[p] || net.standIns.includes(p)))}
{#if net.rematchCall}
{#if net.rematchCall.by === net.you}
<button class="stamp tiny" onclick={() => net.acceptRematch(net.rematchCall!.roomId)}>back to the rematch</button>
@@ -3346,19 +3463,14 @@
</div>
{/if}
{#if youMustShield}
<div class="slip urgent">
Chaos comes for your hand. Tap your Full Shield to sit out, or
<button class="stamp tiny" onclick={pass}>let it take you</button>
</div>
{:else if view.chaosPending}
{#if view.chaosPending && !youMustShield}
<div class="slip">Chaos gathers. Waiting on {view.chaosPending.queue[0]}…</div>
{/if}
{#each idleCreatures as c (c.id)}
{@const moves = c.movesPerTurn - c.movementUsed}
<div class="slip creature-note">
🜲 {c.controllerId === view.you ? "Your" : "The"} {cardDef(c.kind).name} awaits orders:
{#if moves > 0}{moves} move{moves === 1 ? "" : "s"}{/if}{#if moves > 0 && !c.attackUsed && !c.justCreated}&nbsp;·&nbsp;{/if}{#if !c.attackUsed && !c.justCreated}attack ready{/if}.
{#if moves > 0}{moves} move{moves === 1 ? "" : "s"}{/if}{#if moves > 0 && attackReady(c)}&nbsp;·&nbsp;{/if}{#if attackReady(c)}attack ready{/if}.
Tap its token to command it.
</div>
{/each}
@@ -3731,6 +3843,17 @@
<div class="hand" bind:this={handEl} role="list" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
{#if net.spectating}
<span class="gallery-note">👁 You watch from the Peanut Gallery, and every hand stays secret, even from you.</span>
{#if view.phase !== "finished"}
<span class="gallery-note">Had a seat at this table on another device? Choose <em>transfer seat</em> there, or ask {net.hostId ?? "the host"} for a phrase, and claim it here. Failing that, <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${net.roomId}`)}">write to the keeper</a> for a link that brings it back.</span>
<span class="claim-row gallery-claim">
<input class="claim-input" bind:value={claimPhrase}
placeholder="ember-troll-dagger-moss" aria-label="seat transfer phrase" />
<button class="stamp tiny" disabled={!claimPhrase.trim()}
onclick={() => { net.claimTransfer(claimPhrase); claimPhrase = ""; }}>
Claim the seat
</button>
</span>
{/if}
{/if}
{#each view.phase === "finished" ? [] : orderedHand as card (card.instanceId)}
<div class="hand-slot"
@@ -4325,6 +4448,29 @@
border-radius: 3px;
user-select: all;
}
.seats-slip {
width: min(34rem, 100%);
box-sizing: border-box;
margin: 0 auto 0.7rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.seats-head {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
}
.seats-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.seats-name { flex: 1 1 8rem; }
.seats-note { font-weight: normal; font-style: italic; opacity: 0.8; margin-left: 0.4em; }
.gallery-claim { margin-top: 0.4rem; }
.claim-row {
display: flex;
flex-wrap: wrap;
@@ -4399,6 +4545,7 @@
padding: 0 0.3rem;
}
.ledger-forget:hover { color: #b3372b; }
.forget-ask { font-size: 0.8rem; color: #8d8672; flex: none; }
.stamp {
font-family: "Oswald", sans-serif;
@@ -5220,7 +5367,10 @@
height: 56dvh;
}
.chronicle { max-height: 30dvh; flex: initial; }
.masthead { gap: 0.45rem; padding-top: 0.5rem; }
/* The table's links wrap to a second line rather than widen the page:
* an unwrapped row pushed a phone's layout past the screen's edge. */
.masthead { gap: 0.45rem; padding-top: 0.5rem; flex-wrap: wrap; row-gap: 0.25rem; }
.mast-leave, .mast-room { white-space: nowrap; }
.boxlid { padding: 1rem 0; }
.boxlid-inner { padding: 1.5rem 1.2rem 1.4rem; }
.boxlid-title { font-size: 2.3rem; }
+6
View File
@@ -306,6 +306,12 @@
whatever name you like, and know that what you say and do at an
online table is part of its record.
</p>
<p>
Your seat at an online game is kept in this browser. To carry it to
another device, choose <em>transfer seat</em> at the table and claim
the phrase there. If a browser loses it, write to the keeper for a
link that brings it back.
</p>
<h3>Send word</h3>
<p>
Feedback, bug reports and faint praise are all welcome. Ravens fly to
+13 -1
View File
@@ -2,6 +2,14 @@ import { mount } from "svelte";
import Announcement from "./Announcement.svelte";
import * as Sentry from "@sentry/browser";
/** A seat link carries its token in the fragment (#seat=<name>.<token>), and
* the history breadcrumb for taking it out of the address bar still names
* it, so it is cut from every report before one leaves the browser. */
function scrub(text: string): string {
// The cut runs to the end of the JSON string, whatever the name holds.
return text.replace(/(#seat=)(?:[^"\\]|\\.)*/g, "$1…");
}
// Errors only — no tracing, no replay. Without a DSN baked at build time
// (VITE_SENTRY_DSN in .env.production) this is entirely inert.
if (import.meta.env.VITE_SENTRY_DSN) {
@@ -10,8 +18,12 @@ if (import.meta.env.VITE_SENTRY_DSN) {
environment: "production",
// Facebook's in-app browser injects its own scripts (iabjs://) and
// reports their failures as ours when the page unloads.
denyUrls: [/^iabjs:\/\//],
// Extensions run their own scripts in the page, and their failures are not ours.
// Safari hides an extension's address behind webkit-masked-url.
denyUrls: [/^iabjs:\/\//, /^(chrome|moz|safari(-web)?)-extension:\/\//, /^webkit-masked-url:\/\//],
ignoreErrors: ["Java object is gone"],
beforeSend: (event) => JSON.parse(scrub(JSON.stringify(event))),
beforeBreadcrumb: (crumb) => JSON.parse(scrub(JSON.stringify(crumb))),
});
}
+95 -8
View File
@@ -4,6 +4,7 @@ import type { ReplayStep } from "./catchup";
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
import { cardDef, isPermanentDuration } from "@wizwar/engine";
import { receiptFor } from "./receipt";
import * as Sentry from "@sentry/browser";
/** The same server over plain HTTP, for the one door a socket cannot carry: a picture. */
const API_URL =
@@ -223,6 +224,9 @@ export function humanize(e: GameEvent): string | null {
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 "seatChanged": return e.to === "clockwork"
? `⚙ ${e.by ?? "The host"} gives ${e.player}'s seat to the clockwork, which plays on in their place.`
: `${e.player} takes their seat back from the clockwork.`;
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!`
@@ -265,7 +269,7 @@ export interface LogLine {
const ACTING = new Set([
"moved", "warpStepped", "jumpedPit", "counteractionPlayed", "treasurePickedUp", "treasureDropped",
"numberPlayedForMovement", "cardsDiscarded", "turnStarted", "turnEnded", "doorUnlocked", "madDash",
"climbedFromPit", "spellTrapped",
"climbedFromPit", "spellTrapped", "tableTalk",
]);
function actorOf(e: GameEvent, turnOwner: string | null): string | undefined {
if ("caster" in e && typeof e.caster === "string") return e.caster;
@@ -297,6 +301,15 @@ function loadSeen(): Record<string, number> {
export interface Seat { name: string; roomId: string; token: string }
/** A seat the keeper sent in a link, `#seat=<name>.<token>`: a fragment
* stays in the browser, out of every log. */
export function seatInLink(hash: string, roomId: string): Seat | null {
const m = /^#seat=(.+)\.([0-9a-f]{32})$/.exec(hash);
if (!m) return null;
try { return { name: decodeURIComponent(m[1]!), roomId, token: m[2]! }; }
catch { return null; }
}
export interface FeedbackReportView {
id: string;
at: string;
@@ -337,8 +350,20 @@ export function attentionLabel(a: GameSummary["attention"]): string {
}
function loadSeats(): Seat[] {
try { return JSON.parse(localStorage.getItem(SEATS_KEY) ?? "[]"); }
catch { return []; }
let seats: Seat[];
try { seats = JSON.parse(localStorage.getItem(SEATS_KEY) ?? "[]"); }
catch { seats = []; }
// The seat last sat in is kept on its own as well. A wallet that lost
// it (a cap that once dropped the newest seat) takes it back from there.
try {
const last = JSON.parse(localStorage.getItem(SEAT_KEY) ?? "null") as Seat | null;
if (last?.roomId && last.name && last.token &&
!seats.some((s) => s.roomId === last.roomId && s.name === last.name)) {
seats = [last, ...seats];
saveSeats(seats);
}
} catch { /* an unreadable last seat adds nothing */ }
return seats;
}
function saveSeats(seats: Seat[]): void {
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
@@ -350,6 +375,10 @@ class Net {
* socket that never fires onclose; the watchdog closes it by hand so
* the reconnect loop (and the masthead banner) actually engage. */
private lastHeard = Date.now();
/** When the watchdog's last ping went out. A socket is dead when that
* ping went unanswered, not when it has been quiet a while: a hidden
* tab's timers may run only once a minute, so quiet proves nothing. */
private pingSentAt: number | null = null;
/** Whose turn the chronicle is in, for the mark on lines with no actor of their own. */
private turnOwner: string | null = null;
private watchdog: ReturnType<typeof setInterval> | null = null;
@@ -360,6 +389,10 @@ class Net {
/** Lobby standee choices, by player name. */
roomColors = $state<Record<string, number>>({});
roomBots = $state<Record<string, string>>({});
/** People's seats the clockwork plays for now, at the host's word. */
standIns = $state<string[]>([]);
/** A phrase the host made for another wizard's seat, to pass to them. */
seatPhrase = $state<{ name: string; code: string; expiresAt: number } | null>(null);
you = $state<string | null>(null);
/** Seated in the Peanut Gallery: watching nameless, read-only. */
spectating = $state(false);
@@ -445,17 +478,24 @@ class Net {
private ws: WebSocket | null = null;
private token: string | null = null;
private roomIdPending: string | null = null;
/** Where a resume turns when the server refuses its seat. */
private onRefused: (() => void) | null = null;
connect(): void {
if (this.ws) return;
const ws = new WebSocket(SERVER_URL);
this.ws = ws;
this.lastHeard = Date.now();
this.pingSentAt = null;
if (!this.watchdog) {
this.watchdog = setInterval(() => {
if (!this.ws || this.status !== "connected") return;
if (this.pingSentAt !== null && this.lastHeard < this.pingSentAt) {
this.ws.close();
return;
}
this.pingSentAt = Date.now();
this.send({ type: "ping" });
if (Date.now() - this.lastHeard > 45_000) this.ws.close();
}, 15_000);
}
ws.onopen = () => {
@@ -483,6 +523,8 @@ class Net {
ws.onclose = () => {
this.status = "disconnected";
this.ws = null;
// A dropped socket proves nothing about a seat: no fallback outlives it.
this.onRefused = null;
// Whatever was in flight is now in doubt: the board will say what
// landed when the connection returns, and the talk goes back in the box.
if (this.pending) { this.unconfirmed = this.pending.label; this.pending = null; }
@@ -524,6 +566,7 @@ class Net {
case "pong":
break;
case "seat": {
this.onRefused = null;
this.token = msg.token;
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
if (seatRoom) {
@@ -542,8 +585,11 @@ class Net {
break;
case "room":
this.roomId = msg.roomId;
// An error report names the table it happened at, and from which seat.
Sentry.setTags({ room: msg.roomId, seat: this.spectating ? "gallery" : (this.you ?? "none") });
this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? {};
this.standIns = msg.standIns ?? [];
this.audience = msg.audience ?? 0;
this.rematchCall = msg.rematch ?? null;
this.expected = msg.expected ?? [];
@@ -606,6 +652,11 @@ class Net {
break;
case "events": {
let talk = 0;
// A replay is the whole chronicle from the deal, sent on every
// (re)join: it replaces what we hold, or a dropped socket's return
// would count each turn (and each line of talk) twice and the eyes
// would ask for turns the server has not reached.
if (msg.replayed) this.resetChronicle();
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
for (const e of msg.events as GameEvent[]) {
if (e.type === "tableTalk") { talk++; this.lastTalkBy = e.player; }
@@ -638,6 +689,13 @@ class Net {
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
this.leaveLocal();
break;
case "standingIn":
this.log = [...this.log, { text: `— the host gave your seat in ${msg.roomId} to the clockwork —`, turn: null, notable: false }];
this.leaveLocal();
break;
case "seatPhrase":
this.seatPhrase = { name: msg.name, code: msg.code, expiresAt: msg.expiresAt };
break;
case "roomAbandoned":
this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
this.leaveLocal();
@@ -708,14 +766,28 @@ class Net {
for (const key of voided) { if (this.voidedOnce.has(key)) doubted.add(key); }
this.voidedOnce = voided;
let kept = this.seats.filter((s) => !doubted.has(`${s.roomId}:${s.name}`));
if (kept.length > 50) kept = kept.slice(kept.length - 50);
// Newest first (rememberSeat puts a seat at the front): the oldest go.
if (kept.length > 50) kept = kept.slice(0, 50);
if (kept.length !== this.seats.length) {
this.seats = kept;
saveSeats(this.seats);
}
break;
}
case "error":
case "error": {
// A refused seat with somewhere else to turn takes that turn, with
// no toast for a door that was only tried. Only the server's word
// that the token is wrong counts as a refusal; any other error
// disarms the fallback.
const refused = this.onRefused;
this.onRefused = null;
if (refused && this.roomIdPending && /name is taken/.test(msg.message)) {
localStorage.removeItem(SEAT_KEY);
this.roomIdPending = null;
this.token = null;
refused();
break;
}
// "Name is taken" means THIS token was tried and refused: the
// credential itself is dead, and holding it helps nobody. But
// "no such room" proves nothing about the seat — a restarting
@@ -734,6 +806,7 @@ class Net {
this.log = [...this.log, { text: `— ${msg.message} —`, turn: null, notable: false }];
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
break;
}
}
}
@@ -775,8 +848,10 @@ class Net {
}
}
/** Sit back down at a remembered seat. */
resume(seat: Seat): void {
/** Sit back down at a seat. It is remembered only once the server
* takes it; `refused` runs if the server turns it away. */
resume(seat: Seat, refused?: () => void): void {
this.onRefused = refused ?? null;
this.you = seat.name;
this.spectating = false;
this.token = seat.token;
@@ -832,6 +907,16 @@ class Net {
this.send({ type: "kickSeat", name });
}
/** Host only: the clockwork plays this person's seat from here. */
standIn(name: string): void {
this.send({ type: "standIn", name });
}
/** Host only: a phrase that brings this person's seat to whoever claims it. */
makeSeatPhrase(name: string): void {
this.send({ type: "makeSeatPhrase", name });
}
abandonRoom(): void {
this.send({ type: "abandonRoom" });
}
@@ -937,6 +1022,7 @@ class Net {
* line, and wiping it would erase the only notice of why. */
leaveLocal(): void {
localStorage.removeItem(SEAT_KEY);
Sentry.setTags({ room: "none", seat: "none" });
this.missedSteps = null;
this.catchUp = null;
this.pending = null;
@@ -1017,6 +1103,7 @@ class Net {
private resetChronicle(): void {
this.log = [];
this.turnCounter = -1;
this.chatCount = 0;
}
/** Summon one turn's reel by its chronicle turn number. */