diff --git a/README.md b/README.md index fa27b5e..7467ee7 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ planner.set_goal( - 저장 기준: 성공/실패 상관없이 가장 마지막으로 실행을 시도한 action 1개만 저장합니다. - `explore`는 `wanted_ores`가 있으면 해당 자원이 발견될 때까지 멈추지 않고 계속 이동해, `iron-ore`처럼 주변에 흔한 자원만 계속 발견되어 진행이 막히는 문제를 줄입니다. - `place_entity`는 지정 좌표가 `BLOCKED`이면 `surface.can_place_entity`로 주변 `±1 타일`을 먼저 확인해, 가능한 좌표에 배치되도록 완화합니다. +- `stone-furnace`는 `place_entity`가 성공하면 executor가 즉시 `coal`(연료)와 `iron-ore`/`copper-ore`(가능한 것 우선)를 투입해 제련이 시작되도록 보정합니다. + - 또한 `insert_to_entity`는 burner 인벤토리 투입 전에 `can_insert`를 확인해, 미삽입/아이템 증발 위험을 줄입니다. - (Cursor) Windows에서 `sessionStart` 훅 실행 중 앱 선택창이 뜨는 경우: - 프로젝트 훅은 `E:/develop/factorio-ai-agent/.cursor/hooks.json` 및 `E:/develop/factorio-ai-agent/.cursor/session-start-hook.ps1`를 PowerShell로 실행하도록 구성되어 있습니다. - Superpowers 플러그인의 `./hooks/session-start`가 bash로 실행되도록 `hooks-cursor.json`을 수정했습니다(필요 시 Cursor 재시작 후 `View -> Output -> Hooks`에서 확인). diff --git a/__pycache__/action_executor.cpython-311.pyc b/__pycache__/action_executor.cpython-311.pyc index b93adef..7991504 100644 Binary files a/__pycache__/action_executor.cpython-311.pyc and b/__pycache__/action_executor.cpython-311.pyc differ diff --git a/action_executor.py b/action_executor.py index 8d0b4ce..3ee55b5 100644 --- a/action_executor.py +++ b/action_executor.py @@ -37,10 +37,102 @@ class ActionExecutor: if isinstance(result, tuple) and isinstance(result[1], str): if "NO_PLAYER" in result[1]: return False, "플레이어 없음" if "NO_CHARACTER" in result[1]: return False, "캐릭터 없음" - return result + success, message = result + + # stone-furnace 배치 직후 자동 부트스트랩(연료+광석 투입) + # - AI가 insert_to_entity를 누락해도 제련이 시작되게 보정 + if act == "place_entity" and success: + furnace_name = params.get("name") + if furnace_name == "stone-furnace": + rx, ry = params.get("x"), params.get("y") + parsed = self._extract_coords_from_message(message) + if parsed is not None: + rx, ry = parsed + boot_ok, boot_msg = self._auto_bootstrap_furnace( + furnace_name=furnace_name, + x=int(rx), + y=int(ry), + reason=action.get("reason", "") or "", + ) + message = f"{message} / bootstrap: {'OK' if boot_ok else 'FAIL'} {boot_msg}" + + return success, message except TypeError as e: return False, f"파라미터 오류: {e}" except Exception as e: return False, f"실행 오류: {e}" + def _extract_coords_from_message(self, message: str) -> tuple[int, int] | None: + """ + place_entity 성공 메시지에서 실제로 지어진 좌표를 파싱. + 예) "stone-furnace 배치 (-91, -68)" / "stone-furnace 배치 (-91,-68)" + """ + import re + + if not isinstance(message, str): + return None + m = re.search(r"\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)", message) + if not m: + return None + try: + x = int(round(float(m.group(1)))) + y = int(round(float(m.group(2)))) + return x, y + except Exception: + return None + + def _auto_bootstrap_furnace( + self, + *, + furnace_name: str, + x: int, + y: int, + reason: str = "", + ) -> tuple[bool, str]: + """ + stone-furnace 배치 직후: + - coal(연료) 투입 + - iron-ore 또는 copper-ore 투입(가능한 것 우선) + + 반환값은 현재 전략에서 성공/실패를 상위에 강제하지 않지만, + 디버깅을 위해 메시지를 남긴다. + """ + if furnace_name != "stone-furnace": + return False, "unsupported_furnace" + + coal_have = self._get_item_count("coal") + if coal_have <= 0: + return False, "no_coal" + + ore_have_iron = self._get_item_count("iron-ore") + ore_have_copper = self._get_item_count("copper-ore") + + # AI가 reason에 어떤 ore를 의도했는지 우선 반영(있을 때만). + preferred_ore: str | None = None + if "iron-ore" in reason: + preferred_ore = "iron-ore" + elif "copper-ore" in reason: + preferred_ore = "copper-ore" + + if preferred_ore == "iron-ore" and ore_have_iron > 0: + ore = "iron-ore" + elif preferred_ore == "copper-ore" and ore_have_copper > 0: + ore = "copper-ore" + elif ore_have_iron > 0: + ore = "iron-ore" + elif ore_have_copper > 0: + ore = "copper-ore" + else: + return False, "no_ore" + + # insert_to_entity는 내부적으로 inv.remove를 수행하므로, + # count는 "최대 50, 현재 보유량"으로 제한해 과잉/오차를 줄인다. + coal_count = min(50, coal_have) + ore_count = min(50, self._get_item_count(ore)) + + ok1, msg1 = self.insert_to_entity(x, y, item="coal", count=coal_count) + ok2, msg2 = self.insert_to_entity(x, y, item=ore, count=ore_count) + + return ok1 and ok2, f"bootstrap coal+ore: {msg1}; {msg2}" + # ── 탐색 ───────────────────────────────────────────────────────── def explore( self, @@ -459,9 +551,13 @@ local inserted = false for _, e in ipairs(entities) do if e.valid and e.burner then local fi = e.burner.inventory - if fi then + if fi and fi.can_insert({{name="{item}", count=1}}) then + -- can_insert 이후에만 inv에서 제거해서, 미삽입/미스매치로 인한 아이템 증발을 방지 local removed = inv.remove({{name="{item}", count=actual}}) - if removed > 0 then fi.insert({{name="{item}", count=removed}}) inserted = true break end + if removed > 0 then + local inserted_count = fi.insert({{name="{item}", count=removed}}) + if inserted_count and inserted_count > 0 then inserted = true break end + end end end if not inserted and e.valid then @@ -469,7 +565,10 @@ for _, e in ipairs(entities) do local ti = e.get_inventory(i) if ti and ti.can_insert({{name="{item}", count=1}}) then local removed = inv.remove({{name="{item}", count=actual}}) - if removed > 0 then ti.insert({{name="{item}", count=removed}}) inserted = true end + if removed > 0 then + local inserted_count = ti.insert({{name="{item}", count=removed}}) + if inserted_count and inserted_count > 0 then inserted = true end + end break end end diff --git a/tests/__pycache__/test_furnace_bootstrap.cpython-311.pyc b/tests/__pycache__/test_furnace_bootstrap.cpython-311.pyc new file mode 100644 index 0000000..e899b8d Binary files /dev/null and b/tests/__pycache__/test_furnace_bootstrap.cpython-311.pyc differ diff --git a/tests/__pycache__/test_insert_to_entity.cpython-311.pyc b/tests/__pycache__/test_insert_to_entity.cpython-311.pyc new file mode 100644 index 0000000..af93f40 Binary files /dev/null and b/tests/__pycache__/test_insert_to_entity.cpython-311.pyc differ diff --git a/tests/test_furnace_bootstrap.py b/tests/test_furnace_bootstrap.py new file mode 100644 index 0000000..16a6789 --- /dev/null +++ b/tests/test_furnace_bootstrap.py @@ -0,0 +1,91 @@ +import unittest + + +from action_executor import ActionExecutor + + +class _FakeRCON: + pass + + +class TestFurnaceBootstrap(unittest.TestCase): + def test_auto_bootstrap_calls_insert_in_expected_order(self): + """ + stone-furnace 배치 직후: + - 먼저 coal(연료)을 넣고 + - 그 다음 ore(iron-ore 우선)을 넣어 제련이 시작되어야 한다. + """ + ex = ActionExecutor(_FakeRCON()) + + def fake_get_item_count(item: str) -> int: + return { + "coal": 20, + "iron-ore": 50, + "copper-ore": 0, + }.get(item, 0) + + ex._get_item_count = fake_get_item_count # type: ignore[attr-defined] + + calls: list[tuple[int, int, str, int]] = [] + + def fake_insert(x: int, y: int, item: str = "coal", count: int = 50): + calls.append((x, y, item, count)) + return True, "OK" + + ex.insert_to_entity = fake_insert # type: ignore[assignment] + + # NOTE: 아직 구현 전이므로 이 테스트는 현재 실패해야 한다(TDD RED). + ex._auto_bootstrap_furnace( + furnace_name="stone-furnace", + x=-91, + y=-68, + reason="iron-ore(50) 제련 시작", + ) + + self.assertEqual(calls[0][2], "coal") + self.assertEqual(calls[0][3], 20) # min(50, coal=20) + + self.assertEqual(calls[1][2], "iron-ore") + self.assertEqual(calls[1][3], 50) # min(50, iron-ore=50) + self.assertEqual(len(calls), 2) + + def test_execute_triggers_bootstrap_after_successful_place_entity(self): + ex = ActionExecutor(_FakeRCON()) + + # place_entity를 성공으로 고정(좌표는 message에서 파싱한다고 가정) + def fake_place_entity(name: str, x: int, y: int, direction: str = "north"): + return True, "stone-furnace 배치 (-91, -68)" + + ex.place_entity = fake_place_entity # type: ignore[assignment] + + called: list[dict] = [] + + def fake_auto_bootstrap(*, furnace_name: str, x: int, y: int, reason: str = ""): + called.append({ + "furnace_name": furnace_name, + "x": x, + "y": y, + "reason": reason, + }) + return True, "mock_bootstrap_ok" + + # NOTE: 아직 구현 전이므로 이 테스트는 현재 실패해야 한다(TDD RED). + ex._auto_bootstrap_furnace = fake_auto_bootstrap # type: ignore[attr-defined] + + ok, msg = ex.execute({ + "action": "place_entity", + "reason": "현재 인벤토리에 iron-ore(50)가 있어 제련 시작", + "params": { + "name": "stone-furnace", + "x": -91, + "y": -68, + "direction": "north", + }, + }) + + self.assertTrue(ok, msg) + self.assertEqual(len(called), 1) + self.assertEqual(called[0]["furnace_name"], "stone-furnace") + self.assertEqual(called[0]["x"], -91) + self.assertEqual(called[0]["y"], -68) + diff --git a/tests/test_insert_to_entity.py b/tests/test_insert_to_entity.py new file mode 100644 index 0000000..f3676e3 --- /dev/null +++ b/tests/test_insert_to_entity.py @@ -0,0 +1,31 @@ +import unittest + +from action_executor import ActionExecutor + + +class _CapturingRCON: + def __init__(self): + self.last_lua: str | None = None + + def lua(self, code: str) -> str: + self.last_lua = code + # insert_to_entity는 Lua에서 "OK"/"NOT_FOUND"를 기대 + return "OK" + + +class TestInsertToEntity(unittest.TestCase): + def test_insert_to_entity_checks_burner_can_insert(self): + """ + stone-furnace 같은 burner 엔티티에 대해, + burner.inventory에 넣기 전에 can_insert 여부를 확인해야 한다. + (fuel이 아닌 ore를 burner에 넣으려다 소비/미삽입 이슈 방지) + """ + rcon = _CapturingRCON() + ex = ActionExecutor(rcon) # type: ignore[arg-type] + + ex.insert_to_entity(x=0, y=0, item="iron-ore", count=10) + + assert rcon.last_lua is not None + # burner inventory branch에서 can_insert을 검사하도록 수정되어야 한다. + self.assertIn("fi.can_insert", rcon.last_lua) +