Files
SirRoboGarage/PPO_Bot/state_vector.nim
T
SirStone 12624d3069 feat(PPO_Bot): bot-relative bullets + scan staleness (STATE_DIM=57)
- Bullet state (indices 44-55): enemy-relative → bot-relative frame
  (bot needs threat vectors to itself for dodging, not to enemy)
- New index 56: scan staleness = min(ticksSinceLastScan / 30, 1.0)
  (gives policy a confidence signal for enemy data freshness)
- warm_start.py updated: 44→57 dim expansion, TARGET_DIM variable
- Tests updated for new state layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 14:27:28 +02:00

122 lines
5.2 KiB
Nim
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## State vector builder — produces 57-float normalized tensor for PPO policy.
## No bot API imports; takes plain BotState + EnemyTracker structs.
import std/math
import arraymancer
import ./enemy_tracker
type
BotStateData* = object
## Plain data mirror of the bot's observable state.
x*, y*: float64
direction*: float64
speed*: float64
energy*: float64
gunDirection*: float64
gunHeat*: float64
arenaWidth*, arenaHeight*: float64
BulletData* = object
## Enemy bullet in flight (absolute arena coords + fire power).
x*, y*: float64
power*: float64 # fire power in [0.1, 3.0]; speed = 20 - 3*power
proc buildStateVector*(bot: BotStateData; enemy: EnemyTracker;
remainingGotoDistance: float64 = 0.0;
remainingGunAngle: float64 = 0.0;
bullets: openArray[BulletData] = [];
bulletCount: int = 0): Tensor[float32] =
## Build the 57-float normalized state tensor.
## Indices 0-43: existing features. Indices 44-55: up to 3 bullet slots (4 floats each).
## Index 56: scan staleness (ticksSinceLastScan / 30, clamped to 1).
## All values clipped to roughly [-1, 1] via division by physical maxima.
result = zeros[float32](57)
let aW = bot.arenaWidth
let aH = bot.arenaHeight
let diag = sqrt(aW * aW + aH * aH) # ≈ 1700 for 1200×800
let wallMax = max(aW, aH)
# --- Current tick: own bot (indices 0-6) ---
result[0] = float32(bot.x / aW)
result[1] = float32(bot.y / aH)
result[2] = float32(bot.direction / 360.0)
result[3] = float32(bot.speed / 8.0)
result[4] = float32(bot.energy / 100.0)
result[5] = float32(bot.gunDirection / 360.0)
result[6] = float32(bot.gunHeat / 1.8)
# --- Current tick: enemy (indices 7-13) ---
if enemy.hasContact:
result[7] = float32(enemy.current.x / aW)
result[8] = float32(enemy.current.y / aH)
result[9] = float32(enemy.current.direction / 360.0)
result[10] = float32(enemy.current.speed / 8.0)
result[11] = float32(enemy.current.energy / 100.0)
result[12] = float32(if enemy.current.hasFired: 1.0 else: 0.0)
result[13] = float32(enemy.current.lastFirePower / 3.0)
# else: remain 0.0
# --- Derived features (indices 14-21) ---
if enemy.hasContact:
# Enemy acceleration (speed delta from last history entry)
# Max delta is ±8 (stopped ↔ full speed); divide by 8 to normalize
if enemy.historyCount >= 1:
result[14] = float32((enemy.current.speed - enemy.history[0].speed) / 8.0)
# Enemy turn rate (direction delta from last history entry, normalized to [-1,1])
# Divide by 180 (max possible relative rotation) rather than 10 (max body turn rate)
# ponytail: /180 covers all cases; use /10 if you want sensitivity to small turns
if enemy.historyCount >= 1:
let dirDelta = ((enemy.current.direction - enemy.history[0].direction) + 540.0) mod 360.0 - 180.0
result[15] = float32(dirDelta / 180.0)
# Relative bearing to enemy (signed, from bot perspective)
let dx = enemy.current.x - bot.x
let dy = enemy.current.y - bot.y
let absDir = (180.0 * arctan2(dx, dy) / PI + 360.0) mod 360.0
let relBearing = ((absDir - bot.direction) + 540.0) mod 360.0 - 180.0
result[16] = float32(relBearing / 180.0)
# Distance to enemy
let dist = sqrt(dx * dx + dy * dy)
result[17] = float32(dist / diag)
# Wall distances (indices 18-21): top, bottom, left, right
# top = distance from bot to top wall (y=aH), bottom = distance to bottom (y=0)
# left = distance to left (x=0), right = distance to right (x=aW)
result[18] = float32((aH - bot.y) / wallMax) # top
result[19] = float32(bot.y / wallMax) # bottom
result[20] = float32(bot.x / wallMax) # left
result[21] = float32((aW - bot.x) / wallMax) # right
# --- History: 5 ticks × 4 floats = 20 floats (indices 22-41) ---
for i in 0 ..< 5:
let base = 22 + i * 4
if i < enemy.historyCount:
result[base + 0] = float32(enemy.history[i].x / aW)
result[base + 1] = float32(enemy.history[i].y / aH)
result[base + 2] = float32(enemy.history[i].direction / 360.0)
result[base + 3] = float32(enemy.history[i].speed / 8.0)
# else: remain 0.0 (pad)
# --- Goto controller inputs (indices 42-43) ---
result[42] = float32(remainingGotoDistance / diag)
result[43] = float32(remainingGunAngle / 180.0)
# --- Bullet tracking (indices 44-55): up to 3 enemy bullets, 4 floats each ---
# Per bullet: relX/aW, relY/aH, speed/20, ticksToImpact/diag
# Positions are relative to bot (useful for dodging). Slots beyond bulletCount stay 0.
for i in 0 ..< min(bulletCount, 3):
let b = bullets[i]
let bSpeed = 20.0 - 3.0 * b.power # Tank Royale bullet speed formula
let bdx = b.x - bot.x
let bdy = b.y - bot.y
let bdist = sqrt(bdx * bdx + bdy * bdy)
let ticks = if bSpeed > 0.0: bdist / bSpeed else: 0.0
let base = 44 + i * 4
result[base + 0] = float32(bdx / bot.arenaWidth)
result[base + 1] = float32(bdy / bot.arenaHeight)
result[base + 2] = float32(bSpeed / 20.0)
result[base + 3] = float32(ticks / diag)
# --- Scan staleness (index 56) ---
result[56] = float32(min(enemy.current.ticksSinceLastScan.float64 / 30.0, 1.0))