@uzuhq/code-sdk 0.3.8
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/LICENSE +21 -0
- package/README.md +573 -0
- package/dist/dev-globals.d.ts +28 -0
- package/dist/dev-globals.js +16 -0
- package/dist/dev-hooks.d.ts +233 -0
- package/dist/dev-hooks.js +130 -0
- package/dist/dev-hooks.test.d.ts +1 -0
- package/dist/dev-hooks.test.js +294 -0
- package/dist/dev-state-patch.d.ts +81 -0
- package/dist/dev-state-patch.js +295 -0
- package/dist/dev-state-patch.test.d.ts +1 -0
- package/dist/dev-state-patch.test.js +333 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +440 -0
- package/dist/json-patch.d.ts +7 -0
- package/dist/json-patch.js +78 -0
- package/dist/random.d.ts +11 -0
- package/dist/random.js +34 -0
- package/dist/reconnectable-ws.d.ts +60 -0
- package/dist/reconnectable-ws.js +229 -0
- package/dist/room.d.ts +23 -0
- package/dist/room.js +36 -0
- package/dist/run/local-server-action.d.ts +15 -0
- package/dist/run/local-server-action.js +97 -0
- package/dist/run/optimistic-action-client.d.ts +57 -0
- package/dist/run/optimistic-action-client.js +119 -0
- package/dist/run/server-action.d.ts +16 -0
- package/dist/run/server-action.js +170 -0
- package/dist/server-only.d.ts +26 -0
- package/dist/server-only.js +20 -0
- package/dist/sync/local.d.ts +8 -0
- package/dist/sync/local.js +51 -0
- package/dist/sync/online.d.ts +5 -0
- package/dist/sync/online.js +165 -0
- package/dist/types.d.ts +142 -0
- package/dist/types.js +8 -0
- package/package.json +34 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @docs
|
|
3
|
+
* - 外部 automation API: docs/docs/play_screen_v3/sdk-guide/dev-hooks.md
|
|
4
|
+
*
|
|
5
|
+
* 外部 automation (Playwright / AI agent / E2E test) が SDK が動いている iframe の
|
|
6
|
+
* state を決定論的に読み書きするための window.__uzu_dev API。
|
|
7
|
+
*
|
|
8
|
+
* Flutter native ホスト (window.FlutterHost あり) では一切 attach されない。
|
|
9
|
+
* dev harness の子 iframe / Playwright iframe / 単独 page では attach される。
|
|
10
|
+
*
|
|
11
|
+
* state 書き換え API:
|
|
12
|
+
* - `setRawState(fullState)` : 全置換
|
|
13
|
+
* - `mergeRawState(patch)` : RFC 7396 風 JSON Merge Patch (object 階層の部分更新)
|
|
14
|
+
* - `patchRawState(ops)` : RFC 6902 JSON Patch (path-based ops、array index 単体書換可)
|
|
15
|
+
*
|
|
16
|
+
* online ServerAction では state が Worker DO 側にあるため 3 つとも undefined。
|
|
17
|
+
*
|
|
18
|
+
* SDK は state shape に依存しない generic primitive だけを提供する。
|
|
19
|
+
* scenario 固有の helper (特定 field path の読み書き / phase 遷移時の field reset /
|
|
20
|
+
* 特定 action 名のラッパー等) は scenario 側で window.__<scene>_dev を生やす。
|
|
21
|
+
*/
|
|
22
|
+
import type { JsonMergePatch, JsonPatchOp } from './dev-state-patch.js';
|
|
23
|
+
import type { ServerEvent } from './types.js';
|
|
24
|
+
export type { JsonMergePatch, JsonPatchOp } from './dev-state-patch.js';
|
|
25
|
+
export { applyJsonMergePatch, applyJsonPatch } from './dev-state-patch.js';
|
|
26
|
+
export interface UzuDevHooks<S = unknown> {
|
|
27
|
+
/** 現在の per-player 視点 snapshot (onState コールバックの最新値)。未初期化なら null。 */
|
|
28
|
+
getSnapshot(): unknown;
|
|
29
|
+
/** 生 server-side state (dev/local mode のみ。online ServerAction では null)。 */
|
|
30
|
+
getRawState(): S | null;
|
|
31
|
+
playerId(): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* 親 frame で virtual server を実体化した時の seed。
|
|
34
|
+
* 親 frame の `runDevHarness({ server })` 経由でしか attach されず、子 iframe の
|
|
35
|
+
* dev hooks 側では undefined。bug report 用に `?__dev_seed=<value>` として
|
|
36
|
+
* 貼り付けやすい uint32 を返す。
|
|
37
|
+
*/
|
|
38
|
+
getSeed?(): number;
|
|
39
|
+
/**
|
|
40
|
+
* Server-side で action を直接 dispatch する。
|
|
41
|
+
*
|
|
42
|
+
* **run devHarness の親 frame のみ** で attach される。それ以外 (runLocal /
|
|
43
|
+
* sync / online) では `undefined`。
|
|
44
|
+
* 非 parent モードで action を投げたい時は scenario 側の bridge
|
|
45
|
+
* (`window.__uzu.sendAction`) や実 UI 操作を使う。
|
|
46
|
+
*
|
|
47
|
+
* `args.as` は **必須**。server-side dispatch の `from` をこの値で指定する
|
|
48
|
+
* (バリデーションはしないので、未登録 ID で投げて action handler に弾かせる
|
|
49
|
+
* 運用も可)。turn-based の E2E (将棋・人狼・カードゲーム) で「先手 → 後手」
|
|
50
|
+
* を 1 frame から打ち分けたい時に使う。
|
|
51
|
+
*/
|
|
52
|
+
send?(args: {
|
|
53
|
+
as: string;
|
|
54
|
+
type: string;
|
|
55
|
+
payload?: Record<string, unknown>;
|
|
56
|
+
}): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* 生 state を **全置換** する。state owner の frame (= dev/local) のみ。
|
|
59
|
+
*
|
|
60
|
+
* `setup` 後の任意の構造を流し込めるので、`getRawState()` の dump を読み込んで
|
|
61
|
+
* バグ再現する用途や、scenario の途中 state を JSON で復元する用途に使う。
|
|
62
|
+
* 部分更新は {@link mergeRawState} を、array 要素単体の書換は
|
|
63
|
+
* {@link patchRawState} を使うこと。
|
|
64
|
+
*/
|
|
65
|
+
setRawState?(state: S): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* RFC 7396 風 JSON Merge Patch で生 state を部分更新する (dev/local のみ)。
|
|
68
|
+
*
|
|
69
|
+
* - object 階層は recursive merge
|
|
70
|
+
* - **array field は atomic replace のみ** (要素単位の merge 不可、RFC 7396 仕様)
|
|
71
|
+
* - `undefined` は no-op、`null` は明示セット (RFC 7396 strict と異なる)
|
|
72
|
+
* - array 内の cell 単体を書き換えたい場合は {@link patchRawState} を使う
|
|
73
|
+
*
|
|
74
|
+
* array が pure object に化ける silent な破壊を防ぐため、`array に
|
|
75
|
+
* non-array object patch を当てる`と **throw** する。
|
|
76
|
+
*/
|
|
77
|
+
mergeRawState?(patch: JsonMergePatch<S>): Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* RFC 6902 JSON Patch (path-based ops) で生 state を更新する (dev/local のみ)。
|
|
80
|
+
*
|
|
81
|
+
* - JSON Pointer (`/board/1/4`) で array index 単体の書換が可能
|
|
82
|
+
* - サポートする op: `add` / `remove` / `replace` / `move` / `copy` / `test`
|
|
83
|
+
* - 1 op でも失敗するとその場で throw する (atomic ではない)
|
|
84
|
+
*
|
|
85
|
+
* 局面の部分更新 (将棋の一手・盤面の cell 単体書換など) に使う。
|
|
86
|
+
*/
|
|
87
|
+
patchRawState?(ops: JsonPatchOp[]): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* snapshot 更新を event-driven に listen する。返り値は unsubscribe 関数。
|
|
90
|
+
*
|
|
91
|
+
* `waitForSnapshot` が「特定条件まで待つ」のに対し、こちらは「state 変化を全部
|
|
92
|
+
* 取って trace test を書く」「deterministic record を作る」「sequence assert」
|
|
93
|
+
* のような用途で使う canonical な listener。
|
|
94
|
+
*
|
|
95
|
+
* cb には authoritative state の生参照 (run devHarness 親では `getRawState()` と
|
|
96
|
+
* 同一) が渡るので mutate しない。
|
|
97
|
+
*/
|
|
98
|
+
subscribeSnapshot(cb: (snapshot: unknown) => void): () => void;
|
|
99
|
+
/** snapshot が predicate を満たすまで待つ。default 10 秒 timeout。 */
|
|
100
|
+
waitForSnapshot(predicate: (snapshot: unknown) => boolean, options?: {
|
|
101
|
+
timeoutMs?: number;
|
|
102
|
+
}): Promise<unknown>;
|
|
103
|
+
/**
|
|
104
|
+
* action handler / `logic.update` の `emit` を listen する。callback には 1 回の
|
|
105
|
+
* dispatch / tick 内で発火した event を batch でまとめて渡す。**run devHarness 親
|
|
106
|
+
* frame のみ** で attach され、それ以外 (runLocal ソロ /
|
|
107
|
+
* sync / online) では `undefined`。
|
|
108
|
+
*
|
|
109
|
+
* `getSnapshot()` に乗らない raw payload (`gameover` の `reason` 等) を assert
|
|
110
|
+
* したい場合に使う。`config.events` callback とは独立経路で、scenario callback の
|
|
111
|
+
* throw も subscriber 通知を止めない。unsubscribe 関数を返す。
|
|
112
|
+
*/
|
|
113
|
+
subscribeEvents?(cb: (events: readonly ServerEvent[]) => void): () => void;
|
|
114
|
+
/**
|
|
115
|
+
* tick loop を pause する (run() devHarness 親 frame のみ)。
|
|
116
|
+
* 時間駆動の `logic.update` が走らなくなり、shogi の持ち時間減算等の自動進行を
|
|
117
|
+
* 止められる。pause 中も `send` (action dispatch) と state 書換 API は通る。
|
|
118
|
+
* tick loop を持たない frame (子 iframe / sync 単独 frame / online) と
|
|
119
|
+
* `tickRate <= 0` の scenario では undefined / no-op。
|
|
120
|
+
*/
|
|
121
|
+
pauseTick?(): void;
|
|
122
|
+
/** pauseTick を解除する。 */
|
|
123
|
+
resumeTick?(): void;
|
|
124
|
+
/** 手動で n tick 進める。default n = 1。 */
|
|
125
|
+
stepTick?(n?: number): void;
|
|
126
|
+
/** 現在の tick 値を返す。tick loop を持たない frame では undefined。 */
|
|
127
|
+
getCurrentTick?(): number;
|
|
128
|
+
/** pauseTick で止まっているかどうか。 */
|
|
129
|
+
isTickPaused?(): boolean;
|
|
130
|
+
/**
|
|
131
|
+
* `logic.setup` を再実行して virtual server を fresh start させる。
|
|
132
|
+
* run() devHarness の親 frame のみ attach される (state owner でしか reset 不可)。
|
|
133
|
+
*
|
|
134
|
+
* - `seed` 省略時は現在の seed を再利用 (= 決定論的同一初期化)
|
|
135
|
+
* - 数値を渡すとその seed で再初期化、`getSeed()` の戻り値も更新
|
|
136
|
+
* - `'random'` で新 seed (uint32) を生成
|
|
137
|
+
*
|
|
138
|
+
* pause 状態は維持される。`logic.setup` が副作用を持つ場合は再呼出で副作用が再発火する。
|
|
139
|
+
*/
|
|
140
|
+
reset?(opts?: {
|
|
141
|
+
seed?: number | 'random';
|
|
142
|
+
}): void;
|
|
143
|
+
}
|
|
144
|
+
declare global {
|
|
145
|
+
interface Window {
|
|
146
|
+
__uzu_dev?: UzuDevHooks;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* run/* モジュール (run/local-server-action / run/dev-server-action) が
|
|
151
|
+
* dev hooks に state を expose するための handle。
|
|
152
|
+
* online (server-action) モードでは handle 自体を返さない。
|
|
153
|
+
*
|
|
154
|
+
* state 書き換え API は state owner frame のみ提供する。
|
|
155
|
+
*/
|
|
156
|
+
export interface RunHandle<S = unknown> {
|
|
157
|
+
/** 内部 state の getter (dev hooks 用)。host iframe を持たない側では undefined */
|
|
158
|
+
getRawState?(): S | null;
|
|
159
|
+
/** 生 state 全置換。host iframe を持たない側では undefined */
|
|
160
|
+
setRawState?(state: S): Promise<void>;
|
|
161
|
+
/** RFC 7396 風 Merge Patch。host iframe を持たない側では undefined */
|
|
162
|
+
mergeRawState?(patch: JsonMergePatch<S>): Promise<void>;
|
|
163
|
+
/** RFC 6902 JSON Patch。host iframe を持たない側では undefined */
|
|
164
|
+
patchRawState?(ops: JsonPatchOp[]): Promise<void>;
|
|
165
|
+
/** 既存 inputs と同等の action 送信 (dev hooks 用) */
|
|
166
|
+
sendAction(type: string, payload?: Record<string, unknown>): void;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* sync/* モジュール (sync/dev / sync/local) が dev hooks に state を expose
|
|
170
|
+
* するための handle。online sync では handle 自体を返さない。
|
|
171
|
+
*/
|
|
172
|
+
export interface SyncHandle<S = unknown> {
|
|
173
|
+
/** 内部 state の getter (dev hooks 用) */
|
|
174
|
+
getRawState?(): S | null;
|
|
175
|
+
/** 生 state 全置換 */
|
|
176
|
+
setRawState?(state: S): Promise<void>;
|
|
177
|
+
/** RFC 7396 風 Merge Patch */
|
|
178
|
+
mergeRawState?(patch: JsonMergePatch<S>): Promise<void>;
|
|
179
|
+
/** RFC 6902 JSON Patch */
|
|
180
|
+
patchRawState?(ops: JsonPatchOp[]): Promise<void>;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* dev-hooks.ts から見た「SDK 内部のコンテキスト」。
|
|
184
|
+
* 各 run/* / sync/* モジュールがこの形の handle を組み立てて attachDevHooks に渡す。
|
|
185
|
+
*/
|
|
186
|
+
export interface DevHooksCtx<S = unknown> {
|
|
187
|
+
/** 最新 snapshot (per-player view)。未初期化なら null。 */
|
|
188
|
+
getSnapshot(): unknown;
|
|
189
|
+
/** 生 state getter (dev/local mode のみ)。 */
|
|
190
|
+
getRawState?: () => S | null;
|
|
191
|
+
/** 生 state 全置換 (dev/local mode のみ)。 */
|
|
192
|
+
setRawState?: (state: S) => Promise<void>;
|
|
193
|
+
/** RFC 7396 風 Merge Patch (dev/local mode のみ)。 */
|
|
194
|
+
mergeRawState?: (patch: JsonMergePatch<S>) => Promise<void>;
|
|
195
|
+
/** RFC 6902 JSON Patch (dev/local mode のみ)。 */
|
|
196
|
+
patchRawState?: (ops: JsonPatchOp[]) => Promise<void>;
|
|
197
|
+
/** 現在の player ID (URL クエリ or runtime 解決)。 */
|
|
198
|
+
playerId(): string | null;
|
|
199
|
+
/**
|
|
200
|
+
* Action 送信。run devHarness 親 frame の `DevHostHandle` のみが
|
|
201
|
+
* 提供する。それ以外のモードでは省略 (= `__uzu_dev.send` も生えない)。
|
|
202
|
+
* `args.as` で server-side dispatch の `from` を指定する (必須)。
|
|
203
|
+
*/
|
|
204
|
+
sendAction?(args: {
|
|
205
|
+
as: string;
|
|
206
|
+
type: string;
|
|
207
|
+
payload?: Record<string, unknown>;
|
|
208
|
+
}): Promise<void>;
|
|
209
|
+
/** snapshot 更新を listen する。unsubscribe 関数を返す。 */
|
|
210
|
+
subscribeSnapshot(cb: (snapshot: unknown) => void): () => void;
|
|
211
|
+
/**
|
|
212
|
+
* action handler / `logic.update` 由来の emit を listen する。run devHarness 親
|
|
213
|
+
* frame の `DevHostHandle` のみが提供する。それ以外のモードでは省略
|
|
214
|
+
* (= `__uzu_dev.subscribeEvents` も生えない)。
|
|
215
|
+
*/
|
|
216
|
+
subscribeEvents?(cb: (events: readonly ServerEvent[]) => void): () => void;
|
|
217
|
+
/** dev harness 親 frame の virtual server seed。親 attach 経路のみ。 */
|
|
218
|
+
getSeed?: () => number;
|
|
219
|
+
pauseTick?: () => void;
|
|
220
|
+
resumeTick?: () => void;
|
|
221
|
+
stepTick?: (n?: number) => void;
|
|
222
|
+
getCurrentTick?: () => number;
|
|
223
|
+
isTickPaused?: () => boolean;
|
|
224
|
+
/**
|
|
225
|
+
* run() devHarness 親 frame の `DevHostHandle.reset` を expose する。
|
|
226
|
+
* state owner でしか fresh start できないので、それ以外の mode では undefined。
|
|
227
|
+
*/
|
|
228
|
+
reset?: (opts?: {
|
|
229
|
+
seed?: number | 'random';
|
|
230
|
+
}) => void;
|
|
231
|
+
}
|
|
232
|
+
export declare function createDevHooks<S>(ctx: DevHooksCtx<S>): UzuDevHooks<S>;
|
|
233
|
+
export declare function attachDevHooks<S>(ctx: DevHooksCtx<S>): void;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @docs
|
|
3
|
+
* - 外部 automation API: docs/docs/play_screen_v3/sdk-guide/dev-hooks.md
|
|
4
|
+
*
|
|
5
|
+
* 外部 automation (Playwright / AI agent / E2E test) が SDK が動いている iframe の
|
|
6
|
+
* state を決定論的に読み書きするための window.__uzu_dev API。
|
|
7
|
+
*
|
|
8
|
+
* Flutter native ホスト (window.FlutterHost あり) では一切 attach されない。
|
|
9
|
+
* dev harness の子 iframe / Playwright iframe / 単独 page では attach される。
|
|
10
|
+
*
|
|
11
|
+
* state 書き換え API:
|
|
12
|
+
* - `setRawState(fullState)` : 全置換
|
|
13
|
+
* - `mergeRawState(patch)` : RFC 7396 風 JSON Merge Patch (object 階層の部分更新)
|
|
14
|
+
* - `patchRawState(ops)` : RFC 6902 JSON Patch (path-based ops、array index 単体書換可)
|
|
15
|
+
*
|
|
16
|
+
* online ServerAction では state が Worker DO 側にあるため 3 つとも undefined。
|
|
17
|
+
*
|
|
18
|
+
* SDK は state shape に依存しない generic primitive だけを提供する。
|
|
19
|
+
* scenario 固有の helper (特定 field path の読み書き / phase 遷移時の field reset /
|
|
20
|
+
* 特定 action 名のラッパー等) は scenario 側で window.__<scene>_dev を生やす。
|
|
21
|
+
*/
|
|
22
|
+
export { applyJsonMergePatch, applyJsonPatch } from './dev-state-patch.js';
|
|
23
|
+
export function createDevHooks(ctx) {
|
|
24
|
+
const getRawState = () => (ctx.getRawState ? ctx.getRawState() : null);
|
|
25
|
+
const waitForSnapshot = (predicate, options) => {
|
|
26
|
+
const timeoutMs = options?.timeoutMs ?? 10000;
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const current = ctx.getSnapshot();
|
|
29
|
+
if (current != null) {
|
|
30
|
+
let ok = false;
|
|
31
|
+
try {
|
|
32
|
+
ok = predicate(current);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
reject(err);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (ok) {
|
|
39
|
+
resolve(current);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
unsubscribe();
|
|
45
|
+
reject(new Error(`[uzu_dev] waitForSnapshot timed out after ${timeoutMs}ms`));
|
|
46
|
+
}, timeoutMs);
|
|
47
|
+
const unsubscribe = ctx.subscribeSnapshot((snap) => {
|
|
48
|
+
let ok;
|
|
49
|
+
try {
|
|
50
|
+
ok = predicate(snap);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
unsubscribe();
|
|
55
|
+
reject(err);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (ok) {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
unsubscribe();
|
|
61
|
+
resolve(snap);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
const hooks = {
|
|
67
|
+
getSnapshot: () => ctx.getSnapshot(),
|
|
68
|
+
getRawState,
|
|
69
|
+
playerId: () => ctx.playerId(),
|
|
70
|
+
subscribeSnapshot: (cb) => ctx.subscribeSnapshot(cb),
|
|
71
|
+
waitForSnapshot,
|
|
72
|
+
};
|
|
73
|
+
if (ctx.sendAction) {
|
|
74
|
+
const sendAction = ctx.sendAction;
|
|
75
|
+
hooks.send = (args) => sendAction(args);
|
|
76
|
+
}
|
|
77
|
+
if (ctx.setRawState) {
|
|
78
|
+
const setRawState = ctx.setRawState;
|
|
79
|
+
hooks.setRawState = (state) => setRawState(state);
|
|
80
|
+
}
|
|
81
|
+
if (ctx.mergeRawState) {
|
|
82
|
+
const mergeRawState = ctx.mergeRawState;
|
|
83
|
+
hooks.mergeRawState = (patch) => mergeRawState(patch);
|
|
84
|
+
}
|
|
85
|
+
if (ctx.patchRawState) {
|
|
86
|
+
const patchRawState = ctx.patchRawState;
|
|
87
|
+
hooks.patchRawState = (ops) => patchRawState(ops);
|
|
88
|
+
}
|
|
89
|
+
if (ctx.getSeed) {
|
|
90
|
+
const getSeed = ctx.getSeed;
|
|
91
|
+
hooks.getSeed = () => getSeed();
|
|
92
|
+
}
|
|
93
|
+
// Tick 制御: ctx 側で提供されている時だけ wire する。子 iframe / sync /
|
|
94
|
+
// online 等 tick loop を持たない frame では undefined のままなので、scenario 側で
|
|
95
|
+
// `__uzu_dev.pauseTick?.()` の optional chain で safe に呼べる。
|
|
96
|
+
if (ctx.pauseTick) {
|
|
97
|
+
const pauseTick = ctx.pauseTick;
|
|
98
|
+
hooks.pauseTick = () => pauseTick();
|
|
99
|
+
}
|
|
100
|
+
if (ctx.resumeTick) {
|
|
101
|
+
const resumeTick = ctx.resumeTick;
|
|
102
|
+
hooks.resumeTick = () => resumeTick();
|
|
103
|
+
}
|
|
104
|
+
if (ctx.stepTick) {
|
|
105
|
+
const stepTick = ctx.stepTick;
|
|
106
|
+
hooks.stepTick = (n) => stepTick(n);
|
|
107
|
+
}
|
|
108
|
+
if (ctx.getCurrentTick) {
|
|
109
|
+
const getCurrentTick = ctx.getCurrentTick;
|
|
110
|
+
hooks.getCurrentTick = () => getCurrentTick();
|
|
111
|
+
}
|
|
112
|
+
if (ctx.isTickPaused) {
|
|
113
|
+
const isTickPaused = ctx.isTickPaused;
|
|
114
|
+
hooks.isTickPaused = () => isTickPaused();
|
|
115
|
+
}
|
|
116
|
+
if (ctx.reset) {
|
|
117
|
+
const reset = ctx.reset;
|
|
118
|
+
hooks.reset = (opts) => reset(opts);
|
|
119
|
+
}
|
|
120
|
+
if (ctx.subscribeEvents) {
|
|
121
|
+
const subscribeEvents = ctx.subscribeEvents;
|
|
122
|
+
hooks.subscribeEvents = (cb) => subscribeEvents(cb);
|
|
123
|
+
}
|
|
124
|
+
return hooks;
|
|
125
|
+
}
|
|
126
|
+
export function attachDevHooks(ctx) {
|
|
127
|
+
// `init()` → `run()` の順で 2 回 attach されるのは正常フロー (run/sync 経由なら 1 回のみ)。
|
|
128
|
+
// silently 上書きする。
|
|
129
|
+
window.__uzu_dev = createDevHooks(ctx);
|
|
130
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev-hooks.ts の unit test。
|
|
3
|
+
*
|
|
4
|
+
* カバー対象:
|
|
5
|
+
* - createDevHooks: read / send / setRawState / mergeRawState / patchRawState /
|
|
6
|
+
* waitForSnapshot の API contract
|
|
7
|
+
* - attachDevHooks: window.__uzu_dev への代入と上書き
|
|
8
|
+
*
|
|
9
|
+
* applyJsonMergePatch / applyJsonPatch そのものの単体テストは
|
|
10
|
+
* `dev-state-patch.test.ts` に分離。
|
|
11
|
+
*/
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { attachDevHooks, createDevHooks } from './dev-hooks.js';
|
|
14
|
+
import { applyJsonMergePatch, applyJsonPatch } from './dev-state-patch.js';
|
|
15
|
+
function makeState() {
|
|
16
|
+
return {
|
|
17
|
+
game: { currentPhaseId: 'phase_a', timerEndsAt: 1000 },
|
|
18
|
+
players: {
|
|
19
|
+
p1: { ready: false, name: 'Alice' },
|
|
20
|
+
p2: { ready: false, name: 'Bob' },
|
|
21
|
+
},
|
|
22
|
+
scenario: { id: 'scenario_1' },
|
|
23
|
+
tags: ['initial'],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
describe('createDevHooks', () => {
|
|
27
|
+
function makeCtx(overrides = {}) {
|
|
28
|
+
// setRawState で参照ごと差し替えられるよう wrap する。test 側の expect は
|
|
29
|
+
// box.current 経由で常に最新参照を読む。
|
|
30
|
+
const box = { current: makeState() };
|
|
31
|
+
const listeners = new Set();
|
|
32
|
+
const sendAction = vi.fn();
|
|
33
|
+
const notify = (snap) => {
|
|
34
|
+
listeners.forEach((cb) => cb(snap));
|
|
35
|
+
};
|
|
36
|
+
const ctx = {
|
|
37
|
+
getSnapshot: () => box.current,
|
|
38
|
+
getRawState: () => box.current,
|
|
39
|
+
setRawState: async (next) => {
|
|
40
|
+
box.current = next;
|
|
41
|
+
},
|
|
42
|
+
mergeRawState: async (patch) => {
|
|
43
|
+
applyJsonMergePatch(box.current, patch);
|
|
44
|
+
},
|
|
45
|
+
patchRawState: async (ops) => {
|
|
46
|
+
applyJsonPatch(box.current, ops);
|
|
47
|
+
},
|
|
48
|
+
playerId: () => 'p1',
|
|
49
|
+
sendAction,
|
|
50
|
+
subscribeSnapshot: (cb) => {
|
|
51
|
+
listeners.add(cb);
|
|
52
|
+
return () => {
|
|
53
|
+
listeners.delete(cb);
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
return { ctx, state: box, notify, sendAction };
|
|
59
|
+
}
|
|
60
|
+
it('Read API: getSnapshot / getRawState / playerId を返す', () => {
|
|
61
|
+
const { ctx, state } = makeCtx();
|
|
62
|
+
const hooks = createDevHooks(ctx);
|
|
63
|
+
expect(hooks.getSnapshot()).toBe(state.current);
|
|
64
|
+
expect(hooks.getRawState()).toBe(state.current);
|
|
65
|
+
expect(hooks.playerId()).toBe('p1');
|
|
66
|
+
});
|
|
67
|
+
it('send() は ctx.sendAction に args オブジェクトをそのまま渡す', () => {
|
|
68
|
+
const { ctx, sendAction } = makeCtx();
|
|
69
|
+
const hooks = createDevHooks(ctx);
|
|
70
|
+
expect(hooks.send).toBeDefined();
|
|
71
|
+
hooks.send({ as: 'p2', type: 'vote', payload: { target: 'p1' } });
|
|
72
|
+
expect(sendAction).toHaveBeenCalledWith({
|
|
73
|
+
as: 'p2',
|
|
74
|
+
type: 'vote',
|
|
75
|
+
payload: { target: 'p1' },
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
it('ctx.sendAction 未提供だと hooks.send は undefined', () => {
|
|
79
|
+
const { ctx } = makeCtx({ sendAction: undefined });
|
|
80
|
+
const hooks = createDevHooks(ctx);
|
|
81
|
+
expect(hooks.send).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
it('send() は ctx.sendAction の rejection をそのまま伝搬する', async () => {
|
|
84
|
+
const sendActionReject = vi.fn(() => Promise.reject(new Error('Not your turn')));
|
|
85
|
+
const { ctx } = makeCtx({ sendAction: sendActionReject });
|
|
86
|
+
const hooks = createDevHooks(ctx);
|
|
87
|
+
await expect(hooks.send({ as: 'p1', type: 't' })).rejects.toThrow('Not your turn');
|
|
88
|
+
});
|
|
89
|
+
// ─── setRawState (全置換) ─────────────────────────
|
|
90
|
+
it('setRawState() は Promise を返し state を全置換する', async () => {
|
|
91
|
+
const { ctx, state } = makeCtx();
|
|
92
|
+
const hooks = createDevHooks(ctx);
|
|
93
|
+
const next = {
|
|
94
|
+
game: { currentPhaseId: 'phase_z', timerEndsAt: 999 },
|
|
95
|
+
players: { px: { ready: true, name: 'X' } },
|
|
96
|
+
scenario: null,
|
|
97
|
+
};
|
|
98
|
+
await expect(hooks.setRawState(next)).resolves.toBeUndefined();
|
|
99
|
+
expect(state.current).toBe(next);
|
|
100
|
+
// 旧 state にあった players.p1 は新 state に含まれないので消える (= 全置換意味)
|
|
101
|
+
expect(state.current.players.p1).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
it('ctx.setRawState 未提供だと hooks.setRawState は undefined', () => {
|
|
104
|
+
const { ctx } = makeCtx({ setRawState: undefined });
|
|
105
|
+
const hooks = createDevHooks(ctx);
|
|
106
|
+
expect(hooks.setRawState).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
// ─── mergeRawState (RFC 7396 風 merge patch) ─────
|
|
109
|
+
it('mergeRawState() は object 階層を recursive merge する', async () => {
|
|
110
|
+
const { ctx, state } = makeCtx();
|
|
111
|
+
const hooks = createDevHooks(ctx);
|
|
112
|
+
expect(hooks.mergeRawState).toBeDefined();
|
|
113
|
+
await hooks.mergeRawState({ players: { p1: { ready: true } } });
|
|
114
|
+
expect(state.current.players.p1.ready).toBe(true);
|
|
115
|
+
expect(state.current.players.p1.name).toBe('Alice');
|
|
116
|
+
expect(state.current.players.p2.ready).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
it('mergeRawState() で array に object patch を当てると rejection になる', async () => {
|
|
119
|
+
const { ctx } = makeCtx();
|
|
120
|
+
const hooks = createDevHooks(ctx);
|
|
121
|
+
await expect(hooks.mergeRawState({ tags: { 0: 'oops' } })).rejects.toThrow(/refusing to merge a plain object into array field/);
|
|
122
|
+
});
|
|
123
|
+
it('ctx.mergeRawState 未提供だと hooks.mergeRawState は undefined', () => {
|
|
124
|
+
const { ctx } = makeCtx({ mergeRawState: undefined });
|
|
125
|
+
const hooks = createDevHooks(ctx);
|
|
126
|
+
expect(hooks.mergeRawState).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
// ─── patchRawState (RFC 6902 json patch) ─────────
|
|
129
|
+
it('patchRawState() は path-based ops を順に適用する', async () => {
|
|
130
|
+
const { ctx, state } = makeCtx();
|
|
131
|
+
const hooks = createDevHooks(ctx);
|
|
132
|
+
expect(hooks.patchRawState).toBeDefined();
|
|
133
|
+
await hooks.patchRawState([
|
|
134
|
+
{ op: 'replace', path: '/game/currentPhaseId', value: 'phase_b' },
|
|
135
|
+
{ op: 'replace', path: '/players/p1/ready', value: true },
|
|
136
|
+
{ op: 'add', path: '/tags/-', value: 'extra' },
|
|
137
|
+
]);
|
|
138
|
+
expect(state.current.game.currentPhaseId).toBe('phase_b');
|
|
139
|
+
expect(state.current.players.p1.ready).toBe(true);
|
|
140
|
+
expect(state.current.tags).toEqual(['initial', 'extra']);
|
|
141
|
+
});
|
|
142
|
+
it('ctx.patchRawState 未提供だと hooks.patchRawState は undefined', () => {
|
|
143
|
+
const { ctx } = makeCtx({ patchRawState: undefined });
|
|
144
|
+
const hooks = createDevHooks(ctx);
|
|
145
|
+
expect(hooks.patchRawState).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
it('tick 制御: ctx で提供されると hooks に wire される', () => {
|
|
148
|
+
const pauseTick = vi.fn();
|
|
149
|
+
const resumeTick = vi.fn();
|
|
150
|
+
const stepTick = vi.fn();
|
|
151
|
+
const { ctx } = makeCtx({
|
|
152
|
+
pauseTick,
|
|
153
|
+
resumeTick,
|
|
154
|
+
stepTick,
|
|
155
|
+
getCurrentTick: () => 42,
|
|
156
|
+
isTickPaused: () => true,
|
|
157
|
+
});
|
|
158
|
+
const hooks = createDevHooks(ctx);
|
|
159
|
+
hooks.pauseTick();
|
|
160
|
+
hooks.resumeTick();
|
|
161
|
+
hooks.stepTick(3);
|
|
162
|
+
expect(pauseTick).toHaveBeenCalledOnce();
|
|
163
|
+
expect(resumeTick).toHaveBeenCalledOnce();
|
|
164
|
+
expect(stepTick).toHaveBeenCalledWith(3);
|
|
165
|
+
expect(hooks.getCurrentTick()).toBe(42);
|
|
166
|
+
expect(hooks.isTickPaused()).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
it('tick 制御: ctx 未提供だと hooks 側でも undefined のまま', () => {
|
|
169
|
+
const { ctx } = makeCtx(); // pauseTick 等を渡さない
|
|
170
|
+
const hooks = createDevHooks(ctx);
|
|
171
|
+
expect(hooks.pauseTick).toBeUndefined();
|
|
172
|
+
expect(hooks.resumeTick).toBeUndefined();
|
|
173
|
+
expect(hooks.stepTick).toBeUndefined();
|
|
174
|
+
expect(hooks.getCurrentTick).toBeUndefined();
|
|
175
|
+
expect(hooks.isTickPaused).toBeUndefined();
|
|
176
|
+
});
|
|
177
|
+
it('reset: ctx で提供されると hooks に wire され opts がそのまま渡る', () => {
|
|
178
|
+
const reset = vi.fn();
|
|
179
|
+
const { ctx } = makeCtx({ reset });
|
|
180
|
+
const hooks = createDevHooks(ctx);
|
|
181
|
+
expect(hooks.reset).toBeDefined();
|
|
182
|
+
hooks.reset();
|
|
183
|
+
expect(reset).toHaveBeenCalledWith(undefined);
|
|
184
|
+
hooks.reset({ seed: 7 });
|
|
185
|
+
expect(reset).toHaveBeenLastCalledWith({ seed: 7 });
|
|
186
|
+
hooks.reset({ seed: 'random' });
|
|
187
|
+
expect(reset).toHaveBeenLastCalledWith({ seed: 'random' });
|
|
188
|
+
});
|
|
189
|
+
it('reset: ctx 未提供だと hooks.reset は undefined', () => {
|
|
190
|
+
const { ctx } = makeCtx();
|
|
191
|
+
const hooks = createDevHooks(ctx);
|
|
192
|
+
expect(hooks.reset).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
it('subscribeSnapshot: notify を listen し unsubscribe 後は呼ばれない', () => {
|
|
195
|
+
const { ctx, state, notify } = makeCtx();
|
|
196
|
+
const hooks = createDevHooks(ctx);
|
|
197
|
+
const trace = [];
|
|
198
|
+
const unsub = hooks.subscribeSnapshot((s) => {
|
|
199
|
+
trace.push(s.game.currentPhaseId);
|
|
200
|
+
});
|
|
201
|
+
state.current.game.currentPhaseId = 'phase_b';
|
|
202
|
+
notify(state.current);
|
|
203
|
+
state.current.game.currentPhaseId = 'phase_c';
|
|
204
|
+
notify(state.current);
|
|
205
|
+
expect(trace).toEqual(['phase_b', 'phase_c']);
|
|
206
|
+
unsub();
|
|
207
|
+
state.current.game.currentPhaseId = 'phase_d';
|
|
208
|
+
notify(state.current);
|
|
209
|
+
expect(trace).toEqual(['phase_b', 'phase_c']);
|
|
210
|
+
});
|
|
211
|
+
it('subscribeEvents: ctx で提供されると hooks に wire される', () => {
|
|
212
|
+
const subscribeEvents = vi.fn(() => () => { });
|
|
213
|
+
const { ctx } = makeCtx({ subscribeEvents });
|
|
214
|
+
const hooks = createDevHooks(ctx);
|
|
215
|
+
expect(hooks.subscribeEvents).toBeDefined();
|
|
216
|
+
const cb = (_evts) => { };
|
|
217
|
+
hooks.subscribeEvents(cb);
|
|
218
|
+
expect(subscribeEvents).toHaveBeenCalledWith(cb);
|
|
219
|
+
});
|
|
220
|
+
it('subscribeEvents: ctx 未提供だと hooks.subscribeEvents は undefined', () => {
|
|
221
|
+
const { ctx } = makeCtx();
|
|
222
|
+
const hooks = createDevHooks(ctx);
|
|
223
|
+
expect(hooks.subscribeEvents).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
it('subscribeEvents: unsubscribe 関数がそのまま戻る', () => {
|
|
226
|
+
const unsubscribe = vi.fn();
|
|
227
|
+
const subscribeEvents = vi.fn(() => unsubscribe);
|
|
228
|
+
const { ctx } = makeCtx({ subscribeEvents });
|
|
229
|
+
const hooks = createDevHooks(ctx);
|
|
230
|
+
const off = hooks.subscribeEvents(() => { });
|
|
231
|
+
off();
|
|
232
|
+
expect(unsubscribe).toHaveBeenCalledOnce();
|
|
233
|
+
});
|
|
234
|
+
it('waitForSnapshot: predicate が即時 true なら現在 snapshot で resolve', async () => {
|
|
235
|
+
const { ctx } = makeCtx();
|
|
236
|
+
const hooks = createDevHooks(ctx);
|
|
237
|
+
const result = await hooks.waitForSnapshot((s) => s.game.currentPhaseId === 'phase_a');
|
|
238
|
+
expect(result.game.currentPhaseId).toBe('phase_a');
|
|
239
|
+
});
|
|
240
|
+
it('waitForSnapshot: predicate が false なら subscribe で待ち、notify で resolve', async () => {
|
|
241
|
+
const { ctx, state, notify } = makeCtx();
|
|
242
|
+
const hooks = createDevHooks(ctx);
|
|
243
|
+
const promise = hooks.waitForSnapshot((s) => s.game.currentPhaseId === 'phase_b');
|
|
244
|
+
state.current.game.currentPhaseId = 'phase_b';
|
|
245
|
+
notify(state.current);
|
|
246
|
+
const result = await promise;
|
|
247
|
+
expect(result.game.currentPhaseId).toBe('phase_b');
|
|
248
|
+
});
|
|
249
|
+
it('waitForSnapshot: timeoutMs 経過で reject する', async () => {
|
|
250
|
+
const { ctx, state } = makeCtx();
|
|
251
|
+
// 初期 snapshot は phase_a なので、phase_b 待ちはタイムアウトする
|
|
252
|
+
state.current.game.currentPhaseId = 'phase_a';
|
|
253
|
+
const hooks = createDevHooks(ctx);
|
|
254
|
+
await expect(hooks.waitForSnapshot((s) => s.game.currentPhaseId === 'phase_b', {
|
|
255
|
+
timeoutMs: 50,
|
|
256
|
+
})).rejects.toThrow(/timed out/);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
describe('attachDevHooks', () => {
|
|
260
|
+
afterEach(() => {
|
|
261
|
+
delete window.__uzu_dev;
|
|
262
|
+
});
|
|
263
|
+
it('window.__uzu_dev に hooks を attach する', () => {
|
|
264
|
+
const state = makeState();
|
|
265
|
+
attachDevHooks({
|
|
266
|
+
getSnapshot: () => state,
|
|
267
|
+
playerId: () => 'p1',
|
|
268
|
+
sendAction: vi.fn(),
|
|
269
|
+
subscribeSnapshot: () => () => { },
|
|
270
|
+
});
|
|
271
|
+
expect(window.__uzu_dev).toBeDefined();
|
|
272
|
+
expect(window.__uzu_dev.playerId()).toBe('p1');
|
|
273
|
+
});
|
|
274
|
+
it('複数回 attach すると最後のものに置き換わる (silently, no warn)', () => {
|
|
275
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
276
|
+
attachDevHooks({
|
|
277
|
+
getSnapshot: () => 'first',
|
|
278
|
+
playerId: () => 'first_id',
|
|
279
|
+
sendAction: vi.fn(),
|
|
280
|
+
subscribeSnapshot: () => () => { },
|
|
281
|
+
});
|
|
282
|
+
attachDevHooks({
|
|
283
|
+
getSnapshot: () => 'second',
|
|
284
|
+
playerId: () => 'second_id',
|
|
285
|
+
sendAction: vi.fn(),
|
|
286
|
+
subscribeSnapshot: () => () => { },
|
|
287
|
+
});
|
|
288
|
+
expect(window.__uzu_dev.playerId()).toBe('second_id');
|
|
289
|
+
expect(window.__uzu_dev.getSnapshot()).toBe('second');
|
|
290
|
+
// init() -> run() の順で 2 回 attach されるのは正常フローなので warn を出さない契約
|
|
291
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
292
|
+
warnSpy.mockRestore();
|
|
293
|
+
});
|
|
294
|
+
});
|