@uzuhq/code-sdk 0.5.0 → 0.7.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.
package/dist/index.d.ts CHANGED
@@ -15,9 +15,10 @@
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, ActionContext, ActionHandler, ServerActionContext, ServerActionHandler, ServerOnlyAction, ServerOnlyActionContext, ServerOnlyActionHandlerFn, } from './types.js';
19
- export { SERVER_TIME, DEFAULT_ICON_URLS } 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, 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';
20
20
  export { serverOnly, isServerOnlyAction } from './server-only.js';
21
+ export { serverNow } from './server-clock.js';
21
22
  export { Room } from './room.js';
22
23
  export type { RoomLike } from './room.js';
23
24
  export { ReconnectableWebSocket } from './reconnectable-ws.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
- export { SERVER_TIME, DEFAULT_ICON_URLS } from './types.js';
1
+ export { SERVER_TIME, DEFAULT_ICON_URLS, SCHEDULED_ACTOR } from './types.js';
2
2
  export { serverOnly, isServerOnlyAction } from './server-only.js';
3
+ export { serverNow } from './server-clock.js';
3
4
  export { Room } from './room.js';
4
5
  export { ReconnectableWebSocket } from './reconnectable-ws.js';
5
6
  export { SeededRandomImpl } from './random.js';
@@ -15,9 +15,38 @@ 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
+ });
45
+ // ソロモードはこのクライアント自身がサーバーなので、先読みという概念が無い。
46
+ // predict の値によらず、確定として 1 回だけ実行する。
18
47
  const dispatchEvents = (evts) => {
19
48
  for (const e of evts) {
20
- events?.[e.name]?.(e.data);
49
+ events?.[e.name]?.handler(e.data);
21
50
  }
22
51
  };
23
52
  // setRawState で全置換できるよう let。closures は名前参照なので最新束縛を読む。
@@ -43,9 +72,16 @@ export function runLocalServerAction(config) {
43
72
  const serverEvents = [];
44
73
  const plainEmit = (name, data) => plainEvents.push({ name, data: data ?? {} });
45
74
  const serverEmit = (name, data) => serverEvents.push({ name, data: data ?? {} });
75
+ // ソロモードはこのクライアント自身がサーバーなので、実時刻がそのまま正となる。
76
+ const now = Date.now();
46
77
  if (plain && !legacyServerOnly) {
47
78
  try {
48
- plain(state, payload ?? {}, myId, plainEmit, {});
79
+ plain({
80
+ state,
81
+ payload: payload ?? {},
82
+ playerId: myId,
83
+ ctx: { now, emit: plainEmit, ...makeScheduleCtx() },
84
+ });
49
85
  }
50
86
  catch (err) {
51
87
  console.warn('[SDK LocalServerAction] Action error:', err);
@@ -58,7 +94,12 @@ export function runLocalServerAction(config) {
58
94
  return;
59
95
  void (async () => {
60
96
  try {
61
- await server(state, payload ?? {}, myId, serverEmit, { tick, random });
97
+ await server({
98
+ state,
99
+ payload: payload ?? {},
100
+ playerId: myId,
101
+ ctx: { tick, random, now, emit: serverEmit, ...makeScheduleCtx() },
102
+ });
62
103
  }
63
104
  catch (err) {
64
105
  console.warn('[SDK LocalServerAction] Action error:', err);
@@ -76,7 +117,17 @@ export function runLocalServerAction(config) {
76
117
  const tickEvents = [];
77
118
  const tickEmit = (name, data) => tickEvents.push({ name, data: data ?? {} });
78
119
  try {
79
- logic.update(state, { random, tick, emit: tickEmit, playerInputs });
120
+ logic.update({
121
+ state,
122
+ ctx: {
123
+ random,
124
+ tick,
125
+ now: Date.now(),
126
+ ...makeScheduleCtx(),
127
+ emit: tickEmit,
128
+ playerInputs,
129
+ },
130
+ });
80
131
  }
81
132
  catch (err) {
82
133
  console.error(`[SDK LocalServerAction] tick error at tick=${tick}:`, err);
@@ -26,8 +26,18 @@ const run = (logic) => {
26
26
  send = sendAction;
27
27
  },
28
28
  events: {
29
- moved: () => fired.push('moved'),
30
- charged: () => fired.push('charged'),
29
+ moved: {
30
+ predict: true,
31
+ handler: () => {
32
+ fired.push('moved');
33
+ },
34
+ },
35
+ charged: {
36
+ predict: false,
37
+ handler: () => {
38
+ fired.push('charged');
39
+ },
40
+ },
31
41
  },
32
42
  };
33
43
  runLocalServerAction(config);
@@ -45,9 +55,9 @@ describe('runLocalServerAction', () => {
45
55
  it('actions だけなら同期で state と events が確定する', () => {
46
56
  const h = run(baseLogic({
47
57
  actions: {
48
- move: (state, _payload, _playerId, emit) => {
58
+ move: ({ state, ctx }) => {
49
59
  state.moves += 1;
50
- emit('moved');
60
+ ctx.emit('moved');
51
61
  },
52
62
  },
53
63
  }));
@@ -59,10 +69,10 @@ describe('runLocalServerAction', () => {
59
69
  it('serverActions だけなら await 後に反映される', async () => {
60
70
  const h = run(baseLogic({
61
71
  serverActions: {
62
- notifyExternal: async (state, _payload, _playerId, emit) => {
72
+ notifyExternal: async ({ state, ctx }) => {
63
73
  await tick();
64
74
  state.charged += 1;
65
- emit('charged');
75
+ ctx.emit('charged');
66
76
  },
67
77
  },
68
78
  }));
@@ -81,13 +91,13 @@ describe('runLocalServerAction', () => {
81
91
  const order = [];
82
92
  const h = run(baseLogic({
83
93
  actions: {
84
- move: (state) => {
94
+ move: ({ state }) => {
85
95
  order.push('actions');
86
96
  state.moves += 1;
87
97
  },
88
98
  },
89
99
  serverActions: {
90
- move: (state, _payload, _playerId, _emit, ctx) => {
100
+ move: ({ state, ctx }) => {
91
101
  order.push('serverActions');
92
102
  // actions の結果が見えている
93
103
  expect(state.moves).toBe(1);
@@ -114,16 +124,16 @@ describe('runLocalServerAction', () => {
114
124
  it('actions の events は serverActions の await を待たない', async () => {
115
125
  const h = run(baseLogic({
116
126
  actions: {
117
- move: (state, _payload, _playerId, emit) => {
127
+ move: ({ state, ctx }) => {
118
128
  state.moves += 1;
119
- emit('moved');
129
+ ctx.emit('moved');
120
130
  },
121
131
  },
122
132
  serverActions: {
123
- move: async (state, _payload, _playerId, emit) => {
133
+ move: async ({ state, ctx }) => {
124
134
  await tick();
125
135
  state.charged += 1;
126
- emit('charged');
136
+ ctx.emit('charged');
127
137
  },
128
138
  },
129
139
  }));
@@ -161,9 +171,9 @@ describe('runLocalServerAction', () => {
161
171
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
162
172
  const h = run(baseLogic({
163
173
  actions: {
164
- move: (state, _payload, _playerId, emit) => {
174
+ move: ({ state, ctx }) => {
165
175
  state.moves += 1;
166
- emit('moved');
176
+ ctx.emit('moved');
167
177
  },
168
178
  },
169
179
  serverActions: {
@@ -186,13 +196,14 @@ describe('runLocalServerAction', () => {
186
196
  it('actions に入った serverOnly() は server 側として実行される', async () => {
187
197
  const logic = baseLogic({
188
198
  actions: {
189
- // 新しい型では actions serverOnly() を入れられない (それが本 PR の狙い)。
190
- // ここは「R2 に残っている古い logic.js」を再現するための意図的な型違反。
191
- // @ts-expect-error 移行期の互換挙動を検証するため
192
- legacy: serverOnly(async (state, _payload, _playerId, emit, ctx) => {
199
+ // 検証対象は `__serverOnly` brand の判定であって、旧シグネチャの互換ではない。
200
+ // handler 自体は現行の呼び出し形で書いている (旧形式の logic.js は現行テンプレでは
201
+ // 動かない。公開済みゲームが無い前提で互換を切っている)。
202
+ // @ts-expect-error actions serverOnly() を入れるのは型違反だが、brand 判定を試す
203
+ legacy: serverOnly(async ({ state, ctx }) => {
193
204
  await tick();
194
205
  state.charged += ctx.tick + 1;
195
- emit('charged');
206
+ ctx.emit('charged');
196
207
  }),
197
208
  },
198
209
  });
@@ -10,13 +10,13 @@
10
10
  *
11
11
  * - `send(type, payload)`: `logic.actions` の handler を同期で先行実行し pending キューへ。
12
12
  * `logic.serverActions` は先読みせず transport にだけ送る。
13
- * - `applyState(state, { ack, from, events, serverEvents })`: フル state を受信した時に呼ぶ。
14
- * - `applyDelta(patches, { ack, from, events, serverEvents })`: JSON Patch を受信した時に呼ぶ
13
+ * - `applyState(state, { ack, from, events })`: フル state を受信した時に呼ぶ。
14
+ * - `applyDelta(patches, { ack, from, events })`: JSON Patch を受信した時に呼ぶ
15
15
  * (適用失敗時は false を返すので transport 側でフル state を再要求する)。
16
16
  * - `rollback(seq)`: `__action_error` 受信時に該当 action を pending から除去して再適用。
17
17
  * - `reset(state)`: 再接続後の state 復元用 (pending を全クリア)。
18
18
  */
19
- import type { GameLogic } from '../types.js';
19
+ import type { GameLogic, EventSubscription } from '../types.js';
20
20
  import type { Operation } from '../json-patch.js';
21
21
  export interface EventEntry {
22
22
  name: string;
@@ -25,21 +25,22 @@ export interface EventEntry {
25
25
  export interface ConfirmOptions {
26
26
  /** transport から受け取った action ack (確定された pending action の seq) */
27
27
  ack?: number;
28
- /** ack の送信元 player id。自分の pending と一致した時だけ events を skip する */
28
+ /** ack の送信元 player id */
29
29
  from?: string;
30
- /** `logic.actions` 由来の events。自分の ack なら先読み時に発火済みなので skip する */
31
- events?: EventEntry[];
32
30
  /**
33
- * `logic.serverActions` 由来の events。先読みでは走らないので、自分の ack でも必ず配信する。
34
- * これを `events` と混ぜると、同名 action の server 側 events が重複排除で消える。
31
+ * サーバーが確定させた events。`actions` 由来と `serverActions` 由来を区別しない
32
+ * 1 本のリスト。
33
+ *
34
+ * 先読みで既に配信済みのものはクライアント側で差し引く (pending の `fired` 記録と
35
+ * 突き合わせる)。サーバーが袋を分ける必要はない。
35
36
  */
36
- serverEvents?: EventEntry[];
37
+ events?: EventEntry[];
37
38
  }
38
39
  export interface OptimisticActionClientConfig<S> {
39
40
  logic: GameLogic<S>;
40
41
  playerId: string;
41
42
  onState: (state: S, playerId: string) => void;
42
- events?: Record<string, (data: Record<string, unknown>) => void>;
43
+ events?: Record<string, EventSubscription>;
43
44
  /** action を transport に流すコールバック */
44
45
  sendAction: (msg: {
45
46
  action: string;
@@ -50,6 +51,11 @@ export interface OptimisticActionClientConfig<S> {
50
51
  export interface OptimisticActionClient<S> {
51
52
  /** input から呼ばれる action dispatch (楽観更新 + transport 送信) */
52
53
  send(type: string, payload?: any): void;
54
+ /**
55
+ * サーバーが打刻した時刻の観測値を渡してクロックオフセットを更新する。
56
+ * transport が受信した全メッセージで呼んでよい (serverTime を持たないものは無視される)。
57
+ */
58
+ observeServerTime(serverTime: number | undefined): void;
53
59
  /** 仮想サーバー / DO からフル state を受信した時に呼ぶ */
54
60
  applyState(state: S, options?: ConfirmOptions): void;
55
61
  /** DO から JSON Patch delta を受信した時に呼ぶ。適用失敗時は false (transport で再要求) */
@@ -1,6 +1,7 @@
1
1
  import { applyPatch } from '../json-patch.js';
2
2
  import { isServerOnlyAction } from '../server-only.js';
3
3
  import { runPredicted } from '../dev-prediction-traps.js';
4
+ import { observeServerTime, serverNow } from '../server-clock.js';
4
5
  export function createOptimisticActionClient(config) {
5
6
  const { logic, playerId, onState, events, sendAction } = config;
6
7
  /** サーバー確定 state (楽観的更新のベース) */
@@ -9,17 +10,71 @@ export function createOptimisticActionClient(config) {
9
10
  let displayState = null;
10
11
  /** クライアント側の action 通番 */
11
12
  let actionSeq = 0;
12
- /** 送信済みだがサーバー未確認の action キュー */
13
+ /**
14
+ * 送信済みだがサーバー未確認の action キュー。
15
+ * `now` は送信時に推定した値。再適用でも同じ値を使う (取り直すと表示がガタつく)。
16
+ */
13
17
  const pendingActions = [];
14
- const emit = (eventName, data) => {
15
- events?.[eventName]?.(data ?? {});
16
- };
18
+ /**
19
+ * 先読みで実行済みの events。
20
+ *
21
+ * IMPORTANT: pending action ごとではなくクライアント単位で持つ。二重実行は「自分の
22
+ * action の ack」だけでなく「他プレイヤーの action のブロードキャスト」でも起きるため
23
+ * (A と B が同時に同じ行送りを撃つと、B は自分の先読みで実行済みなのに A 由来の
24
+ * 配信でもう一度実行してしまう)。どの配信が来ても、まずここと突き合わせる。
25
+ *
26
+ * 記録には「どの action の先読みで実行したか」(seq) を持たせる。その action が ack
27
+ * された時点で引き当てられていない記録は「予測したが実際には起きなかった出来事」なので
28
+ * 捨てる。seq を持たせずに「pending が空になったら捨てる」だけにすると、別の action が
29
+ * 未確定な間ずっと外れた記録が生き残り、その後に本当に起きた同名イベントを 1 回
30
+ * 握り潰してしまう。
31
+ *
32
+ * サーバーは 1 本の接続へ処理順どおりに送るので、他プレイヤーの配信は自分の ack より
33
+ * 必ず先に届く = 引き当てのチャンスは記録が生きている間に必ず来る。
34
+ */
35
+ let firedPredictions = [];
17
36
  /** 再適用時はイベントを発火しない (送信時に既に発火済み) */
18
37
  const noopEmit = () => { };
19
38
  const dispatchEvents = (evts) => {
20
- for (const e of evts) {
21
- events?.[e.name]?.(e.data);
39
+ for (const e of evts)
40
+ events?.[e.name]?.handler(e.data);
41
+ };
42
+ /** events の同一性キー。name と data が一致すれば「同じ出来事」とみなす。 */
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
+ /**
57
+ * サーバーの events から、先読みで実行済みのものを差し引く (多重集合の差)。
58
+ *
59
+ * - 予測が当たった → 差が空。二重に実行しない
60
+ * - 予測が外れた → サーバー側の正しい event が残り、確定として実行される
61
+ * - 先読みで実行していない (predict: false / serverActions 由来 / 他プレイヤー由来)
62
+ * → そのまま残って実行される
63
+ */
64
+ const subtractFired = (serverEvts) => {
65
+ if (firedPredictions.length === 0)
66
+ return serverEvts;
67
+ const out = [];
68
+ for (const e of serverEvts) {
69
+ const k = eventKey(e);
70
+ const idx = firedPredictions.findIndex((f) => eventKey(f) === k);
71
+ // 一致したら「先読みで実行済み」なので配信せず、記録も 1 件消費する。
72
+ if (idx >= 0)
73
+ firedPredictions.splice(idx, 1);
74
+ else
75
+ out.push(e);
22
76
  }
77
+ return out;
23
78
  };
24
79
  /**
25
80
  * confirmedState をベースに pending actions を再適用して displayState を更新する。
@@ -31,7 +86,7 @@ export function createOptimisticActionClient(config) {
31
86
  displayState = structuredClone(confirmedState);
32
87
  let i = 0;
33
88
  while (i < pendingActions.length) {
34
- const { action, payload } = pendingActions[i];
89
+ const { action, payload, now } = pendingActions[i];
35
90
  const handler = logic.actions[action];
36
91
  // 移行期の互換: 旧 serverOnly() を actions に入れたままの logic では、その handler は
37
92
  // サーバー専用なので先読みの再適用対象から外す (send 時にも pending へ積んでいない)。
@@ -43,7 +98,12 @@ export function createOptimisticActionClient(config) {
43
98
  // publish されてしまう。1 件ごとに直前の確定形から作り直し、成功したものだけ採用する。
44
99
  const base = structuredClone(displayState);
45
100
  try {
46
- runPredicted(action, () => handler(displayState, payload, playerId, noopEmit, {}));
101
+ runPredicted(action, () => handler({
102
+ state: displayState,
103
+ payload,
104
+ playerId,
105
+ ctx: { now, emit: noopEmit, ...predictedScheduleCtx(now) },
106
+ }));
47
107
  i++;
48
108
  }
49
109
  catch {
@@ -54,20 +114,20 @@ export function createOptimisticActionClient(config) {
54
114
  onState(displayState, playerId);
55
115
  };
56
116
  /**
57
- * 自分が出した pending action が ack されたかを判定し、events 重複排除と
58
- * pending キューの掃除を行う。
117
+ * サーバーからの配信を処理する。
118
+ *
119
+ * 送信元が誰であれ、まず先読みの実行記録と突き合わせる。自分の ack だけを見ていると、
120
+ * 他プレイヤーの action が同じ出来事を起こしたときに二重実行になる。
59
121
  */
60
- const handleAck = (ack, from, evts, serverEvts) => {
61
- const isMyAck = from === playerId && ack !== undefined && pendingActions.some((p) => p.seq === ack);
62
- if (!isMyAck) {
63
- dispatchEvents(evts);
64
- }
65
- // serverActions 由来は先読みで走っていないので、自分の ack でも必ず配信する。
66
- dispatchEvents(serverEvts);
122
+ const handleAck = (ack, from, evts) => {
123
+ dispatchEvents(subtractFired(evts));
67
124
  if (from === playerId && ack !== undefined) {
68
125
  while (pendingActions.length > 0 && pendingActions[0].seq <= ack) {
69
126
  pendingActions.shift();
70
127
  }
128
+ // 確定した action の記録で引き当てられなかったものは「予測したが実際には
129
+ // 起きなかった出来事」。残すと次に本当に起きたときに握り潰してしまう。
130
+ firedPredictions = firedPredictions.filter((f) => f.seq > ack);
71
131
  }
72
132
  };
73
133
  return {
@@ -79,12 +139,29 @@ export function createOptimisticActionClient(config) {
79
139
  const handler = logic.actions[type];
80
140
  // callback 内では displayState の narrowing が効かないので const に退避する。
81
141
  const target = displayState;
142
+ // サーバーがこのアクションを処理する時刻の推定。再適用でも同じ値を使うので、
143
+ // ここで 1 回だけ確定させて pending に載せる。
144
+ const now = serverNow();
145
+ // predict: true の event だけ先読み時点で実行し、実行したものを記録する。
146
+ const predictEmit = (eventName, data) => {
147
+ const subscription = events?.[eventName];
148
+ if (!subscription?.predict)
149
+ return;
150
+ const payloadData = data ?? {};
151
+ subscription.handler(payloadData);
152
+ firedPredictions.push({ seq, name: eventName, data: payloadData });
153
+ };
82
154
  // 旧 serverOnly() が actions に残っている logic では、その handler を先読みすると
83
155
  // サーバー専用のはずの副作用がクライアントでも走る。brand を見て弾く。
84
156
  if (handler && !isServerOnlyAction(handler) && target !== null) {
85
157
  try {
86
- runPredicted(type, () => handler(target, payload ?? {}, playerId, emit, {}));
87
- pendingActions.push({ seq, action: type, payload: payload ?? {} });
158
+ runPredicted(type, () => handler({
159
+ state: target,
160
+ payload: payload ?? {},
161
+ playerId,
162
+ ctx: { now, emit: predictEmit, ...predictedScheduleCtx(now) },
163
+ }));
164
+ pendingActions.push({ seq, action: type, payload: payload ?? {}, now });
88
165
  onState(target, playerId);
89
166
  }
90
167
  catch {
@@ -93,8 +170,12 @@ export function createOptimisticActionClient(config) {
93
170
  }
94
171
  sendAction({ action: type, payload: payload ?? {}, seq });
95
172
  },
173
+ observeServerTime(serverTime) {
174
+ // 実体は server-clock.ts (UI 側の serverNow() と同じオフセットを共有する)。
175
+ observeServerTime(serverTime);
176
+ },
96
177
  applyState(state, options = {}) {
97
- handleAck(options.ack, options.from, options.events ?? [], options.serverEvents ?? []);
178
+ handleAck(options.ack, options.from, options.events ?? []);
98
179
  confirmedState = state;
99
180
  reapplyPendingActions();
100
181
  },
@@ -110,7 +191,7 @@ export function createOptimisticActionClient(config) {
110
191
  if (!ok)
111
192
  return false;
112
193
  confirmedState = cloned;
113
- handleAck(options.ack, options.from, options.events ?? [], options.serverEvents ?? []);
194
+ handleAck(options.ack, options.from, options.events ?? []);
114
195
  reapplyPendingActions();
115
196
  return true;
116
197
  },
@@ -118,11 +199,14 @@ export function createOptimisticActionClient(config) {
118
199
  const idx = pendingActions.findIndex((p) => p.seq === seq);
119
200
  if (idx !== -1) {
120
201
  pendingActions.splice(idx, 1);
202
+ // rollback した action の先読み記録も捨てる (その出来事は起きなかった)。
203
+ firedPredictions = firedPredictions.filter((f) => f.seq !== seq);
121
204
  reapplyPendingActions();
122
205
  }
123
206
  },
124
207
  reset(state) {
125
208
  pendingActions.length = 0;
209
+ firedPredictions = [];
126
210
  confirmedState = state;
127
211
  displayState = structuredClone(state);
128
212
  onState(displayState, playerId);
@@ -2,31 +2,50 @@
2
2
  * optimistic-action-client.ts の unit test。
3
3
  *
4
4
  * `logic.actions` (クライアント先読み + サーバーの 2 回実行) と `logic.serverActions`
5
- * (サーバーのみ 1 回実行) を分離した設計が、events の重複排除と pending キューの
6
- * 巻き戻しの両方で正しく閉じることを検証する。
5
+ * (サーバーのみ 1 回実行) を分離した設計が、events の配信と pending キューの巻き戻しの
6
+ * 両方で正しく閉じることを検証する。
7
7
  *
8
- * 特に重要なのは、同名 action で両方が定義されたケース。先読みで走るのは actions 側
9
- * だけなので、serverActions が emit した events まで「自分の ack だから発火済み」として
10
- * 捨ててしまうと、サーバー側のイベントが永久に届かなくなる。
8
+ * events はサーバーから 1 本のリストで届き、クライアントが「先読みで自分が配信した分」を
9
+ * 差し引いて残りを確定として発火する。予測が当たれば二重に鳴らず、外れればサーバー側の
10
+ * 正しい event が追って届く。
11
11
  */
12
12
  import { describe, expect, it, vi } from 'vitest';
13
13
  import { createOptimisticActionClient } from './optimistic-action-client.js';
14
14
  import { serverOnly } from '../server-only.js';
15
- /** serverOnly() ブランド付き handler。actions に残っている古い logic を再現する。 */
16
- const legacyServerOnly = serverOnly((state) => {
15
+ import { resetServerTimeOffset } from '../server-clock.js';
16
+ /**
17
+ * `serverOnly()` の brand が付いた handler。`actions` に入っていても先読みされないこと
18
+ * (brand 判定が効くこと) を確かめる。旧シグネチャの互換は検証対象ではない。
19
+ */
20
+ const legacyServerOnly = serverOnly(({ state }) => {
17
21
  state.charged += 1000;
18
22
  });
19
23
  const makeLogic = () => ({
20
- setup: () => ({ moves: 0, charged: 0 }),
24
+ setup: () => ({ moves: 0, charged: 0, stampedAt: 0 }),
21
25
  actions: {
22
- move: (state) => {
26
+ move: ({ state, ctx }) => {
27
+ state.moves += 1;
28
+ state.stampedAt = ctx.now;
29
+ ctx.emit('moved', {});
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
+ // 予測の結果によって別の event を出す (予測ミスの再現用)
40
+ guess: ({ state, payload, ctx }) => {
23
41
  state.moves += 1;
42
+ ctx.emit(payload?.willEmit ?? 'accepted', payload?.data ?? {});
24
43
  },
25
44
  boom: () => {
26
45
  throw new Error('always fails');
27
46
  },
28
47
  // 途中まで state を書き換えてから throw する (部分ミューテーションの検証用)
29
- partial: (state) => {
48
+ partial: ({ state }) => {
30
49
  state.moves += 1;
31
50
  throw new Error('fails after mutating');
32
51
  },
@@ -36,11 +55,12 @@ const makeLogic = () => ({
36
55
  },
37
56
  serverActions: {
38
57
  // move と同名 = 同じ action の「サーバーだけで走る続き」
39
- move: (state, _payload, _playerId, _emit, ctx) => {
58
+ move: ({ state, ctx }) => {
40
59
  state.charged += ctx.tick;
60
+ ctx.emit('charged', {});
41
61
  },
42
62
  // actions に無い名前 = 先読みされない action (旧 serverOnly 相当)
43
- notifyExternal: (state) => {
63
+ notifyExternal: ({ state }) => {
44
64
  state.charged += 100;
45
65
  },
46
66
  },
@@ -49,6 +69,8 @@ const makeLogic = () => ({
49
69
  /** 直近の onState 通知。tsconfig の lib が Array#at 未対応なので添字で取る。 */
50
70
  const latest = (states) => states[states.length - 1];
51
71
  const setup = () => {
72
+ // サーバー時刻オフセットは module 単位で共有されるので、テスト間で持ち越さない。
73
+ resetServerTimeOffset();
52
74
  const sent = [];
53
75
  const fired = [];
54
76
  const states = [];
@@ -57,12 +79,42 @@ const setup = () => {
57
79
  playerId: 'me',
58
80
  onState: (s) => states.push(structuredClone(s)),
59
81
  events: {
60
- moved: () => fired.push('moved'),
61
- charged: () => fired.push('charged'),
82
+ // predict: true = 先読み時点で実行する
83
+ moved: {
84
+ predict: true,
85
+ handler: (d) => {
86
+ fired.push(`moved${d.n ?? ''}`);
87
+ },
88
+ },
89
+ accepted: {
90
+ predict: true,
91
+ handler: () => {
92
+ fired.push('accepted');
93
+ },
94
+ },
95
+ tooLate: {
96
+ predict: true,
97
+ handler: () => {
98
+ fired.push('tooLate');
99
+ },
100
+ },
101
+ // predict: false = サーバー確定後だけ実行する
102
+ charged: {
103
+ predict: false,
104
+ handler: () => {
105
+ fired.push('charged');
106
+ },
107
+ },
108
+ fanfare: {
109
+ predict: false,
110
+ handler: () => {
111
+ fired.push('fanfare');
112
+ },
113
+ },
62
114
  },
63
115
  sendAction: ({ action, seq }) => sent.push({ action, seq }),
64
116
  });
65
- client.reset({ moves: 0, charged: 0 });
117
+ client.reset({ moves: 0, charged: 0, stampedAt: 0 });
66
118
  return { client, sent, fired, states };
67
119
  };
68
120
  describe('createOptimisticActionClient', () => {
@@ -71,7 +123,7 @@ describe('createOptimisticActionClient', () => {
71
123
  it('actions の handler は送信時に先行実行される', () => {
72
124
  const { client, sent, states } = setup();
73
125
  client.send('move');
74
- expect(latest(states)).toEqual({ moves: 1, charged: 0 });
126
+ expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
75
127
  expect(sent).toEqual([{ action: 'move', seq: 1 }]);
76
128
  });
77
129
  /**
@@ -81,7 +133,7 @@ describe('createOptimisticActionClient', () => {
81
133
  it('serverActions にしか無い action は先行実行されない', () => {
82
134
  const { client, sent, states } = setup();
83
135
  client.send('notifyExternal');
84
- expect(latest(states)).toEqual({ moves: 0, charged: 0 });
136
+ expect(latest(states)).toMatchObject({ moves: 0, charged: 0 });
85
137
  expect(sent).toEqual([{ action: 'notifyExternal', seq: 1 }]);
86
138
  });
87
139
  /**
@@ -91,46 +143,140 @@ describe('createOptimisticActionClient', () => {
91
143
  it('同名で両方ある場合、先読みは actions 側だけ', () => {
92
144
  const { client, states } = setup();
93
145
  client.send('move');
94
- expect(latest(states)).toEqual({ moves: 1, charged: 0 });
146
+ expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
95
147
  });
96
148
  });
97
- describe('events の重複排除', () => {
149
+ describe('events の配信', () => {
98
150
  /**
99
- * 自分が出した action の ack では、actions 由来の events は先読み時に発火済みなので
100
- * skip する。二重発火すると効果音が 2 回鳴るなどの不具合になる。
151
+ * 予測が当たった場合、確定時に再発火しない。二重に鳴ると効果音が 2 回鳴る。
152
+ * 先読みで 1 回だけ (predicted: true) 発火していること。
101
153
  */
102
- it('自分の ack では actions 由来の events を発火しない', () => {
154
+ it('予測が当たったら確定時に再発火しない', () => {
103
155
  const { client, fired } = setup();
104
- client.send('move');
105
- client.applyState({ moves: 1, charged: 0 }, { ack: 1, from: 'me', events: [{ name: 'moved', data: {} }] });
106
- expect(fired).toEqual([]);
156
+ client.send('move'); // 先読みで moved が発火
157
+ expect(fired).toEqual(['moved']);
158
+ client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [{ name: 'moved', data: {} }] });
159
+ expect(fired).toEqual(['moved']);
107
160
  });
108
161
  /**
109
- * serverActions 由来の events は先読みで走っていないので、自分の ack でも必ず発火する。
110
- * ここを events と同じ配列で送ると重複排除に巻き込まれて永久に届かなくなる。
162
+ * IMPORTANT: 予測が外れた場合、サーバー側の正しい event が確定として発火する。
163
+ *
164
+ * 旧実装は「自分の ack なら actions 由来の events を全部 skip」していたため、
165
+ * 別の分岐を通っていると正しい event が永久に届かなかった。
111
166
  */
112
- it('自分の ack でも serverEvents は発火する', () => {
167
+ it('予測が外れたらサーバー側の event が確定として発火する', () => {
113
168
  const { client, fired } = setup();
114
- client.send('move');
115
- client.applyState({ moves: 1, charged: 5 }, {
169
+ client.send('guess', { willEmit: 'accepted' }); // 先読みでは accepted
170
+ expect(fired).toEqual(['accepted']);
171
+ // サーバーは tooLate だった
172
+ client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [{ name: 'tooLate', data: {} }] });
173
+ expect(fired).toEqual(['accepted', 'tooLate']);
174
+ });
175
+ /**
176
+ * serverActions 由来の events は先読みで走っていないので確定として発火する。
177
+ * サーバーは events を 1 本で送るが、クライアントが自分の発火記録を差し引くので
178
+ * 袋を分ける必要がない (旧 serverEvents は廃止)。
179
+ */
180
+ it('先読みしていない event は 1 本のリストからでも発火する', () => {
181
+ const { client, fired } = setup();
182
+ client.send('move'); // 先読みで moved のみ
183
+ client.applyState({ moves: 1, charged: 5, stampedAt: 0 }, {
116
184
  ack: 1,
117
185
  from: 'me',
118
- events: [{ name: 'moved', data: {} }],
119
- serverEvents: [{ name: 'charged', data: {} }],
186
+ // actions 由来 (moved) serverActions 由来 (charged) が同じ配列で届く
187
+ events: [
188
+ { name: 'moved', data: {} },
189
+ { name: 'charged', data: {} },
190
+ ],
120
191
  });
121
- expect(fired).toEqual(['charged']);
192
+ expect(fired).toEqual(['moved', 'charged']);
122
193
  });
123
- /** 他プレイヤーの action なら先読みしていないので、両方とも発火する。 */
124
- it('他プレイヤーの ack では両方発火する', () => {
194
+ /** 他プレイヤーの action なら先読みしていないので全部確定として発火する。 */
195
+ it('他プレイヤーの ack では全部確定として発火する', () => {
125
196
  const { client, fired } = setup();
126
- client.applyState({ moves: 1, charged: 5 }, {
197
+ client.applyState({ moves: 1, charged: 5, stampedAt: 0 }, {
127
198
  ack: 1,
128
199
  from: 'other',
129
- events: [{ name: 'moved', data: {} }],
130
- serverEvents: [{ name: 'charged', data: {} }],
200
+ events: [
201
+ { name: 'moved', data: {} },
202
+ { name: 'charged', data: {} },
203
+ ],
131
204
  });
132
205
  expect(fired).toEqual(['moved', 'charged']);
133
206
  });
207
+ /** predict: false の event は先読みで実行されず、確定時に 1 回だけ実行される。 */
208
+ it('predict: false の event は確定時にだけ実行される', () => {
209
+ const { client, fired } = setup();
210
+ client.send('guess', { willEmit: 'fanfare' });
211
+ expect(fired).toEqual([]);
212
+ client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [{ name: 'fanfare', data: {} }] });
213
+ expect(fired).toEqual(['fanfare']);
214
+ });
215
+ /**
216
+ * IMPORTANT: 他プレイヤーの action が同じ出来事を起こしても二重実行しない。
217
+ *
218
+ * A と B が同時に同じ行送りを撃つと、サーバーは先着の A だけを通し、B の分は
219
+ * 握り潰す。 B から見て届くのは「A の action の ack」なので、記録を自分の pending に
220
+ * 紐づけていると引き当てられず、先読みで実行済みなのにもう一度実行してしまう。
221
+ * (e2e/prediction-misfire.mjs で実測した回帰)
222
+ */
223
+ it('他プレイヤー由来の配信でも先読み済みなら二重実行しない', () => {
224
+ const { client, fired } = setup();
225
+ // 自分も撃った (先読みで moved1 を実行)
226
+ client.send('guess', { willEmit: 'moved', data: { n: 1 } });
227
+ expect(fired).toEqual(['moved1']);
228
+ // 先に着いた他プレイヤーの action が同じ出来事を起こして配信されてくる
229
+ client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 99, from: 'other', events: [{ name: 'moved', data: { n: 1 } }] });
230
+ expect(fired).toEqual(['moved1']);
231
+ });
232
+ /**
233
+ * IMPORTANT: 予測が外れた記録は「その action が ack された時点」で捨てる。
234
+ *
235
+ * 「未確定の action が全部無くなったら捨てる」だけだと、別の action が未確定な間
236
+ * ずっと外れた記録が生き残り、その後に本当に起きた同名イベントを 1 回握り潰す。
237
+ * (レビュー指摘の再現)
238
+ */
239
+ it('別の action が未確定でも、外れた記録はその ack で捨てる', () => {
240
+ const { client, fired } = setup();
241
+ client.send('guess', { willEmit: 'moved', data: { n: 1 } }); // seq 1: 先読みで実行
242
+ client.send('move'); // seq 2: まだ未確定のまま残す
243
+ expect(fired).toEqual(['moved1', 'moved']);
244
+ // seq 1 の ack。サーバーは moved1 を出さなかった = 予測が外れた
245
+ client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [] });
246
+ // seq 2 が未確定でも、seq 1 の記録は捨てられている
247
+ // → 他プレイヤー由来の本物の moved1 は実行されるべき
248
+ client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 99, from: 'other', events: [{ name: 'moved', data: { n: 1 } }] });
249
+ expect(fired).toEqual(['moved1', 'moved', 'moved1']);
250
+ });
251
+ /**
252
+ * 予測が外れて実際には起きなかった出来事の記録は、その action の ack で破棄する。
253
+ * 残したままだと、次に本当にその出来事が起きたときに実行されなくなる。
254
+ */
255
+ it('外れた記録は ack で破棄する', () => {
256
+ const { client, fired } = setup();
257
+ client.send('guess', { willEmit: 'moved', data: { n: 1 } });
258
+ expect(fired).toEqual(['moved1']);
259
+ // 自分の ack。サーバーは何も起こさなかった (予測が外れた)
260
+ client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [] });
261
+ expect(fired).toEqual(['moved1']);
262
+ // 後から本当に起きた同じ出来事は、記録が消えているので実行される
263
+ client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 100, from: 'other', events: [{ name: 'moved', data: { n: 1 } }] });
264
+ expect(fired).toEqual(['moved1', 'moved1']);
265
+ });
266
+ /** 同じ event が 2 回来たら、先読み 1 回分だけを差し引く (多重集合の差)。 */
267
+ it('同じ event が複数回でも先読み分だけ差し引く', () => {
268
+ const { client, fired } = setup();
269
+ client.send('move'); // 先読みで moved 1 回
270
+ client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, {
271
+ ack: 1,
272
+ from: 'me',
273
+ events: [
274
+ { name: 'moved', data: {} },
275
+ { name: 'moved', data: {} },
276
+ ],
277
+ });
278
+ expect(fired).toEqual(['moved', 'moved']);
279
+ });
134
280
  });
135
281
  describe('pending キューと巻き戻し', () => {
136
282
  /**
@@ -142,19 +288,19 @@ describe('createOptimisticActionClient', () => {
142
288
  client.send('move'); // seq 1
143
289
  client.send('move'); // seq 2
144
290
  // seq 1 だけ確定。charged はサーバー側で加算済みの値が入っている
145
- client.applyState({ moves: 1, charged: 7 }, { ack: 1, from: 'me' });
291
+ client.applyState({ moves: 1, charged: 7, stampedAt: 0 }, { ack: 1, from: 'me' });
146
292
  // 確定 (moves:1) + 未 ack の seq 2 を再適用 = moves:2、charged はサーバー値のまま
147
- expect(latest(states)).toEqual({ moves: 2, charged: 7 });
293
+ expect(latest(states)).toMatchObject({ moves: 2, charged: 7 });
148
294
  });
149
295
  /** __action_error で該当 seq を除去し、残りを再適用する。 */
150
296
  it('rollback は該当 action だけを取り消す', () => {
151
297
  const { client, states } = setup();
152
298
  client.send('move'); // seq 1
153
299
  client.send('move'); // seq 2
154
- expect(latest(states)).toEqual({ moves: 2, charged: 0 });
300
+ expect(latest(states)).toMatchObject({ moves: 2, charged: 0 });
155
301
  client.rollback(1);
156
302
  // seq 1 が消えて seq 2 だけ再適用される
157
- expect(latest(states)).toEqual({ moves: 1, charged: 0 });
303
+ expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
158
304
  });
159
305
  /** 先行実行で throw した action は pending に積まれず、送信だけ行われる。 */
160
306
  it('先行実行が throw した action は pending に積まれない', () => {
@@ -162,23 +308,25 @@ describe('createOptimisticActionClient', () => {
162
308
  client.send('boom');
163
309
  expect(sent).toEqual([{ action: 'boom', seq: 1 }]);
164
310
  // pending が空なので、確定 state がそのまま表示される
165
- client.applyState({ moves: 9, charged: 9 }, {});
166
- expect(latest(states)).toEqual({ moves: 9, charged: 9 });
311
+ client.applyState({ moves: 9, charged: 9, stampedAt: 0 }, {});
312
+ expect(latest(states)).toMatchObject({ moves: 9, charged: 9 });
167
313
  });
168
314
  });
169
315
  describe('applyDelta', () => {
170
- /** delta 経路でも serverEvents の扱いは applyState と揃っている。 */
171
- it('自分の ack でも serverEvents を発火する', () => {
316
+ /** delta 経路でも events の差し引きは applyState と揃っている。 */
317
+ it('先読み済みを差し引いた残りだけを発火する', () => {
172
318
  const { client, fired } = setup();
173
- client.send('move');
319
+ client.send('move'); // 先読みで moved
174
320
  const ok = client.applyDelta([{ op: 'replace', path: '/charged', value: 3 }], {
175
321
  ack: 1,
176
322
  from: 'me',
177
- events: [{ name: 'moved', data: {} }],
178
- serverEvents: [{ name: 'charged', data: {} }],
323
+ events: [
324
+ { name: 'moved', data: {} },
325
+ { name: 'charged', data: {} },
326
+ ],
179
327
  });
180
328
  expect(ok).toBe(true);
181
- expect(fired).toEqual(['charged']);
329
+ expect(fired).toEqual(['moved', 'charged']);
182
330
  });
183
331
  /** patch が当たらないときは events を発火せず false を返す (transport がフル state を再要求する)。 */
184
332
  it('patch 適用に失敗したら events を発火せず false を返す', () => {
@@ -186,7 +334,6 @@ describe('createOptimisticActionClient', () => {
186
334
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
187
335
  const ok = client.applyDelta([{ op: 'replace', path: '/missing/deep', value: 1 }], {
188
336
  events: [{ name: 'moved', data: {} }],
189
- serverEvents: [{ name: 'charged', data: {} }],
190
337
  });
191
338
  expect(ok).toBe(false);
192
339
  expect(fired).toEqual([]);
@@ -202,7 +349,7 @@ describe('createOptimisticActionClient', () => {
202
349
  const { client, sent, states } = setup();
203
350
  client.send('legacy');
204
351
  // state は動かず、送信だけ行われる
205
- expect(latest(states)).toEqual({ moves: 0, charged: 0 });
352
+ expect(latest(states)).toMatchObject({ moves: 0, charged: 0 });
206
353
  expect(sent).toEqual([{ action: 'legacy', seq: 1 }]);
207
354
  });
208
355
  /** 再適用でも同じ。pending に積まれていても brand 付きなら実行しない。 */
@@ -210,9 +357,9 @@ describe('createOptimisticActionClient', () => {
210
357
  const { client, states } = setup();
211
358
  client.send('move'); // seq 1 (pending へ)
212
359
  client.send('legacy'); // seq 2 (pending へは積まれない)
213
- client.applyState({ moves: 0, charged: 0 }, {});
360
+ client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, {});
214
361
  // 確定 state に move だけが再適用され、legacy の +1000 は入らない
215
- expect(latest(states)).toEqual({ moves: 1, charged: 0 });
362
+ expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
216
363
  });
217
364
  /**
218
365
  * 再適用中に handler が途中まで state を変更してから throw した場合、その部分変更を
@@ -225,8 +372,84 @@ describe('createOptimisticActionClient', () => {
225
372
  // 送信時点で partial は throw するので pending に入らず、moves は 1 のまま
226
373
  expect(latest(states).moves).toBe(1);
227
374
  // 確定 state を受けて再適用しても、partial の部分変更は混ざらない
228
- client.applyState({ moves: 0, charged: 0 }, {});
229
- expect(latest(states)).toEqual({ moves: 1, charged: 0 });
375
+ client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, {});
376
+ expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
377
+ });
378
+ });
379
+ /**
380
+ * ctx.now の配布。
381
+ *
382
+ * actions はクライアント先読みとサーバーで 2 回走るため、handler の中で Date.now() を
383
+ * 読むと端末の時計ズレがそのまま state に入る。ctx.now はサーバーとのクロック
384
+ * オフセットで補正した推定値を配ることでこれを防ぐ。
385
+ */
386
+ describe('ctx.now', () => {
387
+ /** オフセット未取得なら素のローカル時刻。オフラインでも壊れないこと。 */
388
+ it('サーバー時刻を観測する前はローカル時刻が入る', () => {
389
+ const { client, states } = setup();
390
+ const before = Date.now();
391
+ client.send('move');
392
+ expect(latest(states).stampedAt).toBeGreaterThanOrEqual(before);
393
+ expect(latest(states).stampedAt).toBeLessThanOrEqual(Date.now());
394
+ });
395
+ /**
396
+ * 端末の時計が大きくズレていても、サーバー時刻の観測でオフセットが補正される。
397
+ * ここでは「サーバーが 1 時間先」を観測させ、ctx.now がそちらへ寄ることを見る。
398
+ */
399
+ it('サーバー時刻を観測するとオフセット分ずれた値が入る', () => {
400
+ const { client, states } = setup();
401
+ const ONE_HOUR = 3600 * 1000;
402
+ client.observeServerTime(Date.now() + ONE_HOUR);
403
+ client.send('move');
404
+ const stamped = latest(states).stampedAt;
405
+ // 1 時間先へ寄っている (テスト実行のブレを考慮して幅を持たせる)
406
+ expect(stamped).toBeGreaterThan(Date.now() + ONE_HOUR - 5000);
407
+ expect(stamped).toBeLessThan(Date.now() + ONE_HOUR + 5000);
408
+ });
409
+ /** 数値でない / 欠けている場合は無視する (serverTime を持たないメッセージ用)。 */
410
+ it('不正な観測値は無視される', () => {
411
+ const { client, states } = setup();
412
+ client.observeServerTime(undefined);
413
+ client.observeServerTime(Number.NaN);
414
+ const before = Date.now();
415
+ client.send('move');
416
+ expect(latest(states).stampedAt).toBeGreaterThanOrEqual(before);
417
+ expect(latest(states).stampedAt).toBeLessThanOrEqual(Date.now());
418
+ });
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
+ /**
437
+ * IMPORTANT: 再適用 (reconciliation でのやり直し) では初回予測時の now を使い回す。
438
+ *
439
+ * 取り直すと、サーバーから ack が来るたびに handler が別の時刻で走り、
440
+ * timerEndsAt のような値が毎回ズレて秒読みがガタつく。
441
+ */
442
+ it('再適用でも初回予測時の now を使い回す', () => {
443
+ const { client, states } = setup();
444
+ client.send('move');
445
+ const firstStamp = latest(states).stampedAt;
446
+ // 再適用の直前にオフセットを大きく動かす。now を取り直す実装ならここで
447
+ // stampedAt が 1 時間ジャンプするので、使い回しているかを確実に判別できる。
448
+ client.observeServerTime(Date.now() + 3600 * 1000);
449
+ // 別プレイヤーの ack を受けて再適用させる (自分の pending は残る)
450
+ client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, { ack: 99, from: 'other' });
451
+ expect(latest(states).moves).toBe(1);
452
+ expect(latest(states).stampedAt).toBe(firstStamp);
230
453
  });
231
454
  });
232
455
  });
@@ -74,6 +74,9 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
74
74
  }
75
75
  const msgType = parsed.type;
76
76
  console.log(`[SDK ServerAction] ⬅ recv type=${msgType}`);
77
+ // サーバーは全ブロードキャストに実時刻を載せる。どのメッセージでもオフセットを
78
+ // 更新できるので、tick が回っている限り追加の往復は要らない。
79
+ client.observeServerTime(parsed.serverTime);
77
80
  if (msgType === '__room_init') {
78
81
  // サーバー (relay-room / sync-room / game-room) はいずれも接続 URL の playerId
79
82
  // クエリをそのまま `myId` として echo する。つまり parsed.myId は常に引数 playerId と
@@ -123,10 +126,9 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
123
126
  const ack = parsed.ack;
124
127
  const from = parsed.from;
125
128
  const evts = parsed.events ?? [];
126
- const serverEvts = parsed.serverEvents ?? [];
127
129
  serverSeq = parsed.seq ?? serverSeq + 1;
128
130
  requestStatePending = false;
129
- client.applyState(parsed.state, { ack, from, events: evts, serverEvents: serverEvts });
131
+ client.applyState(parsed.state, { ack, from, events: evts });
130
132
  return;
131
133
  }
132
134
  // ─── Action 結果 (差分パッチ) ────────────────────────
@@ -134,7 +136,6 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
134
136
  const ack = parsed.ack;
135
137
  const from = parsed.from;
136
138
  const evts = parsed.events ?? [];
137
- const serverEvts = parsed.serverEvents ?? [];
138
139
  const newSeq = parsed.seq ?? serverSeq + 1;
139
140
  if (newSeq !== serverSeq + 1) {
140
141
  requestFullState();
@@ -144,7 +145,6 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
144
145
  ack,
145
146
  from,
146
147
  events: evts,
147
- serverEvents: serverEvts,
148
148
  });
149
149
  if (!ok) {
150
150
  requestFullState();
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @docs
3
+ * - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
4
+ *
5
+ * サーバー時刻の推定値をクライアント全体へ配る。
6
+ *
7
+ * state に入っている `endsAt` のような絶対時刻はサーバーの時計で打たれている。
8
+ * それを端末の `Date.now()` と引き算すると、端末の時計ズレがそのまま表示のズレになる
9
+ * (残り時間が恒久的に狂う / 期限判定がサーバーと食い違う)。読み取り側も同じ時計に
10
+ * 揃えるための関数。
11
+ *
12
+ * ```ts
13
+ * const remain = Math.ceil((state.game.timerEndsAt - serverNow()) / 1000);
14
+ * ```
15
+ *
16
+ * オフセットは transport がサーバーからのメッセージを受けるたびに更新する。
17
+ * 未接続 / 未観測ならローカル時刻をそのまま返す (オフラインでも壊れない)。
18
+ */
19
+ /** transport から呼ぶ内部関数。サーバーが打刻した時刻を観測してオフセットを更新する。 */
20
+ export declare function observeServerTime(serverTime: number | undefined): void;
21
+ /**
22
+ * サーバー時刻の推定値 (ms)。
23
+ *
24
+ * カウントダウン描画のように毎秒/毎フレーム呼ぶ用途を想定しているので、
25
+ * state の到着とは無関係にいつでも呼べる。
26
+ */
27
+ export declare function serverNow(): number;
28
+ /** ソロモードなど「自分自身がサーバー」の経路で使う。 */
29
+ export declare function resetServerTimeOffset(): void;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @docs
3
+ * - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
4
+ *
5
+ * サーバー時刻の推定値をクライアント全体へ配る。
6
+ *
7
+ * state に入っている `endsAt` のような絶対時刻はサーバーの時計で打たれている。
8
+ * それを端末の `Date.now()` と引き算すると、端末の時計ズレがそのまま表示のズレになる
9
+ * (残り時間が恒久的に狂う / 期限判定がサーバーと食い違う)。読み取り側も同じ時計に
10
+ * 揃えるための関数。
11
+ *
12
+ * ```ts
13
+ * const remain = Math.ceil((state.game.timerEndsAt - serverNow()) / 1000);
14
+ * ```
15
+ *
16
+ * オフセットは transport がサーバーからのメッセージを受けるたびに更新する。
17
+ * 未接続 / 未観測ならローカル時刻をそのまま返す (オフラインでも壊れない)。
18
+ */
19
+ let offset = 0;
20
+ /** transport から呼ぶ内部関数。サーバーが打刻した時刻を観測してオフセットを更新する。 */
21
+ export function observeServerTime(serverTime) {
22
+ if (typeof serverTime !== 'number' || !Number.isFinite(serverTime))
23
+ return;
24
+ // 下り片道遅延を無視するので推定は実サーバー時刻より僅かに遅れる。
25
+ // 表示用途では無視できる誤差なので、精度より単純さを取る。
26
+ offset = serverTime - Date.now();
27
+ }
28
+ /**
29
+ * サーバー時刻の推定値 (ms)。
30
+ *
31
+ * カウントダウン描画のように毎秒/毎フレーム呼ぶ用途を想定しているので、
32
+ * state の到着とは無関係にいつでも呼べる。
33
+ */
34
+ export function serverNow() {
35
+ return Date.now() + offset;
36
+ }
37
+ /** ソロモードなど「自分自身がサーバー」の経路で使う。 */
38
+ export function resetServerTimeOffset() {
39
+ offset = 0;
40
+ }
package/dist/types.d.ts CHANGED
@@ -56,6 +56,24 @@ export interface PlayersChangedMessage {
56
56
  players: Record<string, PlayerVoiceState>;
57
57
  }
58
58
  export type Emit = (eventName: string, data?: Record<string, unknown>) => void;
59
+ /**
60
+ * events handler。
61
+ *
62
+ * `emit(name, data)` の `data` をそのまま受け取る。先読み中か確定後かは渡さない
63
+ * (どちらで呼ばれても同じ処理をする前提。 `predict` で宣言済みなので分岐の必要がない)。
64
+ */
65
+ export type EventHandler = (data: Record<string, unknown>) => void;
66
+ /**
67
+ * events の購読宣言。
68
+ *
69
+ * 先読みは外れることがあり、実行してしまったものは取り消せない。取り消せない副作用
70
+ * (analytics / 実績解除 / 長い演出) は必ず `predict: false` にすること。
71
+ */
72
+ export interface EventSubscription {
73
+ /** 先読み時点で実行してよいか。宣言必須。 */
74
+ predict: boolean;
75
+ handler: EventHandler;
76
+ }
59
77
  /** dev harness で観測される 1 件の emit。`data` は `emit(name)` 省略時に空 object になる。 */
60
78
  export interface ServerEvent {
61
79
  name: string;
@@ -71,20 +89,93 @@ export interface SeededRandom {
71
89
  * 素の action handler の実行文脈。空なのは意図的。
72
90
  *
73
91
  * 素の handler はサーバーとクライアント先読みの両方で走る。クライアントが自力で
74
- * 再現できない値 (tick / 実時刻 / 乱数) をここで配ると、サーバー・ソロ・dev では
75
- * 本物が入るのにオンラインの先読みだけ値がズレる、という一番気付きにくい形で壊れる。
92
+ * 再現できない値 (tick / 乱数) をここで配ると、サーバー・ソロ・dev では本物が入るのに
93
+ * オンラインの先読みだけ値がズレる、という一番気付きにくい形で壊れる。
76
94
  * そういう値が要る処理は `serverActions` 側に書く。
77
95
  */
78
- export type ActionContext = Record<never, never>;
96
+ export interface ActionContext {
97
+ /**
98
+ * サーバーで処理される時刻 (ms)。先読みではクロックオフセットからの推定値。
99
+ *
100
+ * 推定なのでサーバーとは数十 ms ずれる。時刻での分岐に使うと境界で判定が割れるので、
101
+ * 分岐は `update()` (サーバー専用) で行う。
102
+ *
103
+ * 同じ action を再適用しても値は変わらない (初回予測時の値を使い回す)。
104
+ */
105
+ now: number;
106
+ emit: Emit;
107
+ /** 予約はサーバーだけが持つ。先読みでは戻り値を計算するだけで、予約も取り消しもしない。 */
108
+ schedule(options: ScheduleOptions): number;
109
+ unschedule(key: string): void;
110
+ }
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>;
121
+ }
122
+ /**
123
+ * サーバー権威のタイマー。
124
+ *
125
+ * クライアントの時計やアプリのバックグラウンド化に依存せず、サーバーが指定時刻に
126
+ * 自分で起きて action を撃つ。`tickRate` で毎秒ポーリングする必要が無くなり、
127
+ * その間 Durable Object は hibernate できる。
128
+ */
129
+ export interface Scheduler {
130
+ /**
131
+ * 予約して確定した絶対時刻 (ms) を返す。表示用の endsAt にそのまま使える。
132
+ *
133
+ * 発火は at-least-once。同じ予約が 2 回実行されうるので、handler 側で「自分が予約した
134
+ * ものか」を state と突き合わせて弾くこと (action 名や key では区別できない)。
135
+ */
136
+ schedule(options: ScheduleOptions): number;
137
+ unschedule(key: string): void;
138
+ }
139
+ /**
140
+ * 予約から発火した action に渡る playerId。
141
+ *
142
+ * 送信者がいないので、人間の操作と区別するための定数。
143
+ * `if (playerId !== SCHEDULED_ACTOR) return;` で人間からの直接実行を弾ける。
144
+ */
145
+ export declare const SCHEDULED_ACTOR = "__scheduled";
79
146
  /** `serverActions` handler の実行文脈。サーバーでしか走らないので tick と乱数を渡せる。 */
80
147
  export interface ServerActionContext {
81
148
  tick: number;
82
149
  random: SeededRandom;
150
+ /** サーバーの実時刻 (ms)。同じ dispatch の `actions` に渡る `ctx.now` と同一値。 */
151
+ now: number;
152
+ emit: Emit;
153
+ schedule(options: ScheduleOptions): number;
154
+ unschedule(key: string): void;
83
155
  }
84
156
  /** @deprecated `ServerActionContext` を使う。 */
85
157
  export type ServerOnlyActionContext = ServerActionContext;
86
- export type ActionHandler<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: ActionContext) => void;
87
- export type ServerActionHandler<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: ServerActionContext) => Promise<void> | void;
158
+ /**
159
+ * action handler の引数。
160
+ *
161
+ * オブジェクトなのは使うものだけ書けるようにするため。`ctx` を入れ子で残しているのは、
162
+ * 実行環境が与えるものをひとまとまりで helper へ渡せるようにするため。
163
+ */
164
+ export interface ActionArgs<S> {
165
+ state: S;
166
+ payload: any;
167
+ playerId: string;
168
+ ctx: ActionContext;
169
+ }
170
+ export type ActionHandler<S> = (args: ActionArgs<S>) => void;
171
+ /** `serverActions` handler の引数。`ctx` にサーバー限定の tick / random が入る。 */
172
+ export interface ServerActionArgs<S> {
173
+ state: S;
174
+ payload: any;
175
+ playerId: string;
176
+ ctx: ServerActionContext;
177
+ }
178
+ export type ServerActionHandler<S> = (args: ServerActionArgs<S>) => Promise<void> | void;
88
179
  /** @deprecated `ServerActionHandler` を使う。 */
89
180
  export type ServerOnlyActionHandlerFn<S> = ServerActionHandler<S>;
90
181
  /**
@@ -94,6 +185,21 @@ export type ServerOnlyActionHandlerFn<S> = ServerActionHandler<S>;
94
185
  export type ServerOnlyAction<S> = ServerActionHandler<S> & {
95
186
  readonly __serverOnly: true;
96
187
  };
188
+ /** `update()` の ctx。サーバーでしか走らないので tick / random / playerInputs を持つ。 */
189
+ export interface UpdateContext {
190
+ random: SeededRandom;
191
+ tick: number;
192
+ /** サーバーの実時刻 (ms)。update はサーバーでしか走らないので常に正確。 */
193
+ now: number;
194
+ schedule(options: ScheduleOptions): number;
195
+ unschedule(key: string): void;
196
+ emit: Emit;
197
+ playerInputs: Record<string, Record<string, any>>;
198
+ }
199
+ export interface UpdateArgs<S> {
200
+ state: S;
201
+ ctx: UpdateContext;
202
+ }
97
203
  export interface GameLogic<S> {
98
204
  /**
99
205
  * seats には kind !== 'player' の席 (spectator / admin) も含まれる。
@@ -116,19 +222,22 @@ export interface GameLogic<S> {
116
222
  * (旧 `serverOnly()` 相当) になる。
117
223
  */
118
224
  serverActions?: Record<string, ServerActionHandler<S>>;
119
- update(state: S, ctx: {
120
- random: SeededRandom;
121
- tick: number;
122
- emit: Emit;
123
- playerInputs: Record<string, Record<string, any>>;
124
- }): void;
225
+ update(args: UpdateArgs<S>): void;
125
226
  tickRate?: number;
126
227
  }
127
228
  export interface GameConfig<S> extends ConnectionCallbacks {
128
229
  logic: GameLogic<S>;
129
230
  onState: (state: S, myPlayerId: string) => void;
130
231
  inputs: (sendAction: (type: string, payload?: any) => void) => void;
131
- events?: Record<string, (data: Record<string, unknown>) => void>;
232
+ /**
233
+ * `emit(name, data)` の購読。 キーごとに `predict` の宣言が必須。
234
+ *
235
+ * 同じ出来事が二重に実行されないよう、SDK は「先読みで実行した記録」を持ち、
236
+ * サーバーから同一の event (name と data が完全一致) が届いたら実行を抑止する。
237
+ * したがって `data` には**クライアントとサーバーで必ず同じ値になるもの**だけを
238
+ * 入れること。`ctx.now` や乱数 ID を混ぜると一致せず二重実行になる。
239
+ */
240
+ events?: Record<string, EventSubscription>;
132
241
  playerCount: number;
133
242
  /** Dev harness のデフォルト向き。manifest.json の `orientation` を渡す。 */
134
243
  orientation?: 'portrait' | 'landscape';
package/dist/types.js CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * 予約から発火した action に渡る playerId。
3
+ *
4
+ * 送信者がいないので、人間の操作と区別するための定数。
5
+ * `if (playerId !== SCHEDULED_ACTOR) return;` で人間からの直接実行を弾ける。
6
+ */
7
+ export const SCHEDULED_ACTOR = '__scheduled';
1
8
  /** Sentinel value — patch の value にセットすると、サーバーが Date.now() に置換する */
2
9
  export const SERVER_TIME = '__SERVER_TIME__';
3
10
  /** デフォルトのプレイヤーアイコン URL 一覧(dev / local モード用) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "UZU PlayScreen SDK - Flutter ↔ JS ゲーム通信ライブラリ",
5
5
  "type": "module",
6
6
  "exports": {