From 3d0f7c0517461b08d0a9d6a83a9880ba7f5de2c0 Mon Sep 17 00:00:00 2001 From: gihyeon Date: Wed, 25 Mar 2026 12:47:04 +0900 Subject: [PATCH] =?UTF-8?q?=EC=88=9C=EC=88=98=20=ED=94=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=84=ED=99=98:=20=ED=85=94=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=E2=86=92=EA=B1=B7=EA=B8=B0,=20=EB=AC=B4=ED=95=9C?= =?UTF-8?q?=EC=9E=90=EC=9B=90=E2=86=92=EC=8B=A4=EC=A0=9C=EC=B1=84=EA=B5=B4?= =?UTF-8?q?,=20=EC=A7=81=EC=A0=91=EC=A7=80=EA=B8=89=E2=86=92=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=EC=A0=9C=EC=9E=91,=20create=5Fentity=E2=86=92build=5F?= =?UTF-8?q?from=5Fcursor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- action_executor.py | 460 ++++++++++++++++++++++++++++++--------------- 1 file changed, 311 insertions(+), 149 deletions(-) diff --git a/action_executor.py b/action_executor.py index d02601a..0aefb83 100644 --- a/action_executor.py +++ b/action_executor.py @@ -1,8 +1,11 @@ """ -action_executor.py — 완전 자율 에이전트용 실행기 +action_executor.py — 순수 AI 플레이 버전 (치트 없음) -AI가 계획한 모든 행동을 실제 팩토리오에서 실행. -성공/실패 여부와 이유를 반환해서 AI 피드백 루프에 활용. +모든 행동이 실제 게임 메커닉을 사용: +- 이동: 텔레포트 대신 실제 걷기 (walking_state) +- 채굴: 인벤토리 직접 삽입 대신 실제 채굴 (mining_state) +- 제작: 무조건 지급 대신 실제 제작 (begin_crafting, 재료 소모) +- 건설: create_entity 대신 커서 배치 (build_from_cursor, 거리 제한) """ import time import json @@ -44,38 +47,159 @@ class ActionExecutor: except Exception as e: return False, f"실행 오류: {e}" - # ── 이동 ──────────────────────────────────────────────────── + # ── 이동 (실제 걷기) ──────────────────────────────────────── def move(self, x: int, y: int) -> tuple[bool, str]: - self.rcon.lua(f"game.player.teleport({{x={x}, y={y}}})") - time.sleep(0.2) - return True, f"({x}, {y})로 이동" + """8방향 walking_state로 실제 걷기. 도착까지 폴링.""" + max_ticks = 400 # 최대 ~40초 (먼 거리 대비) + stuck_count = 0 + last_dist = 99999 - # ── 자원 채굴 ─────────────────────────────────────────────── - def mine_resource(self, ore: str = "iron-ore", count: int = 50) -> tuple[bool, str]: - self.rcon.lua(f'game.player.insert({{name="{ore}", count={count}}})') - return True, f"{ore} {count}개 획득" + for _ in range(max_ticks): + result = self.rcon.lua(f""" +local p = game.player +local tx, ty = {x}, {y} +local dx = tx - p.position.x +local dy = ty - p.position.y +local dist = math.sqrt(dx*dx + dy*dy) - # ── 아이템 제작 ───────────────────────────────────────────── +if dist < 1.5 then + p.walking_state = {{walking = false, direction = defines.direction.north}} + rcon.print("ARRIVED:" .. math.floor(p.position.x) .. "," .. math.floor(p.position.y)) + return +end + +-- 8방향 계산 +local angle = math.atan2(dy, dx) +local dir +if angle >= -0.393 and angle < 0.393 then dir = defines.direction.east +elseif angle >= 0.393 and angle < 1.178 then dir = defines.direction.southeast +elseif angle >= 1.178 and angle < 1.963 then dir = defines.direction.south +elseif angle >= 1.963 and angle < 2.749 then dir = defines.direction.southwest +elseif angle >= -1.178 and angle < -0.393 then dir = defines.direction.northeast +elseif angle >= -1.963 and angle < -1.178 then dir = defines.direction.north +elseif angle >= -2.749 and angle < -1.963 then dir = defines.direction.northwest +else dir = defines.direction.west +end + +p.walking_state = {{walking = true, direction = dir}} +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: + stuck_count += 1 + else: + stuck_count = 0 + last_dist = dist + except (ValueError, IndexError): + pass + + if stuck_count > 30: + self.rcon.lua("game.player.walking_state = {walking = false, direction = defines.direction.north}") + return False, f"({x},{y}) 이동 중 장애물에 막힘 (남은 거리: {last_dist:.0f})" + + time.sleep(0.1) + + self.rcon.lua("game.player.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(f""" +local p = game.player +local surface = p.surface +local resources = surface.find_entities_filtered{{ + position = p.position, radius = 5, name = "{ore}" +}} +if #resources == 0 then + resources = surface.find_entities_filtered{{ + position = p.position, radius = 15, name = "{ore}" + }} +end +if #resources > 0 then + local r = resources[1] + rcon.print("FOUND:" .. string.format("%.1f,%.1f", r.position.x, r.position.y)) +else + rcon.print("NOT_FOUND") +end +""") + if find_result == "NOT_FOUND": + return False, f"근처에 {ore} 없음 — 자원 패치로 move 먼저" + + parts = find_result.replace("FOUND:", "").split(",") + rx, ry = float(parts[0]), float(parts[1]) + + before_count = self._get_item_count(ore) + target_count = before_count + count + max_ticks = count * 40 # 자원당 ~4초 여유 + + for _ in range(max_ticks): + self.rcon.lua(f""" +game.player.mining_state = {{mining = true, position = {{{rx}, {ry}}}}} +""") + time.sleep(0.1) + + current = self._get_item_count(ore) + if current >= target_count: + self.rcon.lua("game.player.mining_state = {mining = false}") + return True, f"{ore} {current - before_count}개 채굴 완료" + + self.rcon.lua("game.player.mining_state = {mining = false}") + mined = self._get_item_count(ore) - before_count + if mined > 0: + return True, f"{ore} {mined}개 채굴 (목표 {count}개 중 일부)" + return False, f"{ore} 채굴 실패 — 너무 멀거나 자원 고갈" + + # ── 아이템 제작 (실제 begin_crafting) ─────────────────────── def craft_item(self, item: str, count: int = 1) -> tuple[bool, str]: - # 제작 가능 여부 확인 후 인벤토리에 지급 - lua = f""" -local ok = pcall(function() - game.player.insert({{name="{item}", count={count}}}) -end) -rcon.print(ok and "ok" or "fail") -""" - result = self.rcon.lua(lua) - if "ok" in result: - return True, f"{item} {count}개 제작" - return False, f"{item} 제작 실패 (아이템 이름 확인 필요)" + """실제 제작 큐 사용. 재료가 인벤토리에 있어야 함.""" + result = self.rcon.lua(f""" +local p = game.player +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 - # ── 단일 건물 배치 ────────────────────────────────────────── +local crafted = p.begin_crafting{{count={count}, recipe="{item}"}} +if crafted > 0 then + rcon.print("CRAFTING:" .. crafted) +else + rcon.print("NO_INGREDIENTS") +end +""") + if result.startswith("CRAFTING:"): + crafted = int(result.split(":")[1]) + # 제작 완료 대기 (레시피당 ~0.5~2초) + wait_time = min(crafted * 2, 30) + time.sleep(wait_time) + return True, f"{item} {crafted}개 제작 완료" + elif result == "NO_RECIPE": + return False, f"{item} 레시피 없음 (아이템 이름 확인)" + elif result == "LOCKED": + return False, f"{item} 레시피 잠김 (연구 필요)" + elif result == "NO_INGREDIENTS": + 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", @@ -84,199 +208,237 @@ rcon.print(ok and "ok" or "fail") } lua_dir = dir_map.get(direction, "defines.direction.north") - lua = f""" -local surface = game.player.surface -local force = game.player.force -local inv = game.player.get_main_inventory() + result = self.rcon.lua(f""" +local p = game.player +local inv = p.get_main_inventory() -- 인벤토리 확인 local have = inv.get_item_count("{name}") if have < 1 then - rcon.print("NO_ITEM:" .. have) - return + rcon.print("NO_ITEM:" .. have) + return end --- 이미 해당 위치에 건물이 있는지 확인 -local existing = surface.find_entity("{name}", {{{x}, {y}}}) +-- 건설 거리 확인 +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 + +-- 이미 있는지 확인 +local existing = p.surface.find_entity("{name}", {{{x}, {y}}}) if existing then - rcon.print("ALREADY_EXISTS") - return + rcon.print("ALREADY_EXISTS") + return end --- 배치 -inv.remove({{name="{name}", count=1}}) -local e = surface.create_entity{{ - name = "{name}", - position = {{{x}, {y}}}, - direction = {lua_dir}, - force = force, - raise_built = true +-- 커서에 아이템 놓고 배치 +p.cursor_stack.set_stack({{name="{name}", count=1}}) +local built = p.build_from_cursor{{ + position = {{{x}, {y}}}, + direction = {lua_dir} }} -if e then - rcon.print("OK:" .. e.name) +if built then + p.cursor_stack.clear() + rcon.print("OK") else - rcon.print("BLOCKED") + p.cursor_stack.clear() + rcon.print("BLOCKED") end -""" - result = self.rcon.lua(lua) - - if result.startswith("OK:"): +""") + if result == "OK": return True, f"{name} 배치 완료 ({x},{y})" elif result.startswith("NO_ITEM"): - count = result.split(":")[1] if ":" in result else "0" - return False, f"인벤토리에 {name} 없음 (보유: {count}개) — craft_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})에 있음 — 건너뜀" + return True, f"{name}이 이미 ({x},{y})에 있음" elif result == "BLOCKED": - return False, f"({x},{y}) 위치가 막혀있음 — 다른 좌표 시도" - else: - return False, f"알 수 없는 결과: {result}" + 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]: - lua = f""" -local surface = game.player.surface -local force = game.player.force -local inv = game.player.get_main_inventory() -local placed = 0 -local failed = 0 + """벨트를 한 칸씩 걸어가며 배치. 순수 플레이.""" + placed = 0 + failed = 0 -local x1, y1 = {from_x}, {from_y} -local x2, y2 = {to_x}, {to_y} + # 경로 좌표 계산 + 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)) --- 수평 이동 -if x1 ~= x2 then - local step = x1 < x2 and 1 or -1 - local dir = x1 < x2 and defines.direction.east or defines.direction.west - for bx = x1, x2, step do - if inv.get_item_count("transport-belt") > 0 then - inv.remove({{name="transport-belt", count=1}}) - local e = surface.create_entity{{ - name="transport-belt", position={{bx, y1}}, - direction=dir, force=force, raise_built=true - }} - if e then placed = placed + 1 else failed = failed + 1 end - else - failed = failed + 1 - end - end -end + 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)) --- 수직 이동 -if y1 ~= y2 then - local step = y1 < y2 and 1 or -1 - local dir = y1 < y2 and defines.direction.south or defines.direction.north - for by = y1 + (x1 ~= x2 and 1 or 0), y2, step do - if inv.get_item_count("transport-belt") > 0 then - inv.remove({{name="transport-belt", count=1}}) - local e = surface.create_entity{{ - name="transport-belt", position={{x2, by}}, - direction=dir, force=force, raise_built=true - }} - if e then placed = placed + 1 else failed = failed + 1 end - else - failed = failed + 1 - end - end -end + for bx, by, direction in positions: + # 건설 거리 내로 이동 + ok, msg = self.move(bx, by) + if not ok: + failed += 1 + continue -rcon.print("placed=" .. placed .. " failed=" .. failed) -""" - result = self.rcon.lua(lua) - if "placed=" in result: - p = result.split("placed=")[1].split(" ")[0] - f = result.split("failed=")[1] if "failed=" in result else "0" - if int(f) > 0: - return False, f"벨트 {p}개 설치, {f}개 실패 (벨트 부족 또는 지형 막힘)" - return True, f"벨트 {p}개 설치 완료" - return False, f"벨트 설치 실패: {result}" + 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]: - lua = f""" -local surface = game.player.surface --- 해당 위치 근처 모든 엔티티 검색 -local entities = surface.find_entities_filtered{{ - area = {{{{{x}-1, {y}-1}}, {{{x}+1, {y}+1}}}} + """플레이어 인벤토리에서 꺼내 건물에 넣음. 건설 거리 내 필요.""" + result = self.rcon.lua(f""" +local p = game.player +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 + +local inv = p.get_main_inventory() +local have = inv.get_item_count("{item}") +if have < 1 then + rcon.print("NO_ITEM:" .. have) + 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 inserted = false for _, e in ipairs(entities) do - if e.valid and e.get_fuel_inventory and e.get_fuel_inventory() then - local fi = e.get_fuel_inventory() - fi.insert({{name="{item}", count={count}}}) - inserted = true - elseif e.valid and e.get_inventory then - local inv = e.get_inventory(defines.inventory.chest) - if inv then - inv.insert({{name="{item}", count={count}}}) - inserted = true + 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 + 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 removed = inv.remove({{name="{item}", count=actual}}) + if removed > 0 then + target_inv.insert({{name="{item}", count=removed}}) + inserted = true + end + break + end + end + end + if inserted then break end end - end end rcon.print(inserted and "OK" or "NOT_FOUND") -""" - result = self.rcon.lua(lua) +""") if result == "OK": - return True, f"({x},{y}) 건물에 {item} {count}개 삽입" - return False, f"({x},{y})에서 건물을 찾을 수 없음" + 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} 없음" + return False, f"({x},{y})에 적합한 건물 없음" # ── 조립기 레시피 설정 ────────────────────────────────────── def set_recipe(self, x: int, y: int, recipe: str) -> tuple[bool, str]: - lua = f""" -local e = game.player.surface.find_entities_filtered{{ - area = {{{{{x}-1, {y}-1}}, {{{x}+1, {y}+1}}}}, - type = "assembling-machine" + result = self.rcon.lua(f""" +local p = game.player +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") + e.set_recipe("{recipe}") + rcon.print("OK") else - rcon.print("NOT_FOUND") + rcon.print("NOT_FOUND") end -""" - result = self.rcon.lua(lua) +""") if result == "OK": - return True, f"({x},{y}) 조립기 레시피: {recipe}" + return True, f"({x},{y}) 레시피: {recipe}" + elif result == "TOO_FAR": + return False, f"({x},{y})가 너무 멀음 — move 먼저" return False, f"({x},{y})에 조립기 없음" # ── 연구 시작 ─────────────────────────────────────────────── def start_research(self, tech: str = "automation") -> tuple[bool, str]: - lua = f""" + result = self.rcon.lua(f""" local force = game.player.force local t = force.technologies["{tech}"] if not t then - rcon.print("NO_TECH") + rcon.print("NO_TECH") elseif t.researched then - rcon.print("ALREADY_DONE") + rcon.print("ALREADY_DONE") else - force.research_queue_enabled = true - force.add_research("{tech}") - rcon.print("OK") + force.research_queue_enabled = true + force.add_research("{tech}") + rcon.print("OK") end -""" - result = self.rcon.lua(lua) +""") if result == "OK": return True, f"{tech} 연구 시작" elif result == "ALREADY_DONE": - return True, f"{tech} 이미 연구 완료" + return True, f"{tech} 이미 완료" elif result == "NO_TECH": - return False, f"{tech} 기술을 찾을 수 없음" + return False, f"{tech} 기술 없음" return False, result # ── 대기 ──────────────────────────────────────────────────── def wait(self, seconds: int = 3) -> tuple[bool, str]: - time.sleep(min(seconds, 30)) # 최대 30초 + time.sleep(min(seconds, 30)) return True, f"{seconds}초 대기 완료" + + # ── 유틸리티 ──────────────────────────────────────────────── + def _get_item_count(self, item: str) -> int: + """플레이어 인벤토리의 특정 아이템 수량 조회""" + result = self.rcon.lua(f""" +rcon.print(tostring(game.player.get_main_inventory().get_item_count("{item}"))) +""") + try: + return int(result) + except (ValueError, TypeError): + return 0