## Shared self-consistent lead forecasts used by the Linear gun and the ## GuessFactor family (guess_factor, decay_gf, knn_gun). ## ## Why this exists: the virtual-bullet metric resolves a bullet when its travel ## distance reaches the distance to its aim point, then scores that single point ## against the enemy's position on that tick. A gun that places its aim point at ## the FIRE-time distance therefore stops at the wrong radius whenever the target ## has moved radially over the flight, and misses even when its angle is ## perfect. Measured symptoms: ## * the whole GF family scored ~0% on the circular/wall-bounce/random-walk ## fixtures while the model-fitting guns scored 40-100%; ## * the Linear gun scored 87% (p3.0 = 63/100) on the constant-velocity ## fixture where a self-consistent forecast scores 100%. ## The GF angle range was never clamped (0/837 shots), so the earlier ## "MEA too narrow" hypothesis was wrong. ## ## The self-consistent forecast iterates the flight time until the predicted ## point sits at the distance the bullet actually travels (the same fixed point ## circular.nim uses). The GF family measures its histogram as the residual of ## the actual bearing against this forecast's bearing, so it learns the deviation ## from a base model instead of having to encode the whole lead angle. ## ## RANGE MODEL — why the base is not a plain constant-velocity lead. A ## constant-velocity extrapolation predicts a range of ## `sqrt((d + v_r*t)^2 + (v_t*t)^2)`: it lets the range grow geometrically from ## TANGENTIAL motion. That is correct for a ballistic target but wrong for a ## range-controlling surfer, which curves its tangential motion back to hold the ## range. On the real DrussGT captures (perpendicular movement, 42-49% of ticks ## with negative signed speed) the full geometric range over-shoots by tens of ## pixels, so the GF family resolved its bullets late and at the wrong radius: ## GuessFactor fell 108 -> 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). import std/math import gun_harness/gun_interface type BaseForecast* = object x*, y*: float ## absolute predicted enemy position dist*: float ## distance from shooter to the predicted position bearing*: float ## bearing from shooter to the predicted position (rad) proc forecastLinear*(state: WorldState, bulletSpeed: float): BaseForecast = ## Constant-velocity forecast with self-consistent flight time. The enemy is ## assumed to keep its current heading/speed; the flight time is the fixed ## point t = |predictedPos(t) - self| / bulletSpeed (5 iterations, matching ## circular.nim). Enemy speed (< 8 px/tick) is always below bulletSpeed ## (>= 11), so the iteration contracts. let d0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY) let hr = degToRad(state.enemyHeading) let v = state.enemySpeed var t = if bulletSpeed > 0.0: d0 / bulletSpeed else: 0.0 var ex = state.enemyX var ey = state.enemyY for _ in 0..4: ex = state.enemyX + cos(hr) * v * t ey = state.enemyY + sin(hr) * v * t if bulletSpeed > 0.0: t = hypot(ex - state.selfX, ey - state.selfY) / bulletSpeed result.x = ex 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