diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f310556 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +run_factorio_ai.ps1 +inventory_memory.json +.cursor/ \ No newline at end of file diff --git a/README.md b/README.md index 9359850..e910621 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,20 @@ python main.py FACTORIO_HOST=192.168.1.10 FACTORIO_PORT=25575 FACTORIO_PASSWORD=mypass python main.py ``` +```powershell +# Z.ai API 키 설정 +$env:ZAI_API_KEY="your-key-here" + +# 기본 실행 +python .\main.py + +# 커스텀 서버 (한 줄 버전) +$env:FACTORIO_HOST="192.168.1.10"; $env:FACTORIO_PORT="25575"; $env:FACTORIO_PASSWORD="mypass"; python .\main.py + +# (권장) 스크립트 실행 +.\run_factorio_ai.ps1 "your-key-here" "mypass" +``` + --- ## 동작 원리 @@ -114,8 +128,25 @@ planner.set_goal( - AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다 - `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다 - `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. +- `ai_planner.py`의 `_call_glm()`에서 GLM 지연 원인 분석을 위한 타이밍 로그가 출력됩니다. + - `total`: 요청 시작~콘텐츠 반환까지 전체 소요 + - `http_read`: HTTP 응답 본문 수신까지 소요 + - `json_parse`: 응답 JSON 파싱 시간 +- 파이썬 에이전트 재실행 시 인벤토리 “기억”이 필요하면 `inventory_memory.json` 캐시를 사용합니다. + - `RCON`에서 인벤토리를 읽지 못하거나(비정상 출력/파싱 실패 등) 직전에 성공적으로 읽은 인벤토리를 대신 사용합니다. + - 성공적으로 인벤토리가 읽히면 해당 캐시를 갱신합니다(코드는 `inv.get_contents()`가 `nil`/비테이블을 반환해도 안전하게 `{}`로 취급). + - 또한 일부 환경에서 `p.get_main_inventory()`가 없으면 `defines.inventory.character_main`으로 한 번 더 읽습니다. + - 캐시 파일이 아직 없으면(초기 실행) fallback도 `{}`가 됩니다. +- (중요) 일부 Factorio 버전에서는 `game.table_to_json`이 없을 수 있어서, Lua 내부에 간단 JSON 인코더를 넣어 인벤토리/상태를 안정적으로 가져옵니다. +- 디버깅용으로 `INV_DEBUG=1`을 켜면, `main inventory` 외에 `cursor_stack`/`armor`/`trash` 총합도 함께 출력합니다(어디에 아이템이 있는지 확인용). + - 또한 `main inventory`가 비어 캐시 fallback이 동작하면, 로드된 캐시의 `items/total`도 함께 출력합니다. - `mine_resource`에서 실패한 채굴 타일 제외(`exclude`)는 Lua와 Python 양쪽에서 정수 타일 좌표(`tx, ty`) 키로 통일해, 제외한 좌표가 반복 선택되지 않도록 합니다. - 또한 채굴 시작(`mining_state`) 좌표는 정수 타일이 아니라, Lua가 찾은 실제 자원 엔티티의 `e.position`(정확 실수 좌표)을 사용해 “플레이어가 타일 위에 있는데도 즉시 채굴 감지 실패”를 줄입니다. - `mine_resource`는 move 후 `p.position` 근처(반경 1.2)에서 실제로 해당 광물 엔티티가 있는지 재확인하고, 없으면 채굴을 시도하지 않고 다음 후보로 넘어갑니다. +- `scan_resources()`는 패치 평균 중심(`center_x/y`)만이 아니라 플레이어 기준 가장 가까운 실제 엔티티 좌표(`anchor_x/y`)도 함께 반환하며, `summarize_for_ai()`에서는 앵커를 기준으로 거리/추천을 계산합니다. +- `mine_resource`의 초기 “후보 엔티티 탐색” 반경은 패치 중심 오차를 흡수하기 위해 250으로 확장해, 이동 직후 바로 실패하는 케이스를 줄입니다. - `explore`는 `wanted_ores`가 있으면 해당 자원이 발견될 때까지 멈추지 않고 계속 이동해, `iron-ore`처럼 주변에 흔한 자원만 계속 발견되어 진행이 막히는 문제를 줄입니다. +- (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`에서 확인). - 인벤토리 판독은 `inv.get_contents()`를 우선 사용해, 일부 환경에서 `#inv` / 인덱스 접근이 비어있음으로 오인되는 문제를 줄입니다. diff --git a/__pycache__/action_executor.cpython-311.pyc b/__pycache__/action_executor.cpython-311.pyc index 6d6d1bc..baa10d2 100644 Binary files a/__pycache__/action_executor.cpython-311.pyc and b/__pycache__/action_executor.cpython-311.pyc differ diff --git a/__pycache__/ai_planner.cpython-311.pyc b/__pycache__/ai_planner.cpython-311.pyc index bbb6037..87b8ae4 100644 Binary files a/__pycache__/ai_planner.cpython-311.pyc and b/__pycache__/ai_planner.cpython-311.pyc differ diff --git a/__pycache__/context_compressor.cpython-311.pyc b/__pycache__/context_compressor.cpython-311.pyc index 4c40427..f6d3d51 100644 Binary files a/__pycache__/context_compressor.cpython-311.pyc and b/__pycache__/context_compressor.cpython-311.pyc differ diff --git a/__pycache__/main.cpython-311.pyc b/__pycache__/main.cpython-311.pyc index 12ef78f..b78da32 100644 Binary files a/__pycache__/main.cpython-311.pyc and b/__pycache__/main.cpython-311.pyc differ diff --git a/__pycache__/state_reader.cpython-311.pyc b/__pycache__/state_reader.cpython-311.pyc index ad8384d..5872391 100644 Binary files a/__pycache__/state_reader.cpython-311.pyc and b/__pycache__/state_reader.cpython-311.pyc differ diff --git a/action_executor.py b/action_executor.py index f25933e..2a2a069 100644 --- a/action_executor.py +++ b/action_executor.py @@ -109,9 +109,9 @@ if ok then rcon.print(data) else rcon.print("ERROR") end last_pos = pos_str if stuck_count >= 3: self.rcon.lua(P + "p.walking_state = {walking = false, direction = defines.direction.north}") - return False, f"장애물에 막힘 ({pos_str}) — 다른 방향 시도" + return False, f"장애물에 막힘 ({pos_str}) - 다른 방향 시도" self.rcon.lua(P + "p.walking_state = {walking = false, direction = defines.direction.north}") - return False, f"{direction} 방향 자원 미발견 — 다른 방향 시도" + return False, f"{direction} 방향 자원 미발견 - 다른 방향 시도" # ── 이동 ───────────────────────────────────────────────────────── def move(self, x: int, y: int) -> tuple[bool, str]: @@ -175,7 +175,7 @@ rcon.print("WALK:" .. string.format("%.1f", dist)) find_result = self.rcon.lua(P + f""" {exclude_lua} -local res = p.surface.find_entities_filtered{{position = p.position, radius = 80, name = "{ore}"}} +local res = p.surface.find_entities_filtered{{position = p.position, radius = 250, name = "{ore}"}} if #res == 0 then rcon.print("NOT_FOUND") return end -- 거리순 정렬 local pos = p.position @@ -204,11 +204,11 @@ rcon.print("ALL_EXCLUDED") if find_result == "NOT_FOUND": if total_mined > 0: return True, f"{ore} {total_mined}개 채굴 (주변 광석 소진)" - return False, f"반경 80 내 {ore} 없음 — explore로 다른 광맥 찾기" + return False, f"반경 250 내 {ore} 없음 - explore로 다른 광맥 찾기" if find_result == "ALL_EXCLUDED": if total_mined > 0: return True, f"{ore} {total_mined}개 채굴 (접근 가능 타일 모두 시도)" - return False, f"{ore} 근처 타일 {len(failed_positions)}개 모두 접근 불가 — 다른 위치로 이동 필요" + return False, f"{ore} 근처 타일 {len(failed_positions)}개 모두 접근 불가 - 다른 위치로 이동 필요" try: find_result = find_result.strip() @@ -290,7 +290,7 @@ p.mining_state = {{mining = true, position = {{x = {mine_x}, y = {mine_y}}}}} if not mined_this_tile: # 이 타일에서 한 개도 못 캤음 → 접근 불가 failed_positions.add((ox, oy)) - print(f" [채굴] ({ox:.0f},{oy:.0f}) 접근 불가 → 제외 목록 추가 (총 {len(failed_positions)}개)") + print(f" [채굴] ({ox:.0f},{oy:.0f}) 접근 불가 -> 제외 목록 추가 (총 {len(failed_positions)}개)") else: # 채굴 됐지만 타일 고갈 print(f" [채굴] ({ox:.0f},{oy:.0f}) 고갈/중단. 현재 {total_mined}개 채굴됨") @@ -302,7 +302,7 @@ p.mining_state = {{mining = true, position = {{x = {mine_x}, y = {mine_y}}}}} # 모든 라운드 후 if total_mined > 0: return True, f"{ore} {total_mined}개 채굴 (목표 {count}개 중 일부)" - return False, f"{ore} 채굴 실패 — {len(failed_positions)}개 타일 접근 불가" + return False, f"{ore} 채굴 실패 - {len(failed_positions)}개 타일 접근 불가" # ── 제작/배치/삽입/레시피/연구/대기 ────────────────────────────── def craft_item(self, item: str, count: int = 1) -> tuple[bool, str]: @@ -341,7 +341,7 @@ if built then rcon.print("OK") else rcon.print("BLOCKED") end if not result or result in ("NO_PLAYER", "NO_CHARACTER"): return False, result or "플레이어 없음" if result == "OK": return True, f"{name} 배치 ({x},{y})" elif result == "NO_ITEM": return False, f"인벤토리에 {name} 없음" - elif result.startswith("TOO_FAR"): return False, f"너무 멀음 — move 먼저" + elif result.startswith("TOO_FAR"): return False, f"너무 멀음 - move 먼저" elif result == "BLOCKED": return False, f"배치 불가" return False, f"배치 실패: {result}" diff --git a/agent_log.jsonl b/agent_log.jsonl index 7ea0c07..e69de29 100644 --- a/agent_log.jsonl +++ b/agent_log.jsonl @@ -1,66 +0,0 @@ -{"step": 1, "action": {"action": "move", "params": {"x": 10, "y": 0}, "reason": "시야를 확보하여 인근 자원 패치를 찾기 위해 이동"}, "success": false, "message": "(10,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774414751.1273699} -{"step": 2, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "위치 정보가 불확실하므로 원점(0,0)으로 이동하여 스폰 지점을 기준으로 잡음"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774414896.2017908} -{"step": 3, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "시작 지점으로 이동하여 주변 상황 파악"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774414953.6023395} -{"step": 4, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "원점 방향으로 이동 재시도로 위치 복구 시도"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415087.8350036} -{"step": 5, "action": {"action": "move", "params": {"x": 1, "y": 1}, "reason": "현재 위치에서 아주 가까운 거리로 이동 시도. 이동이 성공하면 좌표계를 잡고 주변 자원 탐색 가능"}, "success": false, "message": "(1,1)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415305.2186906} -{"step": 6, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "스폰 지점 presumed origin으로 이동하여 주변 상태 파악"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415495.6019366} -{"step": 7, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "좌표 (0,0)으로 이동하여 맵 중심에 접근 시도"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415568.9355204} -{"step": 8, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 5}, "reason": "현재 위치에서 돌 채굴 시도 (제련소 제작용)"}, "success": false, "message": "실행 오류: could not convert string to float: ''", "timestamp": 1774415586.6352522} -{"step": 9, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "시작 지점인 (0,0)으로 이동하여 위치를 확정 짓고 주변 자원을 스캔함"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415648.0026102} -{"step": 10, "action": {"action": "move", "params": {"x": 10, "y": 10}, "reason": "기존 (0,0) 이동 실패로 인해 다른 좌표(10,10)로 이동 시도하여 캐릭터 위치를 갱신하고 자원 탐색 범위를 넓힘"}, "success": false, "message": "(10,10)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415715.33529} -{"step": 11, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "맵의 중심 지점으로 이동하여 자원을 찾기 위함"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415784.335003} -{"step": 12, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "가장 기준이 되는 좌표 (0,0)으로 이동 시도. 자원 탐색의 시작점."}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415975.2185514} -{"step": 13, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "좌표계 초기화 및 위치 확인 시도"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416115.7352495} -{"step": 14, "action": {"action": "move", "params": {"x": 100, "y": 100}, "reason": "초기 좌표 이동 실패로 더 넓은 범위 탐색을 위해 이동"}, "success": false, "message": "(100,100)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416251.0025642} -{"step": 15, "action": {"action": "move", "params": {"x": 0, "y": 10}, "reason": "현재 위치에서 짧게 이동하여 유효한 영역 탐색 및 위치 갱신 시도"}, "success": false, "message": "(0,10)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416491.285967} -{"step": 16, "action": {"action": "move", "params": {"x": 2, "y": 2}, "reason": "초기 좌표 이동 실패 후, 근거리 좌표(2,2)로 이동을 시도하여 맵 로딩 및 주변 지형 확인"}, "success": false, "message": "(2,2)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416643.3522403} -{"step": 17, "action": {"action": "move", "params": {"x": 1, "y": 0}, "reason": "현재 위치에서 아주 가까운 북쪽(혹은 x축 방향)으로 1칸 이동 시도하여 이동 시스템 확인"}, "success": false, "message": "(1,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416754.5350137} -{"step": 18, "action": {"action": "move", "params": {"x": 1, "y": 1}, "reason": "좌표 (1,1)로 이동 시도. 위치를 확보하기 위함."}, "success": false, "message": "(1,1)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416850.6186059} -{"step": 19, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "좌표계 기준점인 (0,0)으로 이동하여 위치를 파악"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416972.7515874} -{"step": 20, "action": {"action": "move", "params": {"x": 5, "y": 5}, "reason": "이전 이동들이 모두 실패했으므로, 시작 지점에서 가까운 거리(5,5)로 이동 시도하여 유효한 좌표를 찾고 자원을 탐색"}, "success": false, "message": "(5,5)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417045.2688239} -{"step": 21, "action": {"action": "move", "params": {"x": 0, "y": 200}, "reason": "현재 위치에서 자원을 찾기 위해 북쪽(또는 임의의 방향)으로 길게 이동하여 자원 패치가 있는지 확인."}, "success": false, "message": "(0,200)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417246.6684408} -{"step": 22, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "스폰 지점으로 이동하여 주변 환경 및 자원 패치 확인"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417361.6020026} -{"step": 23, "action": {"action": "wait", "params": {"seconds": 2}, "reason": "게임 로딩 및 플레이어 스폰 대기"}, "success": true, "message": "2초 대기 완료", "timestamp": 1774417381.3811822} -{"step": 24, "action": {"action": "wait", "params": {"seconds": 1}, "reason": "시스템 안정화 및 짧은 대기 후 이동 재시도"}, "success": true, "message": "1초 대기 완료", "timestamp": 1774417415.6203752} -{"step": 25, "action": {"action": "move", "params": {"x": 50, "y": 50}, "reason": "이전 좌표 이동이 실패하여 더 먼 거리나 다른 방향 시도"}, "success": false, "message": "(50,50)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417462.5017407} -{"step": 26, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "원점(0,0)으로 이동 시도하여 기준 위치 확보 및 맵 로딩 유도"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417539.952161} -{"step": 27, "action": {"action": "move", "params": {"x": 10, "y": 10}, "reason": "현재 위치가 불명확하고 멀리 이동이 불가능하므로, 비교적 가까운 곳으로 이동 시도하여 맵 로딩 및 위치 확인"}, "success": false, "message": "(10,10)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417669.6687183} -{"step": 28, "action": {"action": "move", "params": {"x": 500, "y": 500}, "reason": "이전에 (0,0), (50,50) 등으로 이동이 실패했으므로, 지도의 다른 지점(500,500)으로 이동을 시도하여 자원 패치를 찾음"}, "success": false, "message": "(500,500)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417739.6016743} -{"step": 29, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "플레이어 위치를 초기화하거나 기준점(0,0)으로 이동하여 맵 진입 여부 확인"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417812.2184923} -{"step": 30, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "기준점인 (0,0)으로 이동하여 현재 좌표 확보 및 근처 자원 확인 시도"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774418211.5518975} -{"step": 31, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "맵의 원점(0,0)으로 이동하여 자원 탐색 및 채굴 준비"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774418376.3684852} -{"step": 32, "action": {"action": "wait", "params": {"seconds": 1}, "reason": "게임 로딩 안정화 및 상태 갱신 대기"}, "success": true, "message": "1초 대기 완료", "timestamp": 1774418395.778815} -{"step": 33, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 5}, "reason": "현재 위치에 돌이 있을 경우 채굴하여 제련소 제작 시도. 없으면 실패하겠지만 위치 확인용"}, "success": false, "message": "실행 오류: could not convert string to float: ''", "timestamp": 1774418395.985176} -{"step": 34, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "초기 스폰 지점(0,0)으로 이동하여 자원 탐색 준비"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774418485.3684797} -{"step": 35, "action": {"action": "wait", "params": {"seconds": 1}, "reason": "상태 갱신 대기"}, "success": true, "message": "1초 대기 완료", "timestamp": 1774418586.4002311} -{"step": 36, "action": {"action": "move", "params": {"x": 50, "y": 50}, "reason": "임의의 위치로 이동하여 자원 패치 탐색"}, "success": false, "message": "(50,50)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774418633.2684767} -{"step": 37, "action": {"action": "move", "params": {"x": 10, "y": 10}, "reason": "현재 위치에서 근처로 이동하여 자원 탐색 시도"}, "success": false, "message": "(10,10)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774418744.651644} -{"step": 1, "action": {"action": "move", "params": {"x": 20, "y": 20}, "reason": "주변 지형을 탐색하여 자원 광맥을 찾기 위해 이동"}, "success": true, "message": "(20, 20)로 도착", "timestamp": 1774436597.435362} -{"step": 2, "action": {"action": "move", "params": {"x": 25, "y": 25}, "reason": "자원 패치를 찾기 위해 이동하여 시야 확보"}, "success": true, "message": "(25, 25)로 도착", "timestamp": 1774436659.384502} -{"step": 3, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 5}, "reason": "stone-furnace 제작에 필요한 기반 재료 확보"}, "success": false, "message": "근처에 stone 없음 — 자원 패치로 move 먼저", "timestamp": 1774436659.6530797} -{"step": 4, "action": {"action": "move", "params": {"x": 35, "y": 35}, "reason": "자원 탐색을 위해 현재 위치에서 떨어진 지점으로 이동"}, "success": true, "message": "(35, 35)로 도착", "timestamp": 1774436700.2356887} -{"step": 5, "action": {"action": "move", "params": {"x": 45, "y": 45}, "reason": "이전 구역에 자원이 없어 새로운 구역 탐색을 위해 이동"}, "success": false, "message": "(45,45) 이동 중 장애물에 막힘 (남은 거리: 4)", "timestamp": 1774436738.584099} -{"step": 6, "action": {"action": "move", "params": {"x": 46, "y": 46}, "reason": "장애물 피해가며 탐색 시도"}, "success": false, "message": "(46,46) 이동 중 장애물에 막힘 (남은 거리: 5)", "timestamp": 1774436764.9175665} -{"step": 7, "action": {"action": "move", "params": {"x": 50, "y": 40}, "reason": "장애물이 있는 (46,46) 방향을 피해 우측 상단 방향으로 이동하여 자원 탐색"}, "success": false, "message": "(50,40) 이동 중 장애물에 막힘 (남은 거리: 7)", "timestamp": 1774436788.6009731} -{"step": 8, "action": {"action": "move", "params": {"x": 15, "y": 35}, "reason": "동쪽이 막혔으므로 서쪽으로 이동하여 새로운 지역 탐색"}, "success": true, "message": "(15, 35)로 도착", "timestamp": 1774436826.3850791} -{"step": 9, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 5}, "reason": "이동한 위치에서 stone 자원이 있는지 확인 후 채굴 시도 (없을 시 탐색 피드백 활용)"}, "success": false, "message": "근처에 stone 없음 — 자원 패치로 move 먼저", "timestamp": 1774436826.651067} -{"step": 10, "action": {"action": "move", "params": {"x": 5, "y": 25}, "reason": "새로운 지역 탐색을 위해 이동"}, "success": true, "message": "(5, 25)로 도착", "timestamp": 1774436854.2343464} -{"step": 11, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 10}, "reason": "제련소 제작을 위해 stone 채굴 시도"}, "success": false, "message": "근처에 stone 없음 — 자원 패치로 move 먼저", "timestamp": 1774436854.5009618} -{"step": 12, "action": {"action": "move", "params": {"x": 10, "y": 20}, "reason": "현재 (5,25) 근처에 자원이 없어 인근 좌표(10,20)로 이동하여 자원 탐색 시도"}, "success": true, "message": "(10, 20)로 도착", "timestamp": 1774436876.1179383} -{"step": 13, "action": {"action": "move", "params": {"x": 20, "y": 20}, "reason": "이전 위치 (10, 20) 근처에 자원이 없어 새로운 구간인 (20, 20)으로 탐색 이동"}, "success": true, "message": "(20, 20)로 도착", "timestamp": 1774436914.1352172} -{"step": 14, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 5}, "reason": "제련소 제작을 위해 석재 5개 채굴 시도 (해당 위치에 없으면 다음 단계에서 이동)"}, "success": false, "message": "근처에 stone 없음 — 자원 패치로 move 먼저", "timestamp": 1774436914.4015982} -{"step": 15, "action": {"action": "move", "params": {"x": 30, "y": 30}, "reason": "자원이 없는 기존 구역을 벗어나 새로운 지역을 탐색하기 위해 이동"}, "success": true, "message": "(30, 30)로 도착", "timestamp": 1774436961.2677045} -{"step": 16, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 5}, "reason": "도착한 지점에서 Stone 자원이 있는지 확인하고 채굴하여 초기 제련소 건설 재료 확보"}, "success": false, "message": "근처에 stone 없음 — 자원 패치로 move 먼저", "timestamp": 1774436961.534328} -{"step": 17, "action": {"action": "move", "params": {"x": 35, "y": 35}, "reason": "자원이 없는 지역을 벗어나 새로운 구역 탐색"}, "success": true, "message": "(35, 35)로 도착", "timestamp": 1774436982.734539} -{"step": 18, "action": {"action": "move", "params": {"x": 20, "y": 20}, "reason": "이미 지나왔던 지점을 제외하고 다른 방향 탐색을 위해 서쪽으로 이동"}, "success": true, "message": "(20, 20)로 도착", "timestamp": 1774437002.8348992} -{"step": 19, "action": {"action": "mine_resource", "params": {"ore": "iron-ore", "count": 50}, "reason": "철광석 패치를 발견하면 제련 및 초기 기계 제작을 위해 넉넉하게 채굴"}, "success": false, "message": "근처에 iron-ore 없음 — 자원 패치로 move 먼저", "timestamp": 1774437003.1019325} -{"step": 1, "action": {"action": "move", "params": {"x": 50, "y": 50}, "reason": "자원을 찾기 위해 우측 하단 방향으로 탐색 이동"}, "success": false, "message": "(50,50) 이동 중 장애물에 막힘 (남은 거리: 11)", "timestamp": 1774437057.7680883} -{"step": 2, "action": {"action": "move", "params": {"x": 100, "y": 0}, "reason": "이전 이동 실패 좌표에서 벗어나 동쪽 방향으로 장거리 이동하여 자원 탐색"}, "success": false, "message": "(100,0) 이동 중 장애물에 막힘 (남은 거리: 70)", "timestamp": 1774437089.135179} -{"step": 3, "action": {"action": "move", "params": {"x": -200, "y": 0}, "reason": "동쪽 탐색 실패 후, 서쪽으로 장거리 이동하여 육지나 자원을 찾기 위함"}, "success": false, "message": "(-200,0) 이동 중 장애물에 막힘 (남은 거리: 4)", "timestamp": 1774437160.6009545} -{"step": 4, "action": {"action": "move", "params": {"x": 0, "y": 600}, "reason": "반경 500타일 내에 자원이 없어 더 멀리 떨어진 곳으로 이동해야 함. 북쪽 방향이 비교적 개방적일 것으로 판단됨."}, "success": false, "message": "(0,600) 이동 중 장애물에 막힘 (남은 거리: 593)", "timestamp": 1774437213.5008812} -{"step": 5, "action": {"action": "move", "params": {"x": -100, "y": -100}, "reason": "서쪽으로는 비교적 멀리 이동했으므로, 남서쪽 방향으로 우회하여 자원을 탐색"}, "success": true, "message": "(-100, -100)로 도착", "timestamp": 1774437284.0680652} -{"step": 1, "action": {"action": "move", "params": {"x": 550, "y": 0}, "reason": "반경 500타일 내에 자원이 없으므로, 동쪽으로 이동하여 자원을 찾기 위해 빈 구역을 벗어남"}, "success": false, "message": "(550,0) 이동 중 장애물에 막힘 (남은 거리: 466)", "timestamp": 1774437461.3510807} -{"step": 2, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "시작 지점(0,0)으로 복귀하여 위치를 초기화하고 주변을 재탐색"}, "success": true, "message": "(0, 0)로 도착", "timestamp": 1774437533.7178357} -{"step": 3, "action": {"action": "move", "params": {"x": 1000, "y": 1000}, "reason": "남동쪽 방향으로 장거리 이동하여 자원 탐색"}, "success": false, "message": "(1000,1000) 이동 중 장애물에 막힘 (남은 거리: 1354)", "timestamp": 1774437561.450848} -{"step": 1, "action": {"action": "move", "params": {"x": 800, "y": 0}, "reason": "주변 500타일 내에 자원이 없으므로, 동쪽으로 이동하여 자원 탐색 범위를 넓힙니다."}, "success": false, "message": "플레이어 캐릭터가 없습니다 (사망했거나 생성 전).", "timestamp": 1774437849.8178098} -{"step": 2, "action": {"action": "move", "params": {"x": 1000, "y": 0}, "reason": "반경 500타일 내 자원이 없으므로 탐색 범위를 벗어나기 위해 장거리 이동"}, "success": false, "message": "(1000,0) 이동 중 장애물에 막힘 (남은 거리: 612)", "timestamp": 1774438061.2343192} diff --git a/ai_planner.py b/ai_planner.py index 0d62000..116cb16 100644 --- a/ai_planner.py +++ b/ai_planner.py @@ -15,6 +15,7 @@ JSON 파싱 강화: import json import os import re +import time import urllib.request import urllib.error @@ -145,7 +146,7 @@ class AIPlanner: for attempt in range(3): try: - raw = self._call_glm(user_message) + raw = self._call_glm(user_message, attempt=attempt) plan = self._parse_json(raw) break except (ValueError, json.JSONDecodeError) as e: @@ -164,13 +165,13 @@ class AIPlanner: thinking = plan.get("thinking", "") if thinking: - print(f"\n🧠 AI 판단:\n{thinking}\n") + print(f"\n[AI] 판단:\n{thinking}\n") - print(f"🎯 현재 목표: {plan.get('current_goal', '')}") - print(f"📋 완료 후: {plan.get('after_this', '')}") + print(f"[AI] 현재 목표: {plan.get('current_goal', '')}") + print(f"[AI] 완료 후: {plan.get('after_this', '')}") actions = plan.get("actions", []) - print(f"⚡ {len(actions)}개 행동 계획됨") + print(f"[AI] {len(actions)}개 행동 계획됨") return actions def record_feedback(self, action: dict, success: bool, message: str = ""): @@ -188,7 +189,7 @@ class AIPlanner: return "" lines = ["### 이전 행동 결과 (성공/실패)\n"] for fb in self.feedback_log[-8:]: - status = "✅" if fb["success"] else "❌" + status = "OK" if fb["success"] else "FAIL" msg = f" — {fb['message']}" if fb["message"] else "" lines.append( f" {status} {fb['action']} " @@ -196,7 +197,7 @@ class AIPlanner: ) return "\n".join(lines) + "\n\n" - def _call_glm(self, user_message: str) -> str: + def _call_glm(self, user_message: str, attempt: int) -> str: payload = json.dumps({ "model": GLM_MODEL, "messages": [ @@ -207,6 +208,10 @@ class AIPlanner: "max_tokens": 2000, }).encode("utf-8") + prompt_chars = len(user_message) + system_chars = len(SYSTEM_PROMPT) + max_tokens = 2000 + req = urllib.request.Request( GLM_API_URL, data = payload, @@ -217,9 +222,36 @@ class AIPlanner: method = "POST", ) try: + t_total0 = time.perf_counter() + t_payload0 = time.perf_counter() + # payload 직렬화 직후(대략)부터 타임라인 측정 + _t0 = time.perf_counter() with urllib.request.urlopen(req, timeout=90) as resp: - data = json.loads(resp.read().decode("utf-8")) - return data["choices"][0]["message"]["content"].strip() + raw_text = resp.read().decode("utf-8") + t_read_done = time.perf_counter() + + t_json0 = time.perf_counter() + data = json.loads(raw_text) + content = data["choices"][0]["message"]["content"].strip() + t_json_done = time.perf_counter() + + dt_total = time.perf_counter() - t_total0 + dt_payload = t_payload0 - t_total0 + dt_read = t_read_done - _t0 + dt_json = t_json_done - t_json0 + + print( + "[GLM] 타이밍 | " + f"attempt {attempt+1}/3 | " + f"total {dt_total:.2f}s | " + f"http_read {dt_read:.2f}s | " + f"json_parse {dt_json:.2f}s | " + f"prompt_chars {prompt_chars} | " + f"system_chars {system_chars} | " + f"max_tokens {max_tokens} | " + f"resp_chars {len(raw_text)}" + ) + return content except urllib.error.HTTPError as e: raise ConnectionError(f"GLM API 오류 {e.code}: {e.read().decode()}") diff --git a/context_compressor.py b/context_compressor.py index b5a2518..7ccff89 100644 --- a/context_compressor.py +++ b/context_compressor.py @@ -15,6 +15,56 @@ ZONE_SIZE = 64 P = 'local p = game.players[1] if not p then rcon.print("{}") return end ' +# Factorio 버전에 따라 game.table_to_json 이 없을 수 있어, +# Lua 내부에서 간단한 JSON 인코더를 제공한다. +JSON_HELPER_LUA = r""" +local function json_escape(s) + s = tostring(s) + s = s:gsub("\\", "\\\\") + s = s:gsub('"', '\\"') + s = s:gsub('\b', '\\b') + s = s:gsub('\f', '\\f') + s = s:gsub('\n', '\\n') + s = s:gsub('\r', '\\r') + s = s:gsub('\t', '\\t') + return s +end + +local function json_encode_value(v) + local t = type(v) + if t == "string" then + return '"' .. json_escape(v) .. '"' + elseif t == "number" then + return tostring(v) + elseif t == "boolean" then + return v and "true" or "false" + elseif t == "table" then + -- object encoding (배열은 별도 함수에서 처리) + local parts = {} + for k, val in pairs(v) do + local key = '"' .. json_escape(k) .. '"' + parts[#parts + 1] = key .. ":" .. json_encode_value(val) + end + return "{" .. table.concat(parts, ",") .. "}" + else + return "null" + end +end + +local function json_encode_object(t) + return json_encode_value(t) +end + +local function json_encode_array(arr) + local parts = {} + local n = #arr + for i = 1, n do + parts[#parts + 1] = json_encode_value(arr[i]) + end + return "[" .. table.concat(parts, ",") .. "]" +end +""" + class ContextCompressor: def __init__(self, rcon: FactorioRCON): @@ -37,7 +87,7 @@ class ContextCompressor: return self._format_level2(global_summary, zones, problems, drilldown, player) def _get_global_summary(self) -> dict: - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local surface = p.surface local force = p.force @@ -60,7 +110,7 @@ local ok, err = pcall(function() for _, t in pairs(force.technologies) do if t.researched then researched = researched + 1 end end - rcon.print(game.table_to_json({ + rcon.print(json_encode_object({ total_entities = total, counts = result, current_research = current_tech, @@ -78,7 +128,7 @@ if not ok then rcon.print("{}") end return {} def _get_zone_summaries(self) -> list[dict]: - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local surface = p.surface local Z = 64 @@ -109,7 +159,7 @@ local ok, err = pcall(function() for _, z in pairs(zones) do if z.entities > 2 then result[#result+1] = z end end - rcon.print(game.table_to_json(result)) + rcon.print(json_encode_array(result)) end) if not ok then rcon.print("[]") end """ @@ -120,7 +170,7 @@ if not ok then rcon.print("[]") end return [] def _detect_problems(self) -> list[str]: - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local surface = p.surface local problems = {} @@ -140,7 +190,7 @@ local ok, err = pcall(function() if #ore > 0 and ore[1].amount < 5000 then depleting = depleting + 1 end end if depleting > 0 then problems[#problems+1] = "자원 고갈 임박: " .. depleting .. "개" end - rcon.print(game.table_to_json(problems)) + rcon.print(json_encode_array(problems)) end) if not ok then rcon.print("[]") end """ @@ -164,7 +214,7 @@ if not ok then rcon.print("[]") end return "\n".join(lines) def _get_player_info(self) -> dict: - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local inv = p.get_main_inventory() if not inv then rcon.print("{}") return end @@ -181,7 +231,7 @@ local ok, err = pcall(function() end end end - rcon.print(game.table_to_json({ + rcon.print(json_encode_object({ x = math.floor(p.position.x), y = math.floor(p.position.y), inventory = inv_summary diff --git a/docs/plan.md b/docs/plan.md index ca5d063..821720a 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -22,3 +22,93 @@ ### README 업데이트 계획 - 채굴 제외(exclude) 로직이 “정수 타일 키 기반으로 통일”되도록 README의 기술/동작 설명(또는 체크리스트)을 업데이트합니다. +--- + +## 자원 패치 중심 오차로 인한 채굴 실패 완화 계획 + +### 문제 관찰 +- `scan_resources()`가 패치의 평균 중심(`center_x/y`)을 추천 좌표로 사용하면서, + 플레이어가 해당 좌표로 이동한 직후 `mine_resource`의 엔티티 탐색 반경 안에서 + 실제 자원 엔티티를 찾지 못해 실패하는 케이스가 발생할 수 있음. + +### 근거(팩토리오 Lua API) +- `LuaInventory.get_contents()`는 `dictionary[string -> uint]`를 반환하므로, + 인벤토리 판독은 `name -> count` 형태로 처리하는 것이 맞다. +- 자원 엔티티의 `e.position`은 부동소수(MapPosition) 좌표이므로, + 타일 중심/평균 중심과 실제 엔티티 좌표 간 오차가 생길 수 있다. + +### 변경 목표 +1. `scan_resources()` 결과에 패치 대표 좌표를 평균 중심뿐 아니라 + 플레이어 기준 가장 가까운 실제 엔티티 좌표(앵커 `anchor_x/y` 및 `anchor_tile_x/y`)로 함께 제공 +2. `summarize_for_ai()`에서는 평균 중심 대신 앵커 좌표 기반으로 거리/추천을 계산 +3. `mine_resource` 후보 엔티티 탐색 반경을 더 크게 잡아(행동 레벨) 이동 직후 실패를 흡수 + +### 구현 범위 +- `state_reader.py` + - `scan_resources()`에 `anchor_x/y` 및 `anchor_tile_x/y` 추가 + - `summarize_for_ai()` 거리 계산을 앵커 우선으로 변경 +- `action_executor.py` + - `mine_resource`에서 후보 엔티티 탐색 반경을 `80` → `250`으로 확장 + +--- + +## GLM 응답 지연 원인 계측/완화 계획 + +### 문제 관찰 +- `ai_planner.py`에서 GLM 호출이 `[GLM] 생각 중...` 이후 30초~1분 이상 지연되는 현상 발생 +- JSON 파싱 실패로 인한 재시도는 가끔 발생(하지만 느림의 주 원인이 아닐 가능성도 있음) + +### 1단계(증거 수집) +- `ai_planner.py`의 `_call_glm()`에 타이밍 로그 추가 + - `total`: 요청 시작~콘텐츠 반환까지 전체 소요 + - `http_read`: HTTP 응답 본문 수신까지 소요 + - `json_parse`: 응답 JSON 파싱 시간 + - `prompt_chars/system_chars/max_tokens`: 입력 크기 동시 기록 + +### 2단계(완화: 로그 확인 후) +- `json_parse`가 아니라 `http_read/total`이 큰 경우: + - `max_tokens`를 우선 크게 줄여 생성 길이 상한 조정 + - `SYSTEM_PROMPT`/`state_summary` 길이를 더 강하게 제한 +- `json_parse`가 큰 경우: + - JSON 파싱/복구 로직 비용 또는 응답 형식 편차 원인 재점검 + +### README 업데이트 +- `README.md`의 `주의사항`/`실행` 섹션에 GLM 지연 계측 로그 출력 방식 및 파라미터 조정 힌트를 반영 + +--- +## Cursor Hooks - Windows `sessionStart` 앱 선택창 원인/해결 계획 +### 문제 재현/관찰 +- Cursor `@cursor.hooks` 로그에서 `sessionStart` 훅 실행 중 `./hooks/session-start`가 stdout JSON을 내지 못하거나, Windows에서 앱 선택창이 뜨는 현상이 관찰됨 + +### 원인 후보 +- Windows에서 확장자 없는 `./hooks/session-start`를 실행할 때 `bash`로 해석되지 않아, OS가 실행 대신 “어떤 앱으로 열지”를 요청할 수 있음 + +### 변경 목표 +1. 프로젝트 훅은 `powershell`로 stdout에 JSON을 출력하도록 고정 +2. superpowers 플러그인의 Cursor 훅은 `bash`로 감싸 실행되도록 구성 변경 + +### 구현/검증 범위 +- `E:/develop/factorio-ai-agent/.cursor/hooks.json` +- `E:/develop/factorio-ai-agent/.cursor/session-start-hook.ps1` +- `.../superpowers/.../hooks/hooks-cursor.json` +- 검증: Cursor 재시작 후 `View -> Output -> Hooks`에서 sessionStart 훅이 정상 파싱되어 `OUTPUT`에 JSON이 나타나는지 확인 + +--- + +## 인벤토리 캐시(메모리) 추가 계획 + +### 목표 +- 파이썬 에이전트만 종료/재실행할 때 `RCON`으로 인벤토리를 읽지 못하거나(`{}` 반환) 빈 값이 나오면, + 직전에 성공적으로 읽은 인벤토리를 기억해서 프롬프트에 반영한다. + +### 정책(사용자 선택: 1번 fallback only) +- `get_inventory()`가 성공적으로 읽어서 값이 비어있지 않으면 캐시를 갱신한다. +- `get_inventory()` 결과가 빈 딕셔너리이면(실패/빈 값) 캐시 파일을 로드해서 대체한다. + +### 구현 범위 +- `state_reader.py` + - `inventory_memory.json` 로드/저장 유틸 추가 + - `get_inventory()` 반환값을 캐시 fallback으로 교체 +- `README.md` + - 인벤토리 캐시 동작과 파일명(`inventory_memory.json`)을 설명 + diff --git a/main.py b/main.py index 7dad349..2468c69 100644 --- a/main.py +++ b/main.py @@ -41,11 +41,11 @@ if p then rcon.print(p.name .. " @ " .. string.format("%.0f, %.0f", p.position.x, p.position.y)) end """) - print(f"[초기화] ✅ 플레이어 발견: {info}") + print(f"[초기화] OK 플레이어 발견: {info}") return True if attempt == 0: - print("[초기화] ⚠️ 접속한 플레이어가 없습니다!") + print("[초기화] WARN 접속한 플레이어가 없습니다!") print("[초기화] 팩토리오 클라이언트로 이 서버에 접속하세요.") print(f"[초기화] 서버 주소: {RCON_HOST}") print(f"[초기화] 대기 중... (최대 30초)") @@ -86,7 +86,7 @@ def run(): # ── 압축 레벨 자동 선택 ── if entity_count < COMPRESS_THRESHOLD: - print("[상태] 초반 모드 — 상세 상태 수집") + print("[상태] 초반 모드 - 상세 상태 수집") state = reader.get_full_state() summary = reader.summarize_for_ai(state) entity_count = sum( @@ -94,10 +94,10 @@ def run(): if isinstance(v, int) ) elif entity_count < 200: - print(f"[상태] 중반 모드 — 구역 압축 (건물 {entity_count}개)") + print(f"[상태] 중반 모드 - 구역 압축 (건물 {entity_count}개)") summary = compressor.get_compressed_state(detail_level=1) else: - print(f"[상태] 후반 모드 — 글로벌 압축 (건물 {entity_count}개)") + print(f"[상태] 후반 모드 - 글로벌 압축 (건물 {entity_count}개)") summary = compressor.get_compressed_state(detail_level=0) global_info = compressor._get_global_summary() @@ -121,13 +121,13 @@ def run(): print(f" params: {json.dumps(action.get('params',{}), ensure_ascii=False)}") success, message = executor.execute(action) - status = "✅" if success else "❌" + status = "OK" if success else "FAIL" print(f" 결과: {status} {message}") planner.record_feedback(action, success, message) if not success: - print(" → 실패. 재계획 요청") + print(" -> 실패. 재계획 요청") queue.clear() _log(total_actions, action, success, message) diff --git a/state_reader.py b/state_reader.py index ca455a8..1c21d2b 100644 --- a/state_reader.py +++ b/state_reader.py @@ -9,15 +9,133 @@ RCON을 통해 팩토리오 게임 상태를 읽어오는 모듈 - 모든 Python 파싱 try/except 감싸기 """ import json +import os from factorio_rcon import FactorioRCON P = 'local p = game.players[1] if not p then rcon.print("{}") return end ' +INVENTORY_CACHE_FILE = "inventory_memory.json" +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Factorio 버전에 따라 game.table_to_json 이 없을 수 있어, +# Lua 내부에서 간단한 JSON 인코더를 제공한다. +JSON_HELPER_LUA = r""" +local function json_escape(s) + s = tostring(s) + s = s:gsub("\\", "\\\\") + s = s:gsub('"', '\\"') + s = s:gsub('\b', '\\b') + s = s:gsub('\f', '\\f') + s = s:gsub('\n', '\\n') + s = s:gsub('\r', '\\r') + s = s:gsub('\t', '\\t') + return s +end + +local function json_encode_value(v) + local t = type(v) + if t == "string" then + return '"' .. json_escape(v) .. '"' + elseif t == "number" then + return tostring(v) + elseif t == "boolean" then + return v and "true" or "false" + elseif t == "table" then + local parts = {} + for k, val in pairs(v) do + local key = '"' .. json_escape(k) .. '"' + parts[#parts + 1] = key .. ":" .. json_encode_value(val) + end + return "{" .. table.concat(parts, ",") .. "}" + else + return "null" + end +end + +local function json_encode_object(t) + return json_encode_value(t) +end +""" + + +def _load_inventory_cache(cache_path: str) -> dict: + """로컬에 저장된 마지막 인벤토리 캐시를 로드한다.""" + try: + if not os.path.exists(cache_path): + return {} + with open(cache_path, "r", encoding="utf-8") as f: + raw = f.read().strip() + if not raw: + return {} + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except Exception: + # 캐시는 진짜 동작을 막으면 안 되므로 실패해도 {} 로 폴백 + return {} + + +def _save_inventory_cache(cache_path: str, inv: dict) -> None: + """성공적으로 읽은 인벤토리를 캐시에 저장한다.""" + try: + # 게임 아이템 목록이라도 최소한의 정합성만 보장 (숫자 값 위주) + if not isinstance(inv, dict): + return + with open(cache_path, "w", encoding="utf-8") as f: + json.dump(inv, f, ensure_ascii=False) + except Exception: + # 저장 실패는 비중요(다음 실행에서 다시 RCON으로 얻으려 하면 됨) + pass + + +def _try_parse_json_object(raw: str) -> tuple[dict, bool]: + """ + RCON 출력이 JSON으로 시작하지 않더라도(앞에 잡문/개행이 섞임 등) + '{ ... }' 구간을 찾아 파싱을 시도한다. + """ + if not raw: + return {}, False + + s = raw.strip() + if not s: + return {}, False + + # 1) 우선 가장 일반적인 케이스: JSON 오브젝트로 바로 시작 + if s.startswith("{"): + try: + val = json.loads(s) + return val if isinstance(val, dict) else {}, True + except Exception: + return {}, False + + # 2) '{' 위치를 찾아서 구간 파싱 시도 + start = s.find("{") + end = s.rfind("}") + if start == -1 or end == -1 or end <= start: + return {}, False + + candidate = s[start : end + 1] + try: + val = json.loads(candidate) + return val if isinstance(val, dict) else {}, True + except Exception: + return {}, False + + +def _count_total(inv: dict) -> int: + try: + if not isinstance(inv, dict): + return 0 + return int(sum(v for v in inv.values() if isinstance(v, (int, float)))) + except Exception: + return 0 + class StateReader: def __init__(self, rcon: FactorioRCON): self.rcon = rcon + # 현재 작업 디렉터리가 바뀌어도 동일 캐시를 찾게 보장 + self.inventory_cache_path = os.path.join(BASE_DIR, INVENTORY_CACHE_FILE) def get_full_state(self) -> dict: return { @@ -29,7 +147,7 @@ class StateReader: } def get_player_info(self) -> dict: - lua = P + 'rcon.print(game.table_to_json({x=math.floor(p.position.x), y=math.floor(p.position.y), health=p.character and p.character.health or 100}))' + lua = P + JSON_HELPER_LUA + 'rcon.print(json_encode_object({x=math.floor(p.position.x), y=math.floor(p.position.y), health=p.character and p.character.health or 100}))' try: raw = self.rcon.lua(lua) return json.loads(raw) if raw and raw.startswith("{") else {} @@ -38,38 +156,205 @@ class StateReader: def get_inventory(self) -> dict: # Factorio 2.0 get_contents() 호환: pcall로 안전하게 - lua = P + """ + debug_flag = "true" if os.environ.get("INV_DEBUG") == "1" else "false" + lua = P + JSON_HELPER_LUA + f"local DEBUG_INV = {debug_flag}\n" + """ local ok, err = pcall(function() - local inv = p.get_main_inventory() - if not inv then rcon.print("{}") return end - local result = {} + local inv = (p.get_main_inventory and p.get_main_inventory()) or nil + local meta = { DEBUG_INV = DEBUG_INV } + if not inv and p.get_inventory and defines.inventory and defines.inventory.character_main then + -- 일부 환경에서는 get_main_inventory() 대신 character_main을 직접 요청해야 함 + inv = p.get_inventory(defines.inventory.character_main) + end + if not inv then + if DEBUG_INV then + rcon.print(json_encode_object({ inventory = {}, debug = { _inv_error = "inv_nil", meta = meta } })) + else + rcon.print("{}") + end + return + end + local contents = {} + meta.inv_has_get_contents = (inv.get_contents and true) or false if inv.get_contents then - -- Factorio 2.0: get_contents()가 있으면 가장 안정적으로 읽을 수 있음 - result = inv.get_contents() + -- Factorio 2.0: get_contents()가 있으면 가장 안정적으로 읽음 + local c = inv.get_contents() + -- 일부 환경에서 get_contents()가 nil/비테이블을 반환할 수 있음 + meta.get_contents_type = type(c) + if c and type(c) == "table" then + local k = 0 + for _ in pairs(c) do + k = k + 1 + end + meta.get_contents_keys = k + contents = c + end else - -- 호환용 폴백 (일부 버전에서 #inv / 인덱스 접근이 불안정할 수 있음) + -- 호환용 폴백 for i = 1, #inv do local stack = inv[i] - if stack.valid_for_read then - result[stack.name] = (result[stack.name] or 0) + stack.count + if stack and stack.valid_for_read then + contents[stack.name] = (contents[stack.name] or 0) + stack.count end end end - rcon.print(game.table_to_json(result)) + + -- 직렬화 실패를 줄이기 위해, out은 "아이템명(string) -> 개수(number)"만 남긴다. + -- inv.get_contents()가 (1) map 형태(name->count)일 수도 있고, (2) 스택 객체 리스트 형태일 수도 있어 방어적으로 처리한다. + local out = {} + for name, count in pairs(contents) do + if type(name) == "string" then + -- map 형태: contents["iron-plate"] = 3 + out[name] = tonumber(count) or 0 + elseif type(count) == "table" then + -- 리스트 형태: contents[1] = {name=..., count=...} (또는 유사 필드) + local nm = count.name or count[1] + local ct = count.count or count.amount or count[2] + if nm then + out[tostring(nm)] = tonumber(ct) or 0 + end + else + -- 알 수 없는 형태는 무시 + end + end + + local ok2, json_str = pcall(function() return json_encode_object(out) end) + if ok2 and json_str then + if DEBUG_INV then + local debug = {} + + local cursor = p.cursor_stack + if cursor and cursor.valid_for_read then + debug.cursor_name = tostring(cursor.name) + debug.cursor_count = tonumber(cursor.count) or 0 + else + debug.cursor_name = "EMPTY" + debug.cursor_count = 0 + end + + local armor_type = (defines.inventory and defines.inventory.character_armor) or nil + local armor_inv = (armor_type and p.get_inventory) and p.get_inventory(armor_type) or nil + local armor_out = {} + local armor_total = 0 + if armor_inv and armor_inv.get_contents then + local a = armor_inv.get_contents() + if a and type(a) == "table" then + for name, count in pairs(a) do + local c = tonumber(count) or 0 + armor_out[tostring(name)] = c + armor_total = armor_total + c + end + end + end + debug.armor_total = armor_total + debug.armor = armor_out + + local trash_type = (defines.inventory and defines.inventory.character_trash) or nil + local trash_inv = (trash_type and p.get_inventory) and p.get_inventory(trash_type) or nil + local trash_out = {} + local trash_total = 0 + if trash_inv and trash_inv.get_contents then + local t = trash_inv.get_contents() + if t and type(t) == "table" then + for name, count in pairs(t) do + local c = tonumber(count) or 0 + trash_out[tostring(name)] = c + trash_total = trash_total + c + end + end + end + debug.trash_total = trash_total + debug.trash = trash_out + + rcon.print(json_encode_object({ inventory = out, debug = debug })) + else + rcon.print(json_str) + end + else + if DEBUG_INV then + rcon.print(json_encode_object({ + inventory = out, + debug = { _json_encode_ok2 = ok2, _json_encode_err = tostring(json_str), meta = meta } + })) + else + rcon.print("{}") + end + end end) -if not ok then rcon.print("{}") end +if not ok then + if DEBUG_INV then + rcon.print(json_encode_object({ inventory = {}, debug = { _pcall_error = tostring(err), meta = { DEBUG_INV = DEBUG_INV } } })) + else + rcon.print("{}") + end +end """ try: raw = self.rcon.lua(lua) - return json.loads(raw) if raw and raw.startswith("{") else {} + if os.environ.get("INV_DEBUG") == "1": + rs = raw if isinstance(raw, str) else str(raw) + # 너무 길어지지 않게 일부만 출력 + rs1 = rs.replace("\n", "\\n")[:200] + print( + f"[INV_DEBUG] raw_inv_resp_snip={rs1} " + f"has_brace_open={'{' in rs} has_brace_close={'}' in rs}" + ) + inv, parsed_ok = _try_parse_json_object(raw) + if os.environ.get("INV_DEBUG") == "1": + print( + f"[INV_DEBUG] inv_parse parsed_ok={parsed_ok} " + f"inv_is_dict={isinstance(inv, dict)} " + f"inv_keys={list(inv.keys())[:5] if isinstance(inv, dict) else 'na'}" + ) + if os.environ.get("INV_DEBUG") == "1" and parsed_ok and isinstance(inv, dict): + if "inventory" in inv and "debug" in inv: + debug = inv.get("debug", {}) + meta = debug.get("meta", {}) + cursor_name = debug.get("cursor_name", "") + cursor_count = debug.get("cursor_count", 0) + armor_total = debug.get("armor_total", 0) + trash_total = debug.get("trash_total", 0) + get_contents_type = meta.get("get_contents_type", "") + get_contents_keys = meta.get("get_contents_keys", 0) + print( + f"[INV_DEBUG] main_items={len(inv.get('inventory',{}))} " + f"cursor={cursor_name}:{cursor_count} armor_total={armor_total} trash_total={trash_total} " + f"get_contents={get_contents_type}:{get_contents_keys}" + ) + inv = inv.get("inventory", {}) + else: + items = len(inv) if isinstance(inv, dict) else 0 + total = _count_total(inv) + print(f"[INV_DEBUG] main_parseOK | items={items} total={total} raw_snip={raw[:80]}") + + # fallback only: RCON에서 읽어온 inv가 비어있으면 캐시 사용 + # (inv가 {}인 원인이 "진짜 빈 인벤토리"든 "읽기 실패"든, 정책상 캐시 fallback을 한다) + if inv: + _save_inventory_cache(self.inventory_cache_path, inv) + return inv + + cached = _load_inventory_cache(self.inventory_cache_path) + if os.environ.get("INV_DEBUG") == "1": + print( + f"[INV_DEBUG] cache_loaded | items={len(cached)} total={_count_total(cached)} " + f"path={self.inventory_cache_path}" + ) + return cached except Exception: - return {} + # RCON 실패면 캐시로 대체 (cache도 없으면 {}) + cached = _load_inventory_cache(self.inventory_cache_path) + if os.environ.get("INV_DEBUG") == "1": + print( + f"[INV_DEBUG] cache_loaded_on_exception | items={len(cached)} total={_count_total(cached)} " + f"path={self.inventory_cache_path}" + ) + return cached def scan_resources(self) -> dict: """position+radius 방식으로 자원 스캔 (f-string 중괄호 문제 완전 회피)""" - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local surface = p.surface + local px, py = p.position.x, p.position.y local all_res = surface.find_entities_filtered{position = p.position, radius = 500, type = "resource"} if not all_res or #all_res == 0 then rcon.print("{}") @@ -79,22 +364,37 @@ local ok, err = pcall(function() for _, e in ipairs(all_res) do local name = e.name if not resources[name] then - resources[name] = {count = 0, sx = 0, sy = 0} + resources[name] = {count = 0, sx = 0, sy = 0, best_dx2 = math.huge, best_x = 0, best_y = 0} end local r = resources[name] r.count = r.count + 1 r.sx = r.sx + e.position.x r.sy = r.sy + e.position.y + + -- "패치 중심(평균)"이 실제 엔티티 좌표와 멀어질 수 있어, + -- 플레이어 기준으로 가장 가까운 엔티티 좌표를 anchor로 함께 반환한다. + local dx = e.position.x - px + local dy = e.position.y - py + local d2 = dx*dx + dy*dy + if d2 < r.best_dx2 then + r.best_dx2 = d2 + r.best_x = e.position.x + r.best_y = e.position.y + end end local out = {} for name, r in pairs(resources) do out[name] = { count = r.count, center_x = math.floor(r.sx / r.count), - center_y = math.floor(r.sy / r.count) + center_y = math.floor(r.sy / r.count), + anchor_x = r.best_x, + anchor_y = r.best_y, + anchor_tile_x = math.floor(r.best_x + 0.5), + anchor_tile_y = math.floor(r.best_y + 0.5), } end - rcon.print(game.table_to_json(out)) + rcon.print(json_encode_object(out)) end) if not ok then rcon.print("{}") end """ @@ -106,7 +406,7 @@ if not ok then rcon.print("{}") end def get_buildings(self) -> dict: """type 기반 검색 (f-string 없음, pcall 안전)""" - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local surface = p.surface local result = {} @@ -116,7 +416,7 @@ local ok, err = pcall(function() result[e.name] = (result[e.name] or 0) + 1 end end - rcon.print(game.table_to_json(result)) + rcon.print(json_encode_object(result)) end) if not ok then rcon.print("{}") end """ @@ -127,14 +427,14 @@ if not ok then rcon.print("{}") end return {} def get_research_status(self) -> dict: - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local force = p.force local k = 0 for name, tech in pairs(force.technologies) do if tech.researched then k = k + 1 end end - rcon.print(game.table_to_json({ + rcon.print(json_encode_object({ current = force.current_research and force.current_research.name or "none", completed_count = k })) @@ -174,13 +474,23 @@ if not ok then rcon.print("{}") end py = p.get('y', 0) sorted_res = sorted( res.items(), - key=lambda item: ((item[1].get('center_x',0) - px)**2 + (item[1].get('center_y',0) - py)**2) + key=lambda item: ( + ( + item[1].get('anchor_tile_x', item[1].get('center_x', 0)) - px + )**2 + + ( + item[1].get('anchor_tile_y', item[1].get('center_y', 0)) - py + )**2 + ) ) for ore, info in sorted_res: - dist = int(((info.get('center_x',0) - px)**2 + (info.get('center_y',0) - py)**2)**0.5) + ax = info.get('anchor_tile_x', info.get('center_x', 0)) + ay = info.get('anchor_tile_y', info.get('center_y', 0)) + dist = int(((ax - px)**2 + (ay - py)**2)**0.5) lines.append( f"- {ore}: {info.get('count',0)}타일 " - f"(중심: {info.get('center_x','?')}, {info.get('center_y','?')}) " + f"(앵커: {ax}, {ay}) " + f"[중심: {info.get('center_x','?')}, {info.get('center_y','?')}] " f"[거리: ~{dist}타일]" ) else: