diff --git a/README.md b/README.md index 7467ee7..f951f31 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,8 @@ planner.set_goal( - 에이전트를 재시작하더라도 직전에 실행했던 action/result를 `agent_last_action_memory.json`에 저장해, 다음 상태 요약에서 AI가 참고할 수 있게 합니다. - 저장 기준: 성공/실패 상관없이 가장 마지막으로 실행을 시도한 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`(가능한 것 우선)를 투입해 제련이 시작되도록 보정합니다. +- `place_entity`는 지정 좌표가 `BLOCKED`이거나 인벤토리에 해당 아이템이 없어도(예: 이미 설치됨) 주변 `±1 타일` 및 요청 좌표 중심 반경 `3` 타일 내의 기존 엔티티를 우선 찾아 `REUSED`로 재사용합니다. +- `stone-furnace`는 `place_entity`가 성공하면(신규 배치든 `REUSED`든) 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로 실행하도록 구성되어 있습니다. diff --git a/__pycache__/action_executor.cpython-311.pyc b/__pycache__/action_executor.cpython-311.pyc index 7991504..550841e 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 3ee55b5..4d5e0d7 100644 --- a/action_executor.py +++ b/action_executor.py @@ -477,9 +477,6 @@ if crafted > 0 then rcon.print("CRAFTING:" .. crafted) else rcon.print("NO_INGRE "east": "defines.direction.east", "west": "defines.direction.west"} lua_dir = dir_map.get(direction, "defines.direction.north") result = self.rcon.lua(P + f""" -local inv = p.get_main_inventory() -local have = inv.get_item_count("{name}") -if have < 1 then rcon.print("NO_ITEM") return end local dist = math.sqrt(({x} - p.position.x)^2 + ({y} - p.position.y)^2) if dist > p.build_distance + 2 then rcon.print("TOO_FAR:" .. string.format("%.1f", dist)) return end @@ -492,6 +489,53 @@ local candidates = {{ {{ox+1, oy+1}}, {{ox-1, oy-1}}, {{ox+1, oy-1}}, {{ox-1, oy+1}} }} +-- 배치 아이템이 없어도, 이미 같은 엔티티가 있으면 재사용으로 처리 +for _, pos in ipairs(candidates) do + local cx, cy = pos[1], pos[2] + local existing = p.surface.find_entities_filtered{{ + position = {{cx, cy}}, + radius = 0.5, + name = "{name}", + }} + if existing and #existing > 0 then + rcon.print(string.format("REUSED:%.0f,%.0f", cx, cy)) + return + end +end + +-- 요청 좌표가 기존 엔티티와 약간 떨어져 있을 수 있으므로, +-- 더 넓은 반경 내에서도 같은 엔티티가 있으면 재사용 처리한다. +do + local nearby = p.surface.find_entities_filtered{{ + position = {{ox, oy}}, + radius = 3, + name = "{name}", + }} + if nearby and #nearby > 0 then + local best = nearby[1] + local best_d2 = math.huge + for _, e in ipairs(nearby) do + if e and e.valid then + local dx = e.position.x - ox + local dy = e.position.y - oy + local d2 = dx*dx + dy*dy + if d2 < best_d2 then + best = e + best_d2 = d2 + end + end + end + rcon.print(string.format("REUSED:%.0f,%.0f", best.position.x, best.position.y)) + return + end +end + +-- 여기까지 왔는데 배치할 엔티티가 없다면, +-- 그 때서야 인벤토리에 아이템이 있는지 확인한다. +local inv = p.get_main_inventory() +local have = inv.get_item_count("{name}") +if have < 1 then rcon.print("NO_ITEM") return end + for _, pos in ipairs(candidates) do local cx, cy = pos[1], pos[2] local can = p.surface.can_place_entity{{name="{name}", position={{cx, cy}}, direction={lua_dir}}} @@ -513,6 +557,9 @@ rcon.print("BLOCKED") elif result.startswith("OK:"): coords = result.split(":", 1)[1] return True, f"{name} 배치 ({coords})" + elif result.startswith("REUSED:"): + coords = result.split(":", 1)[1] + return True, f"{name} 재사용 ({coords})" elif result == "NO_ITEM": return False, f"인벤토리에 {name} 없음" elif result.startswith("TOO_FAR"): return False, f"너무 멀음 - move 먼저" elif result == "BLOCKED": return False, f"배치 불가" diff --git a/docs/plan.md b/docs/plan.md index 482e371..c278db3 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -170,20 +170,26 @@ ### 문제 관찰 - 로그에서 `stone-furnace` 배치가 `FAIL 배치 불가`(내부적으로 `BLOCKED`)로 반복됨 +- 또한 `stone-furnace`가 이미 맵에 설치돼 있어도, executor가 먼저 `NO_ITEM`으로 종료해 재사용 탐색이 실행되지 않는 케이스가 관찰됨 - `mine_resource`가 자원 엔티티 근처(종종 자원 패치 타일)로 이동한 뒤, 같은 좌표에 건물을 배치하려고 하면 Factorio의 `can_place_entity` 조건에 걸려 막힐 수 있음 ### 변경 목표 -1. `action_executor.py`의 `place_entity`에서 먼저 `surface.can_place_entity`로 배치 가능 여부를 검사 -2. 현재 좌표 `(x,y)`가 불가능하면, `(x,y)` 주변 `±1 타일` 후보(대각 포함)로 순차 시도 -3. 실제로 배치된 좌표를 결과 메시지에 포함해 이후 계획이 더 안정적으로 반복되게 함 +1. `action_executor.py`의 `place_entity`에서 배치 아이템 유무(`NO_ITEM`) 확인 전에, + 먼저 `surface.find_entities_filtered`로 기존 엔티티(같은 `name`)가 있는지 탐색 +2. (기존) 요청 좌표 주변 `±1 타일(9칸)` 후보에서 기존 엔티티가 있으면 `REUSED`로 성공 처리 +3. (추가) 요청 좌표 중심으로 반경 `3` 타일 내에 같은 엔티티가 있으면 가장 가까운 것을 `REUSED`로 성공 처리 +4. 기존 엔티티가 없을 때만 인벤토리에 `name` 아이템이 있는지 확인하고, `surface.can_place_entity` + `±1 타일` 후보로 실제 배치를 시도 +5. 실제로 배치된/재사용된 좌표를 결과 메시지에 포함해 이후 `stone-furnace` 자동 부트스트랩이 좌표를 정확히 파싱하도록 보장 ### 구현 범위 - `action_executor.py` - - `place_entity` 로직을 (x,y) 실패 시 인접 타일 후보로 확장 + - 기존 엔티티 탐색을 `NO_ITEM` 체크보다 먼저 수행 + - 재사용(`REUSED`) 성공 케이스의 메시지/좌표 포맷 확정 + - 반경 3 타일의 넓은 재사용 탐색 추가 ### README 업데이트 -- `place_entity`가 `BLOCKED` 시 인접 타일을 자동 탐색하도록 동작 설명 추가 +- `place_entity`가 `BLOCKED`/`NO_ITEM` 상황에서도 기존 엔티티를 찾아 `REUSED`로 재사용하고, `stone-furnace`는 재사용이어도 자동 부트스트랩이 동작하도록 설명 반영 --- diff --git a/tests/__pycache__/test_place_entity_reuse.cpython-311.pyc b/tests/__pycache__/test_place_entity_reuse.cpython-311.pyc new file mode 100644 index 0000000..53ec988 Binary files /dev/null and b/tests/__pycache__/test_place_entity_reuse.cpython-311.pyc differ diff --git a/tests/test_place_entity_reuse.py b/tests/test_place_entity_reuse.py new file mode 100644 index 0000000..ef8ae33 --- /dev/null +++ b/tests/test_place_entity_reuse.py @@ -0,0 +1,82 @@ +import unittest + +from action_executor import ActionExecutor + + +class _FakeRCON: + def __init__(self, lua_return: str): + self.lua_return = lua_return + self.last_lua: str | None = None + + def lua(self, code: str) -> str: + self.last_lua = code + return self.lua_return + + +class TestPlaceEntityReuse(unittest.TestCase): + def test_place_entity_treats_reused_as_success(self): + """ + place_entity가 REUSED:-91,-68 같은 값을 반환하면, + executor는 이를 성공으로 처리해야 한다. + """ + rcon = _FakeRCON("REUSED:-91,-68") + ex = ActionExecutor(rcon) # type: ignore[arg-type] + + ok, msg = ex.place_entity("stone-furnace", x=-91, y=-68, direction="north") + + self.assertTrue(ok, msg) + self.assertIn("재사용", msg) + + def test_place_entity_reuse_searches_a_wider_radius(self): + """ + REUSED 탐지를 ±1 타일(9칸)에서 끝내지 않고, + 요청 좌표 중심으로 더 넓은 반경 내 기존 엔티티도 찾아야 한다. + """ + rcon = _FakeRCON("BLOCKED") + ex = ActionExecutor(rcon) # type: ignore[arg-type] + + # 실제 성공/실패는 필요 없고, place_entity에 전달된 Lua 코드에 + # wider radius 탐색이 포함됐는지만 확인한다. + ex.place_entity("stone-furnace", x=-92, y=-70, direction="north") + + assert rcon.last_lua is not None + self.assertIn("radius = 3", rcon.last_lua) + + def test_execute_bootstraps_furnace_when_place_entity_reused_message_has_coords(self): + ex = ActionExecutor(_FakeRCON("OK")) + + 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" + + ex._auto_bootstrap_furnace = fake_auto_bootstrap # type: ignore[attr-defined] + + ok, msg = ex.execute({ + "action": "place_entity", + "reason": "기존 제련소 재사용 후 가동", + "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) +