""" ai_planner.py — 순수 AI 플레이 버전 핵심 변경사항 (치트 모드 대비): - 이동에 실제 시간이 걸림 → 불필요한 장거리 이동 최소화 - 채굴은 자원 패치 근처에서만 가능 → 반드시 move 후 mine - 제작은 재료가 인벤토리에 있어야 함 → 재료 확보 순서 중요 - 건설은 건설 거리 내에서만 가능 → 배치 전 move 필수 - AI가 이 제약을 이해하고 행동 순서를 계획해야 함 LLM 백엔드: 로컬 Ollama (structured output으로 JSON 스키마 강제) - OLLAMA_HOST: Ollama 서버 주소 (기본값: http://192.168.50.67:11434) - OLLAMA_MODEL: 사용할 모델 (기본값: qwen3.5:9b) """ import json import os import re import sys import time import threading import traceback import httpx OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3.5:9b") OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://192.168.50.61:1234") ALLOWED_ACTIONS = { "explore", "move", "mine_resource", "craft_item", "place_entity", "build_smelting_line", "place_belt_line", "insert_to_entity", "set_recipe", "start_research", "wait", } ACTION_ALIASES = { "place_building": "place_entity", "build_entity": "place_entity", "mine": "mine_resource", "research": "start_research", } ORE_ALIASES = { "iron": "iron-ore", "iron ore": "iron-ore", "iron-ore": "iron-ore", "copper": "copper-ore", "copper ore": "copper-ore", "copper-ore": "copper-ore", "coal": "coal", "stone": "stone", } SYSTEM_PROMPT = """당신은 팩토리오 게임을 순수하게 플레이하는 AI 에이전트입니다. 치트나 텔레포트 없이, 실제 게임 메커니즘만 사용합니다. 게임 상태와 이전 행동 결과를 분석해서 스스로 판단하고 계획을 세웁니다. ## 재시작/마지막 행동 메모리 state_reader가 상태 요약에 포함하는 `마지막 행동(기억)` 섹션을 확인하세요. 직전에 실패한 action을 그대로 반복하지 말고, 실패 메시지/상태 변화에 맞춰 원인을 먼저 해결한 뒤 다음 action을 선택하세요. ## 핵심 제약 사항 (반드시 준수!) 1. **이동은 실제 걷기** — 먼 거리는 시간이 오래 걸림. 불필요한 왕복 최소화 2. **채굴은 자원 패치 근처에서만 가능** — 반드시 자원 위치로 move한 후 mine_resource 3. **제작은 재료가 있어야 함** — iron-plate 없이 iron-gear-wheel 못 만듬. 재료 확인 후 craft_item. 재료 부족하면 먼저 채굴→제련 4. **건설은 건설 거리 내에서만 가능** — 배치할 좌표 근처로 move한 후 place_entity 5. **자원은 유한** — 직접 채굴해야 하고, 제련소에 넣어야 plate가 됨 ## 효율적인 행동 패턴 - 같은 구역 작업을 묶어서 (이동 최소화) - move → mine/place/insert 순서로 항상 위치 먼저 확보 - 채굴 → 제련 → 제작 → 건설 흐름 유지 - 한 번에 넉넉히 채굴 (왕복 줄이기) ## 팩토리오 자동화 핵심 지식 - 채굴기(burner-mining-drill)는 광맥 위에 배치해야 작동 - 제련소(stone-furnace)에 ore + 석탄 넣으면 plate 생산 - 채굴기 출력 → inserter → 벨트 → inserter → 제련소/조립기 - 제련소/보일러/채굴기는 석탄 연료 필요 - 전력: offshore-pump → pipe → boiler → steam-engine → small-electric-pole - 자동화 연구팩: iron-gear-wheel + iron-plate → assembling-machine - 건물 배치 전 반드시: 1) 인벤토리에 아이템 있는지 2) 가까이 있는지 확인 ## 전체 action 목록 ### 탐색 (★ 자원 없을 때 최우선! 걸으면서 자원 스캔) - "explore" → {"direction": "east|west|north|south|...", "max_steps": 200, "wanted_ores": ["stone","coal", ...]} (선택) ★ 자원이 보이지 않을 때 반드시 explore 사용! move 대신! ★ `wanted_ores`가 있으면: 해당 자원이 발견될 때까지 계속 걷고, 다른 자원(예: iron-ore)만 계속 발견되더라도 즉시 멈추지 말 것 ★ 상태 요약에 "기억된 광맥" 좌표가 있으면, 그 좌표로 먼저 이동(move)해 채굴(mine_resource)을 시도 ★ 방향으로 걸으면서 반경 50타일 자원 스캔, 발견 즉시 멈춤 ★ 장애물 자동 감지. 막히면 다른 방향 시도 ★ 한 방향 실패 시 다음 방향 (east→north→south→west) ### 이동 (자원 좌표를 알 때만 사용) - "move" → {"x": int, "y": int} 주의: 자원/건물의 정확한 좌표를 알 때만 사용. 탐색에는 explore! ### 채굴 (자원 패치 근처에서만 작동) - "mine_resource" → {"ore": "iron-ore", "count": int} 채굴 가능: iron-ore, copper-ore, coal, stone 권장: count는 20~50 단위로 (작으면 비효율, 크면 오래 걸림) ### 제작 (인벤토리에 재료 필요!) - "craft_item" → {"item": str, "count": int} 레시피 예시: stone-furnace: stone 5개 burner-mining-drill: iron-gear-wheel 3 + iron-plate 3 + stone-furnace 1 transport-belt: iron-gear-wheel 1 + iron-plate 1 burner-inserter: iron-gear-wheel 1 + iron-plate 1 iron-gear-wheel: iron-plate 2 pipe: iron-plate 1 ### 건물 배치 (건설 거리 내에서만!) - "place_entity" → {"name": str, "x": int, "y": int, "direction": "north|south|east|west"} 주의: 1) 인벤토리에 아이템 필요 2) 가까이 있어야 함 (약 10칸 내) ### 건설 자동화 (Blueprint) - "build_smelting_line" → {"ore": "iron-ore|copper-ore", "x": int, "y": int, "furnace_count": int} ★ 제련소 라인을 한 번에 배치. furnace_count개의 stone-furnace를 y축 방향으로 일렬 배치. ★ 각 furnace에 석탄 + 광석 자동 투입. 인벤토리에 stone-furnace와 연료가 있어야 함. ★ 예: {"action": "build_smelting_line", "params": {"ore": "iron-ore", "x": -90, "y": -70, "furnace_count": 4}} ### 벨트 라인 (걸어다니면서 하나씩 배치 — 시간 많이 걸림) - "place_belt_line" → {"from_x": int, "from_y": int, "to_x": int, "to_y": int} ### 연료/아이템 삽입 (건설 거리 내에서) - "insert_to_entity" → {"x": int, "y": int, "item": "coal", "count": int} ### 조립기 레시피 설정 - "set_recipe" → {"x": int, "y": int, "recipe": str} ### 연구 - "start_research" → {"tech": "automation"} ### 대기 - "wait" → {"seconds": int} ## 응답 형식 반드시 아래 JSON 형식으로만 응답하세요. 마크다운이나 설명 텍스트 없이 순수 JSON만 출력하세요. {"thinking": "상황 분석 내용", "current_goal": "현재 목표", "actions": [{"action": "액션명", "params": {...}, "reason": "이유"}], "after_this": "다음 계획"}""" SYSTEM_PROMPT_COMPACT = """당신은 팩토리오 순수 플레이 AI입니다. 치트 없이 실제 이동/채굴/제작/건설 제약을 지키세요. 출력은 반드시 JSON 객체 1개만: {"thinking":"...","current_goal":"...","actions":[{"action":"...","params":{},"reason":"..."}],"after_this":"..."} 절대 마크다운/설명문/사고과정 텍스트를 출력하지 마세요. actions는 1~4개, 가능한 짧고 실행 가능한 계획만 반환하세요.""" def _debug_enabled() -> bool: v = os.environ.get("AI_DEBUG", "").strip().lower() return v in ("1", "true", "yes", "on") class AIPlanner: _last_payload_mode = "auto" def __init__(self): self.step = 0 self.feedback_log: list[dict] = [] self._fallback_explore_turn = 0 self._payload_mode_hint = "auto" self.long_term_goal = ( "완전 자동화 달성: " "석탄 채굴 → 철 채굴+제련 자동화 → 구리 채굴+제련 → " "전력 구축 → automation 연구 → 빨간 과학팩 자동 생산" ) def decide(self, state_summary: str) -> list[dict]: self.step += 1 feedback_text = self._format_feedback() user_message = ( f"## 스텝 {self.step}\n\n" f"### 현재 게임 상태\n{state_summary}\n\n" f"{feedback_text}" f"### 장기 목표\n{self.long_term_goal}\n\n" "현재 상태를 분석하고, 장기 목표를 향해 지금 해야 할 행동 시퀀스를 계획하세요.\n" "⚠️ 순수 플레이입니다. 건설/채굴/삽입 전에 반드시 move로 가까이 이동하세요.\n" "⚠️ 제작은 재료가 있어야 합니다. 인벤토리를 확인하세요." ) print(f"\n[AI] 요청 시작 (model={OLLAMA_MODEL})") try: plan = self._call_ollama(user_message) except Exception as e: print(f"[오류] Ollama 호출 실패: {e}") if _debug_enabled(): traceback.print_exc() plan = self._fallback_plan_from_summary(state_summary, last_error=str(e)) plan = self._sanitize_plan(plan) thinking = plan.get("thinking", "") if thinking: print(f"\n[AI] 판단:\n{thinking}\n") actions = plan.get("actions", []) if not isinstance(actions, list): actions = [] if not actions: plan = self._fallback_plan_from_summary( state_summary, last_error="LLM returned empty actions", ) thinking = plan.get("thinking", "") if thinking: print(f"\n[AI] 판단:\n{thinking}\n") actions = plan.get("actions", []) print(f"[AI] 현재 목표: {plan.get('current_goal', '')}") print(f"[AI] 완료 후: {plan.get('after_this', '')}") actions = self._ensure_move_before_build_actions(actions) print(f"[AI] {len(actions)}개 행동 계획됨") return actions def _call_ollama(self, user_message: str) -> dict: t0 = time.perf_counter() stop_event = threading.Event() def _spinner(): while not stop_event.is_set(): elapsed = time.perf_counter() - t0 print(f"\r[AI] 생각 중... {elapsed:.0f}s", end="", flush=True) stop_event.wait(1.0) print("\r", end="") spinner = threading.Thread(target=_spinner, daemon=True) spinner.start() try: data = None last_http_error: Exception | None = None candidates = self._build_payload_candidates(user_message) for i, payload in enumerate(candidates): resp = httpx.post( f"{OLLAMA_HOST}/api/v1/chat", json=payload, timeout=600.0, ) if resp.status_code < 400: data = resp.json() if "messages" in payload: AIPlanner._last_payload_mode = "legacy_chat" else: AIPlanner._last_payload_mode = "input_only" break if resp.status_code == 400 and i < len(candidates) - 1: err_msg = self._extract_http_error_message(resp) print(f"[AI] payload 호환 재시도 {i + 1}: {err_msg}") continue try: resp.raise_for_status() except Exception as e: last_http_error = e break if data is None: if last_http_error is not None: raise last_http_error raise RuntimeError("Ollama/LM Studio 응답을 받지 못했습니다.") finally: stop_event.set() spinner.join() dt = time.perf_counter() - t0 content = self._extract_response_content(data) print(f"[AI] 응답 수신 ({dt:.2f}s, {len(content)}자)") if _debug_enabled(): print(f"[AI][디버그] raw={content[:300]}") plan = self._parse_plan_json(content) if plan is None: raise ValueError("유효한 계획 JSON을 찾지 못했습니다.") return plan @staticmethod def _build_chat_payload(user_message: str) -> dict: messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_message}, ] return { "model": OLLAMA_MODEL, "messages": messages, # LM Studio(OpenAI Responses 호환)에서는 input 필드가 필수인 경우가 있어 함께 보낸다. "input": user_message, "format": "json", "think": False, "stream": False, "options": {"temperature": 0.3, "num_ctx": 8192}, } @staticmethod def _build_input_only_payload(user_message: str) -> dict: merged = f"{SYSTEM_PROMPT_COMPACT}\n\n{user_message}" return { "model": OLLAMA_MODEL, "input": merged, "stream": False, } @staticmethod def _build_payload_candidates(user_message: str) -> list[dict]: # 서버 구현체마다 스키마가 달라 자동 호환을 위해 후보를 순차 시도한다. default = [ AIPlanner._build_chat_payload(user_message), AIPlanner._build_input_only_payload(user_message), ] # 이전 호출에서 성공한 모드를 다음 턴에 우선 사용해 불필요한 400 재시도를 줄인다. if getattr(AIPlanner, "_last_payload_mode", "auto") == "input_only": return [default[1], default[0]] if getattr(AIPlanner, "_last_payload_mode", "auto") == "legacy_chat": return default return default @staticmethod def _extract_http_error_message(resp: httpx.Response) -> str: try: data = resp.json() if isinstance(data, dict): err = data.get("error") if isinstance(err, dict): msg = err.get("message") if isinstance(msg, str) and msg.strip(): return msg except Exception: pass text = resp.text.strip() if text: return text[:200] return f"HTTP {resp.status_code}" @staticmethod def _extract_response_content(data: dict) -> str: message = data.get("message") if isinstance(message, dict): content = message.get("content") if isinstance(content, str) and content.strip(): return content output_text = data.get("output_text") if isinstance(output_text, str) and output_text.strip(): return output_text choices = data.get("choices") if isinstance(choices, list) and choices: first = choices[0] if isinstance(first, dict): msg = first.get("message") if isinstance(msg, dict): content = msg.get("content") if isinstance(content, str) and content.strip(): return content output = data.get("output") if isinstance(output, list): for item in output: if not isinstance(item, dict): continue # LM Studio Responses 스타일: {"type":"reasoning","content":"..."} content_str = item.get("content") if isinstance(content_str, str) and content_str.strip(): return content_str content_items = item.get("content") if not isinstance(content_items, list): continue for c in content_items: if not isinstance(c, dict): continue text = c.get("text") if isinstance(text, str) and text.strip(): return text raise ValueError(f"응답에서 텍스트 콘텐츠를 찾지 못했습니다. keys={list(data.keys())}") @staticmethod def _extract_json_object_text(text: str) -> str | None: start = text.find("{") if start < 0: return None depth = 0 in_str = False esc = False for i in range(start, len(text)): ch = text[i] if in_str: if esc: esc = False elif ch == "\\": esc = True elif ch == '"': in_str = False continue if ch == '"': in_str = True elif ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: return text[start:i + 1] return None @staticmethod def _iter_json_object_texts(text: str) -> list[str]: out: list[str] = [] i = 0 while i < len(text): start = text.find("{", i) if start < 0: break depth = 0 in_str = False esc = False end = -1 for j in range(start, len(text)): ch = text[j] if in_str: if esc: esc = False elif ch == "\\": esc = True elif ch == '"': in_str = False continue if ch == '"': in_str = True elif ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: end = j break if end >= 0: out.append(text[start:end + 1]) i = end + 1 else: break return out @classmethod def _parse_plan_json(cls, content: str) -> dict | None: try: obj = json.loads(content) if cls._looks_like_plan(obj): return obj except Exception: pass for obj_text in cls._iter_json_object_texts(content): try: obj = json.loads(obj_text) except Exception: continue if cls._looks_like_plan(obj): return obj return None @staticmethod def _looks_like_plan(obj: object) -> bool: if not isinstance(obj, dict): return False actions = obj.get("actions") return isinstance(actions, list) @staticmethod def _sanitize_plan(plan: dict) -> dict: if not isinstance(plan, dict): return {"thinking": "", "current_goal": "", "actions": [], "after_this": ""} actions = plan.get("actions") if not isinstance(actions, list): actions = [] valid_actions: list[dict] = [] for a in actions: if not isinstance(a, dict): continue act = a.get("action") if not isinstance(act, str): continue act = ACTION_ALIASES.get(act, act) if act not in ALLOWED_ACTIONS: continue params = a.get("params") if not isinstance(params, dict): params = {} params = AIPlanner._normalize_params(act, params) reason = a.get("reason") if not isinstance(reason, str): reason = "" valid_actions.append({"action": act, "params": params, "reason": reason}) return { "thinking": str(plan.get("thinking", "")), "current_goal": str(plan.get("current_goal", "")), "actions": valid_actions, "after_this": str(plan.get("after_this", "")), } @staticmethod def _normalize_params(action: str, params: dict) -> dict: p = dict(params) if action == "move": if "x" not in p and "target_x" in p: p["x"] = p.get("target_x") if "y" not in p and "target_y" in p: p["y"] = p.get("target_y") return AIPlanner._keep_allowed_keys(p, {"x", "y"}) elif action == "place_entity": if "name" not in p and "item" in p: p["name"] = p.get("item") if "x" not in p and "position_x" in p: p["x"] = p.get("position_x") if "y" not in p and "position_y" in p: p["y"] = p.get("position_y") return AIPlanner._keep_allowed_keys(p, {"name", "x", "y", "direction"}) elif action == "mine_resource": if "ore" not in p: if "resource" in p: p["ore"] = p.get("resource") elif "item" in p: p["ore"] = p.get("item") ore = AIPlanner._normalize_ore_name(p.get("ore", "iron-ore")) try: count = int(p.get("count", 35)) except Exception: count = 35 if count <= 0: count = 35 return {"ore": ore, "count": count} elif action == "craft_item": return AIPlanner._keep_allowed_keys(p, {"item", "count"}) elif action == "explore": return AIPlanner._keep_allowed_keys(p, {"direction", "max_steps", "wanted_ores"}) elif action == "build_smelting_line": return AIPlanner._keep_allowed_keys(p, {"ore", "x", "y", "furnace_count"}) elif action == "place_belt_line": return AIPlanner._keep_allowed_keys(p, {"from_x", "from_y", "to_x", "to_y"}) elif action == "insert_to_entity": return AIPlanner._keep_allowed_keys(p, {"x", "y", "item", "count"}) elif action == "set_recipe": return AIPlanner._keep_allowed_keys(p, {"x", "y", "recipe"}) elif action == "start_research": return AIPlanner._keep_allowed_keys(p, {"tech"}) elif action == "wait": return AIPlanner._keep_allowed_keys(p, {"seconds"}) return p @staticmethod def _keep_allowed_keys(params: dict, allowed: set[str]) -> dict: return {k: v for k, v in params.items() if k in allowed} @staticmethod def _normalize_ore_name(value: object) -> str: if not isinstance(value, str): return "iron-ore" key = value.strip().lower() return ORE_ALIASES.get(key, "iron-ore") @staticmethod def _ensure_move_before_build_actions(actions: list[dict]) -> list[dict]: """ LLM이 "move 후 배치" 순서를 놓치는 경우가 있어, place_entity/insert_to_entity/set_recipe 앞에 최근 move가 같은 좌표가 아니면 안전장치로 move를 끼워 넣는다. """ out: list[dict] = [] last_move_xy: tuple[object, object] | None = None for a in actions: if not isinstance(a, dict): continue act = a.get("action") params = a.get("params") if isinstance(a.get("params"), dict) else {} if act == "move": mx = params.get("x") my = params.get("y") last_move_xy = (mx, my) out.append(a) continue if act in ("place_entity", "insert_to_entity", "set_recipe"): tx = params.get("x") ty = params.get("y") if tx is not None and ty is not None: if last_move_xy != (tx, ty): out.append({ "action": "move", "params": {"x": tx, "y": ty}, "reason": "안전장치: 건설/삽입 거리 제한 때문에 move 먼저", }) last_move_xy = (tx, ty) out.append(a) return out def record_feedback(self, action: dict, success: bool, message: str = ""): self.feedback_log.append({ "action": action.get("action", ""), "params": action.get("params", {}), "success": success, "message": message, }) if len(self.feedback_log) > 15: self.feedback_log.pop(0) def _format_feedback(self) -> str: if not self.feedback_log: return "" lines = ["### 이전 행동 결과 (성공/실패)\n"] for fb in self.feedback_log[-8:]: status = "OK" if fb["success"] else "FAIL" msg = f" — {fb['message']}" if fb["message"] else "" lines.append( f" {status} {fb['action']} " f"{json.dumps(fb['params'], ensure_ascii=False)}{msg}" ) return "\n".join(lines) + "\n\n" def set_goal(self, goal: str): self.long_term_goal = goal self.feedback_log.clear() print(f"[AI] 새 목표: {goal}") def _fallback_plan_from_summary(self, state_summary: str, last_error: str = "") -> dict: """ Ollama 실패 시에도 상태 요약(주변 패치·기억된 광맥 좌표)이 있으면 무한 explore 루프 대신 mine_resource / move 를 선택한다. """ pos = self._parse_player_position(state_summary) if pos is None: px = py = None else: px, py = pos anchors = self._parse_ore_anchors(state_summary) ore_priority = {"iron-ore": 0, "copper-ore": 1, "coal": 2, "stone": 3} best: tuple[str, int, int, float] | None = None best_key: tuple | None = None for ore, ox, oy in anchors: if px is not None and py is not None: dist = ((ox - px) ** 2 + (oy - py) ** 2) ** 0.5 else: dist = 0.0 key = (ore_priority.get(ore, 9), dist) if best_key is None or key < best_key: best_key = key best = (ore, ox, oy, dist) if best is not None: ore, ox, oy, dist = best move_threshold = float(os.environ.get("FALLBACK_MOVE_THRESHOLD", "200")) actions: list[dict] = [] if px is not None and py is not None and dist > move_threshold: actions.append({ "action": "move", "params": {"x": ox, "y": oy}, "reason": f"폴백: 광맥까지 약 {dist:.0f}타일 — 먼저 이동", }) actions.append({ "action": "mine_resource", "params": {"ore": ore, "count": 35}, "reason": "폴백: 상태에 표시된 인근 광맥 채굴", }) err_note = f" ({last_error})" if last_error else "" return { "thinking": ( f"Ollama를 사용할 수 없어 상태 요약의 광맥({ore}, 앵커 {ox},{oy})으로 " f"채굴을 시도합니다.{err_note}" ), "current_goal": f"{ore} 채굴 (휴리스틱)", "actions": actions, "after_this": "Ollama 복구 시 정상 계획으로 복귀", } dirs = [ "east", "north", "south", "west", "northeast", "northwest", "southeast", "southwest", ] self._fallback_explore_turn += 1 direction = dirs[(self._fallback_explore_turn - 1) % len(dirs)] err_note = f" ({last_error})" if last_error else "" return { "thinking": ( f"Ollama 실패이며 상태 요약에서 광맥 좌표를 찾지 못해 " f"{direction} 방향으로 탐색합니다.{err_note}" ), "current_goal": "주변 탐색 (휴리스틱)", "actions": [ { "action": "explore", "params": {"direction": direction, "max_steps": 200}, "reason": "폴백: 광맥 정보 없음 — 탐색", }, ], "after_this": "자원 발견 후 채굴", } @staticmethod def _parse_player_position(text: str) -> tuple[float, float] | None: m = re.search( r"-\s*위치:\s*\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)", text, ) if not m: return None return float(m.group(1)), float(m.group(2)) @staticmethod def _parse_ore_anchors(text: str) -> list[tuple[str, int, int]]: """주변 스캔 '앵커' 좌표와 기억된 광맥 (tile_x, tile_y) 파싱.""" seen: set[tuple[str, int, int]] = set() out: list[tuple[str, int, int]] = [] for m in re.finditer( r"-\s*(iron-ore|copper-ore|coal|stone):\s*\d+타일\s*\(\s*앵커:\s*(-?\d+)\s*,\s*(-?\d+)\s*\)", text, ): t = (m.group(1), int(m.group(2)), int(m.group(3))) if t not in seen: seen.add(t) out.append(t) for m in re.finditer( r"-\s*(iron-ore|copper-ore|coal|stone):\s*\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\)", text, ): t = (m.group(1), int(m.group(2)), int(m.group(3))) if t not in seen: seen.add(t) out.append(t) return out