diff --git a/common_libs/guns/knn_gun.nim b/common_libs/guns/knn_gun.nim new file mode 100644 index 0000000..6ef2079 --- /dev/null +++ b/common_libs/guns/knn_gun.nim @@ -0,0 +1,259 @@ +## KNN gun: K-nearest-neighbor statistical targeting inspired by DrussGT's DC gun. +## Builds a feature vector per scan, stores resolved GF outcomes, queries KNN at +## predict time and picks the GF with the highest Gaussian-weighted density. +## ponytail: linear scan O(n*k), cap at 2000 obs — KD-tree if perf matters at scale. + +import std/[math] +import gun_harness/gun_interface + +const + MaxObs = 2000 # ring-buffer cap + KCap = 50 # hard ceiling on K + KernelW = 0.3 # Gaussian kernel width multiplier + DensityBins = 60 # scan resolution for peak-GF search + +type + Obs = object + feat: array[7, float] # normalized feature vector + gf: float # observed GF at wave resolution + + KNNWave = object + fireX, fireY: float + fireBearing: float + feat: array[7, float] + + KNNGun* = object + obs: seq[Obs] + obsHead: int # ring-buffer write index + waves: seq[KNNWave] + # per-tick cache + cachedTick: int + cachedWaveStored: bool + # rolling normalization ranges + featMin: array[7, float] + featMax: array[7, float] + # state for feature extraction + lastSpeed: float + lastDirection: float # +1 or -1 + timeSinceDirChange: int + +proc initKNNGun*(): KNNGun = + result.cachedTick = -1 + result.lastDirection = 1.0 + for i in 0..6: + result.featMin[i] = 1e18 + result.featMax[i] = -1e18 + +# ── helpers ────────────────────────────────────────────────────────────────── + +proc normFeat(g: KNNGun, raw: array[7, float]): array[7, float] = + for i in 0..6: + let span = g.featMax[i] - g.featMin[i] + result[i] = if span > 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: + if raw[i] < g.featMin[i]: g.featMin[i] = raw[i] + if raw[i] > g.featMax[i]: g.featMax[i] = raw[i] + +proc buildFeatures(state: WorldState, lastSpeed, lastDir: float, + tsdc: int): array[7, float] = + let dx = state.enemyX - state.selfX + let dy = state.enemyY - state.selfY + let dist = sqrt(dx*dx + dy*dy) + let arenaDiag = sqrt(state.arenaWidth*state.arenaWidth + state.arenaHeight*state.arenaHeight) + + # bearing to enemy (0°=East, standard Tank Royale) + let bearing = arctan2(dy, dx) + # angle of enemy heading relative to bearing + let relHead = state.enemyHeading - bearing + let latVel = state.enemySpeed * sin(relHead) + let advVel = state.enemySpeed * (-cos(relHead)) + let accel = state.enemySpeed - lastSpeed # signed delta + + # wall distances: how far enemy can travel fwd/bwd before hitting wall + # approximate: project enemy heading to nearest wall in each axis + let ex = state.enemyX + let ey = state.enemyY + let eh = state.enemyHeading + # forward distances to each wall in heading direction + let fwdX = if cos(eh) > 0: (state.arenaWidth - ex) / max(abs(cos(eh)), 1e-9) + else: ex / max(abs(cos(eh)), 1e-9) + let fwdY = if sin(eh) > 0: (state.arenaHeight - ey) / max(abs(sin(eh)), 1e-9) + else: ey / max(abs(sin(eh)), 1e-9) + let fwdDist = min(fwdX, fwdY) + # backward = forward in opposite direction + let bwdX = if cos(eh) < 0: (state.arenaWidth - ex) / max(abs(cos(eh)), 1e-9) + else: ex / max(abs(cos(eh)), 1e-9) + let bwdY = if sin(eh) < 0: (state.arenaHeight - ey) / max(abs(sin(eh)), 1e-9) + else: ey / max(abs(sin(eh)), 1e-9) + let bwdDist = min(bwdX, bwdY) + + result[0] = abs(latVel) / 8.0 + result[1] = clamp(advVel / 8.0, -1.0, 1.0) * 0.5 + 0.5 # shift to [0,1] + result[2] = clamp(dist / arenaDiag, 0.0, 1.0) + result[3] = clamp(accel / 2.0, -1.0, 1.0) * 0.5 + 0.5 + 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) + +proc euclidean(a, b: array[7, float]): float {.inline.} = + for i in 0..6: + let d = a[i] - b[i] + result += d * d + result = sqrt(result) + +# ── Gun interface ───────────────────────────────────────────────────────────── + +proc predict*(g: var KNNGun, state: WorldState, bulletSpd: float): GunPrediction = + if bulletSpd <= 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 / bulletSpd, -1.0, 1.0)) + + # Track direction change — update state once per tick + if state.tick != g.cachedTick: + g.cachedTick = state.tick + g.cachedWaveStored = false + + let relHead = state.enemyHeading - bearing + let latVel = state.enemySpeed * sin(relHead) + let newDir = if latVel >= 0: 1.0 else: -1.0 + if newDir != g.lastDirection and abs(latVel) > 0.01: + g.timeSinceDirChange = 0 + g.lastDirection = newDir + else: + inc g.timeSinceDirChange + + # Store wave once per tick + if not g.cachedWaveStored: + let feat = buildFeatures(state, g.lastSpeed, g.lastDirection, g.timeSinceDirChange) + g.updateMinMax(feat) + g.waves.add KNNWave( + fireX: state.selfX, + fireY: state.selfY, + fireBearing: bearing, + feat: feat, + ) + g.lastSpeed = state.enemySpeed + g.cachedWaveStored = true + + # Cold start — no data yet + 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), + ) + + # Build query feature vector (use current state) + let queryRaw = buildFeatures(state, g.lastSpeed, g.lastDirection, g.timeSinceDirChange) + let query = g.normFeat(queryRaw) + + # KNN: linear scan, pick k = max(5, min(sqrt(n), KCap)) + # Fall back to head-on when not enough neighbors to be meaningful + 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), + ) + let k = max(5, min(int(sqrt(float(n))), KCap)) + + # Partial selection: maintain k-best by tracking max distance in result set + # ponytail: O(n*k) insertion; fine for n<=2000, k<=50 + var bestDists = newSeq[float](k) + var bestGFs = newSeq[float](k) + var worstIdx = 0 + var filled = 0 + + for i in 0.. bestDists[worstIdx]: worstIdx = j + elif d < bestDists[worstIdx]: + bestDists[worstIdx] = d + bestGFs[worstIdx] = g.obs[i].gf + worstIdx = 0 + for j in 1.. bestDists[worstIdx]: worstIdx = j + + 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), + ) + + # Inverse-distance weights, Gaussian (same as DrussGT getBearingGaussian) + var sumDist = 1e-30 + for i in 0.. gfMax: gfMax = bestGFs[i] + + # Scan DensityBins points in [gfMin, gfMax] for peak density + let span = max(gfMax - gfMin, 1e-9) + let step = span / float(DensityBins - 1) + var bestGF = gfMin + var bestScore = -1.0 + + for b in 0.. bestScore: + 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 + + GunPrediction( + x: clamp(px, BotRadius, state.arenaWidth - BotRadius), + y: clamp(py, BotRadius, state.arenaHeight - BotRadius), + ) + +proc onResult*(g: var KNNGun, e: FeedbackEvent) = + if g.waves.len == 0: return + + let w = g.waves[0] + g.waves.delete(0) + + let speed = bulletSpeed(e.bulletPower) + let mea = arcsin(clamp(8.0 / speed, -1.0, 1.0)) + let ax = e.actualX - w.fireX + let ay = e.actualY - w.fireY + var delta = arctan2(ay, ax) - w.fireBearing + while delta > PI: delta -= 2.0 * PI + while delta < -PI: delta += 2.0 * PI + + let gf = if mea > 1e-10: clamp(delta / mea, -1.0, 1.0) else: 0.0 + + g.updateMinMax(w.feat) + + if g.obs.len < MaxObs: + g.obs.add Obs(feat: w.feat, gf: gf) + else: + # ring buffer + g.obs[g.obsHead] = Obs(feat: w.feat, gf: gf) + g.obsHead = (g.obsHead + 1) mod MaxObs diff --git a/common_libs/movement_harness/bullet_shadows.nim b/common_libs/movement_harness/bullet_shadows.nim new file mode 100644 index 0000000..1abcbaf --- /dev/null +++ b/common_libs/movement_harness/bullet_shadows.nim @@ -0,0 +1,121 @@ +## Bullet shadow tracker — computes GF regions guaranteed safe because +## our in-flight bullets would intercept an enemy bullet traveling there. +## +## Geometry: 0° = East, X = East, Y = North (Tank Royale). +## Algorithm mirrors DrussGT EnemyWave.logShadow: simulate each of our bullets +## forward tick-by-tick, find where it intersects the expanding enemy wave ring, +## convert intersection points to GF values. + +import std/math +import gun_harness/gun_interface + +const + MaxBullets* = 32 ## slots; ponytail: simple array, 1 bullet/tick max + +type + MyBullet* = object + x*, y*: float + headingRad*: float + speed*: float + alive*: bool + + ShadowTracker* = object + bullets*: array[MaxBullets, MyBullet] + numSlots: int ## high-water mark + + BulletShadow* = object + ## GF range [gfLow, gfHigh] shadowed by one of our bullets for a wave. + gfLow*, gfHigh*: float + +# ── bullet lifecycle ────────────────────────────────────────────────────────── + +proc addBullet*(st: var ShadowTracker, x, y, headingRad, power: float) = + for i in 0..= st.numSlots: st.numSlots = i + 1 + return + +proc removeBullet*(st: var ShadowTracker, idx: int) {.inline.} = + if idx >= 0 and idx < MaxBullets: + st.bullets[idx].alive = false + +proc removeBulletNear*(st: var ShadowTracker, x, y: float) = + ## Kill the live bullet slot closest to (x, y). Used when a hit event fires + ## with the bullet's last known position. + var bestIdx = -1 + var bestDist = 1e18 + for i in 0..= 0: + st.bullets[bestIdx].alive = false + +proc tick*(st: var ShadowTracker) = + ## Advance all live bullets one tick (call once per game tick). + for i in 0.. waveAt and curDist < prevDist: + # Angular half-width of bot at crossing distance + let ringR = (waveAt + waveNext) * 0.5 + let angHalf = arctan(BotRadius / max(ringR, 1.0)) + # Center angle of intersection point (absolute bearing from wave origin) + let centerAngle = arctan2(by - waveFireY, bx - waveFireX) + # Convert to GF + var off = centerAngle - waveBearingRad + while off > PI: off -= 2.0 * PI + while off < -PI: off += 2.0 * PI + let gfCenter = clamp(off / maxEA, -1.0, 1.0) + let gfHalf = angHalf / maxEA + + result.add BulletShadow( + gfLow: clamp(gfCenter - gfHalf, -1.0, 1.0), + gfHigh: clamp(gfCenter + gfHalf, -1.0, 1.0), + ) + break # one shadow per bullet per wave + + if curDist > prevDist: break # bullet diverging — no future crossing + prevDist = curDist + +proc isShadowed*(shadows: openArray[BulletShadow], gf: float): bool {.inline.} = + for s in shadows: + if gf >= s.gfLow and gf <= s.gfHigh: return true + false diff --git a/common_libs/movement_harness/gunheat_tracker.nim b/common_libs/movement_harness/gunheat_tracker.nim new file mode 100644 index 0000000..491b069 --- /dev/null +++ b/common_libs/movement_harness/gunheat_tracker.nim @@ -0,0 +1,85 @@ +## Gunheat wave prediction — mirrors DrussGT's enemyGunHeat / imaginaryGunHeat pattern. +## Tracks two heat values: +## confirmedHeat — reset from actual energy-drop fire detections +## predictedHeat — reset speculatively when enemy COULD fire (heat near 0) +## This lets us create a "predicted" wave 1-2 ticks before energy drop confirms it. +## +## Gun cooling rate in Tank Royale: 0.1/tick. +## Initial gun heat at round start: 3.0 → first possible fire at tick 30. + +import std/math +import gun_harness/gun_interface + +const + GunCoolingRate* = 0.1 + InitialGunHeat* = 3.0 + +type + WaveEventKind* = enum + wePredicted ## enemy CAN fire this tick (heat just reached 0) + weConfirmed ## energy drop confirmed a fire last tick + + WaveEvent* = object + kind*: WaveEventKind + fireX*, fireY*: float ## enemy position at (predicted/confirmed) fire time + bulletPower*: float + tick*: int + + GunheatTracker* = object + confirmedHeat*: float ## heat from last confirmed fire (energy drop) + predictedHeat*: float ## heat from last predicted fire (imaginary wave) + prevEnemyEnergy*: float + canFireTick*: int ## tick when enemy next confirmed able to fire + +proc initGunheatTracker*(): GunheatTracker = + result.confirmedHeat = InitialGunHeat + result.predictedHeat = InitialGunHeat + result.prevEnemyEnergy = 100.0 + result.canFireTick = -1 + +proc resetRound*(gt: var GunheatTracker) = + gt.confirmedHeat = InitialGunHeat + gt.predictedHeat = InitialGunHeat + gt.prevEnemyEnergy = 100.0 + gt.canFireTick = -1 + +proc tick*(gt: var GunheatTracker, state: WorldState): seq[WaveEvent] = + ## Call once per tick with current world state. + ## Returns any wave events created this tick (0, 1, or 2 entries). + + # Cool both heat values + gt.confirmedHeat = max(0.0, gt.confirmedHeat - GunCoolingRate) + gt.predictedHeat = max(gt.confirmedHeat, gt.predictedHeat - GunCoolingRate) + + # Check energy drop for confirmed fire (fired last tick) + let drop = gt.prevEnemyEnergy - state.enemyEnergy + gt.prevEnemyEnergy = state.enemyEnergy + + if drop >= 0.1 and drop <= 3.0 and gt.confirmedHeat == 0.0: + let power = drop + # ponytail: subtract one coolingRate because they fired last tick, not this tick + gt.confirmedHeat = 1.0 + power / 5.0 - GunCoolingRate + gt.predictedHeat = gt.confirmedHeat + gt.canFireTick = state.tick + result.add WaveEvent( + kind: weConfirmed, + fireX: state.enemyX, + fireY: state.enemyY, + bulletPower: power, + tick: state.tick, + ) + + # Predicted wave: confirmedHeat just reached 0 AND no confirmed fire this tick + elif gt.confirmedHeat == 0.0 and gt.predictedHeat == 0.0: + # Enemy CAN fire. Guess power = 2.0 (sensible default, caller can override). + # ponytail: flat prior on power; upgrade to BulletPowerPredictor if needed + let guessedPower = 2.0 + gt.predictedHeat = 1.0 + guessedPower / 5.0 + GunCoolingRate # +cooling: fire next tick + gt.canFireTick = state.tick + result.add WaveEvent( + kind: wePredicted, + fireX: state.enemyX, + fireY: state.enemyY, + bulletPower: guessedPower, + tick: state.tick, + ) diff --git a/common_libs/movement_harness/virtual_bodies.nim b/common_libs/movement_harness/virtual_bodies.nim index cf01f6b..94fef99 100644 --- a/common_libs/movement_harness/virtual_bodies.nim +++ b/common_libs/movement_harness/virtual_bodies.nim @@ -6,6 +6,8 @@ import std/math import gun_harness/gun_interface import movement_harness/movement_interface +import movement_harness/gunheat_tracker +import movement_harness/bullet_shadows const VB_BINS* = 31 ## GF bins from -1 to +1 @@ -34,12 +36,13 @@ type waves: array[MaxWaves, VBWave] waveCount: int waveHead: int ## ring buffer head - prevEnemyEnergy: float + gunheat: GunheatTracker + shadows*: ShadowTracker ## our bullets in flight for shadow computation arenaW, arenaH: float proc initVirtualBodyTracker*(numModules: int): VirtualBodyTracker = - result.numModules = numModules - result.prevEnemyEnergy = 100.0 + result.numModules = numModules + result.gunheat = initGunheatTracker() # Seed bins so we have a uniform prior before any real hits for i in 0..= 0.1 and drop <= 3.0: - let bspeed = 20.0 - 3.0 * drop - let bearingRad = arctan2(state.selfY - state.enemyY, state.selfX - state.enemyX) - let d = hypot(state.selfX - state.enemyX, state.selfY - state.enemyY) + # --- Fire detection via gunheat tracker --- + let waveEvents = t.gunheat.tick(state) + for ev in waveEvents: + let bspeed = 20.0 - 3.0 * ev.bulletPower + let bearingRad = arctan2(state.selfY - ev.fireY, state.selfX - ev.fireX) + let d = hypot(state.selfX - ev.fireX, state.selfY - ev.fireY) let slot = t.waveHead mod MaxWaves t.waves[slot] = VBWave( - fireX: state.enemyX, - fireY: state.enemyY, + fireX: ev.fireX, + fireY: ev.fireY, fireBearingRad: bearingRad, - speed: bspeed, - radius: 0.0, - fireTick: state.tick, - startDist: d, + speed: bspeed, + radius: 0.0, + fireTick: ev.tick, + startDist: d, ) t.waveHead = (t.waveHead + 1) mod MaxWaves if t.waveCount < MaxWaves: inc t.waveCount @@ -119,6 +121,9 @@ proc tick*[N: static int](t: var VirtualBodyTracker, state: WorldState, if i < N: advanceBody(t.bodies[i], cmds[i], state.arenaWidth, state.arenaHeight) + # --- Advance our bullets (for shadow tracking) --- + t.shadows.tick() + # --- Advance waves and score --- # ponytail: O(waves * modules), small counts, fine for wi in 0..= 1e-9: let gf = clamp(off / maxA, -1.0, 1.0) let bin = gfToBin(gf) - t.dangerScore[mi] += t.dangerBins[bin] + # Shadow zones are guaranteed safe — reduce danger by 90% + let shadowMul = if isShadowed(waveShadows, gf): 0.1 else: 1.0 + t.dangerScore[mi] += t.dangerBins[bin] * shadowMul proc bestMovement*(t: VirtualBodyTracker): int = ## Returns index of module with lowest accumulated danger score.