@uzuhq/code-sdk 0.7.6 → 0.8.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.
Files changed (53) hide show
  1. package/README.md +52 -9
  2. package/dist/dev-globals.d.ts +12 -27
  3. package/dist/dev-globals.js +0 -15
  4. package/dist/dev-hooks-D6CbhPDP.d.ts +715 -0
  5. package/dist/index.d.ts +158 -66
  6. package/dist/index.js +2178 -416
  7. package/package.json +8 -5
  8. package/dist/action-types.test-d.d.ts +0 -11
  9. package/dist/action-types.test-d.js +0 -101
  10. package/dist/dev-hooks.d.ts +0 -241
  11. package/dist/dev-hooks.js +0 -132
  12. package/dist/dev-hooks.test.d.ts +0 -1
  13. package/dist/dev-hooks.test.js +0 -294
  14. package/dist/dev-prediction-traps.d.ts +0 -32
  15. package/dist/dev-prediction-traps.js +0 -0
  16. package/dist/dev-prediction-traps.test.d.ts +0 -1
  17. package/dist/dev-prediction-traps.test.js +0 -178
  18. package/dist/dev-state-patch.d.ts +0 -81
  19. package/dist/dev-state-patch.js +0 -295
  20. package/dist/dev-state-patch.test.d.ts +0 -1
  21. package/dist/dev-state-patch.test.js +0 -333
  22. package/dist/json-patch.d.ts +0 -7
  23. package/dist/json-patch.js +0 -78
  24. package/dist/random.d.ts +0 -11
  25. package/dist/random.js +0 -34
  26. package/dist/reconnectable-ws.d.ts +0 -60
  27. package/dist/reconnectable-ws.js +0 -229
  28. package/dist/room.d.ts +0 -23
  29. package/dist/room.js +0 -36
  30. package/dist/roster-params.test.d.ts +0 -1
  31. package/dist/roster-params.test.js +0 -86
  32. package/dist/run/local-server-action.d.ts +0 -16
  33. package/dist/run/local-server-action.js +0 -217
  34. package/dist/run/local-server-action.test.d.ts +0 -1
  35. package/dist/run/local-server-action.test.js +0 -242
  36. package/dist/run/optimistic-action-client.d.ts +0 -68
  37. package/dist/run/optimistic-action-client.js +0 -209
  38. package/dist/run/optimistic-action-client.test.d.ts +0 -1
  39. package/dist/run/optimistic-action-client.test.js +0 -430
  40. package/dist/run/server-action.d.ts +0 -16
  41. package/dist/run/server-action.js +0 -181
  42. package/dist/run/server-action.test.d.ts +0 -1
  43. package/dist/run/server-action.test.js +0 -105
  44. package/dist/server-clock.d.ts +0 -29
  45. package/dist/server-clock.js +0 -40
  46. package/dist/server-only.d.ts +0 -33
  47. package/dist/server-only.js +0 -21
  48. package/dist/sync/local.d.ts +0 -8
  49. package/dist/sync/local.js +0 -50
  50. package/dist/sync/online.d.ts +0 -5
  51. package/dist/sync/online.js +0 -165
  52. package/dist/types.d.ts +0 -345
  53. package/dist/types.js +0 -8
package/dist/index.js CHANGED
@@ -1,20 +1,1864 @@
1
- export { SERVER_TIME, DEFAULT_ICON_URLS } from './types.js';
2
- export { serverOnly, isServerOnlyAction } from './server-only.js';
3
- export { serverNow } from './server-clock.js';
4
- export { Room } from './room.js';
5
- export { ReconnectableWebSocket } from './reconnectable-ws.js';
6
- export { SeededRandomImpl } from './random.js';
7
- export { applyJsonMergePatch, applyJsonPatch } from './dev-state-patch.js';
8
- export { attachDevHooks, createDevHooks } from './dev-hooks.js';
9
- export { getPredictionWarnings } from './dev-prediction-traps.js';
10
- import { Room } from './room.js';
11
- import { ReconnectableWebSocket } from './reconnectable-ws.js';
12
- import { runOnlineServerAction } from './run/server-action.js';
13
- import { runLocalServerAction } from './run/local-server-action.js';
14
- import { syncLocal } from './sync/local.js';
15
- import { syncOnline } from './sync/online.js';
16
- import { attachDevHooks } from './dev-hooks.js';
17
- const gameHandlers = new Map();
1
+ //#region ../engine-core/src/types.ts
2
+ /** Sentinel value patch value にセットすると、サーバーが Date.now() に置換する */
3
+ const SERVER_TIME = "__SERVER_TIME__";
4
+ /** デフォルトのプレイヤーアイコン URL 一覧(dev / local モード用) */
5
+ const DEFAULT_ICON_URLS = [
6
+ "https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/4d0da24d-bcf2-4f7b-d1a0-f1bb8c747300/original",
7
+ "https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/8c75fccb-41d6-429d-e943-06c728a72a00/original",
8
+ "https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/43f45d11-da38-4d6e-637d-3df78e583500/original"
9
+ ];
10
+ //#endregion
11
+ //#region ../engine-core/src/game-time.ts
12
+ /**
13
+ * `GameTime` に長さを足す。
14
+ *
15
+ * 算術をすると brand が落ちる (`number + number = number`) ので、`GameTime` を
16
+ * 作り直す道はこの関数と `ctx.after` に限られる。
17
+ */
18
+ const plus = (t, d) => t + d;
19
+ /** 2 つの `GameTime` の差 (ms)。 */
20
+ const minus = (a, b) => a - b;
21
+ /**
22
+ * エンジン内部専用。作家コードから呼ばない。
23
+ *
24
+ * 時計の実装 (play-server の facet / dev-server / ソロ / SDK の補間) だけがここを通る。
25
+ */
26
+ const asGameTime = (ms) => ms;
27
+ //#endregion
28
+ //#region ../engine-core/src/game-clock.ts
29
+ /**
30
+ * @docs
31
+ * - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
32
+ *
33
+ * ゲーム内時計の実体。3 つのサーバー実装 (play-server の facet / cli の dev-server /
34
+ * SDK のソロ) が同じ挙動になるよう、時計の算術と凍結の出入りをここ 1 箇所に置く。
35
+ *
36
+ * ここに置かないもの: 永続化・tick ループ・broadcast・無人凍結の発火条件。
37
+ * それらはランタイムごとに手段が違うので、各実装が持つ。
38
+ */
39
+ /**
40
+ * ゲーム開始からの経過時間を数える時計。停止中は進まない。
41
+ *
42
+ * 実体はスカラー 3 本。シナリオの state は 1 バイトも書き換えない (どのフィールドが
43
+ * 時刻かエンジンは知らないので書き換えられない)。
44
+ */
45
+ var GameClock = class {
46
+ constructor() {
47
+ this.startedAtWall = 0;
48
+ this.pausedTotalMs = 0;
49
+ this.pausedAtWall = null;
50
+ this.frozenBy = /* @__PURE__ */ new Set();
51
+ }
52
+ /** 時計を 0 から始め直す。`setup()` を呼ぶ直前に通す。 */
53
+ restart() {
54
+ this.startedAtWall = Date.now();
55
+ this.pausedTotalMs = 0;
56
+ this.pausedAtWall = null;
57
+ this.frozenBy.clear();
58
+ }
59
+ get frozen() {
60
+ return this.frozenBy.size > 0;
61
+ }
62
+ isFrozenBy(reason) {
63
+ return this.frozenBy.has(reason);
64
+ }
65
+ /**
66
+ * 現在のゲーム内時刻。停止中は `pausedAtWall` で凍るので同じ値を返し続ける。
67
+ *
68
+ * IMPORTANT: [withGameClock] の中で呼んではいけない。実装が `Date.now()` を読むので、
69
+ * 差し替え済みの時刻を実時刻として引き算し、大きく負の値になる。
70
+ * ハンドラへ渡す時刻は必ず外で 1 回取ってから渡すこと。
71
+ */
72
+ now() {
73
+ return asGameTime((this.pausedAtWall ?? Date.now()) - this.startedAtWall - this.pausedTotalMs);
74
+ }
75
+ /** ゲーム内時刻を、alarm / setTimeout が使う実時刻へ直す。 */
76
+ toWall(at) {
77
+ return at + this.startedAtWall + this.pausedTotalMs;
78
+ }
79
+ /** 実際に理由を足したら true。呼び出し側が永続化や tick 停止の要否に使う。 */
80
+ freeze(reason) {
81
+ if (this.frozenBy.has(reason)) return false;
82
+ const wasFrozen = this.frozen;
83
+ this.frozenBy.add(reason);
84
+ if (!wasFrozen) this.pausedAtWall = Date.now();
85
+ return true;
86
+ }
87
+ /** 実際に理由を外したら true。 */
88
+ unfreeze(reason) {
89
+ if (!this.frozenBy.delete(reason)) return false;
90
+ if (!this.frozen && this.pausedAtWall !== null) {
91
+ this.pausedTotalMs += Date.now() - this.pausedAtWall;
92
+ this.pausedAtWall = null;
93
+ }
94
+ return true;
95
+ }
96
+ snapshot() {
97
+ return {
98
+ startedAtWall: this.startedAtWall,
99
+ pausedTotalMs: this.pausedTotalMs,
100
+ pausedAtWall: this.pausedAtWall,
101
+ frozenBy: [...this.frozenBy]
102
+ };
103
+ }
104
+ /**
105
+ * 永続化した内容から復元する。欠損は「未停止」に倒す。
106
+ *
107
+ * 読めなかったせいで世界が止まったままになる方が、動き出すより悪い。
108
+ */
109
+ restore(saved) {
110
+ this.startedAtWall = saved.startedAtWall ?? 0;
111
+ this.pausedTotalMs = saved.pausedTotalMs ?? 0;
112
+ this.pausedAtWall = saved.pausedAtWall ?? null;
113
+ this.frozenBy.clear();
114
+ for (const reason of saved.frozenBy ?? []) this.frozenBy.add(reason);
115
+ if (this.frozen && this.pausedAtWall === null) this.frozenBy.clear();
116
+ }
117
+ };
118
+ /**
119
+ * 作家の**同期**ハンドラを、時刻 API を差し替えた状態で走らせる。
120
+ *
121
+ * `sdk/src/dev-prediction-traps.ts` の runPredicted と同じ形。あちらは dev で警告を
122
+ * 出すための差し替えだが、こちらはゲーム内時計を返すための差し替え。
123
+ * `await` を挟まないので、JS が単一スレッドである以上ほかの部屋と混ざらない。
124
+ *
125
+ * IMPORTANT: 3 実装すべてで通すこと。ここが片方だけだと、生 `Date.now()` を書いた
126
+ * シナリオが「dev では壊れるのに本番では動く」(あるいはその逆) になる。
127
+ *
128
+ * 適用しないもの:
129
+ * - `serverActions` — async なので `await` をまたぐと復元が効かない
130
+ * - `deadlines[].at()` — state だけの純関数という契約があり、毎 state 変更で呼ばれる
131
+ *
132
+ * IMPORTANT: `fn` は**同期で完結すること**。`await` をまたぐと差し替えたまま制御が
133
+ * 抜け、無関係な処理まで偽の `Date.now` を読む。`fn` が Promise を返す形にしたく
134
+ * なったら、それは `serverActions` 側へ寄せるべき処理という合図。
135
+ *
136
+ * IMPORTANT: `fn` の中で [GameClock.now] を呼ばないこと。差し替えた `Date.now` を
137
+ * 実時刻として引き算してしまう。ハンドラへ渡す ctx は呼び出す前に組み立てる。
138
+ */
139
+ function withGameClock(time, fn) {
140
+ const realNow = Date.now;
141
+ const RealDate = Date;
142
+ Date.now = () => time;
143
+ globalThis.Date = new Proxy(RealDate, { construct: (target, args) => Reflect.construct(target, args.length === 0 ? [time] : args) });
144
+ try {
145
+ return fn();
146
+ } finally {
147
+ globalThis.Date = RealDate;
148
+ Date.now = realNow;
149
+ }
150
+ }
151
+ /**
152
+ * `deadlines[].at()` が返した値を締切として使えるかに正規化する。使えなければ null。
153
+ *
154
+ * `null` / `undefined` は「締切なし」で正常なので黙って捨てる。**数値だが有限でない
155
+ * (NaN / Infinity) 場合だけは記録に残す。** これはほぼ確実に `undefined` を含む算術の
156
+ * 結果で、旧 `ctx.now` を読んでいるシナリオを新エンジンで動かすと
157
+ * `undefined + 尺 = NaN` になり、締切が「二度と来ない」形で静かに死ぬ。
158
+ * 黙って捨てるとログにも例外にも出ないので、key ごとに 1 回だけ必ず出す。
159
+ *
160
+ * `report` を受け取るのは、engine-core が `console` を含むどのランタイム API にも
161
+ * 依存できないため (3 ランタイムが lib ES2022 だけでこのファイルを共有している)。
162
+ *
163
+ * @param warned 既に警告した key。呼び出し側がインスタンスごとに持つ。
164
+ */
165
+ function resolveDeadline(at, key, warned, report) {
166
+ if (at === null || at === void 0) return null;
167
+ if (typeof at === "number" && Number.isFinite(at)) return asGameTime(at);
168
+ if (!warned.has(key)) {
169
+ warned.add(key);
170
+ report(`deadline "${key}" の at() が締切に使えない値を返した: ${String(at)}。この締切は二度と発火しない。廃止された ctx.now を読んでいないか確認すること`);
171
+ }
172
+ return null;
173
+ }
174
+ //#endregion
175
+ //#region ../engine-core/src/json-patch.ts
176
+ function unescapePointer(token) {
177
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
178
+ }
179
+ function applyPatch(doc, ops) {
180
+ for (const op of ops) {
181
+ const tokens = op.path.split("/").slice(1).map(unescapePointer);
182
+ if (tokens.length === 0) return false;
183
+ if (op.op === "replace" || op.op === "add") {
184
+ let target = doc;
185
+ for (let i = 0; i < tokens.length - 1; i++) {
186
+ target = target?.[tokens[i]];
187
+ if (target === void 0 || target === null) return false;
188
+ }
189
+ const lastKey = tokens[tokens.length - 1];
190
+ target[lastKey] = op.value;
191
+ } else if (op.op === "remove") {
192
+ let target = doc;
193
+ for (let i = 0; i < tokens.length - 1; i++) {
194
+ target = target?.[tokens[i]];
195
+ if (target === void 0 || target === null) return false;
196
+ }
197
+ const lastKey = tokens[tokens.length - 1];
198
+ if (Array.isArray(target)) target.splice(Number(lastKey), 1);
199
+ else delete target[lastKey];
200
+ }
201
+ }
202
+ return true;
203
+ }
204
+ //#endregion
205
+ //#region ../engine-core/src/random.ts
206
+ var SeededRandomImpl = class SeededRandomImpl {
207
+ constructor(seed) {
208
+ this._state = seed | 0;
209
+ }
210
+ get state() {
211
+ return this._state;
212
+ }
213
+ static fromState(state) {
214
+ const r = new SeededRandomImpl(0);
215
+ r._state = state;
216
+ return r;
217
+ }
218
+ float() {
219
+ this._state |= 0;
220
+ this._state = this._state + 1831565813 | 0;
221
+ let t = Math.imul(this._state ^ this._state >>> 15, 1 | this._state);
222
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
223
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
224
+ }
225
+ int(max) {
226
+ return Math.floor(this.float() * max);
227
+ }
228
+ pick(array) {
229
+ return array[this.int(array.length)];
230
+ }
231
+ shuffle(array) {
232
+ const a = [...array];
233
+ for (let i = a.length - 1; i > 0; i--) {
234
+ const j = this.int(i + 1);
235
+ [a[i], a[j]] = [a[j], a[i]];
236
+ }
237
+ return a;
238
+ }
239
+ };
240
+ //#endregion
241
+ //#region ../engine-core/src/server-only.ts
242
+ /**
243
+ * @deprecated `logic.serverActions` に直接書く。
244
+ *
245
+ * ```ts
246
+ * // before
247
+ * actions: { notifyExternal: serverOnly(async (state) => { ... }) }
248
+ * // after
249
+ * serverActions: { notifyExternal: async (state) => { ... } }
250
+ * ```
251
+ */
252
+ function serverOnly(handler) {
253
+ return Object.assign(handler, { __serverOnly: true });
254
+ }
255
+ /**
256
+ * handler が `serverOnly()` で wrap されているか判定する。
257
+ *
258
+ * 移行期の互換用。`serverActions` へ移行済みの logic では常に false になる。
259
+ */
260
+ function isServerOnlyAction(handler) {
261
+ return typeof handler === "function" && "__serverOnly" in handler && handler.__serverOnly === true;
262
+ }
263
+ //#endregion
264
+ //#region src/server-clock.ts
265
+ /**
266
+ * @docs
267
+ * - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
268
+ * - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
269
+ *
270
+ * サーバーのゲーム内時計の推定値をクライアント全体へ配る。
271
+ *
272
+ * state に入っている `endsAt` のような締切はサーバーのゲーム内時計で打たれている。
273
+ * 読み取り側も同じ時計に揃えないと、残り時間が恒久的に狂う。
274
+ *
275
+ * ```ts
276
+ * const remain = Math.ceil(minus(state.game.timerEndsAt, gameTime()) / 1000);
277
+ * ```
278
+ *
279
+ * 基準は `performance.now()` (単調時計)。`Date.now()` を長さの計算に使うと、端末の時計が
280
+ * NTP やユーザー操作でずれた瞬間に全部のカウントダウンが飛ぶ。POSIX / Rust / C++ が
281
+ * 揃って禁じている形なので、ここでも採らない。
282
+ *
283
+ * オフセットは transport がサーバーからのメッセージを受けるたびに更新する。
284
+ * 未接続 / 未観測ならオフセット 0 のまま (オフラインでも壊れない)。
285
+ */
286
+ let offset = 0;
287
+ /** 停止中に凍らせた値。null なら進んでいる。 */
288
+ let frozenAt = null;
289
+ /**
290
+ * transport から呼ぶ内部関数。サーバーが打刻したゲーム内時刻を観測してオフセットを更新する。
291
+ *
292
+ * 下り片道遅延を無視するので推定は実サーバー時刻より僅かに遅れる。
293
+ * 表示用途では無視できる誤差なので、精度より単純さを取る。
294
+ */
295
+ function observeGameTime(t) {
296
+ if (typeof t !== "number" || !Number.isFinite(t)) return;
297
+ offset = t - performance.now();
298
+ }
299
+ /**
300
+ * ゲーム内時刻の推定値。停止中は凍る。
301
+ *
302
+ * カウントダウン描画のように毎秒/毎フレーム呼ぶ用途を想定しているので、
303
+ * state の到着とは無関係にいつでも呼べる。
304
+ */
305
+ function gameTime() {
306
+ return asGameTime(frozenAt ?? performance.now() + offset);
307
+ }
308
+ /**
309
+ * 時計を凍らせる。
310
+ *
311
+ * 停止中はサーバーの broadcast が止まって打刻が来ないので、`__pause_state` を受けた
312
+ * 時点の値で明示的に止める。解除後は通常の打刻でオフセットが入れ替わるため、
313
+ * 特別な補正は要らない。
314
+ */
315
+ function freezeGameTime() {
316
+ frozenAt = gameTime();
317
+ }
318
+ function unfreezeGameTime() {
319
+ frozenAt = null;
320
+ }
321
+ //#endregion
322
+ //#region src/pause-state.ts
323
+ /**
324
+ * @docs
325
+ * - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
326
+ *
327
+ * 緊急停止の状態を SDK 全体で 1 箇所に持つ。
328
+ *
329
+ * 停止は state ではない。シナリオの state に持ち込むと「停止中は state を変えるコードが
330
+ * 1 行も走らない」という不変条件が崩れるので、`GameLogic` へは渡さず読み取り専用の
331
+ * 関数として出す。用途は演出の停止 (rAF ループを止める等) に限る。
332
+ *
333
+ * ゲーム内時計の凍結もここから駆動する。停止フラグと時計の凍結が別々に動くと、
334
+ * 「止まっているのにカウントダウンだけ進む」がいつか必ず起きる。
335
+ */
336
+ let paused = false;
337
+ let pausedBy = null;
338
+ const listeners = /* @__PURE__ */ new Set();
339
+ /** transport から呼ぶ内部関数。停止状態が変わったときだけ通知する。 */
340
+ function setPauseState(next, by) {
341
+ pausedBy = next ? by : null;
342
+ if (next === paused) return;
343
+ paused = next;
344
+ if (paused) freezeGameTime();
345
+ else unfreezeGameTime();
346
+ for (const cb of listeners) try {
347
+ cb(paused);
348
+ } catch (err) {
349
+ console.warn("[uzu] onPauseChange listener threw:", err);
350
+ }
351
+ }
352
+ /** 緊急停止中か。 */
353
+ function isPaused() {
354
+ return paused;
355
+ }
356
+ /** 停止を要求した playerId。停止していなければ null。 */
357
+ function getPausedBy() {
358
+ return pausedBy;
359
+ }
360
+ /** 停止状態の変化を購読する。 */
361
+ function onPauseChange(cb) {
362
+ listeners.add(cb);
363
+ }
364
+ let requester = null;
365
+ function setPauseRequester(fn) {
366
+ requester = fn;
367
+ }
368
+ function requestPause() {
369
+ if (!requester) {
370
+ console.warn("[uzu] requestPause: サーバーへ接続していないので停止できません");
371
+ return;
372
+ }
373
+ requester(true);
374
+ }
375
+ function requestResume() {
376
+ if (!requester) {
377
+ console.warn("[uzu] requestResume: サーバーへ接続していないので解除できません");
378
+ return;
379
+ }
380
+ requester(false);
381
+ }
382
+ //#endregion
383
+ //#region src/room.ts
384
+ var Room = class {
385
+ constructor(ws, myId) {
386
+ this.handlers = /* @__PURE__ */ new Map();
387
+ this.ws = ws;
388
+ this.myId = myId;
389
+ this.ws.addEventListener("message", (ev) => {
390
+ let parsed;
391
+ try {
392
+ parsed = JSON.parse(ev.data);
393
+ } catch {
394
+ return;
395
+ }
396
+ const type = parsed.type;
397
+ if (!type) return;
398
+ const from = parsed.__from;
399
+ console.log(`[SDK Room] ⬅ recv type=${type} from=${from}`, JSON.stringify(parsed));
400
+ (this.handlers.get(type) || []).forEach((h) => h({
401
+ ...parsed,
402
+ __from: from
403
+ }));
404
+ });
405
+ }
406
+ broadcast(msg) {
407
+ console.log(`[SDK Room] ➡ broadcast type=${msg.type}`, JSON.stringify(msg));
408
+ this.ws.send(JSON.stringify(msg));
409
+ }
410
+ send(id, msg) {
411
+ console.log(`[SDK Room] ➡ send to=${id} type=${msg.type}`, JSON.stringify(msg));
412
+ this.ws.send(JSON.stringify({
413
+ ...msg,
414
+ __to: id
415
+ }));
416
+ }
417
+ on(type, handler) {
418
+ if (!this.handlers.has(type)) this.handlers.set(type, []);
419
+ this.handlers.get(type).push(handler);
420
+ }
421
+ };
422
+ //#endregion
423
+ //#region src/reconnectable-ws.ts
424
+ const DEFAULTS = {
425
+ maxReconnectAttempts: 15,
426
+ baseDelay: 1e3,
427
+ maxDelay: 3e4,
428
+ jitterFactor: .5,
429
+ heartbeatInterval: 25e3,
430
+ heartbeatTimeout: 1e4,
431
+ maxBufferSize: 50
432
+ };
433
+ var ReconnectableWebSocket = class {
434
+ constructor(url, options = {}) {
435
+ this.ws = null;
436
+ this._state = "connecting";
437
+ this.reconnectAttempt = 0;
438
+ this.messageBuffer = [];
439
+ this.messageHandlers = [];
440
+ this.heartbeatTimer = null;
441
+ this.heartbeatTimeoutTimer = null;
442
+ this.reconnectTimer = null;
443
+ this.disposed = false;
444
+ this.url = url;
445
+ this.opts = {
446
+ maxReconnectAttempts: options.maxReconnectAttempts ?? DEFAULTS.maxReconnectAttempts,
447
+ baseDelay: options.baseDelay ?? DEFAULTS.baseDelay,
448
+ maxDelay: options.maxDelay ?? DEFAULTS.maxDelay,
449
+ jitterFactor: options.jitterFactor ?? DEFAULTS.jitterFactor,
450
+ heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
451
+ heartbeatTimeout: options.heartbeatTimeout ?? DEFAULTS.heartbeatTimeout,
452
+ maxBufferSize: options.maxBufferSize ?? DEFAULTS.maxBufferSize,
453
+ shouldBuffer: options.shouldBuffer,
454
+ onConnectionStateChange: options.onConnectionStateChange
455
+ };
456
+ this.connect();
457
+ }
458
+ get connectionState() {
459
+ return this._state;
460
+ }
461
+ /** WebSocket.readyState 互換 (既存コードとの互換用) */
462
+ get readyState() {
463
+ return this.ws?.readyState ?? WebSocket.CLOSED;
464
+ }
465
+ send(data) {
466
+ if (this._state === "connected" && this.ws?.readyState === WebSocket.OPEN) this.ws.send(data);
467
+ else if (this._state === "reconnecting") {
468
+ if (this.opts.shouldBuffer && !this.opts.shouldBuffer(data)) return;
469
+ if (this.messageBuffer.length < this.opts.maxBufferSize) this.messageBuffer.push(data);
470
+ }
471
+ }
472
+ addEventListener(type, handler) {
473
+ if (type === "message") this.messageHandlers.push(handler);
474
+ }
475
+ removeEventListener(type, handler) {
476
+ if (type === "message") {
477
+ const index = this.messageHandlers.indexOf(handler);
478
+ if (index !== -1) this.messageHandlers.splice(index, 1);
479
+ }
480
+ }
481
+ /** バッファをクリア(権威的 state 受信時に呼ぶ) */
482
+ clearBuffer() {
483
+ this.messageBuffer = [];
484
+ }
485
+ /** 全リソース解放 */
486
+ dispose() {
487
+ this.disposed = true;
488
+ this.stopHeartbeat();
489
+ if (this.reconnectTimer) {
490
+ clearTimeout(this.reconnectTimer);
491
+ this.reconnectTimer = null;
492
+ }
493
+ if (this.ws) {
494
+ this.ws.onopen = null;
495
+ this.ws.onclose = null;
496
+ this.ws.onerror = null;
497
+ this.ws.onmessage = null;
498
+ try {
499
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) this.ws.close();
500
+ } catch (e) {
501
+ console.warn("[ReconnectableWS] Error closing WebSocket:", e);
502
+ }
503
+ this.ws = null;
504
+ }
505
+ }
506
+ connect() {
507
+ if (this.disposed) return;
508
+ console.log(`[ReconnectableWS] 🔗 Connecting to ${this.url} (attempt=${this.reconnectAttempt})`);
509
+ const ws = new WebSocket(this.url);
510
+ ws.onopen = () => {
511
+ if (this.disposed) return;
512
+ console.log(`[ReconnectableWS] ✅ Connected`);
513
+ this.reconnectAttempt = 0;
514
+ this.setState("connected");
515
+ this.startHeartbeat();
516
+ this.flushBuffer();
517
+ };
518
+ ws.onclose = (ev) => {
519
+ if (this.disposed) return;
520
+ if (ev.code === 1e3) {
521
+ console.log(`[ReconnectableWS] 🔒 Closed cleanly (code=${ev.code})`);
522
+ return;
523
+ }
524
+ console.log(`[ReconnectableWS] ❌ Closed (code=${ev.code} reason=${ev.reason})`);
525
+ this.stopHeartbeat();
526
+ this.scheduleReconnect();
527
+ };
528
+ ws.onerror = () => {
529
+ if (this.disposed) return;
530
+ console.log(`[ReconnectableWS] ⚠️ Error`);
531
+ };
532
+ ws.onmessage = (ev) => {
533
+ if (this.disposed) return;
534
+ if (ev.data === "__pong") {
535
+ this.handlePong();
536
+ return;
537
+ }
538
+ for (const handler of this.messageHandlers) handler(ev);
539
+ };
540
+ this.ws = ws;
541
+ }
542
+ setState(state) {
543
+ if (this._state === state) return;
544
+ this._state = state;
545
+ console.log(`[ReconnectableWS] 📡 State: ${state}`);
546
+ this.opts.onConnectionStateChange?.(state);
547
+ }
548
+ scheduleReconnect() {
549
+ if (this.disposed) return;
550
+ if (this.reconnectAttempt >= this.opts.maxReconnectAttempts) {
551
+ console.log(`[ReconnectableWS] 🚫 Max reconnect attempts reached (${this.opts.maxReconnectAttempts})`);
552
+ this.setState("disconnected");
553
+ return;
554
+ }
555
+ this.setState("reconnecting");
556
+ const delay = this.getReconnectDelay();
557
+ console.log(`[ReconnectableWS] ⏳ Reconnecting in ${Math.round(delay)}ms (attempt=${this.reconnectAttempt + 1})`);
558
+ this.reconnectTimer = setTimeout(() => {
559
+ this.reconnectAttempt++;
560
+ this.connect();
561
+ }, delay);
562
+ }
563
+ getReconnectDelay() {
564
+ const { baseDelay, maxDelay, jitterFactor } = this.opts;
565
+ const exponentialDelay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempt), maxDelay);
566
+ const jitter = exponentialDelay * jitterFactor * (Math.random() * 2 - 1);
567
+ return Math.max(0, exponentialDelay + jitter);
568
+ }
569
+ flushBuffer() {
570
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
571
+ const buffer = this.messageBuffer;
572
+ this.messageBuffer = [];
573
+ for (const data of buffer) try {
574
+ this.ws.send(data);
575
+ } catch (e) {
576
+ console.warn(`[ReconnectableWS] ⚠️ Failed to flush buffered message:`, e);
577
+ }
578
+ if (buffer.length > 0) console.log(`[ReconnectableWS] 📤 Flushed ${buffer.length} buffered messages`);
579
+ }
580
+ startHeartbeat() {
581
+ this.stopHeartbeat();
582
+ this.heartbeatTimer = setInterval(() => {
583
+ if (this.ws?.readyState === WebSocket.OPEN) {
584
+ this.ws.send("__ping");
585
+ this.heartbeatTimeoutTimer = setTimeout(() => {
586
+ console.log(`[ReconnectableWS] 💔 Heartbeat timeout`);
587
+ this.ws?.close(4001, "heartbeat_timeout");
588
+ }, this.opts.heartbeatTimeout);
589
+ }
590
+ }, this.opts.heartbeatInterval);
591
+ }
592
+ stopHeartbeat() {
593
+ if (this.heartbeatTimer) {
594
+ clearInterval(this.heartbeatTimer);
595
+ this.heartbeatTimer = null;
596
+ }
597
+ if (this.heartbeatTimeoutTimer) {
598
+ clearTimeout(this.heartbeatTimeoutTimer);
599
+ this.heartbeatTimeoutTimer = null;
600
+ }
601
+ }
602
+ handlePong() {
603
+ if (this.heartbeatTimeoutTimer) {
604
+ clearTimeout(this.heartbeatTimeoutTimer);
605
+ this.heartbeatTimeoutTimer = null;
606
+ }
607
+ }
608
+ };
609
+ //#endregion
610
+ //#region src/dev-state-patch.ts
611
+ /**
612
+ * RFC 7396 (Merge Patch) を target に in-place 適用する。
613
+ *
614
+ * 規則:
615
+ * - `undefined` は no-op (key 自体を patch から落としたのと同じ)
616
+ * - `null` は **null をセット** (RFC 7396 strict と異なる、deletion はしない)
617
+ * - patch が object かつ target も object → 再帰 merge
618
+ * - patch が array → atomic replace (要素単位 merge なし、RFC 7396 spec 通り)
619
+ * - patch が primitive → overwrite
620
+ * - **target が array で patch が non-array object** → throw (silent な
621
+ * `[..., ...]` → `{ "0": ..., "1": ... }` 化を防ぐ)
622
+ *
623
+ * 型 mismatch (例: object → primitive) は overwrite で許可する。
624
+ */
625
+ function applyJsonMergePatch(target, patch) {
626
+ if (Array.isArray(target)) throw new TypeError("[uzu_dev] mergeRawState: cannot deep-merge into an array field. Use patchRawState ({ op: \"replace\", path: \"/.../<index>\" }) for element-level writes, or pass a full array to atomic-replace it.");
627
+ for (const key of Object.keys(patch)) {
628
+ const value = patch[key];
629
+ if (value === void 0) continue;
630
+ if (value === null) {
631
+ target[key] = null;
632
+ continue;
633
+ }
634
+ const current = target[key];
635
+ if (typeof value === "object" && !Array.isArray(value) && current !== null && typeof current === "object") {
636
+ if (Array.isArray(current)) throw new TypeError(`[uzu_dev] mergeRawState: refusing to merge a plain object into array field "${key}". Use patchRawState ({ op: "replace", path: "/${key}/<index>" }) for element-level writes, or pass a full array to atomic-replace it.`);
637
+ applyJsonMergePatch(current, value);
638
+ } else target[key] = value;
639
+ }
640
+ }
641
+ /**
642
+ * JSON Pointer (RFC 6901) を tokens に分解する。
643
+ * `/board/1/~04` → `['board', '1', '~4']`
644
+ *
645
+ * RFC 6901 のエスケープ:
646
+ * - `~1` → `/`
647
+ * - `~0` → `~`
648
+ *
649
+ * 復号順は ~1 → ~0。逆順で解くと `~01` が `/` に化けるので注意。
650
+ */
651
+ function parseJsonPointer(pointer) {
652
+ if (pointer === "") return [];
653
+ if (!pointer.startsWith("/")) throw new TypeError(`[uzu_dev] invalid JSON Pointer: ${pointer} (must start with "/")`);
654
+ return pointer.slice(1).split("/").map((token) => token.replace(/~1/g, "/").replace(/~0/g, "~"));
655
+ }
656
+ /**
657
+ * JSON Pointer の **親 container** と **末端 key** に解決する。
658
+ * root pointer (`''`) は対応外: 呼び出し側で別途扱う。
659
+ */
660
+ function resolveParent(doc, tokens) {
661
+ if (tokens.length === 0) throw new TypeError("[uzu_dev] root pointer (\"\") is not addressable by this helper");
662
+ let current = doc;
663
+ for (let i = 0; i < tokens.length - 1; i++) {
664
+ const token = tokens[i];
665
+ if (current === null || typeof current !== "object") throw new TypeError(`[uzu_dev] JSON Pointer path "${tokens.slice(0, i + 1).join("/")}" goes through a non-container`);
666
+ if (Array.isArray(current)) {
667
+ const idx = parseArrayIndex(token, current.length, false);
668
+ current = current[idx];
669
+ } else current = current[token];
670
+ }
671
+ if (current === null || typeof current !== "object") throw new TypeError(`[uzu_dev] JSON Pointer parent "${tokens.slice(0, -1).join("/")}" is not a container`);
672
+ return {
673
+ parent: current,
674
+ key: tokens[tokens.length - 1]
675
+ };
676
+ }
677
+ /**
678
+ * JSON Pointer から値を取得する。root pointer は doc 自体。
679
+ * 存在しない path は throw する。
680
+ */
681
+ function getByPointer(doc, pointer) {
682
+ const tokens = parseJsonPointer(pointer);
683
+ let current = doc;
684
+ for (const token of tokens) {
685
+ if (current === null || typeof current !== "object") throw new TypeError(`[uzu_dev] JSON Pointer path "${pointer}" goes through a non-container`);
686
+ if (Array.isArray(current)) {
687
+ const idx = parseArrayIndex(token, current.length, false);
688
+ current = current[idx];
689
+ } else {
690
+ const obj = current;
691
+ if (!(token in obj)) throw new TypeError(`[uzu_dev] JSON Pointer path "${pointer}" does not exist`);
692
+ current = obj[token];
693
+ }
694
+ }
695
+ return current;
696
+ }
697
+ /**
698
+ * Array index token を numeric index に解決する。
699
+ * `'-'` は append (RFC 6902 仕様、`add` 操作のみ許可) で `length` を返す。
700
+ */
701
+ function parseArrayIndex(token, length, allowAppend) {
702
+ if (token === "-") {
703
+ if (!allowAppend) throw new TypeError("[uzu_dev] array append (\"-\") is only valid for `add` operations");
704
+ return length;
705
+ }
706
+ if (!/^(?:0|[1-9][0-9]*)$/.test(token)) throw new TypeError(`[uzu_dev] invalid array index "${token}"`);
707
+ const idx = Number(token);
708
+ if (idx < 0 || idx > length) throw new RangeError(`[uzu_dev] array index ${idx} out of range (length=${length})`);
709
+ return idx;
710
+ }
711
+ /**
712
+ * 値の深い等価判定 (RFC 6902 `test` 用)。`JSON.stringify` ベース。
713
+ * `undefined` を含む値や key 順序差まで真面目に見るほどには使わないので十分。
714
+ */
715
+ function deepEqual(a, b) {
716
+ if (a === b) return true;
717
+ try {
718
+ return JSON.stringify(a) === JSON.stringify(b);
719
+ } catch {
720
+ return false;
721
+ }
722
+ }
723
+ /**
724
+ * RFC 6902 (JSON Patch) operations を target に in-place 適用する。
725
+ *
726
+ * 1 op でも失敗したら **その時点で throw** する (RFC 6902 spec: operations は
727
+ * sequentially evaluate、失敗時の rollback は規定なし)。caller が atomic に
728
+ * したいなら state を事前に snapshot しておく。
729
+ *
730
+ * root pointer (`''`) は本実装では `add` / `replace` のみ許可し、
731
+ * doc のフィールドを丸ごと差し替える形で動く (= 結局 `setRawState` で十分な
732
+ * ケースなので、わざわざ `patchRawState` で使うことはほぼない)。
733
+ */
734
+ function applyJsonPatch(target, ops) {
735
+ for (const op of ops) applyOne(target, op);
736
+ }
737
+ function applyOne(doc, op) {
738
+ switch (op.op) {
739
+ case "add": {
740
+ const tokens = parseJsonPointer(op.path);
741
+ if (tokens.length === 0) {
742
+ replaceRoot(doc, op.value);
743
+ return;
744
+ }
745
+ const { parent, key } = resolveParent(doc, tokens);
746
+ if (Array.isArray(parent)) {
747
+ const idx = parseArrayIndex(key, parent.length, true);
748
+ parent.splice(idx, 0, op.value);
749
+ } else parent[key] = op.value;
750
+ return;
751
+ }
752
+ case "remove": {
753
+ const tokens = parseJsonPointer(op.path);
754
+ if (tokens.length === 0) throw new TypeError("[uzu_dev] cannot remove the root document");
755
+ const { parent, key } = resolveParent(doc, tokens);
756
+ if (Array.isArray(parent)) {
757
+ const idx = parseArrayIndex(key, parent.length - 1, false);
758
+ parent.splice(idx, 1);
759
+ } else {
760
+ if (!(key in parent)) throw new TypeError(`[uzu_dev] cannot remove non-existent path "${op.path}"`);
761
+ delete parent[key];
762
+ }
763
+ return;
764
+ }
765
+ case "replace": {
766
+ const tokens = parseJsonPointer(op.path);
767
+ if (tokens.length === 0) {
768
+ replaceRoot(doc, op.value);
769
+ return;
770
+ }
771
+ const { parent, key } = resolveParent(doc, tokens);
772
+ if (Array.isArray(parent)) {
773
+ const idx = parseArrayIndex(key, parent.length - 1, false);
774
+ parent[idx] = op.value;
775
+ } else {
776
+ if (!(key in parent)) throw new TypeError(`[uzu_dev] cannot replace non-existent path "${op.path}"`);
777
+ parent[key] = op.value;
778
+ }
779
+ return;
780
+ }
781
+ case "move": {
782
+ if (op.from === op.path) return;
783
+ if (isProperPrefixPointer(op.from, op.path)) throw new TypeError(`[uzu_dev] cannot move into own descendant: from="${op.from}" path="${op.path}"`);
784
+ const value = getByPointer(doc, op.from);
785
+ applyOne(doc, {
786
+ op: "remove",
787
+ path: op.from
788
+ });
789
+ applyOne(doc, {
790
+ op: "add",
791
+ path: op.path,
792
+ value
793
+ });
794
+ return;
795
+ }
796
+ case "copy": {
797
+ const value = getByPointer(doc, op.from);
798
+ const cloned = structuredClone(value);
799
+ applyOne(doc, {
800
+ op: "add",
801
+ path: op.path,
802
+ value: cloned
803
+ });
804
+ return;
805
+ }
806
+ case "test":
807
+ if (!deepEqual(getByPointer(doc, op.path), op.value)) throw new Error(`[uzu_dev] test failed at "${op.path}"`);
808
+ return;
809
+ default: throw new TypeError(`[uzu_dev] unknown JSON Patch op: ${JSON.stringify(op)}`);
810
+ }
811
+ }
812
+ function isProperPrefixPointer(prefix, path) {
813
+ if (prefix === "") return path !== "";
814
+ return path === prefix || path.startsWith(`${prefix}/`);
815
+ }
816
+ /**
817
+ * root pointer (`''`) への add / replace 用。doc を in-place で空にして
818
+ * value の中身を流し込む。value が object でない場合は Record の形に
819
+ * 入らないので throw する (root を primitive / array に置換はサポート対象外、
820
+ * setRawState を使うべき)。
821
+ */
822
+ function replaceRoot(doc, value) {
823
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError("[uzu_dev] root replace requires a plain object value; use setRawState for full state replacement");
824
+ for (const key of Object.keys(doc)) delete doc[key];
825
+ Object.assign(doc, value);
826
+ }
827
+ //#endregion
828
+ //#region src/dev-prediction-traps.ts
829
+ /** 描画経路の警告に使う擬似 action 名。 */
830
+ const RENDER_CONTEXT = "(render)";
831
+ const CLOCK_TRAPS = [{ install: (report) => {
832
+ const RealDate = Date;
833
+ const realNow = Date.now;
834
+ globalThis.Date = new Proxy(RealDate, { construct: (target, args) => {
835
+ if (args.length === 0) report("new Date()");
836
+ return Reflect.construct(target, args);
837
+ } });
838
+ Date.now = () => {
839
+ report("Date.now()");
840
+ return realNow();
841
+ };
842
+ return () => {
843
+ Date.now = realNow;
844
+ globalThis.Date = RealDate;
845
+ };
846
+ } }, { install: (report) => {
847
+ const real = globalThis.performance?.now;
848
+ if (!real) return () => {};
849
+ const bound = real.bind(globalThis.performance);
850
+ globalThis.performance.now = () => {
851
+ report("performance.now()");
852
+ return bound();
853
+ };
854
+ return () => {
855
+ globalThis.performance.now = real;
856
+ };
857
+ } }];
858
+ /** 乱数系。先読みでだけ問題になるので描画経路には張らない。 */
859
+ const RANDOM_TRAPS = [{ install: (report) => {
860
+ const real = Math.random;
861
+ Math.random = () => {
862
+ report("Math.random()");
863
+ return real();
864
+ };
865
+ return () => {
866
+ Math.random = real;
867
+ };
868
+ } }, { install: (report) => {
869
+ const real = globalThis.crypto?.randomUUID;
870
+ if (!real) return () => {};
871
+ const bound = real.bind(globalThis.crypto);
872
+ globalThis.crypto.randomUUID = () => {
873
+ report("crypto.randomUUID()");
874
+ return bound();
875
+ };
876
+ return () => {
877
+ globalThis.crypto.randomUUID = real;
878
+ };
879
+ } }];
880
+ const TRAPS = [...CLOCK_TRAPS, ...RANDOM_TRAPS];
881
+ const warnings = [];
882
+ const reported = /* @__PURE__ */ new Set();
883
+ const printWarning = (action, api) => {
884
+ if (action === RENDER_CONTEXT) {
885
+ console.groupCollapsed(`⚠️ [uzu-code] 描画中に ${api} を呼びました`);
886
+ console.log([
887
+ `${api} は Unix epoch の実時刻を返します。state に入っている締切は`,
888
+ "ゲーム内時刻 (ゲーム開始からの経過 ms) なので、引き算すると桁が合いません。",
889
+ "また実時刻は緊急停止中も進むので、止まっているのにカウントダウンだけ動きます。",
890
+ "",
891
+ "直し方: 時刻は gameTime() から取る。",
892
+ " import { gameTime, minus } from '@uzuhq/code-sdk';",
893
+ " const remain = Math.ceil(minus(state.timerEndsAt, gameTime()) / 1000);"
894
+ ].join("\n"));
895
+ console.groupEnd();
896
+ return;
897
+ }
898
+ console.groupCollapsed(`⚠️ [uzu-code] action "${action}" が先読み中に ${api} を呼びました`);
899
+ console.log([
900
+ "先読み (楽観的更新) は「サーバーと同じコードを同じ入力で走らせれば同じ結果になる」",
901
+ `ことが前提です。${api} はクライアント側の値を読むため、サーバーの実行結果とズレます。`,
902
+ "ズレた state は一瞬表示されたあと ack で上書きされ、画面が飛びます。",
903
+ "",
904
+ "直し方:",
905
+ ` A. action "${action}" を serverOnly() で wrap する (先読みしなくなる)`,
906
+ " B. 値を state に書かず、クライアント側の描画だけで使う",
907
+ "",
908
+ "https://docs.code.uzu-app.com/reference/api#serveronly-handler"
909
+ ].join("\n"));
910
+ console.groupEnd();
911
+ };
912
+ /**
913
+ * dev harness の HUD にバッジを出すため親フレームへ通知する。
914
+ * 単独 page (親が自分自身) では送り先が無いので何もしない。
915
+ */
916
+ const notifyParent = (action, api) => {
917
+ if (window.parent === window) return;
918
+ try {
919
+ window.parent.postMessage(JSON.stringify({
920
+ channel: "sdk",
921
+ type: "predictionWarning",
922
+ payload: {
923
+ action,
924
+ api
925
+ }
926
+ }), "*");
927
+ } catch {}
928
+ };
929
+ /**
930
+ * 素の action handler の先読み実行を計装して走らせる。
931
+ *
932
+ * Flutter native ホスト (本番) では計装せず素通しする。判定基準は dev hooks と同じ。
933
+ */
934
+ const runPredicted = (action, run) => {
935
+ runInstrumented(action, run);
936
+ };
937
+ /**
938
+ * `onState` から同期で走る描画を計装する。
939
+ *
940
+ * 乱数は描画で使ってよいので、時刻の罠だけ張る。ここで拾えるのは同期の描画だけで、
941
+ * `requestAnimationFrame` の中は原理的に拾えない。
942
+ */
943
+ const runRendered = (run) => {
944
+ runInstrumented(RENDER_CONTEXT, run);
945
+ };
946
+ const runInstrumented = (action, run) => {
947
+ if (typeof window === "undefined" || window.FlutterHost) {
948
+ run();
949
+ return;
950
+ }
951
+ const report = (api) => {
952
+ const key = `${action}\u0000${api}`;
953
+ if (reported.has(key)) return;
954
+ reported.add(key);
955
+ warnings.push({
956
+ action,
957
+ api
958
+ });
959
+ printWarning(action, api);
960
+ notifyParent(action, api);
961
+ };
962
+ const restores = (action === RENDER_CONTEXT ? CLOCK_TRAPS : TRAPS).map((trap) => trap.install(report));
963
+ try {
964
+ run();
965
+ } finally {
966
+ for (let i = restores.length - 1; i >= 0; i--) restores[i]();
967
+ }
968
+ };
969
+ /** 検出済みの警告一覧。`__uzu_dev.getPredictionWarnings()` から E2E で assert する用。 */
970
+ const getPredictionWarnings = () => warnings;
971
+ //#endregion
972
+ //#region src/dev-hooks.ts
973
+ function createDevHooks(ctx) {
974
+ const getRawState = () => ctx.getRawState ? ctx.getRawState() : null;
975
+ const waitForSnapshot = (predicate, options) => {
976
+ const timeoutMs = options?.timeoutMs ?? 1e4;
977
+ return new Promise((resolve, reject) => {
978
+ const current = ctx.getSnapshot();
979
+ if (current != null) {
980
+ let ok = false;
981
+ try {
982
+ ok = predicate(current);
983
+ } catch (err) {
984
+ reject(err);
985
+ return;
986
+ }
987
+ if (ok) {
988
+ resolve(current);
989
+ return;
990
+ }
991
+ }
992
+ const timer = setTimeout(() => {
993
+ unsubscribe();
994
+ reject(/* @__PURE__ */ new Error(`[uzu_dev] waitForSnapshot timed out after ${timeoutMs}ms`));
995
+ }, timeoutMs);
996
+ const unsubscribe = ctx.subscribeSnapshot((snap) => {
997
+ let ok;
998
+ try {
999
+ ok = predicate(snap);
1000
+ } catch (err) {
1001
+ clearTimeout(timer);
1002
+ unsubscribe();
1003
+ reject(err);
1004
+ return;
1005
+ }
1006
+ if (ok) {
1007
+ clearTimeout(timer);
1008
+ unsubscribe();
1009
+ resolve(snap);
1010
+ }
1011
+ });
1012
+ });
1013
+ };
1014
+ const hooks = {
1015
+ getSnapshot: () => ctx.getSnapshot(),
1016
+ getRawState,
1017
+ playerId: () => ctx.playerId(),
1018
+ subscribeSnapshot: (cb) => ctx.subscribeSnapshot(cb),
1019
+ waitForSnapshot,
1020
+ getPredictionWarnings: () => getPredictionWarnings()
1021
+ };
1022
+ if (ctx.sendAction) {
1023
+ const sendAction = ctx.sendAction;
1024
+ hooks.send = (args) => sendAction(args);
1025
+ }
1026
+ if (ctx.setRawState) {
1027
+ const setRawState = ctx.setRawState;
1028
+ hooks.setRawState = (state) => setRawState(state);
1029
+ }
1030
+ if (ctx.mergeRawState) {
1031
+ const mergeRawState = ctx.mergeRawState;
1032
+ hooks.mergeRawState = (patch) => mergeRawState(patch);
1033
+ }
1034
+ if (ctx.patchRawState) {
1035
+ const patchRawState = ctx.patchRawState;
1036
+ hooks.patchRawState = (ops) => patchRawState(ops);
1037
+ }
1038
+ if (ctx.getSeed) {
1039
+ const getSeed = ctx.getSeed;
1040
+ hooks.getSeed = () => getSeed();
1041
+ }
1042
+ if (ctx.pauseTick) {
1043
+ const pauseTick = ctx.pauseTick;
1044
+ hooks.pauseTick = () => pauseTick();
1045
+ }
1046
+ if (ctx.resumeTick) {
1047
+ const resumeTick = ctx.resumeTick;
1048
+ hooks.resumeTick = () => resumeTick();
1049
+ }
1050
+ if (ctx.stepTick) {
1051
+ const stepTick = ctx.stepTick;
1052
+ hooks.stepTick = (n) => stepTick(n);
1053
+ }
1054
+ if (ctx.getCurrentTick) {
1055
+ const getCurrentTick = ctx.getCurrentTick;
1056
+ hooks.getCurrentTick = () => getCurrentTick();
1057
+ }
1058
+ if (ctx.isTickPaused) {
1059
+ const isTickPaused = ctx.isTickPaused;
1060
+ hooks.isTickPaused = () => isTickPaused();
1061
+ }
1062
+ if (ctx.reset) {
1063
+ const reset = ctx.reset;
1064
+ hooks.reset = (opts) => reset(opts);
1065
+ }
1066
+ if (ctx.subscribeEvents) {
1067
+ const subscribeEvents = ctx.subscribeEvents;
1068
+ hooks.subscribeEvents = (cb) => subscribeEvents(cb);
1069
+ }
1070
+ return hooks;
1071
+ }
1072
+ function attachDevHooks(ctx) {
1073
+ window.__uzu_dev = createDevHooks(ctx);
1074
+ }
1075
+ //#endregion
1076
+ //#region src/run/optimistic-action-client.ts
1077
+ function createOptimisticActionClient(config) {
1078
+ const { logic, playerId, onState, events, sendAction } = config;
1079
+ /** サーバー確定 state (楽観的更新のベース) */
1080
+ let confirmedState = null;
1081
+ /** 表示用 state (pending actions 適用済み) */
1082
+ let displayState = null;
1083
+ /** クライアント側の action 通番 */
1084
+ let actionSeq = 0;
1085
+ /**
1086
+ * 送信済みだがサーバー未確認の action キュー。
1087
+ * `time` は送信時に推定した値。再適用でも同じ値を使う (取り直すと表示がガタつく)。
1088
+ */
1089
+ const pendingActions = [];
1090
+ /**
1091
+ * 先読みで実行済みの events。
1092
+ *
1093
+ * IMPORTANT: pending action ごとではなくクライアント単位で持つ。二重実行は「自分の
1094
+ * action の ack」だけでなく「他プレイヤーの action のブロードキャスト」でも起きるため
1095
+ * (A と B が同時に同じ行送りを撃つと、B は自分の先読みで実行済みなのに A 由来の
1096
+ * 配信でもう一度実行してしまう)。どの配信が来ても、まずここと突き合わせる。
1097
+ *
1098
+ * 記録には「どの action の先読みで実行したか」(seq) を持たせる。その action が ack
1099
+ * された時点で引き当てられていない記録は「予測したが実際には起きなかった出来事」なので
1100
+ * 捨てる。seq を持たせずに「pending が空になったら捨てる」だけにすると、別の action が
1101
+ * 未確定な間ずっと外れた記録が生き残り、その後に本当に起きた同名イベントを 1 回
1102
+ * 握り潰してしまう。
1103
+ *
1104
+ * サーバーは 1 本の接続へ処理順どおりに送るので、他プレイヤーの配信は自分の ack より
1105
+ * 必ず先に届く = 引き当てのチャンスは記録が生きている間に必ず来る。
1106
+ */
1107
+ let firedPredictions = [];
1108
+ /** 再適用時はイベントを発火しない (送信時に既に発火済み) */
1109
+ const noopEmit = () => {};
1110
+ const dispatchEvents = (evts) => {
1111
+ for (const e of evts) events?.[e.name]?.handler(e.data);
1112
+ };
1113
+ /** events の同一性キー。name と data が一致すれば「同じ出来事」とみなす。 */
1114
+ const eventKey = (e) => `${e.name}\u0000${JSON.stringify(e.data ?? {})}`;
1115
+ /**
1116
+ * サーバーの events から、先読みで実行済みのものを差し引く (多重集合の差)。
1117
+ *
1118
+ * - 予測が当たった → 差が空。二重に実行しない
1119
+ * - 予測が外れた → サーバー側の正しい event が残り、確定として実行される
1120
+ * - 先読みで実行していない (predict: false / serverActions 由来 / 他プレイヤー由来)
1121
+ * → そのまま残って実行される
1122
+ */
1123
+ const subtractFired = (serverEvts) => {
1124
+ if (firedPredictions.length === 0) return serverEvts;
1125
+ const out = [];
1126
+ for (const e of serverEvts) {
1127
+ const k = eventKey(e);
1128
+ const idx = firedPredictions.findIndex((f) => eventKey(f) === k);
1129
+ if (idx >= 0) firedPredictions.splice(idx, 1);
1130
+ else out.push(e);
1131
+ }
1132
+ return out;
1133
+ };
1134
+ /**
1135
+ * confirmedState をベースに pending actions を再適用して displayState を更新する。
1136
+ * 再適用失敗した action はキューから除去する。
1137
+ */
1138
+ const reapplyPendingActions = () => {
1139
+ if (confirmedState === null) return;
1140
+ displayState = structuredClone(confirmedState);
1141
+ let i = 0;
1142
+ while (i < pendingActions.length) {
1143
+ const { action, payload, time } = pendingActions[i];
1144
+ const handler = logic.actions[action];
1145
+ if (!handler || isServerOnlyAction(handler)) {
1146
+ pendingActions.splice(i, 1);
1147
+ continue;
1148
+ }
1149
+ const base = structuredClone(displayState);
1150
+ try {
1151
+ runPredicted(action, () => handler({
1152
+ state: displayState,
1153
+ payload,
1154
+ playerId,
1155
+ ctx: {
1156
+ time,
1157
+ after: (d) => plus(time, d),
1158
+ emit: noopEmit
1159
+ }
1160
+ }));
1161
+ i++;
1162
+ } catch {
1163
+ displayState = base;
1164
+ pendingActions.splice(i, 1);
1165
+ }
1166
+ }
1167
+ onState(displayState, playerId);
1168
+ };
1169
+ /**
1170
+ * サーバーからの配信を処理する。
1171
+ *
1172
+ * 送信元が誰であれ、まず先読みの実行記録と突き合わせる。自分の ack だけを見ていると、
1173
+ * 他プレイヤーの action が同じ出来事を起こしたときに二重実行になる。
1174
+ */
1175
+ const handleAck = (ack, from, evts) => {
1176
+ dispatchEvents(subtractFired(evts));
1177
+ if (from === playerId && ack !== void 0) {
1178
+ while (pendingActions.length > 0 && pendingActions[0].seq <= ack) pendingActions.shift();
1179
+ firedPredictions = firedPredictions.filter((f) => f.seq > ack);
1180
+ }
1181
+ };
1182
+ return {
1183
+ send(type, payload) {
1184
+ if (isPaused()) {
1185
+ console.warn(`[uzu] 緊急停止中は action を送れません (action="${type}")`);
1186
+ return;
1187
+ }
1188
+ actionSeq++;
1189
+ const seq = actionSeq;
1190
+ const handler = logic.actions[type];
1191
+ if (!handler && !logic.serverActions?.[type]) console.error(`[uzu] unknown action: "${type}". logic.actions / logic.serverActions のどちらにも登録されていません。`);
1192
+ const target = displayState;
1193
+ const time = gameTime();
1194
+ const predictEmit = (eventName, data) => {
1195
+ const subscription = events?.[eventName];
1196
+ if (!subscription?.predict) return;
1197
+ const payloadData = data ?? {};
1198
+ subscription.handler(payloadData);
1199
+ firedPredictions.push({
1200
+ seq,
1201
+ name: eventName,
1202
+ data: payloadData
1203
+ });
1204
+ };
1205
+ if (handler && !isServerOnlyAction(handler) && target !== null) try {
1206
+ runPredicted(type, () => handler({
1207
+ state: target,
1208
+ payload: payload ?? {},
1209
+ playerId,
1210
+ ctx: {
1211
+ time,
1212
+ after: (d) => plus(time, d),
1213
+ emit: predictEmit
1214
+ }
1215
+ }));
1216
+ pendingActions.push({
1217
+ seq,
1218
+ action: type,
1219
+ payload: payload ?? {},
1220
+ time
1221
+ });
1222
+ onState(target, playerId);
1223
+ } catch {}
1224
+ sendAction({
1225
+ action: type,
1226
+ payload: payload ?? {},
1227
+ seq
1228
+ });
1229
+ },
1230
+ observeGameTime(t) {
1231
+ observeGameTime(t);
1232
+ },
1233
+ applyState(state, options = {}) {
1234
+ handleAck(options.ack, options.from, options.events ?? []);
1235
+ confirmedState = state;
1236
+ reapplyPendingActions();
1237
+ },
1238
+ applyDelta(patches, options = {}) {
1239
+ if (confirmedState === null) return false;
1240
+ const cloned = structuredClone(confirmedState);
1241
+ if (!applyPatch(cloned, patches)) return false;
1242
+ confirmedState = cloned;
1243
+ handleAck(options.ack, options.from, options.events ?? []);
1244
+ reapplyPendingActions();
1245
+ return true;
1246
+ },
1247
+ rollback(seq) {
1248
+ const idx = pendingActions.findIndex((p) => p.seq === seq);
1249
+ if (idx !== -1) {
1250
+ pendingActions.splice(idx, 1);
1251
+ firedPredictions = firedPredictions.filter((f) => f.seq !== seq);
1252
+ reapplyPendingActions();
1253
+ }
1254
+ },
1255
+ reset(state) {
1256
+ pendingActions.length = 0;
1257
+ firedPredictions = [];
1258
+ confirmedState = state;
1259
+ displayState = structuredClone(state);
1260
+ onState(displayState, playerId);
1261
+ }
1262
+ };
1263
+ }
1264
+ //#endregion
1265
+ //#region src/run/server-action.ts
1266
+ function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, seatKind) {
1267
+ const toWire = (p) => ({
1268
+ id: p.id,
1269
+ name: p.nickname,
1270
+ iconUrl: p.iconUrl,
1271
+ characterId: p.characterId,
1272
+ kind: "player"
1273
+ });
1274
+ const roster = JSON.stringify(players.map(toWire));
1275
+ const wsUrl = `${gameEndpoint}/${roomId}?${new URLSearchParams({
1276
+ seatId,
1277
+ nickname: "Player",
1278
+ players: roster,
1279
+ seats: roster
1280
+ })}`;
1281
+ console.log(`[SDK ServerAction] 🔗 Connecting wsUrl=${wsUrl}`);
1282
+ const ws = new ReconnectableWebSocket(wsUrl, {
1283
+ onConnectionStateChange: (state) => {
1284
+ console.log(`[SDK ServerAction] 📡 Connection state: ${state}`);
1285
+ config.onConnectionStateChange?.(state);
1286
+ },
1287
+ shouldBuffer: (data) => {
1288
+ try {
1289
+ const parsed = JSON.parse(data);
1290
+ return parsed.type !== "__state" && parsed.type !== "__tick" && parsed.type !== "__action_result" && parsed.type !== "__game_start" && parsed.type !== "__tick_delta" && parsed.type !== "__action_result_delta";
1291
+ } catch {
1292
+ return true;
1293
+ }
1294
+ }
1295
+ });
1296
+ setPauseRequester((paused) => {
1297
+ console.log(`[SDK ServerAction] ➡ send ${paused ? "__pause" : "__resume"}`);
1298
+ ws.send(JSON.stringify({ type: paused ? "__pause" : "__resume" }));
1299
+ });
1300
+ /** サーバー seq (delta の連続性チェック用) */
1301
+ let serverSeq = 0;
1302
+ /** フル state 再要求中フラグ (多重要求防止) */
1303
+ let requestStatePending = false;
1304
+ /** 楽観更新クライアント (transport は本ファイル内で WebSocket に bind) */
1305
+ const client = createOptimisticActionClient({
1306
+ logic: config.logic,
1307
+ playerId: seatId,
1308
+ onState: (state, playerId) => config.onState(state, playerId, seatKind),
1309
+ events: config.events,
1310
+ sendAction: ({ action, payload, seq }) => {
1311
+ console.log(`[SDK ServerAction] ➡ send __action action=${action} seq=${seq}`);
1312
+ ws.send(JSON.stringify({
1313
+ type: "__action",
1314
+ action,
1315
+ payload,
1316
+ seq
1317
+ }));
1318
+ }
1319
+ });
1320
+ /** サーバーにフル state の再送を要求する */
1321
+ const requestFullState = () => {
1322
+ if (requestStatePending) return;
1323
+ requestStatePending = true;
1324
+ console.log(`[SDK ServerAction] 🔄 Requesting full state (seq gap or patch failure)`);
1325
+ ws.send(JSON.stringify({ type: "__request_state" }));
1326
+ };
1327
+ config.inputs((type, payload) => {
1328
+ client.send(type, payload);
1329
+ });
1330
+ ws.addEventListener("message", (ev) => {
1331
+ let parsed;
1332
+ try {
1333
+ parsed = JSON.parse(ev.data);
1334
+ } catch {
1335
+ return;
1336
+ }
1337
+ const msgType = parsed.type;
1338
+ console.log(`[SDK ServerAction] ⬅ recv type=${msgType}`);
1339
+ client.observeGameTime(parsed.gameTime);
1340
+ if (msgType === "__pause_state") {
1341
+ const frozen = parsed.frozen === true;
1342
+ console.log(`[SDK ServerAction] ${frozen ? "⏸" : "▶️"} pause_state frozen=${frozen}`);
1343
+ setPauseState(frozen, parsed.by ?? null);
1344
+ return;
1345
+ }
1346
+ if (msgType === "__room_init") {
1347
+ console.log(`[SDK ServerAction] ✅ Room init myId=${parsed.myId}`);
1348
+ return;
1349
+ }
1350
+ if (msgType === "__game_start") {
1351
+ serverSeq = parsed.seq ?? 0;
1352
+ requestStatePending = false;
1353
+ client.reset(parsed.state);
1354
+ console.log(`[SDK ServerAction] 🎮 Game started (pending cleared)`);
1355
+ return;
1356
+ }
1357
+ if (msgType === "__tick") {
1358
+ const evts = parsed.events ?? [];
1359
+ serverSeq = parsed.seq ?? serverSeq + 1;
1360
+ requestStatePending = false;
1361
+ client.applyState(parsed.state, { events: evts });
1362
+ return;
1363
+ }
1364
+ if (msgType === "__tick_delta") {
1365
+ const evts = parsed.events ?? [];
1366
+ const newSeq = parsed.seq ?? serverSeq + 1;
1367
+ if (newSeq !== serverSeq + 1) {
1368
+ requestFullState();
1369
+ return;
1370
+ }
1371
+ if (!client.applyDelta(parsed.patches ?? [], { events: evts })) {
1372
+ requestFullState();
1373
+ return;
1374
+ }
1375
+ serverSeq = newSeq;
1376
+ requestStatePending = false;
1377
+ return;
1378
+ }
1379
+ if (msgType === "__action_result") {
1380
+ const ack = parsed.ack;
1381
+ const from = parsed.from;
1382
+ const evts = parsed.events ?? [];
1383
+ serverSeq = parsed.seq ?? serverSeq + 1;
1384
+ requestStatePending = false;
1385
+ client.applyState(parsed.state, {
1386
+ ack,
1387
+ from,
1388
+ events: evts
1389
+ });
1390
+ return;
1391
+ }
1392
+ if (msgType === "__action_result_delta") {
1393
+ const ack = parsed.ack;
1394
+ const from = parsed.from;
1395
+ const evts = parsed.events ?? [];
1396
+ const newSeq = parsed.seq ?? serverSeq + 1;
1397
+ if (newSeq !== serverSeq + 1) {
1398
+ requestFullState();
1399
+ return;
1400
+ }
1401
+ if (!client.applyDelta(parsed.patches ?? [], {
1402
+ ack,
1403
+ from,
1404
+ events: evts
1405
+ })) {
1406
+ requestFullState();
1407
+ return;
1408
+ }
1409
+ serverSeq = newSeq;
1410
+ requestStatePending = false;
1411
+ return;
1412
+ }
1413
+ if (msgType === "__action_error") {
1414
+ const errorSeq = parsed.seq;
1415
+ console.warn(`[SDK ServerAction] ⚠️ Action error: ${parsed.error} (seq=${errorSeq})`);
1416
+ if (errorSeq !== void 0) client.rollback(errorSeq);
1417
+ return;
1418
+ }
1419
+ if (msgType === "__state") {
1420
+ serverSeq = parsed.seq ?? 0;
1421
+ requestStatePending = false;
1422
+ console.log(`[SDK ServerAction] 🔄 State restored (reconnect/late join) seq=${serverSeq}`);
1423
+ setPauseState(parsed.frozen === true, parsed.pausedBy ?? null);
1424
+ client.reset(parsed.state);
1425
+ return;
1426
+ }
1427
+ });
1428
+ }
1429
+ //#endregion
1430
+ //#region src/run/local-server-action.ts
1431
+ function runLocalServerAction(config) {
1432
+ const { logic, inputs, events } = config;
1433
+ const onState = (next, id) => {
1434
+ observeGameTime(gameTime());
1435
+ config.onState(next, id, "player");
1436
+ };
1437
+ const tickRate = logic.tickRate ?? 0;
1438
+ const clock = new GameClock();
1439
+ const gameTime = () => clock.now();
1440
+ const frozen = () => clock.frozen;
1441
+ /** ハンドラへ渡す ctx の時刻部分。1 回の呼び出し内で値が動かないよう束ねる。 */
1442
+ const timeCtx = (t) => ({
1443
+ time: t,
1444
+ after: (d) => plus(t, d)
1445
+ });
1446
+ /** at() が壊れた値を返したと既に記録した締切。ログを 1 回に絞るため。 */
1447
+ const warnedDeadlines = /* @__PURE__ */ new Set();
1448
+ const random = new SeededRandomImpl(Math.floor(Math.random() * 4294967295));
1449
+ const players = Array.from({ length: config.playerCount }, (_, i) => ({
1450
+ id: `local_${i}`,
1451
+ nickname: `Player ${i + 1}`,
1452
+ iconUrl: DEFAULT_ICON_URLS[i % DEFAULT_ICON_URLS.length]
1453
+ }));
1454
+ const myId = players[0].id;
1455
+ let wakeupTimer = null;
1456
+ let wakeupAt = null;
1457
+ const nextDeadline = () => {
1458
+ if (!logic.deadlines) return null;
1459
+ let earliest = null;
1460
+ for (const [key, deadline] of Object.entries(logic.deadlines)) {
1461
+ let raw;
1462
+ try {
1463
+ raw = deadline.at({ state });
1464
+ } catch (err) {
1465
+ console.error(`[Deadline] ❌ ${key}.at() で例外`, err);
1466
+ continue;
1467
+ }
1468
+ const at = resolveDeadline(raw, key, warnedDeadlines, (m) => console.error(`[Deadline] ❌ ${m}`));
1469
+ if (at === null) continue;
1470
+ if (earliest === null || at < earliest) earliest = at;
1471
+ }
1472
+ return earliest;
1473
+ };
1474
+ const fireDue = () => {
1475
+ wakeupTimer = null;
1476
+ wakeupAt = null;
1477
+ if (!logic.deadlines || frozen()) return;
1478
+ const time = gameTime();
1479
+ const evts = [];
1480
+ const firedKeys = [];
1481
+ for (const [key, deadline] of Object.entries(logic.deadlines)) {
1482
+ const pending = [];
1483
+ let snapshot = null;
1484
+ try {
1485
+ const at = resolveDeadline(deadline.at({ state }), key, warnedDeadlines, (m) => console.error(`[Deadline] ❌ ${m}`));
1486
+ if (at === null || at > time) continue;
1487
+ snapshot = structuredClone(state);
1488
+ withGameClock(time, () => deadline.handler({
1489
+ state,
1490
+ ctx: {
1491
+ ...timeCtx(time),
1492
+ random,
1493
+ emit: (name, data) => pending.push({
1494
+ name,
1495
+ data: data ?? {}
1496
+ })
1497
+ }
1498
+ }));
1499
+ evts.push(...pending);
1500
+ firedKeys.push(key);
1501
+ } catch (err) {
1502
+ if (snapshot !== null) state = snapshot;
1503
+ console.error(`[Deadline] ❌ ${key} で例外`, err);
1504
+ }
1505
+ }
1506
+ syncWakeup();
1507
+ if (firedKeys.length === 0) return;
1508
+ console.log(`[Deadline] ⏰ ${firedKeys.length} 件発火: ${firedKeys.join(", ")}`);
1509
+ dispatchEvents(evts);
1510
+ onState(state, myId);
1511
+ };
1512
+ const syncWakeup = () => {
1513
+ const next = frozen() ? null : nextDeadline();
1514
+ if (next === wakeupAt) return;
1515
+ if (wakeupTimer) clearTimeout(wakeupTimer);
1516
+ wakeupTimer = null;
1517
+ wakeupAt = next;
1518
+ if (next === null) return;
1519
+ wakeupTimer = setTimeout(fireDue, Math.max(0, clock.toWall(next) - Date.now()));
1520
+ };
1521
+ const dispatchEvents = (evts) => {
1522
+ for (const e of evts) events?.[e.name]?.handler(e.data);
1523
+ };
1524
+ clock.restart();
1525
+ const setupTime = gameTime();
1526
+ const setupArgs = {
1527
+ players,
1528
+ seats: players,
1529
+ ctx: {
1530
+ random,
1531
+ ...timeCtx(setupTime)
1532
+ }
1533
+ };
1534
+ let state = withGameClock(setupTime, () => logic.setup(setupArgs));
1535
+ let tick = 0;
1536
+ const playerInputs = {};
1537
+ const dispatchAction = (type, payload) => {
1538
+ if (frozen()) {
1539
+ console.warn(`[SDK LocalServerAction] 緊急停止中は action を実行しません (action="${type}")`);
1540
+ return;
1541
+ }
1542
+ const plain = logic.actions[type];
1543
+ const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
1544
+ const server = logic.serverActions?.[type] ?? legacyServerOnly;
1545
+ if (!plain && !server) return;
1546
+ const plainEvents = [];
1547
+ const serverEvents = [];
1548
+ const plainEmit = (name, data) => plainEvents.push({
1549
+ name,
1550
+ data: data ?? {}
1551
+ });
1552
+ const serverEmit = (name, data) => serverEvents.push({
1553
+ name,
1554
+ data: data ?? {}
1555
+ });
1556
+ const time = gameTime();
1557
+ if (plain && !legacyServerOnly) {
1558
+ try {
1559
+ withGameClock(time, () => plain({
1560
+ state,
1561
+ payload: payload ?? {},
1562
+ playerId: myId,
1563
+ ctx: {
1564
+ ...timeCtx(time),
1565
+ emit: plainEmit
1566
+ }
1567
+ }));
1568
+ } catch (err) {
1569
+ console.warn("[SDK LocalServerAction] Action error:", err);
1570
+ return;
1571
+ }
1572
+ dispatchEvents(plainEvents);
1573
+ syncWakeup();
1574
+ onState(state, myId);
1575
+ }
1576
+ if (!server) return;
1577
+ (async () => {
1578
+ try {
1579
+ await server({
1580
+ state,
1581
+ payload: payload ?? {},
1582
+ playerId: myId,
1583
+ ctx: {
1584
+ tick,
1585
+ random,
1586
+ ...timeCtx(time),
1587
+ emit: serverEmit
1588
+ }
1589
+ });
1590
+ } catch (err) {
1591
+ console.warn("[SDK LocalServerAction] Action error:", err);
1592
+ return;
1593
+ }
1594
+ dispatchEvents(serverEvents);
1595
+ syncWakeup();
1596
+ onState(state, myId);
1597
+ })();
1598
+ };
1599
+ /**
1600
+ * ソロの停止 / 解除。サーバーが居ないので自分の時計を直接触る。
1601
+ *
1602
+ * `__uzu_dev.pauseTick()` とは別物。あちらは tick を止めるだけの開発者ツールで、
1603
+ * ゲーム内時計も action ゲートも持たない。流用しない。
1604
+ */
1605
+ const applyPause = (paused) => {
1606
+ if (!(paused ? clock.freeze("emergency-stop") : clock.unfreeze("emergency-stop"))) return;
1607
+ observeGameTime(gameTime());
1608
+ setPauseState(paused, paused ? myId : null);
1609
+ observeGameTime(gameTime());
1610
+ syncWakeup();
1611
+ };
1612
+ setPauseRequester(applyPause);
1613
+ inputs(dispatchAction);
1614
+ syncWakeup();
1615
+ onState(state, myId);
1616
+ if (tickRate > 0) setInterval(() => {
1617
+ if (frozen()) return;
1618
+ const tickEvents = [];
1619
+ const tickEmit = (name, data) => tickEvents.push({
1620
+ name,
1621
+ data: data ?? {}
1622
+ });
1623
+ const time = gameTime();
1624
+ try {
1625
+ withGameClock(time, () => logic.update({
1626
+ state,
1627
+ ctx: {
1628
+ random,
1629
+ tick,
1630
+ ...timeCtx(time),
1631
+ emit: tickEmit,
1632
+ playerInputs
1633
+ }
1634
+ }));
1635
+ } catch (err) {
1636
+ console.error(`[SDK LocalServerAction] tick error at tick=${tick}:`, err);
1637
+ tick++;
1638
+ return;
1639
+ }
1640
+ tick++;
1641
+ dispatchEvents(tickEvents);
1642
+ syncWakeup();
1643
+ onState(state, myId);
1644
+ }, 1e3 / tickRate);
1645
+ return {
1646
+ getRawState: () => state,
1647
+ setRawState: async (next) => {
1648
+ state = next;
1649
+ syncWakeup();
1650
+ onState(state, myId);
1651
+ },
1652
+ mergeRawState: async (patch) => {
1653
+ applyJsonMergePatch(state, patch);
1654
+ onState(state, myId);
1655
+ },
1656
+ patchRawState: async (ops) => {
1657
+ applyJsonPatch(state, ops);
1658
+ onState(state, myId);
1659
+ },
1660
+ sendAction: dispatchAction
1661
+ };
1662
+ }
1663
+ //#endregion
1664
+ //#region src/sync/local.ts
1665
+ function resolveLocalTime(ops) {
1666
+ const now = Date.now();
1667
+ return ops.map((op) => op.value === "__SERVER_TIME__" ? {
1668
+ ...op,
1669
+ value: now
1670
+ } : op);
1671
+ }
1672
+ function syncLocal(config) {
1673
+ const { initialState, onState, inputs, events: _events } = config;
1674
+ const localCount = config.playerCount;
1675
+ const players = [];
1676
+ for (let i = 0; i < localCount; i++) players.push({
1677
+ id: `local_${i}`,
1678
+ nickname: `Player ${i + 1}`,
1679
+ iconUrl: DEFAULT_ICON_URLS[i % DEFAULT_ICON_URLS.length]
1680
+ });
1681
+ let state = initialState(players);
1682
+ const myId = players[0].id;
1683
+ const patchFn = (ops) => {
1684
+ applyPatch(state, resolveLocalTime(ops));
1685
+ onState(state, myId, Date.now());
1686
+ };
1687
+ const setFn = (path, value) => {
1688
+ patchFn([{
1689
+ op: "replace",
1690
+ path,
1691
+ value
1692
+ }]);
1693
+ };
1694
+ inputs(patchFn, setFn);
1695
+ onState(state, myId, Date.now());
1696
+ setInterval(() => {
1697
+ onState(state, myId, Date.now());
1698
+ }, 100);
1699
+ return {
1700
+ getRawState: () => state,
1701
+ setRawState: async (next) => {
1702
+ state = next;
1703
+ onState(state, myId, Date.now());
1704
+ },
1705
+ mergeRawState: async (patch) => {
1706
+ applyJsonMergePatch(state, patch);
1707
+ onState(state, myId, Date.now());
1708
+ },
1709
+ patchRawState: async (ops) => {
1710
+ applyJsonPatch(state, ops);
1711
+ onState(state, myId, Date.now());
1712
+ }
1713
+ };
1714
+ }
1715
+ //#endregion
1716
+ //#region src/sync/online.ts
1717
+ function syncOnline(config, syncEndpoint, roomId, playerId, hostPlayers) {
1718
+ const { initialState, onState, inputs, events: _events } = config;
1719
+ const wsUrl = `${syncEndpoint}/${roomId}?playerId=${encodeURIComponent(playerId)}`;
1720
+ console.log(`[SDK Sync] 🔗 Connecting wsUrl=${wsUrl}`);
1721
+ const ws = new ReconnectableWebSocket(wsUrl, {
1722
+ onConnectionStateChange: (state) => {
1723
+ console.log(`[SDK Sync] 📡 Connection state: ${state}`);
1724
+ config.onConnectionStateChange?.(state);
1725
+ },
1726
+ shouldBuffer: (data) => {
1727
+ try {
1728
+ const parsed = JSON.parse(data);
1729
+ return parsed.type !== "__patch" && parsed.type !== "__patch_ack";
1730
+ } catch {
1731
+ return true;
1732
+ }
1733
+ }
1734
+ });
1735
+ const myId = playerId;
1736
+ const players = hostPlayers;
1737
+ let state = null;
1738
+ let gameInitSent = false;
1739
+ let serverTimeOffset = 0;
1740
+ let localSeq = 0;
1741
+ let requestStatePending = false;
1742
+ const estimateServerTime = () => Date.now() + serverTimeOffset;
1743
+ const resolveToServerTime = (ops) => {
1744
+ const now = estimateServerTime();
1745
+ return ops.map((op) => op.value === "__SERVER_TIME__" ? {
1746
+ ...op,
1747
+ value: now
1748
+ } : op);
1749
+ };
1750
+ const sendPatch = (ops) => {
1751
+ if (!ops || ops.length === 0) return;
1752
+ if (state) {
1753
+ applyPatch(state, resolveToServerTime(ops));
1754
+ onState(state, myId, estimateServerTime());
1755
+ }
1756
+ console.log(`[SDK Sync] ➡ send __patch ops=${ops.length}`, JSON.stringify(ops));
1757
+ ws.send(JSON.stringify({
1758
+ type: "__patch",
1759
+ ops
1760
+ }));
1761
+ };
1762
+ const sendSet = (path, value) => {
1763
+ sendPatch([{
1764
+ op: "replace",
1765
+ path,
1766
+ value
1767
+ }]);
1768
+ };
1769
+ inputs(sendPatch, sendSet);
1770
+ setInterval(() => {
1771
+ if (state) onState(state, myId, estimateServerTime());
1772
+ }, 100);
1773
+ const tryInitState = () => {
1774
+ if (gameInitSent) return;
1775
+ gameInitSent = true;
1776
+ const initState = initialState(players);
1777
+ console.log(`[SDK Sync] ➡ send __init_state players=${players.length}`);
1778
+ ws.send(JSON.stringify({
1779
+ type: "__init_state",
1780
+ state: initState
1781
+ }));
1782
+ };
1783
+ ws.addEventListener("message", (ev) => {
1784
+ let parsed;
1785
+ try {
1786
+ parsed = JSON.parse(ev.data);
1787
+ } catch {
1788
+ return;
1789
+ }
1790
+ const msgType = parsed.type;
1791
+ console.log(`[SDK Sync] ⬅ recv type=${msgType}`, JSON.stringify(parsed));
1792
+ if (msgType === "__room_init") {
1793
+ console.log(`[SDK Sync] ✅ Room init myId=${myId} players=${players.length}`);
1794
+ tryInitState();
1795
+ return;
1796
+ }
1797
+ if (msgType === "__reconnected") {
1798
+ console.log(`[SDK Sync] 🔄 Reconnected myId=${myId}`);
1799
+ if (!gameInitSent) tryInitState();
1800
+ return;
1801
+ }
1802
+ if (msgType === "__state_cleared") {
1803
+ console.log(`[SDK Sync] 🗑 State cleared, reinitializing`);
1804
+ state = null;
1805
+ localSeq = 0;
1806
+ requestStatePending = false;
1807
+ gameInitSent = false;
1808
+ tryInitState();
1809
+ return;
1810
+ }
1811
+ if (msgType === "__patch_failed") {
1812
+ console.log(`[SDK Sync] ⚠️ Patch failed: ${parsed.reason}`);
1813
+ config.onPatchFailed?.(parsed.reason);
1814
+ return;
1815
+ }
1816
+ if (msgType === "__patch_ack") {
1817
+ if (!state) return;
1818
+ const ackSeq = parsed.seq ?? 0;
1819
+ const ackSenderId = parsed.senderId;
1820
+ const ackOps = parsed.ops;
1821
+ const serverTime = parsed.serverTime;
1822
+ if (ackSenderId === myId) {
1823
+ localSeq = ackSeq;
1824
+ serverTimeOffset = serverTime - Date.now();
1825
+ return;
1826
+ }
1827
+ if (requestStatePending) return;
1828
+ if (ackSeq === localSeq + 1) {
1829
+ if (!applyPatch(state, ackOps)) {
1830
+ console.log(`[SDK Sync] ⚠️ Local applyPatch failed, requesting full state`);
1831
+ requestStatePending = true;
1832
+ ws.send(JSON.stringify({ type: "__request_state" }));
1833
+ return;
1834
+ }
1835
+ localSeq = ackSeq;
1836
+ serverTimeOffset = serverTime - Date.now();
1837
+ onState(state, myId, serverTime);
1838
+ } else if (ackSeq > localSeq + 1) {
1839
+ console.log(`[SDK Sync] ⚠️ Seq gap detected: expected=${localSeq + 1} received=${ackSeq}, requesting full state`);
1840
+ requestStatePending = true;
1841
+ ws.send(JSON.stringify({ type: "__request_state" }));
1842
+ }
1843
+ return;
1844
+ }
1845
+ if (msgType === "__state") {
1846
+ state = parsed.state;
1847
+ const serverTime = parsed.serverTime;
1848
+ localSeq = parsed.seq ?? 0;
1849
+ requestStatePending = false;
1850
+ serverTimeOffset = serverTime - Date.now();
1851
+ console.log(`[SDK Sync] ✅ State received serverTime=${serverTime} offset=${serverTimeOffset} seq=${localSeq}`);
1852
+ ws.clearBuffer();
1853
+ onState(state, myId, serverTime);
1854
+ return;
1855
+ }
1856
+ });
1857
+ return { ws };
1858
+ }
1859
+ //#endregion
1860
+ //#region src/index.ts
1861
+ const gameHandlers = /* @__PURE__ */ new Map();
18
1862
  const _playersChangedHandlers = [];
19
1863
  let _room = null;
20
1864
  let _lastStateSnap = null;
@@ -26,443 +1870,361 @@ let _syncWs = null;
26
1870
  let _initialized = false;
27
1871
  let _usesRun = false;
28
1872
  let _playerId = null;
29
- // ─── Dev hooks state (window.__uzu_dev 経由の外部 automation 用) ──────
30
- // Flutter native ホスト (= window.FlutterHost あり) では一切 attach されない。
31
- // 注意: `isHosted` は dev harness 子 iframe (parent !== window) も含むため、
32
- // dev hooks の本番判定は `isHosted` ではなく `window.FlutterHost` の有無で行う。
33
- // これにより Flutter native では非公開、 dev harness 子 iframe / Playwright iframe /
34
- // 単独 page では attach、 という挙動を両立させる。
35
1873
  let _runHandle = null;
36
1874
  let _syncHandle = null;
37
- const _snapshotListeners = new Set();
1875
+ const _snapshotListeners = /* @__PURE__ */ new Set();
38
1876
  function notifyDevSnapshot(snap) {
39
- _snapshotListeners.forEach((cb) => {
40
- try {
41
- cb(snap);
42
- }
43
- catch (err) {
44
- console.warn('[uzu_dev] snapshot listener threw:', err);
45
- }
46
- });
1877
+ _snapshotListeners.forEach((cb) => {
1878
+ try {
1879
+ cb(snap);
1880
+ } catch (err) {
1881
+ console.warn("[uzu_dev] snapshot listener threw:", err);
1882
+ }
1883
+ });
47
1884
  }
48
1885
  function attachDevHooksIfNotHosted() {
49
- // Flutter native ホスト (本番) では attach しない。
50
- if (window.FlutterHost)
51
- return;
52
- const ctx = {
53
- getSnapshot: () => _lastStateSnap?.state ?? null,
54
- getRawState: _runHandle?.getRawState ?? _syncHandle?.getRawState,
55
- setRawState: _runHandle?.setRawState ?? _syncHandle?.setRawState,
56
- mergeRawState: _runHandle?.mergeRawState ?? _syncHandle?.mergeRawState,
57
- patchRawState: _runHandle?.patchRawState ?? _syncHandle?.patchRawState,
58
- playerId: () => _lastStateSnap?.myId ?? _playerId,
59
- subscribeSnapshot: (cb) => {
60
- _snapshotListeners.add(cb);
61
- return () => {
62
- _snapshotListeners.delete(cb);
63
- };
64
- },
65
- };
66
- attachDevHooks(ctx);
67
- }
68
- // ─── Public API ─────────────────────────────────────────────
69
- // Node (serverActionLogic / vitest) からこの module を import しても落ちないよう
70
- // guard する。 トップレベルで window を読むと、 型やヘルパーを取りたいだけの
71
- // import でその場で ReferenceError になる。
72
- export const isHosted = typeof window !== 'undefined' && (!!window.FlutterHost || window.parent !== window);
73
- export function getRoom() {
74
- return _room;
75
- }
76
- export function onRoom(callback) {
77
- if (_room) {
78
- callback(_room);
79
- }
80
- else {
81
- _onRoomCallbacks.push(callback);
82
- }
83
- }
84
- export function init(opts) {
85
- const params = new URLSearchParams(window.location.search);
86
- // 送信元プレイヤー ID。 ホストが自分宛メッセージかを判定するために
87
- // ブリッジメッセージのエンベロープへ含める (sendRaw 参照)。
88
- // Flutter ホスト (QueryParamsBuilder) と dev ハーネスは必ず seatId を付与する。
89
- // bridge envelope の key 名は playerId のまま (postMessage bridge の既存プロトコル)。
90
- _playerId = params.get('seatId');
91
- if (isHosted && !_playerId) {
92
- console.warn('[UZU SDK] playerId is missing in the URL. ' +
93
- 'Host-side sender filtering will fall back to legacy behavior.');
94
- }
95
- // ?server=ws://host:port → エンドポイントのベースURLを外部注入
96
- const serverParam = params.get('server');
97
- if (serverParam && serverParam.length > 0) {
98
- const base = serverParam.replace(/\/$/, '');
99
- _wsEndpoint = `${base}/ws/rooms`;
100
- _syncEndpoint = `${base}/ws/sync`;
101
- // ServerAction: /ws/games/{revisionId}/{roomId} の形式で接続
102
- const revisionId = params.get('revisionId');
103
- if (!revisionId) {
104
- throw new Error('[UZU SDK] revisionId is required when server is specified. Pass ?revisionId=xxx in the URL.');
105
- }
106
- _gameEndpoint = `${base}/ws/games/${revisionId}`;
107
- }
108
- // init() の引数は query parameter より優先
109
- if (opts?.wsEndpoint)
110
- _wsEndpoint = opts.wsEndpoint;
111
- if (opts?.syncEndpoint)
112
- _syncEndpoint = opts.syncEndpoint;
113
- // 親 frame (= harness が立つ場所) では SDK runtime は何もしない。
114
- // iframe の生成は `uzu dev` CLI が配信する harness の責務に切り出している。
115
- if (!isHosted)
116
- return;
117
- if (!_initialized) {
118
- window.__ps = {
119
- onMessage: (msg) => {
120
- console.log(`[SDK] ⬅ recv __ps.onMessage channel=${msg.channel} type=${msg.type}`, JSON.stringify(msg));
121
- handleMessage(msg);
122
- },
123
- };
124
- // Web iframe モード: 親ウィンドウからの postMessage を受信
125
- window.addEventListener('message', (event) => {
126
- try {
127
- const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
128
- if (data && typeof data.channel === 'string' && typeof data.type === 'string') {
129
- console.log(`[SDK] ⬅ recv postMessage channel=${data.channel} type=${data.type}`, JSON.stringify(data));
130
- handleMessage(data);
131
- }
132
- }
133
- catch {
134
- // JSON でないメッセージは無視
135
- }
136
- });
137
- // HUD 回避領域の CSS カスタムプロパティを設定
138
- // クエリパラメータがあればその値を使用し、 なければ players 数から推定
139
- const hudX = params.get('uzuHudInsetX');
140
- const hudY = params.get('uzuHudInsetY');
141
- const { x: fallbackX, y: fallbackY } = calcHudInsets(params);
142
- document.documentElement.style.setProperty('--uzu-hud-inset-x', `${hudX ?? fallbackX}px`);
143
- document.documentElement.style.setProperty('--uzu-hud-inset-y', `${hudY ?? fallbackY}px`);
144
- sendRaw('sdk', 'ready', {});
145
- _initialized = true;
146
- }
147
- // Relay 自動接続 (wsEndpoint 未設定時はスキップ)
148
- if (!_usesRun) {
149
- const roomIdParam = params.get('roomId');
150
- if (roomIdParam) {
151
- if (_wsEndpoint) {
152
- connectRoom(roomIdParam);
153
- }
154
- else {
155
- console.warn('[SDK] roomId is present but server is not configured — skipping Relay connection');
156
- }
157
- }
158
- }
159
- // run() / sync() を経由しない init() 単独ケースでも dev hooks を attach する
160
- // (Relay モード等。 run/sync 経由なら呼び出し元で再 attach される)
161
- if (!_usesRun) {
162
- attachDevHooksIfNotHosted();
163
- }
1886
+ if (window.FlutterHost) return;
1887
+ attachDevHooks({
1888
+ getSnapshot: () => _lastStateSnap?.state ?? null,
1889
+ getRawState: _runHandle?.getRawState ?? _syncHandle?.getRawState,
1890
+ setRawState: _runHandle?.setRawState ?? _syncHandle?.setRawState,
1891
+ mergeRawState: _runHandle?.mergeRawState ?? _syncHandle?.mergeRawState,
1892
+ patchRawState: _runHandle?.patchRawState ?? _syncHandle?.patchRawState,
1893
+ playerId: () => _lastStateSnap?.myId ?? _playerId,
1894
+ subscribeSnapshot: (cb) => {
1895
+ _snapshotListeners.add(cb);
1896
+ return () => {
1897
+ _snapshotListeners.delete(cb);
1898
+ };
1899
+ }
1900
+ });
1901
+ }
1902
+ const isHosted = typeof window !== "undefined" && (!!window.FlutterHost || window.parent !== window);
1903
+ function getRoom() {
1904
+ return _room;
1905
+ }
1906
+ function onRoom(callback) {
1907
+ if (_room) callback(_room);
1908
+ else _onRoomCallbacks.push(callback);
1909
+ }
1910
+ function init(opts) {
1911
+ const params = new URLSearchParams(window.location.search);
1912
+ _playerId = params.get("seatId");
1913
+ if (isHosted && !_playerId) console.warn("[UZU SDK] playerId is missing in the URL. Host-side sender filtering will fall back to legacy behavior.");
1914
+ const serverParam = params.get("server");
1915
+ if (serverParam && serverParam.length > 0) {
1916
+ const base = serverParam.replace(/\/$/, "");
1917
+ _wsEndpoint = `${base}/ws/rooms`;
1918
+ _syncEndpoint = `${base}/ws/sync`;
1919
+ const revisionId = params.get("revisionId");
1920
+ if (!revisionId) throw new Error("[UZU SDK] revisionId is required when server is specified. Pass ?revisionId=xxx in the URL.");
1921
+ _gameEndpoint = `${base}/ws/games/${revisionId}`;
1922
+ }
1923
+ if (opts?.wsEndpoint) _wsEndpoint = opts.wsEndpoint;
1924
+ if (opts?.syncEndpoint) _syncEndpoint = opts.syncEndpoint;
1925
+ if (!isHosted) return;
1926
+ if (!_initialized) {
1927
+ window.__ps = { onMessage: (msg) => {
1928
+ console.log(`[SDK] recv __ps.onMessage channel=${msg.channel} type=${msg.type}`, JSON.stringify(msg));
1929
+ handleMessage(msg);
1930
+ } };
1931
+ window.addEventListener("message", (event) => {
1932
+ try {
1933
+ const data = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
1934
+ if (data && typeof data.channel === "string" && typeof data.type === "string") {
1935
+ console.log(`[SDK] recv postMessage channel=${data.channel} type=${data.type}`, JSON.stringify(data));
1936
+ handleMessage(data);
1937
+ }
1938
+ } catch {}
1939
+ });
1940
+ const hudX = params.get("uzuHudInsetX");
1941
+ const hudY = params.get("uzuHudInsetY");
1942
+ const { x: fallbackX, y: fallbackY } = calcHudInsets(params);
1943
+ document.documentElement.style.setProperty("--uzu-hud-inset-x", `${hudX ?? fallbackX}px`);
1944
+ document.documentElement.style.setProperty("--uzu-hud-inset-y", `${hudY ?? fallbackY}px`);
1945
+ onPauseChange((paused) => {
1946
+ sendRaw("sdk", "pauseState", {
1947
+ paused,
1948
+ by: getPausedBy()
1949
+ });
1950
+ });
1951
+ sendRaw("sdk", "ready", {});
1952
+ _initialized = true;
1953
+ }
1954
+ if (!_usesRun) {
1955
+ const roomIdParam = params.get("roomId");
1956
+ if (roomIdParam) {
1957
+ if (_wsEndpoint) connectRoom(roomIdParam);
1958
+ else console.warn("[SDK] roomId is present but server is not configured — skipping Relay connection");
1959
+ }
1960
+ }
1961
+ if (!_usesRun) attachDevHooksIfNotHosted();
164
1962
  }
165
1963
  /** game channel でカスタムメッセージを送信する。 native コマンドには使用不可。 */
166
- export function send(type, payload) {
167
- sendRaw('game', type, payload ?? {});
1964
+ function send(type, payload) {
1965
+ sendRaw("game", type, payload ?? {});
168
1966
  }
169
1967
  /** game channel のカスタムメッセージを受信する。 */
170
- export function on(type, handler) {
171
- if (!gameHandlers.has(type))
172
- gameHandlers.set(type, []);
173
- gameHandlers.get(type).push(handler);
1968
+ function on(type, handler) {
1969
+ if (!gameHandlers.has(type)) gameHandlers.set(type, []);
1970
+ gameHandlers.get(type).push(handler);
174
1971
  }
175
- // ─── Native コマンド (sdk channel) ──────────────────────
176
- export function playSound(sound) {
177
- sendRaw('sdk', 'playSound', { sound });
1972
+ function playSound(sound) {
1973
+ sendRaw("sdk", "playSound", { sound });
178
1974
  }
179
- export function playBgm(sound) {
180
- sendRaw('sdk', 'playBgm', { sound });
1975
+ function playBgm(sound) {
1976
+ sendRaw("sdk", "playBgm", { sound });
181
1977
  }
182
- export function stopBgm() {
183
- sendRaw('sdk', 'stopBgm', {});
1978
+ function stopBgm() {
1979
+ sendRaw("sdk", "stopBgm", {});
184
1980
  }
185
- export function setMicEnabled(enabled) {
186
- sendRaw('sdk', 'setMicEnabled', { enabled });
1981
+ function setMicEnabled(enabled) {
1982
+ sendRaw("sdk", "setMicEnabled", { enabled });
187
1983
  }
188
- export function changeRoom(roomId) {
189
- sendRaw('sdk', 'changeRoom', { roomId });
1984
+ function changeRoom(roomId) {
1985
+ sendRaw("sdk", "changeRoom", { roomId });
190
1986
  }
191
1987
  /**
192
- * splash 描画完了を Flutter ホストに通知する。
193
- *
194
- * YouTube Playables の `firstFrameReady()` 相当。 Flutter は受信時に splash overlay を
195
- * 非表示にし WebView を可視化する。 **呼ばないと splash が出っぱなしになる**。
196
- *
197
- * 呼び出しタイミング: scenario が最初の絵 (splash / loading 画面) を画面に描いた直後。
198
- * paint commit を保証するため `requestAnimationFrame` を 1 段挟むのが定石。
199
- *
200
- * @example
201
- * ```ts
202
- * init();
203
- * drawSplash();
204
- * requestAnimationFrame(() => firstFrameReady());
205
- * ```
206
- */
207
- export function firstFrameReady() {
208
- sendRaw('sdk', 'firstFrameReady', {});
1988
+ * splash 描画完了を Flutter ホストに通知する。
1989
+ *
1990
+ * YouTube Playables の `firstFrameReady()` 相当。 Flutter は受信時に splash overlay を
1991
+ * 非表示にし WebView を可視化する。 **呼ばないと splash が出っぱなしになる**。
1992
+ *
1993
+ * 呼び出しタイミング: scenario が最初の絵 (splash / loading 画面) を画面に描いた直後。
1994
+ * paint commit を保証するため `requestAnimationFrame` を 1 段挟むのが定石。
1995
+ *
1996
+ * @example
1997
+ * ```ts
1998
+ * init();
1999
+ * drawSplash();
2000
+ * requestAnimationFrame(() => firstFrameReady());
2001
+ * ```
2002
+ */
2003
+ function firstFrameReady() {
2004
+ sendRaw("sdk", "firstFrameReady", {});
209
2005
  }
210
2006
  /**
211
- * ユーザー操作受付可能を Flutter ホストに通知する。
212
- *
213
- * YouTube Playables の `gameReady()` 相当。 Flutter は受信時に入力受付を開始し、TTI
214
- * (Time To Interactive) 計測を終了する。 将来の広告タイマー / leaderboard / analytics
215
- * の発火点としても使われる予定。
216
- *
217
- * 呼び出しタイミング: asset load 完了、 ゲーム本体の setup 完了、 入力受付可能になった直後。
218
- *
219
- * @example
220
- * ```ts
221
- * await loadAssets();
222
- * setupGame();
223
- * gameReady();
224
- * ```
225
- */
226
- export function gameReady() {
227
- sendRaw('sdk', 'gameReady', {});
2007
+ * ユーザー操作受付可能を Flutter ホストに通知する。
2008
+ *
2009
+ * YouTube Playables の `gameReady()` 相当。 Flutter は受信時に入力受付を開始し、TTI
2010
+ * (Time To Interactive) 計測を終了する。 将来の広告タイマー / leaderboard / analytics
2011
+ * の発火点としても使われる予定。
2012
+ *
2013
+ * 呼び出しタイミング: asset load 完了、 ゲーム本体の setup 完了、 入力受付可能になった直後。
2014
+ *
2015
+ * @example
2016
+ * ```ts
2017
+ * await loadAssets();
2018
+ * setupGame();
2019
+ * gameReady();
2020
+ * ```
2021
+ */
2022
+ function gameReady() {
2023
+ sendRaw("sdk", "gameReady", {});
228
2024
  }
229
2025
  /** Flutter からの playersChanged メッセージを受信するハンドラを登録する。 */
230
- export function onPlayersChanged(handler) {
231
- _playersChangedHandlers.push(handler);
2026
+ function onPlayersChanged(handler) {
2027
+ _playersChangedHandlers.push(handler);
232
2028
  }
233
2029
  /**
234
- * `A` に既定値が要る。 TypeScript は型引数の部分推論ができないので、 既定が無いと
235
- * 既存の `run<State>({…})` が「2 つ必要」で落ちる。 既定を置けば型引数を書かない
236
- * 呼び出しは `A` が推論されて検査が効き、 `run<State>` は従来どおり通る。
237
- *
238
- * `SA` の既定が `ServerActionMap<S>` (= `Record<string, …>`) だと
239
- * `keyof (A & SA)` が `string` へ潰れ、action 名の検査が丸ごと消える。
240
- * `serverActions` を持たない logic でもキーが残るよう空 map を既定にする。
241
- */
242
- export function run(config) {
243
- _usesRun = true;
244
- const params = new URLSearchParams(window.location.search);
245
- // frame では SDK runtime は何もしない。 harness は uzu-cli の `uzu dev` が担当。
246
- if (!isHosted)
247
- return;
248
- init();
249
- // Wrap onState to capture latest state for dev hooks
250
- const origOnState = config.onState;
251
- const wrappedConfig = {
252
- ...config,
253
- onState(state, myPlayerId, mySeatKind) {
254
- _lastStateSnap = { state, serverTime: 0, myId: myPlayerId };
255
- origOnState(state, myPlayerId, mySeatKind);
256
- notifyDevSnapshot(state);
257
- },
258
- };
259
- const roomId = params.get('roomId');
260
- if (!roomId) {
261
- // ServerAction: ローカルで logic を直接実行 (ソロモード)
262
- _runHandle = runLocalServerAction(wrappedConfig);
263
- }
264
- else if (_gameEndpoint) {
265
- // ServerAction モード: GameRoom に接続 (本番 Cloudflare Worker DO / uzu dev の Node ws)
266
- const { seatId, players } = resolveSeatParams(params);
267
- const seatKind = resolveSeatKind(params);
268
- runOnlineServerAction(wrappedConfig, _gameEndpoint, roomId, seatId, players, seatKind);
269
- }
270
- else {
271
- throw new Error('[UZU SDK] roomId is set but ?server= is missing. ' +
272
- 'Multiplayer requires a WebSocket server URL — pass ?server=ws://host:port in the URL.');
273
- }
274
- attachDevHooksIfNotHosted();
275
- }
276
- export function sync(config) {
277
- _usesRun = true;
278
- const params = new URLSearchParams(window.location.search);
279
- // 親 frame では SDK runtime は何もしない。 harness は uzu-cli の `uzu dev` が担当。
280
- if (!isHosted)
281
- return;
282
- init();
283
- // Wrap onState to capture latest state for dev hooks
284
- const origOnState = config.onState;
285
- const wrappedConfig = {
286
- ...config,
287
- onState(state, myPlayerId, serverTime) {
288
- _lastStateSnap = { state, serverTime, myId: myPlayerId };
289
- origOnState(state, myPlayerId, serverTime);
290
- notifyDevSnapshot(state);
291
- },
292
- };
293
- const roomId = params.get('roomId');
294
- if (!roomId) {
295
- _syncHandle = syncLocal(wrappedConfig);
296
- }
297
- else if (_syncEndpoint) {
298
- const { seatId, players } = resolveSeatParams(params);
299
- const { ws } = syncOnline(wrappedConfig, _syncEndpoint, roomId, seatId, players);
300
- _syncWs = ws;
301
- // online sync: dev hooks は read-only (setRawState 未提供)
302
- }
303
- else {
304
- throw new Error('[UZU SDK] roomId is set but ?server= is missing. ' +
305
- 'Multiplayer sync requires a WebSocket server URL — pass ?server=ws://host:port in the URL.');
306
- }
307
- attachDevHooksIfNotHosted();
308
- }
309
- // ─── Internal ───────────────────────────────────────────────
2030
+ * `A` に既定値が要る。 TypeScript は型引数の部分推論ができないので、 既定が無いと
2031
+ * 既存の `run<State>({…})` が「2 つ必要」で落ちる。 既定を置けば型引数を書かない
2032
+ * 呼び出しは `A` が推論されて検査が効き、 `run<State>` は従来どおり通る。
2033
+ *
2034
+ * `SA` の既定が `ServerActionMap<S>` (= `Record<string, …>`) だと
2035
+ * `keyof (A & SA)` が `string` へ潰れ、action 名の検査が丸ごと消える。
2036
+ * `serverActions` を持たない logic でもキーが残るよう空 map を既定にする。
2037
+ */
2038
+ function run(config) {
2039
+ _usesRun = true;
2040
+ const params = new URLSearchParams(window.location.search);
2041
+ if (!isHosted) return;
2042
+ init();
2043
+ const origOnState = config.onState;
2044
+ const wrappedConfig = {
2045
+ ...config,
2046
+ onState(state, myPlayerId, mySeatKind) {
2047
+ _lastStateSnap = {
2048
+ state,
2049
+ serverTime: 0,
2050
+ myId: myPlayerId
2051
+ };
2052
+ runRendered(() => origOnState(state, myPlayerId, mySeatKind));
2053
+ notifyDevSnapshot(state);
2054
+ }
2055
+ };
2056
+ const roomId = params.get("roomId");
2057
+ if (!roomId) _runHandle = runLocalServerAction(wrappedConfig);
2058
+ else if (_gameEndpoint) {
2059
+ const { seatId, players } = resolveSeatParams(params);
2060
+ const seatKind = resolveSeatKind(params);
2061
+ runOnlineServerAction(wrappedConfig, _gameEndpoint, roomId, seatId, players, seatKind);
2062
+ } else throw new Error("[UZU SDK] roomId is set but ?server= is missing. Multiplayer requires a WebSocket server URL — pass ?server=ws://host:port in the URL.");
2063
+ attachDevHooksIfNotHosted();
2064
+ }
2065
+ function sync(config) {
2066
+ _usesRun = true;
2067
+ const params = new URLSearchParams(window.location.search);
2068
+ if (!isHosted) return;
2069
+ init();
2070
+ const origOnState = config.onState;
2071
+ const wrappedConfig = {
2072
+ ...config,
2073
+ onState(state, myPlayerId, serverTime) {
2074
+ _lastStateSnap = {
2075
+ state,
2076
+ serverTime,
2077
+ myId: myPlayerId
2078
+ };
2079
+ origOnState(state, myPlayerId, serverTime);
2080
+ notifyDevSnapshot(state);
2081
+ }
2082
+ };
2083
+ const roomId = params.get("roomId");
2084
+ if (!roomId) _syncHandle = syncLocal(wrappedConfig);
2085
+ else if (_syncEndpoint) {
2086
+ const { seatId, players } = resolveSeatParams(params);
2087
+ const { ws } = syncOnline(wrappedConfig, _syncEndpoint, roomId, seatId, players);
2088
+ _syncWs = ws;
2089
+ } else throw new Error("[UZU SDK] roomId is set but ?server= is missing. Multiplayer sync requires a WebSocket server URL — pass ?server=ws://host:port in the URL.");
2090
+ attachDevHooksIfNotHosted();
2091
+ }
310
2092
  /**
311
- * 自分の席種別を URL から取り出す。
312
- *
313
- * `?seatKind=` はホスト (harness / emulator) が観測席の iframe に付ける。付いていない
314
- * ホストからの接続は player とみなす — 本番 (mobile / uzutokyo) は player 席しか開かない。
315
- *
316
- * この値は iframe URL 限定で、WebSocket には出さない。play-server は roster に居ない接続を
317
- * 通すだけで、その席が admin か spectator かを知る必要が無い。
318
- */
2093
+ * 自分の席種別を URL から取り出す。
2094
+ *
2095
+ * `?seatKind=` はホスト (harness / emulator) が観測席の iframe に付ける。付いていない
2096
+ * ホストからの接続は player とみなす — 本番 (mobile / uzutokyo) は player 席しか開かない。
2097
+ *
2098
+ * この値は iframe URL 限定で、WebSocket には出さない。play-server は roster に居ない接続を
2099
+ * 通すだけで、その席が admin か spectator かを知る必要が無い。
2100
+ */
319
2101
  function resolveSeatKind(params) {
320
- const raw = params.get('seatKind');
321
- return raw === 'admin' || raw === 'spectator' ? raw : 'player';
2102
+ const raw = params.get("seatKind");
2103
+ return raw === "admin" || raw === "spectator" ? raw : "player";
322
2104
  }
323
2105
  /**
324
- * 自席の ID と roster を URL から取り出す。
325
- *
326
- * roster (`?players=`) に自席が居ないことは異常ではない。観測者はそもそも roster に
327
- * 載らないまま接続してくる。
328
- *
329
- * 移行期: 旧ホスト (デプロイ前の mobile / uzutokyo / emulator) は `?seats=` で送ってくる。
330
- */
2106
+ * 自席の ID と roster を URL から取り出す。
2107
+ *
2108
+ * roster (`?players=`) に自席が居ないことは異常ではない。観測者はそもそも roster に
2109
+ * 載らないまま接続してくる。
2110
+ *
2111
+ * 移行期: 旧ホスト (デプロイ前の mobile / uzutokyo / emulator) は `?seats=` で送ってくる。
2112
+ */
331
2113
  function resolveSeatParams(params) {
332
- const seatId = params.get('seatId');
333
- if (!seatId) {
334
- throw new Error('[UZU SDK] seatId is required. Pass ?seatId=xxx in the URL.');
335
- }
336
- const json = params.get('players') ?? params.get('seats');
337
- if (!json) {
338
- throw new Error('[UZU SDK] players is required. Pass ?players=[...] in the URL.');
339
- }
340
- const raw = JSON.parse(json);
341
- const players = raw.map((p) => ({
342
- id: p.id,
343
- nickname: p.name,
344
- iconUrl: p.iconUrl,
345
- characterId: p.characterId,
346
- }));
347
- return { seatId, players };
2114
+ const seatId = params.get("seatId");
2115
+ if (!seatId) throw new Error("[UZU SDK] seatId is required. Pass ?seatId=xxx in the URL.");
2116
+ const json = params.get("players") ?? params.get("seats");
2117
+ if (!json) throw new Error("[UZU SDK] players is required. Pass ?players=[...] in the URL.");
2118
+ return {
2119
+ seatId,
2120
+ players: JSON.parse(json).map((p) => ({
2121
+ id: p.id,
2122
+ nickname: p.name,
2123
+ iconUrl: p.iconUrl,
2124
+ characterId: p.characterId
2125
+ }))
2126
+ };
348
2127
  }
349
2128
  function requireEndpoint(endpoint, name) {
350
- if (!endpoint) {
351
- throw new Error(`[UZU SDK] ${name} is not configured. Pass ?server=ws://host:port or call init({ wsEndpoint, syncEndpoint }).`);
352
- }
353
- return endpoint;
2129
+ if (!endpoint) throw new Error(`[UZU SDK] ${name} is not configured. Pass ?server=ws://host:port or call init({ wsEndpoint, syncEndpoint }).`);
2130
+ return endpoint;
354
2131
  }
355
2132
  function connectRoom(roomId) {
356
- const params = new URLSearchParams(window.location.search);
357
- const { seatId } = resolveSeatParams(params);
358
- const wsParams = new URLSearchParams({ playerId: seatId });
359
- const wsUrl = `${requireEndpoint(_wsEndpoint, 'wsEndpoint')}/${roomId}?${wsParams}`;
360
- console.log(`[SDK] 🔗 connectRoom wsUrl=${wsUrl}`);
361
- const ws = new ReconnectableWebSocket(wsUrl);
362
- ws.addEventListener('message', (ev) => {
363
- let parsed;
364
- try {
365
- parsed = JSON.parse(ev.data);
366
- }
367
- catch {
368
- return;
369
- }
370
- console.log(`[SDK] recv ws type=${parsed.type}`, JSON.stringify(parsed));
371
- if (parsed.type === '__room_init') {
372
- const myId = parsed.myId;
373
- console.log(`[SDK] Room initialized myId=${myId}`);
374
- _room = new Room(ws, myId);
375
- _onRoomCallbacks.forEach((cb) => cb(_room));
376
- _onRoomCallbacks = [];
377
- }
378
- else if (parsed.type === '__reconnected') {
379
- // 再接続: ws は同じ ReconnectableWebSocket インスタンスなので Room 再作成不要
380
- console.log(`[SDK] 🔄 Room reconnected`);
381
- }
382
- });
2133
+ const { seatId } = resolveSeatParams(new URLSearchParams(window.location.search));
2134
+ const wsParams = new URLSearchParams({ playerId: seatId });
2135
+ const wsUrl = `${requireEndpoint(_wsEndpoint, "wsEndpoint")}/${roomId}?${wsParams}`;
2136
+ console.log(`[SDK] 🔗 connectRoom wsUrl=${wsUrl}`);
2137
+ const ws = new ReconnectableWebSocket(wsUrl);
2138
+ ws.addEventListener("message", (ev) => {
2139
+ let parsed;
2140
+ try {
2141
+ parsed = JSON.parse(ev.data);
2142
+ } catch {
2143
+ return;
2144
+ }
2145
+ console.log(`[SDK] ⬅ recv ws type=${parsed.type}`, JSON.stringify(parsed));
2146
+ if (parsed.type === "__room_init") {
2147
+ const myId = parsed.myId;
2148
+ console.log(`[SDK] Room initialized myId=${myId}`);
2149
+ _room = new Room(ws, myId);
2150
+ _onRoomCallbacks.forEach((cb) => cb(_room));
2151
+ _onRoomCallbacks = [];
2152
+ } else if (parsed.type === "__reconnected") console.log(`[SDK] 🔄 Room reconnected`);
2153
+ });
383
2154
  }
384
2155
  /** 内部送信関数。 channel + type + payload のエンベロープで送信する。 */
385
2156
  function sendRaw(channel, type, payload) {
386
- const msg = {
387
- channel,
388
- type,
389
- payload,
390
- ...(_playerId ? { playerId: _playerId } : {}),
391
- };
392
- console.log(`[SDK] ➡ send channel=${channel} type=${type}`, JSON.stringify(msg));
393
- if (window.FlutterHost) {
394
- // Desktop/Mobile: webview_flutter JavaScriptChannel 経由
395
- window.FlutterHost.postMessage(JSON.stringify(msg));
396
- }
397
- else if (window.parent !== window) {
398
- // Web iframe: 親ウィンドウへ postMessage 経由
399
- window.parent.postMessage(JSON.stringify(msg), '*');
400
- }
2157
+ const msg = {
2158
+ channel,
2159
+ type,
2160
+ payload,
2161
+ ..._playerId ? { playerId: _playerId } : {}
2162
+ };
2163
+ console.log(`[SDK] ➡ send channel=${channel} type=${type}`, JSON.stringify(msg));
2164
+ if (window.FlutterHost) window.FlutterHost.postMessage(JSON.stringify(msg));
2165
+ else if (window.parent !== window) window.parent.postMessage(JSON.stringify(msg), "*");
401
2166
  }
402
2167
  function handleMessage(msg) {
403
- const { channel, type, payload } = msg;
404
- if (channel === 'sdk') {
405
- // SDK 内部メッセージの処理
406
- switch (type) {
407
- case 'getState':
408
- console.log(`[SDK] 🔧 handleMessage sdk/getState responding`);
409
- sendRaw('sdk', 'stateResponse', { state: _lastStateSnap });
410
- return;
411
- case 'clearState':
412
- console.log(`[SDK] 🗑 handleMessage sdk/clearState forwarding to server`);
413
- if (_syncWs && _syncWs.connectionState === 'connected') {
414
- _syncWs.send(JSON.stringify({ type: '__clear_state' }));
415
- }
416
- return;
417
- case 'playersChanged': {
418
- const players = (payload?.players ?? {});
419
- _playersChangedHandlers.forEach((fn) => fn(players));
420
- return;
421
- }
422
- }
423
- return;
424
- }
425
- if (channel === 'game') {
426
- const h = gameHandlers.get(type) || [];
427
- console.log(`[SDK] 🔧 handleMessage game/${type} handlers=${h.length}`);
428
- h.forEach((fn) => fn(payload ?? {}));
429
- return;
430
- }
431
- }
432
- // ─── HUD inset 計算 ─────────────────────────────────────────
433
- // Flutter 側の HudInsets と同じ定数・ロジック
434
- const HUD_TOP = 8;
435
- const HUD_LEFT = 12;
436
- const UZU_BUTTON_SIZE = 44;
2168
+ const { channel, type, payload } = msg;
2169
+ if (channel === "sdk") {
2170
+ switch (type) {
2171
+ case "getState":
2172
+ console.log(`[SDK] 🔧 handleMessage sdk/getState → responding`);
2173
+ sendRaw("sdk", "stateResponse", { state: _lastStateSnap });
2174
+ return;
2175
+ case "clearState":
2176
+ console.log(`[SDK] 🗑 handleMessage sdk/clearState → forwarding to server`);
2177
+ if (_syncWs && _syncWs.connectionState === "connected") _syncWs.send(JSON.stringify({ type: "__clear_state" }));
2178
+ return;
2179
+ case "playersChanged": {
2180
+ const players = payload?.players ?? {};
2181
+ _playersChangedHandlers.forEach((fn) => fn(players));
2182
+ return;
2183
+ }
2184
+ case "requestPause":
2185
+ console.log(`[SDK] ⏸ handleMessage sdk/requestPause`);
2186
+ requestPause();
2187
+ return;
2188
+ case "requestResume":
2189
+ console.log(`[SDK] ▶️ handleMessage sdk/requestResume`);
2190
+ requestResume();
2191
+ return;
2192
+ }
2193
+ return;
2194
+ }
2195
+ if (channel === "game") {
2196
+ const h = gameHandlers.get(type) || [];
2197
+ console.log(`[SDK] 🔧 handleMessage game/${type} handlers=${h.length}`);
2198
+ h.forEach((fn) => fn(payload ?? {}));
2199
+ return;
2200
+ }
2201
+ }
437
2202
  const GAP = 6;
438
2203
  const ACTION_ITEM_WIDTH = 38;
439
2204
  /**
440
- * HUD 回避領域の右下座標を算出する。
441
- * クエリパラメータに uzuHudInsetX/Y がない場合のフォールバック値として使用。
442
- *
443
- * 前提: UZU ボタンは常に表示される (onMenuPressed は常に非 null)。
444
- * Dart 側の HudInsets.x() は hasMenu=false を扱えるが、
445
- * フォールバック計算では安全側に倒して常に UZU ボタンありで算出する。
446
- *
447
- * - players が取得できる場合: 2人以上→アクション数2、1人→アクション数0
448
- * - players が取得できない場合: アクション数2 (安全側に倒す)
449
- */
2205
+ * HUD 回避領域の右下座標を算出する。
2206
+ * クエリパラメータに uzuHudInsetX/Y がない場合のフォールバック値として使用。
2207
+ *
2208
+ * 前提: UZU ボタンは常に表示される (onMenuPressed は常に非 null)。
2209
+ * Dart 側の HudInsets.x() は hasMenu=false を扱えるが、
2210
+ * フォールバック計算では安全側に倒して常に UZU ボタンありで算出する。
2211
+ *
2212
+ * - players が取得できる場合: 2人以上→アクション数2、1人→アクション数0
2213
+ * - players が取得できない場合: アクション数2 (安全側に倒す)
2214
+ */
450
2215
  function calcHudInsets(params) {
451
- const y = HUD_TOP + UZU_BUTTON_SIZE;
452
- let actionCount = 2; // デフォルト: 安全側 (マイク + チャット想定)
453
- const json = params.get('players') ?? params.get('seats');
454
- if (json) {
455
- try {
456
- const raw = JSON.parse(json);
457
- actionCount = raw.length >= 2 ? 2 : 0;
458
- }
459
- catch {
460
- // パース失敗時はデフォルト値を使用
461
- }
462
- }
463
- let x = HUD_LEFT + UZU_BUTTON_SIZE;
464
- if (actionCount > 0) {
465
- x += GAP + ACTION_ITEM_WIDTH * actionCount;
466
- }
467
- return { x, y };
2216
+ const y = 52;
2217
+ let actionCount = 2;
2218
+ const json = params.get("players") ?? params.get("seats");
2219
+ if (json) try {
2220
+ actionCount = JSON.parse(json).length >= 2 ? 2 : 0;
2221
+ } catch {}
2222
+ let x = 56;
2223
+ if (actionCount > 0) x += GAP + ACTION_ITEM_WIDTH * actionCount;
2224
+ return {
2225
+ x,
2226
+ y
2227
+ };
468
2228
  }
2229
+ //#endregion
2230
+ export { DEFAULT_ICON_URLS, ReconnectableWebSocket, Room, SERVER_TIME, SeededRandomImpl, applyJsonMergePatch, applyJsonPatch, attachDevHooks, changeRoom, createDevHooks, firstFrameReady, gameReady, gameTime, getPredictionWarnings, getRoom, init, isHosted, isPaused, isServerOnlyAction, minus, on, onPauseChange, onPlayersChanged, onRoom, playBgm, playSound, plus, run, send, serverOnly, setMicEnabled, stopBgm, sync };