@uzuhq/code-sdk 0.7.7 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -223,7 +223,7 @@ import type { GameLogic } from '@uzuhq/code-sdk';
223
223
 
224
224
  const logic: GameLogic<MyState> = {
225
225
  setup({ players, ctx }) {
226
- // 初期 state を生成。ctx.random は SeededRandom、ctx.now はサーバーの実時刻。
226
+ // 初期 state を生成。ctx.random は SeededRandom、ctx.time はゲーム内時刻 (必ず 0)。
227
227
  // players は配役を受け取る参加者だけで、観測者は含まれない
228
228
  return { players: {}, items: [] };
229
229
  },
@@ -235,24 +235,24 @@ const logic: GameLogic<MyState> = {
235
235
  state.players[playerId].x += payload.dx;
236
236
  // ctx.emit でイベント発火 (購読側は events で predict を宣言する)
237
237
  ctx.emit('sound', { sound: 'step' });
238
- // 時刻は ctx.now を使う。Date.now() は端末とサーバーでズレる
238
+ // 時刻は ctx.after(d) を使う。Date.now() は epoch が違う (Unix ms vs ゲーム内時刻)
239
239
  },
240
240
  },
241
241
 
242
242
  // サーバーでのみ走る。実時刻・乱数・fetch などクライアントが再現できない処理。
243
243
  // actions と同名にすると「同じ action のサーバー側の続き」になる。
244
244
  serverActions: {
245
- async notifyExternal({ state, payload, playerId }) {
245
+ async notifyExternal({ state, payload, playerId, ctx }) {
246
246
  await fetch('https://example.com/notify', {
247
247
  method: 'POST',
248
248
  body: JSON.stringify({ playerId, ...payload }),
249
249
  });
250
- state.notifiedAt = Date.now();
250
+ state.notifiedAt = ctx.time;
251
251
  },
252
252
  },
253
253
 
254
254
  update({ state, ctx }) {
255
- // 毎 tick 実行。ctx.tick / ctx.random / ctx.now / ctx.emit / ctx.playerInputs が使える
255
+ // 毎 tick 実行。ctx.tick / ctx.random / ctx.time / ctx.after / ctx.emit / ctx.playerInputs が使える
256
256
  },
257
257
 
258
258
  tickRate: 10, // 秒間 tick 数 (default: 0 = tick なし)
@@ -287,10 +287,10 @@ deadlines: {
287
287
  },
288
288
  ```
289
289
 
290
- | | |
291
- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
292
- | `at` | 締切の絶対時刻 (ms)。`null` / `undefined` で締切なし (optional chain の結果をそのまま返せる)。**state だけから決まる軽い関数にすること** (state が変わるたびに呼ばれる) |
293
- | `handler` | サーバーでのみ走る。`ctx` は `{ now, random, emit }` |
290
+ | | |
291
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
292
+ | `at` | 締切の[ゲーム内時刻](#ゲーム内時計) (`GameTime`)。`null` / `undefined` で締切なし (optional chain の結果をそのまま返せる)。**state だけから決まる軽い純関数にすること** (state が変わるたびに呼ばれる) |
293
+ | `handler` | サーバーでのみ走る。`ctx` は `{ time, after, random, emit }` |
294
294
 
295
295
  締切は state から導出するので、**予約を張り替える処理を書かなくてよい**。action が throw して
296
296
  state が巻き戻れば締切も一緒に巻き戻る。発火直前に `at` を評価し直すので、二重発火を
@@ -299,6 +299,51 @@ handler 側で弾く必要も無い。
299
299
  時刻をきっかけに何かを起こすなら `tickRate` で毎秒ポーリングせずこちらを使う。
300
300
  ポーリング中は Durable Object が hibernate できない。
301
301
 
302
+ ### ゲーム内時計
303
+
304
+ **時刻は Unix epoch ではない。** `ctx.time` はゲーム開始からの経過 ms (`GameTime`) で、
305
+ [緊急一時停止](#緊急一時停止)中は進まない。`Date.now()` とは桁も意味も違う。
306
+
307
+ | API | 意味 |
308
+ | -------------------------- | ----------------------------------------------------------------- |
309
+ | `ctx.time` | そのハンドラが走っているゲーム内時刻。`setup` では必ず `0` |
310
+ | `ctx.after(d)` | 今から `d` ms 後のゲーム内時刻。締切を state に置くときに使う |
311
+ | `gameTime()` | 描画側で読む現在のゲーム内時刻 (推定値)。`performance.now()` 基準 |
312
+ | `plus(t, d)` / `sub(t, d)` | 時刻に長さを足す / 引く |
313
+ | `minus(a, b)` | 2 つの時刻の差 (ms) |
314
+
315
+ ```ts
316
+ import { gameTime, minus } from '@uzuhq/code-sdk';
317
+
318
+ // 締切を置く
319
+ actions: {
320
+ startPhase: ({ state, ctx }) => { state.phaseEndsAt = ctx.after(5 * 60_000); },
321
+ },
322
+
323
+ // 残り時間を描く
324
+ const remain = Math.ceil(minus(state.phaseEndsAt, gameTime()) / 1000);
325
+ ```
326
+
327
+ `GameTime` は brand 型なので実行時はただの数値で、state は素の JSON のまま。ただし型の上では
328
+ `number` と混ざらないので、`Date.now()` を書き込む式も `endsAt - Date.now()` と読む式も
329
+ コンパイルが通らない。
330
+
331
+ ### 緊急一時停止
332
+
333
+ プレイヤーが UZU メニューから全員のタイマーを止められる。**シナリオは停止を知らないし、
334
+ 知る必要も無い。** 停止中は `update()` が呼ばれず、action はサーバーが弾き、締切も来ない。
335
+
336
+ 演出だけを止めたいときに限り、読み取り専用で参照できる。
337
+
338
+ ```ts
339
+ import { isPaused, onPauseChange } from '@uzuhq/code-sdk';
340
+
341
+ onPauseChange((paused) => (paused ? engine.stop() : engine.start()));
342
+ ```
343
+
344
+ `GameLogic` からは触れない。停止を state に持ち込むと「停止中は state が変わらない」という
345
+ 前提が崩れる。
346
+
302
347
  #### `serverOnly(handler)`
303
348
 
304
349
  > **Deprecated**: `serverActions` に直接書く。
@@ -1,4 +1,4 @@
1
- import { i as UzuDevHooks } from "./dev-hooks-ClWM8HzI.js";
1
+ import { i as UzuDevHooks } from "./dev-hooks-BYl_jrcy.js";
2
2
  //#region src/dev-globals.d.ts
3
3
  declare global {
4
4
  interface Window {
@@ -79,19 +79,54 @@ declare function applyJsonMergePatch(target: Record<string, unknown>, patch: Jso
79
79
  */
80
80
  declare function applyJsonPatch(target: Record<string, unknown>, ops: JsonPatchOp[]): void;
81
81
  //#endregion
82
- //#region ../engine-core/src/types.d.ts
82
+ //#region ../engine-core/src/game-time.d.ts
83
83
  /**
84
84
  * @docs
85
- * - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
86
- * - SDK仕様: docs/docs/uzu_code/play-screen-sdk.md
85
+ * - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
86
+ *
87
+ * ゲーム内時刻。ゲーム開始からの ms で、停止中は進まない。**Unix epoch ではない。**
88
+ *
89
+ * 実行時はただの number。brand はコンパイル時だけの区別なので、state は素の JSON の
90
+ * まま保たれる (structuredClone / json-patch / JSON.stringify がそのまま通る)。
91
+ * 値を作れるのは `ctx.after` / `plus` だけになるので、`Date.now()` を混ぜると型で落ちる。
92
+ *
93
+ * 型が効くのは TypeScript を書いているシナリオだけだが、JS のシナリオでも epoch が
94
+ * 違うぶんは残る (`Date.now()` を混ぜれば締切が 56,000 日後になって初回で気付く)。
95
+ */
96
+ declare const gameTimeBrand: unique symbol;
97
+ /** brand の印。`GameTime` を組み立てるためだけに使う。 */
98
+ type GameTimeTag = {
99
+ readonly [gameTimeBrand]: 'GameTime';
100
+ };
101
+ /**
102
+ * ゲーム内時刻 (ゲーム開始からの ms)。停止中は進まない。
87
103
  *
88
- * シナリオが書く `GameLogic` と、その実行文脈の型。
104
+ * `number & Tag` ではなく `(number & Tag) | Tag` にしてあるのは、**読み側を守るため**。
105
+ * 交差だけだと `GameTime` が `number` へ代入できてしまい、
106
+ * `state.endsAt - Date.now()` が型検査を通る。これは `GameTime` を導入した動機
107
+ * そのもののバグで、しかも実行時に静かに壊れる (巨大な負数が
108
+ * `Math.max(0, ...)` に潰されてタイマーが 00:00 で固まる)。
89
109
  *
90
- * SDK (browser) / cli dev-server (Node) / play-server の facet (Workers) の 3 ランタイムが
91
- * この 1 ファイルを共有する。ここに browser / Node / Workers いずれかの API を持ち込むと、
92
- * 残り 2 つのランタイムでビルドが落ちる (engine-core の tsconfig は lib を ES2022 だけに
93
- * 絞ってある)。
110
+ * union にすると `number` への代入と算術が型で止まり、`GameTime` 同士の比較・`===`・
111
+ * テンプレート・`Number.isFinite`・JSON 往復・一段 cast はそのまま使える。
112
+ * TypeScript コンパイラ自身が `__String` (識別子の内部表現) で同じ形を採っている
113
+ * (microsoft/TypeScript#16915)。
94
114
  */
115
+ type GameTime = (number & GameTimeTag) | GameTimeTag;
116
+ /** 長さ (ms)。差や間隔はこちら。 */
117
+ type Duration = number;
118
+ /**
119
+ * `GameTime` に長さを足す。
120
+ *
121
+ * 算術は型で止まるので、`GameTime` を作り直す道はこの関数と `ctx.after` に限られる。
122
+ */
123
+ declare const plus: (t: GameTime, d: Duration) => GameTime;
124
+ /** `GameTime` から長さを引く。`plus(t, -d)` と書かせないための対。 */
125
+ declare const sub: (t: GameTime, d: Duration) => GameTime;
126
+ /** 2 つの `GameTime` の差 (ms)。 */
127
+ declare const minus: (a: GameTime, b: GameTime) => Duration;
128
+ //#endregion
129
+ //#region ../engine-core/src/types.d.ts
95
130
  /**
96
131
  * roster に載る席。
97
132
  *
@@ -128,20 +163,24 @@ interface SeededRandom {
128
163
  */
129
164
  interface ActionContext {
130
165
  /**
131
- * サーバーで処理される時刻 (ms)。先読みではクロックオフセットからの推定値。
166
+ * 現在のゲーム内時刻。先読みではクロックオフセットからの推定値。
132
167
  *
133
168
  * 推定なのでサーバーとは数十 ms ずれる。時刻での分岐に使うと境界で判定が割れるので、
134
- * 分岐は `update()` (サーバー専用) で行う。
169
+ * 分岐は `update()` / `deadlines` (サーバー専用) へ寄せる。
135
170
  *
136
171
  * 同じ action を再適用しても値は変わらない (初回予測時の値を使い回す)。
137
172
  */
138
- now: number;
173
+ time: GameTime;
174
+ /** 今から `d` ms 後のゲーム内時刻。締切を state へ置くときに使う。 */
175
+ after(d: Duration): GameTime;
139
176
  emit: Emit;
140
177
  }
141
178
  /** `deadlines` handler の実行文脈。サーバーでしか走らないので tick 以外を渡せる。 */
142
179
  interface DeadlineContext {
143
- /** 発火時刻 (ms)。サーバー専用なので常に正確。 */
144
- now: number;
180
+ /** 発火時点のゲーム内時刻。サーバー専用なので常に正確。 */
181
+ time: GameTime;
182
+ /** 今から `d` ms 後のゲーム内時刻。次の締切を state へ置き直すときに使う。 */
183
+ after(d: Duration): GameTime;
145
184
  random: SeededRandom;
146
185
  emit: Emit;
147
186
  }
@@ -162,17 +201,21 @@ interface DeadlineArgs<S> {
162
201
  */
163
202
  interface Deadline<S> {
164
203
  /**
165
- * 締切の絶対時刻 (ms)。締切が無いときは null / undefined。
204
+ * 締切のゲーム内時刻。締切が無いときは null / undefined。
166
205
  *
167
206
  * `state.timer?.endsAt` のような optional chain の結果をそのまま返せるよう
168
207
  * undefined も受ける。数値以外は「締切なし」として同じに扱う。
169
208
  *
170
- * state が変わるたびに呼ばれるので、state だけから決まる軽い関数にすること。
209
+ * 戻り値が `GameTime` なのは強制点。`Date.now() + 尺` も素の number も返せないので、
210
+ * 値を得る道が `ctx.after()` / `plus()` に限られ、state の時刻フィールドを
211
+ * `GameTime` で持つ動機が自動的に生まれる。
212
+ *
213
+ * state が変わるたびに呼ばれるので、state だけから決まる軽い純関数にすること。
171
214
  * ここで実時刻や乱数を読むと、呼ばれるたびに答えが変わって予約が暴れる。
172
215
  */
173
216
  at(args: {
174
217
  state: S;
175
- }): number | null | undefined;
218
+ }): GameTime | null | undefined;
176
219
  /**
177
220
  * `at` の時刻を過ぎたときにサーバーで呼ばれる。
178
221
  *
@@ -185,8 +228,10 @@ interface Deadline<S> {
185
228
  interface ServerActionContext {
186
229
  tick: number;
187
230
  random: SeededRandom;
188
- /** サーバーの実時刻 (ms)。同じ dispatch の `actions` に渡る `ctx.now` と同一値。 */
189
- now: number;
231
+ /** 現在のゲーム内時刻。同じ dispatch の `actions` に渡る `ctx.time` と同一値。 */
232
+ time: GameTime;
233
+ /** 今から `d` ms 後のゲーム内時刻。 */
234
+ after(d: Duration): GameTime;
190
235
  emit: Emit;
191
236
  }
192
237
  /** @deprecated `ServerActionContext` を使う。 */
@@ -230,8 +275,10 @@ type ServerOnlyAction<S> = ServerActionHandler<S> & {
230
275
  interface UpdateContext {
231
276
  random: SeededRandom;
232
277
  tick: number;
233
- /** サーバーの実時刻 (ms)。update はサーバーでしか走らないので常に正確。 */
234
- now: number;
278
+ /** 現在のゲーム内時刻。update はサーバーでしか走らないので常に正確。 */
279
+ time: GameTime;
280
+ /** 今から `d` ms 後のゲーム内時刻。 */
281
+ after(d: Duration): GameTime;
235
282
  emit: Emit;
236
283
  playerInputs: Record<string, Record<string, any>>;
237
284
  }
@@ -246,8 +293,10 @@ interface UpdateArgs<S> {
246
293
  */
247
294
  interface SetupContext {
248
295
  random: SeededRandom;
249
- /** サーバーの実時刻 (ms)。setup はサーバーでしか走らないので常に正確。 */
250
- now: number;
296
+ /** ゲーム内時刻。setup は時計の起点なので必ず `0`。 */
297
+ time: GameTime;
298
+ /** 今から `d` ms 後のゲーム内時刻。setup では `d` そのものになる。 */
299
+ after(d: Duration): GameTime;
251
300
  }
252
301
  /** `setup()` の引数。 */
253
302
  interface SetupArgs {
@@ -454,7 +503,7 @@ declare global {
454
503
  //#endregion
455
504
  //#region src/dev-prediction-traps.d.ts
456
505
  interface PredictionWarning {
457
- /** 呼び出した action */
506
+ /** 呼び出した action 名。描画経路では `'(render)'`。 */
458
507
  action: string;
459
508
  /** 呼ばれた API 名 (`'Date.now()'` など) */
460
509
  api: string;
@@ -679,4 +728,4 @@ interface DevHooksCtx<S = unknown> {
679
728
  declare function createDevHooks<S>(ctx: DevHooksCtx<S>): UzuDevHooks<S>;
680
729
  declare function attachDevHooks<S>(ctx: DevHooksCtx<S>): void;
681
730
  //#endregion
682
- export { applyJsonMergePatch as $, DEFAULT_ICON_URLS as A, ServerActionContext as B, SetFn as C, ActionContext as D, ActionArgs as E, GameLogic as F, ServerOnlyActionContext as G, ServerActionMap as H, SERVER_TIME as I, SetupContext as J, ServerOnlyActionHandlerFn as K, Seat as L, DeadlineArgs as M, DeadlineContext as N, ActionHandler as O, Emit as P, JsonPatchOp as Q, SeededRandom as R, SendAction as S, Operation as T, ServerEvent as U, ServerActionHandler as V, ServerOnlyAction as W, UpdateContext as X, UpdateArgs as Y, JsonMergePatch as Z, PayloadMap as _, attachDevHooks as a, PlayersChangedMessage as b, getPredictionWarnings as c, ConnectionCallbacks as d, applyJsonPatch as et, ConnectionState as f, PatchFn as g, GameConfig as h, UzuDevHooks as i, Deadline as j, ActionMap as k, BridgeChannel as l, EventSubscription as m, RunHandle as n, createDevHooks as o, EventHandler as p, SetupArgs as q, SyncHandle as r, PredictionWarning as s, DevHooksCtx as t, BridgeMessage as u, PlayScreenMessage as v, SyncConfig as w, SeatKind as x, PlayerVoiceState as y, ServerActionArgs as z };
731
+ export { minus as $, DEFAULT_ICON_URLS as A, ServerActionContext as B, SetFn as C, ActionContext as D, ActionArgs as E, GameLogic as F, ServerOnlyActionContext as G, ServerActionMap as H, SERVER_TIME as I, SetupContext as J, ServerOnlyActionHandlerFn as K, Seat as L, DeadlineArgs as M, DeadlineContext as N, ActionHandler as O, Emit as P, GameTime as Q, SeededRandom as R, SendAction as S, Operation as T, ServerEvent as U, ServerActionHandler as V, ServerOnlyAction as W, UpdateContext as X, UpdateArgs as Y, Duration as Z, PayloadMap as _, attachDevHooks as a, applyJsonPatch as at, PlayersChangedMessage as b, getPredictionWarnings as c, ConnectionCallbacks as d, plus as et, ConnectionState as f, PatchFn as g, GameConfig as h, UzuDevHooks as i, applyJsonMergePatch as it, Deadline as j, ActionMap as k, BridgeChannel as l, EventSubscription as m, RunHandle as n, JsonMergePatch as nt, createDevHooks as o, EventHandler as p, SetupArgs as q, SyncHandle as r, JsonPatchOp as rt, PredictionWarning as s, DevHooksCtx as t, sub as tt, BridgeMessage as u, PlayScreenMessage as v, SyncConfig as w, SeatKind as x, PlayerVoiceState as y, ServerActionArgs as z };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as applyJsonMergePatch, A as DEFAULT_ICON_URLS, B as ServerActionContext, C as SetFn, D as ActionContext, E as ActionArgs, F as GameLogic, G as ServerOnlyActionContext, H as ServerActionMap, I as SERVER_TIME, J as SetupContext, K as ServerOnlyActionHandlerFn, L as Seat, M as DeadlineArgs, N as DeadlineContext, O as ActionHandler, P as Emit, Q as JsonPatchOp, R as SeededRandom, S as SendAction, T as Operation, U as ServerEvent, V as ServerActionHandler, W as ServerOnlyAction, X as UpdateContext, Y as UpdateArgs, Z as JsonMergePatch, _ as PayloadMap, a as attachDevHooks, b as PlayersChangedMessage, c as getPredictionWarnings, d as ConnectionCallbacks, et as applyJsonPatch, f as ConnectionState, g as PatchFn, h as GameConfig, i as UzuDevHooks, j as Deadline, k as ActionMap, l as BridgeChannel, m as EventSubscription, n as RunHandle, o as createDevHooks, p as EventHandler, q as SetupArgs, r as SyncHandle, s as PredictionWarning, t as DevHooksCtx, u as BridgeMessage, v as PlayScreenMessage, w as SyncConfig, x as SeatKind, y as PlayerVoiceState, z as ServerActionArgs } from "./dev-hooks-ClWM8HzI.js";
1
+ import { $ as minus, A as DEFAULT_ICON_URLS, B as ServerActionContext, C as SetFn, D as ActionContext, E as ActionArgs, F as GameLogic, G as ServerOnlyActionContext, H as ServerActionMap, I as SERVER_TIME, J as SetupContext, K as ServerOnlyActionHandlerFn, L as Seat, M as DeadlineArgs, N as DeadlineContext, O as ActionHandler, P as Emit, Q as GameTime, R as SeededRandom, S as SendAction, T as Operation, U as ServerEvent, V as ServerActionHandler, W as ServerOnlyAction, X as UpdateContext, Y as UpdateArgs, Z as Duration, _ as PayloadMap, a as attachDevHooks, at as applyJsonPatch, b as PlayersChangedMessage, c as getPredictionWarnings, d as ConnectionCallbacks, et as plus, f as ConnectionState, g as PatchFn, h as GameConfig, i as UzuDevHooks, it as applyJsonMergePatch, j as Deadline, k as ActionMap, l as BridgeChannel, m as EventSubscription, n as RunHandle, nt as JsonMergePatch, o as createDevHooks, p as EventHandler, q as SetupArgs, r as SyncHandle, rt as JsonPatchOp, s as PredictionWarning, t as DevHooksCtx, tt as sub, u as BridgeMessage, v as PlayScreenMessage, w as SyncConfig, x as SeatKind, y as PlayerVoiceState, z as ServerActionArgs } from "./dev-hooks-BYl_jrcy.js";
2
2
  //#region ../engine-core/src/random.d.ts
3
3
  declare class SeededRandomImpl implements SeededRandom {
4
4
  private _state;
@@ -32,12 +32,18 @@ declare function isServerOnlyAction<S>(handler: ActionHandler<S> | ServerActionH
32
32
  //#endregion
33
33
  //#region src/server-clock.d.ts
34
34
  /**
35
- * サーバー時刻の推定値 (ms)。
35
+ * ゲーム内時刻の推定値。停止中は凍る。
36
36
  *
37
37
  * カウントダウン描画のように毎秒/毎フレーム呼ぶ用途を想定しているので、
38
38
  * state の到着とは無関係にいつでも呼べる。
39
39
  */
40
- declare function serverNow(): number;
40
+ declare function gameTime(): GameTime;
41
+ //#endregion
42
+ //#region src/pause-state.d.ts
43
+ /** 緊急停止中か。 */
44
+ declare function isPaused(): boolean;
45
+ /** 停止状態の変化を購読する。 */
46
+ declare function onPauseChange(cb: (paused: boolean) => void): void;
41
47
  //#endregion
42
48
  //#region src/room.d.ts
43
49
  type RoomMessageHandler = (data: Record<string, unknown>) => void;
@@ -199,4 +205,4 @@ declare function onPlayersChanged(handler: PlayersChangedHandler): void;
199
205
  declare function run<S, A extends ActionMap<S> = ActionMap<S>, SA extends ServerActionMap<S> = Record<never, never>>(config: GameConfig<S, A, SA>): void;
200
206
  declare function sync<S>(config: SyncConfig<S>): void;
201
207
  //#endregion
202
- export { type ActionArgs, type ActionContext, type ActionHandler, type ActionMap, type BridgeChannel, type BridgeMessage, type ConnectionCallbacks, type ConnectionState, DEFAULT_ICON_URLS, type Deadline, type DeadlineArgs, type DeadlineContext, type DevHooksCtx, type Emit, type EventHandler, type EventSubscription, type GameConfig, type GameLogic, type JsonMergePatch, type JsonPatchOp, type Operation, type PatchFn, type PayloadMap, type PlayScreenMessage, type PlayerVoiceState, type PlayersChangedMessage, type PredictionWarning, type ReconnectableWSOptions, ReconnectableWebSocket, Room, type RoomLike, type RunHandle, SERVER_TIME, type Seat, type SeatKind, type SeededRandom, SeededRandomImpl, type SendAction, type ServerActionArgs, type ServerActionContext, type ServerActionHandler, type ServerActionMap, type ServerEvent, type ServerOnlyAction, type ServerOnlyActionContext, type ServerOnlyActionHandlerFn, type SetFn, type SetupArgs, type SetupContext, type SyncConfig, type SyncHandle, type UpdateArgs, type UpdateContext, type UzuDevHooks, applyJsonMergePatch, applyJsonPatch, attachDevHooks, changeRoom, createDevHooks, firstFrameReady, gameReady, getPredictionWarnings, getRoom, init, isHosted, isServerOnlyAction, on, onPlayersChanged, onRoom, playBgm, playSound, run, send, serverNow, serverOnly, setMicEnabled, stopBgm, sync };
208
+ export { type ActionArgs, type ActionContext, type ActionHandler, type ActionMap, type BridgeChannel, type BridgeMessage, type ConnectionCallbacks, type ConnectionState, DEFAULT_ICON_URLS, type Deadline, type DeadlineArgs, type DeadlineContext, type DevHooksCtx, type Duration, type Emit, type EventHandler, type EventSubscription, type GameConfig, type GameLogic, type GameTime, type JsonMergePatch, type JsonPatchOp, type Operation, type PatchFn, type PayloadMap, type PlayScreenMessage, type PlayerVoiceState, type PlayersChangedMessage, type PredictionWarning, type ReconnectableWSOptions, ReconnectableWebSocket, Room, type RoomLike, type RunHandle, SERVER_TIME, type Seat, type SeatKind, type SeededRandom, SeededRandomImpl, type SendAction, type ServerActionArgs, type ServerActionContext, type ServerActionHandler, type ServerActionMap, type ServerEvent, type ServerOnlyAction, type ServerOnlyActionContext, type ServerOnlyActionHandlerFn, type SetFn, type SetupArgs, type SetupContext, type SyncConfig, type SyncHandle, type UpdateArgs, type UpdateContext, type UzuDevHooks, 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, sub, sync };
package/dist/index.js CHANGED
@@ -8,6 +8,180 @@ const DEFAULT_ICON_URLS = [
8
8
  "https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/43f45d11-da38-4d6e-637d-3df78e583500/original"
9
9
  ];
10
10
  //#endregion
11
+ //#region ../engine-core/src/game-time.ts
12
+ /**
13
+ * エンジン内部で素の ms へ落とす。**パッケージの公開 API には出さない** (`index.ts` を見よ)。
14
+ *
15
+ * 公開すると `toMs(a) - toMs(b)` と書けてしまい brand の保護が丸ごと消える。
16
+ * 差を取る正当な用途は `minus` が既に埋めているので、外へ出す理由が無い。
17
+ * 外部 API へ素の数値を渡したいという実需が出たら、そのとき足す。
18
+ */
19
+ const toMs = (t) => t;
20
+ /**
21
+ * `GameTime` に長さを足す。
22
+ *
23
+ * 算術は型で止まるので、`GameTime` を作り直す道はこの関数と `ctx.after` に限られる。
24
+ */
25
+ const plus = (t, d) => toMs(t) + d;
26
+ /** `GameTime` から長さを引く。`plus(t, -d)` と書かせないための対。 */
27
+ const sub = (t, d) => toMs(t) - d;
28
+ /** 2 つの `GameTime` の差 (ms)。 */
29
+ const minus = (a, b) => toMs(a) - toMs(b);
30
+ /**
31
+ * エンジン内部専用。作家コードから呼ばない。
32
+ *
33
+ * 時計の実装 (play-server の facet / dev-server / ソロ / SDK の補間) だけがここを通る。
34
+ */
35
+ const asGameTime = (ms) => ms;
36
+ //#endregion
37
+ //#region ../engine-core/src/game-clock.ts
38
+ /**
39
+ * @docs
40
+ * - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
41
+ *
42
+ * ゲーム内時計の実体。3 つのサーバー実装 (play-server の facet / cli の dev-server /
43
+ * SDK のソロ) が同じ挙動になるよう、時計の算術と凍結の出入りをここ 1 箇所に置く。
44
+ *
45
+ * ここに置かないもの: 永続化・tick ループ・broadcast・無人凍結の発火条件。
46
+ * それらはランタイムごとに手段が違うので、各実装が持つ。
47
+ */
48
+ /**
49
+ * ゲーム開始からの経過時間を数える時計。停止中は進まない。
50
+ *
51
+ * 実体はスカラー 3 本。シナリオの state は 1 バイトも書き換えない (どのフィールドが
52
+ * 時刻かエンジンは知らないので書き換えられない)。
53
+ */
54
+ var GameClock = class {
55
+ constructor() {
56
+ this.startedAtWall = 0;
57
+ this.pausedTotalMs = 0;
58
+ this.pausedAtWall = null;
59
+ this.frozenBy = /* @__PURE__ */ new Set();
60
+ }
61
+ /** 時計を 0 から始め直す。`setup()` を呼ぶ直前に通す。 */
62
+ restart() {
63
+ this.startedAtWall = Date.now();
64
+ this.pausedTotalMs = 0;
65
+ this.pausedAtWall = null;
66
+ this.frozenBy.clear();
67
+ }
68
+ get frozen() {
69
+ return this.frozenBy.size > 0;
70
+ }
71
+ isFrozenBy(reason) {
72
+ return this.frozenBy.has(reason);
73
+ }
74
+ /**
75
+ * 現在のゲーム内時刻。停止中は `pausedAtWall` で凍るので同じ値を返し続ける。
76
+ *
77
+ * IMPORTANT: [withGameClock] の中で呼んではいけない。実装が `Date.now()` を読むので、
78
+ * 差し替え済みの時刻を実時刻として引き算し、大きく負の値になる。
79
+ * ハンドラへ渡す時刻は必ず外で 1 回取ってから渡すこと。
80
+ */
81
+ now() {
82
+ return asGameTime((this.pausedAtWall ?? Date.now()) - this.startedAtWall - this.pausedTotalMs);
83
+ }
84
+ /** ゲーム内時刻を、alarm / setTimeout が使う実時刻へ直す。 */
85
+ toWall(at) {
86
+ return toMs(at) + this.startedAtWall + this.pausedTotalMs;
87
+ }
88
+ /** 実際に理由を足したら true。呼び出し側が永続化や tick 停止の要否に使う。 */
89
+ freeze(reason) {
90
+ if (this.frozenBy.has(reason)) return false;
91
+ const wasFrozen = this.frozen;
92
+ this.frozenBy.add(reason);
93
+ if (!wasFrozen) this.pausedAtWall = Date.now();
94
+ return true;
95
+ }
96
+ /** 実際に理由を外したら true。 */
97
+ unfreeze(reason) {
98
+ if (!this.frozenBy.delete(reason)) return false;
99
+ if (!this.frozen && this.pausedAtWall !== null) {
100
+ this.pausedTotalMs += Date.now() - this.pausedAtWall;
101
+ this.pausedAtWall = null;
102
+ }
103
+ return true;
104
+ }
105
+ snapshot() {
106
+ return {
107
+ startedAtWall: this.startedAtWall,
108
+ pausedTotalMs: this.pausedTotalMs,
109
+ pausedAtWall: this.pausedAtWall,
110
+ frozenBy: [...this.frozenBy]
111
+ };
112
+ }
113
+ /**
114
+ * 永続化した内容から復元する。欠損は「未停止」に倒す。
115
+ *
116
+ * 読めなかったせいで世界が止まったままになる方が、動き出すより悪い。
117
+ */
118
+ restore(saved) {
119
+ this.startedAtWall = saved.startedAtWall ?? 0;
120
+ this.pausedTotalMs = saved.pausedTotalMs ?? 0;
121
+ this.pausedAtWall = saved.pausedAtWall ?? null;
122
+ this.frozenBy.clear();
123
+ for (const reason of saved.frozenBy ?? []) this.frozenBy.add(reason);
124
+ if (this.frozen && this.pausedAtWall === null) this.frozenBy.clear();
125
+ }
126
+ };
127
+ /**
128
+ * 作家の**同期**ハンドラを、時刻 API を差し替えた状態で走らせる。
129
+ *
130
+ * `sdk/src/dev-prediction-traps.ts` の runPredicted と同じ形。あちらは dev で警告を
131
+ * 出すための差し替えだが、こちらはゲーム内時計を返すための差し替え。
132
+ * `await` を挟まないので、JS が単一スレッドである以上ほかの部屋と混ざらない。
133
+ *
134
+ * IMPORTANT: 3 実装すべてで通すこと。ここが片方だけだと、生 `Date.now()` を書いた
135
+ * シナリオが「dev では壊れるのに本番では動く」(あるいはその逆) になる。
136
+ *
137
+ * 適用しないもの:
138
+ * - `serverActions` — async なので `await` をまたぐと復元が効かない
139
+ * - `deadlines[].at()` — state だけの純関数という契約があり、毎 state 変更で呼ばれる
140
+ *
141
+ * IMPORTANT: `fn` は**同期で完結すること**。`await` をまたぐと差し替えたまま制御が
142
+ * 抜け、無関係な処理まで偽の `Date.now` を読む。`fn` が Promise を返す形にしたく
143
+ * なったら、それは `serverActions` 側へ寄せるべき処理という合図。
144
+ *
145
+ * IMPORTANT: `fn` の中で [GameClock.now] を呼ばないこと。差し替えた `Date.now` を
146
+ * 実時刻として引き算してしまう。ハンドラへ渡す ctx は呼び出す前に組み立てる。
147
+ */
148
+ function withGameClock(time, fn) {
149
+ const realNow = Date.now;
150
+ const RealDate = Date;
151
+ const ms = toMs(time);
152
+ Date.now = () => ms;
153
+ globalThis.Date = new Proxy(RealDate, { construct: (target, args) => Reflect.construct(target, args.length === 0 ? [ms] : args) });
154
+ try {
155
+ return fn();
156
+ } finally {
157
+ globalThis.Date = RealDate;
158
+ Date.now = realNow;
159
+ }
160
+ }
161
+ /**
162
+ * `deadlines[].at()` が返した値を締切として使えるかに正規化する。使えなければ null。
163
+ *
164
+ * `null` / `undefined` は「締切なし」で正常なので黙って捨てる。**数値だが有限でない
165
+ * (NaN / Infinity) 場合だけは記録に残す。** これはほぼ確実に `undefined` を含む算術の
166
+ * 結果で、旧 `ctx.now` を読んでいるシナリオを新エンジンで動かすと
167
+ * `undefined + 尺 = NaN` になり、締切が「二度と来ない」形で静かに死ぬ。
168
+ * 黙って捨てるとログにも例外にも出ないので、key ごとに 1 回だけ必ず出す。
169
+ *
170
+ * `report` を受け取るのは、engine-core が `console` を含むどのランタイム API にも
171
+ * 依存できないため (3 ランタイムが lib ES2022 だけでこのファイルを共有している)。
172
+ *
173
+ * @param warned 既に警告した key。呼び出し側がインスタンスごとに持つ。
174
+ */
175
+ function resolveDeadline(at, key, warned, report) {
176
+ if (at === null || at === void 0) return null;
177
+ if (typeof at === "number" && Number.isFinite(at)) return asGameTime(at);
178
+ if (!warned.has(key)) {
179
+ warned.add(key);
180
+ report(`deadline "${key}" の at() が締切に使えない値を返した: ${String(at)}。この締切は二度と発火しない。廃止された ctx.now を読んでいないか確認すること`);
181
+ }
182
+ return null;
183
+ }
184
+ //#endregion
11
185
  //#region ../engine-core/src/json-patch.ts
12
186
  function unescapePointer(token) {
13
187
  return token.replace(/~1/g, "/").replace(/~0/g, "~");
@@ -101,35 +275,119 @@ function isServerOnlyAction(handler) {
101
275
  /**
102
276
  * @docs
103
277
  * - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
278
+ * - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
104
279
  *
105
- * サーバー時刻の推定値をクライアント全体へ配る。
280
+ * サーバーのゲーム内時計の推定値をクライアント全体へ配る。
106
281
  *
107
- * state に入っている `endsAt` のような絶対時刻はサーバーの時計で打たれている。
108
- * それを端末の `Date.now()` と引き算すると、端末の時計ズレがそのまま表示のズレになる
109
- * (残り時間が恒久的に狂う / 期限判定がサーバーと食い違う)。読み取り側も同じ時計に
110
- * 揃えるための関数。
282
+ * state に入っている `endsAt` のような締切はサーバーのゲーム内時計で打たれている。
283
+ * 読み取り側も同じ時計に揃えないと、残り時間が恒久的に狂う。
111
284
  *
112
285
  * ```ts
113
- * const remain = Math.ceil((state.game.timerEndsAt - serverNow()) / 1000);
286
+ * const remain = Math.ceil(minus(state.game.timerEndsAt, gameTime()) / 1000);
114
287
  * ```
115
288
  *
289
+ * 基準は `performance.now()` (単調時計)。`Date.now()` を長さの計算に使うと、端末の時計が
290
+ * NTP やユーザー操作でずれた瞬間に全部のカウントダウンが飛ぶ。POSIX / Rust / C++ が
291
+ * 揃って禁じている形なので、ここでも採らない。
292
+ *
116
293
  * オフセットは transport がサーバーからのメッセージを受けるたびに更新する。
117
- * 未接続 / 未観測ならローカル時刻をそのまま返す (オフラインでも壊れない)。
294
+ * 未接続 / 未観測ならオフセット 0 のまま (オフラインでも壊れない)。
118
295
  */
119
296
  let offset = 0;
120
- /** transport から呼ぶ内部関数。サーバーが打刻した時刻を観測してオフセットを更新する。 */
121
- function observeServerTime(serverTime) {
122
- if (typeof serverTime !== "number" || !Number.isFinite(serverTime)) return;
123
- offset = serverTime - Date.now();
297
+ /** 停止中に凍らせた値。null なら進んでいる。 */
298
+ let frozenAt = null;
299
+ /**
300
+ * transport から呼ぶ内部関数。サーバーが打刻したゲーム内時刻を観測してオフセットを更新する。
301
+ *
302
+ * 下り片道遅延を無視するので推定は実サーバー時刻より僅かに遅れる。
303
+ * 表示用途では無視できる誤差なので、精度より単純さを取る。
304
+ */
305
+ function observeGameTime(t) {
306
+ if (typeof t !== "number" || !Number.isFinite(t)) return;
307
+ offset = t - performance.now();
124
308
  }
125
309
  /**
126
- * サーバー時刻の推定値 (ms)。
310
+ * ゲーム内時刻の推定値。停止中は凍る。
127
311
  *
128
312
  * カウントダウン描画のように毎秒/毎フレーム呼ぶ用途を想定しているので、
129
313
  * state の到着とは無関係にいつでも呼べる。
130
314
  */
131
- function serverNow() {
132
- return Date.now() + offset;
315
+ function gameTime() {
316
+ return frozenAt ?? asGameTime(performance.now() + offset);
317
+ }
318
+ /**
319
+ * 時計を凍らせる。
320
+ *
321
+ * 停止中はサーバーの broadcast が止まって打刻が来ないので、`__pause_state` を受けた
322
+ * 時点の値で明示的に止める。解除後は通常の打刻でオフセットが入れ替わるため、
323
+ * 特別な補正は要らない。
324
+ */
325
+ function freezeGameTime() {
326
+ frozenAt = gameTime();
327
+ }
328
+ function unfreezeGameTime() {
329
+ frozenAt = null;
330
+ }
331
+ //#endregion
332
+ //#region src/pause-state.ts
333
+ /**
334
+ * @docs
335
+ * - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
336
+ *
337
+ * 緊急停止の状態を SDK 全体で 1 箇所に持つ。
338
+ *
339
+ * 停止は state ではない。シナリオの state に持ち込むと「停止中は state を変えるコードが
340
+ * 1 行も走らない」という不変条件が崩れるので、`GameLogic` へは渡さず読み取り専用の
341
+ * 関数として出す。用途は演出の停止 (rAF ループを止める等) に限る。
342
+ *
343
+ * ゲーム内時計の凍結もここから駆動する。停止フラグと時計の凍結が別々に動くと、
344
+ * 「止まっているのにカウントダウンだけ進む」がいつか必ず起きる。
345
+ */
346
+ let paused = false;
347
+ let pausedBy = null;
348
+ const listeners = /* @__PURE__ */ new Set();
349
+ /** transport から呼ぶ内部関数。停止状態が変わったときだけ通知する。 */
350
+ function setPauseState(next, by) {
351
+ pausedBy = next ? by : null;
352
+ if (next === paused) return;
353
+ paused = next;
354
+ if (paused) freezeGameTime();
355
+ else unfreezeGameTime();
356
+ for (const cb of listeners) try {
357
+ cb(paused);
358
+ } catch (err) {
359
+ console.warn("[uzu] onPauseChange listener threw:", err);
360
+ }
361
+ }
362
+ /** 緊急停止中か。 */
363
+ function isPaused() {
364
+ return paused;
365
+ }
366
+ /** 停止を要求した playerId。停止していなければ null。 */
367
+ function getPausedBy() {
368
+ return pausedBy;
369
+ }
370
+ /** 停止状態の変化を購読する。 */
371
+ function onPauseChange(cb) {
372
+ listeners.add(cb);
373
+ }
374
+ let requester = null;
375
+ function setPauseRequester(fn) {
376
+ requester = fn;
377
+ }
378
+ function requestPause() {
379
+ if (!requester) {
380
+ console.warn("[uzu] requestPause: サーバーへ接続していないので停止できません");
381
+ return;
382
+ }
383
+ requester(true);
384
+ }
385
+ function requestResume() {
386
+ if (!requester) {
387
+ console.warn("[uzu] requestResume: サーバーへ接続していないので解除できません");
388
+ return;
389
+ }
390
+ requester(false);
133
391
  }
134
392
  //#endregion
135
393
  //#region src/room.ts
@@ -578,61 +836,75 @@ function replaceRoot(doc, value) {
578
836
  }
579
837
  //#endregion
580
838
  //#region src/dev-prediction-traps.ts
581
- const TRAPS = [
582
- { install: (report) => {
583
- const RealDate = Date;
584
- const realNow = Date.now;
585
- globalThis.Date = new Proxy(RealDate, { construct: (target, args) => {
586
- if (args.length === 0) report("new Date()");
587
- return Reflect.construct(target, args);
588
- } });
589
- Date.now = () => {
590
- report("Date.now()");
591
- return realNow();
592
- };
593
- return () => {
594
- Date.now = realNow;
595
- globalThis.Date = RealDate;
596
- };
597
- } },
598
- { install: (report) => {
599
- const real = Math.random;
600
- Math.random = () => {
601
- report("Math.random()");
602
- return real();
603
- };
604
- return () => {
605
- Math.random = real;
606
- };
607
- } },
608
- { install: (report) => {
609
- const real = globalThis.crypto?.randomUUID;
610
- if (!real) return () => {};
611
- const bound = real.bind(globalThis.crypto);
612
- globalThis.crypto.randomUUID = () => {
613
- report("crypto.randomUUID()");
614
- return bound();
615
- };
616
- return () => {
617
- globalThis.crypto.randomUUID = real;
618
- };
619
- } },
620
- { install: (report) => {
621
- const real = globalThis.performance?.now;
622
- if (!real) return () => {};
623
- const bound = real.bind(globalThis.performance);
624
- globalThis.performance.now = () => {
625
- report("performance.now()");
626
- return bound();
627
- };
628
- return () => {
629
- globalThis.performance.now = real;
630
- };
631
- } }
632
- ];
839
+ /** 描画経路の警告に使う擬似 action 名。 */
840
+ const RENDER_CONTEXT = "(render)";
841
+ const CLOCK_TRAPS = [{ install: (report) => {
842
+ const RealDate = Date;
843
+ const realNow = Date.now;
844
+ globalThis.Date = new Proxy(RealDate, { construct: (target, args) => {
845
+ if (args.length === 0) report("new Date()");
846
+ return Reflect.construct(target, args);
847
+ } });
848
+ Date.now = () => {
849
+ report("Date.now()");
850
+ return realNow();
851
+ };
852
+ return () => {
853
+ Date.now = realNow;
854
+ globalThis.Date = RealDate;
855
+ };
856
+ } }, { install: (report) => {
857
+ const real = globalThis.performance?.now;
858
+ if (!real) return () => {};
859
+ const bound = real.bind(globalThis.performance);
860
+ globalThis.performance.now = () => {
861
+ report("performance.now()");
862
+ return bound();
863
+ };
864
+ return () => {
865
+ globalThis.performance.now = real;
866
+ };
867
+ } }];
868
+ /** 乱数系。先読みでだけ問題になるので描画経路には張らない。 */
869
+ const RANDOM_TRAPS = [{ install: (report) => {
870
+ const real = Math.random;
871
+ Math.random = () => {
872
+ report("Math.random()");
873
+ return real();
874
+ };
875
+ return () => {
876
+ Math.random = real;
877
+ };
878
+ } }, { install: (report) => {
879
+ const real = globalThis.crypto?.randomUUID;
880
+ if (!real) return () => {};
881
+ const bound = real.bind(globalThis.crypto);
882
+ globalThis.crypto.randomUUID = () => {
883
+ report("crypto.randomUUID()");
884
+ return bound();
885
+ };
886
+ return () => {
887
+ globalThis.crypto.randomUUID = real;
888
+ };
889
+ } }];
890
+ const TRAPS = [...CLOCK_TRAPS, ...RANDOM_TRAPS];
633
891
  const warnings = [];
634
892
  const reported = /* @__PURE__ */ new Set();
635
893
  const printWarning = (action, api) => {
894
+ if (action === RENDER_CONTEXT) {
895
+ console.groupCollapsed(`⚠️ [uzu-code] 描画中に ${api} を呼びました`);
896
+ console.log([
897
+ `${api} は Unix epoch の実時刻を返します。state に入っている締切は`,
898
+ "ゲーム内時刻 (ゲーム開始からの経過 ms) なので、引き算すると桁が合いません。",
899
+ "また実時刻は緊急停止中も進むので、止まっているのにカウントダウンだけ動きます。",
900
+ "",
901
+ "直し方: 時刻は gameTime() から取る。",
902
+ " import { gameTime, minus } from '@uzuhq/code-sdk';",
903
+ " const remain = Math.ceil(minus(state.timerEndsAt, gameTime()) / 1000);"
904
+ ].join("\n"));
905
+ console.groupEnd();
906
+ return;
907
+ }
636
908
  console.groupCollapsed(`⚠️ [uzu-code] action "${action}" が先読み中に ${api} を呼びました`);
637
909
  console.log([
638
910
  "先読み (楽観的更新) は「サーバーと同じコードを同じ入力で走らせれば同じ結果になる」",
@@ -670,12 +942,24 @@ const notifyParent = (action, api) => {
670
942
  * Flutter native ホスト (本番) では計装せず素通しする。判定基準は dev hooks と同じ。
671
943
  */
672
944
  const runPredicted = (action, run) => {
945
+ runInstrumented(action, run);
946
+ };
947
+ /**
948
+ * `onState` から同期で走る描画を計装する。
949
+ *
950
+ * 乱数は描画で使ってよいので、時刻の罠だけ張る。ここで拾えるのは同期の描画だけで、
951
+ * `requestAnimationFrame` の中は原理的に拾えない。
952
+ */
953
+ const runRendered = (run) => {
954
+ runInstrumented(RENDER_CONTEXT, run);
955
+ };
956
+ const runInstrumented = (action, run) => {
673
957
  if (typeof window === "undefined" || window.FlutterHost) {
674
958
  run();
675
959
  return;
676
960
  }
677
961
  const report = (api) => {
678
- const key = `${action}${api}`;
962
+ const key = `${action}\u0000${api}`;
679
963
  if (reported.has(key)) return;
680
964
  reported.add(key);
681
965
  warnings.push({
@@ -685,7 +969,7 @@ const runPredicted = (action, run) => {
685
969
  printWarning(action, api);
686
970
  notifyParent(action, api);
687
971
  };
688
- const restores = TRAPS.map((trap) => trap.install(report));
972
+ const restores = (action === RENDER_CONTEXT ? CLOCK_TRAPS : TRAPS).map((trap) => trap.install(report));
689
973
  try {
690
974
  run();
691
975
  } finally {
@@ -810,7 +1094,7 @@ function createOptimisticActionClient(config) {
810
1094
  let actionSeq = 0;
811
1095
  /**
812
1096
  * 送信済みだがサーバー未確認の action キュー。
813
- * `now` は送信時に推定した値。再適用でも同じ値を使う (取り直すと表示がガタつく)。
1097
+ * `time` は送信時に推定した値。再適用でも同じ値を使う (取り直すと表示がガタつく)。
814
1098
  */
815
1099
  const pendingActions = [];
816
1100
  /**
@@ -866,7 +1150,7 @@ function createOptimisticActionClient(config) {
866
1150
  displayState = structuredClone(confirmedState);
867
1151
  let i = 0;
868
1152
  while (i < pendingActions.length) {
869
- const { action, payload, now } = pendingActions[i];
1153
+ const { action, payload, time } = pendingActions[i];
870
1154
  const handler = logic.actions[action];
871
1155
  if (!handler || isServerOnlyAction(handler)) {
872
1156
  pendingActions.splice(i, 1);
@@ -879,7 +1163,8 @@ function createOptimisticActionClient(config) {
879
1163
  payload,
880
1164
  playerId,
881
1165
  ctx: {
882
- now,
1166
+ time,
1167
+ after: (d) => plus(time, d),
883
1168
  emit: noopEmit
884
1169
  }
885
1170
  }));
@@ -906,12 +1191,16 @@ function createOptimisticActionClient(config) {
906
1191
  };
907
1192
  return {
908
1193
  send(type, payload) {
1194
+ if (isPaused()) {
1195
+ console.warn(`[uzu] 緊急停止中は action を送れません (action="${type}")`);
1196
+ return;
1197
+ }
909
1198
  actionSeq++;
910
1199
  const seq = actionSeq;
911
1200
  const handler = logic.actions[type];
912
1201
  if (!handler && !logic.serverActions?.[type]) console.error(`[uzu] unknown action: "${type}". logic.actions / logic.serverActions のどちらにも登録されていません。`);
913
1202
  const target = displayState;
914
- const now = serverNow();
1203
+ const time = gameTime();
915
1204
  const predictEmit = (eventName, data) => {
916
1205
  const subscription = events?.[eventName];
917
1206
  if (!subscription?.predict) return;
@@ -929,7 +1218,8 @@ function createOptimisticActionClient(config) {
929
1218
  payload: payload ?? {},
930
1219
  playerId,
931
1220
  ctx: {
932
- now,
1221
+ time,
1222
+ after: (d) => plus(time, d),
933
1223
  emit: predictEmit
934
1224
  }
935
1225
  }));
@@ -937,7 +1227,7 @@ function createOptimisticActionClient(config) {
937
1227
  seq,
938
1228
  action: type,
939
1229
  payload: payload ?? {},
940
- now
1230
+ time
941
1231
  });
942
1232
  onState(target, playerId);
943
1233
  } catch {}
@@ -947,8 +1237,8 @@ function createOptimisticActionClient(config) {
947
1237
  seq
948
1238
  });
949
1239
  },
950
- observeServerTime(serverTime) {
951
- observeServerTime(serverTime);
1240
+ observeGameTime(t) {
1241
+ observeGameTime(t);
952
1242
  },
953
1243
  applyState(state, options = {}) {
954
1244
  handleAck(options.ack, options.from, options.events ?? []);
@@ -1013,6 +1303,10 @@ function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, se
1013
1303
  }
1014
1304
  }
1015
1305
  });
1306
+ setPauseRequester((paused) => {
1307
+ console.log(`[SDK ServerAction] ➡ send ${paused ? "__pause" : "__resume"}`);
1308
+ ws.send(JSON.stringify({ type: paused ? "__pause" : "__resume" }));
1309
+ });
1016
1310
  /** サーバー seq (delta の連続性チェック用) */
1017
1311
  let serverSeq = 0;
1018
1312
  /** フル state 再要求中フラグ (多重要求防止) */
@@ -1052,7 +1346,13 @@ function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, se
1052
1346
  }
1053
1347
  const msgType = parsed.type;
1054
1348
  console.log(`[SDK ServerAction] ⬅ recv type=${msgType}`);
1055
- client.observeServerTime(parsed.serverTime);
1349
+ client.observeGameTime(parsed.gameTime);
1350
+ if (msgType === "__pause_state") {
1351
+ const frozen = parsed.frozen === true;
1352
+ console.log(`[SDK ServerAction] ${frozen ? "⏸" : "▶️"} pause_state frozen=${frozen}`);
1353
+ setPauseState(frozen, parsed.by ?? null);
1354
+ return;
1355
+ }
1056
1356
  if (msgType === "__room_init") {
1057
1357
  console.log(`[SDK ServerAction] ✅ Room init myId=${parsed.myId}`);
1058
1358
  return;
@@ -1130,6 +1430,7 @@ function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, se
1130
1430
  serverSeq = parsed.seq ?? 0;
1131
1431
  requestStatePending = false;
1132
1432
  console.log(`[SDK ServerAction] 🔄 State restored (reconnect/late join) seq=${serverSeq}`);
1433
+ setPauseState(parsed.frozen === true, parsed.pausedBy ?? null);
1133
1434
  client.reset(parsed.state);
1134
1435
  return;
1135
1436
  }
@@ -1139,8 +1440,21 @@ function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, se
1139
1440
  //#region src/run/local-server-action.ts
1140
1441
  function runLocalServerAction(config) {
1141
1442
  const { logic, inputs, events } = config;
1142
- const onState = (next, id) => config.onState(next, id, "player");
1443
+ const onState = (next, id) => {
1444
+ observeGameTime(gameTime());
1445
+ config.onState(next, id, "player");
1446
+ };
1143
1447
  const tickRate = logic.tickRate ?? 0;
1448
+ const clock = new GameClock();
1449
+ const gameTime = () => clock.now();
1450
+ const frozen = () => clock.frozen;
1451
+ /** ハンドラへ渡す ctx の時刻部分。1 回の呼び出し内で値が動かないよう束ねる。 */
1452
+ const timeCtx = (t) => ({
1453
+ time: t,
1454
+ after: (d) => plus(t, d)
1455
+ });
1456
+ /** at() が壊れた値を返したと既に記録した締切。ログを 1 回に絞るため。 */
1457
+ const warnedDeadlines = /* @__PURE__ */ new Set();
1144
1458
  const random = new SeededRandomImpl(Math.floor(Math.random() * 4294967295));
1145
1459
  const players = Array.from({ length: config.playerCount }, (_, i) => ({
1146
1460
  id: `local_${i}`,
@@ -1154,14 +1468,15 @@ function runLocalServerAction(config) {
1154
1468
  if (!logic.deadlines) return null;
1155
1469
  let earliest = null;
1156
1470
  for (const [key, deadline] of Object.entries(logic.deadlines)) {
1157
- let at;
1471
+ let raw;
1158
1472
  try {
1159
- at = deadline.at({ state });
1473
+ raw = deadline.at({ state });
1160
1474
  } catch (err) {
1161
1475
  console.error(`[Deadline] ❌ ${key}.at() で例外`, err);
1162
1476
  continue;
1163
1477
  }
1164
- if (typeof at !== "number" || !Number.isFinite(at)) continue;
1478
+ const at = resolveDeadline(raw, key, warnedDeadlines, (m) => console.error(`[Deadline] ❌ ${m}`));
1479
+ if (at === null) continue;
1165
1480
  if (earliest === null || at < earliest) earliest = at;
1166
1481
  }
1167
1482
  return earliest;
@@ -1169,28 +1484,28 @@ function runLocalServerAction(config) {
1169
1484
  const fireDue = () => {
1170
1485
  wakeupTimer = null;
1171
1486
  wakeupAt = null;
1172
- if (!logic.deadlines) return;
1173
- const now = Date.now();
1487
+ if (!logic.deadlines || frozen()) return;
1488
+ const time = gameTime();
1174
1489
  const evts = [];
1175
1490
  const firedKeys = [];
1176
1491
  for (const [key, deadline] of Object.entries(logic.deadlines)) {
1177
1492
  const pending = [];
1178
1493
  let snapshot = null;
1179
1494
  try {
1180
- const at = deadline.at({ state });
1181
- if (typeof at !== "number" || !Number.isFinite(at) || at > now) continue;
1495
+ const at = resolveDeadline(deadline.at({ state }), key, warnedDeadlines, (m) => console.error(`[Deadline] ❌ ${m}`));
1496
+ if (at === null || at > time) continue;
1182
1497
  snapshot = structuredClone(state);
1183
- deadline.handler({
1498
+ withGameClock(time, () => deadline.handler({
1184
1499
  state,
1185
1500
  ctx: {
1186
- now,
1501
+ ...timeCtx(time),
1187
1502
  random,
1188
1503
  emit: (name, data) => pending.push({
1189
1504
  name,
1190
1505
  data: data ?? {}
1191
1506
  })
1192
1507
  }
1193
- });
1508
+ }));
1194
1509
  evts.push(...pending);
1195
1510
  firedKeys.push(key);
1196
1511
  } catch (err) {
@@ -1205,29 +1520,35 @@ function runLocalServerAction(config) {
1205
1520
  onState(state, myId);
1206
1521
  };
1207
1522
  const syncWakeup = () => {
1208
- const next = nextDeadline();
1523
+ const next = frozen() ? null : nextDeadline();
1209
1524
  if (next === wakeupAt) return;
1210
1525
  if (wakeupTimer) clearTimeout(wakeupTimer);
1211
1526
  wakeupTimer = null;
1212
1527
  wakeupAt = next;
1213
1528
  if (next === null) return;
1214
- wakeupTimer = setTimeout(fireDue, Math.max(0, next - Date.now()));
1529
+ wakeupTimer = setTimeout(fireDue, Math.max(0, clock.toWall(next) - Date.now()));
1215
1530
  };
1216
1531
  const dispatchEvents = (evts) => {
1217
1532
  for (const e of evts) events?.[e.name]?.handler(e.data);
1218
1533
  };
1534
+ clock.restart();
1535
+ const setupTime = gameTime();
1219
1536
  const setupArgs = {
1220
1537
  players,
1221
1538
  seats: players,
1222
1539
  ctx: {
1223
1540
  random,
1224
- now: Date.now()
1541
+ ...timeCtx(setupTime)
1225
1542
  }
1226
1543
  };
1227
- let state = logic.setup(setupArgs);
1544
+ let state = withGameClock(setupTime, () => logic.setup(setupArgs));
1228
1545
  let tick = 0;
1229
1546
  const playerInputs = {};
1230
1547
  const dispatchAction = (type, payload) => {
1548
+ if (frozen()) {
1549
+ console.warn(`[SDK LocalServerAction] 緊急停止中は action を実行しません (action="${type}")`);
1550
+ return;
1551
+ }
1231
1552
  const plain = logic.actions[type];
1232
1553
  const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
1233
1554
  const server = logic.serverActions?.[type] ?? legacyServerOnly;
@@ -1242,18 +1563,18 @@ function runLocalServerAction(config) {
1242
1563
  name,
1243
1564
  data: data ?? {}
1244
1565
  });
1245
- const now = Date.now();
1566
+ const time = gameTime();
1246
1567
  if (plain && !legacyServerOnly) {
1247
1568
  try {
1248
- plain({
1569
+ withGameClock(time, () => plain({
1249
1570
  state,
1250
1571
  payload: payload ?? {},
1251
1572
  playerId: myId,
1252
1573
  ctx: {
1253
- now,
1574
+ ...timeCtx(time),
1254
1575
  emit: plainEmit
1255
1576
  }
1256
- });
1577
+ }));
1257
1578
  } catch (err) {
1258
1579
  console.warn("[SDK LocalServerAction] Action error:", err);
1259
1580
  return;
@@ -1272,7 +1593,7 @@ function runLocalServerAction(config) {
1272
1593
  ctx: {
1273
1594
  tick,
1274
1595
  random,
1275
- now,
1596
+ ...timeCtx(time),
1276
1597
  emit: serverEmit
1277
1598
  }
1278
1599
  });
@@ -1285,26 +1606,42 @@ function runLocalServerAction(config) {
1285
1606
  onState(state, myId);
1286
1607
  })();
1287
1608
  };
1609
+ /**
1610
+ * ソロの停止 / 解除。サーバーが居ないので自分の時計を直接触る。
1611
+ *
1612
+ * `__uzu_dev.pauseTick()` とは別物。あちらは tick を止めるだけの開発者ツールで、
1613
+ * ゲーム内時計も action ゲートも持たない。流用しない。
1614
+ */
1615
+ const applyPause = (paused) => {
1616
+ if (!(paused ? clock.freeze("emergency-stop") : clock.unfreeze("emergency-stop"))) return;
1617
+ observeGameTime(gameTime());
1618
+ setPauseState(paused, paused ? myId : null);
1619
+ observeGameTime(gameTime());
1620
+ syncWakeup();
1621
+ };
1622
+ setPauseRequester(applyPause);
1288
1623
  inputs(dispatchAction);
1289
1624
  syncWakeup();
1290
1625
  onState(state, myId);
1291
1626
  if (tickRate > 0) setInterval(() => {
1627
+ if (frozen()) return;
1292
1628
  const tickEvents = [];
1293
1629
  const tickEmit = (name, data) => tickEvents.push({
1294
1630
  name,
1295
1631
  data: data ?? {}
1296
1632
  });
1633
+ const time = gameTime();
1297
1634
  try {
1298
- logic.update({
1635
+ withGameClock(time, () => logic.update({
1299
1636
  state,
1300
1637
  ctx: {
1301
1638
  random,
1302
1639
  tick,
1303
- now: Date.now(),
1640
+ ...timeCtx(time),
1304
1641
  emit: tickEmit,
1305
1642
  playerInputs
1306
1643
  }
1307
- });
1644
+ }));
1308
1645
  } catch (err) {
1309
1646
  console.error(`[SDK LocalServerAction] tick error at tick=${tick}:`, err);
1310
1647
  tick++;
@@ -1615,6 +1952,12 @@ function init(opts) {
1615
1952
  const { x: fallbackX, y: fallbackY } = calcHudInsets(params);
1616
1953
  document.documentElement.style.setProperty("--uzu-hud-inset-x", `${hudX ?? fallbackX}px`);
1617
1954
  document.documentElement.style.setProperty("--uzu-hud-inset-y", `${hudY ?? fallbackY}px`);
1955
+ onPauseChange((paused) => {
1956
+ sendRaw("sdk", "pauseState", {
1957
+ paused,
1958
+ by: getPausedBy()
1959
+ });
1960
+ });
1618
1961
  sendRaw("sdk", "ready", {});
1619
1962
  _initialized = true;
1620
1963
  }
@@ -1716,7 +2059,7 @@ function run(config) {
1716
2059
  serverTime: 0,
1717
2060
  myId: myPlayerId
1718
2061
  };
1719
- origOnState(state, myPlayerId, mySeatKind);
2062
+ runRendered(() => origOnState(state, myPlayerId, mySeatKind));
1720
2063
  notifyDevSnapshot(state);
1721
2064
  }
1722
2065
  };
@@ -1848,6 +2191,14 @@ function handleMessage(msg) {
1848
2191
  _playersChangedHandlers.forEach((fn) => fn(players));
1849
2192
  return;
1850
2193
  }
2194
+ case "requestPause":
2195
+ console.log(`[SDK] ⏸ handleMessage sdk/requestPause`);
2196
+ requestPause();
2197
+ return;
2198
+ case "requestResume":
2199
+ console.log(`[SDK] ▶️ handleMessage sdk/requestResume`);
2200
+ requestResume();
2201
+ return;
1851
2202
  }
1852
2203
  return;
1853
2204
  }
@@ -1886,4 +2237,4 @@ function calcHudInsets(params) {
1886
2237
  };
1887
2238
  }
1888
2239
  //#endregion
1889
- export { DEFAULT_ICON_URLS, ReconnectableWebSocket, Room, SERVER_TIME, SeededRandomImpl, applyJsonMergePatch, applyJsonPatch, attachDevHooks, changeRoom, createDevHooks, firstFrameReady, gameReady, getPredictionWarnings, getRoom, init, isHosted, isServerOnlyAction, on, onPlayersChanged, onRoom, playBgm, playSound, run, send, serverNow, serverOnly, setMicEnabled, stopBgm, sync };
2240
+ 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, sub, sync };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-sdk",
3
- "version": "0.7.7",
3
+ "version": "0.8.1",
4
4
  "description": "UZU PlayScreen SDK - Flutter ↔ JS ゲーム通信ライブラリ",
5
5
  "type": "module",
6
6
  "exports": {