From c0abd4c960c25984825c96d69146b67bd66a344e Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sun, 20 Sep 2026 10:44:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(ModularBot):=20virtual=20body=20movement?= =?UTF-8?q?=20selector=20=E2=80=94=20wave-based=20scoring=20replaces=20EMA?= =?UTF-8?q?=20damage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements VirtualBodyTracker (wave-based hit/miss scoring) instead of EMA damage accumulation. Movement switching now happens every tick, not every 3 rounds. Also refactors radar colors to dark teal (#004444/#0D4D4D) for faint visibility. Co-Authored-By: Claude Opus 4.6 --- ModularBot_garage/src/ModularBot.nim | 54 +++---- .../movement_harness/virtual_bodies.nim | 152 ++++++++++++++++++ 2 files changed, 176 insertions(+), 30 deletions(-) create mode 100644 common_libs/movement_harness/virtual_bodies.nim diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 4e4ec1d..4479808 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -25,6 +25,7 @@ import guns/averaged_lead import guns/decay_gf import movements/phantom_meteor import movements/wave_surfer +import movement_harness/virtual_bodies as mvb const botJsonPath = currentSourcePath().parentDir / "ModularBot.json" const DebugVBullets = false @@ -54,10 +55,7 @@ type mover: PhantomMeteorModule waveMover: WaveSurferModule movementId: int ## 0=phantom, 1=waveSurf - dmgPhantom: float ## rolling damage taken under phantom - dmgWaveSurf: float ## rolling damage taken under waveSurf - roundDmg: float ## damage this round - roundCount: int + moveTracker: VirtualBodyTracker virtualHits: int virtualMiss: int tick: int @@ -215,35 +213,25 @@ method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) = method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) = - bot.roundDmg += e.damage + bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY()) method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) = - # Accumulate damage into active movement bucket (exponential decay, α=0.5) - # ponytail: simple EMA; switch to longer window if thrashing becomes a problem - let decay = 0.5 - if bot.movementId == 0: - bot.dmgPhantom = decay * bot.dmgPhantom + (1.0 - decay) * bot.roundDmg - else: - bot.dmgWaveSurf = decay * bot.dmgWaveSurf + (1.0 - decay) * bot.roundDmg - echo "[move] round dmg=", bot.roundDmg, " phantom_ema=", bot.dmgPhantom, " wave_ema=", bot.dmgWaveSurf + let newId = bot.moveTracker.bestMovement() + if newId != bot.movementId: + echo "[move] switching: ", (if newId == 0: "phantom_meteor" else: "wave_surfer") + bot.movementId = newId method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) = setAdjustGunForBodyTurn(true) setAdjustRadarForBodyTurn(true) setAdjustRadarForGunTurn(true) - setRadarColor("#00CCCC") # RadarLock: cyan/teal - setScanColor("#33FFFF") # RadarLock scan arc: bright teal + setRadarColor("#004444") # RadarLock: dark teal, faint without alpha + setScanColor("#0D4D4D") # RadarLock scan arc: very dark teal, faint without alpha bot.radar.init() bot.hasContact = false bot.tick = 0 bot.currentGun = -1 - bot.roundDmg = 0.0 - inc bot.roundCount - - # Every 3 rounds: switch to lower-damage movement (probe every 3rd round) - # ponytail: simple 3-round probe; add hysteresis if switching thrashes - if bot.roundCount > 2: - bot.movementId = if bot.dmgPhantom <= bot.dmgWaveSurf: 0 else: 1 + bot.moveTracker.resetRound(getX(), getY(), getDirection(), getSpeed()) if bot.movementId == 0: bot.mover.resetRound() @@ -271,10 +259,15 @@ method run*(bot: ModularBot) = go() continue - # Movement harness: dispatch to active mover - let (spd, tr) = - if bot.movementId == 0: bot.mover.computeMove(bot.lastState) - else: bot.waveMover.computeMove(bot.lastState) + # Movement harness: compute all, feed tracker, apply active + let phantomCmd = bot.mover.computeMove(bot.lastState) + let waveSurfCmd = bot.waveMover.computeMove(bot.lastState) + bot.moveTracker.tick(bot.lastState, [phantomCmd, waveSurfCmd]) + let activeId = bot.moveTracker.bestMovement() + if activeId != bot.movementId: + bot.movementId = activeId + echo "[move] switched to: ", (if activeId == 0: "phantom_meteor" else: "wave_surfer") + let (spd, tr) = if bot.movementId == 0: phantomCmd else: waveSurfCmd setTargetSpeed(spd) setTurnRate(tr) @@ -297,9 +290,10 @@ when isMainModule: displace: initDisplacementGun(), avgLead: initAveragedLeadGun(), decayGF: initDecayGFGun(), - radar: RadarLockModule(), - mover: initPhantomMeteor(), - waveMover: initWaveSurfer(), - currentGun: -1, + radar: RadarLockModule(), + mover: initPhantomMeteor(), + waveMover: initWaveSurfer(), + moveTracker: mvb.initVirtualBodyTracker(2), # 0=phantom, 1=waveSurf + currentGun: -1, ) start(bot, botJsonPath) diff --git a/common_libs/movement_harness/virtual_bodies.nim b/common_libs/movement_harness/virtual_bodies.nim new file mode 100644 index 0000000..cf01f6b --- /dev/null +++ b/common_libs/movement_harness/virtual_bodies.nim @@ -0,0 +1,152 @@ +## Virtual body tracker — mirrors virtual_bullets.nim but for movement modules. +## Tracks simulated positions for each movement candidate under enemy waves. +## Scores modules by how often they sit in high-danger GF bins. +## Caller feeds actual hit data via registerHit() to build the danger profile. + +import std/math +import gun_harness/gun_interface +import movement_harness/movement_interface + +const + VB_BINS* = 31 ## GF bins from -1 to +1 + MaxWaves* = 64 ## hard cap; ponytail: ring buffer, resize if needed + MaxModules* = 8 ## max movement modules tracked + +type + VBWave = object + fireX, fireY: float + fireBearingRad: float ## bearing from fireOrigin to REAL bot at fire time (radians) + speed: float + radius: float + fireTick: int + startDist: float + + VirtualBody = object + x, y: float + heading: float ## degrees (Tank Royale / bot API) + speed: float ## current speed px/tick + + VirtualBodyTracker* = object + numModules: int + bodies: array[MaxModules, VirtualBody] + dangerScore: array[MaxModules, float] ## accumulated danger hits + dangerBins: array[VB_BINS, float] ## populated from registerHit + waves: array[MaxWaves, VBWave] + waveCount: int + waveHead: int ## ring buffer head + prevEnemyEnergy: float + arenaW, arenaH: float + +proc initVirtualBodyTracker*(numModules: int): VirtualBodyTracker = + result.numModules = numModules + result.prevEnemyEnergy = 100.0 + # 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) + let slot = t.waveHead mod MaxWaves + t.waves[slot] = VBWave( + fireX: state.enemyX, + fireY: state.enemyY, + fireBearingRad: bearingRad, + speed: bspeed, + radius: 0.0, + fireTick: state.tick, + startDist: d, + ) + t.waveHead = (t.waveHead + 1) mod MaxWaves + if t.waveCount < MaxWaves: inc t.waveCount + + # --- Advance virtual bodies --- + for i in 0.. MaxModules: + {.error: "cmds array exceeds MaxModules".} + if i < N: + advanceBody(t.bodies[i], cmds[i], state.arenaWidth, state.arenaHeight) + + # --- Advance waves and score --- + # ponytail: O(waves * modules), small counts, fine + for wi in 0.. PI: off -= 2.0 * PI + while off < -PI: off += 2.0 * PI + let maxA = mea(w.speed) + if maxA >= 1e-9: + let gf = clamp(off / maxA, -1.0, 1.0) + let bin = gfToBin(gf) + t.dangerScore[mi] += t.dangerBins[bin] + +proc bestMovement*(t: VirtualBodyTracker): int = + ## Returns index of module with lowest accumulated danger score. + result = 0 + var best = t.dangerScore[0] + for i in 1..