pilot: escort-weighted targeting (DEFEND), plus the capital-ship keep-out it needed

While the asset is losing hull, target what is pressing IT — ranked by distance
to the asset minus credit for closing on it — instead of what is nearest to us.
Trigger and ranking both read the live hull (pos+0x154), so nothing is inferred.
DEFEND engaged 1.9 s after the asset's first hit and held 54% of a 330 s run.

It did NOT measurably save the asset: over the window two runs share, the
policies are equal to within noise (t=239: 23218 vs 23038). Two reasons, both
recorded rather than papered over: the runs are not comparable past that window
(spawn timing differs and the hostile count GREW 134->166 in one, fell 147->118
in the other), and the real bottleneck is lethality — the guns are on for 12% of
combat frames because the target is outside the 9 deg cone the rest of the time.

Also corrects a single-run claim in the previous commit: the asset is NOT
reliably safe for the first ~170 s. A second run had first damage at t=70 s. The
stage does not replay identically; only 'the loss is slow' survives.

Fixes a fatal bug the new mode exposed: DEFEND flies at the asset, which sits
inside the friendly formation, and the first escort run went hull 1500 -> DEAD in
one tick at 2026 units/s, 0.6 s from a friendly destroyer that avoidance thought
it would clear by 365 units — the ship's radius is 2000. Keep-out applied only to
hostile turrets. Every entity above BIG_RADIUS now gets its own radius + 800 of
physical keep-out with braking inside it, whatever its faction.
This commit is contained in:
claude-re
2026-07-30 17:49:38 +00:00
parent c277e42c92
commit 402985adbf
3 changed files with 524 additions and 13 deletions

File diff suppressed because one or more lines are too long

View File

@@ -33,11 +33,13 @@ five distinct HP values**:
| `UN_e105_ADAN_Cruiser` | 3800 | 30000 | 30000.0 | | `UN_e105_ADAN_Cruiser` | 3800 | 30000 | 30000.0 |
| **`UN_f101_TCAF_Acropolis`** | 1400 | **25000** | **25000.0** | | **`UN_f101_TCAF_Acropolis`** | 1400 | **25000** | **25000.0** |
Of the 18 capital ships present at t=0, 13 sat exactly at their `HP`; the other 5 Measured directly by `mission_state.py scan` at the start of three separate runs:
sat *slightly below* it (9800/10000, 29933.3/30000, 9725/10000, 9950/10000, **146/150, 147/150 and 147/150 entities** hold exactly their definition's `HP` at
9750/10000) — the battle is already in progress when the player launches, so those `pos+0x154`. The handful that do not sit *slightly below* it (9800/10000,
five had already been shot at. **Nothing read above its `HP`, and nothing read an 29933.3/30000, 9725/10000, …) — the battle is already in progress when the player
unrelated number**, which is what a coincidental offset would produce. launches, so those ships have already been shot at. **Nothing read above its `HP`,
and nothing read an unrelated number**, which is what a coincidental offset would
produce.
The value behaves like a live counter, not a copy of the definition: The value behaves like a live counter, not a copy of the definition:
@@ -65,13 +67,14 @@ t= 210.1s 24149.5
t= 239.1s 23038.2 -1961.8 total, ≈ -600 HP/min once it starts t= 239.1s 23038.2 -1961.8 total, ≈ -600 HP/min once it starts
``` ```
Two useful consequences: **⚠️ Onset is NOT a fixed schedule — corrected by a later run.** From this run alone
it looked like the asset is safe for the first ~170 s. A second run put the first
1. **The attack on the asset starts late** (~t≈170 s here) and then runs at roughly damage at **t = 70 s**, and its hostile population *grew* (134 → 166 ADAN) where this
600 HP/min, i.e. ~40 min to sink from full. The earlier `GAME OVER` run therefore one's shrank (147 → 118). So the stage is not replaying identically, and "the asset
did not lose because the loss is fast — it lost because nothing was defending. is untouched early" is a property of one run, not of Stage 02. What survives the
2. **The asset is not in danger for the first ~23 minutes**, so an escort policy has second run is the weaker, still useful claim: **the loss is slow** — a few hundred to
time to spend on offence early and should tighten later. ~1400 HP/min against 25000, so tens of minutes to sink. The earlier `GAME OVER`
therefore was not a fast loss; it was an undefended one.
## Also captured ## Also captured
@@ -83,6 +86,41 @@ Two useful consequences:
alive, so that counter is **objectives, not hostiles** — its RAM address is still alive, so that counter is **objectives, not hostiles** — its RAM address is still
unknown (❔ open). unknown (❔ open).
## Escort-weighted targeting — implemented, and what it did NOT fix
`pilot.py` gained a **DEFEND** mode (2026-07-30): while the asset is losing hull,
target the hostiles pressing *it* — ranked by distance to the asset minus credit for
closing on it — instead of the ones nearest to us. Trigger and ranking both read the
live hull, so nothing is inferred.
It works mechanically: DEFEND engaged **1.9 s after the asset's first hit** in one run
(t=167.0), and held for 54 % of a 330 s run. **But it did not measurably save the
asset.** Over the window the two policies share, they are the same to within noise:
| t (s) | nearest-fighter | escort-weighted |
|---|---|---|
| 120 | 25000.0 | 24910.0 |
| 180 | 24779.5 | 24460.0 |
| 239 | 23038.2 | 23218.0 |
Two honest reasons it cannot yet be scored better than "no worse":
1. **The runs are not comparable past that window** — different spawn timing and, in
the escort-weighted run, a hostile population that *grew* 134 → 166 while the
baseline's fell 147 → 118.
2. **Lethality is the real bottleneck, not target choice.** The guns are on for only
**12 % of combat frames** (320 of 2630); the rest of the time the target is outside
the 9° firing cone while the loop manoeuvres. Choosing a better target does little
when most passes do not shoot.
**One bug found and fixed by the first escort run** (worth keeping as a pattern): the
new mode flies *at* the asset, which sits inside the friendly formation, and the run
ended `hull 1500 -> DEAD` in a single tick at 2026 units/s, 0.6 s from a friendly
destroyer the avoidance expected to clear by 365 units — against a hull of radius
2000. Keep-out had been applied only to hostile turrets. Every entity above
`BIG_RADIUS` now gets a physical keep-out of **its own radius + 800**, with braking
inside it, whatever its faction; the next run survived its full 330 s untouched.
## Reimplementation notes ## Reimplementation notes
- Defeat conditions for an escort stage are readable as: protected-asset - Defeat conditions for an escort stage are readable as: protected-asset

View File

@@ -18,6 +18,7 @@ make a reaction possible:
So the loop is a state machine on damage rather than a pure pursuit: So the loop is a state machine on damage rather than a pure pursuit:
ENGAGE chase and shoot the nearest hostile fighter 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, EVADE entered the moment the hull drops — turn away from the threats,
full throttle, jink; leave only after several quiet seconds full throttle, jink; leave only after several quiet seconds
RETIRE hull below a floor: break for the friendly capital ship, which the RETIRE hull below a floor: break for the friendly capital ship, which the
@@ -27,6 +28,19 @@ 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 objective is the invading fighters, and the turret is what killed every previous
run. 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 asset is *not*
in danger early — it sat at a full 25000 for the first ~170 s and then lost
~600 HP/min, i.e. ~40 minutes to sink from full. 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] Usage: pilot.py <config.json> [seconds] [--dry]
""" """
import json import json
@@ -40,12 +54,21 @@ from collections import deque
import numpy as np import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gmem # noqa: E402
import navigator # noqa: E402 import navigator # noqa: E402
from navigator import ang, norm # noqa: E402 from navigator import ang, norm # noqa: E402
from flight_probe import Pad # noqa: E402 from flight_probe import Pad # noqa: E402
HULL_OFF = 0x154 # confirmed: == definition HP at spawn, falls when hit HULL_OFF = 0x154 # confirmed: == definition HP at spawn, falls when hit
SHIELD_OFF = 0x430 # candidate: == definition Shield MaxValue at spawn SHIELD_OFF = 0x430 # candidate: == definition Shield MaxValue at spawn
DEF_HP = 0x054 # unit-struct-runtime.md
# 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: class Pilot:
@@ -56,6 +79,14 @@ class Pilot:
EVADE_QUIET = 5.0 # seconds without damage before re-engaging EVADE_QUIET = 5.0 # seconds without damage before re-engaging
RETIRE_FRAC = 0.30 # hull fraction that sends us home RETIRE_FRAC = 0.30 # hull fraction that sends us home
HZ = 8.0 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
def __init__(self, W, pad, dry=False, log=sys.stdout): def __init__(self, W, pad, dry=False, log=sys.stdout):
self.W = W self.W = W
@@ -72,6 +103,49 @@ class Pilot:
self.hp0 = None self.hp0 = None
self.last_hit = -1e9 self.last_hit = -1e9
self.threat_dir = None 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
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))
out.sort(key=lambda e: e[0])
return out
# ------------------------------------------------------------ own state # ------------------------------------------------------------ own state
def own(self, off): def own(self, off):
@@ -191,11 +265,28 @@ class Pilot:
self.threat_dir = self.threat_vector(me_p, hos) self.threat_dir = self.threat_vector(me_p, hos)
frac = hull / self.hp0 if self.hp0 else 1.0 frac = hull / self.hp0 if self.hp0 else 1.0
# ---- mode # ---- 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: if frac <= self.RETIRE_FRAC:
self.mode = "RETIRE" self.mode = "RETIRE"
elif t - self.last_hit < self.EVADE_QUIET: elif t - self.last_hit < self.EVADE_QUIET:
self.mode = "EVADE" self.mode = "EVADE"
elif ast is not None and t - self.asset_last_hit < self.ASSET_QUIET:
self.mode = "DEFEND"
else: else:
self.mode = "ENGAGE" self.mode = "ENGAGE"
@@ -210,6 +301,34 @@ class Pilot:
want = norm(away + jink) want = norm(away + jink)
self.set_throttle(+1) self.set_throttle(+1)
fire = False 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 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)
if best is not None:
_, off, nm, p, v, d_me = best
speed_ref = max(speed, 300.0)
lead = p + v * (d_me / speed_ref)
tgt = (off, nm, lead, lead - me_p, d_me)
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": elif self.mode == "RETIRE":
base = self.friendly_base(ents, me_off) base = self.friendly_base(ents, me_off)
if base is not None: if base is not None:
@@ -245,6 +364,26 @@ class Pilot:
want = norm(want + norm(me_p - p) * (2.0 * (1.0 - d / self.TURRET_KEEPOUT))) want = norm(want + norm(me_p - p) * (2.0 * (1.0 - d / self.TURRET_KEEPOUT)))
break 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)) pn = float(np.linalg.norm(push))
if pn > 1e-6: if pn > 1e-6:
want = norm(want + push * (3.0 if pn > 0.6 else 1.5)) want = norm(want + push * (3.0 if pn > 0.6 else 1.5))
@@ -269,6 +408,10 @@ class Pilot:
f"thr={self.throttle:+d} yaw={math.degrees(yaw):+6.1f} " f"thr={self.throttle:+d} yaw={math.degrees(yaw):+6.1f} "
f"pit={math.degrees(pitch):+6.1f} aim={math.degrees(aim[0]):+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)}") f"/{math.degrees(aim[1]):+6.1f} fire={int(fire)}")
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: if dmg > 0.5:
msg += f" HIT -{dmg:.0f}" msg += f" HIT -{dmg:.0f}"
if tgt: if tgt: