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:
co-authored by
Claude Fable 5
parent
bc8013863b
commit
2184721ab4
@@ -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 : [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user