## wave_surfer.nim — Wave-surfing movement module. ## ## Detects enemy fire via energy drops, maintains a per-round danger histogram ## of the GuessFactor (GF) each resolved wave arrived at, and steers toward the ## lowest-danger reachable GF on the nearest approaching wave. All angles in ## radians internally; output degrees for the bot API. ## ## ── What was broken and what was fixed (j115) ─────────────────────────────── ## The module was written in an early session, never wired to the bot and never ## tested. A full read found four real defects; all are fixed here: ## ## 1. **The dodge was INVERTED.** The perpendicular direction was built from ## the bot→enemy bearing (`arctan2(enemyY-botY, enemyX-botX)`), while the ## GF is measured in the enemy→bot frame. Rotating the bot→enemy bearing by ## +90 deg points the OPPOSITE way from rotating the enemy→bot bearing by ## +90 deg, so when the safest bin was at a higher GF the bot moved toward ## a LOWER GF, i.e. toward MORE danger. Fixed by building the perpendicular ## from the ORIGIN→bot bearing (the wave's own frame): `perp = toBot ± 90`, ## where `toBot = arctan2(botY-originY, botX-originX)`. `+90` now provably ## increases GF (d(theta)/ds is maximal there). ## 2. **The histogram was never reset.** `resetRound` cleared the waves but ## left `bins` accumulating for the WHOLE battle, so it became a global ## static average. It is now reset to the uniform prior every round (it is ## per-round-resettable, as required). ## 3. **Fire detection tracked only the CURRENT TARGET's energy** via a single ## scalar `prevEnergy` (init 100.0). In melee a target switch silently ## compared two different bots' energies, inventing/hiding waves. It is now ## per-enemy (`seq[(id, energy)]`, exactly like the STRAFE mover), a new ## enemy id is seeded without emitting a wave, and a vanished enemy keeps ## its last value until it reappears. ## 4. **The wall penalty projected the wrong future point.** It placed the ## future position at `bot + (wave-bearing + gf*MEA)` — the direction from ## the wave ORIGIN, not from the bot — so the wall test was meaningless. It ## now projects the point on the wave circle at the candidate GF and takes ## the direction from the BOT to it. ## ## Wave geometry uses the ACTUAL fired power: the one-tick energy drop IS the ## firepower (energy cost == firepower), so `speed = 20 - 3*drop` is exact, not ## an assumption. Wave resolution fires when the wave radius reaches the bot's ## distance from the origin, and the GF is normalised by that power's maximum ## escape angle `arcsin(8/speed)`. ## ## KNOWN LIMITATION (measured, not hidden): a low-power hit ON the enemy (our ## bullet doing <= 3.0 energy of damage) is indistinguishable from a small ## firepower in the energy drop. Those shots can spawn a false wave. High-power ## hits (damage > 3.0) are correctly rejected by the drop window. This is the ## standard energy-drop ambiguity; the single 1v1 A/B cannot separate it. import std/[math, os] from std/strutils import parseFloat, strip, toLowerAscii import gun_harness/gun_interface import movement_harness/movement_interface const WS_BINS = 31 DodgeTicks = 15.0 ## approximate ticks to reach the dodge position MaxBotSpeed = 8.0 ## Tank Royale max forward/backward speed (px/tick) ## ── env knobs (all read once at module init) ──────────────────────────────── const SurfPrefDistEnv* = "TR_SURF_PREF_DIST" SurfDistBandEnv* = "TR_SURF_DIST_BAND" SurfWallMarginEnv* = "TR_SURF_WALL_MARGIN" SurfRadialFracEnv* = "TR_SURF_RADIAL_FRAC" SurfLogEnv* = "TR_SURF_LOG" const DefaultPrefDist = 400.0 ## optimal engagement distance (px) DefaultDistBand = 50.0 ## deadband: pure strafe within ±band of pref dist DefaultWallMargin = 48.0 ## px; > bot radius 18, plus braking room DefaultRadialFrac = 0.35 ## radial blend fraction (0=pure strafe, 1=radial) var SurfPrefDist* = DefaultPrefDist SurfDistBand* = DefaultDistBand SurfWallMargin* = DefaultWallMargin SurfRadialFrac* = DefaultRadialFrac SurfLog* = false proc getEnvFloat(name: string, default: float): float = let s = getEnv(name, "") if s.len == 0: return default try: result = parseFloat(s.strip()) except ValueError: result = default proc loadSurfEnv*() = ## Read the surfer knobs; callable again after `putEnv` so a gate can ## exercise arms in one process. SurfPrefDist = max(1.0, getEnvFloat(SurfPrefDistEnv, DefaultPrefDist)) SurfDistBand = max(0.0, getEnvFloat(SurfDistBandEnv, DefaultDistBand)) SurfWallMargin = max(0.0, getEnvFloat(SurfWallMarginEnv, DefaultWallMargin)) SurfRadialFrac = clamp(getEnvFloat(SurfRadialFracEnv, DefaultRadialFrac), 0.0, 1.0) SurfLog = existsEnv(SurfLogEnv) loadSurfEnv() type WSWave = object originX, originY: float64 bearing: float64 ## direction from enemy to us at fire time (rad) speed: float64 radius: float64 startDist: float64 power: float64 WaveSurferModule* = object bins: array[WS_BINS, float64] waves: seq[WSWave] prevEnergy: seq[tuple[id: int, energy: float]] strafeDir: float64 ## +1.0 or -1.0 debugGraphics*: bool proc resetRound*(m: var WaveSurferModule) = ## Wipe per-round state. The danger histogram IS reset here (defect 2): it is ## a per-round learner, not a battle-long static average. m.waves = @[] m.prevEnergy = @[] m.strafeDir = 1.0 for i in 0.. PI: result -= 2.0*PI while result < -PI: result += 2.0*PI proc nearestWave(m: WaveSurferModule, botX, botY: float64): int = ## Index of the wave closest to reaching us (largest radius relative to the ## current distance). -1 when there is no live wave. result = -1 var bestRatio = -1.0 for i in 0.. bestRatio: bestRatio = ratio result = i proc detectFire(m: var WaveSurferModule, id: int, ex, ey, eenergy, botX, botY: float64) = ## One enemy's energy sample: emit a wave iff its energy dropped by a ## plausible firepower in one tick. `drop` is the firepower exactly. let prev = m.prevEnergyGet(id) let drop = prev - eenergy m.prevEnergySet(id, eenergy) if drop >= 0.1 and drop <= 3.0: let bspeed = 20.0 - 3.0 * drop let bearing = arctan2(botY - ey, botX - ex) let d = hypot(botX - ex, botY - ey) m.waves.add WSWave( originX: ex, originY: ey, bearing: bearing, speed: bspeed, radius: 0.0, startDist: d, power: drop, ) proc computeMove*(m: var WaveSurferModule, ws: WorldState): MoveCommand = let botX = ws.selfX let botY = ws.selfY # ── Fire detection: EVERY alive enemy, per-enemy energy (defect 3) ───────── var seen = 0 for ei in ws.enemies: inc seen m.detectFire(ei.id, ei.x, ei.y, ei.energy, botX, botY) if seen == 0 and (ws.enemyX != 0.0 or ws.enemyY != 0.0): # Fallback to the tracked target when `enemies` is empty. m.detectFire(-1, ws.enemyX, ws.enemyY, ws.enemyEnergy, botX, botY) # ── Advance waves; record the GF of each wave that reaches us ───────────── var i = 0 while i < m.waves.len: m.waves[i].radius += m.waves[i].speed let d = hypot(botX - m.waves[i].originX, botY - m.waves[i].originY) if m.waves[i].radius >= d: let toBot = arctan2(botY - m.waves[i].originY, botX - m.waves[i].originX) let off = wrapPi(toBot - m.waves[i].bearing) let maxA = mea(m.waves[i].speed) if maxA >= 1e-9: let gf = clamp(off / maxA, -1.0, 1.0) m.bins[gfToBin(gf)] += 1.0 m.waves.del(i) else: inc i # ── Dodge logic: pick the safest reachable GF on the nearest wave ───────── var perpAngle = 0.0 var havePerp = false let nearest = m.nearestWave(botX, botY) if nearest >= 0: let w = m.waves[nearest] let dx = botX - w.originX let dy = botY - w.originY let d = max(1.0, hypot(dx, dy)) let toBot = arctan2(dy, dx) # origin -> bot (the GF frame) let off = wrapPi(toBot - w.bearing) let maxA = mea(w.speed) let curGF = if maxA >= 1e-9: clamp(off / maxA, -1.0, 1.0) else: 0.0 let curBin = gfToBin(curGF) # Danger = per-bin average; the current bin is the danger baseline. var total = 0.0 for b in m.bins: total += b let avg = total / float64(WS_BINS) if m.bins[curBin] > avg: # Find the least-dangerous reachable GF, penalising wall-bound moves. let dodgeDist = max(MaxBotSpeed, ws.selfSpeed) * DodgeTicks var bestBin = curBin var bestDanger = Inf for j in 0.. ws.arenaWidth - SurfWallMargin or futureY < SurfWallMargin or futureY > ws.arenaHeight - SurfWallMargin var danger = m.bins[j] if wallHit: danger *= 5.0 danger += 0.01 * abs(gfJ - curGF) # mild preference for less travel if danger < bestDanger: bestDanger = danger bestBin = j let bestGF = binToGF(bestBin) m.strafeDir = if bestGF >= curGF: 1.0 else: -1.0 if SurfLog: echo "[surf] wave d=", d.int, " curGF=", (curGF * 100.0).int, " bestGF=", (bestGF * 100.0).int, " dir=", m.strafeDir.int, " curDanger=", m.bins[curBin].int # Perpendicular in the wave's own frame: +90 deg from origin->bot provably # INCREASES the GF (CCW); -90 decreases it (defect 1). perpAngle = if m.strafeDir >= 0.0: toBot + PI * 0.5 else: toBot - PI * 0.5 havePerp = true else: # No live wave: hold the perpendicular of the current threat bearing. if ws.enemyX != 0.0 or ws.enemyY != 0.0: let toBot = arctan2(botY - ws.enemyY, botX - ws.enemyX) perpAngle = if m.strafeDir >= 0.0: toBot + PI * 0.5 else: toBot - PI * 0.5 havePerp = true else: perpAngle = degToRad(ws.selfHeading) # ── Hard wall escape: never drive into a wall ───────────────────────────── let nearLeft = botX < SurfWallMargin let nearRight = botX > ws.arenaWidth - SurfWallMargin let nearBottom = botY < SurfWallMargin let nearTop = botY > ws.arenaHeight - SurfWallMargin if nearLeft or nearRight or nearBottom or nearTop: let px = cos(perpAngle) let py = sin(perpAngle) if (nearLeft and px < 0.0) or (nearRight and px > 0.0) or (nearBottom and py < 0.0) or (nearTop and py > 0.0): # Flip to the opposite perpendicular (same as the other strafe side). m.strafeDir = -m.strafeDir perpAngle = perpAngle + PI # Blend 50% toward the arena centre. let escapeAngle = arctan2(ws.arenaHeight * 0.5 - botY, ws.arenaWidth * 0.5 - botX) let ex = cos(escapeAngle) + cos(perpAngle) let ey = sin(escapeAngle) + sin(perpAngle) perpAngle = arctan2(ey, ex) # ── Distance control: a radial component outside the deadband ──────────── let enemyDist = hypot(ws.enemyX - botX, ws.enemyY - botY) let distErr = enemyDist - SurfPrefDist let radialFrac = if distErr > SurfDistBand: SurfRadialFrac # too far -> approach elif distErr < -SurfDistBand: -SurfRadialFrac # too close -> retreat else: 0.0 if abs(radialFrac) > 1e-9: let radialAngle = arctan2(ws.enemyY - botY, ws.enemyX - botX) + (if radialFrac < 0.0: PI else: 0.0) let rx = cos(perpAngle) * (1.0 - abs(radialFrac)) + cos(radialAngle) * abs(radialFrac) let ry = sin(perpAngle) * (1.0 - abs(radialFrac)) + sin(radialAngle) * abs(radialFrac) perpAngle = arctan2(ry, rx) # ── Turn the body toward the desired heading; move at full speed ───────── let desiredDeg = radToDeg(perpAngle) var delta = desiredDeg - ws.selfHeading while delta > 180.0: delta -= 360.0 while delta < -180.0: delta += 360.0 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))