From 594b19b4563c300beaf1f07ed3d6a97b5270af26 Mon Sep 17 00:00:00 2001 From: "B.B. Jansen" <56940439+bbjansen@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:33:20 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20refresh=20vendored=20@plot/client=20?= =?UTF-8?q?=E2=80=94=20idle-eviction=20close=20(4008)=20is=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform's rooms now evict idle connections with close code 4008 (no client input for the idle window). The old vendored client treated any non-1000 close as abnormal and auto-reconnected — an idle open tab would reconnect every few minutes forever, keeping its room ticking. The refreshed bundle treats 4008 as a clean, terminal close (no retry) and adds the npcChunk event for streamed NPC lines. Type sidecars (protocol/handler/handler-client) refreshed to match. 49 tests + typecheck + build green. --- vendor/plot-client/handler-client.d.ts | 5 +- vendor/plot-client/handler.d.ts | 95 ++++++- vendor/plot-client/index.d.ts | 147 ++++++++++- vendor/plot-client/index.js | 352 +++++++++++++++++++++++-- vendor/plot-client/protocol.d.ts | 7 + 5 files changed, 576 insertions(+), 30 deletions(-) diff --git a/vendor/plot-client/handler-client.d.ts b/vendor/plot-client/handler-client.d.ts index 19e537f..f088477 100644 --- a/vendor/plot-client/handler-client.d.ts +++ b/vendor/plot-client/handler-client.d.ts @@ -1,4 +1,4 @@ -import { Player, RoomDefinition } from './handler'; +import { Player, RoomDefinition } from '@plot/handler'; type RunHandlerCtx = { state: unknown; @@ -73,6 +73,9 @@ declare class CorrectionTrack { private hasRecord; constructor(opts: CorrectionOpts); record(drift: Value, now: number): void; + /** Drop any in-flight correction immediately (used when a drift exceeds the + * snap threshold — the caller snaps instead of easing). */ + reset(): void; read(now: number): Value; } diff --git a/vendor/plot-client/handler.d.ts b/vendor/plot-client/handler.d.ts index bea2c20..30c3df3 100644 --- a/vendor/plot-client/handler.d.ts +++ b/vendor/plot-client/handler.d.ts @@ -1,4 +1,4 @@ -import { Channel, Profile, PeriodOverride, LeaderboardEntry } from './protocol'; +import { Channel, Profile, PeriodOverride, LeaderboardEntry } from '@plot/protocol'; /** * Read-only view of the world at a past timestamp. @@ -40,6 +40,89 @@ interface ReplayApi { data: unknown; }): void; } +interface AssetMeta { + path: string; + hash: string; + size: number; + contentType: string; + visibility: 'public' | 'private'; +} +interface AssetsApi { + /** Public, immutable content-hash CDN URL. Synchronous, offline (built from + * the seeded manifest). Throws if the path is unknown or private (use + * `signedUrl`). */ + url(path: string): string; + /** Short-lived signed URL for a private asset (ttl seconds, clamped to an app + * ceiling). Resolves via local HMAC for managed/proxy apps. */ + signedUrl(path: string, opts?: { + ttl?: number; + }): Promise; + /** Manifest read from the seeded map — no I/O. */ + list(prefix?: string): Promise; +} +/** The completion of an NPC line requested via `ctx.ai.npc(id).say()`. Delivered + * later via the room's `onAiResult(result, ctx)` — never returned inline. */ +interface AiResult { + requestId: string; + npcId: string; + from: string; + /** The generated line, or '' when `error` is set. */ + text: string; + usage?: { + inputTokens: number; + outputTokens: number; + cached: boolean; + }; + /** `timeout` = the request was in flight when the room was evicted and could + * not complete; the reaper delivered this so your handler never hangs. + * `blocked` = the generated line failed the (opt-in) moderation filter. */ + error?: 'rate_limited' | 'quota_exceeded' | 'disabled' | 'model_error' | 'timeout' | 'blocked'; + /** Tool calls the NPC requested (when `say({ tools })` was used). Present + * instead of `text` when the model chose to call a tool. */ + toolCalls?: AiToolCall[]; +} +/** A function/tool the NPC decided to invoke. Your `onAiResult` executes it and + * may continue the exchange with another `say()`. */ +interface AiToolCall { + /** The tool name (matches one you passed in `say({ tools })`). */ + name: string; + /** Arguments the model produced, parsed from its JSON. */ + arguments: Record; +} +/** A tool the NPC may call, declared per `say()`. The model is told the name, + * description, and JSON-schema parameters; if it calls the tool, the call + * arrives on `onAiResult.toolCalls` for your handler to execute. */ +interface AiToolDef { + name: string; + description: string; + /** JSON Schema for the arguments object (as Workers AI expects). */ + parameters: Record; +} +interface NpcHandle { + /** Request one in-character NPC line. Records an intent and resolves with a + * requestId — it does NOT return the line; the completion arrives later via + * `onAiResult` (matching requestId). maxTokens is clamped server-side. + * + * - `stream: true` streams the line to the room's players as it generates + * (an `ai-chunk` message per delta); the final `onAiResult` still fires. + * - `tools` lets the NPC call your game functions — a chosen call arrives on + * `onAiResult.toolCalls` instead of `text`. */ + say(input: { + from: string; + text: string; + conversationKey?: string; + maxTokens?: number; + stream?: boolean; + tools?: AiToolDef[]; + }): Promise<{ + requestId: string; + }>; + /** Reset short-term memory for a conversation (default key = the player id). */ + forget(conversationKey?: string): Promise; +} +interface AiApi { + npc(id: string): NpcHandle; +} interface HandlerContext { state: S; roomCode: string; @@ -68,6 +151,8 @@ interface HandlerContext { leaderboard: (name: string) => LeaderboardHandle; save: SaveApi; replay: ReplayApi; + assets: AssetsApi; + ai: AiApi; /** * Run `cb` against the world state at `targetTs` (clamped to the * server's 500ms rewind horizon). Use this to validate hits or other @@ -116,7 +201,13 @@ interface RoomDefinition { * Workers-for-Platforms isolate boundary). */ onTimer?: (payload: unknown, ctx: HandlerContext) => void | Promise; + /** + * Fired when an NPC completion requested via `ctx.ai.npc(id).say()` is ready. + * Delivered as a fresh RPC (like onTimer) — the originating say() call returned + * a requestId long before the model finished. `result.requestId` matches. + */ + onAiResult?: (result: AiResult, ctx: HandlerContext) => void | Promise; } declare function defineRoom(def: RoomDefinition): RoomDefinition; -export { type ChannelConfig, type ChannelsConfig, type HandlerContext, HandlerReject, type LeaderboardHandle, PersistenceError, type Player, type ProfileApi, type ReplayApi, type RewoundContext, type RoomDefinition, type SaveApi, defineRoom }; +export { type AiApi, type AiResult, type AssetMeta, type AssetsApi, type ChannelConfig, type ChannelsConfig, type HandlerContext, HandlerReject, type LeaderboardHandle, type NpcHandle, PersistenceError, type Player, type ProfileApi, type ReplayApi, type RewoundContext, type RoomDefinition, type SaveApi, defineRoom }; diff --git a/vendor/plot-client/index.d.ts b/vendor/plot-client/index.d.ts index 88af1c4..977b421 100644 --- a/vendor/plot-client/index.d.ts +++ b/vendor/plot-client/index.d.ts @@ -1,4 +1,4 @@ -import { CorrectionType } from '@plot/handler-client'; +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'; @@ -22,6 +22,9 @@ declare class SnapshotBuffer { get size(): number; get oldest(): Snapshot | null; get newest(): Snapshot | null; + /** The snapshot just before `newest` — the other end of the last segment, + * used to estimate velocity when extrapolating past the newest snapshot. */ + get secondNewest(): Snapshot | null; } type InterpType = 'number' | 'vec2' | 'vec3' | 'quat'; @@ -29,6 +32,11 @@ type InterpolateOpts = { path: string; type: InterpType; renderDelay?: number; + /** When the render target runs *past* the newest snapshot (a late/dropped + * packet), project forward using the last segment's velocity instead of + * freezing — clamped to this many ms so a long gap can't overshoot. Default + * 0 (disabled → hold at the last value). Ignored for `quat`. */ + extrapolateMs?: number; }; /** @@ -62,8 +70,50 @@ interface Transport { }) => void): void; } +interface LocalStore { + get(key: string): string | null; + set(key: string, value: string): void; + remove(key: string): void; +} +declare class MemoryStore implements LocalStore { + private map; + get(key: string): string | null; + set(key: string, value: string): void; + remove(key: string): void; +} +declare class WebStorageStore implements LocalStore { + private storage; + constructor(storage: Storage); + get(key: string): string | null; + set(key: string, value: string): void; + remove(key: string): void; +} +declare function createLocalStore(preferred?: LocalStore): LocalStore; + +interface SessionState { + reconnectToken?: string; + tokenExpiresAt?: number; + snapshot?: { + ts: number; + state: unknown; + }; + outbox: QueueEntry[]; + lastAckedSeq: number; + nextSeq: number; +} +declare function sessionKey(appId: string, roomCode: string, playerId: string): string; +declare class SessionStore { + private store; + private key; + constructor(store: LocalStore, key: string); + load(): SessionState | null; + save(patch: Partial): void; + clear(): void; +} + type RoomMessageEvent = { from: string; + channel: Channel; data: unknown; }; type PresenceEvent = { @@ -83,6 +133,12 @@ type PredictOpts = { path: string; type: CorrectionType; correctionMs?: number; + /** Drift magnitude above which the correction snaps instead of easing — + * large mispredictions (teleports, server resets) jump straight to the + * authoritative value rather than sliding across the world. In the same + * units as the predicted value (Euclidean norm for vectors). Default: + * Infinity (always ease). */ + snapThreshold?: number; }; interface RoomEvents { message: (e: RoomMessageEvent) => void; @@ -97,6 +153,13 @@ interface RoomEvents { }) => void; /** A reconnect succeeded; the server will re-send a snapshot to resync. */ reconnect: () => void; + /** A streamed NPC line delta (from a handler `say({ stream: true })`). Append + * `delta` to the line identified by `requestId` to show the NPC "typing". */ + npcChunk: (e: { + requestId: string; + npcId: string; + delta: string; + }) => void; } interface SendOptions { channel?: Channel; @@ -108,6 +171,9 @@ declare class Room { /** Re-establishes a transport for the same room (fresh token, optional * resume token). Null when the Room was created without reconnect support. */ private reconnectFn; + /** Durable session cache (outbox + last snapshot + resume token). Null + * disables persistence; the server snapshot stays authoritative either way. */ + private session; private listeners; /** Latest server-issued resume token (refreshed each (re)connect); presented * on reconnect to resume the same slot within the grace window. */ @@ -128,12 +194,22 @@ declare class Room { private lastDrift; private predictedTracks; correctedState: Record; + /** Unacked predicted inputs, replayed after a reconnect and persisted via + * `session` so a page reload can resume without losing them. */ + private outbox; constructor(_playerId: string, transport: Transport, /** Re-establishes a transport for the same room (fresh token, optional * resume token). Null when the Room was created without reconnect support. */ - reconnectFn?: ((resumeToken: string | null) => Promise) | null); + reconnectFn?: ((resumeToken: string | null) => Promise) | null, + /** Durable session cache (outbox + last snapshot + resume token). Null + * disables persistence; the server snapshot stays authoritative either way. */ + session?: SessionStore | null); /** Internal: register a sink that receives every applied (ts, state). */ _onSnapshot(sink: (ts: number, state: unknown) => void): void; + /** Prune the outbox up to the server's ack and persist the authoritative + * snapshot + outbox. Called from both state-snapshot and state-patch. */ + private onAck; + private persistSession; /** (Re)bind message + close handlers to the current transport. Called on * construction and after each successful reconnect (the transport is swapped). */ private wireTransport; @@ -222,6 +298,42 @@ declare class SaveClient { get(slot: string): Promise; } +interface AssetMeta { + path: string; + hash: string; + size: number; + contentType: string; + visibility: 'public' | 'private'; +} +/** + * Browser SDK asset client (`plot.assets`), mirroring the handler `ctx.assets`. + * `list()` fetches the manifest once and caches it; `url()` then builds public + * immutable URLs offline (no per-call network). `signedUrl()` round-trips to the + * server so private URLs work regardless of backend/sign mode. + */ +declare class AssetClient { + private apiUrl; + private tokenGetter; + /** CDN/proxy base for this app, delivered by /v1/connect. */ + private baseUrlGetter; + private cache; + constructor(apiUrl: string, tokenGetter: () => string, + /** CDN/proxy base for this app, delivered by /v1/connect. */ + baseUrlGetter: () => string); + private auth; + /** Fetch + cache the manifest. Called lazily by list()/ready(). */ + refresh(): Promise; + /** Ensure the manifest is cached (so url() can resolve offline). */ + ready(): Promise; + list(prefix?: string): Promise; + /** Public immutable URL, built offline from the cached manifest. Throws if the + * manifest hasn't been loaded, or the path is unknown/private. */ + url(path: string): string; + signedUrl(path: string, opts?: { + ttl?: number; + }): Promise; +} + /** * Managed netcode layer. * @@ -242,8 +354,14 @@ interface PathDecl { type: InterpType; /** Local paths only: error-correction window (ms). Default 100. */ correctionMs?: number; + /** Local paths only: drift magnitude above which the correction snaps to + * the authoritative value instead of easing. Default: always ease. */ + snapThreshold?: number; /** Remote paths only: snapshot render delay (ms). Default 100. */ renderDelay?: number; + /** Remote paths only: when a snapshot is late, project forward with the last + * velocity up to this many ms instead of freezing. Default 0 (hold). */ + extrapolateMs?: number; } /** Resolved per-frame view handed to `onFrame`, assembled from the Room. */ interface FrameView { @@ -305,12 +423,22 @@ interface Game { interface PlotOptions { appKey: string; - playerId: string; + /** Stable anonymous player id. Optional when `playerToken` is supplied — the + * server then trusts the token's `sub` claim as the player id. */ + playerId?: string; + /** A developer-issued player JWT (HS256, signed with the app's JWT secret, + * `sub` = your player id). When present, the server verifies it and uses its + * `sub` as the authoritative player id — the way to tie Plot identity to + * your own accounts so players can't impersonate one another. */ + playerToken?: string; apiUrl?: string; /** App id for multi-tenant routing — sent as the `X-Plot-App` header so the * server resolves THIS app (not its default APP_ID) and verifies `appKey` * against it. Required when an account hosts more than one app. */ appId?: string; + /** Backing store for durable session resume (outbox + last snapshot). + * Defaults to localStorage when available, in-memory otherwise. */ + store?: LocalStore; } interface JoinOptions { mode?: MatchMode; @@ -318,14 +446,25 @@ interface JoinOptions { maxPlayers?: number; attrs?: Record; rank?: number; + /** Restore a cached session (last snapshot + unacked outbox) for this + * app/room/player and keep persisting it. Default true. */ + resume?: boolean; } declare class Plot { private options; private currentToken; + private assetBaseUrl; profile: ProfileClient; save: SaveClient; leaderboard: (name: string) => LeaderboardClient; + assets: AssetClient; 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; + /** 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(); private getToken; /** Headers for /v1/connect: JSON + the multi-tenant X-Plot-App routing header * when an appId is configured. */ @@ -361,4 +500,4 @@ type Quat = { w: number; }; -export { type FrameEvent, type FrameView, type Game, type InterpType, type InterpolateOpts, type PathDecl, type PlayOptions, Plot, type PredictOpts, type PredictedEvent, type Quat, Room, type SendOptions, type Vec2, type Vec3 }; +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 }; diff --git a/vendor/plot-client/index.js b/vendor/plot-client/index.js index 2f26bd6..e4b6bf4 100644 --- a/vendor/plot-client/index.js +++ b/vendor/plot-client/index.js @@ -37,7 +37,8 @@ function openTransport(wsUrl, roomCode, token, resumeToken) { }); ws.addEventListener("close", (e) => { stopKeepalive(); - const clean = e.code === 1e3; + const code = e.code; + const clean = code === 1e3 || code === 4008; for (const h of closeHandlers) h({ clean }); }); return new Promise((resolve, reject) => { @@ -746,6 +747,11 @@ var SnapshotBuffer = class { get newest() { return this.snapshots[this.snapshots.length - 1] ?? null; } + /** The snapshot just before `newest` — the other end of the last segment, + * used to estimate velocity when extrapolating past the newest snapshot. */ + get secondNewest() { + return this.snapshots.length >= 2 ? this.snapshots[this.snapshots.length - 2] : null; + } }; // src/interpolation/server-clock.ts @@ -903,6 +909,8 @@ var Interpolator = class { const { a, b } = pair; const out = {}; if (b === null) { + const extrap = this.tryExtrapolate(a, targetTs); + if (extrap) return extrap; for (const leaf of resolvePath(this.opts.path, a.state, a.state)) { if (leaf.valueA !== void 0) out[leaf.path] = leaf.valueA; } @@ -924,7 +932,44 @@ var Interpolator = class { } return out; } + /** If `targetTs` is past the newest snapshot and extrapolation is enabled, + * project each leaf forward from the last segment's velocity (clamped). + * Returns null when not applicable (disabled, quat, not the future end, or + * no prior snapshot to derive velocity). */ + tryExtrapolate(anchor, targetTs) { + const ms = this.opts.extrapolateMs ?? 0; + if (ms <= 0 || this.opts.type === "quat") return null; + const newest = this.buffer.newest; + const prev = this.buffer.secondNewest; + if (!newest || !prev || anchor.ts !== newest.ts || targetTs <= newest.ts) return null; + const segMs = newest.ts - prev.ts; + if (segMs <= 0) return null; + const aheadMs = Math.min(targetTs - newest.ts, ms); + const k = aheadMs / segMs; + const out = {}; + for (const leaf of resolvePath(this.opts.path, prev.state, newest.state)) { + if (leaf.valueB === void 0) continue; + out[leaf.path] = leaf.valueA === void 0 ? leaf.valueB : extrapByType(this.opts.type, leaf.valueA, leaf.valueB, k); + } + return out; + } }; +function extrapByType(type, a, b, k) { + switch (type) { + case "number": + return b + (b - a) * k; + case "vec2": { + const av = a, bv = b; + return { x: bv.x + (bv.x - av.x) * k, y: bv.y + (bv.y - av.y) * k }; + } + case "vec3": { + const av = a, bv = b; + return { x: bv.x + (bv.x - av.x) * k, y: bv.y + (bv.y - av.y) * k, z: bv.z + (bv.z - av.z) * k }; + } + case "quat": + return b; + } +} function lerpByType(type, a, b, t) { switch (type) { case "number": @@ -1042,6 +1087,16 @@ function makeCtx(input) { enabled: () => false, append: () => void 0 }, + // Assets are a server-side concern; client prediction never resolves URLs. + assets: { + url: throwClient("assets.url"), + signedUrl: throwClient("assets.signedUrl"), + list: throwClient("assets.list") + }, + // AI runs server-side; client prediction never issues NPC calls. + ai: { + npc: () => ({ say: throwClient("ai.say"), forget: throwClient("ai.forget") }) + }, rewindTo: () => throwClient("rewindTo")() }; return ctx; @@ -1146,6 +1201,12 @@ var CorrectionTrack = class { this.startedAt = now; this.hasRecord = true; } + /** Drop any in-flight correction immediately (used when a drift exceeds the + * snap threshold — the caller snaps instead of easing). */ + reset() { + this.hasRecord = false; + this.value = zero(this.opts.type); + } read(now) { if (!this.hasRecord) return zero(this.opts.type); const elapsed = now - this.startedAt; @@ -1215,11 +1276,8 @@ var Predictor = class { } this._predicted = next; const overflowed = this._queue.push(entry); - if (overflowed && this._queue.size >= 200) { - console.warn("[predictor] input queue overflowed; disabling prediction"); - this._disabled = true; - this._queue.clear(); - this._predicted = structuredClone(this._authoritative); + if (overflowed) { + console.warn("[predictor] input queue overflowed; dropped oldest unacked input"); } } reconcile(serverState, lastAckedSeq) { @@ -1254,6 +1312,13 @@ var Predictor = class { } }; +// ../protocol/src/channels.ts +var CHANNELS = ["state", "event", "chat", "unreliable"]; +function isChannel(x) { + return typeof x === "string" && CHANNELS.includes(x); +} +var DEFAULT_CHANNEL = "event"; + // src/room.ts function readPath(state, path) { let cur = state; @@ -1285,6 +1350,24 @@ function computeDrift(type, prev, next) { } } } +function driftMagnitude(type, drift) { + switch (type) { + case "number": + return Math.abs(drift); + case "vec2": { + const d = drift; + return Math.hypot(d.x, d.y); + } + case "vec3": { + const d = drift; + return Math.hypot(d.x, d.y, d.z); + } + case "quat": { + const d = drift; + return Math.hypot(d.x, d.y, d.z, d.w); + } + } +} function applyOffset(type, base, offset) { if (base === void 0) return void 0; switch (type) { @@ -1308,10 +1391,22 @@ function applyOffset(type, base, offset) { } } var Room = class { - constructor(_playerId, transport, reconnectFn = null) { + constructor(_playerId, transport, reconnectFn = null, session = null) { this._playerId = _playerId; this.transport = transport; this.reconnectFn = reconnectFn; + this.session = session; + const saved = this.session?.load(); + if (saved) { + if (saved.snapshot) this.currentState = saved.snapshot.state; + this.nextSeq = saved.nextSeq; + this.lastAckedSeq = saved.lastAckedSeq; + this.resumeToken = saved.reconnectToken ?? null; + this.outbox = saved.outbox.map((e) => ({ + seq: e.seq, + env: { type: "message", channel: "event", data: e.input, _seq: e.seq } + })); + } this.wireTransport(); this._onSnapshot((ts, state) => { this.clock.observe({ clientNow: Date.now(), serverTs: ts }); @@ -1325,7 +1420,12 @@ var Room = class { for (const t of this.predictedTracks) { const newValue = readPath(this.predictor.predictedState, t.opts.path); const drift = computeDrift(t.opts.type, t.previousValue, newValue); - if (drift !== null) t.track.record(drift, now); + if (drift === null) continue; + if (driftMagnitude(t.opts.type, drift) > (t.opts.snapThreshold ?? Infinity)) { + t.track.reset(); + } else { + t.track.record(drift, now); + } } for (const h of this.listeners.predicted) { h({ state: this.predictor.predictedState, ts, drift: this.lastDrift }); @@ -1340,7 +1440,8 @@ var Room = class { frame: [], predicted: [], disconnect: [], - reconnect: [] + reconnect: [], + npcChunk: [] }; /** Latest server-issued resume token (refreshed each (re)connect); presented * on reconnect to resume the same slot within the grace window. */ @@ -1365,10 +1466,33 @@ var Room = class { lastDrift = 0; predictedTracks = []; correctedState = {}; + /** Unacked predicted inputs, replayed after a reconnect and persisted via + * `session` so a page reload can resume without losing them. */ + outbox = []; /** Internal: register a sink that receives every applied (ts, state). */ _onSnapshot(sink) { this.snapshotSink = sink; } + /** Prune the outbox up to the server's ack and persist the authoritative + * snapshot + outbox. Called from both state-snapshot and state-patch. */ + onAck(lastAckedSeq, ts, state) { + if (typeof lastAckedSeq === "number") { + this.outbox = this.outbox.filter((e) => e.seq > lastAckedSeq); + } + this.session?.save({ + snapshot: { ts, state }, + outbox: this.outbox.map((e) => ({ seq: e.seq, input: e.env.data })), + lastAckedSeq: this.lastAckedSeq, + nextSeq: this.nextSeq + }); + } + persistSession() { + this.session?.save({ + outbox: this.outbox.map((e) => ({ seq: e.seq, input: e.env.data })), + nextSeq: this.nextSeq, + lastAckedSeq: this.lastAckedSeq + }); + } /** (Re)bind message + close handlers to the current transport. Called on * construction and after each successful reconnect (the transport is swapped). */ wireTransport() { @@ -1401,6 +1525,7 @@ var Room = class { try { this.transport = await this.reconnectFn(this.resumeToken); this.wireTransport(); + for (const { env } of this.outbox) this.transport.send(env); this.reconnecting = false; for (const h of this.listeners.reconnect) h(); return; @@ -1424,6 +1549,7 @@ var Room = class { } leave() { this.intentionalLeave = true; + this.session?.clear(); this.transport.close(); } attachHandler(room) { @@ -1462,6 +1588,8 @@ var Room = class { _seq: seq, clientTs: opts?.clientTs ?? Date.now() }; + this.outbox.push({ seq, env }); + this.persistSession(); this.transport.send(env); for (const h of this.listeners.predicted) { h({ state: predictor.predictedState, ts: Date.now(), drift: 0 }); @@ -1575,7 +1703,8 @@ var Room = class { dispatch(env) { switch (env.type) { case "message": - for (const h of this.listeners.message) h({ from: env.from, data: env.data }); + for (const h of this.listeners.message) + h({ from: env.from, channel: isChannel(env.channel) ? env.channel : DEFAULT_CHANNEL, data: env.data }); break; case "join": for (const h of this.listeners.join) h({ playerId: env.playerId, players: env.players }); @@ -1587,6 +1716,7 @@ var Room = class { if (env.lastAckedSeq !== void 0) this.lastAckedSeq = env.lastAckedSeq; this.currentState = env.state; this.snapshotSink?.(env.ts, this.currentState); + this.onAck(env.lastAckedSeq, env.ts, this.currentState); break; case "state-patch": { if (env.lastAckedSeq !== void 0) this.lastAckedSeq = env.lastAckedSeq; @@ -1596,13 +1726,21 @@ var Room = class { ).newDocument; this.currentState = next; this.snapshotSink?.(env.ts, this.currentState); + this.onAck(env.lastAckedSeq, env.ts, this.currentState); break; } case "reconnect-token": if (typeof env.token === "string") { this.resumeToken = env.token; + this.session?.save({ + reconnectToken: this.resumeToken, + tokenExpiresAt: env.expiresAt + }); } break; + case "ai-chunk": + for (const h of this.listeners.npcChunk) h({ requestId: env.requestId, npcId: env.npcId, delta: env.delta }); + break; case "error": break; } @@ -1686,6 +1824,53 @@ var SaveClient = class { } }; +// src/persistence/assets.ts +var AssetClient = class { + constructor(apiUrl, tokenGetter, baseUrlGetter) { + this.apiUrl = apiUrl; + this.tokenGetter = tokenGetter; + this.baseUrlGetter = baseUrlGetter; + } + cache = null; + auth() { + return { authorization: `Bearer ${this.tokenGetter()}` }; + } + /** Fetch + cache the manifest. Called lazily by list()/ready(). */ + async refresh() { + const res = await fetch(`${this.apiUrl}/v1/assets`, { headers: this.auth() }); + if (!res.ok) throw new Error(`assets.list failed: ${res.status}`); + const { assets } = await res.json(); + this.cache = new Map(assets.map((a) => [a.path, a])); + } + /** Ensure the manifest is cached (so url() can resolve offline). */ + async ready() { + if (!this.cache) await this.refresh(); + } + async list(prefix) { + if (!this.cache) await this.refresh(); + const all = [...this.cache.values()]; + return prefix ? all.filter((m) => m.path.startsWith(prefix)) : all; + } + /** Public immutable URL, built offline from the cached manifest. Throws if the + * manifest hasn't been loaded, or the path is unknown/private. */ + url(path) { + if (!this.cache) throw new Error("assets not loaded \u2014 await plot.assets.ready() (or list()) first"); + const m = this.cache.get(path); + if (!m) throw new Error(`unknown asset: ${path}`); + if (m.visibility === "private") throw new Error(`asset is private (use signedUrl): ${path}`); + return `${this.baseUrlGetter()}/${m.hash}/${path}`; + } + async signedUrl(path, opts) { + const res = await fetch(`${this.apiUrl}/v1/assets/sign`, { + method: "POST", + headers: { "content-type": "application/json", ...this.auth() }, + body: JSON.stringify({ path, ttl: opts?.ttl }) + }); + if (!res.ok) throw new Error(`assets.signedUrl failed: ${res.status}`); + return (await res.json()).url; + } +}; + // src/play.ts function resolvePath2(path, me) { return path.replaceAll("{me}", me); @@ -1699,11 +1884,21 @@ function createManagedGame(room, playerId, opts) { for (const d of opts.predict ?? []) { const path = resolvePath2(d.path, playerId); localPaths.push(path); - room.predict({ path, type: d.type, correctionMs: d.correctionMs ?? opts.correctionMs ?? 100 }); + room.predict({ + path, + type: d.type, + correctionMs: d.correctionMs ?? opts.correctionMs ?? 100, + snapThreshold: d.snapThreshold + }); } for (const d of opts.interpolate ?? []) { const path = resolvePath2(d.path, playerId); - room.interpolate({ path, type: d.type, renderDelay: d.renderDelay ?? opts.renderDelayMs ?? 100 }); + room.interpolate({ + path, + type: d.type, + renderDelay: d.renderDelay ?? opts.renderDelayMs ?? 100, + extrapolateMs: d.extrapolateMs + }); } room.setAdaptiveSmoothing({ enabled: opts.adaptiveSmoothing ?? true }); let players = [playerId]; @@ -1755,17 +1950,118 @@ function createManagedGame(room, playerId, opts) { return game; } +// src/persistence/local-store.ts +var MemoryStore = class { + map = /* @__PURE__ */ new Map(); + get(key) { + return this.map.has(key) ? this.map.get(key) : null; + } + set(key, value) { + this.map.set(key, value); + } + remove(key) { + this.map.delete(key); + } +}; +var WebStorageStore = class { + constructor(storage) { + this.storage = storage; + } + get(key) { + try { + return this.storage.getItem(key); + } catch { + return null; + } + } + set(key, value) { + try { + this.storage.setItem(key, value); + } catch { + } + } + remove(key) { + try { + this.storage.removeItem(key); + } catch { + } + } +}; +function createLocalStore(preferred) { + if (preferred) return preferred; + try { + const ls = globalThis.localStorage; + if (ls) { + ls.setItem("__plot_probe__", "1"); + ls.removeItem("__plot_probe__"); + return new WebStorageStore(ls); + } + } catch { + } + return new MemoryStore(); +} + +// src/session/session-store.ts +function sessionKey(appId, roomCode, playerId) { + return `plot:session:${appId}:${roomCode}:${playerId}`; +} +var EMPTY = { outbox: [], lastAckedSeq: 0, nextSeq: 0 }; +var SessionStore = class { + constructor(store, key) { + this.store = store; + this.key = key; + } + load() { + const raw = this.store.get(this.key); + if (raw === null) return null; + try { + const parsed = JSON.parse(raw); + return { ...EMPTY, ...parsed }; + } catch { + return null; + } + } + save(patch) { + const current = this.load() ?? EMPTY; + const next = { ...current, ...patch }; + this.store.set(this.key, JSON.stringify(next)); + } + clear() { + this.store.remove(this.key); + } +}; + // src/plot.ts var DEFAULT_API_URL = "https://api.plot.ws"; var Plot = class { options; currentToken = null; + assetBaseUrl = ""; profile; save; leaderboard; + assets; constructor(options) { + if (!options.playerId && !options.playerToken) { + throw new Error("Plot: supply a playerId or a playerToken"); + } this.options = options; } + /** 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() { + const body = { + appKey: this.options.appKey, + playerId: this.options.playerId ?? "" + }; + if (this.options.playerToken) body.token = this.options.playerToken; + return body; + } + /** 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"; + } getToken() { if (!this.currentToken) throw new Error("not connected \u2014 call connect() or join() first"); return this.currentToken; @@ -1782,33 +2078,33 @@ var Plot = class { this.profile = new ProfileClient(apiUrl, tg); this.save = new SaveClient(apiUrl, tg); this.leaderboard = (name) => new LeaderboardClient(apiUrl, name, tg); + this.assets = new AssetClient(apiUrl, tg, () => this.assetBaseUrl); } async connect() { const apiUrl = this.options.apiUrl ?? DEFAULT_API_URL; const res = await fetch(`${apiUrl}/v1/connect`, { method: "POST", headers: this.connectHeaders(), - body: JSON.stringify({ appKey: this.options.appKey, playerId: this.options.playerId }) + body: JSON.stringify(this.connectBody()) }); if (!res.ok) throw new Error(`connect failed: ${res.status}`); - const { token } = await res.json(); - this.currentToken = token; + const conn = await res.json(); + this.currentToken = conn.token; + this.assetBaseUrl = conn.assetBaseUrl ?? ""; this.wirePersistence(apiUrl); } async join(opts) { const apiUrl = this.options.apiUrl ?? DEFAULT_API_URL; - const body = { - appKey: this.options.appKey, - playerId: this.options.playerId - }; + const body = this.connectBody(); const cres = await fetch(`${apiUrl}/v1/connect`, { method: "POST", headers: this.connectHeaders(), body: JSON.stringify(body) }); if (!cres.ok) throw new Error(`connect failed: ${cres.status}`); - const { token, wsUrl } = await cres.json(); + const { token, wsUrl, assetBaseUrl } = await cres.json(); this.currentToken = token; + this.assetBaseUrl = assetBaseUrl ?? ""; this.wirePersistence(apiUrl); let roomCode = opts.roomCode; if (opts.mode && opts.mode !== "code") { @@ -1834,7 +2130,11 @@ var Plot = class { return openTransport(conn.wsUrl, code, conn.token, resumeToken ?? void 0); }; const transport = await openTransport(wsUrl, code, token); - return new Room(this.options.playerId, transport, reconnect); + const session = opts.resume !== false ? new SessionStore( + createLocalStore(this.options.store), + sessionKey(this.options.appId ?? this.options.appKey, code, this.playerKey) + ) : null; + return new Room(this.playerKey, transport, reconnect, session); } /** * Managed entry point: join a room and wire prediction, interpolation, @@ -1851,12 +2151,18 @@ var Plot = class { attrs: opts.attrs, rank: opts.rank }); - return createManagedGame(room, this.options.playerId, opts); + return createManagedGame(room, this.playerKey, opts); } }; export { + AssetClient, + MemoryStore, Plot, - Room + Room, + SessionStore, + WebStorageStore, + createLocalStore, + sessionKey }; /*! Bundled license information: diff --git a/vendor/plot-client/protocol.d.ts b/vendor/plot-client/protocol.d.ts index 4d998c0..b268ce8 100644 --- a/vendor/plot-client/protocol.d.ts +++ b/vendor/plot-client/protocol.d.ts @@ -12,6 +12,8 @@ type ConnectResponse = { token: string; expiresAt: number; wsUrl: string; + assetBaseUrl?: string; + assetSignMode?: 'local' | 'server'; }; type ServerEnvelope = { type: 'join'; @@ -43,6 +45,11 @@ type ServerEnvelope = { type: 'reconnect-token'; token: string; expiresAt: number; +} | { + type: 'ai-chunk'; + requestId: string; + npcId: string; + delta: string; } | { type: 'error'; code: string;