From 9b3d26aa12a959b24951a3d3ed5b68768b22bcf8 Mon Sep 17 00:00:00 2001 From: 21in7 Date: Thu, 26 Mar 2026 00:00:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=90=EB=8F=99=20=EB=B6=80=ED=8A=B8?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=9E=A9=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=9D=B8=EB=B2=A4=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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`에 대한 단위 테스트 추가 --- README.md | 2 + __pycache__/action_executor.cpython-311.pyc | Bin 29088 -> 33337 bytes action_executor.py | 107 +++++++++++++++++- .../test_furnace_bootstrap.cpython-311.pyc | Bin 0 -> 5152 bytes .../test_insert_to_entity.cpython-311.pyc | Bin 0 -> 1959 bytes tests/test_furnace_bootstrap.py | 91 +++++++++++++++ tests/test_insert_to_entity.py | 31 +++++ 7 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 tests/__pycache__/test_furnace_bootstrap.cpython-311.pyc create mode 100644 tests/__pycache__/test_insert_to_entity.cpython-311.pyc create mode 100644 tests/test_furnace_bootstrap.py create mode 100644 tests/test_insert_to_entity.py 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 b93adef7e09120bb6459dbc75d32aabd53aa2747..799150429c3a3965f468d4b9f4f04ec4a6e9d67d 100644 GIT binary patch delta 6658 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 delta 2750 zcmaJ@drVu`89(Rx`iX7cxCTrd9wrW$21rsKN!Er(8bW{u5(-VA#^;iFF&K_*VhjYj zF$rzSq~%OXr<5vFwKd(=jqHZ4>AI;csT6J1#8kFLcYo}Ut(p>|?PhB`DbXI^xeiX2 zWxMG9zVrRQ$32hleCF!BaPbe={<_U(LGWBsSAswHU$*ZR1QoMGLY>Eq_#dexX30b> zl7*y6X_CpwowbLU5=Mm1iMcitvCixJt;7aScGjhH+S2F6ejD+TEC|ikm>lr1lPZ$8 z!8jbiq?0F!6BsQLqTy5LL`QZ$m@-HK5QXr%;4NaSI%n=CNSjHvrQDOrz>--E5m_m| zgs0*2DusY-QcXM>ktULI@XH}J#0!&hxJ0rQ^jvLHqGTBsNd*Mt5mPtv*0NW{0M25Y z^b@8n0z&7}G-ls4I$pb?$Jox64X@00qY@M?+}ICmLetyWCm-k5W%d``VDMkzqW?NZ0Ev3w%r~&xEsvx zr#&MyxzI|wg<}Ec=(03t>Q>4PO|`leALip)8{jo$XtwIE88~yzss~g=$sdW{ybgIf z~j75#81 z?2l3}YpbY8tKrsK4%^vW#b$#dj`~L=_U(#N>|?*E=+!H_FqK$&Wf%6z@ycb11LKGP=p@hYBzw_ zOw;%{!XXNvm`D8a!DujWGD2U}^bo9lkOU)91rJgm`>bxK@O>m#)qjlh?wW!$JnDma z-sCrpj&W$0OB;(VmNq_a2DmGCwRptreK1ta9i2Uct(}KEntSeIH~Y%|f8zb}m)m}T zVg3#6UvsvCQ_K$FhUbObm*;OSUpeO9LZ{dt+dr69(g%-A(ZOI;8X43!UeQIQDCLV* z%+hFVM56wv^yvDZ=VpB*N`qtC4$vb&Xd9yF$0(c&&EXz8KOqn8i#xe5$rW+Cbx1Dk zIE;lGNRD*o7~#D5JmfP+{wly}%=YExP4c|_2Hb=@g=hkxhc2-1^qp^bB03a#{AE)6tQZW+W%cX>HC8 z?D&ZmJi*>Pk&mBa*H5@I-siRz4%aw*z&<^(DSrrt3MPs$6jy}EB*;nnEpDD;Rl#S3 zH?ce(T*AeQ7zq1AH~IGidVq~59|k&j|E4ncsa@>%M+|KKf+$Yr<+X%ma=&cFfo-Ow!q-}}Tb*@ck{tgFnyJ{)u5CYBkl_PB?GRl(7SM59}# zczf7goLDQabZf=Gn@S_$35m>j*=o3iS!p)Q8~X@qF*97m=Eg;49nbc-qyLrQ@?#P# zkC(AOk56Ut5}|GmZv*tuI7`s{%|C@PR6)!Ja(#Yb{d-s3>u=26UOYp80!%A?hb>dL zCspH6DF4}0=)?7VkJit=hY^aj0nDKrnYmwgo_C#dEsm?Gs8qb+%vW_FlS)nP14T8V zl5*9&VN#RcM~55&neb_(YWe?ytx9WNGfYGb#bcHu9W`6ge*2ib>{r>hdc_r!R^ zy?tfDy?$oy)>}(n-Gf#_e*`A4kW8Ymc)}~(!}4p1GF+89BdrNSkakeo@%SJp!tVui zUTOtN&$7>^N`!x5RxsWBw02dif$o6JU*P610(i03@Idg+1C~{8`Bl>Lo1{3%g@gTY zdPji=hKe{84u@zt4C#{`v}V}HtTQ_eXSrU&o}DSgWlWmcTAibB!Y>mQad|Ozsb{J7 zQr}wnQ%T}6SR?nJei!dA)q7L~aIr)amy%+sThp#5wbkCEtH=DSf#KC-!)v|AHPfTe z&H{4^KE(FCR9~k3mh%5JXbA`YBc*NAUDK|SFo}hvJ@jM5UU_NYb7MyARMF${j>doT JKdf5Te*;El%wqrm 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 0000000000000000000000000000000000000000..e899b8d92d3e2313d49c4fd1f627ccb4cd34efe1 GIT binary patch literal 5152 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 literal 0 HcmV?d00001 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) +