import type { FinalRestScope, FinalRestGrant, EntityStateBatchEntry, GroupRestSnapshotMessage } from './multiplayer-contract/index.js'; import { type StateMessage, type GesturePlay, type AttachmentEntry, type StateAckMessage, type EntityStateAckMessage, type ModuleFusedMessage, type ModuleWakeMessage, type ModuleSleepMessage, type ModuleEngagementMessage, type RoomState, type PlayerState, type EntityState, type RoomVarValue, type Vec3, type Quat } from './multiplayer-contract/index.js'; import type { ModuleActionRequest, VehicleComponentActionRequest, VehicleDetachActionRequest, ModuleEffect, ModuleStateSnapshot } from './universal-vehicles.js'; import { type ModuleAccessors, type VarsView } from './multiplayer-accessors.js'; import type { HelixMultiplayerState } from './protocol.js'; import type { ServerClock } from './server-clock.js'; export type ReplicaInput = Omit; /** Current room authority for one engaged module. The epoch is room-issued and SDK-stamped on verbs. */ export interface ModuleEngagementLease { readonly moduleId: string; readonly action: string; readonly epoch: number; } /** Options for joinRoom — only needed where the API base can't be derived from the token (local dev/tests). */ export interface JoinRoomOptions { /** Override the platform API base URL. Default: the world_session token's `iss`, else configure({ apiBaseUrl }). */ apiBaseUrl?: string; /** * State upload cadence (Hz) for the player-seat flush — from the world's `multiplayer.uploadHz`. Clamped to * [MESSAGE_RATE.stateHz, MESSAGE_RATE.maxUploadHz] (10..20); absent ⇒ the 10 Hz default. The room derives its * own ceiling from the trusted configUrl, so a lying client only throttles itself. */ uploadHz?: number; /** * Same-origin platform reservation route for a runtime that shares the HELIX * room plane without being an InstantWorld row (for example a Home). * Must be an absolute `/api/v1/...` path; cross-origin URLs are rejected so * the world_session bearer can never be exfiltrated. */ reservationPath?: string; /** * Join an OPEN named room type declared in the world's manifest (`multiplayer.roomTypes.`) instead of * the default room. Sent as the join request's JSON body (`{ "roomType": "" }`); the backend answers * 404 `unknown-room-type` for an undeclared name and 403 `ticket-required` for a `ticketed` type, both * surfaced as a HelixRoomJoinError. Omit it (and `ticket`) for the default room — that request carries NO * body and is byte-identical to what every earlier SDK sent. * * A shell-provided `?instance=` does NOT apply to a typed join: it names a room of the DEFAULT type, so a * typed join ignores it entirely (no strict joinById, no helix:multiplayer-join-error). */ roomType?: string; /** * Redeem a ticket to a `ticketed` room instance (delivered by `room.onTicket`). Sent as `{ "ticket": "" }`. * Single-use and bound to the account it was issued to — an unknown, expired, already-used or foreign ticket * is a HelixRoomJoinError with code `ticket-invalid`. Mutually exclusive with `roomType` (the ticket already * names its type); passing both is refused before any request. Like `roomType`, it ignores `?instance=`. * Most worlds hop with `Helix.multiplayer.switchRoom({ ticket })` rather than calling this directly. */ ticket?: string; } /** * Where `Helix.multiplayer.switchRoom` goes. Either a ticket to redeem, or a room type to matchmake into * (omit `roomType` for the world's default room), optionally preferring one specific room instance. */ export type SwitchRoomTarget = { ticket: string; } | { /** An OPEN declared room type; omitted = the default room type. */ roomType?: string; /** * Try THIS room (a `roomId`) first, e.g. the lobby the player came from. Non-strict, unlike the shell's * `?instance=` on a first join: if the room is gone, full or locked the hop falls back to a normal * matchmade join of the same type, and no helix:multiplayer-join-error is published. */ preferInstanceId?: string; }; /** The per-join knobs `switchRoom` forwards to the new room's join (everything joinRoom takes but the target). */ export type SwitchRoomOptions = Omit; /** * A ticket to a `ticketed` room instance, as handed to `room.onTicket`. The wire shape (RoomTicketMessage) * carries `expiresAt` however the backend spelled it; here it is ALWAYS Unix epoch milliseconds, comparable * with `Date.now()` — or NaN when the platform sent something unparseable, in which case treat the expiry as * unknown (redemption is the authority either way). * * `expiresAt` is the TICKET's deadline (a few minutes), not the room's: the ticket must be REDEEMED — * `switchRoom({ ticket })` — before it, or the redeem is refused (`ticket-invalid`). The room instance itself * may live much longer; once inside, this timestamp no longer matters. */ export interface RoomTicket { roomType: string; /** Pass to `Helix.multiplayer.switchRoom({ ticket: ticketId })`. */ ticketId: string; /** The room instance the ticket admits to. */ instanceId: string; /** * Redeem-by deadline of THIS ticket, Unix epoch MILLISECONDS (normalised from an ISO string / epoch seconds / * epoch ms); NaN if unparseable. Not the lifetime of the room. */ expiresAt: number; } /** * Why a join or a room hop failed, as a stable machine-readable code: * - `unknown-room-type` the world's active build declares no such room type (or room types are off). * - `ticket-required` the type is `ticketed`; it can only be entered by redeeming a ticket. * - `ticket-invalid` the ticket is unknown, expired, already used, or was issued to someone else. * - `room-type-unavailable` the platform answered a typed/ticket request with a DEFAULT-room reservation (a * backend that predates room types ignores the body) — refused rather than seating * the player in a room they did not ask for. * - `instance-closed` the ticketed room instance has closed — the match is over. Answered by the * rejoin endpoint (410) when resuming after a reload, and by a ticket redeem whose * instance ended first. Final: the SDK forgets the instance and never retries. * - `rejoin-failed` resuming a ticketed room failed for another reason. 403 and 404 (not this * player's instance) are final and forgotten; a network failure, 5xx, 429 or 401 * (the session may refresh) is RETRYABLE — the instance is kept, so the next * `joinRoom()` asks again. `status` tells them apart (null = no answer). * - `invalid-target` the caller passed an unusable target (both `roomType` and `ticket`, an empty id…). * - `not-ready` `switchRoom` cannot even start: no Helix.init(), no world session, no world id or * no resolvable API base. * - `switch-in-progress` a `switchRoom` hop is still in flight (raised by a second `switchRoom` and by a * `joinRoom` during the hop); the hop decides the outcome, this call changed nothing. * - `join-in-progress` a `joinRoom` is still in flight: `switchRoom` was called under it, or a second * `joinRoom` was (one join runs at a time); nothing changed. * - `cancelled` `leave()` (or the shell's leave message) arrived while this join or hop was in * flight. The caller is in NO room: a seat the call had already taken was released. * - `left` the player has left this world (the shell's Exit / Back — see `Helix.hasLeft()`). * Final for this world document: nothing was sent, and every later `joinRoom()` / * `switchRoom()` is refused the same way. Checked before anything else. * - `join-failed` anything else (transport, a refused seat reservation, an unmapped refusal). * * The backend's own refusal `code` is what is matched (`unknown-room-type`, `ticket-required`, * `unknown-room-ticket`, `room-ticket-expired`, `room-ticket-used`, `room-instance-closed`, * `unknown-room-instance`) — never the HTTP status alone, so an unrelated 404 (world not found) stays * `join-failed` instead of being misreported as a bad ticket. */ export type HelixRoomJoinErrorCode = 'unknown-room-type' | 'ticket-required' | 'ticket-invalid' | 'room-type-unavailable' | 'instance-closed' | 'rejoin-failed' | 'invalid-target' | 'not-ready' | 'switch-in-progress' | 'join-in-progress' | 'cancelled' | 'left' | 'join-failed'; /** * The typed failure of `joinRoom`'s platform request and of every `switchRoom` rejection. It IS an Error with * the same `Helix.multiplayer: join failed — …` message earlier SDKs threw, so code that only reads `.message` * is unaffected; `code` is what new code should branch on, `status` the HTTP status when there was one. */ export declare class HelixRoomJoinError extends Error { readonly code: HelixRoomJoinErrorCode; readonly status: number | null; constructor(code: HelixRoomJoinErrorCode, message: string, status?: number | null, cause?: unknown); } /** Why a room handle's `onLeave` fired — see HelixRoom.onLeave. */ export type HelixRoomLeaveReason = 'switch' | 'leave' | 'dropped'; /** * The curated live-room surface handed to world authors. Colyseus's API, re-exposed under Helix.*. * * STALE HANDLES. A handle belongs to ONE room. Once that room is no longer the current one — you left it, * hopped away with `switchRoom`, or it dropped for good — the handle is stale, and it can never act on * whatever room came after it: * - `leave()` resolves and does nothing (it never leaves the NEW room). * - `sendState`, `sendAction`, `uploadEntity`, `uploadEntities`, `requestModuleEngagement` and * `acknowledge` are no-ops; the boolean-returning senders (`sendModuleAction`, `sendVehicle*Action`, * `releaseModuleEngagement`) return `false`. These all stamp SDK-owned state (the coalesced state slot, * sequence counters, engagement epochs) that now belongs to the next room. * - the plain pass-through senders — `sendAbility`, `sendGesture`, `sendAttachments`, `sendAvatarRefresh`, * `sendEmoteRefresh`, `sendEmoteJoin` — keep their long-standing guarantee: handed to the handle's OWN * (now closed) room, never a throw, never delivered to another room. * - `claimPhysicsGroup`, `commitPhysicsGroup` and `requestDistributionCapability` reject (`not_in_room`). * - every subscription (`onMessage`, `onTicket`, `onStateChange`, `onAdd`/`onRemove`, `onStateAck`, * `onEntityStateAck`, `onModule*`, `onPhysicsGroupRest`, `onPlayerAvatarChanged`, `onDrop`, * `onReconnect`) registers nothing and never fires; existing ones stopped firing at the hop. * - `onLeave` fires once for the handle's own room; registered after that, it never fires. * - `pendingInputs()` is empty and `moduleEngagement()` is null. * - `roomId`, `sessionId`, `roomType`, `instanceKey`, `effectiveUploadHz` keep their values, and `state`, * `vars`, `me`, `player()`, `phase()`, `currentTurn()`, `isMyTurn()`, `module()` keep reading the last * state that room replicated — a frozen snapshot, never the new room's. */ export interface HelixRoom { /** Positive authorized claim; rejects on old servers. Await before applying one impulse. */ claimPhysicsGroup(action: string, group: string, args?: Record, scope?: FinalRestScope): Promise; /** Exact complete stopped batch, epochs from claim; SDK supplies the normal physics sequence and retries frozen bytes. */ commitPhysicsGroup(transactionId: string, states: readonly EntityStateBatchEntry[]): Promise; /** Subscribe to one group's authoritative rest, explicitly selecting module-local delivery when scoped. */ onPhysicsGroupRest(group: string, callback: (snapshot: GroupRestSnapshotMessage) => void, scope?: FinalRestScope): void; readonly roomId: string; readonly sessionId: string; /** * The named room type this room is an instance of, or null for the world's default room. Read it after a * reload: a plain `joinRoom()` RESUMES the room the tab was in, which may be a typed room (see joinRoom). */ readonly roomType: string | null; /** The ticketed room instance id (the credential's `instanceKey`), or null for default and open-type rooms. */ readonly instanceKey: string | null; /** * The clamped player-seat upload cadence (Hz) this connection flushes at — `multiplayer.uploadHz` resolved to * [10,20]. The engine paces its hosted-entity (EntityScene) uploads to the SAME value so both channels match. */ readonly effectiveUploadHz: number; /** Authoritative shared state (a @colyseus/schema instance shaped like the contract RoomState). */ readonly state: RoomState; /** Fires after every applied patch from the server. */ onStateChange(cb: (state: RoomState) => void): () => void; /** Subscribe to a collection's adds (Tier-2 seam: players today, entities reserved). Fires for existing entries too. */ onAdd(collection: 'players', cb: (player: PlayerState, id: string) => void): void; onAdd(collection: 'entities', cb: (entity: EntityState, id: string) => void): void; onRemove(collection: 'players', cb: (player: PlayerState, id: string) => void): void; onRemove(collection: 'entities', cb: (entity: EntityState, id: string) => void): void; /** * A seat's replicated avatar changed mid-session (v23 — the room re-resolved it after an `avatarRefresh` * hint), so the engine can re-skin that replica without a rejoin. Fires per changed seat, never on join * (read state.players[id].avatarUrl for the initial value). Returns an unsubscribe. */ onPlayerAvatarChanged(cb: (playerId: string, avatarUrl: string) => void): () => void; /** A world-authored server→client message (the room broadcasts these for declared events). */ onMessage(type: string, cb: (payload: Payload) => void): () => void; /** * The room handed THIS player a ticket to a `ticketed` room instance (`helix:room-ticket`, sent privately to * each player an `openRoom` rule selected). Redeem it with `Helix.multiplayer.switchRoom({ ticket: * ticket.ticketId })`. `expiresAt` arrives normalised to epoch milliseconds; a malformed ticket is dropped. * Tickets are not replayed: subscribe right after joining. Returns an unsubscribe. */ onTicket(cb: (ticket: RoomTicket) => void): () => void; /** Typed module runtime diagnostics. */ onModuleFused(cb: (event: ModuleFusedMessage) => void): void; onModuleWake(cb: (event: ModuleWakeMessage) => void): void; onModuleSleep(cb: (event: ModuleSleepMessage) => void): void; /** Observe exclusive module grants, denials, releases, and server/drop revocations. */ onModuleEngagement(cb: (event: ModuleEngagementMessage) => void): () => void; /** Observe authoritative mounted-module snapshots, including late-join state. */ onModuleState(cb: (snapshot: ModuleStateSnapshot) => void): void; /** Observe namespaced transient presentation without subscribing to every authored event name. */ onModuleEffect(cb: (effect: ModuleEffect) => void): void; /** * A2 (spec §9) the room's player-seat divergence correction — fires when the movement gate REJECTED an * upload, or proactively when a rule teleport/respawn re-anchored the seat (the accepted case is silent). * The engine snaps the local body to `position`; the SDK has already adopted the ack's teleport epoch (v27), * dropped its coalesced pending frame on an epoch change, and pruned its reconciliation buffer through `seq` * before this fires. SINGLE-SLOT: assigning a second consumer silently replaces the first. */ onStateAck(cb: (ack: StateAckMessage) => void): void; /** * A2 (spec §9) the room's owner-hosted-entity divergence correction, BATCHED — the engine re-anchors each * hosted entity's local sim to its anchor so a sparse/throttled host stops being fenced by the gate. */ onEntityStateAck(cb: (ack: EntityStateAckMessage) => void): void; /** Per-tick predicted state. Coalesced + flushed at MESSAGE_RATE.stateHz, seq-tagged (D3/D4). */ sendState(input: ReplicaInput): void; /** Activate/deactivate a built-in ability (sent immediately; server enforces the rate ceiling). */ sendAbility(ability: string, active: boolean): void; /** * A declared action. When this SDK holds a room engagement for `moduleId`, it automatically stamps * the matching authority epoch; authored code never supplies that epoch. */ sendAction(name: string, args?: Record, moduleId?: string): void; /** Send one identity-fenced action through the existing declared action channel. */ sendModuleAction(request: ModuleActionRequest): boolean; /** * Send one named vehicle component/panel intent through the existing action * channel. `false` is a fail-closed refusal: nothing was sent and no sequence * was advanced because identity/entity state was incomplete, the socket was * closed, the sequence was exhausted, or reconnect-safe persistence failed. * The caller may retry after authoritative state, transport, or storage recovers. * `true` means submitted, not server-accepted; observe replicated state for * confirmation. No caller-provided Build digest, epoch, or sequence is accepted. */ sendVehicleComponentAction(request: VehicleComponentActionRequest): boolean; /** * Send one trusted detachable-part action on the same vehicle action lane. * The request carries only the admitted action id and placement identity; * the SDK stamps the live Build, authority epoch, and durable sequence. The * wire deliberately has no targetBits because detachment spawns a separate * authoritative entity instead of mutating the component bitfield. */ sendVehicleDetachAction(request: VehicleDetachActionRequest): boolean; /** Request the exclusive grant for a declared action on an `engaged` module. */ requestModuleEngagement(moduleId: string, action: string, args?: Record): void; /** Release the current grant through a declared action. False means this SDK holds no live grant. */ releaseModuleEngagement(moduleId: string, action: string, args?: Record): boolean; /** Read-only snapshot of the current room grant, or null after denial/release/revocation/drop. */ moduleEngagement(moduleId: string): ModuleEngagementLease | null; /** * Play/stop a gesture on this player (v19). 'oneshot' relays to every OTHER client as a `gesture` * broadcast (play locally first — the room never echoes it back); 'loop'/'stop' set/clear the seat's * synced PlayerState.loopGesture so late joiners see active loops. `clip` is a world-registered gesture * name ('' only with 'stop'); the engine facade (CharacterMultiplayer.playAnimation) drives this for you. */ sendGesture(clip: string, play: GesturePlay): void; /** * Replace this player's ENTIRE replicated attachment set (v20). Full-set replace, idempotent — the room * writes it to synced PlayerState.attachments (no relay; schema sync fans out, late joiners converge). * Entries carry sender-resolved socket-local offsets and must respect ATTACHMENT_LIMITS; the engine * facade (CharacterMultiplayer.attach/detach) drives this for you. */ sendAttachments(attachments: AttachmentEntry[]): void; /** * Ask the room to re-resolve THIS player's avatar (v23) — a hint with an empty payload: the room re-reads the * equipped avatar server-side and rewrites the seat's PlayerState.avatarUrl, so a client never names the URL. */ sendAvatarRefresh(): void; /** * Ask the room to re-resolve THIS player's carried emote set (v28) — the avatar hint's twin: an empty payload, * because the room re-reads the equipped emotes server-side and replaces the seat's PlayerState.emotes whole, * so a client never names a clip URL that lands on its own seat. */ sendEmoteRefresh(): void; /** * Ask the room for a one-time World acquisition capability for one World route of THIS world (ADR-0003): * `dkey:` or `distribution:`. The room mints it for the signed-in seat and answers only this * client; pass the result's `capability` to the purchase. Rejects with an Error whose message is the room's * reason (`sign_in_required`, `not_found`, `unavailable`, …) or `timeout` when the room never answers. */ requestDistributionCapability(ref: string): Promise; /** * Join `initiator`'s paired emote, or leave the current pair with '' (v28). A REQUEST the room may refuse by * writing nothing — read the outcome off THIS player's state.pairInitiator, never off the send. The engine * facade drives this for you; the room owns every clear (the initiator stops, either side dies, you drift off). */ sendEmoteJoin(initiator: string): void; /** * Upload an owner-authoritative entity's client-simulated state (Tier 2 Phase 4.6c). Call only for an entity * whose `controller` is this client (state.entities[id].controller === sessionId); the room rejects others. * Sent immediately, per-entity seq-tagged; the room gates the position against the kind's maxSpeed + clamps vars. */ uploadEntity(entityId: string, input: { position: Vec3; velocity?: Vec3; angularVelocity?: Vec3; orientation?: Quat; vars?: Record; epoch?: number; }, moduleId?: string): void; /** * Upload MANY owner-authoritative entities in ONE message (Tier 2 Phase 4.10). Prefer this over per-entity * uploadEntity when hosting multiple entities: the per-connection rate cap counts messages, so N single uploads * spend N tokens/tick and starve, while one batch spends one. Position is sent as a compact [x,y,z] tuple; each * entry is gated/clamped server-side exactly like uploadEntity. Pass only entities this client controls. */ uploadEntities(entities: ReadonlyArray<{ id: string; position: Vec3; velocity?: Vec3; angularVelocity?: Vec3; orientation?: Quat; vars?: Record; epoch?: number; }>, moduleId?: string): void; /** Sent-but-unacknowledged states — the reconciliation tail the engine NetworkDriver replays (D4). */ pendingInputs(): readonly StateMessage[]; /** Prune the reconciliation buffer once the server confirms it processed up to `seq` (D4). */ acknowledge(seq: number): void; /** Connection dropped; the client is auto-reconnecting (built into @colyseus/sdk). */ onDrop(cb: () => void): void; /** Auto-reconnection succeeded. */ onReconnect(cb: () => void): void; /** * Left for good — `code` is the close code, `reason` says WHO ended it: * - `'switch'` this client hopped away with `Helix.multiplayer.switchRoom`. Do NOT auto-rejoin from here: * the hop is already joining the next room (a `joinRoom()` now is refused, `switch-in-progress`). * - `'leave'` this client left on purpose (`room.leave()` / the shell's Exit). * - `'dropped'` nobody here asked for it: kicked, the room disposed, or the connection was lost for good. * Fires at most once per handle. `reason` is additive — a callback that only takes `code` is unaffected. */ onLeave(cb: (code: number, reason: HelixRoomLeaveReason) => void): void; /** Leave intentionally (no reconnection grace held server-side). */ leave(): Promise; /** * Guarded, live-reading views over the DECLARED game state — the typed replacement for hand-rolled * `(vars as Record)?.x` casts and frame-0 guards. `vars` = room-level, `me`/`player(id)` = * per-player; `phase`/`currentTurn`/`isMyTurn` read the state machine + turn-order conventions. */ readonly vars: VarsView; readonly me: VarsView; player(id: string): VarsView; phase(): string; currentTurn(): string; isMyTurn(): boolean; /** Guarded, live module-local room/player/entity state. */ module(moduleId: string): ModuleAccessors; } interface ColyseusStateSignal { (cb: (state: unknown) => void): unknown; remove(cb: (state: unknown) => void): void; } interface ColyseusRoom { roomId: string; sessionId: string; reconnectionToken: string; /** * 0.17 internals: send() BUFFERS into a small drop-oldest ring (never throws) on a non-OPEN socket, so this is the * only way to tell a delivered message from an enqueued one. Optional so a colyseus rename degrades to "always open". */ readonly connection?: { readonly isOpen: boolean; close?(code?: number, reason?: string): void; /** What the Room's auto-reconnection calls to open a NEW socket for the same seat (see abandonRoom). */ reconnect?(queryParams: unknown): void; }; /** * 0.17 Room.reconnection — the public auto-reconnection config. `enabled: false` is the documented way to * turn it off ("Set to `false` to disable automatic reconnection entirely"). Optional so a colyseus shape * change degrades to closing the socket only. */ readonly reconnection?: { enabled: boolean; maxRetries?: number; retryCount?: number; isReconnecting?: boolean; /** A drop this soon after the join is not reconnected at all (0.17 default 5000 ms). See followReconnectGrace. */ minUptime?: number; /** The delay before retry `attempt` (0.17: 2^attempt x delay, then clamped to [minDelay, maxDelay]). */ backoff?: (attempt: number, delay: number) => number; }; readonly state: unknown; onStateChange: ColyseusStateSignal; onMessage(type: string, cb: (payload: unknown) => void): unknown; onLeave(cb: (code: number, reason?: string) => void): unknown; onError(cb: (code: number, message?: string) => void): unknown; onDrop(cb: (code: number, reason?: string) => void): unknown; onReconnect(cb: () => void): unknown; send(type: string, payload?: unknown): void; leave(consented?: boolean): Promise; /** Drops every listener registered on the room (0.17). Optional so a rename degrades to the context guard alone. */ removeAllListeners?(): void; } /** * How long ANY leave — `leave()`, the shell's helix:leave, a room hop — waits for the room's consented leave * before detaching it by force. Colyseus only * settles leave() when the socket's close arrives, a LEAVE written to an already-closed socket is silently * lost, and a send while CONNECTING throws — so on a dropped or reconnecting room an unbounded await is a hop * that never returns — and `isOpen` is no help, because a black-holed socket still reports open. 3 s is far * beyond a healthy leave round-trip (tens of ms) and still short enough that a player watching a "joining * match…" screen is not left wondering; past it the seat is the server's to reap through its ordinary * drop/grace path. */ export declare const ROOM_LEAVE_TIMEOUT_MS = 3000; /** @deprecated The bound now covers every leave, not only a hop's — use ROOM_LEAVE_TIMEOUT_MS. */ export declare const ROOM_SWITCH_LEAVE_TIMEOUT_MS = 3000; /** * How long the room holds a dropped seat for its owner to reconnect, counted from when the SERVER notices the * drop: the room server's reconnection grace (helix-colyseus-server `RECONNECT_GRACE_SEC`, 20 s; the room does * not advertise it). See ROOM_RECONNECT_WINDOW_MS for what the SDK's automatic reconnect makes of it. */ export declare const ROOM_RECONNECT_GRACE_MS = 20000; /** * How long after the CLIENT sees a drop the server may still not have noticed it, so that its grace has not * even started. The common drops are client-first (a wifi switch, a network change, the browser going * `offline`: Colyseus closes the socket at once), and the server only notices them on its ping timeout. The * room server runs @colyseus/ws-transport with its defaults, a ping every 3 s and termination at the tick * after 2 unanswered ones: up to 3 x 3 s = 9 s after the last answered ping. */ export declare const ROOM_SERVER_DROP_DETECTION_MS: number; /** * How long the SDK's automatic reconnect keeps scheduling attempts after the drop it saw: the grace plus the * server's detection time (29 s). A seat may be held until then, so giving up earlier loses players the room * would still have taken back. Ending sooner when the seat really is gone is the server's job: it refuses a * reconnection token it no longer holds with 4003, which ends the reconnect at once (`onLeave(4003)`). */ export declare const ROOM_RECONNECT_WINDOW_MS: number; /** * An attempt still connecting when the window closes gets this much longer to finish its handshake; then it is * closed and the reconnect gives up (`onLeave(4003, 'dropped')` at the latest 31 s after the drop). */ export declare const ROOM_RECONNECT_ATTEMPT_ALLOWANCE_MS = 2000; /** * Make Colyseus's automatic reconnection follow the room's grace instead of its own defaults. Left alone, a * 0.17 Room (a) does not reconnect at all when the socket drops within 5 s of the join (`minUptime`): the seat * is still held, but the world is told the room is gone; and (b) when the network stays down, retries 15 times * with an exponential backoff capped at 5 s, about a minute, long after the seat is gone. (A server that is * reachable already ends it sooner: it refuses a token whose seat it gave away with 4003, and that close is * not one Colyseus retries.) * * So: any drop is reconnected, however soon after the join; retries keep Colyseus's own backoff, but none is * scheduled past `windowMs` after the drop the client saw (ROOM_RECONNECT_WINDOW_MS: the grace plus the time * the server may take to notice the drop); a retry that fails after the window ends the reconnect; and an * attempt still connecting ROOM_RECONNECT_ATTEMPT_ALLOWANCE_MS after it is closed, which ends it too. The end * is Colyseus's own: `onLeave` with 4003 (FAILED_TO_RECONNECT). The server's own 4003 refusal still ends it as * soon as it arrives. Each new drop (after a successful reconnect) gets a fresh window. Times are measured on a * monotonic clock. Only the Room's public `reconnection` config, `onDrop`, `onReconnect` and `onLeave` are * used; a Room without that config is left as it is. */ export declare function followReconnectGrace(room: ColyseusRoom, windowMs?: number): void; export interface MultiplayerHost { isInitialized(): boolean; getToken(): string | null; getWorldId(): string | null; /** Optional shell-provided join target (the play URL's ?instance= → helix:init instanceId): a fresh * join is scoped to exactly that room — joinById, with NO matchmaker fallback (see joinRoom). */ getInstanceId?(): string | null; /** Optional shell publish hook (helix:multiplayer-join-error): an instance-scoped join failed and * did NOT fall back — the shell surfaces it so the player can choose back / fresh join explicitly. */ publishInstanceJoinError?(instanceId: string, message: string): void; /** * Optional shell publish hook: the SDK singleton forwards the joined room's live status to the * embedding shell (helix:multiplayer-state) so shell UI (the tablet's Server Details panel) can render * the REAL roomId + roster. Called on join, on roster changes, and with null on leave/teardown. */ publishRoomState?(state: HelixMultiplayerState | null): void; /** * Optional voice hand-off for a room hop (switchRoom). Voice pairs 1:1 with a room instance * (v:{worldId}:{roomId}), so it cannot follow a hop by itself: this leaves the voice room paired to the room * being left and resolves a `resume` that re-joins voice against whatever room is current when it is called, * with the same join options (push-to-talk key, API base / token path) — the channel, mic mode and mutes * already survive a voice teardown. Resolves null when voice is not joined, or is pinned to an explicit * roomId and therefore was never following the multiplayer room. */ detachVoice?(): Promise<(() => Promise) | null>; /** * Optional: true once the player has left this world (the shell's helix:leave — see `Helix.hasLeft()`). * While it is, joinRoom() and switchRoom() reject `left` before doing anything. Absent = never left. */ hasLeft?(): boolean; /** * Optional: the platform clock the SDK judges expiries by (Helix.matchmaking, Helix.party). Fed the room * server's clock (`RoomState.serverTimeMs`) on every state patch of the joined room. */ clock?: Pick; } /** A capability the room minted for one World route (single use, ~60 s). */ export type DistributionCapabilityGrant = { capability: string; expiresAt: string; ref: string; }; /** How long a capability request waits for the room before giving up (the mint is one backend round trip). */ export declare const DISTRIBUTION_CAPABILITY_TIMEOUT_MS = 10000; export declare class HelixMultiplayer { private readonly host; private finalRestClient; private apiBaseUrl; private shellApiBaseUrl; private room; private distributionCapabilitySeq; private readonly pendingDistributionCapabilities; private callbacks; private seq; private teleportSeq; private entitySeq; private entityBatchSeq; private vehicleActionSeq; private vehicleActionReconnectBaseline; private retiredVehicleActionEpochs; private vehicleActionManifestPersistTimer; private reconnectCache; private activeRoomCache; private sessionIdentity; private entering; private joining; private pendingInput; private internalAvatarPose; private flushTimer; private effectiveUploadHz; private inputBuffer; private worldId; private reconnectScope; private roomType; private instanceKey; private ctx; private switching; private roomMaxPlayers; private rosterSig; private pendingReceipts; private onStateAckCb; private onEntityStateAckCb; private engagementRequestSeq; private moduleEngagements; private moduleEngagementCbs; private avatarChangedCbs; private avatarListeners; private nlSend; private nlPatch; constructor(host: MultiplayerHost); configure(options: { apiBaseUrl: string; }): void; adoptShellApiBase(url: string): void; /** * ADR-0003: ask the joined room for a one-time World acquisition capability (see HelixRoom's method of the same * name). Also what the shell's Store/confirm flow reaches through `helix:distribution-capability-request`. */ requestDistributionCapability(ref: string, timeoutMs?: number): Promise; private settleDistributionCapability; private rejectDistributionCapabilities; currentRoomId(): string | null; localLocomotionPack(): string | null; sendPurchaseReceipt(receipt: string): boolean; private stashReceipt; private flushPendingReceipts; sendEmoteRefresh(): boolean; resolveApiBase(token: string | null): string | null; joinRoom(worldId?: string, options?: JoinRoomOptions): Promise; private refuseIfLeft; private beginEntering; private abandonEntered; private abandonable; private releaseLateRoom; private forgetCancelledTarget; switchRoom(target: SwitchRoomTarget, options?: SwitchRoomOptions): Promise; private joinRoomInternal; private connectOrForget; private settleLeave; private detachCurrentRoom; private leaveBounded; private abandonRoom; private forceDetach; private requestJoin; private requestRejoin; private makeHandle; private watchAvatar; private unwatchAvatar; private adoptModuleEngagement; private revokeModuleEngagements; private publishRoomState; private startFlush; private flush; private nlOnPatch; private nlTick; private persistReconnect; private readReconnect; private persistCurrentReconnect; private persistVehicleActionSequence; private retireVehicleActionSequence; /** Adopt the server's exact accepted fences for the reconnect baseline without clobbering actions submitted * after this connection opened. This repairs retirements missed while the seat was disconnected. */ private adoptVehicleActionFences; private flushVehicleActionManifest; private migrateLegacyVehicleActionSequences; private clearReconnect; private removePersistedReconnectMetadata; private readActiveRoom; private writeActiveRoom; private clearActiveRoom; private clearOtherTypedSlots; private forgetReconnectSlots; private tryReconnect; leave(): Promise; private leaveRoom; private teardown; } export declare function epochMsOf(raw: unknown): number; export {};