/** * Typed optimistic-event CHANNEL — the high-level face of {@link PredictedEvents}. * * A channel owns one logical event type end-to-end: **birth** in the predicted * simulation (`ctx.predict(channel, payload)` inside a rollback `step`, or * `channel.predict(payload)` outside the sim), **feedback** (`onPredict` fires * immediately — the optimistic celebration/sound/spawn), and **settlement** * against the server (below). * * Identity is the channel OBJECT — there is no registry key. Where the * low-level store keys entries by strings the app must keep in sync, a channel * is declared once, holds its typed callbacks, and every call site references * the binding. The optional {@link PredictedEventChannelOptions.label} is * debug metadata only; nothing resolves by it. * * const goals = predict.defineEvent({ * onPredict: (team) => { celebrate(team); hidePuck(); }, * onReject: () => showPuck(), * }); * * // inside the reconciler step — live-only by construction, replay-safe: * if (crossedGoalLine) ctx.predict(goals, scoredBy); * * // settlement — the authoritative broadcast: * room.onMessage("score", () => goals.confirm()); * * SETTLEMENT — how a pending prediction resolves: * * - **`confirm()`** — the app saw the authoritative signal (a broadcast, a * state change): the prediction was right. * - **Auto-reject (sim-born)** — the server's ack watermark passed the * entry's birth seq by {@link PredictedEventChannelOptions.graceTicks} * without a confirm. The server processed the very timeline that * predicted the event and stayed silent — the event didn't happen. The * confirm signal travels the same ordered socket as the acks, so a real * event always confirms before this fires; no wall clock, no RTT * estimate, no state polling. (Both obvious alternatives false-reject * under latency: the predicted world flickers on reconciles, and * authoritative state shows PRE-input history for ~RTT after predicting.) * - **TTL (UI-born)** — entries with no sim timeline fall back to * wall-clock eviction (`ttlMs`). * - **`reject()` / `clear()`** — explicit app verdicts, any time. * * Settlement/timing semantics (TTL policy, serverNow clock, accepted-entry * exemption) are delegated to an internal {@link PredictedEvents} store — one * source of truth for both layers. */ import { type PredictedEventsClock } from "./predictedEvents.ts"; import { type PredictSink } from "./rollback.ts"; /** Default {@link PredictedEventChannelOptions.graceTicks}: server progress * past a prediction before an unconfirmed sim-born entry rejects (~333ms of * simulation at 30Hz) — generous against contested-event divergence, and * anchored to the ack stream so latency can't induce a false reject. */ export declare const DEFAULT_GRACE_TICKS = 10; export interface PredictedEventChannelOptions { /** * The optimistic feedback — fires the moment the event is predicted (from * the live sim step or `predict()`), ~RTT before the server could confirm. * Runs SYNCHRONOUSLY at the predicting call site; keep it light (play the * sound, set the flags, start the tween). A callback that needs a render * pose should take it from the payload rather than reading a game object * mid-step. * * OPTIONAL: flag-shaped consumers skip callbacks entirely and DERIVE from * the pending set instead — poll {@link PredictedEventChannel.has} each * frame, OR'd with authoritative state (`!enemy.alive || kills.has(id)`). * Mispredicts then recover implicitly: the entry settles, `has()` flips, * the derived view snaps back. */ onPredict?: (payload: T) => void; /** * The prediction was wrong — undo the optimistic feedback (show the entity * again, refund the item). Fired by TTL expiry, explicit {@link reject}, * or the ack-anchored auto-reject (see * {@link PredictedEventChannelOptions.graceTicks}). NOT fired by * {@link confirm} or {@link clear}. */ onReject?: (payload: T) => void; /** The server agreed (fired by {@link confirm}, once per settled entry). * Usually empty — the optimistic feedback already played. */ onConfirm?: (payload: T) => void; /** * A {@link confirm} settled NOTHING — the authoritative signal arrived for * an event this client never predicted (a remote actor's doing, or a * prediction that already timed out). The push form of `confirm()`'s * `returns 0` contract: play the feedback `onPredict` skipped (the FX for * a remote player's pickup, the un-muted goal horn). Receives the key * `confirm()` was called with (`undefined` for a keyless settle-all). */ onUnpredicted?: (key: string | number | undefined) => void; /** * SIM-BORN settlement deadline, in input ticks: an entry auto-rejects * once the server has processed this many ticks PAST its birth seq * without a {@link PredictedEventChannel.confirm} — "the server ran the * predicted timeline and stayed silent". The slack absorbs client/server * divergence in WHEN the event lands (a contested crossing can occur a * few ticks later server-side than predicted); the confirm signal shares * the ordered socket with the acks, so a real event's confirm always * arrives well before this fires. Default {@link DEFAULT_GRACE_TICKS}. */ graceTicks?: number; /** * Entry IDENTITY: two payloads mapping to the same value are "the same * event" — they dedupe while pending, and {@link confirm}/{@link reject}/ * {@link has} take the value to address one entry among several. * * DEFAULT — pick by payload type: * - a `string`/`number` payload IS its own identity (`(by) => by`): * one pending entry per value — an enemy id, a hit category. * - any other payload shares ONE anonymous pending slot (a goal, a * death): while an entry is pending, further predictions are no-ops. * (Keying objects by value isn't derivable — declare it.) * * Declare `uniqueBy` when an object payload carries presentation data * alongside its identity: * * predict.defineEvent<{ projectileId: string; x: number; y: number }>({ * uniqueBy: (h) => h.projectileId, // payload carries FX data; identity is the projectile * onPredict: (h) => spawnImpactFx(h.x, h.y), * }); * * To force a single pending slot for a primitive payload: `uniqueBy: () => 0`. */ uniqueBy?: (payload: T) => string | number; /** * Eviction window for unconfirmed UI-BORN predictions (`predict()` with * no sim timeline — nothing to anchor a deadline to but the wall clock). * Number for a static TTL; function of the current smoothed RTT for a * dynamic policy. Defaults to {@link DEFAULT_TTL_POLICY} * (`max(2 × rtt, 600ms)`). Sim-born entries are EXEMPT — they settle by * server progress (see {@link graceTicks}). */ ttlMs?: number | ((rtt: number) => number); /** * Minimum gap (ms) between `onPredict` fires, channel-wide — predictions * arriving inside the window are dropped entirely (no entry, no feedback). * The optimistic mirror of a server-side broadcast cooldown: give it the * same value and predicted + confirmed feedback pair one-to-one. */ cooldownMs?: number; /** Debug metadata (panel/telemetry display). Nothing resolves by it. */ label?: string; } export declare class PredictedEventChannel implements PredictSink { private readonly opts; /** Settlement engine: TTL clock + accepted-exemption live here. */ private readonly store; /** Pending payloads by entry key (payload echoed to the settle callbacks). * `acked` is the emitting controller's watermark (sim-born entries only). */ private readonly entries; private readonly now; private cooldownUntil; private warnedReplayPredict; constructor(opts: PredictedEventChannelOptions, clock: PredictedEventsClock | null); /** Sim-born prediction — reached via `ctx.predict(channel, payload)`, which * only forwards LIVE steps (replays are filtered before this is called). */ _predictFromSim(seq: number, payload: T, acked?: () => number): void; /** * Predict from OUTSIDE the sim (a UI-optimistic action with no rollback * timeline). Inside a reconciler `step`, use `ctx.predict(channel, payload)` * instead — this form is backstopped (a call that lands inside a rollback * replay no-ops and warns once) but the ctx form is live-only by * construction. */ predict(payload: T): void; private add; /** Is a prediction pending? With `key`, that entry; without, any. */ has(key?: string | number): boolean; /** Number of pending (unsettled) predictions. */ get pendingCount(): number; /** * The server agreed: settle the entry for `key` — or EVERY pending entry * when omitted (the single-pending shape). Fires `onConfirm` per settled * entry; never `onReject`. Returns how many entries were settled — `0` * means the event arrived unpredicted (the caller usually plays the * feedback it skipped optimistically); {@link * PredictedEventChannelOptions.onUnpredicted} is the push form of that. * * ORDERING GUARANTEES: * - the entry is removed BEFORE its `onConfirm` fires — `has(key)` * inside `onConfirm` is already `false`. Branch on the callbacks * (`onConfirm` = we predicted it, `onUnpredicted` = we didn't), never * on a `has()` pre-read racing the confirm. * - when nothing settles, `onUnpredicted` fires with the key. */ confirm(key?: string | number): number; /** The server overruled: reject the entry for `key` — or every pending * entry when omitted. Fires `onReject` per entry. Returns the count. */ reject(key?: string | number): number; private settleKeys; /** Drop every pending entry SILENTLY (no callbacks) — respawns, scene * teardown, "this life's predictions no longer apply". */ clear(): void; /** Per-frame drive (the owning Predict calls this from `tick()`): * sim-born grace-tick auto-rejects first, then wall-clock TTL eviction. */ prune(): void; /** Set by {@link dispose} — the owning Predict drops a dead channel from * its drive list on the next tick. */ dead: boolean; /** Stop being driven and drop all entries (silently). */ dispose(): void; }