feat: 자동 부트스트랩 기능 추가 및 인벤토리 검사 개선
- `stone-furnace` 배치 후 자동으로 `coal`과 `iron-ore`/`copper-ore`를 투입하여 제련이 시작되도록 보정하는 기능 추가 - `insert_to_entity` 호출 전에 `can_insert` 여부를 확인하여 아이템 증발 위험을 줄이는 로직 개선 - `README.md`에 새로운 기능 설명 추가 - `tests/test_furnace_bootstrap.py` 및 `tests/test_insert_to_entity.py`에 대한 단위 테스트 추가
This commit is contained in:
@@ -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`에서 확인).
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
BIN
tests/__pycache__/test_furnace_bootstrap.cpython-311.pyc
Normal file
BIN
tests/__pycache__/test_furnace_bootstrap.cpython-311.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_insert_to_entity.cpython-311.pyc
Normal file
BIN
tests/__pycache__/test_insert_to_entity.cpython-311.pyc
Normal file
Binary file not shown.
91
tests/test_furnace_bootstrap.py
Normal file
91
tests/test_furnace_bootstrap.py
Normal file
@@ -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)
|
||||
|
||||
31
tests/test_insert_to_entity.py
Normal file
31
tests/test_insert_to_entity.py
Normal file
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user