Compare commits

..

24 Commits

Author SHA1 Message Date
21in7
1d0522dc33 feat: standardize parameters for mine_resource action in AIPlanner to improve error handling and compatibility with LM Studio responses 2026-03-27 20:23:22 +09:00
21in7
d9801ee457 feat: implement action aliasing and parameter normalization in AIPlanner to enhance compatibility with LM Studio responses 2026-03-27 20:19:53 +09:00
21in7
66f3a327e8 feat: add action validation and sanitization in AIPlanner to ensure only allowed actions are processed 2026-03-27 20:14:52 +09:00
21in7
8e743d12e7 feat: enhance AIPlanner with compact system prompt and improved JSON parsing for input-only mode responses 2026-03-27 20:10:44 +09:00
21in7
d054f9aee1 feat: implement automatic payload candidate retry mechanism in AIPlanner for improved LM Studio compatibility 2026-03-27 20:07:32 +09:00
21in7
2cf072d38c feat: enhance AIPlanner payload structure for LM Studio compatibility by including 'input' field and improve response content extraction methods 2026-03-27 20:04:18 +09:00
21in7
4b104f2146 chore: update OLLAMA_HOST and API endpoint in AIPlanner, and add .cursor to .gitignore 2026-03-27 19:58:18 +09:00
kswdev0
c93785a809 chore: increase timeout for API requests in AIPlanner to 600 seconds and add local settings for Claude permissions 2026-03-27 14:46:28 +09:00
kswdev0
1b2688d1e1 refactor: update AIPlanner response format to JSON and simplify structure 2026-03-27 11:03:02 +09:00
kswdev0
dabce8b6fb refactor: replace Ollama client with HTTPX for API requests in AIPlanner 2026-03-27 10:59:43 +09:00
kswdev0
3fccbb20eb refactor: remove assistant message placeholder from AIPlanner user message format 2026-03-27 10:55:48 +09:00
kswdev0
f6947d7345 feat: modify user message format and enhance AIPlanner options with increased context size 2026-03-27 10:51:43 +09:00
kswdev0
a4ade0d5c0 fix: update default Ollama model to qwen3.5:9b 2026-03-27 09:39:38 +09:00
kswdev0
fe1f0c1193 feat: add think parameter to AIPlanner options for enhanced configuration 2026-03-27 09:38:44 +09:00
kswdev0
7014b47231 feat: enhance AIPlanner with loading spinner during Ollama requests and update print statements for clarity 2026-03-27 09:26:38 +09:00
kswdev0
82fa73342f fix: update default Ollama model to qwen3.5:9b-nothink 2026-03-27 09:14:13 +09:00
21in7
2d20e729f9 fix: change default model to qwen3.5:9b 2026-03-27 00:07:48 +09:00
21in7
90a0ada6ff feat: Ollama migration — GLM API → local Ollama, blueprint system, RCON optimization 2026-03-27 00:04:16 +09:00
21in7
21cc94bc87 chore: update startup banner to show Ollama model and host 2026-03-27 00:03:18 +09:00
21in7
dfa4ab1f30 perf: reduce RCON polling in move/explore loops from 0.1s to 0.3s 2026-03-27 00:02:43 +09:00
21in7
e92efc7bdf feat: add build_smelting_line blueprint action to ActionExecutor 2026-03-27 00:01:59 +09:00
21in7
2212dda22f feat: replace GLM API with local Ollama structured output, remove ~400 lines of JSON repair code 2026-03-27 00:00:19 +09:00
21in7
3e2ce49f47 chore: add ollama dependency 2026-03-26 23:57:06 +09:00
21in7
9a9acc07b8 chore: ignore .worktrees directory 2026-03-26 23:53:14 +09:00
12 changed files with 738 additions and 595 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"WebFetch(domain:github.com)"
]
}
}

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@ run_factorio_ai.ps1
inventory_memory.json
ore_patch_memory.json
agent_last_action_memory.json
.cursor/
.cursor/.worktrees/
.cursor

View File

@@ -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` 프롬프트를 덧붙여 모델이 스키마를 다시 따르도록 유도합니다.

View File

@@ -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"""

File diff suppressed because it is too large Load Diff

View File

@@ -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 반복 설정 제거 (우클릭 유지)
### 문제

View File

@@ -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
View File

@@ -0,0 +1 @@
ollama>=0.4

View 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()

View File

@@ -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()

View 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()