@uzuhq/code-sdk 0.7.1 → 0.7.3

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
@@ -271,8 +271,34 @@ const logic: GameLogic<MyState> = {
271
271
  ハンドラの引数は 1 つのオブジェクトで、使うものだけ書けばよい。
272
272
  `state` / `payload` / `playerId` はその呼び出しの事実、`ctx` は実行環境が与えるもの。
273
273
 
274
- 時刻起点で何かを起こしたい場合は `tickRate` で毎秒ポーリングせず `ctx.schedule()` を使う。
275
- サーバーが指定時刻に自分で起きて action を撃つので、その間 Durable Object が hibernate できる。
274
+ #### `deadlines`
275
+
276
+ 「state のこの時刻を過ぎたらこれをする」を宣言する。サーバーが最も早い `at` に合わせて
277
+ 自分で起き、過ぎた締切の `handler` を呼ぶ。
278
+
279
+ ```ts
280
+ deadlines: {
281
+ phaseTimer: {
282
+ at: ({ state }) => state.timerEndsAt, // null / undefined なら締切なし
283
+ handler: ({ state, ctx }) => {
284
+ state.phase = nextPhase(state);
285
+ ctx.emit('phase.changed', { phase: state.phase });
286
+ },
287
+ },
288
+ },
289
+ ```
290
+
291
+ | | |
292
+ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
293
+ | `at` | 締切の絶対時刻 (ms)。`null` / `undefined` で締切なし (optional chain の結果をそのまま返せる)。**state だけから決まる軽い関数にすること** (state が変わるたびに呼ばれる) |
294
+ | `handler` | サーバーでのみ走る。`ctx` は `{ now, random, emit }` |
295
+
296
+ 締切は state から導出するので、**予約を張り替える処理を書かなくてよい**。action が throw して
297
+ state が巻き戻れば締切も一緒に巻き戻る。発火直前に `at` を評価し直すので、二重発火を
298
+ handler 側で弾く必要も無い。
299
+
300
+ 時刻をきっかけに何かを起こすなら `tickRate` で毎秒ポーリングせずこちらを使う。
301
+ ポーリング中は Durable Object が hibernate できない。
276
302
 
277
303
  #### `serverOnly(handler)`
278
304
 
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `run()` 経由の `inputs` でも同じ縛りが効くことの検証。
3
+ *
4
+ * `SendAction<A & SA>` は `SA` が `Record<string, …>` に落ちた瞬間
5
+ * `keyof` が `string` へ潰れて action 名の検査が丸ごと消える。 直接
6
+ * `SendAction<…>` を declare するだけの検査では踏めないので、 実際に
7
+ * `run()` を通す形で押さえる。
8
+ *
9
+ * 型検査だけが目的なので関数は呼ばない (`run` は window を触る)。
10
+ */
11
+ export declare function _runTypeChecks(): void;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * action 名と payload が型で縛られることの検証。
3
+ *
4
+ * 実行時の振る舞いは無いので、 tsc が通ること自体がテストになる。
5
+ * @ts-expect-error が「実際にエラーになる」ことも同時に確かめている
6
+ * (エラーが出なくなったら @ts-expect-error 自体が未使用エラーになる)。
7
+ *
8
+ * SDK は値を出さない。 logic は `satisfies` でキーを保ってから型注釈で組み立てる。
9
+ * 値を export すると scenario の logic.ts が SDK を実行時 import することになり、
10
+ * サーバー用 bundle (Workers) で解決できず落ちる。
11
+ */
12
+ import { run } from './index.js';
13
+ const actions = {
14
+ // payload に型を付けた handler
15
+ 'vote.submit': ({ state, payload }) => {
16
+ state.count += payload.optionId.length;
17
+ },
18
+ // 注釈のない handler は payload: any のまま (段階的移行)
19
+ noop: ({ state }) => {
20
+ state.count += 1;
21
+ },
22
+ };
23
+ const serverActions = {
24
+ 'server.only': ({ payload }) => {
25
+ payload.token.trim();
26
+ },
27
+ };
28
+ const _logic = {
29
+ setup: () => ({ count: 0 }),
30
+ actions,
31
+ serverActions,
32
+ update: () => { },
33
+ };
34
+ send('vote.submit', { optionId: 'a' });
35
+ send('noop', { anything: 1 });
36
+ send('noop'); // 注釈なし (any) は省略できる
37
+ send('server.only', { token: 't' });
38
+ // @ts-expect-error 存在しない action 名
39
+ send('vote.submi', { optionId: 'a' });
40
+ // @ts-expect-error payload の形違い
41
+ send('vote.submit', { wrong: 1 });
42
+ // @ts-expect-error payload が必須の action で省略はできない
43
+ send('vote.submit');
44
+ /**
45
+ * `run()` 経由の `inputs` でも同じ縛りが効くことの検証。
46
+ *
47
+ * `SendAction<A & SA>` は `SA` が `Record<string, …>` に落ちた瞬間
48
+ * `keyof` が `string` へ潰れて action 名の検査が丸ごと消える。 直接
49
+ * `SendAction<…>` を declare するだけの検査では踏めないので、 実際に
50
+ * `run()` を通す形で押さえる。
51
+ *
52
+ * 型検査だけが目的なので関数は呼ばない (`run` は window を触る)。
53
+ */
54
+ export function _runTypeChecks() {
55
+ // serverActions を持たない logic (SA が既定値に落ちる経路)
56
+ run({
57
+ logic: { setup: () => ({ count: 0 }), actions, update: () => { } },
58
+ onState: () => { },
59
+ playerCount: 1,
60
+ inputs: (sendAction) => {
61
+ sendAction('vote.submit', { optionId: 'a' });
62
+ sendAction('noop');
63
+ // @ts-expect-error 存在しない action 名
64
+ sendAction('vote.submi', { optionId: 'a' });
65
+ // @ts-expect-error payload の形違い
66
+ sendAction('vote.submit', { wrong: 1 });
67
+ },
68
+ });
69
+ // 型引数を書かない呼び出しは A が推論されるので検査が効く
70
+ run({
71
+ logic: { setup: () => ({ count: 0 }), actions, update: () => { } },
72
+ onState: () => { },
73
+ playerCount: 1,
74
+ inputs: (sendAction) => {
75
+ sendAction('vote.submit', { optionId: 'a' });
76
+ // @ts-expect-error 存在しない action 名
77
+ sendAction('vote.submi', { optionId: 'a' });
78
+ },
79
+ });
80
+ // 既存シナリオの `run<State>({…})`。 A に既定値が無いと TS2558 で落ちる。
81
+ // 検査は効かなくなるが、 型引数を足さずに済むこと自体が後方互換の条件。
82
+ run({
83
+ logic: { setup: () => ({ count: 0 }), actions, update: () => { } },
84
+ onState: () => { },
85
+ playerCount: 1,
86
+ inputs: (sendAction) => {
87
+ sendAction('vote.submit', { optionId: 'a' });
88
+ },
89
+ });
90
+ // serverActions を持つ logic (SA が推論される経路)
91
+ run({
92
+ logic: { setup: () => ({ count: 0 }), actions, serverActions, update: () => { } },
93
+ onState: () => { },
94
+ playerCount: 1,
95
+ inputs: (sendAction) => {
96
+ sendAction('server.only', { token: 't' });
97
+ // @ts-expect-error 存在しない action 名
98
+ sendAction('server.onl', { token: 't' });
99
+ },
100
+ });
101
+ }
package/dist/index.d.ts CHANGED
@@ -15,8 +15,8 @@
15
15
  * scenario ディレクトリで `uzu dev` を実行するだけで良い。 子 iframe は既存 online mode
16
16
  * (`?server=ws://localhost:<port>` 経路) で dev-server に接続する。
17
17
  */
18
- export type { PlayScreenMessage, BridgeChannel, BridgeMessage, Seat, SeatKind, Emit, ServerEvent, SeededRandom, GameLogic, GameConfig, SyncConfig, PatchFn, SetFn, Operation, ConnectionState, ConnectionCallbacks, PlayerVoiceState, PlayersChangedMessage, ActionArgs, ActionContext, ActionHandler, ServerActionArgs, SetupArgs, SetupContext, UpdateArgs, UpdateContext, EventHandler, EventSubscription, ScheduleOptions, Scheduler, ServerActionContext, ServerActionHandler, ServerOnlyAction, ServerOnlyActionContext, ServerOnlyActionHandlerFn, } from './types.js';
19
- export { SERVER_TIME, DEFAULT_ICON_URLS, SCHEDULED_ACTOR } from './types.js';
18
+ export type { PlayScreenMessage, BridgeChannel, BridgeMessage, Seat, SeatKind, Emit, ServerEvent, SeededRandom, GameLogic, GameConfig, SyncConfig, PatchFn, SetFn, Operation, ConnectionState, ConnectionCallbacks, PlayerVoiceState, PlayersChangedMessage, ActionArgs, ActionContext, ActionHandler, ServerActionArgs, SetupArgs, SetupContext, UpdateArgs, UpdateContext, EventHandler, EventSubscription, Deadline, DeadlineArgs, DeadlineContext, ServerActionContext, ServerActionHandler, ServerOnlyAction, ServerOnlyActionContext, ServerOnlyActionHandlerFn, ActionMap, ServerActionMap, PayloadMap, SendAction, } from './types.js';
19
+ export { SERVER_TIME, DEFAULT_ICON_URLS } from './types.js';
20
20
  export { serverOnly, isServerOnlyAction } from './server-only.js';
21
21
  export { serverNow } from './server-clock.js';
22
22
  export { Room } from './room.js';
@@ -30,7 +30,7 @@ export { attachDevHooks, createDevHooks } from './dev-hooks.js';
30
30
  export type { UzuDevHooks, DevHooksCtx, RunHandle, SyncHandle } from './dev-hooks.js';
31
31
  export { getPredictionWarnings } from './dev-prediction-traps.js';
32
32
  export type { PredictionWarning } from './dev-prediction-traps.js';
33
- import type { BridgeMessage, GameConfig, SyncConfig, PlayerVoiceState } from './types.js';
33
+ import type { ActionMap, ServerActionMap, BridgeMessage, GameConfig, SyncConfig, PlayerVoiceState } from './types.js';
34
34
  import type { RoomLike } from './room.js';
35
35
  type GameMessageHandler = (payload: Record<string, unknown>) => void;
36
36
  type PlayersChangedHandler = (players: Record<string, PlayerVoiceState>) => void;
@@ -103,5 +103,14 @@ export declare function firstFrameReady(): void;
103
103
  export declare function gameReady(): void;
104
104
  /** Flutter からの playersChanged メッセージを受信するハンドラを登録する。 */
105
105
  export declare function onPlayersChanged(handler: PlayersChangedHandler): void;
106
- export declare function run<S>(config: GameConfig<S>): void;
106
+ /**
107
+ * `A` に既定値が要る。 TypeScript は型引数の部分推論ができないので、 既定が無いと
108
+ * 既存の `run<State>({…})` が「2 つ必要」で落ちる。 既定を置けば型引数を書かない
109
+ * 呼び出しは `A` が推論されて検査が効き、 `run<State>` は従来どおり通る。
110
+ *
111
+ * `SA` の既定が `ServerActionMap<S>` (= `Record<string, …>`) だと
112
+ * `keyof (A & SA)` が `string` へ潰れ、action 名の検査が丸ごと消える。
113
+ * `serverActions` を持たない logic でもキーが残るよう空 map を既定にする。
114
+ */
115
+ export declare function run<S, A extends ActionMap<S> = ActionMap<S>, SA extends ServerActionMap<S> = Record<never, never>>(config: GameConfig<S, A, SA>): void;
107
116
  export declare function sync<S>(config: SyncConfig<S>): void;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { SERVER_TIME, DEFAULT_ICON_URLS, SCHEDULED_ACTOR } from './types.js';
1
+ export { SERVER_TIME, DEFAULT_ICON_URLS } from './types.js';
2
2
  export { serverOnly, isServerOnlyAction } from './server-only.js';
3
3
  export { serverNow } from './server-clock.js';
4
4
  export { Room } from './room.js';
@@ -66,7 +66,10 @@ function attachDevHooksIfNotHosted() {
66
66
  attachDevHooks(ctx);
67
67
  }
68
68
  // ─── Public API ─────────────────────────────────────────────
69
- export const isHosted = !!window.FlutterHost || window.parent !== window;
69
+ // Node (serverActionLogic / vitest) からこの module import しても落ちないよう
70
+ // guard する。 トップレベルで window を読むと、 型やヘルパーを取りたいだけの
71
+ // import でその場で ReferenceError になる。
72
+ export const isHosted = typeof window !== 'undefined' && (!!window.FlutterHost || window.parent !== window);
70
73
  export function getRoom() {
71
74
  return _room;
72
75
  }
@@ -227,6 +230,15 @@ export function gameReady() {
227
230
  export function onPlayersChanged(handler) {
228
231
  _playersChangedHandlers.push(handler);
229
232
  }
233
+ /**
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
+ */
230
242
  export function run(config) {
231
243
  _usesRun = true;
232
244
  const params = new URLSearchParams(window.location.search);
@@ -15,33 +15,82 @@ export function runLocalServerAction(config) {
15
15
  }));
16
16
  const myId = players[0].id;
17
17
  // イベント収集→一括配信(DO と同じパターン)
18
- // ソロモードでは alarm の代わりに setTimeout で予約を実現する。
19
- // key ごとに 1 件へ畳む点は本番と同じ。
20
- const scheduled = new Map();
21
- const makeScheduleCtx = () => ({
22
- schedule(options) {
23
- const at = options.at ?? Date.now() + (options.after ?? 0) * 1000;
24
- const prev = scheduled.get(options.key);
25
- if (prev)
26
- clearTimeout(prev);
27
- scheduled.set(options.key, setTimeout(() => {
28
- scheduled.delete(options.key);
29
- try {
30
- dispatchAction(options.action, options.payload ?? {});
31
- }
32
- catch (err) {
33
- console.error(`[Scheduler] ❌ key=${options.key}:`, err);
34
- }
35
- }, Math.max(0, at - Date.now())));
36
- return at;
37
- },
38
- unschedule(key) {
39
- const t = scheduled.get(key);
40
- if (t)
41
- clearTimeout(t);
42
- scheduled.delete(key);
43
- },
44
- });
18
+ // ソロモードでは alarm の代わりに setTimeout で締切に起きる。締切は state から
19
+ // 導出するので、state を変えたら syncWakeup() を通すだけでよい。
20
+ let wakeupTimer = null;
21
+ let wakeupAt = null;
22
+ const nextDeadline = () => {
23
+ if (!logic.deadlines)
24
+ return null;
25
+ let earliest = null;
26
+ for (const [key, deadline] of Object.entries(logic.deadlines)) {
27
+ let at;
28
+ try {
29
+ at = deadline.at({ state });
30
+ }
31
+ catch (err) {
32
+ console.error(`[Deadline] ❌ ${key}.at() で例外`, err);
33
+ continue;
34
+ }
35
+ if (typeof at !== 'number' || !Number.isFinite(at))
36
+ continue;
37
+ if (earliest === null || at < earliest)
38
+ earliest = at;
39
+ }
40
+ return earliest;
41
+ };
42
+ const fireDue = () => {
43
+ wakeupTimer = null;
44
+ wakeupAt = null;
45
+ if (!logic.deadlines)
46
+ return;
47
+ const now = Date.now();
48
+ const evts = [];
49
+ const firedKeys = [];
50
+ for (const [key, deadline] of Object.entries(logic.deadlines)) {
51
+ // handler が emit してから throw したときに捨てられるよう、締切ごとに溜める。
52
+ // state を戻したのに音や演出だけ流れると、起きていない出来事が見えてしまう。
53
+ const pending = [];
54
+ let snapshot = null;
55
+ try {
56
+ const at = deadline.at({ state });
57
+ if (typeof at !== 'number' || !Number.isFinite(at) || at > now)
58
+ continue;
59
+ // 期限が来たものだけ複製する。毎周撮ると締切の数だけ state の複製が走る。
60
+ snapshot = structuredClone(state);
61
+ deadline.handler({
62
+ state,
63
+ ctx: { now, random, emit: (name, data) => pending.push({ name, data: data ?? {} }) },
64
+ });
65
+ evts.push(...pending);
66
+ firedKeys.push(key);
67
+ }
68
+ catch (err) {
69
+ // サーバー (DO) 側と同じく、失敗した締切ぶんだけ巻き戻す。
70
+ if (snapshot !== null)
71
+ state = snapshot;
72
+ console.error(`[Deadline] ❌ ${key} で例外`, err);
73
+ }
74
+ }
75
+ syncWakeup();
76
+ if (firedKeys.length === 0)
77
+ return;
78
+ console.log(`[Deadline] ⏰ ${firedKeys.length} 件発火: ${firedKeys.join(', ')}`);
79
+ dispatchEvents(evts);
80
+ onState(state, myId);
81
+ };
82
+ const syncWakeup = () => {
83
+ const next = nextDeadline();
84
+ if (next === wakeupAt)
85
+ return;
86
+ if (wakeupTimer)
87
+ clearTimeout(wakeupTimer);
88
+ wakeupTimer = null;
89
+ wakeupAt = next;
90
+ if (next === null)
91
+ return;
92
+ wakeupTimer = setTimeout(fireDue, Math.max(0, next - Date.now()));
93
+ };
45
94
  // ソロモードはこのクライアント自身がサーバーなので、先読みという概念が無い。
46
95
  // predict の値によらず、確定として 1 回だけ実行する。
47
96
  const dispatchEvents = (evts) => {
@@ -80,7 +129,7 @@ export function runLocalServerAction(config) {
80
129
  state,
81
130
  payload: payload ?? {},
82
131
  playerId: myId,
83
- ctx: { now, emit: plainEmit, ...makeScheduleCtx() },
132
+ ctx: { now, emit: plainEmit },
84
133
  });
85
134
  }
86
135
  catch (err) {
@@ -88,6 +137,7 @@ export function runLocalServerAction(config) {
88
137
  return;
89
138
  }
90
139
  dispatchEvents(plainEvents);
140
+ syncWakeup();
91
141
  onState(state, myId);
92
142
  }
93
143
  if (!server)
@@ -98,7 +148,7 @@ export function runLocalServerAction(config) {
98
148
  state,
99
149
  payload: payload ?? {},
100
150
  playerId: myId,
101
- ctx: { tick, random, now, emit: serverEmit, ...makeScheduleCtx() },
151
+ ctx: { tick, random, now, emit: serverEmit },
102
152
  });
103
153
  }
104
154
  catch (err) {
@@ -106,10 +156,12 @@ export function runLocalServerAction(config) {
106
156
  return;
107
157
  }
108
158
  dispatchEvents(serverEvents);
159
+ syncWakeup();
109
160
  onState(state, myId);
110
161
  })();
111
162
  };
112
163
  inputs(dispatchAction);
164
+ syncWakeup();
113
165
  onState(state, myId);
114
166
  // Tick ループ(tickRate > 0 の場合のみ、DO と同じ)
115
167
  if (tickRate > 0) {
@@ -123,7 +175,6 @@ export function runLocalServerAction(config) {
123
175
  random,
124
176
  tick,
125
177
  now: Date.now(),
126
- ...makeScheduleCtx(),
127
178
  emit: tickEmit,
128
179
  playerInputs,
129
180
  },
@@ -136,6 +187,7 @@ export function runLocalServerAction(config) {
136
187
  }
137
188
  tick++;
138
189
  dispatchEvents(tickEvents);
190
+ syncWakeup();
139
191
  onState(state, myId);
140
192
  }, 1000 / tickRate);
141
193
  }
@@ -143,6 +195,7 @@ export function runLocalServerAction(config) {
143
195
  getRawState: () => state,
144
196
  setRawState: async (next) => {
145
197
  state = next;
198
+ syncWakeup();
146
199
  onState(state, myId);
147
200
  },
148
201
  mergeRawState: async (patch) => {
@@ -41,18 +41,6 @@ export function createOptimisticActionClient(config) {
41
41
  };
42
42
  /** events の同一性キー。name と data が一致すれば「同じ出来事」とみなす。 */
43
43
  const eventKey = (e) => `${e.name}\u0000${JSON.stringify(e.data ?? {})}`;
44
- /**
45
- * 先読み用の schedule。予約自体はサーバーだけが持つので**何もしない**が、
46
- * 戻り値 (確定した絶対時刻) は返す。シナリオは戻り値を表示用の endsAt として
47
- * state に入れるので、ここで undefined を返すと先読み中だけタイマーが消える。
48
- *
49
- * 型には schedule があるのに実体を渡さないと `ctx.schedule is not a function` で
50
- * 先読みが丸ごと落ちる (predicted な action から呼ばれた瞬間)。
51
- */
52
- const predictedScheduleCtx = (now) => ({
53
- schedule: (options) => options.at != null ? Number(options.at) : now + Number(options.after ?? 0) * 1000,
54
- unschedule: () => { },
55
- });
56
44
  /**
57
45
  * サーバーの events から、先読みで実行済みのものを差し引く (多重集合の差)。
58
46
  *
@@ -102,7 +90,7 @@ export function createOptimisticActionClient(config) {
102
90
  state: displayState,
103
91
  payload,
104
92
  playerId,
105
- ctx: { now, emit: noopEmit, ...predictedScheduleCtx(now) },
93
+ ctx: { now, emit: noopEmit },
106
94
  }));
107
95
  i++;
108
96
  }
@@ -137,6 +125,12 @@ export function createOptimisticActionClient(config) {
137
125
  // 先読みするのは logic.actions だけ。serverActions は transport にだけ送り、
138
126
  // 結果は ack (state + serverEvents) で受け取る。
139
127
  const handler = logic.actions[type];
128
+ // どちらにも無い action は届く先が無い。 黙って捨てると UI 上は無反応なのに
129
+ // 原因が分からないので、 ここで気付けるようにする。 serverActions にだけある
130
+ // action は actions[type] が undefined でも正常なので、 両方を見る。
131
+ if (!handler && !logic.serverActions?.[type]) {
132
+ console.error(`[uzu] unknown action: "${type}". logic.actions / logic.serverActions のどちらにも登録されていません。`);
133
+ }
140
134
  // callback 内では displayState の narrowing が効かないので const に退避する。
141
135
  const target = displayState;
142
136
  // サーバーがこのアクションを処理する時刻の推定。再適用でも同じ値を使うので、
@@ -159,7 +153,7 @@ export function createOptimisticActionClient(config) {
159
153
  state: target,
160
154
  payload: payload ?? {},
161
155
  playerId,
162
- ctx: { now, emit: predictEmit, ...predictedScheduleCtx(now) },
156
+ ctx: { now, emit: predictEmit },
163
157
  }));
164
158
  pendingActions.push({ seq, action: type, payload: payload ?? {}, now });
165
159
  onState(target, playerId);
@@ -28,14 +28,6 @@ const makeLogic = () => ({
28
28
  state.stampedAt = ctx.now;
29
29
  ctx.emit('moved', {});
30
30
  },
31
- // 先読みでも ctx.schedule を呼ぶ action。型にはあるので呼べてしまう。
32
- startTimer: ({ state, ctx }) => {
33
- state.stampedAt = ctx.schedule({
34
- key: 'timer',
35
- after: 60,
36
- action: 'timer.fire',
37
- });
38
- },
39
31
  // 予測の結果によって別の event を出す (予測ミスの再現用)
40
32
  guess: ({ state, payload, ctx }) => {
41
33
  state.moves += 1;
@@ -416,23 +408,6 @@ describe('createOptimisticActionClient', () => {
416
408
  expect(latest(states).stampedAt).toBeGreaterThanOrEqual(before);
417
409
  expect(latest(states).stampedAt).toBeLessThanOrEqual(Date.now());
418
410
  });
419
- /**
420
- * IMPORTANT: 型に schedule があるので、先読みされる action からも呼べてしまう。
421
- * 実体を渡していないと `ctx.schedule is not a function` で先読みが丸ごと落ちる。
422
- *
423
- * 予約自体はサーバーだけが持つので先読みでは何もしないが、戻り値 (絶対時刻) は
424
- * 返す必要がある。シナリオはそれを表示用の endsAt として state に入れるため、
425
- * undefined を返すと先読み中だけタイマーが消える。
426
- */
427
- it('先読みでも ctx.schedule が呼べて絶対時刻を返す', () => {
428
- const { client, states, sent } = setup();
429
- client.send('startTimer');
430
- // 先読みが落ちていたら pending に積まれず state も変わらない
431
- expect(sent).toEqual([{ action: 'startTimer', seq: 1 }]);
432
- const stamped = latest(states).stampedAt;
433
- expect(stamped).toBeGreaterThanOrEqual(Date.now() + 60000 - 5000);
434
- expect(stamped).toBeLessThanOrEqual(Date.now() + 60000 + 5000);
435
- });
436
411
  /**
437
412
  * IMPORTANT: 再適用 (reconciliation でのやり直し) では初回予測時の now を使い回す。
438
413
  *
package/dist/types.d.ts CHANGED
@@ -104,45 +104,50 @@ export interface ActionContext {
104
104
  */
105
105
  now: number;
106
106
  emit: Emit;
107
- /** 予約はサーバーだけが持つ。先読みでは戻り値を計算するだけで、予約も取り消しもしない。 */
108
- schedule(options: ScheduleOptions): number;
109
- unschedule(key: string): void;
110
107
  }
111
- /** 予約されたタイマーの識別子付き宣言。 */
112
- export interface ScheduleOptions {
113
- /** 予約の識別子。同じ key で予約し直すと置き換わる (古い予約が残ると二重に発火する) */
114
- key: string;
115
- /** 絶対時刻 (ms)。`after` と排他。過去なら即座に発火する。 */
116
- at?: number;
117
- /** 何秒後か。`at` と排他。 */
118
- after?: number;
119
- action: string;
120
- payload?: Record<string, unknown>;
108
+ /** `deadlines` handler の実行文脈。サーバーでしか走らないので tick 以外を渡せる。 */
109
+ export interface DeadlineContext {
110
+ /** 発火時刻 (ms)。サーバー専用なので常に正確。 */
111
+ now: number;
112
+ random: SeededRandom;
113
+ emit: Emit;
114
+ }
115
+ /** `deadlines` handler の引数。 */
116
+ export interface DeadlineArgs<S> {
117
+ state: S;
118
+ ctx: DeadlineContext;
121
119
  }
122
120
  /**
123
- * サーバー権威のタイマー。
121
+ * サーバー権威の締切。
122
+ *
123
+ * 「state のこの時刻を過ぎたらこれをする」を宣言する。サーバーが `at` の最も早いものに
124
+ * 合わせて自分で起き、過ぎた締切の `handler` を呼ぶ。`tickRate` で毎秒ポーリングする
125
+ * 必要が無くなり、その間 Durable Object は hibernate できる。
124
126
  *
125
- * クライアントの時計やアプリのバックグラウンド化に依存せず、サーバーが指定時刻に
126
- * 自分で起きて action を撃つ。`tickRate` で毎秒ポーリングする必要が無くなり、
127
- * その間 Durable Object は hibernate できる。
127
+ * 締切は state から導出するので、予約を張り替える処理を書かなくてよい。action が
128
+ * throw して state が巻き戻れば、締切も一緒に巻き戻る。
128
129
  */
129
- export interface Scheduler {
130
+ export interface Deadline<S> {
130
131
  /**
131
- * 予約して確定した絶対時刻 (ms) を返す。表示用の endsAt にそのまま使える。
132
+ * 締切の絶対時刻 (ms)。締切が無いときは null / undefined。
133
+ *
134
+ * `state.timer?.endsAt` のような optional chain の結果をそのまま返せるよう
135
+ * undefined も受ける。数値以外は「締切なし」として同じに扱う。
132
136
  *
133
- * 発火は at-least-once。同じ予約が 2 回実行されうるので、handler 側で「自分が予約した
134
- * ものか」を state と突き合わせて弾くこと (action 名や key では区別できない)。
137
+ * state が変わるたびに呼ばれるので、state だけから決まる軽い関数にすること。
138
+ * ここで実時刻や乱数を読むと、呼ばれるたびに答えが変わって予約が暴れる。
135
139
  */
136
- schedule(options: ScheduleOptions): number;
137
- unschedule(key: string): void;
140
+ at(args: {
141
+ state: S;
142
+ }): number | null | undefined;
143
+ /**
144
+ * `at` の時刻を過ぎたときにサーバーで呼ばれる。
145
+ *
146
+ * 発火は at-least-once だが、SDK が発火直前に `at` を評価し直して過ぎているものだけを
147
+ * 呼ぶので、handler 側で二重発火を弾く必要は無い。
148
+ */
149
+ handler(args: DeadlineArgs<S>): void;
138
150
  }
139
- /**
140
- * 予約から発火した action に渡る playerId。
141
- *
142
- * 送信者がいないので、人間の操作と区別するための定数。
143
- * `if (playerId !== SCHEDULED_ACTOR) return;` で人間からの直接実行を弾ける。
144
- */
145
- export declare const SCHEDULED_ACTOR = "__scheduled";
146
151
  /** `serverActions` handler の実行文脈。サーバーでしか走らないので tick と乱数を渡せる。 */
147
152
  export interface ServerActionContext {
148
153
  tick: number;
@@ -150,8 +155,6 @@ export interface ServerActionContext {
150
155
  /** サーバーの実時刻 (ms)。同じ dispatch の `actions` に渡る `ctx.now` と同一値。 */
151
156
  now: number;
152
157
  emit: Emit;
153
- schedule(options: ScheduleOptions): number;
154
- unschedule(key: string): void;
155
158
  }
156
159
  /** @deprecated `ServerActionContext` を使う。 */
157
160
  export type ServerOnlyActionContext = ServerActionContext;
@@ -161,21 +164,26 @@ export type ServerOnlyActionContext = ServerActionContext;
161
164
  * オブジェクトなのは使うものだけ書けるようにするため。`ctx` を入れ子で残しているのは、
162
165
  * 実行環境が与えるものをひとまとまりで helper へ渡せるようにするため。
163
166
  */
164
- export interface ActionArgs<S> {
167
+ export interface ActionArgs<S, P = any> {
165
168
  state: S;
166
- payload: any;
169
+ payload: P;
167
170
  playerId: string;
168
171
  ctx: ActionContext;
169
172
  }
170
- export type ActionHandler<S> = (args: ActionArgs<S>) => void;
173
+ /**
174
+ * `P` は既定が `any` なので、 注釈のない handler はそのまま動く。 payload の形を
175
+ * 書いた handler だけが検査され、 送信側もその型で縛られる。 全部に注釈しないと
176
+ * 恩恵が無い、 という移行にならないようにするための既定値。
177
+ */
178
+ export type ActionHandler<S, P = any> = (args: ActionArgs<S, P>) => void;
171
179
  /** `serverActions` handler の引数。`ctx` にサーバー限定の tick / random が入る。 */
172
- export interface ServerActionArgs<S> {
180
+ export interface ServerActionArgs<S, P = any> {
173
181
  state: S;
174
- payload: any;
182
+ payload: P;
175
183
  playerId: string;
176
184
  ctx: ServerActionContext;
177
185
  }
178
- export type ServerActionHandler<S> = (args: ServerActionArgs<S>) => Promise<void> | void;
186
+ export type ServerActionHandler<S, P = any> = (args: ServerActionArgs<S, P>) => Promise<void> | void;
179
187
  /** @deprecated `ServerActionHandler` を使う。 */
180
188
  export type ServerOnlyActionHandlerFn<S> = ServerActionHandler<S>;
181
189
  /**
@@ -191,8 +199,6 @@ export interface UpdateContext {
191
199
  tick: number;
192
200
  /** サーバーの実時刻 (ms)。update はサーバーでしか走らないので常に正確。 */
193
201
  now: number;
194
- schedule(options: ScheduleOptions): number;
195
- unschedule(key: string): void;
196
202
  emit: Emit;
197
203
  playerInputs: Record<string, Record<string, any>>;
198
204
  }
@@ -204,7 +210,6 @@ export interface UpdateArgs<S> {
204
210
  * `setup()` の実行文脈。サーバーでしか走らないので実時刻をそのまま渡せる。
205
211
  *
206
212
  * `emit` は無い。まだ誰も購読していない時点なので、鳴らしても届かない。
207
- * `schedule` も無い。開幕から予約したい要求が出たら足す。
208
213
  */
209
214
  export interface SetupContext {
210
215
  random: SeededRandom;
@@ -220,7 +225,31 @@ export interface SetupArgs {
220
225
  seats: Seat[];
221
226
  ctx: SetupContext;
222
227
  }
223
- export interface GameLogic<S> {
228
+ /**
229
+ * `actions` の形だけを見る緩い制約。
230
+ *
231
+ * `Record<string, ActionHandler<S>>` (payload: any) を制約に使うと、 推論時に
232
+ * handler 型がそちらへ広げられ payload の型が取り出せなくなる。 payload を
233
+ * `any` のままにしてキーと引数の形だけ縛る。
234
+ */
235
+ export type ActionMap<S> = Record<string, (args: ActionArgs<S, any>) => void>;
236
+ /** `serverActions` 用。 ActionMap と同じ理由で payload は any のままにする。 */
237
+ export type ServerActionMap<S> = Record<string, (args: ServerActionArgs<S, any>) => Promise<void> | void>;
238
+ /** action 名から payload 型を引く表。 注釈のない handler は `any` になる。 */
239
+ export type PayloadMap<A> = {
240
+ [K in keyof A]: A[K] extends (args: infer G) => any ? G extends {
241
+ payload: infer P;
242
+ } ? P : never : never;
243
+ };
244
+ /**
245
+ * `inputs` が受け取る送信関数。 action 名と payload の両方が型で縛られる。
246
+ *
247
+ * payload を一律 optional にすると、 形が必須の action でも省略が通ってしまう。
248
+ * payload 型が undefined を含むとき (= 注釈なしの any や明示的に optional) だけ
249
+ * 省略できるようにする。
250
+ */
251
+ export type SendAction<A> = <K extends keyof A & string>(...args: undefined extends PayloadMap<A>[K] ? [type: K, payload?: PayloadMap<A>[K]] : [type: K, payload: PayloadMap<A>[K]]) => void;
252
+ export interface GameLogic<S, A extends ActionMap<S> = ActionMap<S>, SA extends ServerActionMap<S> = ServerActionMap<S>> {
224
253
  setup(args: SetupArgs): S;
225
254
  /**
226
255
  * クライアント先読みとサーバーの両方で走る handler。決定的でなければならない。
@@ -229,7 +258,7 @@ export interface GameLogic<S> {
229
258
  * なる。1 つの action を「即座に反映していい部分」と「サーバーが決める部分」へ
230
259
  * 分けられる (例: 駒の移動は先読み、持ち時間の減算はサーバー)。
231
260
  */
232
- actions: Record<string, ActionHandler<S>>;
261
+ actions: A;
233
262
  /**
234
263
  * サーバーでのみ走る handler。実時刻 / 乱数 / fetch など、クライアント先読みで
235
264
  * 再現できない処理をここに書く。async 可。
@@ -237,14 +266,21 @@ export interface GameLogic<S> {
237
266
  * `actions` と同名でも別名でもよい。別名だけに置けば「先読みしない action」
238
267
  * (旧 `serverOnly()` 相当) になる。
239
268
  */
240
- serverActions?: Record<string, ServerActionHandler<S>>;
269
+ serverActions?: SA;
241
270
  update(args: UpdateArgs<S>): void;
271
+ /**
272
+ * state 由来の締切。サーバーが `at` の時刻に自分で起きて `handler` を呼ぶ。
273
+ *
274
+ * 時刻をきっかけに何かを起こすなら `tickRate` で毎秒ポーリングせずこちらを使う。
275
+ * ポーリング中は Durable Object が hibernate できない。
276
+ */
277
+ deadlines?: Record<string, Deadline<S>>;
242
278
  tickRate?: number;
243
279
  }
244
- export interface GameConfig<S> extends ConnectionCallbacks {
245
- logic: GameLogic<S>;
280
+ export interface GameConfig<S, A extends ActionMap<S> = ActionMap<S>, SA extends ServerActionMap<S> = ServerActionMap<S>> extends ConnectionCallbacks {
281
+ logic: GameLogic<S, A, SA>;
246
282
  onState: (state: S, myPlayerId: string) => void;
247
- inputs: (sendAction: (type: string, payload?: any) => void) => void;
283
+ inputs: (sendAction: SendAction<A & SA>) => void;
248
284
  /**
249
285
  * `emit(name, data)` の購読。 キーごとに `predict` の宣言が必須。
250
286
  *
package/dist/types.js CHANGED
@@ -1,10 +1,3 @@
1
- /**
2
- * 予約から発火した action に渡る playerId。
3
- *
4
- * 送信者がいないので、人間の操作と区別するための定数。
5
- * `if (playerId !== SCHEDULED_ACTOR) return;` で人間からの直接実行を弾ける。
6
- */
7
- export const SCHEDULED_ACTOR = '__scheduled';
8
1
  /** Sentinel value — patch の value にセットすると、サーバーが Date.now() に置換する */
9
2
  export const SERVER_TIME = '__SERVER_TIME__';
10
3
  /** デフォルトのプレイヤーアイコン URL 一覧(dev / local モード用) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-sdk",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "UZU PlayScreen SDK - Flutter ↔ JS ゲーム通信ライブラリ",
5
5
  "type": "module",
6
6
  "exports": {