diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b5444aa..1df94f4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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 : []; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 879f7aa..a5f536b 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -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(); +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(); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 40da214..71072ea 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -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 @@ sixth edition {#if net.roomId} room {net.roomId} + {/if} @@ -460,6 +462,14 @@ {/if} + {#if net.transferCode} +
+ Speak this phrase into your other device (good for 10 minutes, one use): + {net.transferCode.code} + +
+ {/if} + {#if !net.roomId}
@@ -480,6 +490,15 @@
+
+ + +
+ {#if net.seats.length > 0}
@@ -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; diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 56395cd..317a702 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -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(); private pollTimer: ReturnType | 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) });