@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,197 @@
1
+ import type { Room } from 'colyseus.js';
2
+ type Unsubscribe = () => void;
3
+ /**
4
+ * Facade for per-player state — one entry per player, only the owner can write
5
+ * to their own entry. Delta-synced via Colyseus schema.
6
+ */
7
+ export declare class PlayerDataFacade {
8
+ private readonly room;
9
+ constructor(room: Room<unknown>);
10
+ /** Write a JSON-serializable value to the caller's own player data. */
11
+ set(key: string, value: unknown): void;
12
+ /** Remove a key from the caller's own player data. */
13
+ delete(key: string): void;
14
+ /** Read a player's data value. Returns null if the player or key is absent. */
15
+ get<T = unknown>(sessionId: string, key: string): T | null;
16
+ }
17
+ /**
18
+ * Facade for room-scope shared state — one map per room, any client may write.
19
+ * Delta-synced via Colyseus schema. Use for shared game state like current
20
+ * turn, scoreboard, shared level-of-the-day, etc.
21
+ */
22
+ export declare class RoomDataFacade {
23
+ private readonly room;
24
+ constructor(room: Room<unknown>);
25
+ /** Write a JSON-serializable value into the room's shared data map. */
26
+ set(key: string, value: unknown): void;
27
+ /** Remove a key from the room's shared data map. */
28
+ delete(key: string): void;
29
+ /** Read a shared room value. Returns null if the key is absent. */
30
+ get<T = unknown>(key: string): T | null;
31
+ }
32
+ /** Maximum text length accepted by `ChatFacade.send`. Longer input is silently
33
+ * truncated. Games should mirror this on their input box's `maxLength`. */
34
+ export declare const MAX_CHAT_TEXT_LEN = 500;
35
+ /**
36
+ * Discriminator for `ChatMessage`:
37
+ * - `'user'` — a real player message sent via `ChatFacade.send`.
38
+ * - `'system.joined'` — auto-emitted locally when a remote player enters the room.
39
+ * - `'system.left'` — auto-emitted locally when a player leaves the room.
40
+ *
41
+ * System messages are computed locally on each client from `room.state.players`
42
+ * add/remove events; they never go over the wire. The first joiner does not
43
+ * see "X joined" for players already in the room when they themselves arrived
44
+ * (initial hydration is suppressed).
45
+ */
46
+ export type ChatMessageKind = 'user' | 'system.joined' | 'system.left';
47
+ /** Canonical chat-message shape delivered to `ChatFacade.onMessage` handlers.
48
+ * See umicat-design/features/multiplayer-chat.md §3 for the rationale on
49
+ * each field. */
50
+ export interface ChatMessage {
51
+ /** What kind of message this is — see `ChatMessageKind`. Default `'user'`. */
52
+ kind: ChatMessageKind;
53
+ /** For user messages: sender's session id (equals `room.sessionId` on the
54
+ * sender's local-echoed copy). For system messages: the joined/left player's
55
+ * session id. */
56
+ from: string;
57
+ /** Sender's (or subject's) display name at the time of the message. Empty if
58
+ * the Player entry carried no displayName. */
59
+ displayName: string;
60
+ /** For user messages: the trimmed, length-capped text. For system messages:
61
+ * a sensible English fallback (e.g. `"Alice joined"`). Games doing their own
62
+ * i18n should switch on `kind` and ignore this. */
63
+ text: string;
64
+ /** Sender-side `Date.now()` for user messages, local `Date.now()` for system
65
+ * messages. Advisory: order chat by arrival, not by ts. */
66
+ ts: number;
67
+ }
68
+ /**
69
+ * Convenience layer over the transient relay for in-game chat. Wraps
70
+ * `room.send('chat', ...)` / `room.on('chat', ...)` with three things every
71
+ * game would otherwise re-implement inconsistently:
72
+ * - Local echo so the sender's own message hits the same handler stream
73
+ * immediately (synchronous, before the network call).
74
+ * - A canonical `ChatMessage` payload shape every chat in every game shares.
75
+ * - Outbound `text.trim()` + length-cap to MAX_CHAT_TEXT_LEN, so a stray
76
+ * paste cannot flood the relay.
77
+ *
78
+ * `displayName` is read from the sender's Player entry at send time and
79
+ * stamped onto the payload so receivers don't have to chase room.state to
80
+ * resolve a sessionId. See umicat-design/features/multiplayer-chat.md §3.4.
81
+ */
82
+ export declare class ChatFacade {
83
+ private readonly room;
84
+ private readonly handlers;
85
+ private remoteOff;
86
+ /** Sids known to this facade, mapped to the displayName they had at the
87
+ * time we last observed them. Populated either at construction (if state
88
+ * was already synced) or on the first `onStateChange` (initial hydration
89
+ * — silent, no system messages). Subsequent state changes diff against
90
+ * this map to emit `system.joined` / `system.left`. We track displayName
91
+ * here because by the time a player is detected as "left" they're already
92
+ * gone from `state.players` and we can't read it from there. */
93
+ private readonly knownNames;
94
+ /** Becomes true the first time we observe a usable `state.players` map.
95
+ * The very first observation is treated as "initial state arrived" and
96
+ * must NOT fire `system.joined` for the players already in the room when
97
+ * the local user joined — only subsequent diffs are real lifecycle events. */
98
+ private hydrated;
99
+ constructor(room: Room<unknown>);
100
+ /**
101
+ * Send a chat line. Empty-after-trim input is dropped silently. Text longer
102
+ * than MAX_CHAT_TEXT_LEN is silently truncated. Local echo fires
103
+ * synchronously before the wire send, so the input box can be cleared on
104
+ * the same tick.
105
+ *
106
+ * Returns a Promise that resolves once dispatch is complete (local echo +
107
+ * wire send queued). Rejects if the underlying `room.send` throws (e.g.
108
+ * the connection has dropped). Note: resolution does NOT confirm remote
109
+ * delivery — the underlying transient relay is fire-and-forget. Use this
110
+ * for catching local-side errors; do not treat it as an ack.
111
+ */
112
+ send(text: string): Promise<void>;
113
+ /**
114
+ * Subscribe to chat messages — both remote and local-echoed (your own), plus
115
+ * auto-emitted `'system.joined'` / `'system.left'` events. Returns an
116
+ * unsubscribe function. Call it on scene shutdown.
117
+ */
118
+ onMessage(handler: (msg: ChatMessage) => void): Unsubscribe;
119
+ /**
120
+ * Hook `onStateChange` to detect joins / leaves and try an eager snapshot
121
+ * if `state.players` is already populated.
122
+ *
123
+ * **The onStateChange subscription must always be set up, even when
124
+ * `state.players` is `undefined` at construction.** Colyseus 0.16 (and
125
+ * earlier) deliver the initial state as a *separate* message that arrives
126
+ * a few ms after `client.joinOrCreate` resolves — so right when UmicatRoom
127
+ * is constructed, `room.state.players` is typically `undefined`. An earlier
128
+ * 0.2.14 implementation early-returned in that case and never subscribed,
129
+ * which meant the diff machinery never armed and BOTH `system.joined` /
130
+ * `system.left` silently dead-stopped in production (Blokus chat game,
131
+ * 2026-04-27).
132
+ *
133
+ * Eager snapshot if `state.players` is already there sets `hydrated=true`
134
+ * so the first state change does a real diff. Otherwise the first state
135
+ * change is treated as initial hydration: populate `knownNames` silently,
136
+ * skip system messages, set `hydrated=true`. Subsequent state changes do
137
+ * the real lifecycle diff.
138
+ *
139
+ * Why `onStateChange` and not `MapSchema.onAdd` / `.onRemove`: Colyseus
140
+ * 0.16 dropped those instance methods in favour of a separate
141
+ * `getStateCallbacks(room)` proxy API. Hooking `onStateChange` and diffing
142
+ * `players` keys ourselves works the same in any Colyseus version, keeps
143
+ * us decoupled from the realtime backend's callback shape, and adds < 1ms
144
+ * of work per state change for typical room sizes (max 16 players).
145
+ */
146
+ private subscribeToPlayers;
147
+ /** Populate `knownNames` from `state.players` if it's already available
148
+ * and mark hydrated. Silent — never emits system messages. A successful
149
+ * `forEach` (even iterating zero items) means the schema is synced and
150
+ * this is "what the room looked like when we joined" — safe to hydrate. */
151
+ private tryEagerSnapshot;
152
+ /** Compare current `players` membership against `knownNames`. On the very
153
+ * first invocation (post-construction) when `hydrated=false`, this is
154
+ * the initial-hydration call: populate `knownNames` silently and bail.
155
+ * Subsequent invocations diff against `knownNames` and emit
156
+ * `system.joined` for new sids, `system.left` for vanished sids. */
157
+ private diffPlayers;
158
+ private dispatch;
159
+ }
160
+ /**
161
+ * Umicat-flavored wrapper around a Colyseus Room. Exposes only the surface
162
+ * documented in SDK-GUIDE.md so games are portable to a different realtime
163
+ * backend should we migrate away from Colyseus.
164
+ */
165
+ export declare class UmicatRoom<State = unknown> {
166
+ private readonly room;
167
+ readonly player: PlayerDataFacade;
168
+ readonly data: RoomDataFacade;
169
+ readonly chat: ChatFacade;
170
+ constructor(room: Room<State>);
171
+ get id(): string;
172
+ get name(): string;
173
+ get sessionId(): string;
174
+ /**
175
+ * Current server-authoritative state. Proxied from Colyseus Schema — read
176
+ * values directly. Mutations do not propagate; only server-side handlers
177
+ * may change state.
178
+ */
179
+ get state(): State;
180
+ /** Send a transient message. Relayed to every other client in the room
181
+ * with `from: sessionId` stamped onto the payload. Not persisted in state. */
182
+ send(type: string, payload?: unknown): void;
183
+ /** Register a handler for a server-sent message type. Returns unsubscribe. */
184
+ on(type: string, handler: (payload: unknown) => void): Unsubscribe;
185
+ /** Fires whenever the server-authoritative state changes. */
186
+ onStateChange(handler: (state: State) => void): Unsubscribe;
187
+ /**
188
+ * Fires when the connection closes (kick, server shutdown, network drop).
189
+ * `code` follows WebSocket close codes plus Colyseus-specific ones.
190
+ */
191
+ onLeave(handler: (code: number) => void): Unsubscribe;
192
+ /** Fires when the server reports an error for this room. */
193
+ onError(handler: (code: number, message?: string) => void): Unsubscribe;
194
+ /** Disconnect from the room. Resolves with the close code. */
195
+ leave(consented?: boolean): Promise<number>;
196
+ }
197
+ export {};
@@ -0,0 +1,353 @@
1
+ function parseOrNull(raw) {
2
+ if (raw == null)
3
+ return null;
4
+ try {
5
+ return JSON.parse(raw);
6
+ }
7
+ catch {
8
+ return null;
9
+ }
10
+ }
11
+ /**
12
+ * Facade for per-player state — one entry per player, only the owner can write
13
+ * to their own entry. Delta-synced via Colyseus schema.
14
+ */
15
+ export class PlayerDataFacade {
16
+ constructor(room) {
17
+ this.room = room;
18
+ }
19
+ /** Write a JSON-serializable value to the caller's own player data. */
20
+ set(key, value) {
21
+ this.room.send('player.set', { key, value });
22
+ }
23
+ /** Remove a key from the caller's own player data. */
24
+ delete(key) {
25
+ this.room.send('player.delete', { key });
26
+ }
27
+ /** Read a player's data value. Returns null if the player or key is absent. */
28
+ get(sessionId, key) {
29
+ const state = this.room.state;
30
+ const player = state?.players?.get?.(sessionId);
31
+ return parseOrNull(player?.data?.get?.(key));
32
+ }
33
+ }
34
+ /**
35
+ * Facade for room-scope shared state — one map per room, any client may write.
36
+ * Delta-synced via Colyseus schema. Use for shared game state like current
37
+ * turn, scoreboard, shared level-of-the-day, etc.
38
+ */
39
+ export class RoomDataFacade {
40
+ constructor(room) {
41
+ this.room = room;
42
+ }
43
+ /** Write a JSON-serializable value into the room's shared data map. */
44
+ set(key, value) {
45
+ this.room.send('room.set', { key, value });
46
+ }
47
+ /** Remove a key from the room's shared data map. */
48
+ delete(key) {
49
+ this.room.send('room.delete', { key });
50
+ }
51
+ /** Read a shared room value. Returns null if the key is absent. */
52
+ get(key) {
53
+ const state = this.room.state;
54
+ return parseOrNull(state?.data?.get?.(key));
55
+ }
56
+ }
57
+ /** Maximum text length accepted by `ChatFacade.send`. Longer input is silently
58
+ * truncated. Games should mirror this on their input box's `maxLength`. */
59
+ export const MAX_CHAT_TEXT_LEN = 500;
60
+ /**
61
+ * Convenience layer over the transient relay for in-game chat. Wraps
62
+ * `room.send('chat', ...)` / `room.on('chat', ...)` with three things every
63
+ * game would otherwise re-implement inconsistently:
64
+ * - Local echo so the sender's own message hits the same handler stream
65
+ * immediately (synchronous, before the network call).
66
+ * - A canonical `ChatMessage` payload shape every chat in every game shares.
67
+ * - Outbound `text.trim()` + length-cap to MAX_CHAT_TEXT_LEN, so a stray
68
+ * paste cannot flood the relay.
69
+ *
70
+ * `displayName` is read from the sender's Player entry at send time and
71
+ * stamped onto the payload so receivers don't have to chase room.state to
72
+ * resolve a sessionId. See umicat-design/features/multiplayer-chat.md §3.4.
73
+ */
74
+ export class ChatFacade {
75
+ constructor(room) {
76
+ this.room = room;
77
+ this.handlers = new Set();
78
+ this.remoteOff = null;
79
+ /** Sids known to this facade, mapped to the displayName they had at the
80
+ * time we last observed them. Populated either at construction (if state
81
+ * was already synced) or on the first `onStateChange` (initial hydration
82
+ * — silent, no system messages). Subsequent state changes diff against
83
+ * this map to emit `system.joined` / `system.left`. We track displayName
84
+ * here because by the time a player is detected as "left" they're already
85
+ * gone from `state.players` and we can't read it from there. */
86
+ this.knownNames = new Map();
87
+ /** Becomes true the first time we observe a usable `state.players` map.
88
+ * The very first observation is treated as "initial state arrived" and
89
+ * must NOT fire `system.joined` for the players already in the room when
90
+ * the local user joined — only subsequent diffs are real lifecycle events. */
91
+ this.hydrated = false;
92
+ this.subscribeToPlayers();
93
+ }
94
+ /**
95
+ * Send a chat line. Empty-after-trim input is dropped silently. Text longer
96
+ * than MAX_CHAT_TEXT_LEN is silently truncated. Local echo fires
97
+ * synchronously before the wire send, so the input box can be cleared on
98
+ * the same tick.
99
+ *
100
+ * Returns a Promise that resolves once dispatch is complete (local echo +
101
+ * wire send queued). Rejects if the underlying `room.send` throws (e.g.
102
+ * the connection has dropped). Note: resolution does NOT confirm remote
103
+ * delivery — the underlying transient relay is fire-and-forget. Use this
104
+ * for catching local-side errors; do not treat it as an ack.
105
+ */
106
+ send(text) {
107
+ if (typeof text !== 'string')
108
+ return Promise.resolve();
109
+ const trimmed = text.trim();
110
+ if (trimmed.length === 0)
111
+ return Promise.resolve();
112
+ const capped = trimmed.length > MAX_CHAT_TEXT_LEN ? trimmed.slice(0, MAX_CHAT_TEXT_LEN) : trimmed;
113
+ const state = this.room.state;
114
+ const me = state?.players?.get?.(this.room.sessionId);
115
+ const displayName = typeof me?.displayName === 'string' ? me.displayName : '';
116
+ const msg = {
117
+ kind: 'user',
118
+ from: this.room.sessionId,
119
+ displayName,
120
+ text: capped,
121
+ ts: Date.now(),
122
+ };
123
+ // Local echo first, synchronously, so callers can clear input on the same
124
+ // tick the message lands in their chat log.
125
+ this.dispatch(msg);
126
+ // Wire send. The server's wildcard relay rebroadcasts to every other
127
+ // client (`except: senderClient`) with `from: client.sessionId` stamped.
128
+ try {
129
+ this.room.send('chat', { kind: 'user', text: capped, ts: msg.ts, displayName });
130
+ }
131
+ catch (err) {
132
+ return Promise.reject(err);
133
+ }
134
+ return Promise.resolve();
135
+ }
136
+ /**
137
+ * Subscribe to chat messages — both remote and local-echoed (your own), plus
138
+ * auto-emitted `'system.joined'` / `'system.left'` events. Returns an
139
+ * unsubscribe function. Call it on scene shutdown.
140
+ */
141
+ onMessage(handler) {
142
+ this.handlers.add(handler);
143
+ if (!this.remoteOff) {
144
+ const cb = (raw) => {
145
+ const p = (raw && typeof raw === 'object' ? raw : {});
146
+ if (typeof p.text !== 'string')
147
+ return; // defensive: drop legacy raw-string sends
148
+ // System messages are always computed locally — clamp wire kind to 'user'.
149
+ const msg = {
150
+ kind: 'user',
151
+ from: typeof p.from === 'string' ? p.from : '',
152
+ displayName: typeof p.displayName === 'string' ? p.displayName : '',
153
+ text: p.text,
154
+ ts: typeof p.ts === 'number' ? p.ts : Date.now(),
155
+ };
156
+ // Server uses `except: senderClient`, so a remote 'chat' is never the
157
+ // sender's own — no de-dupe with local echo needed.
158
+ this.dispatch(msg);
159
+ };
160
+ this.remoteOff = this.room.onMessage('chat', cb);
161
+ }
162
+ return () => {
163
+ this.handlers.delete(handler);
164
+ };
165
+ }
166
+ /**
167
+ * Hook `onStateChange` to detect joins / leaves and try an eager snapshot
168
+ * if `state.players` is already populated.
169
+ *
170
+ * **The onStateChange subscription must always be set up, even when
171
+ * `state.players` is `undefined` at construction.** Colyseus 0.16 (and
172
+ * earlier) deliver the initial state as a *separate* message that arrives
173
+ * a few ms after `client.joinOrCreate` resolves — so right when UmicatRoom
174
+ * is constructed, `room.state.players` is typically `undefined`. An earlier
175
+ * 0.2.14 implementation early-returned in that case and never subscribed,
176
+ * which meant the diff machinery never armed and BOTH `system.joined` /
177
+ * `system.left` silently dead-stopped in production (Blokus chat game,
178
+ * 2026-04-27).
179
+ *
180
+ * Eager snapshot if `state.players` is already there sets `hydrated=true`
181
+ * so the first state change does a real diff. Otherwise the first state
182
+ * change is treated as initial hydration: populate `knownNames` silently,
183
+ * skip system messages, set `hydrated=true`. Subsequent state changes do
184
+ * the real lifecycle diff.
185
+ *
186
+ * Why `onStateChange` and not `MapSchema.onAdd` / `.onRemove`: Colyseus
187
+ * 0.16 dropped those instance methods in favour of a separate
188
+ * `getStateCallbacks(room)` proxy API. Hooking `onStateChange` and diffing
189
+ * `players` keys ourselves works the same in any Colyseus version, keeps
190
+ * us decoupled from the realtime backend's callback shape, and adds < 1ms
191
+ * of work per state change for typical room sizes (max 16 players).
192
+ */
193
+ subscribeToPlayers() {
194
+ // Always subscribe — regardless of whether `state.players` is populated yet.
195
+ this.room.onStateChange(() => this.diffPlayers());
196
+ // Best-effort eager snapshot. If state is already synced (rare — usually
197
+ // only in tests or on a reconnect to a hot room), this lets us mark
198
+ // hydrated immediately and treat the first state change as a real diff.
199
+ this.tryEagerSnapshot();
200
+ }
201
+ /** Populate `knownNames` from `state.players` if it's already available
202
+ * and mark hydrated. Silent — never emits system messages. A successful
203
+ * `forEach` (even iterating zero items) means the schema is synced and
204
+ * this is "what the room looked like when we joined" — safe to hydrate. */
205
+ tryEagerSnapshot() {
206
+ const players = this.room.state?.players;
207
+ if (!players || typeof players.forEach !== 'function')
208
+ return;
209
+ try {
210
+ players.forEach((p, sid) => {
211
+ const name = typeof p?.displayName === 'string' ? p.displayName : '';
212
+ this.knownNames.set(sid, name);
213
+ });
214
+ this.hydrated = true;
215
+ }
216
+ catch {
217
+ // State not iterable yet — leave it for the first onStateChange.
218
+ }
219
+ }
220
+ /** Compare current `players` membership against `knownNames`. On the very
221
+ * first invocation (post-construction) when `hydrated=false`, this is
222
+ * the initial-hydration call: populate `knownNames` silently and bail.
223
+ * Subsequent invocations diff against `knownNames` and emit
224
+ * `system.joined` for new sids, `system.left` for vanished sids. */
225
+ diffPlayers() {
226
+ const state = this.room.state;
227
+ const players = state?.players;
228
+ if (!players || typeof players.forEach !== 'function')
229
+ return;
230
+ if (!this.hydrated) {
231
+ // Initial hydration — silently absorb whoever's in the room when we
232
+ // joined. Don't say "X joined" for everyone already there.
233
+ try {
234
+ players.forEach((p, sid) => {
235
+ const name = typeof p?.displayName === 'string' ? p.displayName : '';
236
+ this.knownNames.set(sid, name);
237
+ });
238
+ }
239
+ catch {
240
+ return;
241
+ }
242
+ this.hydrated = true;
243
+ return;
244
+ }
245
+ const nowSids = new Set();
246
+ try {
247
+ players.forEach((p, sid) => {
248
+ nowSids.add(sid);
249
+ const name = typeof p?.displayName === 'string' ? p.displayName : '';
250
+ if (this.knownNames.has(sid)) {
251
+ // Refresh cached name in case Colyseus updated it post-join.
252
+ this.knownNames.set(sid, name);
253
+ return;
254
+ }
255
+ this.knownNames.set(sid, name);
256
+ if (sid === this.room.sessionId)
257
+ return; // don't announce self-join
258
+ const who = name || 'A player';
259
+ this.dispatch({
260
+ kind: 'system.joined',
261
+ from: sid,
262
+ displayName: name,
263
+ text: `${who} joined`,
264
+ ts: Date.now(),
265
+ });
266
+ });
267
+ }
268
+ catch {
269
+ return;
270
+ }
271
+ // Anyone in knownNames but not in nowSids has left.
272
+ for (const sid of [...this.knownNames.keys()]) {
273
+ if (nowSids.has(sid))
274
+ continue;
275
+ const name = this.knownNames.get(sid) ?? '';
276
+ this.knownNames.delete(sid);
277
+ const who = name || 'A player';
278
+ this.dispatch({
279
+ kind: 'system.left',
280
+ from: sid,
281
+ displayName: name,
282
+ text: `${who} left`,
283
+ ts: Date.now(),
284
+ });
285
+ }
286
+ }
287
+ dispatch(msg) {
288
+ for (const h of this.handlers) {
289
+ try {
290
+ h(msg);
291
+ }
292
+ catch (err) {
293
+ console.error('[umicat.chat] handler threw', err);
294
+ }
295
+ }
296
+ }
297
+ }
298
+ /**
299
+ * Umicat-flavored wrapper around a Colyseus Room. Exposes only the surface
300
+ * documented in SDK-GUIDE.md so games are portable to a different realtime
301
+ * backend should we migrate away from Colyseus.
302
+ */
303
+ export class UmicatRoom {
304
+ constructor(room) {
305
+ this.room = room;
306
+ this.player = new PlayerDataFacade(room);
307
+ this.data = new RoomDataFacade(room);
308
+ this.chat = new ChatFacade(room);
309
+ }
310
+ get id() { return this.room.roomId; }
311
+ get name() { return this.room.name; }
312
+ get sessionId() { return this.room.sessionId; }
313
+ /**
314
+ * Current server-authoritative state. Proxied from Colyseus Schema — read
315
+ * values directly. Mutations do not propagate; only server-side handlers
316
+ * may change state.
317
+ */
318
+ get state() { return this.room.state; }
319
+ /** Send a transient message. Relayed to every other client in the room
320
+ * with `from: sessionId` stamped onto the payload. Not persisted in state. */
321
+ send(type, payload) {
322
+ this.room.send(type, payload);
323
+ }
324
+ /** Register a handler for a server-sent message type. Returns unsubscribe. */
325
+ on(type, handler) {
326
+ return this.room.onMessage(type, handler);
327
+ }
328
+ /** Fires whenever the server-authoritative state changes. */
329
+ onStateChange(handler) {
330
+ const cb = () => handler(this.room.state);
331
+ this.room.onStateChange(cb);
332
+ return () => this.room.onStateChange.remove(cb);
333
+ }
334
+ /**
335
+ * Fires when the connection closes (kick, server shutdown, network drop).
336
+ * `code` follows WebSocket close codes plus Colyseus-specific ones.
337
+ */
338
+ onLeave(handler) {
339
+ const cb = (code) => handler(code);
340
+ this.room.onLeave(cb);
341
+ return () => this.room.onLeave.remove(cb);
342
+ }
343
+ /** Fires when the server reports an error for this room. */
344
+ onError(handler) {
345
+ const cb = (code, message) => handler(code, message);
346
+ this.room.onError(cb);
347
+ return () => this.room.onError.remove(cb);
348
+ }
349
+ /** Disconnect from the room. Resolves with the close code. */
350
+ async leave(consented = true) {
351
+ return this.room.leave(consented);
352
+ }
353
+ }
@@ -0,0 +1,23 @@
1
+ import type { Transport } from '../core/Transport.js';
2
+ /**
3
+ * Per-user key-value save data scoped to the current (game, user).
4
+ *
5
+ * When the viewer is authenticated, reads/writes go through the host to the
6
+ * Umicat backend. When anonymous or standalone, the same API is backed by
7
+ * localStorage — games do not branch on auth state.
8
+ *
9
+ * Size quotas (enforced at the backend):
10
+ * - 100 KB per value
11
+ * - 1 MB total per (game, user)
12
+ * - 64 keys per (game, user)
13
+ */
14
+ export declare class SavesModule {
15
+ private transport;
16
+ constructor(transport: Transport);
17
+ get<T = unknown>(key: string): Promise<T | null>;
18
+ set(key: string, value: unknown, options?: {
19
+ ifVersion?: number;
20
+ }): Promise<number>;
21
+ delete(key: string): Promise<boolean>;
22
+ list(): Promise<string[]>;
23
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Per-user key-value save data scoped to the current (game, user).
3
+ *
4
+ * When the viewer is authenticated, reads/writes go through the host to the
5
+ * Umicat backend. When anonymous or standalone, the same API is backed by
6
+ * localStorage — games do not branch on auth state.
7
+ *
8
+ * Size quotas (enforced at the backend):
9
+ * - 100 KB per value
10
+ * - 1 MB total per (game, user)
11
+ * - 64 keys per (game, user)
12
+ */
13
+ export class SavesModule {
14
+ constructor(transport) {
15
+ this.transport = transport;
16
+ }
17
+ async get(key) {
18
+ const res = await this.transport.call('saves.get', { key });
19
+ return (res?.value ?? null);
20
+ }
21
+ async set(key, value, options) {
22
+ const res = await this.transport.call('saves.set', {
23
+ key,
24
+ value,
25
+ ifVersion: options?.ifVersion,
26
+ });
27
+ return res.version;
28
+ }
29
+ async delete(key) {
30
+ const res = await this.transport.call('saves.delete', { key });
31
+ return res.deleted;
32
+ }
33
+ async list() {
34
+ const res = await this.transport.call('saves.list');
35
+ return res.keys;
36
+ }
37
+ }
@@ -0,0 +1,44 @@
1
+ import type { Transport } from '../core/Transport.js';
2
+ /** BCP-47 language tag for recognition, e.g. 'en-US', 'zh-CN'. */
3
+ export type VoiceLang = string;
4
+ export interface VoiceCallbacks {
5
+ /** Interim transcript — updates live, may change. */
6
+ onPartial?: (text: string) => void;
7
+ /** The recognized text — fires once, on a clean stop (skipped on cancel/no speech). */
8
+ onFinal: (text: string) => void;
9
+ /** Recognition finished — always fires last, after onFinal or on cancel/error. */
10
+ onEnd: () => void;
11
+ /** Failure kind, e.g. 'not-allowed' | 'no-speech' | 'network' | 'unavailable'. */
12
+ onError?: (kind: string) => void;
13
+ }
14
+ export interface VoiceSession {
15
+ /** Current mic loudness, 0..1 (RMS) — poll each frame to drive a waveform. */
16
+ level(): number;
17
+ /** Finish + transcribe (→ onFinal if there was speech). */
18
+ stop(): void;
19
+ /** Abort with no transcript. */
20
+ cancel(): void;
21
+ }
22
+ /**
23
+ * Voice input — device/platform speech-to-text with a live mic level for
24
+ * waveform UIs. Access via `umicat.voice`. Never throws from `start()`; it
25
+ * resolves `null` when unsupported or the mic is denied.
26
+ */
27
+ export declare class VoiceModule {
28
+ private transport;
29
+ constructor(transport: Transport);
30
+ /** Does this host declare the native voice capability (WebView bridge path)? */
31
+ private hasHostVoice;
32
+ /** True if voice input works here — either the native host bridge or the
33
+ * browser's own SpeechRecognition + mic. Check before showing a mic button. */
34
+ supported(): boolean;
35
+ /**
36
+ * Start a voice session. Resolves to a {@link VoiceSession}, or `null` if
37
+ * unsupported / the mic was denied (callers fall back to typing).
38
+ */
39
+ start(lang: VoiceLang, cb: VoiceCallbacks): Promise<VoiceSession | null>;
40
+ /** Native path: drive the platform recognizer through the host bridge. */
41
+ private startHostVoice;
42
+ }
43
+ /** True if the BROWSER can do speech-to-text (its own recognition) AND give us the mic. */
44
+ export declare function webVoiceSupported(): boolean;