Skip to content

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 dev harness の親 frame — CLI が admin channel (/dev/admin WebSocket) 経由で 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 制御 / resetGameRoom = 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 7396await d.mergeRawState({ game: { timerEndsAt: null } })
array 要素単体の書換 / move / 構造変更patchRawState(ops)RFC 6902await 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 セット」を採用、削除は patchRawStateremove 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挙動
addpath, valueobject に key 追加 / array index に insert / path: '/arr/-' で末尾 append
removepathobject key 削除 / array index 削除
replacepath, value既存 path を value で置換 (存在しない path は throw)
movefrom, pathfrom から path へ移動 (from 側は消える)。from が path の真の prefix だと throw
copyfrom, pathfrom の値を deep clone して path に置く
testpath, valuepath の値が 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 側の onState callback の同期呼び出しは 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 は子に流さない)
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.updateemit(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 と gameover emit が同 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.setupfetch 等の副作用を持つ場合、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 devsync() は Node 側 SyncRoom が authoritative cache を持つので、harness 親 frame から read / write 3 兄弟が使える。send / events / tick 制御 / resetrun() 専用