Skip to content

Commit 8250fa8

Browse files
committed
Add replayers for more arenas
1 parent fb83d89 commit 8250fa8

17 files changed

Lines changed: 1590 additions & 59 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""Bomberland replay renderer.
2+
3+
Parses a per-game Bomberland trace (written by ``run_bomberland.py`` as
4+
``sim_<idx>.json``) into normalized playback data and draws the grid: metal /
5+
wood walls, bombs, blasts, and units colored per agent with health-as-brightness.
6+
A ``side(G, i)`` panel shows per-agent unit counts / total hp / current tick.
7+
8+
The trace JSON is a single object ``{width, height, winner, sim, player_order,
9+
frames:[...]}``. Each frame has ``tick``, ``units`` (each with ``unit_id``,
10+
``agent_id``, ``coordinates`` [x,y], ``hp``) and ``entities`` (each with ``type``
11+
in {"m" metal, "w" wood, "b" bomb, "x" blast} and ``coordinates`` [x,y]; bombs
12+
additionally carry ``timer`` / ``owner`` / ``blast_diameter``, blasts carry
13+
``ttl``). ``player_order`` is the two agent ids for this sim; the first is Blue,
14+
the second Red.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
21+
from codeclash.replay.base import ReplayData, ReplayRenderer
22+
23+
START_HP = 3
24+
TEAM_COLORS = {"Blue": "#3B78FF", "Red": "#E5484D"}
25+
26+
DRAW_JS = """
27+
const ARENA = (function(){
28+
let W, H, CELL, PAD, COL, NAMES, AGENTS, MAXHP;
29+
function teamOf(agentId){ return (AGENTS[0] === agentId) ? 'Blue' : 'Red'; }
30+
function setup(cv, G){
31+
W = G.w; H = G.h; COL = G.colors; NAMES = G.names; AGENTS = G.agents; MAXHP = G.maxhp;
32+
CELL = Math.max(16, Math.min(40, Math.floor(640 / Math.max(W, H)))); PAD = CELL * 0.14;
33+
cv.width = W * CELL; cv.height = H * CELL;
34+
}
35+
function px(x){ return x * CELL; }
36+
function py(y){ return y * CELL; }
37+
function draw(ctx, cv, G, i){
38+
const f = G.frames[i];
39+
ctx.clearRect(0, 0, cv.width, cv.height);
40+
ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, cv.width, cv.height);
41+
ctx.strokeStyle = '#21262d'; ctx.lineWidth = 1;
42+
for(let x=0;x<=W;x++){ctx.beginPath();ctx.moveTo(x*CELL,0);ctx.lineTo(x*CELL,H*CELL);ctx.stroke();}
43+
for(let y=0;y<=H;y++){ctx.beginPath();ctx.moveTo(0,y*CELL);ctx.lineTo(W*CELL,y*CELL);ctx.stroke();}
44+
// World entities: draw walls first, then blasts, then bombs.
45+
const bombs = [], blasts = [];
46+
f.entities.forEach(e=>{
47+
const x = e.coordinates[0], y = e.coordinates[1];
48+
if(e.type === 'm'){
49+
ctx.fillStyle = '#6e7681';
50+
ctx.fillRect(px(x)+1, py(y)+1, CELL-2, CELL-2);
51+
} else if(e.type === 'w'){
52+
ctx.fillStyle = '#8a5a2b';
53+
ctx.fillRect(px(x)+2, py(y)+2, CELL-4, CELL-4);
54+
ctx.strokeStyle = '#5c3a1c'; ctx.lineWidth = 1;
55+
ctx.strokeRect(px(x)+2, py(y)+2, CELL-4, CELL-4);
56+
} else if(e.type === 'b'){
57+
bombs.push(e);
58+
} else if(e.type === 'x'){
59+
blasts.push(e);
60+
}
61+
});
62+
blasts.forEach(e=>{
63+
const x = e.coordinates[0], y = e.coordinates[1];
64+
ctx.fillStyle = 'rgba(255,140,0,0.55)';
65+
ctx.fillRect(px(x)+1, py(y)+1, CELL-2, CELL-2);
66+
ctx.fillStyle = 'rgba(255,220,60,0.7)';
67+
ctx.beginPath(); ctx.arc(px(x)+CELL/2, py(y)+CELL/2, CELL*0.22, 0, 7); ctx.fill();
68+
});
69+
bombs.forEach(e=>{
70+
const x = e.coordinates[0], y = e.coordinates[1];
71+
const cx = px(x)+CELL/2, cy = py(y)+CELL/2;
72+
ctx.fillStyle = '#0b0b0b';
73+
ctx.beginPath(); ctx.arc(cx, cy, CELL*0.3, 0, 7); ctx.fill();
74+
ctx.strokeStyle = '#ff5722'; ctx.lineWidth = 2;
75+
ctx.beginPath(); ctx.arc(cx, cy, CELL*0.3, 0, 7); ctx.stroke();
76+
if(e.timer != null){
77+
ctx.fillStyle = '#ffd21f'; ctx.font = `${Math.floor(CELL*0.34)}px system-ui`;
78+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
79+
ctx.fillText(String(e.timer), cx, cy);
80+
}
81+
});
82+
// Units on top.
83+
f.units.forEach(u=>{
84+
if(u.hp <= 0) return;
85+
const tm = teamOf(u.agent_id);
86+
const base = COL[tm] || '#888';
87+
const frac = Math.max(0.3, u.hp / MAXHP);
88+
ctx.globalAlpha = frac;
89+
ctx.fillStyle = base;
90+
const r = CELL * 0.28;
91+
ctx.beginPath(); ctx.roundRect(px(u.x)+PAD, py(u.y)+PAD, CELL-2*PAD, CELL-2*PAD, r); ctx.fill();
92+
ctx.globalAlpha = 1;
93+
const bw = CELL-2*PAD, bx = px(u.x)+PAD, by = py(u.y)+CELL-PAD*0.5;
94+
ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fillRect(bx, by-3, bw, 3);
95+
ctx.fillStyle = '#eafff0'; ctx.fillRect(bx, by-3, bw*Math.min(1, u.hp/MAXHP), 3);
96+
});
97+
}
98+
function side(G, i){
99+
const f = G.frames[i], COL = G.colors, NAMES = G.names, AGENTS = G.agents, MAXHP = G.maxhp;
100+
const agg = {Blue:{n:0,hp:0}, Red:{n:0,hp:0}};
101+
let totalUnits = {Blue:0, Red:0};
102+
f.units.forEach(u=>{
103+
const tm = (AGENTS[0] === u.agent_id) ? 'Blue' : 'Red';
104+
totalUnits[tm]++;
105+
if(u.hp > 0){ agg[tm].n++; agg[tm].hp += u.hp; }
106+
});
107+
return ['Blue','Red'].map(tm=>{
108+
const a = agg[tm], dead = a.n===0, maxhp = Math.max(1, totalUnits[tm]) * MAXHP;
109+
return `<div class="team ${dead?'tdead':''}">
110+
<div class="tname"><span class="sw" style="background:${COL[tm]}"></span>${NAMES[tm]} <span class="muted">(${tm})</span></div>
111+
<div class="stat"><span>units alive</span><b>${a.n}</b></div>
112+
<div class="stat"><span>total hp</span><b>${a.hp}</b></div>
113+
<div class="hb"><span class="hf" style="width:${Math.min(100,100*a.hp/maxhp)}%;background:${COL[tm]}"></span></div>
114+
</div>`;
115+
}).join('') + `<div class="row muted" style="font-size:12px">tick ${f.tick} \\u00b7 hp = brightness \\u00b7 \\ud83d\\udca3 bomb (timer) \\u00b7 \\ud83d\\udd25 blast</div>`;
116+
}
117+
return {setup, draw, side};
118+
})();
119+
"""
120+
121+
122+
class BomberlandReplayer(ReplayRenderer):
123+
arena = "Bomberland"
124+
sim_glob = "sim_*.json"
125+
DRAW_JS = DRAW_JS
126+
127+
def parse(self, raw: bytes, players=None) -> ReplayData:
128+
data = json.loads(raw.decode())
129+
width = int(data.get("width", 0))
130+
height = int(data.get("height", 0))
131+
frames_raw = data.get("frames", [])
132+
player_order = data.get("player_order", [])
133+
134+
frames = []
135+
for fr in frames_raw:
136+
units = []
137+
for u in fr.get("units", []):
138+
coords = u.get("coordinates", [0, 0])
139+
units.append(
140+
{
141+
"unit_id": u.get("unit_id"),
142+
"agent_id": u.get("agent_id"),
143+
"x": coords[0],
144+
"y": coords[1],
145+
"hp": u.get("hp", 0),
146+
}
147+
)
148+
entities = []
149+
for e in fr.get("entities", []):
150+
coords = e.get("coordinates", [0, 0])
151+
ent = {"type": e.get("type"), "coordinates": [coords[0], coords[1]]}
152+
if "timer" in e:
153+
ent["timer"] = e["timer"]
154+
if "owner" in e:
155+
ent["owner"] = e["owner"]
156+
if "ttl" in e:
157+
ent["ttl"] = e["ttl"]
158+
entities.append(ent)
159+
frames.append({"turn": fr.get("tick"), "tick": fr.get("tick"), "units": units, "entities": entities})
160+
161+
# First player_order id -> Blue, second -> Red. Fall back to display names.
162+
blue_id = player_order[0] if len(player_order) > 0 else "Blue"
163+
red_id = player_order[1] if len(player_order) > 1 else "Red"
164+
names = {"Blue": blue_id, "Red": red_id}
165+
166+
winner_raw = data.get("winner")
167+
if winner_raw == blue_id:
168+
winner, draw = names["Blue"], False
169+
elif winner_raw == red_id:
170+
winner, draw = names["Red"], False
171+
else: # "TIE" or unknown
172+
winner, draw = None, True
173+
174+
return ReplayData(
175+
w=width,
176+
h=height,
177+
frames=frames,
178+
winner=winner,
179+
draw=draw,
180+
extra={
181+
"names": names,
182+
"colors": TEAM_COLORS,
183+
"agents": [blue_id, red_id],
184+
"maxhp": START_HP,
185+
},
186+
)

codeclash/arenas/bomberland/runtime/run_bomberland.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,32 @@ def player_score(player, agents, units, stats):
384384
)
385385

386386

387+
def frame_units(agents, units):
388+
"""Serialize per-unit state for one tick: id / owner / coordinates / hp."""
389+
out = []
390+
for player in agents:
391+
for unit_id in agents[player]["unit_ids"]:
392+
unit = units[unit_id]
393+
out.append(
394+
{
395+
"unit_id": unit_id,
396+
"agent_id": unit["agent_id"],
397+
"coordinates": list(unit["coordinates"]),
398+
"hp": unit["hp"],
399+
}
400+
)
401+
return out
402+
403+
404+
def frame_state(agents, units, metal, wood, bombs, blasts, tick):
405+
"""A single per-tick snapshot: tick, units, and world entities."""
406+
return {
407+
"tick": tick,
408+
"units": frame_units(agents, units),
409+
"entities": entities_for_state(metal, wood, bombs, blasts),
410+
}
411+
412+
387413
def run_game(players, callbacks, seed, ticks, width, height, unit_count, agent_timeout):
388414
rng = random.Random(seed)
389415
metal, wood = build_map(width, height, unit_count, rng)
@@ -401,6 +427,7 @@ def run_game(players, callbacks, seed, ticks, width, height, unit_count, agent_t
401427
for player in players
402428
}
403429

430+
frames = []
404431
final_tick = 0
405432
for tick in range(ticks):
406433
final_tick = tick
@@ -409,13 +436,15 @@ def run_game(players, callbacks, seed, ticks, width, height, unit_count, agent_t
409436
players, callbacks, agents, units, metal, wood, bombs, blasts, stats, width, height, tick, agent_timeout
410437
)
411438
tick_bombs(bombs, metal, wood, units, stats, blasts)
439+
frames.append(frame_state(agents, units, metal, wood, bombs, blasts, tick))
412440
if len(live_players(players, units)) <= 1:
413441
break
414442

415443
scores = {player: float(player_score(player, agents, units, stats)) for player in players}
416444
best_score = max(scores.values())
417445
winners = [player for player, score in scores.items() if score == best_score]
418446
winner = winners[0] if len(winners) == 1 else "TIE"
447+
trace = {"width": width, "height": height, "winner": winner, "frames": frames}
419448
detail = {
420449
"seed": seed,
421450
"ticks": final_tick + 1,
@@ -429,7 +458,7 @@ def run_game(players, callbacks, seed, ticks, width, height, unit_count, agent_t
429458
},
430459
"stats": stats,
431460
}
432-
return scores, detail
461+
return scores, detail, trace
433462

434463

435464
def parse_agent_arg(raw):
@@ -462,9 +491,11 @@ def main():
462491
callbacks = {name: path for name, path in args.agent}
463492
totals = {player: 0.0 for player in players}
464493
details = []
494+
output = Path(args.output)
495+
output.parent.mkdir(parents=True, exist_ok=True)
465496
for sim in range(args.sims):
466497
sim_players = players if sim % 2 == 0 else list(reversed(players))
467-
scores, detail = run_game(
498+
scores, detail, trace = run_game(
468499
sim_players,
469500
callbacks,
470501
seed=100_000 + sim,
@@ -479,15 +510,17 @@ def main():
479510
detail["sim"] = sim
480511
detail["player_order"] = sim_players
481512
details.append(json.dumps(detail, sort_keys=True))
513+
trace["sim"] = sim
514+
trace["player_order"] = sim_players
515+
# Per-game replay trace, alongside the aggregate results in the output dir.
516+
(output.parent / f"sim_{sim}.json").write_text(json.dumps(trace) + "\n")
482517

483518
result = {
484519
"average_scores": {player: totals[player] / args.sims for player in players},
485520
"total_scores": totals,
486521
"sims": args.sims,
487522
"details": details,
488523
}
489-
output = Path(args.output)
490-
output.parent.mkdir(parents=True, exist_ok=True)
491524
output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n")
492525

493526

0 commit comments

Comments
 (0)