@unboxy/phaser-sdk 0.2.13 → 0.2.15
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.
- package/SDK-GUIDE.md +2 -0
- package/dist/realtime/UnboxyRoom.d.ts +49 -9
- package/dist/realtime/UnboxyRoom.js +109 -38
- package/package.json +1 -1
package/SDK-GUIDE.md
CHANGED
|
@@ -544,6 +544,8 @@ Don't blindly apply interpolation to everything. It's wrong for a chess piece an
|
|
|
544
544
|
|
|
545
545
|
## Changelog
|
|
546
546
|
|
|
547
|
+
- **0.2.15** — fix: `ChatFacade.subscribeToPlayers` early-returned when `state.players` was `undefined` at construction time, which it always is on a fresh `joinOrCreate` (Colyseus delivers initial state as a separate message a few ms later). The early return meant `onStateChange` never subscribed and `system.joined` / `system.left` stayed silent in production despite the 0.2.14 callback-API fix. Now: always subscribe; treat the first state change as initial hydration (silent), subsequent changes do real diff. Three regression tests cover the deferred-hydration flow.
|
|
548
|
+
- **0.2.14** — fix: `ChatFacade` `system.joined` / `system.left` events were silently no-op'ing in production. The lifecycle subscription used `state.players.onAdd` / `.onRemove` instance methods that Colyseus 0.16 removed in favour of `getStateCallbacks(room)`; the runtime check `typeof players.onAdd === 'function'` evaluated false, so neither system message fired. Now uses `room.onStateChange` + a known-names diff — robust to future Colyseus API changes. Tests updated to match. Patch-only; no API change for game code.
|
|
547
549
|
- **0.2.13** — added `room.chat` helper for in-game chat. New API: `room.chat.send(text)` (returns `Promise<void>`) + `room.chat.onMessage(handler)`, plus exports `ChatMessage`, `ChatMessageKind`, and `MAX_CHAT_TEXT_LEN`. Wraps the existing `'chat'` wildcard relay with local echo (sender sees own message synchronously), stamped `displayName` on every payload, trim + 500-char silent truncate, and auto-emitted `kind: 'system.joined'` / `'system.left'` messages computed locally from `room.state.players` add/remove events (no wire traffic). Replaces the raw `room.send('chat', text)` pattern in the Multiplayer chapter — raw still works but loses local echo, length cap, and join/leave events.
|
|
548
550
|
- **0.2.12** — added `unboxy.rooms.list(options?)` — returns the open rooms for the caller's game right now, scoped server-side to the caller's gameId. Use this to build a lobby browser ("show me open rooms, click to join") without needing an out-of-band room code. SDK-GUIDE adds a "Browsing open rooms" section under Multiplayer covering the `RoomListEntry` shape, the poll-on-timer pattern, and the `{ roomCode }` filter for "is room X up yet?" probes.
|
|
549
551
|
- **0.2.11** — added `roomCode` + `maxClients` to `JoinOptions`. SDK-GUIDE's Multiplayer chapter now documents matchmaking: room type is always `'lobby'`; use `roomCode` to bucket independent rooms per gameId (create-with-fresh-code, join-with-shared-code, quick-match with empty code). Fixes a real bug where agents were passing a gameId-derived string as the room type (e.g. `joinOrCreate('blokus-<code>', ...)`) and failing with "no handler" server-side.
|
|
@@ -83,10 +83,19 @@ export declare class ChatFacade {
|
|
|
83
83
|
private readonly room;
|
|
84
84
|
private readonly handlers;
|
|
85
85
|
private remoteOff;
|
|
86
|
-
/** Sids
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
|
|
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;
|
|
90
99
|
constructor(room: Room<unknown>);
|
|
91
100
|
/**
|
|
92
101
|
* Send a chat line. Empty-after-trim input is dropped silently. Text longer
|
|
@@ -108,13 +117,44 @@ export declare class ChatFacade {
|
|
|
108
117
|
*/
|
|
109
118
|
onMessage(handler: (msg: ChatMessage) => void): Unsubscribe;
|
|
110
119
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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 UnboxyRoom
|
|
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).
|
|
116
145
|
*/
|
|
117
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;
|
|
118
158
|
private dispatch;
|
|
119
159
|
}
|
|
120
160
|
/**
|
|
@@ -76,10 +76,19 @@ export class ChatFacade {
|
|
|
76
76
|
this.room = room;
|
|
77
77
|
this.handlers = new Set();
|
|
78
78
|
this.remoteOff = null;
|
|
79
|
-
/** Sids
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
|
|
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;
|
|
83
92
|
this.subscribeToPlayers();
|
|
84
93
|
}
|
|
85
94
|
/**
|
|
@@ -155,37 +164,97 @@ export class ChatFacade {
|
|
|
155
164
|
};
|
|
156
165
|
}
|
|
157
166
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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 UnboxyRoom
|
|
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).
|
|
163
192
|
*/
|
|
164
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() {
|
|
165
206
|
const players = this.room.state?.players;
|
|
166
|
-
if (!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')
|
|
167
229
|
return;
|
|
168
|
-
if (
|
|
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.
|
|
169
233
|
try {
|
|
170
|
-
players.forEach((
|
|
234
|
+
players.forEach((p, sid) => {
|
|
235
|
+
const name = typeof p?.displayName === 'string' ? p.displayName : '';
|
|
236
|
+
this.knownNames.set(sid, name);
|
|
237
|
+
});
|
|
171
238
|
}
|
|
172
239
|
catch {
|
|
173
|
-
|
|
174
|
-
// which means we'll emit "joined" for the initial hydration. That's
|
|
175
|
-
// a degraded but non-broken behavior — the chat log gets an extra
|
|
176
|
-
// line per existing player. Log once for visibility.
|
|
177
|
-
console.warn('[unboxy.chat] could not snapshot players on subscribe');
|
|
240
|
+
return;
|
|
178
241
|
}
|
|
242
|
+
this.hydrated = true;
|
|
243
|
+
return;
|
|
179
244
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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);
|
|
183
253
|
return;
|
|
184
|
-
|
|
185
|
-
|
|
254
|
+
}
|
|
255
|
+
this.knownNames.set(sid, name);
|
|
186
256
|
if (sid === this.room.sessionId)
|
|
187
|
-
return;
|
|
188
|
-
const name = typeof player?.displayName === 'string' ? player.displayName : '';
|
|
257
|
+
return; // don't announce self-join
|
|
189
258
|
const who = name || 'A player';
|
|
190
259
|
this.dispatch({
|
|
191
260
|
kind: 'system.joined',
|
|
@@ -196,20 +265,22 @@ export class ChatFacade {
|
|
|
196
265
|
});
|
|
197
266
|
});
|
|
198
267
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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(),
|
|
213
284
|
});
|
|
214
285
|
}
|
|
215
286
|
}
|