From 6dee67acff5e69742275e9cd3ba9683354469772 Mon Sep 17 00:00:00 2001 From: "B.B. Jansen" <56940439+bbjansen@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:50:49 +0200 Subject: [PATCH 1/3] fix(siege): reset the world when a player joins a finished room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Siege only reset on the first joiner (index===0). A room that hit DEFEAT/VICTORY and emptied before the 4s auto-restart (which runs in onTick, dead at 0 sockets) stayed durably in a terminal phase — so the next joiner with any lingering player record landed straight on the DEFEAT overlay ('instantly over'). Reset on the first join OR any non-playing phase, computing the spawn slot after the reset. An actively-playing room is never reset by a newcomer. +3 regression tests. --- src/games/siege/handler.test.ts | 60 +++++++++++++++++++++++++++++++++ src/games/siege/handler.ts | 18 +++++++--- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/games/siege/handler.test.ts b/src/games/siege/handler.test.ts index 55ef674..357129b 100644 --- a/src/games/siege/handler.test.ts +++ b/src/games/siege/handler.test.ts @@ -37,6 +37,66 @@ describe('Siege — self-healing persistent room', () => { expect(s.players['p1']!.hp).toBe(100); }); + it('a player joining a room parked on DEFEAT is not left on the game-over screen (regression: instantly-over on join)', () => { + // A persistent room whose tick-loop halted while parked on DEFEAT (a + // zero-socket room does not tick, so the 4s auto-restart never fired) still + // has a lingering player record from an abnormal drop / phantom socket — so + // the newcomer is NOT index 0. Before the fix, resetWorld only ran on + // index === 0, leaving this joiner staring at "DEFEAT — base destroyed". + const s = baseState({ + phase: 'lost', + baseHp: 0, + wave: 7, + score: 640, + players: { phantom: { pos: { x: 10, y: 10 }, hp: 0 } } as never, + }); + handler.onJoin!({ id: 'newcomer', joinedAt: 0 }, ctx(s)); + expect(s.phase).toBe('playing'); // fresh siege, NOT stuck on DEFEAT + expect(s.baseHp).toBe(100); + expect(s.wave).toBe(1); + expect(s.score).toBe(0); + expect(s.players['newcomer']).toBeDefined(); + expect(s.players['newcomer']!.hp).toBe(100); + }); + + it('a second player joining during the WIN/DEFEAT hold window gets a fresh siege', () => { + // p1 present + room on the DEFEAT hold (pre auto-restart). p2 joins (index + // !== 0). The newcomer must land in a playing game, not the results screen. + const s = baseState({ + phase: 'lost', + baseHp: 0, + wave: 4, + score: 200, + players: { p1: { pos: { x: 0, y: 0 }, hp: 0 } } as never, + }); + handler.onJoin!({ id: 'p2', joinedAt: 0 }, ctx(s)); + expect(s.phase).toBe('playing'); + expect(s.baseHp).toBe(100); + expect(Object.keys(s.players).sort()).toEqual(['p1', 'p2']); // both kept, respawned + expect(s.players['p2']!.hp).toBe(100); + }); + + it('does NOT reset a room that is actively playing when a newcomer joins', () => { + // Guard: the broadened reset must only fire on a non-playing phase. A live + // mid-wave game must be untouched by a second player joining. + const s = baseState({ + phase: 'playing', + baseHp: 55, + wave: 3, + score: 120, + players: { p1: { pos: { x: 0, y: 0 }, hp: 80 } } as never, + enemies: { 'w3-0': { pos: { x: 100, y: 0 }, hp: 20 } } as never, + }); + handler.onJoin!({ id: 'p2', joinedAt: 0 }, ctx(s)); + expect(s.phase).toBe('playing'); + expect(s.baseHp).toBe(55); // unchanged + expect(s.wave).toBe(3); // unchanged + expect(s.score).toBe(120); // unchanged + expect(Object.keys(s.enemies)).toHaveLength(1); // wave preserved + expect(s.players['p1']!.hp).toBe(80); // existing player NOT respawned + expect(s.players['p2']).toBeDefined(); + }); + it('auto-restarts a finished game after the hold delay', () => { const s = baseState({ phase: 'lost', baseHp: 0, wave: 6, score: 500, players: { p1: { pos: { x: 10, y: 10 }, hp: 0 } } as never }); // First terminal tick arms the restart timer; state stays 'lost'. diff --git a/src/games/siege/handler.ts b/src/games/siege/handler.ts index 672e4c2..8ffe9fa 100644 --- a/src/games/siege/handler.ts +++ b/src/games/siege/handler.ts @@ -92,10 +92,20 @@ export default defineRoom({ onJoin(player, ctx: HandlerContext) { const index = Object.keys(ctx.state.players).length; - // First player into a room starts a fresh siege — otherwise they'd inherit - // a persistent room's stale/finished state (e.g. a base left destroyed). - if (index === 0) resetWorld(ctx.state); - ctx.state.players[player.id] = { pos: spawnPlayerPos(index), hp: 100 }; + // Start a fresh siege when a player joins into a room that isn't actively + // playing: either the first player into a room (index 0), OR anyone joining + // a room parked on a WIN/DEFEAT result. A persistent room can freeze on a + // terminal phase — the auto-restart only runs while the tick loop is alive, + // and the loop stops once the room empties (a zero-socket room does not + // tick), so a `won`/`lost` snapshot can be durable across an empty period. + // Gating the reset on `index === 0` alone left the next joiner staring at + // "DEFEAT — base destroyed" whenever a stale player record lingered + // (abnormal drop still in its grace window, or a phantom socket), making the + // demo look "instantly over" on join. Resetting on any non-playing phase is + // safe: an in-progress (`playing`) room is never reset by a newcomer. + if (index === 0 || ctx.state.phase !== 'playing') resetWorld(ctx.state); + const spawnIndex = Object.keys(ctx.state.players).length; + ctx.state.players[player.id] = { pos: spawnPlayerPos(spawnIndex), hp: 100 }; }, onMessage(player, msg, ctx: HandlerContext) { From fe1491ae8321d0c6ec66711e42f3c17fe053c446 Mon Sep 17 00:00:00 2001 From: "B.B. Jansen" <56940439+bbjansen@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:31:01 +0200 Subject: [PATCH 2/3] feat(npc-chat): Filler AI regulars in the tavern demo Adds ambient rules-based 'regulars' to the Prancing Pony alongside Bram: a low tickRate (5) lets the platform's Filler AI reconcile presence bots; the client renders bot_ chat-channel lines as muted patron bubbles + a 'regulars by the fire' count. Filler is zero-token (verified live: usage unchanged) and can't reach Bram's metered say() path (different channel/shape). --- src/games/npc-chat/handler.ts | 20 ++++++++++++---- src/games/npc-chat/index.ts | 41 +++++++++++++++++++++++++++++++-- src/games/npc-chat/npc-chat.css | 12 +++++++++- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/games/npc-chat/handler.ts b/src/games/npc-chat/handler.ts index f56fee1..36732db 100644 --- a/src/games/npc-chat/handler.ts +++ b/src/games/npc-chat/handler.ts @@ -5,10 +5,15 @@ * AI flow lives on the server (`ctx.ai.npc().say()` → the room runs inference → * `onAiResult`); the client just sends a `say` message and renders the reply. * - * Cost shape: tickRate 0 — this is a chat room, not a simulation, so there is NO - * game loop. The room only wakes on a player message and hibernates between - * them (zero idle cost). Each reply is one billed "AI message"; per-player rate - * limits, the app's monthly cap, and moderation are enforced server-side. + * Cost shape: a low tickRate (5 Hz) drives the platform's rules-based Filler AI + * so a few "regulars" drift in and chatter ambiently alongside Bram, making the + * tavern feel lived-in even with one traveller present. Filler is ZERO-token + * (rules-based presence bots run on the room's in-memory tick — no LLM, no + * ai_usage, no durable alarm/storage writes; see packages/server/src/ai/filler.ts). + * The only billed cost is still Bram's LLM replies (one "AI message" each); + * per-player rate limits, the monthly cap, and moderation are enforced + * server-side. The idle-input watchdog stops the tick (and clears the bots) a + * few minutes after the last real message, so an empty tavern costs nothing. */ import { defineRoom } from '@plot/handler'; @@ -42,7 +47,12 @@ export const handler = defineRoom({ // Chat is reliable + ordered; nothing is high-frequency here. event: { reliable: true, ordered: true, rateLimit: { perPlayer: 4, perSeconds: 10 } }, }, - tickRate: 0, + // A gentle tick so the platform's Filler AI "regulars" can drift in and chat + // ambiently. There is no onTick handler — the room simulates nothing; the tick + // only exists to let the server reconcile filler presence + ambient chatter. + // 5 Hz is well under the starter tier's 20 Hz cap and keeps idle cost minimal + // (the watchdog parks the tick after the last real message). + tickRate: 5, onJoin(player, ctx) { ctx.sendTo(player.id, 'event', { diff --git a/src/games/npc-chat/index.ts b/src/games/npc-chat/index.ts index 486d25d..472ef73 100644 --- a/src/games/npc-chat/index.ts +++ b/src/games/npc-chat/index.ts @@ -6,6 +6,13 @@ * pure chat UI. Bram's replies stream in token-by-token via the `npcChunk` * event (from `say({ stream: true })`), with the final authoritative line on the * `event` channel. + * + * A few "regulars" also drift through — these are the platform's rules-based + * Filler AI presence bots (`bot_`-prefixed players). They cost ZERO LLM tokens: + * their ambient chatter arrives as plain `chat`-channel messages ({ text }) and + * their comings/goings as `join`/`leave` presence, all driven server-side. We + * render them as muted patron bubbles so the tavern feels lived-in without + * spending anything beyond Bram's replies. */ import './npc-chat.css'; import { Plot, type Room } from '@plot/client'; @@ -62,6 +69,15 @@ function mountPony(host: HTMLElement, config: PlotConfig, roomCode: string): () // Track the in-flight streamed NPC bubble so npcChunk deltas append to it. let streamingBubble: HTMLElement | null = null; + // "Regulars" = the platform's Filler AI presence bots (bot_-prefixed ids). + const isRegular = (id: string): boolean => id.startsWith('bot_'); + const regulars = new Set(); + const baseSub = (): string => `You are ${nameFor(me)} · room ${roomCode}`; + function refreshSub(): void { + const n = regulars.size; + sub.textContent = n > 0 ? `${baseSub()} · ${n} regular${n === 1 ? '' : 's'} by the fire` : baseSub(); + } + const atBottom = (): boolean => logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40; const scroll = (was: boolean): void => { if (was) logEl.scrollTop = logEl.scrollHeight; }; @@ -94,12 +110,19 @@ function mountPony(host: HTMLElement, config: PlotConfig, roomCode: string): () }).join({ mode: 'code', roomCode }); if (disposed) { room.leave(); return; } - sub.textContent = `You are ${nameFor(me)} · room ${roomCode}`; + refreshSub(); input.disabled = false; sendBtn.disabled = false; input.focus(); room.on('message', (e) => { + // Filler "regulars" chatter arrives as a plain chat-channel line from a + // bot_ id (no NpcEvent `kind`). Render it as a muted patron bubble. + if (isRegular(e.from) && e.channel === 'chat') { + const text = (e.data as { text?: unknown } | null)?.text; + if (typeof text === 'string' && text) bubble('regular', nameFor(e.from), text); + return; + } const ev = e.data as NpcEvent; if (!ev || typeof ev !== 'object') return; if (ev.kind === 'player') { @@ -115,6 +138,18 @@ function mountPony(host: HTMLElement, config: PlotConfig, roomCode: string): () } }); + // Regulars drifting in/out — update the "N regulars by the fire" subline. + room.on('join', ({ playerId }) => { + if (!isRegular(playerId)) return; + regulars.add(playerId); + refreshSub(); + }); + room.on('leave', ({ playerId }) => { + if (!isRegular(playerId)) return; + regulars.delete(playerId); + refreshSub(); + }); + // Streamed tokens: append each delta to the current Bram bubble. room.on('npcChunk', ({ delta }) => { const was = atBottom(); @@ -125,9 +160,11 @@ function mountPony(host: HTMLElement, config: PlotConfig, roomCode: string): () }); room.on('disconnect', ({ willRetry }) => { + // A fresh room has no bots yet; clear so the subline is accurate on retry. + regulars.clear(); sub.textContent = willRetry ? 'reconnecting…' : 'disconnected'; }); - room.on('reconnect', () => { sub.textContent = `You are ${nameFor(me)} · room ${roomCode}`; }); + room.on('reconnect', () => { refreshSub(); }); } catch { sub.textContent = 'could not reach the tavern — try again'; } diff --git a/src/games/npc-chat/npc-chat.css b/src/games/npc-chat/npc-chat.css index 23bb5d4..12eea9f 100644 --- a/src/games/npc-chat/npc-chat.css +++ b/src/games/npc-chat/npc-chat.css @@ -48,7 +48,7 @@ max-width: 78%; } .pony-msg.me { align-self: flex-end; align-items: flex-end; } -.pony-msg.them, .pony-msg.npc { align-self: flex-start; } +.pony-msg.them, .pony-msg.npc, .pony-msg.regular { align-self: flex-start; } .pony-who { font-size: 11px; color: var(--pn-muted); padding: 0 4px; } .pony-text { @@ -66,6 +66,16 @@ color: #d8f0d8; border-bottom-left-radius: 4px; } +/* "Regulars" — Filler AI presence bots. Dim, italic background chatter so they + read as ambient patrons and never compete with Bram or real travellers. */ +.pony-msg.regular { max-width: 68%; opacity: 0.72; } +.pony-msg.regular .pony-text { + background: transparent; + border: 1px dashed #4a3c26; + color: var(--pn-muted); + font-style: italic; + border-bottom-left-radius: 4px; +} .pony-form { display: flex; gap: 10px; } .pony-input { From 8b846645204eea7dd2a828fada222cc7ccfe7819 Mon Sep 17 00:00:00 2001 From: "B.B. Jansen" <56940439+bbjansen@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:46:23 +0200 Subject: [PATCH 3/3] chore(vendor): refresh vendored @plot/client to current SDK The vendored bundle was built before managed player identity landed; refresh it so the client surface (auth/playerData + npcChunk/disconnect/reconnect events, message channel, appId) matches source. Showcase typechecks clean; no call-site changes needed. --- vendor/plot-client/handler.d.ts | 21 ++++ vendor/plot-client/index.d.ts | 78 ++++++++++++- vendor/plot-client/index.js | 188 +++++++++++++++++++++++++++++-- vendor/plot-client/protocol.d.ts | 13 ++- 4 files changed, 287 insertions(+), 13 deletions(-) diff --git a/vendor/plot-client/handler.d.ts b/vendor/plot-client/handler.d.ts index 30c3df3..9e4a96d 100644 --- a/vendor/plot-client/handler.d.ts +++ b/vendor/plot-client/handler.d.ts @@ -123,6 +123,24 @@ interface NpcHandle { interface AiApi { npc(id: string): NpcHandle; } +interface AccountsApi { + /** The player's managed account, or null when identity is off / unknown. */ + get(playerId: string): Promise<{ + playerId: string; + kind: 'anonymous' | 'registered'; + status: 'active' | 'banned'; + methods: string[]; + } | null>; + /** Server-authoritative per-player key/value store (durable, anti-cheat). */ + data(playerId: string): { + get(key: string): Promise; + getAll(): Promise>; + set(key: string, value: unknown): Promise; + delete(key: string): Promise; + }; + /** Ban a player: flips account status and revokes their sessions. */ + ban(playerId: string, reason?: string): Promise; +} interface HandlerContext { state: S; roomCode: string; @@ -153,6 +171,9 @@ interface HandlerContext { replay: ReplayApi; assets: AssetsApi; ai: AiApi; + /** Managed player accounts: identity, server-authoritative per-player data, and ban. + * (Distinct from `players`, which is the live in-room roster.) */ + accounts: AccountsApi; /** * Run `cb` against the world state at `targetTs` (clamped to the * server's 500ms rewind horizon). Use this to validate hits or other diff --git a/vendor/plot-client/index.d.ts b/vendor/plot-client/index.d.ts index 977b421..1e87e8f 100644 --- a/vendor/plot-client/index.d.ts +++ b/vendor/plot-client/index.d.ts @@ -1,7 +1,7 @@ import { QueueEntry, CorrectionType } from '@plot/handler-client'; export { CorrectionType } from '@plot/handler-client'; import { RoomDefinition } from '@plot/handler'; -import { ClientEnvelope, ServerEnvelope, Channel, Profile, LeaderboardEntry, MatchMode } from '@plot/protocol'; +import { ClientEnvelope, ServerEnvelope, Channel, Profile, LeaderboardEntry, PlayerIdentity, PlayerSessionResponse, MatchMode } from '@plot/protocol'; export * from '@plot/protocol'; export { CHANNELS, DEFAULT_CHANNEL, isChannel } from '@plot/protocol'; @@ -334,6 +334,74 @@ declare class AssetClient { }): Promise; } +type Stored = { + sessionToken: string; + playerId: string; + kind: 'anonymous' | 'registered'; + methods: string[]; +}; +declare class PlayerSessionStore { + private store; + private appKey; + constructor(store: LocalStore, appKey: string); + private key; + load(): Stored | null; + save(s: Stored): void; + clear(): void; +} +declare class AuthClient { + private apiUrl; + private appKey; + private appId; + private store; + private onChange?; + constructor(apiUrl: string, appKey: string, appId: string, store: PlayerSessionStore, onChange?: ((id: PlayerIdentity | null) => void) | undefined); + current(): PlayerIdentity | null; + isAuthenticated(): boolean; + getSessionToken(): string | null; + anonymous(): Promise; + logout(): Promise; + /** Exchange the durable session for a short-lived connect assertion. Returns null when not signed in. */ + mintIdentityToken(): Promise; + register(opts: { + email: string; + password: string; + turnstileToken?: string; + }): Promise; + login(opts: { + email: string; + password: string; + }): Promise; + /** Upgrade the current (anonymous) session by attaching a password. The durable + * session token is preserved; the account becomes 'registered'. */ + link(opts: { + email: string; + password: string; + }): Promise; + /** URL to send the browser to for Discord OAuth. On return, the callback appends + * `#session=...&playerId=...` (or `#error=...`) — pass that to completeFromRedirect. */ + discordAuthUrl(redirect: string): string; + /** Parse the `#session=...&playerId=...` fragment the OAuth callback appended and + * store it. Returns null (no throw) when the fragment carries an error instead. */ + completeFromRedirect(hash: string): PlayerIdentity | null; + /** Internal: persist a session response and notify. (register/login/link in Phase 3 reuse this.) */ + persist(r: PlayerSessionResponse, methods: string[]): PlayerIdentity; +} + +/** Client access to the managed per-player key/value store. Reads work for the + * authenticated player; writes only succeed on keys the app has marked + * client-writable (otherwise the server returns 403). */ +declare class PlayerDataClient { + private apiUrl; + private tokenGetter; + constructor(apiUrl: string, tokenGetter: () => string); + private h; + get(key: string): Promise; + getAll(): Promise>; + set(key: string, value: unknown): Promise; + delete(key: string): Promise; +} + /** * Managed netcode layer. * @@ -458,10 +526,16 @@ declare class Plot { save: SaveClient; leaderboard: (name: string) => LeaderboardClient; assets: AssetClient; + auth: AuthClient; + playerData: PlayerDataClient; constructor(options: PlotOptions); /** The /v1/connect request body. Includes the developer JWT as `token` when a * `playerToken` is configured (the server derives the player id from it). */ private connectBody; + /** Resolve the managed identity for a /v1/connect: mint a short-lived assertion + * from the durable auth session when signed in. Empty when there is no session + * (the legacy playerId/playerToken path then applies). */ + private resolveIdentity; /** Player id for keying local session storage. Falls back to the token when * no explicit playerId is set (keeps a stable-per-token key). */ private get playerKey(); @@ -500,4 +574,4 @@ type Quat = { w: number; }; -export { AssetClient, type AssetMeta, type FrameEvent, type FrameView, type Game, type InterpType, type InterpolateOpts, type LocalStore, MemoryStore, type PathDecl, type PlayOptions, Plot, type PredictOpts, type PredictedEvent, type Quat, Room, type SendOptions, type SessionState, SessionStore, type Vec2, type Vec3, WebStorageStore, createLocalStore, sessionKey }; +export { AssetClient, type AssetMeta, AuthClient, type FrameEvent, type FrameView, type Game, type InterpType, type InterpolateOpts, type LocalStore, MemoryStore, type PathDecl, type PlayOptions, PlayerDataClient, PlayerSessionStore, Plot, type PredictOpts, type PredictedEvent, type Quat, Room, type SendOptions, type SessionState, SessionStore, type Vec2, type Vec3, WebStorageStore, createLocalStore, sessionKey }; diff --git a/vendor/plot-client/index.js b/vendor/plot-client/index.js index e4b6bf4..b7df4e7 100644 --- a/vendor/plot-client/index.js +++ b/vendor/plot-client/index.js @@ -1097,6 +1097,17 @@ function makeCtx(input) { ai: { npc: () => ({ say: throwClient("ai.say"), forget: throwClient("ai.forget") }) }, + // Player accounts/data are server-authoritative; prediction never touches them. + accounts: { + get: throwClient("accounts.get"), + data: () => ({ + get: throwClient("accounts.data.get"), + getAll: throwClient("accounts.data.getAll"), + set: throwClient("accounts.data.set"), + delete: throwClient("accounts.data.delete") + }), + ban: throwClient("accounts.ban") + }, rewindTo: () => throwClient("rewindTo")() }; return ctx; @@ -1871,6 +1882,146 @@ var AssetClient = class { } }; +// src/persistence/auth.ts +var PlayerSessionStore = class { + constructor(store, appKey) { + this.store = store; + this.appKey = appKey; + } + key() { + return `plot:player-session:${this.appKey}`; + } + load() { + const raw = this.store.get(this.key()); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } + } + save(s) { + this.store.set(this.key(), JSON.stringify(s)); + } + clear() { + this.store.remove(this.key()); + } +}; +var headers = (appId) => ({ "content-type": "application/json", "X-Plot-App": appId }); +var AuthClient = class { + constructor(apiUrl, appKey, appId, store, onChange) { + this.apiUrl = apiUrl; + this.appKey = appKey; + this.appId = appId; + this.store = store; + this.onChange = onChange; + } + current() { + const s = this.store.load(); + return s ? { playerId: s.playerId, kind: s.kind, methods: s.methods } : null; + } + isAuthenticated() { + return this.current()?.kind === "registered"; + } + getSessionToken() { + return this.store.load()?.sessionToken ?? null; + } + async anonymous() { + const existing = this.current(); + if (existing) return existing; + const res = await fetch(`${this.apiUrl}/v1/players/anonymous`, { method: "POST", headers: headers(this.appId), body: JSON.stringify({ appKey: this.appKey }) }); + if (!res.ok) throw new Error(`auth.anonymous failed: ${res.status}`); + return this.persist(await res.json(), []); + } + async logout() { + const token = this.getSessionToken(); + if (token) await fetch(`${this.apiUrl}/v1/players/logout`, { method: "POST", headers: headers(this.appId), body: JSON.stringify({ appKey: this.appKey, sessionToken: token }) }); + this.store.clear(); + this.onChange?.(null); + } + /** Exchange the durable session for a short-lived connect assertion. Returns null when not signed in. */ + async mintIdentityToken() { + const token = this.getSessionToken(); + if (!token) return null; + const res = await fetch(`${this.apiUrl}/v1/players/token`, { method: "POST", headers: headers(this.appId), body: JSON.stringify({ appKey: this.appKey, sessionToken: token }) }); + if (!res.ok) return null; + return (await res.json()).token; + } + async register(opts) { + const res = await fetch(`${this.apiUrl}/v1/players/register`, { method: "POST", headers: headers(this.appId), body: JSON.stringify({ appKey: this.appKey, ...opts }) }); + if (!res.ok) throw new Error(`auth.register failed: ${res.status}`); + return this.persist(await res.json(), ["password"]); + } + async login(opts) { + const res = await fetch(`${this.apiUrl}/v1/players/login`, { method: "POST", headers: headers(this.appId), body: JSON.stringify({ appKey: this.appKey, ...opts }) }); + if (!res.ok) throw new Error(`auth.login failed: ${res.status}`); + const r = await res.json(); + return this.persist(r, r.methods ?? ["password"]); + } + /** Upgrade the current (anonymous) session by attaching a password. The durable + * session token is preserved; the account becomes 'registered'. */ + async link(opts) { + const res = await fetch(`${this.apiUrl}/v1/players/link`, { method: "POST", headers: headers(this.appId), body: JSON.stringify({ appKey: this.appKey, sessionToken: this.getSessionToken(), ...opts }) }); + if (!res.ok) throw new Error(`auth.link failed: ${res.status}`); + const cur = this.store.load(); + if (cur) this.store.save({ ...cur, kind: "registered", methods: [.../* @__PURE__ */ new Set([...cur.methods, "password"])] }); + const id = this.current(); + if (id) this.onChange?.(id); + return id ?? { playerId: "", kind: "registered", methods: ["password"] }; + } + /** URL to send the browser to for Discord OAuth. On return, the callback appends + * `#session=...&playerId=...` (or `#error=...`) — pass that to completeFromRedirect. */ + discordAuthUrl(redirect) { + return `${this.apiUrl}/v1/players/oauth/discord/start?appId=${encodeURIComponent(this.appId)}&redirect=${encodeURIComponent(redirect)}`; + } + /** Parse the `#session=...&playerId=...` fragment the OAuth callback appended and + * store it. Returns null (no throw) when the fragment carries an error instead. */ + completeFromRedirect(hash) { + const p = new URLSearchParams(hash.replace(/^#/, "")); + const session = p.get("session"); + const playerId = p.get("playerId"); + if (!session || !playerId) return null; + return this.persist({ sessionToken: session, playerId, expiresAt: 0, kind: "registered" }, ["oauth_discord"]); + } + /** Internal: persist a session response and notify. (register/login/link in Phase 3 reuse this.) */ + persist(r, methods) { + this.store.save({ sessionToken: r.sessionToken, playerId: r.playerId, kind: r.kind, methods }); + const id = { playerId: r.playerId, kind: r.kind, methods }; + this.onChange?.(id); + return id; + } +}; + +// src/persistence/player-data.ts +var PlayerDataClient = class { + constructor(apiUrl, tokenGetter) { + this.apiUrl = apiUrl; + this.tokenGetter = tokenGetter; + } + h() { + return { "content-type": "application/json", authorization: `Bearer ${this.tokenGetter()}` }; + } + async get(key) { + const res = await fetch(`${this.apiUrl}/v1/players/data/${encodeURIComponent(key)}`, { headers: this.h() }); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`playerData.get failed: ${res.status}`); + return (await res.json()).value; + } + async getAll() { + const res = await fetch(`${this.apiUrl}/v1/players/data`, { headers: this.h() }); + if (!res.ok) throw new Error(`playerData.getAll failed: ${res.status}`); + return res.json(); + } + async set(key, value) { + const res = await fetch(`${this.apiUrl}/v1/players/data/${encodeURIComponent(key)}`, { method: "PUT", headers: this.h(), body: JSON.stringify({ value }) }); + if (!res.ok) throw new Error(`playerData.set failed: ${res.status}`); + } + async delete(key) { + const res = await fetch(`${this.apiUrl}/v1/players/data/${encodeURIComponent(key)}`, { method: "DELETE", headers: this.h() }); + if (!res.ok) throw new Error(`playerData.delete failed: ${res.status}`); + } +}; + // src/play.ts function resolvePath2(path, me) { return path.replaceAll("{me}", me); @@ -2041,26 +2192,38 @@ var Plot = class { save; leaderboard; assets; + auth; + playerData; constructor(options) { - if (!options.playerId && !options.playerToken) { - throw new Error("Plot: supply a playerId or a playerToken"); - } this.options = options; + const apiUrl = options.apiUrl ?? DEFAULT_API_URL; + const store = createLocalStore(options.store); + this.auth = new AuthClient(apiUrl, options.appKey, options.appId ?? "", new PlayerSessionStore(store, options.appKey)); } /** The /v1/connect request body. Includes the developer JWT as `token` when a * `playerToken` is configured (the server derives the player id from it). */ - connectBody() { + connectBody(identity = {}) { const body = { appKey: this.options.appKey, - playerId: this.options.playerId ?? "" + playerId: identity.playerId ?? this.options.playerId ?? "" }; - if (this.options.playerToken) body.token = this.options.playerToken; + const token = identity.token ?? this.options.playerToken; + if (token) body.token = token; return body; } + /** Resolve the managed identity for a /v1/connect: mint a short-lived assertion + * from the durable auth session when signed in. Empty when there is no session + * (the legacy playerId/playerToken path then applies). */ + async resolveIdentity() { + const id = this.auth?.current(); + if (!id) return {}; + const token = await this.auth.mintIdentityToken(); + return { token: token ?? void 0, playerId: id.playerId }; + } /** Player id for keying local session storage. Falls back to the token when * no explicit playerId is set (keeps a stable-per-token key). */ get playerKey() { - return this.options.playerId ?? this.options.playerToken ?? "anon"; + return this.auth?.current()?.playerId ?? this.options.playerId ?? this.options.playerToken ?? "anon"; } getToken() { if (!this.currentToken) throw new Error("not connected \u2014 call connect() or join() first"); @@ -2079,13 +2242,15 @@ var Plot = class { this.save = new SaveClient(apiUrl, tg); this.leaderboard = (name) => new LeaderboardClient(apiUrl, name, tg); this.assets = new AssetClient(apiUrl, tg, () => this.assetBaseUrl); + this.playerData = new PlayerDataClient(apiUrl, tg); } async connect() { const apiUrl = this.options.apiUrl ?? DEFAULT_API_URL; + const identity = await this.resolveIdentity(); const res = await fetch(`${apiUrl}/v1/connect`, { method: "POST", headers: this.connectHeaders(), - body: JSON.stringify(this.connectBody()) + body: JSON.stringify(this.connectBody(identity)) }); if (!res.ok) throw new Error(`connect failed: ${res.status}`); const conn = await res.json(); @@ -2095,7 +2260,7 @@ var Plot = class { } async join(opts) { const apiUrl = this.options.apiUrl ?? DEFAULT_API_URL; - const body = this.connectBody(); + const body = this.connectBody(await this.resolveIdentity()); const cres = await fetch(`${apiUrl}/v1/connect`, { method: "POST", headers: this.connectHeaders(), @@ -2122,7 +2287,7 @@ var Plot = class { const rr = await fetch(`${apiUrl}/v1/connect`, { method: "POST", headers: this.connectHeaders(), - body: JSON.stringify(body) + body: JSON.stringify(this.connectBody(await this.resolveIdentity())) }); if (!rr.ok) throw new Error(`reconnect failed: ${rr.status}`); const conn = await rr.json(); @@ -2156,7 +2321,10 @@ var Plot = class { }; export { AssetClient, + AuthClient, MemoryStore, + PlayerDataClient, + PlayerSessionStore, Plot, Room, SessionStore, diff --git a/vendor/plot-client/protocol.d.ts b/vendor/plot-client/protocol.d.ts index b268ce8..5680f90 100644 --- a/vendor/plot-client/protocol.d.ts +++ b/vendor/plot-client/protocol.d.ts @@ -69,6 +69,17 @@ type PlayerJWTClaims = { exp: number; iat: number; }; +type PlayerIdentity = { + playerId: string; + kind: 'anonymous' | 'registered'; + methods: string[]; +}; +type PlayerSessionResponse = { + playerId: string; + sessionToken: string; + expiresAt: number; + kind: 'anonymous' | 'registered'; +}; type MatchMode = 'quick' | 'ranked' | 'code' | 'lobby'; type MatchmakeRequest = { @@ -112,4 +123,4 @@ type PeriodOverride = 'daily' | 'weekly' | 'monthly' | 'alltime'; declare const SCHEMA_VERSION = "v1b.0"; declare const HANDSHAKE_HEADER = "X-Plot-Protocol"; -export { CHANNELS, type Channel, type ClientEnvelope, type ConnectRequest, type ConnectResponse, DEFAULT_CHANNEL, HANDSHAKE_HEADER, type LeaderboardEntry, type MatchMode, type MatchmakeRequest, type MatchmakeResponse, type OpenRoomEntry, type PeriodOverride, type PlayerJWTClaims, type Profile, SCHEMA_VERSION, type ServerEnvelope, isChannel }; +export { CHANNELS, type Channel, type ClientEnvelope, type ConnectRequest, type ConnectResponse, DEFAULT_CHANNEL, HANDSHAKE_HEADER, type LeaderboardEntry, type MatchMode, type MatchmakeRequest, type MatchmakeResponse, type OpenRoomEntry, type PeriodOverride, type PlayerIdentity, type PlayerJWTClaims, type PlayerSessionResponse, type Profile, SCHEMA_VERSION, type ServerEnvelope, isChannel };