99階のローグライクが「踏破不可能」だった — TypeScriptで自動プレイを書き、GitHub ActionsのCIゲートにする

99階のローグライクが「踏破不可能」だった — TypeScriptで自動プレイを書き、GitHub ActionsのCIゲートにする Webアプリ

ブラウザで動く風来のシレン型のターン制ローグライクを作った。地下100階まで潜る、いわゆる「99階ダンジョン」である。

遊ぶ → pocraft.net/dungeon/

配信されるのは index.html / style.css / main.js の3ファイルだけで、ライブラリもCDNも読み込まない。実行時依存はゼロ、ページを開けばすぐ動く(開発はTypeScript + esbuild。後述するCI/CD導入に合わせて素のJSから移植した)。

作ること自体は、ダンジョン生成・視界・戦闘・アイテムと順に積み上げれば形になる。本稿の読みどころはその後だ。一通り遊べるようになった段階で自動プレイのボットを書き、踏破を狙わせたところ、そもそもクリアが数学的に不可能だと判明した。そして最終的に、この「踏破できるか」の検証はGitHub Actionsのマージゲートになった。

作ったもの

  • ダンジョン生成: マップを格子状の区画に割り、各区画に部屋を1つ置いて隣り合う区画を通路でつなぐ。全区画の連結はUnion-Findで保証し、そのうえで未使用の辺を数本足してループを作る(袋小路だけの構造だと逃げ場がなくなるため)
  • 視界: レイキャストではなく風来のシレン方式。部屋にいるときは部屋全体、通路にいるときは隣接8マスだけ。ターン制の探索では「部屋に入った瞬間に全体が見える」方が判断しやすい
  • 描画: Canvas 2D。画像を1枚も使わず、矩形と漢字グリフ(鼠・蝠・鬼・兵…)だけで描く。NetHackのようにフロア全体を1画面に収める
  • 未識別アイテム: 草と巻物は「赤い草」「ミミズの巻物」など見た目の名前で出る。見た目と中身は1対1で対応し、その組み合わせは潜るたびにシャッフルされる
  • 満腹度、装備の修正値(+n)、スコアとランキング(ゲームセンター方式の3文字ネーム入力つき)

自動プレイに踏破させようとしたら

一通り遊べるようになったので、階段への最短経路を辿るボットを書いて走らせた。平均到達階は5.3階。99階には遠く及ばない。

死因を追うと、序盤は「部屋で複数の敵に囲まれて溶ける」、中盤以降は「レベルが階層に追いつかない」だった。前者はボットの立ち回りの問題だが、後者は数値の問題である。そこで経験値の収支を計算してみて、青ざめた。

必要経験値 その階までに得られる総経験値
地下50階相当(Lv28) 89,231 87,957
地下70階相当(Lv38) 1,433,022 317,067
地下99階相当(Lv53) 92,227,778 1,165,250

最下層に見合うレベルには9,200万expが必要なのに、全99階の敵を1体残らず倒しても116万expしか手に入らない。79倍足りない。誰がどうプレイしても地下100階には到達できなかった。

原因は自分で書いたこの3行だ。

const table = [0, 8, 20, 40, 70, 115, 180, 270, 390, 550, 760, 1050];
while (table.length < 70) {
  table.push(Math.round(table[table.length - 1] * 1.32));  // 等比で伸ばす
}

手で調整したのはLv12まで。その先は「等比で伸ばしておけばいいだろう」と1.32倍を掛け続けていた。等比数列は指数関数的に伸びるので、線形に増える収入をあっという間に置き去りにする。手書きの範囲では気づかず、Lv30を超えたあたりから発散していた。

各階で得られる経験値の積み上げを実測し、それに沿う Lv⁴ / 15.7 のカーブへ差し替えた。4乗でも十分に急峻だが、指数関数とは伸び方の質が違う。

「敵を弱くする」は逆効果だった

経験値カーブを直しても踏破できない。自然回復が1ターンあたり固定1HPで、最大HPが数百になる深層では実質機能していなかったのを見つけて、最大HP比例に変えた。それでも届かない。

そこで「敵の数と強さを下げれば楽になるだろう」と緩和したところ、到達階が83階から44階に下がった

理屈は単純で、敵が減ると経験値も減る。レベルが上がらず、結果的に弱くなる。難易度を下げたつもりが、プレイヤーの成長を削っていた。

難易度は敵側ではなくプレイヤーの成長側で調整するのが正しい。この検証で得た一番の教訓だった。実際、レベルアップ時の伸びを上げたら一発で踏破が出た。

装備を鍛える道を用意する

もう一つ効いたのが、装備の修正値(+n)である。武器の祝福・防具の祝福の巻物で鍛えられ、盾の修正値は被ダメージ軽減に全量効く(素の防御力は半減して効く)ようにした。鍛え上げれば被弾が1に張り付くので、深層攻略の正攻法が「鍛える」になる。

修正値はアイテム1個ごとに保持されるので、持ち替えると投資が消える。どれを育てるかの選択が生まれ、拾った装備が最初から+0〜+10を持っていることもあるため、床の装備を吟味する意味も出た。

調整の過程で分かったのは、あるパラメータを緩めたら別のパラメータで締め直す必要があることだ。修正値を導入したら踏破率100%、満腹度を楽にしたらまた100%と、緩和のたびに跳ね上がる。最終的にレベルの伸びと最深部2種(阿修羅・大魔王)の強さで釣り合いを取り、自動プレイの踏破率は 0% → 約40% に落ち着いた。未踏破でも80〜90階台までは到達する。

バランスそのものをCIのゲートにした

ここまでの検証はすべて、ブラウザのコンソールで自動プレイを走らせる手作業だった。せっかくなのでこれをCI/CDに組み込んだ。

まず素のJSをTypeScriptに移植した(型を付ける過程で、ステータス表示が装備の修正値を無視している既存バグが1つ見つかった)。そのうえでGitHub Actionsを2段構えにした。

  • PR時: ESLint / 型チェック / 単体テスト / ビルド。それぞれ独立したジョブなので、Checksのどれが落ちたかで原因が一目で分かる
  • masterマージ時: 上記に加えて踏破シミュレーションを実行し、全部合格したときだけ本番へ自動デプロイする

踏破シミュレーションは、生存重視の自動プレイを固定12シードで走らせ、「踏破2回以上・10回以下(簡単すぎ検出)・平均到達階55以上」を機械判定する。乱数はシード付きで決定的なので、このテストが落ちたら「揺らぎ」ではなく「バランスが変わった」ことを意味する。flakyにならない踏破テストが書けるのは、ローグライクの乱数設計がそのまま効いている。

単体テストには「必要経験値 ≦ 全99階で得られる総経験値」というバランスの成立条件そのものも入れた。冒頭の79倍事故は、もう機械が防いでくれる。

シミュレーション結果はActionsのJob Summaryに表として出る。seedごとの到達階・レベル・ターン数・結末(踏破/どの敵にやられたか)が、マージのたびにコミット履歴へ残っていく。

おまけ: メッセージが途中で止まるバグ

遊んでいて「2〜3階あたりからログが出なくなる」と指摘を受けた。原因はバッファ処理だった。

ログは60件を超えると古いものをshift()で捨てる作りで、描画側は「配列長 − 描画済み件数」で新着を判定していた。60件に達すると配列長が60で頭打ちになるため差が常に0になり、以降まったく表示されなくなる

捨てた分も含む通算件数を別に持ち、リングバッファ(1000件)に置き換えて解決した。shift()は要素数に比例するコストもかかるので、一石二鳥だった。


コメント

タイトルとURLをコピーしました