## 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; ponytail: fixed, expose if tuning needed PatternRadOffsetEnvVar* = "TR_PATTERN_RAD_OFFSET" ## px; negative = aim short PatternRadScaleEnvVar* = "TR_PATTERN_RAD_SCALE" ## multiplier on aim distance 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 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 # ── 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.lastMatchScore = Inf if g.count < PatternLen * 2: return -1 # key = last PatternLen entries let keyStart = g.count - PatternLen # scan backwards for best match (exclude the key itself) var bestScore = Inf var bestMatch = -1 let scanEnd = g.count - PatternLen - 1 # last valid match start for i in countdown(scanEnd, 0): var score = 0.0 for k in 0 ..< PatternLen: 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 + 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 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.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