import { ServerClock } from './server-clock.js'; /** A formed match: redeem `ticketId` with `Helix.multiplayer.switchRoom({ ticket })` before `expiresAt`. */ export type MatchFound = { /** The ROOM ticket to redeem — not the queue ticket. */ ticketId: string; instanceId: string; roomType: string; /** * When the room ticket stops being redeemable, Unix epoch milliseconds in the PLATFORM's time (NaN if the * backend's value is unreadable). The SDK judges it by the platform's clock as far as it knows it, not by * the device clock alone; a device clock that is off makes `Date.now()` the wrong thing to compare it with. */ expiresAt: number; }; /** How full the forming match is. */ export type QueueWaiting = { /** * Players currently eligible to be matched: those the matcher will actually use, i.e. whose every party * member has polled recently. May be lower than the number of players who pressed the button. */ players: number; /** Players the match needs. */ needed: number; }; /** * Why the queue ended. Absent on a `none` that never was a ticket, and after a match was redeemed. * * Told by the platform (it says how the caller's ticket in this world closed, for 60 s afterwards): * - `cancelled` cancelled by the player or a party member. * - `timeout` the queue's own waiting limit ran out. * - `heartbeat-lapsed` nobody on the ticket polled for 15 s. * - `member-absent` one party member did not poll for 60 s (a closed or suspended tab). * - `party-changed` the party changed while the ticket was waiting. * - `build-changed` the world published a new build. * - `queue-removed` the world no longer declares the queue. * - `replaced` the player queued in another world, which replaces this ticket. * - `match-expired` the match's room ticket ran out, or its room closed, before this player redeemed it. * (The SDK also concludes this one itself: see below.) * - `ended` a reason this SDK does not know; `serverReason` has the platform's word for it. * * Concluded by the SDK, when the platform could not be asked (a platform reason is preferred when there is one): * - `heartbeat-lost` no poll got through for 15 s, so the platform has cancelled the waiting ticket. * - `match-expired` the match's room ticket ran out (by the platform's clock) without being redeemed. * - `refused` the platform refused the poll for good (feature off, no permission, guest session). * - `session-changed` the session ended or another player signed in. * - `left` the world was left (the shell's leave message). */ export type QueueEndReason = 'cancelled' | 'timeout' | 'heartbeat-lapsed' | 'member-absent' | 'party-changed' | 'build-changed' | 'queue-removed' | 'replaced' | 'ended' | 'heartbeat-lost' | 'match-expired' | 'refused' | 'session-changed' | 'left'; /** * Where this player stands in matchmaking. `none` = not queued (never queued, cancelled, expired, or the * match's room ticket was redeemed or ran out), with `reason` when the SDK concluded it. `waiting` carries * `queueId` and, once the backend has reported it, `waiting`. `matched` carries `match`. */ export type QueueStatus = { status: 'none' | 'waiting' | 'matched'; queueId?: string; waiting?: QueueWaiting; match?: MatchFound; reason?: QueueEndReason; /** The platform's own reason, verbatim, whenever it gave one (also when `reason` recognised it). */ serverReason?: string; }; export type PartyMember = { userId: string; displayName: string; }; /** A code other players can type to join a party. */ export type PartyCode = { code: string; /** Unix epoch milliseconds (NaN if the backend's value is unreadable). */ expiresAt: number; }; export type Party = { id: string; leaderId: string; maxSize: number; members: PartyMember[]; /** The party's current join code. Present only for the leader, and only while a code exists. */ code?: PartyCode; }; /** An invite this player has received and not yet answered. */ export type PartyInvite = { id: string; partyId: string; from: PartyMember; /** Unix epoch milliseconds (NaN if the backend's value is unreadable). */ expiresAt: number; }; /** The player's party (null when in none) and their pending incoming invites. */ export type PartyState = { party: Party | null; invites: PartyInvite[]; }; /** What `Helix.party.invite` returns: the invite just sent. */ export type PartyInviteReceipt = { inviteId: string; /** Unix epoch milliseconds (NaN if the backend's value is unreadable). */ expiresAt: number; }; /** * Why a matchmaking call failed. The backend's refusal `code` is what is matched, not the HTTP status alone. * - `unknown-queue` the world's manifest declares no such queue. * - `already-queued` this player (or their party) already holds a waiting ticket in THIS world. The call * rejects, the SDK makes sure that ticket is being polled, and `onStatus` reports it. * (A waiting ticket in another world is not an error: enqueueing here replaces it.) * - `already-matched` a match already stands: `cancel()` came too late, or `enqueue()` was called while a * match is unredeemed. `error.match` is the match and `onMatch` fires for it. From * `enqueue()` it may be null: then the pending match belongs to another member of * your party (the platform does not hand one member's room ticket to another; * `error.memberUserId` names them), and your own status stays what it was. * - `not-party-leader` in a party, only the leader queues. * - `party-too-large` the party does not fit one team of this queue. * - `party-changed` the party kept changing while the request ran. Nothing happened; safe to call * again. The SDK has already retried it twice. * - `guests-cannot-queue` the session is a guest; checked locally first, with no request. * - `rate-limited` backend `queue-rate-limit`, or any 429. * - `missing-permission` the world's manifest lacks the `multiplayer` permission (the platform's * "Insufficient world scopes" 401). Final: asking again cannot help. * - `unavailable` matchmaking is not enabled on this platform (`matchmaking-disabled`). * - `not-ready` no world session, no world id, no API base, or the world was left — nothing was sent. * - `invalid-argument` a bad argument — nothing was sent. * - `request-failed` anything else: no answer within the timeout (`status` null), a 5xx, any other * 401 (an expired or missing token), a 404 with no refusal code, or a refusal code * this SDK does not know (`backendCode` has it). */ export type HelixMatchmakingErrorCode = 'unknown-queue' | 'already-queued' | 'already-matched' | 'not-party-leader' | 'party-too-large' | 'party-changed' | 'guests-cannot-queue' | 'rate-limited' | 'missing-permission' | 'unavailable' | 'not-ready' | 'invalid-argument' | 'request-failed'; /** The typed failure of every `Helix.matchmaking` promise. Branch on `code`; `status` is the HTTP status when there was one. */ export declare class HelixMatchmakingError extends Error { readonly code: HelixMatchmakingErrorCode; readonly status: number | null; /** The backend's own refusal code, verbatim, when the response carried one. */ readonly backendCode: string | null; /** With `already-matched`: the match the ticket was already in. */ readonly match: MatchFound | null; /** With `already-matched` from `enqueue()` and no `match`: the party member who holds the pending match. */ readonly memberUserId: string | null; constructor(code: HelixMatchmakingErrorCode, message: string, status?: number | null, backendCode?: string | null, cause?: unknown, match?: MatchFound | null, memberUserId?: string | null); } /** * Why a party call failed. * - `not-party-leader` only the leader invites, kicks and promotes. * - `cannot-invite` the target is not a friend, and is not invitable as a stranger either: that needs * them to be in this world AND to have public presence with activity sharing on * (the account default is friends-only). A block also gives this. Use a join code. * - `party-full` no room left (inviting or accepting). * - `too-many-invites` the party already has its limit of pending invites. * - `already-in-party` you are in a party of two or more: leave it before accepting an invite or joining * by code. From a party of one (your own invite or code created it) you are moved. * - `code-not-found` no party has that join code (mistyped, expired or revoked). * - `party-changed` the party kept changing while the request ran. Nothing happened; safe to call * again. The SDK has already retried it twice. * - `not-in-party` you are in no party (leave, kick, promote). * - `unknown-party-member` that player is not in your party (kick, promote). * - `invite-not-found` the invite does not exist (or is not yours). * - `invite-expired` the invite ran out. * - `guests-cannot-party` the session is a guest; checked locally first, with no request. * - `rate-limited` backend `invite-rate-limit`, or any 429. * - `missing-permission` the world's manifest lacks the `multiplayer` permission (the platform's * "Insufficient world scopes" 401). Final: asking again cannot help. * - `unavailable` parties are not enabled on this platform (`parties-disabled`). * - `not-ready` / `invalid-argument` / `request-failed` as for HelixMatchmakingError. */ export type HelixPartyErrorCode = 'not-party-leader' | 'cannot-invite' | 'party-full' | 'too-many-invites' | 'already-in-party' | 'party-changed' | 'not-in-party' | 'unknown-party-member' | 'invite-not-found' | 'invite-expired' | 'code-not-found' | 'guests-cannot-party' | 'rate-limited' | 'missing-permission' | 'unavailable' | 'not-ready' | 'invalid-argument' | 'request-failed'; /** The typed failure of every `Helix.party` promise. Branch on `code`; `status` is the HTTP status when there was one. */ export declare class HelixPartyError extends Error { readonly code: HelixPartyErrorCode; readonly status: number | null; /** The backend's own refusal code, verbatim, when the response carried one. */ readonly backendCode: string | null; constructor(code: HelixPartyErrorCode, message: string, status?: number | null, backendCode?: string | null, cause?: unknown); } /** What matchmaking needs from the party namespace: to be told how many members the player's party has. */ export interface PartyWatch { /** * Keeps the party poll alive while watched. `cb` gets the member count (0 = no party) on every fresh * answer. While the tab is hidden the poll rests unless there is a reason to keep it: a party of two or * more, a world subscribed to party events, or `busy()` (the watcher has work that depends on it). * `update()` tells the party that `busy()` may have changed. */ watch(cb: (members: number) => void, busy?: () => boolean): { stop(): void; update(): void; }; } export interface PlatformSocialHost { isInitialized(): boolean; getToken(): string | null; getWorldId(): string | null; getUserId(): string | null; resolveApiBase(token: string | null): string | null; /** * The platform's clock as this page has learned it (see server-clock.ts): every expiry judgement uses it, and * every platform answer feeds it. The SDK singleton shares one with Helix.multiplayer, which feeds it the room * clock. Optional: without one, the namespaces built on the same host share a clock of their own. */ readonly clock?: ServerClock; } /** While a queue ticket is waiting or matched. Also the server heartbeat (a waiting ticket unpolled for 15 s is cancelled). */ export declare const MATCHMAKING_POLL_MS = 3000; /** * While this player is in a party of two or more, is not queued, and the world listens to matchmaking: how * often the SDK looks for a ticket the party's leader may have queued. */ export declare const MATCHMAKING_IDLE_POLL_MS = 5000; /** While a world is subscribed to party changes or invites, or listens to matchmaking. */ export declare const PARTY_POLL_MS = 5000; /** A platform request with no answer after this long is abandoned (and aborted), so it cannot stall a poll loop. */ export declare const PLATFORM_REQUEST_TIMEOUT_MS = 8000; /** * The bound for the queue STATUS POLL alone. Shorter than the general one because the poll is a heartbeat * against a 15 s server limit: at 8 s one hung poll would leave an 11-12 s gap; at 5 s the next tick after it * still lands with room to spare. */ export declare const MATCHMAKING_POLL_TIMEOUT_MS = 5000; /** The server's patience: a waiting ticket not polled for this long has been cancelled. */ export declare const MATCHMAKING_HEARTBEAT_LOST_MS = 15000; /** The longest a failing poll loop waits between attempts. */ export declare const POLL_BACKOFF_MAX_MS = 30000; /** How often a request refused with `party-changed` (safe to retry) is sent again before the refusal is surfaced. */ export declare const PARTY_CHANGED_RETRIES = 2; /** The pause before the first such retry; the second waits twice as long. */ export declare const PARTY_CHANGED_RETRY_MS = 250; export declare class HelixMatchmaking { private readonly host; private readonly party; private readonly lane; private readonly poller; private readonly idle; private partyMembers; private partyWatch; private reportedEnding; private endedLocally; private refused; private readonly backoff; private readonly statusListeners; private readonly matchListeners; private last; private generation; private flight; private lastAnswerAt; private lastRanAt; private wokeAt; private wake; private requests; private resumed; private left; private mutations; private readonly seenMatches; private undelivered; private readonly clock; constructor(host: PlatformSocialHost, party?: PartyWatch | null); /** * Join one of the world's declared queues (manifest `matchmaking.queues`). In a party, only the leader * queues, and the whole party is queued together. Resolves `{ status: 'waiting', queueId, waiting? }` and * starts the 3 s poll that keeps the ticket alive and drives `onStatus` / `onMatch`. Calls to `enqueue` * and `cancel` are carried out one at a time, in the order they were made. While a match still stands * unredeemed it rejects with `already-matched` (`error.match`), the status is `matched` and `onMatch` * fires if that match was not delivered yet. */ enqueue(queueId: string): Promise; /** * Leave the queue. Idempotent: resolves whether a waiting ticket was cancelled or there was none. A party * member cancelling cancels the whole party's ticket. If the ticket has ALREADY MATCHED it rejects with * `already-matched` (`error.match` is the match), the status is `matched`, and `onMatch` fires — a match * is never reported as `none`. If the request got no usable answer, the SDK asks the platform what * happened before rejecting, so a match that formed meanwhile is still delivered. */ cancel(): Promise; /** * Ask the platform where this player stands now. What it finds is treated exactly like a poll's answer: * `onStatus` / `onMatch` fire if it is news, and polling starts or stops to match. */ status(): Promise; /** * Called whenever the queue status CHANGES (waiting counts, matched, back to none) — not once per poll. * A `none` the SDK concluded by itself carries a `reason` (see QueueEndReason). Subscribing also checks, * once per session, whether a ticket from before a reload is still alive. */ onStatus(cb: (status: QueueStatus) => void): () => void; /** * Called exactly once per match (per room `ticketId`), however it was discovered: a poll, an explicit * `status()`, a `cancel()` that came too late, or the first check after a reload. A match found while * nobody was listening is handed to the first subscriber, unless it has expired or the status has since * gone back to `none`. Redeem it with `Helix.multiplayer.switchRoom({ ticket: match.ticketId })`. * Expiry is judged by the platform's clock (learned from a joined room's state and, where the browser * exposes it, the `Date` header of platform answers), and by the device clock only when nothing is known. */ onMatch(cb: (match: MatchFound) => void): () => void; /** @internal Helix.init() resolved: a world that subscribed before then is served now. */ ready(): void; /** * @internal helix:leave. Subscribers are told the queue is over (`reason: 'left'` — the ticket lapses with * the heartbeat), every timer and listener stops, and nothing is sent again until a new session. */ teardown(): void; /** * @internal The session ended or changed hands: nothing known about the old one applies. Subscribers are * told (`reason: 'session-changed'`), and if there is a new session and someone is subscribed, its ticket * is looked up. */ reset(): void; private get listeners(); private subscribed; private unsubscribed; private syncIdle; private request; private exclusive; private queuesPath; private commit; private refresh; private resume; private woke; private lapsed; private wokeIntoSilence; private poll; private apply; private end; } export declare class HelixParty implements PartyWatch { private readonly host; private readonly lane; private readonly watchers; private members; private followingVisibility; private readonly poller; private readonly backoff; private readonly changeListeners; private readonly inviteListeners; private readonly errorListeners; private baseline; private readonly seenInvites; private generation; private pollingGeneration; private refusal; private left; private readonly clock; constructor(host: PlatformSocialHost); /** The player's party (null when in none) and their pending incoming invites, fresh from the platform. */ get(): Promise; /** * Called when the party state actually changed (a member joined or left, the leader changed, an invite * arrived or went away). The first answer after subscribing is the baseline, not a change — read it with * `get()`. The platform is polled every 5 s only while there is at least one `onChange` / `onInvite` * subscriber; when the last one unsubscribes the baseline is forgotten. */ onChange(cb: (state: PartyState) => void): () => void; /** * Called once per incoming invite id, including invites already pending when the world subscribed. * Invites are only ever announced from a fresh answer (subscribing asks for one), and never once expired * (by the platform's clock, as for a match). */ onInvite(cb: (invite: PartyInvite) => void): () => void; /** * Called when the background poll behind `onChange` / `onInvite` meets a FINAL refusal and stops: the * world lacks the `multiplayer` permission (`missing-permission`), parties are not enabled (`unavailable`), * or the session is a guest (`guests-cannot-party`). Fires once per refusal; a subscriber added afterwards * is told too. The poll starts again by itself with a new or refreshed session, and as soon as any party * call of the world's own (`get()`, `invite()`, …) is answered successfully. Every other failure — no * answer, a 5xx, a 429, a 401 from an expired token, any other refusal — is retried with backoff (up to * POLL_BACKOFF_MAX_MS apart) and is not reported here; failures of calls the world makes itself reject * those calls. */ onError(cb: (error: HelixPartyError) => void): () => void; /** * Invite an accepted friend, or any account with a live session in this same world. Creates the party * when the caller has none (they become its leader). The recipient must accept. */ invite(userId: string): Promise; /** * Accept an incoming invite and join that party. Resolves the party joined. From a party of one the * caller is moved into the new party; from a larger one it is `already-in-party`. */ accept(inviteId: string): Promise; /** * Leader only: make a join code for the party — the way players who are not friends group up (an invite * to a non-friend only reaches a player whose presence is public). Creates a party of one when the caller * has none. The code also appears as `party.code` in the leader's `get()` / `onChange` state. */ createCode(): Promise; /** Leader only: withdraw the party's join code. Resolves whether or not there was one. */ revokeCode(): Promise; /** * Join a party by its code. What the player typed is trimmed and upper-cased first; an empty code, one * containing whitespace or one longer than 64 characters is rejected locally (`invalid-argument`). * Resolves the party joined. From a party of one the caller is moved; from a larger one it is * `already-in-party`. */ join(code: string): Promise; /** Decline an incoming invite (or, as the leader, revoke one that was sent). */ decline(inviteId: string): Promise; /** Leave the party. The leader leaving promotes the longest-standing member; the last member leaving ends it. */ leave(): Promise; /** Leader only: remove a member. */ kick(userId: string): Promise; /** Leader only: hand leadership to another member. Resolves the party. */ promote(userId: string): Promise; /** @internal See PartyWatch. Told the last known size at once when the poll is already running. */ watch(cb: (members: number) => void, busy?: () => boolean): { stop(): void; update(): void; }; /** @internal Helix.init() resolved: a world that subscribed before then is served now. */ ready(): void; /** * @internal helix:leave. Every timer and listener stops and nothing is sent again until a new session. * No change is reported: a party is platform-level and outlives the world. */ teardown(): void; /** * @internal The session ended or changed hands. The old player's party is not this session's: subscribers * who knew of one are told there is none, and if there is a new session its party is polled and compared * against that. */ reset(): void; /** @internal The same player's session was refreshed (a new token): a poll that was refused gets another chance. */ sessionRefreshed(): void; private recovered; private get listeners(); private get subscribers(); private tellWatchers; private followVisibility; private visibilityChanged; private restingWhileHidden; private unsubscribed; private id; private partyFrom; private act; private refresh; private sync; private poll; private pollFailed; private apply; }