## State vector builder — produces 42-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 proc buildStateVector*(bot: BotStateData; enemy: EnemyTracker): Tensor[float32] = ## Build the 42-float normalized state tensor. ## All values clipped to roughly [-1, 1] via division by physical maxima. result = zeros[float32](42) 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)