From 8c90e8058231772b163c85f4fbfdffa43ec3a83f Mon Sep 17 00:00:00 2001 From: 21in7 Date: Wed, 25 Mar 2026 23:03:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9D=B8=EB=B2=A4=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EB=B0=8F=20JSON=20=EC=9D=B8=EC=BD=94?= =?UTF-8?q?=EB=8D=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인벤토리 캐시 기능을 추가하여, RCON으로 인벤토리를 읽지 못할 경우 이전에 성공적으로 읽은 데이터를 활용 - Lua에서 JSON 인코딩을 위한 간단한 함수 추가, 일부 Factorio 버전에서 `game.table_to_json`이 없을 경우 대체 - `README.md`에 인벤토리 캐시 및 JSON 인코더 사용에 대한 설명 추가 - `scan_resources()`와 `mine_resource`의 반경을 확장하여 자원 탐색 실패를 줄임 --- .gitignore | 3 + README.md | 31 ++ __pycache__/action_executor.cpython-311.pyc | Bin 26092 -> 26081 bytes __pycache__/ai_planner.cpython-311.pyc | Bin 17528 -> 18733 bytes .../context_compressor.cpython-311.pyc | Bin 14818 -> 16133 bytes __pycache__/main.cpython-311.pyc | Bin 8759 -> 8743 bytes __pycache__/state_reader.cpython-311.pyc | Bin 10479 -> 25358 bytes action_executor.py | 16 +- agent_log.jsonl | 66 ---- ai_planner.py | 50 ++- context_compressor.py | 66 +++- docs/plan.md | 90 +++++ main.py | 14 +- state_reader.py | 360 ++++++++++++++++-- 14 files changed, 573 insertions(+), 123 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f310556 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +run_factorio_ai.ps1 +inventory_memory.json +.cursor/ \ No newline at end of file diff --git a/README.md b/README.md index 9359850..e910621 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,20 @@ python main.py FACTORIO_HOST=192.168.1.10 FACTORIO_PORT=25575 FACTORIO_PASSWORD=mypass python main.py ``` +```powershell +# Z.ai API 키 설정 +$env:ZAI_API_KEY="your-key-here" + +# 기본 실행 +python .\main.py + +# 커스텀 서버 (한 줄 버전) +$env:FACTORIO_HOST="192.168.1.10"; $env:FACTORIO_PORT="25575"; $env:FACTORIO_PASSWORD="mypass"; python .\main.py + +# (권장) 스크립트 실행 +.\run_factorio_ai.ps1 "your-key-here" "mypass" +``` + --- ## 동작 원리 @@ -114,8 +128,25 @@ planner.set_goal( - AI가 "move 먼저 → 작업" 패턴을 학습하도록 프롬프트가 설계되어 있습니다 - `agent_log.jsonl`에 모든 행동과 타임스탬프가 기록됩니다 - `ai_planner.py`는 GLM 응답이 잘리거나(중괄호/대괄호 불일치) 마크다운이 섞여도 JSON 파싱을 복구하도록 `{} / []` 균형 추적과 보정 로직을 사용합니다. +- `ai_planner.py`의 `_call_glm()`에서 GLM 지연 원인 분석을 위한 타이밍 로그가 출력됩니다. + - `total`: 요청 시작~콘텐츠 반환까지 전체 소요 + - `http_read`: HTTP 응답 본문 수신까지 소요 + - `json_parse`: 응답 JSON 파싱 시간 +- 파이썬 에이전트 재실행 시 인벤토리 “기억”이 필요하면 `inventory_memory.json` 캐시를 사용합니다. + - `RCON`에서 인벤토리를 읽지 못하거나(비정상 출력/파싱 실패 등) 직전에 성공적으로 읽은 인벤토리를 대신 사용합니다. + - 성공적으로 인벤토리가 읽히면 해당 캐시를 갱신합니다(코드는 `inv.get_contents()`가 `nil`/비테이블을 반환해도 안전하게 `{}`로 취급). + - 또한 일부 환경에서 `p.get_main_inventory()`가 없으면 `defines.inventory.character_main`으로 한 번 더 읽습니다. + - 캐시 파일이 아직 없으면(초기 실행) fallback도 `{}`가 됩니다. +- (중요) 일부 Factorio 버전에서는 `game.table_to_json`이 없을 수 있어서, Lua 내부에 간단 JSON 인코더를 넣어 인벤토리/상태를 안정적으로 가져옵니다. +- 디버깅용으로 `INV_DEBUG=1`을 켜면, `main inventory` 외에 `cursor_stack`/`armor`/`trash` 총합도 함께 출력합니다(어디에 아이템이 있는지 확인용). + - 또한 `main inventory`가 비어 캐시 fallback이 동작하면, 로드된 캐시의 `items/total`도 함께 출력합니다. - `mine_resource`에서 실패한 채굴 타일 제외(`exclude`)는 Lua와 Python 양쪽에서 정수 타일 좌표(`tx, ty`) 키로 통일해, 제외한 좌표가 반복 선택되지 않도록 합니다. - 또한 채굴 시작(`mining_state`) 좌표는 정수 타일이 아니라, Lua가 찾은 실제 자원 엔티티의 `e.position`(정확 실수 좌표)을 사용해 “플레이어가 타일 위에 있는데도 즉시 채굴 감지 실패”를 줄입니다. - `mine_resource`는 move 후 `p.position` 근처(반경 1.2)에서 실제로 해당 광물 엔티티가 있는지 재확인하고, 없으면 채굴을 시도하지 않고 다음 후보로 넘어갑니다. +- `scan_resources()`는 패치 평균 중심(`center_x/y`)만이 아니라 플레이어 기준 가장 가까운 실제 엔티티 좌표(`anchor_x/y`)도 함께 반환하며, `summarize_for_ai()`에서는 앵커를 기준으로 거리/추천을 계산합니다. +- `mine_resource`의 초기 “후보 엔티티 탐색” 반경은 패치 중심 오차를 흡수하기 위해 250으로 확장해, 이동 직후 바로 실패하는 케이스를 줄입니다. - `explore`는 `wanted_ores`가 있으면 해당 자원이 발견될 때까지 멈추지 않고 계속 이동해, `iron-ore`처럼 주변에 흔한 자원만 계속 발견되어 진행이 막히는 문제를 줄입니다. +- (Cursor) Windows에서 `sessionStart` 훅 실행 중 앱 선택창이 뜨는 경우: + - 프로젝트 훅은 `E:/develop/factorio-ai-agent/.cursor/hooks.json` 및 `E:/develop/factorio-ai-agent/.cursor/session-start-hook.ps1`를 PowerShell로 실행하도록 구성되어 있습니다. + - Superpowers 플러그인의 `./hooks/session-start`가 bash로 실행되도록 `hooks-cursor.json`을 수정했습니다(필요 시 Cursor 재시작 후 `View -> Output -> Hooks`에서 확인). - 인벤토리 판독은 `inv.get_contents()`를 우선 사용해, 일부 환경에서 `#inv` / 인덱스 접근이 비어있음으로 오인되는 문제를 줄입니다. diff --git a/__pycache__/action_executor.cpython-311.pyc b/__pycache__/action_executor.cpython-311.pyc index 6d6d1bc94f73ef562d03c1a56cc23754cbba2252..baa10d27566ba004e83048e95afee3e2c567a96e 100644 GIT binary patch delta 700 zcmaEJn(^UjM!w~|yj%=G@crfCO#ZNqd<(?nB!FB^1zm;L-Ai6C-J$S$!^*eQmMXmI zp7XlDrBr|NIWZYV-OX>s_?g%f7(X&-b0%zNmNa5yOqgsY6(bnPbZH!Bwr1nb<`@@nfApdXgJ?OCCqyV=KlEfZ5r{N&%3r4n%)m@ml1L6Lae z1pz3U++k&J0HWOV5j@URO-LAOdKl1X!UU3b~ot`ag5LBh!K+pki@H zkV$ZI0}2l!b2T;NLT2T~)T%3~Rmf@~?2il#f=*0dz$8!v$Oe;>6YaMET`v~5w)ldv z^F@7^EBY=#9u)D%UEqk@Z0xX#Q&<`lsx2L_*KAREy|Muuj+;MuiZQW-U(gQU%;V!P z78}C&fx(TDHH7hju-FGyFzW*wgNXPCb_kn;K}>3f@|4I0A{RwfKX5_>xfrERA5rh~6H4Q>d-jU?!6c4l^h>10xae*+Du|pZkI8b^b&zULmE#8+t(ujQB$@aD;BYE@dIY7&Tcy-;dE^bAmoEixMBu_SYL`yxO!^!NNe{ zb<-AwQf;7+!kg)BZ|3%ZoHe=6xLm;+DE4Z@9EI1bR=l1#LE+7UiLbWrQFy(rdSI`hNw!Uzyu++qOc-qH#+FxGw*bAi3F?ni^A)b4bV{D{M=KF zi6!!acI0M8AAhmfFvbrIZj7v9j1PpxKCpsWAJ`Z~#6Pe@*c=REQZtmNL@p4yD60B_ z6C%jPz{THDG9kF5@(QQq2X2TU#2Bb)5Nh(;BuA%2pbbGxtci>ZgrSTb%239E(i0&U Vg2PTkUU3hH3Spx*8zgUJ0sxr$2TuS1 diff --git a/__pycache__/ai_planner.cpython-311.pyc b/__pycache__/ai_planner.cpython-311.pyc index bbb6037834c852c750e354333f054645476c72e4..87b8ae4aa44fb55a8f6236f3658939e81820116e 100644 GIT binary patch delta 3162 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 delta 2177 zcmZ`)eN0nV6u%C?E=cprB9?)MVLYW`ZBA_C8%%q4l;5M~cP_ zHy4e8UAO53#ch5~%vhQ&%d%`!G%ovRV47%}xh*cs{!kN)ZXxcE?cP^0L*1qQy>ou& z+;dLfJ?C=a9XP!on$8%FdO(*UIUX3Q8Z_m=jf3uDiwZ%+1=VdT#J6dX(56MIHr-&* z2KAr4={3VcIRgq=**r$btB58z8uP; zvI%9k0FNc^v#jM{l#Gf4a4D(D`5F$8hdFuX(-463^qX3Jsu&nilfBE#q`}r$sC!kR zu|2Ap@c;nnV*-M`lsE=Z8X2|Oo3_ug8)nCZ02z@9rK1d#iL!<`l#R?o{0@jL$T}qS zsapX8eVW~xgKra4u7?*<4yn#9(Ai>I8b5cKCu6yDp`GOA+W0xtc09Kn&Q-KLMRO2? z-3IeXkv$(uWVJotRxr#zSwT!k-+?2(+pgu}gL8XfM?Itw??{q zaNzcVv(@^CP~UB)hN%;WK9%ks>A!P)RJ#4ixx0fy)e?5HZP|*I9FXoD?Z5reLFw+X z{%T2*a1rIPi+KnXZhdf)g!3=tB{jZiRPGE%lRWnIB!zI$*Oi1aR?2XZB~pcYFLjR; zlP+mgJ!xH-ur4Idf}iY*ZyGWuvMSGNDWpD=hN^_2YQj)O?2c_~m%Q6@L>%0Fc=Lp5 zN$NAHU7FA?ozO15k#5~@isaF-7xPQ%)3{4%R&R;wQo$UrOEu2rF?6XYqr3(z#jiTb zp(s)J6j4AWkAM}TfEbhE3%QJLo>I4l_NAV~2g!Fu z;=Be%qFX9gbu$x85q2N(r6O)%SC?z`gN?1(RxbxvxB;c-1&yz-M zL!A?r#RD4#HjG)w7w!vlDyXZUqcF3yRABk^oS!4VmRL~>C#0WzWyjPVSu+|PCv!|ayEX@zUn-`ncdL6~4@f2TSjhU) zpM|w_8<{AxX|r)D6>%N8SLP!2@=~X!8wW#Sxhts(hI}ZJ6tL_=Ni~xFAtWbNkthy? z$-eTEO`24ju+vIv!oJs;I~@RdC)*MU_!L^?feVK$I>9);fQx;4+!LFtll zS-amC47TqGc3x%fKSJ{Pf{k3dg_KpS5>&btNQNrr>}iHyHry2LhkHkyXFMNzPR{#y z-gQUiHAm&xOOuY8grjCstWAiu1Hui_eq_zzJtI34qUWq9Ay$sHCd8H3#l~x5 z5Syk{oUs{BfhX~v!aR72XP$M>+Auvm{dAP4ndv{(Aj3+wEZVrLhW^5fiwYXD!6kE@ zt3l0=FEdc(vRY`+YcA_~CL1A>O^TdtZY<$1yK5U={1um~u_XT2qAe=uCND1Auj{0- zp7U_nPkvw4%KZVzs_K90{gkA~6b=TWI7DS!&j`Ij1Or`iBz52~GgBEe3EKl*2pgDQ z!l0b&U4Clrd@A-@{&PfOACAcF9g$GiEfDvvIKjid_-`w(a4<;D)fZ*-u&uioL>RnA zeyMMQZxFZF1N+De-gPiWhP^K6jE{ToL!*cK(885_<~=grSW5qE-D_MA(@DeXb(O^| z0_zdXs=zS@M=6{Gm|e`N9%2_TX29}AO3C-D4;9-~EpWs?1=#cKqi0H+g^S5sP5btw zB{l8sDCBQ%PrVU#%isePk|sI=!N9gM+RNQCZTL-$G&~|@j6)c2WzHKJv@%eNImx8Q z1wPcS=nSFmpj?YjQ3Jb6BJWd};vs}noRJJP7m;t9%U3q@uw)868}BLnga5HA$^Hdz C#2KOh diff --git a/__pycache__/context_compressor.cpython-311.pyc b/__pycache__/context_compressor.cpython-311.pyc index 4c4042767c94da9f9966b5ee4515f9d61f9c5064..f6d3d519f294662030d6e7629a3ba14e48b77ea8 100644 GIT binary patch delta 2692 zcmcImPf!zA7~i*n5CQ}hA{3N9HeeD-uwe=sMi5lA7PL+sZRO9zO|r4UuwmYAm=eX< zmO4Fj#@gq>UE6xF2ggGX?R4t22aD4~y|gyzjK>bW^n1H8gdsDY+!?;N z`~AMZzwg^6kChLP+pbhpSUI>J{&he4cFSp7EgCrO^sWz^2TOQvCl@Z6=E9a)p5v~; zpK%RZ!u&qYU7Dyw9A~A6O!k|n22m>URS`VK;vq4HBMCXAM&mLbRpN3$QbOXmy*yJN+ReIhKB_Y1_|e)Ll$%jP7ihh;#>x$oWnr_3@!(q!x00E z6vAW!lM7+Qz(^rXH854jQWz6SO%NF?R!I(93o{{y;$bN;A;uDtYr^QkU`%DKOae<; zZ8U;G)rAFvC;_YQNV3i!<1m{E=)kS52Fa-`%M)&<0z@~KVv3XtB`3y$5-A)iH<4`q zC62*(JSK^9Vb2LH%3&;|RFaSc94A%rSo?jJ%F)&%uiPwwc!BHusVu^7BJAqy4 zxw*{w+nJdYIIT^mPfg>0&R)%YbRl#8lg#u*oVj%&J$v2Yj&Wh|H=YuS%2w;>RF0(x z6m!Ibfo&X>@wgZz3ebzg@tnn?{}E{tobw6I@_A$lpUi<><1E{dLqBi}_Ko0F7iKYl zd!bXvO+{}iH*hhwST@@cBUWyl!cpB(r>Qj*mqViJ(lfyO;1TpXgsDYdm%689EXavi zEQ>?*=WU_EyGu1{%C400!sf3j;LnRB5+bH1U2x>Dv_Vd8BF3C#VA%nNdhu*PQBe3i zSk52Ia(272Uy}U7Aj(j5#Ka!0XoNDgt$8kw`4kIR~P$_qe8_i+#!Md7HZMF3CB0HV4S5=fPMi@2IpY3%N zTS}0P-oOs6-%@2-mCv>@4sE>5jqH|rw0FMp^?h&b3B1-fu($8cz`*`qI#b?(n&_|P zy)_3RuyPc{3GR1W<=G7nY>iL3#%AiQAgj*wntSWHd-dhL>rMCE2qbN_uYO@YH9|je zTND3dG2LSs(EhM>7q853i|xiF^;K_1ZYo!Au6e&u=|VHTTP>p&Dwe$Tf-80Ki6*++ zvRezUK4U6SskN@7%nLP=Mkd^}zs_HCoz=_+o=X@i! zVi!%iy3m4l$92`TwvB0C1@R>!ERzDi8#qkZ?+&t&PI)%+<%oXc5mK8#^TYxoX~K9& zw@vabVbaAcyP2LJ!3mZRAH$iO(|><`n`~hidjk_COrN=QF@5PO=~+}Q|(UPfwbB7gUG=Wkzpo6Ot654nVH=&(WeO;AEENn z`I0~&91jHoWF0N_x0X4c8ITa|^4D75g=aQ#Tab3;&k?0S2_O Aga7~l delta 1427 zcmcgrUu;uV7(d^+{Woa4trzXOE$*zfwuO~sWnIVY?kEgd5W^BP2rT4gtd(rpRy}uB zQ<3?AMiU>z&qz#$0UyT5Vj^4&JYWK9)DR)@&5ZB@CT45~7T@NJ=j%pTgh%2@&hMQ2 z`@VC|@B4G#KX%9RpY{3HF*T}h{V@Ff3n%<_(0d}56rAm1dDn54cTaQ1X4PC72ier_C-UW z;ctm&a55V3RXsX0;>0hb&EUnW(TMH$>002(;85W$(+Tp*_Iu-;Vg9=q+Ye!!abPXluA$2nI{XV-LZF05T^28BZpb0|G1zE3 z#W05{EQbGtHryYHK^i9`J@AG7W5j@`+coyp=xh8*neK?ss!ab8-`Y6+ADMN!V~N#O zJ3iDNr0`7BkP`e=;w<;QsQi|pyUbgJ?81F5%f20oNjPMiTI5bVlnge$M2t&P`^wz} z^#lzBz4%G83x2V0CTFy8D=}LXoC8VsTGWF=t|Pi#K{X2%WQx8b9nZEa(FEKG=80|$77i)yxkt)e=%HYZ%B10&v}wn z=0cQtr$}fk0-eJXstGHi63!JVCOzAWMn*tCnwdDCXZTU(14!F79d*2M8<{CRC9i_T zN*)$YU0FPJS$1PjXX|rRn*?9EE3NhxrBSR_XaKP@#+R6VrSkx+?^hN%KyVmEHVC@i zk-Y_8&z##d^Ti?4lu>LlHpg8R7Xo^-QtM?;cC45$jTQ{~mg1-htaq?%>;M=3WOV-v DXXHhq diff --git a/__pycache__/main.cpython-311.pyc b/__pycache__/main.cpython-311.pyc index 12ef78ff8a301b10a130de443612e39e7035ae45..b78da32bee2686de3e51953d7fcd79a2ac5bd035 100644 GIT binary patch delta 518 zcmdn)vfPDlIWI340}$MJaX9mc*hao!HWw8jH~P(%j#oQ2yq!5ER>9v};q8>3*9$t{ z%-!;4+7^Y^8|J*)w8lyyq=*TqveXhydAMVcpTguUR+-I5*z}nU?RSV=5V8lNi#!fj zcpNV9INT5xpI|b<$Pgv2hJ4x76?f)|8*FY@?Z;qkk`jQW$~M1Y*h)gopxMqulj8{an1iB)(zyXEzU849mgt$00oj)Lyw^CJ5-Z!!6M z-(qod^z)b1yxP3SO5qoyLa97dOxJGnK2bd;M(@dQ#nl*NHj7BuGjjP} zl<>PE;dep8Z*r_;tyt29fS3z%Nf2~VBKe9$@&$?H$sZ(T#ZoT>$6t_3g`kTPX;&oD zE=Z&SrNlN{N~th%D{5SjG?^hXLu5tT-a}ePHB9cLb1(3MKl9ZpHQzQ!F$AAd;$$J%47>hPPQV3-P0LHedX#fBK delta 539 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 Ob)41c_dbKGsq-IshciY1 diff --git a/action_executor.py b/action_executor.py index f25933e..2a2a069 100644 --- a/action_executor.py +++ b/action_executor.py @@ -109,9 +109,9 @@ if ok then rcon.print(data) else rcon.print("ERROR") end 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}) — 다른 방향 시도" + return False, f"장애물에 막힘 ({pos_str}) - 다른 방향 시도" self.rcon.lua(P + "p.walking_state = {walking = false, direction = defines.direction.north}") - return False, f"{direction} 방향 자원 미발견 — 다른 방향 시도" + return False, f"{direction} 방향 자원 미발견 - 다른 방향 시도" # ── 이동 ───────────────────────────────────────────────────────── def move(self, x: int, y: int) -> tuple[bool, str]: @@ -175,7 +175,7 @@ rcon.print("WALK:" .. string.format("%.1f", dist)) find_result = self.rcon.lua(P + f""" {exclude_lua} -local res = p.surface.find_entities_filtered{{position = p.position, radius = 80, name = "{ore}"}} +local res = p.surface.find_entities_filtered{{position = p.position, radius = 250, name = "{ore}"}} if #res == 0 then rcon.print("NOT_FOUND") return end -- 거리순 정렬 local pos = p.position @@ -204,11 +204,11 @@ rcon.print("ALL_EXCLUDED") if find_result == "NOT_FOUND": if total_mined > 0: return True, f"{ore} {total_mined}개 채굴 (주변 광석 소진)" - return False, f"반경 80 내 {ore} 없음 — explore로 다른 광맥 찾기" + return False, f"반경 250 내 {ore} 없음 - explore로 다른 광맥 찾기" if find_result == "ALL_EXCLUDED": if total_mined > 0: return True, f"{ore} {total_mined}개 채굴 (접근 가능 타일 모두 시도)" - return False, f"{ore} 근처 타일 {len(failed_positions)}개 모두 접근 불가 — 다른 위치로 이동 필요" + return False, f"{ore} 근처 타일 {len(failed_positions)}개 모두 접근 불가 - 다른 위치로 이동 필요" try: find_result = find_result.strip() @@ -290,7 +290,7 @@ p.mining_state = {{mining = true, position = {{x = {mine_x}, y = {mine_y}}}}} if not mined_this_tile: # 이 타일에서 한 개도 못 캤음 → 접근 불가 failed_positions.add((ox, oy)) - print(f" [채굴] ({ox:.0f},{oy:.0f}) 접근 불가 → 제외 목록 추가 (총 {len(failed_positions)}개)") + print(f" [채굴] ({ox:.0f},{oy:.0f}) 접근 불가 -> 제외 목록 추가 (총 {len(failed_positions)}개)") else: # 채굴 됐지만 타일 고갈 print(f" [채굴] ({ox:.0f},{oy:.0f}) 고갈/중단. 현재 {total_mined}개 채굴됨") @@ -302,7 +302,7 @@ p.mining_state = {{mining = true, position = {{x = {mine_x}, y = {mine_y}}}}} # 모든 라운드 후 if total_mined > 0: return True, f"{ore} {total_mined}개 채굴 (목표 {count}개 중 일부)" - return False, f"{ore} 채굴 실패 — {len(failed_positions)}개 타일 접근 불가" + return False, f"{ore} 채굴 실패 - {len(failed_positions)}개 타일 접근 불가" # ── 제작/배치/삽입/레시피/연구/대기 ────────────────────────────── def craft_item(self, item: str, count: int = 1) -> tuple[bool, str]: @@ -341,7 +341,7 @@ if built then rcon.print("OK") else 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 == "NO_ITEM": return False, f"인벤토리에 {name} 없음" - elif result.startswith("TOO_FAR"): return False, f"너무 멀음 — move 먼저" + elif result.startswith("TOO_FAR"): return False, f"너무 멀음 - move 먼저" elif result == "BLOCKED": return False, f"배치 불가" return False, f"배치 실패: {result}" diff --git a/agent_log.jsonl b/agent_log.jsonl index 7ea0c07..e69de29 100644 --- a/agent_log.jsonl +++ b/agent_log.jsonl @@ -1,66 +0,0 @@ -{"step": 1, "action": {"action": "move", "params": {"x": 10, "y": 0}, "reason": "시야를 확보하여 인근 자원 패치를 찾기 위해 이동"}, "success": false, "message": "(10,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774414751.1273699} -{"step": 2, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "위치 정보가 불확실하므로 원점(0,0)으로 이동하여 스폰 지점을 기준으로 잡음"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774414896.2017908} -{"step": 3, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "시작 지점으로 이동하여 주변 상황 파악"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774414953.6023395} -{"step": 4, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "원점 방향으로 이동 재시도로 위치 복구 시도"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415087.8350036} -{"step": 5, "action": {"action": "move", "params": {"x": 1, "y": 1}, "reason": "현재 위치에서 아주 가까운 거리로 이동 시도. 이동이 성공하면 좌표계를 잡고 주변 자원 탐색 가능"}, "success": false, "message": "(1,1)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415305.2186906} -{"step": 6, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "스폰 지점 presumed origin으로 이동하여 주변 상태 파악"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415495.6019366} -{"step": 7, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "좌표 (0,0)으로 이동하여 맵 중심에 접근 시도"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415568.9355204} -{"step": 8, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 5}, "reason": "현재 위치에서 돌 채굴 시도 (제련소 제작용)"}, "success": false, "message": "실행 오류: could not convert string to float: ''", "timestamp": 1774415586.6352522} -{"step": 9, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "시작 지점인 (0,0)으로 이동하여 위치를 확정 짓고 주변 자원을 스캔함"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415648.0026102} -{"step": 10, "action": {"action": "move", "params": {"x": 10, "y": 10}, "reason": "기존 (0,0) 이동 실패로 인해 다른 좌표(10,10)로 이동 시도하여 캐릭터 위치를 갱신하고 자원 탐색 범위를 넓힘"}, "success": false, "message": "(10,10)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415715.33529} -{"step": 11, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "맵의 중심 지점으로 이동하여 자원을 찾기 위함"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415784.335003} -{"step": 12, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "가장 기준이 되는 좌표 (0,0)으로 이동 시도. 자원 탐색의 시작점."}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774415975.2185514} -{"step": 13, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "좌표계 초기화 및 위치 확인 시도"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416115.7352495} -{"step": 14, "action": {"action": "move", "params": {"x": 100, "y": 100}, "reason": "초기 좌표 이동 실패로 더 넓은 범위 탐색을 위해 이동"}, "success": false, "message": "(100,100)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416251.0025642} -{"step": 15, "action": {"action": "move", "params": {"x": 0, "y": 10}, "reason": "현재 위치에서 짧게 이동하여 유효한 영역 탐색 및 위치 갱신 시도"}, "success": false, "message": "(0,10)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416491.285967} -{"step": 16, "action": {"action": "move", "params": {"x": 2, "y": 2}, "reason": "초기 좌표 이동 실패 후, 근거리 좌표(2,2)로 이동을 시도하여 맵 로딩 및 주변 지형 확인"}, "success": false, "message": "(2,2)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416643.3522403} -{"step": 17, "action": {"action": "move", "params": {"x": 1, "y": 0}, "reason": "현재 위치에서 아주 가까운 북쪽(혹은 x축 방향)으로 1칸 이동 시도하여 이동 시스템 확인"}, "success": false, "message": "(1,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416754.5350137} -{"step": 18, "action": {"action": "move", "params": {"x": 1, "y": 1}, "reason": "좌표 (1,1)로 이동 시도. 위치를 확보하기 위함."}, "success": false, "message": "(1,1)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416850.6186059} -{"step": 19, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "좌표계 기준점인 (0,0)으로 이동하여 위치를 파악"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774416972.7515874} -{"step": 20, "action": {"action": "move", "params": {"x": 5, "y": 5}, "reason": "이전 이동들이 모두 실패했으므로, 시작 지점에서 가까운 거리(5,5)로 이동 시도하여 유효한 좌표를 찾고 자원을 탐색"}, "success": false, "message": "(5,5)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417045.2688239} -{"step": 21, "action": {"action": "move", "params": {"x": 0, "y": 200}, "reason": "현재 위치에서 자원을 찾기 위해 북쪽(또는 임의의 방향)으로 길게 이동하여 자원 패치가 있는지 확인."}, "success": false, "message": "(0,200)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417246.6684408} -{"step": 22, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "스폰 지점으로 이동하여 주변 환경 및 자원 패치 확인"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417361.6020026} -{"step": 23, "action": {"action": "wait", "params": {"seconds": 2}, "reason": "게임 로딩 및 플레이어 스폰 대기"}, "success": true, "message": "2초 대기 완료", "timestamp": 1774417381.3811822} -{"step": 24, "action": {"action": "wait", "params": {"seconds": 1}, "reason": "시스템 안정화 및 짧은 대기 후 이동 재시도"}, "success": true, "message": "1초 대기 완료", "timestamp": 1774417415.6203752} -{"step": 25, "action": {"action": "move", "params": {"x": 50, "y": 50}, "reason": "이전 좌표 이동이 실패하여 더 먼 거리나 다른 방향 시도"}, "success": false, "message": "(50,50)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417462.5017407} -{"step": 26, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "원점(0,0)으로 이동 시도하여 기준 위치 확보 및 맵 로딩 유도"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417539.952161} -{"step": 27, "action": {"action": "move", "params": {"x": 10, "y": 10}, "reason": "현재 위치가 불명확하고 멀리 이동이 불가능하므로, 비교적 가까운 곳으로 이동 시도하여 맵 로딩 및 위치 확인"}, "success": false, "message": "(10,10)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417669.6687183} -{"step": 28, "action": {"action": "move", "params": {"x": 500, "y": 500}, "reason": "이전에 (0,0), (50,50) 등으로 이동이 실패했으므로, 지도의 다른 지점(500,500)으로 이동을 시도하여 자원 패치를 찾음"}, "success": false, "message": "(500,500)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417739.6016743} -{"step": 29, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "플레이어 위치를 초기화하거나 기준점(0,0)으로 이동하여 맵 진입 여부 확인"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774417812.2184923} -{"step": 30, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "기준점인 (0,0)으로 이동하여 현재 좌표 확보 및 근처 자원 확인 시도"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774418211.5518975} -{"step": 31, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "맵의 원점(0,0)으로 이동하여 자원 탐색 및 채굴 준비"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774418376.3684852} -{"step": 32, "action": {"action": "wait", "params": {"seconds": 1}, "reason": "게임 로딩 안정화 및 상태 갱신 대기"}, "success": true, "message": "1초 대기 완료", "timestamp": 1774418395.778815} -{"step": 33, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 5}, "reason": "현재 위치에 돌이 있을 경우 채굴하여 제련소 제작 시도. 없으면 실패하겠지만 위치 확인용"}, "success": false, "message": "실행 오류: could not convert string to float: ''", "timestamp": 1774418395.985176} -{"step": 34, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "초기 스폰 지점(0,0)으로 이동하여 자원 탐색 준비"}, "success": false, "message": "(0,0)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774418485.3684797} -{"step": 35, "action": {"action": "wait", "params": {"seconds": 1}, "reason": "상태 갱신 대기"}, "success": true, "message": "1초 대기 완료", "timestamp": 1774418586.4002311} -{"step": 36, "action": {"action": "move", "params": {"x": 50, "y": 50}, "reason": "임의의 위치로 이동하여 자원 패치 탐색"}, "success": false, "message": "(50,50)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774418633.2684767} -{"step": 37, "action": {"action": "move", "params": {"x": 10, "y": 10}, "reason": "현재 위치에서 근처로 이동하여 자원 탐색 시도"}, "success": false, "message": "(10,10)에 도달 못함 (시간 초과, 남은 거리: 99999)", "timestamp": 1774418744.651644} -{"step": 1, "action": {"action": "move", "params": {"x": 20, "y": 20}, "reason": "주변 지형을 탐색하여 자원 광맥을 찾기 위해 이동"}, "success": true, "message": "(20, 20)로 도착", "timestamp": 1774436597.435362} -{"step": 2, "action": {"action": "move", "params": {"x": 25, "y": 25}, "reason": "자원 패치를 찾기 위해 이동하여 시야 확보"}, "success": true, "message": "(25, 25)로 도착", "timestamp": 1774436659.384502} -{"step": 3, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 5}, "reason": "stone-furnace 제작에 필요한 기반 재료 확보"}, "success": false, "message": "근처에 stone 없음 — 자원 패치로 move 먼저", "timestamp": 1774436659.6530797} -{"step": 4, "action": {"action": "move", "params": {"x": 35, "y": 35}, "reason": "자원 탐색을 위해 현재 위치에서 떨어진 지점으로 이동"}, "success": true, "message": "(35, 35)로 도착", "timestamp": 1774436700.2356887} -{"step": 5, "action": {"action": "move", "params": {"x": 45, "y": 45}, "reason": "이전 구역에 자원이 없어 새로운 구역 탐색을 위해 이동"}, "success": false, "message": "(45,45) 이동 중 장애물에 막힘 (남은 거리: 4)", "timestamp": 1774436738.584099} -{"step": 6, "action": {"action": "move", "params": {"x": 46, "y": 46}, "reason": "장애물 피해가며 탐색 시도"}, "success": false, "message": "(46,46) 이동 중 장애물에 막힘 (남은 거리: 5)", "timestamp": 1774436764.9175665} -{"step": 7, "action": {"action": "move", "params": {"x": 50, "y": 40}, "reason": "장애물이 있는 (46,46) 방향을 피해 우측 상단 방향으로 이동하여 자원 탐색"}, "success": false, "message": "(50,40) 이동 중 장애물에 막힘 (남은 거리: 7)", "timestamp": 1774436788.6009731} -{"step": 8, "action": {"action": "move", "params": {"x": 15, "y": 35}, "reason": "동쪽이 막혔으므로 서쪽으로 이동하여 새로운 지역 탐색"}, "success": true, "message": "(15, 35)로 도착", "timestamp": 1774436826.3850791} -{"step": 9, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 5}, "reason": "이동한 위치에서 stone 자원이 있는지 확인 후 채굴 시도 (없을 시 탐색 피드백 활용)"}, "success": false, "message": "근처에 stone 없음 — 자원 패치로 move 먼저", "timestamp": 1774436826.651067} -{"step": 10, "action": {"action": "move", "params": {"x": 5, "y": 25}, "reason": "새로운 지역 탐색을 위해 이동"}, "success": true, "message": "(5, 25)로 도착", "timestamp": 1774436854.2343464} -{"step": 11, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 10}, "reason": "제련소 제작을 위해 stone 채굴 시도"}, "success": false, "message": "근처에 stone 없음 — 자원 패치로 move 먼저", "timestamp": 1774436854.5009618} -{"step": 12, "action": {"action": "move", "params": {"x": 10, "y": 20}, "reason": "현재 (5,25) 근처에 자원이 없어 인근 좌표(10,20)로 이동하여 자원 탐색 시도"}, "success": true, "message": "(10, 20)로 도착", "timestamp": 1774436876.1179383} -{"step": 13, "action": {"action": "move", "params": {"x": 20, "y": 20}, "reason": "이전 위치 (10, 20) 근처에 자원이 없어 새로운 구간인 (20, 20)으로 탐색 이동"}, "success": true, "message": "(20, 20)로 도착", "timestamp": 1774436914.1352172} -{"step": 14, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 5}, "reason": "제련소 제작을 위해 석재 5개 채굴 시도 (해당 위치에 없으면 다음 단계에서 이동)"}, "success": false, "message": "근처에 stone 없음 — 자원 패치로 move 먼저", "timestamp": 1774436914.4015982} -{"step": 15, "action": {"action": "move", "params": {"x": 30, "y": 30}, "reason": "자원이 없는 기존 구역을 벗어나 새로운 지역을 탐색하기 위해 이동"}, "success": true, "message": "(30, 30)로 도착", "timestamp": 1774436961.2677045} -{"step": 16, "action": {"action": "mine_resource", "params": {"ore": "stone", "count": 5}, "reason": "도착한 지점에서 Stone 자원이 있는지 확인하고 채굴하여 초기 제련소 건설 재료 확보"}, "success": false, "message": "근처에 stone 없음 — 자원 패치로 move 먼저", "timestamp": 1774436961.534328} -{"step": 17, "action": {"action": "move", "params": {"x": 35, "y": 35}, "reason": "자원이 없는 지역을 벗어나 새로운 구역 탐색"}, "success": true, "message": "(35, 35)로 도착", "timestamp": 1774436982.734539} -{"step": 18, "action": {"action": "move", "params": {"x": 20, "y": 20}, "reason": "이미 지나왔던 지점을 제외하고 다른 방향 탐색을 위해 서쪽으로 이동"}, "success": true, "message": "(20, 20)로 도착", "timestamp": 1774437002.8348992} -{"step": 19, "action": {"action": "mine_resource", "params": {"ore": "iron-ore", "count": 50}, "reason": "철광석 패치를 발견하면 제련 및 초기 기계 제작을 위해 넉넉하게 채굴"}, "success": false, "message": "근처에 iron-ore 없음 — 자원 패치로 move 먼저", "timestamp": 1774437003.1019325} -{"step": 1, "action": {"action": "move", "params": {"x": 50, "y": 50}, "reason": "자원을 찾기 위해 우측 하단 방향으로 탐색 이동"}, "success": false, "message": "(50,50) 이동 중 장애물에 막힘 (남은 거리: 11)", "timestamp": 1774437057.7680883} -{"step": 2, "action": {"action": "move", "params": {"x": 100, "y": 0}, "reason": "이전 이동 실패 좌표에서 벗어나 동쪽 방향으로 장거리 이동하여 자원 탐색"}, "success": false, "message": "(100,0) 이동 중 장애물에 막힘 (남은 거리: 70)", "timestamp": 1774437089.135179} -{"step": 3, "action": {"action": "move", "params": {"x": -200, "y": 0}, "reason": "동쪽 탐색 실패 후, 서쪽으로 장거리 이동하여 육지나 자원을 찾기 위함"}, "success": false, "message": "(-200,0) 이동 중 장애물에 막힘 (남은 거리: 4)", "timestamp": 1774437160.6009545} -{"step": 4, "action": {"action": "move", "params": {"x": 0, "y": 600}, "reason": "반경 500타일 내에 자원이 없어 더 멀리 떨어진 곳으로 이동해야 함. 북쪽 방향이 비교적 개방적일 것으로 판단됨."}, "success": false, "message": "(0,600) 이동 중 장애물에 막힘 (남은 거리: 593)", "timestamp": 1774437213.5008812} -{"step": 5, "action": {"action": "move", "params": {"x": -100, "y": -100}, "reason": "서쪽으로는 비교적 멀리 이동했으므로, 남서쪽 방향으로 우회하여 자원을 탐색"}, "success": true, "message": "(-100, -100)로 도착", "timestamp": 1774437284.0680652} -{"step": 1, "action": {"action": "move", "params": {"x": 550, "y": 0}, "reason": "반경 500타일 내에 자원이 없으므로, 동쪽으로 이동하여 자원을 찾기 위해 빈 구역을 벗어남"}, "success": false, "message": "(550,0) 이동 중 장애물에 막힘 (남은 거리: 466)", "timestamp": 1774437461.3510807} -{"step": 2, "action": {"action": "move", "params": {"x": 0, "y": 0}, "reason": "시작 지점(0,0)으로 복귀하여 위치를 초기화하고 주변을 재탐색"}, "success": true, "message": "(0, 0)로 도착", "timestamp": 1774437533.7178357} -{"step": 3, "action": {"action": "move", "params": {"x": 1000, "y": 1000}, "reason": "남동쪽 방향으로 장거리 이동하여 자원 탐색"}, "success": false, "message": "(1000,1000) 이동 중 장애물에 막힘 (남은 거리: 1354)", "timestamp": 1774437561.450848} -{"step": 1, "action": {"action": "move", "params": {"x": 800, "y": 0}, "reason": "주변 500타일 내에 자원이 없으므로, 동쪽으로 이동하여 자원 탐색 범위를 넓힙니다."}, "success": false, "message": "플레이어 캐릭터가 없습니다 (사망했거나 생성 전).", "timestamp": 1774437849.8178098} -{"step": 2, "action": {"action": "move", "params": {"x": 1000, "y": 0}, "reason": "반경 500타일 내 자원이 없으므로 탐색 범위를 벗어나기 위해 장거리 이동"}, "success": false, "message": "(1000,0) 이동 중 장애물에 막힘 (남은 거리: 612)", "timestamp": 1774438061.2343192} diff --git a/ai_planner.py b/ai_planner.py index 0d62000..116cb16 100644 --- a/ai_planner.py +++ b/ai_planner.py @@ -15,6 +15,7 @@ JSON 파싱 강화: import json import os import re +import time import urllib.request import urllib.error @@ -145,7 +146,7 @@ class AIPlanner: for attempt in range(3): try: - raw = self._call_glm(user_message) + raw = self._call_glm(user_message, attempt=attempt) plan = self._parse_json(raw) break except (ValueError, json.JSONDecodeError) as e: @@ -164,13 +165,13 @@ class AIPlanner: thinking = plan.get("thinking", "") if thinking: - print(f"\n🧠 AI 판단:\n{thinking}\n") + print(f"\n[AI] 판단:\n{thinking}\n") - print(f"🎯 현재 목표: {plan.get('current_goal', '')}") - print(f"📋 완료 후: {plan.get('after_this', '')}") + print(f"[AI] 현재 목표: {plan.get('current_goal', '')}") + print(f"[AI] 완료 후: {plan.get('after_this', '')}") actions = plan.get("actions", []) - print(f"⚡ {len(actions)}개 행동 계획됨") + print(f"[AI] {len(actions)}개 행동 계획됨") return actions def record_feedback(self, action: dict, success: bool, message: str = ""): @@ -188,7 +189,7 @@ class AIPlanner: return "" lines = ["### 이전 행동 결과 (성공/실패)\n"] for fb in self.feedback_log[-8:]: - status = "✅" if fb["success"] else "❌" + status = "OK" if fb["success"] else "FAIL" msg = f" — {fb['message']}" if fb["message"] else "" lines.append( f" {status} {fb['action']} " @@ -196,7 +197,7 @@ class AIPlanner: ) return "\n".join(lines) + "\n\n" - def _call_glm(self, user_message: str) -> str: + def _call_glm(self, user_message: str, attempt: int) -> str: payload = json.dumps({ "model": GLM_MODEL, "messages": [ @@ -207,6 +208,10 @@ class AIPlanner: "max_tokens": 2000, }).encode("utf-8") + prompt_chars = len(user_message) + system_chars = len(SYSTEM_PROMPT) + max_tokens = 2000 + req = urllib.request.Request( GLM_API_URL, data = payload, @@ -217,9 +222,36 @@ class AIPlanner: method = "POST", ) try: + t_total0 = time.perf_counter() + t_payload0 = time.perf_counter() + # payload 직렬화 직후(대략)부터 타임라인 측정 + _t0 = time.perf_counter() with urllib.request.urlopen(req, timeout=90) as resp: - data = json.loads(resp.read().decode("utf-8")) - return data["choices"][0]["message"]["content"].strip() + raw_text = resp.read().decode("utf-8") + t_read_done = time.perf_counter() + + t_json0 = time.perf_counter() + data = json.loads(raw_text) + content = data["choices"][0]["message"]["content"].strip() + t_json_done = time.perf_counter() + + dt_total = time.perf_counter() - t_total0 + dt_payload = t_payload0 - t_total0 + dt_read = t_read_done - _t0 + dt_json = t_json_done - t_json0 + + print( + "[GLM] 타이밍 | " + f"attempt {attempt+1}/3 | " + f"total {dt_total:.2f}s | " + f"http_read {dt_read:.2f}s | " + f"json_parse {dt_json:.2f}s | " + f"prompt_chars {prompt_chars} | " + f"system_chars {system_chars} | " + f"max_tokens {max_tokens} | " + f"resp_chars {len(raw_text)}" + ) + return content except urllib.error.HTTPError as e: raise ConnectionError(f"GLM API 오류 {e.code}: {e.read().decode()}") diff --git a/context_compressor.py b/context_compressor.py index b5a2518..7ccff89 100644 --- a/context_compressor.py +++ b/context_compressor.py @@ -15,6 +15,56 @@ ZONE_SIZE = 64 P = 'local p = game.players[1] if not p then rcon.print("{}") return end ' +# Factorio 버전에 따라 game.table_to_json 이 없을 수 있어, +# Lua 내부에서 간단한 JSON 인코더를 제공한다. +JSON_HELPER_LUA = r""" +local function json_escape(s) + s = tostring(s) + s = s:gsub("\\", "\\\\") + s = s:gsub('"', '\\"') + s = s:gsub('\b', '\\b') + s = s:gsub('\f', '\\f') + s = s:gsub('\n', '\\n') + s = s:gsub('\r', '\\r') + s = s:gsub('\t', '\\t') + return s +end + +local function json_encode_value(v) + local t = type(v) + if t == "string" then + return '"' .. json_escape(v) .. '"' + elseif t == "number" then + return tostring(v) + elseif t == "boolean" then + return v and "true" or "false" + elseif t == "table" then + -- object encoding (배열은 별도 함수에서 처리) + local parts = {} + for k, val in pairs(v) do + local key = '"' .. json_escape(k) .. '"' + parts[#parts + 1] = key .. ":" .. json_encode_value(val) + end + return "{" .. table.concat(parts, ",") .. "}" + else + return "null" + end +end + +local function json_encode_object(t) + return json_encode_value(t) +end + +local function json_encode_array(arr) + local parts = {} + local n = #arr + for i = 1, n do + parts[#parts + 1] = json_encode_value(arr[i]) + end + return "[" .. table.concat(parts, ",") .. "]" +end +""" + class ContextCompressor: def __init__(self, rcon: FactorioRCON): @@ -37,7 +87,7 @@ class ContextCompressor: return self._format_level2(global_summary, zones, problems, drilldown, player) def _get_global_summary(self) -> dict: - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local surface = p.surface local force = p.force @@ -60,7 +110,7 @@ local ok, err = pcall(function() for _, t in pairs(force.technologies) do if t.researched then researched = researched + 1 end end - rcon.print(game.table_to_json({ + rcon.print(json_encode_object({ total_entities = total, counts = result, current_research = current_tech, @@ -78,7 +128,7 @@ if not ok then rcon.print("{}") end return {} def _get_zone_summaries(self) -> list[dict]: - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local surface = p.surface local Z = 64 @@ -109,7 +159,7 @@ local ok, err = pcall(function() for _, z in pairs(zones) do if z.entities > 2 then result[#result+1] = z end end - rcon.print(game.table_to_json(result)) + rcon.print(json_encode_array(result)) end) if not ok then rcon.print("[]") end """ @@ -120,7 +170,7 @@ if not ok then rcon.print("[]") end return [] def _detect_problems(self) -> list[str]: - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local surface = p.surface local problems = {} @@ -140,7 +190,7 @@ local ok, err = pcall(function() if #ore > 0 and ore[1].amount < 5000 then depleting = depleting + 1 end end if depleting > 0 then problems[#problems+1] = "자원 고갈 임박: " .. depleting .. "개" end - rcon.print(game.table_to_json(problems)) + rcon.print(json_encode_array(problems)) end) if not ok then rcon.print("[]") end """ @@ -164,7 +214,7 @@ if not ok then rcon.print("[]") end return "\n".join(lines) def _get_player_info(self) -> dict: - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local inv = p.get_main_inventory() if not inv then rcon.print("{}") return end @@ -181,7 +231,7 @@ local ok, err = pcall(function() end end end - rcon.print(game.table_to_json({ + rcon.print(json_encode_object({ x = math.floor(p.position.x), y = math.floor(p.position.y), inventory = inv_summary diff --git a/docs/plan.md b/docs/plan.md index ca5d063..821720a 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -22,3 +22,93 @@ ### README 업데이트 계획 - 채굴 제외(exclude) 로직이 “정수 타일 키 기반으로 통일”되도록 README의 기술/동작 설명(또는 체크리스트)을 업데이트합니다. +--- + +## 자원 패치 중심 오차로 인한 채굴 실패 완화 계획 + +### 문제 관찰 +- `scan_resources()`가 패치의 평균 중심(`center_x/y`)을 추천 좌표로 사용하면서, + 플레이어가 해당 좌표로 이동한 직후 `mine_resource`의 엔티티 탐색 반경 안에서 + 실제 자원 엔티티를 찾지 못해 실패하는 케이스가 발생할 수 있음. + +### 근거(팩토리오 Lua API) +- `LuaInventory.get_contents()`는 `dictionary[string -> uint]`를 반환하므로, + 인벤토리 판독은 `name -> count` 형태로 처리하는 것이 맞다. +- 자원 엔티티의 `e.position`은 부동소수(MapPosition) 좌표이므로, + 타일 중심/평균 중심과 실제 엔티티 좌표 간 오차가 생길 수 있다. + +### 변경 목표 +1. `scan_resources()` 결과에 패치 대표 좌표를 평균 중심뿐 아니라 + 플레이어 기준 가장 가까운 실제 엔티티 좌표(앵커 `anchor_x/y` 및 `anchor_tile_x/y`)로 함께 제공 +2. `summarize_for_ai()`에서는 평균 중심 대신 앵커 좌표 기반으로 거리/추천을 계산 +3. `mine_resource` 후보 엔티티 탐색 반경을 더 크게 잡아(행동 레벨) 이동 직후 실패를 흡수 + +### 구현 범위 +- `state_reader.py` + - `scan_resources()`에 `anchor_x/y` 및 `anchor_tile_x/y` 추가 + - `summarize_for_ai()` 거리 계산을 앵커 우선으로 변경 +- `action_executor.py` + - `mine_resource`에서 후보 엔티티 탐색 반경을 `80` → `250`으로 확장 + +--- + +## GLM 응답 지연 원인 계측/완화 계획 + +### 문제 관찰 +- `ai_planner.py`에서 GLM 호출이 `[GLM] 생각 중...` 이후 30초~1분 이상 지연되는 현상 발생 +- JSON 파싱 실패로 인한 재시도는 가끔 발생(하지만 느림의 주 원인이 아닐 가능성도 있음) + +### 1단계(증거 수집) +- `ai_planner.py`의 `_call_glm()`에 타이밍 로그 추가 + - `total`: 요청 시작~콘텐츠 반환까지 전체 소요 + - `http_read`: HTTP 응답 본문 수신까지 소요 + - `json_parse`: 응답 JSON 파싱 시간 + - `prompt_chars/system_chars/max_tokens`: 입력 크기 동시 기록 + +### 2단계(완화: 로그 확인 후) +- `json_parse`가 아니라 `http_read/total`이 큰 경우: + - `max_tokens`를 우선 크게 줄여 생성 길이 상한 조정 + - `SYSTEM_PROMPT`/`state_summary` 길이를 더 강하게 제한 +- `json_parse`가 큰 경우: + - JSON 파싱/복구 로직 비용 또는 응답 형식 편차 원인 재점검 + +### README 업데이트 +- `README.md`의 `주의사항`/`실행` 섹션에 GLM 지연 계측 로그 출력 방식 및 파라미터 조정 힌트를 반영 + +--- +## Cursor Hooks - Windows `sessionStart` 앱 선택창 원인/해결 계획 +### 문제 재현/관찰 +- Cursor `@cursor.hooks` 로그에서 `sessionStart` 훅 실행 중 `./hooks/session-start`가 stdout JSON을 내지 못하거나, Windows에서 앱 선택창이 뜨는 현상이 관찰됨 + +### 원인 후보 +- Windows에서 확장자 없는 `./hooks/session-start`를 실행할 때 `bash`로 해석되지 않아, OS가 실행 대신 “어떤 앱으로 열지”를 요청할 수 있음 + +### 변경 목표 +1. 프로젝트 훅은 `powershell`로 stdout에 JSON을 출력하도록 고정 +2. superpowers 플러그인의 Cursor 훅은 `bash`로 감싸 실행되도록 구성 변경 + +### 구현/검증 범위 +- `E:/develop/factorio-ai-agent/.cursor/hooks.json` +- `E:/develop/factorio-ai-agent/.cursor/session-start-hook.ps1` +- `.../superpowers/.../hooks/hooks-cursor.json` +- 검증: Cursor 재시작 후 `View -> Output -> Hooks`에서 sessionStart 훅이 정상 파싱되어 `OUTPUT`에 JSON이 나타나는지 확인 + +--- + +## 인벤토리 캐시(메모리) 추가 계획 + +### 목표 +- 파이썬 에이전트만 종료/재실행할 때 `RCON`으로 인벤토리를 읽지 못하거나(`{}` 반환) 빈 값이 나오면, + 직전에 성공적으로 읽은 인벤토리를 기억해서 프롬프트에 반영한다. + +### 정책(사용자 선택: 1번 fallback only) +- `get_inventory()`가 성공적으로 읽어서 값이 비어있지 않으면 캐시를 갱신한다. +- `get_inventory()` 결과가 빈 딕셔너리이면(실패/빈 값) 캐시 파일을 로드해서 대체한다. + +### 구현 범위 +- `state_reader.py` + - `inventory_memory.json` 로드/저장 유틸 추가 + - `get_inventory()` 반환값을 캐시 fallback으로 교체 +- `README.md` + - 인벤토리 캐시 동작과 파일명(`inventory_memory.json`)을 설명 + diff --git a/main.py b/main.py index 7dad349..2468c69 100644 --- a/main.py +++ b/main.py @@ -41,11 +41,11 @@ if p then rcon.print(p.name .. " @ " .. string.format("%.0f, %.0f", p.position.x, p.position.y)) end """) - print(f"[초기화] ✅ 플레이어 발견: {info}") + print(f"[초기화] OK 플레이어 발견: {info}") return True if attempt == 0: - print("[초기화] ⚠️ 접속한 플레이어가 없습니다!") + print("[초기화] WARN 접속한 플레이어가 없습니다!") print("[초기화] 팩토리오 클라이언트로 이 서버에 접속하세요.") print(f"[초기화] 서버 주소: {RCON_HOST}") print(f"[초기화] 대기 중... (최대 30초)") @@ -86,7 +86,7 @@ def run(): # ── 압축 레벨 자동 선택 ── if entity_count < COMPRESS_THRESHOLD: - print("[상태] 초반 모드 — 상세 상태 수집") + print("[상태] 초반 모드 - 상세 상태 수집") state = reader.get_full_state() summary = reader.summarize_for_ai(state) entity_count = sum( @@ -94,10 +94,10 @@ def run(): if isinstance(v, int) ) elif entity_count < 200: - print(f"[상태] 중반 모드 — 구역 압축 (건물 {entity_count}개)") + print(f"[상태] 중반 모드 - 구역 압축 (건물 {entity_count}개)") summary = compressor.get_compressed_state(detail_level=1) else: - print(f"[상태] 후반 모드 — 글로벌 압축 (건물 {entity_count}개)") + print(f"[상태] 후반 모드 - 글로벌 압축 (건물 {entity_count}개)") summary = compressor.get_compressed_state(detail_level=0) global_info = compressor._get_global_summary() @@ -121,13 +121,13 @@ def run(): print(f" params: {json.dumps(action.get('params',{}), ensure_ascii=False)}") success, message = executor.execute(action) - status = "✅" if success else "❌" + status = "OK" if success else "FAIL" print(f" 결과: {status} {message}") planner.record_feedback(action, success, message) if not success: - print(" → 실패. 재계획 요청") + print(" -> 실패. 재계획 요청") queue.clear() _log(total_actions, action, success, message) diff --git a/state_reader.py b/state_reader.py index ca455a8..1c21d2b 100644 --- a/state_reader.py +++ b/state_reader.py @@ -9,15 +9,133 @@ RCON을 통해 팩토리오 게임 상태를 읽어오는 모듈 - 모든 Python 파싱 try/except 감싸기 """ import json +import os from factorio_rcon import FactorioRCON P = 'local p = game.players[1] if not p then rcon.print("{}") return end ' +INVENTORY_CACHE_FILE = "inventory_memory.json" +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Factorio 버전에 따라 game.table_to_json 이 없을 수 있어, +# Lua 내부에서 간단한 JSON 인코더를 제공한다. +JSON_HELPER_LUA = r""" +local function json_escape(s) + s = tostring(s) + s = s:gsub("\\", "\\\\") + s = s:gsub('"', '\\"') + s = s:gsub('\b', '\\b') + s = s:gsub('\f', '\\f') + s = s:gsub('\n', '\\n') + s = s:gsub('\r', '\\r') + s = s:gsub('\t', '\\t') + return s +end + +local function json_encode_value(v) + local t = type(v) + if t == "string" then + return '"' .. json_escape(v) .. '"' + elseif t == "number" then + return tostring(v) + elseif t == "boolean" then + return v and "true" or "false" + elseif t == "table" then + local parts = {} + for k, val in pairs(v) do + local key = '"' .. json_escape(k) .. '"' + parts[#parts + 1] = key .. ":" .. json_encode_value(val) + end + return "{" .. table.concat(parts, ",") .. "}" + else + return "null" + end +end + +local function json_encode_object(t) + return json_encode_value(t) +end +""" + + +def _load_inventory_cache(cache_path: str) -> dict: + """로컬에 저장된 마지막 인벤토리 캐시를 로드한다.""" + try: + if not os.path.exists(cache_path): + return {} + with open(cache_path, "r", encoding="utf-8") as f: + raw = f.read().strip() + if not raw: + return {} + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except Exception: + # 캐시는 진짜 동작을 막으면 안 되므로 실패해도 {} 로 폴백 + return {} + + +def _save_inventory_cache(cache_path: str, inv: dict) -> None: + """성공적으로 읽은 인벤토리를 캐시에 저장한다.""" + try: + # 게임 아이템 목록이라도 최소한의 정합성만 보장 (숫자 값 위주) + if not isinstance(inv, dict): + return + with open(cache_path, "w", encoding="utf-8") as f: + json.dump(inv, f, ensure_ascii=False) + except Exception: + # 저장 실패는 비중요(다음 실행에서 다시 RCON으로 얻으려 하면 됨) + pass + + +def _try_parse_json_object(raw: str) -> tuple[dict, bool]: + """ + RCON 출력이 JSON으로 시작하지 않더라도(앞에 잡문/개행이 섞임 등) + '{ ... }' 구간을 찾아 파싱을 시도한다. + """ + if not raw: + return {}, False + + s = raw.strip() + if not s: + return {}, False + + # 1) 우선 가장 일반적인 케이스: JSON 오브젝트로 바로 시작 + if s.startswith("{"): + try: + val = json.loads(s) + return val if isinstance(val, dict) else {}, True + except Exception: + return {}, False + + # 2) '{' 위치를 찾아서 구간 파싱 시도 + start = s.find("{") + end = s.rfind("}") + if start == -1 or end == -1 or end <= start: + return {}, False + + candidate = s[start : end + 1] + try: + val = json.loads(candidate) + return val if isinstance(val, dict) else {}, True + except Exception: + return {}, False + + +def _count_total(inv: dict) -> int: + try: + if not isinstance(inv, dict): + return 0 + return int(sum(v for v in inv.values() if isinstance(v, (int, float)))) + except Exception: + return 0 + class StateReader: def __init__(self, rcon: FactorioRCON): self.rcon = rcon + # 현재 작업 디렉터리가 바뀌어도 동일 캐시를 찾게 보장 + self.inventory_cache_path = os.path.join(BASE_DIR, INVENTORY_CACHE_FILE) def get_full_state(self) -> dict: return { @@ -29,7 +147,7 @@ class StateReader: } def get_player_info(self) -> dict: - lua = P + 'rcon.print(game.table_to_json({x=math.floor(p.position.x), y=math.floor(p.position.y), health=p.character and p.character.health or 100}))' + lua = P + JSON_HELPER_LUA + 'rcon.print(json_encode_object({x=math.floor(p.position.x), y=math.floor(p.position.y), health=p.character and p.character.health or 100}))' try: raw = self.rcon.lua(lua) return json.loads(raw) if raw and raw.startswith("{") else {} @@ -38,38 +156,205 @@ class StateReader: def get_inventory(self) -> dict: # Factorio 2.0 get_contents() 호환: pcall로 안전하게 - lua = P + """ + debug_flag = "true" if os.environ.get("INV_DEBUG") == "1" else "false" + lua = P + JSON_HELPER_LUA + f"local DEBUG_INV = {debug_flag}\n" + """ local ok, err = pcall(function() - local inv = p.get_main_inventory() - if not inv then rcon.print("{}") return end - local result = {} + local inv = (p.get_main_inventory and p.get_main_inventory()) or nil + local meta = { DEBUG_INV = DEBUG_INV } + if not inv and p.get_inventory and defines.inventory and defines.inventory.character_main then + -- 일부 환경에서는 get_main_inventory() 대신 character_main을 직접 요청해야 함 + inv = p.get_inventory(defines.inventory.character_main) + end + if not inv then + if DEBUG_INV then + rcon.print(json_encode_object({ inventory = {}, debug = { _inv_error = "inv_nil", meta = meta } })) + else + rcon.print("{}") + end + return + end + local contents = {} + meta.inv_has_get_contents = (inv.get_contents and true) or false if inv.get_contents then - -- Factorio 2.0: get_contents()가 있으면 가장 안정적으로 읽을 수 있음 - result = inv.get_contents() + -- Factorio 2.0: get_contents()가 있으면 가장 안정적으로 읽음 + local c = inv.get_contents() + -- 일부 환경에서 get_contents()가 nil/비테이블을 반환할 수 있음 + meta.get_contents_type = type(c) + if c and type(c) == "table" then + local k = 0 + for _ in pairs(c) do + k = k + 1 + end + meta.get_contents_keys = k + contents = c + end else - -- 호환용 폴백 (일부 버전에서 #inv / 인덱스 접근이 불안정할 수 있음) + -- 호환용 폴백 for i = 1, #inv do local stack = inv[i] - if stack.valid_for_read then - result[stack.name] = (result[stack.name] or 0) + stack.count + if stack and stack.valid_for_read then + contents[stack.name] = (contents[stack.name] or 0) + stack.count end end end - rcon.print(game.table_to_json(result)) + + -- 직렬화 실패를 줄이기 위해, out은 "아이템명(string) -> 개수(number)"만 남긴다. + -- inv.get_contents()가 (1) map 형태(name->count)일 수도 있고, (2) 스택 객체 리스트 형태일 수도 있어 방어적으로 처리한다. + local out = {} + for name, count in pairs(contents) do + if type(name) == "string" then + -- map 형태: contents["iron-plate"] = 3 + out[name] = tonumber(count) or 0 + elseif type(count) == "table" then + -- 리스트 형태: contents[1] = {name=..., count=...} (또는 유사 필드) + local nm = count.name or count[1] + local ct = count.count or count.amount or count[2] + if nm then + out[tostring(nm)] = tonumber(ct) or 0 + end + else + -- 알 수 없는 형태는 무시 + end + end + + local ok2, json_str = pcall(function() return json_encode_object(out) end) + if ok2 and json_str then + if DEBUG_INV then + local debug = {} + + local cursor = p.cursor_stack + if cursor and cursor.valid_for_read then + debug.cursor_name = tostring(cursor.name) + debug.cursor_count = tonumber(cursor.count) or 0 + else + debug.cursor_name = "EMPTY" + debug.cursor_count = 0 + end + + local armor_type = (defines.inventory and defines.inventory.character_armor) or nil + local armor_inv = (armor_type and p.get_inventory) and p.get_inventory(armor_type) or nil + local armor_out = {} + local armor_total = 0 + if armor_inv and armor_inv.get_contents then + local a = armor_inv.get_contents() + if a and type(a) == "table" then + for name, count in pairs(a) do + local c = tonumber(count) or 0 + armor_out[tostring(name)] = c + armor_total = armor_total + c + end + end + end + debug.armor_total = armor_total + debug.armor = armor_out + + local trash_type = (defines.inventory and defines.inventory.character_trash) or nil + local trash_inv = (trash_type and p.get_inventory) and p.get_inventory(trash_type) or nil + local trash_out = {} + local trash_total = 0 + if trash_inv and trash_inv.get_contents then + local t = trash_inv.get_contents() + if t and type(t) == "table" then + for name, count in pairs(t) do + local c = tonumber(count) or 0 + trash_out[tostring(name)] = c + trash_total = trash_total + c + end + end + end + debug.trash_total = trash_total + debug.trash = trash_out + + rcon.print(json_encode_object({ inventory = out, debug = debug })) + else + rcon.print(json_str) + end + else + if DEBUG_INV then + rcon.print(json_encode_object({ + inventory = out, + debug = { _json_encode_ok2 = ok2, _json_encode_err = tostring(json_str), meta = meta } + })) + else + rcon.print("{}") + end + end end) -if not ok then rcon.print("{}") end +if not ok then + if DEBUG_INV then + rcon.print(json_encode_object({ inventory = {}, debug = { _pcall_error = tostring(err), meta = { DEBUG_INV = DEBUG_INV } } })) + else + rcon.print("{}") + end +end """ try: raw = self.rcon.lua(lua) - return json.loads(raw) if raw and raw.startswith("{") else {} + if os.environ.get("INV_DEBUG") == "1": + rs = raw if isinstance(raw, str) else str(raw) + # 너무 길어지지 않게 일부만 출력 + rs1 = rs.replace("\n", "\\n")[:200] + print( + f"[INV_DEBUG] raw_inv_resp_snip={rs1} " + f"has_brace_open={'{' in rs} has_brace_close={'}' in rs}" + ) + inv, parsed_ok = _try_parse_json_object(raw) + if os.environ.get("INV_DEBUG") == "1": + print( + f"[INV_DEBUG] inv_parse parsed_ok={parsed_ok} " + f"inv_is_dict={isinstance(inv, dict)} " + f"inv_keys={list(inv.keys())[:5] if isinstance(inv, dict) else 'na'}" + ) + if os.environ.get("INV_DEBUG") == "1" and parsed_ok and isinstance(inv, dict): + if "inventory" in inv and "debug" in inv: + debug = inv.get("debug", {}) + meta = debug.get("meta", {}) + cursor_name = debug.get("cursor_name", "") + cursor_count = debug.get("cursor_count", 0) + armor_total = debug.get("armor_total", 0) + trash_total = debug.get("trash_total", 0) + get_contents_type = meta.get("get_contents_type", "") + get_contents_keys = meta.get("get_contents_keys", 0) + print( + f"[INV_DEBUG] main_items={len(inv.get('inventory',{}))} " + f"cursor={cursor_name}:{cursor_count} armor_total={armor_total} trash_total={trash_total} " + f"get_contents={get_contents_type}:{get_contents_keys}" + ) + inv = inv.get("inventory", {}) + else: + items = len(inv) if isinstance(inv, dict) else 0 + total = _count_total(inv) + print(f"[INV_DEBUG] main_parseOK | items={items} total={total} raw_snip={raw[:80]}") + + # fallback only: RCON에서 읽어온 inv가 비어있으면 캐시 사용 + # (inv가 {}인 원인이 "진짜 빈 인벤토리"든 "읽기 실패"든, 정책상 캐시 fallback을 한다) + if inv: + _save_inventory_cache(self.inventory_cache_path, inv) + return inv + + cached = _load_inventory_cache(self.inventory_cache_path) + if os.environ.get("INV_DEBUG") == "1": + print( + f"[INV_DEBUG] cache_loaded | items={len(cached)} total={_count_total(cached)} " + f"path={self.inventory_cache_path}" + ) + return cached except Exception: - return {} + # RCON 실패면 캐시로 대체 (cache도 없으면 {}) + cached = _load_inventory_cache(self.inventory_cache_path) + if os.environ.get("INV_DEBUG") == "1": + print( + f"[INV_DEBUG] cache_loaded_on_exception | items={len(cached)} total={_count_total(cached)} " + f"path={self.inventory_cache_path}" + ) + return cached def scan_resources(self) -> dict: """position+radius 방식으로 자원 스캔 (f-string 중괄호 문제 완전 회피)""" - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local surface = p.surface + local px, py = p.position.x, p.position.y local all_res = surface.find_entities_filtered{position = p.position, radius = 500, type = "resource"} if not all_res or #all_res == 0 then rcon.print("{}") @@ -79,22 +364,37 @@ local ok, err = pcall(function() for _, e in ipairs(all_res) do local name = e.name if not resources[name] then - resources[name] = {count = 0, sx = 0, sy = 0} + resources[name] = {count = 0, sx = 0, sy = 0, best_dx2 = math.huge, best_x = 0, best_y = 0} end local r = resources[name] r.count = r.count + 1 r.sx = r.sx + e.position.x r.sy = r.sy + e.position.y + + -- "패치 중심(평균)"이 실제 엔티티 좌표와 멀어질 수 있어, + -- 플레이어 기준으로 가장 가까운 엔티티 좌표를 anchor로 함께 반환한다. + local dx = e.position.x - px + local dy = e.position.y - py + local d2 = dx*dx + dy*dy + if d2 < r.best_dx2 then + r.best_dx2 = d2 + r.best_x = e.position.x + r.best_y = e.position.y + end end local out = {} for name, r in pairs(resources) do out[name] = { count = r.count, center_x = math.floor(r.sx / r.count), - center_y = math.floor(r.sy / r.count) + center_y = math.floor(r.sy / r.count), + anchor_x = r.best_x, + anchor_y = r.best_y, + anchor_tile_x = math.floor(r.best_x + 0.5), + anchor_tile_y = math.floor(r.best_y + 0.5), } end - rcon.print(game.table_to_json(out)) + rcon.print(json_encode_object(out)) end) if not ok then rcon.print("{}") end """ @@ -106,7 +406,7 @@ if not ok then rcon.print("{}") end def get_buildings(self) -> dict: """type 기반 검색 (f-string 없음, pcall 안전)""" - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local surface = p.surface local result = {} @@ -116,7 +416,7 @@ local ok, err = pcall(function() result[e.name] = (result[e.name] or 0) + 1 end end - rcon.print(game.table_to_json(result)) + rcon.print(json_encode_object(result)) end) if not ok then rcon.print("{}") end """ @@ -127,14 +427,14 @@ if not ok then rcon.print("{}") end return {} def get_research_status(self) -> dict: - lua = P + """ + lua = P + JSON_HELPER_LUA + """ local ok, err = pcall(function() local force = p.force local k = 0 for name, tech in pairs(force.technologies) do if tech.researched then k = k + 1 end end - rcon.print(game.table_to_json({ + rcon.print(json_encode_object({ current = force.current_research and force.current_research.name or "none", completed_count = k })) @@ -174,13 +474,23 @@ if not ok then rcon.print("{}") end py = p.get('y', 0) sorted_res = sorted( res.items(), - key=lambda item: ((item[1].get('center_x',0) - px)**2 + (item[1].get('center_y',0) - py)**2) + key=lambda item: ( + ( + item[1].get('anchor_tile_x', item[1].get('center_x', 0)) - px + )**2 + + ( + item[1].get('anchor_tile_y', item[1].get('center_y', 0)) - py + )**2 + ) ) for ore, info in sorted_res: - dist = int(((info.get('center_x',0) - px)**2 + (info.get('center_y',0) - py)**2)**0.5) + ax = info.get('anchor_tile_x', info.get('center_x', 0)) + ay = info.get('anchor_tile_y', info.get('center_y', 0)) + dist = int(((ax - px)**2 + (ay - py)**2)**0.5) lines.append( f"- {ore}: {info.get('count',0)}타일 " - f"(중심: {info.get('center_x','?')}, {info.get('center_y','?')}) " + f"(앵커: {ax}, {ay}) " + f"[중심: {info.get('center_x','?')}, {info.get('center_y','?')}] " f"[거리: ~{dist}타일]" ) else: