diff --git a/common_libs/guns/decay_gf.nim b/common_libs/guns/decay_gf.nim index 614e3ac..1e9e0a1 100644 --- a/common_libs/guns/decay_gf.nim +++ b/common_libs/guns/decay_gf.nim @@ -5,6 +5,7 @@ import std/math import gun_harness/gun_interface import gun_harness/virtual_bullets as vb # PowerBins +import guns/lead_forecast const GFBins = 31 @@ -78,11 +79,10 @@ proc predict*(g: var DecayGFGun, state: WorldState, bulletSpeed: float): GunPred if bulletSpeed <= 0.0: return GunPrediction(x: state.enemyX, y: state.enemyY) - let dx = state.enemyX - state.selfX - let dy = state.enemyY - state.selfY - let dist = sqrt(dx*dx + dy*dy) - let bearing = arctan2(dy, dx) - let mea = arcsin(clamp(8.0 / bulletSpeed, -1.0, 1.0)) + 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: # Decay all bins once per tick @@ -94,15 +94,15 @@ proc predict*(g: var DecayGFGun, state: WorldState, bulletSpeed: float): GunPred # call for the selected bin lands on the same tick and reuses the queued wave. let binIdx = binForSpeed(bulletSpeed) if binIdx >= 0 and g.waveStoredTick[binIdx] != state.tick: - g.waves[binIdx].add DWave(fireX: state.selfX, fireY: state.selfY, fireBearing: bearing) + g.waves[binIdx].add DWave(fireX: state.selfX, fireY: state.selfY, fireBearing: f.bearing) g.waveStoredTick[binIdx] = state.tick inc g.wavePushes let peak = g.peakBin() let peakGF = indexToGF(peak) - let gfAngle = bearing + peakGF * mea - let px = state.selfX + cos(gfAngle) * dist - let py = state.selfY + sin(gfAngle) * dist + let gfAngle = f.bearing + peakGF * mea + let px = state.selfX + cos(gfAngle) * f.dist + let py = state.selfY + sin(gfAngle) * f.dist GunPrediction( x: clamp(px, BotRadius, state.arenaWidth - BotRadius), diff --git a/common_libs/guns/guess_factor.nim b/common_libs/guns/guess_factor.nim index 8c8db22..2f31602 100644 --- a/common_libs/guns/guess_factor.nim +++ b/common_libs/guns/guess_factor.nim @@ -5,6 +5,7 @@ import std/[math, strformat] import gun_harness/gun_interface import gun_harness/virtual_bullets as vb # PowerBins: the four power bins the harness spawns +import guns/lead_forecast const GFBins = 31 @@ -86,11 +87,11 @@ proc predict*(g: var GFGun, state: WorldState, bulletSpeed: float): GunPredictio if bulletSpeed <= 0.0: return GunPrediction(x: state.enemyX, y: state.enemyY) - let dx = state.enemyX - state.selfX - let dy = state.enemyY - state.selfY - let dist = sqrt(dx*dx + dy*dy) - let bearing = arctan2(dy, dx) - let mea = arcsin(clamp(8.0 / bulletSpeed, -1.0, 1.0)) + 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, so the aim point sits at the radius the bullet + # actually travels to (see lead_forecast.nim for why this is required). + let f = forecastLinear(state, bulletSpeed) # Queue at most one wave per (tick, power bin). The fire site's extra predict() # call for the selected bin lands on the same tick and reuses the queued wave. @@ -99,17 +100,16 @@ proc predict*(g: var GFGun, state: WorldState, bulletSpeed: float): GunPredictio g.waves[binIdx].add Wave( fireX: state.selfX, fireY: state.selfY, - fireBearing: bearing, + fireBearing: f.bearing, ) g.waveStoredTick[binIdx] = state.tick inc g.wavePushes let peak = g.peakBin() let peakGF = indexToGF(peak) - let gfAngle = bearing + peakGF * mea - # Aim from self at gfAngle, at current dist (angular targeting) - let px = state.selfX + cos(gfAngle) * dist - let py = state.selfY + sin(gfAngle) * dist + let gfAngle = f.bearing + peakGF * mea + let px = state.selfX + cos(gfAngle) * f.dist + let py = state.selfY + sin(gfAngle) * f.dist when DebugGF: echo fmt"[gf-dbg] predict: peakGF={peakGF:.2f} peakBin={peak} mea={radToDeg(mea):.1f}° aimAngle={radToDeg(gfAngle):.1f}° waves={g.waves[binIdx].len}" diff --git a/common_libs/guns/knn_gun.nim b/common_libs/guns/knn_gun.nim index ea9670e..37bac15 100644 --- a/common_libs/guns/knn_gun.nim +++ b/common_libs/guns/knn_gun.nim @@ -6,6 +6,7 @@ import std/[math] import gun_harness/gun_interface import gun_harness/virtual_bullets as vb # PowerBins +import guns/lead_forecast const MaxObs = 2000 # ring-buffer cap @@ -148,9 +149,11 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction let dx = state.enemyX - state.selfX let dy = state.enemyY - state.selfY - let dist = sqrt(dx*dx + dy*dy) let bearing = arctan2(dy, dx) let mea = arcsin(clamp(8.0 / bulletSpd, -1.0, 1.0)) + # Base forecast: the KNN learns the GF residual against this self-consistent + # constant-velocity prediction (see lead_forecast.nim for why this is required). + let f = forecastLinear(state, bulletSpd) # Track direction change — update state once per tick if state.tick != g.cachedTick: @@ -182,15 +185,19 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction # call for the selected bin lands on the same tick and reuses the queued wave. let binIdx = binForSpeed(bulletSpd) if binIdx >= 0 and g.waveStoredTick[binIdx] != state.tick: - g.waves[binIdx].add g.tickWave + # fireBearing is the base forecast bearing, which is per-power (flight time + # differs per bin); override the shared per-tick template here. + var w = g.tickWave + w.fireBearing = f.bearing + g.waves[binIdx].add w g.waveStoredTick[binIdx] = state.tick inc g.wavePushes - # Cold start — no data yet + # Cold start — no data yet: fall back to the self-consistent linear forecast. if g.obs.len == 0: return GunPrediction( - x: clamp(state.selfX + cos(bearing) * dist, BotRadius, state.arenaWidth - BotRadius), - y: clamp(state.selfY + sin(bearing) * dist, BotRadius, state.arenaHeight - BotRadius), + x: clamp(f.x, BotRadius, state.arenaWidth - BotRadius), + y: clamp(f.y, BotRadius, state.arenaHeight - BotRadius), ) # Build query feature vector (use current state) @@ -202,8 +209,8 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction let n = g.obs.len if n < 5: return GunPrediction( - x: clamp(state.selfX + cos(bearing) * dist, BotRadius, state.arenaWidth - BotRadius), - y: clamp(state.selfY + sin(bearing) * dist, BotRadius, state.arenaHeight - BotRadius), + x: clamp(f.x, BotRadius, state.arenaWidth - BotRadius), + y: clamp(f.y, BotRadius, state.arenaHeight - BotRadius), ) let k = max(5, min(int(sqrt(float(n))), KCap)) @@ -235,8 +242,8 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction if filled == 0: return GunPrediction( - x: clamp(state.selfX + cos(bearing) * dist, BotRadius, state.arenaWidth - BotRadius), - y: clamp(state.selfY + sin(bearing) * dist, BotRadius, state.arenaHeight - BotRadius), + x: clamp(f.x, BotRadius, state.arenaWidth - BotRadius), + y: clamp(f.y, BotRadius, state.arenaHeight - BotRadius), ) # Inverse-distance weights, Gaussian (same as DrussGT getBearingGaussian) @@ -268,9 +275,9 @@ proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction bestScore = score bestGF = testGF - let aimAngle = bearing + clamp(bestGF, -1.0, 1.0) * mea - let px = state.selfX + cos(aimAngle) * dist - let py = state.selfY + sin(aimAngle) * dist + let aimAngle = f.bearing + clamp(bestGF, -1.0, 1.0) * mea + let px = state.selfX + cos(aimAngle) * f.dist + let py = state.selfY + sin(aimAngle) * f.dist GunPrediction( x: clamp(px, BotRadius, state.arenaWidth - BotRadius), diff --git a/common_libs/guns/lead_forecast.nim b/common_libs/guns/lead_forecast.nim new file mode 100644 index 0000000..cbaebd8 --- /dev/null +++ b/common_libs/guns/lead_forecast.nim @@ -0,0 +1,54 @@ +## Shared self-consistent constant-velocity forecast 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. +## +## Fix: iterate 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 additionally 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. +## +## 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) diff --git a/common_libs/guns/linear.nim b/common_libs/guns/linear.nim index d8f2750..909d19d 100644 --- a/common_libs/guns/linear.nim +++ b/common_libs/guns/linear.nim @@ -3,20 +3,20 @@ import std/math import gun_harness/gun_interface +import guns/lead_forecast type LinearGun* = object debugGraphics*: bool proc predict*(g: var LinearGun, state: WorldState, bulletSpeed: float): GunPrediction = - let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY) - let ticksToArrive = dist / bulletSpeed - let headingRad = degToRad(state.enemyHeading) - var px = state.enemyX + cos(headingRad) * state.enemySpeed * ticksToArrive - var py = state.enemyY + sin(headingRad) * state.enemySpeed * ticksToArrive - # Clamp to arena bounds - px = clamp(px, 0.0, state.arenaWidth) - py = clamp(py, 0.0, state.arenaHeight) - GunPrediction(x: px, y: py) + # Self-consistent constant-velocity forecast: iterate the flight time until the + # predicted point sits at the distance the virtual bullet actually travels. + # Without the iteration the bullet resolves early/late whenever the target + # moves radially, which is why this gun scored 87% (p3.0 = 63/100) on the + # constant-velocity fixture where a self-consistent forecast scores 100%. + let f = forecastLinear(state, bulletSpeed) + GunPrediction(x: clamp(f.x, 0.0, state.arenaWidth), + y: clamp(f.y, 0.0, state.arenaHeight)) proc onResult*(g: var LinearGun, e: FeedbackEvent) = discard # analytical gun — no learning