0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

VS Codeで動く推理ゲームエンジンを作った——AIが書いた事件が「ちゃんと解けるか」まで自動チェックする

0
Last updated at Posted at 2026-08-20

生成AIに「面白い推理事件を作って」と頼むと、それらしい文章は出てきます。読むぶんには悪くない。
ところがゲームとして遊ぶと、途中で手が止まります。

証拠Aを手に入れるには証拠Bが要る。ところが証拠Bを手に入れるには証拠Aが要る。

鍵のかかった箱の中に、その箱の鍵が入っている。

人間が文章を読んで見つけるのは大変ですが、機械なら確実に検出できます。 ただしそれは、
AIに書かせるものが「文章」ではなく「構造」である場合に限ります。

この記事は、その方針でVS Code拡張機能として推理アドベンチャーを作った記録です。

  • 拡張機能: 昭和ミステリー(VS Code Marketplace・テスト版 v0.1.0):

  • リポジトリ:

⚠️ テスト版(preview)です。事件データの形式は今後変わる可能性があります。


1. 作ったもの

昭和レトロな港町を舞台にした、コマンド選択式の推理アドベンチャーです。
Ctrl+Shift+P →「昭和ミステリー: 捜査を開始する」でWebviewパネルが開きます。

捜査開始。現場の書斎に到着したところ

操作は6つだけです。

操作 内容
調べる いまいる場所の対象を調べ、物証を得る
聞く その場の人物に、話題を選んで質問する
突きつける 手持ちの証拠を人物に示し、嘘の証言を崩す
移動 別の場所へ行く
相談 相棒から助言をもらう
告発 犯人だと思う人物を指名する

「聞く」で話題を選ぶ。人物には立ち絵が付く

技術的にはごく素朴です。

項目
コード規模 index.html 952行 / extension.js 344行 / 検証器 137行
依存パッケージ 0(ビルド不要)
同梱事件 3件(各5〜6場所・5人・証拠7〜8・証言9〜11)
効果音 Web Audio APIで合成(音源ファイルなし)
制作期間 3日

ただしこの記事で書きたいのは、拡張機能の作り方ではありません。
中身の事件をAIに作らせるにあたって、何を作らせ、何を検査したかです。


2. 最初の失敗:解けない事件

最初は素直に、事件そのものを都度AIに生成させるつもりでした。
プレイヤーが「聞く」を選んだら、そのときAIに証言を書かせる。自由度が高くて良さそうに見えます。

これは早々に破綻しました。

  • 聞き込みを3人目まで進めると、1人目の証言と辻褄が合わなくなる
  • 「アリバイがある」と言っていた人物が、別の場面では現場にいたことになっている
  • 決定的な物証を突きつけても、AIがその場で新しい言い訳を作るので終わらない

要するに、犯人が確定していない推理小説が生成されていました。
文章としては読めるので、パッと見では気づきません。プレイして詰まってはじめて分かります。

じゃあ生成をプレイ前に1回だけにして、事件を丸ごと文章として書かせればいい——これも駄目でした。
辻褄の破綻は減りますが、今度は冒頭に書いた依存の循環が出ます。

「金庫の暗号は、社長の手帳に書かれている」
「社長の手帳は、金庫の中にある」

AIはこの種のミスを平然とやります。しかも文章としては両方とも自然なので、
書かれたものを読んでいる限り、違和感がありません。


3. なぜ失敗したか:文章を書かせたから

問題は「AIの推理力が足りない」ことではなく、成果物が検査できない形をしていたことです。

散文で書かれた事件を検査しようとすると、こうなります。

  • 検査するには文章を読解する必要がある
  • 読解にAIを使うと、生成したのと同じ弱点を持つ検査になる
  • 人間が読むと、1件あたり数十分かかるうえに循環を見落とす

「それらしいか」は判定できても、「成立しているか」が判定できない。
ここが本質でした。

AI生成物の品質を、生成物の見た目で測ってはいけない。「成立するか」で測る。
そして成立性を機械で測るには、生成させる対象そのものを検査可能な形式にしておく必要があります。


4. 設計変更:真相の「構造」を書かせる

そこで、AIに書かせるものを散文からJSONに変えました。
書かせるのは物語ではなく、犯人・動機・証拠の依存グラフです。

事件データは大きく5つの要素からできています。

要素 id 内容
場所 pl1 捜査できる場所
人物 p1 容疑者・関係者
証拠 e1 調べて手に入る物証
証言 t1 聞いて手に入る発言
真相手順 solutionChain 真相に至るまでの手がかりの並び

肝は、証拠と証言が両方とも requires(それを得るのに必要な手がかり) を持つことです。
これが捜査の連鎖を作ります。

矢印は「これを手に入れると、次に手が出せるようになる」と読みます。
角の丸いものが最初から取れる手がかり、六角形が真相に直結する山場です。

t5:truth に矢印が2本入っていることに注目してください。
両方を持っていないと到達できません。 この「2本とも要る」がこの記事の後半で効いてきます。

もうひとつの肝が contradictedBy です。
証言に「この証拠を突きつけられると崩れる」という指定を書くと、突きつけたときに truthText が語られます。
推理ゲームの核はここにあります。

{
  "id": "t5",
  "personId": "p4",
  "topic": "事件当夜のこと",
  "requires": [],
  "text": "「ずっと倉庫におりました。埠頭からは一歩も出ておりません」",
  "contradictedBy": "e5",
  "truthText": "「……社長に呼ばれたんです。あの晩、邸に来いと」"
}

1つずつ見ていきます。**前半が「いつ聞けるか」、後半が「何を語るか」**です。

フィールド 意味
id この証言のid。t は testimony(証言)の頭文字
personId 誰の証言か。 p4(倉庫番)に紐づく
topic 「聞く」を選んだときに選択肢として並ぶ話題名
requires これを聞くのに必要な手がかり。 空なので最初から聞ける
text 実際に語られる台詞。そしてこれは嘘です
contradictedBy この嘘を崩す証拠のid。 e5(下足札)を突きつけると崩れる
truthText 崩れたあとに語り直される、本当のこと

プレイヤーから見ると、こう動きます。

  1. 倉庫番に「事件当夜のこと」を聞く → text が返る(「ずっと倉庫におりました」)
  2. 別の場所で e5(下足札)を見つける
  3. 倉庫番に e5突きつけるtruthText に変わる(「社長に呼ばれたんです」)

アリバイが崩れた瞬間です。この3手が推理ゲームの最小単位で、
contradictedBy はその3手を1行で宣言しているにすぎません。

実際のデータにはもう1つ lockedText があります。requires を満たしていないときに
代わりに表示する文(「その話は、あんたにはまだ早い」のような門前払い)です。
この証言は requires が空で誰でも聞けるため、上の例では空文字にしてあります。

そして重要なのが、texttruthText の両方が最初からデータに書いてあることです。
プレイ中にAIが呼ばれることはありません。真相は生成時に確定し、あとは参照するだけです。
2章で破綻したのは、まさにここをその場で作らせていたからでした。

t5:truth という「証言が崩れた状態」は、データには書きません。
contradictedBy を持つ証言から自動的に生えるidで、必要条件は [t5, e5] です。
つまり「その証言を聞いていて、かつ崩す証拠を持っている」ときだけ到達できます。

この時点で、事件は有向グラフになりました。 グラフなら機械で調べられます。

AIへの指示も変わりました。「面白い事件を作って」ではなく、こうです。

  1. トリックの型を1つ選ぶ(アリバイ崩し / 身元誤認 / 時刻トリック / 共犯者 / 自殺偽装)
  2. 犯人・動機・被害者を決める
  3. solutionChain を先に書く(例: ["e2","e3","e4","t3","e5","t5:truth"]
  4. その手順が成立するようにデータを埋める

「先に結論、あとから材料」の順です。逆順にすると必ず破綻します。


5. 検証器を書く

事件データを受け取ったら、組み込む前に検証します。中核は不動点展開です。

不動点展開とは

難しそうな名前ですが、やることは1つです。

初期状態(手ぶら)から始めて、「いま持っているものだけで取れる手がかり」を全部足す。
足せるものが無くなるまで、これを繰り返す。

繰り返しても何も増えなくなった状態を不動点(fixed point=これ以上動かない点)と呼びます。
そこに集まっているものが、プレイヤーが実際に到達できる手がかりの全体です。

4章の例で実際に回してみます。手がかりと必要条件はこうでした。

手がかり 必要なもの
e2 机の引き出し (なし)
e3 経理帳簿 e2
e4 木箱の中身 e3
t3 女将の証言 (なし)
t5 倉庫番の証言 (なし)
e5 下足札 t3
t5:truth 嘘が崩れる t5e5

手ぶらの状態から始めます。

周回 新しく足せるもの 手持ち
1周目 e2 t3 t5(条件なし) e2, t3, t5
2周目 e3(e2がある) e5(t3がある) +e3, e5
3周目 e4(e3がある) t5:truth(t5とe5がある) +e4, t5:truth
4周目 何も足せない → ここで停止 全7件

7件すべてが手持ちに入りました。この事件は解ける、と機械が判定できたことになります。

なぜ普通のグラフ探索ではだめなのか

ここが地味に効く点です。requiresAND条件、つまり「並んでいるものを全部持っていること」です。

t5:trutht5e5両方が要ります。片方だけでは駄目です。
普通の経路探索(あるノードから辿れるか)は「どれか1本つながっていればOK」というORの考え方なので、
この判定には使えません。

不動点展開なら、条件がいくつ並んでいても
「その全部がいま手持ちにあるか」を毎周チェックするだけなので、素直に扱えます。

そして、循環しているものは自動的に取り残されます。

e6 を取るには e7 が要り、e7 を取るには e6 が要る。

この2つは、何周回しても条件が揃いません。周回が止まったとき手持ちに入っていない=
鍵のかかった箱の中に、その箱の鍵が入っている状態です。
循環検出のコードを別途書く必要はありません。「増えなくなるまで回す」だけで落ちます。

必ず止まることも保証されています。手持ちは増える一方で、しかも手がかりの総数は有限だからです。
最悪でも手がかりの数だけ回れば終わります。

この「増えなくなるまで繰り返す」手法自体は珍しいものではなく、
コンパイラのデータフロー解析やDatalogの評価など、依存関係を扱う場面で広く使われています。
ビルドツールが「まだビルドできない対象」を後回しにしながら回るのも同じ考え方です。

実装

コードにすると、素朴な二重ループで済みます。

// 手がかりid -> それを得るのに必要なidの一覧
function unlockables(c) {
  const reqs = {};
  for (const e of c.evidence || []) reqs[e.id] = (e.requires || []).slice();
  for (const t of c.testimonies || []) {
    reqs[t.id] = (t.requires || []).slice();
    // 証言を崩すには、その証言を聞いていて、かつ崩す証拠を持っている必要がある
    if (t.contradictedBy) reqs[t.id + ':truth'] = [t.id, t.contradictedBy];
  }
  return reqs;
}

// 初期状態から到達可能な手がかりを不動点まで展開する
function reachable(c) {
  const reqs = unlockables(c);
  const known = new Set();
  let changed = true;
  while (changed) {
    changed = false;
    for (const key of Object.keys(reqs)) {
      if (known.has(key)) continue;
      if (reqs[key].every(n => known.has(n))) { known.add(key); changed = true; }
    }
  }
  return known;
}

changed が「今の周回で何か増えたか」のフラグです。1周回して何も増えなければ不動点
なので、そこでループを抜けます。every が先ほどのAND条件の判定にあたります。

返ってきた集合に入っていない手がかりが、鍵の入った箱です。

検査項目は4つです。

検査 落ちる事件
全ての手がかりが到達可能か 依存が循環している/鍵が箱の中にある
真相への手順が4手以上か 捜査が浅い
「証言を証拠で崩す」段階があるか ただの物探しになっている
連鎖の深さが2以上か 全部最初から取れて総当たりで解けてしまう

最後の1つが地味に効く

最初、検査は上の3つだけでした。「解ける」ことを保証すれば十分だと思っていたからです。

ところが**全ての手がかりが requires: [](最初から全部取れる)**という事件が普通に通ります。
これは論理的には解けます。解けますが、プレイヤーは全部の場所を1周して全部拾い、
全員に全部の話題を振れば終わります。推理ではなく作業です。

そこで連鎖の深さ(依存グラフの最大の高さ)を測り、2未満を弾くようにしました。

if (depthOf(c) < 2) errors.push('手がかりが全て最初から入手可能で、総当たりで解けてしまう');

ここから引き出せる一般論があります。

成立性の検査は、下限と上限の両方を見る必要がある。

「解けない」を弾くだけでは足りません。「解けすぎる」も弾かないと、
生成AIは最も検査を通しやすい形——つまり依存のない平坦なデータ——に寄っていきます。
制約を課すと、AIは制約の隙間に逃げます。隙間を先に塞いでおく必要があります。


6. 実際に弾かれた例:検証器の穴が実プレイで見つかった

ここからが本題です。検証器を書いて満足していたところ、プレイ中に不具合が見つかりました。

症状: 誰にも話を聞いていないのに、証拠の説明文が
**「この品は誰かの証言と食い違っている」**と語り出す。

プレイヤーは何のことか分かりません。まだ誰の主張も聞いていないからです。

原因: 嘘を崩す証拠 e5 の入手条件に、崩す対象の証言 t5 が入っていませんでした。

// 誤り: 証言を聞く前に証拠が取れてしまう
{ "id": "e5", "requires": ["t3"] }

// 正: 崩す対象の証言も条件に入れる(requiresはAND条件)
{ "id": "e5", "requires": ["t3", "t5"] }

図にすると、抜けていたのは1本の矢印です。

この1本が無いと、t5 を聞く前に e5 が取れてしまいます。
e5 の説明文は「誰かの主張と食い違う」と書いてあるので、
まだ誰の主張も聞いていないプレイヤーには意味の通らない文が出るわけです。

検証器は e5 にも t5:truth にも問題なく到達できると判定します。論理的には解けるからです。
落ちていたのは論理ではなく、物語の前後関係でした。

「解ける」ことは検証していたが、「話の順番が成立するか」は検証していなかった。

この話が自分にとって効いたのは、次の点です。
同じ誤りが、AIが作った事件だけでなく、私が手で構成した同梱3件すべてで起きていました。

AI固有の問題ではありません。依存グラフを人間が頭の中で追うと、人間も同じ穴に落ちます。
だから検証器が要る、という話に戻ってきます。

対処として、検証ルールを1つ追加しました。

// 崩す証拠を証言より先に入手できると、証拠の説明文が「主張と食い違う」と
// 語ってしまい、まだ何も聞いていないプレイヤーには意味が通らない
const breaker = c.evidence.find(e => e.id === t.contradictedBy);
if (breaker && !(breaker.requires || []).includes(t.id)) {
  errors.push(`証拠${breaker.id}は証言${t.id}を崩す証拠なので、`
    + `${breaker.id}のrequiresに「${t.id}」を入れること(証言を聞く前に入手できてしまう)`);
}

追加した瞬間、同梱3件と利用者が作った1件がまとめて赤くなりました。全部直しました。

検証器は最初から完全にはなりません。「検証を通ったのに体験が壊れている」ケースが出るたびに、
それを規則として書き足していく——検証器はテストコードと同じで、育てるものです。


7. 他人のAIが作った事件も動く

事件が検査可能な形式になった副産物として、面白いことが起きました。

利用者が自分のChatGPTに作らせた事件が、検証を通り、実際に最後まで解けたのです。

拡張機能には「事件づくりの道具一式を書き出す」というコマンドを付けてあります。
実行すると、作問ルール(AUTHORING.md)とスキーマ(case-schema.json)と実例が書き出されます。
利用者はそれを自分のAIに渡し、できたJSONを所定のフォルダに置いて読み込み直すだけです。

私は相手のAIが何かを知りません。知る必要もありません。
検証を通る形式であることだけが条件だからです。

エラー文は人間ではなくAIに読ませる前提で書いた

これは意識的な設計判断でした。検証エラーの文面は、そのままAIに貼り付けられるように書いてあります。

読み込めなかった事件があります。
AIに直させる場合は、以下の指摘をそのまま渡してください。

  case-002.json: 2件の不備
    - e5に到達できない(依存の循環、または鍵が箱の中にある)
    - solutionChainに「証言を証拠で崩す」ステップ(xx:truth)がない

ポイントは3つです。

  1. 何が悪いかだけでなく、なぜ悪いかを書く(「鍵が箱の中にある」)。AIは理由があると直せる
  2. 可能なら直し方まで書く(前節の requirest5 を入れろ、というエラー文)
  3. 人間向けの丁寧語や励ましを入れない。 貼り付けたときに指示として読めることを優先する

こうしておくと、「エラーを貼る → AIが直す → また読み込む」の往復が成立します。
利用者は作問ルールを理解していなくても、往復を数回まわせば動く事件にたどり着けます。

素材はプール方式にした

もうひとつ、地味ですが効いた設計があります。
背景15種・立ち絵20種を拡張機能側に持ち、事件データは画像を持たず、idで参照するだけにしました。

{ "id": "pl3", "name": "三崎邸・書斎", "bg": "mansion_study" }
{ "id": "p2", "name": "女将", "portrait": "woman_40s_kimono" }

おかげで、他人のAIが作った事件にも画像生成なしで絵が付きます。
第三者のコンテンツを受け入れる仕組みでは、「持ち込むものを減らす」ほど成功率が上がります。
テキストだけ持ち込めばいい、という状態が理想です。


8. 既存のノベルゲームエンジンとどう違うのか

ここまで読んで「Ren'PyやTwineでいいのでは」と思った方へ。そのとおりで、汎用エンジンとして
見れば全面的に負けています。
ただし比べる軸を「表現力」ではなく
**「作者に何を書かせる設計か」**に取ると、違いがはっきりします。

作者が書くもの 実行環境 破綻を見つける仕組み 第三者の話の受け入れ
Ren'Py 台本と分岐(Pythonで拡張可) 単体アプリとして配布 Lintが「潜在的な誤りや最適化不足」を報告 作品ごとに再配布
Twine パッセージと接続 ブラウザ(HTMLとして出力) パッセージ間のリンク構造を物語マップで視覚的に把握(判断は作者の目) HTMLごと配布
ink 物語の流れ(専用スクリプト) 他エンジンに組み込む 構文エラー+フローが意図せず終了する loose end の警告 組み込み側次第
これ 真相の依存グラフ(JSON) VS Codeの中 全手がかりの到達可能性・連鎖の深さ・「証言を崩す」段の有無を自動判定 JSONを1つ置くだけ

誤解のないように補足すると、この表は優劣ではありません。 どれも「作者が意図した流れを
そのまま形にする」ための道具で、検査はその補助です。Ren'Pyの公式ドキュメントも
Lintについて「十分なテストの代わりにはならない」と明記しています。

4つの中でinkがいちばん近いことも書いておきます。inkはコンパイル時に
「flow runs out(フローが尽きた)。-> END か選択肢か divert が要るのでは」という
警告を出します。物語の流れそのものを検査対象にしているという点で、発想は同じ方向です。
違いは検査する性質で、inkが見るのは**「意図せず終わっていないか」**、こちらが見るのは
**「初期状態から全部に手が届くか」**です。

今後の課題

汎用エンジンに対して足りていない点は、そのまま伸びしろでもあります。

  • 表現力: トランジション・演出・音声の自由度が無く、コマンド選択式の型に固定されている。
    演出の幅は、型を保ったまま広げられる余地がある
  • 配布力: 単体アプリとして配れず、遊ぶのにVS Codeが要る
    ゲーム本体は単一HTMLでブラウザでも動くので、配布形態は今後の検討課題
  • 検証できる範囲: いま見ているのは到達可能性と連鎖の深さまで。
    「面白いか」は測れていない(ミスリードの効き方、容疑者の配置の妥当さなど)
  • 成熟度: 長年使われてきたエンジン群に対して、こちらは3日で作ったテスト版。
    事件データの形式もまだ固まっていない

一方で、分岐エンディングが無く真相が1つに確定していることは課題ではありません。
これは意図した制約で、理由は後述します。

このツールの強み

  • 「解けない話」を出荷できない。 汎用エンジンでは、作者が破綻に気づかなければそのまま出ます。
    こちらは検証を通らない事件は読み込まれません
  • 他人のAIが作ったものを、信用せずに動かせる。 素材はID参照なので絵も付きます(7章)
  • 編集環境がそのまま実行環境。 作る → Ctrl+Shift+P で読み込む → 遊ぶ、が同じウィンドウで完結します

トレードオフの正体

分岐エンディングが無いことは、単なる機能不足に見えます。
しかし真相が1つに確定しているからこそ、「その真相に到達できるか」を機械で判定できます。
複数の真相が並立する物語では、そもそも何を検査すればいいのかが決まりません。

汎用エンジンが作者に与えているのが表現の自由度だとすれば、
この設計が与えているのは制約です。真相を1つに固定し、データ形式を狭め、
検証に通らないものを弾く。
その制約と引き換えに、書き手が人間でなくても成立するようになりました。


9. 分かったこと

推理ゲームの話をしてきましたが、一般化するとこうなります。

1. AI生成物は「それらしさ」ではなく「成立するか」を検査する

読んで自然かどうかは、AIが最も得意な部分です。そこを検査しても通ります。
検査すべきは、その生成物が目的の用途で機能するかです。

2. そのためには、生成させる対象を検査可能な形式に設計する

これが順序として先です。「AIに書かせたものをどう検査するか」ではなく、
**「機械で検査できるものは何か。それをAIに書かせるにはどう分解するか」**と考えます。

散文は検査できません。依存グラフは検査できます。
だから散文ではなくグラフを書かせ、散文はグラフの各ノードに短く添える形にしました。

3. 検査は下限と上限の両方を見る

「壊れていない」だけを条件にすると、生成AIは最も検査を通しやすい退化した形に寄ります。
「浅すぎる」「平坦すぎる」も同時に弾く必要があります。

4. 検査を通る形式にしておくと、誰のAIが作ったものでも受け入れられる

これは最初は狙っていなかった副産物です。
形式が検査可能なら、生成元を問う必要がなくなります。検査が信頼の代わりになるからです。

利用者が自分のChatGPTで作った事件がそのまま動いたとき、
これは推理ゲームの話ではなく、第三者コンテンツを受け入れる仕組み一般の話だと気づきました。

5. 検証器は一度で完成しない

「解ける」を検証していても、「話の順番が成立する」は検証できていませんでした。
そして同じ誤りを、AIも人間も等しくやりました。

検証器はテストコードと同じです。すり抜けが見つかるたびに規則を1つ足して育てる。
最初から完全なものを目指すより、すり抜けたときに規則として書き残せる構造にしておくほうが重要です。


遊んでみる / 事件を作ってみる

VS Code Marketplaceの昭和ミステリーからインストールできます(VS Code内の拡張機能検索でも出ます)。
Ctrl+Shift+P →「昭和ミステリー: 捜査を開始する」。同梱の事件は3件、各30分ほどです。

自分のAIで事件を作りたい場合は「昭和ミステリー: 事件づくりの道具一式を書き出す」を実行し、
書き出された AUTHORING.md をお使いのAIに渡してください。
検証に落ちたら、出力パネルの文面をそのままAIに貼れば直ります。

  • Marketplace:

  • リポジトリ:

  • 検証器の全文(137行): validate.js
  • 作問ルール: authoring/AUTHORING.md

不具合や気づいた点はIssuesへどうぞ。


参考(8章の比較で参照した一次情報)

  • Ren'Py Documentation — Developer Tools(Lintの位置づけ):

  • Twine — 物語マップとリンクの扱い(Twine Forum):

  • ink — Writing with ink(-> END / loose end の警告):

各エンジンは更新が続いているため、この記事の記述は執筆時点のものです。
比較の目的は優劣付けではなく、設計思想の違いを示すことにあります。


注記

  • テスト版(v0.1.0)です。事件データの形式は今後変わる可能性があります
  • 背景画像・人物立ち絵・アイコンは生成AIで作成したものです(実在の人物・団体とは関係ありません)
  • 効果音は音源ファイルを使わず、Web Audio APIで合成しています
  • ライセンスはMITです
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?