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>
100 lines
3.8 KiB
Python
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.')
|