RECORDANDRONCO/tools/balance_report.py
type-two 39ca9c78a5 AI balance pass driven by arena telemetry
Two fair-map matches showed MRP annihilating Stream by ~5min. Build-order
telemetry (new BALTYPES histogram in balance.lua) found the cause: Stream's
podfarm arrived at 5.8min vs MRP's merchtable at 1.7min, so the cheap-swarm
faction fielded 1 bufferbot against 17 djgrunts, and Stream spent 1500cr on the
Algorithm superweapon before it had an army.

Fixes:
 * BuildingFractions: barracks 1 -> 10, factories 1 -> 6 so army buildings come
   before tech. Result: podfarm 5.8min -> 2.5min, algorithm 3.3min -> 5.8min,
   and Stream survived to 9.5min with 13 buildings instead of being wiped at 5.2.
 * BuildingDelays on mastering/algorithm (4500) — no superweapon before an army.
 * UnitsToBuild shares normalised. Reading UnitBuilderBotModule showed these are
   PERCENTAGES of army composition, and mine summed to 233% per faction, which
   made unit selection collapse to iteration order and starved Stream of infantry.

balance_report.py now emits a verdict (flatline / runaway / stalled production)
and refuses to judge matches under 5 minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 10:48:13 +10:00

100 lines
3.8 KiB
Python

#!/usr/bin/env python3
"""Summarize an arena bot-war from lua.log BAL telemetry, with a verdict.
Usage: balance_report.py [path-to-lua.log]
Reports the economy/army curve, who led when, and flags the specific failure
modes we have actually hit before:
* economy flatline (income cannot sustain spending — wax seep rate too low)
* runaway (one side >2x the other for a sustained stretch)
* stalled army (both sides sitting on cash without building units)
"""
import os, re, sys
path = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser('~/Library/Application Support/OpenRA/Logs/lua.log')
rows, events = [], []
for line in open(path):
m = re.search(r'BAL t=(\d+) (\w+) cash=(\d+) stored=(\d+) units=(\d+) buildings=(\d+)', line)
if m:
rows.append(dict(t=int(m[1]), who=m[2], cash=int(m[3]), stored=int(m[4]),
units=int(m[5]), buildings=int(m[6])))
m = re.search(r'BAL (?:t=(\d+) )?(\w+) (ELIMINATED)', line)
if m:
events.append((int(m[1] or 0), m[2], m[3]))
m = re.search(r'BAL (WON|LOST): (\w+)', line)
if m:
events.append((0, m[2], m[1]))
if not rows:
sys.exit('no BAL telemetry found')
bots = sorted({r['who'] for r in rows})
ticks = sorted({r['t'] for r in rows})
def at(t, who):
return next((r for r in rows if r['t'] == t and r['who'] == who), None)
print(f'{"tick":>6} {"~min":>5} ' + ''.join(f'{b + " cash/stored/u/b":>26}' for b in bots))
step = max(1, len(ticks) // 14)
for t in ticks[::step]:
line = f'{t:>6} {t / 1500:>5.1f} '
for b in bots:
r = at(t, b)
line += f'{f"{r['cash']}/{r['stored']}/{r['units']}/{r['buildings']}" if r else "-":>26}'
print(line)
print('\nfinal state:')
for b in bots:
r = [x for x in rows if x['who'] == b][-1]
print(f' {b}: cash={r["cash"]} stored={r["stored"]} units={r["units"]} buildings={r["buildings"]}')
for t, who, what in events:
print(f' *** {who} {what}' + (f' at tick {t} (~{t / 1500:.1f} min)' if t else ''))
# ---- verdict -------------------------------------------------------------
print('\nverdict:')
issues = []
# Don't draw conclusions from an opening. Both bots are still teching at 3 min
# and every heuristic below misfires on that.
if ticks[-1] < 7500:
print(f' DATA TOO THIN — match only reached {ticks[-1] / 1500:.1f} min. '
'Let it run past 5 min before trusting any verdict.')
sys.exit(0)
# economy flatline: both sides broke for a sustained late stretch
late = [r for r in rows if r['t'] >= ticks[len(ticks) // 2]]
broke = [r for r in late if r['cash'] + r['stored'] < 200]
if len(broke) >= max(4, len(late) // 3):
issues.append(f'ECONOMY FLATLINE — {len(broke)}/{len(late)} late samples under 200 banked. '
'Raise wax seep rate (SeedsResource Interval) or add seeps.')
# runaway: one side dominating on army for a sustained stretch
if len(bots) == 2:
a, b = bots
lead = 0
for t in ticks:
ra, rb = at(t, a), at(t, b)
if ra and rb:
hi, lo = max(ra['units'], rb['units']), min(ra['units'], rb['units'])
if hi >= 6 and lo * 2 < hi:
lead += 1
if lead >= len(ticks) // 3:
issues.append(f'RUNAWAY — one side held >2x army on {lead}/{len(ticks)} samples. '
'Check unit cost/HP and the AI UnitsToBuild weights.')
# stalled army: lots of money, nobody building
rich_idle = [r for r in late if r['cash'] + r['stored'] > 3000 and r['units'] < 4]
if len(rich_idle) >= max(3, len(late) // 4):
issues.append(f'STALLED PRODUCTION — {len(rich_idle)} samples rich but under 4 units. '
'AI is banking instead of spending; check UnitLimits / factory count.')
if issues:
for i in issues:
print(' ! ' + i)
else:
print(' no flatline, no runaway, no production stall detected.')