#!/usr/bin/env python3
"""A pilot that tries to stay alive, not just to shoot.

Every earlier loop flew a straight pursuit and was shot down; the trace from
ctrl_probe.py shows why — 1500 hull points gone in twelve seconds while sitting
in a turret's line of fire, with no reaction of any kind. Three measured facts
make a reaction possible:

* **Hull is `position + 0x154`** — at spawn it equals the unit definition's own
  `HP` (1500 for the Delta Saber), it steps down 30/60/90 per hit, and it goes
  negative at death. So damage is observable *as it happens*, not inferred.
* **`RT` accelerates and `LT` brakes**, and the setting persists: measured
  ground speed went 488 → 1510 under RT, 488 → 174 under LT and then *stayed*
  near 130 with the sticks neutral. (The older note "RT is not the throttle" was
  drawn from a value-scan for a speed field, not from measuring the speed.)
* **`RB` fires** (autopilot-memory-driven.md).

So the loop is a state machine on damage rather than a pure pursuit:

    ENGAGE  chase and shoot the nearest hostile fighter
    DEFEND  the escorted asset is being attacked — go kill what is attacking IT
    EVADE   entered the moment the hull drops — turn away from the threats,
            full throttle, jink; leave only after several quiet seconds
    RETIRE  hull below a floor: break for the friendly capital ship, which the
            mission's own hint says is where you resupply

Turrets are treated as threats to be *kept at a distance*, not as targets: the
objective is the invading fighters, and the turret is what killed every previous
run.

**Why DEFEND exists, and why it is not simply "always guard the asset".** Stage
02 is an escort: a 240 s run ended in GAME OVER with our own hull at 1500/1500
because the ACROPOLIS sank while the pilot chased the nearest fighter 2 km away.
But the measurement in docs/re/mission-escort-state.md says the loss is *slow* —
a few hundred to ~1400 HP/min against 25000, i.e. tens of minutes to sink. (When
it starts varies: t≈170 s in one run, t≈70 s in another, so do not schedule on
it — react to the hull.) So permanently orbiting it would throw away most of the
mission for nothing. The policy that fits the
measurement is: **fight freely until the asset is actually being hurt, then
switch to killing its attackers specifically.** Both the trigger and the target
choice are read live — every entity's hull is `position + 0x154`, confirmed for
seven classes, so "is the asset losing hull" and "which hostiles are closing on
it" are both observable rather than inferred.

Usage: pilot.py <config.json> [seconds] [--dry]
"""
import json
import math
import os
import struct
import sys
import time
from collections import deque

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gmem  # noqa: E402
import navigator  # noqa: E402
from navigator import ang, norm  # noqa: E402
from flight_probe import Pad  # noqa: E402

HULL_OFF = 0x154          # confirmed: == definition HP at spawn, falls when hit
SHIELD_OFF = 0x430        # candidate: == definition Shield MaxValue at spawn
DEF_HP = 0x054            # unit-struct-runtime.md

# The player Delta Saber's guns, from the solved Shell records
# (docs/re/captures/weapon-runtime-fields.csv, all ✅ CONFIRMED):
#   Shell_TCAF_DeltaSaber_{NoseGun,Gun,Beam}_P  Velocity 8000, LifeTime 0.5 s,
#   MaximumRange 4000 (= 8000 × 0.5, self-consistent), shell Radius 20–30.
# Both numbers were previously wrong in this loop, and both mattered:
#   * flight time was computed as d / OUR speed (400–2000 u/s), so every shot
#     was led 4–16× too far ahead of the target;
#   * FIRE_RANGE was 5000, i.e. a quarter of the shots were fired at targets
#     the shells expire before reaching.
SHELL_VELOCITY = 8000.0
SHELL_MAX_RANGE = 4000.0
SHELL_RADIUS = 20.0

# The MAIN weapon, fired with Y — measured, not assumed: a hold-each-input probe
# (fire_probe.sh) moved NOSE BM 06000 -> 05956 under RB and MAIN MPM 00300 ->
# 00299 under Y, so RB is the nose gun (~11 rounds/s) and Y is the main mount.
# That probe also settled the lethality question the other way round: we DO
# shoot, so the kill counters reading 0000 mean we shoot and MISS.
#
# Which is exactly what the disc data says to stop doing. Shell_TCAF_DeltaSaber_
# Missile_P is Power 200 with GuidanceType 5 (guided) and MaximumRange 5000,
# against the nose gun's Power 15 unguided — one missile is worth ~14 gun hits
# on a 500 HP fighter, and it steers itself, which is the accuracy problem
# solved rather than tuned. Range is held under the confirmed 5000 because which
# main weapon is actually loaded is not read from RAM yet.
MISSILE_RANGE = 4000.0
MISSILE_CONE = math.radians(20.0)
MISSILE_PERIOD = 2.0      # s between launches; 300 rounds is not unlimited

# Stage 02's protected asset. Named rather than derived: "the biggest friendly"
# picks the f105 cruiser (30000 HP > the Acropolis's 25000), and "the friendly
# with the most HP" picks it too, so neither rule finds the right ship. The
# per-stage asset is mission script, not a property of the entity, so it is
# configuration here — override with $SYLPH_ASSET for another stage.
ASSET_NAME = os.environ.get("SYLPH_ASSET", "Acropolis")

# Turrets are keep-out zones everywhere else in this file, and that rule is what
# stopped the pilot being shot down. But the 2026-08-10 outcome run measured the
# cost of it: of the damage that sinks the ACROPOLIS, roughly half comes from
# e007 turrets sitting 1.5-3 km off it (the other half from e010 bombers), and a
# turret is 100 HP -- one missile, or ~7 nose-gun hits. Several were already at
# 45-95 HP from stray fire and were never finished off, because nothing ever
# targets them. So the rule that keeps us alive is also what loses the escort.
# Gated rather than simply changed, so the measured baseline stays reproducible:
# SYLPH_KILL_TURRETS=1 lets DEFEND -- and only DEFEND -- treat a turret that is
# near the asset as a target. ENGAGE still avoids them.
KILL_TURRETS = os.environ.get("SYLPH_KILL_TURRETS") == "1"


class Pilot:
    KP, KD = 2.2, 0.45
    FIRE_CONE = math.radians(9)       # fallback only; the real gate is angular size
    FIRE_RANGE = SHELL_MAX_RANGE      # the shells simply do not arrive past this
    CONE_MIN = math.radians(2.0)
    CONE_MAX = math.radians(25.0)   # close-in the target subtends a lot; let it
    TURRET_KEEPOUT = 2500.0   # ...and stay this far from things that shoot back
    EVADE_QUIET = 5.0         # seconds without damage before re-engaging
    RETIRE_FRAC = 0.30        # hull fraction that sends us home
    HZ = 8.0
    # --- escort ---
    ASSET_GUARD = 9000.0      # hostiles this close to the asset count as its attackers
    ASSET_QUIET = 20.0        # s of no asset damage before dropping out of DEFEND
    ASSET_ALERT = 0.5         # HP of asset damage that counts as "under attack"
    ASSET_STANDOFF = 3500.0   # loiter this far out when guarding with no target
    HULL_CLEARANCE = 800.0    # clearance ON TOP of a capital ship's own radius
    CLOSING_WEIGHT = 4.0      # s of closing-rate credit when ranking attackers
    MY_RANGE_WEIGHT = 0.35    # how much our own distance discounts a target
    # --- target commitment ---
    # The loop re-scored every contact every tick, so the nose chased whichever
    # fighter was momentarily best and the aim error wandered 10-40 deg through
    # a pass. A missile lock is time-on-target (the OPTIONS screen calls it
    # Padlock), so switching targets constantly is the one thing guaranteed to
    # prevent a kill. Stay on the chosen contact until it dies, leaves range, or
    # sits behind us long enough that chasing it is pointless.
    COMMIT_MAX = 14.0         # s before we are allowed to reconsider anyway
    COMMIT_DROP = 6000.0      # ...or it gets this far away
    COMMIT_BEHIND = 2.5       # ...or stays >90 deg off the nose this long
    # --- moves the ADVANCED CONTROLS tutorial teaches (tutorial_capture.sh) ---
    # "Target an enemy and pull LT and RT [together]. This sets your fighter's
    #  speed to that of the target. This works well when you are trying to get
    #  behind an enemy. Once behind an enemy, this also helps you attack them."
    # That is the overshoot problem solved by the game itself: matching speed
    # holds us in the target's rear hemisphere instead of flying through it,
    # which is the only way a time-on-target lock ever completes.
    # Scope matters, and a measured regression proved it: applying the match at
    # 4500 with a 70 deg cone dropped kills 9 -> 0. Matching a target's speed
    # while still 5 km behind it means never closing — the pilot sat at 272 u/s
    # and fired 28 frames all run. The tutorial's own wording scopes it: "when
    # you are trying to get BEHIND an enemy... ONCE BEHIND an enemy, this also
    # helps you attack them". So it is station-keeping in the saddle, not an
    # approach throttle. Only match when we are already there.
    # MEASURED: both tutorial moves are a NET REGRESSION as applied here, so
    # both ship DISABLED. One run each, same everything else:
    #   commitment only ............ 101 missiles, 364 fire frames,  9 kills
    #   + match(4500) + snap-face ....  9 missiles,  28 fire frames,  0 kills
    #   + match(1200) + snap-face ...  57 missiles, 225 fire frames,  2 kills
    # The moves are real and the tutorial is right about them; the loop just
    # cannot use them yet. Snap-face (B+A) reorients the craft mid-pursuit and
    # destroys the very dwell that commitment buys, and speed-match needs to be
    # entered from the saddle rather than commanded at range. Set MATCH_RANGE
    # and lower FACE_MIN to re-enable, and A/B them over SEVERAL runs — one run
    # per config is inside this stage's spawn variance.
    MATCH_RANGE = 0.0             # 1200.0 to re-enable
    MATCH_CONE = math.radians(25)
    # "Press B and A together to face [the target]" — a snap turn, far quicker
    # than winding the PD controller around for a contact behind us.
    FACE_MIN = math.radians(999)  # 50 deg to re-enable the B+A snap turn
    # HEADS-UP DISPLAY tutorial, verbatim: "Press A twice to target the enemy
    # closest to the center of the screen." A DOUBLE tap — which is why every
    # single-tap button sweep found nothing and concluded targeting was
    # automatic. It also explains the missiles: GuidanceType 5 needs the GAME's
    # selection, and we had never made one, so 98 launches guided to nothing.
    # Select only when our committed contact is already near screen centre, so
    # the game's choice and ours are the same object.
    SELECT_CONE = math.radians(14)
    SELECT_PERIOD = 3.0
    FACE_PERIOD = 4.0

    def __init__(self, W, pad, dry=False, log=sys.stdout):
        self.W = W
        self.pad = pad
        self.dry = dry
        self.log = log
        # closest-point-of-approach avoidance is navigator.py's, reused as-is
        self.av = navigator.Navigator(W, pad, dry=True, log=log)
        self.prevM = None
        self.firing = False
        self.throttle = 0      # -1 brake, 0 coast, +1 accelerate
        self.mode = "ENGAGE"
        self.hp_hist = deque(maxlen=64)
        self.hp0 = None
        self.last_hit = -1e9
        self.threat_dir = None
        # escort bookkeeping
        self.def_hp = {}          # def_va -> definition HP
        for va in W.defs:
            self.def_hp[va] = self.f32(gmem.va_to_off(va) + DEF_HP)
        self.asset_hist = deque(maxlen=64)
        self.asset_hp0 = None
        self.asset_last_hit = -1e9
        self.missile_down = False
        self.missile_t = -1e9
        self.missiles = 0
        self.commit_off = None    # entity we are committed to
        self.commit_t = -1e9
        self.behind_since = None
        self.matching = False
        self.face_t = -1e9
        self.face_down = None
        self.faces = 0
        self.select_t = -1e9
        self.selects = 0

    def f32(self, off):
        b = os.pread(self.W.fd, 4, off)
        if len(b) < 4:
            return float("nan")
        return struct.unpack(">f", b)[0]

    # ---------------------------------------------------------------- escort
    def asset(self, ents):
        """The protected ship, and its live hull — same anchor as everyone's."""
        for off, nm, p, v, r in ents:
            if ASSET_NAME in nm:
                hull = self.f32(off + HULL_OFF)
                return off, nm, p, v, r, hull
        return None

    def asset_attackers(self, hos, a_p):
        """Hostile fighters near the asset, ranked by how hard they press it.

        Ranking is distance to the asset *minus* credit for closing on it, so a
        fighter 4 km out and running in outranks one sitting at 2 km drifting
        away. Turrets and hulls are excluded for the same reason as everywhere
        else: they are not killable objectives, they are keep-out zones.
        """
        out = []
        for off, nm, p, v, r, hard in hos:
            # A turret near the asset IS a killable objective (100 HP) when
            # SYLPH_KILL_TURRETS is set; a capital hull never is.
            if hard and not (KILL_TURRETS and "Turret" in nm):
                continue
            rel = a_p - p
            d = float(np.linalg.norm(rel))
            if d > self.ASSET_GUARD:
                continue
            closing = float(np.dot(norm(rel), v))     # +ve = moving at the asset
            out.append((d - self.CLOSING_WEIGHT * max(closing, 0.0), off, nm, p, v, d, r))
        out.sort(key=lambda e: e[0])
        return out

    # ------------------------------------------------------------ own state
    def own(self, off):
        b = os.pread(self.W.fd, 8, off + HULL_OFF)
        hull = struct.unpack_from(">f", b, 0)[0] if len(b) >= 4 else float("nan")
        b2 = os.pread(self.W.fd, 4, off + SHIELD_OFF)
        shield = struct.unpack(">f", b2)[0] if len(b2) == 4 else float("nan")
        return hull, shield

    def set_throttle(self, want):
        """RT / LT are a persistent setting, so only send the change.

        `want` is +1 accelerate, -1 brake, 0 coast, or the string "match" for
        the tutorial's both-triggers speed-match onto the current target.
        """
        if want == self.throttle or self.dry:
            return
        if want == "match":
            self.pad.trig("RT", 1.0)
            self.pad.trig("LT", 1.0)
        else:
            self.pad.trig("RT", 1.0 if want > 0 else 0.0)
            self.pad.trig("LT", 1.0 if want < 0 else 0.0)
        self.throttle = want

    def select_target(self, t):
        """A, twice: make the GAME target what we are already pointing at."""
        if self.dry or t - self.select_t < self.SELECT_PERIOD:
            return
        self.pad.f.write("tap A 90\n")
        self.pad.f.write("tap A 90\n")
        self.select_t = t
        self.selects += 1

    def face_target(self, t):
        """B + A: snap the nose onto the selected target."""
        if self.dry or t - self.face_t < self.FACE_PERIOD:
            return
        self.pad.press("B")
        self.pad.press("A")
        self.face_down = t
        self.face_t = t
        self.faces += 1

    # -------------------------------------------------------------- targets
    def hostiles(self, ents, me_off):
        out = []
        for off, nm, p, v, r in ents:
            if off == me_off or navigator.faction(nm) != "ADAN":
                continue
            out.append((off, nm, p, v, r, "Turret" in nm or r >= navigator.Navigator.BIG_RADIUS))
        return out

    def lead_point(self, p, v, d):
        """Where to aim: the target moved on by the shell's real flight time."""
        return p + v * (d / SHELL_VELOCITY)

    def fire_cone(self, d, r):
        """How far off the nose we will still pull the trigger.

        The target's angular half-size, atan((r_target + r_shell) / range), is
        the angle that can actually *hit* — but gating on it alone was measured
        to be much worse than the old fixed 9°: at 2584 units a fighter subtends
        2.7°, the steering loop holds the nose to ~10–30°, and firing collapsed
        to 1 frame in 2639. Ammunition is free and the guns are continuous, so
        the angular size belongs here as a **floor** that opens the gate wider
        up close, never as a cap that closes it far out.
        """
        if d < 1.0:
            return self.CONE_MAX
        return min(self.CONE_MAX, max(self.FIRE_CONE, math.atan2(r + SHELL_RADIUS, d)))

    def pick_committed(self, t, me_p, me_v, fwd, hos):
        """pick(), but stay on the same contact long enough to actually kill it."""
        cur = None
        for off, nm, p, v, r, hard in hos:
            if off == self.commit_off and not hard:
                cur = (off, nm, p, v, r)
                break
        if cur is not None:
            off, nm, p, v, r = cur
            rel = p - me_p
            d = float(np.linalg.norm(rel))
            behind = ang(rel, fwd) > math.pi / 2
            self.behind_since = (self.behind_since if behind else None) or (t if behind else None)
            stale = (t - self.commit_t > self.COMMIT_MAX
                     or d > self.COMMIT_DROP
                     or (self.behind_since is not None
                         and t - self.behind_since > self.COMMIT_BEHIND))
            if not stale:
                lead = self.lead_point(p, v, d)
                return (off, nm, lead, lead - me_p, d, r)
        # commit to a fresh one
        tgt = self.pick(me_p, me_v, fwd, hos)
        self.commit_off = tgt[0] if tgt else None
        self.commit_t = t
        self.behind_since = None
        return tgt

    def pick(self, me_p, me_v, fwd, hos):
        """Nearest *fighter*, weighted by how far off the nose it is."""
        best, bestscore = None, 1e18
        for off, nm, p, v, r, hard in hos:
            if hard:
                continue          # turrets and hulls are not the objective
            rel = p - me_p
            d = float(np.linalg.norm(rel))
            if d < 1e-3:
                continue
            lead = self.lead_point(p, v, d)
            theta = ang(lead - me_p, fwd)
            score = d * (1.0 + 3.0 * (theta / math.pi) ** 2)
            if score < bestscore:
                best, bestscore = (off, nm, lead, lead - me_p, d, r), score
        return best

    def threat_vector(self, me_p, hos):
        """Where the danger is: inverse-square weighted direction to shooters."""
        acc = np.zeros(3)
        for off, nm, p, v, r, hard in hos:
            rel = p - me_p
            d = float(np.linalg.norm(rel))
            if d < 1.0 or d > 8000.0:
                continue
            w = (1500.0 / d) ** 2 * (3.0 if hard else 1.0)
            acc += norm(rel) * w
        return norm(acc) if np.linalg.norm(acc) > 1e-6 else None

    def friendly_base(self, ents, me_off):
        """The biggest friendly — the carrier the briefing says to resupply at."""
        best = None
        for off, nm, p, v, r in ents:
            if off == me_off or navigator.faction(nm) != "TCAF":
                continue
            if best is None or r > best[4]:
                best = (off, nm, p, v, r)
        return best

    # ------------------------------------------------------------ steering
    def sticks(self, want, M, w):
        fwd = M[self.W.fwd_row] * self.W.fwd_sign
        right = M[(self.W.fwd_row + 1) % 3]
        up = np.cross(fwd, right)
        ex, ey, ez = (float(np.dot(want, right)), float(np.dot(want, up)),
                      float(np.dot(want, fwd)))
        yaw = math.atan2(ex, ez if abs(ez) > 1e-3 else 1e-3)
        pitch = math.atan2(ey, ez if abs(ez) > 1e-3 else 1e-3)
        if ez < 0:                        # target behind: commit to a full turn
            yaw = math.copysign(math.pi / 2, ex if ex else 1.0)
        sx = max(-1.0, min(1.0, self.KP * yaw - self.KD * float(np.dot(w, up))))
        sy = max(-1.0, min(1.0, -(self.KP * pitch - self.KD * float(np.dot(w, right)))))
        return sx, sy, yaw, pitch

    # ---------------------------------------------------------------- step
    def step(self, t, dt):
        ents = self.W.sample(t)
        me = next((e for e in ents if "Player" in e[1]), None)
        if me is None:
            return None
        me_off, me_nm, me_p, me_v, me_r = me
        M = self.W.rot(me_off)
        if M is None:
            return "no-orientation"
        fwd = M[self.W.fwd_row] * self.W.fwd_sign
        right = M[(self.W.fwd_row + 1) % 3]
        up = np.cross(fwd, right)
        speed = float(np.linalg.norm(me_v))

        hull, shield = self.own(me_off)
        if self.hp0 is None and math.isfinite(hull) and hull > 0:
            self.hp0 = hull
        self.hp_hist.append((t, hull))
        # damage over the last ~2 s; the hull only ever falls, so any drop is a hit
        recent = [h for (ts, h) in self.hp_hist if t - ts <= 2.0]
        dmg = (max(recent) - hull) if recent else 0.0
        if dmg > 0.5:
            self.last_hit = t
        if hull <= 0:
            return "DEAD"

        # body angular velocity, for the damping term
        w = np.zeros(3)
        if self.prevM is not None and dt > 1e-3:
            D = self.prevM @ M.T
            w = np.array([D[2, 1] - D[1, 2], D[0, 2] - D[2, 0], D[1, 0] - D[0, 1]]) / (2 * dt)
        self.prevM = M

        hos = self.hostiles(ents, me_off)
        self.threat_dir = self.threat_vector(me_p, hos)
        frac = hull / self.hp0 if self.hp0 else 1.0

        # ---- the escorted asset, read exactly like our own hull
        ast = self.asset(ents)
        a_frac, a_dmg = 1.0, 0.0
        if ast is not None:
            a_hull = ast[5]
            if self.asset_hp0 is None and math.isfinite(a_hull) and a_hull > 0:
                self.asset_hp0 = a_hull
            self.asset_hist.append((t, a_hull))
            recent = [h for (ts, h) in self.asset_hist if t - ts <= 4.0]
            a_dmg = (max(recent) - a_hull) if recent else 0.0
            if a_dmg > self.ASSET_ALERT:
                self.asset_last_hit = t
            a_frac = a_hull / self.asset_hp0 if self.asset_hp0 else 1.0

        # ---- mode. Our own survival still outranks the escort: a dead pilot
        # defends nothing, and RETIRE/EVADE are what stopped us being shot down.
        if frac <= self.RETIRE_FRAC:
            self.mode = "RETIRE"
        elif t - self.last_hit < self.EVADE_QUIET:
            self.mode = "EVADE"
        elif ast is not None and t - self.asset_last_hit < self.ASSET_QUIET:
            self.mode = "DEFEND"
        else:
            self.mode = "ENGAGE"

        tgt = self.pick_committed(t, me_p, me_v, fwd, hos)
        push, worst = self.av.avoidance(me_p, me_v, me_r, ents, me_off)

        if self.mode == "EVADE":
            # Away from the guns, plus a jink so a straight escape line is not
            # itself an easy solution for whatever is shooting.
            away = -self.threat_dir if self.threat_dir is not None else fwd
            jink = right * math.sin(t * 1.7) * 0.5 + up * math.cos(t * 2.3) * 0.35
            want = norm(away + jink)
            self.set_throttle(+1)
            fire = False
        elif self.mode == "DEFEND":
            # Kill what is hitting the ship, not what is nearest to us. Among
            # the asset's attackers prefer the one pressing it hardest, with a
            # modest discount for being closer to us so the loop does not fly
            # past three targets to reach a marginally worse fourth.
            atk = self.asset_attackers(hos, ast[2])
            best = None
            for score, off, nm, p, v, d_a, r in atk:
                d_me = float(np.linalg.norm(p - me_p))
                total = score + self.MY_RANGE_WEIGHT * d_me
                if best is None or total < best[0]:
                    best = (total, off, nm, p, v, d_me, r)
            if best is not None:
                _, off, nm, p, v, d_me, r = best
                lead = self.lead_point(p, v, d_me)
                tgt = (off, nm, lead, lead - me_p, d_me, r)
                want = norm(tgt[3])
                self.set_throttle(+1 if d_me > 2500.0 else 0)
            else:
                # Nothing on it right now: hold station near the ship instead of
                # wandering off, so the next wave is met at the asset.
                rel = ast[2] - me_p
                d = float(np.linalg.norm(rel))
                want = (norm(rel) if d > ast[4] + self.ASSET_STANDOFF
                        else norm(np.cross(rel, up)))
                self.set_throttle(+1 if d > ast[4] + self.ASSET_STANDOFF else 0)
            fire = True
        elif self.mode == "RETIRE":
            base = self.friendly_base(ents, me_off)
            if base is not None:
                rel = base[2] - me_p
                d = float(np.linalg.norm(rel))
                want = norm(rel) if d > base[4] + 400.0 else norm(np.cross(rel, up))
            else:
                want = -self.threat_dir if self.threat_dir is not None else fwd
            self.set_throttle(+1)
            fire = False
        else:
            # Straight lead pursuit and fly *through*. An earlier version orbited
            # once inside a standoff radius and braked while doing it: it then
            # circled one attacker for 40 s at ~700 m, at 60-100 units/s, never
            # inside the firing cone. Overshooting and re-acquiring is better
            # than a stall in the middle of a battle; collision avoidance already
            # keeps a fighter-sized margin.
            want = norm(tgt[3]) if tgt else fwd
            if tgt and tgt[4] < self.MATCH_RANGE and ang(tgt[3], fwd) < self.MATCH_CONE:
                self.set_throttle("match")     # sit in its rear hemisphere
            elif tgt and tgt[4] > 2500.0:
                self.set_throttle(+1)
            else:
                self.set_throttle(0)
            fire = True

        # A turret inside its keep-out radius outranks the target -- except the
        # one we are deliberately attacking, or the keep-out would steer us off
        # the very thing we chose to kill and neither goal would be served.
        tgt_off = tgt[0] if tgt else None
        for off, nm, p, v, r, hard in hos:
            if not hard or (KILL_TURRETS and off == tgt_off):
                continue
            d = float(np.linalg.norm(p - me_p))
            if d < self.TURRET_KEEPOUT:
                want = norm(want + norm(me_p - p) * (2.0 * (1.0 - d / self.TURRET_KEEPOUT)))
                break

        # A capital ship is a wall, whatever its faction. DEFEND flies at the
        # asset — which sits in the middle of the friendly formation — and the
        # first escort run ended with hull 1500 -> DEAD in a single tick at
        # 2026 units/s, 0.6 s from a friendly destroyer that the avoidance
        # thought it would clear by 365 units. A destroyer's own radius is
        # 2000. Closest-point-of-approach with a fighter-sized margin cannot
        # keep us out of something that big, so give every large entity a hard
        # physical keep-out scaled by ITS radius and brake inside it.
        for off, nm, p, v, r in ents:
            if off == me_off or r < navigator.Navigator.BIG_RADIUS:
                continue
            rel = me_p - p
            d = float(np.linalg.norm(rel))
            keep = r + self.HULL_CLEARANCE
            if d < keep:
                want = norm(want + norm(rel) * (2.5 * (1.0 - d / keep)))
                if speed > 900.0:
                    self.set_throttle(-1)
                break

        pn = float(np.linalg.norm(push))
        if pn > 1e-6:
            want = norm(want + push * (3.0 if pn > 0.6 else 1.5))

        sx, sy, yaw, pitch = self.sticks(want, M, w)
        # The firing gate has to be measured against the TARGET, not against the
        # commanded direction: `want` carries the avoidance and keep-out terms,
        # so gating on it means the guns stay cold exactly when the loop is
        # manoeuvring — which is most of a dogfight.
        aim = self.sticks(norm(tgt[3]), M, w)[2:] if tgt else (math.pi, math.pi)
        cone = self.fire_cone(tgt[4], tgt[5]) if tgt else self.FIRE_CONE
        aim_ok = abs(aim[0]) < cone and abs(aim[1]) < cone
        fire = bool(fire and tgt and aim_ok and tgt[4] < self.FIRE_RANGE and pn < 1.2)

        # The main mount is a discrete launch, not a continuous stream: press Y
        # and let go a tick later, then wait out MISSILE_PERIOD. Holding it
        # would empty 300 rounds in half a minute.
        msl = bool(tgt and self.mode in ("ENGAGE", "DEFEND")
                   and tgt[4] < MISSILE_RANGE
                   and abs(aim[0]) < MISSILE_CONE and abs(aim[1]) < MISSILE_CONE
                   and pn < 1.2)
        if not self.dry:
            self.pad.axis("LX", sx)
            self.pad.axis("LY", sy)
            if fire != self.firing:
                (self.pad.press if fire else self.pad.release)("RB")
                self.firing = fire
            if (tgt and self.mode in ("ENGAGE", "DEFEND")
                    and abs(aim[0]) < self.SELECT_CONE
                    and abs(aim[1]) < self.SELECT_CONE
                    and tgt[4] < MISSILE_RANGE and pn < 1.2):
                self.select_target(t)
            if self.face_down is not None and t - self.face_down > 0.2:
                self.pad.release("B")
                self.pad.release("A")
                self.face_down = None
            elif (tgt and self.mode in ("ENGAGE", "DEFEND")
                  and abs(aim[0]) > self.FACE_MIN and pn < 1.2):
                self.face_target(t)
            if self.missile_down and t - self.missile_t > 0.15:
                self.pad.release("Y")
                self.missile_down = False
            elif (not self.missile_down and msl
                  and t - self.missile_t > MISSILE_PERIOD):
                self.pad.press("Y")
                self.missile_down = True
                self.missile_t = t
                self.missiles += 1

        msg = (f"{self.mode:<7} hull={hull:6.0f} shd={shield:6.0f} spd={speed:6.0f} "
               f"thr={str(self.throttle):>5} yaw={math.degrees(yaw):+6.1f} "
               f"pit={math.degrees(pitch):+6.1f} aim={math.degrees(aim[0]):+6.1f}"
               f"/{math.degrees(aim[1]):+6.1f} fire={int(fire)} msl={self.missiles}"
               f" fc={self.faces} sel={self.selects}")
        if ast is not None:
            msg += f" ast={a_frac*100:5.1f}%"
            if a_dmg > self.ASSET_ALERT:
                msg += f" ASSET-HIT -{a_dmg:.0f}"
        if dmg > 0.5:
            msg += f" HIT -{dmg:.0f}"
        if tgt:
            msg += f" tgt={tgt[1][3:24]:<21} d={tgt[4]:6.0f}"
        if worst:
            msg += f" | AVOID {worst[1][3:18]} miss={worst[3]:5.0f} t={worst[4]:4.1f}"
        return msg

    def run(self, secs):
        self.W.scan()
        t0 = time.time()
        last, last_scan = t0, 0.0
        hostiles0 = None
        while time.time() - t0 < secs:
            t = time.time()
            if t - last_scan > 5.0:
                ents = self.W.scan()
                last_scan = t
                n_ad = sum(1 for _, va in ents
                           if navigator.faction(self.W.defs[va]) == "ADAN")
                if hostiles0 is None:
                    hostiles0 = n_ad
                print(f"[{t-t0:6.1f}] scan: {len(ents)} entities, {n_ad} ADAN "
                      f"(start {hostiles0})", file=self.log, flush=True)
            msg = self.step(t, t - last)
            last = t
            if msg is None:
                print(f"[{t-t0:6.1f}] player object gone — stopping",
                      file=self.log, flush=True)
                break
            print(f"[{t-t0:6.1f}] {msg}", file=self.log, flush=True)
            if msg == "DEAD":
                break
            time.sleep(max(0.0, 1.0 / self.HZ - (time.time() - t)))
        if not self.dry:
            self.pad.reset()
        print(f"# flew {time.time()-t0:.0f}s", file=self.log, flush=True)


def main():
    cfg = json.load(open(sys.argv[1]))
    secs = float(sys.argv[2]) if len(sys.argv) > 2 else 180.0
    W = navigator.World(cfg)
    Pilot(W, Pad(), dry="--dry" in sys.argv).run(secs)


if __name__ == "__main__":
    main()
