diff --git a/action_executor.py b/action_executor.py index e538582..5884fc6 100644 --- a/action_executor.py +++ b/action_executor.py @@ -1,20 +1,15 @@ """ action_executor.py — 순수 AI 플레이 버전 (치트 없음) -모든 행동이 실제 게임 메커니즘을 사용: -- 이동: 텔레포트 대신 실제 걷기 (walking_state) -- 채굴: 인벤토리 직접 삽입 대신 실제 채굴 (mining_state) -- 제작: 무조건 지급 대신 실제 제작 (begin_crafting, 재료 소모) -- 건설: create_entity 대신 커서 배치 (build_from_cursor, 거리 제한) - -핵심 변경: game.player → game.players[1] (RCON 호환) +핵심 변경: +- explore 액션 추가: 방향으로 걸으면서 자원 스캔, 발견 시 즉시 멈춤 +- game.player → game.players[1] (RCON 호환) """ import time import json from factorio_rcon import FactorioRCON -# 모든 Lua 코드 앞에 붙일 플레이어 참조 (RCON에서 game.player는 nil) P = """local p = game.players[1] if not p then rcon.print("NO_PLAYER") return end if not p.character then rcon.print("NO_CHARACTER") return end @@ -31,6 +26,7 @@ class ActionExecutor: handlers = { "move": self.move, + "explore": self.explore, "mine_resource": self.mine_resource, "craft_item": self.craft_item, "place_entity": self.place_entity, @@ -47,18 +43,97 @@ class ActionExecutor: try: result = handler(**params) - # NO_PLAYER / NO_CHARACTER 에러 공통 처리 if isinstance(result, tuple) and isinstance(result[1], str): if "NO_PLAYER" in result[1]: - return False, "서버에 접속한 플레이어가 없습니다. 팩토리오 클라이언트로 서버에 접속하세요." + return False, "서버에 접속한 플레이어가 없습니다." if "NO_CHARACTER" in result[1]: - return False, "플레이어 캐릭터가 없습니다 (사망했거나 생성 전)." + return False, "플레이어 캐릭터가 없습니다." return result except TypeError as e: return False, f"파라미터 오류: {e}" except Exception as e: return False, f"실행 오류: {e}" + # ── 탐색 (걸으면서 자원 스캔) ──────────────────────────────────── + def explore(self, direction: str = "east", max_steps: int = 200) -> tuple[bool, str]: + """방향으로 걸으면서 매 20틱마다 반경 50에서 자원을 스캔. + 자원 발견 시 즉시 멈추고 위치 + 자원 정보 반환.""" + + dir_map = { + "north": "defines.direction.north", + "south": "defines.direction.south", + "east": "defines.direction.east", + "west": "defines.direction.west", + "northeast": "defines.direction.northeast", + "northwest": "defines.direction.northwest", + "southeast": "defines.direction.southeast", + "southwest": "defines.direction.southwest", + } + lua_dir = dir_map.get(direction, "defines.direction.east") + + # 걷기 시작 + self.rcon.lua(P + f"p.walking_state = {{walking = true, direction = {lua_dir}}}") + + scan_interval = 20 # 매 20틱(~2초)마다 스캔 + stuck_count = 0 + last_pos = None + + for step in range(max_steps): + time.sleep(0.1) + + # 매 scan_interval 틱마다 자원 스캔 + if step % scan_interval == 0: + result = self.rcon.lua(P + """ +local ok, data = pcall(function() + local surface = p.surface + local pos = p.position + local resources = surface.find_entities_filtered{position = pos, radius = 50, type = "resource"} + if #resources == 0 then + return "NONE:" .. string.format("%.0f,%.0f", pos.x, pos.y) + end + -- 자원 이름별 카운트 + local counts = {} + for _, e in ipairs(resources) do + counts[e.name] = (counts[e.name] or 0) + 1 + end + local parts = {} + for name, count in pairs(counts) do + parts[#parts+1] = name .. "=" .. count + end + return "FOUND:" .. string.format("%.0f,%.0f", pos.x, pos.y) .. "|" .. table.concat(parts, ",") +end) +if ok then rcon.print(data) else rcon.print("ERROR") end +""") + + if not result or result in ("NO_PLAYER", "NO_CHARACTER"): + return False, result or "플레이어 없음" + + if result.startswith("FOUND:"): + # 멈추기 + self.rcon.lua(P + "p.walking_state = {walking = false, direction = defines.direction.north}") + # 파싱: "FOUND:x,y|iron-ore=500,coal=200" + parts = result.replace("FOUND:", "").split("|") + pos_str = parts[0] + res_str = parts[1] if len(parts) > 1 else "" + return True, f"자원 발견! 위치({pos_str}), 자원: {res_str}" + + if result.startswith("NONE:"): + pos_str = result.replace("NONE:", "") + # stuck 체크 + if last_pos == pos_str: + stuck_count += 1 + else: + stuck_count = 0 + last_pos = pos_str + + if stuck_count >= 3: + self.rcon.lua(P + "p.walking_state = {walking = false, direction = defines.direction.north}") + return False, f"탐색 중 장애물에 막힘 위치({pos_str}) — 다른 방향 시도" + + # 시간 초과 + self.rcon.lua(P + "p.walking_state = {walking = false, direction = defines.direction.north}") + return False, f"{direction} 방향 {max_steps}틱 탐색했으나 자원 미발견 — 다른 방향 시도" + # ── 이동 (실제 걷기) ───────────────────────────────────────────── def move(self, x: int, y: int) -> tuple[bool, str]: """8방향 walking_state로 실제 걷기. 도착까지 폴링.""" @@ -79,7 +154,6 @@ if dist < 1.5 then return end --- 8방향 계산 local angle = math.atan2(dy, dx) local dir if angle >= -0.393 and angle < 0.393 then dir = defines.direction.east @@ -101,7 +175,6 @@ rcon.print("WALK:" .. string.format("%.1f", dist)) if result.startswith("ARRIVED"): return True, f"({x}, {y})로 도착" - # 진행 안 되면 stuck 판정 try: dist = float(result.split(":")[1]) if abs(dist - last_dist) < 0.3: @@ -121,10 +194,8 @@ rcon.print("WALK:" .. string.format("%.1f", dist)) self.rcon.lua(P + "p.walking_state = {walking = false, direction = defines.direction.north}") return False, f"({x},{y})에 도달 못함 (시간 초과, 남은 거리: {last_dist:.0f})" - # ── 자원 채굴 (실제 mining_state) ──────────────────────────────── + # ── 자원 채굴 ──────────────────────────────────────────────────── def mine_resource(self, ore: str = "iron-ore", count: int = 10) -> tuple[bool, str]: - """플레이어 근처의 자원을 실제로 채굴.""" - # 근처 자원 찾기 find_result = self.rcon.lua(P + f""" local surface = p.surface local resources = surface.find_entities_filtered{{ @@ -144,7 +215,6 @@ end """) if not find_result or find_result in ("NO_PLAYER", "NO_CHARACTER"): return False, find_result or "플레이어 없음" - if find_result == "NOT_FOUND": return False, f"근처에 {ore} 없음 — 자원 패치로 move 먼저" @@ -156,11 +226,8 @@ end max_ticks = count * 40 for _ in range(max_ticks): - self.rcon.lua(P + f""" -p.mining_state = {{mining = true, position = {{{rx}, {ry}}}}} -""") + self.rcon.lua(P + f"p.mining_state = {{mining = true, position = {{{rx}, {ry}}}}}") time.sleep(0.1) - current = self._get_item_count(ore) if current >= target_count: self.rcon.lua(P + "p.mining_state = {mining = false}") @@ -170,210 +237,107 @@ p.mining_state = {{mining = true, position = {{{rx}, {ry}}}}} mined = self._get_item_count(ore) - before_count if mined > 0: return True, f"{ore} {mined}개 채굴 (목표 {count}개 중 일부)" - return False, f"{ore} 채굴 실패 — 너무 멀거나 자원 고갈" + return False, f"{ore} 채굴 실패" - # ── 아이템 제작 (실제 begin_crafting) ───────────────────────────── + # ── 제작 ───────────────────────────────────────────────────────── def craft_item(self, item: str, count: int = 1) -> tuple[bool, str]: - """실제 제작 큐 사용. 재료가 인벤토리에 있어야 함.""" result = self.rcon.lua(P + f""" local recipe = p.force.recipes["{item}"] -if not recipe then - rcon.print("NO_RECIPE") - return -end -if not recipe.enabled then - rcon.print("LOCKED") - return -end - +if not recipe then rcon.print("NO_RECIPE") return end +if not recipe.enabled then rcon.print("LOCKED") return end local crafted = p.begin_crafting{{count={count}, recipe="{item}"}} -if crafted > 0 then - rcon.print("CRAFTING:" .. crafted) -else - rcon.print("NO_INGREDIENTS") -end +if crafted > 0 then rcon.print("CRAFTING:" .. crafted) +else rcon.print("NO_INGREDIENTS") end """) if not result or result in ("NO_PLAYER", "NO_CHARACTER"): return False, result or "플레이어 없음" - if result.startswith("CRAFTING:"): crafted = int(result.split(":")[1]) - wait_time = min(crafted * 2, 30) - time.sleep(wait_time) + time.sleep(min(crafted * 2, 30)) return True, f"{item} {crafted}개 제작 완료" elif result == "NO_RECIPE": - return False, f"{item} 레시피 없음 (아이템 이름 확인)" + return False, f"{item} 레시피 없음" elif result == "LOCKED": - return False, f"{item} 레시피 잠김 (연구 필요)" + return False, f"{item} 레시피 잠김" elif result == "NO_INGREDIENTS": - return False, f"{item} 재료 부족 — 필요 재료를 먼저 채굴/제작" + return False, f"{item} 재료 부족" return False, f"제작 실패: {result}" - # ── 건물 배치 (커서 + build_from_cursor) ────────────────────────── - def place_entity( - self, - name: str, - x: int, y: int, - direction: str = "north" - ) -> tuple[bool, str]: - """실제 건설: 건설 거리 내에 있어야 하고, 인벤토리에 아이템 필요.""" - dir_map = { - "north": "defines.direction.north", - "south": "defines.direction.south", - "east": "defines.direction.east", - "west": "defines.direction.west", - } + # ── 건물 배치 ──────────────────────────────────────────────────── + def place_entity(self, name: str, x: int, y: int, direction: str = "north") -> tuple[bool, str]: + dir_map = {"north": "defines.direction.north", "south": "defines.direction.south", + "east": "defines.direction.east", "west": "defines.direction.west"} lua_dir = dir_map.get(direction, "defines.direction.north") - result = self.rcon.lua(P + f""" local inv = p.get_main_inventory() - --- 인벤토리 확인 local have = inv.get_item_count("{name}") -if have < 1 then - rcon.print("NO_ITEM:" .. have) - return -end - --- 건설 거리 확인 +if have < 1 then rcon.print("NO_ITEM:" .. have) return end local dist = math.sqrt(({x} - p.position.x)^2 + ({y} - p.position.y)^2) -if dist > p.build_distance + 2 then - rcon.print("TOO_FAR:" .. string.format("%.1f", dist)) - return -end - --- 이미 있는지 확인 +if dist > p.build_distance + 2 then rcon.print("TOO_FAR:" .. string.format("%.1f", dist)) return end local existing = p.surface.find_entity("{name}", {{{x}, {y}}}) -if existing then - rcon.print("ALREADY_EXISTS") - return -end - --- 커서에 아이템 놓고 배치 +if existing then rcon.print("ALREADY_EXISTS") return end p.cursor_stack.set_stack({{name="{name}", count=1}}) -local built = p.build_from_cursor{{ - position = {{{x}, {y}}}, - direction = {lua_dir} -}} - -if built then - p.cursor_stack.clear() - rcon.print("OK") -else - p.cursor_stack.clear() - rcon.print("BLOCKED") -end +local built = p.build_from_cursor{{position = {{{x}, {y}}}, direction = {lua_dir}}} +if built then p.cursor_stack.clear() rcon.print("OK") +else p.cursor_stack.clear() rcon.print("BLOCKED") end """) if not result or result in ("NO_PLAYER", "NO_CHARACTER"): return False, result or "플레이어 없음" - - if result == "OK": - return True, f"{name} 배치 완료 ({x},{y})" - elif result.startswith("NO_ITEM"): - return False, f"인벤토리에 {name} 없음 — craft_item 먼저" - elif result.startswith("TOO_FAR"): - dist = result.split(":")[1] - return False, f"({x},{y})가 너무 멀음 (거리:{dist}) — move로 가까이 이동 먼저" - elif result == "ALREADY_EXISTS": - return True, f"{name}이 이미 ({x},{y})에 있음" - elif result == "BLOCKED": - return False, f"({x},{y}) 배치 불가 — 지형 또는 건물 겹침" + if result == "OK": return True, f"{name} 배치 완료 ({x},{y})" + elif result.startswith("NO_ITEM"): return False, f"인벤토리에 {name} 없음" + elif result.startswith("TOO_FAR"): return False, f"({x},{y})가 너무 멀음 — move 먼저" + elif result == "ALREADY_EXISTS": return True, f"{name}이 이미 ({x},{y})에 있음" + elif result == "BLOCKED": return False, f"({x},{y}) 배치 불가" return False, f"배치 실패: {result}" - # ── 벨트 라인 (걸어다니면서 하나씩 배치) ───────────────────────── - def place_belt_line( - self, - from_x: int, from_y: int, - to_x: int, to_y: int - ) -> tuple[bool, str]: - """벨트를 한 칸씩 걸어가면서 배치.""" - placed = 0 - failed = 0 - + # ── 벨트 라인 ──────────────────────────────────────────────────── + def place_belt_line(self, from_x: int, from_y: int, to_x: int, to_y: int) -> tuple[bool, str]: + placed, failed = 0, 0 positions = [] if from_x != to_x: step = 1 if from_x < to_x else -1 - direction = "east" if step == 1 else "west" - for bx in range(from_x, to_x + step, step): - positions.append((bx, from_y, direction)) - + d = "east" if step == 1 else "west" + for bx in range(from_x, to_x + step, step): positions.append((bx, from_y, d)) if from_y != to_y: step = 1 if from_y < to_y else -1 - direction = "south" if step == 1 else "north" - start_y = from_y + (step if from_x != to_x else 0) - for by in range(start_y, to_y + step, step): - positions.append((to_x, by, direction)) + d = "south" if step == 1 else "north" + sy = from_y + (step if from_x != to_x else 0) + for by in range(sy, to_y + step, step): positions.append((to_x, by, d)) + for bx, by, d in positions: + ok, _ = self.move(bx, by) + if not ok: failed += 1; continue + ok, _ = self.place_entity("transport-belt", bx, by, d) + if ok: placed += 1 + else: failed += 1 + if placed == 0: return False, f"벨트 설치 실패 ({failed}개 실패)" + return True, f"벨트 {placed}개 설치 완료" + (f", {failed}개 실패" if failed else "") - for bx, by, direction in positions: - ok, msg = self.move(bx, by) - if not ok: - failed += 1 - continue - - ok, msg = self.place_entity("transport-belt", bx, by, direction) - if ok: - placed += 1 - else: - failed += 1 - - if placed == 0: - return False, f"벨트 설치 실패 ({failed}개 실패)" - if failed > 0: - return True, f"벨트 {placed}개 설치, {failed}개 실패" - return True, f"벨트 {placed}개 설치 완료" - - # ── 건물에 아이템 삽입 (수동 전달) ──────────────────────────────── - def insert_to_entity( - self, - x: int, y: int, - item: str = "coal", - count: int = 50 - ) -> tuple[bool, str]: - """플레이어 인벤토리에서 꺼내 건물에 넣음.""" + # ── 아이템 삽입 ────────────────────────────────────────────────── + def insert_to_entity(self, x: int, y: int, item: str = "coal", count: int = 50) -> tuple[bool, str]: result = self.rcon.lua(P + f""" local dist = math.sqrt(({x} - p.position.x)^2 + ({y} - p.position.y)^2) -if dist > p.build_distance + 2 then - rcon.print("TOO_FAR:" .. string.format("%.1f", dist)) - return -end - +if dist > p.build_distance + 2 then rcon.print("TOO_FAR") return end local inv = p.get_main_inventory() local have = inv.get_item_count("{item}") -if have < 1 then - rcon.print("NO_ITEM:" .. have) - return -end - +if have < 1 then rcon.print("NO_ITEM") return end local actual = math.min(have, {count}) -local entities = p.surface.find_entities_filtered{{ - area = {{{{{x}-1, {y}-1}}, {{{x}+1, {y}+1}}}} -}} - +local entities = p.surface.find_entities_filtered{{position = {{{x},{y}}}, radius = 2}} local inserted = false for _, e in ipairs(entities) do if e.valid then - -- 연료 인벤토리 (채굴기, 제련소, 보일러) if e.burner then local fi = e.burner.inventory if fi then local removed = inv.remove({{name="{item}", count=actual}}) - if removed > 0 then - fi.insert({{name="{item}", count=removed}}) - inserted = true - break - end + if removed > 0 then fi.insert({{name="{item}", count=removed}}) inserted = true break end end end - -- 일반 인벤토리 (상자, 연구소 등) if not inserted then for i = 1, 10 do - local target_inv = e.get_inventory(i) - if target_inv and target_inv.can_insert({{name="{item}", count=1}}) then + local ti = e.get_inventory(i) + if ti and ti.can_insert({{name="{item}", count=1}}) then local removed = inv.remove({{name="{item}", count=actual}}) - if removed > 0 then - target_inv.insert({{name="{item}", count=removed}}) - inserted = true - end + if removed > 0 then ti.insert({{name="{item}", count=removed}}) inserted = true end break end end @@ -381,74 +345,40 @@ for _, e in ipairs(entities) do if inserted then break end end end - rcon.print(inserted and "OK" or "NOT_FOUND") """) - if not result or result in ("NO_PLAYER", "NO_CHARACTER"): - return False, result or "플레이어 없음" - - if result == "OK": - return True, f"({x},{y}) 건물에 {item} 삽입 완료" - elif result.startswith("TOO_FAR"): - return False, f"({x},{y})가 너무 멀음 — move로 가까이 이동 먼저" - elif result.startswith("NO_ITEM"): - return False, f"인벤토리에 {item} 없음" + if not result or result in ("NO_PLAYER", "NO_CHARACTER"): return False, result or "플레이어 없음" + if result == "OK": return True, f"({x},{y}) 건물에 {item} 삽입 완료" + elif result == "TOO_FAR": return False, f"({x},{y})가 너무 멀음" + elif result == "NO_ITEM": return False, f"인벤토리에 {item} 없음" return False, f"({x},{y})에 적합한 건물 없음" - # ── 조립기 레시피 설정 ──────────────────────────────────────────── + # ── 레시피 설정 ────────────────────────────────────────────────── def set_recipe(self, x: int, y: int, recipe: str) -> tuple[bool, str]: result = self.rcon.lua(P + f""" local dist = math.sqrt(({x} - p.position.x)^2 + ({y} - p.position.y)^2) -if dist > p.build_distance + 2 then - rcon.print("TOO_FAR") - return -end - -local e = p.surface.find_entities_filtered{{ - area = {{{{{x}-1, {y}-1}}, {{{x}+1, {y}+1}}}}, - type = "assembling-machine" -}}[1] - -if e then - e.set_recipe("{recipe}") - rcon.print("OK") -else - rcon.print("NOT_FOUND") -end +if dist > p.build_distance + 2 then rcon.print("TOO_FAR") return end +local e = p.surface.find_entities_filtered{{position = {{{x},{y}}}, radius = 2, type = "assembling-machine"}}[1] +if e then e.set_recipe("{recipe}") rcon.print("OK") else rcon.print("NOT_FOUND") end """) - if not result or result in ("NO_PLAYER", "NO_CHARACTER"): - return False, result or "플레이어 없음" - - if result == "OK": - return True, f"({x},{y}) 레시피: {recipe}" - elif result == "TOO_FAR": - return False, f"({x},{y})가 너무 멀음 — move 먼저" + if not result or result in ("NO_PLAYER", "NO_CHARACTER"): return False, result or "플레이어 없음" + if result == "OK": return True, f"({x},{y}) 레시피: {recipe}" + elif result == "TOO_FAR": return False, f"({x},{y})가 너무 멀음" return False, f"({x},{y})에 조립기 없음" - # ── 연구 시작 ──────────────────────────────────────────────────── + # ── 연구 ───────────────────────────────────────────────────────── def start_research(self, tech: str = "automation") -> tuple[bool, str]: result = self.rcon.lua(P + f""" local force = p.force local t = force.technologies["{tech}"] -if not t then - rcon.print("NO_TECH") -elseif t.researched then - rcon.print("ALREADY_DONE") -else - force.research_queue_enabled = true - force.add_research("{tech}") - rcon.print("OK") -end +if not t then rcon.print("NO_TECH") +elseif t.researched then rcon.print("ALREADY_DONE") +else force.research_queue_enabled = true force.add_research("{tech}") rcon.print("OK") end """) - if not result or result in ("NO_PLAYER", "NO_CHARACTER"): - return False, result or "플레이어 없음" - - if result == "OK": - return True, f"{tech} 연구 시작" - elif result == "ALREADY_DONE": - return True, f"{tech} 이미 완료" - elif result == "NO_TECH": - return False, f"{tech} 기술 없음" + if not result or result in ("NO_PLAYER", "NO_CHARACTER"): return False, result or "플레이어 없음" + if result == "OK": return True, f"{tech} 연구 시작" + elif result == "ALREADY_DONE": return True, f"{tech} 이미 완료" + elif result == "NO_TECH": return False, f"{tech} 기술 없음" return False, result # ── 대기 ───────────────────────────────────────────────────────── @@ -458,10 +388,6 @@ end # ── 유틸리티 ───────────────────────────────────────────────────── def _get_item_count(self, item: str) -> int: - result = self.rcon.lua(P + f""" -rcon.print(tostring(p.get_main_inventory().get_item_count("{item}"))) -""") - try: - return int(result) - except (ValueError, TypeError): - return 0 + result = self.rcon.lua(P + f'rcon.print(tostring(p.get_main_inventory().get_item_count("{item}")))') + try: return int(result) + except: return 0