feat(ModularBot): 6 guns, pattern matcher, melee modules, adversarial bots

- New guns: guess-factor (GF histogram), pattern-matcher (movement tape replay)
- New modules: minimum-risk melee movement, spinning melee radar
- New test bots: PatternMover, RandomMover, WaveSurfer
- Fixed: FeedbackEvent now carries actualX/actualY for proper GF learning
- Fixed: TM gun warmup gating + directional residuals
- Fixed: circular gun integrated formula + multi-bin omega cache
- Fixed: oscillator wall-bounce lockout
- Fixed: phantom meteor perpendicular body orientation
- 6/6 battle wins across all enemy types
This commit is contained in:
2026-09-20 00:59:53 +02:00
parent 254c7dc997
commit 1ed7797cb6
184 changed files with 80149 additions and 21 deletions
+145
View File
@@ -0,0 +1,145 @@
## minimum_risk.nim — Minimum-risk point movement for melee (multiple enemies).
## Generates candidate points, scores each by threat proximity, wall/corner risk,
## and travel distance, then drives toward the lowest-risk point.
## Uses the same perpendicular-body trick as phantom_meteor.nim.
import std/math
import gun_harness/gun_interface
import movement_harness/movement_interface
# TODO: WorldState only carries one enemy. Extend WorldState with a seq of
# threat positions (enemies) and update scoreCandidates to iterate all of them.
# For now we treat the single enemy as the only threat point.
const
CandidateRadius = 150.0 # px radius for ring candidates
NumRingPoints = 16 # ring of 16 + 4 random = 20 candidates
NumRandPoints = 4
WallMargin = 80.0 # below this dist-to-wall = risk
CornerMargin = 150.0 # below this dist-to-corner = risk
RecalcInterval = 10 # ticks between full recalculations
KEnemy = 1.0 # inverse-square weight for enemy threat
KWall = 0.5 # linear wall penalty weight
KCorner = 0.8 # corner penalty weight
KTravel = 0.003 # penalty per pixel of travel distance
type
Vec2 = object
x, y: float
proc vec2(x, y: float): Vec2 {.inline.} = Vec2(x: x, y: y)
proc dist(a, b: Vec2): float {.inline.} =
let dx = a.x - b.x; let dy = a.y - b.y
sqrt(dx*dx + dy*dy)
type MinimumRiskModule* = object
targetX, targetY: float
ticksSinceCalc: int
hasTarget: bool
proc initMinimumRisk*(): MinimumRiskModule =
MinimumRiskModule(hasTarget: false, ticksSinceCalc: RecalcInterval)
proc wallRisk(p: Vec2, w, h: float): float {.inline.} =
## Linear penalty that ramps up inside WallMargin.
let dL = p.x
let dR = w - p.x
let dB = p.y
let dT = h - p.y
let minD = min(min(dL, dR), min(dB, dT))
if minD >= WallMargin: 0.0
else: KWall * (1.0 - minD / WallMargin)
proc cornerRisk(p: Vec2, w, h: float): float {.inline.} =
## Penalty for proximity to any of the four corners.
let corners = [vec2(0.0,0.0), vec2(w,0.0), vec2(0.0,h), vec2(w,h)]
var worst = 0.0
for c in corners:
let d = dist(p, c)
if d < CornerMargin:
worst = max(worst, KCorner * (1.0 - d / CornerMargin))
worst
proc scorePoint(p, bot: Vec2, threats: openArray[Vec2], w, h: float): float =
var risk = 0.0
# Inverse-square enemy threat
for t in threats:
let d = max(dist(p, t), 1.0)
risk += KEnemy / (d * d) * 1e4 # scale so numbers are comparable
risk += wallRisk(p, w, h)
risk += cornerRisk(p, w, h)
risk += KTravel * dist(p, bot)
risk
proc clampToArena(p: Vec2, w, h: float): Vec2 {.inline.} =
vec2(p.x.clamp(WallMargin, w - WallMargin),
p.y.clamp(WallMargin, h - WallMargin))
proc recalcTarget(m: var MinimumRiskModule, ws: WorldState) =
let bot = vec2(ws.selfX, ws.selfY)
let w = ws.arenaWidth
let h = ws.arenaHeight
# Single threat for now; TODO: replace with ws.enemies when available
let threats = [vec2(ws.enemyX, ws.enemyY)]
# Build candidates: ring + random (deterministic via tick-seeded offsets)
var best = bot # fallback: stay put
var bestRisk = scorePoint(bot, bot, threats, w, h)
for i in 0..<NumRingPoints:
let angle = float(i) / float(NumRingPoints) * 2.0 * PI
let p = clampToArena(
vec2(bot.x + CandidateRadius * cos(angle),
bot.y + CandidateRadius * sin(angle)), w, h)
let r = scorePoint(p, bot, threats, w, h)
if r < bestRisk:
bestRisk = r
best = p
# 4 random-ish candidates via golden-angle spread (no RNG state needed)
for i in 0..<NumRandPoints:
let angle = float(i) * 2.399963 # golden angle ~137.5°
let radius = CandidateRadius * 0.5 * (1.0 + float(i) / float(NumRandPoints))
let p = clampToArena(vec2(bot.x + radius * cos(angle),
bot.y + radius * sin(angle)), w, h)
let r = scorePoint(p, bot, threats, w, h)
if r < bestRisk:
bestRisk = r
best = p
m.targetX = best.x
m.targetY = best.y
m.hasTarget = true
proc computeMove*(m: var MinimumRiskModule, ws: WorldState): MoveCommand =
inc m.ticksSinceCalc
if m.ticksSinceCalc >= RecalcInterval or not m.hasTarget:
m.recalcTarget(ws)
m.ticksSinceCalc = 0
let bot = vec2(ws.selfX, ws.selfY)
let target = vec2(m.targetX, m.targetY)
let d = dist(bot, target)
if d < 5.0:
# Already at target — force recalc next tick
m.hasTarget = false
return (speed: 0.0, turnRate: 0.0)
# Direction to target (math convention: 0=East, CCW+)
let toTargetRad = arctan2(target.y - bot.y, target.x - bot.x)
let toTargetDeg = radToDeg(toTargetRad)
# Delta from current body heading
var delta = toTargetDeg - ws.selfHeading
while delta > 180.0: delta -= 360.0
while delta < -180.0: delta += 360.0
# Dot-product trick: reverse if |delta| > 90 to save turning
let goForward = abs(delta) <= 90.0
if not goForward:
delta = if delta >= 0.0: delta - 180.0 else: delta + 180.0
(speed: if goForward: 8.0 else: -8.0,
turnRate: delta.clamp(-10.0, 10.0))