From 9caf1d372861a7156f5a2a3e178e8702e9be3e79 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Mon, 21 Sep 2026 23:46:47 +0200 Subject: [PATCH] movement: range-weighted TFIL variant + tamed heat field (opt-in, default unchanged) New mover `the_floor_is_lava_ring.nim`, a COPY of `the_floor_is_lava.nim` (which stays byte-identical - the user explicitly wants the current TFIL preserved). Selected only via `TR_MOVEMENT=tfil_ring`; the default stays `tfil`. WHY: our measured real hit rate vs DrussGT is strongly range-dependent - 21.6% at 0-100px, 27.1% at 100-200px, 19.3% at 200-300, 10.9% at 300-400, 6.8% at 400-600, 5.4% at 600-800 - but we shoot from ~450px on average. Plain TFIL has no range preference at all. THE ONE CHANGE: the final tile draw is re-weighted toward a target band. rangeW(d) = 1.0 if lo<=d<=hi; exp(-((lo-d)/K)^2) if dhi w_i = rangeW(d_i)^(1/T); chosen ~ Categorical(w) FLAT TOP on purpose: a Gaussian centred on the band midpoint would collapse the band to a point and destroy the within-band hedge. `T` is the only knob; `TR_TFIL_RANGE_TEMP=0` gives plain `rand(candidates.high)` - the exact control arm. Safety stays a HARD constraint: the weighting only reorders the draw among the pool the old code already accepted, so it can never pick a tile the old code rejected (monotone refinement). Small pools (<4) stay uniform. Randomness is deliberately KEPT: a measured A/B showed committing to the "best" tile made real hit rate WORSE (7.02% -> 5.10%), so the distribution is tilted, never removed. HEAT TAMING (ring copy only; env-overridable): TR_TFIL_CORRIDOR_HEAT 20.0 -> 5.0 TR_TFIL_WALL_HOTNESS 30.0 -> 10.0 Rationale, measured: `CorridorHeat=20` is TWICE `PathDangerThreshold=10`, so a single corridor could poison a path by itself; `WallHotness=30` with `WallRadiance=10` put the outer two tile rings over threshold on their own. Per-source shares of total lava: wall 60.6%, corridor 25.4%, pillar 7.4%, everything else <3%. MEASURED EFFECT (primary fixture, 20,026 ticks / 15 rounds, field identity verified max diff 0.000e+00): metric original(20/30) ring(5/10) band-weightable ticks 10.79% 26.45% mean safeTiles/tick 15.19 85.04 ticks with 0 safe (pre-fallback) 58.5% 7.5% safePool >= 4 39.05% 92.47% >=1 safe tile in 100-200px 11.84% 26.64% MEAN CLOSEST-SAFE-TILE DISTANCE 397.78px 284.84px tiles > 10 threshold 0.61 0.15 The 397.78px figure is why the bot stayed far away: the safety filter left nothing safe near the target, and 397px is our WORST range. Control: setting corridor=20 wall=30 reproduces the original baseline exactly. CEILING, honestly: even at corridor 0 / wall 0 only ~40% of ticks are band-weightable, so no constant tweak fully unlocks the range weighting. Ram unification: the ring mover takes a `band` field; ramming becomes just `band=(0,50)`, so there is one movement engine. The `tfil` path is unchanged. Observability: magenta annulus at the band edges, candidates tinted by weight, chosen tile marked; one `[tfil_ring]` log line on change (now including corridorHeat/wallHotness). Guards: test_tfil_ring_weights 24/24 (new, pure, no battle), test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41. UNVERIFIED: the mover's live effect. It has not been run in a battle yet. --- ModularBot_garage/src/ModularBot.nim | 37 +- .../movements/the_floor_is_lava_ring.nim | 891 ++++++++++++++++++ common_libs/tests/test_tfil_ring_weights.nim | 158 ++++ 3 files changed, 1082 insertions(+), 4 deletions(-) create mode 100644 common_libs/movements/the_floor_is_lava_ring.nim create mode 100644 common_libs/tests/test_tfil_ring_weights.nim diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 5f1de10..50540e9 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -28,6 +28,7 @@ import guns/tm_selector import movements/phantom_meteor import movements/rammer import movements/the_floor_is_lava +import movements/the_floor_is_lava_ring import movement_harness/virtual_bodies as mvb import movement_harness/bullet_shadows import targeting/enemy_tracker @@ -87,6 +88,15 @@ let DisabledGuns = s proc gunDisabled(id: int): bool {.inline.} = id in DisabledGuns +## ── Movement rack (runtime switch) ───────────────────────────────────────── +## `TR_MOVEMENT` selects the movement engine, mirroring the gun rack's +## env-driven construction. Both engines are always constructed, so the switch +## needs no rebuild: +## tfil (DEFAULT) — today's behaviour, byte-for-byte, including the +## separate rammer dispatch. +## tfil_ring — the new RANGE-WEIGHTED mover (movements/the_floor_is_lava_ring). +## UNPROVEN: it must never become the default silently. +let MovementName* = getEnv("TR_MOVEMENT", "tfil").strip().toLowerAscii() ## Per-process output paths so concurrent A/B runs do not clobber each other. let GunStatsPath = getEnv("GUN_STATS_PATH", "/tmp/gun_stats.jsonl") let ShotLogPath = getEnv("GUN_SHOTLOG_PATH", "/tmp/shot_log.jsonl") @@ -137,6 +147,7 @@ type knnGun: KNNGun tmSelector: TmSelectorGun mover: TFILModule + ringMover: TFILRingModule rammer: RammerModule isRamming: bool ramStuckTicks: int @@ -241,7 +252,11 @@ proc printConfig(bot: ModularBot, forceAll: bool = false) = let rc = if bot.radarMode != bot.prevRadar or forceAll: CLR_CHANGE else: "" let tc = if bot.currentTargetId != bot.prevTarget or forceAll: CLR_CHANGE else: "" let radarName = if bot.radarMode == 0: "radar_lock" else: "adaptive_melee" - let moveName = if bot.isRamming: "rammer" else: "tfil" + let moveName = + if MovementName == "tfil_ring": + if bot.isRamming: "tfil_ring(ram)" else: "tfil_ring" + elif bot.isRamming: "rammer" + else: "tfil" var line = "[config] " if bot.currentGun >= 0 and bot.currentGun < GunNames.len: line &= gc & "gun=" & GunNames[bot.currentGun] & (if gc != "": rst else: "") & " | " @@ -471,12 +486,14 @@ method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) = bot.resolveShotLog(e.bullet.bulletId, false) bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y) bot.mover.removeBulletNear(e.bullet.x, e.bullet.y) + bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y) method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) = discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot bot.resolveShotLog(e.bullet.bulletId, false) bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y) bot.mover.removeBulletNear(e.bullet.x, e.bullet.y) + bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y) method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) = bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY()) @@ -611,6 +628,7 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) = bot.radar.init() bot.meleeRadar.init() bot.mover.resetRound() + bot.ringMover.resetRound() bot.enemyTracker.resetRound() bot.isRamming = false bot.ramStuckTicks = 0 @@ -767,11 +785,21 @@ method run*(bot: ModularBot) = bot.ramDurationTicks = 0 bot.ramCooldownTicks = 30 - let (spd, tr) = if shouldRam: + # Movement dispatch. `TR_MOVEMENT=tfil_ring` routes BOTH holding and + # ramming through the single ring engine (ram is just band [0, 50]); the ram + # DECISION above is unchanged. The default `tfil` keeps the exact old + # two-engine behaviour (tfil when holding, rammer when ramming). + var spd, tr: float + if MovementName == "tfil_ring": + bot.ringMover.band = + if shouldRam: (lo: 0.0, hi: 50.0) + else: (lo: RangeLo, hi: RangeHi) + (spd, tr) = bot.ringMover.computeMove(ws) + elif shouldRam: bot.mover.clearGraphics() - bot.rammer.computeMove(ws) + (spd, tr) = bot.rammer.computeMove(ws) else: - bot.mover.computeMove(ws) + (spd, tr) = bot.mover.computeMove(ws) setTargetSpeed(spd) setTurnRate(tr) @@ -999,6 +1027,7 @@ when isMainModule: radar: RadarLockModule(), meleeRadar: initAdaptiveMeleeRadar(), mover: TFILModule(debugGraphics: true), + ringMover: TFILRingModule(debugGraphics: true), rammer: initRammer(), moveTracker: mvb.initVirtualBodyTracker(1), currentGun: -1, diff --git a/common_libs/movements/the_floor_is_lava_ring.nim b/common_libs/movements/the_floor_is_lava_ring.nim new file mode 100644 index 0000000..561f66d --- /dev/null +++ b/common_libs/movements/the_floor_is_lava_ring.nim @@ -0,0 +1,891 @@ +## TFIL-RING — The Floor Is Lava, range-weighted. A COPY of +## `the_floor_is_lava.nim` that keeps that mover's semantics and adds ONE +## behavioural lever: the per-tick random tile pick is re-weighted so tiles at +## the bot's preferred TARGET RANGE are drawn more often. +## +## ── Why ───────────────────────────────────────────────────────────────────── +## ModularBot's measured real hit rate vs DrussGT is strongly range-dependent +## (21.6% at 0-100px, 27.1% at 100-200px, then falling off: 19.3% at 200-300, +## 10.9% at 300-400, 6.8% at 400-600, 5.4% at 600-800). The bot shoots from +## ~450px on average, where it hits ~5%. The plain TFIL mover has NO range +## preference, so it drifts; this mover SHIFTS the random distribution toward a +## configured band. 100-200 is DrussGT-only data and MUST be re-measured per +## adversary (set TR_TFIL_RANGE_LO/HI). +## +## ── What is preserved (safety stays a HARD constraint) ────────────────────── +## The reachable hull, the cool-tile pool, the path-heat scoring/sort, the +## `PathDangerThreshold = 10.0` safety filter and the `safeTiles.len < 2` +## promote fallback are IDENTICAL to `the_floor_is_lava.nim`. The weighting is +## applied ONLY to the final draw over `candidates`, which is exactly the pool +## the unweighted `rand(candidates.high)` picked from. It can therefore never +## select a tile the old code would have rejected — a monotone refinement of +## the same safe set. The per-tick randomness is deliberately KEPT (a measured +## A/B showed committing to the "best" tile made hit rate WORSE, 7.02% -> +## 5.10%); only its distribution is tilted, never removed. +## +## ── The one change ────────────────────────────────────────────────────────── +## For each candidate tile, d = distance from the tile CENTRE to the current +## TARGET enemy (ws.enemyX/enemyY). Band weight with a FLAT TOP: +## +## rangeW(d) = 1.0 if lo <= d <= hi +## exp(-((lo - d)/K)^2) if d < lo +## exp(-((d - hi)/K)^2) if d > hi +## +## The flat top matters: a Gaussian centred on the band midpoint would collapse +## the band to a point and destroy the within-band hedge. Then a +## temperature-shaped categorical draw: +## +## w_i = rangeW(d_i) ^ (1/T); chosen ~ Categorical(w) +## +## T -> infinity gives uniform weights (the old distribution). Small safe pools +## (len < 4, i.e. melee) are always drawn uniformly, so shaping cannot drag the +## bot toward the enemy while the tiles are hot. +## +## ── Env knobs (read once at module init, like the gun rack) ───────────────── +## TR_TFIL_RANGE_LO default 100.0 band lower edge (px) +## TR_TFIL_RANGE_HI default 200.0 band upper edge (px) +## TR_TFIL_RANGE_TEMP default 0.4 softmax temperature; <= 0 = OFF path +## TR_TFIL_RANGE_K default 60.0 Gaussian falloff scale (px) +## TR_TFIL_CORRIDOR_HEAT default 5.0 lava per corridor-overlapping tile +## TR_TFIL_WALL_HOTNESS default 10.0 peak wall radiance at a wall tile +## TR_MOVEMENT_LOG=1 log band/range-class changes (not/tick) +## `TR_TFIL_RANGE_TEMP=0` calls plain `rand(candidates.high)` exactly as the +## original mover did, so the same binary can serve as the control arm. +## +## ── Heat-field knobs (the deviation from the original TFIL) ───────────────── +## Measured (offline, DrussGT fixtures): corridors are 21.4% and walls 56.4% of +## the over-threshold set, so they dominate the field. The two constants below +## are retuned so that NO SINGLE soft source can poison a path on its own: +## CorridorHeat = 5.0 — below `PathDangerThreshold` (10.0): one corridor can +## no longer make a path unsafe by itself. +## WallHotness = 10.0 — equals the threshold: the outer two tile rings are no +## longer over-threshold from wall radiance alone. +## Both are env-overridable (TR_TFIL_CORRIDOR_HEAT / TR_TFIL_WALL_HOTNESS) for +## an A/B; the defaults are the values measured to unlock the range weighting. +## WallRadiance, PillarHotness/Radiance, Enemy*/Bullet* and PathDangerThreshold +## are UNCHANGED from the original mover. + +import std/[math, random, os, strformat] +from std/strutils import parseFloat, strip # selective: strutils.fromHex clashes with color.fromHex +import gun_harness/gun_interface +import movement_harness/movement_interface +import robocode_tankroyale_botapi/graphics +import robocode_tankroyale_botapi/color + +const GridSize = 36.0 +const MaxSpeed = 8.0 + +const BulletCoreRadiusMin = 9.0 ## core radius at power 0.1 +const BulletCoreRadiusMax = 54.0 ## core radius at power 3.0 +const BulletAuraExtMin = 36.0 ## aura extension at power 3.0 (slow) +const BulletAuraExtMax = 54.0 ## aura extension at power 0.1 (fast) + +const BulletCore = 10.0 ## lava accumulation per bullet-overlapping tile +const BulletAura = 5.0 ## lava accumulation for aura ring tiles + +const EnemyCoreRadius = 18.0 ## half of 36px body +const EnemyAuraRadius = 54.0 ## 18 + 36 + +const EnemyCore = 40.0 ## lava per tile overlapping enemy body circle +const EnemyAura = 10.0 ## lava per tile in enemy aura ring + +## NOTE: CorridorHeat and WallHotness are NOT const here (unlike the original +## `the_floor_is_lava.nim`): they are env-overridable and read at module init, +## below, in the same style as the range-weighting knobs. +const WallRadiance = 10.0 + +const PillarHotness = 30.0 +const PillarRadiance = 10.0 + +const CommitTicks = 15 ## ticks to commit to a dodge point +const MinCommitTicks = 5 ## must commit for this many ticks before danger replan allowed +const DangerReplanThreshold = 25.0 ## replan on serious threats only (bullet core), not corridors/auras +const CoolestLevels = 2 ## how many distinct lava values count as "cool" +const MaxTrackedBullets = 20 ## hard cap on tracked bullets + +# ── Range-weighting knobs (read once at module init, like the gun rack) ────── +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 + +const + DefaultRangeLo = 100.0 + DefaultRangeHi = 200.0 + DefaultRangeTemp = 0.4 + DefaultRangeK = 60.0 + ## Minimum safe-pool size before range shaping applies. Below this the draw is + ## uniform: a 2-3 tile melee pool must not be pulled toward the enemy while + ## those tiles are hot. + MinRingPool = 4 + +let RangeLo* = getEnvFloat("TR_TFIL_RANGE_LO", DefaultRangeLo) +let RangeHi* = getEnvFloat("TR_TFIL_RANGE_HI", DefaultRangeHi) +let RangeTemp* = getEnvFloat("TR_TFIL_RANGE_TEMP", DefaultRangeTemp) +let RangeK* = getEnvFloat("TR_TFIL_RANGE_K", DefaultRangeK) +let MovementLog* = existsEnv("TR_MOVEMENT_LOG") + +# ── Heat-field knobs (see the rationale in the file header) ────────────────── +let CorridorHeat* = getEnvFloat("TR_TFIL_CORRIDOR_HEAT", 5.0) +let WallHotness* = getEnvFloat("TR_TFIL_WALL_HOTNESS", 10.0) + +proc rangeWeight*(d, lo, hi, k: float): float = + ## Flat-topped range weight. Exactly 1.0 for d inside [lo, hi]; Gaussian + ## falloff with scale `k` outside. The flat top is deliberate: a Gaussian + ## centred ON the band would collapse it to a point, whereas this keeps every + ## in-band tile equally likely and preserves the within-band hedge. `k <= 0` + ## degrades to a hard band edge (1 inside, 0 outside). + if d >= lo and d <= hi: return 1.0 + if k <= 0.0: return 0.0 + let z = (if d < lo: (lo - d) / k else: (d - hi) / k) + exp(-z * z) + +proc bandWeights*(dists: openArray[float], + lo, hi, k, temp: float): seq[float] = + ## Categorical weights over a candidate pool. `temp` is the softmax + ## temperature: `temp -> inf` drives every weight to 1.0 (uniform = the old + ## distribution). `temp <= 0` (the explicit OFF path) and pools smaller than + ## `MinRingPool` also return uniform weights. + result = newSeq[float](dists.len) + let uniform = temp <= 0.0 or dists.len < MinRingPool + for i in 0.. 0). + if weights.len == 0: return 0 + var total = 0.0 + for w in weights: total += w + if not (total > 0.0): + return min(weights.high, int(u * weights.len.float)) + let target = u * total + var acc = 0.0 + for i in 0.. 200 + + TFILRingModule* = object + debugGraphics*: bool + ## Preferred target-range band (px from the target enemy). The BOT sets this + ## each tick from the existing ram decision: (RangeLo, RangeHi) while + ## holding, (0.0, 50.0) while ramming. Ram is therefore just a band, so one + ## movement engine covers both modes. Defaults to (RangeLo, RangeHi). + band*: tuple[lo, hi: float] + cols, rows: int + marginX, marginY: float + arenaWidth, arenaHeight: float + lava: seq[float] # flat row-major, index = row*cols + col + bullets: seq[TrackedBullet] + prevEnergy: seq[tuple[id: int, energy: float]] # enemy id -> last known energy + commitTarget: tuple[x, y: float] ## world coords of committed dodge point + commitTicks: int ## ticks remaining on commitment + commitLava: float ## lava at commit time (for spike detection) + blockedTile: tuple[col, row: int; active: bool] ## excluded from next pick after danger replan + cachedHull: seq[tuple[x, y: float]] + cachedInsideTiles: seq[tuple[col, row: int]] + callCount: int ## computeMove call count; 0 = never called + lastBotX, lastBotY: float ## bot position at last call; used to detect position jumps + lastTileCol, lastTileRow: int ## grid tile at last call; used to detect gradual displacement + # Ring-mode observability (TR_MOVEMENT_LOG): last logged range class and + # band, so the log fires on change rather than every tick. + lastLogClass: int ## -1 = never; 0 near, 1 in-band, 2 far + lastLogBandLo: float + lastLogBandHi: float + loggedOnce: bool + +proc initTFILRing*(): TFILRingModule = + TFILRingModule(debugGraphics: false, band: (lo: RangeLo, hi: RangeHi)) + +proc removeBulletNear*(m: var TFILRingModule, x, y: float) = + ## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead. + var bestIdx = -1 + var bestD2 = GridSize * GridSize # tolerance² + for i, b in m.bullets: + let d2 = (b.x - x)*(b.x - x) + (b.y - y)*(b.y - y) + if d2 < bestD2: + bestD2 = d2 + bestIdx = i + if bestIdx >= 0: + m.bullets.del(bestIdx) + +proc prevEnergyGet(m: TFILRingModule, id: int): float = + for e in m.prevEnergy: + if e.id == id: return e.energy + 100.0 + +proc prevEnergySet(m: var TFILRingModule, id: int, energy: float) = + for i in 0..= 0.09 and drop <= 3.01: + let speed = 20.0 - 3.0 * drop + # Linear prediction: aim at where we will be when the bullet arrives + let dist = sqrt((ws.selfX - ei.x)^2 + (ws.selfY - ei.y)^2) + let travelTime = dist / speed + let predX = ws.selfX + ws.selfSpeed * cos(ws.selfHeading * PI / 180.0) * travelTime + let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * PI / 180.0) * travelTime + let heading = arctan2(predY - ei.y, predX - ei.x) + if m.bullets.len >= MaxTrackedBullets: + m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap + m.bullets.add TrackedBullet( + originX: ei.x, originY: ei.y, + x: ei.x, y: ei.y, + velX: speed * cos(heading), + velY: speed * sin(heading), + power: drop, + alive: true, + age: 0) + +proc advanceBullets(m: var TFILRingModule, selfX, selfY: float) = + ## Advance positions and reap bullets that are: passed us, out of bounds, or too old. + var i = 0 + while i < m.bullets.len: + var b = m.bullets[i] + b.x += b.velX + b.y += b.velY + b.age += 1 + # Death conditions (any triggers removal): + # 1. Passed us (dot < 0, moving away) + # 2. Out of arena bounds + # 3. Too old (>200 ticks) + let dx = selfX - b.x + let dy = selfY - b.y + let dot = b.velX * dx + b.velY * dy + let outOfBounds = b.x < 0.0 or b.x > m.arenaWidth or b.y < 0.0 or b.y > m.arenaHeight + if dot < 0.0 or outOfBounds or b.age > 200: + b.alive = false + m.bullets[i] = b + if b.alive: inc i + else: m.bullets.del(i) + +type CorridorGeom = object + dx, dy: float ## unit heading + px, py: float ## unit perpendicular + tMin: float ## distance to wall + bx, by: float ## bullet origin + +proc corridorGeom(b: TrackedBullet, arenaWidth, arenaHeight: float): CorridorGeom = + let speed = sqrt(b.velX * b.velX + b.velY * b.velY) + if speed < 0.001: return + let dx = b.velX / speed + let dy = b.velY / speed + var tMin = Inf + if dx > 0.0: tMin = min(tMin, (arenaWidth - b.x) / dx) + elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx) + if dy > 0.0: tMin = min(tMin, (arenaHeight - b.y) / dy) + elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy) + CorridorGeom(dx: dx, dy: dy, px: -dy, py: dx, tMin: tMin, bx: b.x, by: b.y) + +proc lavaAt(m: TFILRingModule, col, row: int): float = + m.lava[row * m.cols + col] + +proc tileAt(m: TFILRingModule, wx, wy: float): tuple[col, row: int] = + (col: clamp(int((wx - m.marginX) / GridSize), 0, m.cols - 1), + row: clamp(int((wy - m.marginY) / GridSize), 0, m.rows - 1)) + +proc pointInHull(px, py: float, hull: seq[(float, float)]): bool = + var inside = false + var j = hull.high + for i in 0..hull.high: + if ((hull[i][1] > py) != (hull[j][1] > py)) and + (px < (hull[j][0] - hull[i][0]) * (py - hull[i][1]) / (hull[j][1] - hull[i][1]) + hull[i][0]): + inside = not inside + j = i + inside + +proc computeReachableHull(x0, y0, heading0, speed0, + arenaW, arenaH: float, ticks: int = 50): seq[(float, float)] = + ## Simulate `ticks` ticks at various turn rates / target speeds. + ## Returns convex hull (gift-wrap) of final positions. + const TargetSpeeds = [8.0, 4.0, -4.0, -8.0] + const NumRates = 11 + + var pts: seq[(float, float)] + pts.add (x0, y0) # always reachable: stay + + for tSpeed in TargetSpeeds: + # Max turn rate at target speed (approximate; actual varies per tick but close enough) + let mtr = 10.0 - 0.75 * abs(tSpeed) + for ri in 0.. tSpeed: spd = max(spd - 1.0, tSpeed) + spd = clamp(spd, -MaxSpeed, MaxSpeed) + # Turn (clamp to current max turn rate) + let curMtr = 10.0 - 0.75 * abs(spd) + let tr = clamp(turnRate, -curMtr, curMtr) + h += tr + let hr = h * PI / 180.0 + x = clamp(x + spd * cos(hr), 0.0, arenaW) + y = clamp(y + spd * sin(hr), 0.0, arenaH) + pts.add (x, y) + + # Gift-wrap convex hull (O(n²), ~45 points — fine) + # Find leftmost point as start + var startIdx = 0 + for i in 1.. 0) and (jumpDist > 12.0) + if jumped: + m.commitTicks = 0 # force replan — old target invalid + m.cachedHull = @[] # stale position/heading + m.cachedInsideTiles = @[] + m.bullets = @[] # bullet positions are hopelessly stale + m.blockedTile = (col: 0, row: 0, active: false) + # Re-snapshot prevEnergy so energy changes during ramming aren't misread as fires + m.prevEnergy = @[] + for ei in ws.enemies: + m.prevEnergySet(ei.id, ei.energy) + + # Tile-change replan: catches gradual displacement that position threshold misses + if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0): + let curTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1) + let curTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1) + if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow: + m.commitTicks = 0 + m.cachedHull = @[] + m.cachedInsideTiles = @[] + + # Per-tick: advance existing bullets, detect new fires + m.advanceBullets(ws.selfX, ws.selfY) + m.detectFires(ws) + + # Recompute lava from scratch each tick + for i in 0..= 0.0 and along <= cg.tMin and perp >= -auraR and perp <= auraR: + m.lava[row * m.cols + col] += CorridorHeat + + # Enemy heat auras — core (18px) and aura ring (54px), same pattern as bullets + for ei in ws.enemies: + let ex = ei.x + let ey = ei.y + let colMin = max(0, int(floor((ex - EnemyAuraRadius - m.marginX) / GridSize))) + let colMax = min(m.cols-1, int(floor((ex + EnemyAuraRadius - m.marginX) / GridSize))) + let rowMin = max(0, int(floor((ey - EnemyAuraRadius - m.marginY) / GridSize))) + let rowMax = min(m.rows-1, int(floor((ey + EnemyAuraRadius - m.marginY) / GridSize))) + for row in rowMin..rowMax: + for col in colMin..colMax: + let x0 = m.marginX + col.float * GridSize + let y0 = m.marginY + row.float * GridSize + let nearX = clamp(ex, x0, x0 + GridSize) + let nearY = clamp(ey, y0, y0 + GridSize) + let dx = nearX - ex + let dy = nearY - ey + let d2 = dx*dx + dy*dy + if d2 <= EnemyCoreRadius * EnemyCoreRadius: + m.lava[row * m.cols + col] += EnemyCore + elif d2 <= EnemyAuraRadius * EnemyAuraRadius: + m.lava[row * m.cols + col] += EnemyAura + + # Wall radiance heat — additive with bullet heat + for row in 0.. maxLava: maxLava = v + + # Non-zero tiles: colored border + colored value text (yellow→orange→red) + setFont("Arial", 10.0) + for row in 0.. 0.0: val / maxLava else: 0.0 + let heatColor = fromRgb(255'u8, uint8(255.0 * (1.0 - t)), 0'u8) + let x0 = m.marginX + col.float * GridSize + let y0 = m.marginY + row.float * GridSize + setStrokeColor(heatColor) + setStrokeWidth(1.0) + drawRectangle(x0, y0, GridSize, GridSize) + setFillColor(heatColor) + drawText($int(val), x0 + 12.0, y0 + 22.0) + + # Draw tracked bullet circles + setStrokeColor(RED) + setStrokeWidth(1.0) + setFillColor(RED) + for b in m.bullets: + let (coreR, auraR) = bulletRadii(b.power) + drawCircle(b.x, b.y, coreR) + fillCircle(b.x, b.y, 3.0) + setStrokeColor(fromHex("#FF8800")) # orange aura + setStrokeWidth(1.0) + drawCircle(b.x, b.y, auraR) + setStrokeColor(RED) + setStrokeWidth(1.0) + + # Danger corridor: rotated rectangle projecting each bullet forward to arena wall + setStrokeColor(fromHex("#AAAAAA")) + setStrokeWidth(1.0) + for b in m.bullets: + let (_, auraR) = bulletRadii(b.power) + let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight) + if cg.tMin == 0.0: continue + let wx = cg.bx + cg.dx * cg.tMin + let wy = cg.by + cg.dy * cg.tMin + let corners: seq[(float, float)] = @[ + (cg.bx + cg.px * auraR, cg.by + cg.py * auraR), + (cg.bx - cg.px * auraR, cg.by - cg.py * auraR), + (wx - cg.px * auraR, wy - cg.py * auraR), + (wx + cg.px * auraR, wy + cg.py * auraR), + ] + drawPolygon(corners) + + # Enemy core (cyan) and aura (green) + setStrokeColor(fromHex("#00FFFF")) # cyan core + setStrokeWidth(1.5) + for ei in ws.enemies: + drawCircle(ei.x, ei.y, EnemyCoreRadius) + setStrokeColor(fromHex("#00CC00")) # green aura + setStrokeWidth(1.0) + for ei in ws.enemies: + drawCircle(ei.x, ei.y, EnemyAuraRadius) + + # ── Tile-based Dodge System ─────────────────────────────────────────────────── + let botCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1) + let botRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1) + + # Hull + inside-tiles: only recompute on replan tick (commitTicks == 0) + type TileRef = tuple[col, row: int] + if m.commitTicks == 0: + let hull = computeReachableHull(ws.selfX, ws.selfY, ws.selfHeading, ws.selfSpeed, + m.arenaWidth, m.arenaHeight, 50) + # store as named-field seq to match cachedHull type + m.cachedHull = @[] + for p in hull: m.cachedHull.add (x: p[0], y: p[1]) + m.cachedInsideTiles = @[] + if hull.len >= 3: + for row in 0..= 0 and distinctVals[j] > key: + distinctVals[j + 1] = distinctVals[j] + dec j + distinctVals[j + 1] = key + + # Collect tiles matching the CoolestLevels coolest distinct values + var coolTiles: seq[TileRef] + let numLevels = min(CoolestLevels, distinctVals.len) + for t in insideTiles: + let v = m.lavaAt(t.col, t.row) + for li in 0.. 0.1: + let steps = max(1, int(lineDist / PathSampleStep)) + for si in 0..steps: + let frac = si.float / steps.float + let sx = ws.selfX + ddx * frac + let sy = ws.selfY + ddy * frac + let (sc, sr) = m.tileAt(sx, sy) + pathMaxHeat = max(pathMaxHeat, m.lavaAt(sc, sr)) + scoredTiles.add (col: t.col, row: t.row, pathMaxHeat: pathMaxHeat) + + # Sort by pathMaxHeat ascending (insertion sort — small N) + for i in 1..= 0 and scoredTiles[j].pathMaxHeat > key.pathMaxHeat: + scoredTiles[j + 1] = scoredTiles[j] + dec j + scoredTiles[j + 1] = key + + # Absolute threshold filter: safe = path max lava <= PathDangerThreshold. + # Fallback: if everything is hot, keep the 2 coolest paths anyway. + var safeTiles: seq[ScoredTile] + var blockedTiles: seq[ScoredTile] + for t in scoredTiles: + if t.pathMaxHeat <= PathDangerThreshold: safeTiles.add t + else: blockedTiles.add t + if safeTiles.len < 2: + # Fallback: promote the least-hot blocked tiles until we have 2 + # ponytail: O(n) scan on already-sorted seq — fine for small N + let needed = 2 - safeTiles.len + let promote = min(needed, blockedTiles.len) + for i in 0.. 0: + # Only allow danger replan after MinCommitTicks have elapsed + let ticksElapsed = CommitTicks - m.commitTicks + if ticksElapsed >= MinCommitTicks: + let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y) + let curLava = m.lavaAt(cc, cr) + if curLava > m.commitLava + DangerReplanThreshold: + # Mark committed tile blocked so we don't re-pick it + m.blockedTile = (col: cc, row: cr, active: true) + m.commitTicks = 0 # replan + else: + dec m.commitTicks + else: + dec m.commitTicks + + if m.commitTicks == 0 and safeTiles.len > 0: + # Filter out the blocked tile from candidates + var candidates: seq[ScoredTile] + for t in safeTiles: + if m.blockedTile.active and t.col == m.blockedTile.col and t.row == m.blockedTile.row: + continue + candidates.add t + if candidates.len == 0: candidates = safeTiles # all blocked → ignore block + # Safety is a HARD constraint: the weighting below only re-orders the draw + # AMONG `candidates`, which is exactly the pool the old `rand` picked from. + # It can never select a tile the unweighted code would have rejected + # (monotone refinement of the same safe set). + var chosen: int + if RangeTemp <= 0.0: + # Explicit OFF path: byte-for-byte the old uniform draw (control arm). + chosen = rand(candidates.high) + else: + var dists = newSeq[float](candidates.len) + for i, t in candidates: + let tx = m.marginX + (t.col.float + 0.5) * GridSize + let ty = m.marginY + (t.row.float + 0.5) * GridSize + dists[i] = hypot(tx - ws.enemyX, ty - ws.enemyY) + let weights = bandWeights(dists, m.band.lo, m.band.hi, RangeK, RangeTemp) + chosen = weightedIndex(weights, rand(1.0)) + let ct = candidates[chosen] + m.commitTarget = (x: m.marginX + (ct.col.float + 0.5) * GridSize, + y: m.marginY + (ct.row.float + 0.5) * GridSize) + m.commitTicks = CommitTicks + m.commitLava = m.lavaAt(ct.col, ct.row) + m.blockedTile.active = false # clear after successful pick + + # One concise log line on a range-class or band change (never per-tick). + if MovementLog: + let d = hypot(m.commitTarget.x - ws.enemyX, m.commitTarget.y - ws.enemyY) + let cls = if d < m.band.lo: 0 elif d <= m.band.hi: 1 else: 2 + if (not m.loggedOnce) or cls != m.lastLogClass or + m.band.lo != m.lastLogBandLo or m.band.hi != m.lastLogBandHi: + let clsName = ["near", "band", "far"][cls] + echo fmt"[tfil_ring] mode=ring band=[{m.band.lo.int},{m.band.hi.int}] " & + fmt"chosenDist={d.int} class={clsName} cands={candidates.len} " & + fmt"corridorHeat={CorridorHeat} wallHotness={WallHotness}" + m.loggedOnce = true + m.lastLogClass = cls + m.lastLogBandLo = m.band.lo + m.lastLogBandHi = m.band.hi + + if m.debugGraphics: + # Reachable hull perimeter (darker blue) + if m.cachedHull.len >= 3: + let hullPairs: seq[(float, float)] = block: + var s: seq[(float, float)] + for p in m.cachedHull: s.add (p.x, p.y) + s + setStrokeColor(fromHex("#336699")) + setStrokeWidth(1.0) + drawPolygon(hullPairs) + + # Dim (dark cyan) for path-blocked cool tiles + setStrokeColor(fromHex("#006666")) + setStrokeWidth(1.0) + for t in blockedTiles: + let x0 = m.marginX + t.col.float * GridSize + let y0 = m.marginY + t.row.float * GridSize + drawRectangle(x0, y0, GridSize, GridSize) + + # Bright cyan borders on safe-to-reach tiles + setStrokeColor(fromHex("#00FFFF")) + setStrokeWidth(2.0) + for t in safeTiles: + let x0 = m.marginX + t.col.float * GridSize + let y0 = m.marginY + t.row.float * GridSize + drawRectangle(x0, y0, GridSize, GridSize) + + # Green on chosen tile + let (chosenCol, chosenRow) = m.tileAt(m.commitTarget.x, m.commitTarget.y) + let gx0 = m.marginX + chosenCol.float * GridSize + let gy0 = m.marginY + chosenRow.float * GridSize + setStrokeColor(fromHex("#00FF00")) + setStrokeWidth(2.5) + drawRectangle(gx0, gy0, GridSize, GridSize) + + # Blue on bot tile + let bx0 = m.marginX + botCol.float * GridSize + let by0 = m.marginY + botRow.float * GridSize + setStrokeColor(fromHex("#0088FF")) + setStrokeWidth(2.5) + drawRectangle(bx0, by0, GridSize, GridSize) + + # Committed target line + setStrokeColor(fromHex("#00FF00")) + setStrokeWidth(1.5) + drawLine(ws.selfX, ws.selfY, m.commitTarget.x, m.commitTarget.y) + + # ── Ring-mode observability ───────────────────────────────────────────── + # Target band annulus around the current target enemy: two magenta rings at + # lo and hi radius read as the annulus between them. (lo == 0 in ram mode, + # so the inner ring is skipped there.) + setStrokeColor(fromHex("#FF00FF")) + setStrokeWidth(1.5) + if m.band.lo > 0.0: drawCircle(ws.enemyX, ws.enemyY, m.band.lo) + drawCircle(ws.enemyX, ws.enemyY, m.band.hi) + + # Tint each safe tile by its range weight (red = 0, green = 1) and thicken + # the border with the weight, so the shaped distribution is visible. + setFont("Arial", 9.0) + for t in safeTiles: + let tx = m.marginX + (t.col.float + 0.5) * GridSize + let ty = m.marginY + (t.row.float + 0.5) * GridSize + let d = hypot(tx - ws.enemyX, ty - ws.enemyY) + let w = rangeWeight(d, m.band.lo, m.band.hi, RangeK) + let x0 = m.marginX + t.col.float * GridSize + let y0 = m.marginY + t.row.float * GridSize + setStrokeColor(fromRgb(255'u8, uint8(255.0 * (1.0 - w)), 0'u8)) + setStrokeWidth(1.0 + 3.0 * w) + drawRectangle(x0, y0, GridSize, GridSize) + + # Chosen tile: a filled magenta marker, distinct from the tinted candidates. + setFillColor(fromHex("#FF00FF")) + fillCircle(m.commitTarget.x, m.commitTarget.y, 6.0) + setStrokeColor(fromHex("#FFFFFF")) + setStrokeWidth(1.0) + drawCircle(m.commitTarget.x, m.commitTarget.y, 6.0) + + # Update position snapshot and call counter for next gap detection + m.lastBotX = ws.selfX + m.lastBotY = ws.selfY + m.lastTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1) + m.lastTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1) + m.callCount += 1 + + # ── Steering ───────────────────────────────────────────────────────────────── + let stepDx = m.commitTarget.x - ws.selfX + let stepDy = m.commitTarget.y - ws.selfY + let dist2 = stepDx*stepDx + stepDy*stepDy + if dist2 < 324.0: # already at target (18px radius) + return (speed: 0.0, turnRate: 0.0) + + let targetBearing = arctan2(stepDy, stepDx) * 180.0 / PI + var delta = targetBearing - ws.selfHeading + while delta > 180.0: delta -= 360.0 + while delta < -180.0: delta += 360.0 + + let maxTurnRate = 10.0 - 0.75 * abs(ws.selfSpeed) + var speed: float + var turnRate: float + if abs(delta) <= 90.0: + speed = MaxSpeed + turnRate = clamp(delta, -maxTurnRate, maxTurnRate) + else: + let flipped = if delta > 0.0: delta - 180.0 else: delta + 180.0 + speed = -MaxSpeed + turnRate = clamp(flipped, -maxTurnRate, maxTurnRate) + + result = (speed: speed, turnRate: turnRate) diff --git a/common_libs/tests/test_tfil_ring_weights.nim b/common_libs/tests/test_tfil_ring_weights.nim new file mode 100644 index 0000000..ee923c7 --- /dev/null +++ b/common_libs/tests/test_tfil_ring_weights.nim @@ -0,0 +1,158 @@ +## Pure unit tests for the range-weighted tile draw in +## `movements/the_floor_is_lava_ring.nim` (the new TFIL-RING mover). +## +## NO battle, NO Java, NO server. Run with: +## nim c -r common_libs/tests/test_tfil_ring_weights.nim +## +## These pin the two contracts that make the mover safe to ship: +## 1. the weight SHAPE (flat top exactly 1.0 in band, Gaussian falloff +## outside, uniform as T -> inf, uniform for pools smaller than 4); +## 2. the draw can only ever return an index INSIDE the candidate pool, so it +## can never pick a tile the unweighted mover would have rejected. + +import std/[math, random] +import movements/the_floor_is_lava_ring + +var failures = 0 +proc check(name: string, ok: bool) = + if ok: echo "PASS: ", name + else: echo "FAIL: ", name; inc failures + +const + Lo = 100.0 + Hi = 200.0 + K = 60.0 + +# ── 1. flat top is exactly 1.0 across the band ─────────────────────────────── + +proc testFlatTop() = + var allOne = true + var d = Lo + while d <= Hi: + if rangeWeight(d, Lo, Hi, K) != 1.0: allOne = false + d += 2.5 + check "flat top: weight is exactly 1.0 for every d in [lo, hi]", allOne + check "flat top: lower edge d == lo is 1.0", rangeWeight(Lo, Lo, Hi, K) == 1.0 + check "flat top: upper edge d == hi is 1.0", rangeWeight(Hi, Lo, Hi, K) == 1.0 + # The flat top is what preserves the within-band hedge: all in-band tiles are + # weighted identically, so the random draw still spreads across the band. + check "flat top: two different in-band tiles are equally weighted", + rangeWeight(120.0, Lo, Hi, K) == rangeWeight(180.0, Lo, Hi, K) + +# ── 2. weights fall off outside the band ───────────────────────────────────── + +proc testFalloff() = + let wBelow = rangeWeight(Lo - 10.0, Lo, Hi, K) + let wAbove = rangeWeight(Hi + 10.0, Lo, Hi, K) + check "falloff: below lo is strictly between 0 and 1", wBelow > 0.0 and wBelow < 1.0 + check "falloff: above hi is strictly between 0 and 1", wAbove > 0.0 and wAbove < 1.0 + check "falloff: symmetric (lo-d == d-hi)", + abs(wBelow - wAbove) < 1e-12 + check "falloff: monotone decreasing away from the band", + rangeWeight(Lo - 40.0, Lo, Hi, K) < rangeWeight(Lo - 20.0, Lo, Hi, K) and + rangeWeight(Hi + 40.0, Lo, Hi, K) < rangeWeight(Hi + 20.0, Lo, Hi, K) + check "falloff: exactly 1.0 at the band edge, < 1.0 just outside", + rangeWeight(Lo, Lo, Hi, K) == 1.0 and + rangeWeight(Lo - 0.001, Lo, Hi, K) < 1.0 + +# ── 3. T -> infinity gives uniform weights (the old distribution) ──────────── + +proc testTemperature() = + let dists = @[50.0, 120.0, 180.0, 260.0, 400.0] + let wHuge = bandWeights(dists, Lo, Hi, K, 1e9) + var allOne = true + for w in wHuge: + if abs(w - 1.0) > 1e-6: allOne = false + check "T -> inf: every weight collapses to 1.0 (uniform)", allOne + + let wTiny = bandWeights(dists, Lo, Hi, K, 0.05) + check "T -> 0: in-band tiles dominate the outside tiles", + wTiny[1] > wTiny[0] and wTiny[2] > wTiny[3] and wTiny[2] > wTiny[4] + # In-band weight is exactly 1.0 regardless of T (1 ^ anything == 1). + let wDefault = bandWeights(dists, Lo, Hi, K, 0.4) + check "in-band weight is exactly 1.0 at the default T", wDefault[1] == 1.0 + +# ── 4. small pools are forced uniform (melee safety) ───────────────────────── + +proc testSmallPoolUniform() = + let w2 = bandWeights(@[50.0, 900.0], Lo, Hi, K, 0.4) + check "pool of 2 -> uniform (never shaped)", + w2.len == 2 and w2[0] == 1.0 and w2[1] == 1.0 + let w3 = bandWeights(@[50.0, 150.0, 900.0], Lo, Hi, K, 0.4) + check "pool of 3 -> uniform (never shaped)", + w3[0] == 1.0 and w3[1] == 1.0 and w3[2] == 1.0 + let w4 = bandWeights(@[50.0, 150.0, 900.0, 950.0], Lo, Hi, K, 0.4) + check "pool of 4 IS shaped (shaping starts at MinRingPool)", + w4[1] > w4[0] and w4[1] > w4[2] + +# ── 5. the chosen index is ALWAYS inside the candidate pool ────────────────── + +proc testIndexInsidePool() = + let dists = @[50.0, 120.0, 180.0, 260.0, 400.0, 900.0] + let w = bandWeights(dists, Lo, Hi, K, 0.4) + + var rng = initRand(12345) + var allIn = true + for _ in 0..<20000: + let idx = weightedIndex(w, rand(rng, 1.0)) + if idx < 0 or idx >= dists.len: allIn = false + check "random draws: chosen index is always within candidates", allIn + + # Edge variates and degenerate weight vectors must stay in range too. + check "u == 0 -> index 0", weightedIndex(w, 0.0) == 0 + # A near-1 variate returns the LAST tile only when that tile has weight; use a + # uniform vector so the expectation is unambiguous. + check "u just below 1 -> last index (uniform weights)", + weightedIndex(@[1.0, 1.0, 1.0, 1.0], 0.999999) == 3 + check "empty pool -> 0", weightedIndex(newSeq[float](), 0.5) == 0 + check "all-zero weights -> a valid in-pool index", + weightedIndex(@[0.0, 0.0, 0.0], 0.5) in 0..2 + check "single weight -> index 0", weightedIndex(@[1.0], 0.99) == 0 + + # The `var Rand` wrapper must agree on the range contract. + var rng2 = initRand(1) + let idx2 = weightedIndex(w, rng2) + check "var Rand wrapper returns an in-pool index", + idx2 >= 0 and idx2 < w.len + +# ── 6. the default T actually tilts the draw toward the band ───────────────── + +proc testTilt() = + # Two in-band tiles, three far. Uniform would pick an in-band tile 2/5 = 40% + # of the time; the default T = 0.4 must push that well above 80%. + let dists = @[60.0, 120.0, 180.0, 500.0, 700.0] + let w = bandWeights(dists, Lo, Hi, K, 0.4) + var rng = initRand(999) + var inBand = 0 + const N = 20000 + for _ in 0..80% vs 40%)", + inBand.float / N.float > 0.8 + +# ── 7. OFF path (temp <= 0) is uniform, matching the old control arm ───────── + +proc testOffPathUniform() = + let dists = @[50.0, 120.0, 180.0, 260.0, 400.0] + let wOff = bandWeights(dists, Lo, Hi, K, 0.0) + var allOne = true + for w in wOff: + if w != 1.0: allOne = false + check "temp <= 0 (OFF path) -> uniform weights", allOne + +# ── run ────────────────────────────────────────────────────────────────────── + +testFlatTop() +testFalloff() +testTemperature() +testSmallPoolUniform() +testIndexInsidePool() +testTilt() +testOffPathUniform() + +if failures == 0: + echo "\nAll tfil-ring weight checks passed." +else: + echo "\n", failures, " tfil-ring weight check(s) FAILED." + quit(1)