開発ツール

✅ 検証ガイド

対象: ひつまぶし向けのゲームを作る人・AI向けのドキュメント。 概要: 「自分のゲームは本当に大丈夫か」に答えるための地図です。3つの手段 (verify / 同梱テスト / 開発ハーネス)がそれぞれ何を保証し、何を保証しないかを 1箇所にまとめています。どれでも確かめられないものは、人間が見るしかありません。


1. 3つの手段と守備範囲

手段何を確かめるか結果の読み方人間が要るか
npx hitsumabushi verifyPortalの要件のうち機械的に判定できるもの終了コード / --json不要
同梱テスト(npm test)試合を進めないと分からないもの(リマッチのリセット漏れ、決着後の上書き)Vitestの結果不要
開発ハーネス(npx hitsumabushi dev)実際の見た目・操作感ブラウザ上の表示のみ必要
手動チェック上のどれも判定できないもの—必要

この3つを全部緑にしても「本番で絶対に壊れない」にはなりません。 各節の「保証しないこと」を読んでください。


2. verify — 機械的に検査する

コマンドの使い方(オプション・CI設定)は cli.md にあります。ここでは結果の読み方を扱います。

✓ manifest.json が仕様を満たしている
✓ 開発ハーネス専用パスを参照していない
✗ overscroll-behavior でスクロールバウンスを抑止している
    html / body に overscroll-behavior: none が見つかりません(モバイルで画面が引っ張られます)
− canvas のタッチ操作が passive: false で登録されている
    canvas を使っていないため該当しません
✓ ゲームが起動し GAME_READY を送っている
⚠ 観戦者が送信系APIを呼んでいない
    観戦クライアントが sendGameData を呼びました。SDKが遮断するため本番でも実害はありませんが、観戦者にも操作UIを出している可能性があります
− 通信制約(サイズ・レート)に収まっている
    3秒間に送信が1件もありませんでした。verify は盤面を操作しないため、操作を契機に送信するゲームではこの項目を判定できません。実際に手を進めるテスト(`npm test`)で確認してください

4 passed, 1 failed, 1 warned, 3 skipped

4つのステータス

意味終了コード
✓ pass要件を満たしている0
⚠ warn実害はないが、想定外の実装になっている0(落とさない)
✗ failそのままデプロイすると本番で壊れる1
− skipこのツールは何も言っていない0

fail にするのは「本番で壊れる」ものだけという基準を守っています。実害のない実装の粗さ(観戦者が送信系APIを呼んでいる、など。SDKが必ず遮断するため本番は壊れません)は warn に置き、終了コードには影響させません。ここを混ぜると「verifyが赤い=本番で壊れる」が偽になり、CIゲートとして信用できなくなるためです。

検査は2種類あります。

  • 静的検査 — エントリHTMLと、そこから参照されているローカルのJS/CSS、そして manifest.json を読む(manifest.json の適合、カード画像 thumbnail.png、SDK の URL の版、ハーネス専用パス /__hitsumabushi_dev__/ の参照、overscroll-behavior、passive: false、ResizeObserver、isTie)
  • 実行時検査 — テストダブル(@milightpartner/hitsumabushi-sdk/testing)で実際にゲームを起動し、観戦者を1人加えて仮想時間を3秒進める(起動と GAME_READY、観戦者が送信系APIを呼んでいないこと、通信制約の超過)

skip を pass と読まないでください

判定できないものを pass にはしません。 適用条件に当てはまらない項目(canvasを使っていないゲームの passive: false など)と、機械的に判定できない項目は、理由付きで skip として報告されます。skip は「問題なし」ではなく「このツールは何も言っていない」という意味です。§5 人間が確認するしかないこと のうち 🤖 が付いていない項目は、引き続き人間が確認してください。

実行時検査には jsdom が必要です(optional peer dependency)。未導入の場合、静的検査の結果はそのまま出したうえで、実行時検査を skip として報告します。

npm install --save-dev jsdom

⚠️ jsdom は 28 以上が必要です。 27以下の window には TextEncoder がないため、SDKが送信サイズを測れず、全ての送信が破棄されます。npx hitsumabushi init は適合するバージョンを package.json に書き出します。

verify が扱わないこと

verify は盤面を操作しません。 起動して仮想時間を3秒進めるだけなので、操作を契機に送信するゲーム(盤面ゲームの大半)では通信が一度も発生しません。この場合 通信制約 は pass ではなく skip になります。通信が成立しているかどうかは、実際に手を進めるテスト(npm test)でしか分かりません。

リマッチ時の状態リセット漏れや、決着後に手番表示が結果表示を上書きしてしまう類の不具合は、試合を実際に進めないと判定できません。任意のゲームのUIを知らないまま外から判定すると必ずヒューリスティックになり、誤検知します。これらは npx hitsumabushi init が同梱するテスト(テストダブルを使ってゲーム自身が書く)の担当です。


3. 同梱テスト — 試合を進めて確かめる

同梱されるテストは、消さずに育ててください

games/<gameId>/game.test.js には、verify が構造的に検査できない項目だけが入っています。

テスト何を防ぐか
両プレイヤーのクライアントが起動する起動時の例外・init() の呼び忘れ
リマッチで画面が初期状態に戻るリセットを onStartMatch ではなく onCreateMatch に書いてしまい、前の試合が残る
決着後に遅れて届いたパケットで結果表示が壊れないonGameData を Hitsumabushi.phase === 'over' でガードし忘れ、結果表示が上書きされる

下の2つは試合を実際に進めないと判定できないため、外から見る verify では扱えません(任意のゲームのUIを知らないまま判定すると必ず誤検知します)。だからゲーム自身が持ちます。

UIの中身に依存しないようDOMのスナップショット比較で書いてあるので、雛形の時点では素通りし、ゲームを実装していくと自動的に意味を持ちます。落ちたときは、直し方がメッセージに出ます。

× リマッチで画面が初期状態に戻る
  → リマッチ後も前の試合の表示が残っています。
    盤面・スコア・タイマーのリセットを onCreateMatch ではなく onStartMatch に移してください

テストダブル (@milightpartner/hitsumabushi-sdk/testing)

ゲームの自動テスト用のテストダブル。ポータル本体・おひつサーバー(ひつサバ)・対戦相手のクライアントを立てずに、対戦フローを検証できる。

npm install -D jsdom   # DOMの実装は jsdom に委譲している(optional peerDependency)
import { createTestRoom } from '@milightpartner/hitsumabushi-sdk/testing';

const room = await createTestRoom({
  entry: 'index.html',
  players: [{ id: 'p1' }, { id: 'p2' }],
  spectators: 1,
});

room.client('p1').click('.cell[data-index="0"]');
await room.settle();

expect(room.client('spectator1').text('.cell[data-index="0"]')).toBe('o');

await room.advanceTime(12_000);   // 仮想時間(実時間では待たない)
await room.rematch();             // INIT_GAME 再注入(iframeはリロードしない)

expect(room.gameOvers()).toEqual([expect.objectContaining({ winnerPlayerId: 'p1' })]);

room.close();

自作のスタブとの違い(このモジュールが存在する理由):

  • 中継の意味論が本番と同一。 配送先・エコー有無・senderId の解決・CPU代理送信の認可は dev-harness/logic.js の planRelay を開発ハーネスと共有している。各自がスタブを書くと「本番と食い違っていてもテストが緑になる」が、それが起きない。
  • 通信制約を実際に執行する。 32KB超過は破棄して ERROR を返し、レート超過は無言で破棄する(開発ハーネスは警告のみで中継を続けるが、テストでは本番挙動側に寄せる)。
  • SDKの保証を再実装しない。 観戦者の送信遮断などをスタブ側で実装し直すと、ゲームが送信APIを呼んでいてもテストが緑になる「空テスト」ができる。代わりに遮断された事実を観測可能にしてある。
  • 本番と同じくページとして読み込む。 ゲームの index.html から読み込むファイル(<script src="game.js"> など、ゲームのディレクトリの中のもの)も、本番と同じ順番で読み込まれます。SDK も、ゲームが書いた URL から本番と同じく取得します。版を固定した URL なら、テストで動くのは本番で読まれるのと同じ SDK です。/ で始まるパスは createTestRoom({ root })(verify では --dir)から解決します。<script type="module"> は jsdom が実行しないため対象外です。
観測API意味
room.attempts()ゲームが送信APIを呼んだ事実(SDKに遮断されたものも含む)
room.relayPackets()実際に中継されたパケット
room.gameOvers()ポータルへ届いた結果
room.denied()相手に届かなかった送信(制約超過・認可違反)。source が 'sdk' ならSDKがローカルで遮断、'portal' ならポータル側で破棄
room.errors()ダブルがゲームへ返した ERROR
room.matchState()進行役が登録した権威状態

その他の操作: room.addSpectator()(試合中の途中参加)、room.setObsMode(bool)、room.advanceTime(ms)、room.now()、room.matchNumber()。

createTestRoom({ matchNumber }) で、2試合目以降から始まる部屋を作れます。同梱テストの「リマッチで画面が初期状態に戻る」は、リマッチ後の画面をこの部屋と比べるので、先手の交代など試合番号で変わる表示があっても正しく判定できます。

jsdom に無いもの(ResizeObserver と canvas)

jsdom はレイアウトエンジンと描画エンジンを持たないため、ResizeObserver と canvas の2Dコンテキスト(getContext('2d'))を実装していません。テストダブルは、どちらも最小の実装を差し込みます。ガイドラインが盤面の実測フィットに ResizeObserver を求めている以上、また canvas で描くゲームがある以上、それらがテストで起動できないのは筋が通らないためです。

ただし寸法と描画は再現しません。ResizeObserver が伝える寸法は常に 0 で、canvas の2Dコンテキストはどのメソッドを呼んでも何もしません(measureText() の幅なども 0)。「コードパスが通ること」を保証するもので、レイアウトや描画の検証には使えません。盤面が実際に収まるか、正しく描けているかは実機またはブラウザで確認してください。


4. 開発ハーネス — ブラウザで目視する

ハーネスは「ゲームに対するハーネス」であり、「Portalに対するハーネス」ではありません。

主語扱い
ゲームのロジック・プロトコル実装の検証ゲーム✅ 対象
宣言した人数・チーム・観戦者構成での動作ゲーム✅ 対象
OBSモード・リマッチへの対応ゲーム✅ 対象
Portalが課す通信制約への適合ゲーム✅ 対象
ルーム作成・ロビー・大会モードPortal❌ 対象外
ゲームカタログ(Firestore)との連携Portalのカタログ❌ 対象外
クロスオリジンでの接続Portalとゲームの境界❌ 対象外

外部ドメインのゲーム(絶対URL)は対象外です

--entry に絶対URL(https://…)を指定すると、明示的にエラーになります。

$ npx hitsumabushi dev --entry https://example.com/game/index.html
ENTRY に絶対URLは指定できません: "https://example.com/game/index.html"

この判断は「ゲームかPortalか」の原則だけからは導けません(自分のデプロイ済みゲームを指定したい場合、主語はゲームです)。対象外とする理由は、オリジンの取り決めがゲーム単体の性質ではなく、Portalとゲームの境界の性質だからです。ゲームのロジックは同一オリジンでもクロスオリジンでも変わらず、変わるのは接続の握手の仕方のみで、それはPortal側が定める領域にあたります。

ハーネスで検証できないこと

以下はハーネスで確認できないため、「ハーネスで動いたから本番でも大丈夫」とは言えません。外部公開前にPortal側での確認が必要です。

  • クロスオリジンでの接続 — 本番の外部ゲームはPortalとクロスオリジンになります(postMessage のオリジン検証・document.referrer の扱い等がハーネスとは異なります)
  • サンドボックス — 本番のゲームは sandbox="allow-scripts" の iframe で動きますが、ハーネスの iframe にはサンドボックスがありません。ストレージや Cookie を使うコード、配信元の CORS が要る読み込みは、ハーネスでは動いても本番で失敗します(制約 §7)
  • ルーム作成・ロビー・観戦者の参加管理などPortal側の挙動(マッチメイキング・Room管理・再接続)
  • 大会モード(トーナメント/総当たり/グランプリ)の進行と結果の集計
  • CPU補完(allowCpuFill)のシミュレーション
  • ゲームカタログへの掲載
  • manifest.json の適合判定(→ npx hitsumabushi verify)

5. 人間が確認するしかないこと

実装完了前に以下をすべて確認してください。印の無い項目は、ブラウザのDevToolsのデバイスモードで確認できます。

印確認の仕方
🤖npx hitsumabushi verify が機械的に検査します(適用外の項目は理由付きで skip されます)
📱スマートフォン実機でしか判定できません。 npx hitsumabushi dev --host で表示されるURLを実機で開いてください
  • すべての操作可能なUI要素のタッチターゲットが 44 × 44 px 以上
  • グリッド盤面で44pxを下回る場合、§2「例外:多セルのグリッド盤面」の許容ラインと追加要件(取り消し手段等)を満たしている
  • 画面高さ・幅に応じた可変レイアウト設計(固定高さ指定を避け、flex: 1 や clamp() を活用)
  • 📱 アドレスバーの伸縮でレイアウトが崩れない(100vh は実機のスクロールで変わります。100dvh を使ってください)
  • 🤖 N×Nグリッド盤面の場合、ResizeObserver による実測フィットで1:1を保ち、画面内に収まっている
  • 狭小画面でボタンテキスト省略(アイコン化)等の情報密度制御を行っている
  • ボタンをアイコン化した場合もタッチターゲット(44 × 44 px)を維持している
  • 🤖 overscroll-behavior: none が html / body に適用されている
  • 🤖 ゲームキャンバス上の touchstart / touchmove に passive: false + preventDefault() を設定
  • ランドスケープ専用ゲームの場合、ポートレート時に回転案内が表示される
  • 📱 BGM・効果音がユーザー操作を契機に再生される(自動再生なし)— iOS Safari の自動再生ポリシーはデスクトップと異なります
  • ミュートボタン(最小 44 × 44 px)が提供されている
  • OBS モードで背景が透け、BGM がミュートになる(制約 §8。開発ハーネスの OBS モードの切り替えで確かめる)
  • 🤖 sendGameData の送信が 60パケット/秒 以内・32KB/パケット 以内に収まっている(毎フレーム送信していない)
  • 🤖 GAME_READY イベントをポータルに送信している(Hitsumabushi.init() が送ります)
  • sendGameOver に正しい勝者・スコアを渡している(イベント名は GAME_OVER)
  • 🤖 引き分けがありうるゲームは sendGameOver に isTie: true を指定している
  • 対戦終了後、手番表示等の他のUI更新パスが結果表示を上書きしない(Hitsumabushi.phase === 'over' でガードする)
  • 🤖 manifest.json が仕様を満たし、開発ハーネス専用パス(/__hitsumabushi_dev__/)を参照していない
  • localStorage・sessionStorage・IndexedDB・document.cookie を使っていない(制約 §7)
  • 自分のサーバーで配信する場合、ゲームの全ファイルに Access-Control-Allow-Origin: * を返している
  • 📱 実際に指で操作できる(44px は最低ライン。親指の届く範囲や誤タップは実機でしか分かりません)