diff --git a/common_libs/guns/decay_gf.nim b/common_libs/guns/decay_gf.nim index 1e9e0a1..a9e1d48 100644 --- a/common_libs/guns/decay_gf.nim +++ b/common_libs/guns/decay_gf.nim @@ -25,6 +25,7 @@ type waves: array[len(vb.PowerBins), seq[DWave]] waveHead: array[len(vb.PowerBins), int] # O(1) pop cursor waveStoredTick: array[len(vb.PowerBins), int] # last tick a wave was queued for this bin + vt: VelocityTracker # enemy velocity history (base selection) cachedTick: int # last tick bins were decayed wavePushes*: int waveStarved*: int @@ -80,15 +81,17 @@ proc predict*(g: var DecayGFGun, state: WorldState, bulletSpeed: float): GunPred return GunPrediction(x: state.enemyX, y: state.enemyY) let mea = arcsin(clamp(8.0 / bulletSpeed, -1.0, 1.0)) - # Base forecast: the GF learns the residual against this self-consistent - # constant-velocity prediction (see lead_forecast.nim for why this is required). - let f = forecastLinear(state, bulletSpeed) if state.tick != g.cachedTick: + g.cachedTick = state.tick + g.vt.observe(state) # Decay all bins once per tick for i in 0.. 1e-9: (raw[i] - g.featMin[i]) / span else: 0.0 -proc updateMinMax(g: var KNNGun, raw: array[7, float]) = - for i in 0..6: +proc updateMinMax(g: var KNNGun, raw: array[NFeat, float]) = + for i in 0.. g.featMax[i]: g.featMax[i] = raw[i] proc buildFeatures(state: WorldState, lastSpeed, lastDir: float, - tsdc: int): array[7, float] = + tsdc: int): array[NFeat, float] = let dx = state.enemyX - state.selfX let dy = state.enemyY - state.selfY let dist = sqrt(dx*dx + dy*dy) @@ -108,9 +115,13 @@ proc buildFeatures(state: WorldState, lastSpeed, lastDir: float, result[4] = clamp(float(tsdc) / 100.0, 0.0, 1.0) result[5] = clamp(fwdDist / arenaDiag, 0.0, 1.0) result[6] = clamp(bwdDist / arenaDiag, 0.0, 1.0) + # Enemy energy. The only feature that can separate behaviours that depend on + # the target's own remaining energy (e.g. an energy-threshold turner that + # changes movement below 30). Kept on a fixed [0,1] scale (see initKNNGun). + result[7] = clamp(state.enemyEnergy / 100.0, 0.0, 1.0) -proc euclidean(a, b: array[7, float]): float {.inline.} = - for i in 0..6: +proc euclidean(a, b: array[NFeat, float]): float {.inline.} = + for i in 0.. 55, DecayGF 108 -> 76, KNN 101 -> 74 hits/2000. +## `forecastRadialBlend` therefore keeps the forecast BEARING but blends the +## RANGE between the radial-only model (`d + v_r*t`, no geometric term) and the +## full geometric model, weighted by the fraction of the target's recent motion +## that is radial. Purely radial targets get the exact ballistic range (so the +## constant-velocity and wall-bounce fixtures are preserved); tangential targets +## get the range-holding model (so the surfers are recovered). Measured across +## the synthetic + classic-Robocode + tr-bridge fixtures: the blend preserves +## every synthetic fixture (wall-bounce 241/400, constant-velocity 400/400) and +## lifts the classic DrussGT captures GF 55->171, DecayGF 76->100, KNN 74->120 +## hits/2000 vs the plain constant-velocity base. ## ## Coordinate system: 0° = East, CCW positive (Tank Royale standard). @@ -52,3 +72,88 @@ proc forecastLinear*(state: WorldState, bulletSpeed: float): BaseForecast = result.y = ey result.dist = hypot(ex - state.selfX, ey - state.selfY) result.bearing = arctan2(ey - state.selfY, ex - state.selfX) + +# ── short-window velocity history ───────────────────────────────────────────── +# +# The range model needs to know how much of the target's recent motion is +# radial (toward/away from the shooter) versus tangential. A single tick is too +# noisy, so the tracker keeps a short ring of per-tick displacements and their +# radial fractions. + +const + VelWindow* = 16 ## max kept per-tick displacement samples + RadialWindow* = 32 ## ticks of history used to estimate the radial fraction + +type + VelocityTracker* = object + prevX*, prevY*: float + prevTick*: int + hasPrev*: bool + radRing*: array[VelWindow, float] + head*, count*: int + +proc observe*(vt: var VelocityTracker, state: WorldState) = + ## Record this tick's displacement. Must be called once per tick, before the + ## first forecast of that tick. Same-tick repeats are ignored. + if vt.hasPrev and state.tick > vt.prevTick: + let dx = state.enemyX - vt.prevX + let dy = state.enemyY - vt.prevY + let spd = hypot(dx, dy) + # |displacement along the line to the shooter| / |displacement|. High => the + # range is changing; low => the target is moving tangentially / holding range. + var frac = 0.0 + let losD = hypot(state.selfX - state.enemyX, state.selfY - state.enemyY) + if spd > 0.1 and losD > 1e-6: + frac = abs((dx * (state.selfX - state.enemyX) + + dy * (state.selfY - state.enemyY)) / losD) / spd + vt.radRing[vt.head] = frac + vt.head = (vt.head + 1) mod VelWindow + if vt.count < VelWindow: inc vt.count + if state.tick != vt.prevTick or not vt.hasPrev: + vt.prevX = state.enemyX + vt.prevY = state.enemyY + vt.prevTick = state.tick + vt.hasPrev = true + +proc radialFrac*(vt: VelocityTracker, window: int): float = + ## Mean |radial velocity| / speed over the most recent `window` ticks. + ## 0.0 until at least one displacement has been observed (which biases the + ## very first tick toward the range-holding model; harmless and bounded). + if vt.count == 0: return 0.0 + let n = min(window, vt.count) + var s = 0.0 + for i in 0.. 1e-9: + let ux = lx / d + let uy = ly / d + let hr = degToRad(state.enemyHeading) + vr = cos(hr) * state.enemySpeed * ux + sin(hr) * state.enemySpeed * uy + # Radial-only self-consistent range: dist = d + v_r * (dist / bulletSpeed). + var t = if bulletSpeed > 0.0: d / bulletSpeed else: 0.0 + var radialDist = d + for _ in 0..4: + radialDist = d + vr * t + if bulletSpeed > 0.0: t = radialDist / bulletSpeed + radialDist = max(radialDist, 1.0) + let rf = clamp(vt.radialFrac(window), 0.0, 1.0) + result.dist = radialDist + rf * (f.dist - radialDist) + result.bearing = f.bearing + result.x = state.selfX + cos(f.bearing) * result.dist + result.y = state.selfY + sin(f.bearing) * result.dist