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
+394
View File
@@ -0,0 +1,394 @@
"""
Multi-enemy comparison: Linear+Hebbian vs WiSARD K=12 vs TsetlinBot (RTM).
Uses 181-col decimal CSVs (4 enemies: crazy, spinbot, target, walls).
Online learning, all predictors share state across battles of the same enemy.
Rep power: p1.07 for learning signal; all powers reported in summary.
"""
import csv, math, os, random, glob
from collections import defaultdict
DATA_DIR = os.path.join(os.path.dirname(__file__), "../data")
OUT_PATH = os.path.join(os.path.dirname(__file__), "battle_comparison.txt")
POWER_LEVELS = [0.10, 0.42, 0.74, 1.07, 1.39, 1.71, 2.03, 2.36, 2.68, 3.00]
POWER_STRS = ["p0.10","p0.42","p0.74","p1.07","p1.39","p1.71","p2.03","p2.36","p2.68","p3.00"]
REP_PS, REP_POWER = "p1.07", 1.07
MAX_DIST = 1414.0
HIT_RADIUS = 18.0 # approx half tank width in pixels (0-99 scale ≈ arena/14)
# ── encoding ───────────────────────────────────────────────────────────────────
def to_bits(v, n):
g = int(v) ^ (int(v) >> 1)
return [(g >> (n - 1 - i)) & 1 for i in range(n)]
def row_to_binary(row, n_frames=4):
"""Build n_frames*69-bit input. 181-col CSVs have x/y as 0-99 int."""
bits = []
for fi in range(n_frames):
p = f"f{fi}_"
bits += to_bits(int(row[p+"bearing_sin"]), 8)
bits += to_bits(int(row[p+"bearing_cos"]), 8)
bits += to_bits(int(row[p+"distance"]), 7)
bits += to_bits(int(row[p+"velocity"]), 5)
bits += to_bits(int(row[p+"heading_sin"]), 8)
bits += to_bits(int(row[p+"heading_cos"]), 8)
bits += to_bits(int(row[p+"enemy_x"]), 7)
bits += to_bits(int(row[p+"enemy_y"]), 7)
bits += to_bits(min(int(row[p+"enemy_energy"]), 2046), 11)
return bits # 4*69 = 276 bits
# ── WiSARD K=12, 276 bits ──────────────────────────────────────────────────────
class WiSARD:
def __init__(self, n_bits=276, k=12, seed=42):
self.k = k
n_pad = math.ceil(n_bits / k) * k
self.n_nodes = n_pad // k
rng = random.Random(seed)
idx = list(range(n_bits)) + [0] * (n_pad - n_bits)
rng.shuffle(idx)
self.perm = idx
self.tables = [{} for _ in range(self.n_nodes)] # sparse dicts
def _addrs(self, bits):
out = []
for nd in range(self.n_nodes):
a = 0
for b in range(self.k):
pi = nd * self.k + b
a = (a << 1) | (bits[self.perm[pi]] if pi < len(bits) else 0)
out.append(a)
return out
def predict(self, bits):
addrs = self._addrs(bits)
sx = sy = cnt = 0.0
for nd, a in enumerate(addrs):
e = self.tables[nd].get(a)
if e and e[2] > 0:
sx += e[0] / e[2]; sy += e[1] / e[2]; cnt += 1
return (sx/cnt, sy/cnt) if cnt else (0.0, 0.0)
def learn(self, bits, dx, dy):
for nd, a in enumerate(self._addrs(bits)):
e = self.tables[nd].get(a)
if e is None:
self.tables[nd][a] = [dx, dy, 1]
else:
e[0] += dx; e[1] += dy; e[2] += 1
# ── Regression TM (one output dimension) ──────────────────────────────────────
N_CLAUSES = 60
N_STATES = 15
S_SPEC = 3.0
T_THRESH = 30
RESID_MAX = 30.0 # residual clamp (0-99 scale)
class RTM:
def __init__(self):
n_lit = 276 * 2
half = N_CLAUSES // 2
self.ta = [[N_STATES] * n_lit for _ in range(N_CLAUSES)]
self.pol = [1] * half + [-1] * half
self.n_lit = n_lit
def _clause_out(self, c, x_aug):
ta_c = self.ta[c]
for l in range(self.n_lit):
if ta_c[l] > N_STATES and x_aug[l] == 0:
return 0
return 1
def predict(self, x):
x_aug = x + [1 - b for b in x]
v = sum(self.pol[c] * self._clause_out(c, x_aug) for c in range(N_CLAUSES))
v = max(-T_THRESH, min(T_THRESH, v))
return v / T_THRESH * RESID_MAX
def learn(self, x, residual):
x_aug = x + [1 - b for b in x]
err = residual - self.predict(x)
p_fb = min(1.0, abs(err) / (2 * RESID_MAX))
for c in range(N_CLAUSES):
if random.random() >= p_fb:
continue
pol = self.pol[c]; o = self._clause_out(c, x_aug); ta_c = self.ta[c]
if (err > 0 and pol > 0) or (err < 0 and pol < 0):
if o == 1:
for l in range(self.n_lit):
if x_aug[l] == 1:
if random.random() < (S_SPEC-1)/S_SPEC:
if ta_c[l] < 2*N_STATES: ta_c[l] += 1
else:
if random.random() < 1.0/S_SPEC:
if ta_c[l] > 1: ta_c[l] -= 1
else:
for l in range(self.n_lit):
if random.random() < 1.0/S_SPEC:
if ta_c[l] > 1: ta_c[l] -= 1
else:
if o == 1:
for l in range(self.n_lit):
if x_aug[l] == 0 and ta_c[l] > N_STATES:
ta_c[l] -= 1
# ── helpers ────────────────────────────────────────────────────────────────────
def bullet_speed(p): return 20.0 - 3.0 * p
def flight_ticks(dist_enc, p): return (dist_enc / 99.0 * MAX_DIST) / bullet_speed(p)
def euclid(ax, ay, bx, by): return math.sqrt((ax-bx)**2 + (ay-by)**2)
def predict_linear(row, power):
t = flight_ticks(row["f0_distance"], power)
x0, y0 = row["f0_enemy_x"], row["f0_enemy_y"]
x1, y1 = row["f1_enemy_x"], row["f1_enemy_y"]
return x0 + (x0-x1)*t, y0 + (y0-y1)*t
def heading_sector(hs, hc):
s = hs/199*2-1; c = hc/199*2-1
return int(math.degrees(math.atan2(s,c)) % 360 / 45) % 8
def dist_band(d, lo, hi):
return 0 if d <= lo else (1 if d <= hi else 2)
def stats(errs):
if not errs: return None
n = len(errs)
mae = sum(errs)/n
rmse = math.sqrt(sum(e*e for e in errs)/n)
hit = sum(1 for e in errs if e < HIT_RADIUS)/n*100
return {"n":n, "mae":mae, "rmse":rmse, "hit":hit}
_REQUIRED_KEYS = (
[f"f{fi}_{col}" for fi in range(4) for col in
("bearing_sin","bearing_cos","distance","velocity","heading_sin","heading_cos","enemy_x","enemy_y","enemy_energy")]
+ [f"{ps}_enemy_x" for ps in ["p0.10","p0.42","p0.74","p1.07","p1.39","p1.71","p2.03","p2.36","p2.68","p3.00"]]
+ [f"{ps}_enemy_y" for ps in ["p0.10","p0.42","p0.74","p1.07","p1.39","p1.71","p2.03","p2.36","p2.68","p3.00"]]
)
def load_csv(path):
rows = []
with open(path) as f:
for row in csv.DictReader(f):
try:
d = {k: float(v) for k,v in row.items() if v not in (None, "NA", "")}
if all(k in d for k in _REQUIRED_KEYS):
rows.append(d)
except (ValueError, TypeError):
pass
return rows
# ── per-enemy backtest ─────────────────────────────────────────────────────────
def run_enemy(enemy_files, seed=42):
"""
Process all battles for one enemy type.
Predictors share state across battles (cumulative online learning).
Returns dict of results.
"""
random.seed(seed)
# shared predictor state
ws = WiSARD(seed=seed)
rtm_x, rtm_y = RTM(), RTM()
heb = [[[0.0, 0.0] for _ in range(3)] for _ in range(8)]
# compute global band thresholds across all battles
all_dists = []
all_rows_list = []
for path in sorted(enemy_files):
rows = load_csv(path)
all_rows_list.append(rows)
all_dists.extend(r["f0_distance"] for r in rows)
all_dists.sort()
n_d = len(all_dists)
band_lo, band_hi = all_dists[n_d//3], all_dists[2*n_d//3]
errs = {
"lin": defaultdict(list),
"wis": defaultdict(list),
"tm": defaultdict(list),
}
errs_last50_tm = defaultdict(list)
all_processed = 0
for rows in all_rows_list:
n = len(rows)
for i, row in enumerate(rows):
x = row_to_binary(row)
sec = heading_sector(row["f0_heading_sin"], row["f0_heading_cos"])
band = dist_band(row["f0_distance"], band_lo, band_hi)
cx_heb, cy_heb = heb[sec][band]
cx_ws, cy_ws = ws.predict(x)
rx_tm = rtm_x.predict(x)
ry_tm = rtm_y.predict(x)
rx_acc = ry_acc = 0.0
for ps, power in zip(POWER_STRS, POWER_LEVELS):
ax = row[f"{ps}_enemy_x"]; ay = row[f"{ps}_enemy_y"]
lx, ly = predict_linear(row, power)
errs["lin"][ps].append(euclid(lx+cx_heb, ly+cy_heb, ax, ay))
errs["wis"][ps].append(euclid(lx+cx_ws, ly+cy_ws, ax, ay))
errs["tm"][ps].append( euclid(lx+rx_tm, ly+ry_tm, ax, ay))
if i >= n - 50:
errs_last50_tm[ps].append(errs["tm"][ps][-1])
rx_acc += ax - lx; ry_acc += ay - ly
# online learning
rx_mean, ry_mean = rx_acc/len(POWER_STRS), ry_acc/len(POWER_STRS)
ws.learn(x, rx_mean, ry_mean)
rtm_x.learn(x, rx_mean); rtm_y.learn(x, ry_mean)
lx_r, ly_r = predict_linear(row, REP_POWER)
ax_r = row[f"{REP_PS}_enemy_x"]; ay_r = row[f"{REP_PS}_enemy_y"]
heb[sec][band][0] += 0.1 * (ax_r - (lx_r + cx_heb))
heb[sec][band][1] += 0.1 * (ay_r - (ly_r + cy_heb))
all_processed += 1
return errs, errs_last50_tm, all_processed
# ── main ──────────────────────────────────────────────────────────────────────
def main():
random.seed(42)
# Find all 181-col decimal CSVs grouped by enemy type
all_files = sorted(glob.glob(os.path.join(DATA_DIR, "*_decimal.csv")))
by_enemy = defaultdict(list)
for f in all_files:
base = os.path.basename(f)
enemy = base.split("_battle")[0].replace("_decimal","") # shouldn't be needed but safe
by_enemy[enemy].append(f)
lines = []
w = lines.append
w("=" * 72)
w("PREDICTOR COMPARISON: BNNBot (Linear+Hebbian) vs WiSARD K=12 vs TsetlinBot (RTM)")
w(f"Rep power: {REP_PS} | Hit threshold: {HIT_RADIUS} (0-99 scale, ~18px)")
w(f"Online learning, cumulative per-enemy | Scores measured across all powers")
w("=" * 72)
w("")
global_errs = {"lin": [], "wis": [], "tm": [], "tm_warm": []}
enemy_summaries = {}
for enemy in sorted(by_enemy.keys()):
files = by_enemy[enemy]
print(f" {enemy}: {len(files)} battles...")
errs, errs_last50_tm, n_rows = run_enemy(files)
# avg MAE across all powers
def avg_mae(d): return sum(stats(d[ps])["mae"] for ps in POWER_STRS)/len(POWER_STRS)
def avg_hit(d): return sum(stats(d[ps])["hit"] for ps in POWER_STRS)/len(POWER_STRS)
s_lin = {"mae": avg_mae(errs["lin"]), "hit": avg_hit(errs["lin"])}
s_wis = {"mae": avg_mae(errs["wis"]), "hit": avg_hit(errs["wis"])}
s_tm = {"mae": avg_mae(errs["tm"]), "hit": avg_hit(errs["tm"])}
s_tm_warm = {
"mae": sum(stats(errs_last50_tm[ps])["mae"] for ps in POWER_STRS)/len(POWER_STRS)
if errs_last50_tm["p1.07"] else float("nan"),
}
enemy_summaries[enemy] = (s_lin, s_wis, s_tm, s_tm_warm)
winner = min([("BNNBot(Lin+Heb)", s_lin["mae"]),
("WiSARD", s_wis["mae"]),
("TsetlinBot", s_tm["mae"])], key=lambda x:x[1])[0]
# accumulate globals
for ps in POWER_STRS:
global_errs["lin"].extend(errs["lin"][ps])
global_errs["wis"].extend(errs["wis"][ps])
global_errs["tm"].extend(errs["tm"][ps])
global_errs["tm_warm"].extend(errs_last50_tm[ps])
w(f"Enemy: {enemy:12s} battles={len(files)} rows={n_rows}")
w(f" Avg MAE (all powers): BNNBot={s_lin['mae']:5.2f} WiSARD={s_wis['mae']:5.2f} TsetlinBot={s_tm['mae']:5.2f} (TM-warm={s_tm_warm['mae']:5.2f})")
w(f" Avg Hit% (all powers): BNNBot={s_lin['hit']:5.1f}% WiSARD={s_wis['hit']:5.1f}% TsetlinBot={s_tm['hit']:5.1f}%")
w(f" Winner by MAE: {winner}")
w("")
# per-power detail for this enemy
w(f" Per-power breakdown (MAE, {enemy}):")
w(f" {'Power':<8} {'BNNBot':>8} {'WiSARD':>8} {'TsetlinBot':>10} {'Best':>12}")
w(" " + "-" * 52)
for ps in POWER_STRS:
ml = stats(errs["lin"][ps])["mae"]
mw = stats(errs["wis"][ps])["mae"]
mt = stats(errs["tm"][ps])["mae"]
best = min([("BNNBot",ml),("WiSARD",mw),("TsetlinBot",mt)], key=lambda x:x[1])[0]
w(f" {ps:<8} {ml:>8.2f} {mw:>8.2f} {mt:>10.2f} {best:>12s}")
w("")
# global summary
w("=" * 72)
w("OVERALL SUMMARY (all enemies, all powers combined)")
w("=" * 72)
n_all = len(global_errs["lin"])
if n_all:
g_lin = sum(global_errs["lin"])/n_all
g_wis = sum(global_errs["wis"])/n_all
g_tm = sum(global_errs["tm"])/n_all
g_tm_warm = sum(global_errs["tm_warm"])/len(global_errs["tm_warm"]) if global_errs["tm_warm"] else float("nan")
g_hit_lin = sum(1 for e in global_errs["lin"] if e<HIT_RADIUS)/n_all*100
g_hit_wis = sum(1 for e in global_errs["wis"] if e<HIT_RADIUS)/n_all*100
g_hit_tm = sum(1 for e in global_errs["tm"] if e<HIT_RADIUS)/n_all*100
w(f" n={n_all} data points")
w(f" MAE: BNNBot={g_lin:.2f} WiSARD={g_wis:.2f} TsetlinBot={g_tm:.2f} (TM warm-phase={g_tm_warm:.2f})")
w(f" Hit%: BNNBot={g_hit_lin:.1f}% WiSARD={g_hit_wis:.1f}% TsetlinBot={g_hit_tm:.1f}%")
w("")
# win counts
wins = defaultdict(int)
for enemy, (sl, sw, st, _) in enemy_summaries.items():
best = min([("BNNBot",sl["mae"]),("WiSARD",sw["mae"]),("TsetlinBot",st["mae"])], key=lambda x:x[1])[0]
wins[best] += 1
w(" Wins per enemy type (by avg MAE, all powers):")
for k, v in sorted(wins.items(), key=lambda x:-x[1]):
w(f" {k}: {v}/{len(enemy_summaries)}")
w("")
global_winner = min([("BNNBot(Lin+Heb)", g_lin),
("WiSARD K=12", g_wis),
("TsetlinBot(RTM)", g_tm)], key=lambda x:x[1])[0]
global_winner_warm = min([("BNNBot(Lin+Heb)", g_lin),
("WiSARD K=12", g_wis),
("TsetlinBot(RTM,warm)", g_tm_warm)], key=lambda x:x[1])[0]
w(f" VERDICT (all-rows MAE): {global_winner}")
w(f" VERDICT (TM warm-phase): {global_winner_warm}")
w("")
w("Notes:")
w(" - BNNBot: linear extrapolation + 24-cell Hebbian residual table (lr=0.1)")
w(" - WiSARD: K=12, 276-bit input (4 frames × 69 bits), 23 LUT nodes, bleach=1")
w(" - TsetlinBot (RTM): 60 clauses, N_states=15, s=3.0, T=30, shared x/y RTMs")
w(" - TM warm = last-50-rows MAE; accounts for cold-start overhead")
w(f" - Hit threshold: {HIT_RADIUS} (0-99 scale units, ~equivalent to tank body)")
w(" - Residuals learned on mean across all powers; tested at each power level")
w(f" - Enemy types: {', '.join(sorted(enemy_summaries.keys()))}")
report = "\n".join(lines)
print("\n" + report)
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
with open(OUT_PATH, "w") as f:
f.write(report + "\n")
print(f"\n[saved to {OUT_PATH}]")
if __name__ == "__main__":
main()