@uzuhq/code-sdk 0.3.10 → 0.5.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.
@@ -20,6 +20,7 @@
20
20
  * 特定 action 名のラッパー等) は scenario 側で window.__<scene>_dev を生やす。
21
21
  */
22
22
  import type { JsonMergePatch, JsonPatchOp } from './dev-state-patch.js';
23
+ import type { PredictionWarning } from './dev-prediction-traps.js';
23
24
  import type { ServerEvent } from './types.js';
24
25
  export type { JsonMergePatch, JsonPatchOp } from './dev-state-patch.js';
25
26
  export { applyJsonMergePatch, applyJsonPatch } from './dev-state-patch.js';
@@ -36,6 +37,13 @@ export interface UzuDevHooks<S = unknown> {
36
37
  * 貼り付けやすい uint32 を返す。
37
38
  */
38
39
  getSeed?(): number;
40
+ /**
41
+ * 素の action handler が先読み実行中に実時刻 / 乱数を読んだ記録。
42
+ *
43
+ * 先読みはサーバーと同じ結果を再現できることが前提なので、ここに何か入っていたら
44
+ * そのシナリオはオンラインでだけ予測がズレる。E2E で `[]` を assert すると回帰を防げる。
45
+ */
46
+ getPredictionWarnings(): readonly PredictionWarning[];
39
47
  /**
40
48
  * Server-side で action を直接 dispatch する。
41
49
  *
package/dist/dev-hooks.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * scenario 固有の helper (特定 field path の読み書き / phase 遷移時の field reset /
20
20
  * 特定 action 名のラッパー等) は scenario 側で window.__<scene>_dev を生やす。
21
21
  */
22
+ import { getPredictionWarnings } from './dev-prediction-traps.js';
22
23
  export { applyJsonMergePatch, applyJsonPatch } from './dev-state-patch.js';
23
24
  export function createDevHooks(ctx) {
24
25
  const getRawState = () => (ctx.getRawState ? ctx.getRawState() : null);
@@ -69,6 +70,7 @@ export function createDevHooks(ctx) {
69
70
  playerId: () => ctx.playerId(),
70
71
  subscribeSnapshot: (cb) => ctx.subscribeSnapshot(cb),
71
72
  waitForSnapshot,
73
+ getPredictionWarnings: () => getPredictionWarnings(),
72
74
  };
73
75
  if (ctx.sendAction) {
74
76
  const sendAction = ctx.sendAction;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @docs
3
+ * - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
4
+ * - 開発パターン: docs/docs/uzu_code/sdk-guide/patterns.md
5
+ *
6
+ * 素の action handler のクライアント先読み実行中に、サーバーと結果が一致しない API
7
+ * (実時刻 / 乱数) が呼ばれたら警告する。
8
+ *
9
+ * 先読みは「サーバーと同じコードを同じ入力で走らせれば同じ結果になる」ことが前提で、
10
+ * 実時刻や乱数を読むとその前提が崩れる。ズレた state は一瞬表示されたあと ack で
11
+ * 上書きされ、画面が飛ぶ。
12
+ *
13
+ * 静的解析ではなく実行時に差し替えるのは、handler がヘルパー関数を何段挟んでいても
14
+ * 捕まえたいから。同じ理由で、演出用途 (描画ループの `Math.sin(Date.now() / 400)` など)
15
+ * は先読み経路を通らないので原理的に誤検知しない。
16
+ */
17
+ export interface PredictionWarning {
18
+ /** 呼び出した action 名 */
19
+ action: string;
20
+ /** 呼ばれた API 名 (`'Date.now()'` など) */
21
+ api: string;
22
+ }
23
+ /**
24
+ * 素の action handler の先読み実行を計装して走らせる。
25
+ *
26
+ * Flutter native ホスト (本番) では計装せず素通しする。判定基準は dev hooks と同じ。
27
+ */
28
+ export declare const runPredicted: (action: string, run: () => void) => void;
29
+ /** 検出済みの警告一覧。`__uzu_dev.getPredictionWarnings()` から E2E で assert する用。 */
30
+ export declare const getPredictionWarnings: () => readonly PredictionWarning[];
31
+ /** 検出結果をリセットする (テスト用)。 */
32
+ export declare const clearPredictionWarnings: () => void;
Binary file
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,178 @@
1
+ /**
2
+ * dev-prediction-traps.ts の unit test。
3
+ *
4
+ * 素の action handler をクライアント先読みで実行する間だけグローバルを差し替え、
5
+ * サーバーと結果が一致しない API (実時刻 / 乱数) の呼び出しを検出する仕組みを検証する。
6
+ *
7
+ * カバー対象:
8
+ * - 検出対象 API それぞれが記録されること
9
+ * - ヘルパー関数を経由した呼び出しも捕まえること (静的解析では追えない経路)
10
+ * - 同じ action / API の重複を 1 件に畳むこと
11
+ * - 実行後にグローバルが必ず元へ戻ること (例外時も含む)
12
+ * - 先読みの外では計装されていないこと
13
+ * - 本番 (Flutter native ホスト) では計装しないこと
14
+ */
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+ import { clearPredictionWarnings, getPredictionWarnings, runPredicted, } from './dev-prediction-traps.js';
17
+ beforeEach(() => {
18
+ clearPredictionWarnings();
19
+ // 警告本文は console に出るので、テスト出力を汚さないよう黙らせる。
20
+ vi.spyOn(console, 'groupCollapsed').mockImplementation(() => { });
21
+ vi.spyOn(console, 'log').mockImplementation(() => { });
22
+ vi.spyOn(console, 'groupEnd').mockImplementation(() => { });
23
+ });
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ delete window.FlutterHost;
27
+ });
28
+ describe('runPredicted', () => {
29
+ /** 検出対象 API を先読み中に呼ぶと、action 名とセットで記録される。 */
30
+ describe('非決定的な API の検出', () => {
31
+ it('Date.now() を記録する', () => {
32
+ runPredicted('gm.timer.set', () => {
33
+ Date.now();
34
+ });
35
+ expect(getPredictionWarnings()).toEqual([{ action: 'gm.timer.set', api: 'Date.now()' }]);
36
+ });
37
+ it('引数なしの new Date() を記録する', () => {
38
+ runPredicted('startPresentation', () => {
39
+ new Date();
40
+ });
41
+ expect(getPredictionWarnings()).toEqual([{ action: 'startPresentation', api: 'new Date()' }]);
42
+ });
43
+ /**
44
+ * 引数付きの new Date() は与えられた値から決まるので、サーバーと結果がズレない。
45
+ * 誤検知を避けるため記録しない。
46
+ */
47
+ it('引数付きの new Date(...) は記録しない', () => {
48
+ runPredicted('setDeadline', () => {
49
+ new Date(1700000000000);
50
+ });
51
+ expect(getPredictionWarnings()).toEqual([]);
52
+ });
53
+ it('Math.random() を記録する', () => {
54
+ runPredicted('restart', () => {
55
+ Math.random();
56
+ });
57
+ expect(getPredictionWarnings()).toEqual([{ action: 'restart', api: 'Math.random()' }]);
58
+ });
59
+ it('performance.now() を記録する', () => {
60
+ runPredicted('tickLocal', () => {
61
+ performance.now();
62
+ });
63
+ expect(getPredictionWarnings()).toEqual([{ action: 'tickLocal', api: 'performance.now()' }]);
64
+ });
65
+ /**
66
+ * 静的解析が最も苦手とする経路。handler 本体に API が現れず、ヘルパー関数の中で
67
+ * 呼ばれていても捕まえられることを保証する (kikaidochu の setLine が実際にこの形)。
68
+ */
69
+ it('ヘルパー関数を経由した呼び出しも記録する', () => {
70
+ const setDeadlineViaHelper = () => Date.now() + 60000;
71
+ runPredicted('setLine', () => {
72
+ setDeadlineViaHelper();
73
+ });
74
+ expect(getPredictionWarnings()).toEqual([{ action: 'setLine', api: 'Date.now()' }]);
75
+ });
76
+ /** 元の API の戻り値はそのまま通す (計装で挙動を変えない)。 */
77
+ it('元の API の戻り値を変えない', () => {
78
+ let observed = 0;
79
+ runPredicted('noop', () => {
80
+ observed = Date.now();
81
+ });
82
+ expect(observed).toBeGreaterThan(0);
83
+ });
84
+ });
85
+ describe('重複の抑制', () => {
86
+ /** 同じ action で同じ API を何度呼んでも記録は 1 件。連打でログが溢れない。 */
87
+ it('同一 action / 同一 API は 1 件に畳む', () => {
88
+ runPredicted('gm.timer.set', () => {
89
+ Date.now();
90
+ Date.now();
91
+ });
92
+ runPredicted('gm.timer.set', () => {
93
+ Date.now();
94
+ });
95
+ expect(getPredictionWarnings()).toEqual([{ action: 'gm.timer.set', api: 'Date.now()' }]);
96
+ });
97
+ /** action が違えば別件として記録する。どの action を直すべきか分かる必要がある。 */
98
+ it('action が違えば別件として記録する', () => {
99
+ runPredicted('a', () => {
100
+ Date.now();
101
+ });
102
+ runPredicted('b', () => {
103
+ Date.now();
104
+ });
105
+ expect(getPredictionWarnings()).toEqual([
106
+ { action: 'a', api: 'Date.now()' },
107
+ { action: 'b', api: 'Date.now()' },
108
+ ]);
109
+ });
110
+ /** 同じ action でも API が違えば別件。両方直す必要があるため。 */
111
+ it('API が違えば別件として記録する', () => {
112
+ runPredicted('restart', () => {
113
+ Date.now();
114
+ Math.random();
115
+ });
116
+ expect(getPredictionWarnings()).toEqual([
117
+ { action: 'restart', api: 'Date.now()' },
118
+ { action: 'restart', api: 'Math.random()' },
119
+ ]);
120
+ });
121
+ });
122
+ describe('グローバルの復元', () => {
123
+ /** 先読みの外で呼ばれる API は計装されていない (描画ループが誤検知しない前提)。 */
124
+ it('実行後にグローバルが元へ戻る', () => {
125
+ const beforeNow = Date.now;
126
+ const beforeRandom = Math.random;
127
+ const beforeDate = Date;
128
+ runPredicted('a', () => {
129
+ Date.now();
130
+ });
131
+ expect(Date.now).toBe(beforeNow);
132
+ expect(Math.random).toBe(beforeRandom);
133
+ expect(Date).toBe(beforeDate);
134
+ });
135
+ /**
136
+ * handler が throw しても復元する。楽観実行は throw を握りつぶして
137
+ * サーバー送信だけ続ける経路があるので、ここで漏れるとグローバルが汚染されたまま残る。
138
+ */
139
+ it('handler が例外を投げてもグローバルが元へ戻る', () => {
140
+ const beforeNow = Date.now;
141
+ const beforeDate = Date;
142
+ expect(() => {
143
+ runPredicted('a', () => {
144
+ Date.now();
145
+ throw new Error('handler failed');
146
+ });
147
+ }).toThrow('handler failed');
148
+ expect(Date.now).toBe(beforeNow);
149
+ expect(Date).toBe(beforeDate);
150
+ // 例外で中断しても、そこまでに呼ばれた API は記録されている。
151
+ expect(getPredictionWarnings()).toEqual([{ action: 'a', api: 'Date.now()' }]);
152
+ });
153
+ /** 先読みの外の呼び出しは記録されない。描画コードの Date.now() を拾わない保証。 */
154
+ it('先読みの外で呼んだ API は記録しない', () => {
155
+ Date.now();
156
+ Math.random();
157
+ expect(getPredictionWarnings()).toEqual([]);
158
+ });
159
+ });
160
+ describe('本番での無効化', () => {
161
+ /**
162
+ * Flutter native ホストでは計装しない。判定基準は dev hooks の attach と同じく
163
+ * `window.FlutterHost` の有無。
164
+ */
165
+ it('window.FlutterHost があるときは計装せず素通しする', () => {
166
+ window.FlutterHost = { postMessage: () => { } };
167
+ const beforeNow = Date.now;
168
+ let ran = false;
169
+ runPredicted('gm.timer.set', () => {
170
+ Date.now();
171
+ ran = true;
172
+ });
173
+ expect(ran).toBe(true);
174
+ expect(Date.now).toBe(beforeNow);
175
+ expect(getPredictionWarnings()).toEqual([]);
176
+ });
177
+ });
178
+ });
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@
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, ActionHandler, ServerOnlyAction, ServerOnlyActionHandlerFn, } 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, ServerActionContext, ServerActionHandler, ServerOnlyAction, ServerOnlyActionContext, ServerOnlyActionHandlerFn, } from './types.js';
19
19
  export { SERVER_TIME, DEFAULT_ICON_URLS } from './types.js';
20
20
  export { serverOnly, isServerOnlyAction } from './server-only.js';
21
21
  export { Room } from './room.js';
@@ -27,6 +27,8 @@ export { applyJsonMergePatch, applyJsonPatch } from './dev-state-patch.js';
27
27
  export type { JsonMergePatch, JsonPatchOp } from './dev-state-patch.js';
28
28
  export { attachDevHooks, createDevHooks } from './dev-hooks.js';
29
29
  export type { UzuDevHooks, DevHooksCtx, RunHandle, SyncHandle } from './dev-hooks.js';
30
+ export { getPredictionWarnings } from './dev-prediction-traps.js';
31
+ export type { PredictionWarning } from './dev-prediction-traps.js';
30
32
  import type { BridgeMessage, GameConfig, SyncConfig, PlayerVoiceState } from './types.js';
31
33
  import type { RoomLike } from './room.js';
32
34
  type GameMessageHandler = (payload: Record<string, unknown>) => void;
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export { ReconnectableWebSocket } from './reconnectable-ws.js';
5
5
  export { SeededRandomImpl } from './random.js';
6
6
  export { applyJsonMergePatch, applyJsonPatch } from './dev-state-patch.js';
7
7
  export { attachDevHooks, createDevHooks } from './dev-hooks.js';
8
+ export { getPredictionWarnings } from './dev-prediction-traps.js';
8
9
  import { Room } from './room.js';
9
10
  import { ReconnectableWebSocket } from './reconnectable-ws.js';
10
11
  import { runOnlineServerAction } from './run/server-action.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';
@@ -24,39 +24,49 @@ export function runLocalServerAction(config) {
24
24
  let state = logic.setup(players, random);
25
25
  let tick = 0;
26
26
  const playerInputs = {};
27
- // Action 処理。standard handler は同期実行で `sendAction()` 直後の同期 onState
28
- // 保証する (online の楽観的更新と同じ挙動)。serverOnly handler は `Promise<void>`
29
- // を返しうるので `await` で実行し、完了後に events と onState を発火する。
27
+ // Action 処理。`actions` は同期実行で `sendAction()` 直後の同期 onState を保証する
28
+ // (online の楽観的更新と同じ挙動)。`serverActions` は `Promise<void>` を返しうるので
29
+ // `await` で実行し、完了後に events と onState を発火する。
30
30
  // どちらも 1 回しか実行しない (online の「楽観 → サーバー確定」の 2 段階は再現しない)。
31
31
  const dispatchAction = (type, payload) => {
32
- const handler = logic.actions[type];
33
- if (!handler)
32
+ const plain = logic.actions[type];
33
+ // 移行期: 旧 serverOnly() を actions に入れたままの logic も動かす。
34
+ const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
35
+ const server = logic.serverActions?.[type] ?? legacyServerOnly;
36
+ if (!plain && !server)
34
37
  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, { tick });
38
+ // events emit 元ごとに分ける。online では actions 由来がクライアント先読みの
39
+ // 時点で発火するので、ここでも plain の実行直後に流す。共有すると serverActions
40
+ // `await fetch()` を持つ場合に actions 側の events までその分遅れてしまい、
41
+ // 「ローカルモードだけ音が遅れる」というモード差になる。
42
+ const plainEvents = [];
43
+ const serverEvents = [];
44
+ const plainEmit = (name, data) => plainEvents.push({ name, data: data ?? {} });
45
+ const serverEmit = (name, data) => serverEvents.push({ name, data: data ?? {} });
46
+ if (plain && !legacyServerOnly) {
47
+ try {
48
+ plain(state, payload ?? {}, myId, plainEmit, {});
49
+ }
50
+ catch (err) {
51
+ console.warn('[SDK LocalServerAction] Action error:', err);
52
+ return;
53
+ }
54
+ dispatchEvents(plainEvents);
55
+ onState(state, myId);
53
56
  }
54
- catch (err) {
55
- console.warn('[SDK LocalServerAction] Action error:', err);
57
+ if (!server)
56
58
  return;
57
- }
58
- dispatchEvents(actionEvents);
59
- onState(state, myId);
59
+ void (async () => {
60
+ try {
61
+ await server(state, payload ?? {}, myId, serverEmit, { tick, random });
62
+ }
63
+ catch (err) {
64
+ console.warn('[SDK LocalServerAction] Action error:', err);
65
+ return;
66
+ }
67
+ dispatchEvents(serverEvents);
68
+ onState(state, myId);
69
+ })();
60
70
  };
61
71
  inputs(dispatchAction);
62
72
  onState(state, myId);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,208 @@
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: () => fired.push('moved'),
30
+ charged: () => fired.push('charged'),
31
+ },
32
+ };
33
+ runLocalServerAction(config);
34
+ return { send: (t, p) => send(t, p), fired, states };
35
+ };
36
+ const baseLogic = (overrides = {}) => ({
37
+ setup: () => ({ moves: 0, charged: 0, rolled: -1 }),
38
+ actions: {},
39
+ update: () => { },
40
+ ...overrides,
41
+ });
42
+ describe('runLocalServerAction', () => {
43
+ describe('actions と serverActions の実行', () => {
44
+ /** actions だけの action は同期実行され、その場で state と events が確定する。 */
45
+ it('actions だけなら同期で state と events が確定する', () => {
46
+ const h = run(baseLogic({
47
+ actions: {
48
+ move: (state, _payload, _playerId, emit) => {
49
+ state.moves += 1;
50
+ emit('moved');
51
+ },
52
+ },
53
+ }));
54
+ h.send('move');
55
+ expect(h.states[h.states.length - 1].moves).toBe(1);
56
+ expect(h.fired).toEqual(['moved']);
57
+ });
58
+ /** serverActions だけの action は、await 完了後に state と events が反映される。 */
59
+ it('serverActions だけなら await 後に反映される', async () => {
60
+ const h = run(baseLogic({
61
+ serverActions: {
62
+ notifyExternal: async (state, _payload, _playerId, emit) => {
63
+ await tick();
64
+ state.charged += 1;
65
+ emit('charged');
66
+ },
67
+ },
68
+ }));
69
+ h.send('notifyExternal');
70
+ expect(h.fired).toEqual([]); // まだ await 中
71
+ await tick();
72
+ await tick();
73
+ expect(h.states[h.states.length - 1].charged).toBe(1);
74
+ expect(h.fired).toEqual(['charged']);
75
+ });
76
+ /**
77
+ * 同名で両方定義されている場合、サーバーと同じく actions → serverActions の順で走る。
78
+ * serverActions は actions が書いた結果を見られる。
79
+ */
80
+ it('同名なら actions → serverActions の順に走る', async () => {
81
+ const order = [];
82
+ const h = run(baseLogic({
83
+ actions: {
84
+ move: (state) => {
85
+ order.push('actions');
86
+ state.moves += 1;
87
+ },
88
+ },
89
+ serverActions: {
90
+ move: (state, _payload, _playerId, _emit, ctx) => {
91
+ order.push('serverActions');
92
+ // actions の結果が見えている
93
+ expect(state.moves).toBe(1);
94
+ state.charged += ctx.tick + 1;
95
+ state.rolled = ctx.random.int(100);
96
+ },
97
+ },
98
+ }));
99
+ h.send('move');
100
+ await tick();
101
+ expect(order).toEqual(['actions', 'serverActions']);
102
+ const last = h.states[h.states.length - 1];
103
+ expect(last.moves).toBe(1);
104
+ expect(last.charged).toBe(1); // tick 0 + 1
105
+ // ctx.random が渡っている (setup 前の -1 から変わっている)
106
+ expect(last.rolled).toBeGreaterThanOrEqual(0);
107
+ });
108
+ });
109
+ describe('events の発火タイミング', () => {
110
+ /**
111
+ * online では actions 由来の events は先読み時に即発火する。ソロでも同じタイミングで
112
+ * 流し、serverActions の await を待たせない。ここが揃っていないとモード差になる。
113
+ */
114
+ it('actions の events は serverActions の await を待たない', async () => {
115
+ const h = run(baseLogic({
116
+ actions: {
117
+ move: (state, _payload, _playerId, emit) => {
118
+ state.moves += 1;
119
+ emit('moved');
120
+ },
121
+ },
122
+ serverActions: {
123
+ move: async (state, _payload, _playerId, emit) => {
124
+ await tick();
125
+ state.charged += 1;
126
+ emit('charged');
127
+ },
128
+ },
129
+ }));
130
+ h.send('move');
131
+ // serverActions がまだ await 中でも actions 側の events は出ている
132
+ expect(h.fired).toEqual(['moved']);
133
+ await tick();
134
+ await tick();
135
+ expect(h.fired).toEqual(['moved', 'charged']);
136
+ });
137
+ });
138
+ describe('エラー処理', () => {
139
+ /** actions が throw したら serverActions へ進まず、state 更新も通知しない。 */
140
+ it('actions が throw したら serverActions を実行しない', () => {
141
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
142
+ let serverRan = false;
143
+ const h = run(baseLogic({
144
+ actions: {
145
+ move: () => {
146
+ throw new Error('boom');
147
+ },
148
+ },
149
+ serverActions: {
150
+ move: () => {
151
+ serverRan = true;
152
+ },
153
+ },
154
+ }));
155
+ h.send('move');
156
+ expect(serverRan).toBe(false);
157
+ warn.mockRestore();
158
+ });
159
+ /** serverActions が throw しても、既に流れた actions 側の events は取り消さない。 */
160
+ it('serverActions が throw しても actions の events は残る', async () => {
161
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
162
+ const h = run(baseLogic({
163
+ actions: {
164
+ move: (state, _payload, _playerId, emit) => {
165
+ state.moves += 1;
166
+ emit('moved');
167
+ },
168
+ },
169
+ serverActions: {
170
+ move: () => {
171
+ throw new Error('boom');
172
+ },
173
+ },
174
+ }));
175
+ h.send('move');
176
+ await tick();
177
+ expect(h.fired).toEqual(['moved']);
178
+ warn.mockRestore();
179
+ });
180
+ });
181
+ describe('旧 serverOnly() の互換', () => {
182
+ /**
183
+ * R2 の古い logic.js は serverOnly() の brand を付けたまま actions に入っている。
184
+ * ソロモードでも brand を見て「先読みしない handler」として扱う。
185
+ */
186
+ it('actions に入った serverOnly() は server 側として実行される', async () => {
187
+ const logic = baseLogic({
188
+ actions: {
189
+ // 新しい型では actions に serverOnly() を入れられない (それが本 PR の狙い)。
190
+ // ここは「R2 に残っている古い logic.js」を再現するための意図的な型違反。
191
+ // @ts-expect-error 移行期の互換挙動を検証するため
192
+ legacy: serverOnly(async (state, _payload, _playerId, emit, ctx) => {
193
+ await tick();
194
+ state.charged += ctx.tick + 1;
195
+ emit('charged');
196
+ }),
197
+ },
198
+ });
199
+ const h = run(logic);
200
+ h.send('legacy');
201
+ expect(h.fired).toEqual([]); // 先読みされていない
202
+ await tick();
203
+ await tick();
204
+ expect(h.states[h.states.length - 1].charged).toBe(1);
205
+ expect(h.fired).toEqual(['charged']);
206
+ });
207
+ });
208
+ });
@@ -8,10 +8,10 @@
8
8
  * トランスポート (WebSocket / BroadcastChannel) と上位ロジック (楽観更新 / pending
9
9
  * キュー / ack ベースの確定 / rollback) を分離し、3 モードで挙動が揃うことを実装で保証する。
10
10
  *
11
- * - `send(type, payload)`: standard handler は同期で先行実行し pending キューへ。
12
- * serverOnly handler は skip して transport にだけ送る。
13
- * - `applyState(state, { ack, from, events })`: フル state を受信した時に呼ぶ。
14
- * - `applyDelta(patches, { ack, from, events })`: JSON Patch を受信した時に呼ぶ
11
+ * - `send(type, payload)`: `logic.actions` handler を同期で先行実行し pending キューへ。
12
+ * `logic.serverActions` は先読みせず transport にだけ送る。
13
+ * - `applyState(state, { ack, from, events, serverEvents })`: フル state を受信した時に呼ぶ。
14
+ * - `applyDelta(patches, { ack, from, events, serverEvents })`: JSON Patch を受信した時に呼ぶ
15
15
  * (適用失敗時は false を返すので transport 側でフル state を再要求する)。
16
16
  * - `rollback(seq)`: `__action_error` 受信時に該当 action を pending から除去して再適用。
17
17
  * - `reset(state)`: 再接続後の state 復元用 (pending を全クリア)。
@@ -27,8 +27,13 @@ export interface ConfirmOptions {
27
27
  ack?: number;
28
28
  /** ack の送信元 player id。自分の pending と一致した時だけ events を skip する */
29
29
  from?: string;
30
- /** サーバー (or 仮想サーバー) dispatch した events */
30
+ /** `logic.actions` 由来の events。自分の ack なら先読み時に発火済みなので skip する */
31
31
  events?: EventEntry[];
32
+ /**
33
+ * `logic.serverActions` 由来の events。先読みでは走らないので、自分の ack でも必ず配信する。
34
+ * これを `events` と混ぜると、同名 action の server 側 events が重複排除で消える。
35
+ */
36
+ serverEvents?: EventEntry[];
32
37
  }
33
38
  export interface OptimisticActionClientConfig<S> {
34
39
  logic: GameLogic<S>;
@@ -1,5 +1,6 @@
1
1
  import { applyPatch } from '../json-patch.js';
2
2
  import { isServerOnlyAction } from '../server-only.js';
3
+ import { runPredicted } from '../dev-prediction-traps.js';
3
4
  export function createOptimisticActionClient(config) {
4
5
  const { logic, playerId, onState, events, sendAction } = config;
5
6
  /** サーバー確定 state (楽観的更新のベース) */
@@ -32,16 +33,21 @@ export function createOptimisticActionClient(config) {
32
33
  while (i < pendingActions.length) {
33
34
  const { action, payload } = pendingActions[i];
34
35
  const handler = logic.actions[action];
35
- if (!handler) {
36
+ // 移行期の互換: 旧 serverOnly() を actions に入れたままの logic では、その handler は
37
+ // サーバー専用なので先読みの再適用対象から外す (send 時にも pending へ積んでいない)。
38
+ if (!handler || isServerOnlyAction(handler)) {
36
39
  pendingActions.splice(i, 1);
37
40
  continue;
38
41
  }
42
+ // handler が途中まで state を変更してから throw すると、その部分変更が残ったまま
43
+ // publish されてしまう。1 件ごとに直前の確定形から作り直し、成功したものだけ採用する。
44
+ const base = structuredClone(displayState);
39
45
  try {
40
- // tick はサーバー側でのみ正確に管理される。再適用ではサーバー tick が不明のため 0 を使う。
41
- handler(displayState, payload, playerId, noopEmit, { tick: 0 });
46
+ runPredicted(action, () => handler(displayState, payload, playerId, noopEmit, {}));
42
47
  i++;
43
48
  }
44
49
  catch {
50
+ displayState = base;
45
51
  pendingActions.splice(i, 1);
46
52
  }
47
53
  }
@@ -51,11 +57,13 @@ export function createOptimisticActionClient(config) {
51
57
  * 自分が出した pending action が ack されたかを判定し、events 重複排除と
52
58
  * pending キューの掃除を行う。
53
59
  */
54
- const handleAck = (ack, from, evts) => {
60
+ const handleAck = (ack, from, evts, serverEvts) => {
55
61
  const isMyAck = from === playerId && ack !== undefined && pendingActions.some((p) => p.seq === ack);
56
62
  if (!isMyAck) {
57
63
  dispatchEvents(evts);
58
64
  }
65
+ // serverActions 由来は先読みで走っていないので、自分の ack でも必ず配信する。
66
+ dispatchEvents(serverEvts);
59
67
  if (from === playerId && ack !== undefined) {
60
68
  while (pendingActions.length > 0 && pendingActions[0].seq <= ack) {
61
69
  pendingActions.shift();
@@ -66,14 +74,18 @@ export function createOptimisticActionClient(config) {
66
74
  send(type, payload) {
67
75
  actionSeq++;
68
76
  const seq = actionSeq;
69
- // serverOnly handler は先行実行を skip。pending にも積まないので、ack 受信時の
70
- // isMyAck 判定で false となり、サーバー発の events が普通に emit される。
77
+ // 先読みするのは logic.actions だけ。serverActions transport にだけ送り、
78
+ // 結果は ack (state + serverEvents) で受け取る。
71
79
  const handler = logic.actions[type];
72
- if (handler && !isServerOnlyAction(handler) && displayState !== null) {
80
+ // callback 内では displayState narrowing が効かないので const に退避する。
81
+ const target = displayState;
82
+ // 旧 serverOnly() が actions に残っている logic では、その handler を先読みすると
83
+ // サーバー専用のはずの副作用がクライアントでも走る。brand を見て弾く。
84
+ if (handler && !isServerOnlyAction(handler) && target !== null) {
73
85
  try {
74
- handler(displayState, payload ?? {}, playerId, emit, { tick: 0 });
86
+ runPredicted(type, () => handler(target, payload ?? {}, playerId, emit, {}));
75
87
  pendingActions.push({ seq, action: type, payload: payload ?? {} });
76
- onState(displayState, playerId);
88
+ onState(target, playerId);
77
89
  }
78
90
  catch {
79
91
  // ローカル実行失敗 → 楽観的更新せずサーバーに送るだけ
@@ -82,7 +94,7 @@ export function createOptimisticActionClient(config) {
82
94
  sendAction({ action: type, payload: payload ?? {}, seq });
83
95
  },
84
96
  applyState(state, options = {}) {
85
- handleAck(options.ack, options.from, options.events ?? []);
97
+ handleAck(options.ack, options.from, options.events ?? [], options.serverEvents ?? []);
86
98
  confirmedState = state;
87
99
  reapplyPendingActions();
88
100
  },
@@ -98,7 +110,7 @@ export function createOptimisticActionClient(config) {
98
110
  if (!ok)
99
111
  return false;
100
112
  confirmedState = cloned;
101
- handleAck(options.ack, options.from, options.events ?? []);
113
+ handleAck(options.ack, options.from, options.events ?? [], options.serverEvents ?? []);
102
114
  reapplyPendingActions();
103
115
  return true;
104
116
  },
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,232 @@
1
+ /**
2
+ * optimistic-action-client.ts の unit test。
3
+ *
4
+ * `logic.actions` (クライアント先読み + サーバーの 2 回実行) と `logic.serverActions`
5
+ * (サーバーのみ 1 回実行) を分離した設計が、events の重複排除と pending キューの
6
+ * 巻き戻しの両方で正しく閉じることを検証する。
7
+ *
8
+ * 特に重要なのは、同名 action で両方が定義されたケース。先読みで走るのは actions 側
9
+ * だけなので、serverActions が emit した events まで「自分の ack だから発火済み」として
10
+ * 捨ててしまうと、サーバー側のイベントが永久に届かなくなる。
11
+ */
12
+ import { describe, expect, it, vi } from 'vitest';
13
+ import { createOptimisticActionClient } from './optimistic-action-client.js';
14
+ import { serverOnly } from '../server-only.js';
15
+ /** 旧 serverOnly() ブランド付き handler。actions に残っている古い logic を再現する。 */
16
+ const legacyServerOnly = serverOnly((state) => {
17
+ state.charged += 1000;
18
+ });
19
+ const makeLogic = () => ({
20
+ setup: () => ({ moves: 0, charged: 0 }),
21
+ actions: {
22
+ move: (state) => {
23
+ state.moves += 1;
24
+ },
25
+ boom: () => {
26
+ throw new Error('always fails');
27
+ },
28
+ // 途中まで state を書き換えてから throw する (部分ミューテーションの検証用)
29
+ partial: (state) => {
30
+ state.moves += 1;
31
+ throw new Error('fails after mutating');
32
+ },
33
+ // 旧 serverOnly() が actions に残っている logic の再現
34
+ // @ts-expect-error 移行期の互換挙動を検証するため意図的に型違反させる
35
+ legacy: legacyServerOnly,
36
+ },
37
+ serverActions: {
38
+ // move と同名 = 同じ action の「サーバーだけで走る続き」
39
+ move: (state, _payload, _playerId, _emit, ctx) => {
40
+ state.charged += ctx.tick;
41
+ },
42
+ // actions に無い名前 = 先読みされない action (旧 serverOnly 相当)
43
+ notifyExternal: (state) => {
44
+ state.charged += 100;
45
+ },
46
+ },
47
+ update: () => { },
48
+ });
49
+ /** 直近の onState 通知。tsconfig の lib が Array#at 未対応なので添字で取る。 */
50
+ const latest = (states) => states[states.length - 1];
51
+ const setup = () => {
52
+ const sent = [];
53
+ const fired = [];
54
+ const states = [];
55
+ const client = createOptimisticActionClient({
56
+ logic: makeLogic(),
57
+ playerId: 'me',
58
+ onState: (s) => states.push(structuredClone(s)),
59
+ events: {
60
+ moved: () => fired.push('moved'),
61
+ charged: () => fired.push('charged'),
62
+ },
63
+ sendAction: ({ action, seq }) => sent.push({ action, seq }),
64
+ });
65
+ client.reset({ moves: 0, charged: 0 });
66
+ return { client, sent, fired, states };
67
+ };
68
+ describe('createOptimisticActionClient', () => {
69
+ describe('先読みの対象', () => {
70
+ /** actions にある handler だけがクライアントで先行実行される。 */
71
+ it('actions の handler は送信時に先行実行される', () => {
72
+ const { client, sent, states } = setup();
73
+ client.send('move');
74
+ expect(latest(states)).toEqual({ moves: 1, charged: 0 });
75
+ expect(sent).toEqual([{ action: 'move', seq: 1 }]);
76
+ });
77
+ /**
78
+ * serverActions にしか無い action は先読みされない。state を触らずサーバーへ送るだけ。
79
+ * 旧 serverOnly() と同じ挙動。
80
+ */
81
+ it('serverActions にしか無い action は先行実行されない', () => {
82
+ const { client, sent, states } = setup();
83
+ client.send('notifyExternal');
84
+ expect(latest(states)).toEqual({ moves: 0, charged: 0 });
85
+ expect(sent).toEqual([{ action: 'notifyExternal', seq: 1 }]);
86
+ });
87
+ /**
88
+ * 同名で両方定義されている場合、先読みされるのは actions 側だけ。
89
+ * serverActions 側の結果 (charged) はサーバーの ack が来るまで反映されない。
90
+ */
91
+ it('同名で両方ある場合、先読みは actions 側だけ', () => {
92
+ const { client, states } = setup();
93
+ client.send('move');
94
+ expect(latest(states)).toEqual({ moves: 1, charged: 0 });
95
+ });
96
+ });
97
+ describe('events の重複排除', () => {
98
+ /**
99
+ * 自分が出した action の ack では、actions 由来の events は先読み時に発火済みなので
100
+ * skip する。二重発火すると効果音が 2 回鳴るなどの不具合になる。
101
+ */
102
+ it('自分の ack では actions 由来の events を発火しない', () => {
103
+ 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([]);
107
+ });
108
+ /**
109
+ * serverActions 由来の events は先読みで走っていないので、自分の ack でも必ず発火する。
110
+ * ここを events と同じ配列で送ると重複排除に巻き込まれて永久に届かなくなる。
111
+ */
112
+ it('自分の ack でも serverEvents は発火する', () => {
113
+ const { client, fired } = setup();
114
+ client.send('move');
115
+ client.applyState({ moves: 1, charged: 5 }, {
116
+ ack: 1,
117
+ from: 'me',
118
+ events: [{ name: 'moved', data: {} }],
119
+ serverEvents: [{ name: 'charged', data: {} }],
120
+ });
121
+ expect(fired).toEqual(['charged']);
122
+ });
123
+ /** 他プレイヤーの action なら先読みしていないので、両方とも発火する。 */
124
+ it('他プレイヤーの ack では両方発火する', () => {
125
+ const { client, fired } = setup();
126
+ client.applyState({ moves: 1, charged: 5 }, {
127
+ ack: 1,
128
+ from: 'other',
129
+ events: [{ name: 'moved', data: {} }],
130
+ serverEvents: [{ name: 'charged', data: {} }],
131
+ });
132
+ expect(fired).toEqual(['moved', 'charged']);
133
+ });
134
+ });
135
+ describe('pending キューと巻き戻し', () => {
136
+ /**
137
+ * pending に積まれるのは actions 分だけ。サーバー確定 state を受けたら、
138
+ * 未 ack の actions だけが再適用される (serverActions 分は state に含まれて来る)。
139
+ */
140
+ it('確定 state の上に未 ack の actions だけを再適用する', () => {
141
+ const { client, states } = setup();
142
+ client.send('move'); // seq 1
143
+ client.send('move'); // seq 2
144
+ // seq 1 だけ確定。charged はサーバー側で加算済みの値が入っている
145
+ client.applyState({ moves: 1, charged: 7 }, { ack: 1, from: 'me' });
146
+ // 確定 (moves:1) + 未 ack の seq 2 を再適用 = moves:2、charged はサーバー値のまま
147
+ expect(latest(states)).toEqual({ moves: 2, charged: 7 });
148
+ });
149
+ /** __action_error で該当 seq を除去し、残りを再適用する。 */
150
+ it('rollback は該当 action だけを取り消す', () => {
151
+ const { client, states } = setup();
152
+ client.send('move'); // seq 1
153
+ client.send('move'); // seq 2
154
+ expect(latest(states)).toEqual({ moves: 2, charged: 0 });
155
+ client.rollback(1);
156
+ // seq 1 が消えて seq 2 だけ再適用される
157
+ expect(latest(states)).toEqual({ moves: 1, charged: 0 });
158
+ });
159
+ /** 先行実行で throw した action は pending に積まれず、送信だけ行われる。 */
160
+ it('先行実行が throw した action は pending に積まれない', () => {
161
+ const { client, sent, states } = setup();
162
+ client.send('boom');
163
+ expect(sent).toEqual([{ action: 'boom', seq: 1 }]);
164
+ // pending が空なので、確定 state がそのまま表示される
165
+ client.applyState({ moves: 9, charged: 9 }, {});
166
+ expect(latest(states)).toEqual({ moves: 9, charged: 9 });
167
+ });
168
+ });
169
+ describe('applyDelta', () => {
170
+ /** delta 経路でも serverEvents の扱いは applyState と揃っている。 */
171
+ it('自分の ack でも serverEvents を発火する', () => {
172
+ const { client, fired } = setup();
173
+ client.send('move');
174
+ const ok = client.applyDelta([{ op: 'replace', path: '/charged', value: 3 }], {
175
+ ack: 1,
176
+ from: 'me',
177
+ events: [{ name: 'moved', data: {} }],
178
+ serverEvents: [{ name: 'charged', data: {} }],
179
+ });
180
+ expect(ok).toBe(true);
181
+ expect(fired).toEqual(['charged']);
182
+ });
183
+ /** patch が当たらないときは events を発火せず false を返す (transport がフル state を再要求する)。 */
184
+ it('patch 適用に失敗したら events を発火せず false を返す', () => {
185
+ const { client, fired } = setup();
186
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
187
+ const ok = client.applyDelta([{ op: 'replace', path: '/missing/deep', value: 1 }], {
188
+ events: [{ name: 'moved', data: {} }],
189
+ serverEvents: [{ name: 'charged', data: {} }],
190
+ });
191
+ expect(ok).toBe(false);
192
+ expect(fired).toEqual([]);
193
+ warn.mockRestore();
194
+ });
195
+ });
196
+ describe('レビュー指摘の回帰防止', () => {
197
+ /**
198
+ * 旧 serverOnly() が actions に残っている logic では、その handler をクライアントで
199
+ * 先読みしてはいけない。サーバー専用の副作用 (fetch 等) が client でも走ってしまう。
200
+ */
201
+ it('actions に残った serverOnly() は先読みしない', () => {
202
+ const { client, sent, states } = setup();
203
+ client.send('legacy');
204
+ // state は動かず、送信だけ行われる
205
+ expect(latest(states)).toEqual({ moves: 0, charged: 0 });
206
+ expect(sent).toEqual([{ action: 'legacy', seq: 1 }]);
207
+ });
208
+ /** 再適用でも同じ。pending に積まれていても brand 付きなら実行しない。 */
209
+ it('再適用でも serverOnly() を実行しない', () => {
210
+ const { client, states } = setup();
211
+ client.send('move'); // seq 1 (pending へ)
212
+ client.send('legacy'); // seq 2 (pending へは積まれない)
213
+ client.applyState({ moves: 0, charged: 0 }, {});
214
+ // 確定 state に move だけが再適用され、legacy の +1000 は入らない
215
+ expect(latest(states)).toEqual({ moves: 1, charged: 0 });
216
+ });
217
+ /**
218
+ * 再適用中に handler が途中まで state を変更してから throw した場合、その部分変更を
219
+ * 残したまま publish してはいけない。
220
+ */
221
+ it('再適用で throw した handler の部分変更を残さない', () => {
222
+ const { client, states } = setup();
223
+ client.send('move'); // seq 1: moves +1
224
+ client.send('partial'); // seq 2: moves +1 してから throw (pending には積まれない)
225
+ // 送信時点で partial は throw するので pending に入らず、moves は 1 のまま
226
+ expect(latest(states).moves).toBe(1);
227
+ // 確定 state を受けて再適用しても、partial の部分変更は混ざらない
228
+ client.applyState({ moves: 0, charged: 0 }, {});
229
+ expect(latest(states)).toEqual({ moves: 1, charged: 0 });
230
+ });
231
+ });
232
+ });
@@ -123,9 +123,10 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
123
123
  const ack = parsed.ack;
124
124
  const from = parsed.from;
125
125
  const evts = parsed.events ?? [];
126
+ const serverEvts = parsed.serverEvents ?? [];
126
127
  serverSeq = parsed.seq ?? serverSeq + 1;
127
128
  requestStatePending = false;
128
- client.applyState(parsed.state, { ack, from, events: evts });
129
+ client.applyState(parsed.state, { ack, from, events: evts, serverEvents: serverEvts });
129
130
  return;
130
131
  }
131
132
  // ─── Action 結果 (差分パッチ) ────────────────────────
@@ -133,6 +134,7 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
133
134
  const ack = parsed.ack;
134
135
  const from = parsed.from;
135
136
  const evts = parsed.events ?? [];
137
+ const serverEvts = parsed.serverEvents ?? [];
136
138
  const newSeq = parsed.seq ?? serverSeq + 1;
137
139
  if (newSeq !== serverSeq + 1) {
138
140
  requestFullState();
@@ -142,6 +144,7 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
142
144
  ack,
143
145
  from,
144
146
  events: evts,
147
+ serverEvents: serverEvts,
145
148
  });
146
149
  if (!ok) {
147
150
  requestFullState();
@@ -3,24 +3,31 @@
3
3
  * - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
4
4
  * - 開発パターン: docs/docs/uzu_code/sdk-guide/patterns.md
5
5
  *
6
- * serverOnly() wrap した handler はクライアント側の楽観的更新(先行実行)を
7
- * スキップし、サーバー側でだけ実行される。fetch などの副作用付き処理を安全に
8
- * 書ける。
6
+ * `serverOnly()` `logic.serverActions` に置き換わった互換 API。
7
+ *
8
+ * 新しく書くコードでは `serverActions` フィールドへ直接置く。`actions` の型からは
9
+ * ユニオンが外れたため、`serverOnly()` で wrap した handler を `actions` に入れることは
10
+ * できない (型エラーになる)。
11
+ *
12
+ * `isServerOnlyAction()` はサーバー側テンプレが残す必要がある。R2 に保存済みの古い
13
+ * logic.js は `serverOnly()` の brand を持ったまま `actions` に入っており、新しい
14
+ * テンプレと組み合わさるため、brand を見ないと先読み対象として扱ってしまう。
9
15
  */
10
- import type { ActionHandler, ServerOnlyAction, ServerOnlyActionHandlerFn } from './types.js';
16
+ import type { ActionHandler, ServerActionHandler, ServerOnlyAction } from './types.js';
11
17
  /**
12
- * server-only action handler を作る。
18
+ * @deprecated `logic.serverActions` に直接書く。
13
19
  *
14
- * 例:
15
20
  * ```ts
16
- * actions: {
17
- * notifyExternal: serverOnly(async (state, payload, playerId) => {
18
- * await fetch('https://example.com/notify', { ... });
19
- * state.notifiedAt = Date.now();
20
- * }),
21
- * }
21
+ * // before
22
+ * actions: { notifyExternal: serverOnly(async (state) => { ... }) }
23
+ * // after
24
+ * serverActions: { notifyExternal: async (state) => { ... } }
22
25
  * ```
23
26
  */
24
- export declare function serverOnly<S>(handler: ServerOnlyActionHandlerFn<S>): ServerOnlyAction<S>;
25
- /** handler が serverOnly() で wrap されているか判定する。 */
26
- export declare function isServerOnlyAction<S>(handler: ActionHandler<S> | ServerOnlyAction<S> | undefined): handler is ServerOnlyAction<S>;
27
+ export declare function serverOnly<S>(handler: ServerActionHandler<S>): ServerOnlyAction<S>;
28
+ /**
29
+ * handler `serverOnly()` wrap されているか判定する。
30
+ *
31
+ * 移行期の互換用。`serverActions` へ移行済みの logic では常に false になる。
32
+ */
33
+ export declare function isServerOnlyAction<S>(handler: ActionHandler<S> | ServerActionHandler<S> | ServerOnlyAction<S> | undefined): handler is ServerOnlyAction<S>;
@@ -1,20 +1,21 @@
1
1
  /**
2
- * server-only action handler を作る。
2
+ * @deprecated `logic.serverActions` に直接書く。
3
3
  *
4
- * 例:
5
4
  * ```ts
6
- * actions: {
7
- * notifyExternal: serverOnly(async (state, payload, playerId) => {
8
- * await fetch('https://example.com/notify', { ... });
9
- * state.notifiedAt = Date.now();
10
- * }),
11
- * }
5
+ * // before
6
+ * actions: { notifyExternal: serverOnly(async (state) => { ... }) }
7
+ * // after
8
+ * serverActions: { notifyExternal: async (state) => { ... } }
12
9
  * ```
13
10
  */
14
11
  export function serverOnly(handler) {
15
12
  return Object.assign(handler, { __serverOnly: true });
16
13
  }
17
- /** handler が serverOnly() で wrap されているか判定する。 */
14
+ /**
15
+ * handler が `serverOnly()` で wrap されているか判定する。
16
+ *
17
+ * 移行期の互換用。`serverActions` へ移行済みの logic では常に false になる。
18
+ */
18
19
  export function isServerOnlyAction(handler) {
19
20
  return (typeof handler === 'function' && '__serverOnly' in handler && handler.__serverOnly === true);
20
21
  }
package/dist/types.d.ts CHANGED
@@ -67,14 +67,31 @@ export interface SeededRandom {
67
67
  pick<T>(array: T[]): T;
68
68
  shuffle<T>(array: T[]): T[];
69
69
  }
70
- export type ActionHandler<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: {
71
- tick: number;
72
- }) => void;
73
- export type ServerOnlyActionHandlerFn<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: {
70
+ /**
71
+ * 素の action handler の実行文脈。空なのは意図的。
72
+ *
73
+ * 素の handler はサーバーとクライアント先読みの両方で走る。クライアントが自力で
74
+ * 再現できない値 (tick / 実時刻 / 乱数) をここで配ると、サーバー・ソロ・dev では
75
+ * 本物が入るのにオンラインの先読みだけ値がズレる、という一番気付きにくい形で壊れる。
76
+ * そういう値が要る処理は `serverActions` 側に書く。
77
+ */
78
+ export type ActionContext = Record<never, never>;
79
+ /** `serverActions` handler の実行文脈。サーバーでしか走らないので tick と乱数を渡せる。 */
80
+ export interface ServerActionContext {
74
81
  tick: number;
75
- }) => Promise<void> | void;
76
- /** serverOnly() で wrap された handler。`__serverOnly` brand で識別する。 */
77
- export type ServerOnlyAction<S> = ServerOnlyActionHandlerFn<S> & {
82
+ random: SeededRandom;
83
+ }
84
+ /** @deprecated `ServerActionContext` を使う。 */
85
+ 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;
88
+ /** @deprecated `ServerActionHandler` を使う。 */
89
+ export type ServerOnlyActionHandlerFn<S> = ServerActionHandler<S>;
90
+ /**
91
+ * @deprecated `serverActions` フィールドに直接書く。
92
+ * `serverOnly()` で wrap された handler。`__serverOnly` brand で識別する。
93
+ */
94
+ export type ServerOnlyAction<S> = ServerActionHandler<S> & {
78
95
  readonly __serverOnly: true;
79
96
  };
80
97
  export interface GameLogic<S> {
@@ -83,7 +100,22 @@ export interface GameLogic<S> {
83
100
  * ゲームの配役は kind === 'player' (または kind 省略) だけを対象にすること。
84
101
  */
85
102
  setup(seats: Seat[], random: SeededRandom): S;
86
- actions: Record<string, ActionHandler<S> | ServerOnlyAction<S>>;
103
+ /**
104
+ * クライアント先読みとサーバーの両方で走る handler。決定的でなければならない。
105
+ *
106
+ * `serverActions` に同名のキーを置くと、同じ action の「サーバーだけで走る続き」に
107
+ * なる。1 つの action を「即座に反映していい部分」と「サーバーが決める部分」へ
108
+ * 分けられる (例: 駒の移動は先読み、持ち時間の減算はサーバー)。
109
+ */
110
+ actions: Record<string, ActionHandler<S>>;
111
+ /**
112
+ * サーバーでのみ走る handler。実時刻 / 乱数 / fetch など、クライアント先読みで
113
+ * 再現できない処理をここに書く。async 可。
114
+ *
115
+ * `actions` と同名でも別名でもよい。別名だけに置けば「先読みしない action」
116
+ * (旧 `serverOnly()` 相当) になる。
117
+ */
118
+ serverActions?: Record<string, ServerActionHandler<S>>;
87
119
  update(state: S, ctx: {
88
120
  random: SeededRandom;
89
121
  tick: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-sdk",
3
- "version": "0.3.10",
3
+ "version": "0.5.0",
4
4
  "description": "UZU PlayScreen SDK - Flutter ↔ JS ゲーム通信ライブラリ",
5
5
  "type": "module",
6
6
  "exports": {