Slay the Spire 2自動プレイボット(1)Pythonから観測・操作する
作りたいもの、今回の到達点
Slay the Spire 2(以下 STS2)で、スーパープレイを量産する自動プレイボットを作りたいと思っています。人間が高難易度を何百回も回して最適解を詰めるのは現実的ではありません。それを機械にやらせます。
ただし方針として、1 手ごとの判断に生成 AI(LLM)は使いません。理由は次の節に書きますが、要は遅くて高く、しかもこの手のゲームで強いとは限らないからです。LLM は「倒すべきベースライン」として一度だけ測る対象に置き、本体は古典的な探索とルールで組みます。まったく LLM を使わないわけではなく、判断のウエイト調整など、システムを改善する側では使うつもりです。挟まないのは 1 手ずつの判断、という線引きです。STS1 の bottled_ai が示した路線で、設計は 2 層――メタ判断(カード取得・パス選択)はルールベース、戦闘は内部シミュレータ+探索――にする予定です。
この第 1 回は、その前段にあたる I/O(入出力) の話です。到達点はシンプルで、Python からゲームの状態を読み、行動を送れるようにしました。Jupyter のセルを実行すると、ネオーの祝福を選び、マップを 1 歩進み、スライムと戦う――ところまで無人で動きます。下に実際のログを貼ります。
なぜ 1 手ごとの判断に LLM を使わないのか
「AI で STS2 をプレイ」と聞くと、LLM をゲームに繋いで指し手を考えさせる構成を想像する人が多いと思います。実際それを試せる土台は整っています(後述の Mod がまさにそれです)。ですが、1 手ごとの判断役に LLM を据えるのは目的に対して筋が悪い、と考えています。
- 遅い: 1 手ごとに API を往復するため、1 ラン通すだけでもまとまった時間がかかります。何百ランも回したい用途とは相性がよくありません。
- 高い: 今回使う Mod の作者の計測では、Claude Sonnet 4.6 で 1 ランあたり 800 万トークン超を消費したそうです(出典: STS2MCP)。これを何百ランも回すのは現実的ではありません。
- 強いとは限らない: 賢く見えても勝ち切れるかは別問題です。これは現時点では想像ですが、根拠もあります。本業でも、ドメイン知識(社内ドキュメントや前提情報)を与えられていない LLM が、もっともらしい顔のままおかしな方向に進む、という場面を何度も見てきました。STS2 のように細かな相互作用が勝敗を分けるゲームでも、同じことが起きると踏んでいます。とはいえ確かめないと意味がないので、後述のとおり LLM をベースラインとして実測します。
面白いことに、この Mod は作者自身が「言語モデルの意思決定能力を評価するベンチマークにしたい」と述べています。つまり LLM をベンチマークに置くという今回の方針は、Mod 本来の狙いとも一致しています。だから比較相手としては最適で、本体は別に作る、というわけです。
土台に使う Mod「STS2MCP」
ゼロからゲームに介入する仕組みを書く必要はありませんでした。コミュニティ製の Mod STS2MCP(Gennadiyev 氏) が、ゲームの状態取得と盤面操作を localhost の REST API として公開してくれています(ポートは 15526)。ゲームプレイ自体は改変せず、あくまで外部プログラムから読み書きするためのインターフェースです。
導入は、リリースの STS2_MCP.dll と STS2_MCP.json を <ゲームインストール先>/mods/ に置き、ゲーム内設定で Mod を有効化します(初回に同意ダイアログが出ます)。

起動すると localhost:15526 で HTTP サーバが立ち上がります。ブラウザでルートを叩くと、生きているかどうかが返ってきます。

先に言っておくと、ここは素直にはいきませんでした。公開版(v0.4.0)は少し前のゲームビルド向けで、私の環境(後述)ではそのまま噛み合いませんでした。対処は最後の「つまずき」の節にまとめます。
なお Mod の注意書きにもあるとおり、外部から状態を読み書きする以上、大事なセーブでは使わないほうが無難です。私は空きプロフィール(スロット 2/3)で動かしています。
ゲームの状態を読む
STS2 は Godot 4.5 系・.NET 9 で動いていて、Mod が返す状態はそこそこネストが深いです。たとえばメインメニューはこれくらい単純ですが――

――ゲーム開始直後のネオー(イベント)になると、各祝福(若葉・黄金の真珠・重い石板)の説明やキーワード定義まで丸ごと入ってきます。

これは、実際のゲーム画面とそのまま対応しています。

生 JSON のままでは扱いづらいので、game_api というラッパーモジュールを用意しました。状態の取得・整形と、各種行動メソッドをまとめたものです(Mod の docs を Claude Code に読ませて生成させました。中身は地味に大きいので、別途取り上げるかもしれません)。観測側は、要点を summary() に畳んで読めるようにしてあります。
import simple_agent # examples/simple_agent.py(戦略の本体)
from game_api import GameAPI, StateType # 自作ラッパー
api = GameAPI() # 既定 127.0.0.1:15526
assert api.ping(), "STS2MCP に接続できません。ゲームと Mod を確認してください。"
state = api.get_state()
print(state.summary())
print("available:", ", ".join(state.available_actions))
[event] | Act1 Floor1 A0 | アイアンクラッド HP 80/80 Gold 99
available: choose_event_option, advance_dialogue
ブラウザで見ていたネオーと同じ状態が、Python 側でも「いまネオーにいて、取れる手は choose_event_option か advance_dialogue」として見えています。available_actions を状態ごとに返してくれるのが地味に効きます。場面に応じて打てる手が変わるからです。
行動を送る — 観測→行動ループ
ボットの心臓は、「状態を観測する → 次の 1 手を決める → 送る」を繰り返す観測→行動ループです。今回はこのループが回る土台までを作りました。
手を決めるのは simple_agent.decide(state) です。(メソッド名, kwargs) か、打つ手がなければ None を返します。戦略の本体はここで、これから育てていく中核になります。いまは最小限のルールしか入っていません。
state = api.get_state()
action = simple_agent.decide(state)
print(state.state_type) # event
print(action) # ('choose_event_option', {'index': 0})
決めた手を実際に流すのが、次の step() です。execute=True のときだけ api.<method>(**kwargs) を呼びます(既定はプレビューで、ゲームには触れません)。
def step(execute: bool = False):
s = api.get_state()
act = simple_agent.decide(s)
print(s.summary())
if act is None:
print(" -> 取れる手なし / この雛形では未対応の画面")
return None
method, kwargs = act
print(f" -> {method}({kwargs})")
if execute:
res = getattr(api, method)(**kwargs)
print(" ", res.status, res.message or res.error)
return res
return act
step(execute=True)
[map] | Act1 Floor1 A0 | アイアンクラッド HP 80/80 Gold 99
-> choose_map_node({'index': 0})
ok Traveling to Monster at (1,1)
無人でマップ上の最初のノードを選び、(1,1) のモンスター戦へ歩いていきました。これを連続で回すのが run_loop() です。安全のため既定はドライランで、実操作は dry_run=False(空きプロフィール推奨)です。
simple_agent.run_loop(api, max_steps=200, dry_run=False, interval=2.0)
戦闘に入ると、こう動きました(一部抜粋)。
[000] [monster] | Act1 Floor4 A0 | アイアンクラッド HP 61/80 | Energy 3/3 | vs 細枝スライム (中)(21/27)
-> play_card({'card_index': 0, 'target': None})
ok: Playing '防御'
[001] [monster] | ... | Energy 2/3 | vs 細枝スライム (中)(21/27)
-> play_card({'card_index': 0, 'target': None})
ok: Playing '粘液'
[002] [monster] | ... | Energy 1/3 | vs 細枝スライム (中)(21/27)
-> play_card({'card_index': 0, 'target': 'TWIG_SLIME_M_0'})
ok: Playing 'ヘッドバット' targeting 細枝スライム (中)
[003] [card_select] | ...
-> select_card({'index': 0})
ok: Toggling card selection: 防御
[004] [monster] | ... | Energy 0/3 | vs 細枝スライム (中)(12/27)
-> end_turn({})
ok: Ending turn
防御を張り、粘液を出し、ヘッドバットでスライムを削り(HP 21 → 12)、エネルギーを使い切ってターンを返しています。TWIG_SLIME_M_0 のように対象指定もできていますし、card_select(カード選択画面)のような中間状態も拾えています。土台としては動いています。
正直な現状: すぐ綻びます
ただし、この v0 の戦略はあっさり破綻しました。少し回すと、こうなりました。
[006] [monster] | ... | Energy 3/3
-> play_card({'card_index': 0, 'target': None})
アクション失敗のため停止します: action 'play_card': Not in play phase - cannot act during enemy turn
decide() が「いまは敵のターン(プレイフェーズではない)」を考慮せず、カードを出そうとして弾かれています。これは API の問題ではなく、ボット側(decide())がまだターンフェーズを見ていない、という未対応です。自分のターン/敵のターン/カード選択中…といった状態機械を、戦略の前にちゃんと扱う必要があります。第 2 回で最初に潰すところとして、隠さず記録しておきます。
つまずき: Early Access のバージョン差分(と、その片付け方)
技術的に一番の関門は、アルゴリズムではなくバージョンでした。
STS2 は Early Access で、頻繁に更新されます。私の環境は v0.107.0(2026.06.04)(前掲のゲーム画面の右上に表示)でしたが、公開されている Mod(v0.4.0 系)はより前のビルド向けで、そのままでは動きませんでした。EA のゲームに何かを作るとき、この「ゲームが更新されるたびに Mod が壊れる」常時メンテこそが、一番こわい部分です。本業でも、外部仕様が動く相手にデータパイプラインを維持するのと同じ性質の問題で、ここが続かないと企画ごと頓挫します。
具体的に何が壊れていたかというと、こうでした。
| 壊れていたゲーム API | 対応 |
|---|---|
CombatManager.IsPlayPhase(廃止。戦闘中の 500 エラーの主因。7 箇所) | player.PlayerCombatState.Phase == PlayerTurnPhase.Play(互換ヘルパー IsInPlayPhase を追加) |
Creature.CombatState が ICombatState 化(2 箇所) | ResolveTarget の引数を ICombatState に変更 |
MerchantRoom.Inventory → Inventories(リスト化、2 箇所) | Inventories.FirstOrDefault() に変更 |
一番上の CombatManager.IsPlayPhase の廃止が、戦闘中に 500 エラーを連発させていた主因で、これは私が見つけました。実体は「無くなったメソッドを呼んだことによる MissingMethodException」で、状態取得(StateBuilder.cs)とアクションのガードの両方で落ちていました。残り 2 つは、IsPlayPhase を直したあと現行 DLL へ向けて再コンパイルしたら、コンパイルエラーとして芋づる式に出てきたものです。コンパイラを差分検出器として使うかたちで、私が気づいてもいなかった破綻まで一度に洗い出せました。
対処はこうしました。Mod のソースと docs を Claude Code に渡し、現行ビルド向けに更新させ、ついでに前述の Python ラッパーも生成させました。結果として更新は一発で通り、しかも上のとおり私が踏んでいないバージョン間の差分まで拾って直してくれました。私が手で C# のコードを追ったわけではありません。
修正後、ゲームを再起動して実機の戦闘で確認すると、さっきまで 500 だった状態取得が 200 で返ってきました。
RESULT: COMBAT_OK http=200 state=monster turn=player is_play_phase=True enemy0=リーフスライム (小)/11
修正 Mod → HTTP API → game_api → 型付きの状態、というパイプライン全体が、戦闘でひととおり通りました。
さらに効いたのが、更新手順を Claude Code のメモリに保存したことです。これで次にゲームが更新されても、同じ手順で追従できます。「EA の常時メンテ」という最大の不安要素を、AI ツールで“定常作業”に落とせた、という感触があります。本業でデータ基盤や ML の運用に AI ツールを組み込んでいる感覚と、まったく地続きでした。このブログを AI と並走でまた始めたのも、結局は同じ筋の話です。
ちなみに、この直したフェーズ判定(PlayerTurnPhase.Play の判定)が正しく働くと、敵ターン中の行動は「Not in play phase」とクリーンに弾かれます。以前のように 500 で落ちるのではなく、ボットが尊重すべき明確なシグナルとして返ってくる。先ほどの戦闘ログ末尾で出ていたのは、まさにこの状態です。あとはボット側が対応するだけで、それが第 2 回の話になります。
正直に補足しておくと、ですからこの段階は「自力でモッディングを頑張った」話ではありません。土台は、既存 Mod + AI ツールで一気に組みました。価値はこの先の判断エンジンにあります。
次回: 戦闘の自動化
第 2 回は、ここで露呈した状態機械の未対応を潰しつつ、戦闘の自動化に踏み込みます。内部シミュレータ+探索で、1 戦を勝ち切るところまで。あわせて、LLM ベースラインの初回計測もこの回でやる予定です。
筆者について
コーディング歴 20 年、いまも現役で手を動かすエンジニアリングマネジャー兼データサイエンティスト。データ基盤と AI ワークフローが専門。