9caf1d3728
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 d<lo; exp(-((d-hi)/K)^2) if d>hi 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.
159 lines
6.7 KiB
Nim
159 lines
6.7 KiB
Nim
## 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..<N:
|
|
let idx = weightedIndex(w, rand(rng, 1.0))
|
|
if idx == 1 or idx == 2: inc inBand
|
|
check "default T=0.4 tilts the draw toward the in-band tiles (>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)
|