feat: 배치 엔티티 재사용 로직 개선 및 README 업데이트
- `place_entity` 함수에서 인벤토리 아이템 유무를 확인하기 전에 기존 엔티티를 탐색하여 재사용(`REUSED`) 처리하도록 로직 개선 - 요청 좌표 주변 `±1 타일` 및 반경 `3` 타일 내에서 기존 엔티티를 찾아 재사용할 수 있도록 확장 - `stone-furnace`의 재사용 시 자동 부트스트랩 기능이 정상 작동하도록 보장 - README.md에 변경 사항 및 새로운 동작 설명 추가 - `tests/test_place_entity_reuse.py`에 대한 단위 테스트 추가
This commit is contained in:
@@ -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로 실행하도록 구성되어 있습니다.
|
||||
|
||||
Binary file not shown.
@@ -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"배치 불가"
|
||||
|
||||
16
docs/plan.md
16
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`는 재사용이어도 자동 부트스트랩이 동작하도록 설명 반영
|
||||
|
||||
---
|
||||
|
||||
|
||||
BIN
tests/__pycache__/test_place_entity_reuse.cpython-311.pyc
Normal file
BIN
tests/__pycache__/test_place_entity_reuse.cpython-311.pyc
Normal file
Binary file not shown.
82
tests/test_place_entity_reuse.py
Normal file
82
tests/test_place_entity_reuse.py
Normal 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)
|
||||
|
||||
Reference in New Issue
Block a user