@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.
@@ -0,0 +1,333 @@
1
+ /**
2
+ * `dev-state-patch.ts` の単体テスト。
3
+ *
4
+ * カバー対象:
5
+ * - applyJsonMergePatch: undefined / null / 再帰 merge / array atomic replace /
6
+ * array に object patch を当てる際の throw / target が array の入口でも throw
7
+ * - applyJsonPatch: add / remove / replace / move / copy / test の 6 op、
8
+ * JSON Pointer のエスケープ (~0 / ~1)、array index と `-` (append)、
9
+ * 存在しない path や不正な op の throw、root (`''`) replace
10
+ */
11
+ import { describe, expect, it } from 'vitest';
12
+ import { applyJsonMergePatch, applyJsonPatch } from './dev-state-patch.js';
13
+ function makeState() {
14
+ return {
15
+ game: { currentPhaseId: 'phase_a', timerEndsAt: 1000 },
16
+ players: {
17
+ p1: { ready: false, name: 'Alice' },
18
+ p2: { ready: false, name: 'Bob' },
19
+ },
20
+ scenario: { id: 'scenario_1' },
21
+ tags: ['initial'],
22
+ board: [
23
+ [0, 0, 0],
24
+ [0, 0, 0],
25
+ [0, 0, 0],
26
+ ],
27
+ };
28
+ }
29
+ describe('applyJsonMergePatch (RFC 7396 風)', () => {
30
+ it('既存 nested フィールドだけを上書きし他のフィールドを保持する', () => {
31
+ const state = makeState();
32
+ applyJsonMergePatch(state, {
33
+ game: { timerEndsAt: null },
34
+ });
35
+ expect(state.game.timerEndsAt).toBeNull();
36
+ expect(state.game.currentPhaseId).toBe('phase_a');
37
+ });
38
+ it('深い path への object patch を recursive merge する', () => {
39
+ const state = makeState();
40
+ applyJsonMergePatch(state, {
41
+ players: { p1: { ready: true } },
42
+ });
43
+ expect(state.players.p1.ready).toBe(true);
44
+ expect(state.players.p1.name).toBe('Alice');
45
+ expect(state.players.p2.ready).toBe(false);
46
+ });
47
+ it('undefined は no-op で何もしない', () => {
48
+ const state = makeState();
49
+ applyJsonMergePatch(state, {
50
+ game: { currentPhaseId: undefined },
51
+ });
52
+ expect(state.game.currentPhaseId).toBe('phase_a');
53
+ });
54
+ it('null は明示的に null をセットする (RFC 7396 strict と異なる、deletion はしない)', () => {
55
+ const state = makeState();
56
+ applyJsonMergePatch(state, {
57
+ scenario: null,
58
+ });
59
+ expect(state.scenario).toBeNull();
60
+ // 削除ではないので key は残る
61
+ expect('scenario' in state).toBe(true);
62
+ });
63
+ it('array は要素 merge せずまるごと overwrite する (RFC 7396 spec)', () => {
64
+ const state = makeState();
65
+ applyJsonMergePatch(state, {
66
+ tags: ['new1', 'new2'],
67
+ });
68
+ expect(state.tags).toEqual(['new1', 'new2']);
69
+ });
70
+ it('object → primitive へ型が変わるパスは overwrite する', () => {
71
+ const state = makeState();
72
+ applyJsonMergePatch(state, {
73
+ game: 'replaced',
74
+ });
75
+ expect(state.game).toBe('replaced');
76
+ });
77
+ it('array field に non-array object patch を当てると throw する', () => {
78
+ const state = makeState();
79
+ expect(() => applyJsonMergePatch(state, {
80
+ board: { 1: { 4: 99 } },
81
+ })).toThrow(/refusing to merge a plain object into array field/);
82
+ // throw 後に array が dict 化していないことも確認
83
+ expect(Array.isArray(state.board)).toBe(true);
84
+ });
85
+ it('array field を別の array で atomic replace するのは OK', () => {
86
+ const state = makeState();
87
+ const next = [
88
+ [1, 1, 1],
89
+ [2, 2, 2],
90
+ [3, 3, 3],
91
+ ];
92
+ applyJsonMergePatch(state, {
93
+ board: next,
94
+ });
95
+ expect(state.board).toEqual(next);
96
+ });
97
+ it('target が array (直接呼び出し) でも throw する', () => {
98
+ const arr = [1, 2, 3];
99
+ expect(() => applyJsonMergePatch(arr, { 0: 99 })).toThrow(/cannot deep-merge into an array field/);
100
+ });
101
+ });
102
+ describe('applyJsonPatch (RFC 6902)', () => {
103
+ describe('replace', () => {
104
+ it('object フィールドを replace する', () => {
105
+ const state = makeState();
106
+ applyJsonPatch(state, [
107
+ { op: 'replace', path: '/game/currentPhaseId', value: 'phase_b' },
108
+ ]);
109
+ expect(state.game.currentPhaseId).toBe('phase_b');
110
+ });
111
+ it('array 要素単体を replace する', () => {
112
+ const state = makeState();
113
+ applyJsonPatch(state, [
114
+ { op: 'replace', path: '/board/1/1', value: 99 },
115
+ ]);
116
+ expect(state.board[1][1]).toBe(99);
117
+ // 他の cell は破壊されない
118
+ expect(state.board[0]).toEqual([0, 0, 0]);
119
+ expect(state.board[1][0]).toBe(0);
120
+ expect(Array.isArray(state.board)).toBe(true);
121
+ expect(Array.isArray(state.board[1])).toBe(true);
122
+ });
123
+ it('存在しない object key を replace すると throw', () => {
124
+ const state = makeState();
125
+ expect(() => applyJsonPatch(state, [
126
+ { op: 'replace', path: '/missing', value: 1 },
127
+ ])).toThrow(/cannot replace non-existent path/);
128
+ });
129
+ it('array の range 外 index を replace すると throw', () => {
130
+ const state = makeState();
131
+ expect(() => applyJsonPatch(state, [
132
+ { op: 'replace', path: '/tags/5', value: 'x' },
133
+ ])).toThrow(/out of range/);
134
+ });
135
+ });
136
+ describe('add', () => {
137
+ it('object に新しい key を add する', () => {
138
+ const state = makeState();
139
+ applyJsonPatch(state, [
140
+ { op: 'add', path: '/players/p3', value: { ready: true, name: 'Charlie' } },
141
+ ]);
142
+ expect(state.players.p3).toEqual({ ready: true, name: 'Charlie' });
143
+ });
144
+ it('array に index 指定で insert する', () => {
145
+ const state = makeState();
146
+ applyJsonPatch(state, [
147
+ { op: 'add', path: '/tags/0', value: 'first' },
148
+ ]);
149
+ expect(state.tags).toEqual(['first', 'initial']);
150
+ });
151
+ it('array に `-` で append する', () => {
152
+ const state = makeState();
153
+ applyJsonPatch(state, [
154
+ { op: 'add', path: '/tags/-', value: 'last' },
155
+ ]);
156
+ expect(state.tags).toEqual(['initial', 'last']);
157
+ });
158
+ it('既存 object key を add すると replace と同じく上書きする', () => {
159
+ const state = makeState();
160
+ applyJsonPatch(state, [
161
+ { op: 'add', path: '/game/currentPhaseId', value: 'phase_z' },
162
+ ]);
163
+ expect(state.game.currentPhaseId).toBe('phase_z');
164
+ });
165
+ });
166
+ describe('remove', () => {
167
+ it('object key を remove する', () => {
168
+ const state = makeState();
169
+ applyJsonPatch(state, [
170
+ { op: 'remove', path: '/players/p1' },
171
+ ]);
172
+ expect(state.players.p1).toBeUndefined();
173
+ expect(state.players.p2).toBeDefined();
174
+ });
175
+ it('array element を remove する', () => {
176
+ const state = makeState();
177
+ applyJsonPatch(state, [
178
+ { op: 'add', path: '/tags/-', value: 'extra' },
179
+ { op: 'remove', path: '/tags/0' },
180
+ ]);
181
+ expect(state.tags).toEqual(['extra']);
182
+ });
183
+ it('存在しない path の remove は throw', () => {
184
+ const state = makeState();
185
+ expect(() => applyJsonPatch(state, [
186
+ { op: 'remove', path: '/players/missing' },
187
+ ])).toThrow(/cannot remove non-existent path/);
188
+ });
189
+ it('root 自体の remove は throw', () => {
190
+ const state = makeState();
191
+ expect(() => applyJsonPatch(state, [{ op: 'remove', path: '' }])).toThrow(/cannot remove the root document/);
192
+ });
193
+ });
194
+ describe('move / copy', () => {
195
+ it('move は from から path へ値を移し from は消える', () => {
196
+ const state = makeState();
197
+ applyJsonPatch(state, [
198
+ { op: 'move', from: '/players/p1', path: '/players/p3' },
199
+ ]);
200
+ expect(state.players.p1).toBeUndefined();
201
+ expect(state.players.p3).toEqual({ ready: false, name: 'Alice' });
202
+ });
203
+ it('move で from と path が同じなら no-op', () => {
204
+ const state = makeState();
205
+ const before = JSON.stringify(state);
206
+ applyJsonPatch(state, [
207
+ { op: 'move', from: '/players/p1', path: '/players/p1' },
208
+ ]);
209
+ expect(JSON.stringify(state)).toBe(before);
210
+ });
211
+ it('move で from が path の真の prefix だと throw', () => {
212
+ const state = makeState();
213
+ expect(() => applyJsonPatch(state, [
214
+ { op: 'move', from: '/game', path: '/game/nested' },
215
+ ])).toThrow(/cannot move into own descendant/);
216
+ });
217
+ it('copy は from の値を deep clone して path に置く (参照共有なし)', () => {
218
+ const state = makeState();
219
+ applyJsonPatch(state, [
220
+ { op: 'copy', from: '/players/p1', path: '/players/p3' },
221
+ ]);
222
+ expect(state.players.p3).toEqual(state.players.p1);
223
+ // 別参照なので p3 を変えても p1 は変わらない
224
+ state.players.p3.name = 'Charlie';
225
+ expect(state.players.p1.name).toBe('Alice');
226
+ });
227
+ });
228
+ describe('test', () => {
229
+ it('値が一致すれば no-op', () => {
230
+ const state = makeState();
231
+ applyJsonPatch(state, [
232
+ { op: 'test', path: '/game/currentPhaseId', value: 'phase_a' },
233
+ { op: 'replace', path: '/game/currentPhaseId', value: 'phase_b' },
234
+ ]);
235
+ expect(state.game.currentPhaseId).toBe('phase_b');
236
+ });
237
+ it('値が異なれば throw し以降の op は実行されない', () => {
238
+ const state = makeState();
239
+ expect(() => applyJsonPatch(state, [
240
+ { op: 'test', path: '/game/currentPhaseId', value: 'phase_x' },
241
+ { op: 'replace', path: '/game/currentPhaseId', value: 'phase_b' },
242
+ ])).toThrow(/test failed/);
243
+ expect(state.game.currentPhaseId).toBe('phase_a');
244
+ });
245
+ it('test は deep equality でチェックする (object / array)', () => {
246
+ const state = makeState();
247
+ applyJsonPatch(state, [
248
+ { op: 'test', path: '/tags', value: ['initial'] },
249
+ ]);
250
+ expect(() => applyJsonPatch(state, [
251
+ { op: 'test', path: '/tags', value: ['other'] },
252
+ ])).toThrow(/test failed/);
253
+ });
254
+ });
255
+ describe('JSON Pointer (RFC 6901)', () => {
256
+ it('~1 は `/` にエスケープされる', () => {
257
+ const state = { 'a/b': { c: 1 } };
258
+ applyJsonPatch(state, [{ op: 'replace', path: '/a~1b/c', value: 2 }]);
259
+ expect(state['a/b'].c).toBe(2);
260
+ });
261
+ it('~0 は `~` にエスケープされる', () => {
262
+ const state = { 'a~b': 1 };
263
+ applyJsonPatch(state, [{ op: 'replace', path: '/a~0b', value: 2 }]);
264
+ expect(state['a~b']).toBe(2);
265
+ });
266
+ it('複合エスケープ ~01 は `~1` に戻る (~1 と取り違えない)', () => {
267
+ const state = { '~1': 1 };
268
+ applyJsonPatch(state, [{ op: 'replace', path: '/~01', value: 2 }]);
269
+ expect(state['~1']).toBe(2);
270
+ });
271
+ it('先頭が `/` でない pointer は throw', () => {
272
+ const state = makeState();
273
+ expect(() => applyJsonPatch(state, [
274
+ { op: 'replace', path: 'game/currentPhaseId', value: 'phase_b' },
275
+ ])).toThrow(/must start with "\/"/);
276
+ });
277
+ it('array 内に `-` を `replace` で渡すと throw (RFC 6902: append は add のみ)', () => {
278
+ const state = makeState();
279
+ expect(() => applyJsonPatch(state, [
280
+ { op: 'replace', path: '/tags/-', value: 'x' },
281
+ ])).toThrow(/array append.*only valid for `add`/);
282
+ });
283
+ it('array index は先頭 0 を許容しない (RFC 6901)', () => {
284
+ const state = makeState();
285
+ expect(() => applyJsonPatch(state, [
286
+ { op: 'replace', path: '/tags/01', value: 'x' },
287
+ ])).toThrow(/invalid array index/);
288
+ });
289
+ });
290
+ describe('root (`""`)', () => {
291
+ it('replace で doc を丸ごと差し替える (in-place)', () => {
292
+ const state = makeState();
293
+ const ref = state;
294
+ applyJsonPatch(ref, [
295
+ {
296
+ op: 'replace',
297
+ path: '',
298
+ value: { onlyKey: 'kept' },
299
+ },
300
+ ]);
301
+ expect(Object.keys(ref)).toEqual(['onlyKey']);
302
+ expect(ref.onlyKey).toBe('kept');
303
+ });
304
+ it('root を primitive で replace するのは throw', () => {
305
+ const state = makeState();
306
+ expect(() => applyJsonPatch(state, [
307
+ { op: 'replace', path: '', value: 42 },
308
+ ])).toThrow(/root replace requires a plain object/);
309
+ });
310
+ });
311
+ it('複数 op を順番に適用する (使用例: 将棋の王の移動)', () => {
312
+ const state = {
313
+ board: [
314
+ [1, 0, 0],
315
+ [0, 0, 0],
316
+ [0, 0, 0],
317
+ ],
318
+ currentTurn: 'dev_0',
319
+ };
320
+ applyJsonPatch(state, [
321
+ { op: 'replace', path: '/board/0/0', value: 0 },
322
+ { op: 'replace', path: '/board/2/2', value: 1 },
323
+ { op: 'replace', path: '/currentTurn', value: 'dev_1' },
324
+ ]);
325
+ expect(state.board).toEqual([
326
+ [0, 0, 0],
327
+ [0, 0, 0],
328
+ [0, 0, 1],
329
+ ]);
330
+ expect(state.currentTurn).toBe('dev_1');
331
+ expect(Array.isArray(state.board)).toBe(true);
332
+ });
333
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @docs
3
+ * - SDK仕様: docs/docs/play_screen_v3/play-screen-sdk.md
4
+ * - ブリッジ仕様: docs/docs/play_screen_v3/bridge.md
5
+ * - Relay仕様: docs/docs/play_screen_v3/relay.md
6
+ * - SyncRoom仕様: docs/docs/play_screen_v3/sync-room.md
7
+ * - ゲーム仕様: docs/docs/play_screen_v3/games.md
8
+ * - システム全体像: docs/docs/play_screen_v3/overview.md
9
+ * - AI用APIリファレンス (README): javascript/play_screen_v3/uzuhq-sdk/README.md
10
+ *
11
+ * SDK は runtime のみ。 dev harness の DOM 描画 (iframe grid / HUD / state
12
+ * inspector) と in-memory サーバー (Node ws による GameRoom / SyncRoom / RelayRoom) は
13
+ * `@uzuhq/code-cli` の `uzu dev` サブコマンドに統合されている。 parent frame で
14
+ * `init()` / `run()` / `sync()` を呼んでも何もしない (= noop)。 harness を立てるには
15
+ * scenario ディレクトリで `uzu dev` を実行するだけで良い。 子 iframe は既存 online mode
16
+ * (`?server=ws://localhost:<port>` 経路) で dev-server に接続する。
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';
19
+ export { SERVER_TIME, DEFAULT_ICON_URLS } from './types.js';
20
+ export { serverOnly, isServerOnlyAction } from './server-only.js';
21
+ export { Room } from './room.js';
22
+ export type { RoomLike } from './room.js';
23
+ export { ReconnectableWebSocket } from './reconnectable-ws.js';
24
+ export type { ReconnectableWSOptions } from './reconnectable-ws.js';
25
+ export { SeededRandomImpl } from './random.js';
26
+ export { applyJsonMergePatch, applyJsonPatch } from './dev-state-patch.js';
27
+ export type { JsonMergePatch, JsonPatchOp } from './dev-state-patch.js';
28
+ export { attachDevHooks, createDevHooks } from './dev-hooks.js';
29
+ export type { UzuDevHooks, DevHooksCtx, RunHandle, SyncHandle } from './dev-hooks.js';
30
+ import type { BridgeMessage, GameConfig, SyncConfig, PlayerVoiceState } from './types.js';
31
+ import type { RoomLike } from './room.js';
32
+ type GameMessageHandler = (payload: Record<string, unknown>) => void;
33
+ type PlayersChangedHandler = (players: Record<string, PlayerVoiceState>) => void;
34
+ declare global {
35
+ interface Window {
36
+ __ps?: {
37
+ onMessage: (msg: BridgeMessage) => void;
38
+ };
39
+ FlutterHost?: {
40
+ postMessage: (message: string) => void;
41
+ };
42
+ }
43
+ }
44
+ export declare const isHosted: boolean;
45
+ export declare function getRoom(): RoomLike | null;
46
+ export declare function onRoom(callback: (room: RoomLike) => void): void;
47
+ export declare function init(opts?: {
48
+ wsEndpoint?: string;
49
+ syncEndpoint?: string;
50
+ playerCount?: number;
51
+ orientation?: 'portrait' | 'landscape';
52
+ /**
53
+ * Dev harness の iframe inner viewport 短辺 (CSS px) の下限。default 360。
54
+ * harness 側で参照される値で、 SDK runtime 本体は使わない。
55
+ */
56
+ devMinIframeShortEdge?: number;
57
+ }): void;
58
+ /** game channel でカスタムメッセージを送信する。 native コマンドには使用不可。 */
59
+ export declare function send(type: string, payload?: Record<string, unknown>): void;
60
+ /** game channel のカスタムメッセージを受信する。 */
61
+ export declare function on(type: string, handler: GameMessageHandler): void;
62
+ export declare function playSound(sound: string): void;
63
+ export declare function playBgm(sound: string): void;
64
+ export declare function stopBgm(): void;
65
+ export declare function setMicEnabled(enabled: boolean): void;
66
+ export declare function changeRoom(roomId: string | null): void;
67
+ /**
68
+ * splash 描画完了を Flutter ホストに通知する。
69
+ *
70
+ * YouTube Playables の `firstFrameReady()` 相当。 Flutter は受信時に splash overlay を
71
+ * 非表示にし WebView を可視化する。 **呼ばないと splash が出っぱなしになる**。
72
+ *
73
+ * 呼び出しタイミング: scenario が最初の絵 (splash / loading 画面) を画面に描いた直後。
74
+ * paint commit を保証するため `requestAnimationFrame` を 1 段挟むのが定石。
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * init();
79
+ * drawSplash();
80
+ * requestAnimationFrame(() => firstFrameReady());
81
+ * ```
82
+ */
83
+ export declare function firstFrameReady(): void;
84
+ /**
85
+ * ユーザー操作受付可能を Flutter ホストに通知する。
86
+ *
87
+ * YouTube Playables の `gameReady()` 相当。 Flutter は受信時に入力受付を開始し、TTI
88
+ * (Time To Interactive) 計測を終了する。 将来の広告タイマー / leaderboard / analytics
89
+ * の発火点としても使われる予定。
90
+ *
91
+ * 呼び出しタイミング: asset load 完了、 ゲーム本体の setup 完了、 入力受付可能になった直後。
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * await loadAssets();
96
+ * setupGame();
97
+ * gameReady();
98
+ * ```
99
+ */
100
+ export declare function gameReady(): void;
101
+ /** Flutter からの playersChanged メッセージを受信するハンドラを登録する。 */
102
+ export declare function onPlayersChanged(handler: PlayersChangedHandler): void;
103
+ export declare function run<S>(config: GameConfig<S>): void;
104
+ export declare function sync<S>(config: SyncConfig<S>): void;