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>
This commit is contained in:
parent
6847a47fea
commit
39ca9c78a5
@ -32,6 +32,26 @@ Tick = function()
|
||||
end
|
||||
print(string.format("BAL t=%d %s cash=%d stored=%d units=%d buildings=%d",
|
||||
tick, p.InternalName, p.Cash, p.Resources, units, buildings))
|
||||
|
||||
-- type histogram: which buildings/units does this bot actually have?
|
||||
if tick % 1250 == 0 then
|
||||
local kinds = {}
|
||||
local order = {}
|
||||
for _, a in ipairs(Map.ActorsInWorld) do
|
||||
if a.Owner == p then
|
||||
if kinds[a.Type] == nil then
|
||||
kinds[a.Type] = 0
|
||||
order[#order + 1] = a.Type
|
||||
end
|
||||
kinds[a.Type] = kinds[a.Type] + 1
|
||||
end
|
||||
end
|
||||
local parts = ""
|
||||
for _, k in ipairs(order) do
|
||||
parts = parts .. k .. "=" .. kinds[k] .. " "
|
||||
end
|
||||
print(string.format("BALTYPES t=%d %s %s", tick, p.InternalName, parts))
|
||||
end
|
||||
if units + buildings == 0 and not dead[p.InternalName] then
|
||||
dead[p.InternalName] = true
|
||||
print(string.format("BAL t=%d %s ELIMINATED", tick, p.InternalName))
|
||||
|
||||
@ -41,35 +41,48 @@ player:
|
||||
droneport: 1
|
||||
BuildingFractions:
|
||||
pressplant: 30
|
||||
transcoder: 30
|
||||
hornpit: 8
|
||||
adblocker: 8
|
||||
transcoder: 30
|
||||
merchtable: 1
|
||||
podfarm: 1
|
||||
garage: 1
|
||||
printfarm: 1
|
||||
mastering: 1
|
||||
algorithm: 1
|
||||
# barracks first: infantry is the cheapest army and both sides need
|
||||
# it early. Telemetry showed Stream getting its podfarm at 5.8min
|
||||
# (1 bufferbot fielded) vs MRP's merchtable at 1.7min (17 djgrunts).
|
||||
merchtable: 10
|
||||
podfarm: 10
|
||||
garage: 6
|
||||
printfarm: 6
|
||||
launchramp: 2
|
||||
droneport: 2
|
||||
mastering: 1
|
||||
algorithm: 1
|
||||
BuildingDelays:
|
||||
# no superweapon building before there is an army to protect it
|
||||
mastering: 4500
|
||||
algorithm: 4500
|
||||
UnitBuilderBotModule:
|
||||
RequiresCondition: enable-wax-ai
|
||||
UnitsToBuild:
|
||||
djgrunt: 60
|
||||
roadie: 5
|
||||
spinner: 30
|
||||
stacktank: 45
|
||||
basscannon: 15
|
||||
digger: 10
|
||||
bufferbot: 60
|
||||
drmwalker: 45
|
||||
scraper: 10
|
||||
boombike: 20
|
||||
crawler: 8
|
||||
flyer45: 25
|
||||
compressor: 25
|
||||
selector: 5
|
||||
curator: 5
|
||||
# these are PERCENTAGES of army composition, not weights: the AI
|
||||
# builds whatever sits furthest below its share. They previously
|
||||
# summed to 233% per faction, which made selection arbitrary and
|
||||
# starved Stream of infantry entirely.
|
||||
# MRP ~98
|
||||
digger: 8
|
||||
djgrunt: 28
|
||||
roadie: 3
|
||||
spinner: 14
|
||||
stacktank: 20
|
||||
basscannon: 7
|
||||
boombike: 6
|
||||
flyer45: 10
|
||||
selector: 2
|
||||
# Stream ~98
|
||||
scraper: 8
|
||||
bufferbot: 34
|
||||
crawler: 4
|
||||
drmwalker: 26
|
||||
compressor: 24
|
||||
curator: 2
|
||||
UnitLimits:
|
||||
digger: 4
|
||||
scraper: 4
|
||||
|
||||
@ -1,34 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Summarize a shellmap bot-war from lua.log BAL telemetry.
|
||||
"""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 = []
|
||||
|
||||
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((int(m[1]), m[2], int(m[3]), int(m[4]), int(m[5]), int(m[6])))
|
||||
m = re.search(r'BAL t=(\d+) (\w+) ELIMINATED', line)
|
||||
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:
|
||||
print(f'*** {m[2]} ELIMINATED at tick {m[1]} (~{int(m[1]) // 25}s)')
|
||||
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[1] for r in rows})
|
||||
print(f'{"tick":>6} ' + ''.join(f'{b + " $/store/u/b":>28}' for b in bots))
|
||||
ticks = sorted({r[0] for r in rows})
|
||||
for t in ticks[:: max(1, len(ticks) // 12)]:
|
||||
line = f'{t:>6} '
|
||||
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 = next((x for x in rows if x[0] == t and x[1] == b), None)
|
||||
line += f'{f"{r[2]}/{r[3]}/{r[4]}/{r[5]}" if r else "-":>28}'
|
||||
r = at(t, b)
|
||||
line += f'{f"{r['cash']}/{r['stored']}/{r['units']}/{r['buildings']}" if r else "-":>26}'
|
||||
print(line)
|
||||
|
||||
print('\nfinal:')
|
||||
print('\nfinal state:')
|
||||
for b in bots:
|
||||
r = [x for x in rows if x[1] == b][-1]
|
||||
print(f' {b}: cash={r[2]} stored={r[3]} units={r[4]} buildings={r[5]}')
|
||||
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.')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user