@uzuhq/code-sdk 0.4.0 → 0.6.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, 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, ActionContext, ActionHandler, 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';
@@ -6,9 +6,10 @@
6
6
  * ブラウザ内 LocalGameRoom — GameRoom DO の動作をサーバーレスで再現する。
7
7
  * server_url がない場合に vite dev だけで ServerAction シナリオをローカル実行する。
8
8
  *
9
- * online (`runOnlineServerAction`) と挙動を揃えるため、standard handler は同期で実行し
9
+ * online (`runOnlineServerAction`) と挙動を揃えるため、`logic.actions` は同期で実行し
10
10
  * `sendAction()` 直後に `onState()` を同期発火する (楽観的更新だけで完結する仕様)。
11
- * serverOnly handler のみ `await` で非同期実行し、完了後に `onState()` を呼ぶ。
11
+ * `logic.serverActions` `Promise<void>` を返しうるので `await` で実行し、完了後に
12
+ * `onState()` を呼ぶ。同名なら actions → serverActions の順で走る (サーバーと同じ)。
12
13
  */
13
14
  import type { GameConfig } from '../types.js';
14
15
  import type { RunHandle } from '../dev-hooks.js';
@@ -15,48 +15,94 @@ 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 は名前参照なので最新束縛を読む。
24
53
  let state = logic.setup(players, random);
25
54
  let tick = 0;
26
55
  const playerInputs = {};
27
- // Action 処理。standard handler は同期実行で `sendAction()` 直後の同期 onState
28
- // 保証する (online の楽観的更新と同じ挙動)。serverOnly handler は `Promise<void>`
29
- // を返しうるので `await` で実行し、完了後に events と onState を発火する。
56
+ // Action 処理。`actions` は同期実行で `sendAction()` 直後の同期 onState を保証する
57
+ // (online の楽観的更新と同じ挙動)。`serverActions` は `Promise<void>` を返しうるので
58
+ // `await` で実行し、完了後に events と onState を発火する。
30
59
  // どちらも 1 回しか実行しない (online の「楽観 → サーバー確定」の 2 段階は再現しない)。
31
60
  const dispatchAction = (type, payload) => {
32
- const handler = logic.actions[type];
33
- if (!handler)
61
+ const plain = logic.actions[type];
62
+ // 移行期: 旧 serverOnly() を actions に入れたままの logic も動かす。
63
+ const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
64
+ const server = logic.serverActions?.[type] ?? legacyServerOnly;
65
+ if (!plain && !server)
34
66
  return;
35
- const actionEvents = [];
36
- const actionEmit = (name, data) => actionEvents.push({ name, data: data ?? {} });
37
- if (isServerOnlyAction(handler)) {
38
- void (async () => {
39
- try {
40
- await handler(state, payload ?? {}, myId, actionEmit, { tick });
41
- }
42
- catch (err) {
43
- console.warn('[SDK LocalServerAction] Action error:', err);
44
- return;
45
- }
46
- dispatchEvents(actionEvents);
47
- onState(state, myId);
48
- })();
49
- return;
50
- }
51
- try {
52
- handler(state, payload ?? {}, myId, actionEmit, {});
67
+ // events emit 元ごとに分ける。online では actions 由来がクライアント先読みの
68
+ // 時点で発火するので、ここでも plain の実行直後に流す。共有すると serverActions
69
+ // `await fetch()` を持つ場合に actions 側の events までその分遅れてしまい、
70
+ // 「ローカルモードだけ音が遅れる」というモード差になる。
71
+ const plainEvents = [];
72
+ const serverEvents = [];
73
+ const plainEmit = (name, data) => plainEvents.push({ name, data: data ?? {} });
74
+ const serverEmit = (name, data) => serverEvents.push({ name, data: data ?? {} });
75
+ // ソロモードはこのクライアント自身がサーバーなので、実時刻がそのまま正となる。
76
+ const now = Date.now();
77
+ if (plain && !legacyServerOnly) {
78
+ try {
79
+ plain(state, payload ?? {}, myId, plainEmit, { now, ...makeScheduleCtx() });
80
+ }
81
+ catch (err) {
82
+ console.warn('[SDK LocalServerAction] Action error:', err);
83
+ return;
84
+ }
85
+ dispatchEvents(plainEvents);
86
+ onState(state, myId);
53
87
  }
54
- catch (err) {
55
- console.warn('[SDK LocalServerAction] Action error:', err);
88
+ if (!server)
56
89
  return;
57
- }
58
- dispatchEvents(actionEvents);
59
- onState(state, myId);
90
+ void (async () => {
91
+ try {
92
+ await server(state, payload ?? {}, myId, serverEmit, {
93
+ tick,
94
+ random,
95
+ now,
96
+ ...makeScheduleCtx(),
97
+ });
98
+ }
99
+ catch (err) {
100
+ console.warn('[SDK LocalServerAction] Action error:', err);
101
+ return;
102
+ }
103
+ dispatchEvents(serverEvents);
104
+ onState(state, myId);
105
+ })();
60
106
  };
61
107
  inputs(dispatchAction);
62
108
  onState(state, myId);
@@ -66,7 +112,14 @@ export function runLocalServerAction(config) {
66
112
  const tickEvents = [];
67
113
  const tickEmit = (name, data) => tickEvents.push({ name, data: data ?? {} });
68
114
  try {
69
- logic.update(state, { random, tick, emit: tickEmit, playerInputs });
115
+ logic.update(state, {
116
+ random,
117
+ tick,
118
+ now: Date.now(),
119
+ ...makeScheduleCtx(),
120
+ emit: tickEmit,
121
+ playerInputs,
122
+ });
70
123
  }
71
124
  catch (err) {
72
125
  console.error(`[SDK LocalServerAction] tick error at tick=${tick}:`, err);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,218 @@
1
+ /**
2
+ * local-server-action.ts (ソロモード) の unit test。
3
+ *
4
+ * ソロモードは「クライアント = サーバー」なので `actions` と `serverActions` の
5
+ * 両方を 1 回ずつ実行する。online の「楽観 → サーバー確定」の 2 段階は再現しないが、
6
+ * **events の発火タイミングは online に揃える** 必要がある。
7
+ *
8
+ * online では actions 由来の events はクライアント先読みの時点で即座に発火する。
9
+ * ソロでも同じく plain の実行直後に流さないと、`serverActions` が `await fetch()` を
10
+ * 持つシナリオで「ソロモードだけ音が遅れる」というモード差になる。
11
+ */
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { runLocalServerAction } from './local-server-action.js';
14
+ import { serverOnly } from '../server-only.js';
15
+ /** 遅延させた serverActions を持たせるための待ち。 */
16
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
17
+ const run = (logic) => {
18
+ const fired = [];
19
+ const states = [];
20
+ let send = () => { };
21
+ const config = {
22
+ logic,
23
+ playerCount: 1,
24
+ onState: (s) => states.push(structuredClone(s)),
25
+ inputs: (sendAction) => {
26
+ send = sendAction;
27
+ },
28
+ events: {
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
+ },
41
+ },
42
+ };
43
+ runLocalServerAction(config);
44
+ return { send: (t, p) => send(t, p), fired, states };
45
+ };
46
+ const baseLogic = (overrides = {}) => ({
47
+ setup: () => ({ moves: 0, charged: 0, rolled: -1 }),
48
+ actions: {},
49
+ update: () => { },
50
+ ...overrides,
51
+ });
52
+ describe('runLocalServerAction', () => {
53
+ describe('actions と serverActions の実行', () => {
54
+ /** actions だけの action は同期実行され、その場で state と events が確定する。 */
55
+ it('actions だけなら同期で state と events が確定する', () => {
56
+ const h = run(baseLogic({
57
+ actions: {
58
+ move: (state, _payload, _playerId, emit) => {
59
+ state.moves += 1;
60
+ emit('moved');
61
+ },
62
+ },
63
+ }));
64
+ h.send('move');
65
+ expect(h.states[h.states.length - 1].moves).toBe(1);
66
+ expect(h.fired).toEqual(['moved']);
67
+ });
68
+ /** serverActions だけの action は、await 完了後に state と events が反映される。 */
69
+ it('serverActions だけなら await 後に反映される', async () => {
70
+ const h = run(baseLogic({
71
+ serverActions: {
72
+ notifyExternal: async (state, _payload, _playerId, emit) => {
73
+ await tick();
74
+ state.charged += 1;
75
+ emit('charged');
76
+ },
77
+ },
78
+ }));
79
+ h.send('notifyExternal');
80
+ expect(h.fired).toEqual([]); // まだ await 中
81
+ await tick();
82
+ await tick();
83
+ expect(h.states[h.states.length - 1].charged).toBe(1);
84
+ expect(h.fired).toEqual(['charged']);
85
+ });
86
+ /**
87
+ * 同名で両方定義されている場合、サーバーと同じく actions → serverActions の順で走る。
88
+ * serverActions は actions が書いた結果を見られる。
89
+ */
90
+ it('同名なら actions → serverActions の順に走る', async () => {
91
+ const order = [];
92
+ const h = run(baseLogic({
93
+ actions: {
94
+ move: (state) => {
95
+ order.push('actions');
96
+ state.moves += 1;
97
+ },
98
+ },
99
+ serverActions: {
100
+ move: (state, _payload, _playerId, _emit, ctx) => {
101
+ order.push('serverActions');
102
+ // actions の結果が見えている
103
+ expect(state.moves).toBe(1);
104
+ state.charged += ctx.tick + 1;
105
+ state.rolled = ctx.random.int(100);
106
+ },
107
+ },
108
+ }));
109
+ h.send('move');
110
+ await tick();
111
+ expect(order).toEqual(['actions', 'serverActions']);
112
+ const last = h.states[h.states.length - 1];
113
+ expect(last.moves).toBe(1);
114
+ expect(last.charged).toBe(1); // tick 0 + 1
115
+ // ctx.random が渡っている (setup 前の -1 から変わっている)
116
+ expect(last.rolled).toBeGreaterThanOrEqual(0);
117
+ });
118
+ });
119
+ describe('events の発火タイミング', () => {
120
+ /**
121
+ * online では actions 由来の events は先読み時に即発火する。ソロでも同じタイミングで
122
+ * 流し、serverActions の await を待たせない。ここが揃っていないとモード差になる。
123
+ */
124
+ it('actions の events は serverActions の await を待たない', async () => {
125
+ const h = run(baseLogic({
126
+ actions: {
127
+ move: (state, _payload, _playerId, emit) => {
128
+ state.moves += 1;
129
+ emit('moved');
130
+ },
131
+ },
132
+ serverActions: {
133
+ move: async (state, _payload, _playerId, emit) => {
134
+ await tick();
135
+ state.charged += 1;
136
+ emit('charged');
137
+ },
138
+ },
139
+ }));
140
+ h.send('move');
141
+ // serverActions がまだ await 中でも actions 側の events は出ている
142
+ expect(h.fired).toEqual(['moved']);
143
+ await tick();
144
+ await tick();
145
+ expect(h.fired).toEqual(['moved', 'charged']);
146
+ });
147
+ });
148
+ describe('エラー処理', () => {
149
+ /** actions が throw したら serverActions へ進まず、state 更新も通知しない。 */
150
+ it('actions が throw したら serverActions を実行しない', () => {
151
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
152
+ let serverRan = false;
153
+ const h = run(baseLogic({
154
+ actions: {
155
+ move: () => {
156
+ throw new Error('boom');
157
+ },
158
+ },
159
+ serverActions: {
160
+ move: () => {
161
+ serverRan = true;
162
+ },
163
+ },
164
+ }));
165
+ h.send('move');
166
+ expect(serverRan).toBe(false);
167
+ warn.mockRestore();
168
+ });
169
+ /** serverActions が throw しても、既に流れた actions 側の events は取り消さない。 */
170
+ it('serverActions が throw しても actions の events は残る', async () => {
171
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
172
+ const h = run(baseLogic({
173
+ actions: {
174
+ move: (state, _payload, _playerId, emit) => {
175
+ state.moves += 1;
176
+ emit('moved');
177
+ },
178
+ },
179
+ serverActions: {
180
+ move: () => {
181
+ throw new Error('boom');
182
+ },
183
+ },
184
+ }));
185
+ h.send('move');
186
+ await tick();
187
+ expect(h.fired).toEqual(['moved']);
188
+ warn.mockRestore();
189
+ });
190
+ });
191
+ describe('旧 serverOnly() の互換', () => {
192
+ /**
193
+ * R2 の古い logic.js は serverOnly() の brand を付けたまま actions に入っている。
194
+ * ソロモードでも brand を見て「先読みしない handler」として扱う。
195
+ */
196
+ it('actions に入った serverOnly() は server 側として実行される', async () => {
197
+ const logic = baseLogic({
198
+ actions: {
199
+ // 新しい型では actions に serverOnly() を入れられない (それが本 PR の狙い)。
200
+ // ここは「R2 に残っている古い logic.js」を再現するための意図的な型違反。
201
+ // @ts-expect-error 移行期の互換挙動を検証するため
202
+ legacy: serverOnly(async (state, _payload, _playerId, emit, ctx) => {
203
+ await tick();
204
+ state.charged += ctx.tick + 1;
205
+ emit('charged');
206
+ }),
207
+ },
208
+ });
209
+ const h = run(logic);
210
+ h.send('legacy');
211
+ expect(h.fired).toEqual([]); // 先読みされていない
212
+ await tick();
213
+ await tick();
214
+ expect(h.states[h.states.length - 1].charged).toBe(1);
215
+ expect(h.fired).toEqual(['charged']);
216
+ });
217
+ });
218
+ });
@@ -8,15 +8,15 @@
8
8
  * トランスポート (WebSocket / BroadcastChannel) と上位ロジック (楽観更新 / pending
9
9
  * キュー / ack ベースの確定 / rollback) を分離し、3 モードで挙動が揃うことを実装で保証する。
10
10
  *
11
- * - `send(type, payload)`: standard handler は同期で先行実行し pending キューへ。
12
- * serverOnly handler は skip して transport にだけ送る。
11
+ * - `send(type, payload)`: `logic.actions` handler を同期で先行実行し pending キューへ。
12
+ * `logic.serverActions` は先読みせず transport にだけ送る。
13
13
  * - `applyState(state, { ack, from, events })`: フル state を受信した時に呼ぶ。
14
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,16 +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
- /** サーバー (or 仮想サーバー) が dispatch した events */
30
+ /**
31
+ * サーバーが確定させた events。`actions` 由来と `serverActions` 由来を区別しない
32
+ * 1 本のリスト。
33
+ *
34
+ * 先読みで既に配信済みのものはクライアント側で差し引く (pending の `fired` 記録と
35
+ * 突き合わせる)。サーバーが袋を分ける必要はない。
36
+ */
31
37
  events?: EventEntry[];
32
38
  }
33
39
  export interface OptimisticActionClientConfig<S> {
34
40
  logic: GameLogic<S>;
35
41
  playerId: string;
36
42
  onState: (state: S, playerId: string) => void;
37
- events?: Record<string, (data: Record<string, unknown>) => void>;
43
+ events?: Record<string, EventSubscription>;
38
44
  /** action を transport に流すコールバック */
39
45
  sendAction: (msg: {
40
46
  action: string;
@@ -45,6 +51,11 @@ export interface OptimisticActionClientConfig<S> {
45
51
  export interface OptimisticActionClient<S> {
46
52
  /** input から呼ばれる action dispatch (楽観更新 + transport 送信) */
47
53
  send(type: string, payload?: any): void;
54
+ /**
55
+ * サーバーが打刻した時刻の観測値を渡してクロックオフセットを更新する。
56
+ * transport が受信した全メッセージで呼んでよい (serverTime を持たないものは無視される)。
57
+ */
58
+ observeServerTime(serverTime: number | undefined): void;
48
59
  /** 仮想サーバー / DO からフル state を受信した時に呼ぶ */
49
60
  applyState(state: S, options?: ConfirmOptions): void;
50
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,52 +86,78 @@ 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
- // serverOnly handler send 時点で pending に積まれないので、ここに来るのは
37
- // 素の handler だけ。型を絞るためにも明示的に弾く。
91
+ // 移行期の互換: 旧 serverOnly() actions に入れたままの logic では、その handler は
92
+ // サーバー専用なので先読みの再適用対象から外す (send 時にも pending へ積んでいない)。
38
93
  if (!handler || isServerOnlyAction(handler)) {
39
94
  pendingActions.splice(i, 1);
40
95
  continue;
41
96
  }
97
+ // handler が途中まで state を変更してから throw すると、その部分変更が残ったまま
98
+ // publish されてしまう。1 件ごとに直前の確定形から作り直し、成功したものだけ採用する。
99
+ const base = structuredClone(displayState);
42
100
  try {
43
- runPredicted(action, () => handler(displayState, payload, playerId, noopEmit, {}));
101
+ runPredicted(action, () => handler(displayState, payload, playerId, noopEmit, {
102
+ now,
103
+ ...predictedScheduleCtx(now),
104
+ }));
44
105
  i++;
45
106
  }
46
107
  catch {
108
+ displayState = base;
47
109
  pendingActions.splice(i, 1);
48
110
  }
49
111
  }
50
112
  onState(displayState, playerId);
51
113
  };
52
114
  /**
53
- * 自分が出した pending action が ack されたかを判定し、events 重複排除と
54
- * pending キューの掃除を行う。
115
+ * サーバーからの配信を処理する。
116
+ *
117
+ * 送信元が誰であれ、まず先読みの実行記録と突き合わせる。自分の ack だけを見ていると、
118
+ * 他プレイヤーの action が同じ出来事を起こしたときに二重実行になる。
55
119
  */
56
120
  const handleAck = (ack, from, evts) => {
57
- const isMyAck = from === playerId && ack !== undefined && pendingActions.some((p) => p.seq === ack);
58
- if (!isMyAck) {
59
- dispatchEvents(evts);
60
- }
121
+ dispatchEvents(subtractFired(evts));
61
122
  if (from === playerId && ack !== undefined) {
62
123
  while (pendingActions.length > 0 && pendingActions[0].seq <= ack) {
63
124
  pendingActions.shift();
64
125
  }
126
+ // 確定した action の記録で引き当てられなかったものは「予測したが実際には
127
+ // 起きなかった出来事」。残すと次に本当に起きたときに握り潰してしまう。
128
+ firedPredictions = firedPredictions.filter((f) => f.seq > ack);
65
129
  }
66
130
  };
67
131
  return {
68
132
  send(type, payload) {
69
133
  actionSeq++;
70
134
  const seq = actionSeq;
71
- // serverOnly handler は先行実行を skip。pending にも積まないので、ack 受信時の
72
- // isMyAck 判定で false となり、サーバー発の events が普通に emit される。
135
+ // 先読みするのは logic.actions だけ。serverActions transport にだけ送り、
136
+ // 結果は ack (state + serverEvents) で受け取る。
73
137
  const handler = logic.actions[type];
74
138
  // callback 内では displayState の narrowing が効かないので const に退避する。
75
139
  const target = displayState;
140
+ // サーバーがこのアクションを処理する時刻の推定。再適用でも同じ値を使うので、
141
+ // ここで 1 回だけ確定させて pending に載せる。
142
+ const now = serverNow();
143
+ // predict: true の event だけ先読み時点で実行し、実行したものを記録する。
144
+ const predictEmit = (eventName, data) => {
145
+ const subscription = events?.[eventName];
146
+ if (!subscription?.predict)
147
+ return;
148
+ const payloadData = data ?? {};
149
+ subscription.handler(payloadData);
150
+ firedPredictions.push({ seq, name: eventName, data: payloadData });
151
+ };
152
+ // 旧 serverOnly() が actions に残っている logic では、その handler を先読みすると
153
+ // サーバー専用のはずの副作用がクライアントでも走る。brand を見て弾く。
76
154
  if (handler && !isServerOnlyAction(handler) && target !== null) {
77
155
  try {
78
- runPredicted(type, () => handler(target, payload ?? {}, playerId, emit, {}));
79
- pendingActions.push({ seq, action: type, payload: payload ?? {} });
156
+ runPredicted(type, () => handler(target, payload ?? {}, playerId, predictEmit, {
157
+ now,
158
+ ...predictedScheduleCtx(now),
159
+ }));
160
+ pendingActions.push({ seq, action: type, payload: payload ?? {}, now });
80
161
  onState(target, playerId);
81
162
  }
82
163
  catch {
@@ -85,6 +166,10 @@ export function createOptimisticActionClient(config) {
85
166
  }
86
167
  sendAction({ action: type, payload: payload ?? {}, seq });
87
168
  },
169
+ observeServerTime(serverTime) {
170
+ // 実体は server-clock.ts (UI 側の serverNow() と同じオフセットを共有する)。
171
+ observeServerTime(serverTime);
172
+ },
88
173
  applyState(state, options = {}) {
89
174
  handleAck(options.ack, options.from, options.events ?? []);
90
175
  confirmedState = state;
@@ -110,11 +195,14 @@ export function createOptimisticActionClient(config) {
110
195
  const idx = pendingActions.findIndex((p) => p.seq === seq);
111
196
  if (idx !== -1) {
112
197
  pendingActions.splice(idx, 1);
198
+ // rollback した action の先読み記録も捨てる (その出来事は起きなかった)。
199
+ firedPredictions = firedPredictions.filter((f) => f.seq !== seq);
113
200
  reapplyPendingActions();
114
201
  }
115
202
  },
116
203
  reset(state) {
117
204
  pendingActions.length = 0;
205
+ firedPredictions = [];
118
206
  confirmedState = state;
119
207
  displayState = structuredClone(state);
120
208
  onState(displayState, playerId);