テーマ
Dev Hooks(window.__uzu_dev)
外部 automation(Playwright / AI agent / E2E test)が SDK が動いている iframe の state を決定論的に読み書きするための API。
button click + sleep + 状態 polling の fragile な手段を排除し、state 書換 API や waitForSnapshot で状態を完全同期できる。
SDK は scenario の state shape を知らない generic primitive だけを提供する。特定 field path の読み書き、phase 遷移時の field reset、特定 action 名のラッパー (markReady 等) のような scenario 固有の helper は scenario 側で window.__<scene>_dev を生やして組み立てる(後述)。
いつ attach されるか
__uzu_dev の実体はモードによって 2 系統ある:
uzu devharness の親 frame — CLI が admin channel (/dev/adminWebSocket) 経由で Node.js 側 dev-server の authoritative state (GameRoom/SyncRoom) を読み書きする remote proxy を attach する。full API (write 3 兄弟 /send/ events / tick 制御 /reset) はここ。run()だけでなくsync()も Node 側SyncRoomが authoritative cache を持つので read / write できる (send/ events / tick 制御 /resetはGameRoom=run()専用)- SDK が動く frame (dev harness の子 iframe / 単独 page / Playwright iframe) — SDK が read / subscribe 中心の hooks を attach する。write 3 兄弟はローカルモード (
runLocalServerActionソロ / sync ローカル単独 frame) のときだけ生える - Flutter native (
window.FlutterHost) では一切 attach しない - online ServerAction は state が Worker DO 側なので状態書換 API は不可、read 系のみ
Playwright / E2E は基本 page.mainFrame() (= harness 親 frame) を見れば良い。
汎用
isHosted(!!window.FlutterHost || window.parent !== window) は子 iframe も真になるため、dev hooks の本番判定にはwindow.FlutterHostの有無だけ使う。
API
ts
interface UzuDevHooks<S = unknown> {
// ─── Read ──────────────────────────────
getSnapshot(): unknown;
getRawState(): S | null;
playerId(): string | null;
// ─── Write ─────────────────────────────
send?(args: { as: string; type: string; payload?: Record<string, unknown> }): Promise<void>;
setRawState?(state: S): Promise<void>;
mergeRawState?(patch: JsonMergePatch<S>): Promise<void>;
patchRawState?(ops: JsonPatchOp[]): Promise<void>;
// ─── Listen ────────────────────────────
subscribeSnapshot(cb: (snapshot: unknown) => void): () => void;
// ─── Wait ──────────────────────────────
waitForSnapshot(
predicate: (snapshot: unknown) => boolean,
options?: { timeoutMs?: number },
): Promise<unknown>;
// ─── Events ────────────────────────────
subscribeEvents?(cb: (events: readonly ServerEvent[]) => void): () => void;
// ─── Tick 制御 ─────────────────────────
pauseTick?(): void;
resumeTick?(): void;
stepTick?(n?: number): void;
// ─── Reset ─────────────────────────────
reset?(opts?: { seed?: number | 'random' }): void;
}Read
getSnapshot(): authoritative raw state を返す。harness 親 frame では admin channel の常時 snapshot 購読で温めた最新値 (JSON 経由の copy) が返り、接続直後の初回 snapshot 到着前はnull。決定論的に待つならwaitForSnapshotを使うgetRawState(): 生の server-side state (dev/local のみ。online ではnull)。harness 親 frame ではgetSnapshot()と同じ値playerId(): 現在の player ID (harness 親 frame はnull、子 iframe では自分の ID)
Write: state 書換 3 API
state を書き換える API は用途別に 3 つに分かれている。間違うとデータが破壊されるので 使い分け表 を必ず確認:
| 用途 | API | 規格 | 例 |
|---|---|---|---|
| dump した state を丸ごと流し込み (bug 再現 / fixture 復元) | setRawState(state) | — | await d.setRawState(jsonFromBugReport) |
| object 階層の特定 field だけ書換 (array に触れない) | mergeRawState(patch) | RFC 7396 風 | await d.mergeRawState({ game: { timerEndsAt: null } }) |
| array 要素単体の書換 / move / 構造変更 | patchRawState(ops) | RFC 6902 | await d.patchRawState([{ op: 'replace', path: '/board/1/4', value: 99 }]) |
3 つとも dev/local mode でしか生えない (uzu dev harness 親 frame の proxy、またはローカル単独 frame。online ServerAction では undefined)。3 つすべて Promise<void> を返し、await すると state 更新 + broadcast 投函まで待つ (子 iframe canvas paint は含まない、後述)。
1. setRawState(state: S): 全置換
logic.setup で生成された initial state と同形の値を流し込む。dump 復元や bug 再現に使う。
ts
const dump = await page.mainFrame().evaluate(() => window.__uzu_dev.getRawState());
// ...重要な分岐に来たら同じ state から再開できる
await page.mainFrame().evaluate((s) => window.__uzu_dev.setRawState(s), dump);2. mergeRawState(patch: JsonMergePatch<S>): RFC 7396 風 Merge Patch
object 階層の部分更新。undefined は no-op、null は明示セット (RFC 7396 strict は null = delete だが本実装は「null セット」を採用、削除は patchRawState の remove op を使う)。
array は atomic replace のみ で要素単位 merge はできない。array に non-array object を当てると 即 throw する (array が pure object に化ける silent な破壊を構造的に防止)。
ts
// 1 フィールドだけ書き換え (object 階層、array 無関係)
await window.__uzu_dev.mergeRawState({ game: { timerEndsAt: null } });
// 複数 player の状態を一括書換 (recursive merge)
await window.__uzu_dev.mergeRawState({
players: { dev_0: { ready: true }, dev_1: { ready: true } },
});
// array は丸ごと差し替えしか許されない (要素単位 merge 不可)
await window.__uzu_dev.mergeRawState({ board: fullBoard9x9 });
// 以下は throw する (array が pure object に化けるのを防ぐ)
await window.__uzu_dev.mergeRawState({ board: { 1: { 4: 99 } } });
// → TypeError: refusing to merge a plain object into array field "board"3. patchRawState(ops: JsonPatchOp[]): RFC 6902 JSON Patch
JSON Pointer (/board/1/4) で深い path を指定し、add / remove / replace / move / copy / test の 6 op を順番に適用する。array 要素単体の書換が可能。
ts
// 将棋: 王を 5九 → 5八 へ移動 (= board の 2 cell を replace + currentTurn 交代)
await window.__uzu_dev.patchRawState([
{ op: 'replace', path: '/board/8/4', value: 0 },
{ op: 'replace', path: '/board/7/4', value: 1 },
{ op: 'replace', path: '/currentTurn', value: 'dev_1' },
]);
// array に append / insert
await window.__uzu_dev.patchRawState([
{ op: 'add', path: '/log/-', value: 'extra-entry' }, // 末尾
{ op: 'add', path: '/log/0', value: 'head-entry' }, // 0 番目に insert
]);
// field を削除 (RFC 7396 で null セットしかできないので、削除はこっち)
await window.__uzu_dev.patchRawState([{ op: 'remove', path: '/scenario' }]);
// test op で「想定通りの state か」確認してから書き換える (失敗時は throw + 以降の op は skip)
await window.__uzu_dev.patchRawState([
{ op: 'test', path: '/currentTurn', value: 'dev_0' },
{ op: 'replace', path: '/currentTurn', value: 'dev_1' },
]);op 6 種の挙動 (RFC 6902 spec):
| op | 必須 field | 挙動 |
|---|---|---|
add | path, value | object に key 追加 / array index に insert / path: '/arr/-' で末尾 append |
remove | path | object key 削除 / array index 削除 |
replace | path, value | 既存 path を value で置換 (存在しない path は throw) |
move | from, path | from から path へ移動 (from 側は消える)。from が path の真の prefix だと throw |
copy | from, path | from の値を deep clone して path に置く |
test | path, value | path の値が value と deep equal でなければ throw (アサーション用) |
JSON Pointer のエスケープは ~0 = ~ / ~1 = / (例: key with / を含む path は /key with ~1)。
ops は順番に評価する。途中で 1 op が失敗するとその場で throw し、それ以降の op は実行されない (RFC 6902 spec、atomic ではない)。 ロールバックは行わないので、atomic にしたいなら caller が事前に snapshot しておく。
Caveat — await d.*RawState(...) が待たないもの
- 子 iframe canvas の paint 完了は含まれない。screenshot / visual e2e test では
await page.evaluate(() => new Promise(r => requestAnimationFrame(r)))等を別途挟むこと - 子 iframe 側の
onStatecallback の同期呼び出しは broadcastState の microtask 跨ぎで発火する。state 反映を呼び出し側 frame のgetRawState()で確認するなら問題ない (server 側 state は handler resolve 時点で確定済み)
親 frame 以外で state 書換ができないケース
非 parent モード (online / dev harness の子 iframe など) では 3 API すべて undefined で生えない。scenario 側で書き換えが必要なら、window.__<scene>_dev 経由で scenario 固有の API を生やすか、__uzu.sendAction で正規の action 経路を使う。
send: server-side action dispatch
send({ as, type, payload? }) は server-side で action handler を直接 dispatch する。
- harness 親 frame のみ で有効 (
run()=GameRoomに対して。sync()/ 他モードでは不可) asは 必須 — server-side のfromをこの値で固定する (バリデーションなし、未登録 ID で投げて action handler に弾かせる運用も可)- host gate / validation は通常通り通る
Promise<void>を返す。awaitすると以下が完了するまで待つ:- (a) action handler の resolve (serverOnly handler なら最後の
awaitまで) - (b) parent state の
broadcastState投函 (子 iframe への postMessage 投函) - handler が
throwした場合は Promise が rejection になる - rejection 時は broadcastState は skip される (失敗 action の途中 state は子に流さない)
- (a) action handler の resolve (serverOnly handler なら最後の
js
// turn-based: await すれば各手が反映されるまで同期できる
await window.__uzu_dev.send({
as: 'dev_0',
type: 'move.piece',
payload: { from: 'a1', to: 'a2' },
});
await window.__uzu_dev.send({
as: 'dev_1',
type: 'move.piece',
payload: { from: 'h7', to: 'h6' },
});
// illegal move の rejection 検証 (E2E test)
await expect(
window.__uzu_dev.send({
as: 'dev_0',
type: 'move.piece',
payload: {/* 相手の手番 */},
}),
).rejects.toThrow('Not your turn');harness 親 frame 以外 (runLocal ソロ / online) では __uzu_dev.send は生えない (= undefined)。sync() は action の概念自体がないので使えない。それらのモードで action を投げたい時は scenario 側の bridge (window.__uzu.sendAction) や実 UI 操作を使う。
Listen
subscribeSnapshot(cb) は snapshot 更新を event-driven に listen する。返り値は unsubscribe 関数。
waitForSnapshot は「特定条件まで待つ」、subscribeSnapshot は「state 変化を全て拾う」。trace test、deterministic record の記録、sequence assert などに使う canonical な listener。
js
const trace = [];
const unsub = window.__uzu_dev.subscribeSnapshot((s) => {
trace.push({ moveCount: s.moveCount, lastMove: s.lastMove });
});
await window.__uzu_dev.send({ as: 'dev_0', type: 'move', payload: {/* ... */} });
await window.__uzu_dev.send({ as: 'dev_1', type: 'move', payload: {/* ... */} });
unsub();
expect(trace).toEqual([
{ moveCount: 1, lastMove: {/* ... */} },
{ moveCount: 2, lastMove: {/* ... */} },
]);harness 親 frame では cb に admin channel 経由の snapshot (JSON deserialize された copy) が渡る。mutate してもサーバー側 state には反映されないので、書き換えは write 3 兄弟を使う。
Wait
waitForSnapshot(predicate, { timeoutMs }) は predicate(snapshot) === true まで onState を listen。default 10 秒で timeout reject、即時 true ならその場で resolve。
js
await window.__uzu_dev.waitForSnapshot((s) => s.self.isReady === true, {
timeoutMs: 5000,
});Events
subscribeEvents(cb) は action handler / logic.update が emit(name, data) で発火した event を listen する。callback には 1 dispatch / 1 tick 内で発火した event を batch (配列) で渡し、空 batch (= その action / tick で 1 件も emit しなかった) のときは callback を呼ばない。unsubscribe 関数を返す。
derived state (getSnapshot()) には乗らない raw payload — 例えば shogi の gameover event の reason: 'timeout' vs 'checkmate' — を assert したい場合に使う。config.events callback とは独立経路で、scenario callback の throw も subscriber 通知を止めない。
attach される場所: run() の dev harness 親 frame のみ (= state owner 側で emit を観測している)。それ以外のモードでは undefined なので optional chain で safe に呼ぶ。
js
// 王取り move で gameover を誘発し、reason まで含めて検証する
const events = [];
const unsub = window.__uzu_dev.subscribeEvents?.((evts) => events.push(...evts));
await window.__uzu_dev.send({
as: 'dev_0',
type: 'move.piece',
payload: { from: [1, 4], to: [0, 4] },
});
unsub?.();
expect(events).toContainEqual({
name: 'gameover',
data: { winner: 'dev_0', reason: 'checkmate' },
});Caveat:
- 1 回の action handler で複数 emit すると 1 batch で渡る (= shogi の
phase遷移 emit とgameoveremit が同 frame 発火しても 1 callback) - handler が throw した action では subscriber は呼ばれない (= broadcast skip と同じ。失敗 action の途中 emit を流出させない)
- subscriber に渡る配列は frozen + 型は
readonly。subscriber 側 mutation は他 subscriber に影響しない - subscriber 内 throw は warn で握りつぶす (subscribeSnapshot と同じ pattern)
Tick 制御
uzu dev の dev-server は Node.js 側で setInterval を回して logic.update を tick 駆動するため、debug 中も時間駆動の state (shogi の持ち時間、カウントダウン、アニメ進行など) が勝手に進んでしまう。tick 制御 API で止められる:
pauseTick()— tick loop を pause。logic.updateが走らなくなり broadcast も skip されるresumeTick()— pauseTick を解除stepTick(n = 1)— pause 中でも手動で n tick だけ進める (決定論的 step-through 用)
tick 値や pause 状態を harness 親 frame から同期的に読む API はない (admin channel 越しの取得は非同期になり、同期シグネチャだと嘘の初期値を返すため提供しない)。状態の観測は subscribeSnapshot / waitForSnapshot で行う。
pause 中も以下は通る (action handler と state 書換は tick とは別ループ):
__uzu_dev.send(...)(= server-side action dispatch)__uzu_dev.setRawState(...)/mergeRawState(...)/patchRawState(...)(broadcast 付き)
時間切れ系の挙動を序盤 tick から狙って test したいときは、harness を開いた直後に pauseTick() → write 3 兄弟で狙いの state を仕込む → stepTick() で 1 tick ずつ進める (旧実装の ?__dev_paused=1 URL param は廃止)。
js
// debug 中の自動 advance を止める
window.__uzu_dev.pauseTick();
// 任意のタイミングで 1 tick だけ進める
window.__uzu_dev.stepTick();
// state を巻き戻してから再開
await window.__uzu_dev.mergeRawState({ game: { timerEndsAt: futureTs } });
window.__uzu_dev.resumeTick();効くケース / 効かないケース:
| 対象 | 挙動 |
|---|---|
logic.update 内のカウントダウン / アニメ進行 | ✅ 止まる |
pause 中の send (action dispatch) | ✅ 即時 dispatch |
pause 中の setRawState / mergeRawState / patchRawState | ✅ 反映 + broadcast |
tickRate: 0 の純 action-driven scenario | ⚠️ pause/resume/step は no-op + warn |
scenario 内で Date.now() を直接参照 | ❌ 止まらない (scenario 側で tick 由来の時計を使うこと) |
有効な場所: harness 親 frame から run() (= GameRoom) に対してのみ有効。sync() は tick loop を持たないので no-op。子 iframe / online ServerAction では undefined。Scenario helper では optional chain で safe に呼ぶ:
ts
window.__uzu_dev?.pauseTick?.();Reset
reset({ seed? }) は logic.setup(seats, random) を再実行して dev-server の GameRoom を fresh start させる。e2e test の beforeEach で「ゲームを完全に最初から始め直す」用途を想定。
seed省略 → 現在の seed を再利用 (= 決定論的同一初期化、mergeRawStateで再現できない random initial 配置もそのまま戻る)seedに数値 → その seed で再初期化seed: 'random'→ 新 seed (uint32) を生成
tick は 0 に戻り、subscriber / 子 iframe に新 state が broadcast される。pause は解除される (reset = 新規ゲーム開始 = 実行可能状態)。初期 state で止めた状態から始めたいなら reset() 直後に pauseTick() を呼ぶ。
現在の seed を読む API はないので、再現性が必要な test では seed を明示指定する:
js
// e2e: 毎テストで決定論的にやり直す
test.beforeEach(async ({ page }) => {
await page.mainFrame().evaluate(() => window.__uzu_dev.reset({ seed: 42 }));
});
// 別 seed に切替
window.__uzu_dev.reset({ seed: 100 });
// 新 seed をランダムに生成 (バリエーション試行)
window.__uzu_dev.reset({ seed: 'random' });Caveat: logic.setup が fetch 等の副作用を持つ場合、reset で副作用が再発火する。mergeRawState / patchRawState で部分だけ巻き戻せる場合はそちらを優先。
state 書換 API との使い分け:
| 用途 | API |
|---|---|
| 既知 shape の特定 field だけ巻き戻す | mergeRawState (object) / patchRawState (array index) |
logic.setup 内の random initial 配置を含めて完全に initial へ戻す | reset |
| seed を変えて別バリエーションを試す | reset({ seed }) |
| ページ全体を作り直す (重い、Playwright の race あり) | page.goto(...) |
有効な場所: harness 親 frame から run() (= GameRoom) のみ。それ以外では効かない。
Scenario 側 helper の書き方
phase 遷移時の field reset (dialogueLineIndex / timerEndsAt / activeOverlay 等) や、player.ready.set のような特定 action 名のラッパーは scenario の state shape / action 名を知っている立場で書く。SDK の primitive を組み合わせて window.__<scene>_dev を生やす:
ts
// scenarios/<name>/src/lib/dev-helpers.ts
const dev = (): UzuDevHooks | null => (window as any).__uzu_dev ?? null;
export const sceneDev = {
// send / mergeRawState は Promise を返す。caller が await できるよう
// helper も Promise<void | undefined> を流すだけ。
markReady: (as: string) =>
dev()?.send?.({ as, type: 'player.ready.set', payload: { isReady: true } }),
goToPhase: (id: string) =>
dev()?.mergeRawState?.({
status: 'in_progress',
game: {
currentPhaseId: id,
dialogueLineIndex: 0,
timerEndsAt: null,
activeOverlay: null,
},
}),
phaseId: () => (dev()?.getSnapshot() as any)?.room?.game?.currentPhaseId ?? null,
};
if (import.meta.env.DEV) {
(window as any).__scene_dev = sceneDev;
}これで SDK は state shape の仮定ゼロ、scenario は自分の state を知っている立場で書けるので壊れない。reversi / shogi 等の他 scenario も自分用 helper を独立に持てる。
Use Cases
uzu dev harness では全部 page.mainFrame() (= 親 frame) で OK。
js
// visual regression 安定化: phase に飛ばして mergeRawState の resolve + paint を待つ
await page.mainFrame().evaluate(async () => {
await window.__uzu_dev.mergeRawState({
status: 'in_progress',
game: { currentPhaseId: 'phase_discussion_panorama_1' },
});
// mergeRawState の resolve は parent state + broadcast 投函まで。
// 子 iframe の paint まで待ちたいので rAF を 1 段挟む
await new Promise((r) => requestAnimationFrame(r));
});
await page.screenshot({ path: 'discussion-panorama-1.png' });
// 将棋の局面を構築 (array 要素単体の書換)
await page.mainFrame().evaluate(async () => {
await window.__uzu_dev.patchRawState([
{ op: 'replace', path: '/board/8/4', value: 0 }, // 王の元位置を空に
{ op: 'replace', path: '/board/7/4', value: 1 }, // 王を 1 マス前に
{ op: 'replace', path: '/currentTurn', value: 'dev_1' },
]);
});
// E2E test の sleep 排除: send が await できるので setTimeout が不要
await page.mainFrame().evaluate(async () => {
await window.__uzu_dev.send({ as: 'dev_0', type: 'player.ready.set' });
// この時点で server 側 state は確定済み
});
// illegal move を構造的に検証
// expect は Playwright のテストランナー側 API なので evaluate() の外に置く
// (evaluate 内はブラウザコンテキストで expect が undefined)
await expect(
page.mainFrame().evaluate(() =>
window.__uzu_dev.send({
as: 'dev_1',
type: 'move.piece',
payload: {/* 相手の手番 */},
}),
),
).rejects.toThrow('Not your turn');per-player view が欲しい場合は scenario の onState 内で derive している view を子 iframe の window.__<scene>_dev で expose する設計を取る (SDK 自体は raw state しか持たないため)。
制約
- 本番では一切 attach されないので Flutter 側 debug には使えない
- online ServerAction は state が Worker DO 側、
setRawState/mergeRawState/patchRawState不可 (read とsendのみ) uzu devのsync()は Node 側SyncRoomが authoritative cache を持つので、harness 親 frame から read / write 3 兄弟が使える。send/ events / tick 制御 /resetはrun()専用