Files

354 lines
15 KiB
Nim

## 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
import movement_harness/fire_tracker
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
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
## Off = the shipped `prev - energy` detector byte-for-byte.
SurfFireFix* = true
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 envOn(name: string, default = false): bool =
let s = getEnv(name, "").strip().toLowerAscii()
if s.len == 0: return default
s notin ["0", "false", "no", "off"]
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)
SurfFireFix = envOn("TR_FIRE_FIX", true)
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]
fire: FireTracker ## shared energy-drop detector (j134)
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.fire.reset()
m.strafeDir = 1.0
for i in 0..<WS_BINS: m.bins[i] = 1.0
proc initWaveSurfer*(): WaveSurferModule =
var m = WaveSurferModule(debugGraphics: false, fire: initFireTracker())
m.resetRound()
m
proc clearGraphics*(m: var WaveSurferModule) {.inline.} =
## No-op: the SVG buffer is a framework global cleared after every go().
## Exists so callers can signal "the surfer is inactive".
discard
proc removeBulletNear*(m: var WaveSurferModule, x, y: float) {.inline.} =
## No-op: the surfer tracks WAVES (energy drops), not bullet bodies.
discard
proc prevEnergyGet(m: WaveSurferModule, id: int): float = m.fire.prevEnergyGet(id)
proc prevEnergySet(m: var WaveSurferModule, id: int, energy: float) =
m.fire.prevEnergySet(id, energy)
proc noteEnemyBulletHit*(m: var WaveSurferModule, power: float) =
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
if SurfFireFix:
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var WaveSurferModule, damage: float) =
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
if SurfFireFix:
m.fire.noteDamageDealt(damage)
proc gfToBin(gf: float64): int {.inline.} =
clamp(int(round((gf.clamp(-1.0, 1.0) + 1.0) * 0.5 * float64(WS_BINS - 1))),
0, WS_BINS - 1)
proc binToGF(idx: int): float64 {.inline.} =
float64(idx) / float64(WS_BINS - 1) * 2.0 - 1.0
proc mea(speed: float64): float64 {.inline.} =
## Maximum escape angle for a bot moving at max speed relative to a bullet of
## the given speed.
if speed <= 1e-9: return 0.0
arcsin(min(MaxBotSpeed / speed, 1.0))
proc wrapPi(x: float64): float64 {.inline.} =
result = x
while result > 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..<m.waves.len:
let d = max(1e-6, hypot(botX - m.waves[i].originX, botY - m.waves[i].originY))
let ratio = m.waves[i].radius / d
if ratio > 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 per plausible firepower in the
## (corrected) one-tick drop. `drop` IS the firepower. Window 0.1..3.0,
## SURF's shipped window; the shared tracker corrects the delta and splits an
## over-cap drop across several waves.
for power in m.fire.detect(id, eenergy, 0.1, 3.0, SurfFireFix):
let bspeed = 20.0 - 3.0 * power
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: power,
)
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)
m.fire.endScan()
# ── 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_BINS:
let gfJ = binToGF(j)
let angleJ = w.bearing + gfJ * maxA
# Point on the wave circle at this GF, then the DIRECTION FROM THE BOT.
let px = w.originX + cos(angleJ) * d
let py = w.originY + sin(angleJ) * d
var ux = px - botX
var uy = py - botY
let ul = hypot(ux, uy)
if ul < 1e-6:
ux = 0.0; uy = 0.0
else:
ux /= ul; uy /= ul
let futureX = botX + ux * dodgeDist
let futureY = botY + uy * dodgeDist
let wallHit = futureX < SurfWallMargin or futureX > 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))