自動プレイは何を見つけ、何を見逃したか — 遊ばずに書かれたゲームを検査する

1,900行のゲームが、構文検査を通ったまま開始ボタンで落ちました。完成までブラウザで動作を確かめていなかったのです。そこから作った自動プレイの検査が、何を見つけ、何を誤って報告し、統計のどこで自分自身をだましかけたか。再現の仕組みと判定の決め方をコードつきで書きます。

1,900行が、開始ボタンで落ちた

PlayNagiでは、ゲームの実装にもAIの支援を使っています。ある日、書き上がったゲームは1,900行ありました。構文検査も、作品どうしで共通の約束事を確かめる検査も通っています。ところが、開始ボタンを押した瞬間に落ちました。

原因は単純でした。プレビューを更新する関数 updatePreview() だけが、作り替える前の古い関数 computeDist() と tracePath() を呼んだままだったのです。実装中にブラウザで一度も遊んでいなかったため、開始直後の不具合に気づけませんでした。コードの検査に加えて、実際に操作する確認が必要でした。

調べると、もう一つ定義の無い呼び出し drawVoid() が見つかりました。こちらはもっと厄介で、床を実際に壊したときだけ通る枝にありました。何も押さなくても、同じボタンを連打しても、そこへは届きません。ブラウザで自動的に遊ばせる検査を作っても、この不具合は素通りしていたはずです。

この件から、検査を二つに分けました。「呼ばれているのに定義が無い関数」は、ブラウザを使わずにソースを解析する検査にし、全作品のテストへ入れました。届きにくい枝の不具合は、こちらが受け持ちます。そして自動プレイには、別の問いを任せることにしました。

落ちないのに、壊れている

エラーを出さない壊れ方があります。

どれも例外を出さず、従来の検査を通過し得ます。遊んで初めて気づく問題もあるため、別の検査が必要です。ここが自動プレイの持ち場です。面白いかどうかを判定する道具ではなく、面白さが成り立つ前提が壊れていないかを見る検査です。

白物戦記の戦闘画面。家電の機体が等角のマップに並ぶ
ターン制の作品でも、押した操作が盤面の状態を変えたかどうかを数えれば、入力が効いているかを確かめられます。

一回の試行を、あとから再現できるものにする

最初の版は、実時間で6秒待って画面を見るだけでした。乱数も実行ごとに違うので、何かを見つけても二度と同じ状況を作れません。

そこで三つを同時に変えました。時間を実時間ではなくフレーム数で進める。乱数に種を与える。入力を記録する。これで一回の試行は「種と入力の記録」の組だけで決まります。何かを見つけた試行は、同じ組でもう一度再生します。最終状態が一致しなければ、それ自体を「非決定」という別の所見として報告します。再現できない不具合は、再現できないことのほうが問題だからです。

遊び方も一種類にしません。

操作の方針ねらい
無操作何もしないのに点が入る・進む
連打一つの操作だけで稼げる
乱択基準になる「でたらめな遊び方」
探索まだ試していない「状態と入力」の組を優先する
わざと偏った操作画面の角だけ、押しっぱなし、一方向のスワイプなど8種

偏った操作が乱択より良い点を出すなら、同じ操作の繰り返しが最善になっている疑いがあります。入力は8フレームに1回、1秒に7.5回までにしました。検査では通常の操作を想定し、それを超える速さの連打は対象から外しました。

点だけでは測れないゲームもあるので、「押した入力のうち、直後に状態を変えた割合」も数えます。入力しても120フレームのあいだ状態が変わらなければ「詰まり」です。終わるかどうかは一回では分からないので、120/360/1200/12000フレームの長さを、種を変えて3本ずつ走らせます。落ちた試行は、入力の記録を少しずつ削りながら同じ失敗が残るかを確かめ(ddminという手法です)、最小の再現手順まで縮めてから、もう一度再生して確認します。

統計で、自分自身をだましかけた

最初は各方針を5回ずつ走らせ、平均を比べていました。少ない回数の平均は、たまたまで簡単にひっくり返ります。試行を10回にし、比べ方を二つに変えました。

// A12: 方針aの一回が、方針bの一回を上回る確率(同点は半分と数える)
function a12(a, b) {
  let win = 0;
  for (const x of a) for (const y of b) win += x > y ? 1 : x === y ? 0.5 : 0;
  return win / (a.length * b.length);
}

// Wilson区間(95%): n回中k回起きたとき、本当の割合はどの範囲にありそうか
function wilson(k, n, z = 1.96) {
  const p = k / n, d = 1 + z * z / n;
  const center = (p + z * z / (2 * n)) / d;
  const half = z * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d;
  return [center - half, center + half];
}

wilson(10, 10); // → [0.722, 1]
wilson(9, 10);  // → [0.596, 0.982]

ここで一つ、はまりかけた穴があります。「一定以上の割合で終了する」と判定するための条件を、最初は「Wilson区間の下限が0.8以上」にしていました。ところが10回試して10回とも終わっても、下限は0.72です。10回の試行では、何回やっても基準を満たせない閾値を置いていたことになります。閾値は、試す回数と一緒に決めなければ意味がありません。今は下限0.7を基準にしています。

当たったと思ったら、外れていた

実際に全作品へ掛けると、所見はいくつも出ました。ただ、その読み方にこそ手間がかかりました。

作品「ロープリフト」は、無操作で80点と出ました。何もしないのに点が入る、典型的な壊れ方に見えます。調べると、検査のたびに保存データを消していたため、初回だけ流れるお手本の実演が毎回流れ、その得点表示を読んでいただけでした。これは所見ではなく「無操作ではゲームが始まらないので判定できない」に分けました。

作品「奈落オトシ」では、無操作で48点が10回中10回出ました。こちらは壊れていません。落ちた深さが得点になる作りで、6秒の試行では入力の差が表に出なかったのです。直すべき不具合ではなく、設計を見直すかどうかを人が判断する材料でした。

検査ツールそのものも疑いました。無操作で点が入る、終わらない、入力が効かない、結果が毎回変わる、点が減る、という5種類の故障をわざと注入し、それぞれを検出できるかを測ります。最初の集計では「5つとも検出」と書きました。ところが、はっきり所見として出たものと、「未確定」にとどまったものを同じ当たりに数えていました。2作品×5故障で数え直すと、所見7、未確定止まり3、見逃し0です。検出器は、故障を意図的に入れ、検出できるか確かめてから信用する。これを自分の道具にも当てはめることになりました。

もう一つ、フレーム数で時間を進める方式には限界がありました。フレームを詰めて進めると、ブラウザは描画をまとめて後回しにします。ある作品では、この方式で測った1フレームの処理が2〜3msだったのに、CPUを4倍に絞って実時間で測り直すと50msかかっていました。処理の重さは別の方法で測る必要があります(Canvasゲームの負荷の記事)。

自動プレイに、何を言わせるか

判定に使う言葉は「所見」「未確定」「判定不能」の三つだけにしました。合格か不合格かの二択にすると、「まだ分からない」を表す場所がなくなり、迷っている状態が合格か不合格のどちらかにまぎれ込むからです。

所見が出ても、自動のテストは落としません。誤検出で開発を止めないためです。上の「お手本の得点」のように、機械の報告を読む段階で人の判断が要ります。重さもあります。当時の19作品を一通り回すと2,632秒、44分ほどかかりました。そのため毎回の変更には掛けず、必要なときに回す運用にしています。

そして最後は、人が実際の端末で遊びます。操作の手触り、文字の読みやすさ、難しさへの納得感は、どの検査にも測れません。自動プレイが確かめるのは、その手前の「遊べる前提」までです。

ブラウザでの検査を組むなら、時間を制御するPlaywrightのClock APIと、失敗した操作を後から追えるTrace Viewerが役に立ちます。ただしCanvasの中身はDOMから見えないので、ゲーム側に内部の状態を読む口を用意しておくと、検査できる範囲が大きく変わります。からくり双星では、必要な状態を直接用意し、短く動かして結果を確かめる検査を置きました。「ボス戦で攻撃するとボスの体力が減る」「力尽きたあと1操作で同じ面を再開できる」「面の終端へ跳ぶとボス戦が始まる」の三つです。でたらめな操作ではなかなか届かない場面を、そこから始めて確かめられます。