"""
grind_local.py — mode: grind-local. Learn the local mobs' colours by scanning the
area, then grind them. No per-mob config needed — walk somewhere new and run this.

LEARN phase:
  rotate camera through several views; in each, find salient non-ground colour
  blobs and CLICK a few to validate (a monster pops the top-center target frame).
  Tab to deselect. Record confirmed mobs' colours, then cluster them into 1+ HSV
  signatures (handles e.g. normal vs captain with different colours).
GRIND phase:
  the proven loop — two-click engage -> hold until target frame gone -> next;
  camera-rotate to scan; heal '1' at <50% HP. (Same state machine as grind.py.)

Requires host.py running. Run:  python -u grind_local.py   (Ctrl+C to stop)
Writes learned_profile.json and learn_overlay.png for inspection/reuse.
"""
import json
import os
import re
import glob
import time
import base64
from collections import deque
import cv2
import numpy as np
from playwright.sync_api import sync_playwright
from session import get_page, viewport
from shots import shot_path
import packet_log as plog          # per-session RE log (no-op unless control.py opened a session)
import re_sense                    # WASM-memory sensing (own HP/MP, selected target HP) — vision-free

# ---- config ----
FOOD_KEY = "1"
HEAL_AT = 0.50
HEAL_COOLDOWN = 3.0
HP_RECOVER = False       # OFF by default. The "HP display missing → press T" recovery is risky: T
                         # TOGGLES the status window, so if the HP read fails for any OTHER reason
                         # (e.g. region miscalibrated for your resolution) it flip-flops the HUD on/off.
                         # Only enable if the HUD genuinely hides on you AND the HP read is accurate.
HP_RECOVER_COOLDOWN = 5.0  # min seconds between "HP display missing → nudge cursor + press T" tries
ATTACK_KEY = "Digit0"    # after SELECTING a mob, press this to attack (bind "0" in-game to an
                         # attack skill). A keypress can't miss a tiny mob the way a 2nd click can.
SELF_BUFF_KEY = "Digit2" # self-buff skill (key "2"). Recast every SELF_BUFF_EVERY seconds, but only
                         # while NOT in a fight (so it never interrupts attacking a mob).
SELF_BUFF_EVERY = 50.0   # seconds between self-buffs (overridable from the control window)
SELF_BUFF_SETTLE = 1.2   # pause after a self-buff to let it cast before engaging anything
SELECT_GAP = 0.15        # gap between the select-click and the verify-grab / attack keypress
ENGAGE_ATTEMPTS = 2      # re-grab + retry this many times per engage. Each MISS costs SELECT_GAP +
                         # two grabs (~300-400 ms), and a phantom-candidate click is a stray MOVE, so
                         # we cap this low — the outer loop (ENGAGE_RETRY) re-tries a visible mob anyway.
                         # If NO candidate has a confidently-located body we DON'T click at all (a click
                         # on ground is read as a MOVE, which is why the bot "walks around").
MIN_BODY_PX = 14         # min body-blob area (px) for a click point to count as "on the mob".
                         # Below this the point is treated as unconfident (likely ground) → skip.
ENGAGE_SETTLE = 0.35     # settle after a (re)engage / drop before the loop polls again
FIGHT_TIMEOUT = 20.0     # pure-hold safety: only re-engage if the target frame is STILL up this long
                         # (kept high so it never interrupts a real fight — we'd rather stall than run away)
DEATH_CONFIRM_CONSECUTIVE_ABSENT = 3   # the target frame must read ABSENT this many CONSECUTIVE rapid
                         # polls (each ~FIGHT_POLL apart, so the effective confirm window ≈ FIGHT_POLL
                         # × N) before we count the mob dead. Any reappearance = still the same fight,
                         # so an intermittent low read during a LIVE fight never makes us run off and
                         # attack a new mob. Lower N (or FIGHT_POLL) = faster kill→next-mob; too low
                         # risks a flicker being read as death (recoverable: we'd just re-search).
PLAYER_BAIL = False      # OFF by default. The fast "target HP not dropping → likely a player" bail
                         # relies on reading the TARGET-FRAME HP bar (mob_hp), which has proven
                         # unreliable and made the bot abandon LIVE mobs mid-fight. With it OFF the
                         # bot PURE-HOLDS each fight until the target actually dies, and GIVE_UP_AFTER
                         # is the safe backstop for the rare player-select. Turn True only once mob_hp
                         # is verified accurate on your mobs.
NO_PROGRESS_TARGET = 6.0  # (only used when PLAYER_BAIL) seconds of no HP drop before bailing a target
PROGRESS_DROP = 0.08     # (only used when PLAYER_BAIL) HP must fall this far below the MAX seen this
                         # fight to count as "we're damaging it" — a drop OVER TIME, not a level
NO_DAMAGE_GIVEUP = 5.0   # ALWAYS ON, panel-exposed. If the target takes NO damage (its HP never drops
                         # below the max seen) within this many seconds of engaging, assume we can't reach
                         # it (path blocked / stuck out of range) → drop it and search again. Uses the
                         # EXACT memory HP in memory-target mode (and the mob_hp bar in pixel mode), so a
                         # real fight — where HP drops within ~1 s — never trips it. Default 5 s.
GIVE_UP_AFTER = 30.0     # HARD backstop (ALWAYS on): never let ONE target trap us. If it isn't dead
                         # this long after we engaged (from fight start, NEVER reset by re-attacks),
                         # drop it and look elsewhere. Kept ABOVE the longest normal time-to-kill so
                         # real (even slow) fights finish first — now just a rare safety net, since
                         # the tag-colour filter should stop us selecting players in the first place.
# A mob/target is SELECTED iff the top-centre target frame is up. We key on its GOLD NAME line:
# both mob and player frames show a gold name (and ornate gold border), it's present at ANY HP,
# and gold is neither blue water nor red terrain — so this avoids false-fighting on scenery AND
# never abandons a low-HP mob. (Previously keyed on the blue MP bar, which blue WATER false-fired.)
TARGET_NAME_BOX = (0.34, 0.0, 0.66, 0.085)  # top-centre target-frame extent (kept for reference; the
TARGET_NAME_MIN = 200    # gold-name signal was retired — yellowish terrain false-fired it. See below.
# Target-present (death) detection keys on the target frame's BAR STRUCTURE, NOT the gold name. The
# frame ALWAYS shows a RED HP bar with a CYAN/BLUE MP bar directly BELOW it, and BOTH fill LEFT→RIGHT.
# We require a wide LEFT-ANCHORED blue bar (HP-independent → holds to ~0% HP) AND a left-anchored red
# bar ABOVE it (stacked). Requiring both-and-stacked rejects the single-colour false positives: a lone
# RED aggro NAME TAG in the top-centre (no blue bar under it) and blue WATER (no red bar above it).
# The frame is FIXED-PIXEL, top-anchored, horizontally CENTRED, so the box is ABSOLUTE pixels (x given
# RELATIVE TO THE WINDOW CENTRE, y from the screen TOP) — a FRACTIONAL box cut the lower MP bar off at
# shorter window heights, which made detection effectively red-only and false-fired on red aggro tags.
TARGET_BAR_X = (-72, 123)   # bar-fill x span, px relative to the window's horizontal CENTRE (the level
                            # circle is to the LEFT of this, excluded; the bars sit centre-to-right)
TARGET_BAR_Y = (26, 74)     # both bars' rows, px from the screen TOP (tall enough for HP+MP + slack)
TARGET_BAR_MIN_RUN = 14     # min left-anchored width (px) of the MP (blue) bar — it doesn't shrink w/ HP
TARGET_BAR_RED_MIN = 5      # min left-anchored width of the HP (red) bar — small, so a nearly-dead mob
                            # (short red fill) still reads present; red→0 only when truly dead/deselected
TARGET_BAR_LEFT_FRAC = 0.18 # both fills must START within this fraction of the box width from the left
                            # (bars fill left→right) — rejects a CENTRED red name tag / stray colour
ROTATE_DX = 320          # right-drag px per scan turn — tune so one turn is ~90° (a quarter turn,
                         # so WALK_AFTER_SCANS turns sweep a full circle). After each turn the loop
                         # re-checks for a mob and attacks it, instead of turning further.
CALIBRATE_SCANS = 5      # after this many fruitless scans, recalibrate the camera
CALIBRATE_EVERY = 35.0   # also recalibrate the view on a timer (s), when not mid-fight, so the camera
                         # never drifts far from the tuned top-down reference (detection stays consistent)
ENGAGE_RETRY = 2         # if a mob is VISIBLE but engage() keeps missing it, retry IN PLACE this many
                         # times (a transient close-miss) before…
APPROACH_MAX = 5         # …WALKING TOWARD it this many steps (a mob whose nameplate we see but whose
                         # BODY is too far to click confidently — step toward it instead of wandering).
                         # After this many steps with no engage, treat it as unreachable/false → search.
WALK_AFTER_SCANS = 3     # after this many ~90° turns with NO mob found (a full look-around), walk a
                         # bit in a random direction and scan again, instead of spinning in place
# ── Cursor fallback mob finder (reads the game's CSS cursor — NO screenshot/template) ──────────────
# Flyff's cursor is a CSS cursor (url .../cursor/<name>.cur) that the game SWAPS by what's under the
# pointer: `curbase` over empty ground, **`curattack` over an attackable MOB**. It's the OS/CSS cursor
# so it's NOT in screenshots — but we can READ it straight from the DOM (canvas.style.cursor). So when
# colour-tag search keeps failing (mobs on screen but missed), we HOVER a grid of points and check the
# cursor name — wherever it's the ATTACK cursor a mob is under it → click to select. 100% reliable,
# no template, faster than grabbing. (Found via getComputedStyle on universe.flyff.com/play.)
DETECT_MODE = "hybrid2"    # mob-find method (panel-selectable, applied on Start):
                           #   "hybrid2"   = DEFAULT / SMARTEST: fuse THREE candidate sources — memory-map
                           #                 projections + nameplate pixels + frame-diff MOTION — into one
                           #                 ranked list, then VERIFY each with the game cursor (curattack)
                           #                 best-first (adaptive spiral breadth); also pre-scans the next mob
                           #                 mid-fight and engages it the instant the current one dies. See
                           #                 the HYBRID 2 block.
                           #   "cursor"    = aggro/RED colour FIRST, then a radial HOVER-scan that reads the
                           #                 game cursor (curattack) to find mobs colour missed
                           #   "nameplate" = colour name-tag scan (sees the whole frame at once, fast)
                           #   "hybrid"    = "nameplate", but if it finds NOTHING for HYBRID_FALLBACK_SECS
                           #                 it does one cursor hover-scan (best of both: fast when colour
                           #                 works, cursor-thorough when it doesn't)
                           #   "hybrid3"   = OPTIMAL-search variant of hybrid2: fuse every source into a HIT-
                           #                 PROBABILITY field (each shaped by its uncertainty — point/ring/
                           #                 vertical-ellipse) and probe it in greedy max-probability order.
                           #                 See the "HYBRID 3" block for the search-theory derivation.
HYBRID_FALLBACK_SECS = 4.0  # hybrid: seconds of nameplate finding NOTHING before a hover-scan kicks in
ENGAGE_CURSOR_CONFIRM = True  # before a select-CLICK, move the cursor there + confirm the game shows the
                            # ATTACK cursor (a real mob) — else skip it (an off-mob click = a MOVE = the
                            # bot "walks around"). The cursor is the game's ground truth. Set False to
                            # revert to clicking on body-blob confidence alone (faster, less accurate).
ATTACK_CURSOR = "curattack"  # the game's cursor name when hovering an attackable mob (the "sword")
HOVER_SCAN_ON = True     # master switch for the cursor hover-scan
HOVER_READ_MODE = "settle"  # (2026-07-02f) per-point cursor read method — restored to the PROVEN fixed
                         # settle after live testing showed the rAF-synced read was WORSE on both axes
                         # (slower AND missed hovers). "settle" (DEFAULT) = move → sleep HOVER_SETTLE →
                         # cursor_kind (the pre-2026-07-02e method that detected reliably). "raf" =
                         # cursor_kind_synced (kept available + CURSOR_SYNC_FRAMES, but no longer default).
                         # We can AFFORD the slightly longer settle now because the radial-mem scan (item 1)
                         # hovers only the memory-indicated radii — far fewer points — so net it's faster AND
                         # reliable. Both modes get a one-shot re-read on an EMPTY ('' unreadable) result.
HOVER_SETTLE = 0.035     # (2026-07-02f) seconds to wait after a mouse-move before reading the cursor in
                         # "settle" mode. Balance point: 0.022 was too aggressive (missed swaps), 0.045 was
                         # conservative — 0.035 (~2 frames @60fps) is still comfortably enough for the CSS
                         # cursor swap (~1 frame) while being noticeably faster per point. Tune UP toward
                         # 0.045 if hover misses return; the empty-result re-read guard covers the rare slow
                         # frame. Also the per-move PACE floor of the batched sweep.
HOVER_RADIAL_STEP = 52   # px between hover samples (ring + arc spacing) — ~a mob's clickable size;
                         # smaller = finer coverage but slower. Points per scan ≈ π·(MAX/STEP)², so this
                         # + MAX bound the EMPTY-scan time (a present mob is found early & stops the scan)
HOVER_RADIAL_MAX = 0.33  # outermost hover radius as a fraction of min(viewport w,h) — scans OUTWARD from
                         # the centre (character), NEAREST mob first. Mobs beyond it are reached after the
                         # look-around turns the view. A PRESENT mob is found in the first few points
                         # (~0.1s, stops early); an EMPTY scan is bounded by point-count × HOVER_SETTLE.
# ── hoverScanSpeedup (2026-07-02e, see hoverScanSpeedup_PLAN.md): rAF-synced cursor reads, a memory
# select-confirm, a memory-guided TARGETED hover (projects each wanted mob's world pos to a screen point
# instead of scanning blind), and a batched blind-sweep fallback. Tunables for all of it, grouped here: ──
SELECT_CONFIRM_MAX = 0.35   # (4.) select_and_attack: after the select-click, poll re_sense.read_target for
                             # up to this many seconds for a resolved target before falling back to ONE
                             # legacy pixel check (has_target/is_player_target) — a memory-outage-only path
SELECT_CONFIRM_POLL = 0.02  # (4.) spacing between those polls
REJECT_TTL = 10.0           # (A4) seconds an unwanted-species select point stays "hot" — long enough that
                             # the BLIND hover scan routes around a livelocking unwanted mob, short enough
                             # that the spot isn't permanently blinded once that mob moves off/dies
REJECT_RADIUS = 48          # (A4) px — a hover point within this of a fresh reject counts as "known
                             # unwanted" and is skipped (blind scans only — the targeted scan (A2) never
                             # hovers unwanted species in the first place, it's memory-species-filtered)
PROJ_MIN_SAMPLES = 6        # (A1) min accumulated passive click samples before the world<->screen
                             # projection is (re)fit
PROJ_MAX_ERR = 3.0          # (A1) world-units — a fit whose mean residual exceeds this is stale/bad, so
                             # _proj_ok() reports False and A2/A3 fall back to the blind hover scan
TARGETED_MOB_LIMIT = 4      # (A2) hover-target at most this many nearest WANTED mobs per targeted pass
TARGETED_SPIRAL_STEP = 16   # (A2) px between spiral rings around a mob's projected screen point
TARGETED_SPIRAL_MAX = 80    # (A2) outermost spiral ring (px) — click_nav._SPIRAL's proven radii/step
TARGETED_VIEWPORT_MARGIN = 40  # (A2) px — a mob whose PROJECTED point falls this close to the viewport
                             # edge is skipped for this pass (off-screen / a stale-looking projection)
CURSOR_SYNC_FRAMES = 1      # (B) how many rAF frame boundaries cursor_kind_synced waits after a mouse-move
                             # before reading the cursor. DEFAULT 1 (single rAF): the game registered its
                             # own rAF loop at startup, and within any frame the browser runs rAF callbacks
                             # in REGISTRATION order — so a callback we register mid-frame runs AFTER the
                             # game's cursor-update callback of the NEXT frame → one boundary is already a
                             # post-update read. Set 2 (double rAF, the belt-and-braces original) if hover
                             # misses return on your setup. 2 cost ~33ms/pt at 60fps (up to ~67ms under
                             # render load) on EVERY targeted-spiral point + engage() confirm and was the
                             # main cause of the 2026-07-02e "scan got slower" regression.
HOVER_SELF_RADII = (28, 56)  # SELF-FIRST close scan: radii (px) of the tight rings hovered around the
                             # screen centre (the character) BEFORE any targeted/blind scan. A mob already
                             # ATTACKING us stands adjacent to the char, so it must win over everything
                             # else — and these points are deliberately NEVER memory-filtered (projection
                             # error / the sector filter must not be able to skip a mob standing on top of
                             # us). ~8 points on the first ring, +4 per further ring (~20 pts ≈ 0.5s worst
                             # case, typically far less — it stops on the first curattack).
HOVER_BATCHED = True        # (C) — SUPERSEDED 2026-07-02g by the smooth GLIDE (HOVER_GLIDE) as the blind-scan
                             # primary. `_hover_scan_batched` (the paced TELEPORT sweep) is kept for reference
                             # but no longer routed to; glide replaced it (smooth, not jittery). Left in place
                             # so the health-flag lineage (_HOVER_BATCH_DEAD) + this flag aren't churned.
HOVER_SWEEP_PACE = None     # (C) seconds between mouse-moves in the (superseded) batched teleport sweep
# ── SMOOTH two-pass GLIDE scan (2026-07-02g): instead of teleporting point→point and pausing for a settle
# (visually jittery — the overlay shows the cursor jumping+stopping), GLIDE the cursor continuously along
# each ring with interpolated moves and detect curattack DURING the motion via the in-page cursor-swap
# logger (drained after each ring). TWO passes: pass 1 fast+coarse (wider ring separation), then — only if
# nothing found — pass 2 slower+tight (finer). Falls back to the per-point settle scan when the logger is
# dead (_HOVER_BATCH_DEAD). Applies to both hover_radial_memory (its memory radii) and the blind scan.
HOVER_GLIDE = True          # master switch for smooth glide-TRAVEL between sample points; False → plain
                             # teleport(steps=1)+settle per-point scan. EITHER way detection is the SAME
                             # reliable per-point dwell-read (_cursor_over_mob) — glide only changes how the
                             # cursor TRAVELS between points (interpolated = smooth, no teleport jumps).
HOVER_PASS1_STEP = 94       # pass-1 ring RADIAL separation (px) — ~1.8× HOVER_RADIAL_STEP: fewer rings, a
                             # quick coarse sweep. (Blind scan: ring spacing. Radial-mem: pass-1 arc spacing.)
# (2026-07-02h) DETECTION dwell per point, per pass. The game only swaps its CSS cursor to curattack when
# the pointer RESTS on the mob for ~1 render frame (~16ms), so a pure continuous glide sweeps past a mob
# within a frame and never triggers the swap — we therefore DWELL this long at each sample point and read
# the cursor there (the same reliable read the fight-prefetch uses). PASS1 uses HOVER_SETTLE (coarse+quick
# by having fewer points, not by a risky-short dwell); PASS2 dwells a touch longer for the tight pass.
HOVER_PASS2_SETTLE = 0.045  # pass-2 per-point dwell (s) — slightly longer than HOVER_SETTLE for reliability
HOVER_PASS1_PACE = 0.004    # (VESTIGIAL 2026-07-02h) old glide pace — detection is now per-point dwell, not
HOVER_PASS2_PACE = 0.018    # (VESTIGIAL 2026-07-02h)  a mid-motion logger read; kept only to avoid churn
HOVER_PASS2_TOL = 40        # radial-mem pass-2 ONLY: add ±this-px tolerance SUB-RINGS around each mob radius
                             # (finer radial coverage on the slow pass) — 0 disables the sub-rings
GLIDE_PX_PER_STEP = 12      # cursor travel per interpolation step (px) — smaller = smoother glide but more CDP
                             # events; steps per segment = round(segment_len / this), capped at GLIDE_MAX_STEPS
GLIDE_MAX_STEPS = 30        # cap on interpolation steps for one glide segment (bounds CDP traffic on long hops)
HOVER_VIZ = False           # 🔵 live hover-path DEBUG overlay (panel: Grind local → "Show hover-scan path",
                             # applied on Start): injects a pointer-events-none <canvas> into the GAME window
                             # that draws a blue circle at the live pointer + a ~3s fading trace of its path,
                             # entirely page-side from real mousemove events (ZERO extra CDP traffic) — shows
                             # exactly where the scan hovered. Removed best-effort when the grind run ends.
HOVER_EDGE_MARGIN = 0.01    # (2026-07-02f) fraction the calibrated boxes are treated as EXPANDED (exclusions)
                             # / SHRUNK (ROI) by in _hover_point_ok — the game UI's reactive area is a hair
                             # larger than the drawn calibration box, so keep every hover point clear of the
                             # boundary (stops the cursor slipping into the bottom toolbar/banner on an edge point).
# ── radial-mem scan + fight-prefetch (2026-07-02f) ─────────────────────────────────────────────────
# The primary cursor finder now scans FULL CIRCLES at memory-derived radii around the character (see
# hover_radial_memory) instead of a per-mob projected spiral. KEY ROBUSTNESS INSIGHT: a wanted mob's world
# DISTANCE is exact from memory, but its screen ANGLE depends on the shaky/sign-ambiguous camera-yaw
# calibration. The projected screen RADIUS, however, = magnitude of the projection's linear part applied to
# the world offset, and magnitude is ROTATION-INVARIANT — so the radius is trustworthy even when the angle
# isn't. Hence: scan a full circle at each mob's radius; don't trust the angle to pick a sector.
FIGHT_PREFETCH = True       # during the pure-hold FIGHT (memory sense), use the idle time between target
                             # polls to hover-scan for the NEXT mob and cache confirmed curattack screen
                             # points, so on kill we engage a KNOWN candidate cold instead of scanning from
                             # scratch. HOVERING NEVER CLICKS, so it can't disturb the pure-hold fight. Set
                             # False to disable if it ever interferes with fight responsiveness.
FIGHT_PREFETCH_POINTS = 3   # hover at most this many prefetch points PER fight poll (tiny budget → the
                             # target read still happens every ~MEM_FIGHT_POLL). 2-4 is sensible.
FIGHT_PREFETCH_TTL = 2.0     # seconds the prefetch cache (+ its overlay boxes) stays usable AFTER the
                             # target's DEATH — the countdown starts at death (`death_t`), NOT at each
                             # candidate's found-time, so candidates found early in a long fight are still
                             # valid at the kill. Beyond this the mobs have likely moved → discard + scan fresh.
FIGHT_PREFETCH_MIN_RADIUS = 140  # PREFETCH-ONLY hard inner-radius exclusion (px from screen centre): skip any
                             # radial-mem candidate closer than this to the character. The mob we're FIGHTING
                             # sits melee-close to the char (near screen centre), so a close prefetch point
                             # keeps re-confirming the SAME mob we're already on — prefetch only cares about
                             # OTHER mobs further out. This is IN ADDITION to the target-projected-pos
                             # exclusion (belt-and-suspenders; that one is skipped when the target can't
                             # project, this one always applies). NOT applied to the self-first scan or the
                             # idle radial-mem scan — those still want close mobs (a mob meleeing us while
                             # idle is exactly the self-first scan's job).
# Blind-scan MEMORY point-filter (extension, 2026-07-02e): before a blind scan, read the memory mob-map
# ONCE and pre-filter the ring points to the regions where mobs actually ARE — keeping the outward
# nearest-ring-first ORDER, just skipping points the map says are empty. Tiered by what's available
# (see _blind_scan_filter): tier 1 = projected-mob proximity, tier 2 = angular sector via the camera
# heading, tier 3 = no filter (a memory outage must never shrink coverage).
HOVER_MOB_NEAR_PX = 110     # (tier 1) keep a blind-scan point only if it's within this many px of at
                             # least one wanted mob's PROJECTED screen point. Generous vs the fit error
                             # (~3u ≈ tens of px) + mob movement during the scan; shrink for a tighter
                             # (faster) scan once the projection proves accurate on your setup.
HOVER_SECTOR_TOL_DEG = 40   # (tier 2, no healthy projection but camera heading readable) keep a point
                             # only if its polar angle about the screen centre is within this many
                             # degrees of a wanted mob's predicted screen bearing — OR its left/right
                             # MIRROR (the mirror sign of the heading→screen mapping isn't established,
                             # so both are kept; still skips a big fraction of an empty half-screen).
WALK_TIME = 1.2          # seconds to let the character walk before the next scan
WALK_RADIUS = 0.22       # walk distance as a fraction of min(viewport) — how far the click-to-move
                         # point is from the player (bigger = walk further each relocate)
STUCK_NO_KILL = 120.0    # seconds with NO kill → assume we're wedged/stuck: press Enter twice (1s
                         # apart) to clear any open chat/dialog, then WASD-wiggle out of a narrowed-in
                         # spot. Keep high so ordinary searching for mobs doesn't trigger it.
WASD_HOLD = (0.6, 1.1)   # random hold range (seconds) per WASD key during the unstuck wiggle
EXTRA_TILT = 90          # phase-2 search: extra FORWARD camera tilt (right-drag UP px) to see
                         # further into the distance before giving up on the current spot
TEXT_MIN = 40            # min mob-name-tag text pixels in view to walk TOWARD the densest text;
                         # below this we don't trust a direction and walk a random way instead
CYCLE = 0.28             # loop interval while searching (IDLE) — also the post-turn settle. Lower =
                         # faster mob-finding (the camera snaps, so little settle is needed)
FIGHT_POLL = 0.1         # loop interval while FIGHTING (PIXEL target mode) — poll the top-mid mob display
                         # this often so we notice the fight ended (frame gone) quickly. Also the spacing
                         # between the death-confirm polls (see DEATH_CONFIRM_CONSECUTIVE_ABSENT). Only
                         # used in TARGET_SENSE="pixel"; memory mode uses MEM_FIGHT_POLL and no confirm.
TARGET_SENSE = "memory"  # how the FIGHT loop senses the SELECTED target (alive/HP/death), panel-selectable
                         # (Grind local → ⚙️ Advanced), applied on Start:
                         #   "memory" = DEFAULT: read the exact target from WASM memory (controller+2656 ->
                         #              registry -> deobfuscated HP + kind). Death is TRUSTED IMMEDIATELY
                         #              (hp<=0 / target cleared) with NO death-confirm poll — memory is
                         #              exact and never flickers. Fastest kill->next-mob.
                         #   "pixel"  = the original vision path: the top-centre target FRAME + mob_hp bar,
                         #              with the FIGHT_POLL + DEATH_CONFIRM_CONSECUTIVE_ABSENT confirm window
                         #              (needed because the pixel bar can flicker). Slower but vision-only.
MEM_FIGHT_POLL = 0.1     # loop interval while FIGHTING in TARGET_SENSE="memory" — how often we re-read the
                         # target's exact HP from memory (fixed ~100ms; independent of the pixel FIGHT_POLL)
# ── Mob-type selection (panel: 🎯 Nearby mob types + toggles; applied on Start via apply_grind_config) ──
MOB_SCAN_TYPE = False    # "scan for mob type": after a hover-scan SELECTS a mob, read its species from
                         # MEMORY and only ATTACK it if it's a wanted type (in MOB_GRIND_SET). Off = attack
                         # whatever the cursor found (original behaviour).
MOB_GRIND_SET = None     # set of wanted species ids (IDS_PROPMOVER numbers as strings), or None = grind ALL.
                         # Built on Start from the panel's ticked mob types.
MOB_STEER = False        # EXPERIMENTAL "reposition toward wanted mobs": when idle (no mob engaged) use the
                         # MEMORY mob map to walk toward — and face — the wanted-mob centroid, so we drift
                         # into a denser spot while grinding instead of wandering randomly. Off by default.
MOB_STEER_MIN_DIST = 12.0  # world-units: don't bother repositioning while a wanted mob is already this close
MOB_STEER_FAR = 60.0     # world-units: if the nearest wanted mob is beyond this, skip the (close) hover-scan
                         # and just walk toward them first — no point hovering empty space around us
FACE_TOLERANCE = 0.5     # radians (~29°): when repositioning, don't rotate the camera if the wanted-mob
                         # CENTER OF MASS is already roughly ahead — only turn when it's meaningfully off.
FACE_SIGN = 1.0          # sign of the right-drag→view-rotation relation; flip to -1.0 if the camera turns
                         # the WRONG way when facing the cluster (self-corrects over a walk either way).
# Cut UNNECESSARY camera adjustments: use the memory mob-map to avoid turning/recalibrating when wanted
# mobs are already right here (detection just missed them this frame). Only adjust the view when it's
# actually needed — no mobs nearby (must search) or repeated detection misses (must bring them into view).
VIEW_MOB_RANGE = 45.0    # world-units: wanted mobs within this count as "here / should be findable soon"
MEM_NEAR_RETRY = 3       # when wanted mobs ARE near but detection found none, retry in place this many times
                         # (no camera turn) before falling back to a look-around turn
# learn
LEARN_VIEWS = 6          # camera steps to scan during learning
MIN_SAMPLES = 3          # a colour must recur >= this many times to count as a mob
MAX_TYPES = 2            # keep at most this many mob colours (most-recurring win)
MIN_ENGAGE = 35          # mob blob size bounds (apparent px area; smaller when zoomed out)
MAX_MOB = 600
NAMEPLATE_DROP = 25      # the mob sits ~this many px below its name tag (measured at this zoom)
# MOB name tags are RED (aggro) or light YELLOW/BEIGE (normal). PLAYER tags are WHITE or GREEN
# and must NEVER be targeted. We detect the yellow/beige band below; white is excluded by the
# saturation floor (player white ≈ 0 sat), green by the warm hue band.
TAG_YELLOW_H = (12, 42)  # OpenCV hue band (0-179) for yellow/beige normal-mob name tags
TAG_YELLOW_SMIN = 28     # min saturation — EXCLUDES near-white PLAYER tags. RAISE if players still
                         # get clicked; LOWER if pale/beige mob tags are being missed.
TAG_YELLOW_VMIN = 160    # min brightness for a mob name tag
# Your PET's name tag is LIGHT PURPLE/LAVENDER. It leaks a few yellow-edged pixels into the beige
# band, so a name-tag is REJECTED when its box holds more PURPLE than beige pixels (the pet) — same
# spirit as never matching white/green PLAYER tags. (Pet hue ≈131; mobs red/beige, players green.)
TAG_PURPLE_H = (122, 160)
TAG_PURPLE_SMIN = 80
TAG_PURPLE_VMIN = 150
PET_BODY_SPAN = 80       # px below a purple pet-name tag to treat as the pet's body (exclusion zone)
PET_MAX_ZONES = 3        # at most this many purple "pet" tags are believable (you + a couple nearby).
                         # MORE than this = a purple effect/UI is false-firing → ignore them all, else a
                         # transient purple blob silently masks real mobs and the bot wanders past them.
AGGRO_EXCL_X = (0.30, 0.70)  # central column EXCLUDED from aggro (healer-flee) detection — the
AGGRO_EXCL_Y = (0.40, 0.92)  # player's own tag and RED system messages ('Nicht genug MP' etc.) live
                         # here and otherwise read as red name tags → false flee triggers.
# Mob-name avoidance by template (no OCR). Two groups, each a binary word template (white letters
# on black) in templates/: 'giant_*.png' (DANGEROUS — skip targeting AND don't walk toward) and
# 'skip_*.png' (e.g. Catcher, Red Horse — NOT dangerous, just don't attack). No template = off.
GIANT_MATCH_THRESH = 0.55  # match strength for a name-tag word vs a template
AVOID_GIANT = True       # skip Giant mobs (target + walk-toward). Toggle in the control panel.
AVOID_SKIP = True        # skip Catcher/Red Horse mobs (don't attack). Toggle in the control panel.
MENU_Y0_F = 0.10         # menu-search vertical margins (fractions): skip the top/bottom edges so
MENU_Y1_F = 0.88         # bright HUD/chat/floating text there can't false-match the menu label.
MENU_MATCH_THRESH = 0.60  # the player context menu is detected by template-matching its "Handel"/
                         # "Trade" label (templates/menu_*.png) — ONLY that menu triggers Esc, so
                         # selecting a mob never does. Lower if a real menu is missed (e.g. slight
                         # scale diff), raise if it ever false-triggers. Needs same game resolution as the
                         # template screenshot (rebuild templates if you change window size/UI scale).
CAPTCHA_FILL_MIN = 0.60  # the anti-bot reCAPTCHA panel ('Ich bin kein Roboter') is a SOLIDLY white
                         # box — this min white-fill ratio rejects white-text boxes (instructions/chat)
CAPTCHA_CB_X = 0.089     # checkbox centre x as a fraction of the panel width (from its left edge)
# Line-of-sight-BLOCKED message (we can't reach the char we're healing). Detected by template-
# matching the keyword per language (templates/los_*.png, binary RED system-message text). Add one
# template per client language you use. Keywords to make templates for (for reference):
LOS_KEYWORDS = ["Hindernisses", "Hindernis", "Sichtlinie", "obstacle", "line of sight", "blocked"]
LOS_MATCH_THRESH = 0.55  # match strength for the line-of-sight keyword. No template = feature OFF.
ZOOM_IN_STEPS = 6        # after maxing zoom-out, zoom back IN this many (more = bigger mobs)
TILT_BACK_UP = 230       # after maxing top-down, ease the camera back toward the horizon by
                         # this many px (right-drag UP) so more mobs fit in view. 0 = full
                         # bird's-eye; higher = more oblique/forward angle (more mobs, less top-down)
ROI = (0.0, 0.08, 1.0, 0.90)     # scan area (x0,y0,x1,y1 fractions). All the *_EXCL regions below are
                                 # carved out of it. Calibrate visually via the control panel.
HUD_EXCL = (0.0, 0.0, 0.23, 0.20)      # top-left player HUD (red portrait/bars) — ignored
MINIMAP_EXCL = (0.78, 0.0, 1.0, 0.26)  # top-right minimap + panel — ignored
SELF_EXCL = (0.40, 0.44, 0.60, 0.68)   # the player's own char + name tag (centre) — mobs here skipped
CHAT_EXCL = (0.0, 0.76, 0.31, 1.0)      # (x0,y0,x1,y1) bottom-LEFT chat window — its (often red/pink)
                                 # text reads as mob/aggro tags. Excluded from ALL detection (grind
                                 # targeting AND the healer's aggro/red-tag flee).
BANNER_EXCL = (0.36, 0.74, 0.64, 0.89)  # (x0,y0,x1,y1) bottom-centre scrolling action-log banner
                                 # ("NaxozRM benutzt Heal." …). Its text is misread as a mob tag — we
                                 # carve it out of the scan mask AND reject any mob whose BODY lands
                                 # here (a tag just above the banner would otherwise drop a click into
                                 # it). Widen if clicks still land there.

# HUD positions as FRACTIONS of the viewport (so they scale with the window size).
# Flyff's UI scales with the canvas, so fractions stay correct when the window is
# resized; absolute pixels would not.
# The player status window is anchored at a FIXED PIXEL offset from the top-left and does NOT
# scale with the window size — so the HP bar is read at ABSOLUTE pixels, not fractions.
HP_X0, HP_X1 = 100, 232    # search span for the red HP bar (px from LEFT)
HP_Y0, HP_Y1 = 35, 46      # HP bar band (px from TOP)
FILL_L, FILL_R = 116, 224  # bar fill: left edge = 0%, right edge = 100% (px)
# MP bar (BLUE) — stacked under HP in the same status window. Same px scheme as HP.
MP_X0, MP_X1 = 100, 232
MP_Y0, MP_Y1 = 48, 59
MP_FILL_L, MP_FILL_R = 116, 224
# EXP bar (LIGHT BLUE / cyan) — thin bar lower in the status window.
EXP_X0, EXP_X1 = 100, 232
EXP_Y0, EXP_Y1 = 74, 83
EXP_FILL_L, EXP_FILL_R = 116, 224
# TARGET HP bar (RED) — the SELECTED target's HP in the TOP-CENTRE target frame (mob, or the char a
# healer has selected). Absolute px (defaults ≈ a 1278-wide view; CALIBRATE for your resolution —
# the target frame is centred, so it shifts if you resize the window width).
TGT_X0, TGT_X1 = 571, 700
TGT_Y0, TGT_Y1 = 41, 56
TGT_FILL_L, TGT_FILL_R = 571, 700
TGT_MIN_RED = 60         # min red px in the box to count a target as present (else None)
HP_CALIB_KEYS = ("HP_X0", "HP_X1", "HP_Y0", "HP_Y1", "FILL_L", "FILL_R",   # absolute px (ints)
                 "MP_X0", "MP_X1", "MP_Y0", "MP_Y1", "MP_FILL_L", "MP_FILL_R",
                 "EXP_X0", "EXP_X1", "EXP_Y0", "EXP_Y1", "EXP_FILL_L", "EXP_FILL_R",
                 "TGT_X0", "TGT_X1", "TGT_Y0", "TGT_Y1", "TGT_FILL_L", "TGT_FILL_R")
GRIND_CALIB_KEYS = ("ROI", "HUD_EXCL", "MINIMAP_EXCL", "BANNER_EXCL", "CHAT_EXCL", "SELF_EXCL")  # (x0,y0,x1,y1) fractions


def _calib_path():
    return os.path.join(os.path.dirname(os.path.abspath(__file__)), "scanner_calib.json")


def save_calib():
    """Persist the HP-bar pixel calibration (ints) + grind region fractions to scanner_calib.json."""
    d = {k: int(globals()[k]) for k in HP_CALIB_KEYS}
    for k in GRIND_CALIB_KEYS:
        d[k] = [round(float(v), 4) for v in globals()[k]]
    try:
        with open(_calib_path(), "w") as f:
            json.dump(d, f, indent=2)
        return True
    except Exception:
        return False


try:                                       # load saved calibration over the defaults above
    with open(_calib_path()) as _f:
        for _k, _v in json.load(_f).items():
            if _k in HP_CALIB_KEYS:
                globals()[_k] = int(_v)
            elif _k in GRIND_CALIB_KEYS:
                globals()[_k] = tuple(float(x) for x in _v)
except Exception:
    pass
MOB_BOX = (0.447, 0.050, 0.548, 0.067)        # selected mob's HP-bar fill span (top-centre target frame)
TARGET_MP_BOX = (0.455, 0.072, 0.650, 0.100)  # the BLUE MP bar just below it — the reliable
                                              # "a mob is selected" signal (present at any HP, none
                                              # in the world). Tune the y-band to your resolution.


# ---------- sensing ----------
def hp_report(img):
    """Read the player HP bar (top-left, vision). Returns (frac, edge_x, red_px):
      frac   = HP fraction 0..1 (rightmost red column mapped across the fill span)
      edge_x = absolute x of the rightmost red pixel (-1 if none) — the detected fill edge
      red_px = how many red pixels were found in the read band
    edge_x/red_px are for diagnosis: if red_px is ~0 the band is OFF the bar; if edge_x
    sits at the far right even at low HP, the band is catching the bar's frame/border."""
    h, w = img.shape[:2]
    y0, y1 = HP_Y0, min(HP_Y1, h)              # ABSOLUTE pixels (fixed-position HUD)
    x0, x1 = HP_X0, min(HP_X1, w)
    fl, fr = FILL_L, FILL_R
    box = img[y0:y1, x0:x1].astype(int)
    if box.size == 0:
        return 0.0, -1, 0
    r, g, b = box[:, :, 2], box[:, :, 1], box[:, :, 0]
    red = (r > 110) & (r > b + 40) & (r > g + 25)
    xs = np.where(red.any(axis=0))[0]
    if len(xs) == 0:
        return 0.0, -1, 0
    edge = int(xs.max() + x0)
    return max(0.0, min(1.0, (edge - fl) / (fr - fl))), edge, int(red.sum())


def hp_fraction(img):
    return hp_report(img)[0]


def _bar_report(img, x0, x1, y0, y1, fl, fr, kind):
    """Generic status-bar read: find the rightmost coloured (kind) pixel in the box and map it
    across the fill span. kind: 'red' (HP), 'blue' (MP), 'gold' (EXP). Returns (frac, edge_x, px)."""
    h, w = img.shape[:2]
    y0, y1 = y0, min(y1, h)
    x0, x1 = x0, min(x1, w)
    box = img[y0:y1, x0:x1].astype(int)
    if box.size == 0:
        return 0.0, -1, 0
    r, g, b = box[:, :, 2], box[:, :, 1], box[:, :, 0]
    if kind == "blue":                             # MP = DARK blue: blue dominates, green stays low
        mask = (b > 90) & (b > r + 35) & (b > g + 30)
    elif kind == "cyan":                           # EXP = LIGHT blue/cyan: blue AND green high, red low
        mask = (b > 110) & (g > 110) & (b > r + 30) & (g > r + 30)
    else:                                          # 'red' (HP)
        mask = (r > 110) & (r > b + 40) & (r > g + 25)
    xs = np.where(mask.any(axis=0))[0]
    if len(xs) == 0:
        return 0.0, -1, 0
    edge = int(xs.max() + x0)
    frac = (edge - fl) / (fr - fl) if fr > fl else 0.0
    return max(0.0, min(1.0, frac)), edge, int(mask.sum())


def mp_report(img):
    """Read the player MP bar (blue). Returns (frac, edge_x, blue_px)."""
    return _bar_report(img, MP_X0, MP_X1, MP_Y0, MP_Y1, MP_FILL_L, MP_FILL_R, "blue")


def exp_report(img):
    """Read the player EXP bar (light blue / cyan). Returns (frac, edge_x, cyan_px)."""
    return _bar_report(img, EXP_X0, EXP_X1, EXP_Y0, EXP_Y1, EXP_FILL_L, EXP_FILL_R, "cyan")


def dump_hp_crop(img, outdir):
    """Save the top-left status window + the HP detection box drawn on it (yellow=search box,
    green=fill 0% edge, blue=fill 100% edge) to outdir, so the crop calibration can be eyeballed."""
    os.makedirs(outdir, exist_ok=True)
    h, w = img.shape[:2]
    cv2.imwrite(os.path.join(outdir, "full.png"), img)
    # crop wide enough to include BOTH the top-left status window AND the top-centre TARGET frame
    cw = max(int(0.66*w), TGT_X1 + 10)
    sw = img[0:int(0.16*h), 0:min(w, cw)]
    ann = sw.copy()
    bars = [("HP", HP_X0, HP_X1, HP_Y0, HP_Y1, FILL_L, FILL_R),
            ("MP", MP_X0, MP_X1, MP_Y0, MP_Y1, MP_FILL_L, MP_FILL_R),
            ("EXP", EXP_X0, EXP_X1, EXP_Y0, EXP_Y1, EXP_FILL_L, EXP_FILL_R),
            ("TGT", TGT_X0, TGT_X1, TGT_Y0, TGT_Y1, TGT_FILL_L, TGT_FILL_R)]
    for name, x0, x1, y0, y1, fl, fr in bars:
        cv2.rectangle(ann, (x0, y0), (x1, y1), (0, 255, 255), 1)      # yellow = search box
        cv2.line(ann, (fl, y0), (fl, y1), (0, 255, 0), 1)            # green  = fill 0% edge
        cv2.line(ann, (fr, y0), (fr, y1), (255, 0, 0), 1)           # blue   = fill 100% edge
        cv2.putText(ann, name, (max(0, x0 - 26), max(8, y0 - 2)),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.32, (255, 255, 255), 1, cv2.LINE_AA)
    cv2.imwrite(os.path.join(outdir, "hp_box_annotated_x3.png"),
                cv2.resize(ann, None, fx=2, fy=2, interpolation=cv2.INTER_NEAREST))
    hp, mp, ex = hp_report(img), mp_report(img), exp_report(img)
    tgt = mob_hp(img)
    with open(os.path.join(outdir, "readme.txt"), "w", encoding="utf-8") as f:
        f.write(f"resolution: {w}x{h}\n"
                f"HP : {hp[0]*100:5.1f}%  px={hp[2]}   box x[{HP_X0}:{HP_X1}] y[{HP_Y0}:{HP_Y1}]  fill {FILL_L}-{FILL_R}\n"
                f"MP : {mp[0]*100:5.1f}%  px={mp[2]}   box x[{MP_X0}:{MP_X1}] y[{MP_Y0}:{MP_Y1}]  fill {MP_FILL_L}-{MP_FILL_R}\n"
                f"EXP: {ex[0]*100:5.1f}%  px={ex[2]}   box x[{EXP_X0}:{EXP_X1}] y[{EXP_Y0}:{EXP_Y1}]  fill {EXP_FILL_L}-{EXP_FILL_R}\n"
                f"TGT: {'(no target)' if tgt is None else f'{tgt*100:5.1f}%'}   box x[{TGT_X0}:{TGT_X1}] y[{TGT_Y0}:{TGT_Y1}]  fill {TGT_FILL_L}-{TGT_FILL_R}\n"
                "hp_box_annotated_x3.png: YELLOW=search box, GREEN=fill 0%, BLUE=fill 100% (per bar).\n")
    return w, h, hp[2]


def dump_grind_regions(img, outdir, maxw=760, show_hover=False):
    """Draw the grind-scanner regions on a live screenshot so they can be eyeballed/calibrated:
    GREEN = ROI (scan area); RED = ignored zones (HUD, minimap, banner, chat); ORANGE = SELF
    (player centre, mobs here skipped). If show_hover=True, ALSO draws small CYAN dots at every
    point `hover_scan_radial` would hover over (the cursor/hybrid detection fallback that reads the
    game's CSS cursor to find mobs colour missed) — the exact same points, via `_hover_scan_points`,
    so the overlay always matches what actually runs. Saves grind_regions.png (scaled to <= maxw
    wide) and a readme. Returns (w, h, path, n_hover_points)."""
    os.makedirs(outdir, exist_ok=True)
    h, w = img.shape[:2]
    vis = img.copy()

    def rect(box, color, label):
        x0, y0 = int(box[0]*w), int(box[1]*h)
        x1, y1 = int(box[2]*w), int(box[3]*h)
        cv2.rectangle(vis, (x0, y0), (x1, y1), color, 2)
        cv2.putText(vis, label, (x0 + 4, min(y1 - 6, y0 + 18)),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1, cv2.LINE_AA)

    rect(ROI, (0, 255, 0), "ROI scan")              # green = where we look
    rect(HUD_EXCL, (0, 0, 255), "HUD")              # red = ignored
    rect(MINIMAP_EXCL, (0, 0, 255), "minimap")
    rect(BANNER_EXCL, (0, 0, 255), "banner")
    rect(CHAT_EXCL, (0, 0, 255), "chat")
    rect(SELF_EXCL, (0, 165, 255), "self")          # orange = player centre (mobs skipped)

    hover_pts = _hover_scan_points(w, h) if show_hover else []

    scale = min(1.0, maxw / float(w))
    out = cv2.resize(vis, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) if scale < 1 else vis
    if hover_pts:
        # Draw the hover-scan PATH on the RESIZED image at a FIXED pixel size (drawing pre-resize at
        # full-res coords made the marks shrink to <1px and vanish once scaled down for the panel).
        # Show: the outward-spiral PATH (a cyan polyline through the points in scan order = the order the
        # cursor actually visits) + a dot at each sample + the centre (the character). A dark under-stroke
        # keeps it visible against both bright and dark backgrounds.
        sp = [(int(gx * scale), int(gy * scale)) for gx, gy in hover_pts]
        poly = np.array(sp, np.int32).reshape(-1, 1, 2)
        cv2.polylines(out, [poly], False, (0, 0, 0), 3, cv2.LINE_AA)        # dark under-stroke
        cv2.polylines(out, [poly], False, (255, 255, 0), 1, cv2.LINE_AA)    # cyan spiral path
        for cx, cy in sp:
            cv2.circle(out, (cx, cy), 4, (0, 0, 0), -1, cv2.LINE_AA)        # outline for contrast
            cv2.circle(out, (cx, cy), 3, (255, 255, 0), -1, cv2.LINE_AA)    # cyan dot
        cxx, cyy = int(w * 0.5 * scale), int(h * 0.5 * scale)               # centre = the character
        cv2.drawMarker(out, (cxx, cyy), (0, 165, 255), cv2.MARKER_CROSS, 16, 2, cv2.LINE_AA)
        cv2.putText(out, f"hover-scan path: {len(hover_pts)} pts (step={HOVER_RADIAL_STEP}px max={HOVER_RADIAL_MAX}, ROI-clipped)",
                    (10, out.shape[0] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 0), 1, cv2.LINE_AA)
    path = os.path.join(outdir, "grind_regions.png")
    cv2.imwrite(path, out)
    with open(os.path.join(outdir, "grind_readme.txt"), "w", encoding="utf-8") as f:
        f.write(f"resolution: {w}x{h}\n"
                f"ROI (scan): {ROI}\nHUD_EXCL: {HUD_EXCL}\nMINIMAP_EXCL: {MINIMAP_EXCL}\n"
                f"BANNER_EXCL: {BANNER_EXCL}\nCHAT_EXCL: {CHAT_EXCL}\nSELF_EXCL: {SELF_EXCL}\n"
                "GREEN=scan area, RED=ignored zones, ORANGE=player centre (mobs there skipped).\n"
                + (f"CYAN=hover-scan points ({len(hover_pts)}), clipped to ROI minus exclusions.\n" if show_hover else ""))
    return w, h, path, len(hover_pts)


_last_hp_recover = 0.0
def _park_point(w, h):
    """A screen point SAFE to park the idle cursor on: inside ROI and clear of every exclusion box (+the
    HOVER_EDGE_MARGIN), so it never lands in the bottom toolbar/banner (the old park at y=0.94 was BELOW
    ROI, straight into the banner — it popped the UI overlay + wedged an Esc loop). Prefers a low, off-centre
    spot (clear of the centre where mobs sit and of the bottom-centre banner); returns the first candidate
    _hover_point_ok accepts, else the ROI vertical centre as a guaranteed-inside fallback."""
    for fy in (ROI[3] - 0.03, 0.55, 0.5, 0.45, (ROI[1] + ROI[3]) / 2):
        for fx in (0.72, 0.28, 0.62, 0.38, 0.5):
            if _hover_point_ok(fx, fy):
                return int(fx * w), int(fy * h)
    return int(w * 0.5), int(h * (ROI[1] + ROI[3]) / 2)


def park_cursor(page):
    """Move the in-canvas cursor to a neutral SAFE spot (see _park_point — inside ROI, clear of every
    blocked region) so it stops HOVERING ground items / players — their tooltips and context menus block
    detection and otherwise wedge the bot in an Esc/T loop. NEVER parks in a blocked region now (the old
    bottom-centre park landed in the toolbar/banner)."""
    try:
        w, h = viewport(page)
        x, y = _park_point(w, h)
        jx = x + float(np.random.uniform(-20, 20))
        if not _hover_point_ok(jx / w, y / h):
            jx = x                                   # jitter would leave the safe area → drop it
        page.mouse.move(jx, y, steps=1)              # steps=1: only the endpoint fires, no path events
        time.sleep(0.1)
    except Exception:
        pass


def ensure_hp_visible(page, img=None):
    """If the player HP display can't be read (no red HP bar in its band), recover it. Flyff draws
    an in-canvas cursor, so a cursor parked over the top-left HP bar OCCLUDES it in the screenshot —
    so FIRST move the cursor to the upper-middle (clear of the HP bar) EVERY time, re-check, and
    only press T (rate-limited) if it's still hidden (HUD actually off). Returns True if the HP
    display was missing this tick (caller should skip the tick and re-read next time)."""
    global _last_hp_recover
    if img is None:
        img = grab(page)
    if hp_report(img)[2] > 0:                     # red_px > 0 → HP bar found, all good
        return False
    park_cursor(page)                             # off the HP bar AND off play-area items/tooltips
    if hp_report(grab(page))[2] > 0:              # cursor WAS occluding it → now clear, no T needed
        print("[hp   ] HP was cursor-occluded — parked cursor")
        return True
    now = time.time()                             # still hidden → HUD is off; press T (rate-limited)
    if now - _last_hp_recover >= HP_RECOVER_COOLDOWN:
        _last_hp_recover = now
        page.keyboard.press("t")
        time.sleep(0.3)
        print("[hp   ] HP display not found — moved cursor up + pressed T")
    return True


def _mob_red(img):
    h, w = img.shape[:2]
    x0, y0, x1, y1 = (int(MOB_BOX[0]*w), int(MOB_BOX[1]*h),
                      int(MOB_BOX[2]*w), int(MOB_BOX[3]*h))
    box = img[y0:y1, x0:x1].astype(int)
    r, g, b = box[:, :, 2], box[:, :, 1], box[:, :, 0]
    red = (r > 120) & (r > b + 50) & (r > g + 50)
    return red, box.shape[1]


PLAYERTAG_MATCH_THRESH = 0.62  # the top-centre target frame is a PLAYER (not a mob) if its gold name
                         # line contains a '#XXXXX' id — mobs never do. Template-match the '#' glyph
                         # (templates/playertag_*.png). Player≈1.0, mob≈0.47, so 0.62 separates cleanly.
_PLAYERTAG_TPLS = None
def _playertag_templates():
    global _PLAYERTAG_TPLS
    if _PLAYERTAG_TPLS is None:
        d = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
        _PLAYERTAG_TPLS = [t for t in (cv2.imread(p, cv2.IMREAD_GRAYSCALE)
                           for p in sorted(glob.glob(os.path.join(d, "playertag_*.png")))) if t is not None]
    return _PLAYERTAG_TPLS


def is_player_target(img):
    """True if the top-centre target frame belongs to a PLAYER, not a mob — players have a
    '#XXXXX' id in the gold name line; mobs never do. Template-matches the '#' glyph in the gold
    text of the name line. No template = always False."""
    tpls = _playertag_templates()
    if not tpls:
        return False
    h, w = img.shape[:2]
    hsv = cv2.cvtColor(img[0:int(0.09*h), int(0.34*w):int(0.66*w)], cv2.COLOR_BGR2HSV)
    H, S, V = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2]
    gold = (((H >= 18) & (H <= 40) & (S >= 80) & (V >= 170)).astype(np.uint8)) * 255   # name-line text
    for t in tpls:
        if gold.shape[0] < t.shape[0] or gold.shape[1] < t.shape[1]:
            continue
        r = cv2.matchTemplate(gold, t, cv2.TM_CCOEFF_NORMED)
        if r.size and float(r.max()) >= PLAYERTAG_MATCH_THRESH:
            return True
    return False


def _left_run(cols, margin):
    """Length of the contiguous True run that STARTS within `margin` columns of the left edge — i.e. a
    bar FILLED from the left. 0 if the first True is further right than margin (a centred blob / text,
    not a left-anchored bar fill). `cols` is a 1-D boolean array (per-column presence)."""
    idx = np.flatnonzero(cols)
    if len(idx) == 0 or idx[0] > margin:
        return 0
    run = 0
    for c in range(int(idx[0]), len(cols)):
        if cols[c]:
            run += 1
        else:
            break
    return run


def _target_masks(img):
    """(red, blue) boolean masks of the target-frame BAR region — ABSOLUTE px: x centred on the window
    (the fixed-pixel frame is centred), y from the top. Covers both the HP (red) and MP (cyan-blue) bars."""
    h, w = img.shape[:2]
    cx = w // 2
    x0, x1 = max(0, cx + TARGET_BAR_X[0]), min(w, cx + TARGET_BAR_X[1])
    y0, y1 = max(0, TARGET_BAR_Y[0]), min(h, TARGET_BAR_Y[1])
    reg = img[y0:y1, x0:x1]
    if reg.size == 0 or x1 - x0 < 10:
        return None, None
    hsv = cv2.cvtColor(reg, cv2.COLOR_BGR2HSV)
    H, S, V = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2]
    red = (((H <= 9) | (H >= 170)) & (S > 110) & (V > 110))
    blue = ((H >= 98) & (H <= 132) & (S > 90) & (V > 110))     # cyan/blue MP bar (NOT the purple circle)
    return red, blue


def target_present(img):
    """Is the mob flag (top-centre target frame) up? It ALWAYS shows a RED HP bar with a BLUE MP bar
    directly BELOW it, both FILLED FROM THE LEFT. We require a wide LEFT-ANCHORED blue bar (the MP bar
    is HP-independent, so a live mob reads present down to ~0% HP) AND a left-anchored red bar ABOVE it.
    Requiring both-and-stacked rejects the single-colour false positives: a lone RED aggro name tag in
    the top-centre (no blue under it) and blue WATER (no red bar above it). Drops the instant the flag
    closes (mob dead/deselected)."""
    red, blue = _target_masks(img)
    if red is None:
        return False
    margin = max(6, int(TARGET_BAR_LEFT_FRAC * red.shape[1]))
    if _left_run(blue.any(axis=0), margin) < TARGET_BAR_MIN_RUN:
        return False                                          # no left-anchored MP bar → not a flag
    if _left_run(red.any(axis=0), margin) < TARGET_BAR_RED_MIN:
        return False                                          # no left-anchored HP bar → not a flag
    red_ys = np.flatnonzero(red.any(axis=1))
    blue_ys = np.flatnonzero(blue.any(axis=1))
    if len(red_ys) == 0 or len(blue_ys) == 0:
        return False
    return float(blue_ys.mean()) > float(red_ys.mean())       # MP bar sits BELOW the HP bar (stacked)


def has_target(img):
    """A target is selected ⇒ the mob flag is up (RED HP bar over a BLUE MP bar, both left-filled).
    Same robust structural check as target_present (rejects lone red aggro tags + blue water)."""
    return target_present(img)


# ── OCR (Tesseract) for the status header: exact HP/Exp numbers + selected-mob name + HP ──────
# Reads the actual on-screen text (what bar fills can't give). Needs Tesseract installed; if it
# isn't, OCR_OK is False and read_status() returns empty (header just shows '—').
try:
    import shutil as _shutil
    import pytesseract as _pt
    _cands = [r"C:\Program Files\Tesseract-OCR\tesseract.exe",
              r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe",
              os.path.join(os.environ.get("LOCALAPPDATA", ""), r"Programs\Tesseract-OCR\tesseract.exe")]
    _tess = next((p for p in _cands if p and os.path.exists(p)), None) or _shutil.which("tesseract")
    if _tess:
        _pt.pytesseract.tesseract_cmd = _tess
    _pt.get_tesseract_version()
    OCR_OK = True
except Exception:
    OCR_OK = False

# Text regions as (x0,y0,x1,y1) fractions of the window — calibrated to a ~1264×759 capture.
# If your play resolution/UI-scale differs and the header misreads, nudge these.
# NOTE: HP/MP/Exp OCR boxes are now derived from the calibrated bar PIXELS in read_status
# (the numbers sit on the bars, the HUD is fixed-position) — these fractional consts are unused.
EXP_BOX = (0.093, 0.108, 0.165, 0.132)    # (deprecated) player Exp % — kept for reference
MOBNAME_BOX = (0.44, 0.004, 0.63, 0.037)  # selected-mob name (top-centre, gold)
MOBHP_BOX = (0.46, 0.049, 0.585, 0.069)   # selected-mob HP numbers
_OCR_NUM = "--psm 7 -c tessedit_char_whitelist=0123456789#/.%"   # '/' often reads as '#' — regex'd out


def _ocr_prep(img, box, gold=False):
    """Crop a region, isolate the text (white, or gold for names), pad + upscale for Tesseract.
    box is fractions of (w,h) when all values <= 1, else ABSOLUTE pixels (the fixed-position HUD)."""
    h, w = img.shape[:2]
    if max(box) <= 1.0:
        x0, y0, x1, y1 = int(box[0]*w), int(box[1]*h), int(box[2]*w), int(box[3]*h)
    else:
        x0, y0, x1, y1 = int(box[0]), int(box[1]), int(box[2]), int(box[3])
    c = img[y0:y1, x0:x1]
    if c.size == 0:
        return None
    hsv = cv2.cvtColor(c, cv2.COLOR_BGR2HSV)
    H, S, V = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2]
    m = ((H >= 16) & (H <= 42) & (S >= 70) & (V >= 140)) if gold else ((V >= 160) & (S <= 90))
    m = cv2.copyMakeBorder((m.astype(np.uint8)) * 255, 8, 8, 12, 12, cv2.BORDER_CONSTANT, value=0)
    return cv2.bitwise_not(cv2.resize(m, None, fx=4, fy=4, interpolation=cv2.INTER_CUBIC))  # dark on light


def _ocr(img, box, cfg, gold=False):
    if not OCR_OK:
        return ""
    p = _ocr_prep(img, box, gold)
    if p is None:
        return ""
    try:
        return _pt.image_to_string(p, config=cfg).strip()
    except Exception:
        return ""


def _read_pair(img, box):
    """A 'current / max' field → 'cur/max (pct%)', or None. Robust to the '/'→'#' misread via regex."""
    g = re.findall(r"\d+", _ocr(img, box, _OCR_NUM))
    if len(g) >= 2:
        cur, mx = int(g[0]), int(g[1])
        pct = round(100 * cur / mx, 1) if mx else 0
        return f"{cur}/{mx} ({pct:g}%)"
    return None


def _ocr_pair_fraction(img, box):
    """OCR a 'cur/max' field and return cur/max as a 0..1 fraction, or None if unreadable."""
    if not OCR_OK:
        return None
    g = re.findall(r"\d+", _ocr(img, box, _OCR_NUM))
    if len(g) >= 2 and int(g[1]) > 0:
        return max(0.0, min(1.0, int(g[0]) / int(g[1])))
    return None


def read_vital(page, img, kind, recover=False):
    """Effective HP/MP fraction for heal/potion decisions. Prefers the fast PIXEL bar scan; if the
    bar isn't visible (0 coloured px — HUD off or cursor occluding) falls back to OCR of the numbers;
    and when recover=True ALSO runs the cursor-move + press-T routine to bring the HUD back.
    kind='hp' or 'mp'. Returns (frac_or_None, source) with source in {'pixel','ocr',None}."""
    if kind == "mp":
        frac, _e, px = mp_report(img)
        box = (MP_X0, MP_Y0 - 2, MP_X1, MP_Y1 + 2)
    else:
        frac, _e, px = hp_report(img)
        box = (HP_X0, HP_Y0 - 2, HP_X1, HP_Y1 + 2)
    if px > 0:
        return frac, "pixel"
    of = _ocr_pair_fraction(img, box)          # pixel bar missing → try the OCR numbers
    if recover:
        ensure_hp_visible(page, img)           # move cursor off the HUD, press T if still hidden
    return (of, "ocr") if of is not None else (None, None)


def read_status(img, ocr=True):
    """Status-header values. Always includes the fast BAR reads (cheap pixel scans). When ocr=True
    AND Tesseract is available, ALSO does the exact OCR text (HP 'Y/X (Z%)', Exp, mob name+HP) — that
    part is HEAVY (3-5 tesseract subprocesses), so pass ocr=False to skip it on a hot path (e.g. the
    heal loop) and keep only the cheap pixel reads."""
    h, w = img.shape[:2]
    out = {"hp": None, "mp": None, "exp": None, "mob": None, "mobhp": None,
           "hp_bar": None, "mp_bar": None, "exp_bar": None, "mobhp_bar": None, "res": f"{w}x{h}"}
    # fast bar reads (always) — the pixel-detection scans of the HP / MP / EXP bars
    out["hp_bar"] = f"{int(round(hp_fraction(img)*100))}%"
    out["mp_bar"] = f"{int(round(mp_report(img)[0]*100))}%"
    out["exp_bar"] = f"{exp_report(img)[0]*100:.1f}%"
    tp = target_present(img)
    if tp:
        mf = mob_hp(img)
        out["mobhp_bar"] = f"{int(round(mf*100))}%" if mf is not None else "—"
    # exact OCR reads (HEAVY — skipped when ocr=False)
    if ocr and OCR_OK:
        # OCR the numbers overlaid ON each bar — boxes derived from the calibrated bar pixels
        # (absolute, fixed-position HUD), so calibrating the bars also aligns the OCR.
        hp_box = (HP_X0, HP_Y0 - 2, HP_X1, HP_Y1 + 2)
        mp_box = (MP_X0, MP_Y0 - 2, MP_X1, MP_Y1 + 2)
        exp_box = (EXP_X0, EXP_Y0 - 3, EXP_X1, EXP_Y1 + 3)
        out["hp"] = _read_pair(img, hp_box)
        out["mp"] = _read_pair(img, mp_box)
        m = re.search(r"(\d+\.\d+)", _ocr(img, exp_box, _OCR_NUM))
        out["exp"] = f"{m.group(1)}%" if m else None
        if tp:                                    # only OCR the mob fields when one is selected
            name = re.sub(r"^[^A-Za-z]+", "", _ocr(img, MOBNAME_BOX, "--psm 7", gold=True)).strip()
            out["mob"] = name or None
            out["mobhp"] = _read_pair(img, MOBHP_BOX)
    return out


_MENU_TPLS = None
def _menu_templates():
    """Binary letter-shape templates of the player menu's label ('Handel'/'Trade'),
    loaded once from templates/menu_*.png (white text on black)."""
    global _MENU_TPLS
    if _MENU_TPLS is None:
        d = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
        _MENU_TPLS = []
        for p in sorted(glob.glob(os.path.join(d, "menu_*.png"))):
            t = cv2.imread(p, cv2.IMREAD_GRAYSCALE)
            if t is not None:
                _MENU_TPLS.append(t)
    return _MENU_TPLS


def player_menu_open(img):
    """True only if the PLAYER context menu is open — detected by template-matching its
    'Handel'/'Trade' label. We match the BINARY letter shapes (bright text), which is
    robust to the menu's semi-transparent background, and ONLY this specific word triggers
    Esc — so merely selecting a mob (no such menu) never does. Localisation: drop a binary
    template of the trade label as templates/menu_<lang>.png to support other clients."""
    tpls = _menu_templates()
    if not tpls:
        return False
    h = img.shape[0]                                            # full width, but margin top/bottom
    hsv = cv2.cvtColor(img[int(MENU_Y0_F*h):int(MENU_Y1_F*h), :], cv2.COLOR_BGR2HSV)
    S, V = hsv[:, :, 1], hsv[:, :, 2]
    mask = (((V >= 160) & (S <= 120)).astype(np.uint8)) * 255    # bright cream/white text
    for t in tpls:
        if mask.shape[0] < t.shape[0] or mask.shape[1] < t.shape[1]:
            continue
        res = cv2.matchTemplate(mask, t, cv2.TM_CCOEFF_NORMED)
        if res.size and float(res.max()) >= MENU_MATCH_THRESH:
            return True
    return False


# ── aggro alert bubble ────────────────────────────────────────────────────────────
# When a mob aggros you, a white rounded-square bubble with an ORANGE anger-mark pops over its head.
# Signature = a small ORANGE blob surrounded by WHITE (the bubble). This is scale-tolerant and does
# NOT false-match red name text or gold event banners (those have little white around their orange).
AGGRO_DROP = 8            # px below the bubble to click (the aggro mob's body/name)
AGGRO_WHITE_FRAC = 0.40   # min fraction of white around the orange to count as the bubble


def find_aggro_symbol(img):
    """Locate the aggro alert bubble. Returns (x, y) to click (the mob below it), or None."""
    h, w = img.shape[:2]
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    H, S, V = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2]
    orange = (((H <= 16) | (H >= 172)) & (S > 110) & (V > 110)).astype(np.uint8) * 255
    white = (V > 195) & (S < 60)
    orange = cv2.morphologyEx(orange, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
    cnts, _ = cv2.findContours(orange, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    best = None
    for c in cnts:
        x, y, bw, bh = cv2.boundingRect(c)
        if bw < 6 or bh < 6 or bw > 44 or bh > 44 or cv2.contourArea(c) < 35:
            continue
        cx, cy = x + bw // 2, y + bh // 2
        if (cx < 0.24*w and cy < 0.22*h) or (cx > 0.76*w and cy < 0.28*h):
            continue                                   # skip the top-left HUD / top-right minimap
        pad = 6
        ring = white[max(0, y-pad):y+bh+pad, max(0, x-pad):x+bw+pad]
        wf = float(ring.mean()) if ring.size else 0.0
        if wf < AGGRO_WHITE_FRAC:
            continue
        if best is None or wf > best[0]:
            best = (wf, cx, y + bh + AGGRO_DROP)
    return (best[1], best[2]) if best else None


def find_captcha_checkbox(img):
    """If the anti-bot captcha ('Ich bin kein Roboter' reCAPTCHA box) is up, return the
    (x, y) of its checkbox to click, else None. Keyed on the SOLID white reCAPTCHA panel —
    a big, centred, solidly-white rectangle (the game world and white-TEXT boxes aren't
    solidly white). The checkbox is at the panel's left edge, vertically centred."""
    h, w = img.shape[:2]
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    S, V = hsv[:, :, 1], hsv[:, :, 2]
    white = ((V >= 200) & (S <= 40)).astype(np.uint8) * 255
    closed = cv2.morphologyEx(white, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
    cnts, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    best = None
    for c in cnts:
        x, y, bw, bh = cv2.boundingRect(c)
        # wide, bright panel — sizes RELATIVE to the window so it works at any resolution
        if not (bw > 0.10*w and 0.035*h <= bh <= 0.16*h and bw > bh):
            continue
        cx, cy = (x + bw/2)/w, (y + bh/2)/h
        if not (0.2 < cx < 0.8 and 0.2 < cy < 0.9):              # a centred modal, not edge UI
            continue
        fill = float(white[y:y+bh, x:x+bw].mean()) / 255.0
        if fill < CAPTCHA_FILL_MIN:                              # SOLID white, not a text box
            continue
        if best is None or bw*bh > best[0]:
            best = (bw*bh, int(x + CAPTCHA_CB_X*bw), int(y + 0.5*bh))
    return (best[1], best[2]) if best else None


def human_move_click(page, tx, ty):
    """Move the cursor to (tx,ty) with a smooth, medium-fast, slightly wandering path (like a
    human hand) through a couple of jittered waypoints, then click — used to tick the captcha."""
    w, h = viewport(page)
    sx = float(np.random.uniform(0.30*w, 0.70*w))               # start somewhere off the target
    sy = float(np.random.uniform(0.30*h, 0.70*h))
    page.mouse.move(sx, sy, steps=3)
    n = int(np.random.randint(2, 4))                            # 2-3 curving waypoints
    for i in range(1, n+1):
        f = i/(n+1)
        wx = sx + (tx-sx)*f + float(np.random.uniform(-30, 30))
        wy = sy + (ty-sy)*f + float(np.random.uniform(-25, 25))
        page.mouse.move(wx, wy, steps=int(np.random.randint(6, 13)))
        time.sleep(float(np.random.uniform(0.05, 0.15)))
    page.mouse.move(tx + float(np.random.uniform(-2, 2)), ty + float(np.random.uniform(-2, 2)),
                    steps=int(np.random.randint(8, 15)))        # settle onto the checkbox
    time.sleep(float(np.random.uniform(0.10, 0.25)))
    page.mouse.click(tx, ty)


def solve_captcha(page):
    """If the anti-bot captcha is on screen, human-move the cursor to its checkbox and click
    it. Returns True if it acted, False if no captcha was present."""
    cb = find_captcha_checkbox(grab(page))
    if not cb:
        return False
    human_move_click(page, cb[0], cb[1])
    time.sleep(0.6)
    return True


_LOS_TPLS = None
def _los_templates():
    """Binary keyword templates ('Hindernis'/obstacle/…), loaded once from templates/los_*.png
    (white letters on black). One per language. Empty (feature off) until you drop one in."""
    global _LOS_TPLS
    if _LOS_TPLS is None:
        d = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
        _LOS_TPLS = [t for t in (cv2.imread(p, cv2.IMREAD_GRAYSCALE)
                     for p in sorted(glob.glob(os.path.join(d, "los_*.png")))) if t is not None]
    return _LOS_TPLS


def line_of_sight_blocked(img):
    """True if a line-of-sight-blocked system message ('Hindernis'/obstacle/…) is on screen —
    template-matched against the RED system-message text in the central message area. No
    template = always False (feature off)."""
    tpls = _los_templates()
    if not tpls:
        return False
    h, w = img.shape[:2]
    sub = img[int(0.38*h):int(0.86*h), int(0.18*w):int(0.82*w)].astype(int)  # central message area
    r, g, b = sub[:, :, 2], sub[:, :, 1], sub[:, :, 0]
    redtext = (((r > 120) & (r > b + 50) & (r > g + 50)).astype(np.uint8)) * 255  # red system text
    for t in tpls:
        if redtext.shape[0] < t.shape[0] or redtext.shape[1] < t.shape[1]:
            continue
        res = cv2.matchTemplate(redtext, t, cv2.TM_CCOEFF_NORMED)
        if res.size and float(res.max()) >= LOS_MATCH_THRESH:
            return True
    return False


def mob_hp(img):
    """Selected target's HP fraction (0..1) from the TOP-CENTRE target frame's red bar, or None if
    nothing is targeted. Same rightmost-red-column method as the player HP bar, on the calibrated
    absolute-pixel TARGET box (TGT_* in scanner_calib.json)."""
    h, w = img.shape[:2]
    y0, y1 = TGT_Y0, min(TGT_Y1, h)
    x0, x1 = TGT_X0, min(TGT_X1, w)
    box = img[y0:y1, x0:x1].astype(int)
    if box.size == 0:
        return None
    r, g, b = box[:, :, 2], box[:, :, 1], box[:, :, 0]
    red = (r > 120) & (r > b + 50) & (r > g + 50)
    if int(red.sum()) < TGT_MIN_RED:
        return None                                    # nothing targeted
    edge = int(np.where(red.any(axis=0))[0].max()) + x0
    fl, fr = TGT_FILL_L, TGT_FILL_R
    return max(0.0, min(1.0, (edge - fl) / (fr - fl))) if fr > fl else 0.0


def _roi_mask(h, w):
    m = np.zeros((h, w), np.uint8)
    m[int(ROI[1]*h):int(ROI[3]*h), int(ROI[0]*w):int(ROI[2]*w)] = 255
    for box in (HUD_EXCL, MINIMAP_EXCL, BANNER_EXCL, CHAT_EXCL):   # carve out the ignored zones
        m[int(box[1]*h):int(box[3]*h), int(box[0]*w):int(box[2]*w)] = 0
    return m


def _hue_dist(H, gh):
    d = np.abs(H.astype(int) - int(gh))
    return np.minimum(d, 180 - d)


def candidate_blobs(img):
    """Salient non-ground, non-UI colour blobs -> [{cx,cy,area,h,s,v}]."""
    h, w = img.shape[:2]
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    H, S, V = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2]
    roi = _roi_mask(h, w)
    # dominant (ground) hue among saturated ROI pixels
    sat_roi = (S > 50) & (roi > 0)
    gh = int(np.bincount(H[sat_roi], minlength=180).argmax()) if sat_roi.any() else 0
    gs = int(np.median(S[sat_roi])) if sat_roi.any() else 0
    sal = (S > 90) & (V > 40) & (V < 235)
    if gs > 60:                                   # colourful ground -> exclude its hue
        sal &= _hue_dist(H, gh) > 14
    sal &= ~((V >= 185) & (S <= 80))              # drop white text/UI
    mask = (sal & (roi > 0)).astype(np.uint8) * 255
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
    cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    out = []
    for c in cnts:
        a = cv2.contourArea(c)
        if not (MIN_ENGAGE <= a <= MAX_MOB):
            continue
        m = np.zeros((h, w), np.uint8)
        cv2.drawContours(m, [c], -1, 255, -1)
        px = hsv[m > 0]
        med = np.median(px, axis=0).astype(int)
        M = cv2.moments(c)
        out.append({"cx": M["m10"]/M["m00"], "cy": M["m01"]/M["m00"], "area": float(a),
                    "h": int(med[0]), "s": int(med[1]), "v": int(med[2])})
    return out


def detect_mobs(img, ranges):
    """Blobs matching any learned HSV range -> [(cx,cy,area)]."""
    h, w = img.shape[:2]
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    roi = _roi_mask(h, w)
    mask = np.zeros((h, w), np.uint8)
    for lo, hi in ranges:
        mask |= cv2.inRange(hsv, np.array(lo), np.array(hi))
    mask = cv2.bitwise_and(mask, roi)
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8))
    cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    out = []
    for c in cnts:
        a = cv2.contourArea(c)
        if MIN_ENGAGE <= a <= MAX_MOB:
            M = cv2.moments(c)
            if M["m00"]:
                out.append((M["m10"]/M["m00"], M["m01"]/M["m00"], a))
    return out


# ---------- selection arrows (the 4 cyan arrows around the SELECTED mob) ----------
ARROW_LO = (104, 35, 150)
ARROW_HI = (122, 180, 255)


def _arrow_blobs(img):
    h, w = img.shape[:2]
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    m = cv2.inRange(hsv, np.array(ARROW_LO), np.array(ARROW_HI))
    r = np.zeros((h, w), np.uint8)
    r[int(0.10*h):int(0.82*h), int(0.06*w):int(0.90*w)] = 255
    m = cv2.bitwise_and(m, r)
    cnts, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    out = []
    for c in cnts:
        if cv2.contourArea(c) >= 1:
            M = cv2.moments(c)
            if M["m00"]:
                out.append((M["m10"]/M["m00"], M["m01"]/M["m00"]))
    return out


def arrows_center(img, near=None):
    """Center of the 4 selection arrows = exact point of the SELECTED mob, or None.
    With `near` (e.g. where we just clicked) it picks the 4 arrows around there;
    without it, it finds the compact 4-blob cluster anywhere (for grind_selected)."""
    bl = _arrow_blobs(img)
    if len(bl) < 4:
        return None
    if near is not None:
        bl = [b for b in bl if (b[0]-near[0])**2 + (b[1]-near[1])**2 < 100**2]
        if len(bl) < 4:
            return None
        four = sorted(bl, key=lambda b: (b[0]-near[0])**2 + (b[1]-near[1])**2)[:4]
    else:
        best = None
        for a in bl:
            grp = sorted(bl, key=lambda b: (b[0]-a[0])**2 + (b[1]-a[1])**2)[:4]
            xs = [b[0] for b in grp]; ys = [b[1] for b in grp]
            bw, bh = max(xs)-min(xs), max(ys)-min(ys)
            if 8 <= bw <= 110 and 10 <= bh <= 120:
                spread = bw + bh
                if best is None or spread < best[0]:
                    best = (spread, grp)
        if best is None:
            return None
        four = best[1]
    return (sum(b[0] for b in four)/4, sum(b[1] for b in four)/4)


# ---------- actions ----------
def grab(page):
    return cv2.imdecode(np.frombuffer(page.screenshot(), np.uint8), cv2.IMREAD_COLOR)


JPEG_QUALITY = 80        # quality for grab_fast's JPEG (lossy). Fine for the wide-threshold bar/frame
                         # checks; NOT used for colour detection (rank_targets/nameplates stay on grab()).
_CDP_SESS = None         # cached raw CDP session for grab_fast (reset to None on every hot-reload)
_CDP_FOR = None

def grab_fast(page):
    """~2x faster screenshot than grab() — raw CDP captureScreenshot with JPEG + optimizeForSpeed
    (~18 ms vs ~35 ms median, and avoids PNG's worst-case spikes). LOSSY, so use it ONLY for the
    lightweight binary frame-checks (target_present / has_target / death-poll / engage-confirm), whose
    red/cyan-blue/gold thresholds are wide enough to survive JPEG. Colour DETECTION keeps using grab()."""
    global _CDP_SESS, _CDP_FOR
    if _CDP_SESS is None or _CDP_FOR is not page:
        _CDP_SESS = page.context.new_cdp_session(page)
        _CDP_FOR = page
    r = _CDP_SESS.send("Page.captureScreenshot",
                       {"format": "jpeg", "quality": JPEG_QUALITY, "optimizeForSpeed": True})
    return cv2.imdecode(np.frombuffer(base64.b64decode(r["data"]), np.uint8), cv2.IMREAD_COLOR)


def two_click(page, x, y):
    page.mouse.click(x, y); time.sleep(SELECT_GAP); page.mouse.click(x, y)


def _attack_point(shot, w, h, near, ranges):
    """Where to make the 2nd (ATTACK) click, given the mob is selected and `near` is
    the select-click point. The mob may have MOVED since (aggro walks toward us), so we
    relocate it on this fresh frame. Priority:
      1) the cyan selection ARROWS, but only if their centre is sanely near the select
         point (rejects stray-cyan false detections);
      2) else RE-DETECT the nearest name-tag mob on this frame and refine onto its body
         (tracks a mob that walked);
      3) else fall back to the original select point.
    Relocating beats clicking a stale point — a stale/ground click = a walk, not a hit."""
    c = arrows_center(shot, near=near)
    if c and (c[0]-near[0])**2 + (c[1]-near[1])**2 < 85**2:
        return c
    cand = rank_targets(shot, w, h, ranges)
    if cand:
        m = min(cand, key=lambda p: (p[0]-near[0])**2 + (p[1]-near[1])**2)
        if (m[0]-near[0])**2 + (m[1]-near[1])**2 < 120**2:
            return refine_click_point(shot, m[0], m[1], m[2], ranges)
    return near


def engage(page, w, h, ranges, attempts=ENGAGE_ATTEMPTS, img0=None, cands0=None):
    """Click to SELECT, then attack via the ATTACK_KEY keybind (no 2nd click).
    Each attempt ranks candidates and clicks the best one whose BODY we can confidently locate
    (refine_click_point `found`):
      1) click that confident body point to SELECT;
      2) confirm a mob got selected (CYAN arrows OR the top-centre target frame);
      3) if selected, press ATTACK_KEY to attack.
    The FIRST attempt reuses the caller's already-grabbed `img0` + ranked `cands0` (the IDLE loop
    just detected them) so we don't re-screenshot + re-detect; retries grab a fresh frame.
    Crucially, if NO candidate has a confident body we return None WITHOUT clicking — a
    click on an unconfident (likely ground) point is read as a MOVE, which is exactly why
    the bot 'walks around' instead of attacking. Better to rotate/re-scan than to walk.
    When ENGAGE_CURSOR_CONFIRM, we ALSO move the cursor to the chosen point and require the game's
    ATTACK cursor there before clicking — the game's own ground truth that a mob is under it.
    Returns the select point, or None (caller rotates)."""
    for i in range(attempts):
        if i == 0 and img0 is not None:
            img, cands = img0, (cands0 if cands0 is not None else rank_targets(img0, w, h, ranges))
        else:
            img = grab(page)
            cands = rank_targets(img, w, h, ranges)
        target = None
        for mob in cands:                                    # best-first; take the first CONFIDENT body
            cx, cy, found = refine_click_point(img, mob[0], mob[1], mob[2], ranges, return_conf=True)
            if not found:
                continue
            if ENGAGE_CURSOR_CONFIRM:                        # game cursor must read ATTACK here, else it's
                if not _hover_point_ok(cx / w, cy / h):      # body point in a blocked region → don't hover
                    continue                                 # /click there (would enter the toolbar/banner)
                page.mouse.move(float(cx), float(cy))        # ground → clicking would WALK us. Skip it.
                if not _cursor_over_mob(page):               # (2026-07-02f) unified settle/raf read + guard
                    continue
            target = (cx, cy); break
        if target is None:
            return None                                      # nothing confident → don't click (no walk)
        cx, cy = target
        plog.action(f"click SELECT mob ({int(cx)},{int(cy)})")
        page.mouse.click(cx, cy)                             # 1) select (confident body pixels)
        time.sleep(SELECT_GAP)
        shot = grab_fast(page)                               # fast JPEG — only binary frame-checks below
        if has_target(shot) or arrows_center(shot, near=(cx, cy)):
            if is_player_target(shot):
                continue                                      # selected a PLAYER (# tag) — try next candidate
            plog.action(f"key ATTACK '{ATTACK_KEY}'")
            page.keyboard.down(ATTACK_KEY); time.sleep(0.06)  # 2) attack via keybind (no 2nd click)
            page.keyboard.up(ATTACK_KEY)
            return (cx, cy)
        # missed selection — loop re-grabs (accounts for any small walk) and retries
    return None


# Camera/position GENERATION counter (2026-07-02f): incremented on any camera turn/tilt/reset OR player
# walk, so screen-projected caches (the FIGHT prefetch, below) can be discarded the instant the view or our
# position moves — a cached screen point is only valid while neither changed.
_CAM_GEN = 0


def _bump_cam_gen():
    global _CAM_GEN
    _CAM_GEN += 1


def rotate_view(page, w, h):
    cx, cy = w*0.5, h*0.5
    page.mouse.move(cx, cy); page.mouse.down(button="right")
    page.mouse.move(cx + ROTATE_DX, cy, steps=8); page.mouse.up(button="right")
    _proj_invalidate()   # A1: drop the projection fit if we can't compensate this rotation (no live yaw)
    _bump_cam_gen()      # camera moved → invalidate screen-projected caches


def tilt_forward(page, w, h, px):
    """Tilt the camera further toward the horizon (right-drag UP) to see into the distance."""
    cx, cy = w*0.5, h*0.5
    page.mouse.move(cx, cy); page.mouse.down(button="right")
    page.mouse.move(cx, cy - px, steps=6); page.mouse.up(button="right")
    time.sleep(0.1)
    _proj_invalidate()   # A1: (see rotate_view)
    _bump_cam_gen()


def walk_dir(page, w, h, ang):
    """Walk a short distance in screen-direction `ang` (radians) via click-to-move. The target point is
    clamped into the play area and far enough out to clear the player. Also CALIBRATES the camera-yaw→world
    mapping: it measures the world heading actually walked (from memory pos delta) — camera-forward =
    SCREEN_FWD + (world - ang) — and feeds it to re_sense.calibrate_camera, so ANY grind walk (approach,
    wander, steer) teaches the live camera-yaw arrow, not just steering."""
    r = min(w, h) * WALK_RADIUS
    tx = int(np.clip(w*0.5 + r*np.cos(ang), w*0.20, w*0.80))
    ty = int(np.clip(h*0.5 + r*np.sin(ang), h*0.20, h*0.75))
    p0 = None
    try: p0 = re_sense._pos_xyz(page)
    except Exception: pass
    _bump_cam_gen()                                   # we're about to MOVE → invalidate screen-projected caches
    plog.action(f"click WALK ({tx},{ty}) ang={ang:.2f}")
    page.mouse.click(tx, ty)                          # ground click = move there
    # A1 passive projection sample: the game writes the world point THIS click raycast to at
    # controller+2540/+2548 almost immediately (well before the character finishes walking there over
    # WALK_TIME) — read it now, not after the full walk, and feed the projection fit. Every grind walk
    # (approach, wander, steer) is a free calibration sample; no dedicated calibration routine needed.
    time.sleep(0.15)
    try:
        dest = _read_click_dest(page)
        _proj_feed(page, tx, ty, p0, dest)
    except Exception:
        pass
    time.sleep(max(0.0, WALK_TIME - 0.15))             # let the character walk before scanning
    try:
        p1 = re_sense._pos_xyz(page)
        if p0 and p1:
            dx, dz = p1[0] - p0[0], p1[2] - p0[2]
            if float(np.hypot(dx, dz)) > 1.0:            # actually moved → calibrate camera yaw ↔ world
                world = float(np.arctan2(dz, dx))
                cam_fwd = float(np.arctan2(np.sin(SCREEN_FWD + world - ang), np.cos(SCREEN_FWD + world - ang)))
                re_sense.calibrate_camera(cam_fwd, page)
    except Exception:
        pass


def walk_random(page, w, h):
    """Relocate in a random direction when a spot is mob-empty and gives no text to aim at."""
    walk_dir(page, w, h, float(np.random.uniform(0, 2*np.pi)))


def unstuck(page):
    """Recover from a prolonged no-kill (likely wedged against terrain, or a stuck dialog):
    press Enter TWICE (1s apart) to clear any open chat/dialog, then a short WASD wiggle —
    back out (S) then a few random directions — to walk free of a narrowed-in spot."""
    print("[stuck] long time with no kill — Enter x2, then WASD wiggle to break free")
    page.keyboard.press("Enter"); time.sleep(1.0)
    page.keyboard.press("Enter"); time.sleep(1.0)
    seq = ["s"] + [str(np.random.choice(("w", "a", "s", "d"))) for _ in range(4)]
    for k in seq:
        page.keyboard.down(k); time.sleep(float(np.random.uniform(*WASD_HOLD))); page.keyboard.up(k)
        time.sleep(0.1)


def most_text_direction(img):
    """Screen-direction (radians from centre) of the densest MOB name-tag text, plus the
    total tag-text pixel count. Lets us walk TOWARD where mobs are when their tags are
    visible but none is close enough to engage. Returns (angle, count); angle is None when
    there's too little text to trust a direction."""
    h, w = img.shape[:2]
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    H, S, V = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2]
    roi = _roi_mask(h, w)                              # excludes HUD + the player's own centre/tag
    beige = ((H >= TAG_YELLOW_H[0]) & (H <= TAG_YELLOW_H[1]) & (S >= TAG_YELLOW_SMIN) & (V >= TAG_YELLOW_VMIN))
    red = (((H <= 8) | (H >= 172)) & (S >= 120) & (V >= 150))
    mask = cv2.bitwise_and(((beige | red).astype(np.uint8) * 255), roi)
    if AVOID_GIANT and _avoid_templates("giant"):     # never head toward a GIANT — blank its tag out
        bars = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_RECT, (25, 3)))
        for c in cv2.findContours(bars, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]:
            x, y, bw, bh = cv2.boundingRect(c)
            if 25 <= bw <= 230 and 4 <= bh <= 18 and _tag_matches(mask[y:y+bh, x:x+bw], "giant"):
                mask[y:y+bh, x:x+bw] = 0
    ys, xs = np.where(mask > 0)
    if len(xs) < TEXT_MIN:
        return None, int(len(xs))
    ang = float(np.arctan2(ys.mean() - h*0.5, xs.mean() - w*0.5))   # toward the text centroid
    return ang, int(len(xs))


def find_aggro(img):
    """On-screen positions of AGGRO mobs (RED name tags) — for a follower-healer to flee from.
    The central column (own char + RED system messages like 'Nicht genug MP') is excluded so
    those don't false-trigger a flee."""
    h, w = img.shape[:2]
    out = []
    for m in find_nameplate_mobs(img, None):
        if len(m) <= 3 or not m[3]:
            continue
        mx, my = m[0], m[1]
        if (AGGRO_EXCL_X[0]*w <= mx <= AGGRO_EXCL_X[1]*w
                and AGGRO_EXCL_Y[0]*h <= my <= AGGRO_EXCL_Y[1]*h):
            continue
        out.append((mx, my))
    return out


def find_follow_target(img):
    """Best-effort on-screen position of the FOLLOWED character: the nearest WHITE/GREEN player
    name tag that ISN'T our own (our tag is at screen centre, excluded). Returns (x, y) or None.
    Used only to bias a flee — if wrong it just nudges the escape angle, never targets anything."""
    h, w = img.shape[:2]
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    H, S, V = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2]
    roi = _roi_mask(h, w)
    white = ((V >= 165) & (S <= 85))
    green = ((H >= 45) & (H <= 95) & (S >= 60) & (V >= 120))
    mask = cv2.bitwise_and(((white | green).astype(np.uint8) * 255), roi)
    bars = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_RECT, (25, 3)))
    cx, cy = w*0.5, h*0.5
    cands = []
    for c in cv2.findContours(bars, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]:
        x, y, bw, bh = cv2.boundingRect(c)
        if not (25 <= bw <= 230 and 4 <= bh <= 18):
            continue
        mx, my = x + bw/2.0, y + bh/2.0 + NAMEPLATE_DROP
        if 0.40*w <= mx <= 0.60*w and 0.42*h <= my <= 0.70*h:   # our own char (centre) — skip
            continue
        cands.append((mx, my))
    if not cands:
        return None
    return min(cands, key=lambda p: (p[0]-cx)**2 + (p[1]-cy)**2)


def viewport(page):
    """Current viewport size in CSS px (== screenshot px at DPR 1). Re-read so the
    bot adapts when the window is resized."""
    s = page.evaluate("({w: window.innerWidth, h: window.innerHeight})")
    return s["w"], s["h"]


def calibrate_view(page):
    """Reset to a consistent, fully zoomed-OUT camera so mobs are uniform and click
    points accurate. Scroll all the way out, tilt to max top-down for a consistent
    reference, then ease the angle back up by TILT_BACK_UP so more mobs are in view."""
    w, h = viewport(page)
    cx, cy = w*0.5, h*0.5
    page.mouse.move(cx, cy)
    for _ in range(16):                                  # max zoom-out
        page.mouse.wheel(0, -120); time.sleep(0.04)
    for _ in range(5):                                   # tilt to max top-down (consistent reference)
        page.mouse.move(cx, cy); page.mouse.down(button="right")
        page.mouse.move(cx, cy + 230, steps=6); page.mouse.up(button="right")
        time.sleep(0.06)
    if TILT_BACK_UP:                                     # ease back toward horizon → more mobs in frame
        page.mouse.move(cx, cy); page.mouse.down(button="right")
        page.mouse.move(cx, cy - TILT_BACK_UP, steps=6); page.mouse.up(button="right")
        time.sleep(0.06)
    time.sleep(0.1)
    _proj_invalidate()   # A1: (see rotate_view) — a full camera reset is a bigger perturbation than a turn
    _bump_cam_gen()      # camera reset → invalidate screen-projected caches


# ---------- learn ----------
def cluster_to_ranges(samples):
    """Cluster confirmed-mob HSV samples by hue into inRange (lo,hi) tuples."""
    if not samples:
        return []
    hues = sorted({s["h"] for s in samples})
    groups, cur = [], [hues[0]]
    for hv in hues[1:]:
        if hv - cur[-1] <= 12:
            cur.append(hv)
        else:
            groups.append(cur); cur = [hv]
    groups.append(cur)
    ranges = []
    for g in groups:
        gs = [s for s in samples if g[0] <= s["h"] <= g[-1]]
        hs = [s["h"] for s in gs]; ss = [s["s"] for s in gs]; vs = [s["v"] for s in gs]
        lo = [max(0, min(hs) - 6), max(80, int(np.percentile(ss, 20)) - 25),
              max(50, int(np.percentile(vs, 20)) - 25)]
        hi = [min(179, max(hs) + 6), 255, 255]
        ranges.append((lo, hi, len(gs)))
    ranges.sort(key=lambda r: -r[2])
    return ranges


def learn(page, w, h, report=None, should_stop=None):
    """Scan the area (camera sweep, no clicking) and keep colours that RECUR —
    real mobs appear many times; player/decor appear once or twice."""
    print("LEARN: scanning area (camera sweep, no clicking)...")
    samples = []
    overlay = None
    for view in range(LEARN_VIEWS):
        if should_stop and should_stop():
            return []
        if report:
            report(f"learning {view+1}/{LEARN_VIEWS}")
        img = grab(page)
        if overlay is None:
            overlay = img.copy()
        cbs = candidate_blobs(img)
        samples.extend(cbs)
        print(f"  view {view+1}/{LEARN_VIEWS}: {len(cbs)} candidate blobs")
        rotate_view(page, w, h)
        time.sleep(0.6)

    clusters = cluster_to_ranges(samples)                 # (lo,hi,count) sorted by -count
    kept = [(lo, hi, n) for lo, hi, n in clusters if n >= MIN_SAMPLES][:MAX_TYPES]
    ranges = [(lo, hi) for lo, hi, _ in kept]

    json.dump({"ranges": [[lo, hi] for lo, hi in ranges]},
              open("learned_profile.json", "w"), indent=2)
    if overlay is not None:
        palette = [(0, 0, 255), (255, 255, 0), (0, 255, 0), (255, 0, 255)]
        for i, (lo, hi, _) in enumerate(kept):
            col = palette[i % len(palette)]
            for s in samples:
                if lo[0] <= s["h"] <= hi[0]:
                    cv2.circle(overlay, (int(s["cx"]), int(s["cy"])), 10, col, 2)
        cv2.imwrite(shot_path("learn_overlay.png"), overlay)

    keptset = {(tuple(lo), tuple(hi)) for lo, hi, _ in kept}
    print(f"LEARNED {len(ranges)} mob colour(s) (top {MAX_TYPES} recurring, >={MIN_SAMPLES}x):")
    for lo, hi, n in clusters:
        flag = "KEEP" if (tuple(lo), tuple(hi)) in keptset else "drop"
        print(f"  [{flag}] H[{lo[0]}-{hi[0]}] S>={lo[1]} V>={lo[2]}  ({n} seen)")
    if report:
        report(f"learned {len(ranges)} mob colour(s)")
    return ranges


def learn_selected(page, report=None, near=None):
    """Learn the colour of the mob the USER has manually selected. `near` is where
    the user clicked (recorded by the panel) — we find the 4 arrows there and sample
    the mob body at their center."""
    img = grab(page)
    # Locate the selected mob: prefer the selection-arrows center (most accurate),
    # else trust where the user clicked. NOT hard-gated on has_target/arrows so a
    # detection hiccup never blocks the grind (the caller grinds any mob if we fail).
    ctr = arrows_center(img, near=near) if near else None
    if ctr is None:
        ctr = arrows_center(img)               # search anywhere
    if ctr is None:
        ctr = near                             # last resort: the raw click point
    if ctr is None:
        if report:
            report("click the mob first, then Grind selected")
        print("learn_selected: no arrows and no click point")
        return []
    cx, cy = int(ctr[0]), int(ctr[1])
    # Sample the mob body around the arrows' center (the mob sits between the 4 arrows).
    # Drop the bright cyan arrow pixels and dark/grey ground; the rest is the mob.
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    patch = hsv[max(0, cy-8):cy+10, max(0, cx-8):cx+9].reshape(-1, 3).astype(int)
    H, S, V = patch[:, 0], patch[:, 1], patch[:, 2]
    keep = (~((H >= 100) & (H <= 124) & (V > 175))) & (S > 40) & (V > 40)   # not cyan arrows, not dark
    body = patch[keep]
    if len(body) < 5:
        body = patch[S > 30]
    if len(body) < 5:
        if report:
            report("couldn't read colour — grinding any nearby mob")
        print("learn_selected: not enough body pixels (will grind any mob)")
        return []
    if len(body) >= 12:                       # focus on the vivid mob colour, not tan stem/ground
        thr = np.percentile(body[:, 1], 55)
        vivid = body[body[:, 1] >= thr]
        if len(vivid) >= 6:
            body = vivid
    hm, sm, vm = [int(v) for v in np.median(body, axis=0)]
    lo = [max(0, hm-10), max(50, sm-60), max(40, vm-55)]   # a touch broader -> matches same-type mobs
    hi = [min(179, hm+10), 255, 255]
    json.dump({"ranges": [[lo, hi]]}, open("learned_profile.json", "w"), indent=2)
    print(f"LEARNED selected mob: H[{lo[0]}-{hi[0]}] S>={lo[1]} V>={lo[2]} (center patch, {len(body)}px)")
    if report:
        report(f"learned mob H~{int(hm)}")
    return [(lo, hi)]


# ---------- grind ----------
_AVOID_TPLS = {}
def _avoid_templates(prefix):
    """Binary word templates (white letters on black) loaded once from templates/<prefix>_*.png.
    'giant' = dangerous mobs (skip + avoid); 'skip' = non-dangerous do-not-attack mobs (Catcher,
    Red Horse). Empty list (that group off) until a template is dropped in."""
    if prefix not in _AVOID_TPLS:
        d = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
        _AVOID_TPLS[prefix] = [t for t in (cv2.imread(p, cv2.IMREAD_GRAYSCALE)
                               for p in sorted(glob.glob(os.path.join(d, prefix + "_*.png")))) if t is not None]
    return _AVOID_TPLS[prefix]


def _tag_matches(tag_mask, prefix):
    """Does this name-tag's binary text match any <prefix> template (e.g. 'Giant'/'Catcher')?
    tag_mask is the binary (colour-thresholded) tag text — same form as the templates."""
    tpls = _avoid_templates(prefix)
    if not tpls or tag_mask.size == 0:
        return False
    for t in tpls:
        if tag_mask.shape[0] < t.shape[0] or tag_mask.shape[1] < t.shape[1]:
            continue
        res = cv2.matchTemplate(tag_mask, t, cv2.TM_CCOEFF_NORMED)
        if res.size and float(res.max()) >= GIANT_MATCH_THRESH:
            return True
    return False


def find_nameplate_mobs(img, ranges=None):
    """Locate MOBS by their NAME TAGS (colour-agnostic on the body — works even when the
    mob blends with the ground). Mob tags are light YELLOW/BEIGE (normal) or RED (aggro,
    flagged so the grinder can prioritise them). We deliberately do NOT match WHITE or
    GREEN tags — those are PLAYER characters and must never be targeted. The mob body sits
    below the tag; the offset SCALES with the tag's size. If `ranges` is given, yellow/beige
    tags are kept only when the body colour matches (red/aggro tags bypass that).
    Returns [(mob_x, mob_y, name_width, aggro)]."""
    h, w = img.shape[:2]
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    H, S, V = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2]
    roi = _roi_mask(h, w)
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 3))
    # light yellow/beige text = a normal mob tag. The saturation floor EXCLUDES white
    # PLAYER tags (≈0 sat) and the warm hue band excludes green PLAYER tags.
    beige = cv2.bitwise_and(
        (((H >= TAG_YELLOW_H[0]) & (H <= TAG_YELLOW_H[1]) & (S >= TAG_YELLOW_SMIN)
          & (V >= TAG_YELLOW_VMIN)).astype(np.uint8) * 255), roi)
    # bright, saturated red text = aggro mob tag (hue wraps 0/179). Strict V/S so dull
    # reddish terrain/mob bodies don't read as tags.
    red = cv2.bitwise_and(
        ((((H <= 8) | (H >= 172)) & (S >= 120) & (V >= 150)).astype(np.uint8) * 255), roi)
    # light-purple PET name tags → exclusion zones. The PET (e.g. an orange fox, whose body can read
    # as a beige tag) sits in the strip BELOW its purple name, so we drop any detection that lands
    # there — same spirit as never targeting white/green PLAYER tags.
    purple = cv2.bitwise_and(
        (((H >= TAG_PURPLE_H[0]) & (H <= TAG_PURPLE_H[1]) & (S >= TAG_PURPLE_SMIN)
          & (V >= TAG_PURPLE_VMIN)).astype(np.uint8) * 255), roi)
    pet_zones = []                                            # (x0, x1, y_name_bottom) per pet name
    pcnts, _ = cv2.findContours(cv2.morphologyEx(purple, cv2.MORPH_CLOSE, kernel),
                                cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    for c in pcnts:
        px, py, pbw, pbh = cv2.boundingRect(c)
        if 12 <= pbw <= 230 and 4 <= pbh <= 18:              # name-tag shaped
            pet_zones.append((px, px + pbw, py + pbh))
    pet_overflow = len(pet_zones) > PET_MAX_ZONES
    if pet_overflow:
        pet_zones = []                                       # implausibly many → a purple effect/UI is
                                                             # false-firing; don't let it mask real mobs

    def _in_pet_zone(mx, my):
        for (x0, x1, ytop) in pet_zones:                     # body is below the name, within ~80 px
            if x0 - 12 <= mx <= x1 + 12 and ytop - 6 <= my <= ytop + PET_BODY_SPAN:
                return True
        return False

    out = []
    fn = {"beige_tags": 0, "red_tags": 0, "pet_zones": len(pet_zones), "pet_overflow": pet_overflow,
          "drop_pet": 0, "drop_self_banner": 0, "drop_avoid": 0, "drop_ranges": 0, "kept": 0}
    for mask, aggro in ((beige, False), (red, True)):
        bars = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)   # merge letters into a bar
        cnts, _ = cv2.findContours(bars, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        for c in cnts:
            x, y, bw, bh = cv2.boundingRect(c)
            if not (25 <= bw <= 230 and 4 <= bh <= 18):   # text-bar shape (rejects terrain)
                continue
            fn["red_tags" if aggro else "beige_tags"] += 1   # a real, tag-shaped name found in ROI
            drop = int(NAMEPLATE_DROP * max(1.0, bh / 7.0))   # bigger tag = closer mob = body further below
            mx, my = x + bw/2.0, y + bh/2.0 + drop
            if _in_pet_zone(x + bw/2.0, y + bh/2.0) or _in_pet_zone(mx, my):
                fn["drop_pet"] += 1
                continue                                            # our PET (light-purple name) — never target
            if (SELF_EXCL[0]*w <= mx <= SELF_EXCL[2]*w               # our own character / name tag
                    and SELF_EXCL[1]*h <= my <= SELF_EXCL[3]*h):
                fn["drop_self_banner"] += 1
                continue
            if (BANNER_EXCL[0]*w <= mx <= BANNER_EXCL[2]*w          # body lands in the action-log
                    and BANNER_EXCL[1]*h <= my <= BANNER_EXCL[3]*h):  # banner — never click here
                fn["drop_self_banner"] += 1
                continue
            tagmask = mask[int(y):int(y)+int(bh), int(x):int(x)+int(bw)]
            if AVOID_GIANT and _tag_matches(tagmask, "giant"):
                fn["drop_avoid"] += 1
                continue                       # skip GIANT mobs — they would kill us
            if AVOID_SKIP and _tag_matches(tagmask, "skip"):
                fn["drop_avoid"] += 1
                continue                       # skip Catcher/Red Horse — don't attack them
            if ranges and not aggro:          # lenient body-colour filter (aggro tags skip it)
                p = hsv[max(0, int(my)-10):int(my)+14, max(0, int(mx)-12):int(mx)+12]
                m = np.zeros(p.shape[:2], np.uint8)
                for lo, hi in ranges:
                    m |= cv2.inRange(p, np.array(lo), np.array(hi))
                if int((m > 0).sum()) < 10:
                    fn["drop_ranges"] += 1
                    continue
            fn["kept"] += 1
            out.append((mx, my, float(bw), aggro))
    global _LAST_FUNNEL
    _LAST_FUNNEL = fn
    return out


_LAST_FUNNEL = {}      # detection funnel of the most recent find_nameplate_mobs call (for [wander] diag)
def funnel_summary():
    """One-line summary of where the last detection's name-tags were lost — printed when the bot starts
    wandering so a 'mobs visible but ignored' spell is diagnosable: raw tags found vs each drop reason."""
    f = _LAST_FUNNEL
    if not f:
        return "no detection run yet"
    raw = f.get("beige_tags", 0) + f.get("red_tags", 0)
    s = (f"raw_tags={raw} (beige {f.get('beige_tags',0)}, red {f.get('red_tags',0)}) -> kept={f.get('kept',0)}"
         f"  | dropped: pet={f.get('drop_pet',0)} self/banner={f.get('drop_self_banner',0)} "
         f"avoid(giant/skip)={f.get('drop_avoid',0)} colour={f.get('drop_ranges',0)}"
         f"  | pet_zones={f.get('pet_zones',0)}{' OVERFLOW(ignored)' if f.get('pet_overflow') else ''}")
    if raw == 0:
        s += "  >> ZERO raw tags: view drift / zoom / colour / ROI, not a filter"
    return s


def pick_target(img, w, h, ranges):
    """Pick the best mob. AGGRO mobs (red name tag — actively hostile) come FIRST so
    we deal with what's attacking us; among equals, the nearest IN FRONT wins. Locate
    primarily by NAME TAG (colour-agnostic); colour only filters/falls back."""
    mobs = find_nameplate_mobs(img, ranges)
    if not mobs and ranges:
        mobs = find_nameplate_mobs(img, None)         # any name-tagged mob (colour filter off)
    # NOTE: no raw colour-blob fallback — it detected the red PLAYER as a mob. With no
    # name-tagged mob we return None so the loop ROTATES to look around (not click us).
    if not mobs:
        return None
    fx, fy = w * 0.5, h * 0.58
    # aggro first (0 sorts before 1), then nearest-to-front
    mobs.sort(key=lambda c: (0 if (len(c) > 3 and c[3]) else 1, (c[0]-fx)**2 + (c[1]-fy)**2))
    return mobs[0]


def rank_targets(img, w, h, ranges):
    """Like pick_target but returns ALL candidates best-first (so engage can try the
    next one when a click misses, instead of giving up / walking off)."""
    mobs = find_nameplate_mobs(img, ranges)
    if not mobs and ranges:
        mobs = find_nameplate_mobs(img, None)
    if not mobs:
        return []
    fx, fy = w * 0.5, h * 0.58
    mobs.sort(key=lambda c: (0 if (len(c) > 3 and c[3]) else 1, (c[0]-fx)**2 + (c[1]-fy)**2))
    return mobs


def refine_click_point(img, mx, my, bw, ranges, return_conf=False):
    """The name-tag gives a reliable X but only an APPROXIMATE Y (a fixed drop below
    the tag). Refine the click onto the mob's actual BODY: scan a small window below
    the tag for mob-body pixels (learned colour if known, else salient non-ground),
    take the LARGEST blob's centroid. Falls back to the tag-drop point if nothing is
    found. A click on real body pixels selects the mob; a click on ground walks us —
    so this is the single biggest accuracy lever.
    With return_conf=True, returns (x, y, found): found is True only when a real body
    blob (>= MIN_BODY_PX) was located — callers use it to AVOID clicking a guess."""
    def out(x, y, found):
        return (x, y, found) if return_conf else (x, y)
    h, w = img.shape[:2]
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    half = int(max(18, min(70, bw * 0.45)))
    x0, x1 = max(0, int(mx - half)), min(w, int(mx + half))
    y0, y1 = max(0, int(my - 24)), min(h, int(my + 30))   # from just above the est. body to below it
    if x1 - x0 < 6 or y1 - y0 < 6:
        return out(mx, my, False)
    sub = hsv[y0:y1, x0:x1]
    def _salient(sb):                                     # salient, non-ground, non-UI body pixels
        S, V = sb[:, :, 1], sb[:, :, 2]
        return ((S > 85) & (V > 45) & (V < 235)).astype(np.uint8) * 255
    if ranges:
        mask = np.zeros(sub.shape[:2], np.uint8)
        for lo, hi in ranges:
            mask |= cv2.inRange(sub, np.array(lo), np.array(hi))
        if int((mask > 0).sum()) < 8:                     # learned colours don't match THIS mob (a poor
            mask = _salient(sub)                          # learn sweep) → DON'T give up; fall back to the
                                                          # salient-body mask, else engage misses EVERY mob
                                                          # and the bot just walks around visible mobs.
    else:
        mask = _salient(sub)
    # never anchor on the cyan selection arrows
    arrow = cv2.inRange(sub, np.array((100, 35, 175)), np.array((124, 255, 255)))
    mask = cv2.bitwise_and(mask, cv2.bitwise_not(arrow))
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8))
    if int((mask > 0).sum()) < 8:
        return out(mx, my, False)
    cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not cnts:
        return out(mx, my, False)
    c = max(cnts, key=cv2.contourArea)
    M = cv2.moments(c)
    if M["m00"] < MIN_BODY_PX:                            # blob too small → not a confident body
        return out(mx, my, False)
    return out(x0 + M["m10"]/M["m00"], y0 + M["m01"]/M["m00"], True)


def arrow_counts(img):
    """Bright selection-arrow pixels by colour: (cyan, red). Arrows are CYAN when a
    mob is merely SELECTED and turn RED while it's actively being attacked. Both are
    bright (V>=180) so dimmer mob bodies don't count. Compare them: red>cyan = attacking."""
    h, w = img.shape[:2]
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    roi = _roi_mask(h, w)
    cyan = cv2.bitwise_and(cv2.inRange(hsv, np.array((104, 40, 180)), np.array((124, 200, 255))), roi)
    red = cv2.bitwise_and(
        cv2.inRange(hsv, np.array((0, 120, 180)), np.array((9, 255, 255)))
        | cv2.inRange(hsv, np.array((171, 120, 180)), np.array((179, 255, 255))), roi)
    return int((cyan > 0).sum()), int((red > 0).sum())


# ── Cursor fallback: find a mob by HOVERING and reading the game's CSS cursor (curattack = sword) ──
_CURSOR_CSS_JS = ("() => { const c = document.querySelector('canvas');"
                   " return (c && c.style.cursor) || document.body.style.cursor"
                   " || (c && getComputedStyle(c).cursor) || ''; }")


def _cursor_name(css):
    """Parse a CSS cursor value (e.g. 'url(.../cursor/curattack.cur) 4 4, auto') down to the game's short
    cursor name ('curattack', 'curbase', ...), or '' if unmatched. Shared by cursor_kind (fixed-sleep read)
    and cursor_kind_synced (rAF-synced read, B) so both readers parse the same way."""
    m = re.search(r"cursor/(\w+)\.", css or "")
    return m.group(1) if m else ""


def cursor_kind(page):
    """The game's CURRENT cursor name (e.g. 'curbase' over ground, 'curattack' over a mob, 'curtalk'
    over an NPC), read IMMEDIATELY (no settle wait — call after your own sleep/settle if needed). '' if
    unreadable. NOTE: the cursor is the OS/CSS cursor so it is NOT in screenshots — this DOM read is how
    we sense it. Prefer cursor_kind_synced (B) for a fresh hover read; this is kept for other callers that
    already know the cursor has settled."""
    try:
        css = page.evaluate(_CURSOR_CSS_JS)
    except Exception:
        return ""
    return _cursor_name(css)


# (B, 2026-07-02e) rAF-synced cursor read: the game swaps its CSS cursor inside its OWN mousemove handler,
# which runs on the browser's NEXT animation frame — so "wait a frame" is the TRUE condition we want, not
# a guessed fixed sleep. A fixed HOVER_SETTLE (~22ms) assumes ~60fps; under render load a frame can stretch
# to 100-250ms, so the fixed sleep silently reads a STALE cursor and misses mobs. The rAF wait is adaptive:
# it costs the TRUE frame time, however long that is. HOW MANY frames = CURSOR_SYNC_FRAMES (default 1 —
# the game's rAF callback was registered at startup, so it runs BEFORE ours within the next frame; the
# original always-double-rAF cost ~2× per point and caused a live speed regression). The setTimeout race
# is a safety valve so a stalled rAF (backgrounded tab, dead render loop) can never hang the bot — worst
# case it degrades to the old fixed-sleep latency.
_CURSOR_SYNCED_JS = """(n) => Promise.race([
  new Promise(r => {
    const read = () => { const c = document.querySelector('canvas');
      r((c && c.style.cursor) || document.body.style.cursor || (c && getComputedStyle(c).cursor) || ''); };
    let hop = read;
    for (let i = 1; i < n; i++) { const next = hop; hop = () => requestAnimationFrame(next); }
    requestAnimationFrame(hop);
  }),
  new Promise(r => setTimeout(() => r(''), 400))   // safety: never hang if rAF stalls
])"""


def cursor_kind_synced(page):
    """The game's cursor name, but the READ is synced to the game's own render frame instead of a fixed
    sleep (see the rationale above `_CURSOR_SYNCED_JS`; frame count = CURSOR_SYNC_FRAMES) — call right
    after a page.mouse.move with NO sleep in between. '' on any exception (same contract as cursor_kind)."""
    try:
        css = page.evaluate(_CURSOR_SYNCED_JS, max(1, int(CURSOR_SYNC_FRAMES)))
    except Exception:
        return ""
    return _cursor_name(css)


def cursor_over_mob_synced(page):
    """rAF-synced counterpart to cursor_over_mob — True if the game cursor is the ATTACK cursor right now."""
    return cursor_kind_synced(page) == ATTACK_CURSOR


def cursor_over_mob(page):
    """True if the game cursor is the ATTACK cursor right now — i.e. an attackable mob is under it."""
    return cursor_kind(page) == ATTACK_CURSOR


def _cursor_over_mob(page, settle=None):
    """(2026-07-02f) THE unified per-point cursor read shared by every hover site (self-scan, radial-mem,
    blind per-point, batched re-hover confirm, engage cursor-confirm, fight-prefetch) so they all use the
    SAME method + guard. Call right after a page.mouse.move (no other sleep). Mode = HOVER_READ_MODE:
      "settle" (default) → sleep HOVER_SETTLE, then cursor_kind — the PROVEN fixed-settle read, restored
                            after the rAF read regressed detection; reliable + now affordable (radial-mem
                            hovers far fewer points);
      "raf"              → cursor_kind_synced (rAF-synced, CURSOR_SYNC_FRAMES) — kept selectable.
    FALSE-NEGATIVE GUARD: an EMPTY read ('' = the DOM cursor was unreadable this instant, NOT a confirmed
    'not a mob') is re-read ONCE more before we believe the point is empty, so a one-frame hiccup can't
    silently drop a real mob. Returns True iff the game shows the ATTACK cursor.
    `settle` overrides the dwell (the glide's two passes pass their own); None = HOVER_SETTLE."""
    def _read():
        if HOVER_READ_MODE == "raf":
            return cursor_kind_synced(page)
        time.sleep(HOVER_SETTLE if settle is None else settle)
        return cursor_kind(page)
    k = _read()
    if k == "":                              # unreadable ≠ "not a mob" → one retry before giving up
        k = _read()
    return k == ATTACK_CURSOR


def _hover_point_ok(fx, fy):
    """May the cursor hover this FRACTIONAL point? True iff it's inside the calibrated scan area: within
    `ROI` AND outside the HUD/minimap/banner/chat exclusion boxes — exactly the region `_roi_mask` carves
    out for colour detection (all five boxes come from scanner_calib.json via the Calibrate Grind scanner
    tab, so the hover paths now respect the user's calibration too; previously only HUD/minimap/chat were
    checked and neither ROI nor BANNER_EXCL was). Replaces the old `_in_excluded`, folded in — this was
    its only caller set. DELIBERATELY does NOT exclude `SELF_EXCL` (the player's own char box): the
    innermost hover rings fall inside it, a mob standing right next to the char is exactly the FIRST
    thing we want to find, and hovering the own char is harmless — it never reads `curattack`.
    HOVER_EDGE_MARGIN (2026-07-02f) SHRINKS ROI and EXPANDS every exclusion box by a small fraction, so a
    point never lands right on a boundary — the UI's reactive area is slightly larger than the drawn box."""
    m = HOVER_EDGE_MARGIN
    if not (ROI[0] + m <= fx <= ROI[2] - m and ROI[1] + m <= fy <= ROI[3] - m):
        return False
    for b in (HUD_EXCL, MINIMAP_EXCL, BANNER_EXCL, CHAT_EXCL):
        if b[0] - m <= fx <= b[2] + m and b[1] - m <= fy <= b[3] + m:
            return False
    return True


# ── A4: unwanted-species reject-list (blind-scan livelock fix) ─────────────────────────────────────
# select_and_attack Tab-rejects an unwanted species after selecting it (see (4.) below); the blind hover
# scans (this file's per-point B path AND the batched C sweep) then skip hovering near that point for a
# while, so they don't just re-select the SAME unwanted mob over and over (a livelock the targeted scan
# (A2) never suffers from — it only ever targets memory-confirmed WANTED mobs in the first place).
_REJECTED = deque()   # (x, y, t) of recent unwanted-species select points


def _reject_recent(x, y):
    """True if (x,y) is within REJECT_RADIUS of a still-fresh (< REJECT_TTL old) rejected point. Purges
    expired entries lazily (oldest-first, so this is cheap) — no background timer needed."""
    now = time.time()
    while _REJECTED and now - _REJECTED[0][2] > REJECT_TTL:
        _REJECTED.popleft()
    return any((x - rx) ** 2 + (y - ry) ** 2 < REJECT_RADIUS ** 2 for rx, ry, _ in _REJECTED)


def _hover_scan_rings(w, h, radial_step=None, arc_step=None):
    """The points hover_scan_radial checks, GROUPED BY RING (outward from the centre/character, nearest
    ring first), skipping HUD/minimap/chat exclusion zones. `radial_step` = px BETWEEN rings (default
    HOVER_RADIAL_STEP; the coarse pass-1 glide passes HOVER_PASS1_STEP for fewer rings). `arc_step` = px
    between points around a ring (default HOVER_RADIAL_STEP). The batched/glide paths read the in-page
    cursor-swap log per ring; the per-point path + the grind-calibration overlay want the flat list (see
    _hover_scan_points — this flattened with the defaults, so those callers are unchanged)."""
    cx, cy = w * 0.5, h * 0.5
    rmax = HOVER_RADIAL_MAX * min(w, h)
    rstep = max(12, radial_step if radial_step else HOVER_RADIAL_STEP)
    astep = max(12, arc_step if arc_step else HOVER_RADIAL_STEP)
    rings = []
    r = rstep
    while r <= rmax:
        n = max(8, int(2 * np.pi * r / astep))           # ~arc_step spacing around this ring
        ring = []
        for i in range(n):
            ang = 2 * np.pi * i / n
            gx, gy = cx + r * np.cos(ang), cy + r * np.sin(ang)
            if not (0 < gx < w and 0 < gy < h) or not _hover_point_ok(gx / w, gy / h):
                continue
            ring.append((gx, gy))
        if ring:
            rings.append(ring)
        r += rstep
    return rings


def _hover_scan_points(w, h):
    """The (x,y) points hover_scan_radial checks, in scan order (nearest ring first) — flattened
    _hover_scan_rings. Shared by the actual scan AND the grind-calibration overlay (so what you see is
    what runs); signature/output are UNCHANGED by the B/C rework, only the internals moved."""
    return [p for ring in _hover_scan_rings(w, h) for p in ring]


# (C, 2026-07-02e) One-time-per-page in-page cursor-swap LOGGER: a passive mousemove listener tracks the
# latest pointer position, and a MutationObserver on the canvas's `style` attribute records {x,y,cur} on
# EVERY cursor swap (parsed to the short name, e.g. 'curattack'). This lets the batched sweep move through
# a whole ring at a steady pace WITHOUT a per-point read-and-wait — a hit can't be missed by pacing (the
# observer logs it even if the sweep has already moved past that point), only mis-located by a few px,
# which the re-hover confirm in _hover_scan_batched fixes.
_CUR_LOG_JS = r"""() => {
  if (window.__curLogInstalled) return true;
  const canvas = document.querySelector('canvas');
  if (!canvas) return false;
  window.__curLogInstalled = true;
  window.__curLog = [];
  window.__curMoves = 0;   // raw mousemove count — the batched sweep's logger HEALTH check (0 after a
                           // sweep that moved the mouse = the listener is dead → fall back to per-point)
  let lastX = 0, lastY = 0;
  canvas.addEventListener('mousemove', (e) => { lastX = e.clientX; lastY = e.clientY;
    window.__curMoves = (window.__curMoves || 0) + 1; }, {passive: true});
  const push = () => {
    const m = (canvas.style.cursor || '').match(/cursor\/(\w+)\./);
    const log = window.__curLog;
    log.push({x: lastX, y: lastY, cur: m ? m[1] : ''});
    if (log.length > 200) log.shift();
  };
  new MutationObserver(push).observe(canvas, {attributes: true, attributeFilter: ['style']});
  return true;
}"""
_CUR_LOG_FOR = None      # page the logger was installed on (module flag; reset on hot-reload/Start, so
                         # it's re-installed lazily instead of re-checked with an evaluate every scan)


def _ensure_cursor_logger(page):
    """Install the cursor-swap logger on `page` if it isn't already (idempotent JS-side too, via
    window.__curLogInstalled — covers a page navigation resetting window state between checks). Returns
    True once the logger is confirmed installed."""
    global _CUR_LOG_FOR
    if _CUR_LOG_FOR is page:
        return True
    try:
        ok = bool(page.evaluate(_CUR_LOG_JS))
    except Exception:
        return False
    if ok:
        _CUR_LOG_FOR = page
    return ok


def _drain_cursor_log(page):
    """Fetch + clear window.__curLog (the cursor-swap events logged since the last drain)."""
    try:
        return page.evaluate("() => { const l = window.__curLog || []; window.__curLog = []; return l; }") or []
    except Exception:
        return []


def _confirm_logged_attack(page, w, h):
    """Drain the cursor-swap log; for the FIRST 'curattack' entry inside the calibrated ROI (+not
    reject-listed), re-hover that logged point and confirm with _cursor_over_mob. Returns (x,y) or None.
    The re-hover confirm fixes the 'pointer already glided past' race (same idea as the batched sweep)."""
    for ev in _drain_cursor_log(page):
        if ev.get("cur") != ATTACK_CURSOR:
            continue
        hx, hy = ev.get("x"), ev.get("y")
        if hx is None or hy is None or _reject_recent(hx, hy) or not _hover_point_ok(hx / w, hy / h):
            continue
        page.mouse.move(float(hx), float(hy))
        if _cursor_over_mob(page):
            return (float(hx), float(hy))
    return None


def _glide_scan(page, w, h, rings, settle, should_stop=None):
    """(B, 2026-07-02h) SMOOTH-TRAVEL + per-point DWELL-READ scan of ring-grouped points — returns the first
    curattack hit (x,y) or None. The cursor GLIDES between consecutive sample points with interpolated moves
    (steps ∝ segment length, GLIDE_PX_PER_STEP px/step) so the path is continuous — no teleport jumps (the
    'circles jump too much' fix); each ring is entered at the point NEAREST the previous ring's end so the
    ring→ring transition glides too instead of snapping back to angle 0.
    DETECTION IS PER-POINT, NOT mid-motion: at each sample point we DWELL `settle` s and read the cursor with
    the SAME reliable read the fight-prefetch uses (_cursor_over_mob). This is deliberate — the game only
    swaps its CSS cursor to curattack when the pointer RESTS on the mob for ~1 render frame, so a pure
    continuous glide sweeps past a ~30px sprite within a frame and never triggers the swap (that render-frame
    constraint is exactly why the old logger-during-motion glide missed mobs it passed over). Reject-listed
    points are TRAVELLED THROUGH (hovering never clicks, so gliding across one is harmless) but never
    dwelt/read/selected. ROI is already baked into `rings`; should_stop + nearest-ring-first order honoured."""
    prev = None
    for ring in rings:
        if should_stop and should_stop():
            return None
        if not ring:
            continue
        if prev is not None:                             # enter this ring near where the last one ended → a
            si = min(range(len(ring)),                   # short smooth transition, not a jump back to angle 0
                     key=lambda i: (ring[i][0] - prev[0]) ** 2 + (ring[i][1] - prev[1]) ** 2)
            ring = ring[si:] + ring[:si]
        for gx, gy in ring:
            if should_stop and should_stop():
                return None
            if prev is None:
                page.mouse.move(float(gx), float(gy), steps=1)   # one initial placement (unavoidable)
            else:
                seg = float(np.hypot(gx - prev[0], gy - prev[1]))
                k = int(max(1, min(GLIDE_MAX_STEPS, round(seg / GLIDE_PX_PER_STEP))))
                page.mouse.move(float(gx), float(gy), steps=k)   # interpolated → smooth continuous travel
            prev = (gx, gy)
            if _reject_recent(gx, gy):
                continue                                 # travelled through, but don't dwell/select here
            if _cursor_over_mob(page, settle):           # DWELL ≥1 frame then read (the reliable per-point read)
                return (float(gx), float(gy))
    return None


def _ang_diff(a, b):
    """Absolute circular difference between two angles (radians, 0..π) — same wrap as _ang_lerp."""
    return abs(float(np.arctan2(np.sin(a - b), np.cos(a - b))))


def _blind_scan_filter(page, w, h):
    """Build a per-point KEEP predicate for a blind hover scan from ONE memory mob-map read, so the scan
    skips regions the map says are empty while KEEPING the outward nearest-ring-first ORDER (the caller
    still walks the same rings; filtered points are just skipped). Returns (keep_fn_or_None, desc) —
    keep_fn None = no filtering. Tiered by what's available (each tier falls through to the next):
      tier 1 — projection healthy (_proj_ok): keep points within HOVER_MOB_NEAR_PX of at least one
               wanted mob's projected screen point (generous vs fit error + movement during the scan);
      tier 2 — no healthy projection but the camera heading is readable: ANGULAR sector filter. A mob's
               predicted screen polar angle about the centre = SCREEN_FWD + (world bearing − camera
               heading); the left/right MIRROR sign of that mapping isn't established, so a point is kept
               when within HOVER_SECTOR_TOL_DEG of EITHER the predicted angle OR its mirror, for ANY mob
               — still skips a big fraction of an empty half-screen while safe against the ambiguity;
      tier 3 — neither available / memory read empty or failing: None (today's full radial scan) — a
               memory outage must NEVER shrink coverage.
    All names referenced here (read_wanted_mobs, _proj_*, SCREEN_FWD) resolve at call time, so this can
    live before their definitions in the file."""
    d = read_wanted_mobs(page)                                # already exception-safe + species-filtered
    mobs = [m for m in d["mobs"] if m[0] <= VIEW_MOB_RANGE]
    if not mobs:
        return None, "no filter: no close mob in memory"
    px, pz = d["px"], d["pz"]
    if _proj_ok():
        proj = []
        for m in mobs:
            pt = _proj_world_to_screen(page, m[3] - px, m[5] - pz)
            if pt is not None:
                proj.append(pt)
        if proj:
            r2 = HOVER_MOB_NEAR_PX ** 2
            def keep(gx, gy, _proj=proj, _r2=r2):
                return any((gx - mx) ** 2 + (gy - my) ** 2 <= _r2 for mx, my in _proj)
            return keep, f"proj-filter, {len(proj)} mob(s)"
    ch = None
    try: ch = re_sense.camera_heading(page)
    except Exception: pass
    if ch is not None:
        tol = float(np.radians(HOVER_SECTOR_TOL_DEG))
        angs = []
        for m in mobs:
            rel = float(np.arctan2(m[5] - pz, m[3] - px)) - ch   # mob world bearing, camera-relative
            angs.append(SCREEN_FWD + rel)                         # predicted screen polar angle…
            angs.append(SCREEN_FWD - rel)                         # …and its mirror (sign unestablished)
        cx, cy = w * 0.5, h * 0.5
        def keep(gx, gy, _angs=angs, _tol=tol, _cx=cx, _cy=cy):
            pa = float(np.arctan2(gy - _cy, gx - _cx))
            return any(_ang_diff(pa, a) <= _tol for a in _angs)
        return keep, f"sector-filter, {len(mobs)} mob(s)"
    return None, "no filter: no projection/heading"


def _filter_scan_points(page, w, h, rings, filtered):
    """Apply _blind_scan_filter to ring-grouped scan points (order preserved). Returns (rings, desc) —
    desc = the diagnosable 'kept/total pts (tier)' string the CALLER logs at scan END together with the
    elapsed time (so one line answers both "what did it scan" and "how long did it take").
    filtered=False = deliberately unfiltered (e.g. the backup scan after targeted misses, where the
    projection itself is suspect — filtering the backup by the same suspect data would defeat its
    purpose). SAFETY: if the filter would keep ZERO points (e.g. a stale projection placed every mob
    off-path) the full unfiltered rings are returned instead — memory guidance may speed the scan up but
    must never blind it completely."""
    total = sum(len(r) for r in rings)
    if not filtered:
        return rings, f"{total}/{total} pts (unfiltered backup)"
    keep, desc = _blind_scan_filter(page, w, h)
    if keep is None:
        return rings, f"{total}/{total} pts ({desc})"
    kept_rings = [[p for p in ring if keep(*p)] for ring in rings]
    kept = sum(len(r) for r in kept_rings)
    if kept == 0:
        return rings, f"filter kept 0/{total} pts ({desc}) — scanned unfiltered instead"
    return [r for r in kept_rings if r], f"{kept}/{total} pts ({desc})"


# Batched-logger HEALTH: True once the in-page cursor-swap logger has been caught dead (a full sweep
# moved the mouse but the page counted ZERO mousemove events — CDP moves MUST fire mousemove, so zero
# means the listener isn't seeing input at all, i.e. every swap is being silently lost). From then on
# hover_scan_radial forces the per-point path (which reads the cursor itself and can't lose a hit that
# way) for the rest of the session. Module-level, so a hot-reload/Start resets it and re-tries batched.
_HOVER_BATCH_DEAD = False


def _hover_scan_batched(page, w, h, should_stop=None, filtered=True):
    """(C) SUPERSEDED 2026-07-02g by the smooth _glide_scan (which the blind + radial-mem scans now use);
    kept for reference / as the origin of the _HOVER_BATCH_DEAD health check, but no longer called.
    Blind sweep at the ~one-frame-per-point floor: move through each RING at a steady pace with NO
    per-point read, then drain the cursor-swap log once per ring — so a HIT surfaces at the drain right
    after its ring (inner rings are small; there is deliberately NO wait-until-sweep-end); any 'curattack'
    entry re-hovers that logged (x,y) and confirms it with cursor_kind_synced (kills the "pointer already
    moved on" race) before returning it. Skips A4-rejected points; ring points are pre-filtered by the
    memory mob-map (_filter_scan_points) unless filtered=False. HEALTH CHECK: after a full empty sweep
    that DID move the mouse, if the page counted zero mousemove events the logger is dead → warn loudly +
    set _HOVER_BATCH_DEAD so the session falls back to per-point scanning (a dead logger otherwise reads
    as "no mobs anywhere", silently blinding the scan). Logs 'kept/total pts (tier) in X.Xs' at scan end."""
    global _HOVER_BATCH_DEAD
    if not _ensure_cursor_logger(page):
        return None                      # logger install failed (e.g. mid-navigation) — caller retries later
    t0 = time.time()
    pace = HOVER_SWEEP_PACE if HOVER_SWEEP_PACE is not None else max(HOVER_SETTLE, 0.016)
    rings, desc = _filter_scan_points(page, w, h, _hover_scan_rings(w, h), filtered)
    _drain_cursor_log(page)              # discard stale entries logged before this scan started
    try: page.evaluate("() => { window.__curMoves = 0; }")   # reset the health counter for THIS sweep
    except Exception: pass
    moved = 0
    for ring in rings:
        if should_stop and should_stop():
            return None
        for gx, gy in ring:
            if _reject_recent(gx, gy):
                continue
            page.mouse.move(float(gx), float(gy), steps=1)
            moved += 1
            time.sleep(pace)
        for ev in _drain_cursor_log(page):
            if ev.get("cur") != ATTACK_CURSOR:
                continue
            hx, hy = ev.get("x"), ev.get("y")
            if hx is None or hy is None or _reject_recent(hx, hy):
                continue
            if not _hover_point_ok(hx / w, hy / h):
                continue                 # logged pointer pos outside the calibrated scan area — ignore
            page.mouse.move(float(hx), float(hy))
            if _cursor_over_mob(page):        # (2026-07-02f) unified settle/raf read + empty-retry guard
                print(f"[hover] blind sweep: {desc} in {time.time()-t0:.1f}s (hit)")
                return (float(hx), float(hy))
    print(f"[hover] blind sweep: {desc} in {time.time()-t0:.1f}s")
    if moved > 0:                        # health check: we DID move the mouse — did the page see it?
        try: seen = int(page.evaluate("window.__curMoves||0") or 0)
        except Exception: seen = -1     # unreadable ≠ dead — don't disable batched on a flaky evaluate
        if seen == 0:
            print("[hover] WARNING: in-page logger saw no mouse events — falling back to per-point scanning")
            _HOVER_BATCH_DEAD = True
    return None


def hover_scan_radial(page, w, h, should_stop=None, filtered=True):
    """Find a mob by hovering OUTWARD from the centre (the character) in growing rings and reading the game
    cursor — returns the FIRST point where it's the ATTACK cursor, so the NEAREST mob wins (or None).
    Hovering never clicks, so a wrong point is harmless; the character at the centre isn't attackable.
    Pre-filters by the memory mob-map (_filter_scan_points) unless filtered=False (the unfiltered backup
    scan). (2026-07-02g) DEFAULT = a SMOOTH two-pass GLIDE (HOVER_GLIDE): pass 1 = coarse ring set
    (HOVER_PASS1_STEP separation) glided fast; if nothing, pass 2 = tight ring set (HOVER_RADIAL_STEP)
    glided slower. Falls back to the per-point settle scan when the glide can't run (HOVER_GLIDE off, or the
    logger is dead — _HOVER_BATCH_DEAD). Logs 'kept/total pts (tier) in X.Xs' per pass/scan."""
    if not HOVER_SCAN_ON:
        return None
    # SMOOTH two-pass glide (default): smooth interpolated TRAVEL + per-point dwell-read detection.
    if HOVER_GLIDE:
        t0 = time.time()
        rings1, desc1 = _filter_scan_points(page, w, h, _hover_scan_rings(w, h, radial_step=HOVER_PASS1_STEP), filtered)
        hit = _glide_scan(page, w, h, rings1, HOVER_SETTLE, should_stop)   # PASS 1: coarse (few rings), quick
        if hit:
            print(f"[hover] blind glide p1: {desc1} in {time.time()-t0:.1f}s (hit)")
            return hit
        if should_stop and should_stop():
            return None
        rings2, desc2 = _filter_scan_points(page, w, h, _hover_scan_rings(w, h), filtered)
        hit = _glide_scan(page, w, h, rings2, HOVER_PASS2_SETTLE, should_stop)   # PASS 2: tight, longer dwell
        print(f"[hover] blind glide p2: {desc2} in {time.time()-t0:.1f}s" + (" (hit)" if hit else ""))
        return hit
    # PER-POINT SETTLE fallback (HOVER_GLIDE off): the proven teleport+settle+read, single tight pass.
    t0 = time.time()
    rings, desc = _filter_scan_points(page, w, h, _hover_scan_rings(w, h), filtered)
    for ring in rings:
        for gx, gy in ring:
            if should_stop and should_stop():
                return None
            if _reject_recent(gx, gy):
                continue
            page.mouse.move(float(gx), float(gy), steps=1)
            if _cursor_over_mob(page):        # (2026-07-02f) unified settle/raf read + empty-retry guard
                print(f"[hover] blind scan: {desc} in {time.time()-t0:.1f}s (hit)")
                return (float(gx), float(gy))
    print(f"[hover] blind scan: {desc} in {time.time()-t0:.1f}s")
    return None


def _target_species(page):
    """Species id (the IDS_PROPMOVER number, as a string) of the currently SELECTED target, read from
    memory (controller+2656 → registry). None if nothing resolved."""
    try:
        t = re_sense.read_target(page)
    except Exception:
        return None
    if not t or not t.get("resolved"):
        return None
    nm = t.get("name") or ""
    return nm.replace("IDS_PROPMOVER_TXT_", "") if nm else None


def _wanted_species(sp):
    """Is species id `sp` one we want to grind? True when type-filtering is off (MOB_GRIND_SET None/empty)
    or `sp` is in the wanted set. Unknown/None species → True (don't drop a mob we couldn't identify)."""
    if not MOB_SCAN_TYPE or not MOB_GRIND_SET:
        return True
    return sp is None or str(sp) in MOB_GRIND_SET


def select_and_attack(page, x, y):
    """Click (x,y) to SELECT, confirm via a MEMORY poll (4., 2026-07-02e — replaces a fixed SELECT_GAP
    sleep + JPEG grab), then press ATTACK. Returns (x,y) or None. Used by the cursor fallback, which
    already knows a mob is under (x,y) (no nameplate needed).
    Polls re_sense.read_target every SELECT_CONFIRM_POLL for up to SELECT_CONFIRM_MAX after the click:
      - resolved kind==5 (a mob) → species-check from THIS SAME read (no extra evaluate) when
        MOB_SCAN_TYPE is on; wanted → attack; unwanted → Tab-deselect + remember the point (A4's
        _REJECTED, so the blind hover scan routes around this livelocking mob for a while) and return None.
      - resolved kind!=5 (a player/NPC) → Tab-deselect and give up on this point immediately (no need to
        keep polling — it's exact, not a flicker).
      - nothing resolves within the window (memory outage / click never selected anything) → fall back to
        ONE legacy pixel check (grab_fast + has_target/is_player_target), so a memory outage degrades
        gracefully to the pre-4. behaviour instead of silently treating it as "nothing selected"."""
    plog.action(f"click SELECT (cursor) ({int(x)},{int(y)})")
    page.mouse.click(float(x), float(y))
    deadline = time.time() + SELECT_CONFIRM_MAX
    while time.time() < deadline:
        time.sleep(SELECT_CONFIRM_POLL)
        try:
            t = re_sense.read_target(page)
        except Exception:
            t = None
        if not t or not t.get("has") or not t.get("resolved"):
            continue                                        # not selected yet (or id set, not resolved) — keep polling
        if t.get("kind") != 5:
            page.keyboard.press("Tab")                       # selected a player/NPC — drop it, don't wait out the window
            return None
        sp = (t.get("name") or "").replace("IDS_PROPMOVER_TXT_", "") or None
        if MOB_SCAN_TYPE and MOB_GRIND_SET and not _wanted_species(sp):
            print(f"[type ] selected species #{sp} not wanted — skipping")
            page.keyboard.press("Tab")                       # deselect so it isn't left targeted
            _REJECTED.append((x, y, time.time()))            # A4: blind scans skip this spot for a while
            return None
        plog.action(f"key ATTACK '{ATTACK_KEY}' (cursor)")
        page.keyboard.down(ATTACK_KEY); time.sleep(0.06); page.keyboard.up(ATTACK_KEY)
        return (float(x), float(y))
    # memory never resolved a target in the window — legacy pixel fallback (memory outage only)
    shot = grab_fast(page)
    if has_target(shot) and not is_player_target(shot):
        plog.action(f"key ATTACK '{ATTACK_KEY}' (cursor, pixel-fallback)")
        page.keyboard.down(ATTACK_KEY); time.sleep(0.06); page.keyboard.up(ATTACK_KEY)
        return (float(x), float(y))
    return None


SCREEN_FWD = -np.pi / 2   # walk_dir screen angle for "forward/up" (into the scene, away from the player)
# The CAMERA's view direction in WORLD coords (map convention atan2(z,x)), or None if unknown. PREFERRED
# source = the EXACT live camera yaw read from memory (re_sense.read_camera_yaw + a learned deg→world
# mapping), which is drift-free and always available once calibrated. FALLBACK (before calibration) = the
# self-calibrated walk offset cam_off (camera-forward = SCREEN_FWD + cam_off). The mini-map draws THIS as
# the self-arrow (where the CAMERA looks), not the movement heading.
_CAM_HEADING = None


def _update_cam_heading(cam_off):
    """FALLBACK setter: derive _CAM_HEADING from the walk offset cam_off — but ONLY while the exact-yaw
    mapping isn't calibrated yet (once it is, the grind loop sets _CAM_HEADING from the live yaw, so we
    must not clobber it with the coarser estimate)."""
    global _CAM_HEADING
    if cam_off is None or re_sense.camera_calibrated():
        return
    _CAM_HEADING = float(np.arctan2(np.sin(SCREEN_FWD + cam_off), np.cos(SCREEN_FWD + cam_off)))


def read_wanted_mobs(page):
    """Nearby WANTED mobs from memory (re_sense.read_nearby_mobs filtered to MOB_GRIND_SET; None=all):
    {'px','pz','mobs':[row,...]} where row = [dist, species, hp, x, y, z, idLo, idHi, aggro, name, level]."""
    try:
        d = re_sense.read_nearby_mobs(page, limit=40)
    except Exception:
        return {"px": 0, "pz": 0, "mobs": []}
    mobs = d.get("mobs", [])
    if MOB_GRIND_SET:
        mobs = [m for m in mobs if str(m[1]) in MOB_GRIND_SET]
    return {"px": d.get("px", 0), "pz": d.get("pz", 0), "mobs": mobs}


def nearby_wanted_count(page, rng):
    """How many WANTED mobs (memory, filtered by MOB_GRIND_SET when curated) are within `rng` world-units.
    Used to avoid needless camera turns / recalibration when mobs are already right here (detection just
    missed them this frame). 0 on memory error → safe fallback to the normal turn-to-search behaviour."""
    try:
        d = read_wanted_mobs(page)
        return sum(1 for m in d["mobs"] if m[0] <= rng)
    except Exception:
        return 0


def wanted_mob_summary(page, close_rng):
    """ONE memory read → (close_count, centroid_world_angle, nearest_dist, total_count) of WANTED nearby
    mobs, so a loop pass can make all its decisions (skip-hover / steer / retry) from a single registry
    walk. close_count = mobs within close_rng world-units. angle/nearest are None when none are loaded."""
    try:
        d = read_wanted_mobs(page)
    except Exception:
        return (0, None, None, 0)
    mobs = d["mobs"]
    if not mobs:
        return (0, None, None, 0)
    px, pz = d["px"], d["pz"]
    close = sum(1 for m in mobs if m[0] <= close_rng)
    cx = sum(m[3] for m in mobs) / len(mobs)
    cz = sum(m[5] for m in mobs) / len(mobs)
    nearest = min(m[0] for m in mobs)
    return (close, float(np.arctan2(cz - pz, cx - px)), float(nearest), len(mobs))


def wanted_centroid_dir(page):
    """(world_angle, nearest_dist, count) toward the centroid of WANTED nearby mobs (from memory), or
    None if none are loaded. world_angle = atan2(cz−pz, cx−px) in world coords (screen x=world x, y=world z)."""
    d = read_wanted_mobs(page)
    mobs = d["mobs"]
    if not mobs:
        return None
    px, pz = d["px"], d["pz"]
    cx = sum(m[3] for m in mobs) / len(mobs)
    cz = sum(m[5] for m in mobs) / len(mobs)
    nearest = min(m[0] for m in mobs)
    return (float(np.arctan2(cz - pz, cx - px)), float(nearest), len(mobs))


# ── A. Memory-guided targeted hover (2026-07-02e) ───────────────────────────────────────────────────
# Instead of scanning blind rings, project each nearby WANTED mob's memory world position to a screen
# point and hover a small spiral there, nearest mob first — empty scans were already skipped via
# wanted_mob_summary; this makes the NON-empty case ~0.3s instead of ~4s, targets the RIGHT mob, chases
# its movement, and (being memory-species-filtered) never hovers an unwanted species.
#
# A1: projection state + PASSIVE calibration (no dedicated calibration walk). Ported from click_nav.py's
# _fit/world_to_screen (validated live 2026-07-02: the game writes the world point a click raycasts to at
# controller+2540/+2548) — but fed from the clicks the bot ALREADY makes during normal play, not a
# dedicated calibration routine.
CLICK_CALIB_CACHE = "click_calib.json"   # click_nav's cached fit — bootstraps A1 (same file, same format)
_PROJ = {"samples": deque(maxlen=40), "coef": None, "err": None, "yaw_fit": None}
_proj_last_dest = None   # last-seen click dest (world x,z), for _proj_feed's "not a near-duplicate" gate
_PROJ_YAW_SIGN = 1.0      # sign of the yaw-delta rotation compensation applied in _proj_world_to_screen —
                          # UNVERIFIED a priori (the rotation handedness isn't derived, just asserted), so
                          # it's self-checked against live passive samples (_proj_check_sign) and flipped
                          # if the other sign fits the data better.
_PROJ_SIGN_ERR = {1.0: deque(maxlen=8), -1.0: deque(maxlen=8)}   # rolling world-unit residuals per sign

# controller ptr shifted by the auto-located data-section delta (re_sense._LOC sets window.__S each session)
_DEST_JS = ("()=>{const dv=new DataView(HEAPU8.buffer);const c=dv.getUint32(1230588+(window.__S||0),true);"
            "return c?[dv.getFloat32(c+2540,true),dv.getFloat32(c+2548,true)]:null;}")


def _read_click_dest(page):
    """The world (x,z) the game's LAST click raycast to — controller+2540/+2548 (click_nav.py's proven
    ground truth). None on any read failure (no controller / no heap)."""
    try:
        return page.evaluate(_DEST_JS)
    except Exception:
        return None


def _affine_fit(rows):
    """rows = [(sx,sy,dwx,dwz), ...] -> ((cx,cz) affine coeffs, per-row world-unit residuals). Same lstsq
    as click_nav.ClickNav._fit: dwx ≈ cx·[sx,sy,1], dwz ≈ cz·[sx,sy,1]."""
    A = np.array([[r[0], r[1], 1] for r in rows])
    cx = np.linalg.lstsq(A, np.array([r[2] for r in rows]), rcond=None)[0]
    cz = np.linalg.lstsq(A, np.array([r[3] for r in rows]), rcond=None)[0]
    err = np.hypot(A @ cx - np.array([r[2] for r in rows]), A @ cz - np.array([r[3] for r in rows]))
    return (cx, cz), err


def _proj_ok():
    """Is the passively-calibrated projection usable right now? Needs a fit AND (no confirmed error yet,
    or a confirmed error within PROJ_MAX_ERR world-units)."""
    return _PROJ["coef"] is not None and (_PROJ["err"] is None or _PROJ["err"] <= PROJ_MAX_ERR)


def _proj_refit(page):
    """(Re)fit worldOffset = affine(screen) from the accumulated passive samples (click_nav._fit's lstsq
    + one outlier-rejection pass). Samples were taken at POSSIBLY DIFFERENT camera yaws (normal play keeps
    rotating the camera), so each sample's world offset is first de-rotated into a single reference frame
    (the MOST RECENT sample's yaw) using the current _PROJ_YAW_SIGN before fitting — this is also why the
    fit tightens up once the sign is confirmed correct by _proj_check_sign. Samples with no recorded yaw
    (a live yaw read failed) are folded in un-rotated (best effort)."""
    pts = list(_PROJ["samples"])
    if len(pts) < PROJ_MIN_SAMPLES:
        return
    ref_yaw = pts[-1][4]                                      # most recent sample's yaw = reference frame
    rows = []
    for sx, sy, dwx, dwz, yaw in pts:
        if yaw is not None and ref_yaw is not None:
            delta = (ref_yaw - yaw) * _PROJ_YAW_SIGN
            ca, sa = np.cos(delta), np.sin(delta)
            dwx, dwz = dwx * ca - dwz * sa, dwx * sa + dwz * ca
        rows.append((sx, sy, dwx, dwz))
    try:
        coef, err = _affine_fit(rows)
        thr = max(1.5, 2.0 * float(np.median(err)))           # drop outlier rows, refit (click_nav.calibrate)
        keep = [r for r, ee in zip(rows, err) if ee <= thr]
        if PROJ_MIN_SAMPLES <= len(keep) < len(rows):
            coef, err = _affine_fit(keep)
        _PROJ["coef"] = coef
        _PROJ["err"] = float(np.mean(err))
        _PROJ["yaw_fit"] = ref_yaw
    except Exception:
        pass


def _proj_check_sign(sx, sy, dwx, dwz, yaw):
    """Before folding a NEW passive sample into the fit, score it against the EXISTING coef+yaw_fit under
    BOTH sign hypotheses: de-rotate this sample into the fit's reference frame (using each candidate sign),
    then compare to what the raw affine predicts from its screen point — a world-unit residual, same units
    as PROJ_MAX_ERR. Flip _PROJ_YAW_SIGN to whichever sign is tracking MEANINGFULLY better once both have
    enough data. If NEITHER fits within PROJ_MAX_ERR, the fit itself is stale (probably a big rotation
    happened without enough passive samples to track it) — drop it and let it rebuild from fresh samples."""
    global _PROJ_YAW_SIGN
    coef, yaw_fit = _PROJ["coef"], _PROJ["yaw_fit"]
    if coef is None or yaw is None or yaw_fit is None:
        return
    (a, b, e), (d, f, g) = coef
    for sign in (1.0, -1.0):
        delta = (yaw_fit - yaw) * sign
        ca, sa = np.cos(delta), np.sin(delta)
        rx, rz = dwx * ca - dwz * sa, dwx * sa + dwz * ca       # sample, de-rotated into the fit frame
        px_, pz_ = a * sx + b * sy + e, d * sx + f * sy + g     # what the raw affine predicts from screen
        _PROJ_SIGN_ERR[sign].append(float(np.hypot(rx - px_, rz - pz_)))
    ep, em = _PROJ_SIGN_ERR[1.0], _PROJ_SIGN_ERR[-1.0]
    if len(ep) < 4 or len(em) < 4:
        return
    mp, mm = float(np.median(ep)), float(np.median(em))
    better = 1.0 if mp <= mm else -1.0
    if better != _PROJ_YAW_SIGN and min(mp, mm) < max(mp, mm) - 0.5:     # meaningfully better, not noise
        print(f"[hover] projection yaw-sign flipped ({_PROJ_YAW_SIGN:+.0f} -> {better:+.0f}, err {mp:.1f}/{mm:.1f}u)")
        _PROJ_YAW_SIGN = better
        ep.clear(); em.clear()
    if mp > PROJ_MAX_ERR and mm > PROJ_MAX_ERR:
        print(f"[hover] projection fit stale under both signs (err {mp:.1f}/{mm:.1f}u > {PROJ_MAX_ERR}) — dropping, rebuilding")
        _PROJ["coef"] = None; _PROJ["err"] = None; _PROJ["yaw_fit"] = None
        _PROJ["samples"].clear()
        ep.clear(); em.clear()


def _proj_feed(page, sx, sy, p0, dest):
    """PASSIVE projection calibration: fold one click-to-move's (screen point, resulting world-offset)
    pair into the projection fit. `p0` = player pos read BEFORE the click, `dest` = the world point the
    click raycast to (read ~0.15s after, by the caller — walk_dir owns the click/read TIMING; this owns
    sample ACCEPTANCE + refit). Applies click_nav.calibrate()'s sanity gates (a plausible click-to-move
    distance, and not a near-duplicate of the previous dest) so degenerate samples don't bias the fit. NO
    dedicated calibration walk needed — every normal grind walk quietly improves the projection."""
    global _proj_last_dest
    if p0 is None or dest is None:
        return
    dwx, dwz = dest[0] - p0[0], dest[1] - p0[2]
    off = float(np.hypot(dwx, dwz))
    pd, _proj_last_dest = _proj_last_dest, dest
    if pd is not None and float(np.hypot(dest[0] - pd[0], dest[1] - pd[1])) <= 0.5:
        return                                                # near-duplicate dest — no new information
    if not (1 < off < 40):
        return                                                # not a plausible click-to-move distance
    yaw = None
    try: yaw = re_sense.read_camera_yaw_rad(page)
    except Exception: pass
    _proj_check_sign(sx, sy, dwx, dwz, yaw)                    # self-check BEFORE folding the sample in
    _PROJ["samples"].append((sx, sy, dwx, dwz, yaw))
    if len(_PROJ["samples"]) >= PROJ_MIN_SAMPLES:
        _proj_refit(page)


def _proj_world_to_screen(page, dwx, dwz):
    """Camera-relative world offset (target - player) -> screen pixel, via the passively-fit affine
    (click_nav.world_to_screen's math). The fit's reference frame is a specific past camera yaw (yaw_fit)
    and the camera keeps rotating during normal play, so first rotate the QUERY offset by the live yaw
    delta (yaw_fit - yaw_now) * _PROJ_YAW_SIGN into that reference frame — this is what lets one fit stay
    valid across camera turns without refitting. Returns None if unfit / unreadable / degenerate."""
    coef = _PROJ["coef"]
    if coef is None:
        return None
    (a, b, e), (d, f, g) = coef
    dx, dz = dwx, dwz
    yaw_fit = _PROJ["yaw_fit"]
    yaw_now = None
    try: yaw_now = re_sense.read_camera_yaw_rad(page)
    except Exception: pass
    if yaw_fit is not None and yaw_now is not None:
        delta = (yaw_fit - yaw_now) * _PROJ_YAW_SIGN
        ca, sa = np.cos(delta), np.sin(delta)
        dx, dz = dwx * ca - dwz * sa, dwx * sa + dwz * ca
    det = a * f - b * d
    if abs(det) < 1e-9:
        return None
    return (((dx - e) * f - (dz - g) * b) / det, (a * (dz - g) - d * (dx - e)) / det)


def _proj_invalidate():
    """Drop the projection fit after a camera rotation (rotate_view/tilt_forward/calibrate_view) WHEN THE
    LIVE YAW ISN'T READABLE — without yaw there's no way to compensate for the rotation, so the old fit
    (tied to the pre-rotation camera orientation) is now wrong; passive samples rebuild it. When yaw IS
    readable, _proj_world_to_screen's yaw-delta compensation handles rotation without a refit, so this is
    a cheap no-op — left in place for if yaw ever becomes unreadable mid-session (e.g. a game patch)."""
    if _PROJ["coef"] is None:
        return
    try:
        yaw_ok = re_sense.camera_yaw_source_set()
    except Exception:
        yaw_ok = False
    if not yaw_ok:
        _PROJ["coef"] = None; _PROJ["err"] = None; _PROJ["yaw_fit"] = None


def _proj_bootstrap(page):
    """Seed the projection from click_nav's cached calibration (CLICK_CALIB_CACHE), if any, right after
    grind()'s startup calibrate_view() has reset the camera to the standard zoom/tilt that cache assumed —
    so the targeted hover (A2) can work from the FIRST grind cycle instead of waiting for live passive
    samples to accumulate. err is left None ('unconfirmed') until a live sample validates it; if the
    cached fit turns out stale THIS session, _proj_check_sign / PROJ_MAX_ERR drop it and the projection
    quietly rebuilds itself from fresh passive samples — no user action needed."""
    if _PROJ["coef"] is not None:
        return                                                # already have a live fit — don't clobber it
    if not os.path.exists(CLICK_CALIB_CACHE):
        return
    try:
        j = json.load(open(CLICK_CALIB_CACHE))
        _PROJ["coef"] = (np.array(j["cx"], dtype=float), np.array(j["cz"], dtype=float))
        _PROJ["err"] = None
        try: _PROJ["yaw_fit"] = re_sense.read_camera_yaw_rad(page)
        except Exception: _PROJ["yaw_fit"] = None
        print("[hover] projection bootstrapped from click_calib.json (unconfirmed until a live sample checks it)")
    except Exception:
        pass


# A2: targeted spiral hover. (0,0) then 8-point rings at r = STEP, 2*STEP, ... up to MAX — click_nav's
# _SPIRAL pattern, so a fast local search around a (possibly slightly-off) projected point still finds
# the mob.
def _spiral_offsets(step=TARGETED_SPIRAL_STEP, rmax=TARGETED_SPIRAL_MAX):
    pts = [(0, 0)]
    r = step
    while r <= rmax:
        for i in range(8):
            t = i * np.pi / 4
            pts.append((int(r * np.cos(t)), int(r * np.sin(t))))
        r += step
    return pts


_SPIRAL_OFFSETS = _spiral_offsets()


# ── (2026-07-02f) RADIAL-MEM primary scan: full circles at memory-derived radii around the character ──
def _proj_screen_radius(dwx, dwz):
    """Screen RADIUS (px from the character) for a world offset, from the projection's LINEAR part ONLY —
    the inverse 2×2 [[a,b],[d,f]] of the affine, applied to (dwx,dwz), magnitude. ROTATION-INVARIANT: a
    (near-)conformal top-down affine gives ||Minv·R·v|| = scale·||v|| for ANY yaw rotation R, so this
    radius is trustworthy even when the yaw/angle compensation is wrong or absent — which is exactly why we
    scan a FULL circle at this radius rather than trusting the (shaky) angle. Gate on coef alone (NOT
    _proj_ok): the SCALE is usable even when the fit's angle error is high. Returns None if no coef /
    degenerate."""
    coef = _PROJ["coef"]
    if coef is None:
        return None
    (a, b, e), (d, f, g) = coef
    det = a * f - b * d
    if abs(det) < 1e-9:
        return None
    sx = (f * dwx - b * dwz) / det                        # Minv · (dwx,dwz), no translation (radius about
    sy = (-d * dwx + a * dwz) / det                       # the player) and no rotation (magnitude-invariant)
    return float(np.hypot(sx, sy))


def _merge_radii(radii):
    """Cluster sorted radii into ring radii, merging any within HOVER_RADIAL_STEP of the previous — this IS
    the ±HOVER_RADIAL_STEP tolerance band that covers fit error + mob movement (nearby mobs share one ring;
    clusters end up ≥ a step apart). Returns the per-cluster MEAN radius, nearest first."""
    step = max(12, HOVER_RADIAL_STEP)
    out, cur = [], []
    for r in sorted(radii):
        if cur and r - cur[-1] > step:
            out.append(float(np.mean(cur))); cur = []
        cur.append(r)
    if cur:
        out.append(float(np.mean(cur)))
    return out


def _ring_points(w, h, r, arc_step=None):
    """A FULL circle of hover points at radius `r` (px) about the screen centre (the character), at
    ~`arc_step` arc spacing (default HOVER_RADIAL_STEP; the coarse pass-1 glide passes a wider step), each
    guarded by the viewport + _hover_point_ok (reject-list checked by the caller at scan time). Same
    geometry as _hover_scan_rings, but at a chosen radius."""
    cx, cy = w * 0.5, h * 0.5
    step = max(12, arc_step if arc_step else HOVER_RADIAL_STEP)
    n = max(8, int(2 * np.pi * max(r, 1) / step))
    pts = []
    for i in range(n):
        ang = 2 * np.pi * i / n
        gx, gy = cx + r * np.cos(ang), cy + r * np.sin(ang)
        if 0 < gx < w and 0 < gy < h and _hover_point_ok(gx / w, gy / h):
            pts.append((gx, gy))
    return pts


def _mem_rings(w, h, ring_radii, arc_step, tol=0.0):
    """Ring-grouped points for the memory-derived radii (nearest first): each R → a full circle at
    `arc_step` arc spacing; with tol>0 ALSO R±tol tolerance SUB-RINGS (the slow pass-2's finer radial
    coverage). Empty/degenerate rings dropped. Consumed by hover_radial_memory's glide passes."""
    rings = []
    for R in ring_radii:
        for rr in ([R - tol, R, R + tol] if tol > 0 else [R]):
            if rr <= 4:
                continue
            pts = _ring_points(w, h, rr, arc_step=arc_step)
            if pts:
                rings.append(pts)
    return rings


def _mem_ring_radii(page, w, h):
    """(mobs, ring_radii) — the WANTED mobs within VIEW_MOB_RANGE (memory, already species-filtered +
    distance-sorted) and the memory-derived RING RADII (px) to scan for them (see _proj_screen_radius /
    _merge_radii). ring_radii is None when there's no coef (no scale available) so the caller can fall
    back to the blind scan; [] means mobs exist but none produced a usable radius."""
    d = read_wanted_mobs(page)
    px, pz = d["px"], d["pz"]
    mobs = [m for m in d["mobs"] if m[0] <= VIEW_MOB_RANGE]
    if not mobs or _PROJ["coef"] is None:
        return mobs, None
    radii = []
    for m in mobs:
        r = _proj_screen_radius(m[3] - px, m[5] - pz)
        if r is not None and r > 4:                       # >4px: a mob basically ON us is the self-scan's job
            radii.append(r)
    return mobs, _merge_radii(radii)


def hover_radial_memory(page, w, h, should_stop=None):
    """(2026-07-02f) PRIMARY cursor finder: scan FULL CIRCLES at the memory-derived radii around the
    character, nearest ring first. Radius = rotation-invariant projection SCALE × exact memory distance, so
    it's reliable even when the camera-yaw angle isn't — hence a full circle per radius, no sector guess.
    Returns (x,y) at the first curattack, or None. Behaviour by availability:
      * no WANTED mob within VIEW_MOB_RANGE → None (caller repositions; this is the "don't scan empty space
        close to us" the memory map lets us do);
      * mobs close but NO projection coef → fall back to the memory-filtered blind radial scan (as before);
      * else → scan the merged mob radii (±HOVER_RADIAL_STEP tolerance from the merge). Skips radial bands
        with no mob at that distance (the memory skip, now expressed radially — cleaner than the sector filter)."""
    t0 = time.time()
    mobs, ring_radii = _mem_ring_radii(page, w, h)
    if not mobs:
        return None                                       # nothing close → let the caller reposition
    if ring_radii is None:
        return hover_scan_radial(page, w, h, should_stop) # no scale → memory-filtered blind scan
    if not ring_radii:
        return hover_scan_radial(page, w, h, should_stop) # mobs but no usable radius → blind scan
    rr_txt = f"{[int(r) for r in ring_radii]}px from {len(mobs)} mobs"
    # (2026-07-02h) SMOOTH two-pass scan over the memory radii: smooth interpolated TRAVEL + per-point
    # dwell-read detection. Pass 1 = coarse arc sampling (quick); if nothing, pass 2 = finer arc sampling +
    # ±HOVER_PASS2_TOL tolerance sub-rings with a slightly longer per-point dwell. HOVER_GLIDE off → the
    # per-point teleport+settle scan of the same radii below.
    if HOVER_GLIDE:
        hit = _glide_scan(page, w, h, _mem_rings(w, h, ring_radii, arc_step=HOVER_PASS1_STEP),
                          HOVER_SETTLE, should_stop)       # PASS 1: coarse arc, quick
        if hit:
            print(f"[hover] radial-mem glide p1: {rr_txt}, hit in {time.time()-t0:.1f}s")
            return hit
        if should_stop and should_stop():
            return None
        hit = _glide_scan(page, w, h, _mem_rings(w, h, ring_radii, arc_step=HOVER_RADIAL_STEP, tol=HOVER_PASS2_TOL),
                          HOVER_PASS2_SETTLE, should_stop)  # PASS 2: tight arc + tolerance sub-rings, longer dwell
        print(f"[hover] radial-mem glide p2: {rr_txt}, {'hit' if hit else 'miss'} in {time.time()-t0:.1f}s")
        return hit
    # PER-POINT SETTLE fallback (HOVER_GLIDE off): the proven tight full-circle settle scan.
    for R in ring_radii:
        for gx, gy in _ring_points(w, h, R):
            if should_stop and should_stop():
                return None
            if _reject_recent(gx, gy):
                continue
            page.mouse.move(float(gx), float(gy), steps=1)
            if _cursor_over_mob(page):
                print(f"[hover] radial-mem: {len(ring_radii)} rings at radii {rr_txt}, hit in {time.time()-t0:.1f}s")
                return (float(gx), float(gy))
    print(f"[hover] radial-mem: {len(ring_radii)} rings at radii {rr_txt}, miss in {time.time()-t0:.1f}s")
    return None


def hover_mob_targeted(page, w, h, should_stop=None):
    """A2: instead of scanning blind rings, PROJECT each nearby WANTED mob's memory world position to a
    screen point (A1) and hover a small spiral there — nearest mob first, capped at TARGETED_MOB_LIMIT.
    Needs _proj_ok(); the caller (_hover_find, A3) falls back to the blind hover_scan_radial otherwise.
    Halfway through a mob's spiral we re-read + re-project THAT mob (matched by idLo/idHi) so the
    remaining points centre on its FRESH position (mobs move); the mob is dropped (next candidate tried)
    if it's gone. Never hovers an unwanted species — read_wanted_mobs is already species-filtered.
    Returns (x, y) on the first curattack hit, or None if every candidate mob's spiral was exhausted."""
    if not _proj_ok():
        return None
    d = read_wanted_mobs(page)
    px, pz = d["px"], d["pz"]
    cands = [m for m in d["mobs"] if m[0] <= VIEW_MOB_RANGE][:TARGETED_MOB_LIMIT]   # already dist-sorted
    mid = len(_SPIRAL_OFFSETS) // 2
    for m in cands:
        if should_stop and should_stop():
            return None
        id_lo, id_hi = m[6], m[7]
        pt = _proj_world_to_screen(page, m[3] - px, m[5] - pz)
        if pt is None:
            continue
        bx, by = pt
        if not (TARGETED_VIEWPORT_MARGIN < bx < w - TARGETED_VIEWPORT_MARGIN
                and TARGETED_VIEWPORT_MARGIN < by < h - TARGETED_VIEWPORT_MARGIN) or not _hover_point_ok(bx / w, by / h):
            continue                                          # off-screen/outside calibrated ROI — skip this mob
        for i, (ox, oy) in enumerate(_SPIRAL_OFFSETS):
            if should_stop and should_stop():
                return None
            if i == mid:                                      # mobs move — re-project halfway through
                d2 = read_wanted_mobs(page)
                match = next((mm for mm in d2["mobs"] if mm[6] == id_lo and mm[7] == id_hi), None)
                if match is None:
                    break                                      # this mob is gone — try the next candidate
                pt2 = _proj_world_to_screen(page, match[3] - d2["px"], match[5] - d2["pz"])
                if pt2 is not None:
                    bx, by = pt2
            sx, sy = bx + ox, by + oy
            if not (0 < sx < w and 0 < sy < h) or not _hover_point_ok(sx / w, sy / h):
                continue
            page.mouse.move(float(sx), float(sy), steps=1)
            if _cursor_over_mob(page):        # (2026-07-02f) unified settle/raf read + empty-retry guard
                return (float(sx), float(sy))
    return None


def _self_scan_points(w, h):
    """Tight rings hugging the screen CENTRE (the character) for the SELF-FIRST close scan — radii from
    HOVER_SELF_RADII (~8 points on the first ring, +4 per further ring). ROI-checked (_hover_point_ok) but
    deliberately NOT memory-filtered and NOT SELF_EXCL'd: a mob MELEEING us stands at our own position, so
    neither a projection error nor the sector filter may ever skip these points, and (per _hover_point_ok's
    note) hovering the own char is harmless — it never reads curattack."""
    cx, cy = w * 0.5, h * 0.5
    pts = []
    for i, r in enumerate(HOVER_SELF_RADII):
        n = 8 + 4 * i
        for k in range(n):
            ang = 2 * np.pi * k / n
            gx, gy = cx + r * np.cos(ang), cy + r * np.sin(ang)
            if not (0 < gx < w and 0 < gy < h) or not _hover_point_ok(gx / w, gy / h):
                continue
            pts.append((gx, gy))
    return pts


def _hover_self_first(page, w, h, should_stop=None):
    """SELF-FIRST close scan: hover the tight rings around the character BEFORE any targeted/blind scan.
    RATIONALE: a mob already ATTACKING us is standing adjacent to the char — it is both the most urgent
    target (it's hurting us) and the one every other strategy can miss (the targeted spiral goes where
    the projection points, the blind filter can skip the centre region, and the big radial rings start
    OUTSIDE melee range). ~20 points worst case (~0.5s), typically far less — stops on first curattack.
    Reject-list-checked so an unwanted melee-er can't livelock it."""
    for gx, gy in _self_scan_points(w, h):
        if should_stop and should_stop():
            return None
        if _reject_recent(gx, gy):
            continue
        page.mouse.move(float(gx), float(gy), steps=1)
        if _cursor_over_mob(page):            # (2026-07-02f) unified settle/raf read + empty-retry guard
            return (float(gx), float(gy))
    return None


# ── (item 4, 2026-07-02f) FIGHT PREFETCH: hover for the NEXT mob during the pure-hold fight ──────────
# While the memory FIGHT branch pure-holds (idle between MEM_FIGHT_POLL target reads) we spend a tiny
# budget hovering radial-mem points for OTHER mobs and cache the screen points where curattack showed, so
# on kill _hover_find engages a KNOWN candidate cold. HOVERING NEVER CLICKS → can't disturb the fight.
# DISCARD TIMING (2026-07-02f, revised): candidates NEVER expire by age DURING the fight — they accumulate
# for the whole fight and the only mid-fight invalidation is a camera move (_CAM_GEN). The discard
# countdown starts at the target's DEATH: on the FIGHT→IDLE death transition we stamp `death_t`, and the
# cache + its overlay boxes then live FIGHT_PREFETCH_TTL (2.0s) from death. Cleared on: (a) a different mob
# engaged (a cached candidate confirmed + returned to be engaged), (b) camera moved, (c) death_t set and
# older than the TTL. A fresh fight (or a camera-move rebuild) resets death_t to None so a new fight's
# candidates never inherit a stale death stamp.
_FIGHT_PREFETCH = {"cam_gen": -1, "points": [], "queue": None, "qi": 0, "death_t": None}


def _fight_prefetch(page, w, h, target):
    """One tiny prefetch step, called each FIGHT poll AFTER confirming the current target is still alive.
    Hovers up to FIGHT_PREFETCH_POINTS radial-mem points for OTHER nearby wanted mobs, EXCLUDING the region
    around the current target's projected screen pos (so we don't cache the mob we're killing) and the inner
    FIGHT_PREFETCH_MIN_RADIUS, and caches any curattack-confirmed points. Candidates NEVER expire by age
    during the fight — they accumulate; only a camera move (or a stale death stamp from the PREVIOUS fight)
    triggers a fresh rebuild. No-ops (no click, no camera move) when disabled / no projection / the target
    can't be projected (can't safely exclude it) / no other wanted mob is near."""
    if not FIGHT_PREFETCH or _PROJ["coef"] is None:
        return
    tpos = target.get("pos") if target else None
    if not tpos:
        return
    c = _FIGHT_PREFETCH
    gen = _CAM_GEN
    changed = False                                       # did the cache 'points' change? (→ resync boxes)
    # A live target ⇒ a fight is in progress. Start the cache FRESH if the camera moved (screen coords now
    # invalid) OR a death stamp is still set (it belongs to the PREVIOUS fight — reset death_t so this
    # fight's candidates don't inherit its death-relative TTL). NO age-based purge: candidates accumulate
    # for the whole fight and stay valid to the kill.
    if c["cam_gen"] != gen or c["death_t"] is not None:
        if c["points"]:
            changed = True
        c.update(cam_gen=gen, points=[], queue=None, qi=0, death_t=None)
    d = read_wanted_mobs(page)
    px, pz = d["px"], d["pz"]
    tp = _proj_world_to_screen(page, tpos[0] - px, tpos[2] - pz)   # current target on screen (to exclude)
    if tp is None:
        if changed:
            _prefetch_sync_viz(page)                      # boxes stay in lockstep even on an early skip
        return                                            # can't locate the fight on screen → skip safely
    near2 = HOVER_MOB_NEAR_PX ** 2
    scx, scy = w * 0.5, h * 0.5                            # screen centre = the character
    minr2 = FIGHT_PREFETCH_MIN_RADIUS ** 2                 # PREFETCH-ONLY inner-radius exclusion
    if c["queue"] is None:                                # build the candidate point queue once per cam_gen
        mobs, ring_radii = _mem_ring_radii(page, w, h)
        q = []
        for R in (ring_radii or []):
            for (gx, gy) in _ring_points(w, h, R):
                if (gx - scx) ** 2 + (gy - scy) ** 2 < minr2:
                    continue                              # too close to the char → likely the mob we're
                                                          # fighting (melee-close); prefetch only wants OTHER
                                                          # mobs further out (self-first covers adjacent ones)
                if (gx - tp[0]) ** 2 + (gy - tp[1]) ** 2 <= near2:
                    continue                              # inside the current-fight region → skip
                q.append((gx, gy))
        c["queue"] = q; c["qi"] = 0
    q = c["queue"]; i = c["qi"]; budget = 0
    while i < len(q) and budget < FIGHT_PREFETCH_POINTS:
        gx, gy = q[i]; i += 1; budget += 1
        if _reject_recent(gx, gy) or (gx - tp[0]) ** 2 + (gy - tp[1]) ** 2 <= near2:
            continue                                      # re-check the (moving) target region live
        page.mouse.move(float(gx), float(gy), steps=1)
        if _cursor_over_mob(page):
            if not any((gx - ax) ** 2 + (gy - ay) ** 2 < REJECT_RADIUS ** 2 for ax, ay, _ in c["points"]):
                c["points"].append((gx, gy, time.time())); changed = True   # a box appears for this hit
    c["qi"] = i
    if changed:
        _prefetch_sync_viz(page)                          # push the current candidate set to the overlay


def _try_prefetch(page, w, h):
    """Post-death IDLE ticks (top of _hover_find, after self-first): re-hover the FIGHT-prefetched candidate
    points and return the FIRST that still reads curattack (the CALLER then select_and_attacks it, same as
    every other _hover_find branch — species-wanted filtering happens there). Discards the cache ONLY on:
    (b) a camera move, or (c) death_t set + older than FIGHT_PREFETCH_TTL (2s from the target's DEATH, NOT
    from found-time). On a HIT the cache is consumed (we're engaging that mob = "(a) engaged a different
    mob"). On a FAILED tick the cache is LEFT INTACT so later IDLE ticks within the 2s window can retry and
    the boxes stay visible — prefetch is a 'try first', never a gate (the caller still runs radial-mem/blind
    when this returns None). None if disabled / discarded / nothing re-confirmed this tick."""
    if not FIGHT_PREFETCH:
        return None
    c = _FIGHT_PREFETCH
    now = time.time()
    if c["cam_gen"] != _CAM_GEN:                          # (b) camera moved → screen coords invalid → discard
        _prefetch_clear(page)
        return None
    if c["death_t"] is not None and now - c["death_t"] > FIGHT_PREFETCH_TTL:   # (c) death-relative TTL up
        _prefetch_clear(page)
        return None
    fresh = [(x, y) for (x, y, _t) in c["points"] if not _reject_recent(x, y) and _hover_point_ok(x / w, y / h)]
    if not fresh:
        return None                                       # nothing to try → LEAVE cache intact, fall through
    cx, cy = w * 0.5, h * 0.5                              # the char sits at screen centre → check the CLOSEST
    fresh.sort(key=lambda p: (p[0] - cx) ** 2 + (p[1] - cy) ** 2)   # candidate first, take the first mob found
    for (x, y) in fresh:
        page.mouse.move(float(x), float(y), steps=1)
        if _cursor_over_mob(page):
            print(f"[hover] prefetch: tried {len(fresh)} cached (nearest-first), hit ({int(x)},{int(y)})")
            _prefetch_clear(page)                         # (a) engaging this candidate → consume the cache
            return (float(x), float(y))
    print(f"[hover] prefetch: tried {len(fresh)} cached, miss (cache kept)")
    return None                                           # FAILED tick → keep the cache for the 2s window


# A3: shared by both grind-loop hover call sites, in order — (0) FIGHT-prefetched candidates (fresh, from
# the just-ended fight), (1) SELF-FIRST close rings (a mob meleeing us must win over everything), (2) the
# RADIAL-MEM full-circle scan at memory-derived radii when a projection SCALE exists and a wanted mob is
# close, (3) else the memory-filtered blind radial scan (unchanged fallback). If radial-mem runs but finds
# NOTHING for 2 consecutive passes (mobs known but never confirmed under the cursor — occlusion, a subtly
# off scale, whatever) spend one UNFILTERED blind scan as a backup and reset the streak.
def _hover_find(page, w, h, should_stop, miss_streak):
    """Returns (hit_or_None, new_miss_streak). hit = a hover point (x,y) at which curattack was confirmed;
    the CALLER select_and_attacks it (so all branches share one select path). NOTE (2026-07-02g): the
    FIGHT-prefetch check was moved OUT of here into the cursor IDLE branch (it must run BEFORE the memory
    'is a mob close?' gate that decides whether _hover_find is even called — see part A), so this now does
    self-first → radial-mem → blind only."""
    hit = _hover_self_first(page, w, h, should_stop)
    if hit:
        print(f"[hover] self-ring hit ({int(hit[0])},{int(hit[1])})")
        return hit, 0
    if _PROJ["coef"] is None:
        # no projection SCALE at all → memory-filtered blind radial scan (radial-mem needs a coef).
        return hover_scan_radial(page, w, h, should_stop), 0
    hit = hover_radial_memory(page, w, h, should_stop)
    if hit:
        return hit, 0
    miss_streak += 1
    print(f"[hover] radial-mem miss {miss_streak}/2")
    if miss_streak >= 2:
        # backup blind scan runs UNFILTERED: after 2 radial-mem misses the projection scale is suspect, and
        # a memory point-filter built from that same suspect projection would defeat the backup's purpose
        # (full coverage is the point here).
        hit = hover_scan_radial(page, w, h, should_stop, filtered=False)
        if hit:
            print(f"[hover] blind fallback hit ({int(hit[0])},{int(hit[1])})")
        return hit, 0
    return None, miss_streak


# ── (2026-07-02i) HYBRID 2 detection: fuse MEMORY + NAMEPLATE-pixel + frame-diff MOTION candidates, then
# VERIFY each with the game cursor (curattack — the one 100%-reliable primitive). The three sources are each
# BLIND to a different mob: MEMORY gives exact world positions but needs a projection scale; NAMEPLATE reads
# colour tags but misses low-contrast / occluded / no-tag mobs; MOTION catches anything that MOVES (mobs
# shimmer/step even when "idle") but is noisy. Fusing them AND boosting points that MULTIPLE sources agree on
# makes the candidate list both broad (few mobs missed) AND precise (agreed points first), so the cursor
# verify usually confirms a real mob in its first 1-2 hovers. A source's false positive that isn't actually a
# mob is simply skipped by the cursor gate — no misclick / walk-off. All screen-space; no packets.
HYBRID2_VERIFY_MAX = 6       # max fused candidates to cursor-VERIFY per idle pass (best-first). A touch
                             # lower than before since each candidate's confirm hover is now BROADER (below).
HYBRID2_FUSE_RADIUS = 34     # px — raw candidates within this of each other MERGE into one (corroboration)
H2_SCORE_MEM = 3.0           # base score: a memory-projected wanted mob (exact world pos → precise point)
H2_SCORE_NAME = 3.0          # base score: a nameplate tag (strong, but its Y is a body-blob guess)
H2_NAME_BODY_BONUS = 1.0     # +this when refine_click_point found a CONFIDENT body blob (not just the tag drop)
H2_SCORE_MOTION = 1.2        # base score: a frame-diff motion blob (weak alone; strong when it corroborates)
H2_SELF_CHECK_RANGE = 18.0   # world-units: when searching, if the memory map shows a wanted mob this close it
                             # COULD be meleeing us → the two tight self-rings are checked FIRST (before aggro
                             # / prefetch / the fused search), so a mob already attacking us wins. Gated on the
                             # map so an empty area doesn't pay the self-scan every tick (an adjacent attacker
                             # is by definition within this range, so the gate never hides a real melee-er).
H2_SEARCH_TURN_SECS = 5.0    # s: if no mob is found within this long of searching, turn the camera view ~90°
                             # (rotate_view) and search the new arc — or relocate/steer when memory says so.
ONUS_RANGE = 8.0             # world-units: right after a kill, if the memory map shows a wanted mob THIS
                             # close it's standing basically ON us → check the self-rings and attack it FIRST
                             # (zero travel, already in melee = the fastest possible next target).
# BROADER centre-out confirm spiral hovered at each candidate (was a tight ~32px hand-list that "focused a
# lot on individual spots"): rings every H2_CONFIRM_STEP out to H2_CONFIRM_MAX_R, so a candidate whose point
# is off by more (projection/tag/motion error, or a mob that moved) is still caught. It stops on the first
# curattack, so a good candidate still confirms near the centre fast; only misses pay the full breadth.
H2_CONFIRM_MAX_R = 60        # px — how far out the per-candidate confirm hover reaches (breadth)
H2_CONFIRM_STEP = 18         # px between confirm-spiral rings / points


def _h2_confirm_offsets(max_r, step):
    offs = [(0, 0)]
    r = step
    while r <= max_r:
        n = max(6, int(2 * np.pi * r / step))
        for i in range(n):
            a = 2 * np.pi * i / n
            offs.append((int(round(r * np.cos(a))), int(round(r * np.sin(a)))))
        r += step
    return offs


H2_CONFIRM_OFFSETS = _h2_confirm_offsets(H2_CONFIRM_MAX_R, H2_CONFIRM_STEP)
# ADAPTIVE verify breadth (the single biggest speed lever): a candidate's POSITION ERROR decides how far its
# confirm spiral must look. Near-exact candidates (body-confirmed nameplate, healthy-projection memory) need
# only a tight spiral; approximate ones (tag-drop nameplate, scale-only projection) a medium one; only fuzzy
# MOTION blobs pay the full broad spiral. ~8 / ~20 / ~38 points ≈ 0.3s / 0.8s / 1.6s worst-case each — vs the
# old one-size-fits-all broad spiral for everything, which could burn ~9s on 6 candidates.
H2_SPIRAL_TIGHT_R = 26       # px reach for near-exact candidates
H2_SPIRAL_MED_R = 42         # px reach for approximate candidates
_H2_OFFS = {"tight": _h2_confirm_offsets(H2_SPIRAL_TIGHT_R, H2_CONFIRM_STEP),
            "med": _h2_confirm_offsets(H2_SPIRAL_MED_R, H2_CONFIRM_STEP),
            "broad": H2_CONFIRM_OFFSETS}
_H2_PREC_RANK = {"tight": 0, "med": 1, "broad": 2}
# verified-EMPTY memory: a spot whose confirm spiral completed with NO curattack isn't re-spiraled for this
# long — without it a persistent false candidate (a misread tag, a stale projection) burns its full spiral
# cost EVERY pass. Short TTL so a mob wandering into the spot is only delayed, never missed.
H2_CHECKED_TTL = 2.5         # s
_H2_CHECKED = deque()        # (x, y, t) of spiral-verified-empty candidate centres


def _h2_mark_checked(x, y):
    _H2_CHECKED.append((x, y, time.time()))


def _h2_checked_recent(x, y):
    """True if (x,y) is within HYBRID2_FUSE_RADIUS of a spot spiral-verified empty < H2_CHECKED_TTL ago."""
    now = time.time()
    while _H2_CHECKED and now - _H2_CHECKED[0][2] > H2_CHECKED_TTL:
        _H2_CHECKED.popleft()
    return any((x - cx) ** 2 + (y - cy) ** 2 < HYBRID2_FUSE_RADIUS ** 2 for cx, cy, _ in _H2_CHECKED)
# frame-diff MOTION detector (source 3). Two frames MOTION_INTERVAL apart differ where a mob is (it animates
# even standing still). Only meaningful when the CAMERA is still (a turn changes every pixel) → gated on
# _CAM_GEN. Reuses the loop's existing grab (no extra screenshot).
MOTION_INTERVAL = 0.5        # s between the two diffed frames (the "500ms apart" the design asked for)
MOTION_THRESH = 18           # per-pixel gray abs-diff over this = "changed"
MOTION_MIN_AREA = 10         # min changed-blob px (rejects single-pixel sparkle/compression noise)
MOTION_MAX_AREA = 3500       # max (rejects huge changes = a UI popup / residual camera drift)
MOTION_MAX_CANDS = 14        # cap motion candidates per diff (keeps fusion cheap)
H2_MOTION_PROBE_EVERY = 1.2  # s cooldown on the ACTIVE motion probe (a deliberate 2-frame 500ms capture the
                             # idle finder does when the cheap sources found nothing — see hybrid2_find). The
                             # passive _motion_candidates only fires on lucky loop timing (2 same-camera
                             # frames 500ms apart), so the active probe is what makes motion RELIABLY hunt.
_MOTION_PREV = None          # last stored gray frame for the passive diff
_MOTION_PREV_T = 0.0
_MOTION_PREV_GEN = -1        # _CAM_GEN when _MOTION_PREV was stored (diff only valid if unchanged since)
_H2_LAST_MOTION_PROBE = 0.0  # last active-probe wall-clock (cooldown gate)


def _motion_blobs(prev, cur):
    """Shared motion-blob extractor: abs-diff two SAME-SIZE gray frames → mob-sized changed-blob centroids,
    restricted to the calibrated ROI (_roi_mask) and excluding the own-character box (SELF_EXCL). Returns
    [(x, y), ...] largest-first, capped at MOTION_MAX_CANDS. Used by BOTH the passive _motion_candidates and
    the active _motion_probe."""
    if prev is None or cur is None or prev.shape != cur.shape:
        return []
    h, w = cur.shape[:2]
    diff = cv2.absdiff(prev, cur)
    _, th = cv2.threshold(diff, MOTION_THRESH, 255, cv2.THRESH_BINARY)
    th = cv2.bitwise_and(th, _roi_mask(h, w))                # only the calibrated play area
    th = cv2.morphologyEx(th, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
    cnts, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    sx0, sy0, sx1, sy1 = SELF_EXCL
    out = []
    for c in sorted(cnts, key=cv2.contourArea, reverse=True):
        a = cv2.contourArea(c)
        if a < MOTION_MIN_AREA or a > MOTION_MAX_AREA:
            continue
        M = cv2.moments(c)
        if M["m00"] <= 0:
            continue
        cx, cy = M["m10"] / M["m00"], M["m01"] / M["m00"]
        if sx0 * w <= cx <= sx1 * w and sy0 * h <= cy <= sy1 * h:
            continue                                         # our own character's shimmer
        out.append((cx, cy))
        if len(out) >= MOTION_MAX_CANDS:
            break
    return out


def _motion_candidates(img):
    """Source 3 (PASSIVE) — frame-diff MOTION reusing the loop's frames at zero cost. Diff the current gray
    frame against one stored ~MOTION_INTERVAL ago (only when the CAMERA hasn't moved since — via _CAM_GEN; a
    turn would light up the whole frame). Contributes FREE whenever the loop happens to give two same-camera
    frames 500ms apart; when it can't (frequent turning during search) the ACTIVE _motion_probe in
    hybrid2_find covers it. Rebaselines on a camera move / first call / size change. Returns [(x, y), ...]."""
    global _MOTION_PREV, _MOTION_PREV_T, _MOTION_PREV_GEN
    now = time.time()
    try:
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    except Exception:
        return []
    if _MOTION_PREV is None or _MOTION_PREV_GEN != _CAM_GEN or _MOTION_PREV.shape != gray.shape:
        _MOTION_PREV = gray; _MOTION_PREV_T = now; _MOTION_PREV_GEN = _CAM_GEN
        return []                                            # (re)baseline; can't diff yet
    if now - _MOTION_PREV_T < MOTION_INTERVAL:
        return []                                            # wait for ~500ms of shimmer to accumulate
    prev = _MOTION_PREV
    _MOTION_PREV = gray; _MOTION_PREV_T = now; _MOTION_PREV_GEN = _CAM_GEN   # advance the baseline (≥500ms apart)
    return _motion_blobs(prev, gray)


def _motion_probe(page, img0):
    """Source 3 (ACTIVE) — deliberately hunt movement: take a SECOND grab ~MOTION_INTERVAL after `img0` and
    diff them, so motion produces candidates on demand instead of relying on lucky loop timing. Camera-still
    gated: aborts (returns []) if _CAM_GEN changes during the wait (a turn would light up the whole frame).
    Costs ~MOTION_INTERVAL of wall-clock, so the caller runs it on a cooldown and only when the cheap sources
    came up empty. Returns [(x, y), ...] motion-blob centroids."""
    gen = _CAM_GEN
    try:
        g0 = cv2.cvtColor(img0, cv2.COLOR_BGR2GRAY)
    except Exception:
        return []
    time.sleep(MOTION_INTERVAL)
    if _CAM_GEN != gen:
        return []                                            # camera moved during the wait → invalid diff
    try:
        g1 = cv2.cvtColor(grab(page), cv2.COLOR_BGR2GRAY)
    except Exception:
        return []
    return _motion_blobs(g0, g1)


def _fuse_candidates(raw, radius):
    """Cluster raw (x, y, score, src, prec) candidates within `radius` px, SUMMING scores (multi-source
    agreement boosts a point) and score-WEIGHTING the position (a precise memory/nameplate point dominates a
    fuzzy motion blob it merges with). A cluster keeps the TIGHTEST member precision — the precise member
    dominates the merged position, so the merged point only needs that member's spiral breadth. Greedy
    single-pass — fine for the handful of candidates we ever have.
    Returns [(x, y, score, srcs, prec)] where srcs is e.g. 'MN' (memory+nameplate agreed)."""
    clusters = []   # [wx, wy, wsum, score, {src}, best_prec_rank]
    r2 = radius * radius
    for (x, y, s, src, prec) in raw:
        pr = _H2_PREC_RANK.get(prec, 2)
        hit = None
        for cl in clusters:
            cx, cy = cl[0] / cl[2], cl[1] / cl[2]
            if (x - cx) ** 2 + (y - cy) ** 2 <= r2:
                hit = cl; break
        if hit is None:
            clusters.append([x * s, y * s, s, s, {src}, pr])
        else:
            hit[0] += x * s; hit[1] += y * s; hit[2] += s; hit[3] += s; hit[4].add(src)
            hit[5] = min(hit[5], pr)
    inv = {0: "tight", 1: "med", 2: "broad"}
    return [(cl[0] / cl[2], cl[1] / cl[2], cl[3], "".join(sorted(cl[4])), inv[cl[5]]) for cl in clusters]


def hybrid2_candidates(page, w, h, img, ranges, exclude=None, min_radius=0.0, include_motion=True, mem=None):
    """Build the fused, ranked, PRECISION-TAGGED HYBRID 2 candidate list from up to three sources (each
    contributes screen points): MEMORY (project each wanted mob within VIEW_MOB_RANGE — needs a projection
    coef; tight when _proj_ok else med), NAMEPLATE (rank_targets → refine_click_point body point; tight when
    the body blob was confidently found, else med), MOTION (_motion_candidates; broad). Fuses them
    (_fuse_candidates: merge + score + corroboration boost + tightest precision wins), drops points outside
    the calibrated ROI / reject-listed / spiral-verified-empty (< H2_CHECKED_TTL ago) / inside `exclude` (an
    (x,y) within HOVER_MOB_NEAR_PX — e.g. the current fight) / inside `min_radius` px of the screen centre,
    and returns them best-first: score desc, then CLOSEST TO THE CHARACTER (screen centre) — the nearest mob
    is the fastest to start attacking. Pass `mem` (a read_wanted_mobs result) to reuse ONE registry walk
    across a whole search tick. Also returns (nmem, nname, nmot) source counts for logging. PURE CV + memory
    reads — NO mouse movement (the caller verifies with the cursor)."""
    raw = []
    nmem = nname = nmot = 0
    try:                                                     # 1) memory-projected wanted mobs
        d = mem if mem is not None else read_wanted_mobs(page)
        px, pz = d["px"], d["pz"]
        if _PROJ["coef"] is not None:
            mprec = "tight" if _proj_ok() else "med"         # scale-only projection = approximate point
            for m in d["mobs"]:
                if m[0] > VIEW_MOB_RANGE:
                    continue
                pt = _proj_world_to_screen(page, m[3] - px, m[5] - pz)
                if pt is not None and 0 < pt[0] < w and 0 < pt[1] < h:
                    raw.append((pt[0], pt[1], H2_SCORE_MEM, "M", mprec)); nmem += 1
    except Exception:
        pass
    try:                                                     # 2) nameplate-pixel bodies
        for c in rank_targets(img, w, h, ranges):
            rx, ry, found = refine_click_point(img, c[0], c[1], c[2], ranges, return_conf=True)
            raw.append((rx, ry, H2_SCORE_NAME + (H2_NAME_BODY_BONUS if found else 0.0), "N",
                        "tight" if found else "med")); nname += 1
    except Exception:
        pass
    if include_motion:                                       # 3) frame-diff motion blobs
        try:
            for (mx, my) in _motion_candidates(img):
                raw.append((mx, my, H2_SCORE_MOTION, "D", "broad")); nmot += 1
        except Exception:
            pass
    fused = _fuse_candidates(raw, HYBRID2_FUSE_RADIUS)
    scx, scy = w * 0.5, h * 0.5
    ex2 = HOVER_MOB_NEAR_PX ** 2
    minr2 = min_radius * min_radius
    keep = []
    for (x, y, sc, srcs, prec) in fused:
        if not _hover_point_ok(x / w, y / h) or _reject_recent(x, y) or _h2_checked_recent(x, y):
            continue
        if min_radius > 0 and (x - scx) ** 2 + (y - scy) ** 2 < minr2:
            continue
        if exclude is not None and (x - exclude[0]) ** 2 + (y - exclude[1]) ** 2 <= ex2:
            continue
        keep.append((x, y, sc, srcs, prec))
    keep.sort(key=lambda c: (-c[2], (c[0] - scx) ** 2 + (c[1] - scy) ** 2))   # best, then CLOSEST to the char
    return keep, (nmem, nname, nmot)


def _hover_confirm_spiral(page, w, h, x, y, should_stop=None, offsets=None):
    """Hover a centre-out spiral (`offsets`, default the broad H2_CONFIRM_OFFSETS) around candidate (x,y),
    dwelling+reading the cursor at each with the reliable per-point _cursor_over_mob; return the first point
    that reads curattack, or None. The offsets choose the breadth — pass the tight/med/broad set matching the
    candidate's position error (_H2_OFFS)."""
    for ox, oy in (offsets if offsets is not None else H2_CONFIRM_OFFSETS):
        if should_stop and should_stop():
            return None
        sx, sy = x + ox, y + oy
        if not (0 < sx < w and 0 < sy < h) or not _hover_point_ok(sx / w, sy / h) or _reject_recent(sx, sy):
            continue
        page.mouse.move(float(sx), float(sy), steps=1)
        if _cursor_over_mob(page):
            return (float(sx), float(sy))
    return None


def _h2_verify(page, w, h, cands, should_stop, limit):
    """Cursor-VERIFY up to `limit` ranked (x,y,score,srcs,prec) candidates — each with the spiral breadth its
    precision needs (_H2_OFFS) — and select+attack the FIRST real wanted mob. Returns (clicked_point, srcs,
    tried) or (None, None, tried). A spiral that completes empty MARKS the spot (_h2_mark_checked) so the
    next pass doesn't pay to re-verify it. The cursor is the ground-truth gate — no misclick on a false
    candidate."""
    tried = 0
    for (x, y, sc, srcs, prec) in cands[:limit]:
        if should_stop and should_stop():
            return None, None, tried
        tried += 1
        pt = _hover_confirm_spiral(page, w, h, x, y, should_stop, offsets=_H2_OFFS.get(prec))
        if pt is None:
            _h2_mark_checked(x, y)                        # verified empty — skip this spot for a short TTL
            continue
        clicked = select_and_attack(page, pt[0], pt[1])
        if clicked:
            return clicked, srcs, tried
    return None, None, tried


def hybrid2_find(page, w, h, img, ranges, should_stop=None, deadline=None, mem=None):
    """HYBRID 2 budgeted search pipeline — every tool we have, cheapest-information first, HARD-STOPPED at
    `deadline` (the loop turns the view 90° there, so a slow pass can never eat the next arc's time):
      stage 0 (free, no mouse): ONE memory read (`mem`, reusable across the tick) + nameplate CV + passive
              motion diff on the already-grabbed frame → fused, precision-tagged candidates, ranked best
              score then CLOSEST-TO-CHAR;
      stage 1: cursor-VERIFY candidates with ADAPTIVE spirals (tight/med/broad by position error) — a real
              mob usually confirms here in ~0.3-0.8s; spiral-verified-empty spots are remembered
              (H2_CHECKED_TTL) so the next pass never re-pays them;
      stage 2: ACTIVE motion — diff the pass's OWN starting frame against a fresh grab once MOTION_INTERVAL
              has elapsed SINCE THE PASS STARTED. Stage-1 hovering runs INSIDE that window (the cursor is
              not in screenshots), so the probe's 500ms wait is usually already spent — near-zero extra
              wall-clock. Cooldown-gated; aborted if the camera moved. Movers verified broad, nearest-first;
      stage 3: RADIUS-RING sweep — memory still says a mob is close but no point candidate confirmed →
              glide the FULL circle at the nearest wanted mob's projected radius. The radius is
              rotation-invariant (scale × exact memory distance), so this finds the mob even when the
              projection's ANGLE is untrusted and nameplate/motion both missed it — the one tool that
              covers that case, and it's saved for last because it costs a full circle.
    Returns (clicked_point, srcs) or None. The cursor verify is the ground-truth gate at every stage."""
    global _H2_LAST_MOTION_PROBE
    t0 = time.time()
    gen0 = _CAM_GEN
    stop = lambda: ((should_stop() if should_stop else False)
                    or (deadline is not None and time.time() >= deadline))
    if mem is None:
        mem = read_wanted_mobs(page)
    close = [m for m in mem["mobs"] if m[0] <= VIEW_MOB_RANGE]
    # stage 0+1: fused candidates, adaptive verify
    cands, (nmem, nname, nmot) = hybrid2_candidates(page, w, h, img, ranges, mem=mem)
    clicked, srcs, tried = _h2_verify(page, w, h, cands, stop, HYBRID2_VERIFY_MAX)
    if clicked:
        print(f"[hybrid2] {nmem}M+{nname}N+{nmot}D -> {len(cands)} cands; hit ({srcs}) after {tried} in {time.time()-t0:.1f}s")
        return (clicked, srcs)
    if stop():
        return None
    # stage 2: ACTIVE motion, interleaved — frame A is this pass's img (t0); only the REMAINDER of the 500ms
    # window is slept (usually 0 — stage 1 already burned it), then a fresh grab is diffed against A.
    if (nmem or nname or close) and time.time() - _H2_LAST_MOTION_PROBE >= H2_MOTION_PROBE_EVERY:
        _H2_LAST_MOTION_PROBE = time.time()
        try: g0 = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        except Exception: g0 = None
        rem = MOTION_INTERVAL - (time.time() - t0)
        if rem > 0:
            time.sleep(rem)
        if g0 is not None and _CAM_GEN == gen0 and not stop():
            try: g1 = cv2.cvtColor(grab(page), cv2.COLOR_BGR2GRAY)
            except Exception: g1 = None
            movers = _motion_blobs(g0, g1) if g1 is not None else []
            scx, scy = w * 0.5, h * 0.5
            mc = [(x, y, H2_SCORE_MOTION, "d", "broad") for (x, y) in movers
                  if _hover_point_ok(x / w, y / h) and not _reject_recent(x, y) and not _h2_checked_recent(x, y)]
            mc.sort(key=lambda c: (c[0] - scx) ** 2 + (c[1] - scy) ** 2)   # closest to the char first
            mclick, msrcs, mtried = _h2_verify(page, w, h, mc, stop, 3)
            if mclick:
                print(f"[hybrid2] motion: {len(movers)} movers -> hit after {mtried} in {time.time()-t0:.1f}s")
                return (mclick, "d")
    if stop():
        return None
    # stage 3: radius-ring sweep at the NEAREST close mob's rotation-invariant radius
    if close and _PROJ["coef"] is not None:
        px, pz = mem["px"], mem["pz"]
        r = _proj_screen_radius(close[0][3] - px, close[0][5] - pz)
        if r is not None and r > 4:
            hit = _glide_scan(page, w, h, _mem_rings(w, h, [r], arc_step=HOVER_RADIAL_STEP), HOVER_SETTLE, stop)
            if hit:
                clicked = select_and_attack(page, hit[0], hit[1])
                if clicked:
                    print(f"[hybrid2] radius-ring r={int(r)}px -> hit in {time.time()-t0:.1f}s")
                    return (clicked, "R")
    print(f"[hybrid2] {nmem}M+{nname}N+{nmot}D -> {len(cands)} cands; miss in {time.time()-t0:.1f}s")
    return None


def _fight_prefetch_hybrid2(page, w, h, img, target, ranges):
    """HYBRID 2 fight-time candidate creation: like _fight_prefetch, but the candidate points come from the
    FULL hybrid2 fusion (memory+nameplate+motion) so we pre-locate the NEXT mob using every source while
    still holding the current one. Excludes the current target's region + the inner FIGHT_PREFETCH_MIN_RADIUS,
    verifies up to FIGHT_PREFETCH_POINTS NEW candidates with the cursor, and caches the confirmed points into
    _FIGHT_PREFETCH (same cache/boxes/death-anchored TTL as _fight_prefetch). Hovering never clicks → the
    pure-hold fight is undisturbed. Same cam_gen/death_t rebuild rules so the cache stays coherent."""
    if not FIGHT_PREFETCH:
        return
    c = _FIGHT_PREFETCH
    gen = _CAM_GEN
    changed = False
    if c["cam_gen"] != gen or c["death_t"] is not None:      # camera moved / stale death stamp → fresh cache
        if c["points"]:
            changed = True
        c.update(cam_gen=gen, points=[], queue=None, qi=0, death_t=None)
    exclude = None                                           # project the current target to exclude its region
    tpos = target.get("pos") if target else None
    if tpos is not None and _PROJ["coef"] is not None:
        try:
            d = read_wanted_mobs(page)
            exclude = _proj_world_to_screen(page, tpos[0] - d["px"], tpos[2] - d["pz"])
        except Exception:
            exclude = None
    cands, _counts = hybrid2_candidates(page, w, h, img, ranges, exclude=exclude,
                                        min_radius=FIGHT_PREFETCH_MIN_RADIUS)
    budget = 0
    for (x, y, sc, srcs, prec) in cands:
        if budget >= FIGHT_PREFETCH_POINTS:
            break
        if any((x - ax) ** 2 + (y - ay) ** 2 < REJECT_RADIUS ** 2 for ax, ay, _ in c["points"]):
            continue                                         # already cached near here
        budget += 1
        pt = _hover_confirm_spiral(page, w, h, x, y, offsets=_H2_OFFS.get(prec))   # breadth per precision
        if pt is not None:
            c["points"].append((pt[0], pt[1], time.time())); changed = True   # a green box appears here
    if changed:
        _prefetch_sync_viz(page)


# ══ HYBRID 3 — (idealised-)OPTIMAL search: probe the fused HIT-PROBABILITY field in greedy max-probability
# order ══════════════════════════════════════════════════════════════════════════════════════════════════
# MODEL. A cursor probe at screen point p costs a fixed dwell τ (~1 render frame) and returns hit ⇔ p lies on
# a wanted-mob sprite (footprint radius ~H3_SPRITE_R). We want to minimise E[time to FIRST hit] = τ·E[probes]
# + travel; travel (a cheap glide) ≪ τ, so we minimise E[probes]. Search theory (perfect detection, uniform
# look-cost) says the OPTIMAL policy probes cells in DECREASING posterior hit-probability ρ(p), downdating
# misses. So we build ρ over an ROI grid by fusing every source as a bump SHAPED BY ITS OWN UNCERTAINTY,
# weighted by reliability, then probe highest-ρ first:
#   • memory + healthy projection  → sharp Gaussian at the projected point (small σ);
#   • memory + scale-only (angle untrusted, radius is rotation-invariant) → a RING at that radius (uniform in
#     angle) — the correct shape, not a disk;
#   • memory aggro/melee, unprojectable → a small ring at melee radius about the char;
#   • nameplate, body-confirmed → sharp Gaussian at the body point;
#   • nameplate, tag-drop only (X reliable, Y a guess) → a VERTICAL-ellipse Gaussian (tall σy, narrow σx);
#   • motion blob → medium Gaussian;
#   • fight-prefetch cached point (already cursor-verified mid-fight) → the sharpest, highest-weight bump.
# Overlapping sources SUM → corroboration raises ρ quantitatively (no ad-hoc score bonus). Cells are then
# CLUSTERED into mob-hypotheses (so travel stays local within a mob), clusters ordered by peak-ρ then
# nearest-to-char (nearest mob = fastest to reach), cells within a cluster center-out (highest-ρ first). This
# subsumes Hybrid2's spirals / radius-ring / adaptive breadth as special cases of the one principle. All
# vision/cursor; no packets.
H3_GRID = 16                 # px probe-grid spacing (≈ sprite radius → adjacent probes cover without redundancy)
H3_SPRITE_R = 20             # px mob clickable-footprint radius (coverage/cluster scale)
H3_CLUSTER_R = 24            # px: cells within this of a cluster seed join it (one mob-hypothesis; bounded size)
H3_RHO_FLOOR_FRAC = 0.18     # skip cells below this FRACTION of the field peak (don't probe near-empty space)
H3_MELEE_R = 70              # px screen radius approximating melee range for an unprojectable aggro/close mob
H3_AGGRO_BOOST = 1.6         # × reliability weight for a mob whose memory aggro flag = 2 (targeting the player)
H3_MAX_PROBES = 60           # hard cap on probes per pass (the arc deadline usually stops it far sooner)
H3_W_PREFETCH = 1.7          # source reliability weights (π)
H3_W_MEM = 1.2
H3_W_NAME = 1.35
H3_W_MOTION = 0.55
H3_SIG_MEM = 9               # source positional spreads (σ, px)
H3_SIG_RING = 11             # radial σ of a ring source
H3_SIG_NAME = 11
H3_SIG_NAME_X = 9            # tag-drop nameplate: narrow horizontal σ …
H3_SIG_NAME_TALL = 26        # … tall vertical σ (the body sits somewhere below the tag)
H3_SIG_MOTION = 16


def _h3_field(page, w, h, img, ranges, mem):
    """Build the fused HIT-PROBABILITY field over the ROI probe grid — a sparse {(gx,gy): [rho, {srcs}]} on
    the H3_GRID lattice — from every source as a bump shaped by its uncertainty (see the HYBRID 3 header).
    Returns (field, (nmem, nname, nmot)). Pure CV + memory reads; NO mouse movement."""
    s = H3_GRID
    field = {}

    def add(x, y, val, src):
        if val <= 0:
            return
        gx, gy = int(round(x / s)), int(round(y / s))
        pxs, pys = gx * s, gy * s
        if not (0 < pxs < w and 0 < pys < h) or not _hover_point_ok(pxs / w, pys / h):
            return
        e = field.get((gx, gy))
        if e:
            e[0] += val
            e[1].add(src)
        else:
            field[(gx, gy)] = [val, {src}]

    def bump(mx, my, sigy, weight, src, sigx=None):
        sigx = sigx if sigx else sigy
        gx0, gx1 = int(round((mx - 3 * sigx) / s)), int(round((mx + 3 * sigx) / s))
        gy0, gy1 = int(round((my - 3 * sigy) / s)), int(round((my + 3 * sigy) / s))
        for gx in range(gx0, gx1 + 1):
            for gy in range(gy0, gy1 + 1):
                pxs, pys = gx * s, gy * s
                d2 = ((pxs - mx) / sigx) ** 2 + ((pys - my) / sigy) ** 2
                if d2 <= 9.0:
                    add(pxs, pys, weight * np.exp(-0.5 * d2), src)

    def ring(cx, cy, R, sig_r, weight, src):
        n = max(8, int(2 * np.pi * R / s))
        for i in range(n):
            a = 2 * np.pi * i / n
            ca, sa = np.cos(a), np.sin(a)
            dr = -3 * sig_r
            while dr <= 3 * sig_r:
                rr = R + dr
                add(cx + rr * ca, cy + rr * sa, weight * np.exp(-0.5 * (dr / sig_r) ** 2), src)
                dr += s

    nmem = nname = nmot = 0
    scx, scy = w * 0.5, h * 0.5
    healthy = _proj_ok()
    try:                                                     # 1) MEMORY (point / ring / melee-ring)
        px, pz = mem["px"], mem["pz"]
        for m in mem["mobs"]:
            if m[0] > VIEW_MOB_RANGE:
                continue
            wgt = H3_W_MEM * (H3_AGGRO_BOOST if m[8] == 2 else 1.0)
            pt = _proj_world_to_screen(page, m[3] - px, m[5] - pz) if _PROJ["coef"] is not None else None
            if pt is not None and healthy and 0 < pt[0] < w and 0 < pt[1] < h:
                bump(pt[0], pt[1], H3_SIG_MEM, wgt, "M"); nmem += 1
            elif _PROJ["coef"] is not None:                  # scale-only: radius trusted, ANGLE not → ring
                r = _proj_screen_radius(m[3] - px, m[5] - pz)
                if r is not None and r > 4:
                    ring(scx, scy, r, H3_SIG_RING, wgt * 0.85, "R"); nmem += 1
            elif m[8] == 2 or m[0] <= 8.0:                   # on us but unprojectable → melee ring at centre
                ring(scx, scy, H3_MELEE_R, H3_SIG_RING, wgt, "S"); nmem += 1
    except Exception:
        pass
    try:                                                     # 2) NAMEPLATE (point / vertical-ellipse)
        for c in rank_targets(img, w, h, ranges):
            aggro = len(c) > 3 and c[3]
            rxp, ryp, found = refine_click_point(img, c[0], c[1], c[2], ranges, return_conf=True)
            wgt = H3_W_NAME * (H3_AGGRO_BOOST if aggro else 1.0)
            if found:
                bump(rxp, ryp, H3_SIG_NAME, wgt, "N")
            else:                                            # tag-drop: X reliable, Y a guess → tall ellipse
                bump(rxp, ryp, H3_SIG_NAME_TALL, wgt * 0.75, "N", sigx=H3_SIG_NAME_X)
            nname += 1
    except Exception:
        pass
    try:                                                     # 3) MOTION (passive frame-diff)
        for (mxp, myp) in _motion_candidates(img):
            bump(mxp, myp, H3_SIG_MOTION, H3_W_MOTION, "D"); nmot += 1
    except Exception:
        pass
    for (x, y, _t) in _FIGHT_PREFETCH["points"]:             # 4) fight-prefetch (already cursor-verified)
        bump(x, y, H3_SIG_NAME, H3_W_PREFETCH, "P")
    return field, (nmem, nname, nmot)


def _h3_probe_order(cells, w, h):
    """Order field cells (x, y, rho, srcs) for probing: CLUSTER them into mob-hypotheses (greedy, each cell
    joins the first seed within H3_CLUSTER_R else opens a new cluster), order CLUSTERS by peak-rho then
    nearest-to-char, and cells WITHIN a cluster by rho (center-out). Greedy max-probability globally, with
    travel kept local inside each mob-sized cluster."""
    cells = sorted(cells, key=lambda c: -c[2])               # seeds emerge highest-rho first
    r2 = H3_CLUSTER_R ** 2
    clusters = []                                            # [seedx, seedy, peak, [cells]]
    for c in cells:
        for cl in clusters:
            if (c[0] - cl[0]) ** 2 + (c[1] - cl[1]) ** 2 <= r2:
                cl[3].append(c); break
        else:
            clusters.append([c[0], c[1], c[2], [c]])
    scx, scy = w * 0.5, h * 0.5
    clusters.sort(key=lambda cl: (-cl[2], (cl[0] - scx) ** 2 + (cl[1] - scy) ** 2))
    order = []
    for cl in clusters:
        cl[3].sort(key=lambda c: -c[2])
        order.extend(cl[3])
    return order


def hybrid3_find(page, w, h, img, ranges, should_stop=None, deadline=None, mem=None):
    """HYBRID 3 finder: build the fused hit-probability field (_h3_field), probe its cells in greedy
    max-probability order (_h3_probe_order) until a cursor probe reads curattack → select+attack the first
    real wanted mob. Hard-stopped at `deadline` (the loop turns 90° there). Spiral-verified-empty spots are
    remembered (H2_CHECKED_TTL) and skipped. Returns (clicked_point, srcs) or None. The cursor is the
    ground-truth gate at every probe — a mis-shaped/false field cell that isn't a mob is simply skipped."""
    t0 = time.time()
    stop = lambda: ((should_stop() if should_stop else False)
                    or (deadline is not None and time.time() >= deadline))
    if mem is None:
        mem = read_wanted_mobs(page)
    field, (nmem, nname, nmot) = _h3_field(page, w, h, img, ranges, mem)
    if not field:
        return None
    s = H3_GRID
    peak = max(v[0] for v in field.values())
    floor = peak * H3_RHO_FLOOR_FRAC
    cells = [(gx * s, gy * s, v[0], "".join(sorted(v[1]))) for (gx, gy), v in field.items()
             if v[0] >= floor and not _reject_recent(gx * s, gy * s) and not _h2_checked_recent(gx * s, gy * s)]
    if not cells:
        return None
    order = _h3_probe_order(cells, w, h)
    probed = 0
    for (x, y, rho, srcs) in order:
        if stop() or probed >= H3_MAX_PROBES:
            break
        probed += 1
        page.mouse.move(float(x), float(y), steps=1)
        if _cursor_over_mob(page):
            clicked = select_and_attack(page, x, y)
            if clicked:
                print(f"[hybrid3] {nmem}M+{nname}N+{nmot}D field={len(field)} probed {probed} -> hit ({srcs}) in {time.time()-t0:.1f}s")
                return (clicked, srcs)
            _h2_mark_checked(x, y)                            # selected but not a wanted mob → don't re-probe
        else:
            _h2_mark_checked(x, y)                            # verified empty → skip for a short TTL
    print(f"[hybrid3] {nmem}M+{nname}N+{nmot}D field={len(field)} probed {probed} -> miss in {time.time()-t0:.1f}s")
    return None


def _ang_lerp(a, b, t):
    """Circular interpolation from angle a toward b by fraction t (radians)."""
    d = float(np.arctan2(np.sin(b - a), np.cos(b - a)))
    return a + d * t


def steer_toward_mobs(page, w, h, world_ang, st):
    """EXPERIMENTAL: take one repositioning WALK toward `world_ang` (the wanted-mob centroid direction in
    world coords), self-calibrating the screen↔world camera offset from the actual movement. `st` is a dict
    {'cam_off': float|None}. Uncalibrated → walk 'forward' (screen up) once to learn the mapping; then aim
    the click-to-move at the centroid via cam_off. Safe: each hop is a short click-to-move; cam_off is
    re-measured every hop so a bad estimate self-corrects within a couple of walks. (No packets — normal
    click-to-move, so this stays account-safe.)"""
    p0 = None
    try: p0 = re_sense._pos_xyz(page)
    except Exception: pass
    cam_off = st.get("cam_off")
    sa = SCREEN_FWD if cam_off is None else (world_ang - cam_off)
    walk_dir(page, w, h, sa)                                  # short click-to-move in screen-direction sa
    p1 = None
    try: p1 = re_sense._pos_xyz(page)
    except Exception: pass
    if p0 and p1:
        dx, dz = p1[0] - p0[0], p1[2] - p0[2]
        if float(np.hypot(dx, dz)) > 1.0:                    # we actually moved → learn the mapping
            meas = float(np.arctan2(dz, dx))                 # world heading we moved in
            new_off = float(np.arctan2(np.sin(meas - sa), np.cos(meas - sa)))
            st["cam_off"] = new_off if cam_off is None else _ang_lerp(cam_off, new_off, 0.5)
            _update_cam_heading(st["cam_off"])               # refresh the camera-view arrow (fallback;
            # the live-yaw→world calibration itself is done inside walk_dir, on every walk)


def face_toward_mobs(page, w, h, world_ang, st):
    """Rotate the CAMERA to LOOK toward the wanted-mob CENTER OF MASS (`world_ang`, world coords), so we see
    as many mobs as possible. CLOSED-LOOP on the EXACT camera yaw: read the camera's world facing
    (re_sense.camera_heading), right-drag by the angular error, re-read, repeat (≤3 turns) — self-correcting
    the drag SIGN (FACE_SIGN) if a turn increases the error. Needs the persisted deg→world calibration (so no
    per-session setup); returns False when not calibrated yet (caller then walk-steers, which calibrates).
    Returns True if it turned and is now roughly facing the cluster."""
    global FACE_SIGN
    cur = re_sense.camera_heading(page)                       # exact camera world-facing (rad), or None
    if cur is None:
        return False
    turned = False
    for _ in range(3):
        err = float(np.arctan2(np.sin(world_ang - cur), np.cos(world_ang - cur)))
        if abs(err) < FACE_TOLERANCE:
            return True                                      # looking at the cluster
        drag = int(np.clip(FACE_SIGN * err / (np.pi / 2) * ROTATE_DX, -2 * ROTATE_DX, 2 * ROTATE_DX))
        if drag == 0:
            return True
        cx, cy = w * 0.5, h * 0.5
        try:
            page.mouse.move(cx, cy); page.mouse.down(button="right")
            page.mouse.move(cx + drag, cy, steps=8)
        finally:
            try: page.mouse.up(button="right")
            except Exception: pass
        _bump_cam_gen()                                  # camera turned → invalidate screen-projected caches
        time.sleep(0.12)
        new = re_sense.camera_heading(page)
        if new is None:
            return turned
        new_err = float(np.arctan2(np.sin(world_ang - new), np.cos(world_ang - new)))
        if abs(new_err) > abs(err) + 0.05:                   # turned the WRONG way → flip the drag sign
            FACE_SIGN = -FACE_SIGN
        print(f"[face ] toward mob centre of mass: err {np.degrees(err):.0f}° → {np.degrees(new_err):.0f}°")
        cur = new
        turned = True
    return turned


def reposition_toward_mobs(page, w, h, world_ang, st):
    """One repositioning action toward the wanted-mob centre of mass. Preferred (once the camera yaw is
    calibrated): FACE the cluster with the exact yaw, then WALK FORWARD (screen-up) toward it. Fallback (yaw
    not calibrated yet): the walk-based steer, which also self-calibrates the yaw so facing works soon."""
    if face_toward_mobs(page, w, h, world_ang, st):
        walk_dir(page, w, h, SCREEN_FWD)                     # now looking at the cluster → walk into it
    else:
        steer_toward_mobs(page, w, h, world_ang, st)


def look_around(page, w, h, scans, hp_txt, now):
    """No reachable mob in view → escalate the search in phases (PURE look-around; the colour/cursor
    detection runs in the caller BEFORE this). Returns (scans, last_calibrate_or_None) — shared by both
    detection modes. Phase 1: turn in place. Phase 2: tilt further out + keep turning. Phase 3: walk
    toward the densest name-text (else random) and recalibrate."""
    scans += 1
    if scans <= WALK_AFTER_SCANS:
        rotate_view(page, w, h)
        print(f"[scan ] HP~{hp_txt} no mob — turned ({scans}/{WALK_AFTER_SCANS}), rechecking")
    elif scans <= 2 * WALK_AFTER_SCANS:
        if scans == WALK_AFTER_SCANS + 1:
            tilt_forward(page, w, h, EXTRA_TILT)
            print("[scan ] nothing near — tilting forward to see further, looking around")
        rotate_view(page, w, h)
        print(f"[scan ] no mob (far view) — turned ({scans-WALK_AFTER_SCANS}/{WALK_AFTER_SCANS})")
    else:
        print(f"[wander] no mob detected — {funnel_summary()}")
        ang, cnt = most_text_direction(grab(page))
        if ang is not None:
            walk_dir(page, w, h, ang)
            print(f"[walk ] no mob — walking toward most name-text ({cnt}px)")
        else:
            walk_random(page, w, h)
            print(f"[walk ] no mob & little text ({cnt}px) — random-direction walk")
        calibrate_view(page)            # back to the normal tilt + consistent view
        return 0, now                   # scans reset; last_calibrate = now
    return scans, None


def _yaw_drag_step(page, w, h, sign):
    """One small right-drag camera-rotation step (direction = sign, +1/-1), for re_sense.autolocate_yaw's
    differential scan (6 fwd + 6 rev) that re-finds the live camera-yaw memory address each session.
    Smaller than rotate_view's turn so 6 of them don't spin the camera all the way around."""
    cx, cy = w * 0.5, h * 0.5
    dx = int(sign * 90)
    page.mouse.move(cx, cy); page.mouse.down(button="right")
    page.mouse.move(cx + dx, cy, steps=4); page.mouse.up(button="right")
    _bump_cam_gen()      # camera turned → invalidate screen-projected caches
    time.sleep(0.05)


# ── 🔵 live hover-path DEBUG overlay (HOVER_VIZ, panel-toggleable) ──────────────────────────────────
# A pointer-events-none full-viewport <canvas> injected into the GAME window + a page-side mousemove
# listener + rAF draw loop: a blue circle rides the LIVE pointer and a fading polyline traces its last
# ~3s of movement. Drawn entirely page-side from real mousemove events → ZERO extra CDP traffic, and it
# shows exactly where the real cursor went (self rings, targeted spiral, blind sweep, drift — all of it).
# Purely visual: pointer-events:none means it can never eat a click, and it reads nothing the bot uses.
# It ALSO draws GREEN candidate BOXES (window.__hoverVizSetBoxes([[x,y],…])) at the FIGHT-prefetch points
# confirmed during the current fight; the box set is driven from the SAME prefetch cache (see
# _prefetch_sync_viz) so it appears/accumulates as points are found and clears exactly when the cache is
# invalidated (mob died+engaged another / camera moved / TTL expired).
_HOVER_VIZ_INSTALL_JS = r"""() => {
  if (window.__hoverViz && window.__hoverViz.on) return true;
  const cv = document.createElement('canvas');
  cv.id = '__hoverVizCanvas';
  cv.style.cssText = 'position:fixed;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:2147483646';
  cv.width = innerWidth; cv.height = innerHeight;
  document.body.appendChild(cv);
  const ctx = cv.getContext('2d');
  const st = {on: true, pts: [], boxes: [], cv: cv};
  st.mm = (e) => { st.pts.push({x: e.clientX, y: e.clientY, t: performance.now()});
                   if (st.pts.length > 600) st.pts.splice(0, st.pts.length - 600); };
  document.addEventListener('mousemove', st.mm, {capture: true, passive: true});
  // Python pushes the current FIGHT-prefetch candidate points here (or [] to clear) — see _hover_viz_boxes.
  window.__hoverVizSetBoxes = (arr) => { if (window.__hoverViz) window.__hoverViz.boxes = Array.isArray(arr) ? arr : []; };
  const KEEP = 3000;                                   // trace window (ms)
  const draw = () => {
    if (!st.on) return;
    if (cv.width !== innerWidth || cv.height !== innerHeight) { cv.width = innerWidth; cv.height = innerHeight; }
    ctx.clearRect(0, 0, cv.width, cv.height);
    const now = performance.now();
    while (st.pts.length && now - st.pts[0].t > KEEP) st.pts.shift();
    const p = st.pts;
    for (let i = 1; i < p.length; i++) {               // fading polyline, newest = most opaque
      const a = 1 - (now - p[i].t) / KEEP;
      ctx.strokeStyle = 'rgba(40,120,255,' + (0.12 + 0.55 * a).toFixed(3) + ')';
      ctx.lineWidth = 2;
      ctx.beginPath(); ctx.moveTo(p[i-1].x, p[i-1].y); ctx.lineTo(p[i].x, p[i].y); ctx.stroke();
    }
    const B = st.boxes || [], S = 28;                  // GREEN prefetch-candidate boxes (distinct from blue)
    ctx.strokeStyle = 'rgba(70,230,90,0.95)'; ctx.lineWidth = 2;
    for (let k = 0; k < B.length; k++) {
      const b = B[k]; if (!b) continue;
      ctx.strokeRect(b[0] - S / 2, b[1] - S / 2, S, S);
    }
    if (p.length) {                                    // blue circle at the live pointer
      const q = p[p.length - 1];
      ctx.strokeStyle = 'rgba(40,120,255,0.9)'; ctx.lineWidth = 2.5;
      ctx.beginPath(); ctx.arc(q.x, q.y, 9, 0, 6.2832); ctx.stroke();
    }
    st.raf = requestAnimationFrame(draw);
  };
  st.raf = requestAnimationFrame(draw);
  window.__hoverViz = st;
  return true;
}"""
_HOVER_VIZ_REMOVE_JS = r"""() => {
  const st = window.__hoverViz;
  if (!st) return false;
  st.on = false;                                       // stops the rAF loop on its next tick too
  try { cancelAnimationFrame(st.raf); } catch (e) {}
  try { document.removeEventListener('mousemove', st.mm, {capture: true}); } catch (e) {}
  try { st.cv.remove(); } catch (e) {}
  window.__hoverViz = null;
  return true;
}"""
_VIZ_ON = False          # is the overlay installed by US right now (so remove is a cheap no-op otherwise)


def _hover_viz_install(page):
    """Install the hover-path overlay (idempotent JS-side). Best-effort — a failure never blocks the grind."""
    global _VIZ_ON
    try:
        if page.evaluate(_HOVER_VIZ_INSTALL_JS):
            _VIZ_ON = True
            print("[hover] 🔵 debug overlay ON (blue circle + trace in the game window)")
    except Exception:
        pass


def _hover_viz_remove(page):
    """Remove the overlay if we installed it (cheap no-op otherwise). Best-effort."""
    global _VIZ_ON
    if not _VIZ_ON:
        return
    _VIZ_ON = False
    try:
        page.evaluate(_HOVER_VIZ_REMOVE_JS)
    except Exception:
        pass


def _hover_viz_boxes(page, points):
    """Push the FIGHT-prefetch candidate points to the overlay as GREEN boxes (see _HOVER_VIZ_INSTALL_JS).
    `points` = list of [x, y]. PURE NO-OP unless the overlay is actually installed by us (HOVER_VIZ on):
    when the toggle is off there's no evaluate at all, so the prefetch feature runs unaffected — the boxes
    are purely a debug viz gated by the same toggle as the circle/trace. Best-effort (try/except) so a viz
    failure never breaks prefetch."""
    if not (_VIZ_ON and HOVER_VIZ):
        return
    try:
        page.evaluate("(pts)=>{ if (window.__hoverVizSetBoxes) window.__hoverVizSetBoxes(pts); }",
                      [[int(x), int(y)] for x, y in points])
    except Exception:
        pass


def _prefetch_sync_viz(page):
    """Keep the overlay's boxes in LOCKSTEP with the prefetch cache: push the current
    _FIGHT_PREFETCH['points'] (empty list clears the boxes). Call this after EVERY mutation of that cache
    (add / invalidate / consume) so the boxes can never drift from the cache state."""
    _hover_viz_boxes(page, [(x, y) for (x, y, _t) in _FIGHT_PREFETCH["points"]])


def _prefetch_clear(page):
    """DISCARD the prefetch cache: empty points/queue, reset death_t to None, and clear the overlay boxes
    (lockstep). Used on every discard path — camera move, death-relative TTL expiry, or engaging a cached
    candidate. Only re-syncs the boxes if there was anything to clear (cheap no-op otherwise)."""
    c = _FIGHT_PREFETCH
    had = bool(c["points"])
    c.update(points=[], queue=None, qi=0, death_t=None)
    if had:
        _prefetch_sync_viz(page)


def grind(page, w, h, ranges, should_stop=None, report=None):
    """Thin wrapper around the real grind loop (_grind_run): installs the 🔵 hover-path debug overlay when
    the panel toggle is on (HOVER_VIZ) and ALWAYS removes it when the run ends (Stop / crash / Ctrl+C) —
    the try/finally keeps overlay lifecycle out of the (large) loop body."""
    if HOVER_VIZ:
        _hover_viz_install(page)
    try:
        return _grind_run(page, w, h, ranges, should_stop=should_stop, report=report)
    finally:
        _hover_viz_remove(page)


def _grind_run(page, w, h, ranges, should_stop=None, report=None):
    print(f"GRIND-LOCAL — food='{FOOD_KEY}', heal<{int(HEAL_AT*100)}%. Ctrl+C to stop.")
    last_heal = 0.0; last_self_buff = 0.0
    state = "IDLE"; engage_time = 0.0; fight_start = 0.0; confirmed = False
    progressed = False; max_hp = None        # have we seen the target's HP drop? + highest HP seen
    kills = 0; scans = 0; engage_misses = 0; approach = 0; mem_retries = 0; targeted_misses = 0
    hp_reader = re_sense.HpReader()      # own HP/MP from WASM memory, peak-tracked (vision-free)
    steer_state = {"cam_off": None}      # EXPERIMENTAL steering: self-calibrated screen↔world camera offset
    global _CAM_HEADING; _CAM_HEADING = None   # camera-view arrow unknown until steering calibrates it
    calibrate_view(page)                 # start from a consistent top-down view
    # A1: locate the live camera-yaw memory address once per session — not just when MOB_STEER is on (the
    # targeted hover's projection, A2, also wants yaw to compensate camera rotation without refitting).
    # Cheap (~2.5s) and a no-op once already located THIS session: re_sense._cam_src persists across grind
    # Starts within one panel process (re_sense is never hot-reloaded, unlike this module).
    if (MOB_STEER or DETECT_MODE in ("cursor", "hybrid")) and not re_sense.camera_yaw_source_set():
        try:
            re_sense.autolocate_yaw(page, lambda sign: _yaw_drag_step(page, w, h, sign))
        except Exception:
            pass
        calibrate_view(page)             # autolocate rotates the camera — re-centre afterward
    # A1: seed the world<->screen projection from click_nav's cached calibration (if any) so the targeted
    # hover (A2) can work from the FIRST grind cycle instead of waiting on live passive samples.
    if DETECT_MODE in ("cursor", "hybrid"):
        _proj_bootstrap(page)
    last_kill = time.time()              # for the stuck-recovery (no-kill) timer
    last_calibrate = time.time()         # for the periodic view recalibration
    last_nameplate_hit = time.time()     # hybrid: when nameplate last SAW a mob (dry timer)
    h2_search_t0 = time.time()           # hybrid2: when the CURRENT candidate search began (5s → turn 90°)
    while True:
        if should_stop and should_stop():
            return kills
        _ch = re_sense.camera_heading(page)   # EXACT live camera yaw (once calibrated) → drift-free arrow
        if _ch is not None:
            _CAM_HEADING = _ch
        img = grab(page)
        h, w = img.shape[:2]            # adapt to current window size (handles resizing)
        if player_menu_open(img):
            # A hovered ground-item tooltip OR a real player context menu is blocking detection.
            # FIRST just move the cursor away — that clears an item-hover tooltip with no Esc needed.
            # Only if it's STILL there afterwards (a real context menu, which a cursor move can't
            # dismiss) do we press Esc.
            park_cursor(page)
            if player_menu_open(grab(page)):
                page.keyboard.press("Escape")
                print("[menu ] context menu persisted — moved cursor + Esc")
            else:
                print("[hover] item tooltip — moved cursor away (no Esc)")
            state = "IDLE"; confirmed = False; progressed = False; max_hp = None
            time.sleep(CYCLE)
            continue
        cb = find_captcha_checkbox(img)            # anti-bot captcha → human-move cursor + tick box
        if cb:
            human_move_click(page, *cb)
            print("[captcha] detected — moved cursor + clicked 'not a robot'")
            state = "IDLE"; confirmed = False; progressed = False; max_hp = None
            time.sleep(0.8); time.sleep(CYCLE)
            continue
        # HP for the food/heal decision: read straight from WASM memory (deobfuscated, exact,
        # vision-free — no screenshot/HUD-visibility dependency, unlike the old pixel/OCR read_vital).
        vit = hp_reader.read(page)
        hp, hp_src = (vit["frac"], "mem") if vit else (None, None)
        now = time.time()
        hp_txt = f"{int(hp*100)}%" if hp is not None else "—"   # safe for logs (hp may be None)
        if report:
            report(f"{state.lower()} · kills {kills} · HP {hp_txt} ({hp_src})" if hp is not None
                   else f"{state.lower()} · kills {kills} · HP —")
        if hp is not None and hp < HEAL_AT and now - last_heal > HEAL_COOLDOWN:
            plog.action(f"key FOOD '{FOOD_KEY}' (HP {int(hp*100)}%)")
            page.keyboard.down(FOOD_KEY); time.sleep(0.06); page.keyboard.up(FOOD_KEY)
            last_heal = now
            print(f"[heal] HP~{hp*100:3.0f}% ({hp_src}) < {int(HEAL_AT*100)}% -> ate '{FOOD_KEY}'")

        # Periodic view recalibration: keep the camera locked to the tuned top-down reference so it
        # never drifts (only when NOT mid-fight, so it can't interrupt attacking a mob). SKIP it while
        # wanted mobs are already nearby (memory) — we're in a productive spot, the view is clearly
        # working, and a recalibration would needlessly disrupt it. Only recalibrate when the spot is
        # empty (part of searching). Just reset the timer when we skip so it doesn't re-fire every loop.
        if state != "FIGHT" and now - last_calibrate > CALIBRATE_EVERY:
            if nearby_wanted_count(page, VIEW_MOB_RANGE) > 0:
                last_calibrate = now                 # mobs here → view is fine, don't disrupt it
            else:
                print("[calib] periodic view recalibration (spot empty)")
                calibrate_view(page)
                last_calibrate = now
                time.sleep(CYCLE)
                continue

        # Stuck recovery: a prolonged stretch with NO kill (and not currently fighting) likely
        # means we're wedged — Enter x2 to clear dialogs, then WASD-wiggle out, then re-scan.
        if state != "FIGHT" and now - last_kill > STUCK_NO_KILL:
            unstuck(page)
            last_kill = now
            calibrate_view(page)                # re-establish the view after wiggling
            last_calibrate = now
            time.sleep(CYCLE)
            continue

        # Self-buff (key 2) on its interval — but ONLY when NOT in a fight, so it never
        # interrupts attacking a mob. If it comes due mid-fight it waits until the fight ends.
        if state != "FIGHT" and now - last_self_buff > SELF_BUFF_EVERY:
            plog.action(f"key SELF-BUFF '{SELF_BUFF_KEY}'")
            page.keyboard.down(SELF_BUFF_KEY); time.sleep(0.06); page.keyboard.up(SELF_BUFF_KEY)
            last_self_buff = now
            print(f"[buff ] self-buff (key '{SELF_BUFF_KEY}')")
            time.sleep(SELF_BUFF_SETTLE)        # let it cast before engaging anything
            continue

        # Aggro fast-path: a mob just AGGROED us (its alert bubble is up) → it's attacking us. Click
        # it + attack right away, ahead of normal scanning. Only when NOT already in a fight (a click
        # mid-fight would cancel the current auto-attack).
        if state != "FIGHT":
            ag = find_aggro_symbol(img)
            if ag:
                plog.action(f"click AGGRO mob ({ag[0]},{ag[1]})")
                page.mouse.click(ag[0], ag[1])                       # select the aggro mob
                time.sleep(0.12)
                plog.action(f"key ATTACK '{ATTACK_KEY}' (aggro)")
                page.keyboard.down(ATTACK_KEY); time.sleep(0.06); page.keyboard.up(ATTACK_KEY)
                print(f"[aggro] alert bubble — clicked mob ({ag[0]},{ag[1]}) + attack '{ATTACK_KEY}'")
                state = "FIGHT"; confirmed = False; progressed = False; max_hp = None
                engage_time = now; fight_start = now
                time.sleep(ENGAGE_SETTLE)
                continue

        if state == "FIGHT" and TARGET_SENSE == "memory":
            # MEMORY target sense (DEFAULT): PURE HOLD while a mob is selected (a click would cancel the
            # auto-attack / walk us off it). The SELECTED target — alive/HP/kind — is read straight from
            # WASM memory (controller+2656 -> registry -> deobfuscated exact HP + kind), which is EXACT and
            # never flickers, so death is TRUSTED IMMEDIATELY: no FIGHT_POLL, no DEATH_CONFIRM poll window.
            t = re_sense.read_target(page)
            alive = bool(t and t.get("has") and t.get("resolved") and t.get("hp", 0) > 0)
            if alive and t.get("kind") != 5:
                # selected something that isn't a mob (e.g. a player) → drop it and search.
                print(f"[skip ] selected target isn't a mob (kind {t.get('kind')}) — re-searching")
                rotate_view(page, w, h)
                state = "IDLE"; confirmed = False; progressed = False; max_hp = None
                time.sleep(ENGAGE_SETTLE)
            elif alive:
                confirmed = True
                cur_hp = t["hp"]
                if max_hp is None or cur_hp > max_hp:
                    max_hp = cur_hp                       # highest HP seen this fight (baseline)
                if cur_hp < max_hp:
                    progressed = True                    # exact HP fell → we ARE damaging it
                if not progressed and now - fight_start > NO_DAMAGE_GIVEUP:
                    # No damage yet after NO_DAMAGE_GIVEUP s → we can't reach it (path blocked / out of
                    # range). Don't wait for the long backstop — drop it and search again now.
                    print(f"[skip ] no damage in {NO_DAMAGE_GIVEUP:.0f}s (blocked/unreachable) — re-searching")
                    rotate_view(page, w, h)
                    state = "IDLE"; confirmed = False; progressed = False; max_hp = None
                    time.sleep(ENGAGE_SETTLE)
                elif now - fight_start > GIVE_UP_AFTER:
                    # HARD backstop (timed from fight start, never reset) — never let ONE target trap us.
                    print("[skip ] fight too long — dropping target and re-searching")
                    rotate_view(page, w, h)
                    state = "IDLE"; confirmed = False; progressed = False; max_hp = None
                    time.sleep(ENGAGE_SETTLE)
                elif now - engage_time > FIGHT_TIMEOUT:      # auto-attack may have stalled — re-issue attack
                    # on the SAME target (do NOT engage() — that would re-select a different mob).
                    page.keyboard.down(ATTACK_KEY); time.sleep(0.06); page.keyboard.up(ATTACK_KEY)
                    engage_time = now
                    print("[re-eng] held too long — re-issue attack on current target")
                    time.sleep(ENGAGE_SETTLE)
                else:
                    print(f"[fight] holding (target {cur_hp}HP)")
                    # (item 4) pure-hold idle time: hover-prefetch the NEXT mob so the kill→next-mob is warm.
                    # Only in the plain-holding case (not while giving up / re-issuing) so it never delays a
                    # state change; hovering never clicks, so the pure-hold fight is undisturbed.
                    if FIGHT_PREFETCH:
                        try:
                            if DETECT_MODE in ("hybrid2", "hybrid3"):   # fuse memory+nameplate+motion for next mob
                                _fight_prefetch_hybrid2(page, w, h, img, t, ranges)
                            else:
                                _fight_prefetch(page, w, h, t)
                        except Exception: pass
            else:
                # No live target in memory (hp<=0 or the game cleared +2656 on death) → dead NOW. Memory is
                # exact and doesn't flicker, so we trust it immediately — no re-poll/confirm window.
                if confirmed:
                    kills += 1; last_kill = now; print(f"[kill ] mob down (mem) — total {kills}")
                else:
                    print("[miss ] engage didn't land (mem) — re-scan")
                # START the prefetch discard countdown: the cache + its overlay boxes now live
                # FIGHT_PREFETCH_TTL from THIS death (not from when each candidate was found). During the
                # fight death_t was None (reset by _fight_prefetch), so this stamps exactly once per death.
                if FIGHT_PREFETCH:
                    _FIGHT_PREFETCH["death_t"] = now
                state = "IDLE"; confirmed = False; progressed = False; max_hp = None
                # (0) A mob standing DIRECTLY ON us? Attack it FIRST — zero travel, already in melee = the
                # fastest possible next target. Only when memory shows a wanted mob right on us (≤ONUS_RANGE),
                # so an empty spot doesn't pay the self-scan. Checked BEFORE the cached prefetch candidates
                # (which may be metres away).
                if DETECT_MODE in ("cursor", "hybrid2", "hybrid3") and nearby_wanted_count(page, ONUS_RANGE) > 0:
                    shit = _hover_self_first(page, w, h, should_stop)
                    if shit:
                        sclick = select_and_attack(page, shit[0], shit[1])
                        if sclick:
                            scans = 0; engage_misses = 0; approach = 0; mem_retries = 0; targeted_misses = 0
                            h2_search_t0 = now
                            engage_time = now; fight_start = now
                            state = "FIGHT"; confirmed = False; progressed = False; max_hp = None
                            print(f"[engage] (on-us) -> ({sclick[0]/w:.2f},{sclick[1]/h:.2f})")
                            time.sleep(ENGAGE_SETTLE)
                            continue
                # IMMEDIATE next-mob: the target just died and we cached candidates DURING the fight → engage
                # one RIGHT NOW, before the CYCLE wait + the normal search flow (top-of-loop checks, aggro
                # scan, fused search), so kill→next-attack is instant. _try_prefetch re-hovers + confirms each
                # cached point (self-validating vs a stale/moved mob) and consumes the cache on a hit. Only in
                # the modes that populate the cache (cursor / hybrid2 / hybrid3).
                if DETECT_MODE in ("cursor", "hybrid2", "hybrid3") and _FIGHT_PREFETCH["points"]:
                    phit = _try_prefetch(page, w, h)
                    if phit:
                        pclick = select_and_attack(page, phit[0], phit[1])
                        if pclick:
                            scans = 0; engage_misses = 0; approach = 0; mem_retries = 0; targeted_misses = 0
                            engage_time = now; fight_start = now
                            state = "FIGHT"; confirmed = False; progressed = False; max_hp = None
                            print(f"[engage] (prefetch-immediate) -> ({pclick[0]/w:.2f},{pclick[1]/h:.2f})")
                            time.sleep(ENGAGE_SETTLE)
                            continue                         # skip the trailing CYCLE wait — straight into the fight
        elif state == "FIGHT":
            # PIXEL target sense: the original vision path. PURE HOLD while the top-centre target FRAME is
            # up; death confirmed by the FIGHT_POLL × DEATH_CONFIRM_CONSECUTIVE_ABSENT poll window (the pixel
            # bar can flicker, so a single absent read never abandons a live mob).
            if target_present(img) and is_player_target(img):
                # we selected a PLAYER (their frame has a '#' id), not a mob → drop it and search.
                print("[skip ] selected a PLAYER (# tag) — not a mob, re-searching")
                rotate_view(page, w, h)
                state = "IDLE"; confirmed = False; progressed = False; max_hp = None
                time.sleep(ENGAGE_SETTLE)
            elif target_present(img):
                confirmed = True
                cur_hp = mob_hp(img)
                if cur_hp is not None:
                    if max_hp is None or cur_hp > max_hp:
                        max_hp = cur_hp                       # highest HP seen this fight (baseline)
                    if cur_hp <= max_hp - PROGRESS_DROP:
                        progressed = True                    # HP fell over time → we ARE damaging it
                if not progressed and cur_hp is not None and now - fight_start > NO_DAMAGE_GIVEUP:
                    # No damage yet after NO_DAMAGE_GIVEUP s (HP readable but not dropping) → blocked /
                    # unreachable → drop it and search again. (Gated on a readable mob_hp so a bar-read
                    # failure doesn't false-bail; that's why it needs the target-frame HP.)
                    print(f"[skip ] no damage in {NO_DAMAGE_GIVEUP:.0f}s (blocked/unreachable) — re-searching")
                    rotate_view(page, w, h)
                    state = "IDLE"; confirmed = False; progressed = False; max_hp = None
                    time.sleep(ENGAGE_SETTLE)
                elif PLAYER_BAIL and not progressed and now - fight_start > NO_PROGRESS_TARGET:
                    # (opt-in) target's HP isn't dropping — we can't damage it (a PLAYER, even one
                    # already below full HP) → drop it & re-search; rotate so we don't re-pick it.
                    print("[skip ] target HP not dropping — likely a player; re-searching")
                    rotate_view(page, w, h)
                    state = "IDLE"; confirmed = False; progressed = False; max_hp = None
                    time.sleep(ENGAGE_SETTLE)
                elif now - fight_start > GIVE_UP_AFTER:
                    # HARD backstop (timed from fight start, never reset) — covers any heuristic miss
                    # so a single target can NEVER trap us permanently.
                    print("[skip ] fight too long — dropping target and re-searching")
                    rotate_view(page, w, h)
                    state = "IDLE"; confirmed = False; progressed = False; max_hp = None
                    time.sleep(ENGAGE_SETTLE)
                elif now - engage_time > FIGHT_TIMEOUT:      # auto-attack may have stalled — resume the
                    # SAME target (re-press attack), do NOT engage() — that would re-select a
                    # different mob and abandon the live one we're already fighting.
                    page.keyboard.down(ATTACK_KEY); time.sleep(0.06); page.keyboard.up(ATTACK_KEY)
                    engage_time = now
                    print("[re-eng] held too long — re-issue attack on current target")
                    time.sleep(ENGAGE_SETTLE)
                else:
                    print("[fight] holding (target up)")
            else:
                # The target frame looks gone — only count it dead if it reads ABSENT for
                # DEATH_CONFIRM_CONSECUTIVE_ABSENT consecutive rapid polls (≈ FIGHT_POLL apart). Any
                # reappearance = still the same fight, so a brief flicker never abandons a live mob.
                gone = True
                for _ in range(DEATH_CONFIRM_CONSECUTIVE_ABSENT):
                    time.sleep(FIGHT_POLL)
                    if target_present(grab_fast(page)):      # fast JPEG — bar-band check only
                        gone = False; break
                if not gone:
                    pass                                     # target reappeared → keep fighting
                else:
                    if confirmed:
                        kills += 1; last_kill = now; print(f"[kill ] mob down — total {kills}")
                    else:
                        print("[miss ] engage didn't land — re-scan")
                    state = "IDLE"; confirmed = False; progressed = False; max_hp = None
        elif DETECT_MODE == "cursor":
            # aggro/RED colour FIRST (fast + reliable), then use the MEMORY mob-map (ONE read) to decide:
            # hover-scan ONLY when a mob is actually close; steer toward a far cluster; else reposition.
            red = [m for m in find_nameplate_mobs(img, ranges) if len(m) > 3 and m[3]]   # aggro tags only
            clicked = engage(page, w, h, ranges, img0=img, cands0=red) if red else None
            src = "aggro" if clicked else ""
            steered = False
            close_n, cen_ang, nearest, total_n = 0, None, None, 0
            if not clicked:
                # (A, 2026-07-02g) FIGHT-prefetch candidates FIRST — before the memory 'is a mob close?'
                # gate below (which would drop them whenever it reports total_n==0 / close_n==0, even
                # though they're valid from the just-ended fight). _try_prefetch self-guards (returns None
                # fast when the cache is empty/invalid/TTL-expired/cam-moved), so it's cheap every idle tick.
                hit = _try_prefetch(page, w, h)
                if hit:
                    clicked = select_and_attack(page, hit[0], hit[1]); src = "prefetch"
            if not clicked:
                close_n, cen_ang, nearest, total_n = wanted_mob_summary(page, VIEW_MOB_RANGE)
                if total_n == 0:
                    # MEMORY: NO wanted mob loaded near us — the area is empty. Don't waste a (seconds-long)
                    # hover-scan of space we already KNOW has no mobs; drop straight to repositioning below.
                    print("[skip ] memory: no mob nearby — skipping hover-scan, repositioning")
                elif MOB_STEER and nearest is not None and nearest > MOB_STEER_FAR:
                    # a wanted cluster exists but it's FAR → face + walk toward it instead of hovering here
                    print(f"[steer] nearest wanted mob {nearest:.0f}u away ({total_n} in memory) — repositioning toward cluster")
                    reposition_toward_mobs(page, w, h, cen_ang, steer_state)
                    scans = 0; steered = True
                elif close_n > 0:
                    # a mob IS close (memory) → hover to pinpoint + engage it. A3: try the memory-guided
                    # TARGETED spiral first (projects each wanted mob's world pos, nearest-first — A2),
                    # falling back to the blind radial scan when the projection isn't healthy yet / keeps
                    # missing (_hover_find).
                    hit, targeted_misses = _hover_find(page, w, h, should_stop, targeted_misses)
                    if hit:
                        clicked = select_and_attack(page, hit[0], hit[1]); src = "cursor"
                # else: mobs loaded but none close and (steer off / not far enough) → reposition below
            if clicked:
                scans = 0; engage_misses = 0; approach = 0; mem_retries = 0; targeted_misses = 0
                engage_time = now; fight_start = now
                state = "FIGHT"; confirmed = False; progressed = False; max_hp = None
                print(f"[engage] ({src}) HP~{hp_txt} -> ({clicked[0]/w:.2f},{clicked[1]/h:.2f})")
                time.sleep(ENGAGE_SETTLE)
            elif steered:
                mem_retries = 0                               # took a steering walk this cycle; re-loop
            else:
                engage_misses = 0; approach = 0
                # AVOID NEEDLESS TURNS: if wanted mobs ARE close (memory) but the hover found none this frame,
                # retry in place a few times before turning — they're here, detection just missed them.
                if close_n > 0 and mem_retries < MEM_NEAR_RETRY:
                    mem_retries += 1
                    print(f"[hold ] {close_n} wanted mob(s) near (memory), none under cursor — retry {mem_retries}/{MEM_NEAR_RETRY}, no turn")
                    time.sleep(CYCLE)
                    continue
                mem_retries = 0
                # STEER toward a wanted cluster (any distance) instead of a random wander, if enabled —
                # face it (to see the most mobs) then walk toward it.
                if MOB_STEER and cen_ang is not None and nearest is not None and nearest > MOB_STEER_MIN_DIST:
                    print(f"[steer] no mob under cursor — repositioning toward wanted cluster ({total_n} mobs, {nearest:.0f}u)")
                    reposition_toward_mobs(page, w, h, cen_ang, steer_state)
                    scans = 0
                    time.sleep(CYCLE)
                    continue
                # DON'T stand/turn in place when MEMORY confirms the area is empty (no mobs loaded nearby in
                # ANY direction) — turning can't reveal what isn't there. Jump straight to look_around's WALK
                # phase to relocate to a new area. (Guarded on has_heap so a memory-read failure still uses
                # the normal turn-then-walk search.)
                if total_n == 0 and re_sense.has_heap(page):
                    print("[relocate] area empty (memory) — walking to a new area (skipping in-place turns)")
                    scans = max(scans, 2 * WALK_AFTER_SCANS + 1)
                scans, lc = look_around(page, w, h, scans, hp_txt, now)
                if lc is not None:
                    last_calibrate = lc
        elif DETECT_MODE == "hybrid3":
            # HYBRID 3 (idealised-OPTIMAL search): probe the fused HIT-PROBABILITY field in greedy
            # max-probability order (see the HYBRID 3 header + hybrid3_find). Aggro/RED nameplate stays a fast
            # pre-check; fight-prefetch is engaged immediately in the death handler and folded into the field.
            # If nothing is found for H2_SEARCH_TURN_SECS (5s) → turn ~90° and rebuild the field for the new arc.
            clicked = None; src = ""
            mem = read_wanted_mobs(page)                     # ONE registry walk, reused by the field this tick
            red = [m for m in find_nameplate_mobs(img, ranges) if len(m) > 3 and m[3]]   # (a) aggro/RED first
            if red:
                clicked = engage(page, w, h, ranges, img0=img, cands0=red)
                if clicked:
                    src = "aggro"
            if not clicked:                                  # (b) fight-prefetched candidates (nearest-first)
                hit = _try_prefetch(page, w, h)
                if hit:
                    clicked = select_and_attack(page, hit[0], hit[1]); src = "prefetch"
            if not clicked:                                  # (c) OPTIMAL probability-field search
                res = hybrid3_find(page, w, h, img, ranges, should_stop,
                                   deadline=h2_search_t0 + H2_SEARCH_TURN_SECS, mem=mem)
                if res:
                    clicked, src = res[0], f"h3:{res[1]}"
            if clicked:
                scans = 0; engage_misses = 0; approach = 0; mem_retries = 0; targeted_misses = 0
                h2_search_t0 = now
                engage_time = now; fight_start = now
                state = "FIGHT"; confirmed = False; progressed = False; max_hp = None
                print(f"[engage] ({src}) HP~{hp_txt} -> ({clicked[0]/w:.2f},{clicked[1]/h:.2f})")
                time.sleep(ENGAGE_SETTLE)
            elif not mem["mobs"] and re_sense.has_heap(page):
                print("[relocate] area empty (memory) — walking to a new area")
                scans, lc = look_around(page, w, h, 2 * WALK_AFTER_SCANS + 1, hp_txt, now)
                if lc is not None:
                    last_calibrate = lc
                h2_search_t0 = time.time()
            elif time.time() - h2_search_t0 >= H2_SEARCH_TURN_SECS:
                close_n, cen_ang, nearest, total_n = wanted_mob_summary(page, VIEW_MOB_RANGE)
                if MOB_STEER and cen_ang is not None and nearest is not None and nearest > MOB_STEER_MIN_DIST:
                    print(f"[steer] {H2_SEARCH_TURN_SECS:.0f}s no mob — repositioning toward wanted cluster ({total_n} mobs, {nearest:.0f}u)")
                    reposition_toward_mobs(page, w, h, cen_ang, steer_state)
                else:
                    print(f"[search] {H2_SEARCH_TURN_SECS:.0f}s no candidate — turning view 90°")
                    rotate_view(page, w, h)
                h2_search_t0 = time.time()
            else:
                engage_misses = 0; approach = 0
        elif DETECT_MODE == "hybrid2":
            # HYBRID 2 search order: (1) a brief SELF-scan (the two tight rings around the char) FIRST — a mob
            # already meleeing us stands adjacent and must win before anything else (memory-gated so an empty
            # area doesn't pay it); then (2) aggro/RED nameplate; (3) FIGHT-prefetch candidates (nearest-first,
            # already confirmed from the last fight); (4) the FUSED finder (memory + nameplate + motion,
            # cursor-verified). If NOTHING is found for H2_SEARCH_TURN_SECS (5s), turn the view ~90° and search
            # the new arc (or steer/relocate when memory says so).
            clicked = None; src = ""
            mem = read_wanted_mobs(page)                     # ONE registry walk, reused by every stage this tick
            # (1) self-rings FIRST — but only when a mob is plausibly ON us: memory's per-mob AGGRO field
            # (row[8] == 2 means that mob is TARGETING THE PLAYER — the literal "it's attacking us" signal)
            # or a mob within actual melee distance (≤8u). A merely-nearby idle mob (≤18u) no longer pays
            # the ~0.7s self-scan every tick — it's found by the fused search's projected point instead.
            if any(m[0] <= H2_SELF_CHECK_RANGE and (m[8] == 2 or m[0] <= 8.0) for m in mem["mobs"]):
                hit = _hover_self_first(page, w, h, should_stop)
                if hit:
                    clicked = select_and_attack(page, hit[0], hit[1]); src = "self"
            if not clicked:                                            # (2) aggro/RED nameplate fast-path
                red = [m for m in find_nameplate_mobs(img, ranges) if len(m) > 3 and m[3]]
                if red:
                    clicked = engage(page, w, h, ranges, img0=img, cands0=red)
                    if clicked:
                        src = "aggro"
            if not clicked:                                            # (3) fight-prefetched candidates (nearest-first)
                hit = _try_prefetch(page, w, h)
                if hit:
                    clicked = select_and_attack(page, hit[0], hit[1]); src = "prefetch"
            if not clicked:                                            # (4) budgeted fused pipeline (candidates →
                res = hybrid2_find(page, w, h, img, ranges, should_stop,   # interleaved motion → radius-ring),
                                   deadline=h2_search_t0 + H2_SEARCH_TURN_SECS, mem=mem)  # hard-stopped at the
                if res:                                                    # 5s arc deadline
                    clicked, src = res[0], f"h2:{res[1]}"
            if clicked:
                scans = 0; engage_misses = 0; approach = 0; mem_retries = 0; targeted_misses = 0
                h2_search_t0 = now                            # engaged → reset the search-turn clock
                engage_time = now; fight_start = now
                state = "FIGHT"; confirmed = False; progressed = False; max_hp = None
                print(f"[engage] ({src}) HP~{hp_txt} -> ({clicked[0]/w:.2f},{clicked[1]/h:.2f})")
                time.sleep(ENGAGE_SETTLE)
            elif not mem["mobs"] and re_sense.has_heap(page):
                # memory PROVES the area is empty (no wanted mob loaded in ANY direction) — don't burn the
                # 5s arc window scanning/turning at nothing; relocate NOW.
                print("[relocate] area empty (memory) — walking to a new area")
                scans, lc = look_around(page, w, h, 2 * WALK_AFTER_SCANS + 1, hp_txt, now)
                if lc is not None:
                    last_calibrate = lc
                h2_search_t0 = time.time()
            elif time.time() - h2_search_t0 >= H2_SEARCH_TURN_SECS:
                # 5s of searching this arc with NO mob found → turn the view ~90° and search the new arc
                # (time.time(), not the loop-top `now` — a search pass may have consumed seconds). If memory
                # says the mobs are all FAR and steering is on, walk toward the cluster instead of turning.
                close_n, cen_ang, nearest, total_n = wanted_mob_summary(page, VIEW_MOB_RANGE)
                if MOB_STEER and cen_ang is not None and nearest is not None and nearest > MOB_STEER_MIN_DIST:
                    print(f"[steer] {H2_SEARCH_TURN_SECS:.0f}s no mob — repositioning toward wanted cluster ({total_n} mobs, {nearest:.0f}u)")
                    reposition_toward_mobs(page, w, h, cen_ang, steer_state)
                else:
                    print(f"[search] {H2_SEARCH_TURN_SECS:.0f}s no candidate — turning view 90°")
                    rotate_view(page, w, h)
                h2_search_t0 = time.time()                    # restart the arc window
            else:
                engage_misses = 0; approach = 0
        else:  # IDLE, "nameplate" mode — colour name-tag scan (the default / original detection).
            # FAST PATH: rank targets ONCE on the already-grabbed frame. If none, skip the heavy
            # engage() and just turn. If some, hand the frame + ranked candidates straight to engage
            # so it doesn't re-screenshot + re-detect (saves a grab + a detection per engage).
            mobs = rank_targets(img, w, h, ranges)
            if mobs:
                last_nameplate_hit = now                      # nameplate is SEEING mobs → not "dry"
            clicked = engage(page, w, h, ranges, img0=img, cands0=mobs) if mobs else None
            if clicked:
                scans = 0; engage_misses = 0; approach = 0; mem_retries = 0
                engage_time = now; fight_start = now
                state = "FIGHT"; confirmed = False
                progressed = False; max_hp = None            # fresh damage-progress baseline
                print(f"[engage] HP~{hp_txt} -> ({clicked[0]/w:.2f},{clicked[1]/h:.2f})")
                time.sleep(ENGAGE_SETTLE)
            elif mobs and engage_misses < ENGAGE_RETRY:
                # a mob IS visible but engage missed it — retry IN PLACE a couple times (transient
                # close-miss) before stepping toward it.
                engage_misses += 1
                print(f"[idle ] mob visible, engage missed ({engage_misses}/{ENGAGE_RETRY}) — retrying")
            elif mobs and approach < APPROACH_MAX:
                # A mob's NAMEPLATE is in sight but engage can't get a confident BODY click at this
                # distance → it's a bit FAR. WALK TOWARD it (don't rotate away and wander); closing the
                # gap makes its body clickable next loop. nearest/best target = mobs[0].
                engage_misses = 0; approach += 1
                mx, my = mobs[0][0], mobs[0][1]
                ang = float(np.arctan2(my - h*0.5, mx - w*0.5))
                walk_dir(page, w, h, ang)
                print(f"[walk ] mob in sight but far ({int(mx)},{int(my)}) — step toward it ({approach}/{APPROACH_MAX})")
                scans = 0
            else:
                # No reachable mob in view. Read the MEMORY mob-map once. HYBRID: only spend a cursor
                # hover-scan when a mob is actually CLOSE (memory) — no point hovering an area we know is
                # empty. Then retry-in-place / look-around as usual.
                engage_misses = 0; approach = 0
                engaged = False
                close_n = nearby_wanted_count(page, VIEW_MOB_RANGE)
                if DETECT_MODE == "hybrid" and close_n > 0 and now - last_nameplate_hit >= HYBRID_FALLBACK_SECS:
                    print(f"[hybrid] nameplate dry {now-last_nameplate_hit:.0f}s, {close_n} mob(s) near — cursor hover-scan")
                    hit, targeted_misses = _hover_find(page, w, h, should_stop, targeted_misses)  # A3
                    last_nameplate_hit = now                  # restart dry timer (one hover pass spent)
                    if hit:
                        clicked = select_and_attack(page, hit[0], hit[1])
                        if clicked:
                            engaged = True
                            scans = 0; engage_misses = 0; approach = 0; mem_retries = 0; targeted_misses = 0
                            engage_time = now; fight_start = now
                            state = "FIGHT"; confirmed = False; progressed = False; max_hp = None
                            print(f"[engage] (hybrid→cursor) HP~{hp_txt} -> ({clicked[0]/w:.2f},{clicked[1]/h:.2f})")
                            time.sleep(ENGAGE_SETTLE)
                if not engaged:
                    # AVOID NEEDLESS TURNS: wanted mobs close (memory) but detection saw none → retry in
                    # place a few times before turning the camera (they're here, detection just missed them).
                    if close_n > 0 and mem_retries < MEM_NEAR_RETRY:
                        mem_retries += 1
                        print(f"[hold ] {close_n} wanted mob(s) near (memory), none detected — retry {mem_retries}/{MEM_NEAR_RETRY}, no turn")
                        time.sleep(CYCLE)
                        continue
                    mem_retries = 0
                    scans, lc = look_around(page, w, h, scans, hp_txt, now)
                    if lc is not None:
                        last_calibrate = lc
        if state == "FIGHT":
            h2_search_t0 = now                                  # pin the hybrid2 search-turn clock to NOW while
            #                                                     fighting → its 5s counts from the fight's END
            time.sleep(MEM_FIGHT_POLL if TARGET_SENSE == "memory" else FIGHT_POLL)  # re-read target fast
        else:
            time.sleep(CYCLE)                                   # searching cadence


def main():
    with sync_playwright() as p:
        browser, page = get_page(p)
        w, h = viewport(page)
        ranges = learn(page, w, h)
        if not ranges:
            print("No mobs learned — make sure mobs are around, then re-run.")
            return
        grind(page, w, h, ranges)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\nstopped.")
