feat: 메모리 섹션 추가 및 상태 요약 개선
- `_append_memory_sections` 함수를 추가하여 압축 모드에서 기억된 광맥과 마지막 행동 정보를 상태 요약에 포함하도록 개선 - `main.py`에서 상태 요약 생성 시 메모리 섹션을 자동으로 추가하여 AI의 의사결정에 필요한 정보를 제공 - `README.md`에 새로운 메모리 기능 및 사용 방법에 대한 설명 추가 - `state_reader.py`에서 인벤토리 판독 로직을 개선하여 캐시 사용 조건을 명확히 하여 안정성 향상
This commit is contained in:
@@ -152,13 +152,14 @@ planner.set_goal(
|
|||||||
- `json_parse`: 응답 JSON 파싱 시간
|
- `json_parse`: 응답 JSON 파싱 시간
|
||||||
- 파이썬 에이전트 재실행 시 인벤토리 “기억”이 필요하면 `inventory_memory.json` 캐시를 사용합니다.
|
- 파이썬 에이전트 재실행 시 인벤토리 “기억”이 필요하면 `inventory_memory.json` 캐시를 사용합니다.
|
||||||
- `RCON`에서 인벤토리를 읽지 못하거나(비정상 출력/파싱 실패 등) 직전에 성공적으로 읽은 인벤토리를 대신 사용합니다.
|
- `RCON`에서 인벤토리를 읽지 못하거나(비정상 출력/파싱 실패 등) 직전에 성공적으로 읽은 인벤토리를 대신 사용합니다.
|
||||||
- 성공적으로 인벤토리가 읽히면 해당 캐시를 갱신합니다(코드는 `inv.get_contents()`가 `nil`/비테이블을 반환해도 안전하게 `{}`로 취급).
|
- 성공적으로 인벤토리가 읽히면 해당 캐시를 갱신합니다(코드는 `inv.get_contents()`가 `nil`/비테이블을 반환해도 안전하게 처리).
|
||||||
|
- 또한 `inv.get_contents()` 결과가 비면 Lua에서 인덱스 기반으로 한 번 더 읽어서 `{}`가 과도하게 나오는 걸 줄입니다.
|
||||||
|
- 캐시 대체는 `get_inventory()`의 파싱이 실패한 경우에만 수행합니다.
|
||||||
- 또한 일부 환경에서 `p.get_main_inventory()`가 없으면 `defines.inventory.character_main`으로 한 번 더 읽습니다.
|
- 또한 일부 환경에서 `p.get_main_inventory()`가 없으면 `defines.inventory.character_main`으로 한 번 더 읽습니다.
|
||||||
- 캐시 파일이 아직 없으면(초기 실행) fallback도 `{}`가 됩니다.
|
- 캐시 파일이 아직 없으면(초기 실행) fallback도 `{}`가 됩니다.
|
||||||
- (중요) 일부 Factorio 버전에서는 `game.table_to_json`이 없을 수 있어서, Lua 내부에 간단 JSON 인코더를 넣어 인벤토리/상태를 안정적으로 가져옵니다.
|
- (중요) 일부 Factorio 버전에서는 `game.table_to_json`이 없을 수 있어서, Lua 내부에 간단 JSON 인코더를 넣어 인벤토리/상태를 안정적으로 가져옵니다.
|
||||||
- `explore` 및 `scan_resources()`로 발견한 광맥(자원 엔티티) 좌표는 `ore_patch_memory.json`에 ore 종류별로 여러 패치 좌표 목록으로 저장되고, 다음 상태 요약에서 AI가 좌표를 재사용(우선 이동)할 수 있게 합니다.
|
- `explore` 및 `scan_resources()`로 발견한 광맥(자원 엔티티) 좌표는 `ore_patch_memory.json`에 ore 종류별로 여러 패치 좌표 목록으로 저장되고, 다음 상태 요약에서 AI가 좌표를 재사용(우선 이동)할 수 있게 합니다.
|
||||||
- 디버깅용으로 `INV_DEBUG=1`을 켜면, `main inventory` 외에 `cursor_stack`/`armor`/`trash` 총합도 함께 출력합니다(어디에 아이템이 있는지 확인용).
|
- 디버깅용으로 `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`는 `LuaControl.mining_state`를 **해당 타깃에 대해 한 번만** 설정합니다. 이전에는 대기 루프마다 같은 값을반복 설정해 우클릭을 연타하는 것과 비슷한 동작이었으며, 이제는 인간이 우클릭을 누른 채로 두는 것에 가깝게 유지합니다.
|
- `mine_resource`는 `LuaControl.mining_state`를 **해당 타깃에 대해 한 번만** 설정합니다. 이전에는 대기 루프마다 같은 값을반복 설정해 우클릭을 연타하는 것과 비슷한 동작이었으며, 이제는 인간이 우클릭을 누른 채로 두는 것에 가깝게 유지합니다.
|
||||||
|
|||||||
@@ -117,9 +117,10 @@
|
|||||||
- 파이썬 에이전트만 종료/재실행할 때 `RCON`으로 인벤토리를 읽지 못하거나(`{}` 반환) 빈 값이 나오면,
|
- 파이썬 에이전트만 종료/재실행할 때 `RCON`으로 인벤토리를 읽지 못하거나(`{}` 반환) 빈 값이 나오면,
|
||||||
직전에 성공적으로 읽은 인벤토리를 기억해서 프롬프트에 반영한다.
|
직전에 성공적으로 읽은 인벤토리를 기억해서 프롬프트에 반영한다.
|
||||||
|
|
||||||
### 정책(사용자 선택: 1번 fallback only)
|
### 정책(캐시 fallback 조건 변경)
|
||||||
- `get_inventory()`가 성공적으로 읽어서 값이 비어있지 않으면 캐시를 갱신한다.
|
- `get_inventory()`에서 **JSON 파싱/출력 추출이 성공(parsed_ok=True)** 했으면, 결과가 `{}`라도 캐시로 덮지 않는다.
|
||||||
- `get_inventory()` 결과가 빈 딕셔너리이면(실패/빈 값) 캐시 파일을 로드해서 대체한다.
|
- 캐시를 덮어쓰면 실제 진행이 반영되지 않아 같은 행동을 반복하는 루프가 생길 수 있다.
|
||||||
|
- `get_inventory()`에서 **파싱이 실패(parsed_ok=False)** 하거나 예외가 발생한 경우에만 `inventory_memory.json` 캐시를 로드한다.
|
||||||
|
|
||||||
### 구현 범위
|
### 구현 범위
|
||||||
- `state_reader.py`
|
- `state_reader.py`
|
||||||
|
|||||||
79
main.py
79
main.py
@@ -17,7 +17,8 @@ from state_reader import StateReader
|
|||||||
from context_compressor import ContextCompressor
|
from context_compressor import ContextCompressor
|
||||||
from ai_planner import AIPlanner
|
from ai_planner import AIPlanner
|
||||||
from action_executor import ActionExecutor
|
from action_executor import ActionExecutor
|
||||||
from agent_last_action_memory import save_last_action_memory
|
from agent_last_action_memory import save_last_action_memory, load_last_action_memory
|
||||||
|
from ore_patch_memory import load_ore_patch_memory, compute_distance_sq
|
||||||
|
|
||||||
RCON_HOST = os.getenv("FACTORIO_HOST", "127.0.0.1")
|
RCON_HOST = os.getenv("FACTORIO_HOST", "127.0.0.1")
|
||||||
RCON_PORT = int(os.getenv("FACTORIO_PORT", "25575"))
|
RCON_PORT = int(os.getenv("FACTORIO_PORT", "25575"))
|
||||||
@@ -58,6 +59,80 @@ end
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _append_memory_sections(summary: str) -> str:
|
||||||
|
"""
|
||||||
|
압축 모드(중반/후반)에서는 context_compressor 출력만 들어가서
|
||||||
|
state_reader의 "기억된 광맥/마지막 행동" 섹션이 사라질 수 있다.
|
||||||
|
이 정보를 프롬프트에 다시 덧붙여 폴백/복구를 가능하게 한다.
|
||||||
|
"""
|
||||||
|
ore_mem = load_ore_patch_memory()
|
||||||
|
last_action = load_last_action_memory()
|
||||||
|
|
||||||
|
# compressed summary에도 "위치: (x, y)"가 포함되므로, 여기서 플레이어 좌표만 뽑는다.
|
||||||
|
m = None
|
||||||
|
try:
|
||||||
|
import re
|
||||||
|
|
||||||
|
m = re.search(r"위치:\s*\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\)", summary)
|
||||||
|
except Exception:
|
||||||
|
m = None
|
||||||
|
|
||||||
|
px = py = None
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
px = int(m.group(1))
|
||||||
|
py = int(m.group(2))
|
||||||
|
except Exception:
|
||||||
|
px = py = None
|
||||||
|
|
||||||
|
mem_lines: list[str] = []
|
||||||
|
|
||||||
|
mem_lines.append("### 기억된 광맥 (좌표)")
|
||||||
|
if ore_mem:
|
||||||
|
known: list[tuple[int, str, int, int, object]] = []
|
||||||
|
for ore, patches in ore_mem.items():
|
||||||
|
if not isinstance(patches, list):
|
||||||
|
continue
|
||||||
|
for patch in patches:
|
||||||
|
if not isinstance(patch, dict):
|
||||||
|
continue
|
||||||
|
tx = patch.get("tile_x")
|
||||||
|
ty = patch.get("tile_y")
|
||||||
|
if isinstance(tx, int) and isinstance(ty, int):
|
||||||
|
if px is not None and py is not None:
|
||||||
|
dist = int(compute_distance_sq(px, py, tx, ty) ** 0.5)
|
||||||
|
else:
|
||||||
|
dist = 10**18
|
||||||
|
known.append((dist, ore, tx, ty, patch.get("count", "?")))
|
||||||
|
|
||||||
|
known.sort(key=lambda x: x[0])
|
||||||
|
for dist, ore, tx, ty, cnt in known[:10]:
|
||||||
|
if dist >= 10**18:
|
||||||
|
mem_lines.append(f"- {ore}: ({tx},{ty}) [거리: ~?타일] count={cnt}")
|
||||||
|
else:
|
||||||
|
mem_lines.append(f"- {ore}: ({tx},{ty}) [거리: ~{dist}타일] count={cnt}")
|
||||||
|
else:
|
||||||
|
mem_lines.append("- 없음")
|
||||||
|
|
||||||
|
mem_lines.append("")
|
||||||
|
mem_lines.append("### 마지막 행동(기억)")
|
||||||
|
if last_action and isinstance(last_action, dict) and last_action.get("action"):
|
||||||
|
act = last_action.get("action", "")
|
||||||
|
success = last_action.get("success", None)
|
||||||
|
msg = last_action.get("message", "")
|
||||||
|
mem_lines.append(f"- action={act} success={success} message={msg}")
|
||||||
|
params = last_action.get("params", {})
|
||||||
|
if params:
|
||||||
|
try:
|
||||||
|
mem_lines.append(f"- params={json.dumps(params, ensure_ascii=False)}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
mem_lines.append("- 없음")
|
||||||
|
|
||||||
|
return summary + "\n\n" + "\n".join(mem_lines)
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print(" 팩토리오 순수 AI 에이전트 (치트 없음)")
|
print(" 팩토리오 순수 AI 에이전트 (치트 없음)")
|
||||||
@@ -97,9 +172,11 @@ def run():
|
|||||||
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)
|
||||||
|
summary = _append_memory_sections(summary)
|
||||||
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)
|
||||||
|
summary = _append_memory_sections(summary)
|
||||||
|
|
||||||
global_info = compressor._get_global_summary()
|
global_info = compressor._get_global_summary()
|
||||||
entity_count = global_info.get("total_entities", entity_count)
|
entity_count = global_info.get("total_entities", entity_count)
|
||||||
|
|||||||
@@ -184,21 +184,29 @@ local ok, err = pcall(function()
|
|||||||
end
|
end
|
||||||
local contents = {}
|
local contents = {}
|
||||||
meta.inv_has_get_contents = (inv.get_contents and true) or false
|
meta.inv_has_get_contents = (inv.get_contents and true) or false
|
||||||
|
local used_get_contents = false
|
||||||
if inv.get_contents then
|
if inv.get_contents then
|
||||||
-- Factorio 2.0: get_contents()가 있으면 가장 안정적으로 읽음
|
-- Factorio 2.0: get_contents()가 있으면 가장 안정적으로 읽음
|
||||||
local c = inv.get_contents()
|
local c = inv.get_contents()
|
||||||
-- 일부 환경에서 get_contents()가 nil/비테이블을 반환할 수 있음
|
-- 일부 환경에서 get_contents()가 nil/비테이블을 반환할 수 있음
|
||||||
meta.get_contents_type = type(c)
|
meta.get_contents_type = type(c)
|
||||||
if c and type(c) == "table" then
|
if c and type(c) == "table" then
|
||||||
|
contents = c
|
||||||
|
used_get_contents = true
|
||||||
|
|
||||||
local k = 0
|
local k = 0
|
||||||
for _ in pairs(c) do
|
for _ in pairs(c) do
|
||||||
k = k + 1
|
k = k + 1
|
||||||
end
|
end
|
||||||
meta.get_contents_keys = k
|
meta.get_contents_keys = k
|
||||||
contents = c
|
|
||||||
end
|
end
|
||||||
else
|
end
|
||||||
-- 호환용 폴백
|
|
||||||
|
-- 방어적 폴백:
|
||||||
|
-- get_contents()가 존재해도 빈/비정상 구조가 올 수 있어,
|
||||||
|
-- out이 완전히 비면 index 기반으로 한 번 더 시도한다.
|
||||||
|
if (not used_get_contents) or next(contents) == nil then
|
||||||
|
contents = {}
|
||||||
for i = 1, #inv do
|
for i = 1, #inv do
|
||||||
local stack = inv[i]
|
local stack = inv[i]
|
||||||
if stack and stack.valid_for_read then
|
if stack and stack.valid_for_read then
|
||||||
@@ -335,10 +343,13 @@ end
|
|||||||
total = _count_total(inv)
|
total = _count_total(inv)
|
||||||
print(f"[INV_DEBUG] main_parseOK | items={items} total={total} raw_snip={raw[:80]}")
|
print(f"[INV_DEBUG] main_parseOK | items={items} total={total} raw_snip={raw[:80]}")
|
||||||
|
|
||||||
# fallback only: RCON에서 읽어온 inv가 비어있으면 캐시 사용
|
# 캐시 fallback 정책:
|
||||||
# (inv가 {}인 원인이 "진짜 빈 인벤토리"든 "읽기 실패"든, 정책상 캐시 fallback을 한다)
|
# - 파싱이 성공(parsed_ok=True)했으면, inv가 {}여도 그대로 반환한다.
|
||||||
if inv:
|
# (캐시를 덮어쓰면 실제 진행이 반영되지 않는 반복 루프가 생긴다.)
|
||||||
_save_inventory_cache(self.inventory_cache_path, inv)
|
# - 파싱이 실패(parsed_ok=False)했을 때만 캐시를 사용한다.
|
||||||
|
if parsed_ok:
|
||||||
|
if inv:
|
||||||
|
_save_inventory_cache(self.inventory_cache_path, inv)
|
||||||
return inv
|
return inv
|
||||||
|
|
||||||
cached = _load_inventory_cache(self.inventory_cache_path)
|
cached = _load_inventory_cache(self.inventory_cache_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user