Compare commits
24 Commits
084c17418a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d0522dc33 | ||
|
|
d9801ee457 | ||
|
|
66f3a327e8 | ||
|
|
8e743d12e7 | ||
|
|
d054f9aee1 | ||
|
|
2cf072d38c | ||
|
|
4b104f2146 | ||
|
|
c93785a809 | ||
|
|
1b2688d1e1 | ||
|
|
dabce8b6fb | ||
|
|
3fccbb20eb | ||
|
|
f6947d7345 | ||
|
|
a4ade0d5c0 | ||
|
|
fe1f0c1193 | ||
|
|
7014b47231 | ||
|
|
82fa73342f | ||
|
|
2d20e729f9 | ||
|
|
90a0ada6ff | ||
|
|
21cc94bc87 | ||
|
|
dfa4ab1f30 | ||
|
|
e92efc7bdf | ||
|
|
2212dda22f | ||
|
|
3e2ce49f47 | ||
|
|
9a9acc07b8 |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:github.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,4 +2,5 @@ run_factorio_ai.ps1
|
||||
inventory_memory.json
|
||||
ore_patch_memory.json
|
||||
agent_last_action_memory.json
|
||||
.cursor/
|
||||
.cursor/.worktrees/
|
||||
.cursor
|
||||
@@ -142,6 +142,11 @@ planner.set_goal(
|
||||
|
||||
- 순수 플레이이므로 **걷기, 채굴, 제작에 실제 시간이 소요**됩니다
|
||||
- AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다
|
||||
- `ai_planner.py`의 Ollama/LM Studio 호출은 서버 스키마 차이를 자동으로 맞추기 위해 payload 후보를 순차 재시도합니다. 먼저 `messages` 기반(legacy chat)으로 시도하고, 400(`unrecognized_keys`/`input required`)이면 `input` 중심 최소 payload로 재시도합니다.
|
||||
- LM Studio `input-only` 모드에서 `output[].type=reasoning` 형태로 응답이 오는 경우도 파서가 처리하며, 응답 텍스트에 JSON 앞뒤 설명이 섞이면 `{...}` 구간을 복구 추출해 파싱을 시도합니다(복구 불가 시 기존 휴리스틱 폴백).
|
||||
- 파싱된 계획은 `actions` 스키마와 지원 action 화이트리스트를 통과한 경우에만 채택합니다. 유효 액션이 없거나 형식이 깨지면 LLM 출력을 버리고 상태 기반 휴리스틱 폴백으로 전환해 `알 수 없는 행동` 루프를 줄입니다.
|
||||
- LM Studio가 표준과 다른 동의어를 반환할 때(예: `place_building`, `target_x/target_y`, `position_x/position_y`), planner가 표준 스키마(`place_entity`, `x/y`, `name`)로 자동 정규화한 뒤 검증합니다.
|
||||
- 특히 `mine_resource`는 실행기 시그니처에 맞게 `ore`, `count`만 남기도록 강제 정규화합니다. `resource`/`item`은 `ore`로 매핑하고 `tile`/`target` 같은 비표준 키는 제거해 `unexpected keyword argument` 오류를 방지합니다.
|
||||
- `ai_planner.py`는 LLM이 move 순서를 놓쳐 `place_entity`/`insert_to_entity`/`set_recipe`가 건설 거리 제한으로 실패하는 경우를 줄이기 위해, 해당 액션 직전에 최근 `move`가 같은 좌표가 아니면 자동으로 `move`를 끼워 넣습니다
|
||||
- `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다
|
||||
- `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. 또한 최상위에서 JSON 배열(`[...]`)로 답하는 경우도 `actions`로 래핑해 처리합니다. (추가) `finish_reason=length` 등으로 `message.content`가 비거나(또는 JSON 형태가 아니면) `message.reasoning_content`를 우선 사용합니다. 그리고 응답 안에 여러 개의 `{...}`가 섞여 있어도 그중 `actions`를 포함한 계획 객체를 우선 선택합니다. 또한 JSON 파싱 실패가 감지되면 다음 재시도에는 `JSON-only repair` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다.
|
||||
|
||||
Binary file not shown.
@@ -28,6 +28,7 @@ class ActionExecutor:
|
||||
"place_entity": self.place_entity, "place_belt_line": self.place_belt_line,
|
||||
"insert_to_entity": self.insert_to_entity, "set_recipe": self.set_recipe,
|
||||
"start_research": self.start_research, "wait": self.wait,
|
||||
"build_smelting_line": self.build_smelting_line,
|
||||
}
|
||||
handler = handlers.get(act)
|
||||
if not handler:
|
||||
@@ -160,7 +161,7 @@ class ActionExecutor:
|
||||
|
||||
stuck_count, last_pos = 0, None
|
||||
for step in range(max_steps):
|
||||
time.sleep(0.1)
|
||||
time.sleep(0.3)
|
||||
if step % 20 == 0:
|
||||
result = self.rcon.lua(P + wanted_lua + """
|
||||
local ok, data = pcall(function()
|
||||
@@ -300,7 +301,7 @@ rcon.print("WALK:" .. string.format("%.1f", dist))
|
||||
if stuck_count > 30:
|
||||
self.rcon.lua(P + "p.walking_state = {walking = false, direction = defines.direction.north}")
|
||||
return False, f"({x},{y}) 장애물 (거리: {last_dist:.0f})"
|
||||
time.sleep(0.1)
|
||||
time.sleep(0.3)
|
||||
self.rcon.lua(P + "p.walking_state = {walking = false, direction = defines.direction.north}")
|
||||
return False, f"({x},{y}) 시간초과 (거리: {last_dist:.0f})"
|
||||
|
||||
@@ -454,6 +455,54 @@ p.mining_state = {{mining = true, position = {{x = {mine_x}, y = {mine_y}}}}}
|
||||
return True, f"{ore} {total_mined}개 채굴 (목표 {count}개 중 일부)"
|
||||
return False, f"{ore} 채굴 실패 - {len(failed_positions)}개 타일 접근 불가"
|
||||
|
||||
# ── 건설 자동화 (Blueprint) ───────────────────────────────────────
|
||||
def build_smelting_line(
|
||||
self,
|
||||
ore: str = "iron-ore",
|
||||
x: int = 0,
|
||||
y: int = 0,
|
||||
furnace_count: int = 4,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
석탄 제련 라인 자동 배치.
|
||||
stone-furnace를 y축 방향으로 furnace_count개 일렬 배치하고
|
||||
각각에 석탄 + 광석을 자동 투입한다.
|
||||
"""
|
||||
spacing = 3 # furnace 간격 (타일)
|
||||
placed = 0
|
||||
failed = 0
|
||||
|
||||
for i in range(furnace_count):
|
||||
fy = y + i * spacing
|
||||
ok, msg = self.move(x, fy)
|
||||
if not ok:
|
||||
print(f" [blueprint] ({x},{fy}) 이동 실패: {msg}")
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
ok, msg = self.place_entity(name="stone-furnace", x=x, y=fy)
|
||||
if not ok:
|
||||
print(f" [blueprint] ({x},{fy}) 배치 실패: {msg}")
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
# 실제 배치된 좌표 파싱 (REUSED 포함)
|
||||
parsed = self._extract_coords_from_message(msg)
|
||||
rx, ry = (parsed if parsed is not None else (x, fy))
|
||||
|
||||
boot_ok, boot_msg = self._auto_bootstrap_furnace(
|
||||
furnace_name="stone-furnace",
|
||||
x=int(rx),
|
||||
y=int(ry),
|
||||
reason=f"build_smelting_line: {ore}",
|
||||
)
|
||||
placed += 1
|
||||
print(f" [blueprint] ({rx},{ry}) OK — bootstrap: {boot_msg}")
|
||||
|
||||
if placed == 0:
|
||||
return False, f"build_smelting_line 실패: {failed}개 모두 배치 불가"
|
||||
return True, f"build_smelting_line: {placed}개 배치 완료 ({failed}개 실패)"
|
||||
|
||||
# ── 제작/배치/삽입/레시피/연구/대기 ──────────────────────────────
|
||||
def craft_item(self, item: str, count: int = 1) -> tuple[bool, str]:
|
||||
result = self.rcon.lua(P + f"""
|
||||
|
||||
933
ai_planner.py
933
ai_planner.py
File diff suppressed because it is too large
Load Diff
106
docs/plan.md
106
docs/plan.md
@@ -1,3 +1,109 @@
|
||||
## 2026-03-27 LM Studio `input required` 오류 수정 계획
|
||||
|
||||
### 문제 요약
|
||||
- LM Studio 서버로 `POST /api/v1/chat` 호출 시 `{"error":{"message":"'input' is required"}}` 400이 발생.
|
||||
- 현재 `ai_planner.py`는 `messages` 기반 payload만 전송하고 있어, `input` 기반 스키마를 요구하는 서버와 비호환.
|
||||
|
||||
### 구현 계획
|
||||
1. `ai_planner.py` 요청 payload를 함수로 분리해 `messages` + `input`을 함께 포함하도록 수정한다.
|
||||
2. 응답 파싱을 LM Studio/OpenAI 호환 형태(`message.content` 우선, `output_text` 대안)까지 처리한다.
|
||||
3. 테스트를 먼저 추가해 payload에 `input`이 포함되는지, 응답 추출이 대체 경로에서 동작하는지 검증한다.
|
||||
4. 문서(`README.md`)에 LM Studio 호환 동작을 반영한다.
|
||||
|
||||
### 검증 계획
|
||||
- `pytest tests/test_ai_planner_lmstudio_compat.py -q`
|
||||
- 필요 시 전체 관련 테스트 재실행
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-27 LM Studio `unrecognized_keys` 400 추가 수정 계획
|
||||
|
||||
### 문제 요약
|
||||
- `input`을 추가한 뒤에도 LM Studio 서버가 `messages`, `format`, `think`, `options` 키를 거부하며 400을 반환.
|
||||
- 즉, 서버 모드에 따라 허용 스키마가 다르며 단일 payload로는 호환이 불안정.
|
||||
|
||||
### 구현 계획
|
||||
1. `ai_planner.py`에서 payload를 단일 형태로 고정하지 않고, `legacy chat`와 `input-only` 후보를 순차 시도한다.
|
||||
2. 400 응답일 때 에러 메시지를 파싱해 다음 후보 payload로 자동 재시도한다.
|
||||
3. README 설명을 “messages+input 동시 전송”에서 “서버 스키마 자동 호환 재시도”로 정정한다.
|
||||
|
||||
### 검증 계획
|
||||
- `python -m py_compile ai_planner.py`
|
||||
- LM Studio 로그에서 `unrecognized_keys`가 사라지고 정상 응답으로 전환되는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-27 LM Studio `output.reasoning` 파싱/잘림 대응 계획
|
||||
|
||||
### 문제 요약
|
||||
- `input-only` 재시도는 성공하지만, 응답이 `output[0].type=reasoning` + `content` 문자열로 돌아와 기존 파서가 텍스트를 추출하지 못함.
|
||||
- 또한 서버 컨텍스트(4096)에서 장문 추론으로 출력 토큰이 소진되어 JSON 본문이 잘리는 케이스가 발생.
|
||||
|
||||
### 구현 계획
|
||||
1. `ai_planner.py`의 input-only payload는 긴 시스템 프롬프트 대신 압축 프롬프트를 사용해 토큰 사용량을 줄인다.
|
||||
2. 응답 파서를 `output[].content`가 문자열인 경우까지 지원한다.
|
||||
3. 텍스트에 JSON이 섞여 있을 때 `{...}` 구간을 복구 파싱하는 보조 로직을 추가한다.
|
||||
4. README에 LM Studio input-only 모드와 파싱/폴백 동작을 명시한다.
|
||||
|
||||
### 검증 계획
|
||||
- `python -m py_compile ai_planner.py`
|
||||
- 실제 실행 로그에서 `응답에서 텍스트 콘텐츠를 찾지 못했습니다`가 사라지는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-27 LM Studio 비JSON/잘못된 action 방어 계획
|
||||
|
||||
### 문제 요약
|
||||
- LM Studio가 reasoning 텍스트를 길게 반환하면, JSON 복구가 불완전해 임의 객체가 계획으로 채택될 수 있음.
|
||||
- 그 결과 executor에서 `알 수 없는 행동` 실패가 발생해 루프가 생김.
|
||||
|
||||
### 구현 계획
|
||||
1. 응답 텍스트에서 `{...}` 후보를 모두 스캔해 `actions`를 포함한 계획 객체만 채택한다.
|
||||
2. 액션명을 화이트리스트로 검증해 지원되지 않는 action은 제거한다.
|
||||
3. 유효 액션이 0개면 LLM 결과를 버리고 즉시 상태 기반 휴리스틱 폴백으로 전환한다.
|
||||
4. README에 “유효 계획 검증 후 채택” 동작을 반영한다.
|
||||
|
||||
### 검증 계획
|
||||
- `python -m py_compile ai_planner.py`
|
||||
- 런타임 로그에서 `알 수 없는 행동` 반복이 사라지는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-27 LM Studio action/params 동의어 정규화 계획
|
||||
|
||||
### 문제 요약
|
||||
- LM Studio가 JSON은 지키지만 프로젝트 스키마와 다른 action/params를 반환함.
|
||||
- 예: `place_building` vs `place_entity`, `target_x`/`target_y`, `position_x`/`position_y`.
|
||||
|
||||
### 구현 계획
|
||||
1. `ai_planner.py`에서 action alias를 표준 action으로 매핑한다.
|
||||
2. params alias를 표준 키로 정규화한다.
|
||||
3. 정규화 후 화이트리스트 검증을 적용해 실행 가능한 액션만 남긴다.
|
||||
4. README에 동의어 자동 정규화 동작을 반영한다.
|
||||
|
||||
### 검증 계획
|
||||
- `python -m py_compile ai_planner.py`
|
||||
- 런타임에서 `move`/`place_entity`로 정상 변환되는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-27 mine_resource 파라미터 강제 표준화 계획
|
||||
|
||||
### 문제 요약
|
||||
- LM Studio가 `mine_resource`에 `tile`, `target`, `resource` 같은 비표준 키를 보내면서 실행기(`mine_resource(ore, count)`)에서 TypeError 발생.
|
||||
|
||||
### 구현 계획
|
||||
1. `ai_planner.py`에서 `mine_resource`는 `ore`, `count`만 남기도록 필터링한다.
|
||||
2. `resource`/`item` 동의어를 `ore`로 매핑하고, `iron`/`copper` 같은 축약값도 정규 ore 이름으로 보정한다.
|
||||
3. `count`가 없거나 비정상이면 기본값(35)으로 보정한다.
|
||||
4. README에 `mine_resource` 파라미터 표준화 동작을 반영한다.
|
||||
|
||||
### 검증 계획
|
||||
- `python -m py_compile ai_planner.py`
|
||||
- 런타임에서 `unexpected keyword argument 'tile'/'target'`가 사라지는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 채굴 시 mining_state 반복 설정 제거 (우클릭 유지)
|
||||
|
||||
### 문제
|
||||
|
||||
5
main.py
5
main.py
@@ -15,7 +15,7 @@ import json
|
||||
from factorio_rcon import FactorioRCON
|
||||
from state_reader import StateReader
|
||||
from context_compressor import ContextCompressor
|
||||
from ai_planner import AIPlanner
|
||||
from ai_planner import AIPlanner, OLLAMA_MODEL, OLLAMA_HOST
|
||||
from action_executor import ActionExecutor
|
||||
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
|
||||
@@ -137,7 +137,8 @@ def run():
|
||||
print("=" * 60)
|
||||
print(" 팩토리오 순수 AI 에이전트 (치트 없음)")
|
||||
print(" - 실제 걷기 / 실제 채굴 / 실제 제작 / 건설 거리 제한")
|
||||
print(" - RCON 호환: game.players[1] 사용")
|
||||
print(f" - LLM: Ollama {OLLAMA_MODEL}")
|
||||
print(f" - Ollama host: {OLLAMA_HOST}")
|
||||
print("=" * 60)
|
||||
|
||||
with FactorioRCON(RCON_HOST, RCON_PORT, RCON_PASSWORD) as rcon:
|
||||
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
ollama>=0.4
|
||||
51
tests/test_ai_planner_lmstudio_compat.py
Normal file
51
tests/test_ai_planner_lmstudio_compat.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import unittest
|
||||
|
||||
from ai_planner import AIPlanner
|
||||
|
||||
|
||||
class TestAIPlannerLMStudioCompat(unittest.TestCase):
|
||||
def test_build_chat_payload_has_legacy_chat_keys(self):
|
||||
payload = AIPlanner._build_chat_payload("hello")
|
||||
|
||||
self.assertIn("messages", payload)
|
||||
self.assertIn("options", payload)
|
||||
self.assertEqual(payload["messages"][1]["content"], "hello")
|
||||
|
||||
def test_build_input_only_payload_has_minimal_keys(self):
|
||||
payload = AIPlanner._build_input_only_payload("hello")
|
||||
|
||||
self.assertIn("input", payload)
|
||||
self.assertNotIn("messages", payload)
|
||||
self.assertEqual(payload["stream"], False)
|
||||
self.assertIn("hello", payload["input"])
|
||||
|
||||
def test_payload_candidates_order(self):
|
||||
payloads = AIPlanner._build_payload_candidates("hello")
|
||||
self.assertEqual(len(payloads), 2)
|
||||
self.assertIn("messages", payloads[0])
|
||||
self.assertIn("input", payloads[1])
|
||||
self.assertNotIn("messages", payloads[1])
|
||||
|
||||
def test_extract_response_content_supports_message_content(self):
|
||||
data = {
|
||||
"message": {
|
||||
"content": (
|
||||
'{"thinking":"t","current_goal":"g","actions":[],"after_this":"a"}'
|
||||
)
|
||||
}
|
||||
}
|
||||
content = AIPlanner._extract_response_content(data)
|
||||
self.assertIn('"current_goal":"g"', content)
|
||||
|
||||
def test_extract_response_content_supports_output_text(self):
|
||||
data = {
|
||||
"output_text": (
|
||||
'{"thinking":"t","current_goal":"g","actions":[],"after_this":"a"}'
|
||||
)
|
||||
}
|
||||
content = AIPlanner._extract_response_content(data)
|
||||
self.assertIn('"after_this":"a"', content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,87 +1,47 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
from ai_planner import AIPlanner
|
||||
|
||||
|
||||
class TestAIPlannerParseJson(unittest.TestCase):
|
||||
class TestAIPlannerFallback(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# AIPlanner 생성 시 ZAI_API_KEY가 필요하므로 테스트에서는 더미를 주입한다.
|
||||
os.environ.setdefault("ZAI_API_KEY", "dummy")
|
||||
self.planner = AIPlanner()
|
||||
|
||||
def test_parse_json_object(self):
|
||||
raw = (
|
||||
def test_fallback_uses_ore_anchor_from_summary(self):
|
||||
summary = "- 위치: (0, 0)\n- iron-ore: 100타일 (앵커: 50, 30)\n"
|
||||
plan = self.planner._fallback_plan_from_summary(summary)
|
||||
actions = plan["actions"]
|
||||
self.assertTrue(any(a["action"] == "mine_resource" for a in actions))
|
||||
|
||||
def test_fallback_explore_when_no_anchors(self):
|
||||
summary = "- 위치: (0, 0)\n"
|
||||
plan = self.planner._fallback_plan_from_summary(summary)
|
||||
actions = plan["actions"]
|
||||
self.assertTrue(any(a["action"] == "explore" for a in actions))
|
||||
|
||||
def test_decide_returns_actions_from_ollama(self):
|
||||
mock_response = MagicMock()
|
||||
mock_response.message.content = (
|
||||
'{"thinking":"t","current_goal":"g",'
|
||||
'"actions":[{"action":"explore","params":{"direction":"east","max_steps":1},"reason":"x"}],'
|
||||
'"after_this":"a"}'
|
||||
)
|
||||
plan = self.planner._parse_json(raw)
|
||||
self.assertEqual(plan["current_goal"], "g")
|
||||
self.assertEqual(len(plan["actions"]), 1)
|
||||
self.assertEqual(plan["actions"][0]["action"], "explore")
|
||||
with patch("ai_planner.ollama.Client") as MockClient:
|
||||
MockClient.return_value.chat.return_value = mock_response
|
||||
actions = self.planner.decide("## 스텝 1\n현재 상태: 초기")
|
||||
self.assertIsInstance(actions, list)
|
||||
self.assertTrue(len(actions) >= 1)
|
||||
self.assertEqual(actions[0]["action"], "explore")
|
||||
|
||||
def test_parse_json_array_top_level(self):
|
||||
raw = '[{"action":"explore","params":{"direction":"east","max_steps":1},"reason":"x"}]'
|
||||
plan = self.planner._parse_json(raw)
|
||||
self.assertEqual(len(plan["actions"]), 1)
|
||||
self.assertEqual(plan["actions"][0]["action"], "explore")
|
||||
self.assertIn("after_this", plan)
|
||||
def test_decide_falls_back_when_ollama_raises(self):
|
||||
summary = "- 위치: (10, 10)\n- iron-ore: 50타일 (앵커: 60, 10)\n"
|
||||
with patch("ai_planner.ollama.Client") as MockClient:
|
||||
MockClient.return_value.chat.side_effect = Exception("connection refused")
|
||||
actions = self.planner.decide(summary)
|
||||
self.assertIsInstance(actions, list)
|
||||
self.assertTrue(len(actions) >= 1)
|
||||
|
||||
def test_parse_json_array_with_code_fence(self):
|
||||
raw = (
|
||||
"```json\n"
|
||||
'[{"action":"explore","params":{"direction":"east","max_steps":1},"reason":"x"}]\n'
|
||||
"```"
|
||||
)
|
||||
plan = self.planner._parse_json(raw)
|
||||
self.assertEqual(len(plan["actions"]), 1)
|
||||
self.assertEqual(plan["actions"][0]["action"], "explore")
|
||||
|
||||
def test_extract_glm_text_prefers_content_then_reasoning(self):
|
||||
# content가 비어있고 reasoning_content에 JSON이 들어있는 케이스
|
||||
fake = {
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "length",
|
||||
"message": {
|
||||
"content": "",
|
||||
"reasoning_content": '{"thinking":"t","current_goal":"g","actions":[],"after_this":"a"}',
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
extracted = self.planner._extract_glm_assistant_text(fake)
|
||||
self.assertIn('"current_goal":"g"', extracted)
|
||||
|
||||
def test_extract_glm_text_uses_reasoning_when_content_has_no_json(self):
|
||||
fake = {
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "length",
|
||||
"message": {
|
||||
"content": "1. **Current State Analysis:**\n- Location: (0, 0)\n- Inventory: {...}",
|
||||
"reasoning_content": '{"thinking":"t","current_goal":"g","actions":[{"action":"explore","params":{"direction":"east","max_steps":1},"reason":"x"}],"after_this":"a"}',
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
extracted = self.planner._extract_glm_assistant_text(fake)
|
||||
self.assertIn('"current_goal":"g"', extracted)
|
||||
|
||||
def test_parse_json_selects_actions_object_when_multiple_json_objects_exist(self):
|
||||
# 분석 텍스트 안에 먼저 나오는 하위 JSON({ "foo": 1 })이 있고,
|
||||
# 뒤에 실제 계획 JSON({ "actions": [...] })이 있는 경우를 검증한다.
|
||||
raw = (
|
||||
"1. Analyze...\n"
|
||||
'{"foo": 1}\n'
|
||||
"2. Continue...\n"
|
||||
'{"thinking":"t","current_goal":"g",'
|
||||
'"actions":[{"action":"explore","params":{"direction":"east","max_steps":1},"reason":"x"}],'
|
||||
'"after_this":"a"}'
|
||||
)
|
||||
plan = self.planner._parse_json(raw)
|
||||
self.assertEqual(plan["current_goal"], "g")
|
||||
self.assertEqual(len(plan["actions"]), 1)
|
||||
self.assertEqual(plan["actions"][0]["action"], "explore")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
63
tests/test_build_smelting_line.py
Normal file
63
tests/test_build_smelting_line.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from action_executor import ActionExecutor
|
||||
|
||||
|
||||
def make_executor():
|
||||
rcon = MagicMock()
|
||||
rcon.lua.return_value = "OK"
|
||||
ex = ActionExecutor(rcon)
|
||||
return ex, rcon
|
||||
|
||||
|
||||
class TestBuildSmeltingLine(unittest.TestCase):
|
||||
def test_build_smelting_line_calls_place_entity_n_times(self):
|
||||
ex, rcon = make_executor()
|
||||
with patch.object(ex, "place_entity", return_value=(True, "stone-furnace 배치 (0, 0)")) as mock_place, \
|
||||
patch.object(ex, "_auto_bootstrap_furnace", return_value=(True, "OK")), \
|
||||
patch.object(ex, "move", return_value=(True, "도착")):
|
||||
ok, msg = ex.build_smelting_line(ore="iron-ore", x=0, y=0, furnace_count=3)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(mock_place.call_count, 3)
|
||||
|
||||
def test_build_smelting_line_default_furnace_count(self):
|
||||
ex, rcon = make_executor()
|
||||
with patch.object(ex, "place_entity", return_value=(True, "stone-furnace 배치 (0, 0)")), \
|
||||
patch.object(ex, "_auto_bootstrap_furnace", return_value=(True, "OK")), \
|
||||
patch.object(ex, "move", return_value=(True, "도착")):
|
||||
ok, msg = ex.build_smelting_line(ore="iron-ore", x=0, y=0)
|
||||
self.assertTrue(ok)
|
||||
|
||||
def test_build_smelting_line_partial_failure_still_reports(self):
|
||||
ex, rcon = make_executor()
|
||||
# 첫 번째 성공, 두 번째 실패, 세 번째 성공
|
||||
side_effects = [(True, "stone-furnace 배치 (0, 0)"), (False, "막힘"), (True, "stone-furnace 배치 (0, 6)")]
|
||||
with patch.object(ex, "place_entity", side_effect=side_effects), \
|
||||
patch.object(ex, "_auto_bootstrap_furnace", return_value=(True, "OK")), \
|
||||
patch.object(ex, "move", return_value=(True, "도착")):
|
||||
ok, msg = ex.build_smelting_line(ore="iron-ore", x=0, y=0, furnace_count=3)
|
||||
# 2개 성공이므로 전체 실패가 아님
|
||||
self.assertTrue(ok)
|
||||
self.assertIn("2개 배치", msg)
|
||||
|
||||
def test_build_smelting_line_all_fail_returns_false(self):
|
||||
ex, rcon = make_executor()
|
||||
with patch.object(ex, "place_entity", return_value=(False, "막힘")), \
|
||||
patch.object(ex, "_auto_bootstrap_furnace", return_value=(True, "OK")), \
|
||||
patch.object(ex, "move", return_value=(True, "도착")):
|
||||
ok, msg = ex.build_smelting_line(ore="iron-ore", x=0, y=0, furnace_count=2)
|
||||
self.assertFalse(ok)
|
||||
|
||||
def test_build_smelting_line_via_execute_dispatch(self):
|
||||
ex, rcon = make_executor()
|
||||
with patch.object(ex, "build_smelting_line", return_value=(True, "2개 배치 완료")) as mock_bsl:
|
||||
ok, msg = ex.execute({
|
||||
"action": "build_smelting_line",
|
||||
"params": {"ore": "iron-ore", "x": -90, "y": -70, "furnace_count": 4},
|
||||
})
|
||||
mock_bsl.assert_called_once_with(ore="iron-ore", x=-90, y=-70, furnace_count=4)
|
||||
self.assertTrue(ok)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user