diff --git a/mods/vinylgod/maps/arena/balance.lua b/mods/vinylgod/maps/arena/balance.lua index 7a446c3..da3687c 100644 --- a/mods/vinylgod/maps/arena/balance.lua +++ b/mods/vinylgod/maps/arena/balance.lua @@ -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)) diff --git a/mods/vinylgod/rules/ai.yaml b/mods/vinylgod/rules/ai.yaml index 16a7efd..ec330d5 100644 --- a/mods/vinylgod/rules/ai.yaml +++ b/mods/vinylgod/rules/ai.yaml @@ -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 diff --git a/tools/balance_report.py b/tools/balance_report.py index 026d749..24b5577 100644 --- a/tools/balance_report.py +++ b/tools/balance_report.py @@ -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.')