feat: 배치 엔티티 재사용 로직 개선 및 README 업데이트

- `place_entity` 함수에서 인벤토리 아이템 유무를 확인하기 전에 기존 엔티티를 탐색하여 재사용(`REUSED`) 처리하도록 로직 개선
- 요청 좌표 주변 `±1 타일` 및 반경 `3` 타일 내에서 기존 엔티티를 찾아 재사용할 수 있도록 확장
- `stone-furnace`의 재사용 시 자동 부트스트랩 기능이 정상 작동하도록 보장
- README.md에 변경 사항 및 새로운 동작 설명 추가
- `tests/test_place_entity_reuse.py`에 대한 단위 테스트 추가
This commit is contained in:
21in7
2026-03-26 00:36:05 +09:00
parent 9b3d26aa12
commit abd388fc1e
6 changed files with 145 additions and 10 deletions

View File

@@ -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로 실행하도록 구성되어 있습니다.

View File

@@ -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"배치 불가"

View File

@@ -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`는 재사용이어도 자동 부트스트랩이 동작하도록 설명 반영
---

View File

@@ -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)