Skip to content

Commit 279f532

Browse files
committed
Make RoboCode traces smaller, add replay mechanic
1 parent b888a33 commit 279f532

10 files changed

Lines changed: 555 additions & 8 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""RoboCode replay renderer.
2+
3+
Parses a compact ``sim_*.jsonl`` (produced by :mod:`codeclash.arenas.robocode.trace` from
4+
Robocode's ``-recordXML``) into normalized playback data and draws one Robocode round: the
5+
battlefield, each tank with its body/gun/radar headings and energy, and bullets in flight.
6+
7+
The jsonl format (see ``trace.py``): a header line ``{w, h, round, robots:{id:name}}``,
8+
per-turn frames ``{t, u:[{i,x,y,e,bh,gh,rh,v,s}], b:[{o,x,y,p,s}]}``, and a final result
9+
line ``{winner, draw}``. Robocode uses a y-up field (origin bottom-left) and headings in
10+
radians measured clockwise from north, both handled in the draw code.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
17+
from codeclash.replay.base import ReplayData, ReplayRenderer
18+
19+
PALETTE = ["#3B78FF", "#E5484D", "#30A46C", "#F5A623", "#8E4EC6", "#12A594", "#E93D82", "#F76B15"]
20+
21+
DRAW_JS = """
22+
const ARENA = (function(){
23+
let W, H, S, COL, NAMES, cw, ch;
24+
const R = 18; // Robocode robots are ~36px across
25+
function setup(cv, G){
26+
W = G.w; H = G.h; COL = G.colors; NAMES = G.names;
27+
S = Math.min(760 / W, 560 / H);
28+
cw = cv.width = Math.round(W * S); ch = cv.height = Math.round(H * S);
29+
}
30+
const px = (x) => x * S, py = (y) => ch - y * S; // y-up world -> y-down canvas
31+
// Robocode heading: radians clockwise from north. Unit vector on the y-down canvas.
32+
const dx = (a) => Math.sin(a), dy = (a) => -Math.cos(a);
33+
function tank(ctx, u){
34+
const cx = px(u.x), cy = py(u.y), c = COL[u.i] || '#888', dead = (u.s === 'DEAD');
35+
// radar (thin, faint) and gun (barrel) as rays from the center
36+
if(!dead){
37+
ctx.strokeStyle = 'rgba(255,255,255,0.35)'; ctx.lineWidth = 1;
38+
ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(cx + dx(u.rh)*R*2.4, cy + dy(u.rh)*R*2.4); ctx.stroke();
39+
}
40+
// body: rounded rect rotated to bodyHeading (north = up before rotation)
41+
ctx.save(); ctx.translate(cx, cy); ctx.rotate(u.bh);
42+
ctx.globalAlpha = dead ? 0.25 : 1;
43+
ctx.fillStyle = c;
44+
const w = R*1.5, h = R*1.7;
45+
ctx.beginPath(); ctx.roundRect(-w/2, -h/2, w, h, 4); ctx.fill();
46+
ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(-w/2, -h/2, w, h*0.18); // "front" band
47+
ctx.restore();
48+
if(!dead){
49+
ctx.strokeStyle = '#e6edf3'; ctx.lineWidth = 3; ctx.lineCap = 'round';
50+
ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(cx + dx(u.gh)*R*1.9, cy + dy(u.gh)*R*1.9); ctx.stroke();
51+
ctx.lineCap = 'butt';
52+
} else {
53+
ctx.strokeStyle = '#e5484d'; ctx.lineWidth = 2;
54+
ctx.beginPath(); ctx.moveTo(cx-6,cy-6); ctx.lineTo(cx+6,cy+6); ctx.moveTo(cx+6,cy-6); ctx.lineTo(cx-6,cy+6); ctx.stroke();
55+
}
56+
ctx.globalAlpha = 1;
57+
}
58+
function draw(ctx, cv, G, i){
59+
const f = G.frames[i];
60+
ctx.clearRect(0,0,cw,ch);
61+
ctx.fillStyle = 'rgba(255,255,255,0.02)'; ctx.fillRect(0,0,cw,ch);
62+
(f.b || []).forEach(b=>{
63+
const c = COL[b.o] || '#ffd21f', hit = (b.s === 'HIT_VICTIM' || b.s === 'EXPLODED');
64+
ctx.fillStyle = hit ? '#ffd21f' : c;
65+
ctx.globalAlpha = hit ? 0.9 : 1;
66+
ctx.beginPath(); ctx.arc(px(b.x), py(b.y), hit ? 5 : (1.6 + (b.p||1)*1.3), 0, 7); ctx.fill();
67+
ctx.globalAlpha = 1;
68+
});
69+
(f.u || []).forEach(u=>tank(ctx, u));
70+
}
71+
function side(G, i){
72+
const f = G.frames[i], ids = Object.keys(G.names);
73+
const byId = {}; (f.u || []).forEach(u=>byId[u.i]=u);
74+
return ids.map(id=>{
75+
const u = byId[id], dead = !u || u.s === 'DEAD' || u.e <= 0;
76+
const e = u ? Math.max(0, u.e) : 0, pct = Math.min(100, e);
77+
return `<div class="sn ${dead?'dead':''}"><span class="sw" style="background:${G.colors[id]}"></span>
78+
<span style="min-width:120px">${NAMES[id]}</span>
79+
<span class="hb"><span class="hf" style="width:${pct}%;background:${G.colors[id]}"></span></span>
80+
<span>${dead?'\\u2620':e.toFixed(0)}</span></div>`;
81+
}).join('') + '<div class="row muted" style="font-size:12px">bar = energy \\u00b7 white ray = gun \\u00b7 faint ray = radar</div>';
82+
}
83+
return {setup, draw, side};
84+
})();
85+
"""
86+
87+
88+
class RoboCodeReplayer(ReplayRenderer):
89+
arena = "RoboCode"
90+
sim_glob = "sim_*.jsonl"
91+
DRAW_JS = DRAW_JS
92+
93+
def parse(self, raw: bytes, players=None) -> ReplayData:
94+
rows = [json.loads(line) for line in raw.decode().splitlines() if line.strip()]
95+
if not rows:
96+
return ReplayData(w=800, h=600, frames=[])
97+
header = rows[0]
98+
result = rows[-1] if isinstance(rows[-1], dict) and "winner" in rows[-1] else {}
99+
robots = {str(k): v for k, v in header.get("robots", {}).items()}
100+
# Stable color per robot id, in id order.
101+
colors = {rid: PALETTE[i % len(PALETTE)] for i, rid in enumerate(sorted(robots, key=int))}
102+
103+
frames = []
104+
for r in rows:
105+
if not (isinstance(r, dict) and "t" in r):
106+
continue
107+
frames.append({"turn": r["t"], "u": r.get("u", []), "b": r.get("b", [])})
108+
109+
return ReplayData(
110+
w=header.get("w", 800),
111+
h=header.get("h", 600),
112+
frames=frames,
113+
winner=result.get("winner"),
114+
draw=result.get("draw", False),
115+
extra={"colors": colors, "names": robots},
116+
)
117+
118+
def peek_winner(self, raw: bytes, players=None) -> tuple[str | None, bool] | None:
119+
"""Read just the trailing result line — no frame building."""
120+
for line in reversed(raw.decode().splitlines()):
121+
line = line.strip()
122+
if not line:
123+
continue
124+
try:
125+
obj = json.loads(line)
126+
except ValueError:
127+
return None
128+
if isinstance(obj, dict) and "winner" in obj:
129+
return obj.get("winner"), obj.get("draw", False)
130+
return None
131+
return None

codeclash/arenas/robocode/robocode.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from codeclash.agents.player import Player
1212
from codeclash.arenas.arena import CodeArena, RoundStats
13+
from codeclash.arenas.robocode.trace import process_record, write_aggregate_trace
1314
from codeclash.utils.environment import create_file_in_container
1415

1516
RC_FILE = Path("MyTank.java")
@@ -79,8 +80,10 @@ def _run_single_simulation(self, agents: list[Player], idx: int, cmd: str) -> st
7980
rc_results = self.log_env / f"results_{idx}.txt"
8081
rc_record = self.log_env / f"record_{idx}.xml"
8182
cmd = f"{cmd} -results {rc_results}"
82-
if random.random() < self.game_config.get("record_ratio", 1):
83-
# Only record a fraction of simulations to save space
83+
# Always record one representative battle (idx 0) per round so the model and the replay
84+
# viewer get behavioral traces; record extra battles only if record_ratio is raised.
85+
# Each recorded battle's raw XML is later parsed into compact sim/trace files and deleted.
86+
if idx == 0 or random.random() < self.game_config.get("record_ratio", 0):
8487
cmd = f"{cmd} -recordXML {rc_record}"
8588
try:
8689
output = self.environment.execute(cmd, timeout=120)
@@ -128,6 +131,31 @@ def execute_round(self, agents: list[Player]):
128131
for future in tqdm(as_completed(futures), total=len(futures)):
129132
future.result()
130133

134+
def copy_logs_from_env(self, round_num: int) -> None:
135+
"""Copy the round's raw logs to the host, then distill each recorded battle's
136+
(enormous, ~30 MB) ``record_{idx}.xml`` into compact per-round ``sim_{n}.jsonl`` files
137+
(the single source for both the agent-facing behavioral trace and the replay viewer),
138+
and pool every recorded game into one readable ``trace.md``. The raw XML is deleted
139+
afterwards so it never bloats the logs shipped to the competing agents."""
140+
super().copy_logs_from_env(round_num)
141+
round_dir = self.log_round(round_num)
142+
summaries = []
143+
for xml in sorted(round_dir.glob("record_*.xml")):
144+
match = re.search(r"record_(\d+)\.xml", xml.name)
145+
if not match:
146+
continue
147+
try:
148+
summaries.extend(process_record(xml, round_dir, int(match.group(1)), SIMS_PER_RUN))
149+
except Exception as e:
150+
self.logger.warning(f"Failed to distill RoboCode sims from {xml.name}: {e}")
151+
finally:
152+
xml.unlink(missing_ok=True)
153+
if summaries:
154+
try:
155+
write_aggregate_trace(round_dir, summaries, self.game_config.get("sims_per_round", 100))
156+
except Exception as e:
157+
self.logger.warning(f"Failed to write aggregate RoboCode trace: {e}")
158+
131159
def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
132160
scores = defaultdict(int)
133161
for idx in range(self.game_config.get("sims_per_round", 100) // SIMS_PER_RUN):

0 commit comments

Comments
 (0)