292 lines
13 KiB
Nim
292 lines
13 KiB
Nim
## Pattern-matching gun: searches movement history for a matching sequence,
|
|
## then plays it forward to predict future position.
|
|
## Reference: https://robowiki.net/wiki/Pattern_Matching
|
|
## Coordinate system: 0° = East, CCW positive (Tank Royale standard).
|
|
##
|
|
## RADIAL OFFSET KNOB (`TR_PATTERN_RAD_OFFSET` / `TR_PATTERN_RAD_SCALE`):
|
|
## the measured base-linear forecast systematically OVERSHOOTS range on these
|
|
## range-holding surfers (commit 9cd6e9b), so this gun exposes a constant radial
|
|
## correction on the predicted aim point: the BEARING is untouched, the aim
|
|
## DISTANCE becomes `dist * scale + offsetPx`. Both default to (1.0, 0.0), and
|
|
## the default path returns the raw prediction UNCHANGED — byte-for-byte, so an
|
|
## unset environment cannot perturb the shipped gun. See
|
|
## `common_libs/tests/test_pattern_radial_offset.nim` for the parity proof and
|
|
## `common_libs/tests/measure_pattern_radial.nim` for the measurement.
|
|
|
|
import std/[math, os, strutils]
|
|
import gun_harness/gun_interface
|
|
|
|
const
|
|
HistorySize* = 500
|
|
PatternLen* = 10 # ticks used as search key; shipped default of TR_PATTERN_LEN
|
|
## Shipped defaults of the two MATCH-SHAPE runtime knobs (Task A, gun j123).
|
|
## `TR_PATTERN_LEN` is the length of the movement segment compared (the
|
|
## search key); `TR_PATTERN_DEPTH` is how many recent history entries the
|
|
## matcher is allowed to scan. Both default to the exact pre-knob constants,
|
|
## so an unset environment reproduces today's gun byte-for-byte.
|
|
PatternDepth* = HistorySize # max history entries scanned; default = full buffer
|
|
PatternRadOffsetEnvVar* = "TR_PATTERN_RAD_OFFSET" ## px; negative = aim short
|
|
PatternRadScaleEnvVar* = "TR_PATTERN_RAD_SCALE" ## multiplier on aim distance
|
|
PatternLenEnvVar* = "TR_PATTERN_LEN" ## search-key length (ticks)
|
|
## History/replay depth: the search never looks further back than this many
|
|
## entries. The buffer itself is a fixed `array[HistorySize]`, so the useful
|
|
## range is 1..HistorySize; the ceiling cannot be raised at runtime.
|
|
PatternDepthEnvVar* = "TR_PATTERN_DEPTH"
|
|
|
|
proc patternEnvFloat(name: string, default: float): float =
|
|
## Read an env knob like every other runtime switch in the harness: unset or
|
|
## unparsable falls back to the shipped default, so a typo cannot move the gun.
|
|
let v = getEnv(name, "")
|
|
if v.len == 0: return default
|
|
try: parseFloat(v.strip())
|
|
except ValueError: default
|
|
|
|
proc patternEnvInt(name: string, default: int): int =
|
|
## Integer twin of `patternEnvFloat`: unset or unparsable -> shipped default.
|
|
let v = getEnv(name, "")
|
|
if v.len == 0: return default
|
|
try: parseInt(v.strip())
|
|
except ValueError: default
|
|
|
|
proc clampInt(v, lo, hi: int): int {.inline.} =
|
|
result = v
|
|
if result < lo: result = lo
|
|
if result > hi: result = hi
|
|
|
|
type
|
|
MoveTick = object
|
|
velocity: float ## signed speed (px/tick)
|
|
headingDelta: float ## heading change in radians this tick
|
|
|
|
PatternMatcherGun* = object
|
|
buf: array[HistorySize, MoveTick]
|
|
head: int ## next write index (circular)
|
|
count: int ## filled entries (capped at HistorySize)
|
|
prevHeading: float
|
|
prevSpeed: float
|
|
prevTick: int
|
|
hasPrev: bool
|
|
# Per-tick, speed-INDEPENDENT pattern state. Both the history search and the
|
|
# replayed enemy path depend only on observed movement, never on bulletSpeed,
|
|
# so they are built at most once per tick. The bullet lead (number of replay
|
|
# steps + coast) is derived from this path on every call.
|
|
cacheValid: bool
|
|
cacheTick: int
|
|
bestMatch: int ## -1 = no usable match (linear fallback)
|
|
lastMatchScore*: float ## best pattern-match cost (lower = better);
|
|
## set by findBestMatch, exposed as the gun's
|
|
## intrinsic per-sample confidence
|
|
## (TMComposites gate, docs/tmcomposites_gate.md)
|
|
playStart: int
|
|
playAvail: int
|
|
pathX: array[HistorySize + 1, float]
|
|
pathY: array[HistorySize + 1, float]
|
|
pathHeading: array[HistorySize + 1, float] ## radians after s steps
|
|
pathSpeed: array[HistorySize + 1, float] ## speed after s steps
|
|
# ── match-shape knobs (defaults leave the prediction byte-identical) ──
|
|
patternLen*: int ## search-key length actually used (default PatternLen)
|
|
histDepth*: int ## history entries actually scanned (default full)
|
|
paramConfigured*: bool ## true once the env/setter has populated the two above
|
|
# ── radial offset knob (defaults leave the prediction byte-identical) ──
|
|
radScale*: float ## multiplier on the predicted aim distance
|
|
radOffset*: float ## px added to the predicted aim distance
|
|
radConfigured*: bool ## true once the env/setter has populated the two above
|
|
debugGraphics*: bool
|
|
|
|
# --- circular buffer helpers ---
|
|
|
|
proc write(g: var PatternMatcherGun, m: MoveTick) {.inline.} =
|
|
g.buf[g.head] = m
|
|
g.head = (g.head + 1) mod HistorySize
|
|
if g.count < HistorySize: inc g.count
|
|
|
|
proc readAt(g: PatternMatcherGun, i: int): MoveTick {.inline.} =
|
|
## i = 0 is oldest, i = count-1 is newest
|
|
g.buf[(g.head - g.count + i + HistorySize * 2) mod HistorySize]
|
|
|
|
# --- linear fallback (same style as linear.nim) ---
|
|
|
|
proc linearPredict(state: WorldState, bulletSpeed: float): (float, float) =
|
|
let dist = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
|
let t = dist / bulletSpeed
|
|
let hRad = degToRad(state.enemyHeading)
|
|
let ex = clamp(state.enemyX + cos(hRad) * state.enemySpeed * t,
|
|
BotRadius, state.arenaWidth - BotRadius)
|
|
let ey = clamp(state.enemyY + sin(hRad) * state.enemySpeed * t,
|
|
BotRadius, state.arenaHeight - BotRadius)
|
|
(ex, ey)
|
|
|
|
# --- pattern search + play-forward ---
|
|
|
|
proc findBestMatch(g: var PatternMatcherGun): int =
|
|
## Speed-independent history search. Returns the start index of the best
|
|
## matching pattern, or -1 when there is not enough history. Stores the best
|
|
## match cost in `g.lastMatchScore` for the confidence readout.
|
|
##
|
|
## `g.patternLen` (TR_PATTERN_LEN) is the key length; `g.histDepth`
|
|
## (TR_PATTERN_DEPTH) bounds how far back the scan may reach. With the shipped
|
|
## defaults (10, HistorySize) this is the exact pre-knob loop.
|
|
let plen = g.patternLen
|
|
g.lastMatchScore = Inf
|
|
if g.count < plen * 2:
|
|
return -1
|
|
|
|
# key = last plen entries
|
|
let keyStart = g.count - plen
|
|
|
|
# scan backwards for best match (exclude the key itself), but never further
|
|
# back than `histDepth` entries; when count <= histDepth this floor is 0 and
|
|
# the loop is identical to the original.
|
|
var bestScore = Inf
|
|
var bestMatch = -1
|
|
let scanEnd = g.count - plen - 1 # last valid match start
|
|
let scanFloor = max(0, g.count - g.histDepth)
|
|
if scanEnd < scanFloor:
|
|
return -1
|
|
for i in countdown(scanEnd, scanFloor):
|
|
var score = 0.0
|
|
for k in 0 ..< plen:
|
|
let a = g.readAt(keyStart + k)
|
|
let b = g.readAt(i + k)
|
|
let dv = a.velocity - b.velocity
|
|
let dh = a.headingDelta - b.headingDelta
|
|
score += dv * dv + dh * dh
|
|
if score < bestScore:
|
|
bestScore = score
|
|
bestMatch = i
|
|
g.lastMatchScore = bestScore
|
|
bestMatch
|
|
|
|
proc buildPath(g: var PatternMatcherGun, state: WorldState, bestMatch: int) =
|
|
## Precompute the matched pattern replayed forward from the current state.
|
|
## Only depends on observed movement, so it is valid for every power bin.
|
|
g.playStart = bestMatch + g.patternLen
|
|
g.playAvail = g.count - 1 - g.playStart # ticks we can replay
|
|
g.pathX[0] = state.enemyX
|
|
g.pathY[0] = state.enemyY
|
|
g.pathHeading[0] = degToRad(state.enemyHeading)
|
|
g.pathSpeed[0] = state.enemySpeed
|
|
for s in 1 .. g.playAvail:
|
|
let m = g.readAt(g.playStart + s - 1)
|
|
g.pathHeading[s] = g.pathHeading[s - 1] + m.headingDelta
|
|
g.pathSpeed[s] = m.velocity
|
|
g.pathX[s] = g.pathX[s - 1] + cos(g.pathHeading[s]) * g.pathSpeed[s]
|
|
g.pathY[s] = g.pathY[s - 1] + sin(g.pathHeading[s]) * g.pathSpeed[s]
|
|
|
|
proc projectFromPath(g: PatternMatcherGun, state: WorldState,
|
|
bulletSpeed: float): (float, float) =
|
|
## Speed-dependent lead: walk the cached path as far as this bulletSpeed's
|
|
## estimated flight time reaches, then coast linearly for the remainder.
|
|
# iterative time estimate
|
|
let dist0 = hypot(state.enemyX - state.selfX, state.enemyY - state.selfY)
|
|
var t = dist0 / bulletSpeed
|
|
var ex = state.enemyX
|
|
var ey = state.enemyY
|
|
|
|
for _ in 0..4:
|
|
let steps = min(int(t + 0.5), g.playAvail)
|
|
ex = g.pathX[steps]
|
|
ey = g.pathY[steps]
|
|
# if we ran out of replay data, coast linearly from last simulated pos
|
|
let remaining = t - steps.float
|
|
if remaining > 0.0:
|
|
ex += cos(g.pathHeading[steps]) * g.pathSpeed[steps] * remaining
|
|
ey += sin(g.pathHeading[steps]) * g.pathSpeed[steps] * remaining
|
|
let ndx = ex - state.selfX
|
|
let ndy = ey - state.selfY
|
|
t = sqrt(ndx * ndx + ndy * ndy) / bulletSpeed
|
|
|
|
ex = clamp(ex, BotRadius, state.arenaWidth - BotRadius)
|
|
ey = clamp(ey, BotRadius, state.arenaHeight - BotRadius)
|
|
(ex, ey)
|
|
|
|
# --- Gun interface ---
|
|
|
|
proc ensureRadialConfig(g: var PatternMatcherGun) {.inline.} =
|
|
## Lazily read the radial env knobs on first use, so the live binary can be
|
|
## re-tuned with `TR_PATTERN_RAD_*` and a unit test can poke the env in-process.
|
|
if g.radConfigured: return
|
|
g.radScale = patternEnvFloat(PatternRadScaleEnvVar, 1.0)
|
|
g.radOffset = patternEnvFloat(PatternRadOffsetEnvVar, 0.0)
|
|
g.radConfigured = true
|
|
|
|
proc ensureParamConfig(g: var PatternMatcherGun) {.inline.} =
|
|
## Lazily read the match-shape env knobs on first use, mirroring the radial
|
|
## knobs: the shipped binary reads the env once per gun instance.
|
|
if g.paramConfigured: return
|
|
g.patternLen = clampInt(patternEnvInt(PatternLenEnvVar, PatternLen), 1, HistorySize)
|
|
g.histDepth = clampInt(patternEnvInt(PatternDepthEnvVar, PatternDepth), 1, HistorySize)
|
|
g.paramConfigured = true
|
|
|
|
proc setMatchParams*(g: var PatternMatcherGun, patternLen, histDepth: int) =
|
|
## Explicit per-gun override used by offline sweeps/tests. Writes the same
|
|
## fields the env path writes, so the measured code path is identical.
|
|
g.patternLen = clampInt(patternLen, 1, HistorySize)
|
|
g.histDepth = clampInt(histDepth, 1, HistorySize)
|
|
g.paramConfigured = true
|
|
|
|
proc setRadialCorrection*(g: var PatternMatcherGun, scale, offsetPx: float) =
|
|
## Explicit per-gun override used by the offline sweep. Writes the same fields
|
|
## the env path writes, so the measured code path is identical.
|
|
g.radScale = scale
|
|
g.radOffset = offsetPx
|
|
g.radConfigured = true
|
|
|
|
proc applyRadial(g: var PatternMatcherGun, state: WorldState,
|
|
px, py: float): GunPrediction =
|
|
## Scale/shift the aim DISTANCE along the (unchanged) base bearing. The
|
|
## (1.0, 0.0) case returns the raw point, so the shipped default is
|
|
## byte-identical to the pre-knob gun. `radOffset` may not pull the aim point
|
|
## behind the shooter; the distance is floored at 0.
|
|
g.ensureRadialConfig()
|
|
if g.radScale == 1.0 and g.radOffset == 0.0:
|
|
return GunPrediction(x: px, y: py)
|
|
let dx = px - state.selfX
|
|
let dy = py - state.selfY
|
|
let d = hypot(dx, dy)
|
|
if d < 1e-9:
|
|
return GunPrediction(x: px, y: py)
|
|
let nd = max(0.0, d * g.radScale + g.radOffset)
|
|
GunPrediction(x: state.selfX + dx / d * nd, y: state.selfY + dy / d * nd)
|
|
|
|
proc predict*(g: var PatternMatcherGun, state: WorldState,
|
|
bulletSpeed: float): GunPrediction =
|
|
if bulletSpeed <= 0.0:
|
|
return GunPrediction(x: state.enemyX, y: state.enemyY)
|
|
|
|
# Roll history forward and (re)build the speed-independent pattern path at
|
|
# most once per tick. The old code returned a single cached (x, y) per tick,
|
|
# so all four power bins shared bin 0's lead.
|
|
if not g.cacheValid or state.tick != g.cacheTick:
|
|
if g.hasPrev:
|
|
var dh = degToRad(state.enemyHeading) - degToRad(g.prevHeading)
|
|
# wrap to [-π, π]
|
|
while dh > PI: dh -= 2.0 * PI
|
|
while dh < -PI: dh += 2.0 * PI
|
|
g.write(MoveTick(velocity: g.prevSpeed, headingDelta: dh))
|
|
|
|
g.prevHeading = state.enemyHeading
|
|
g.prevSpeed = state.enemySpeed
|
|
g.prevTick = state.tick
|
|
g.hasPrev = true
|
|
g.cacheValid = true
|
|
g.cacheTick = state.tick
|
|
|
|
g.ensureParamConfig()
|
|
g.bestMatch = g.findBestMatch()
|
|
if g.bestMatch >= 0:
|
|
g.buildPath(state, g.bestMatch)
|
|
|
|
if g.bestMatch < 0:
|
|
let (px, py) = linearPredict(state, bulletSpeed)
|
|
return g.applyRadial(state, px, py)
|
|
|
|
let (px, py) = g.projectFromPath(state, bulletSpeed)
|
|
result = g.applyRadial(state, px, py)
|
|
# Match quality as a confidence: a perfect historical match (cost 0) gives 1.0,
|
|
# a worse match decays toward 0. Deterministic and per-sample.
|
|
result.confidence = 1.0 / (1.0 + max(0.0, g.lastMatchScore))
|
|
|
|
proc onResult*(g: var PatternMatcherGun, e: FeedbackEvent) =
|
|
discard # pattern matcher learns from movement observation, not feedback
|