순수 플레이 전환: 텔레포트→걷기, 무한자원→실제채굴, 직접지급→실제제작, create_entity→build_from_cursor

This commit is contained in:
2026-03-25 12:47:04 +09:00
parent 7288976a99
commit 3d0f7c0517

View File

@@ -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 time
import json import json
@@ -44,38 +47,159 @@ class ActionExecutor:
except Exception as e: except Exception as e:
return False, f"실행 오류: {e}" return False, f"실행 오류: {e}"
# ── 이동 ──────────────────────────────────────────────────── # ── 이동 (실제 걷기) ────────────────────────────────────────
def move(self, x: int, y: int) -> tuple[bool, str]: def move(self, x: int, y: int) -> tuple[bool, str]:
self.rcon.lua(f"game.player.teleport({{x={x}, y={y}}})") """8방향 walking_state로 실제 걷기. 도착까지 폴링."""
time.sleep(0.2) max_ticks = 400 # 최대 ~40초 (먼 거리 대비)
return True, f"({x}, {y})로 이동" stuck_count = 0
last_dist = 99999
# ── 자원 채굴 ─────────────────────────────────────────────── for _ in range(max_ticks):
def mine_resource(self, ore: str = "iron-ore", count: int = 50) -> tuple[bool, str]: result = self.rcon.lua(f"""
self.rcon.lua(f'game.player.insert({{name="{ore}", count={count}}})') local p = game.player
return True, f"{ore} {count}개 획득" 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]: def craft_item(self, item: str, count: int = 1) -> tuple[bool, str]:
# 제작 가능 여부 확인 후 인벤토리에 지급 """실제 제작 큐 사용. 재료가 인벤토리에 있어야 함."""
lua = f""" result = self.rcon.lua(f"""
local ok = pcall(function() local p = game.player
game.player.insert({{name="{item}", count={count}}}) local recipe = p.force.recipes["{item}"]
end) if not recipe then
rcon.print(ok and "ok" or "fail") rcon.print("NO_RECIPE")
""" return
result = self.rcon.lua(lua) end
if "ok" in result: if not recipe.enabled then
return True, f"{item} {count}개 제작" rcon.print("LOCKED")
return False, f"{item} 제작 실패 (아이템 이름 확인 필요)" 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( def place_entity(
self, self,
name: str, name: str,
x: int, y: int, x: int, y: int,
direction: str = "north" direction: str = "north"
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""실제 건설: 건설 거리 내에 있어야 하고, 인벤토리에 아이템 필요."""
dir_map = { dir_map = {
"north": "defines.direction.north", "north": "defines.direction.north",
"south": "defines.direction.south", "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_dir = dir_map.get(direction, "defines.direction.north")
lua = f""" result = self.rcon.lua(f"""
local surface = game.player.surface local p = game.player
local force = game.player.force local inv = p.get_main_inventory()
local inv = game.player.get_main_inventory()
-- 인벤토리 확인 -- 인벤토리 확인
local have = inv.get_item_count("{name}") local have = inv.get_item_count("{name}")
if have < 1 then if have < 1 then
rcon.print("NO_ITEM:" .. have) rcon.print("NO_ITEM:" .. have)
return return
end 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 if existing then
rcon.print("ALREADY_EXISTS") rcon.print("ALREADY_EXISTS")
return return
end end
-- 배치 -- 커서에 아이템 놓고 배치
inv.remove({{name="{name}", count=1}}) p.cursor_stack.set_stack({{name="{name}", count=1}})
local e = surface.create_entity{{ local built = p.build_from_cursor{{
name = "{name}", position = {{{x}, {y}}},
position = {{{x}, {y}}}, direction = {lua_dir}
direction = {lua_dir},
force = force,
raise_built = true
}} }}
if e then if built then
rcon.print("OK:" .. e.name) p.cursor_stack.clear()
rcon.print("OK")
else else
rcon.print("BLOCKED") p.cursor_stack.clear()
rcon.print("BLOCKED")
end end
""" """)
result = self.rcon.lua(lua) if result == "OK":
if result.startswith("OK:"):
return True, f"{name} 배치 완료 ({x},{y})" return True, f"{name} 배치 완료 ({x},{y})"
elif result.startswith("NO_ITEM"): elif result.startswith("NO_ITEM"):
count = result.split(":")[1] if ":" in result else "0" return False, f"인벤토리에 {name} 없음 — craft_item 먼저"
return False, f"인벤토리에 {name} 없음 (보유: {count}개) — craft_item 먼저" elif result.startswith("TOO_FAR"):
dist = result.split(":")[1]
return False, f"({x},{y})가 너무 멀음 (거리:{dist}) — move로 가까이 이동 먼저"
elif result == "ALREADY_EXISTS": elif result == "ALREADY_EXISTS":
return True, f"{name}이 이미 ({x},{y})에 있음 — 건너뜀" return True, f"{name}이 이미 ({x},{y})에 있음"
elif result == "BLOCKED": elif result == "BLOCKED":
return False, f"({x},{y}) 위치가 막혀있음 — 다른 좌표 시도" return False, f"({x},{y}) 배치 불가 — 지형 또는 건물 겹침"
else: return False, f"배치 실패: {result}"
return False, f"알 수 없는 결과: {result}"
# ── 벨트 라인 ─────────────────────────────────────────────── # ── 벨트 라인 (걸어다니며 하나씩 배치) ─────────────────────
def place_belt_line( def place_belt_line(
self, self,
from_x: int, from_y: int, from_x: int, from_y: int,
to_x: int, to_y: int to_x: int, to_y: int
) -> tuple[bool, str]: ) -> tuple[bool, str]:
lua = f""" """벨트를 한 칸씩 걸어가며 배치. 순수 플레이."""
local surface = game.player.surface placed = 0
local force = game.player.force failed = 0
local inv = game.player.get_main_inventory()
local placed = 0
local 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 from_y != to_y:
if x1 ~= x2 then step = 1 if from_y < to_y else -1
local step = x1 < x2 and 1 or -1 direction = "south" if step == 1 else "north"
local dir = x1 < x2 and defines.direction.east or defines.direction.west start_y = from_y + (step if from_x != to_x else 0)
for bx = x1, x2, step do for by in range(start_y, to_y + step, step):
if inv.get_item_count("transport-belt") > 0 then positions.append((to_x, by, direction))
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
-- 수직 이동 for bx, by, direction in positions:
if y1 ~= y2 then # 건설 거리 내로 이동
local step = y1 < y2 and 1 or -1 ok, msg = self.move(bx, by)
local dir = y1 < y2 and defines.direction.south or defines.direction.north if not ok:
for by = y1 + (x1 ~= x2 and 1 or 0), y2, step do failed += 1
if inv.get_item_count("transport-belt") > 0 then continue
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
rcon.print("placed=" .. placed .. " failed=" .. failed) ok, msg = self.place_entity("transport-belt", bx, by, direction)
""" if ok:
result = self.rcon.lua(lua) placed += 1
if "placed=" in result: else:
p = result.split("placed=")[1].split(" ")[0] failed += 1
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}"
# ── 건물에 아이템 삽입 ────────────────────────────────────── if placed == 0:
return False, f"벨트 설치 실패 ({failed}개 실패)"
if failed > 0:
return True, f"벨트 {placed}개 설치, {failed}개 실패"
return True, f"벨트 {placed}개 설치 완료"
# ── 건물에 아이템 삽입 (수동 전달) ──────────────────────────
def insert_to_entity( def insert_to_entity(
self, self,
x: int, y: int, x: int, y: int,
item: str = "coal", item: str = "coal",
count: int = 50 count: int = 50
) -> tuple[bool, str]: ) -> tuple[bool, str]:
lua = f""" """플레이어 인벤토리에서 꺼내 건물에 넣음. 건설 거리 내 필요."""
local surface = game.player.surface result = self.rcon.lua(f"""
-- 해당 위치 근처 모든 엔티티 검색 local p = game.player
local entities = surface.find_entities_filtered{{ local dist = math.sqrt(({x} - p.position.x)^2 + ({y} - p.position.y)^2)
area = {{{{{x}-1, {y}-1}}, {{{x}+1, {y}+1}}}} 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 local inserted = false
for _, e in ipairs(entities) do for _, e in ipairs(entities) do
if e.valid and e.get_fuel_inventory and e.get_fuel_inventory() then if e.valid then
local fi = e.get_fuel_inventory() -- 연료 인벤토리 (채굴기, 제련소, 보일러)
fi.insert({{name="{item}", count={count}}}) if e.burner then
inserted = true local fi = e.burner.inventory
elseif e.valid and e.get_inventory then if fi then
local inv = e.get_inventory(defines.inventory.chest) local removed = inv.remove({{name="{item}", count=actual}})
if inv then if removed > 0 then
inv.insert({{name="{item}", count={count}}}) fi.insert({{name="{item}", count=removed}})
inserted = true 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
end end
rcon.print(inserted and "OK" or "NOT_FOUND") rcon.print(inserted and "OK" or "NOT_FOUND")
""" """)
result = self.rcon.lua(lua)
if result == "OK": if result == "OK":
return True, f"({x},{y}) 건물에 {item} {count}삽입" return True, f"({x},{y}) 건물에 {item} 삽입 완료"
return False, f"({x},{y})에서 건물을 찾을 수 없음" 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]: def set_recipe(self, x: int, y: int, recipe: str) -> tuple[bool, str]:
lua = f""" result = self.rcon.lua(f"""
local e = game.player.surface.find_entities_filtered{{ local p = game.player
area = {{{{{x}-1, {y}-1}}, {{{x}+1, {y}+1}}}}, local dist = math.sqrt(({x} - p.position.x)^2 + ({y} - p.position.y)^2)
type = "assembling-machine" 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] }}[1]
if e then if e then
e.set_recipe("{recipe}") e.set_recipe("{recipe}")
rcon.print("OK") rcon.print("OK")
else else
rcon.print("NOT_FOUND") rcon.print("NOT_FOUND")
end end
""" """)
result = self.rcon.lua(lua)
if result == "OK": 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})에 조립기 없음" return False, f"({x},{y})에 조립기 없음"
# ── 연구 시작 ─────────────────────────────────────────────── # ── 연구 시작 ───────────────────────────────────────────────
def start_research(self, tech: str = "automation") -> tuple[bool, str]: def start_research(self, tech: str = "automation") -> tuple[bool, str]:
lua = f""" result = self.rcon.lua(f"""
local force = game.player.force local force = game.player.force
local t = force.technologies["{tech}"] local t = force.technologies["{tech}"]
if not t then if not t then
rcon.print("NO_TECH") rcon.print("NO_TECH")
elseif t.researched then elseif t.researched then
rcon.print("ALREADY_DONE") rcon.print("ALREADY_DONE")
else else
force.research_queue_enabled = true force.research_queue_enabled = true
force.add_research("{tech}") force.add_research("{tech}")
rcon.print("OK") rcon.print("OK")
end end
""" """)
result = self.rcon.lua(lua)
if result == "OK": if result == "OK":
return True, f"{tech} 연구 시작" return True, f"{tech} 연구 시작"
elif result == "ALREADY_DONE": elif result == "ALREADY_DONE":
return True, f"{tech} 이미 연구 완료" return True, f"{tech} 이미 완료"
elif result == "NO_TECH": elif result == "NO_TECH":
return False, f"{tech} 기술을 찾을 수 없음" return False, f"{tech} 기술 없음"
return False, result return False, result
# ── 대기 ──────────────────────────────────────────────────── # ── 대기 ────────────────────────────────────────────────────
def wait(self, seconds: int = 3) -> tuple[bool, str]: def wait(self, seconds: int = 3) -> tuple[bool, str]:
time.sleep(min(seconds, 30)) # 최대 30초 time.sleep(min(seconds, 30))
return True, f"{seconds}초 대기 완료" 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