j128 learned movement: state-conditional counted-SBC wave danger (TR_MOVEMENT=learned, default-off), offline gate + pre-registered panel arms
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
## learned_surfer.nim — LEARNED movement: state-conditional wave danger.
|
||||
##
|
||||
## The novelty over every other mover in this repo (`strafe`, `tfil`,
|
||||
## `wave_surfer`): the danger of a guess-factor bin is **learned per movement
|
||||
## state** instead of being a single hand-tuned/global quantity. The learner is
|
||||
## the counted SBC with global fractional decay from `common_libs/bitbrain`
|
||||
## (jobs j102/j103: measured to forget a changed mapping and to produce true
|
||||
## probabilities), so it can track an opponent that adapts to us.
|
||||
##
|
||||
## ── The design (and its measured basis) ─────────────────────────────────────
|
||||
## 1. Waves: enemy fire is detected from the one-tick energy drop (the drop IS
|
||||
## the firepower), exactly as `wave_surfer.nim`/`strafe.nim` do. `WorldState`
|
||||
## carries no bullet bodies, so the wave frame is "origin = enemy position at
|
||||
## the fire tick, centre line = bearing from the origin to us at that tick".
|
||||
##
|
||||
## 2. Label. A wave RESOLVES at the nominal arrival tick
|
||||
## `ceil(startDist/speed)` — the time the bullet needs for the range at fire
|
||||
## time, known at fire time — and the label is the 31-bin guess factor of our
|
||||
## angular offset from the centre line at that tick (`gfToBin`, the same
|
||||
## 31-bin quantisation `wave_surfer.nim` uses).
|
||||
##
|
||||
## 3. State = ONE coarse wave-relative state at the fire tick, 4 fields x 4
|
||||
## symbols = 256 states, NEVER a temporal window (measured dead:
|
||||
## `docs/state_window_gate.md`):
|
||||
## vlat lateral velocity in the wave frame, px/tick (signed)
|
||||
## dist range to the enemy at the fire tick, px
|
||||
## room directional wall room along the direction we are running, px
|
||||
## turn our own signed heading change, deg/tick
|
||||
## `lat` (the perpendicular offset from the centre line) is deliberately NOT
|
||||
## a field: at the fire tick the centre line passes through us, so it is
|
||||
## identically 0. 256 states over ~55k recorded shots is ~150 observations
|
||||
## per state — no recurrence problem.
|
||||
##
|
||||
## 4. Learner = counted SBC (`initCountedSbc`), read with `inferProb` (the
|
||||
## per-cell posterior), interpolated with the global 31-bin histogram that
|
||||
## `wave_surfer.nim` used: `p = (posterior + alpha*global)/(1 + alpha)`. With
|
||||
## no data at a cell this is exactly the old global surfer.
|
||||
##
|
||||
## 5. Danger of a candidate escape direction = the predicted probability of the
|
||||
## GF bin we would arrive in, SUMMED over every live wave, plus a wall
|
||||
## penalty, a travel penalty and a reversal penalty. The safest reachable
|
||||
## bin wins, then the same perpendicular steering / wall escape / radial
|
||||
## blend the other movers use.
|
||||
##
|
||||
## MEASURED (offline gate, `common_libs/tests/learned_surfer_gate.py`, 70
|
||||
## recorded battles, held out BY BATTLE, 3 seeds): the state-conditional model
|
||||
## beats the global histogram and chance on held-out log-loss (4.927 vs 4.974
|
||||
## vs 4.954 bits) in 63/63 held-out battles (sign-flip p = 5e-5), but the effect
|
||||
## is TINY: top-1 3.93% (global 3.96%, chance 3.23%). Read the ledger section
|
||||
## "Learned movement (SBC)" in `docs/movement_campaign.md` before trusting it.
|
||||
##
|
||||
## env knobs (all read by `loadLearnedEnv`):
|
||||
## TR_MOVEMENT = learned (selects this engine)
|
||||
## TR_LEARNED_DECAY_EVERY learns between decay passes (default 128)
|
||||
## TR_LEARNED_DECAY_SHIFT `c -= c shr shift`; 0 disables forgetting
|
||||
## TR_LEARNED_ALPHA prior mix weight (default 5.0)
|
||||
## TR_LEARNED_TRAVEL danger cost of travelling across the wave (0.01)
|
||||
## TR_LEARNED_REVERSAL danger cost of flipping the strafe side (0.02)
|
||||
## TR_LEARNED_PREF_DIST preferred engagement distance (400 px)
|
||||
## TR_LEARNED_DIST_BAND deadband around it (50 px)
|
||||
## TR_LEARNED_RADIAL_FRAC radial blend outside the band (0.35)
|
||||
## TR_LEARNED_WALL_MARGIN wall margin (48 px)
|
||||
## TR_LEARNED_GLOBAL =1: ignore the state (ABLATION, the same mover
|
||||
## with a pure global histogram)
|
||||
## TR_LEARNED_LOG per-decision log line
|
||||
|
||||
import std/[math, os]
|
||||
from std/strutils import parseFloat, parseInt, strip
|
||||
import gun_harness/gun_interface
|
||||
import movement_harness/movement_interface
|
||||
import bitbrain/sbc
|
||||
|
||||
const
|
||||
LS_BINS = 31 ## GF bins, identical to `wave_surfer.WS_BINS`
|
||||
LS_Q = 4 ## symbols per field
|
||||
LS_NADE = LS_Q * LS_Q ## 16: (vlat, dist) on one axis, (room, turn) on the other
|
||||
DodgeTicks = 15.0
|
||||
MaxBotSpeed = 8.0
|
||||
|
||||
## State-bin edges (4 symbols/field): the quantiles of the recorded corpus,
|
||||
## derived by `common_libs/tests/learned_surfer_gate.py` (section E) and frozen
|
||||
## here. Quantisation is the 25/50/75 % quantiles; a value is placed in the
|
||||
## number of edges strictly below it.
|
||||
const
|
||||
VlatEdges = [-6.736, 0.000, 6.753]
|
||||
DistEdges = [431.321, 487.612, 552.670]
|
||||
RoomEdges = [137.965, 206.589, 296.753]
|
||||
TurnEdges = [-0.142, 0.000, 0.105]
|
||||
|
||||
const
|
||||
LearnedDecayEveryEnv* = "TR_LEARNED_DECAY_EVERY"
|
||||
LearnedDecayShiftEnv* = "TR_LEARNED_DECAY_SHIFT"
|
||||
LearnedAlphaEnv* = "TR_LEARNED_ALPHA"
|
||||
LearnedTravelEnv* = "TR_LEARNED_TRAVEL"
|
||||
LearnedReversalEnv* = "TR_LEARNED_REVERSAL"
|
||||
LearnedPrefDistEnv* = "TR_LEARNED_PREF_DIST"
|
||||
LearnedDistBandEnv* = "TR_LEARNED_DIST_BAND"
|
||||
LearnedRadialFracEnv* = "TR_LEARNED_RADIAL_FRAC"
|
||||
LearnedWallMarginEnv* = "TR_LEARNED_WALL_MARGIN"
|
||||
LearnedGlobalEnv* = "TR_LEARNED_GLOBAL"
|
||||
LearnedLogEnv* = "TR_LEARNED_LOG"
|
||||
|
||||
var
|
||||
LearnedDecayEvery* = 128
|
||||
LearnedDecayShift* = 1
|
||||
LearnedAlpha* = 5.0
|
||||
LearnedTravel* = 0.01
|
||||
LearnedReversal* = 0.02
|
||||
LearnedPrefDist* = 400.0
|
||||
LearnedDistBand* = 50.0
|
||||
LearnedRadialFrac* = 0.35
|
||||
LearnedWallMargin* = 48.0
|
||||
LearnedGlobal* = false
|
||||
LearnedLog* = false
|
||||
|
||||
proc getEnvFloat(name: string, default: float): float =
|
||||
let s = getEnv(name, "")
|
||||
if s.len == 0: return default
|
||||
try: result = parseFloat(s.strip())
|
||||
except ValueError: result = default
|
||||
|
||||
proc getEnvInt(name: string, default: int): int =
|
||||
let s = getEnv(name, "")
|
||||
if s.len == 0: return default
|
||||
try: result = parseInt(s.strip())
|
||||
except ValueError: result = default
|
||||
|
||||
proc envOn(name: string, default = false): bool =
|
||||
let s = getEnv(name, "").strip()
|
||||
if s.len == 0: return default
|
||||
s notin ["0", "false", "no", "off"]
|
||||
|
||||
proc loadLearnedEnv*() =
|
||||
## Read the knobs; callable again after `putEnv` so a gate can sweep arms in
|
||||
## one process.
|
||||
LearnedDecayEvery = max(0, getEnvInt(LearnedDecayEveryEnv, 128))
|
||||
LearnedDecayShift = max(0, getEnvInt(LearnedDecayShiftEnv, 1))
|
||||
LearnedAlpha = max(0.0, getEnvFloat(LearnedAlphaEnv, 5.0))
|
||||
LearnedTravel = max(0.0, getEnvFloat(LearnedTravelEnv, 0.01))
|
||||
LearnedReversal = max(0.0, getEnvFloat(LearnedReversalEnv, 0.02))
|
||||
LearnedPrefDist = max(1.0, getEnvFloat(LearnedPrefDistEnv, 400.0))
|
||||
LearnedDistBand = max(0.0, getEnvFloat(LearnedDistBandEnv, 50.0))
|
||||
LearnedRadialFrac = clamp(getEnvFloat(LearnedRadialFracEnv, 0.35), 0.0, 1.0)
|
||||
LearnedWallMargin = max(0.0, getEnvFloat(LearnedWallMarginEnv, 48.0))
|
||||
LearnedGlobal = envOn(LearnedGlobalEnv)
|
||||
LearnedLog = envOn(LearnedLogEnv)
|
||||
|
||||
loadLearnedEnv()
|
||||
|
||||
# ── small helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
proc wrapPi(x: float64): float64 {.inline.} =
|
||||
result = x
|
||||
while result > PI: result -= 2.0*PI
|
||||
while result < -PI: result += 2.0*PI
|
||||
|
||||
proc wrap180(d: float): float {.inline.} =
|
||||
result = d
|
||||
while result > 180.0: result -= 360.0
|
||||
while result < -180.0: result += 360.0
|
||||
|
||||
proc mea(speed: float64): float64 {.inline.} =
|
||||
if speed <= 1e-9: return 0.0
|
||||
arcsin(min(MaxBotSpeed / speed, 1.0))
|
||||
|
||||
proc gfToBin(gf: float64): int {.inline.} =
|
||||
clamp(int(round((gf.clamp(-1.0, 1.0) + 1.0) * 0.5 * float64(LS_BINS - 1))),
|
||||
0, LS_BINS - 1)
|
||||
|
||||
proc binToGF(idx: int): float64 {.inline.} =
|
||||
float64(idx) / float64(LS_BINS - 1) * 2.0 - 1.0
|
||||
|
||||
proc code(value: float64, edges: array[3, float64]): int {.inline.} =
|
||||
result = 0
|
||||
for e in edges:
|
||||
if value > e: inc result
|
||||
|
||||
proc roomToWall(px, py, dx, dy, arenaW, arenaH: float64): float64 =
|
||||
## Distance from (px,py) along unit (dx,dy) until leaving the arena, keeping
|
||||
## the 18 px bot radius. Mirrors the offline gate.
|
||||
var t = Inf
|
||||
for i in 0..1:
|
||||
let p = if i == 0: px else: py
|
||||
let d = if i == 0: dx else: dy
|
||||
let lo = BotRadius
|
||||
let hi = (if i == 0: arenaW else: arenaH) - BotRadius
|
||||
if abs(d) > 1e-9:
|
||||
let cand = if d > 0.0: (hi - p) / d else: (lo - p) / d
|
||||
if cand < t: t = cand
|
||||
if t == Inf: return 0.0
|
||||
max(0.0, t)
|
||||
|
||||
# ── module types ────────────────────────────────────────────────────────────
|
||||
|
||||
type
|
||||
LSWave = object
|
||||
originX, originY: float64
|
||||
bearing: float64 ## enemy -> us at the fire tick (centre line)
|
||||
speed: float64
|
||||
startDist: float64
|
||||
power: float64
|
||||
ticksLeft: int ## ticks until the nominal arrival
|
||||
fresh: bool ## created this tick: do not age it yet
|
||||
stateRow: int ## (vlat, dist) code
|
||||
stateCol: int ## (room, turn) code
|
||||
|
||||
LearnedSurferModule* = object
|
||||
sbc*: Sbc
|
||||
glob*: array[LS_BINS, int]
|
||||
glc: int ## learns since the last global-histogram decay
|
||||
scores: seq[float]
|
||||
waves: seq[LSWave]
|
||||
prevEnergy: seq[tuple[id: int, energy: float]]
|
||||
strafeDir: float64
|
||||
dir: float64 ## direction commanded last tick (+-1)
|
||||
prevX, prevY: float64 ## our position one tick ago
|
||||
prevHeading: float64
|
||||
debugGraphics*: bool
|
||||
decisions*: int ## decisions taken (diagnostic)
|
||||
|
||||
proc resetRound*(m: var LearnedSurferModule) =
|
||||
## Per-ROUND reset: the waves and the smoothed global prior are per round, but
|
||||
## the counted SBC memory is deliberately NOT wiped — the whole point of the
|
||||
## counted+decay mode is that the memory is bounded and decays on its own, so
|
||||
## it survives a round boundary without becoming a battle-long static average
|
||||
## (the j115 defect). Use `resetBattle` for a hard wipe.
|
||||
m.waves = @[]
|
||||
m.prevEnergy = @[]
|
||||
m.strafeDir = 1.0
|
||||
m.dir = 1.0
|
||||
m.prevX = 0.0
|
||||
m.prevY = 0.0
|
||||
m.prevHeading = 0.0
|
||||
m.decisions = 0
|
||||
for i in 0..<LS_BINS: m.glob[i] = 0
|
||||
m.glc = 0
|
||||
|
||||
proc initLearnedSurfer*(): LearnedSurferModule =
|
||||
result.debugGraphics = false
|
||||
result.sbc = initCountedSbc(LS_NADE, LS_BINS,
|
||||
max(0, LearnedDecayEvery),
|
||||
max(0, LearnedDecayShift))
|
||||
result.sbc.decayEvery = max(0, LearnedDecayEvery)
|
||||
result.sbc.decayShift = max(0, LearnedDecayShift)
|
||||
result.scores = newSeq[float](LS_BINS)
|
||||
result.resetRound()
|
||||
|
||||
proc resetBattle*(m: var LearnedSurferModule) =
|
||||
## Hard wipe of the learned memory (round the SBC's counters and `clear`).
|
||||
m.sbc.clear()
|
||||
m.resetRound()
|
||||
|
||||
proc clearGraphics*(m: var LearnedSurferModule) {.inline.} = discard
|
||||
proc removeBulletNear*(m: var LearnedSurferModule, x, y: float) {.inline.} = discard
|
||||
|
||||
# ── the learner ─────────────────────────────────────────────────────────────
|
||||
|
||||
proc prior(m: LearnedSurferModule, k: int): float =
|
||||
var tot = 0
|
||||
for g in m.glob: tot += g
|
||||
(float(m.glob[k]) + 1.0) / (float(tot) + float(LS_BINS))
|
||||
|
||||
proc learnWave(m: var LearnedSurferModule, row, col, bin: int) =
|
||||
## One supervised sample: (state) -> (GF bin at arrival), in the counted SBC.
|
||||
## The SBC applies its own global decay every `decayEvery` learns; the global
|
||||
## histogram below is aged on the same schedule so the two stay comparable.
|
||||
discard m.sbc.learn([row.int32], [col.int32], bin)
|
||||
if m.glob[bin] < 255: inc m.glob[bin]
|
||||
inc m.glc
|
||||
if LearnedDecayEvery > 0 and LearnedDecayShift > 0 and
|
||||
m.glc >= LearnedDecayEvery:
|
||||
for k in 0..<LS_BINS:
|
||||
m.glob[k] = m.glob[k] - (m.glob[k] shr LearnedDecayShift)
|
||||
m.glc = 0
|
||||
|
||||
proc predictState*(m: var LearnedSurferModule, row, col: int,
|
||||
p: var array[LS_BINS, float]) =
|
||||
## p(bin | state) — the counted SBC's per-cell posterior interpolated with the
|
||||
## global histogram. With no data at the cell this is exactly the old surfer.
|
||||
## `p` is filled with the prior FIRST, so the fallback costs nothing.
|
||||
var tot = 0
|
||||
for g in m.glob: tot += g
|
||||
let den = float(tot) + float(LS_BINS)
|
||||
for k in 0..<LS_BINS: p[k] = (float(m.glob[k]) + 1.0) / den
|
||||
if LearnedGlobal: return
|
||||
for k in 0..<LS_BINS: m.scores[k] = 0.0
|
||||
m.sbc.inferProb([row.int32], [col.int32], m.scores)
|
||||
var st = 0.0
|
||||
for k in 0..<LS_BINS: st += m.scores[k]
|
||||
if st <= 0.0: return
|
||||
for k in 0..<LS_BINS:
|
||||
p[k] = (m.scores[k] + LearnedAlpha * p[k]) / (1.0 + LearnedAlpha)
|
||||
|
||||
# ── fire detection + state ──────────────────────────────────────────────────
|
||||
|
||||
proc prevEnergyGet(m: LearnedSurferModule, id: int): float =
|
||||
for e in m.prevEnergy:
|
||||
if e.id == id: return e.energy
|
||||
100.0
|
||||
|
||||
proc prevEnergySet(m: var LearnedSurferModule, id: int, energy: float) =
|
||||
for i in 0..<m.prevEnergy.len:
|
||||
if m.prevEnergy[i].id == id:
|
||||
m.prevEnergy[i].energy = energy
|
||||
return
|
||||
m.prevEnergy.add((id: id, energy: energy))
|
||||
|
||||
proc detectFire(m: var LearnedSurferModule, id: int, ex, ey, eenergy: float,
|
||||
ws: WorldState) =
|
||||
## One enemy's energy sample. A plausible one-tick firepower drop IS a wave;
|
||||
## the state is the wave-relative state at THIS tick (the fire tick).
|
||||
let prev = m.prevEnergyGet(id)
|
||||
let drop = prev - eenergy
|
||||
m.prevEnergySet(id, eenergy)
|
||||
if drop < 0.09 or drop > 3.01: return
|
||||
|
||||
let botX = ws.selfX
|
||||
let botY = ws.selfY
|
||||
let bspeed = 20.0 - 3.0 * drop
|
||||
let d = hypot(botX - ex, botY - ey)
|
||||
let bearing = arctan2(botY - ey, botX - ex) # centre line
|
||||
let ux = cos(bearing)
|
||||
let uy = sin(bearing)
|
||||
|
||||
# vlat: lateral velocity in the wave frame. The centre line passes through us
|
||||
# at this tick, so lat(now) == 0 and vlat == -lat(prev).
|
||||
let dxp = m.prevX - ex
|
||||
let dyp = m.prevY - ey
|
||||
var vlat = -(dxp * (-uy) + dyp * ux)
|
||||
if m.prevX == 0.0 and m.prevY == 0.0 and m.prevHeading == 0.0: vlat = 0.0
|
||||
let roomDx = if vlat >= 0.0: -uy else: uy
|
||||
let roomDy = if vlat >= 0.0: ux else: -ux
|
||||
let room = roomToWall(botX, botY, roomDx, roomDy, ws.arenaWidth, ws.arenaHeight)
|
||||
let turn = wrap180(float(ws.selfHeading) - float(m.prevHeading))
|
||||
|
||||
let row = code(vlat, VlatEdges) * LS_Q + code(d, DistEdges)
|
||||
let col = code(room, RoomEdges) * LS_Q + code(turn, TurnEdges)
|
||||
|
||||
m.waves.add LSWave(
|
||||
originX: ex, originY: ey, bearing: bearing, speed: bspeed,
|
||||
startDist: d, power: drop,
|
||||
ticksLeft: max(1, int(ceil(d / max(bspeed, 1e-9)))),
|
||||
fresh: true,
|
||||
stateRow: row, stateCol: col,
|
||||
)
|
||||
|
||||
# ── the mover ───────────────────────────────────────────────────────────────
|
||||
|
||||
proc computeMove*(m: var LearnedSurferModule, ws: WorldState): MoveCommand =
|
||||
let botX = ws.selfX
|
||||
let botY = ws.selfY
|
||||
|
||||
# ── 1. fire detection (every alive enemy, per-enemy energy) ───────────────
|
||||
var seen = 0
|
||||
for ei in ws.enemies:
|
||||
inc seen
|
||||
m.detectFire(ei.id, ei.x, ei.y, ei.energy, ws)
|
||||
if seen == 0 and (ws.enemyX != 0.0 or ws.enemyY != 0.0):
|
||||
m.detectFire(-1, ws.enemyX, ws.enemyY, ws.enemyEnergy, ws)
|
||||
|
||||
# ── 2. advance + resolve waves; every resolution is a training sample ────
|
||||
var i = 0
|
||||
while i < m.waves.len:
|
||||
if m.waves[i].fresh:
|
||||
m.waves[i].fresh = false # created this tick: not one tick old yet
|
||||
else:
|
||||
dec m.waves[i].ticksLeft
|
||||
if m.waves[i].ticksLeft <= 0:
|
||||
let w = m.waves[i]
|
||||
let maxA = mea(w.speed)
|
||||
if maxA >= 1e-9:
|
||||
let off = wrapPi(arctan2(botY - w.originY, botX - w.originX) - w.bearing)
|
||||
let bin = gfToBin(clamp(off / maxA, -1.0, 1.0))
|
||||
m.learnWave(w.stateRow, w.stateCol, bin)
|
||||
if LearnedLog:
|
||||
echo "[learned] resolve bin=", bin, " state=", w.stateRow, "/",
|
||||
w.stateCol, " d=", w.startDist.int, " e=", w.originX.int, ",",
|
||||
w.originY.int
|
||||
m.waves.del(i)
|
||||
else:
|
||||
inc i
|
||||
|
||||
# ── 3. danger of every candidate bin, summed over every live wave ────────
|
||||
var ps: seq[array[LS_BINS, float]]
|
||||
ps.setLen(m.waves.len)
|
||||
for j in 0..<m.waves.len:
|
||||
m.predictState(m.waves[j].stateRow, m.waves[j].stateCol, ps[j])
|
||||
|
||||
# nearest wave = largest radius/distance ratio (the one about to arrive)
|
||||
var nearest = -1
|
||||
var bestRatio = -1.0
|
||||
for j in 0..<m.waves.len:
|
||||
let dd = max(1e-6, hypot(botX - m.waves[j].originX, botY - m.waves[j].originY))
|
||||
let done = 1.0 - float(m.waves[j].ticksLeft) /
|
||||
float(max(1, int(ceil(m.waves[j].startDist /
|
||||
max(m.waves[j].speed, 1e-9)))))
|
||||
let ratio = done / dd
|
||||
if ratio > bestRatio:
|
||||
bestRatio = ratio
|
||||
nearest = j
|
||||
|
||||
var perpAngle = 0.0
|
||||
|
||||
if nearest >= 0:
|
||||
let w = m.waves[nearest]
|
||||
let dx = botX - w.originX
|
||||
let dy = botY - w.originY
|
||||
let d = max(1.0, hypot(dx, dy))
|
||||
let toBot = arctan2(dy, dx)
|
||||
let maxA = mea(w.speed)
|
||||
let curGF = if maxA >= 1e-9:
|
||||
clamp(wrapPi(toBot - w.bearing) / maxA, -1.0, 1.0)
|
||||
else: 0.0
|
||||
let dodgeDist = max(MaxBotSpeed, ws.selfSpeed) * DodgeTicks
|
||||
|
||||
var bestBin = gfToBin(curGF)
|
||||
var bestDanger = Inf
|
||||
var bestDirs: array[LS_BINS, float]
|
||||
|
||||
for j in 0..<LS_BINS:
|
||||
let gfJ = binToGF(j)
|
||||
let angleJ = w.bearing + gfJ * maxA
|
||||
let px = w.originX + cos(angleJ) * d
|
||||
let py = w.originY + sin(angleJ) * d
|
||||
var ux = px - botX
|
||||
var uy = py - botY
|
||||
let ul = hypot(ux, uy)
|
||||
if ul < 1e-6:
|
||||
ux = cos(perpAngle); uy = sin(perpAngle)
|
||||
else:
|
||||
ux /= ul; uy /= ul
|
||||
let futureX = botX + ux * dodgeDist
|
||||
let futureY = botY + uy * dodgeDist
|
||||
bestDirs[j] = if gfJ >= curGF: 1.0 else: -1.0
|
||||
|
||||
var danger = ps[nearest][j]
|
||||
# every OTHER live wave contributes its own predicted mass at the bin the
|
||||
# candidate direction would land in for THAT wave.
|
||||
for k in 0..<m.waves.len:
|
||||
if k == nearest: continue
|
||||
let w2 = m.waves[k]
|
||||
let d2 = max(1.0, hypot(futureX - w2.originX, futureY - w2.originY))
|
||||
let mA2 = mea(w2.speed)
|
||||
if mA2 < 1e-9: continue
|
||||
let off2 = wrapPi(arctan2(futureY - w2.originY, futureX - w2.originX) -
|
||||
w2.bearing)
|
||||
let b2 = gfToBin(clamp(off2 / mA2, -1.0, 1.0))
|
||||
danger += ps[k][b2]
|
||||
|
||||
let wallHit = futureX < LearnedWallMargin or
|
||||
futureX > ws.arenaWidth - LearnedWallMargin or
|
||||
futureY < LearnedWallMargin or
|
||||
futureY > ws.arenaHeight - LearnedWallMargin
|
||||
if wallHit: danger *= 5.0
|
||||
danger += LearnedTravel * abs(gfJ - curGF)
|
||||
if bestDirs[j] != m.dir: danger += LearnedReversal
|
||||
|
||||
if danger < bestDanger:
|
||||
bestDanger = danger
|
||||
bestBin = j
|
||||
|
||||
let bestGF = binToGF(bestBin)
|
||||
m.strafeDir = bestDirs[bestBin]
|
||||
m.dir = m.strafeDir
|
||||
inc m.decisions
|
||||
if LearnedLog:
|
||||
var pAvg = 0.0
|
||||
for k in 0..<LS_BINS: pAvg += ps[nearest][k] / float(LS_BINS)
|
||||
echo "[learned] wave d=", d.int, " curGF=", (curGF * 100.0).int,
|
||||
" bestGF=", (bestGF * 100.0).int, " dir=", m.strafeDir.int,
|
||||
" pSafe=", (ps[nearest][bestBin] * 1000.0).int,
|
||||
" pCur=", (ps[nearest][gfToBin(curGF)] * 1000.0).int,
|
||||
" pAvg=", (pAvg * 1000.0).int,
|
||||
" state=", m.waves[nearest].stateRow, "/", m.waves[nearest].stateCol
|
||||
# perpendicular in the wave's own frame: +90 deg from origin->bot increases
|
||||
# the GF (CCW), -90 decreases it.
|
||||
perpAngle = if m.strafeDir >= 0.0: toBot + PI * 0.5
|
||||
else: toBot - PI * 0.5
|
||||
elif ws.enemyX != 0.0 or ws.enemyY != 0.0:
|
||||
let toBot = arctan2(botY - ws.enemyY, botX - ws.enemyX)
|
||||
perpAngle = if m.strafeDir >= 0.0: toBot + PI * 0.5
|
||||
else: toBot - PI * 0.5
|
||||
else:
|
||||
perpAngle = degToRad(ws.selfHeading)
|
||||
|
||||
# ── 4. never drive into a wall ───────────────────────────────────────────
|
||||
let nearLeft = botX < LearnedWallMargin
|
||||
let nearRight = botX > ws.arenaWidth - LearnedWallMargin
|
||||
let nearBottom = botY < LearnedWallMargin
|
||||
let nearTop = botY > ws.arenaHeight - LearnedWallMargin
|
||||
if nearLeft or nearRight or nearBottom or nearTop:
|
||||
let px = cos(perpAngle)
|
||||
let py = sin(perpAngle)
|
||||
if (nearLeft and px < 0.0) or (nearRight and px > 0.0) or
|
||||
(nearBottom and py < 0.0) or (nearTop and py > 0.0):
|
||||
m.strafeDir = -m.strafeDir
|
||||
m.dir = m.strafeDir
|
||||
perpAngle = perpAngle + PI
|
||||
let escapeAngle = arctan2(ws.arenaHeight * 0.5 - botY,
|
||||
ws.arenaWidth * 0.5 - botX)
|
||||
let ex = cos(escapeAngle) + cos(perpAngle)
|
||||
let ey = sin(escapeAngle) + sin(perpAngle)
|
||||
perpAngle = arctan2(ey, ex)
|
||||
|
||||
# ── 5. distance control outside the deadband ────────────────────────────
|
||||
let enemyDist = hypot(ws.enemyX - botX, ws.enemyY - botY)
|
||||
let distErr = enemyDist - LearnedPrefDist
|
||||
let radialFrac =
|
||||
if distErr > LearnedDistBand: LearnedRadialFrac # too far -> approach
|
||||
elif distErr < -LearnedDistBand: -LearnedRadialFrac # too close -> retreat
|
||||
else: 0.0
|
||||
if abs(radialFrac) > 1e-9:
|
||||
let radialAngle = arctan2(ws.enemyY - botY, ws.enemyX - botX) +
|
||||
(if radialFrac < 0.0: PI else: 0.0)
|
||||
let rx = cos(perpAngle) * (1.0 - abs(radialFrac)) +
|
||||
cos(radialAngle) * abs(radialFrac)
|
||||
let ry = sin(perpAngle) * (1.0 - abs(radialFrac)) +
|
||||
sin(radialAngle) * abs(radialFrac)
|
||||
perpAngle = arctan2(ry, rx)
|
||||
|
||||
# ── 6. steer the body, full speed ───────────────────────────────────────
|
||||
let desiredDeg = radToDeg(perpAngle)
|
||||
var delta = desiredDeg - ws.selfHeading
|
||||
while delta > 180.0: delta -= 360.0
|
||||
while delta < -180.0: delta += 360.0
|
||||
let goForward = abs(delta) <= 90.0
|
||||
if not goForward:
|
||||
delta = if delta >= 0.0: delta - 180.0 else: delta + 180.0
|
||||
|
||||
m.prevX = botX
|
||||
m.prevY = botY
|
||||
m.prevHeading = float(ws.selfHeading)
|
||||
|
||||
(speed: (if goForward: 8.0 else: -8.0),
|
||||
turnRate: delta.clamp(-10.0, 10.0))
|
||||
Reference in New Issue
Block a user