/** * Minimal clock shape consumed by the store — a strict subset of * `RoomClockLike`, declared locally so this module stays portable without an * intra-package dependency on `../RoomClock.ts`. */ export interface PredictedSpawnsClock { /** Lag-invariant timestamp source for spawn `at` and TTL comparison. */ serverNow(): number; /** Current smoothed round-trip time, fed to the dynamic TTL policy. */ smoothedRtt(): number; } /** * How a candidate server entity is paired to a pending local prediction: * - `"fifo"` (default): consume the oldest unmatched prediction. Zero server * cooperation; relies only on spawn order. Fragile only if the server * rejects this client's actions *out of order*. * - predicate: match the first pending local for which it returns true — * e.g. `(local, server) => Math.abs(server.spawnTime - local.spawnTime) < * TOL`. Robust to out-of-order without any extra wire field. */ export type SpawnCorrelation = "fifo" | ((local: L, server: S) => boolean); /** Options for {@link PredictedSpawns}. `S` is the server element type; `L` the * predicted-local shape (defaults to `Partial` — annotate a callback param * or pass `L` explicitly to model client-only fields). */ export interface PredictedSpawnsOptions, D = undefined> { /** * Which incoming server entities are this client's to correlate. Entities * for which this returns false are surfaced as *foreign* (server-only) * entries and never consume a prediction — e.g. `s => s.ownerId === * room.sessionId`. Omit to treat every server entity as correlatable * (single-owner rooms). */ owned?: (server: S) => boolean; /** Pairing strategy. Defaults to `"fifo"`. */ correlate?: SpawnCorrelation; /** * Server-clock spawn instant of an authoritative entity (e.g. `r => * r.bornMs`). When set, confirmation measures the entry's **input lead** * — `spawnTime(server) − entry.at` — the exact uplink + input-buffering * delay between the client predicting the spawn and the server executing * it. Measured per spawn; no RTT/2 estimating. * * Why: a lag-compensated projectile is hit-tested through the *shooter's* * rewound view, so the trajectory the shooter predicted at fire time is * the one the server judges. Rendering the confirmed entity at reckoned * server-present would snap it back by `lead × velocity` and re-fly that * stretch. `predict.spawns(..., { fields })` reckons owned entities with * this lead so the confirmed entity continues the prediction's flight * seamlessly; foreign entities (never predicted, `lead = 0`) render at * server-present as usual. */ spawnTime?: (server: S) => number; /** * Advance a *pending* (not-yet-confirmed) local each frame. `dt` is seconds * since the previous {@link tick}. Confirmed entries read from the server * entity and are never stepped. */ step?: (local: L, dt: number) => void; /** * Eviction window for unmatched predictions, in ms, given the current RTT. * A pending local older than this with no server match is a mispredict * (server rejected the action) and is dropped on {@link prune}. Defaults to * {@link DEFAULT_TTL_POLICY} — `max(2 × rtt, 600ms)`. */ ttl?: (rtt: number) => number; /** Invoked when a prediction is dropped as a mispredict (TTL expiry). */ onReject?: (local: L, id: number) => void; /** * Per-entry render-scratch factory. Called once when each logical entry is * created — both predicted spawns and foreign/server-native adds. The * returned object is exposed as `entry.data` and dropped automatically when * the entry dies (handoff preserves it; remove/prune/cancel discard it). * * Lets the render layer keep id-keyed scratch — a catch-up accumulator, a * hit/hidden latch — on a *server-owned* entry without a side map (you can * hang fields on your own `entry.local`, but not on the decoder's * `entry.server`, which can be recycled on remove/re-add). `D` is inferred * from the return type; omit it and `entry.data` is `undefined`. */ data?: () => D; } /** * A merged logical entity. Exactly one per logical spawn, regardless of * predicted/authoritative status. Render via `server ?? local`, keyed on `id`. */ export interface SpawnEntry, D = undefined> { /** Stable across the predicted → authoritative handoff. Key sprites on it. */ readonly id: number; /** Authoritative instance; set once correlated (and for foreign entities). */ server?: S; /** Predicted local; present until pruned or (for foreign entries) absent. */ local?: L; /** `"pending"` = local only; `"confirmed"` = authoritative entity present. */ readonly state: "pending" | "confirmed"; /** Measured input lead (ms) — `spawnTime(server) − at`, set at confirmation * when {@link PredictedSpawnsOptions.spawnTime} is configured. 0 for * foreign entries and while pending. */ readonly leadMs: number; /** Per-entry render scratch from {@link PredictedSpawnsOptions.data}; the * reference is stable for the entry's life (mutate its fields freely) and * dropped with the entry. `undefined` when no `data` factory was given. */ readonly data: D; } /** Handle returned by {@link PredictedSpawns.spawn}. */ export interface SpawnHandle { /** The logical id assigned to this prediction (survives handoff). */ readonly id: number; /** The predicted local instance. */ readonly local: L; /** This entry's render scratch (same object as `entry.data`). */ readonly data: D; /** Drop this prediction (e.g. a local cancel or rollback). No-op once the entry * has been confirmed by its authoritative `onAdd`, so a late/duplicate rollback * can't nuke a legitimate entity. */ cancel(): void; /** Mark the prediction accepted: exempt the still-pending entry from TTL * eviction (its authoritative patch may land a tick after the confirmation). */ accept(): void; } export declare class PredictedSpawns, D = undefined> { private opts; private clock; private correlate; private ttl; /** Master index: every live entry (pending + confirmed + foreign), in * insertion order — FIFO correlation walks this picking the oldest * still-`pending` entry. */ private byId; /** Secondary index: authoritative instance → entry, for `onRemove`. */ private byServer; private nextId; private lastTickAt; private detach; /** Set by {@link dispose}; the owning Predict drops a `dead` child on its * next tick. */ dead: boolean; constructor(opts?: PredictedSpawnsOptions, clock?: PredictedSpawnsClock | null); /** * Wire the store to a collection's add/remove stream. `subscribe` receives * the store's handlers and returns a detacher. Called once by * `predict.spawns(...)`; call it yourself for standalone use, e.g. * * ```ts * const cb = Callbacks.get(room); * spawns.attach((onAdd, onRemove) => { * const a = cb.onAdd("bullets", onAdd); * const r = cb.onRemove("bullets", onRemove); * return () => { a?.(); r?.(); }; * }); * ``` */ attach(subscribe: (onAdd: (server: S, key: string | number) => void, onRemove: (server: S, key: string | number) => void) => () => void): void; /** Record an optimistic local spawn. Returns a handle for cancellation. */ spawn(local: L): SpawnHandle; private handleAdd; /** Transition a matched pending entry to confirmed in place (the live object * is reused across handoff — same `id`). */ private confirmEntry; private handleRemove; /** Find a pending local to pair with `server`, per the correlation * strategy. The matched entry transitions in place (not removed). */ private takeMatch; private isOwned; private makeData; /** * Advance pending locals via {@link PredictedSpawnsOptions.step}. * Confirmed/foreign entries are left to the authoritative state. * * With a clock, `dt` is derived on the clock's `serverNow()` axis — the * SAME axis `at` (and thus the measured input lead) live on — so a pending * local's flight and the confirmed entity's lead-reckon are the same * expression by construction: the handoff cannot jump, no matter how * biased or drifty the client's server-clock estimate is. Without a * clock, `now` (typically `performance.now()`) paces the step. */ tick(now?: number): void; /** Drop pending locals older than the TTL policy — mispredicts the server * never confirmed. Uses `serverNow()` and the RTT-aware TTL. */ prune(): void; /** Iterate the merged view — exactly one entry per logical entity. */ entries(): IterableIterator>; /** The entry an authoritative instance collapsed onto (or was surfaced as, * for foreign entities) — e.g. to reach `leadMs`/`data` from a collection * callback that only has the server instance. */ entryFor(server: S): SpawnEntry | undefined; /** * Unified field read across the predicted → authoritative handoff: * pending entries read the stepped local; confirmed entries read the * authoritative instance through the bound reader — `predict.value()` * (reckoned, lead-aware) when created via `predict.spawns(...)` with * `fields`, a raw field read otherwise. Render from this and the handoff * is invisible: same `id`, same timeline, one code path. */ value(entry: SpawnEntry, field: keyof S & string): number; /** Route confirmed-entry `value()` reads (wired by `predict.spawns` to its * reckon slots; standalone stores keep the raw default). */ bindReader(read: (server: S, field: keyof S & string) => number): void; private readServer; /** Is `id` still live this frame? Useful for despawning stale sprites. */ alive(id: number): boolean; /** Total live entries (pending + confirmed + foreign). */ get size(): number; /** Drop all predictions and tracked entries (keeps the subscription). */ clear(): void; /** Detach from the collection, drop everything, and mark dead so the owning * Predict stops driving it. */ dispose(): void; private now; }