Seat transfer phrases: carry your games to another device

While seated, "transfer seat" in the masthead mints a spoken-word
one-time phrase from the game's own vocabulary (ember-troll-dagger),
good for ten minutes. Typing it into the lobby's "claim a transferred
seat" box on any other device hands over the seat's token, adds the
game to that browser's ledger, and sits you straight down at the
table. Phrases are single-use, expire, are voided by a server restart
(seats never are), and both devices keep the seat afterward — the
phone on the couch and the desktop upstairs can play the same wizard.
Verified end to end: mint, claim, rejoin, and a second claim refused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 23:58:52 -04:00
co-authored by Claude Fable 5
parent bc8013863b
commit 2184721ab4
4 changed files with 137 additions and 0 deletions
+16
View File
@@ -14,10 +14,12 @@
import { WebSocketServer, WebSocket } from "ws";
import type { Command, PlayerId } from "@wizwar/engine";
import {
claimTransferCode,
createRoom,
getRoom,
joinRoom,
loadPersistedRooms,
makeTransferCode,
redactFor,
runCommand,
startGame,
@@ -131,6 +133,20 @@ wss.on("connection", (socket) => {
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
break;
}
case "makeTransfer": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const result = makeTransferCode(room, session.playerId);
if ("error" in result) return send(socket, { type: "error", message: result.error });
send(socket, { type: "transferCode", code: result.code, expiresAt: result.expiresAt });
break;
}
case "claimTransfer": {
const result = claimTransferCode(String(msg.code ?? ""));
if ("error" in result) return send(socket, { type: "error", message: result.error });
send(socket, { type: "transferClaimed", seat: result });
break;
}
case "myGames": {
// {seats: [{roomId, name, token}]} -> summaries for valid seats.
const seats = Array.isArray(msg.seats) ? msg.seats : [];
+48
View File
@@ -181,6 +181,54 @@ export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[]
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
}
// ---------------------------------------------------------------------------
// Seat transfers: a spoken-word one-time code hands a seat to another device.
// Ephemeral by design — a server restart voids pending codes, never seats.
const TRANSFER_WORDS = [
"ember", "troll", "dagger", "raven", "flagon", "goat", "wand", "rune",
"moss", "torch", "skull", "frost", "amber", "wisp", "cellar", "gable",
"onyx", "briar", "tome", "cinder", "gloom", "spiral", "hex", "mirror",
"portal", "quill", "shade", "tusk", "vault", "wyrm", "zephyr", "idol",
] as const;
interface PendingTransfer {
roomId: string;
name: PlayerId;
token: string;
expiresAt: number;
}
const transfers = new Map<string, PendingTransfer>();
const TRANSFER_TTL_MS = 10 * 60 * 1000;
export function makeTransferCode(room: Room, playerId: PlayerId): { code: string; expiresAt: number } | { error: string } {
const token = room.tokens.get(playerId);
if (!token) return { error: "you hold no seat in this room" };
// Sweep expired codes while we are here.
const now = Date.now();
for (const [code, t] of transfers) {
if (t.expiresAt < now) transfers.delete(code);
}
let code: string;
do {
code = Array.from({ length: 3 }, () => 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 });
return { code, expiresAt };
}
export function claimTransferCode(code: string): { roomId: string; name: PlayerId; token: string } | { error: string } {
const normalized = code.trim().toLowerCase().replace(/\s+/g, "-");
const t = transfers.get(normalized);
if (!t || t.expiresAt < Date.now()) return { error: "that transfer phrase is unknown or has expired" };
transfers.delete(normalized); // one-time
const room = rooms.get(t.roomId);
if (!room || room.tokens.get(t.name) !== t.token) return { error: "that seat no longer exists" };
return { roomId: t.roomId, name: t.name, token: t.token };
}
/** Rebuild every persisted room by replaying its file. */
export function loadPersistedRooms(): void {
ensureDataDir();
+54
View File
@@ -10,6 +10,7 @@
let name = $state("");
let joinCode = $state("");
let claimPhrase = $state("");
let drawCount = $state(2);
let withExpansion = $state(true);
/** Your creature selected for movement/attacks. */
@@ -449,6 +450,7 @@
<span class="mast-sub">sixth edition</span>
{#if net.roomId}
<span class="mast-room">room <b>{net.roomId}</b></span>
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
<button class="mast-leave" onclick={() => net.leave()}>leave table</button>
{/if}
<span class="mast-status" class:offline={net.status !== "connected"}>
@@ -460,6 +462,14 @@
<div class="toast" role="alert">{net.error}</div>
{/if}
{#if net.transferCode}
<div class="slip transfer-slip">
Speak this phrase into your other device (good for 10 minutes, one use):
<strong class="transfer-phrase">{net.transferCode.code}</strong>
<button class="hint-cancel" onclick={() => (net.transferCode = null)}>dismiss</button>
</div>
{/if}
{#if !net.roomId}
<section class="boxlid">
<div class="boxlid-inner">
@@ -480,6 +490,15 @@
</button>
</div>
<div class="claim-row">
<input class="claim-input" bind:value={claimPhrase}
placeholder="ember-troll-dagger" aria-label="seat transfer phrase" />
<button class="stamp tiny" disabled={!claimPhrase.trim()}
onclick={() => { net.claimTransfer(claimPhrase); claimPhrase = ""; }}>
Claim a transferred seat
</button>
</div>
{#if net.seats.length > 0}
<div class="ledger">
<div class="ledger-head">
@@ -848,6 +867,41 @@
.check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
.waiting { color: #6b5a41; font-style: italic; }
.transfer-slip {
max-width: 30rem;
margin: 0 auto 0.7rem;
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.transfer-phrase {
font-family: "Courier Prime", monospace;
font-size: 1.05rem;
letter-spacing: 0.04em;
background: rgba(67, 51, 31, 0.12);
padding: 0.1rem 0.45rem;
border-radius: 3px;
user-select: all;
}
.claim-row {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
margin-top: 1.1rem;
}
.claim-input {
background: #f4eede;
border: 1px solid #b3a687;
border-radius: 4px;
padding: 0.4rem 0.6rem;
font-family: "Courier Prime", monospace;
font-size: 0.9rem;
color: #43331f;
width: 13rem;
}
/* the games ledger */
.ledger {
margin-top: 1.6rem;
+19
View File
@@ -173,6 +173,8 @@ class Net {
notificationsEnabled = $state(
typeof Notification !== "undefined" && Notification.permission === "granted",
);
/** A transfer phrase we minted, to show the user. */
transferCode = $state<{ code: string; expiresAt: number } | null>(null);
private lastYourTurn = new Map<string, boolean>();
private pollTimer: ReturnType<typeof setInterval> | null = null;
@@ -237,6 +239,16 @@ class Net {
if (line) this.log = [...this.log, line];
}
break;
case "transferCode":
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
break;
case "transferClaimed": {
const seat = msg.seat as Seat & { roomId: string };
this.rememberSeat({ name: seat.name, roomId: seat.roomId, token: seat.token });
this.refreshGames();
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
break;
}
case "games": {
this.games = msg.games;
for (const g of msg.games as GameSummary[]) {
@@ -299,6 +311,13 @@ class Net {
this.games = this.games.filter((g) => g.roomId !== roomId);
}
requestTransferCode(): void {
this.send({ type: "makeTransfer" });
}
claimTransfer(code: string): void {
this.send({ type: "claimTransfer", code });
}
/** Ask the server how all our games are doing. */
refreshGames(): void {
if (this.seats.length > 0) this.send({ type: "myGames", seats: $state.snapshot(this.seats) });