HEADS-UP DISPLAY tutorial, verbatim: 'Press A twice to target the enemy closest to the center of the screen.' A double tap, which is exactly why every button sweep in flight-controls-runtime.md found nothing and why I concluded targeting was automatic — each sweep tapped once. It also explains the missiles: GuidanceType 5 guides to the GAME's selection and the loop had never made one, so 98 launches guided to nothing. Wired in: double-tap A when the committed contact is already within 14 deg of the nose, so the game's choice and ours are the same object. One run: 8 kills from 66 missiles (12% per missile) against the previous 9 from 101 (8.9%). The absolute count is inside run variance and the efficiency gain is one sample, so neither is claimed as decisive — it needs repeat runs. Also documents that expository tutorials self-advance while interactive ones stall (BASIC CONTROLS waits forever on 'Go to the box'), and that captions need cropping across many frames because they type out.
658 lines
30 KiB
Python
658 lines
30 KiB
Python
#!/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")
|
||
|
||
|
||
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:
|
||
if hard:
|
||
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
|
||
for off, nm, p, v, r, hard in hos:
|
||
if not hard:
|
||
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()
|