LLMにFactorioを遊ばせてみた
皆さんFactorioをご存知でしょうか?
宇宙飛行の途中に未知の惑星に不時着してしまったエンジニアが、脱出用ロケットを完成させることをゴールとして石炭・鉄・銅といった資源を採掘して様々な設備・素材を生産しながら自動化工場を構築・拡張していくゲームなのですが、ベルトコンベアを使った物流システムを駆使した生産の効率化・自動化要素が奥深く、あまたのエンジニアを廃人にしてきたことで有名なゲームです。
先日、そんなFactorioを題材としたプレプリントが公開されました。
この論文では、FactorioをLLMのベンチマークとして使うことを提案しています。LLM技術の進歩が目覚ましい昨今ではベンチマークの飽和が大きな課題となっており、Factorioのように規模がログスケールで変化するようなゲームを利用することで、LLMの長期的なプランニング能力や扱える情報量を評価できるとしています。
FactorioではMinecraftのようなゲームと同様に、マルチプレイヤーサーバーを立ててそこに複数人が接続してプレイする形式がサポートされており、このサーバーに対してRCONというプロトコルで接続することでプログラム経由でゲーム内の操作を実行することができます。
今回の論文ではこの仕組みを用いて、ゲーム内の操作を抽象化してPythonコードとしてツール化し、LLMにこれらを使ったPythonスクリプトを生成させることで「LLMにFactorioをプレイさせる」ことを実現しています。
※実際のプレイとは異なり、画面を見てコントローラーで操作しているわけではないことに注意が必要です。「自分に一番近い水の位置を取得する」「蒸気機関と採掘機を電柱で繋ぐ」といったアクションが一発で実行できるようになっており、失敗した場合には原因を示すエラーメッセージが返されるようになっています。
実際に試す
コードがMITライセンスで公開されているので、実際に試してみます。
※Factorioのライセンスが必要です
基本的にはREADMEに従ってセットアップしていけばOKです。自作フォーク版 で変更内容も公開しているので参考にしてください。
- 依存関係のインストール
uvがサポートされているので uv venv && source .venv/binv/activate && uv sync
でセットアップできます。
- Dockerイメージのビルド
factorio-docker をベースとした独自dockerイメージをビルドします。
cd cluster/docker && docker build -t factorio .
- ヘッドレスサーバーの立ち上げ
ビルドしたイメージを使ってサーバーを起動します。
cd cluster/local && docker compose -f docker-compose-1.yml up -d
- Factorioでログイン
Factorioのゲームクライアントで「マルチプレイ > IPアドレスを指定」で localhost:34197
に接続し、無事ログインできればOKです。
このままワールド内閲覧用の画面としてログインしたままにしておきます。^1
- タスク・LLMモデルの設定
eval/open/independent_runs/run_config_example_lab_play.json
を編集してタスクとLLMモデルを設定します。
[
{"task": "electronic_circuit_throughput_16.json", "model": "open-router-anthropic/claude-3.5-sonnet"}
]
ここでは「電子基盤を16以上のスループットで生産する」というタスクを設定しました。^2
モデルはOpenRouter経由で、元論文で最も性能が良かったとされるClaude-3.5-Sonnetを設定しています。
- データベースの設定
評価記録を保存するバックエンドを設定する必要があります。READMEに従い、コード内で create_db_client()
の実装をPostgresからSQLiteに差し替えるのが簡単です。(2箇所あるので注意)
sqliteでテーブルを作成しておく必要があります。
$ sqlite3 mydatabase.db
> CREATE TABLE programs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL,
value REAL DEFAULT 0.0,
visits INTEGER DEFAULT 0,
parent_id INTEGER,
state_json TEXT,
conversation_json TEXT NOT NULL,
completion_token_usage INTEGER,
prompt_token_usage INTEGER,
token_usage INTEGER,
response TEXT,
holdout_value REAL,
raw_reward REAL,
version INTEGER DEFAULT 1,
version_description TEXT DEFAULT '',
model TEXT DEFAULT 'gpt-4o',
meta TEXT,
achievements_json TEXT,
instance INTEGER DEFAULT -1,
depth REAL DEFAULT 0.0,
advantage REAL DEFAULT 0.0,
ticks INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
- 環境変数の設定
最後に example.env
をコピーして .env
を作成し、必要な環境変数を設定します。
# 利用するモデルのAPIキーを入れる
OPENAI_API_KEY=XXX
...
OPEN_ROUTER_API_KEY=sk-or-...
# 上で作成したデータベースファイルを指定
SQLITE_DB_FILE=mydatabase.db
...
- 実行
これでセットアップは完了です。
あとは python eval/open/independent_runs/run.py --run_config=eval/open/independent_runs/run_config_example_lab_play.json
を実行することでセットアップが走り、その後LLMの推論が走って操作が始まります。
プレイ結果
Labモードは整備された資源環境と手元にあらかじめ主要な設備が与えられた上で、与えられたタスクを完了するまでの時間を計測する設定となっています。
電子基盤を生産する今回のタスクでは、約15分、17ステップでタスクを完了させることができました。APIの利用料金は$3.6程度でした。
例えばこちらが1ステップ目にClaudeが生成したコードとその実行結果になります。
# First find water for power generation
water_pos = nearest(Resource.Water)
print(f"Found water at {water_pos}")
# Place offshore pump
move_to(water_pos)
offshore_pump = place_entity(Prototype.OffshorePump, position=water_pos)
print(f"Placed offshore pump at {offshore_pump.position}")
# Place boiler near the offshore pump with space for connections
building_box = BuildingBox(width=Prototype.Boiler.WIDTH + 4, height=Prototype.Boiler.HEIGHT + 4)
boiler_coords = nearest_buildable(Prototype.Boiler, building_box, offshore_pump.position)
move_to(boiler_coords.center)
boiler = place_entity(Prototype.Boiler, position=boiler_coords.center, direction=Direction.LEFT)
print(f"Placed boiler at {boiler.position}")
# Add coal to boiler
boiler = insert_item(Prototype.Coal, boiler, 50)
print(f"Added 50 coal to boiler")
# Place steam engine
building_box = BuildingBox(width=Prototype.SteamEngine.WIDTH + 4, height=Prototype.SteamEngine.HEIGHT + 4)
engine_coords = nearest_buildable(Prototype.SteamEngine, building_box, boiler.position)
move_to(engine_coords.center)
steam_engine = place_entity(Prototype.SteamEngine, position=engine_coords.center, direction=Direction.LEFT)
print(f"Placed steam engine at {steam_engine.position}")
# Connect everything with pipes
water_pipes = connect_entities(offshore_pump, boiler, Prototype.Pipe)
print(f"Connected offshore pump to boiler with pipes")
steam_pipes = connect_entities(boiler, steam_engine, Prototype.Pipe)
print(f"Connected boiler to steam engine with pipes")
# Wait for power generation to start
sleep(5)
steam_engine = get_entity(Prototype.SteamEngine, steam_engine.position)
assert steam_engine.energy > 0, "Steam engine is not generating power"
print(f"Power generation system is operational!")
print(f"Current inventory {inspect_inventory()}")
print(f"Entities on the map: {get_entities()}")
==================================================
>>> 3: ('Found water at x=-11.0 y=0.0',)[1m
>>> 8: ('Placed offshore pump at x=-9.5 y=1.5',)[1m
>>> 15: ('Placed boiler at x=-5.0 y=4.5',)[1m
>>> 19: ('Added 50 coal to boiler',)[1m
>>> 26: ('Placed steam engine at x=0.5 y=8.5',)[1m
>>> 30: ('Connected offshore pump to boiler with pipes',)[1m
>>> 32: ('Connected boiler to steam engine with pipes',)[1m
>>> 38: ('Power generation system is operational!',)[1m
>>> 39: ('Current inventory {'wooden-chest': 10, 'storage-tank': 10, 'transport-belt': 500, 'underground-belt': 100, 'burner-inserter': 50, 'inserter': 50, 'small-electric-pole': 500, 'pipe': 485, 'pipe-to-ground': 100, 'boiler': 1, 'steam-engine': 1, 'burner-mining-drill': 50, 'electric-mining-drill': 50, 'offshore-pump': 1, 'pumpjack': 10, 'stone-furnace': 10, 'electric-furnace': 10, 'assembling-machine-2': 10, 'oil-refinery': 5, 'chemical-plant': 5, 'coal': 450}',)[1m
>>> 41: ('Entities on the map: [[1m
>>> OffshorePump(name='offshore-pump', direction=Direction.RIGHT, position=Position(x=-9.5, y=1.5), energy=0.0, status=EntityStatus.WORKING, neighbours=[Entity(name='pipe', direction=RIGHT, position=Position(x=-8.5 y=1.5)], connection_points=[Position(x=-8.5, y=1.5)], fluid_box=[{'name': 'water', 'amount': 98.999999999962, 'temperature': 15}], fluid_systems=[4], height=1.0, width=1.0), [1m
>>> Boiler(fuel=Inventory({'coal': 44}), name='boiler', direction=Direction.LEFT, position=Position(x=-5.0, y=4.5), energy=2000.0, status=EntityStatus.WORKING, neighbours=[Entity(name='pipe', direction=LEFT, position=Position(x=-6.5 y=5.5), Entity(name='pipe', direction=LEFT, position=Position(x=-6.5 y=4.5)], connection_points=[Position(x=-4.5, y=2.5), Position(x=-4.5, y=6.5)], fluid_box=[{'name': 'water', 'amount': 199.84999969803, 'temperature': 15}], fluid_systems=[4], steam_output_point=Position(x=-6.5, y=4.5), height=2.0, width=3.0), [1m
>>> Generator(name='steam-engine', direction=Direction.RIGHT, position=Position(x=0.5, y=8.5), energy=15000.0, warnings=['not connected to power network'], status=EntityStatus.NOT_PLUGGED_IN_ELECTRIC_NETWORK, neighbours=[Entity(name='character', direction=DOWN, position=Position(x=-0.5 y=10.5)], connection_points=[Position(x=3.0, y=8.5), Position(x=-2.0, y=8.5)], fluid_box=[{'name': 'steam', 'amount': 160.45814267758, 'temperature': 165}], fluid_systems=[6], height=5.0, width=3.0), [1m
>>> PipeGroup(fluid_system=4, fluid=water position=x=-8.5 y=1.5, status=EntityStatus.WORKING, pipes=[(x=-8.5,y=1.5),(x=-6.5,y=1.5),(x=-7.5,y=1.5),(x=-4.5,y=1.5),(x=-5.5,y=1.5),(x=-4.5,y=2.5)]), [1m
>>> PipeGroup(fluid_system=6, fluid=steam position=x=-6.5 y=5.5, status=EntityStatus.WORKING, pipes=[(x=-6.5,y=5.5),(x=-6.5,y=4.5),(x=-6.5,y=7.5),...,(x=-5.5,y=8.5),(x=-2.5,y=8.5),(x=-3.5,y=8.5)])]',)[0m
このコードでは、
- 水を見つけて水汲みポンプを設置する
- 近くの設置可能な場所にボイラーを設置する
- 近くの設置可能な場所に蒸気機関を設置する
- パイプで接続する
- 石炭の採掘場所を見つけて石炭採掘機を設置し、ベルトでボイラーに引いてくる
- ボイラーにいくつか石炭を入れて発電を開始する
という処理を正確に実行できていることがわかります。
いきなり発電所が爆誕
# Now I'll start building the electronic circuit production line. Electronic circuits require copper cables and iron plates. Let's set up the mining and smelting first.
# Find iron ore patch
iron_pos = nearest(Resource.IronOre)
print(f"Found iron ore at {iron_pos}")
# Place electric mining drill for iron
building_box = BuildingBox(width=Prototype.ElectricMiningDrill.WIDTH + 2, height=Prototype.ElectricMiningDrill.HEIGHT + 2)
drill_coords = nearest_buildable(Prototype.ElectricMiningDrill, building_box, iron_pos)
move_to(drill_coords.center)
iron_drill = place_entity(Prototype.ElectricMiningDrill, position=drill_coords.center, direction=Direction.DOWN)
print(f"Placed electric mining drill for iron at {iron_drill.position}")
# Place furnace to catch iron ore
iron_furnace = place_entity_next_to(Prototype.StoneFurnace, iron_drill.position, direction=Direction.DOWN)
print(f"Placed furnace for iron at {iron_furnace.position}")
# Add coal to furnace
iron_furnace = insert_item(Prototype.Coal, iron_furnace, 50)
print(f"Added 50 coal to iron furnace")
# Connect power to iron drill
poles = connect_entities(steam_engine, iron_drill, Prototype.SmallElectricPole)
print(f"Connected power to iron drill")
# Find copper ore patch
copper_pos = nearest(Resource.CopperOre)
print(f"Found copper ore at {copper_pos}")
# Place electric mining drill for copper
building_box = BuildingBox(width=Prototype.ElectricMiningDrill.WIDTH + 2, height=Prototype.ElectricMiningDrill.HEIGHT + 2)
drill_coords = nearest_buildable(Prototype.ElectricMiningDrill, building_box, copper_pos)
move_to(drill_coords.center)
copper_drill = place_entity(Prototype.ElectricMiningDrill, position=drill_coords.center, direction=Direction.DOWN)
print(f"Placed electric mining drill for copper at {copper_drill.position}")
# Place furnace to catch copper ore
copper_furnace = place_entity_next_to(Prototype.StoneFurnace, copper_drill.position, direction=Direction.DOWN)
print(f"Placed furnace for copper at {copper_furnace.position}")
# Add coal to furnace
copper_furnace = insert_item(Prototype.Coal, copper_furnace, 50)
print(f"Added 50 coal to copper furnace")
# Connect power to copper drill
poles = connect_entities(steam_engine, copper_drill, Prototype.SmallElectricPole)
print(f"Connected power to copper drill")
# Wait for some initial plates to be produced
sleep(15)
print(f"Mining and smelting system set up")
print(f"Current inventory: {inspect_inventory()}")
print(f"Entities on map: {get_entities()}")
==================================================
>>> 5: ('Found iron ore at x=15.5 y=70.5',)[1m
>>> 12: ('Placed electric mining drill for iron at x=18.5 y=73.5',)[1m
>>> 16: ('Placed furnace for iron at x=19.0 y=76.0',)[1m
>>> 20: ('Added 50 coal to iron furnace',)[1m
>>> 24: ('Connected power to iron drill',)[1m
>>> 28: ('Found copper ore at x=-46.5 y=73.5',)[1m
>>> 35: ('Placed electric mining drill for copper at x=-48.5 y=81.5',)[1m
>>> 39: ('Placed furnace for copper at x=-48.0 y=84.0',)[1m
>>> 43: ('Added 50 coal to copper furnace',)[1m
>>> 47: ('Connected power to copper drill',)[1m
>>> 52: ('Mining and smelting system set up',)[1m
>>> 53: ("Current inventory: {'wooden-chest': 10, 'storage-tank': 10, 'transport-belt': 500, 'underground-belt': 100, 'burner-inserter': 50, 'inserter': 50, 'small-electric-pole': 456, 'pipe': 485, 'pipe-to-ground': 100, 'boiler': 1, 'steam-engine': 1, 'burner-mining-drill': 50, 'electric-mining-drill': 48, 'offshore-pump': 1, 'pumpjack': 10, 'stone-furnace': 8, 'electric-furnace': 10, 'assembling-machine-2': 10, 'oil-refinery': 5, 'chemical-plant': 5, 'coal': 350}",)[1m
>>> 54: ("Entities on map: [[1m
>>> \tOffshorePump(name='offshore-pump', direction=Direction.RIGHT, position=Position(x=-9.5, y=1.5), energy=0.0, status=EntityStatus.WORKING, neighbours=[Entity(name='pipe', direction=RIGHT, position=Position(x=-8.5 y=1.5)], connection_points=[Position(x=-8.5, y=1.5)], fluid_box=[{'name': 'water', 'amount': 99.899999998981, 'temperature': 15}], fluid_systems=[21], height=1.0, width=1.0), [1m
>>> \tBoiler(fuel=Inventory({'coal': 29}), name='boiler', direction=Direction.LEFT, position=Position(x=-5.0, y=4.5), energy=28999.99999148, status=EntityStatus.WORKING, neighbours=[Entity(name='pipe', direction=LEFT, position=Position(x=-6.5 y=4.5), Entity(name='pipe', direction=LEFT, position=Position(x=-6.5 y=5.5)], connection_points=[Position(x=-4.5, y=2.5), Position(x=-4.5, y=6.5)], fluid_box=[{'name': 'water', 'amount': 199.98499995154, 'temperature': 15}, {'name': 'steam', 'amount': 199.89999999972, 'temperature': 165}], fluid_systems=[21], steam_output_point=Position(x=-6.5, y=4.5), height=2.0, width=3.0), [1m
>>> \tGenerator(name='steam-engine', direction=Direction.RIGHT, position=Position(x=0.5, y=8.5), energy=12000.0, status=EntityStatus.WORKING, neighbours=[Entity(name='small-electric-pole', direction=DOWN, position=Position(x=0.5 y=10.5)], connection_points=[Position(x=3.0, y=8.5), Position(x=-2.0, y=8.5)], fluid_box=[{'name': 'steam', 'amount': 199.97749993541, 'temperature': 165}], fluid_systems=[24], height=5.0, width=3.0), [1m
>>> \tElectricMiningDrill(electrical_id=4, name='electric-mining-drill', direction=Direction.DOWN, position=Position(x=18.5, y=73.5), energy=1600.0, status=EntityStatus.WORKING, neighbours=[Entity(name='stone-furnace', direction=DOWN, position=Position(x=19.0 y=76.0)], drop_position=Position(x=18.5, y=75.5), resources=[Ingredient(name='iron-ore', count=249989, type=None)], height=3.0, width=3.0), [1m
>>> \tFurnace(fuel=Inventory({'coal': 49}), name='stone-furnace', direction=Direction.UP, position=Position(x=19.0, y=76.0), energy=1600.0, status=EntityStatus.WORKING, furnace_source=Inventory({'iron-ore': 8}), furnace_result=Inventory({'iron-plate': 6}), height=2.0, width=2.0), [1m
>>> \tElectricMiningDrill(electrical_id=4, name='electric-mining-drill', direction=Direction.DOWN, position=Position(x=-48.5, y=81.5), energy=1600.0, status=EntityStatus.WORKING, neighbours=[Entity(name='small-electric-pole', direction=UP, position=Position(x=-46.5 y=79.5), Entity(name='stone-furnace', direction=DOWN, position=Position(x=-48.0 y=84.0)], drop_position=Position(x=-48.5, y=83.5), resources=[Ingredient(name='copper-ore', count=249992, type=None)], height=3.0, width=3.0), [1m
>>> \tFurnace(fuel=Inventory({'coal': 49}), name='stone-furnace', direction=Direction.UP, position=Position(x=-48.0, y=84.0), energy=1600.0, status=EntityStatus.WORKING, furnace_source=Inventory({'copper-ore': 6}), furnace_result=Inventory({'copper-plate': 4}), height=2.0, width=2.0), [1m
>>> \tPipeGroup(fluid_system=21, fluid=water position=x=-8.5 y=1.5, status=EntityStatus.WORKING, pipes=[(x=-8.5,y=1.5),(x=-7.5,y=1.5),(x=-6.5,y=1.5),(x=-5.5,y=1.5),(x=-4.5,y=1.5),(x=-4.5,y=2.5)]), [1m
>>> \tPipeGroup(fluid_system=24, fluid=steam position=x=-6.5 y=4.5, status=EntityStatus.WORKING, pipes=[(x=-6.5,y=4.5),(x=-6.5,y=5.5),(x=-6.5,y=6.5),...,(x=-4.5,y=8.5),(x=-3.5,y=8.5),(x=-2.5,y=8.5)]), \tElectricityGroup(id=4, poles=[(x=0.5,y=26.5),(x=0.5,y=18.5),(x=0.5,y=22.5),...,(x=0.5,y=62.5),(x=-46.5,y=79.5),(x=-45.5,y=77.5)], voltage=3643700)]",)[0m
続く2ステップ目では、早速鉄鉱床と銅鉱床に電気採掘機を設置して電気を接続し、炭焼き窯で精錬するところまでを完成させてしまいました。^3
鉄・銅の生産ラインが早くも完成
といった調子で、最終的には組み立て機も設置して銅線を作成、鉄板と組み合わせて電子基盤を生産するところまでを17ステップで完了してしまいました。
Well done!
途中はかなりカオスな配線も見られたのですが、都度リファクタリングを行うような挙動も見られ、最終的にはかなりすっきりしたレイアウトに落ち着いています。
ただし、石炭ラインは自動化できておらず、最後まで自分で石炭を入れていました。今回の設定だと毎ターン自分で石炭を補充していればなんとかなったので、必要性を感じなかったのかもしれません。
なんで一周させたの?
元々左下に伸びていた鉄板生産ラインをシンプルに作り直しているところ
最終的な全体レイアウト
なお、Gemeni-2.0-Flash-Thinking-Experimentalでも試してみましたが、謎の大量石焼窯工場を生成し続けて終了してしまいました。Claudeの実務能力がいかにずば抜けているかがわかりますね。。。
ただしClaudeでも一度ハマると抜け出せなくなるようなパターンも見られました。失敗からの復帰能力 (error correction) が少なくともFactorioにおいては性能を分ける要因になりそうです。
Geminiは石焼窯がお好きなご様子
おわりに
Claudeのポケモン配信 を見てLLMにゲームをプレイさせるのを一度はやってみたいと思っていたので、自分の好きなFactorioというゲームで試せる機会ができたのはとても嬉しいですね。
今回はLab環境で予めアイテムも与えられてのプレイだったので、やっぱり全てをクラフトする必要のあるOpen環境でLLMがどこまでいけるのか試してみたいですよね。引き続き遊んでみようと思います。
Discussion
私もゲームでLLMをつかってみたく、チャレンジしています。今夜やっとFactorioとサーバー接続ができました。私はchatGPTで試してみたいと思っています。その場合は、modelの指定はどのようになるのでしょうか?アドバイスいただけると嬉しいです。
[
{"task": "electronic_circuit_throughput_16.json", "model": "open-router-anthropic/claude-3.5-sonnet"}
]
自己解決しました。以下をみれば、例がありました。お騒がせしました。
eval/open/independent_runs/run_config.json