## phantom_meteor.nim — PhantomMeteor gravity engine as a MovementModule. ## Wraps gravity.nim (GravityEngine) to satisfy the MovementModule concept. ## Fire detection, phantom bullets, waves, and danger histogram are all internal. ## All angles in radians internally; interface outputs degrees for bot API. import std/[math, tables] import gun_harness/gun_interface import movement_harness/movement_interface import movement_harness/gunheat_tracker # Inline the gravity engine types and logic here to keep the module self-contained. # We re-export nothing from gravity.nim — it's not on the common_libs path. # ponytail: copy instead of import; if gravity.nim moves to common_libs, collapse. # ── Vec2 (local, unexported) ────────────────────────────────────────────────── type Vec2 = object x, y: float64 proc vec2(x, y: float64): Vec2 {.inline.} = Vec2(x: x, y: y) proc `+`(a, b: Vec2): Vec2 {.inline.} = vec2(a.x+b.x, a.y+b.y) proc `-`(a, b: Vec2): Vec2 {.inline.} = vec2(a.x-b.x, a.y-b.y) proc `*`(a: Vec2, s: float64): Vec2 {.inline.} = vec2(a.x*s, a.y*s) proc magnitude(v: Vec2): float64 {.inline.} = sqrt(v.x*v.x + v.y*v.y) proc normalize(v: Vec2): Vec2 = let m = v.magnitude if m < 1e-9: vec2(0.0, 0.0) else: vec2(v.x/m, v.y/m) proc dist(a, b: Vec2): float64 {.inline.} = (a-b).magnitude # ── Gravity engine types ────────────────────────────────────────────────────── const NumBins = 41 type DangerHistogram = object bins: array[NumBins, float64] PhantomBullet = object pos, vel: Vec2 weight: float64 alive: bool ticks: int Wave = object origin: Vec2 heading: float64 speed: float64 radius: float64 startDist: float64 GravityEngine = object histogram: DangerHistogram phantoms: seq[PhantomBullet] waves: seq[Wave] prevEnemyEnergy: Table[int, float64] # keyed by enemy id; id=-1 for legacy single-enemy # ── Gravity engine internals ────────────────────────────────────────────────── const CLR_CYAN = "\e[36m" CLR_RST = "\e[0m" const KBullet = 1500.0 KWall = 4000.0 KEnemy = 300.0 PreferredDist = 400.0 NumPhantoms = 25 MinDist = 20.0 WallMinDist = 40.0 VeryClose = 40.0 proc initEngine(): GravityEngine = var h: DangerHistogram for i in 0..= 0.1 and drop <= 3.0: (true, drop) else: (false, 0.0) proc spawnPhantoms(eng: var GravityEngine, enemyPos, botPos: Vec2, bspeed: float64) = let base = arctan2(botPos.y - enemyPos.y, botPos.x - enemyPos.x) let maxA = mea(bspeed) for i in 0..= 50: eng.phantoms[i].alive = false if eng.phantoms.len > 200: var live: seq[PhantomBullet] for p in eng.phantoms: if p.alive: live.add p eng.phantoms = live proc spawnWave(eng: var GravityEngine, enemyPos, botPos: Vec2, bspeed: float64) = eng.waves.add Wave( origin: enemyPos, heading: arctan2(botPos.y - enemyPos.y, botPos.x - enemyPos.x), speed: bspeed, radius: 0.0, startDist: dist(enemyPos, botPos)) proc tickWaves(eng: var GravityEngine, botPos: Vec2) = var i = 0 while i < eng.waves.len: eng.waves[i].radius += eng.waves[i].speed if eng.waves[i].radius >= eng.waves[i].startDist: let toBot = arctan2(botPos.y - eng.waves[i].origin.y, botPos.x - eng.waves[i].origin.x) var off = toBot - eng.waves[i].heading while off > PI: off -= 2.0*PI while off < -PI: off += 2.0*PI let maxA = mea(eng.waves[i].speed) if maxA >= 1e-9: let gf = (off / maxA).clamp(-1.0, 1.0) eng.histogram.bins[gfToBin(gf)] += 1.0 eng.waves.del(i) else: inc i proc computeForces(eng: GravityEngine, botPos: Vec2, enemies: openArray[Vec2], arenaW, arenaH: float64): Vec2 = var total = vec2(0.0, 0.0) # Priority 1: nearby phantoms var hasClose = false for ph in eng.phantoms: if ph.alive: let d = dist(ph.pos, botPos) if d < 250.0: hasClose = true total = total + normalize(botPos - ph.pos) * (KBullet * (1.0 - d/250.0)) # Priority 2: wall escape (additive — blends with phantom forces) let minW = min(min(botPos.x, arenaW-botPos.x), min(botPos.y, arenaH-botPos.y)) if minW < VeryClose: var ex = 0.0; var ey = 0.0 if botPos.x < VeryClose: ex = 1.0 if arenaW - botPos.x < VeryClose: ex = -1.0 if botPos.y < VeryClose: ey = 1.0 if arenaH - botPos.y < VeryClose: ey = -1.0 let wm = sqrt(ex*ex + ey*ey) if wm > 0.1: total = total + normalize(vec2(ex, ey)) * 500.0 # Priority 3: repulsion from ALL enemies; attraction only toward nearest. # ponytail: linear ramp per enemy, attraction only toward nearest to avoid being pulled into a crowd if not hasClose and enemies.len > 0: const rampWidth = 150.0 # Find nearest enemy for possible attraction var nearestDist = Inf var nearestIdx = 0 for i, ep in enemies: let d = dist(ep, botPos) if d < nearestDist: nearestDist = d nearestIdx = i for i, ep in enemies: let de = dist(ep, botPos) if de <= 100.0: continue let distErr = de - PreferredDist let t = clamp(distErr / rampWidth, -1.0, 1.0) if t < 0.0: # Too close → repel from this enemy total = total + normalize(botPos - ep) * (KEnemy * abs(t)) elif i == nearestIdx: # Too far from nearest only → mild attraction total = total + normalize(ep - botPos) * (KEnemy * t) # Priority 4: weak wall repulsion let dL = max(botPos.x, WallMinDist) let dR = max(arenaW - botPos.x, WallMinDist) let dB = max(botPos.y, WallMinDist) let dT = max(arenaH - botPos.y, WallMinDist) total = total + vec2(KWall*0.5/(dL*dL) - KWall*0.5/(dR*dR), KWall*0.5/(dB*dB) - KWall*0.5/(dT*dT)) total # ── MovementModule wrapper ──────────────────────────────────────────────────── type PhantomMeteorModule* = object engine: GravityEngine gunheat: GunheatTracker proc initPhantomMeteor*(): PhantomMeteorModule = PhantomMeteorModule(engine: initEngine(), gunheat: initGunheatTracker()) proc resetRound*(m: var PhantomMeteorModule) = ## Clear per-round transients (phantoms, waves, energy baseline), keep histogram. m.engine.phantoms = @[] m.engine.waves = @[] m.engine.prevEnemyEnergy = {-1: 100.0}.toTable m.gunheat.resetRound() proc computeMove*(m: var PhantomMeteorModule, ws: WorldState): MoveCommand = let botPos = vec2(ws.selfX, ws.selfY) let enemyPos = vec2(ws.enemyX, ws.enemyY) # Build enemies Vec2 seq (falls back to single target when ws.enemies is empty) var enemyVecs: seq[Vec2] if ws.enemies.len > 0: for ei in ws.enemies: enemyVecs.add vec2(ei.x, ei.y) else: enemyVecs.add enemyPos # ── Ram override ───────────────────────────────────────────────────────────── let ramDist = dist(botPos, enemyPos) let ramFinisher = ramDist < 300.0 and ws.enemyEnergy < 20.0 and ws.selfEnergy > ws.enemyEnergy let ramOpportunity = ramDist < 80.0 and ws.selfEnergy > ws.enemyEnergy + 10.0 let ramDesperation = ws.selfEnergy < 5.0 and ws.enemyEnergy < 5.0 and ramDist < 150.0 if ramFinisher or ramOpportunity or ramDesperation: let tag = if ramDesperation: "desperation" elif ramFinisher: "finisher" else: "opportunity" echo CLR_CYAN & "[ram:" & tag & "] dist=" & $int(ramDist) & " selfE=" & $int(ws.selfEnergy) & " enemyE=" & $int(ws.enemyEnergy) & CLR_RST let headingRad = arctan2(enemyPos.y - botPos.y, enemyPos.x - botPos.x) let desiredDeg = radToDeg(headingRad) var delta = desiredDeg - ws.selfHeading while delta > 180.0: delta -= 360.0 while delta < -180.0: delta += 360.0 return (speed: 8.0, turnRate: delta.clamp(-10.0, 10.0)) # Advance simulation m.engine.tickPhantoms() m.engine.tickWaves(botPos) # Fire detection via gunheat tracker for the primary target (1-2 ticks earlier than energy drop). var spawnedThisTick = false for ev in m.gunheat.tick(ws): let bspeed = 20.0 - 3.0 * ev.bulletPower let evPos = vec2(ev.fireX, ev.fireY) case ev.kind of wePredicted: m.engine.spawnPhantoms(evPos, botPos, bspeed) m.engine.spawnWave(evPos, botPos, bspeed) spawnedThisTick = true of weConfirmed: if not spawnedThisTick: m.engine.spawnPhantoms(evPos, botPos, bspeed) m.engine.spawnWave(evPos, botPos, bspeed) spawnedThisTick = true # Energy-drop fallback for primary target; also sync prevEnergy so non-target check is clean. if not spawnedThisTick: let (fired, power) = m.engine.detectFire(-1, ws.enemyEnergy) if fired: let bspeed = 20.0 - 3.0 * power m.engine.spawnPhantoms(enemyPos, botPos, bspeed) m.engine.spawnWave(enemyPos, botPos, bspeed) else: discard m.engine.detectFire(-1, ws.enemyEnergy) # Energy-drop detection for non-target enemies (raw energy comparison; no gunheat needed) for ei in ws.enemies: if ei.id == -1: continue # skip sentinel let (fired, power) = m.engine.detectFire(ei.id, ei.energy) if fired: let bspeed = 20.0 - 3.0 * power let epos = vec2(ei.x, ei.y) m.engine.spawnPhantoms(epos, botPos, bspeed) m.engine.spawnWave(epos, botPos, bspeed) # Compute force vector let force = m.engine.computeForces(botPos, enemyVecs, ws.arenaWidth, ws.arenaHeight) if force.magnitude < 1e-9: return (speed: 0.0, turnRate: 0.0) # Threat centroid (inverse-distance weighted) for perpendicular direction. # With 1 enemy this equals the single enemy bearing — identical to prior behavior. var cx = 0.0; var cy = 0.0; var wsum = 0.0 for ep in enemyVecs: let d = max(dist(ep, botPos), 1.0) let w = 1.0 / d cx += ep.x * w; cy += ep.y * w; wsum += w let centroid = if wsum > 1e-9: vec2(cx/wsum, cy/wsum) else: enemyPos let centroidBearingRad = arctan2(centroid.y - botPos.y, centroid.x - botPos.x) # Two perpendicular directions to centroid bearing (±90°) let perpCCW = vec2(-sin(centroidBearingRad), cos(centroidBearingRad)) # +90° let perpCW = vec2( sin(centroidBearingRad), -cos(centroidBearingRad)) # -90° # Pick perpendicular direction that aligns with force vector let fn = force.normalize let desiredDeg = if fn.x * perpCCW.x + fn.y * perpCCW.y >= fn.x * perpCW.x + fn.y * perpCW.y: radToDeg(arctan2(perpCCW.y, perpCCW.x)) else: radToDeg(arctan2(perpCW.y, perpCW.x)) # Delta from current heading var delta = desiredDeg - ws.selfHeading while delta > 180.0: delta -= 360.0 while delta < -180.0: delta += 360.0 # Dot-product trick: if |delta| > 90 → reverse, less turning let goForward = abs(delta) <= 90.0 if not goForward: delta = if delta >= 0.0: delta - 180.0 else: delta + 180.0 (speed: if goForward: 8.0 else: -8.0, turnRate: delta.clamp(-10.0, 10.0))