From b68707c86799354247e896bf988c46c346c6a2b9 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Wed, 23 Sep 2026 00:12:04 +0200 Subject: [PATCH] Energy economy: the cliff becomes a SLOPE, plus a finishing cap. 11% less energy. The user's request: "when our bot is low OR enemy is low, it is useless to use high power instead low fast bullets have more chances to finish the enemy. Let's do a math slope: starting from some health down, the power goes down with it." 1. ENERGY SLOPE (`TR_POWER_ENERGY_*`), replacing the old hard step at 50 energy: cap = ENERGY_MAX at/above ENERGY_HI, ENERGY_MIN at/below ENERGY_LO, LINEAR in power between, clamped. Defaults HI=80 LO=20 MIN=0.5 MAX=3.0, so no cap >=80, 0.5 at <=20, and e.g. E=65 -> 2.375, E=50 -> 1.75, E=35 -> 1.125. Rationale: bullet speed is 20-3p, so lower power = FASTER bullet (less lead error, higher hit chance), fires more often (10+2p) and drains slower (p/shot). E[dE] = p(3P-1) => break-even hit probability is 1/3 INDEPENDENT of power, and our measured rates are 5-27%, far below it. 2. FINISHING CAP (`TR_POWER_FINISH_KILL`, default ON): cap power at the SMALLEST bullet that still removes the enemy's remaining energy - `E<=4 -> p=E/4` (min 0.1), `4 p=(E+2)/6`, `E>16 -> no cap`. Rationale, and it makes the user's instinct stronger than a heuristic: server 1.3.1 caps the damage SCORE at the energy ACTUALLY REMOVED, so overkill is WASTED damage AND ~6x the energy for ZERO extra score. Damage is 4p (p<=1) / 6p-2 (p>1). Both are min-composed with the existing far/below-average caps, may only LOWER power (exhaustively tested), and are exempt while ramming. `TR_POWER_POLICY=0` still returns the uncapped control exactly. MEASURED ENERGY SAVING (offline replay of the DrussGT fixtures, 28,797 ticks): arm shots energy meanP E/1k ticks vs cliff control(uncapped) 1913 4646 2.43 161.4 -90.2% cliff (today) 2363 2443 1.03 84.8 0.0% slope 2404 2178 0.91 75.6 ** 10.9% LESS ** slope+finish 2404 2167 0.90 75.3 ** 11.3% LESS ** So the slope spends ~11% less energy than the cliff AND fires slightly MORE shots (2404 vs 2363) - both directions at once. HONEST NOTE on the finishing rule's reach here: ticks where the enemy is low (0 < E <= 16) are only 2252/28797 = 7.8% of these fixtures, so finishing adds just ~11 energy of saving against DrussGT. It matters in CLOSER fights, not this one. Verification: test_power_policy 58 (was 26) in BOTH the default and TR_POWER_POLICY=0 control arms - slope at E=100/80/65/50/35/20/5, powerToKill across E=0.1..100, the inverse-cover property for E<=16, monotonicity, ram exemption, and an exhaustive sweep proving power <= preference. Guards: test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41, test_tfil_ring_weights 24, test_ram_decision 40, test_rack_membership 48, test_selector_tiebreak 19, test_tm_pattern_registration 20, test_vbullet_admit_gate 12. acceptance 12/12 PASS. ModularBot compiles release. Adds `common_libs/tests/measure_power_policy.nim` (the energy/histogram tool) and updates docs/env_reference.md for the new `energySlope|finishKill` log reasons. NOT MEASURED: the battle/hit-rate effect. The offline figures use the fixture shooter's energy as a proxy, open-loop; the RELATIVE saving is the meaningful part. --- .gitignore | 1 + ModularBot_garage/src/ModularBot.nim | 6 +- common_libs/gun_harness/selector.nim | 26 +- common_libs/gun_harness/virtual_bullets.nim | 158 ++++++++--- common_libs/tests/measure_power_policy.nim | 210 +++++++++++++++ common_libs/tests/test_power_policy.nim | 274 +++++++++++++++++--- docs/env_reference.md | 2 +- 7 files changed, 582 insertions(+), 95 deletions(-) create mode 100644 common_libs/tests/measure_power_policy.nim diff --git a/.gitignore b/.gitignore index 08654b2..71621e0 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ common_libs/tests/tm_measure common_libs/tests/run_range common_libs/tests/gen_synthetic_fixtures common_libs/tests/acceptance_offline_vs_online +common_libs/tests/measure_power_policy common_libs/tests/test_tsetlin_gun common_libs/tests/test_tsetlin_live common_libs/tests/test_tm_pattern_learning diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 9348acb..1e1fc0a 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -1129,7 +1129,8 @@ method run*(bot: ModularBot) = # energy; `shouldRam` (the movement code's decision) exempts it. let (selectedGun, _, power, pdec) = selectShotPolicy( bot.tracker, tid, bot.tick, - dist = ramDist, selfEnergy = ws.selfEnergy, ramming = shouldRam, + dist = ramDist, selfEnergy = ws.selfEnergy, + enemyEnergy = ws.enemyEnergy, ramming = shouldRam, rackMode = bot.rackMode, membership = ActiveRackMembership) if PowerLog: let pkey = fmt"{power:.1f}|{pdec.cap:.1f}|{pdec.reason}" @@ -1137,7 +1138,8 @@ method run*(bot: ModularBot) = bot.lastPowerLogKey = pkey echo fmt"[power] p={power:.1f} cap={pdec.cap:.1f} " & fmt"reason={powerReasonName(pdec.reason)} " & - fmt"dist={ramDist:.0f} selfE={ws.selfEnergy:.0f} gun={GunNames[selectedGun]}" + fmt"dist={ramDist:.0f} selfE={ws.selfEnergy:.0f} " & + fmt"enemyE={ws.enemyEnergy:.0f} gun={GunNames[selectedGun]}" bot.gunSelectionCount[selectedGun] += 1 if selectedGun != bot.currentGun: bot.currentGun = selectedGun diff --git a/common_libs/gun_harness/selector.nim b/common_libs/gun_harness/selector.nim index a9dd2b3..3088f06 100644 --- a/common_libs/gun_harness/selector.nim +++ b/common_libs/gun_harness/selector.nim @@ -193,6 +193,7 @@ proc shouldFire*(currentGunDir, targetAngle, gunHeat, distPx: float): bool = proc selectShotPolicy*(t: var VirtualTracker, targetId = -1, tick = 0, dist = 0.0, selfEnergy = 100.0, + enemyEnergy = 100.0, ramming = false, rackMode: RackMode = rm1v1, membership: openArray[RackMembership] = [] @@ -200,10 +201,11 @@ proc selectShotPolicy*(t: var VirtualTracker, targetId = -1, tick = 0, ## `selectShot` plus the energy-aware power-policy decision, so a caller can ## log the cap and its reason (see `applyPowerPolicy` in virtual_bullets). ## - ## `dist` is the current distance (px) to the target and `selfEnergy` our own - ## energy; `ramming` exempts the caps (the movement code's `shouldRam` is the - ## single source of truth). The policy is applied identically wherever this is - ## called, so live and any offline caller cannot diverge. + ## `dist` is the current distance (px) to the target, `selfEnergy` our own + ## energy and `enemyEnergy` the target's remaining energy (drives the + ## finishing cap); `ramming` exempts the caps (the movement code's `shouldRam` + ## is the single source of truth). The policy is applied identically wherever + ## this is called, so live and any offline caller cannot diverge. ## ## `rackMode` is the server-truth enemy-count mode (`rackMode`); `membership` ## is the process-wide `TR_RACK_*` table, passed by the live bot. An empty @@ -221,21 +223,25 @@ proc selectShotPolicy*(t: var VirtualTracker, targetId = -1, tick = 0, let pEst = if fit[gunId].bins[prefBin].count == 0: pRef else: fit[gunId].bins[prefBin].hitRate() - let dec = applyPowerPolicy(preferred, dist, selfEnergy, pEst, pRef, ramming) + let dec = applyPowerPolicy(preferred, dist, selfEnergy, pEst, pRef, ramming, + enemyEnergy = enemyEnergy) result = (gunId, binIndexForPower(dec.power), dec.power, dec) proc selectShot*(t: var VirtualTracker, targetId = -1, tick = 0, dist = 0.0, selfEnergy = 100.0, + enemyEnergy = 100.0, ramming = false, rackMode: RackMode = rm1v1, membership: openArray[RackMembership] = []): (GunId, int, float) = ## Returns (gunId, powerBinIdx, power) — the shot to take this tick. ## Pass targetId to pick the best gun for that specific enemy. `tick` drives - ## the minimum-dwell hysteresis (see `selectGun`). `dist`/`selfEnergy`/`ramming` - ## feed the energy-aware power cap (`TR_POWER_POLICY`); defaults keep every - ## existing caller compiling, and `TR_POWER_POLICY=0` reproduces the uncapped - ## `bestPower` preference. Use `selectShotPolicy` when the cap/reason is needed. + ## the minimum-dwell hysteresis (see `selectGun`). `dist`/`selfEnergy`/ + ## `enemyEnergy`/`ramming` feed the energy-aware power cap (`TR_POWER_POLICY`); + ## defaults keep every existing caller compiling, and `TR_POWER_POLICY=0` + ## reproduces the uncapped `bestPower` preference. Use `selectShotPolicy` when + ## the cap/reason is needed. let (gunId, binIdx, power, _) = - t.selectShotPolicy(targetId, tick, dist, selfEnergy, ramming, + t.selectShotPolicy(targetId, tick, dist, selfEnergy, + enemyEnergy = enemyEnergy, ramming = ramming, rackMode = rackMode, membership = membership) result = (gunId, binIdx, power) diff --git a/common_libs/gun_harness/virtual_bullets.nim b/common_libs/gun_harness/virtual_bullets.nim index 5af4c7c..cf8fe46 100644 --- a/common_libs/gun_harness/virtual_bullets.nim +++ b/common_libs/gun_harness/virtual_bullets.nim @@ -798,40 +798,66 @@ proc bestPower*(t: VirtualTracker, gunId: GunId, targetId: int = -1): (int, floa # # `bestPower` answers "which power bin does this gun's own virtual data prefer?" # and is deliberately left untouched. The policy below CAPS that preference using -# only cheap, always-available state — range, our own energy, and the gun's own -# rate — so a long-range or low-energy shot trades single-hit damage for a -# faster bullet (speed = 20-3p, so LOW power is FASTER and needs less lead) and a -# shorter fire interval (10+2p, so LOW power = MORE shots). It never RAISES -# power, so the shipped behaviour is exactly the `cap = 3.0` case, which is also -# the control arm (`TR_POWER_POLICY=0`). +# only cheap, always-available state — range, our own energy, the ENEMY's energy, +# and the gun's own rate. It never RAISES power, so the shipped behaviour is +# exactly the `cap = 3.0` case, which is also the control arm +# (`TR_POWER_POLICY=0`). Every rule is an ADDITIONAL cap: the applied cap is the +# minimum of all of them. +# +# Two rules are the energy-economy improvement: +# +# 1. ENERGY SLOPE (replaces the old `TR_POWER_LOW_ENERGY` CLIFF at 50): cap our +# power as a LINEAR function of OUR energy — `TR_POWER_ENERGY_MAX` at/above +# `TR_POWER_ENERGY_HI`, `TR_POWER_ENERGY_MIN` at/below `TR_POWER_ENERGY_LO`, +# linear in between. A lower power is a FASTER bullet (speed = 20-3p, so less +# lead error -> higher hit chance), fires more often (interval 10+2p) and +# drains energy more slowly (cost p/shot). Energy math: E[dE] = p(3P-1), so +# the break-even hit probability is 1/3 INDEPENDENT of power; our measured +# hit rates are 5-27%, far below 1/3, so every point of power above the +# minimum costs more than it returns. Below HI the old code was a hard step. +# +# 2. FINISHING SLOPE (`TR_POWER_FINISH_KILL`): when the ENEMY is low, cap power +# at the SMALLEST bullet that still removes its remaining energy. Server +# 1.3.1 caps the `bulletDamage` score at the energy ACTUALLY REMOVED, so +# overkill is WASTED damage AND ~6x the energy for ZERO extra score. Damage +# is `4p` for p<=1 and `6p-2` for p>1, so the minimum power that covers `E` +# is `E/4` (clamped to >=0.1) for E<=4, `(E+2)/6` for 41). +# 5.4% at 600-800. Damage is 4p (p<=1) / 6p-2 (p>1). const PowerPolicyEnvVar* = "TR_POWER_POLICY" ## 0 = control arm (uncapped) PowerFarDistEnvVar* = "TR_POWER_FAR_DIST" ## px; beyond this = bad-chances zone - PowerLowEnergyEnvVar* = "TR_POWER_LOW_ENERGY" ## self energy below this = conserve - PowerFarCapEnvVar* = "TR_POWER_FAR_CAP" ## cap for far / low-energy + PowerFarCapEnvVar* = "TR_POWER_FAR_CAP" ## cap for far PowerMidCapEnvVar* = "TR_POWER_MID_CAP" ## cap when close+healthy but not above avg PowerRefEnvVar* = "TR_POWER_REF" ## 0 = gun's own mean; >0 = fixed P_ref + PowerEnergyHiEnvVar* = "TR_POWER_ENERGY_HI" ## self energy at/above which = no slope cap + PowerEnergyLoEnvVar* = "TR_POWER_ENERGY_LO" ## self energy at/below which = ENERGY_MIN + PowerEnergyMinEnvVar* = "TR_POWER_ENERGY_MIN" ## the cap at/below ENERGY_LO + PowerEnergyMaxEnvVar* = "TR_POWER_ENERGY_MAX" ## the cap at/above ENERGY_HI (3.0 = uncapped) + PowerFinishKillEnvVar* = "TR_POWER_FINISH_KILL" ## 1 = cap to the smallest killing bullet let PowerPolicyEnabled* = envBool(PowerPolicyEnvVar, true) let PowerFarDist* = envFloat(PowerFarDistEnvVar, 200.0) -let PowerLowEnergy* = envFloat(PowerLowEnergyEnvVar, 50.0) let PowerFarCap* = envFloat(PowerFarCapEnvVar, 1.0) let PowerMidCap* = envFloat(PowerMidCapEnvVar, 2.0) let PowerRefFixed* = envFloat(PowerRefEnvVar, 0.0) +let PowerEnergyHi* = envFloat(PowerEnergyHiEnvVar, 80.0) +let PowerEnergyLo* = envFloat(PowerEnergyLoEnvVar, 20.0) +let PowerEnergyMin* = envFloat(PowerEnergyMinEnvVar, 0.5) +let PowerEnergyMax* = envFloat(PowerEnergyMaxEnvVar, 3.0) +let PowerFinishKill* = envBool(PowerFinishKillEnvVar, true) type PowerReason* = enum - prFull ## above-average chances, close, healthy -> full power - prFar ## beyond TR_POWER_FAR_DIST -> bad-chances zone - prLowEnergy ## self energy below TR_POWER_LOW_ENERGY -> conserve - prBelowAvg ## chances not above the gun's own average -> no power 3.0 - prRam ## ramming: exempt (at contact P->1, so 3.0 is correct) + prFull ## no cap binds -> the gun's own preference + prFar ## beyond TR_POWER_FAR_DIST -> bad-chances zone + prEnergySlope ## OUR energy below TR_POWER_ENERGY_HI -> linear conserve cap + prFinishKill ## ENEMY energy low -> smallest bullet that still finishes it + prBelowAvg ## chances not above the gun's own average -> no power 3.0 + prRam ## ramming: exempt (at contact P->1, so 3.0 is correct) PowerCap* = object power*: float ## the power to fire (<= the gun's preference) @@ -840,11 +866,52 @@ type proc powerReasonName*(r: PowerReason): string = case r - of prFull: "full" - of prFar: "far" - of prLowEnergy: "lowEnergy" - of prBelowAvg: "belowAvg" - of prRam: "ram" + of prFull: "full" + of prFar: "far" + of prEnergySlope: "energySlope" + of prFinishKill: "finishKill" + of prBelowAvg: "belowAvg" + of prRam: "ram" + +proc bulletDamageAtPower*(power: float): float = + ## Energy the server removes for a bullet of `power`: `4p` for `p <= 1` and + ## `6p - 2` for `p > 1` (and 0 for `p <= 0`), mirroring the server's + ## `rules/math.kt calcBulletDamage`. Kept local so `gun_harness` has no + ## dependency on the movement modules (which define the same `bulletDamage`). + if power <= 0.0: return 0.0 + var p = power + if p < 0.1: p = 0.1 + elif p > 3.0: p = 3.0 + result = 4.0 * p + if p > 1.0: result += 2.0 * (p - 1.0) + +proc powerToKill*(enemyEnergy: float): float = + ## The SMALLEST power whose bullet damage covers `enemyEnergy`, i.e. the + ## inverse of `bulletDamageAtPower`: + ## E <= 4 -> p = E/4 (clamped to >= 0.1) + ## E > 4 -> p = (E+2)/6 + ## E > 16 -> 3.0 (no cap: even p=3.0 removes only 16, so nothing smaller + ## helps and the normal rules decide) + ## Exactly covers E in the first two branches (`4p = E` and `6p-2 = E`); the + ## clamp makes it cover E <= 0.4 too. PURE, exposed for testing. + if enemyEnergy > 16.0: return 3.0 + if enemyEnergy <= 4.0: return max(0.1, enemyEnergy / 4.0) + (enemyEnergy + 2.0) / 6.0 + +proc energySlopeCap*(selfEnergy: float, + hi = PowerEnergyHi, + lo = PowerEnergyLo, + capMin = PowerEnergyMin, + capMax = PowerEnergyMax): float = + ## Linear power cap vs OUR energy: `capMax` at/above `hi`, `capMin` at/below + ## `lo`, linear in POWER in between, clamped to `[capMin, capMax]`. The + ## shipped defaults (3.0 at 80, 0.5 at 20) mean "no cap above 80, half-power at + ## or below 20". `capMax` is the value at/above HI; at its default 3.0 that is + ## exactly "no cap from this rule". PURE, exposed for testing. + if selfEnergy >= hi: return capMax + if selfEnergy <= lo: return capMin + let t = (selfEnergy - lo) / (hi - lo) + clamp(capMin + t * (capMax - capMin), capMin, capMax) proc binIndexForPower*(power: float): int = ## Index of `power` in `PowerBins`; if it is not an exact bin value, the @@ -857,38 +924,51 @@ proc binIndexForPower*(power: float): int = proc applyPowerPolicy*(preferredPower, dist, selfEnergy, pEst, pRef: float, ramming: bool, + enemyEnergy = 100.0, enabled = PowerPolicyEnabled, farDist = PowerFarDist, - lowEnergy = PowerLowEnergy, + energyHi = PowerEnergyHi, + energyLo = PowerEnergyLo, + energyMin = PowerEnergyMin, + energyMax = PowerEnergyMax, + finishKill = PowerFinishKill, farCap = PowerFarCap, midCap = PowerMidCap): PowerCap = ## PURE cap core — no tracker, no battle. `power = min(preferredPower, cap)`, - ## so the result can only ever LOWER the gun's own preference. Order of - ## precedence: ram (exempt) > far > low energy > below average > full. + ## so the result can only ever LOWER the gun's own preference. Each rule is an + ## additional cap; the applied cap is the MINIMUM of all of them, and `reason` + ## names the rule that set it (ties resolved by the order below, which is also + ## the precedence order): ram (exempt) > far > energy slope > below average > + ## finishing. `prFull` means nothing capped. ## ## `pEst` is the gun's rate for the bin it chose (or its aggregate when that ## bin is empty); `pRef` is the gun's aggregate mean (or the fixed ## `TR_POWER_REF`). A cold gun has no data, so `pEst <= pRef` is vacuously ## true and it gets the mid cap — deliberately conservative until it has ## evidence its chances are above average. + ## + ## `enemyEnergy` drives the finishing rule; its default (100) means "no + ## finishing cap", so every pre-existing caller is unchanged. The finishing + ## rule is skipped for `enemyEnergy <= 0` (a dead/unknown target), so a zero + ## energy reading cannot collapse power to 0.1. if ramming: return PowerCap(power: preferredPower, cap: 3.0, reason: prRam) if not enabled: return PowerCap(power: preferredPower, cap: 3.0, reason: prFull) - var cap: float - var reason: PowerReason - if dist > farDist: - cap = farCap - reason = prFar - elif selfEnergy < lowEnergy: - cap = farCap - reason = prLowEnergy - elif pEst <= pRef: - cap = midCap - reason = prBelowAvg - else: - cap = 3.0 - reason = prFull + var cap = 3.0 + var reason = prFull + template noteCap(c: float, r: PowerReason) = + ## Adopt `c` as the cap only when it is STRICTLY smaller, so ties keep the + ## higher-precedence rule's reason (the order of the calls below). + if c < cap: + cap = c + reason = r + if dist > farDist: noteCap(farCap, prFar) + noteCap(energySlopeCap(selfEnergy, energyHi, energyLo, energyMin, energyMax), + prEnergySlope) + if pEst <= pRef: noteCap(midCap, prBelowAvg) + if finishKill and enemyEnergy > 0.0: + noteCap(powerToKill(enemyEnergy), prFinishKill) PowerCap(power: min(preferredPower, cap), cap: cap, reason: reason) proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil, diff --git a/common_libs/tests/measure_power_policy.nim b/common_libs/tests/measure_power_policy.nim new file mode 100644 index 0000000..06f5116 --- /dev/null +++ b/common_libs/tests/measure_power_policy.nim @@ -0,0 +1,210 @@ +## Offline energy-economy measurement for the power policy (TR_POWER_*). +## +## Replays the committed DrussGT movement fixtures through the real VirtualTracker +## (same path the range/acceptance tests use) and, per tick, computes the power +## the SHIPPED rack's only gun (Pattern, id 5) would prefer (`bestPower`). It then +## simulates four policy arms against that SAME preference sequence: +## +## control : uncapped (TR_POWER_POLICY=0) — the baseline +## cliff : TODAY's shipped rule (far 1.0, self energy < 50 -> 1.0, belowAvg 2.0) +## slope : the new linear self-energy cap (finishing OFF) +## finish : slope + the new smallest-killing-bullet cap (finishing ON) +## +## The fire schedule uses the server's gun heat: a shot adds `1 + p/5` heat and +## the gun cools `0.1`/tick, so the interval is `10 + 2p` ticks — LOWER power fires +## more often. `energySpent` sums the fired power over shots; the histogram counts +## shots at each 0.1-wide power bucket. +## +## IMPORTANT (labelled in the output): the trajectory is recorded (open-loop, +## perfect-information) so this is NOT a closed-loop hit-rate A/B. What is +## MEASURED is the policy's energy draw over real recorded movement; what is +## INFERRED is the resulting battle outcome. Hit rates are NOT modelled here. +## +## Run: nim c -r common_libs/tests/measure_power_policy.nim + +import std/[os, strformat, strutils, math, tables, algorithm] +import gun_harness/offline_range +import gun_harness/virtual_bullets +import range_guns + +const + repoRoot = currentSourcePath().parentDir.parentDir.parentDir + fixturesDir = repoRoot / "tools" / "fixtures" + ShippedGunId = 5 ## Pattern — the only gun in the shipped rack + CoolRate = 0.1 ## gun heat lost per tick (server config) + OldCliffEnergy = 50.0 ## TR_POWER_LOW_ENERGY's old hard threshold + +const FixtureNames = [ + "drussgt_vs_spinbot.jsonl", + "drussgt_vs_ramfire.jsonl", + "drussgt_vs_crazy.jsonl", + "drussgt_vs_corners.jsonl", + "drussgt_vs_drussgt.jsonl", +] + +type + Arm* = enum + aControl ## uncapped — the control arm + aCliff ## today's shipped cliff + aSlope ## new energy slope only + aFinish ## energy slope + finishing + + TickRec = object + dist, selfE, enemyE: float + prefPower: float + pEst, pRef: float + + SimResult = object + shots: int + energy: float + hist: Table[int, int] ## key = round(power*10) + +proc armName(a: Arm): string = + case a + of aControl: "control" + of aCliff: "cliff " + of aSlope: "slope " + of aFinish: "finish " + +proc firedPower(arm: Arm, r: TickRec): float = + ## The power this arm would fire given the gun's own preference and the + ## per-tick state. `applyPowerPolicy` already returns `min(preference, cap)`. + case arm + of aControl: + r.prefPower + of aCliff: + var cap = 3.0 + if r.dist > PowerFarDist: cap = PowerFarCap + elif r.selfE < OldCliffEnergy: cap = PowerFarCap + elif r.pEst <= r.pRef: cap = PowerMidCap + min(r.prefPower, cap) + of aSlope: + applyPowerPolicy(r.prefPower, r.dist, r.selfE, r.pEst, r.pRef, false, + enemyEnergy = r.enemyE, enabled = true, + finishKill = false).power + of aFinish: + applyPowerPolicy(r.prefPower, r.dist, r.selfE, r.pEst, r.pRef, false, + enemyEnergy = r.enemyE, enabled = true, + finishKill = true).power + +proc collect(fx: Fixture): seq[TickRec] = + ## One pass through the real tracker, recording the per-tick preference and + ## policy inputs. The preference sequence is arm-INDEPENDENT (virtual bullets + ## are spawned for every bin regardless of what we fire), so all four arms are + ## compared against the exact same sequence. + var recs: seq[TickRec] + var tickIdx = 0 + let drivers = buildAllGunDrivers(seed = 1) + let cb = proc(t: ptr VirtualTracker) = + let si = tickIdx + inc tickIdx + if si >= fx.states.len: return + let st = fx.states[si] + let tid = fx.enemyId + let (prefBin, prefPower) = t[].bestPower(ShippedGunId, tid) + let fit = t[].fitnessFor(tid) + let pRef = gunRate(fit[ShippedGunId], pooled = true) + let pEst = + if fit[ShippedGunId].bins[prefBin].count == 0: pRef + else: fit[ShippedGunId].bins[prefBin].hitRate() + recs.add TickRec( + dist: hypot(st.enemyX - st.selfX, st.enemyY - st.selfY), + selfE: st.selfEnergy, + enemyE: st.enemyEnergy, + prefPower: prefPower, + pEst: pEst, pRef: pRef) + discard replayFixture(fx, drivers, metric = bmPath, tickCb = cb) + recs + +proc simulate(recs: seq[TickRec], arm: Arm): SimResult = + ## Fire whenever the gun is cool (heat <= 0), drawing `power` energy per shot + ## and adding `1 + p/5` heat. Mirrors the live `setFire` + `getEnergy() > power` + ## guard, so a shot is skipped if our energy cannot cover it. + var heat = 0.0 + for r in recs: + heat = max(0.0, heat - CoolRate) + if heat > 1e-9: continue + let p = firedPower(arm, r) + if r.selfE <= p: continue + result.energy += p + inc result.shots + let key = int(round(p * 10.0)) + result.hist[key] = result.hist.getOrDefault(key) + 1 + heat = 1.0 + p / 5.0 + +proc addHist(dst: var Table[int, int], src: Table[int, int]) = + for k, v in src: dst[k] = dst.getOrDefault(k) + v + +proc histLine(h: Table[int, int]): string = + var keys: seq[int] + for k in h.keys: keys.add k + keys.sort() + for k in keys: + if result.len > 0: result.add " " + result.add fmt"p={k.float/10.0:.1f}:{h[k]}" + +proc main() = + echo "=== offline energy-economy measurement (power policy) ===" + echo "fixtures: ", FixtureNames.len, " gun: Pattern(id=", ShippedGunId, ")" + echo "heat model: +1+p/5 per shot, -0.1/tick => interval 10+2p ticks" + echo "" + + var totalTicks = 0 + var lowEnemyTicks = 0 + var agg: array[Arm, SimResult] + + echo "fixture ticks arm shots energy meanP" + echo "-".repeat(72) + for name in FixtureNames: + let path = fixturesDir / name + if not fileExists(path): + echo " (missing: ", path, ")" + continue + let fx = loadFixture(path) + let recs = collect(fx) + totalTicks += recs.len + for r in recs: + if r.enemyE > 0.0 and r.enemyE <= 16.0: inc lowEnemyTicks + for arm in Arm: + let s = simulate(recs, arm) + agg[arm].shots += s.shots + agg[arm].energy += s.energy + agg[arm].hist.addHist(s.hist) + let meanP = if s.shots > 0: s.energy / s.shots.float else: 0.0 + echo fmt"{name:<26} {recs.len:>6} {armName(arm):<8} {s.shots:>6} " & + fmt"{s.energy:>8.0f} {meanP:>6.2f}" + echo "" + + echo "=== AGGREGATE over all fixtures (", totalTicks, " ticks) ===" + echo "arm shots energy meanP E/1k ticks vs control vs cliff" + echo "-".repeat(72) + let base = agg[aControl].energy + let cliff = agg[aCliff].energy + for arm in Arm: + let s = agg[arm] + let meanP = if s.shots > 0: s.energy / s.shots.float else: 0.0 + let per1k = if totalTicks > 0: s.energy / totalTicks.float * 1000.0 else: 0.0 + let vsControl = if base > 0: (base - s.energy) / base * 100.0 else: 0.0 + let vsCliff = if cliff > 0: (cliff - s.energy) / cliff * 100.0 else: 0.0 + echo fmt"{armName(arm):<8} {s.shots:>7} {s.energy:>9.0f} {meanP:>7.2f} " & + fmt"{per1k:>11.1f} {vsControl:>10.1f}% {vsCliff:>9.1f}%" + + echo "" + echo fmt"low-enemy ticks (0 < E <= 16, where the finishing rule can bind): " & + fmt"{lowEnemyTicks}/{totalTicks} ({lowEnemyTicks.float/max(1,totalTicks).float*100.0:.1f}%)" + echo "" + echo "=== POWER HISTOGRAM (shots per 0.1-wide power bucket, all fixtures) ===" + for arm in Arm: + echo armName(arm), ": ", histLine(agg[arm].hist) + + echo "" + echo "=== HIT-CHANCE / BREAK-EVEN REASONING (INFERRED, not measured here) ===" + echo "E[dE] = p(3P-1): the break-even hit probability is 1/3 INDEPENDENT of power." + echo "Our measured real hit rates are 5-27% (far below 1/3), so every point of" + echo "power costs more energy than it returns. A smaller bullet needs MORE hits" + echo "(ceil(E/damage)) but each hit is MORE LIKELY (speed 20-3p => less lead" + echo "error) and shots come FASTER (interval 10+2p). This tool measures only the" + echo "ENERGY side; which effect wins for hit rate needs the battle A/B." + +when isMainModule: + main() diff --git a/common_libs/tests/test_power_policy.nim b/common_libs/tests/test_power_policy.nim index 5bb49ac..18cf18b 100644 --- a/common_libs/tests/test_power_policy.nim +++ b/common_libs/tests/test_power_policy.nim @@ -1,10 +1,16 @@ ## Unit guard for the energy-aware power policy (TR_POWER_*). ## -## The policy is a CAP on the gun's own preferred bin: at long range or low -## energy it trades single-hit damage for a faster bullet and more shots, and it -## withholds power 3.0 unless the gun's own chances for the chosen bin are above -## that gun's average. It must NEVER raise power, and `TR_POWER_POLICY=0` must -## reproduce the uncapped preference exactly (the control arm). +## The policy is a CAP on the gun's own preferred bin. Every rule can only LOWER +## power; `power = min(preference, min(all caps))`. The two energy-economy rules +## under test here are: +## +## * the ENERGY SLOPE — a linear cap on OUR energy (`energySlopeCap`), replacing +## the old hard cliff at `TR_POWER_LOW_ENERGY` (50); +## * the FINISHING SLOPE — cap power at the smallest bullet that still removes +## the ENEMY's remaining energy (`powerToKill`), because server 1.3.1 caps the +## `bulletDamage` score at the energy actually removed, so overkill is wasted. +## +## `TR_POWER_POLICY=0` must reproduce the uncapped preference exactly (control). ## ## Pure: no Java, no battle. Run: ## nim c -r common_libs/tests/test_power_policy.nim @@ -20,6 +26,8 @@ proc check(name: string, ok: bool) = if ok: echo "PASS: ", name else: echo "FAIL: ", name; inc failures +proc close(a, b: float, eps = 1e-9): bool = abs(a - b) < eps + proc recordHit(fw: var FitnessWindow, hit: bool) = fw.hits[fw.head] = hit fw.head = (fw.head + 1) mod WindowSize @@ -32,7 +40,92 @@ proc seedWindow(t: var VirtualTracker, targetId, gunId, binIdx, hits, misses: in for _ in 0.. cap 3.0 (no cap)", + close(energySlopeCap(100.0, 80.0, 20.0, 0.5, 3.0), 3.0) + check "energy slope: selfE=80 (HI) -> cap 3.0", + close(energySlopeCap(80.0, 80.0, 20.0, 0.5, 3.0), 3.0) + # At/below LO: the floor cap. + check "energy slope: selfE=20 (LO) -> cap 0.5", + close(energySlopeCap(20.0, 80.0, 20.0, 0.5, 3.0), 0.5) + check "energy slope: selfE=5 -> cap 0.5 (clamped at LO)", + close(energySlopeCap(5.0, 80.0, 20.0, 0.5, 3.0), 0.5) + # Linear interpolation in POWER. + check "energy slope: selfE=50 -> cap 1.75 (halfway 0.5..3.0)", + close(energySlopeCap(50.0, 80.0, 20.0, 0.5, 3.0), 1.75) + check "energy slope: selfE=65 -> cap 2.375 (t=0.75)", + close(energySlopeCap(65.0, 80.0, 20.0, 0.5, 3.0), 2.375) + check "energy slope: selfE=35 -> cap 1.125 (t=0.25)", + close(energySlopeCap(35.0, 80.0, 20.0, 0.5, 3.0), 1.125) + # A non-default top: the interpolation endpoint is capMax, not hardcoded 3.0. + check "energy slope: custom capMax=2.0 at HI", + close(energySlopeCap(80.0, 80.0, 20.0, 0.5, 2.0), 2.0) + +proc testEnergySlopeMonotone() = + # The cap must be non-decreasing in our energy (more energy never caps lower). + var mono = true + var prev = -1.0 + for i in 0..20: + let e = i.float * 5.0 + let c = energySlopeCap(e, 80.0, 20.0, 0.5, 3.0) + if c < prev - 1e-12: mono = false + prev = c + check "energy slope: cap is non-decreasing in self energy", mono + +# ── CHANGE 2: the finishing slope (pure formula) ───────────────────────────── + +proc testPowerToKill() = + # E <= 4: p = E/4, clamped to >= 0.1. + check "finish: E=0.1 -> p=0.1 (clamp)", + close(powerToKill(0.1), 0.1) + check "finish: E=0.4 -> p=0.1 (clamp)", + close(powerToKill(0.4), 0.1) + check "finish: E=1 -> p=0.25", + close(powerToKill(1.0), 0.25) + check "finish: E=2 -> p=0.5", + close(powerToKill(2.0), 0.5) + check "finish: E=4 -> p=1.0 (branch seam)", + close(powerToKill(4.0), 1.0) + # E > 4: p = (E+2)/6. + check "finish: E=6 -> p=4/3", + close(powerToKill(6.0), 4.0 / 3.0) + check "finish: E=10 -> p=2.0", + close(powerToKill(10.0), 2.0) + check "finish: E=16 -> p=3.0", + close(powerToKill(16.0), 3.0) + # E > 16: no cap (p=3.0 removes only 16). + check "finish: E=17 -> p=3.0 (no cap)", + close(powerToKill(17.0), 3.0) + check "finish: E=20 -> p=3.0 (no cap)", + close(powerToKill(20.0), 3.0) + check "finish: E=100 -> p=3.0 (no cap)", + close(powerToKill(100.0), 3.0) + +proc testPowerToKillCovers() = + # The formula must be a TRUE inverse for the range it can cover (E <= 16); + # above 16 even p=3.0 removes only 16, so there is no cap to find. + var covers = true + for i in 0..159: + let e = 0.1 + i.float * 0.1 # 0.1 .. 16.0 + if bulletDamageAtPower(powerToKill(e)) < e - 1e-9: covers = false + check "finish: powerToKill(E) always removes at least E (E in 0.1..16.0)", + covers + +proc testPowerToKillMonotone() = + var mono = true + var prev = -1.0 + for i in 0..200: + let e = i.float * 0.1 + let p = powerToKill(e) + if p < prev - 1e-12: mono = false + prev = p + check "finish: powerToKill is non-decreasing in enemy energy", mono + +# ── the combined cap core ──────────────────────────────────────────────────── proc testFar() = let d = applyPowerPolicy(preferredPower = 3.0, dist = 250.0, selfEnergy = 100.0, @@ -40,17 +133,50 @@ proc testFar() = check "distance > TR_POWER_FAR_DIST -> cap 1.0 (far)", d.power == 1.0 and d.cap == 1.0 and d.reason == prFar -proc testLowEnergy() = - let d = applyPowerPolicy(3.0, 100.0, 30.0, 0.9, 0.1, false, enabled = true) - check "self energy < TR_POWER_LOW_ENERGY -> cap 1.0 (lowEnergy)", - d.power == 1.0 and d.cap == 1.0 and d.reason == prLowEnergy +proc testEnergySlopeCap() = + # Close, above average, healthy enemy: only the energy slope binds. + let d = applyPowerPolicy(3.0, 100.0, 50.0, 0.9, 0.1, false, + enemyEnergy = 100.0, enabled = true) + check "selfE=50 -> cap 1.75 (energySlope)", + close(d.power, 1.75) and close(d.cap, 1.75) and d.reason == prEnergySlope + let e = applyPowerPolicy(3.0, 100.0, 20.0, 0.9, 0.1, false, + enemyEnergy = 100.0, enabled = true) + check "selfE=20 -> cap 0.5 (energySlope)", + close(e.power, 0.5) and e.reason == prEnergySlope + let f = applyPowerPolicy(3.0, 100.0, 80.0, 0.9, 0.1, false, + enemyEnergy = 100.0, enabled = true) + check "selfE=80 -> no energy cap, full power", f.power == 3.0 + +proc testFinishingCap() = + # Close, healthy self, above average, low enemy: only finishing binds. + let d = applyPowerPolicy(3.0, 100.0, 100.0, 0.9, 0.1, false, + enemyEnergy = 2.0, enabled = true, finishKill = true) + check "enemyE=2 -> smallest killing bullet p=0.5 (finishKill)", + close(d.power, 0.5) and close(d.cap, 0.5) and d.reason == prFinishKill + let e = applyPowerPolicy(3.0, 100.0, 100.0, 0.9, 0.1, false, + enemyEnergy = 10.0, enabled = true, finishKill = true) + check "enemyE=10 -> p=2.0 (finishKill)", + close(e.power, 2.0) and e.reason == prFinishKill + let f = applyPowerPolicy(3.0, 100.0, 100.0, 0.9, 0.1, false, + enemyEnergy = 16.0, enabled = true, finishKill = true) + check "enemyE=16 -> p=3.0 (finish cap does not bite)", f.power == 3.0 + let g = applyPowerPolicy(3.0, 100.0, 100.0, 0.9, 0.1, false, + enemyEnergy = 20.0, enabled = true, finishKill = true) + check "enemyE=20 -> no finish cap", g.power == 3.0 + # Disabled finishing leaves power alone. + let h = applyPowerPolicy(3.0, 100.0, 100.0, 0.9, 0.1, false, + enemyEnergy = 2.0, enabled = true, finishKill = false) + check "enemyE=2 with finishKill=false -> uncapped", h.power == 3.0 + # A dead/unknown target (energy 0) must NOT collapse power to 0.1. + let i = applyPowerPolicy(3.0, 100.0, 100.0, 0.9, 0.1, false, + enemyEnergy = 0.0, enabled = true, finishKill = true) + check "enemyE=0 (dead) -> finish rule skipped, no collapse", i.power == 3.0 proc testBelowAverage() = # pEst == pRef is "not above average": withhold power 3.0 -> cap 2.0. let d = applyPowerPolicy(3.0, 100.0, 100.0, 0.2, 0.2, false, enabled = true) check "chances not above average -> cap 2.0 (belowAvg)", d.power == 2.0 and d.cap == 2.0 and d.reason == prBelowAvg - # Strictly below also caps. let e = applyPowerPolicy(3.0, 100.0, 100.0, 0.1, 0.2, false, enabled = true) check "chances strictly below average -> cap 2.0 (belowAvg)", e.power == 2.0 and e.reason == prBelowAvg @@ -61,36 +187,58 @@ proc testAboveAverageFull() = d.power == 3.0 and d.cap == 3.0 and d.reason == prFull proc testRamExempt() = - # Far, low energy, no chance data: still full power because we are ramming. - let d = applyPowerPolicy(3.0, 500.0, 5.0, 0.0, 0.9, true) - check "ramming exempts the caps even far + low energy", + # Far, low self energy, low enemy energy, no chance data: still full power. + let d = applyPowerPolicy(3.0, 500.0, 5.0, 0.0, 0.9, true, + enemyEnergy = 1.0, enabled = true, finishKill = true) + check "ramming exempts far + energy slope + finishing", d.power == 3.0 and d.reason == prRam - # Ram beats far even with the policy on. - let e = applyPowerPolicy(3.0, 500.0, 5.0, 0.0, 0.9, true, enabled = true) - check "ram exemption takes precedence over the far cap", e.power == 3.0 proc testCapNeverRaises() = # Preferred below every cap must pass through untouched. - let a = applyPowerPolicy(1.0, 500.0, 5.0, 0.0, 0.9, false, enabled = true) - check "far cap does not raise a preferred p=1.0", a.power == 1.0 - let b = applyPowerPolicy(1.5, 100.0, 100.0, 0.1, 0.2, false, enabled = true) - check "mid cap does not raise a preferred p=1.5", b.power == 1.5 - let c = applyPowerPolicy(1.5, 100.0, 100.0, 0.9, 0.2, false, enabled = true) - check "full cap does not raise a preferred p=1.5", c.power == 1.5 + # Caps may LOWER a preference (the new slope does exactly that here) but must + # never raise it: power is always min(preference, cap). + let a = applyPowerPolicy(1.0, 500.0, 5.0, 0.0, 0.9, false, + enemyEnergy = 1.0, enabled = true, finishKill = true) + check "all caps never raise a preferred p=1.0 (power = min(pref, cap))", + a.power <= 1.0 and close(a.power, min(1.0, a.cap)) + let b = applyPowerPolicy(1.5, 100.0, 100.0, 0.1, 0.2, false, + enemyEnergy = 20.0, enabled = true, finishKill = true) + check "belowAvg cap does not raise a preferred p=1.5", b.power == 1.5 + # Exhaustive: for every cap combination, power <= preference. + var never = true + for pref in [0.5, 1.0, 1.5, 2.0, 3.0]: + for selfE in [5.0, 20.0, 50.0, 80.0, 100.0]: + for enemyE in [0.0, 0.5, 2.0, 10.0, 20.0]: + for d in [50.0, 250.0]: + let r = applyPowerPolicy(pref, d, selfE, 0.1, 0.2, false, + enemyEnergy = enemyE, enabled = true, + finishKill = true) + if r.power > pref + 1e-12: never = false + check "cap never raises power (exhaustive sweep)", never proc testPrecedence() = - # far beats low energy, and low energy beats belowAvg. - let a = applyPowerPolicy(3.0, 500.0, 5.0, 0.0, 0.9, false, enabled = true) - check "far takes precedence over low energy", a.reason == prFar - let b = applyPowerPolicy(3.0, 100.0, 5.0, 0.0, 0.9, false, enabled = true) - check "low energy takes precedence over belowAvg", b.reason == prLowEnergy + # far (1.0) beats the energy slope (1.75 at selfE=50). + let a = applyPowerPolicy(3.0, 500.0, 50.0, 0.0, 0.9, false, + enemyEnergy = 100.0, enabled = true) + check "far takes precedence over the energy slope", a.reason == prFar + # the energy slope (1.75) beats belowAvg (2.0). + let b = applyPowerPolicy(3.0, 100.0, 50.0, 0.0, 0.9, false, + enemyEnergy = 100.0, enabled = true) + check "energy slope takes precedence over belowAvg", + b.reason == prEnergySlope + # finishing (0.5 at enemyE=2) is smaller than far (1.0), so it wins. + let c = applyPowerPolicy(3.0, 500.0, 100.0, 0.9, 0.1, false, + enemyEnergy = 2.0, enabled = true, finishKill = true) + check "finishing beats far when it is the smaller cap", + close(c.power, 0.5) and c.reason == prFinishKill proc testDisabledUncapped() = # enabled=false is the code path TR_POWER_POLICY=0 drives. for p in [1.0, 1.5, 2.0, 3.0]: - let d = applyPowerPolicy(p, 500.0, 5.0, 0.0, 0.9, false, enabled = false) + let d = applyPowerPolicy(p, 500.0, 5.0, 0.0, 0.9, false, + enemyEnergy = 1.0, enabled = false, finishKill = true) check fmt"policy off reproduces the uncapped preference p={p:.1f}", - d.power == p and d.cap == 3.0 + d.power == p and d.cap == 3.0 and d.reason == prFull proc testEnvFlag() = # The flag is read once at module init, so the active branch is selected by @@ -98,9 +246,13 @@ proc testEnvFlag() = if PowerPolicyEnabled: check "TR_POWER_POLICY default (on): far shot is capped to 1.0", applyPowerPolicy(3.0, 300.0, 100.0, 0.5, 0.2, false).power == 1.0 + check "TR_POWER_POLICY default (on): low self energy is capped by the slope", + applyPowerPolicy(3.0, 100.0, 20.0, 0.5, 0.2, false).power == 0.5 else: check "TR_POWER_POLICY=0: far shot is NOT capped (control arm)", applyPowerPolicy(3.0, 300.0, 100.0, 0.5, 0.2, false).power == 3.0 + check "TR_POWER_POLICY=0: low self energy is NOT capped (control arm)", + applyPowerPolicy(3.0, 100.0, 20.0, 0.5, 0.2, false).power == 3.0 proc testBinIndex() = check "binIndexForPower maps the shipped bins exactly", @@ -112,7 +264,8 @@ proc testBinIndex() = proc testReasonNames() = check "reason names match the documented log vocabulary", powerReasonName(prFar) == "far" and - powerReasonName(prLowEnergy) == "lowEnergy" and + powerReasonName(prEnergySlope) == "energySlope" and + powerReasonName(prFinishKill) == "finishKill" and powerReasonName(prBelowAvg) == "belowAvg" and powerReasonName(prFull) == "full" and powerReasonName(prRam) == "ram" @@ -129,7 +282,8 @@ proc testTrackerAboveAverage() = seedWindow(t, 7, 0, 2, 10, 90) seedWindow(t, 7, 0, 3, 50, 50) let (g, _, p, d) = t.selectShotPolicy(7, tick = 0, dist = 100.0, - selfEnergy = 100.0, ramming = false) + selfEnergy = 100.0, enemyEnergy = 100.0, + ramming = false) if PowerPolicyEnabled: check "tracker: above-average bin + close + healthy -> power 3.0", g == 0 and p == 3.0 and d.reason == prFull @@ -137,36 +291,61 @@ proc testTrackerAboveAverage() = check "tracker (control): above-average bin still fires power 3.0", g == 0 and p == 3.0 +proc testTrackerEnergySlope() = + var t = initTracker(1) + seedWindow(t, 7, 0, 3, 50, 50) + let (_, _, p, d) = t.selectShotPolicy(7, tick = 0, dist = 100.0, + selfEnergy = 50.0, enemyEnergy = 100.0, + ramming = false) + if PowerPolicyEnabled: + check "tracker: selfE=50 -> power 1.75 (energySlope)", + close(p, 1.75) and d.reason == prEnergySlope + else: + check "tracker (control): selfE=50 keeps the uncapped preference (3.0)", + p == 3.0 + +proc testTrackerFinishing() = + var t = initTracker(1) + seedWindow(t, 7, 0, 3, 50, 50) + let (_, _, p, d) = t.selectShotPolicy(7, tick = 0, dist = 100.0, + selfEnergy = 100.0, enemyEnergy = 2.0, + ramming = false) + if PowerPolicyEnabled and PowerFinishKill: + check "tracker: enemyE=2 -> power 0.5 (finishKill)", + close(p, 0.5) and d.reason == prFinishKill + else: + check "tracker (control/finish off): enemyE=2 keeps preference (3.0)", + p == 3.0 + proc testTrackerBelowAverage() = var t = initTracker(1) for b in 0.. cap 2.0 (belowAvg)", p == 2.0 and d.reason == prBelowAvg else: check "tracker (control): flat gun keeps its uncapped preference (power 3.0)", p == 3.0 -proc testTrackerFarAndLowEnergy() = +proc testTrackerFar() = var t = initTracker(1) seedWindow(t, 7, 0, 3, 50, 50) let (_, _, pFar, dFar) = t.selectShotPolicy(7, 0, dist = 250.0, - selfEnergy = 100.0, ramming = false) - let (_, _, pLow, dLow) = t.selectShotPolicy(7, 0, dist = 100.0, - selfEnergy = 30.0, ramming = false) + selfEnergy = 100.0, enemyEnergy = 100.0, + ramming = false) if PowerPolicyEnabled: check "tracker: far -> power 1.0", pFar == 1.0 and dFar.reason == prFar - check "tracker: low energy -> power 1.0", pLow == 1.0 and dLow.reason == prLowEnergy else: check "tracker (control): far does NOT cap (uncapped preference)", pFar == 3.0 - check "tracker (control): low energy does NOT cap (uncapped preference)", pLow == 3.0 proc testTrackerColdAndEmptyBin() = var t = initTracker(1) let (_, _, pCold, dCold) = t.selectShotPolicy(7, 0, dist = 100.0, - selfEnergy = 100.0, ramming = false) + selfEnergy = 100.0, enemyEnergy = 100.0, + ramming = false) if PowerPolicyEnabled: # Cold gun: no data at all -> pEst <= pRef vacuously -> mid cap, but the # preferred bin is already 1.0, so the fired power stays 1.0. @@ -179,13 +358,20 @@ proc testTrackerRamExempt() = var t = initTracker(1) seedWindow(t, 7, 0, 3, 50, 50) let (_, _, p, d) = t.selectShotPolicy(7, 0, dist = 500.0, - selfEnergy = 5.0, ramming = true) + selfEnergy = 5.0, enemyEnergy = 1.0, + ramming = true) check "tracker: ramming exempts the caps", p == 3.0 and d.reason == prRam # ── driver ─────────────────────────────────────────────────────────────────── +testEnergySlope() +testEnergySlopeMonotone() +testPowerToKill() +testPowerToKillCovers() +testPowerToKillMonotone() testFar() -testLowEnergy() +testEnergySlopeCap() +testFinishingCap() testBelowAverage() testAboveAverageFull() testRamExempt() @@ -196,8 +382,10 @@ testEnvFlag() testBinIndex() testReasonNames() testTrackerAboveAverage() +testTrackerEnergySlope() +testTrackerFinishing() testTrackerBelowAverage() -testTrackerFarAndLowEnergy() +testTrackerFar() testTrackerColdAndEmptyBin() testTrackerRamExempt() diff --git a/docs/env_reference.md b/docs/env_reference.md index f1b5fb0..bb883af 100644 --- a/docs/env_reference.md +++ b/docs/env_reference.md @@ -79,7 +79,7 @@ rate (7.02% → 5.10%, p=0.002). Three attempts to "smarten" the band all failed | `TR_POWER_ENERGY_MIN` | `0.5` | the cap at/below `ENERGY_LO` | | `TR_POWER_ENERGY_MAX` | `3.0` | the cap at/above `ENERGY_HI` (3.0 = effectively uncapped) | | `TR_POWER_FINISH_KILL` | `1` | cap power at the **smallest bullet that can still kill** the enemy | -| `TR_POWER_LOG` | off | `1` = log each decision: `[power] p=… cap=… reason=far\|lowEnergy\|belowAvg\|full\|ram` | +| `TR_POWER_LOG` | off | `1` = log each decision: `[power] p=… cap=… reason=far\|energySlope\|finishKill\|belowAvg\|full\|ram` | WHY the slope: bullet speed is `20-3p`, so **lower power = faster bullet** (less lead error, higher hit chance), fires more often (`10+2p` ticks) and drains energy