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:
330
docs/re/captures/mission-state-stage02-escort-weighted.jsonl
Normal file
330
docs/re/captures/mission-state-stage02-escort-weighted.jsonl
Normal file
File diff suppressed because one or more lines are too long
@@ -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 ~2–3 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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user