@umicat/platform-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Game-scope key-value store shared by all players of a game.
3
+ *
4
+ * Unlike `umicat.saves` (per-user), `gameData` lives at the game level — one
5
+ * value per key, visible to everyone. Reads are public (anonymous players
6
+ * see the same data). Writes require an authenticated user; calling
7
+ * `set()` or `delete()` when `umicat.user === null` throws an `RpcError`
8
+ * with code `UNAUTHENTICATED`.
9
+ *
10
+ * Typical uses: scoreboards, shared inventories, tournament state, level
11
+ * of the day. Values are opaque JSON — primitive, object, or collection.
12
+ *
13
+ * Trust model (see SDK-GUIDE.md for details):
14
+ * - The backend does not enforce invariants INSIDE a value. If you store
15
+ * a list and append your entry, you own the read-modify-write loop —
16
+ * including not mutating other players' entries, truncating to cap
17
+ * the list size, and retrying on 409 conflicts.
18
+ * - Use `ifVersion` to avoid lost updates when concurrent writes race.
19
+ *
20
+ * Quotas (enforced at the backend):
21
+ * - 100 KB per value
22
+ * - 1 MB total per game
23
+ * - 64 keys per game
24
+ */
25
+ export class GameDataModule {
26
+ constructor(transport) {
27
+ this.transport = transport;
28
+ }
29
+ /** Read the value under `key`. Returns `null` if unset. Public — works for anonymous viewers. */
30
+ async get(key) {
31
+ const res = await this.transport.call('gameData.get', { key });
32
+ return (res?.value ?? null);
33
+ }
34
+ /**
35
+ * Write `value` under `key`. Requires an authenticated user.
36
+ * @param options.ifVersion — only succeed if the stored version matches; otherwise throws
37
+ * an RpcError with code `VERSION_MISMATCH`. Use this to implement safe read-modify-write
38
+ * loops for list values (scoreboards, shared lists).
39
+ * @returns the new version number.
40
+ */
41
+ async set(key, value, options) {
42
+ const res = await this.transport.call('gameData.set', {
43
+ key,
44
+ value,
45
+ ifVersion: options?.ifVersion,
46
+ });
47
+ return res.version;
48
+ }
49
+ /** Delete the value at `key`. Requires an authenticated user. */
50
+ async delete(key) {
51
+ const res = await this.transport.call('gameData.delete', { key });
52
+ return res.deleted;
53
+ }
54
+ /** List all keys that have a value set for this game. Public. */
55
+ async list() {
56
+ const res = await this.transport.call('gameData.list');
57
+ return res.keys;
58
+ }
59
+ }
@@ -0,0 +1,23 @@
1
+ export { UmicatCore } from './core/UmicatCore.js';
2
+ export type { UmicatInitOptions } from './core/UmicatCore.js';
3
+ export { RpcError } from './core/Transport.js';
4
+ export type { Transport, TransportKind } from './core/Transport.js';
5
+ export { PostMessageTransport } from './core/transports/PostMessageTransport.js';
6
+ export { LocalStorageTransport } from './core/transports/LocalStorageTransport.js';
7
+ export { bustCache } from './core/cacheBust.js';
8
+ export { SavesModule } from './saves/SavesModule.js';
9
+ export { GameDataModule } from './gamedata/GameDataModule.js';
10
+ export { RealtimeModule } from './realtime/RealtimeModule.js';
11
+ export { UmicatRoom, PlayerDataFacade, RoomDataFacade, ChatFacade, MAX_CHAT_TEXT_LEN, } from './realtime/UmicatRoom.js';
12
+ export type { ChatMessage, ChatMessageKind } from './realtime/UmicatRoom.js';
13
+ export type { JoinOptions, RoomListEntry, RoomListOptions } from './realtime/RealtimeModule.js';
14
+ export { AiModule, Npc } from './ai/AiModule.js';
15
+ export type { NpcConfig } from './ai/AiModule.js';
16
+ export { PlatformModule } from './platform/PlatformModule.js';
17
+ export { VoiceModule, webVoiceSupported } from './voice/VoiceModule.js';
18
+ export type { VoiceCallbacks, VoiceSession, VoiceLang } from './voice/VoiceModule.js';
19
+ export { DialogueModule } from './dialogue/DialogueModule.js';
20
+ export type { DialoguePlayOptions, DialogueRenderer } from './dialogue/DialogueModule.js';
21
+ export { DialogueRunner, resolveLangText } from './dialogue/runner.js';
22
+ export type { DialogueScript, DialogueNode, DialogueNodeType, DialogueHost, LangText, DialogueLineNode, DialogueChoiceNode, DialogueChoiceOption, DialogueSetNode, DialogueIfNode, DialogueEndNode, } from './dialogue/runner.js';
23
+ export * from './protocol.js';
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ // @umicat/platform-sdk — the engine-neutral half of the Umicat game SDK.
2
+ //
3
+ // Everything here works the same whether the game is drawn by Phaser, by
4
+ // three.js, or by something we have not chosen yet. Anything that needs to
5
+ // know how pixels get on screen belongs in a runtime package, not here.
6
+ export { UmicatCore } from './core/UmicatCore.js';
7
+ export { RpcError } from './core/Transport.js';
8
+ export { PostMessageTransport } from './core/transports/PostMessageTransport.js';
9
+ export { LocalStorageTransport } from './core/transports/LocalStorageTransport.js';
10
+ export { bustCache } from './core/cacheBust.js';
11
+ export { SavesModule } from './saves/SavesModule.js';
12
+ export { GameDataModule } from './gamedata/GameDataModule.js';
13
+ export { RealtimeModule } from './realtime/RealtimeModule.js';
14
+ export { UmicatRoom, PlayerDataFacade, RoomDataFacade, ChatFacade, MAX_CHAT_TEXT_LEN, } from './realtime/UmicatRoom.js';
15
+ export { AiModule, Npc } from './ai/AiModule.js';
16
+ export { PlatformModule } from './platform/PlatformModule.js';
17
+ export { VoiceModule, webVoiceSupported } from './voice/VoiceModule.js';
18
+ export { DialogueModule } from './dialogue/DialogueModule.js';
19
+ export { DialogueRunner, resolveLangText } from './dialogue/runner.js';
20
+ export * from './protocol.js';
@@ -0,0 +1,47 @@
1
+ import type { Transport } from '../core/Transport.js';
2
+ /**
3
+ * Actions that belong to the surface the game is running on, rather than to
4
+ * the game itself.
5
+ *
6
+ * Today that is one thing: leaving the game and going back to Umicat.
7
+ *
8
+ * ## Why the game owns the exit UI
9
+ *
10
+ * On a phone a game fills the screen edge to edge — there is no letterbox for
11
+ * the platform to hide chrome in, the way a desktop browser has. Anything the
12
+ * host draws sits on top of art it knows nothing about. Every game's art is
13
+ * different, so there is no corner that is safe for all of them.
14
+ *
15
+ * The host therefore draws nothing during normal play, and the game puts
16
+ * "Exit" where players already look for it: its own pause or settings menu.
17
+ * That is also where players expect it — games do not normally keep a quit
18
+ * button parked on the play field.
19
+ *
20
+ * ```ts
21
+ * exitButton.on('pointerup', () => umicat.platform.exit());
22
+ * ```
23
+ *
24
+ * The host keeps one escape hatch that does not depend on the game working at
25
+ * all (a two-finger long press, plus a real exit control on the crash and
26
+ * load-failure screens), because a game that dies before its menu exists must
27
+ * not be able to trap a player.
28
+ */
29
+ export declare class PlatformModule {
30
+ private transport;
31
+ constructor(transport: Transport);
32
+ /**
33
+ * True when a host is attached and can act on `exit()`. False when the game
34
+ * is running standalone (opened directly, no Umicat around it) — hide your
35
+ * exit control in that case rather than offering one that does nothing.
36
+ */
37
+ get canExit(): boolean;
38
+ /**
39
+ * Leave the game and return to Umicat.
40
+ *
41
+ * Resolves once the host has accepted the request; the game is usually torn
42
+ * down immediately after, so do not rely on code that follows. Save first.
43
+ *
44
+ * No-op when running standalone — there is nothing to return to.
45
+ */
46
+ exit(): Promise<void>;
47
+ }
@@ -0,0 +1,63 @@
1
+ import { RpcError } from '../core/Transport.js';
2
+ /**
3
+ * Actions that belong to the surface the game is running on, rather than to
4
+ * the game itself.
5
+ *
6
+ * Today that is one thing: leaving the game and going back to Umicat.
7
+ *
8
+ * ## Why the game owns the exit UI
9
+ *
10
+ * On a phone a game fills the screen edge to edge — there is no letterbox for
11
+ * the platform to hide chrome in, the way a desktop browser has. Anything the
12
+ * host draws sits on top of art it knows nothing about. Every game's art is
13
+ * different, so there is no corner that is safe for all of them.
14
+ *
15
+ * The host therefore draws nothing during normal play, and the game puts
16
+ * "Exit" where players already look for it: its own pause or settings menu.
17
+ * That is also where players expect it — games do not normally keep a quit
18
+ * button parked on the play field.
19
+ *
20
+ * ```ts
21
+ * exitButton.on('pointerup', () => umicat.platform.exit());
22
+ * ```
23
+ *
24
+ * The host keeps one escape hatch that does not depend on the game working at
25
+ * all (a two-finger long press, plus a real exit control on the crash and
26
+ * load-failure screens), because a game that dies before its menu exists must
27
+ * not be able to trap a player.
28
+ */
29
+ export class PlatformModule {
30
+ constructor(transport) {
31
+ this.transport = transport;
32
+ }
33
+ /**
34
+ * True when a host is attached and can act on `exit()`. False when the game
35
+ * is running standalone (opened directly, no Umicat around it) — hide your
36
+ * exit control in that case rather than offering one that does nothing.
37
+ */
38
+ get canExit() {
39
+ return this.transport.kind !== 'standalone';
40
+ }
41
+ /**
42
+ * Leave the game and return to Umicat.
43
+ *
44
+ * Resolves once the host has accepted the request; the game is usually torn
45
+ * down immediately after, so do not rely on code that follows. Save first.
46
+ *
47
+ * No-op when running standalone — there is nothing to return to.
48
+ */
49
+ async exit() {
50
+ if (!this.canExit)
51
+ return;
52
+ try {
53
+ await this.transport.call('platform.exit');
54
+ }
55
+ catch (error) {
56
+ // An older host will not know the method. That is not the game's problem
57
+ // and should not surface as an unhandled rejection mid-play.
58
+ if (error instanceof RpcError && error.code === 'UNKNOWN_METHOD')
59
+ return;
60
+ throw error;
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Wire protocol between a game and its Umicat host.
3
+ *
4
+ * Extracted from `@umicat/phaser-sdk`'s `protocol.ts`, which carries BOTH this
5
+ * platform/RPC contract and the 2D editor + tilemap message set. Only the
6
+ * platform half is engine-neutral, so only that half lives here; the editor
7
+ * half stays with the Phaser SDK that implements it.
8
+ */
9
+ export declare const PROTOCOL_VERSION = 1;
10
+ export interface UmicatUser {
11
+ id: string;
12
+ name: string;
13
+ avatar?: string;
14
+ }
15
+ export interface HelloMessage {
16
+ type: 'umicat:hello';
17
+ protocolVersion: number;
18
+ sdkVersion: string;
19
+ }
20
+ export interface RpcRequestMessage {
21
+ type: 'umicat:rpc';
22
+ id: string;
23
+ method: string;
24
+ params?: unknown;
25
+ }
26
+ export type SdkToHostMessage = HelloMessage | RpcRequestMessage;
27
+ export interface InitMessage {
28
+ type: 'umicat:init';
29
+ protocolVersion: number;
30
+ gameId: string;
31
+ user: UmicatUser | null;
32
+ capabilities: string[];
33
+ /**
34
+ * WebSocket endpoint for umicat-realtime-service. Present only when the host
35
+ * has multiplayer configured (capabilities includes 'realtime'). SDK connects
36
+ * here after fetching a JWT via the 'realtime.getToken' RPC.
37
+ */
38
+ realtimeUrl?: string;
39
+ /**
40
+ * The player's preferred language (the host's UI locale, e.g. 'en', 'zh-CN',
41
+ * 'ja'). Lets a game default its UI + AI NPC language to the player's without
42
+ * asking. Absent in standalone mode → SDK falls back to the browser locale.
43
+ */
44
+ locale?: string;
45
+ }
46
+ export interface RpcResultOk {
47
+ type: 'umicat:rpc.result';
48
+ id: string;
49
+ ok: true;
50
+ result: unknown;
51
+ }
52
+ export interface RpcErrorPayload {
53
+ code: string;
54
+ message: string;
55
+ }
56
+ export interface RpcResultError {
57
+ type: 'umicat:rpc.result';
58
+ id: string;
59
+ ok: false;
60
+ error: RpcErrorPayload;
61
+ }
62
+ export interface VoiceEventMessage {
63
+ type: 'umicat:voice';
64
+ /**
65
+ * 'partial' — live (may change) transcript · 'final' — the recognized text ·
66
+ * 'level' — current mic loudness 0..1 (drive a waveform) · 'end' — session
67
+ * finished (fires once, after any final) · 'error' — recognition failed.
68
+ */
69
+ event: 'partial' | 'final' | 'level' | 'end' | 'error';
70
+ /** partial / final transcript text. */
71
+ text?: string;
72
+ /** 'level' loudness, 0..1. */
73
+ level?: number;
74
+ /** 'error' kind, e.g. 'not-allowed' | 'no-speech' | 'unavailable'. */
75
+ code?: string;
76
+ }
77
+ /** Params for the `voice.start` RPC. */
78
+ export interface VoiceStartParams {
79
+ /** BCP-47 language tag for recognition, e.g. 'en-US', 'zh-CN'. */
80
+ lang: string;
81
+ }
82
+ export type HostToSdkMessage = InitMessage | RpcResultOk | RpcResultError | VoiceEventMessage;
83
+ /**
84
+ * Patch shape applied to a live entity. Only the fields present are updated;
85
+ * `null` is the explicit clear (drops the field).
86
+ *
87
+ * **Flat schema** (SDK 0.3.0). Visual / render fields live at the patch root,
88
+ * the same place they live on the entity. Sprite tint goes in `tint`, not
89
+ * `visual.tint`. Same rule for every renderable field across every entity kind.
90
+ *
91
+ * The bridge applies only the slot relevant to the entity's kind — passing
92
+ * `width` on a sprite is a no-op, same as `tint` on a panel.
93
+ */
94
+ export type RpcMethod = 'saves.get' | 'saves.set' | 'saves.delete' | 'saves.list' | 'gameData.get' | 'gameData.set' | 'gameData.delete' | 'gameData.list' | 'realtime.getToken' | 'ai.act' | 'ai.complete';
95
+ export interface SavesGetParams {
96
+ key: string;
97
+ }
98
+ export interface SavesGetResult {
99
+ value: unknown | null;
100
+ version: number | null;
101
+ }
102
+ export interface SavesSetParams {
103
+ key: string;
104
+ value: unknown;
105
+ ifVersion?: number;
106
+ }
107
+ export interface SavesSetResult {
108
+ version: number;
109
+ }
110
+ export interface SavesDeleteParams {
111
+ key: string;
112
+ }
113
+ export type SavesDeleteResult = {
114
+ deleted: boolean;
115
+ };
116
+ export interface SavesListResult {
117
+ keys: string[];
118
+ }
119
+ export interface GameDataGetParams {
120
+ key: string;
121
+ }
122
+ export interface GameDataGetResult {
123
+ value: unknown | null;
124
+ version: number | null;
125
+ }
126
+ export interface GameDataSetParams {
127
+ key: string;
128
+ value: unknown;
129
+ ifVersion?: number;
130
+ }
131
+ export interface GameDataSetResult {
132
+ version: number;
133
+ }
134
+ export interface GameDataDeleteParams {
135
+ key: string;
136
+ }
137
+ export type GameDataDeleteResult = {
138
+ deleted: boolean;
139
+ };
140
+ export interface GameDataListResult {
141
+ keys: string[];
142
+ }
143
+ /** Why an ai.act could not run. The game branches on these in-fiction. */
144
+ export type AiReason = 'SIGN_IN_REQUIRED' | 'INSUFFICIENT_CREDITS' | 'RATE_LIMITED' | 'UNAVAILABLE';
145
+ export interface AiPersona {
146
+ role?: string;
147
+ goals?: string[];
148
+ style?: string;
149
+ rules?: string[];
150
+ }
151
+ /** Shorthand arg spec — `{ field: "string" | "number" | "integer" | "boolean" }`. */
152
+ export interface AiActionDef {
153
+ name: string;
154
+ description?: string;
155
+ args?: Record<string, string>;
156
+ }
157
+ export interface AiHistoryMsg {
158
+ from: 'player' | 'npc' | 'event';
159
+ text?: string;
160
+ data?: unknown;
161
+ }
162
+ export interface AiActOptions {
163
+ model?: string;
164
+ maxTokens?: number;
165
+ temperature?: number;
166
+ }
167
+ export interface AiActParams {
168
+ /**
169
+ * Name of a playbook shipped with the game at `public/playbooks/<name>.md`
170
+ * (ADR-018) — a natural-language behavior pack (persona + strategy) the platform
171
+ * loads and injects. Pass just the NAME; the host resolves it under the game's
172
+ * own id (tamper-resistant). Supplies the character behavior; can be combined
173
+ * with `persona` (the playbook leads, `persona` augments).
174
+ */
175
+ playbook?: string;
176
+ persona?: AiPersona;
177
+ /** Arbitrary game-defined JSON of what the AI perceives this turn. */
178
+ observation?: unknown;
179
+ actions?: AiActionDef[];
180
+ history?: AiHistoryMsg[];
181
+ options?: AiActOptions;
182
+ }
183
+ /** A player-level intent the AI chose — the game must validate + execute it. */
184
+ export interface AiActionCall {
185
+ name: string;
186
+ args: unknown;
187
+ }
188
+ export interface AiUsage {
189
+ credits: number;
190
+ balanceCredits: number;
191
+ limitReached: boolean;
192
+ model: string;
193
+ }
194
+ export interface AiActSuccess {
195
+ ok: true;
196
+ /** Dialogue — display only, no state effect. */
197
+ say?: string;
198
+ /** Chosen player-level intents — the game validates + executes each. */
199
+ do?: AiActionCall[];
200
+ usage?: AiUsage;
201
+ }
202
+ export interface AiActFailure {
203
+ ok: false;
204
+ reason: AiReason;
205
+ }
206
+ export type AiActResult = AiActSuccess | AiActFailure;
207
+ export interface AiCompleteParams {
208
+ /** The prompt to complete. Required. Untrusted to the platform — the safety rails win. */
209
+ prompt: string;
210
+ /** Optional extra system framing; the platform PREPENDS its own safety preamble (can't be stripped). */
211
+ system?: string;
212
+ /** Max response tokens (backend clamps + applies a small default — this is a utility call, not a chat). */
213
+ maxTokens?: number;
214
+ /** Sampling temperature (backend clamps). */
215
+ temperature?: number;
216
+ /** Model id (backend allow-lists + defaults to a cheap model). */
217
+ model?: string;
218
+ }
219
+ export interface AiCompleteSuccess {
220
+ ok: true;
221
+ /** The model's text output. */
222
+ text: string;
223
+ usage?: AiUsage;
224
+ }
225
+ export interface AiCompleteFailure {
226
+ ok: false;
227
+ reason: AiReason;
228
+ }
229
+ export type AiCompleteResult = AiCompleteSuccess | AiCompleteFailure;
230
+ export interface RealtimeGetTokenParams {
231
+ /** Opaque, forwarded to server-side auth (future use). */
232
+ purpose?: string;
233
+ }
234
+ export interface RealtimeGetTokenResult {
235
+ token: string;
236
+ /** Epoch millis when the token expires. */
237
+ expiresAt: number;
238
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Wire protocol between a game and its Umicat host.
3
+ *
4
+ * Extracted from `@umicat/phaser-sdk`'s `protocol.ts`, which carries BOTH this
5
+ * platform/RPC contract and the 2D editor + tilemap message set. Only the
6
+ * platform half is engine-neutral, so only that half lives here; the editor
7
+ * half stays with the Phaser SDK that implements it.
8
+ */
9
+ // Shared postMessage protocol between the game SDK (iframe) and its host (umicat-home-ui).
10
+ // Kept as a single file so host and SDK can be type-checked against the same contract.
11
+ export const PROTOCOL_VERSION = 1;
@@ -0,0 +1,93 @@
1
+ import type { Transport } from '../core/Transport.js';
2
+ import { UmicatRoom } from './UmicatRoom.js';
3
+ export interface RoomListEntry {
4
+ /** Opaque Colyseus room id — pass to `joinById` to connect. */
5
+ roomId: string;
6
+ /** Short code the host passed on create (empty string for default rooms). */
7
+ roomCode: string;
8
+ /** Current number of connected clients. */
9
+ clients: number;
10
+ /** Max clients the host set (server-clamped to [1, 16]). */
11
+ maxClients: number;
12
+ /** Whatever the host attached via `room.setMetadata(...)`. Unvalidated. */
13
+ metadata?: unknown;
14
+ /** Server-side creation time (ms since epoch). */
15
+ createdAt?: number;
16
+ }
17
+ export interface RoomListOptions {
18
+ /**
19
+ * Filter to rooms with this exact roomCode. Useful for "is a room with code
20
+ * X up yet?" polling without enumerating the whole list.
21
+ */
22
+ roomCode?: string;
23
+ /** Max entries to return. Server caps to 100 regardless. */
24
+ limit?: number;
25
+ }
26
+ export interface JoinOptions extends Record<string, unknown> {
27
+ /** Optional display name passed to the server-side room on join. */
28
+ displayName?: string;
29
+ /**
30
+ * Short code that distinguishes independent rooms for the same game.
31
+ * Empty / omitted means "the one shared room per game."
32
+ *
33
+ * Pass a non-empty value on `joinOrCreate` / `create` / `join` to bucket
34
+ * players into a private room. Two clients with the same `(gameId, roomCode)`
35
+ * land in the same room; different `roomCode` values create separate rooms.
36
+ * This is how you implement "Create Room" / "Join with code" UX — the
37
+ * server's `filterBy(["gameId", "roomCode"])` enforces the separation.
38
+ *
39
+ * Not a password: anyone who knows the code can join. Treat as an
40
+ * unguessability measure, not an access control.
41
+ */
42
+ roomCode?: string;
43
+ /**
44
+ * Cap on concurrent clients in the room the creator makes. Server clamps
45
+ * to [1, 16]. Defaults to 8 server-side. Only honored by the FIRST client
46
+ * (the creator) — later joiners see whatever the creator set.
47
+ */
48
+ maxClients?: number;
49
+ }
50
+ /**
51
+ * Multiplayer rooms backed by umicat-realtime-service (Colyseus under the hood).
52
+ *
53
+ * Availability: only when the host provided a `realtimeUrl` during the
54
+ * handshake (i.e. connected to umicat-home-ui with multiplayer enabled).
55
+ * Unavailable in standalone/anonymous mode — methods throw RpcError
56
+ * 'REALTIME_UNAVAILABLE'.
57
+ *
58
+ * Tokens are fetched per connection attempt via the `realtime.getToken`
59
+ * RPC — the iframe never holds a long-lived credential.
60
+ */
61
+ export declare class RealtimeModule {
62
+ private readonly transport;
63
+ private readonly realtimeUrl;
64
+ private client;
65
+ constructor(transport: Transport, realtimeUrl: string | undefined);
66
+ /** `true` when a realtime endpoint was provided by the host. */
67
+ get available(): boolean;
68
+ joinOrCreate<State = unknown>(roomType: string, options?: JoinOptions): Promise<UmicatRoom<State>>;
69
+ create<State = unknown>(roomType: string, options?: JoinOptions): Promise<UmicatRoom<State>>;
70
+ join<State = unknown>(roomType: string, options?: JoinOptions): Promise<UmicatRoom<State>>;
71
+ joinById<State = unknown>(roomId: string, options?: JoinOptions): Promise<UmicatRoom<State>>;
72
+ /**
73
+ * List the open rooms for the caller's game, as seen by the realtime
74
+ * service right now. Results are scoped server-side to the caller's
75
+ * gameId — you can't enumerate rooms from a different game.
76
+ *
77
+ * Use this to build a lobby browser ("show me open rooms, click to join").
78
+ * Poll on a timer or on a user's "Refresh" click; the API is request/
79
+ * response, not a live subscription.
80
+ *
81
+ * Example:
82
+ * ```ts
83
+ * const rooms = await umicat.rooms.list();
84
+ * rooms
85
+ * .filter(r => r.clients < r.maxClients)
86
+ * .forEach(r => {
87
+ * // render a button; on click: umicat.rooms.joinById(r.roomId)
88
+ * });
89
+ * ```
90
+ */
91
+ list(options?: RoomListOptions): Promise<RoomListEntry[]>;
92
+ private connect;
93
+ }
@@ -0,0 +1,115 @@
1
+ import { RpcError } from '../core/Transport.js';
2
+ import { UmicatRoom } from './UmicatRoom.js';
3
+ /**
4
+ * Multiplayer rooms backed by umicat-realtime-service (Colyseus under the hood).
5
+ *
6
+ * Availability: only when the host provided a `realtimeUrl` during the
7
+ * handshake (i.e. connected to umicat-home-ui with multiplayer enabled).
8
+ * Unavailable in standalone/anonymous mode — methods throw RpcError
9
+ * 'REALTIME_UNAVAILABLE'.
10
+ *
11
+ * Tokens are fetched per connection attempt via the `realtime.getToken`
12
+ * RPC — the iframe never holds a long-lived credential.
13
+ */
14
+ export class RealtimeModule {
15
+ constructor(transport, realtimeUrl) {
16
+ this.transport = transport;
17
+ this.realtimeUrl = realtimeUrl;
18
+ this.client = null;
19
+ }
20
+ /** `true` when a realtime endpoint was provided by the host. */
21
+ get available() {
22
+ return typeof this.realtimeUrl === 'string' && this.realtimeUrl.length > 0;
23
+ }
24
+ async joinOrCreate(roomType, options = {}) {
25
+ return this.connect(roomType, options, 'joinOrCreate');
26
+ }
27
+ async create(roomType, options = {}) {
28
+ return this.connect(roomType, options, 'create');
29
+ }
30
+ async join(roomType, options = {}) {
31
+ return this.connect(roomType, options, 'join');
32
+ }
33
+ async joinById(roomId, options = {}) {
34
+ return this.connect(roomId, options, 'joinById');
35
+ }
36
+ /**
37
+ * List the open rooms for the caller's game, as seen by the realtime
38
+ * service right now. Results are scoped server-side to the caller's
39
+ * gameId — you can't enumerate rooms from a different game.
40
+ *
41
+ * Use this to build a lobby browser ("show me open rooms, click to join").
42
+ * Poll on a timer or on a user's "Refresh" click; the API is request/
43
+ * response, not a live subscription.
44
+ *
45
+ * Example:
46
+ * ```ts
47
+ * const rooms = await umicat.rooms.list();
48
+ * rooms
49
+ * .filter(r => r.clients < r.maxClients)
50
+ * .forEach(r => {
51
+ * // render a button; on click: umicat.rooms.joinById(r.roomId)
52
+ * });
53
+ * ```
54
+ */
55
+ async list(options = {}) {
56
+ if (!this.available) {
57
+ throw new RpcError('REALTIME_UNAVAILABLE', 'Multiplayer is not available for this host. Umicat.rooms.list requires running inside umicat-home-ui with realtime enabled.');
58
+ }
59
+ const { token } = await this.transport.call('realtime.getToken', {});
60
+ const url = new URL(toHttpBase(this.realtimeUrl) + '/rooms');
61
+ url.searchParams.set('gameId', this.transport.gameId);
62
+ if (typeof options.roomCode === 'string')
63
+ url.searchParams.set('roomCode', options.roomCode);
64
+ if (typeof options.limit === 'number' && Number.isFinite(options.limit)) {
65
+ url.searchParams.set('limit', String(Math.floor(options.limit)));
66
+ }
67
+ const res = await fetch(url.toString(), {
68
+ headers: { Authorization: `Bearer ${token}` },
69
+ });
70
+ if (!res.ok) {
71
+ let detail = '';
72
+ try {
73
+ const body = await res.json();
74
+ if (body && typeof body.error === 'string')
75
+ detail = `: ${body.error}`;
76
+ }
77
+ catch { }
78
+ throw new RpcError('ROOMS_LIST_FAILED', `/rooms returned ${res.status}${detail}`);
79
+ }
80
+ const body = await res.json();
81
+ if (!body || !Array.isArray(body.rooms)) {
82
+ throw new RpcError('ROOMS_LIST_FAILED', '/rooms response missing rooms[]');
83
+ }
84
+ return body.rooms;
85
+ }
86
+ async connect(nameOrId, options, method) {
87
+ if (!this.available) {
88
+ throw new RpcError('REALTIME_UNAVAILABLE', 'Multiplayer is not available for this host. Umicat.rooms requires running inside umicat-home-ui with realtime enabled.');
89
+ }
90
+ if (!this.client) {
91
+ // Dynamic import so single-player games do not pull Colyseus into their
92
+ // bundle. Vite tree-shakes this path when `rooms` is never referenced.
93
+ const { Client } = await import('colyseus.js');
94
+ this.client = new Client(this.realtimeUrl);
95
+ }
96
+ const { token } = await this.transport.call('realtime.getToken', {});
97
+ // Inject gameId from the transport so the server-side gameId check sees
98
+ // a value consistent with the JWT — games never pass this directly.
99
+ const merged = { ...options, gameId: this.transport.gameId };
100
+ this.client.auth.token = token;
101
+ const room = await this.client[method](nameOrId, merged);
102
+ return new UmicatRoom(room);
103
+ }
104
+ }
105
+ // Convert the realtime WebSocket base (wss://ws.unboxy.com/rt or ws://…) to
106
+ // the matching HTTP base used for the /rooms REST endpoint. Colyseus's HTTP
107
+ // routes (/matchmake, /rooms, /health) ride the same nginx vhost + path
108
+ // prefix as the WebSocket, so this is just a scheme swap.
109
+ function toHttpBase(wsUrl) {
110
+ if (wsUrl.startsWith('wss://'))
111
+ return 'https://' + wsUrl.slice('wss://'.length);
112
+ if (wsUrl.startsWith('ws://'))
113
+ return 'http://' + wsUrl.slice('ws://'.length);
114
+ return wsUrl;
115
+ }