@uzuhq/code-sdk 0.3.10 → 0.4.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, 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';
@@ -49,7 +49,7 @@ export function runLocalServerAction(config) {
49
49
  return;
50
50
  }
51
51
  try {
52
- handler(state, payload ?? {}, myId, actionEmit, { tick });
52
+ handler(state, payload ?? {}, myId, actionEmit, {});
53
53
  }
54
54
  catch (err) {
55
55
  console.warn('[SDK LocalServerAction] Action error:', err);
@@ -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,13 +33,14 @@ 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 handler は send 時点で pending に積まれないので、ここに来るのは
37
+ // 素の handler だけ。型を絞るためにも明示的に弾く。
38
+ if (!handler || isServerOnlyAction(handler)) {
36
39
  pendingActions.splice(i, 1);
37
40
  continue;
38
41
  }
39
42
  try {
40
- // tick はサーバー側でのみ正確に管理される。再適用ではサーバー tick が不明のため 0 を使う。
41
- handler(displayState, payload, playerId, noopEmit, { tick: 0 });
43
+ runPredicted(action, () => handler(displayState, payload, playerId, noopEmit, {}));
42
44
  i++;
43
45
  }
44
46
  catch {
@@ -69,11 +71,13 @@ export function createOptimisticActionClient(config) {
69
71
  // serverOnly handler は先行実行を skip。pending にも積まないので、ack 受信時の
70
72
  // isMyAck 判定で false となり、サーバー発の events が普通に emit される。
71
73
  const handler = logic.actions[type];
72
- if (handler && !isServerOnlyAction(handler) && displayState !== null) {
74
+ // callback 内では displayState narrowing が効かないので const に退避する。
75
+ const target = displayState;
76
+ if (handler && !isServerOnlyAction(handler) && target !== null) {
73
77
  try {
74
- handler(displayState, payload ?? {}, playerId, emit, { tick: 0 });
78
+ runPredicted(type, () => handler(target, payload ?? {}, playerId, emit, {}));
75
79
  pendingActions.push({ seq, action: type, payload: payload ?? {} });
76
- onState(displayState, playerId);
80
+ onState(target, playerId);
77
81
  }
78
82
  catch {
79
83
  // ローカル実行失敗 → 楽観的更新せずサーバーに送るだけ
package/dist/types.d.ts CHANGED
@@ -67,12 +67,21 @@ 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
+ * そういう値が要る action は serverOnly() にして先読みの対象から外す。
77
+ */
78
+ export type ActionContext = Record<never, never>;
79
+ /** serverOnly() handler の実行文脈。サーバーでしか走らないので tick を渡せる。 */
80
+ export interface ServerOnlyActionContext {
74
81
  tick: number;
75
- }) => Promise<void> | void;
82
+ }
83
+ export type ActionHandler<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: ActionContext) => void;
84
+ export type ServerOnlyActionHandlerFn<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: ServerOnlyActionContext) => Promise<void> | void;
76
85
  /** serverOnly() で wrap された handler。`__serverOnly` brand で識別する。 */
77
86
  export type ServerOnlyAction<S> = ServerOnlyActionHandlerFn<S> & {
78
87
  readonly __serverOnly: true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-sdk",
3
- "version": "0.3.10",
3
+ "version": "0.4.0",
4
4
  "description": "UZU PlayScreen SDK - Flutter ↔ JS ゲーム通信ライブラリ",
5
5
  "type": "module",
6
6
  "exports": {