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();