From 053a016c8b371c9c6123316a5bd453f9719cf0dc Mon Sep 17 00:00:00 2001 From: Boki Date: Sat, 21 Feb 2026 09:18:10 -0500 Subject: [PATCH] rwork on kulemak bot and cleanup --- assets/black-cathedral-door.png | Bin 0 -> 4174 bytes assets/return-the-ring.png | Bin 0 -> 21478 bytes src/Poe2Trade.Bot/BossRunExecutor.cs | 394 ++++++++++++------ src/Poe2Trade.Bot/BotOrchestrator.cs | 12 +- src/Poe2Trade.Bot/GameExecutor.cs | 222 ++++++++++ src/Poe2Trade.Game/InputSender.cs | 2 + .../NavigationExecutor.cs | 1 + src/Poe2Trade.Navigation/WorldMap.cs | 37 +- src/Poe2Trade.Screen/IScreenReader.cs | 2 + src/Poe2Trade.Screen/LootLabel.cs | 110 +++++ src/Poe2Trade.Screen/ScreenReader.cs | 76 +++- src/Poe2Trade.Ui/Overlay/D2dOverlay.cs | 2 +- src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs | 18 + .../ViewModels/MappingViewModel.cs | 7 - src/Poe2Trade.Ui/Views/MainWindow.axaml | 4 +- 15 files changed, 727 insertions(+), 160 deletions(-) create mode 100644 assets/black-cathedral-door.png create mode 100644 assets/return-the-ring.png create mode 100644 src/Poe2Trade.Bot/GameExecutor.cs create mode 100644 src/Poe2Trade.Screen/LootLabel.cs diff --git a/assets/black-cathedral-door.png b/assets/black-cathedral-door.png new file mode 100644 index 0000000000000000000000000000000000000000..f1fee0dbc8c8a711f2882180ce88fc63f2ed3f3c GIT binary patch literal 4174 zcmV-U5V7xxP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D593KhK~z{rEtpx8 z8|QU~F@wE<272FDG#Y5^%V01V%tCS~iZn%0A|=aZTV>g?t75BCN#!EWRjQI-{-nNd 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 literal 0 HcmV?d00001 diff --git a/assets/return-the-ring.png b/assets/return-the-ring.png new file mode 100644 index 0000000000000000000000000000000000000000..c76eae2d6528aebe65008fd80e6830f5ade00242 GIT binary patch literal 21478 zcmY(pWmp_d&^Efby9Kx45Zs**+}+(RIEy<355Zl7YY4u;;%>p+T^4uwc;53~-#O>U z%v8%%-F;6@b@kO9rJ^K_hD?ME007WrWhB)A04SCZnI94U<8D6Vzwq&Za#NF*0Mty7 z9DNjEt;H3^0f71hlowODk1~?8jIJ91fZqMz1=W&hfA;Z0q^*XIyN;rQfcbaFFQyjX z%`CqF9i2aV0{}vzKxb2PdrNl;GfQh*Ct*5Bdlwyrt%We1Hn$?1qVrcv8(SG4S4(vt zB@J^QdvksZI#CfcWFeryhXzMWcT)UCBpDVRQ=!k%mH`jm-0@ z0Vaz}T}r7$*ogI(!E%Vfa{LVbS)6qK-LJHw{ACwL`NpvT*^l zKqjZuvUZv2CMAcIS#7 zzmD6JBX0AdTuTpqElJ}s@=sEKEAJFnG@=8>`|?(dv6w2}1VJb^!iH9=evV_WQt5fBpiAh_NRE7H{spJ+Va!Jg{-4Z&gG!6V*mN%(vQ+psZW#Y1iA zqkD6Ifqn-b=@p(92+VmLJkIIFzRs7lmc3N{I>9{CMgvnfH{$DlS20n5O=ehQBf}ke zI`mL_6V8zTxktsszT23U+vylj-TMRl^IfwH+SDG|5cIbYNftJ_5?DmOpk!dJ)F*A+ ziV}nP6cQ|>Nxs@T7EO)}>KMmVuX2vEjw%nE8q4FamGYX36iCg`Lr_sLwATTFI1G%P z`tnp@_p?Pr{jot(eks?zHUw%ID-yZ3IM7#0M9KEJ8xN}$PCOOKsiu;P?AoVwV0Uu< zK?74GGvuOe>{n;5MlcfIs*!DD8KSGv-Nu=PjC|LOYTlqD75MSCSSMhLT(w=un6;mR zJOx)JkS?e=KT3Kyc7gr4dy4O-W6Q`l9|F6KqPK)F&7g;Tx$GWt^n@G9+f>%4che@Z zz*+!k>z|e*b!Swg7Mm%OMdG&}T08goQ=?dliN&lXKX$dOt=YmE^KEp(h;y0 zg7{-;8S4_NREh%77&kNxabn>{!=p=Dfce#(Qm|iOHr9cU0Yg>Nhge)eITa|)`hBdx zm%WS*=chS{gbvHcEI`zO&dWIOpPKXryV?zh6VrDEn7fv^-RON{MJhER*quRC+5O;X z`nCxn?rTuoB@I%hg%pkwN;y}OiAHtqN{FLmD7aa7&4cUZ}?7OQU; z?B#%%_?Xz%2s+r%QcS7R!%YQzXVFy)lK*8&;Q}m`q%gU~wUBYP&e@XjkY1$kR&aIDr^3Q(R>vkcCuoFK{A4-)-v9a zsviX$oCcER^DUTSQ<*1ls@qUclBD#g2=3$PY<>2mX25u=Vee`l_Z`1&jy0-0xaS%0 z2wUm9-ydI5nFp)h_@n7k&`T-9&6>|bWW~crCva&7LVOFxlF~HM8xb7i4=ID+cj!aP z=)1BsP>4k_MWPETI<(+Co2zFk06sJ8RJ5%B(%%5M;~I%35%6|GOG;zGgaGh#s+CD2 zKubP<_?be+CJW#71?JxeTHkEY^>Ydw^ovW5A#t@GYD&U1-S0KJHgP!d1mi*b?maH6izIlXh5L@7T8 zBHUn&-jE+wlM~uXk&8-p#|@tW*sUaZToGCwUu@L$Y5ei-WwYZxO&&-ZtJm1Ih!XKUWZ$@NtBNXrY;)>kC& zhJNhNgd`PuSD9CpS{sJ%MVZCe^wF}xO>ta5i_8q}vZZ>vi=i3j)NE5Y#vrzhYz)p! zd5ZzX_jaafGt1s3wz}lzIKVT`9qcBKKA%z5xGmSAgBw}G6 zA`QiSK$|9*rk7$J1E?7>@17Q>Die3;(OaEC$GKDWhDD zkZF5J5F$lwj8DXfOs?gF14eaFwWVP^jQE2FaGWW$)g$f@OSH7~M}0-u)3g2D?f^;S z>>r4W?nIzf-+?$G4VB+#Ra8(~Hje19;8oJpt>VepIln}Oybhvd%0{TwVOd#W3vwUU zVT?arEE3Ls566Trq~YkK=I1VOf{=6+a_mx{e|GsX0@@z?A@)}o#9qs({>cHT)4{T% zt!|V_J&69m=t&M{Q{vK4_}v5@YSngAWa28<%cr+tUDmd#zY4ZK0+fquO{qoVrlh`m ztm0XIHR#_mEhN~id*1lB1@_a8i=vsP|Bc-1w-WJdyzkTU7XH6g?>Yzqg@lw*n%WPy z+Ic1W2buAoWPmA_s_ZmY51vLy(6kWu+()2xGh(;#^6&mI3Gvp%67{L8m~ zwrxc$u@K55ZL{d*XNmMMxo=@D*E*Ql@pysp)PoIiMs~#iP`@Hpw!X5UOX6Ia1>uhI z35Dq}*V|&3BylUys+SQZK#S%1GPfIgw&1ef#~ad!wBgk)Hl`q#*T1nG1ZBH!lnaCk zLb>wndI?W8g*0(DPlj(88sXSb4L@&=ghW&J&}n!wz{=u;?#KJlRRCmMqy`PuwMs-R zEZ(|NM{Mr;kkB|%)slP#Grs1ms@p#PjPo908>;tV8g2k)t>^BHT%`lr&0-Ow6=h7g zw86Q`e7K3OZgW@dqHsI+QBe0Z8M-XTa&c z7&@+`imAn^9q!{t)P+3G7`f7gP`6tFB?qTv3@^ml$dq{{7N}N_3QM@%UJ6p~f6%s2 zo7B8(wF=0TiunS+Iv_*O1@jr&FhjqjYTiiym5&hs6#%A@Mc7mK~5ACgVgq z&~n@>9Upr1Y zTzFo+V>zZQvk+yKpO>F~CN)B+z!r=!KIqQG=Q*^1DK8#m`j(*TBWz@+eHk&B@6fxW zVdB)~>}W@(au9iJ9BfFDO1G`mr_cJQi079{6X$#GmCMnSW^JZUEkZrE$vGP`#%V~g z+ez_VGWPFlpYwAx4D3ppQR0ms2RwaZ->Z~W#@w3i|O>?1T4qwW& zMZC^%=Np=&@J=6*=-Q77T65A8fs_WN^QuKFlF^JilXV72+k8bABO;Nd-blqf|1oK# zhtU5e*VS~Ep}$cGDq#Cz|6DRq#1Z;k)zsTH;$oMSc{oxZ_gtL9qc1;67Ou|!UizsHTtoq zZdlzytSf;p{$83eQmi)O>+CFjW;u%_Tzf6zgX0vG_Nb%X?*rw5QPltLrE2vCn_!Q1#-C`iFxHWTVMQt^^nt%=~23t^^Lhd z8llN!_P0;-2@O1Q^2vhtI=Gto)GRCIxJ0th^Yc})#BFvL0N7duJROg9L}_I%W&qoo zw1^44b)!Z5c@(4E*k3dus%_33PnszJ*NM%FKj!$M2mCjTA1slFg!f)JA96 zoCKyQZ!+bxN^@GZoIf=ZE*@R2WPqb&O+8KH1bSJjEUWbNusbR;=W-FYwWB$lq_TZ| zX{Ju?j2Gm*F6WX~$wUw>`!;aT0beAfgrzr;bb%NBWzPq&EK`fHZ|UX~ks$j*OBgdm@{v@lnYf|4DxmcFmJC z^GQSlm71v)(p&NGSWAN$0AQG@;rW8W{VzI5a(oGMcP*CR-@#ErF#8c2;Bq2usN|$Vp=x^^O88cx}1#gA}?jLEZ}fu9Wdj?pzyuYk^IO;R*xymU|LQ>us%Z_1=UK) z1Xkx^BaEpTisIg^o&wvnwIWrVtq^}FJgFRSzQv|>s)W{~ABhyUGkjiqtsgyCdcthx zaBuyofJBd;uULksJ*y|S@CO`Ny>}7b_6G;M$s?EEAnaeXBf8;K3D_?TsB)AtGDE95 zQ}?|EauVG1Un~@|%3aDhLEF9%|Oe8UkZ9CdDMmhPY!Sx|d& zg>DPz)$D!spCnpW+;e4b!q>8nd}*2iT9atcHUC0RPBQG3)vp`9IXKV=u7{i zd2KtoAOyqgc`(5(Kws=;Nj@c>ClW{kELI@toKyFmk$?8Z=ePaubmdL>yxDSZ7<1e7 z#HaPrl)FP`2@X`RqUH`B=&G|j9)?tCEM-#NZXJa94Wtr?sPA6Wes{_&{25vr@uBaA zdCo+w$MT~1-padL&HR?uPWdjz5`pYvRK9gu+1%S!6?D&eQH!irBxBa04aeaMWvrRs z;W38SB%HuUyfgL2^bkIse}IlK*Y-mfY6ymmFd|ZT&1$}I38q7Qw9dpuvm-;ypv+dz z7YCHRA~V5E;bwo8()!lqDW`U!)u(mpOGk>tQv|P|=fm=sV!A@@wCd--7;26zEy}pH zU59n$b}j3LCy^p!-E1+poE`$bXcuB9Zg`X@zctlihjj@pob~Q27=q)gd?G|yft!e7 zX2m}t%fx1q#q_EeZ+A<{TBe0!p=LiGjO`j>^TLVSs@F%TzCE(n#nm~{LuLEBL@;A& zuZ9z@X5$eFurMbT(SfZ8DjPd_A_z=I`nS{?(Uv-!=xMH`nxq^u*ODcR_xK*EbTzZ+ zb;c^wD#R`o!i!R%UKcl9)O@1jS0t$^0iS7Pdc)Vd`7e_BV2xP};J=f4<>OF5|8>#* z`cu&PC?Z5Iif^pn=JAwbB*IoZEPSD;=Uw1eM)C-Cg%o*^+$dg3ONfg^%l#&C5_L$K zt-M!Xgs9d!QR52TSak};m zspS;2c}&mg^sA`rP3ogrMCMkOSv$Z@6U)DRxd!xhBEJj&W)Vd)*r18jaCV@SVK>vN zk8C9#SW<2mVj3uPGlnKh?CJ0QV|0FD03-?+*yx!TXU9n?e$AELhk6v;lZ_aU-@DS5Uv?5kxvc*hE z#dGz;D5L^xp1eR^HFwqcH&^i!V?dqD-odfl3Dr-goNI|jM>=*b0iMa*#8WiAVYZru zHdRlKFC(Z}hbdTsy2Cnd)IjjAl!H&`WxfCrp&zzvAS}39D}qy(z0>65FR#CZrv@YG zCq8B5hGE(1(9;yPMDRsXFA4FV(_MEWSkc!ZP4O&6$kc7=+m&$vZkU{iya44~8gW14 z^EhzbXX|s)x#-__qha`m-0q}oG()v#>p&<{lZ3?WjRZA|Z9vH=^rLu-+f=7; zN=58zSR8W$;Yg{y!^~9hbFA@?;JMCcs+Fxe#xxIk(`g5dMO-=M2^3#pn8i<(3=nZ; zup#F5dV{ZBpAf?J_H0Bq^FOYTG1F4QI=z2S-wYD-gq>NZLkm@Ll%5Za3e+MyYNo_; zF9YbS6d|dXdN~Gc0$Mv34^~zU?7fmeJWcdX;I8jGi`=(N6{R3{phexP4WEDsVrWZ4 z=Mr%YUPOHO0Pa54S5uc6PO`=tG7ytor8wcg#n2YT$Td24=F)bs(xROsHBNAlk8ZH* zOn#SgY#e{0{lOMX0}dQkIJQ$rQ8bzjO_roAz;*_PZWEUQuh zaf8P16ruzedUH{$y&O~0CY?apc=Bz|PI6{}8xw(6nUTVNOu;QlgFAcr4Vzo{0`77n3eP_Hj z;2T~KbK2g#!8GYmKatiZXAM$gvRDtXRk|lR6>+&Mv4wNge_0ex!eM2X(OyQhd=c?> zP6;u+CE5?0jE1|p`Yl4BK+JO&;D;WX6~&pz?*^2TeEhT;l>!|b??hL@MA@r-7W8?b zFvNNjsPQHLI)FQF9Q_f`H0h621#;}IFVG@xM|Cb440!k9YUtnaogPs&Oz+Q&@y2IZ zGS)txuNUJ*&O}L?Dct;{Ws9HsKwCCIUTj<7wwomLeBl=>7`QJ_2@*ljh0a-mD-p(Q zBFF{3mw8}(>Evi@>EXz1N+#IC$Lm@~Vl$+}nC^|%wwZ8N*gGZ5PBQ+ryxd0rH5=0< zSKd$C`P--M{i*T`Zc0G?4HUv`y;S7o3f1{Z~?ly>jw1g`WP`8LyD=&Z(h+28ko&;)#d;CQ` zi4;N3s1WvwPIYi(P_Ew)74rdE#U!Zd#(CEIU@S%^KoY^ak5>JTX>**p5pEC1>llL_ zXWI&PI0L`zAc#Kzl@03zLg|gvW%&9VCz1iEcY$}-KU49bkzi?QKTD__RFN(> z?g6wRi}E>1eX&*rh_8g1Jh8d$vFu5TS&`IC336@S$K!;FE0%Y05nIy@*6GpGboovh z$!%0AsAl%%T(inF93rhi+i26yQQswFrv*F9=n08_^&e*1Rez&@G208_9r98wF+lRU z0=>oBP?DNIe{rMcE8`PB7Z}@_l_kUEP_TGVRpAA}|LDt|xl^Hz*DuM`W^n(@1qCmi+_Q~<=d3Q( zDgDPLP&3Bx;UdZ-L~a>#EQ7yh6<%Vn0|_Hl5C&`Plr!eDhLy8u5Aya33zat(G;xLA zCLdu@1jk2Od9>!{q64ad{w}%uK$ddExx!M+kEg9qM#g7AMb8zK` zKrZ9gdB3;WxL%__>^7s1J>Geap&%WQ?|qeouozgmkP?kdrxn{yCYUoO059mPKYBLd zpCH^z9UZo^uy3twbH_t6R_tgWTV2S7gRyyMYE5S~7PfhDsK0vm3W&Wdo z33`d*<%LDDEHaQf>27s4=a~qe!)J~s61IHIGFRf0UJ3~}664w+AHtCdr^ZsmDrw}x z^bd<;D-Jb>!Rg_|r(94Hpg0*Q+ueArij%5xW8iI^DocMI4*ItFvfY8=u!W;sAWT8e z^9HvS|7~E{*;CigSI%wLJNSkWJl{rRs(6rc&Vn;Xknm zq3dKV?stk)a{YNQJX?>a_Rz2D8t(82Z!~Maz#6r1VFt8g z27&QD!8Y-!pv9njm487L&6_o*wn$|-P?-;y6&3l+H{w_t4yz)>5RTKa-=L0$y0Xn_ zS50}?c~7O(8OK6@5mt$!j}Ih@?eimY-nCu}ZAq9} zDd*0A;#e&EL>K z=TOFhHospylaU3JV?NA=Pk4aU;D-~E9I5kwuFp`A>s7*sJePnbvhGB2IATHf0_|IKMMli!p6?DT z5z0U65_VZLM*q8NCtM=$2i+4pWSPY0wtd*Lb27x@5C9fb{Wn7H5b&W?ncn*7qabkv z7W;4P%`v)FVvK;(;ahPT2vAk4d{KK~ z7%DFsz#njTpZ6oHvn`H^wbgqHBa*bQ`z=&G+wWK$bgN0QDW1^IOU4xfiI#U7pxd~I z;NzHQ_zLgVJj~PO2n^jv-5ZDRDCukvi@?>qf0%6hFzGSuaR}^_yE25H2(pMj=-jPu z*)=!rB@prUuI{O3k5r)wvF^BF-xEeWpkjQaGm@UentAF(dOHKz zJ0A_!ijhxjxW4&J?VNky8k%8$3b59fU%d|Y0uL1MTdgqtH!=gi?%Rjc$F#3_4~fRl zJO=LFw=IV+Op0O*iwsrlp#af`(S+(0Cg(>Q-kp$pBdg0Si_zd;h@IC6OIDI{252=|Jx&~HfhFZuU^W?BK+S4mQM`5zZur==u!pm64n+;AR%F!; z7Rk9+vsr8ziR>QczK5Z7gY1=YXFI_-;d|@g*~J9}L`?Q7T)KT;niqyL*=46viqMH+ z==*7fm~i89Y{q%Sws~-39(&Xq0m^y7cOAWS{rwKOQu$73Equ}qd}!VvRix(I>!jNz zyBEeP72f?UbXwKE1(hH~==G8c(=u8%40Up=~6u!MFt$mzl=MIHaSfN3CdD z8oxK3h0Utc4f1>c!yM<{xvYmNV?!On)MB~fTbv1RHnKO;h$P?B@T7JUu;Dp-->~=h zF5Ji(!Px5F4=R|qE!?8KI?r*wP4kPc0&h(XZYCEtGeqw%enA{-TLDxdSzSITO$oCC zp3SK`W8>kiC&J$&|ML&fPUAcDNP71ebZX1z}obkt)un}nEqdz~r*@0MM*ZCKkK1@_y$s&W>3XV zgx&(hXQeASdZGOnbU%%nGw#q}kkAo8sfjgg_h1AP3QC`X68;NR39o0C z4-LkmaB)+5yKXQ6oMi-kkx|wE@d(5iY7$_BWCVaaJ z%uZaDdgRP0TA1mX}{&AGX7JDeu8>kdCUqzHjX!3H=83 z+m{0rMtRRYoR@2&FPo-8i*T7oB_~k}n<=zVZ<7mt1pgCHj#sKoTo7G+ZP)4swAD_lp zL~XhCu*jI~VyeskkOTCnd9XuTTPt*vO(4<)zvu7ToAcAf`OD2{NB$=VuL4i$qMd2+ z$`sPThHPu*1L4$|SSQ<<)|e1vkF-2-Z#?Q_|hO?hkdf?n*;h*`BI)2(5>!@-~21)^>+-4c`|( zTaNq$X5vVW06O1w)VIm1OljBLr-zR_hhX8B9 zP8qX?y^i@D%7K-pv*Gw&o}5iR$p$KF{njd-8r;#}S7Av{{n>L8acKg*vKvd@5Zaws z;vSw=-Wy4j(hG;bw=hUE{KoR~eL2lPqVjxV zY&Eqo&-bHx@0P(ktAy&f^<2^kK3BJd-bf#kRvOUCVSQs_z4zkVgeQk*GlyrBkHhzy z#ii5wcE`YGUY{{94t%%v54W2eNj8uz!IPU>t6tepgv0-EF4GauCi|X-hVsRwiaj@s zQwPga2kSBG=rz*klLZIu`UXeH&P3``O^5y%Zj52OReSW>TI?G5MkU$W$jU2Hdnk?5 zuKk(_P4k;=pfBf6$;o0@FUqL{gJ+ZKQ)7p?PIJ{u8mmYctFa#<;NncV1Ecd`kUTz4 z--m>M)_f&eT)!(!E=Dt)}CuP{VaWp$XN9Q3$&m<_#C~~(d2`N>pH`F z#{I=l-M%8+#EaX$($^io<=JG;mvv^vjBoW~K<3q4&dI~E@8)|h<=$GQ zeXbzx$<%LW$_3Gi*l>cY5@^Tn!&loUOFz>wZ*5ST^g3I*E1|USlrLDi@&Zq!W|~ps zJheYM`SY%@Tej#ykG7hzH~3hu+lo_N=~18&9-)dTq;1zq|ko>LNX<+lJS%8m8I z`i8l*gG5tv|DBU5Z7($R0H%o(W?8f@ePY5DeL@7~ziY6zcvy`IxyuKpe{?#b!N6T9KrT-C*Sb$aeO zFfdM-)%?k^yV04r*}`GnLGG-T)rZT|t7_F%)x{F{IhPl2P*v*7%b~~O(%9mfM(I{5fL|JQ4(1mXW5J=NyR$el{YYO2(uu_-6^HMO8%s@uIJ}-#8l0ZZ{AMUV=Jvg) z^IKWI4y)Pq9(}Pj#K+aW!ttxKI~Q?Q4$aN3?C65}^LcBE-#7|*&XxyvEc{=N%-5as zdR~D-RU(2zqY?I=w5b@}{+IbbUhnwQo)EZg$^r8`9@ml+PH;lIE08#KB~d8j&Iini zz=MNf)PNx4oPr-?&tV7I@!Fkd!w=(K0mw%01=c$O2_QF@`gXiXSUdB#^yq%wPo|zc ziaL&l;}rIUu8)G+e{_Q%W6jYyB%7ms1s*blm<^Lx<`ZNx~=5gS!|x z(1h~s;Z*KT=(tg}jIark_aTp2GmyZ8v(9KpFu*Q&wBg$+$A=@^E8UY2#1K4j0-hX9 zcI&eW555Jwk%0Doxm}b5Hk>GViP{kQ!#5ONA?F@>2_LwFZpM{+nAYDcKwF^y(6+b{ zlCkd7l0<$#cG+nZ?z~GO<2A4!gFHOde2i@);6CZ&5NKWq@7v7GrLpnT5VE@r&3VX{ zAl7D=DBb2Q;eB_)%W_d&I?T$ATF&GX*P zW0Uje%uE+X*-w)57TzD*Lr}9zUGh1re33S{Za9^DD%Y=ZLC3<% z-PcBAZ^G}P_@DEZc?zs5Y!*)KfYFTEtSyUImXoB6HEA7tQQrvtkK9}yLo(*afEIU4 z`i!o!L9YC={2YVoY(fD`1db60KK}dE3)|ipJ}3c)!M!Kh2%T`EFF%_%BkY^xjI1@t z;ejmVekTN%O&jmb6Yp6}&X;tZRyagUA_I(3xnQe-t*o(-ENQJBFXM+r6X^h>f6INP^1B%L#Q!3?IWMl?X~3a!TN7__d4g|D zHt5L5{%b)B+=Dh#%zc^8*lnbdE3MIecj+dps2BR%g7sv#{e(X<*XsmJNnw9zE1GvUJ0JU%Y*em-U@AzEFl$P9R0Gz zh_+;qQa)4r6{EODKIzXiF;VRs&Tr=q?9Fix3i%p%uYU$z=Zo}jB6zQ)UB|o9iD4Fz zy*bPZ=POrp1`JE$sQq7?Kgo_86hs~L#g@L_m+&=!fl-tdJJ3+L8_xjQ3J zxWDcjO~g|&^QN^2TllA!TKu*?Dpki#?n~*Vd=yk}Czdzx?Vo40PwJ(b(J7rA ziUP2{7hr6Iy!;6P&dZ^YA8DPBr@z63WSe2c4O>O#B|YQ{>r!0w7vWJt zjWYzcK2Niv266Xq(=-n`c~6M{xx-<7`jjVXtS{+~`*<4f#rw+<%@E;3FF_#cLDvED z59J+%y;)ay?nV-**k6E$Mr%L7LC1+a;PNR8^0r>|l&IFDX5xFWZDHsKi*$6+*^|3AXk89~3 z7D(FvdBQy0C^}%eXr(bh+*)ZMeIa$EaW~RWTRwN*G2FjtBDc6tv9D9aEI<30#-uaK zpLaY%ANyxX2-nthcfbICiUqk4<*luf4_A5P^^!OQJkzM{u!&*(q6)eiTu%t}OF-J? zXBEJC-}6-}nm2FHZkrUoXNC*0RGQu=d$?F(?YB^5Gi?fbRpBz{s z^ZUawBp}fD2VIxt!P`DdS(7yxNW_l+E?Rw6Z$jYs>}Z+zG5U7zsVTn*BJsT>v#+OT z+dBHrm0=&0!vi7Y#%dQRVf`^?1I2sR$9Hn&Q0tFRD8a(XCB*nxSu|fJzb?Rn^WKh2 z;iFGdAUMUA%kwCDz(23d0 zko^!UrLwJ|$3FsD&`4i6LMoxccxig`i?Z+r&3Moq@|mOG>LrfMdW&*+TjT90u~)ak z@T&c1LEC>Rs{8DJMIww@m_KcJ8K`wro0HhsuL>NF(9srb16Lpc$a(LxXz4YLz++`$ zw!R$Zh`Ma37(-O(YB;N!m%9gfE9P-4i->4I4Y(Y3ZRLnC5@5~RowEEoVkJIi#%3iB zTEC7J+`OQK8A4*eKz$Go4S)kZ&h@?#s$-Q}hH`9~Z20}qPM7^y+nJTMOaz=C{cfd) z-5E_<5Kg_g5|V3pC!HRPVN9@MScj<4diO+JigRg+t)0Ll%W~GwaGWaC`uq|5x=wNe z0%C_iIUVW935t+!hA-sB;x2ruWk8w;Oz^(K0M6!InzQ~#E>7BVOVHd3+spv z_KNU8*TZ>TFG(%QLN_;g0Vat|zqiuQcgnyEAa2jU8Y{(rGB+?}{+j0;i^EsI+gwel zsHi0dvJI?Z!Zqcm5JuNe;v0qNlnu5GN)*cQ7}b7ht+3U@$W@;26FBG%ugjGyyej0V z6L28%;GWbO=g@LfN46Ajo8V@5tJ%4-9!m({YdGefQIF!WSW+X@e8GPF*Z@`;*Br4cTpuZ97rMnh8}5 z0hJjknv8Li*@2{hO%aS%7_xUR^Ys9@n{zWSaKhWY^j4qXc`sa`m*yqCYl*d17NfuZ zc<{oD``HGhhGqp`lIOw6hA8ybea;nBn)WiynIrA8 z6s6$7zF<@{&ttdm5$Los>wT{;r4`DR4v9od-xj!6l-`-MkiA^!de6WFiK2BqL^P;~ zj1=R$U7rv9B|yd?5hbNZ006s*CpgH6tVl2VzrXf*x9rPHJ8#QAUqq67`~1!T*q?AG zC;F0O(QBbR-vvkLSj&;Hly{#SrTI4PZ9mCiMDb0Gz=?((Jp)=40W9@dr9EewwbSI)+is7A(_fMTl5>cFl(=0)FP z@X34AQ#~kG0V?IT^RyK*xb>AY8#w3>?2XI|hCEJl0*t#S`}62lhd&*+2VQW!ETjp; zD?deXUfdkyMQL~3+ddqp=v(+SE+RhvdCzY|0$_NrGU!Z<+hB37AlZ*Z(Pe+PQ-o1uoqd)uePz>y1y$vb0jjsz8 zorQ$A+m*__e>D=UBycq-+|eQ9vYiS_v;&-}jyyjCSMMa~m>oD0C%I(;H0w@EwQ-QOLZwX)K zDkFj1zbzofm(=9o!Sz7A)EM;ta`1<1WnqN(g*<FHw9~y1U{~?ZoLEh&FeY?6O`%I^Np@K$$9p=mnMcaE*v)kiO8N>+Ba+N+YE<& zKc;Ztt+GEZVi>aofP$Mx0-L81N;2}UERS*2@klG zW&ghU`g}clQA^&rj0!t_7s4w-R+>lnbWe~51ub-ra2~azT(-yB^hhirJ~tyIyVvL)UV~Pu=`e!{yrAlp<@-MMz{y=+m}|twAU5Iy;iq_J%Wu`-pdGzFf2;n!!`>Vny73f+ z0Ru34o2NtfKD>a2_|6bq9AOw5HgSsw8T+mMjC?y+OxuFIA8(AisXZHo@R2J-lzvl~ zKM{|lDybrJVu6W#jiO9;V#uDxWwze0=l4#2vA~mdm|y%15Ht+S)x){-z3Y(Z+|vk< z7=y~l98H&;zR0%A=h<+G_|c7KvTZZnl^>rfHw;Ori|c>{4dRzjkP|w zyzVSF(a`V&U%GS>P_in}8rjr1a#!7M3gl*foQdj}`EUHp8|A>L>dC}@pdnEAT*7E# z`lC+l$|Q=ZM@z6t0(};xemcUoY451tj*5OnVvHWMfWeX7ta~UBG1#nw z$(uNlo7|#oMytX9SuT#fEU)6o+l>dsXw!74CkecK;k9mjX~LGF59N-+3EcZTcS@J| zfF0T9Typ_I$aC~|O% zuR^S^`8v$Cgk>nrHz8BC6D41+kGn~VzDeI%+Rw)XWIHRtKPe>+J$-7 zFq+APw&Q2|BBqO=dq*LdfMrbv(QNGBnn!5bS=aoDFo|-N0=_TT3c^l+9YN+A%$e#0 z0Y+VTOom+gv7gL-99&F|ezQ`yK7>^ZPuFN?9Rsi_nfUho_9Fo=YkkT78y#16=94q+ zEYZTd9das>33MNg_T3jU9{vgp4}Q%a_cdT~EJGX+J}p+;$M{7%98w*%o_oI*MV_K% z+LOp8IBlTIPnq)SA)gvTbtHHacmnY|UcXPM=KQ3XJxXTZpBzogA@3JCl}3_WqS6|J zf%Yj45YEj}v(Gs7dt;245Z{Oyblrt-(#&FopRaB6ojezA$6-CKh(CqTHujNeLyt@w zmTZjG*U~pe+QWe4s3=n5fmExs_I`4U^~Of60Aq+15wi^od%OB)@a?U*EuKNYO1x;2 z59x6vtCfe!fG5GzXs5i8ljKi_5z|XROioT~B8soZ8(hypXEdSKN#Ga;j%cmF(AYSd9 z&8U57BoK@-EO;YqRGAI1D7MJ;8CA(v`Ggafd>zB$Rd6t{OmmhF}jPfyh?NH)1{rRHW=~6oR@kP%Q)aW@x*W~KTIYv z`66QJHvhDLrt>UAoey@LP+=l6)N{b!r8TT1H+q6(E!d5e)Sz64p(LVAMyOpSuRKQJ z3>VMtrD?+MlP-{8g8dh4UpdU`5~}1FOnrRRbOCbBe)sI(HZ_yXrY02@7r1~IiCUC8 z6gv8`sDnx`p&Zp!Y|L_r1lZsK!^L0Qsr3ivzI4Dp?D>h!n3GsRt|p?3805t=kF_Ns z(g;D`$y$bf9aseX(M$$Qy%OGQHz8UmCkGaBsF=$WH;Ai6^pju5pxE~BEKb6Y1qY=k zn%KPseqYIf?JRFJwwI4G_?a|M{ub!A@}l1a`pKl9cKzbDH|tn6F2kebp+2xH{s&G8 z-O2_jW?Fq|aK`w|rFpz1U0FreV|+Ns6T43W&AWdT#7sGiYt3!5OYSq;!yS8$7Ca!& zpxrbE2H!sEV4z)K(QuLZJ+De4y^eyIUjfr79jrvP%1ALX*oRDC_I%eUfMb^nd zBYM#E@r*=s|W;<;XtUDCntLSK2A1EP|Jbw2Gi^|WE~HS z99HkQdsB$kHF#MLn=2=vGMzv3J}PlxOl5f_3DVS7ON-Ew77ul(B;i)piQvlCtxZFM zxiN6&DPA7RfonP7#bI;2BPdQdPJCG63{2X3c^492OB3^YCFWuAu(&`QWxo<*hMKu+ zg5{NM3}7G~Bsn8moi5ackVMr{$?M>6r=eiTKL}e|j6T+BgM}-$#YPkU9OOlBCbm=Z zjP3_8^6dA*ldQn;=VhR#R1G-G>oGa6&v`U^T-b+8?3%%{EAb=Rv-_-{5PX z*)zC_J6QV-7oZyNvy4|t3Z>b1W@OoU3GBy85Nc*N~kF}A4)Y7 zflz6AJCTU$u?^*4&*Qgb1c?Tr2z9NGBbhQhwPBXmV--3MA@&VFtJ4_RywRW>S+~b0 z9G?zD*5Pom@9l&UJ5MR(^^U~kfna9vz&c=cyO(W!GjFV6j5c{koo}@|d=;9blfx2m z>pzlzY?U}Jqf9EJ!gm=^4>y2x;XYrFJ9=KBqu|bh)yuE<__8AQ!|JweFbJ`(K_5qH zuV>)%O#D0UQezV?A~nVY4C#_rfCwD{XFMj2N~jjG`($+Sl!6Q;;sqcMSholR@udO> zsxV$0!v=*C2=B>a8H@wb8Z>cnS+OFN8q_vC6((Yt7<_Y!>V$O#3RcIssMQ%fF}MN= z3m0_ZzHglM7U!1sDF;XMG$7K8DjM z2(f#696rzM~lVE6Y0 zJ}?jeTAxT@A2tR>Wv+28TDs|b=}Ns?0=w$ROuJD~>vv2A#xUe9c>}D9VSl4h^mhb( zP)D{8X@6Zw9akYax*@j7z3>Y8Noe%O#fUk$_DvRe`?5VO6DuLsDO`1S@^HO749uuFjNwbJHVbLzKcpd_oG=y|F(m_ zhh;9xAuwrKuLT{%#zxrypeicyj)Xphq!~aS%Yxt78aJT!(KCvm96j!Mf^tn>E*P`C zC!aDcbxQOWn9pJk{LRK3*`<;sLkl_P(9vx!)>UogE0e$kQN$!v+B$1u8SFy_A~vL` zi{$K5?|iHfATg@K8NWwRR!0#c^fS4YCqu=h6RKp_(q^o1h>~KNhSeL6+u>2ck@zVH zuA&ZYO7gbd0}Ex{3mY&K`yCbBIE_em6lukq;c-LddGM|zc*tI+cuad33vq9|Jg}<{ zORLc;jhfsI;SKMVXPn(yav1U;KC5U$9LH(r$|b@n!l6{Sk14zNOE@*$4~%yh^*HX} z6C1*?0F!dm;jm~QJau4b*&Jra$+#YuJy;$$HtHj!WAKoSynpkU?WH8TxwS;R85;Y@ z1lj`H)KL<+U=V=ZnM)o33nn7~Wu3MM#cGTQ0k);=Bw(($$Diy0{}QpFHC3<=p*`_H zX>vnm;CUMkTN&50j`*&CNCh0@OXWS^IZ+BQK0F)Z7I3A-$~{QxOpwVeg}DO(#?0#i zEG!!>oNwy&6pKd7D`HDE0INfYTMbL!NfJg?b{WrjMQ|c8oA?L+fvJPy7xe# z5;i6-zH_S)8jLQj(yQ_`;qnQ$l+jx`D!CU%3+8wdu4_?RnatJf-%J|zO>NPg6J1kq zZHIZ&C{CklQ`1)5wm_9s%(A739~mi<;z1CG!9v(k-l&X4LKA9+L%3oeV}=;<+gPc` zltM5Vv7SAwZc#vl_#ZYxr?cPRzIb6ompA=!x>iLnvKj|R^m zo&@0|y$F}X9P@c{T1YU8W1bkN0S@-)_py?ieg%k@M6b4ZeWQB(-f`i-oi4uG-tVok}G)+RE1zo>1qmzMd z)!PR+)&RNlsYffVF(7b@E;BHkexdxvBWcl+GC18O~m!A1h&o{n(wrI2vT znQb4~3-|t;)g1M1+^@c?pDY%drjmEWkOO965Wicg&3sVUOTLhKjGx$NpS|>a1N?Xz zcWji@z21;-%;1XKt_K0GBGAsL!g>QTOWkZs*{<_SC^ksmhNm&?{$2!ia;UNJz+$(~ zTG^Hz%crmdAeBo9{9VZd8oaRi-}{#&C_3hIpq2MZkJh(SIex~I2r&)#C(z`!k}DfT zTm>W!_%P&&&xPKRa*2odK1U8Mg?$Eyb9z}q883b0dr*dpGV~!T7{g{p?F^Wv0o0G~ zW*sd*9_vYTij*c$1y;+9#cYYJ0__DkcQ$120lC}hQ%=#@x+t*iF(X9;D2d}I71rMH zy)T{&$P?J_Xw{vey&YppzN7$`Cjv}440qhR;!6B*s>!{IYAQa`V5@!igAV z-Kuy#A3KXMrUBILAP#R}0%wn6o&0_A2>Bjr)4YtOr~={_!1-9{1DYhQgKpr5-O?D;dD5@k6YRgWydo@#QxK?u0Vg zzGn(>=W(p601m9Jx0tNi%Wh>~ow0MFcw{hzHYMP|3C7PNcG|#v*Yb$~sZ~s;f_?iN zv}=vk#ZS)l9k&9JPWuiDQc7qiR#)HIC%6{7xvu$3z;rEP5>Fxt$-97n=fUJa;VD>0 zL;k`C#EC1gST_8r^yhXd5qsFQ3G4e3+G#@W74w~*U%$!&XCfuwxZ5?5lx+Q6hMl*? z?L_Sqm`sGaqA_K(uDn`C9j2MU?namXW(Xb285cBeVZ-8ZP+;#;M z-DrosR|~dSz68xCT!onlkFC||%W7?Cs=wLhvEYfqqKuV!KYR&Ft;aeaVRv{^Gn@&Q zU^pbDQU5GnI!L^2nuN);fh0lS*#UOTd53P*hvRPy{^@>TZQm44ldxK3>~5{FoOjmu z#$aqG4fY+JqMZPXd5_t))#VHAn$|RO(>7S&*Fv*>N^W<%!*XupM#tCsfeEH-m4C+g ziMSL$SC~vDIB;M;pn}DGg`OvTbX|v( z5)K}>AM((%4ye#2UoARJ*IOf0GEYmh1$4V`szTEUn$`u&cHR4@xl=NvLre*b>&ab0 z)0n33J3~N8fijZJqUAmt6Bc)rd%8rBDI{J;?uU~Dpw$qXZ6R^JACkcNbuwtvK(isq zn5TMhDH(&66c`F+I8$Md9*Gh*RV@m%_AAWHf{L0&O&C~bj4Kt`4LHk_%5Vh~5Km>M zld13Q+$&F6aZGq(QYsY3bK;p&p8P;Z5fc)p1z<|c>E**^$Ft|%J4M6eeGPAWJAu=^ ze9%i&1yojO+XhXWc#N>aj!A>oJ)&<}zj)EZ=TJ zg!P@WpOp{2e~=N5!;%?OwB94*mDSRR&D9z3dREIGnee6L>&YCKR^H#al1w5%%1B8t znKV!UA_>byUylB9`nY_G2=*N}MLSJc%zE^l<@2-TAx6Ws0|{-Lu$cGQ+33*u(N4)e zH@2LwAR?Hqw>WfNkDX0^BVuWv6ay#by1c-2I>B)VjziyNGeqUP0U4{VGlTZs&%1Mx zF7@vgilCh&G(?CqSh0+>{Qk}ah{8qY?)!|~nSzP+bbY1(JGH!V%Mh7zU`@V()HK!G z7|TNDreIH#NmKI>_`UmVrx& zZ-JxS+Jk8Q$7w7~x0*KtlWBc9hTRPZ0xBHOG389f&h~Sk*%#uPhp5`9U-7x?gd-1$** z!(-*@b8p*jMzjt0lEQZ##c+o}Q*lGc-?84stV7?KM`!#U3D59P;#YK}4|OJ_unuIp z)?m5lu~>B2I=sNn;Y_bqG25c21;z)lhJyzVAgf|?bBDO|k7^kHbIy<#zwVKWrhz~( ziL)EGfzUy!9xwo4x$LNvj#1A@fR0Lg!Sjuzz1oavOys?iW>c_%Sz@_y7rYv2T8IE) zOXW9~js*{HrO{E++z-A5cx$PUNCc7^^8k)f?`@`Uw%jN!mxBdFd!_*iMS#>mQZq0d zEk++tM1b)a^?p5l7$~Y$Z-U*lP*rGGU05yuG0Cm8%OtIRlU})ER8nKH<*X2{F5PS0 z*s)UVPS=}<=Jn_xXq=I|t_~-qLC(IBGokT=wL+@(*jU#1$3DR9sq4&JLRM_va3e1L zTxVZU;wWOd+hcokVc)M@Owg}@#k|Ar&I-$!AxlKibAMgaG>{kn&ixbsQqy7@&&2-^ zr1^XgX88uwe?^#K|hm3VppvM9{Pi+Hcla{3oSv zhJQp7q@9$bY5-u8_E=!`pPZJ9uIw|F)C~CNn@zDE>reGG|08m-;#w!Lo*b1{&c}vb z0I6rF4^pgWFa9a`QN?^`i3>luz)ja~WBBKy-l^LP`7iQxU@in@T4gdfE07*qoM6N<$ Ef=DS7l>h($ literal 0 HcmV?d00001 diff --git a/src/Poe2Trade.Bot/BossRunExecutor.cs b/src/Poe2Trade.Bot/BossRunExecutor.cs index 8132ec9..2881713 100644 --- a/src/Poe2Trade.Bot/BossRunExecutor.cs +++ b/src/Poe2Trade.Bot/BossRunExecutor.cs @@ -3,38 +3,37 @@ using Poe2Trade.Core; using Poe2Trade.Game; using Poe2Trade.GameLog; using Poe2Trade.Inventory; +using Poe2Trade.Navigation; using Poe2Trade.Screen; using Serilog; namespace Poe2Trade.Bot; -public class BossRunExecutor +public class BossRunExecutor : GameExecutor { private static readonly string WellOfSoulsTemplate = Path.Combine("assets", "well-of-souls.png"); private static readonly string BlackCathedralTemplate = Path.Combine("assets", "black-cathedral.png"); private static readonly string InvitationTemplate = Path.Combine("assets", "invitation.png"); + private static readonly string CathedralDoorTemplate = Path.Combine("assets", "black-cathedral-door.png"); + private static readonly string ReturnTheRingTemplate = Path.Combine("assets", "return-the-ring.png"); private BossRunState _state = BossRunState.Idle; - private bool _stopped; - private readonly IGameController _game; - private readonly IScreenReader _screen; - private readonly IInventoryManager _inventory; private readonly IClientLogWatcher _logWatcher; - private readonly SavedSettings _config; private readonly BossDetector _bossDetector; + private readonly HudReader _hudReader; + private readonly NavigationExecutor _nav; public event Action? StateChanged; public BossRunExecutor(IGameController game, IScreenReader screen, IInventoryManager inventory, IClientLogWatcher logWatcher, SavedSettings config, - BossDetector bossDetector) + BossDetector bossDetector, HudReader hudReader, NavigationExecutor nav) + : base(game, screen, inventory, config) { - _game = game; - _screen = screen; - _inventory = inventory; _logWatcher = logWatcher; - _config = config; _bossDetector = bossDetector; + _hudReader = hudReader; + _nav = nav; } public BossRunState State => _state; @@ -45,9 +44,9 @@ public class BossRunExecutor StateChanged?.Invoke(s); } - public void Stop() + public override void Stop() { - _stopped = true; + base.Stop(); Log.Information("Boss run executor stop requested"); } @@ -100,6 +99,7 @@ public class BossRunExecutor await Fight(); if (_stopped) break; + SetState(BossRunState.Looting); await Loot(); if (_stopped) break; @@ -299,17 +299,251 @@ public class BossRunExecutor private async Task Fight() { SetState(BossRunState.Fighting); - Log.Information("[PLACEHOLDER] Fight phase - waiting for manual combat"); - // Placeholder: user handles combat manually for now - await Helpers.Sleep(1000); + Log.Information("Fight phase starting"); + + // Wait for arena to settle + await Helpers.Sleep(6000); + if (_stopped) return; + + // Find and click the cathedral door + Log.Information("Looking for cathedral door..."); + var door = await _screen.TemplateMatch(CathedralDoorTemplate); + if (door == null) + { + Log.Error("Could not find cathedral door template"); + return; + } + Log.Information("Found cathedral door at ({X},{Y}), clicking", door.X, door.Y); + await _game.LeftClickAt(door.X, door.Y); + + // Wait for cathedral interior to load + await Helpers.Sleep(12000); + if (_stopped) return; + + // Walk to fight area (world coords) + const double fightWorldX = -454; + const double fightWorldY = -332; + const double wellWorldX = -496; + const double wellWorldY = -378; + + await WalkToWorldPosition(fightWorldX, fightWorldY); + if (_stopped) return; + + // 3x fight-then-well loop + for (var phase = 1; phase <= 3; phase++) + { + if (_stopped) return; + Log.Information("=== Boss phase {Phase}/4 ===", phase); + + await AttackBossUntilGone(); + if (_stopped) return; + + // Walk to well and click it + Log.Information("Phase {Phase} done, walking to well", phase); + await WalkToWorldPosition(wellWorldX, wellWorldY); + // Click at screen center (well should be near character) + await _game.LeftClickAt(1280, 720); + await Helpers.Sleep(2000); + + // Walk back to fight position for next phase + await WalkToWorldPosition(fightWorldX, fightWorldY); + } + + // 4th fight - no well after + if (_stopped) return; + Log.Information("=== Boss phase 4/4 ==="); + await AttackBossUntilGone(); + if (_stopped) return; + + // Return the ring + Log.Information("Looking for Return the Ring..."); + var ring = await _screen.TemplateMatch(ReturnTheRingTemplate); + if (ring == null) + { + Log.Warning("Could not find Return the Ring template, retrying after 2s..."); + await Helpers.Sleep(2000); + ring = await _screen.TemplateMatch(ReturnTheRingTemplate); + } + if (ring != null) + { + Log.Information("Found Return the Ring at ({X},{Y}), clicking", ring.X, ring.Y); + await _game.LeftClickAt(ring.X, ring.Y); + await Helpers.Sleep(2000); + } + else + { + Log.Error("Could not find Return the Ring template"); + } + if (_stopped) return; + + // Walk up and press Q + Log.Information("Walking up and pressing Q"); + await _game.KeyDown(InputSender.VK.W); + await Helpers.Sleep(1500); + await _game.KeyUp(InputSender.VK.W); + await Helpers.Sleep(300); + await _game.PressKey(InputSender.VK.Q); + await Helpers.Sleep(500); + + // Spam L+R at position for 7s + Log.Information("Attacking at ring fight position (Q phase)"); + await AttackAtPosition(1280, 720, 7000); + if (_stopped) return; + + // Press E, spam L+R at same position for 7s + Log.Information("Pressing E and continuing attack"); + await _game.PressKey(InputSender.VK.E); + await Helpers.Sleep(500); + await AttackAtPosition(1280, 720, 7000); + + Log.Information("Fight complete"); } - private async Task Loot() + private async Task AttackBossUntilGone(int timeoutMs = 120_000) { - SetState(BossRunState.Looting); - Log.Information("[PLACEHOLDER] Loot phase - waiting for manual looting"); - // Placeholder: user handles looting manually for now - await Helpers.Sleep(1000); + // Move mouse to screen center initially + await _game.MoveMouseFast(1280, 720); + await Helpers.Sleep(200); + + var sw = Stopwatch.StartNew(); + var consecutiveMisses = 0; + + while (sw.ElapsedMilliseconds < timeoutMs) + { + if (_stopped) return; + + var snapshot = _bossDetector.Latest; + if (snapshot.Bosses.Count > 0) + { + consecutiveMisses = 0; + var boss = snapshot.Bosses[0]; + + // Check mana before attacking + var hud = _hudReader.Current; + if (hud.ManaPct < 0.80f) + { + await Helpers.Sleep(200); + continue; + } + + // Move to boss and attack + var targetX = boss.Cx + Rng.Next(-10, 11); + var targetY = boss.Cy + Rng.Next(-10, 11); + await _game.MoveMouseFast(targetX, targetY); + + _game.LeftMouseDown(); + await Helpers.Sleep(Rng.Next(30, 50)); + _game.LeftMouseUp(); + await Helpers.Sleep(Rng.Next(20, 40)); + _game.RightMouseDown(); + await Helpers.Sleep(Rng.Next(30, 50)); + _game.RightMouseUp(); + + await Helpers.Sleep(Rng.Next(100, 150)); + } + else + { + consecutiveMisses++; + if (consecutiveMisses >= 15) + { + Log.Information("Boss gone after {Ms}ms ({Misses} consecutive misses)", + sw.ElapsedMilliseconds, consecutiveMisses); + return; + } + await Helpers.Sleep(200); + } + } + + Log.Warning("AttackBossUntilGone timed out after {Ms}ms", timeoutMs); + } + + private async Task AttackAtPosition(int x, int y, int durationMs) + { + var sw = Stopwatch.StartNew(); + while (sw.ElapsedMilliseconds < durationMs) + { + if (_stopped) return; + + var targetX = x + Rng.Next(-20, 21); + var targetY = y + Rng.Next(-20, 21); + await _game.MoveMouseFast(targetX, targetY); + + _game.LeftMouseDown(); + await Helpers.Sleep(Rng.Next(30, 50)); + _game.LeftMouseUp(); + await Helpers.Sleep(Rng.Next(20, 40)); + _game.RightMouseDown(); + await Helpers.Sleep(Rng.Next(30, 50)); + _game.RightMouseUp(); + + await Helpers.Sleep(Rng.Next(100, 150)); + } + } + + /// + /// Walk to a world position using WASD keys, checking minimap position each iteration. + /// + private async Task WalkToWorldPosition(double worldX, double worldY, int timeoutMs = 10000, double arrivalDist = 15) + { + Log.Information("Walking to world ({X:F0},{Y:F0})", worldX, worldY); + + var sw = Stopwatch.StartNew(); + var heldKeys = new HashSet(); + + try + { + while (sw.ElapsedMilliseconds < timeoutMs) + { + if (_stopped) break; + + var pos = _nav.WorldPosition; + var dx = worldX - pos.X; + var dy = worldY - pos.Y; + var dist = Math.Sqrt(dx * dx + dy * dy); + + if (dist <= arrivalDist) + { + Log.Information("Arrived at ({X:F0},{Y:F0}), dist={Dist:F0}", pos.X, pos.Y, dist); + break; + } + + // Normalize direction + var len = Math.Sqrt(dx * dx + dy * dy); + var dirX = dx / len; + var dirY = dy / len; + + // Map direction to WASD keys + var wanted = new HashSet(); + if (dirY < -0.3) wanted.Add(InputSender.VK.W); // up + if (dirY > 0.3) wanted.Add(InputSender.VK.S); // down + if (dirX < -0.3) wanted.Add(InputSender.VK.A); // left + if (dirX > 0.3) wanted.Add(InputSender.VK.D); // right + + // Release keys no longer needed + foreach (var key in heldKeys.Except(wanted).ToList()) + { + await _game.KeyUp(key); + heldKeys.Remove(key); + } + // Press new keys + foreach (var key in wanted.Except(heldKeys).ToList()) + { + await _game.KeyDown(key); + heldKeys.Add(key); + } + + await Helpers.Sleep(100); + } + + if (sw.ElapsedMilliseconds >= timeoutMs) + Log.Warning("WalkToWorldPosition timed out after {Ms}ms", timeoutMs); + } + finally + { + // Release all held keys + foreach (var key in heldKeys) + await _game.KeyUp(key); + } } private async Task ReturnHome() @@ -408,122 +642,4 @@ public class BossRunExecutor Log.Information("Loot stored"); } - - private async Task WalkAndMatch(string templatePath, int vk1, int vk2, - int timeoutMs = 15000, int closeRadius = 350) - { - const int screenCx = 2560 / 2; - const int screenCy = 1440 / 2; - - await _game.KeyDown(vk1); - await _game.KeyDown(vk2); - try - { - var sw = Stopwatch.StartNew(); - bool spotted = false; - while (sw.ElapsedMilliseconds < timeoutMs) - { - if (_stopped) return null; - var match = await _screen.TemplateMatch(templatePath); - if (match == null) - { - await Helpers.Sleep(500); - continue; - } - - var dx = match.X - screenCx; - var dy = match.Y - screenCy; - var dist = Math.Sqrt(dx * dx + dy * dy); - - if (!spotted) - { - Log.Information("Template spotted at ({X},{Y}), dist={Dist:F0}px from center, approaching...", - match.X, match.Y, dist); - spotted = true; - } - - if (dist <= closeRadius) - { - Log.Information("Close enough at ({X},{Y}), dist={Dist:F0}px, stopping", match.X, match.Y, dist); - - // Stop, settle, re-match for accurate position - await _game.KeyUp(vk2); - await _game.KeyUp(vk1); - await Helpers.Sleep(300); - - var fresh = await _screen.TemplateMatch(templatePath); - if (fresh != null) - { - Log.Information("Final position at ({X},{Y})", fresh.X, fresh.Y); - return fresh; - } - Log.Warning("Re-match failed, using last known position"); - return match; - } - - await Helpers.Sleep(200); - } - Log.Error("WalkAndMatch timed out after {Ms}ms (spotted={Spotted})", timeoutMs, spotted); - return null; - } - finally - { - await _game.KeyUp(vk2); - await _game.KeyUp(vk1); - } - } - - private (StashTabInfo? Tab, StashTabInfo? Folder) ResolveTabPath(string tabPath) - { - if (string.IsNullOrEmpty(tabPath) || _config.StashCalibration == null) - return (null, null); - - var parts = tabPath.Split('/'); - if (parts.Length == 1) - { - // Simple tab name - var tab = _config.StashCalibration.Tabs.FirstOrDefault(t => t.Name == parts[0]); - return (tab, null); - } - - if (parts.Length == 2) - { - // Folder/SubTab - var folder = _config.StashCalibration.Tabs.FirstOrDefault(t => t.Name == parts[0] && t.IsFolder); - if (folder == null) return (null, null); - var subTab = folder.SubTabs.FirstOrDefault(t => t.Name == parts[1]); - return (subTab, folder); - } - - return (null, null); - } - - private async Task RecoverToHideout() - { - try - { - Log.Information("Recovering: escaping and going to hideout"); - await _game.FocusGame(); - await _game.PressEscape(); - await Helpers.Sleep(Delays.PostEscape); - await _game.PressEscape(); - await Helpers.Sleep(Delays.PostEscape); - - var arrived = await _inventory.WaitForAreaTransition( - _config.TravelTimeoutMs, () => _game.GoToHideout()); - if (arrived) - { - _inventory.SetLocation(true); - Log.Information("Recovery: arrived at hideout"); - } - else - { - Log.Warning("Recovery: timed out going to hideout"); - } - } - catch (Exception ex) - { - Log.Error(ex, "Recovery failed"); - } - } } diff --git a/src/Poe2Trade.Bot/BotOrchestrator.cs b/src/Poe2Trade.Bot/BotOrchestrator.cs index 8def5e6..7797666 100644 --- a/src/Poe2Trade.Bot/BotOrchestrator.cs +++ b/src/Poe2Trade.Bot/BotOrchestrator.cs @@ -74,7 +74,9 @@ public class BotOrchestrator : IAsyncDisposable GameState = new GameStateDetector(); HudReader = new HudReader(); EnemyDetector = new EnemyDetector(); + EnemyDetector.Enabled = true; BossDetector = new BossDetector(); + BossDetector.Enabled = true; FrameSaver = new FrameSaver(); // Register on shared pipeline @@ -89,7 +91,7 @@ public class BotOrchestrator : IAsyncDisposable Navigation = new NavigationExecutor(game, pipelineService.Pipeline, minimapCapture, enemyDetector: EnemyDetector); - BossRunExecutor = new BossRunExecutor(game, screen, inventory, logWatcher, store.Settings, BossDetector); + BossRunExecutor = new BossRunExecutor(game, screen, inventory, logWatcher, store.Settings, BossDetector, HudReader, Navigation); logWatcher.AreaEntered += area => { @@ -111,13 +113,7 @@ public class BotOrchestrator : IAsyncDisposable if (BossZones.TryGetValue(area, out var boss)) { BossDetector.SetBoss(boss); - BossDetector.Enabled = true; - Log.Information("Boss zone detected: {Area} → enabling {Boss} detector", area, boss); - } - else if (BossDetector.Enabled) - { - BossDetector.Enabled = false; - Log.Information("Left boss zone → disabling boss detector"); + Log.Information("Boss zone detected: {Area} → switching to {Boss} model", area, boss); } } diff --git a/src/Poe2Trade.Bot/GameExecutor.cs b/src/Poe2Trade.Bot/GameExecutor.cs new file mode 100644 index 0000000..5bd5d1e --- /dev/null +++ b/src/Poe2Trade.Bot/GameExecutor.cs @@ -0,0 +1,222 @@ +using System.Diagnostics; +using Poe2Trade.Core; +using Poe2Trade.Game; +using Poe2Trade.Inventory; +using Poe2Trade.Screen; +using Serilog; + +namespace Poe2Trade.Bot; + +/// +/// Base class for game executors that interact with the game world. +/// Provides shared utilities: loot pickup, recovery, walking, stash tab resolution. +/// +public abstract class GameExecutor +{ + protected static readonly Random Rng = new(); + + protected readonly IGameController _game; + protected readonly IScreenReader _screen; + protected readonly IInventoryManager _inventory; + protected readonly SavedSettings _config; + protected volatile bool _stopped; + + protected GameExecutor(IGameController game, IScreenReader screen, + IInventoryManager inventory, SavedSettings config) + { + _game = game; + _screen = screen; + _inventory = inventory; + _config = config; + } + + public virtual void Stop() + { + _stopped = true; + } + + // ------ Loot pickup ------ + + // Tiers to skip (noise, low-value, or hidden by filter) + private static readonly HashSet SkipTiers = ["unknown", "gold"]; + + public async Task Loot() + { + Log.Information("Starting loot pickup"); + + const int maxRounds = 5; + for (var round = 0; round < maxRounds; round++) + { + if (_stopped) return; + + // Move mouse out of the way so it doesn't cover labels + _game.MoveMouseInstant(0, 1440); + await Helpers.Sleep(100); + + // Hold Alt to ensure all labels are visible, then capture + await _game.KeyDown(InputSender.VK.MENU); + await Helpers.Sleep(250); + var capture = _screen.CaptureRawBitmap(); + + // Detect magenta-bordered labels directly (no diff needed) + var labels = _screen.DetectLootLabels(capture, capture); + capture.Dispose(); + + // Filter out noise and unwanted tiers + var pickups = labels.Where(l => !SkipTiers.Contains(l.Tier)).ToList(); + + if (pickups.Count == 0) + { + await _game.KeyUp(InputSender.VK.MENU); + Log.Information("No loot labels in round {Round} (total detected: {Total}, filtered: {Filtered})", + round + 1, labels.Count, labels.Count - pickups.Count); + break; + } + + Log.Information("Round {Round}: {Count} loot labels ({Skipped} skipped)", + round + 1, pickups.Count, labels.Count - pickups.Count); + + foreach (var skip in labels.Where(l => SkipTiers.Contains(l.Tier))) + Log.Debug("Skipped: tier={Tier} color=({R},{G},{B}) at ({X},{Y})", + skip.Tier, skip.AvgR, skip.AvgG, skip.AvgB, skip.CenterX, skip.CenterY); + + // Click each label center (Alt still held so labels visible) + foreach (var label in pickups) + { + if (_stopped) break; + + Log.Information("Picking up: tier={Tier} color=({R},{G},{B}) at ({X},{Y})", + label.Tier, label.AvgR, label.AvgG, label.AvgB, label.CenterX, label.CenterY); + await _game.LeftClickAt(label.CenterX, label.CenterY); + await Helpers.Sleep(300); + } + + await _game.KeyUp(InputSender.VK.MENU); + await Helpers.Sleep(500); + } + + Log.Information("Loot pickup complete"); + } + + // ------ Recovery ------ + + protected async Task RecoverToHideout() + { + try + { + Log.Information("Recovering: escaping and going to hideout"); + await _game.FocusGame(); + await _game.PressEscape(); + await Helpers.Sleep(Delays.PostEscape); + await _game.PressEscape(); + await Helpers.Sleep(Delays.PostEscape); + + var arrived = await _inventory.WaitForAreaTransition( + _config.TravelTimeoutMs, () => _game.GoToHideout()); + if (arrived) + { + _inventory.SetLocation(true); + Log.Information("Recovery: arrived at hideout"); + } + else + { + Log.Warning("Recovery: timed out going to hideout"); + } + } + catch (Exception ex) + { + Log.Error(ex, "Recovery failed"); + } + } + + // ------ Walk + template match ------ + + protected async Task WalkAndMatch(string templatePath, int vk1, int vk2, + int timeoutMs = 15000, int closeRadius = 350) + { + const int screenCx = 2560 / 2; + const int screenCy = 1440 / 2; + + await _game.KeyDown(vk1); + await _game.KeyDown(vk2); + try + { + var sw = Stopwatch.StartNew(); + bool spotted = false; + while (sw.ElapsedMilliseconds < timeoutMs) + { + if (_stopped) return null; + var match = await _screen.TemplateMatch(templatePath); + if (match == null) + { + await Helpers.Sleep(500); + continue; + } + + var dx = match.X - screenCx; + var dy = match.Y - screenCy; + var dist = Math.Sqrt(dx * dx + dy * dy); + + if (!spotted) + { + Log.Information("Template spotted at ({X},{Y}), dist={Dist:F0}px from center, approaching...", + match.X, match.Y, dist); + spotted = true; + } + + if (dist <= closeRadius) + { + Log.Information("Close enough at ({X},{Y}), dist={Dist:F0}px, stopping", match.X, match.Y, dist); + + // Stop, settle, re-match for accurate position + await _game.KeyUp(vk2); + await _game.KeyUp(vk1); + await Helpers.Sleep(300); + + var fresh = await _screen.TemplateMatch(templatePath); + if (fresh != null) + { + Log.Information("Final position at ({X},{Y})", fresh.X, fresh.Y); + return fresh; + } + Log.Warning("Re-match failed, using last known position"); + return match; + } + + await Helpers.Sleep(200); + } + Log.Error("WalkAndMatch timed out after {Ms}ms (spotted={Spotted})", timeoutMs, spotted); + return null; + } + finally + { + await _game.KeyUp(vk2); + await _game.KeyUp(vk1); + } + } + + // ------ Stash tab resolution ------ + + protected (StashTabInfo? Tab, StashTabInfo? Folder) ResolveTabPath(string tabPath) + { + if (string.IsNullOrEmpty(tabPath) || _config.StashCalibration == null) + return (null, null); + + var parts = tabPath.Split('/'); + if (parts.Length == 1) + { + var tab = _config.StashCalibration.Tabs.FirstOrDefault(t => t.Name == parts[0]); + return (tab, null); + } + + if (parts.Length == 2) + { + var folder = _config.StashCalibration.Tabs.FirstOrDefault(t => t.Name == parts[0] && t.IsFolder); + if (folder == null) return (null, null); + var subTab = folder.SubTabs.FirstOrDefault(t => t.Name == parts[1]); + return (subTab, folder); + } + + return (null, null); + } +} diff --git a/src/Poe2Trade.Game/InputSender.cs b/src/Poe2Trade.Game/InputSender.cs index c469c97..7c93bc2 100644 --- a/src/Poe2Trade.Game/InputSender.cs +++ b/src/Poe2Trade.Game/InputSender.cs @@ -34,6 +34,8 @@ public class InputSender public const int W = 0x57; public const int S = 0x53; public const int D = 0x44; + public const int E = 0x45; + public const int Q = 0x51; public const int Z = 0x5A; } diff --git a/src/Poe2Trade.Navigation/NavigationExecutor.cs b/src/Poe2Trade.Navigation/NavigationExecutor.cs index 5470900..9bfbe2e 100644 --- a/src/Poe2Trade.Navigation/NavigationExecutor.cs +++ b/src/Poe2Trade.Navigation/NavigationExecutor.cs @@ -491,6 +491,7 @@ public class NavigationExecutor : IDisposable public bool IsExploring => _state != NavigationState.Idle && _state != NavigationState.Completed && _state != NavigationState.Failed; public MapPosition Position => _worldMap.Position; + public MapPosition WorldPosition => _worldMap.WorldPosition; public byte[] GetMapSnapshot() => _worldMap.GetMapSnapshot(); public byte[] GetViewportSnapshot(int viewSize = 400) { diff --git a/src/Poe2Trade.Navigation/WorldMap.cs b/src/Poe2Trade.Navigation/WorldMap.cs index f697d57..79ed437 100644 --- a/src/Poe2Trade.Navigation/WorldMap.cs +++ b/src/Poe2Trade.Navigation/WorldMap.cs @@ -21,7 +21,19 @@ public class WorldMap : IDisposable private readonly List<(Point Pos, long LastSeenMs)> _checkpointsOn = []; private const int CheckpointDedupRadius = 20; + // World origin: cumulative offset from canvas (0,0) to world (0,0). + // World coords = canvas coords - _worldOrigin. Stable across canvas growth. + private double _worldOriginX; + private double _worldOriginY; + public MapPosition Position => _position; + + /// + /// Player position in stable world coordinates (invariant to canvas growth). + /// World (0,0) = where the player spawned. + /// + public MapPosition WorldPosition => new(_position.X - _worldOriginX, _position.Y - _worldOriginY); + public bool LastMatchSucceeded { get; private set; } public int CanvasSize => _canvasSize; internal List? LastBfsPath => _pathFinder.LastResult?.Path; @@ -36,6 +48,9 @@ public class WorldMap : IDisposable _canvas = new Mat(_canvasSize, _canvasSize, MatType.CV_8UC1, Scalar.Black); _confidence = new Mat(_canvasSize, _canvasSize, MatType.CV_16SC1, Scalar.Black); _position = new MapPosition(_canvasSize / 2.0, _canvasSize / 2.0); + // World origin = initial player position, so WorldPosition starts at (0,0) + _worldOriginX = _position.X; + _worldOriginY = _position.Y; } private void EnsureCapacity() @@ -63,7 +78,17 @@ public class WorldMap : IDisposable _confidence = newConf; _canvasSize = newSize; _position = new MapPosition(_position.X + offset, _position.Y + offset); - Log.Information("Canvas grown: {Old}→{New}, offset={Offset}", oldSize, newSize, offset); + _worldOriginX += offset; + _worldOriginY += offset; + + // Shift checkpoint canvas coordinates + for (var i = 0; i < _checkpointsOff.Count; i++) + _checkpointsOff[i] = (new Point(_checkpointsOff[i].Pos.X + offset, _checkpointsOff[i].Pos.Y + offset), _checkpointsOff[i].LastSeenMs); + for (var i = 0; i < _checkpointsOn.Count; i++) + _checkpointsOn[i] = (new Point(_checkpointsOn[i].Pos.X + offset, _checkpointsOn[i].Pos.Y + offset), _checkpointsOn[i].LastSeenMs); + + Log.Information("Canvas grown: {Old}→{New}, offset={Offset}, worldOrigin=({Ox:F0},{Oy:F0})", + oldSize, newSize, offset, _worldOriginX, _worldOriginY); } /// @@ -619,6 +644,14 @@ public class WorldMap : IDisposable return best; } + /// Convert world coordinates to canvas coordinates. + public MapPosition WorldToCanvas(double worldX, double worldY) => + new(worldX + _worldOriginX, worldY + _worldOriginY); + + /// Convert canvas coordinates to world coordinates. + public MapPosition CanvasToWorld(double canvasX, double canvasY) => + new(canvasX - _worldOriginX, canvasY - _worldOriginY); + public void Reset() { _canvas.Dispose(); @@ -629,6 +662,8 @@ public class WorldMap : IDisposable _prevWallMask?.Dispose(); _prevWallMask = null; _position = new MapPosition(_canvasSize / 2.0, _canvasSize / 2.0); + _worldOriginX = _position.X; + _worldOriginY = _position.Y; _frameCount = 0; _consecutiveMatchFails = 0; LastMatchSucceeded = false; diff --git a/src/Poe2Trade.Screen/IScreenReader.cs b/src/Poe2Trade.Screen/IScreenReader.cs index b9b9b95..fe35ebc 100644 --- a/src/Poe2Trade.Screen/IScreenReader.cs +++ b/src/Poe2Trade.Screen/IScreenReader.cs @@ -18,6 +18,8 @@ public interface IScreenReader : IDisposable Task DiffOcr(string? savePath = null, Region? region = null); Task TemplateMatch(string templatePath, Region? region = null); Task NameplateDiffOcr(System.Drawing.Bitmap reference, System.Drawing.Bitmap current); + void SetLootBaseline(System.Drawing.Bitmap frame); + List DetectLootLabels(System.Drawing.Bitmap reference, System.Drawing.Bitmap current); System.Drawing.Bitmap CaptureRawBitmap(); Task SaveScreenshot(string path); Task SaveRegion(Region region, string path); diff --git a/src/Poe2Trade.Screen/LootLabel.cs b/src/Poe2Trade.Screen/LootLabel.cs new file mode 100644 index 0000000..0ae2d0b --- /dev/null +++ b/src/Poe2Trade.Screen/LootLabel.cs @@ -0,0 +1,110 @@ +namespace Poe2Trade.Screen; + +/// +/// A detected loot label on screen with its position and classified tier. +/// +public record LootLabel(int CenterX, int CenterY, int Width, int Height, string Tier, byte AvgR, byte AvgG, byte AvgB); + +/// +/// Classifies loot label background colors to NeverSink filter tiers +/// by matching against known filter color palette. +/// +public static class LootColorClassifier +{ + private record ColorEntry(byte R, byte G, byte B, string Tier); + + // Background colors from NeverSink's Uber Strict filter + private static readonly ColorEntry[] KnownBgColors = + [ + // S-tier (apex): white bg + new(255, 255, 255, "S"), + // A-tier: red bg, white text + new(245, 105, 90, "A"), + // C-tier: orange bg + new(245, 139, 87, "C"), + // D-tier: yellow bg + new(240, 180, 100, "D"), + // E-tier: text-only, yellowish + new(240, 207, 132, "E"), + + // Unique high: brown bg + new(188, 96, 37, "unique"), + // Unique T3: dark red bg + new(53, 13, 13, "unique-low"), + + // Exotic bases: dark green bg + new(0, 75, 30, "exotic"), + // Identified mods: dark purple bg + new(47, 0, 74, "exotic-mod"), + + // Rare jewellery: olive bg + new(75, 75, 0, "rare-jewellery"), + + // Fragments: bright purple bg + new(220, 0, 255, "fragment"), + // Fragments lower: light purple bg + new(180, 75, 225, "fragment"), + // Fragment splinter: dark purple bg + new(50, 0, 75, "fragment"), + + // Maps special: lavender bg + new(235, 220, 245, "map"), + // Maps regular high: light grey bg + new(235, 235, 235, "map"), + // Maps regular: grey bg + new(200, 200, 200, "map"), + + // Crafting magic: dark blue-purple bg + new(30, 0, 70, "crafting"), + + // Gems: cyan text (20,240,240) - no bg + new(20, 240, 240, "gem"), + // Gems: dark blue bg + new(6, 0, 60, "gem"), + + // Flasks/charms: dark green bg + new(10, 60, 40, "flask"), + + // Currency artifact: dark brown bg + new(76, 51, 12, "artifact"), + + // Socketables (runes): orange-tan bg + new(220, 175, 132, "socketable"), + + // Gold drops: gold/yellow text + new(180, 160, 80, "gold"), + new(200, 180, 100, "gold"), + + // Pink/magenta catch-all (e.g. boss-specific drops like invitations) + new(255, 0, 255, "special"), + new(220, 50, 220, "special"), + ]; + + private const double MaxDistance = 50.0; + + /// + /// Classify an average RGB color to the closest NeverSink filter tier. + /// Returns "unknown" if no known color is within MaxDistance. + /// + public static string Classify(byte avgR, byte avgG, byte avgB) + { + double bestDist = double.MaxValue; + string bestTier = "unknown"; + + foreach (var entry in KnownBgColors) + { + double dr = avgR - entry.R; + double dg = avgG - entry.G; + double db = avgB - entry.B; + double dist = Math.Sqrt(dr * dr + dg * dg + db * db); + + if (dist < bestDist) + { + bestDist = dist; + bestTier = entry.Tier; + } + } + + return bestDist <= MaxDistance ? bestTier : "unknown"; + } +} diff --git a/src/Poe2Trade.Screen/ScreenReader.cs b/src/Poe2Trade.Screen/ScreenReader.cs index 77f6d82..ae378a9 100644 --- a/src/Poe2Trade.Screen/ScreenReader.cs +++ b/src/Poe2Trade.Screen/ScreenReader.cs @@ -1,10 +1,12 @@ using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; -using Poe2Trade.Core; +using OpenCvSharp; using OpenCvSharp.Extensions; +using Poe2Trade.Core; using Serilog; using Region = Poe2Trade.Core.Region; +using Size = OpenCvSharp.Size; namespace Poe2Trade.Screen; @@ -320,6 +322,78 @@ public class ScreenReader : IScreenReader return boxes; } + // -- Loot label detection (magenta background) -- + // + // All loot labels: white border, magenta (255,0,255) background, black text. + // Magenta never appears in the game world → detect directly, no diff needed. + + public void SetLootBaseline(Bitmap frame) { } + + public List DetectLootLabels(Bitmap reference, Bitmap current) + { + using var mat = BitmapConverter.ToMat(current); + if (mat.Channels() == 4) + Cv2.CvtColor(mat, mat, ColorConversionCodes.BGRA2BGR); + + // Mask magenta background pixels (BGR: B≈255, G≈0, R≈255) + using var mask = new Mat(); + Cv2.InRange(mat, new Scalar(200, 0, 200), new Scalar(255, 60, 255), mask); + + // Morph close fills text gaps within a label + // Height=2 bridges line gaps within multi-line labels but not between separate labels + using var kernel = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(12, 2)); + using var closed = new Mat(); + Cv2.MorphologyEx(mask, closed, MorphTypes.Close, kernel); + + // Save debug images + try + { + Cv2.ImWrite("debug_loot_mask.png", mask); + Cv2.ImWrite("debug_loot_closed.png", closed); + current.Save("debug_loot_capture.png", System.Drawing.Imaging.ImageFormat.Png); + Log.Information("Saved debug images: debug_loot_mask.png, debug_loot_closed.png, debug_loot_capture.png"); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to save debug images"); + } + + Cv2.FindContours(closed, out var contours, out _, + RetrievalModes.External, ContourApproximationModes.ApproxSimple); + + Log.Information("DetectLootLabels: {N} magenta contours", contours.Length); + + const int minW = 40, maxW = 600; + const int minH = 8, maxH = 100; + const double minAspect = 1.5; + int yMax = mat.Height - 210; + + var labels = new List(); + foreach (var contour in contours) + { + var box = Cv2.BoundingRect(contour); + double aspect = box.Height > 0 ? (double)box.Width / box.Height : 0; + + if (box.Width < minW || box.Width > maxW || + box.Height < minH || box.Height > maxH || + aspect < minAspect || + box.Y < 65 || box.Y + box.Height > yMax) + { + Log.Information("Rejected contour: ({X},{Y}) {W}x{H} aspect={Aspect:F1} yMax={YMax}", + box.X, box.Y, box.Width, box.Height, aspect, yMax); + continue; + } + + int cx = box.X + box.Width / 2; + int cy = box.Y + box.Height / 2; + + Log.Information("Label at ({X},{Y}) {W}x{H}", box.X, box.Y, box.Width, box.Height); + labels.Add(new LootLabel(cx, cy, box.Width, box.Height, "loot", 255, 0, 255)); + } + + return labels; + } + public void Dispose() => _pythonBridge.Dispose(); // -- OCR text matching -- diff --git a/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs b/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs index ee7bab2..d03d1a2 100644 --- a/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs +++ b/src/Poe2Trade.Ui/Overlay/D2dOverlay.cs @@ -189,7 +189,7 @@ public sealed class D2dOverlay InferenceMs: detection.InferenceMs, Hud: _bot.HudReader.Current, NavState: _bot.Navigation.State, - NavPosition: _bot.Navigation.Position, + NavPosition: _bot.Navigation.WorldPosition, IsExploring: _bot.Navigation.IsExploring, ShowHudDebug: _bot.Store.Settings.ShowHudDebug, Fps: fps, diff --git a/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs b/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs index b403995..6e350ec 100644 --- a/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/DebugViewModel.cs @@ -323,4 +323,22 @@ public partial class DebugViewModel : ObservableObject catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; } } + [RelayCommand] + private async Task LootTest() + { + try + { + DebugResult = "Loot test: focusing game..."; + await _bot.Game.FocusGame(); + await Task.Delay(300); + await _bot.BossRunExecutor.Loot(); + DebugResult = "Loot test: complete"; + } + catch (Exception ex) + { + DebugResult = $"Loot test failed: {ex.Message}"; + Log.Error(ex, "Loot test failed"); + } + } + } diff --git a/src/Poe2Trade.Ui/ViewModels/MappingViewModel.cs b/src/Poe2Trade.Ui/ViewModels/MappingViewModel.cs index 2b83730..1798a1e 100644 --- a/src/Poe2Trade.Ui/ViewModels/MappingViewModel.cs +++ b/src/Poe2Trade.Ui/ViewModels/MappingViewModel.cs @@ -16,7 +16,6 @@ public partial class MappingViewModel : ObservableObject, IDisposable [ObservableProperty] private MapType _selectedMapType; [ObservableProperty] private bool _isFrameSaverEnabled; [ObservableProperty] private int _framesSaved; - [ObservableProperty] private bool _isDetectionEnabled; [ObservableProperty] private int _enemiesDetected; [ObservableProperty] private float _inferenceMs; [ObservableProperty] private bool _hasModel; @@ -106,12 +105,6 @@ public partial class MappingViewModel : ObservableObject, IDisposable _bot.FrameSaver.Enabled = value; } - partial void OnIsDetectionEnabledChanged(bool value) - { - _bot.EnemyDetector.Enabled = value; - _bot.BossDetector.Enabled = value; - } - private void OnDetectionUpdated(DetectionSnapshot snapshot) { Dispatcher.UIThread.Post(() => diff --git a/src/Poe2Trade.Ui/Views/MainWindow.axaml b/src/Poe2Trade.Ui/Views/MainWindow.axaml index 3ea8a1e..b420fcd 100644 --- a/src/Poe2Trade.Ui/Views/MainWindow.axaml +++ b/src/Poe2Trade.Ui/Views/MainWindow.axaml @@ -287,9 +287,6 @@ -