feat: 인벤토리 캐시 및 JSON 인코더 추가
- 인벤토리 캐시 기능을 추가하여, RCON으로 인벤토리를 읽지 못할 경우 이전에 성공적으로 읽은 데이터를 활용 - Lua에서 JSON 인코딩을 위한 간단한 함수 추가, 일부 Factorio 버전에서 `game.table_to_json`이 없을 경우 대체 - `README.md`에 인벤토리 캐시 및 JSON 인코더 사용에 대한 설명 추가 - `scan_resources()`와 `mine_resource`의 반경을 확장하여 자원 탐색 실패를 줄임
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
run_factorio_ai.ps1
|
||||||
|
inventory_memory.json
|
||||||
|
.cursor/
|
||||||
31
README.md
31
README.md
@@ -75,6 +75,20 @@ python main.py
|
|||||||
FACTORIO_HOST=192.168.1.10 FACTORIO_PORT=25575 FACTORIO_PASSWORD=mypass 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 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다
|
- AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다
|
||||||
- `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다
|
- `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다
|
||||||
- `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다.
|
- `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`) 키로 통일해, 제외한 좌표가 반복 선택되지 않도록 합니다.
|
- `mine_resource`에서 실패한 채굴 타일 제외(`exclude`)는 Lua와 Python 양쪽에서 정수 타일 좌표(`tx, ty`) 키로 통일해, 제외한 좌표가 반복 선택되지 않도록 합니다.
|
||||||
- 또한 채굴 시작(`mining_state`) 좌표는 정수 타일이 아니라, Lua가 찾은 실제 자원 엔티티의 `e.position`(정확 실수 좌표)을 사용해 “플레이어가 타일 위에 있는데도 즉시 채굴 감지 실패”를 줄입니다.
|
- 또한 채굴 시작(`mining_state`) 좌표는 정수 타일이 아니라, Lua가 찾은 실제 자원 엔티티의 `e.position`(정확 실수 좌표)을 사용해 “플레이어가 타일 위에 있는데도 즉시 채굴 감지 실패”를 줄입니다.
|
||||||
- `mine_resource`는 move 후 `p.position` 근처(반경 1.2)에서 실제로 해당 광물 엔티티가 있는지 재확인하고, 없으면 채굴을 시도하지 않고 다음 후보로 넘어갑니다.
|
- `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`처럼 주변에 흔한 자원만 계속 발견되어 진행이 막히는 문제를 줄입니다.
|
- `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` / 인덱스 접근이 비어있음으로 오인되는 문제를 줄입니다.
|
- 인벤토리 판독은 `inv.get_contents()`를 우선 사용해, 일부 환경에서 `#inv` / 인덱스 접근이 비어있음으로 오인되는 문제를 줄입니다.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -109,9 +109,9 @@ if ok then rcon.print(data) else rcon.print("ERROR") end
|
|||||||
last_pos = pos_str
|
last_pos = pos_str
|
||||||
if stuck_count >= 3:
|
if stuck_count >= 3:
|
||||||
self.rcon.lua(P + "p.walking_state = {walking = false, direction = defines.direction.north}")
|
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}")
|
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]:
|
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"""
|
find_result = self.rcon.lua(P + f"""
|
||||||
{exclude_lua}
|
{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
|
if #res == 0 then rcon.print("NOT_FOUND") return end
|
||||||
-- 거리순 정렬
|
-- 거리순 정렬
|
||||||
local pos = p.position
|
local pos = p.position
|
||||||
@@ -204,11 +204,11 @@ rcon.print("ALL_EXCLUDED")
|
|||||||
if find_result == "NOT_FOUND":
|
if find_result == "NOT_FOUND":
|
||||||
if total_mined > 0:
|
if total_mined > 0:
|
||||||
return True, f"{ore} {total_mined}개 채굴 (주변 광석 소진)"
|
return True, f"{ore} {total_mined}개 채굴 (주변 광석 소진)"
|
||||||
return False, f"반경 80 내 {ore} 없음 — explore로 다른 광맥 찾기"
|
return False, f"반경 250 내 {ore} 없음 - explore로 다른 광맥 찾기"
|
||||||
if find_result == "ALL_EXCLUDED":
|
if find_result == "ALL_EXCLUDED":
|
||||||
if total_mined > 0:
|
if total_mined > 0:
|
||||||
return True, f"{ore} {total_mined}개 채굴 (접근 가능 타일 모두 시도)"
|
return True, f"{ore} {total_mined}개 채굴 (접근 가능 타일 모두 시도)"
|
||||||
return False, f"{ore} 근처 타일 {len(failed_positions)}개 모두 접근 불가 — 다른 위치로 이동 필요"
|
return False, f"{ore} 근처 타일 {len(failed_positions)}개 모두 접근 불가 - 다른 위치로 이동 필요"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
find_result = find_result.strip()
|
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:
|
if not mined_this_tile:
|
||||||
# 이 타일에서 한 개도 못 캤음 → 접근 불가
|
# 이 타일에서 한 개도 못 캤음 → 접근 불가
|
||||||
failed_positions.add((ox, oy))
|
failed_positions.add((ox, oy))
|
||||||
print(f" [채굴] ({ox:.0f},{oy:.0f}) 접근 불가 → 제외 목록 추가 (총 {len(failed_positions)}개)")
|
print(f" [채굴] ({ox:.0f},{oy:.0f}) 접근 불가 -> 제외 목록 추가 (총 {len(failed_positions)}개)")
|
||||||
else:
|
else:
|
||||||
# 채굴 됐지만 타일 고갈
|
# 채굴 됐지만 타일 고갈
|
||||||
print(f" [채굴] ({ox:.0f},{oy:.0f}) 고갈/중단. 현재 {total_mined}개 채굴됨")
|
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:
|
if total_mined > 0:
|
||||||
return True, f"{ore} {total_mined}개 채굴 (목표 {count}개 중 일부)"
|
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]:
|
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 not result or result in ("NO_PLAYER", "NO_CHARACTER"): return False, result or "플레이어 없음"
|
||||||
if result == "OK": return True, f"{name} 배치 ({x},{y})"
|
if result == "OK": return True, f"{name} 배치 ({x},{y})"
|
||||||
elif result == "NO_ITEM": return False, f"인벤토리에 {name} 없음"
|
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"배치 불가"
|
elif result == "BLOCKED": return False, f"배치 불가"
|
||||||
return False, f"배치 실패: {result}"
|
return False, f"배치 실패: {result}"
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ JSON 파싱 강화:
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
|
||||||
@@ -145,7 +146,7 @@ class AIPlanner:
|
|||||||
|
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
raw = self._call_glm(user_message)
|
raw = self._call_glm(user_message, attempt=attempt)
|
||||||
plan = self._parse_json(raw)
|
plan = self._parse_json(raw)
|
||||||
break
|
break
|
||||||
except (ValueError, json.JSONDecodeError) as e:
|
except (ValueError, json.JSONDecodeError) as e:
|
||||||
@@ -164,13 +165,13 @@ class AIPlanner:
|
|||||||
|
|
||||||
thinking = plan.get("thinking", "")
|
thinking = plan.get("thinking", "")
|
||||||
if 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"[AI] 현재 목표: {plan.get('current_goal', '')}")
|
||||||
print(f"📋 완료 후: {plan.get('after_this', '')}")
|
print(f"[AI] 완료 후: {plan.get('after_this', '')}")
|
||||||
|
|
||||||
actions = plan.get("actions", [])
|
actions = plan.get("actions", [])
|
||||||
print(f"⚡ {len(actions)}개 행동 계획됨")
|
print(f"[AI] {len(actions)}개 행동 계획됨")
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
def record_feedback(self, action: dict, success: bool, message: str = ""):
|
def record_feedback(self, action: dict, success: bool, message: str = ""):
|
||||||
@@ -188,7 +189,7 @@ class AIPlanner:
|
|||||||
return ""
|
return ""
|
||||||
lines = ["### 이전 행동 결과 (성공/실패)\n"]
|
lines = ["### 이전 행동 결과 (성공/실패)\n"]
|
||||||
for fb in self.feedback_log[-8:]:
|
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 ""
|
msg = f" — {fb['message']}" if fb["message"] else ""
|
||||||
lines.append(
|
lines.append(
|
||||||
f" {status} {fb['action']} "
|
f" {status} {fb['action']} "
|
||||||
@@ -196,7 +197,7 @@ class AIPlanner:
|
|||||||
)
|
)
|
||||||
return "\n".join(lines) + "\n\n"
|
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({
|
payload = json.dumps({
|
||||||
"model": GLM_MODEL,
|
"model": GLM_MODEL,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -207,6 +208,10 @@ class AIPlanner:
|
|||||||
"max_tokens": 2000,
|
"max_tokens": 2000,
|
||||||
}).encode("utf-8")
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
prompt_chars = len(user_message)
|
||||||
|
system_chars = len(SYSTEM_PROMPT)
|
||||||
|
max_tokens = 2000
|
||||||
|
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
GLM_API_URL,
|
GLM_API_URL,
|
||||||
data = payload,
|
data = payload,
|
||||||
@@ -217,9 +222,36 @@ class AIPlanner:
|
|||||||
method = "POST",
|
method = "POST",
|
||||||
)
|
)
|
||||||
try:
|
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:
|
with urllib.request.urlopen(req, timeout=90) as resp:
|
||||||
data = json.loads(resp.read().decode("utf-8"))
|
raw_text = resp.read().decode("utf-8")
|
||||||
return data["choices"][0]["message"]["content"].strip()
|
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:
|
except urllib.error.HTTPError as e:
|
||||||
raise ConnectionError(f"GLM API 오류 {e.code}: {e.read().decode()}")
|
raise ConnectionError(f"GLM API 오류 {e.code}: {e.read().decode()}")
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,56 @@ ZONE_SIZE = 64
|
|||||||
|
|
||||||
P = 'local p = game.players[1] if not p then rcon.print("{}") return end '
|
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:
|
class ContextCompressor:
|
||||||
def __init__(self, rcon: FactorioRCON):
|
def __init__(self, rcon: FactorioRCON):
|
||||||
@@ -37,7 +87,7 @@ class ContextCompressor:
|
|||||||
return self._format_level2(global_summary, zones, problems, drilldown, player)
|
return self._format_level2(global_summary, zones, problems, drilldown, player)
|
||||||
|
|
||||||
def _get_global_summary(self) -> dict:
|
def _get_global_summary(self) -> dict:
|
||||||
lua = P + """
|
lua = P + JSON_HELPER_LUA + """
|
||||||
local ok, err = pcall(function()
|
local ok, err = pcall(function()
|
||||||
local surface = p.surface
|
local surface = p.surface
|
||||||
local force = p.force
|
local force = p.force
|
||||||
@@ -60,7 +110,7 @@ local ok, err = pcall(function()
|
|||||||
for _, t in pairs(force.technologies) do
|
for _, t in pairs(force.technologies) do
|
||||||
if t.researched then researched = researched + 1 end
|
if t.researched then researched = researched + 1 end
|
||||||
end
|
end
|
||||||
rcon.print(game.table_to_json({
|
rcon.print(json_encode_object({
|
||||||
total_entities = total,
|
total_entities = total,
|
||||||
counts = result,
|
counts = result,
|
||||||
current_research = current_tech,
|
current_research = current_tech,
|
||||||
@@ -78,7 +128,7 @@ if not ok then rcon.print("{}") end
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def _get_zone_summaries(self) -> list[dict]:
|
def _get_zone_summaries(self) -> list[dict]:
|
||||||
lua = P + """
|
lua = P + JSON_HELPER_LUA + """
|
||||||
local ok, err = pcall(function()
|
local ok, err = pcall(function()
|
||||||
local surface = p.surface
|
local surface = p.surface
|
||||||
local Z = 64
|
local Z = 64
|
||||||
@@ -109,7 +159,7 @@ local ok, err = pcall(function()
|
|||||||
for _, z in pairs(zones) do
|
for _, z in pairs(zones) do
|
||||||
if z.entities > 2 then result[#result+1] = z end
|
if z.entities > 2 then result[#result+1] = z end
|
||||||
end
|
end
|
||||||
rcon.print(game.table_to_json(result))
|
rcon.print(json_encode_array(result))
|
||||||
end)
|
end)
|
||||||
if not ok then rcon.print("[]") end
|
if not ok then rcon.print("[]") end
|
||||||
"""
|
"""
|
||||||
@@ -120,7 +170,7 @@ if not ok then rcon.print("[]") end
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def _detect_problems(self) -> list[str]:
|
def _detect_problems(self) -> list[str]:
|
||||||
lua = P + """
|
lua = P + JSON_HELPER_LUA + """
|
||||||
local ok, err = pcall(function()
|
local ok, err = pcall(function()
|
||||||
local surface = p.surface
|
local surface = p.surface
|
||||||
local problems = {}
|
local problems = {}
|
||||||
@@ -140,7 +190,7 @@ local ok, err = pcall(function()
|
|||||||
if #ore > 0 and ore[1].amount < 5000 then depleting = depleting + 1 end
|
if #ore > 0 and ore[1].amount < 5000 then depleting = depleting + 1 end
|
||||||
end
|
end
|
||||||
if depleting > 0 then problems[#problems+1] = "자원 고갈 임박: " .. depleting .. "개" end
|
if depleting > 0 then problems[#problems+1] = "자원 고갈 임박: " .. depleting .. "개" end
|
||||||
rcon.print(game.table_to_json(problems))
|
rcon.print(json_encode_array(problems))
|
||||||
end)
|
end)
|
||||||
if not ok then rcon.print("[]") end
|
if not ok then rcon.print("[]") end
|
||||||
"""
|
"""
|
||||||
@@ -164,7 +214,7 @@ if not ok then rcon.print("[]") end
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _get_player_info(self) -> dict:
|
def _get_player_info(self) -> dict:
|
||||||
lua = P + """
|
lua = P + JSON_HELPER_LUA + """
|
||||||
local ok, err = pcall(function()
|
local ok, err = pcall(function()
|
||||||
local inv = p.get_main_inventory()
|
local inv = p.get_main_inventory()
|
||||||
if not inv then rcon.print("{}") return end
|
if not inv then rcon.print("{}") return end
|
||||||
@@ -181,7 +231,7 @@ local ok, err = pcall(function()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
rcon.print(game.table_to_json({
|
rcon.print(json_encode_object({
|
||||||
x = math.floor(p.position.x),
|
x = math.floor(p.position.x),
|
||||||
y = math.floor(p.position.y),
|
y = math.floor(p.position.y),
|
||||||
inventory = inv_summary
|
inventory = inv_summary
|
||||||
|
|||||||
90
docs/plan.md
90
docs/plan.md
@@ -22,3 +22,93 @@
|
|||||||
### README 업데이트 계획
|
### README 업데이트 계획
|
||||||
- 채굴 제외(exclude) 로직이 “정수 타일 키 기반으로 통일”되도록 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`)을 설명
|
||||||
|
|
||||||
|
|||||||
14
main.py
14
main.py
@@ -41,11 +41,11 @@ if p then
|
|||||||
rcon.print(p.name .. " @ " .. string.format("%.0f, %.0f", p.position.x, p.position.y))
|
rcon.print(p.name .. " @ " .. string.format("%.0f, %.0f", p.position.x, p.position.y))
|
||||||
end
|
end
|
||||||
""")
|
""")
|
||||||
print(f"[초기화] ✅ 플레이어 발견: {info}")
|
print(f"[초기화] OK 플레이어 발견: {info}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
print("[초기화] ⚠️ 접속한 플레이어가 없습니다!")
|
print("[초기화] WARN 접속한 플레이어가 없습니다!")
|
||||||
print("[초기화] 팩토리오 클라이언트로 이 서버에 접속하세요.")
|
print("[초기화] 팩토리오 클라이언트로 이 서버에 접속하세요.")
|
||||||
print(f"[초기화] 서버 주소: {RCON_HOST}")
|
print(f"[초기화] 서버 주소: {RCON_HOST}")
|
||||||
print(f"[초기화] 대기 중... (최대 30초)")
|
print(f"[초기화] 대기 중... (최대 30초)")
|
||||||
@@ -86,7 +86,7 @@ def run():
|
|||||||
|
|
||||||
# ── 압축 레벨 자동 선택 ──
|
# ── 압축 레벨 자동 선택 ──
|
||||||
if entity_count < COMPRESS_THRESHOLD:
|
if entity_count < COMPRESS_THRESHOLD:
|
||||||
print("[상태] 초반 모드 — 상세 상태 수집")
|
print("[상태] 초반 모드 - 상세 상태 수집")
|
||||||
state = reader.get_full_state()
|
state = reader.get_full_state()
|
||||||
summary = reader.summarize_for_ai(state)
|
summary = reader.summarize_for_ai(state)
|
||||||
entity_count = sum(
|
entity_count = sum(
|
||||||
@@ -94,10 +94,10 @@ def run():
|
|||||||
if isinstance(v, int)
|
if isinstance(v, int)
|
||||||
)
|
)
|
||||||
elif entity_count < 200:
|
elif entity_count < 200:
|
||||||
print(f"[상태] 중반 모드 — 구역 압축 (건물 {entity_count}개)")
|
print(f"[상태] 중반 모드 - 구역 압축 (건물 {entity_count}개)")
|
||||||
summary = compressor.get_compressed_state(detail_level=1)
|
summary = compressor.get_compressed_state(detail_level=1)
|
||||||
else:
|
else:
|
||||||
print(f"[상태] 후반 모드 — 글로벌 압축 (건물 {entity_count}개)")
|
print(f"[상태] 후반 모드 - 글로벌 압축 (건물 {entity_count}개)")
|
||||||
summary = compressor.get_compressed_state(detail_level=0)
|
summary = compressor.get_compressed_state(detail_level=0)
|
||||||
|
|
||||||
global_info = compressor._get_global_summary()
|
global_info = compressor._get_global_summary()
|
||||||
@@ -121,13 +121,13 @@ def run():
|
|||||||
print(f" params: {json.dumps(action.get('params',{}), ensure_ascii=False)}")
|
print(f" params: {json.dumps(action.get('params',{}), ensure_ascii=False)}")
|
||||||
|
|
||||||
success, message = executor.execute(action)
|
success, message = executor.execute(action)
|
||||||
status = "✅" if success else "❌"
|
status = "OK" if success else "FAIL"
|
||||||
print(f" 결과: {status} {message}")
|
print(f" 결과: {status} {message}")
|
||||||
|
|
||||||
planner.record_feedback(action, success, message)
|
planner.record_feedback(action, success, message)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
print(" → 실패. 재계획 요청")
|
print(" -> 실패. 재계획 요청")
|
||||||
queue.clear()
|
queue.clear()
|
||||||
|
|
||||||
_log(total_actions, action, success, message)
|
_log(total_actions, action, success, message)
|
||||||
|
|||||||
360
state_reader.py
360
state_reader.py
@@ -9,15 +9,133 @@ RCON을 통해 팩토리오 게임 상태를 읽어오는 모듈
|
|||||||
- 모든 Python 파싱 try/except 감싸기
|
- 모든 Python 파싱 try/except 감싸기
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from factorio_rcon import FactorioRCON
|
from factorio_rcon import FactorioRCON
|
||||||
|
|
||||||
|
|
||||||
P = 'local p = game.players[1] if not p then rcon.print("{}") return end '
|
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:
|
class StateReader:
|
||||||
def __init__(self, rcon: FactorioRCON):
|
def __init__(self, rcon: FactorioRCON):
|
||||||
self.rcon = rcon
|
self.rcon = rcon
|
||||||
|
# 현재 작업 디렉터리가 바뀌어도 동일 캐시를 찾게 보장
|
||||||
|
self.inventory_cache_path = os.path.join(BASE_DIR, INVENTORY_CACHE_FILE)
|
||||||
|
|
||||||
def get_full_state(self) -> dict:
|
def get_full_state(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -29,7 +147,7 @@ class StateReader:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_player_info(self) -> dict:
|
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:
|
try:
|
||||||
raw = self.rcon.lua(lua)
|
raw = self.rcon.lua(lua)
|
||||||
return json.loads(raw) if raw and raw.startswith("{") else {}
|
return json.loads(raw) if raw and raw.startswith("{") else {}
|
||||||
@@ -38,38 +156,205 @@ class StateReader:
|
|||||||
|
|
||||||
def get_inventory(self) -> dict:
|
def get_inventory(self) -> dict:
|
||||||
# Factorio 2.0 get_contents() 호환: pcall로 안전하게
|
# 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 ok, err = pcall(function()
|
||||||
local inv = p.get_main_inventory()
|
local inv = (p.get_main_inventory and p.get_main_inventory()) or nil
|
||||||
if not inv then rcon.print("{}") return end
|
local meta = { DEBUG_INV = DEBUG_INV }
|
||||||
local result = {}
|
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
|
if inv.get_contents then
|
||||||
-- Factorio 2.0: get_contents()가 있으면 가장 안정적으로 읽을 수 있음
|
-- Factorio 2.0: get_contents()가 있으면 가장 안정적으로 읽음
|
||||||
result = inv.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
|
else
|
||||||
-- 호환용 폴백 (일부 버전에서 #inv / 인덱스 접근이 불안정할 수 있음)
|
-- 호환용 폴백
|
||||||
for i = 1, #inv do
|
for i = 1, #inv do
|
||||||
local stack = inv[i]
|
local stack = inv[i]
|
||||||
if stack.valid_for_read then
|
if stack and stack.valid_for_read then
|
||||||
result[stack.name] = (result[stack.name] or 0) + stack.count
|
contents[stack.name] = (contents[stack.name] or 0) + stack.count
|
||||||
end
|
end
|
||||||
end
|
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)
|
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:
|
try:
|
||||||
raw = self.rcon.lua(lua)
|
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:
|
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:
|
def scan_resources(self) -> dict:
|
||||||
"""position+radius 방식으로 자원 스캔 (f-string 중괄호 문제 완전 회피)"""
|
"""position+radius 방식으로 자원 스캔 (f-string 중괄호 문제 완전 회피)"""
|
||||||
lua = P + """
|
lua = P + JSON_HELPER_LUA + """
|
||||||
local ok, err = pcall(function()
|
local ok, err = pcall(function()
|
||||||
local surface = p.surface
|
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"}
|
local all_res = surface.find_entities_filtered{position = p.position, radius = 500, type = "resource"}
|
||||||
if not all_res or #all_res == 0 then
|
if not all_res or #all_res == 0 then
|
||||||
rcon.print("{}")
|
rcon.print("{}")
|
||||||
@@ -79,22 +364,37 @@ local ok, err = pcall(function()
|
|||||||
for _, e in ipairs(all_res) do
|
for _, e in ipairs(all_res) do
|
||||||
local name = e.name
|
local name = e.name
|
||||||
if not resources[name] then
|
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
|
end
|
||||||
local r = resources[name]
|
local r = resources[name]
|
||||||
r.count = r.count + 1
|
r.count = r.count + 1
|
||||||
r.sx = r.sx + e.position.x
|
r.sx = r.sx + e.position.x
|
||||||
r.sy = r.sy + e.position.y
|
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
|
end
|
||||||
local out = {}
|
local out = {}
|
||||||
for name, r in pairs(resources) do
|
for name, r in pairs(resources) do
|
||||||
out[name] = {
|
out[name] = {
|
||||||
count = r.count,
|
count = r.count,
|
||||||
center_x = math.floor(r.sx / 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
|
end
|
||||||
rcon.print(game.table_to_json(out))
|
rcon.print(json_encode_object(out))
|
||||||
end)
|
end)
|
||||||
if not ok then rcon.print("{}") end
|
if not ok then rcon.print("{}") end
|
||||||
"""
|
"""
|
||||||
@@ -106,7 +406,7 @@ if not ok then rcon.print("{}") end
|
|||||||
|
|
||||||
def get_buildings(self) -> dict:
|
def get_buildings(self) -> dict:
|
||||||
"""type 기반 검색 (f-string 없음, pcall 안전)"""
|
"""type 기반 검색 (f-string 없음, pcall 안전)"""
|
||||||
lua = P + """
|
lua = P + JSON_HELPER_LUA + """
|
||||||
local ok, err = pcall(function()
|
local ok, err = pcall(function()
|
||||||
local surface = p.surface
|
local surface = p.surface
|
||||||
local result = {}
|
local result = {}
|
||||||
@@ -116,7 +416,7 @@ local ok, err = pcall(function()
|
|||||||
result[e.name] = (result[e.name] or 0) + 1
|
result[e.name] = (result[e.name] or 0) + 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
rcon.print(game.table_to_json(result))
|
rcon.print(json_encode_object(result))
|
||||||
end)
|
end)
|
||||||
if not ok then rcon.print("{}") end
|
if not ok then rcon.print("{}") end
|
||||||
"""
|
"""
|
||||||
@@ -127,14 +427,14 @@ if not ok then rcon.print("{}") end
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
def get_research_status(self) -> dict:
|
def get_research_status(self) -> dict:
|
||||||
lua = P + """
|
lua = P + JSON_HELPER_LUA + """
|
||||||
local ok, err = pcall(function()
|
local ok, err = pcall(function()
|
||||||
local force = p.force
|
local force = p.force
|
||||||
local k = 0
|
local k = 0
|
||||||
for name, tech in pairs(force.technologies) do
|
for name, tech in pairs(force.technologies) do
|
||||||
if tech.researched then k = k + 1 end
|
if tech.researched then k = k + 1 end
|
||||||
end
|
end
|
||||||
rcon.print(game.table_to_json({
|
rcon.print(json_encode_object({
|
||||||
current = force.current_research and force.current_research.name or "none",
|
current = force.current_research and force.current_research.name or "none",
|
||||||
completed_count = k
|
completed_count = k
|
||||||
}))
|
}))
|
||||||
@@ -174,13 +474,23 @@ if not ok then rcon.print("{}") end
|
|||||||
py = p.get('y', 0)
|
py = p.get('y', 0)
|
||||||
sorted_res = sorted(
|
sorted_res = sorted(
|
||||||
res.items(),
|
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:
|
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(
|
lines.append(
|
||||||
f"- {ore}: {info.get('count',0)}타일 "
|
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}타일]"
|
f"[거리: ~{dist}타일]"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user