feat(PPO_Bot): enemy tracker + 42-float state vector (#15)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:12:13 +02:00
parent aa4bc77068
commit 588c9ebc2f
4 changed files with 327 additions and 1 deletions
+146
View File
@@ -0,0 +1,146 @@
## Assert-based tests for EnemyTracker and StateVector.
## Run: nim c -r tests/test_state.nim
import std/[math, strformat]
import arraymancer
# Import from parent dir
import "../enemy_tracker"
import "../state_vector"
template check(cond: bool, msg: string) =
if not cond:
quit("FAIL: " & msg, 1)
# ---------------------------------------------------------------------------
# EnemyTracker tests
# ---------------------------------------------------------------------------
block testBasicUpdate:
var t = initEnemyTracker()
t.update(200.0, 300.0, 90.0, 5.0, 80.0)
check t.hasContact, "hasContact after update"
check t.current.x == 200.0, "x after update"
check t.current.y == 300.0, "y after update"
check t.current.direction == 90.0, "direction after update"
check t.current.speed == 5.0, "speed after update"
check t.current.energy == 80.0, "energy after update"
check t.current.ticksSinceLastScan == 0, "ticksSinceLastScan reset"
block testFireDetection:
var t = initEnemyTracker()
# First update sets prevEnergy
t.update(100.0, 100.0, 0.0, 0.0, 100.0)
# Second update: energy drop of 3.0 → enemy fired power 3.0
t.update(100.0, 100.0, 0.0, 0.0, 97.0)
check t.current.hasFired, "hasFired when energy drops by 3.0"
check abs(t.current.lastFirePower - 3.0) < 0.001, "lastFirePower == 3.0"
block testNoFireOnSmallDrop:
var t = initEnemyTracker()
t.update(100.0, 100.0, 0.0, 0.0, 100.0)
# Drop of 0.05 — below MIN_FIRE_POWER threshold
t.update(100.0, 100.0, 0.0, 0.0, 99.95)
check not t.current.hasFired, "no fire on small energy drop"
block testDeadReckoning:
var t = initEnemyTracker()
# direction=0° in Tank Royale means north (y increases)
t.update(100.0, 100.0, 0.0, 5.0, 100.0)
t.deadReckon()
# x unchanged (sin 0° = 0), y increases by speed (cos 0° = 1)
check abs(t.current.x - 100.0) < 0.001, "dead reckon: x unchanged for dir=0"
check abs(t.current.y - 105.0) < 0.001, "dead reckon: y += speed for dir=0"
check t.current.ticksSinceLastScan == 1, "ticksSinceLastScan incremented"
block testDeadReckonEast:
var t = initEnemyTracker()
# direction=90° → east (sin 90° = 1, cos 90° = 0)
t.update(100.0, 100.0, 90.0, 5.0, 100.0)
t.deadReckon()
check abs(t.current.x - 105.0) < 0.001, "dead reckon east: x += speed"
check abs(t.current.y - 100.0) < 0.001, "dead reckon east: y unchanged"
block testHistoryWindow:
var t = initEnemyTracker()
# Feed 6 updates — history should hold last 5
for i in 1 .. 6:
t.update(float64(i) * 10.0, float64(i) * 20.0, 0.0, float64(i), 100.0)
check t.historyCount == 5, "historyCount capped at 5"
# history[0] should be the second-to-last scan (i=5)
check abs(t.history[0].x - 50.0) < 0.001, "history[0].x == 50 (i=5)"
check abs(t.history[4].x - 10.0) < 0.001, "history[4].x == 10 (i=1)"
# ---------------------------------------------------------------------------
# StateVector tests
# ---------------------------------------------------------------------------
block testStateVectorLength:
var t = initEnemyTracker()
t.update(400.0, 300.0, 45.0, 3.0, 80.0)
let bot = BotStateData(
x: 200.0, y: 200.0, direction: 90.0, speed: 4.0, energy: 50.0,
gunDirection: 180.0, gunHeat: 0.5,
arenaWidth: 800.0, arenaHeight: 600.0,
)
let sv = buildStateVector(bot, t)
check sv.shape == [42], "state vector has 42 elements"
block testStateVectorRange:
var t = initEnemyTracker()
t.update(400.0, 300.0, 180.0, 8.0, 100.0)
let bot = BotStateData(
x: 800.0, y: 600.0, direction: 360.0, speed: 8.0, energy: 100.0,
gunDirection: 360.0, gunHeat: 1.8,
arenaWidth: 800.0, arenaHeight: 600.0,
)
let sv = buildStateVector(bot, t)
for i in 0 ..< 42:
check sv[i] >= -2.0f32 and sv[i] <= 2.0f32,
&"sv[{i}]={sv[i]} out of [-2,2] range"
block testWallDistances:
# Bot at (100, 200) in 800×600 arena
# wallMax = max(800, 600) = 800
# top = (600 - 200) / 800 = 400/800 = 0.5
# bottom = 200 / 800 = 0.25
# left = 100 / 800 = 0.125
# right = (800 - 100) / 800 = 700/800 = 0.875
var t = initEnemyTracker()
let bot = BotStateData(
x: 100.0, y: 200.0, direction: 0.0, speed: 0.0, energy: 100.0,
gunDirection: 0.0, gunHeat: 0.0,
arenaWidth: 800.0, arenaHeight: 600.0,
)
let sv = buildStateVector(bot, t)
check abs(sv[18] - 0.5f32) < 0.001f32, "top wall = 0.5"
check abs(sv[19] - 0.25f32) < 0.001f32, "bottom wall = 0.25"
check abs(sv[20] - 0.125f32) < 0.001f32, "left wall = 0.125"
check abs(sv[21] - 0.875f32) < 0.001f32, "right wall = 0.875"
block testRelativeBearing:
# Bot at (0,0) dir=0°. Enemy at (0,100) → arctan2(100,0)=90° absolute.
# relBearing = (90 - 0 + 540) mod 360 - 180 = 90°
# normalized = 90 / 180 = 0.5
var t = initEnemyTracker()
t.update(0.0, 100.0, 0.0, 0.0, 100.0)
let bot = BotStateData(
x: 0.0, y: 0.0, direction: 0.0, speed: 0.0, energy: 100.0,
gunDirection: 0.0, gunHeat: 0.0,
arenaWidth: 800.0, arenaHeight: 600.0,
)
let sv = buildStateVector(bot, t)
check abs(sv[16] - 0.5f32) < 0.01f32, "relative bearing = 0.5, got " & $sv[16]
block testHistoryPaddedWhenEmpty:
var t = initEnemyTracker()
let bot = BotStateData(
x: 400.0, y: 300.0, direction: 0.0, speed: 0.0, energy: 100.0,
gunDirection: 0.0, gunHeat: 0.0,
arenaWidth: 800.0, arenaHeight: 600.0,
)
let sv = buildStateVector(bot, t)
for i in 22 ..< 42:
check sv[i] == 0.0f32, &"history slot {i} should be 0 when no contact"
echo "All tests passed"