Skip to content

Commit 2d40983

Browse files
committed
Add replay functionality to corewar. Bug fix robocode
1 parent f799ef0 commit 2d40983

5 files changed

Lines changed: 493 additions & 30 deletions

File tree

codeclash/arenas/corewar/corewar.py

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,55 @@
55

66
from codeclash.agents.player import Player
77
from codeclash.arenas.arena import CodeArena, RoundStats
8+
from codeclash.arenas.corewar.trace import distill_trace
89
from codeclash.constants import RESULT_TIE
910

1011
COREWAR_LOG = "sim_{idx}.log"
12+
TRACE_RAW = "trace_raw.txt"
1113

1214

1315
class CoreWarArena(CodeArena):
1416
name: str = "CoreWar"
1517
description: str = """CoreWar is a programming battle where you write "warriors" in an assembly-like language called Redcode to compete within a virtual machine (MARS), aiming to eliminate your rivals by making their code self-terminate.
16-
Victory comes from crafting clever tactics—replicators, scanners, bombers—that exploit memory layout and instruction timing to control the core."""
18+
Victory comes from crafting clever tactics—replicators, scanners, bombers—that exploit memory layout and instruction timing to control the core.
19+
20+
Reading the logs: each round's score is computed over many simulated battles, but only a sample of them are saved as replay traces (`sim_*.jsonl`) in `/logs/` due to storage limits. The score reflects every battle played, not just the replayed subset, so treat the replays as representative examples rather than the full basis of the result."""
1721
submission: str = "warrior.red"
1822

1923
def __init__(self, config, **kwargs):
2024
super().__init__(config, **kwargs)
21-
self.run_cmd_round: str = "./src/pmars"
25+
# Always run in brief mode (`-b`). Without it, pmars prints disassembled listing of
26+
# every warrior, which leaks opponent
27+
self.run_cmd_round: str = "./src/pmars -b"
2228
for arg, val in self.game_config.get("args", self.default_args).items():
2329
if isinstance(val, bool):
2430
if val:
2531
self.run_cmd_round += f" -{arg}"
2632
else:
2733
self.run_cmd_round += f" -{arg} {val}"
2834

35+
def _record_count(self) -> int:
36+
# Bounded subset of scored battles to record for replay (recording is the costly part).
37+
sims = self.game_config["sims_per_round"]
38+
return max(1, min(sims, self.game_config.get("record_battles", 100)))
39+
2940
def _run_single_simulation(self, agents: list[Player], idx: int):
3041
# Shift agents by idx to vary starting positions
3142
agents = agents[idx:] + agents[:idx]
3243
args = [f"/{agent.name}/{self.submission}" for agent in agents]
33-
cmd = (
34-
f"{self.run_cmd_round} {shlex.join(args)} "
35-
f"-r {self.game_config['sims_per_round']} "
36-
f"> {self.log_env / COREWAR_LOG.format(idx=idx)};"
37-
)
44+
n = self.game_config["sims_per_round"]
45+
log = self.log_env / COREWAR_LOG.format(idx=idx)
46+
if idx == 0:
47+
# Record R scored battles with -T + the rest plain; both score blocks append to one
48+
# log (get_results sums them), so replays are genuine scored battles. i == agents[i].
49+
r = self._record_count()
50+
self._trace_agent_names = [agent.name for agent in agents]
51+
parts = [f"{self.run_cmd_round} {shlex.join(args)} -r {r} -T {self.log_env / TRACE_RAW} >> {log}"]
52+
if n - r > 0:
53+
parts.append(f"{self.run_cmd_round} {shlex.join(args)} -r {n - r} >> {log}")
54+
cmd = f"rm -f {log}; " + "; ".join(parts) + ";"
55+
else:
56+
cmd = f"{self.run_cmd_round} {shlex.join(args)} -r {n} > {log};"
3857
self.logger.info(f"Running game: {cmd}")
3958
response = self.environment.execute(cmd)
4059
assert response["returncode"] == 0, response
@@ -45,28 +64,51 @@ def execute_round(self, agents: list[Player]):
4564
for future in as_completed(futures):
4665
future.result()
4766

67+
def copy_logs_from_env(self, round_num: int) -> None:
68+
# Distill the -T battles into sim_{i}.jsonl + trace.md on the host, then drop the raw stream.
69+
super().copy_logs_from_env(round_num)
70+
raw = self.log_round(round_num) / TRACE_RAW
71+
if not raw.exists():
72+
return
73+
try:
74+
battles = distill_trace(raw, self.log_round(round_num), getattr(self, "_trace_agent_names", []))
75+
if battles:
76+
dropped = sum(b.cells_dropped for b in battles)
77+
note = f" ({dropped} cell-changes sampled out for size)" if dropped else ""
78+
self.logger.info(f"CoreWar trace distilled: {len(battles)} replay(s) for round {round_num}{note}")
79+
else:
80+
self.logger.warning("CoreWar trace was empty; no replay for this round")
81+
except Exception as e:
82+
self.logger.warning(f"Failed to distill CoreWar trace: {e}")
83+
finally:
84+
raw.unlink(missing_ok=True)
85+
4886
def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
4987
scores, wins = defaultdict(int), defaultdict(int)
88+
score_pat = re.compile(r".*\sby\s.*\sscores\s(\d+)")
5089
for idx in range(len(agents)):
51-
shift = agents[idx:] + agents[:idx] # Shift agents by idx to match simulation order
90+
shift = agents[idx:] + agents[:idx] # Match the command-line warrior order in _run_single_simulation
5291
with open(self.log_round(round_num) / COREWAR_LOG.format(idx=idx)) as f:
5392
result_output = f.read()
5493

55-
# Get the last n lines which contain the scores (closer to original)
56-
lines = result_output.strip().split("\n")
57-
relevant_lines = lines[-len(shift) * 2 :] if len(lines) >= len(shift) * 2 else lines
58-
relevant_lines = [l for l in relevant_lines if len(l.strip()) > 0]
59-
60-
# Go through each line; score position is correlated with agent index
61-
for i, line in enumerate(relevant_lines):
62-
match = re.search(r".*\sby\s.*\sscores\s(\d+)", line)
63-
if match:
64-
scores[shift[i].name] += int(match.group(1))
65-
66-
# Last line corresponds to absolute number of wins
67-
last = relevant_lines[-1][len("Results:") :].strip()
68-
for i, w in enumerate(last.split()[:-1]): # NOTE: Omitting ties (last entry)
69-
wins[shift[i].name] += int(w)
94+
lines = result_output.splitlines()
95+
# Sum across every "…scores…" + "Results:" block (the record shift writes two).
96+
pending: list[int] = []
97+
saw_results = False
98+
for line in lines:
99+
m = score_pat.search(line)
100+
if m:
101+
pending.append(int(m.group(1)))
102+
elif line.strip().startswith("Results:"):
103+
saw_results = True
104+
for i, v in enumerate(pending[-len(shift) :]): # the N score lines of this block
105+
scores[shift[i].name] += v
106+
for i, w in enumerate(line.strip()[len("Results:") :].split()[:-1]): # omit ties
107+
if i < len(shift):
108+
wins[shift[i].name] += int(w)
109+
pending = []
110+
if not saw_results:
111+
self.logger.error(f"No 'Results:' line in {COREWAR_LOG.format(idx=idx)} for round {round_num}")
70112

71113
if len(wins) != len(agents):
72114
# Should not happen

codeclash/arenas/corewar/replay.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""CoreWar replay renderer: the MARS core as a grid, each cell coloured by its owning warrior.
2+
3+
Parses ``sim_*.jsonl`` from ``trace.py`` and folds the per-frame ``c`` deltas into a cumulative
4+
owner-map; recent cells flash bright, PCs are white. No cell holds Redcode contents, so replays
5+
show behaviour, not source (conventions per the corewar-docs visualisation page).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
12+
from codeclash.replay.base import ReplayData, ReplayRenderer
13+
14+
PALETTE = ["#3B78FF", "#E5484D", "#30A46C", "#F5A623", "#8E4EC6", "#12A594", "#E93D82", "#F76B15"]
15+
16+
DRAW_JS = """
17+
const ARENA = (function(){
18+
let CORE, GW, GH, CELL, COL, NAMES, cw, ch;
19+
// Cumulative owner-map folded from frame deltas, with a cursor so forward playback is O(delta).
20+
let owner, builtTo;
21+
function setup(cv, G){
22+
CORE = G.core; GW = G.w; GH = G.h; COL = G.colors; NAMES = G.names;
23+
CELL = Math.max(2, Math.floor(Math.min(880 / GW, 620 / GH)));
24+
cw = cv.width = GW * CELL; ch = cv.height = GH * CELL;
25+
owner = new Int16Array(CORE).fill(-1);
26+
builtTo = -1;
27+
}
28+
function ensure(F, i){
29+
if(builtTo > i){ owner.fill(-1); builtTo = -1; } // scrubbed backward: rebuild
30+
for(let k = builtTo + 1; k <= i; k++){
31+
const cs = F[k].c;
32+
for(let j = 0; j < cs.length; j++) owner[cs[j][0]] = cs[j][1];
33+
}
34+
builtTo = i;
35+
}
36+
const cellX = (a) => (a % GW) * CELL, cellY = (a) => Math.floor(a / GW) * CELL;
37+
function rect(ctx, a, color){ ctx.fillStyle = color; ctx.fillRect(cellX(a), cellY(a), CELL, CELL); }
38+
// Dim the owner colour for settled cells; recent activity is drawn at full strength.
39+
function dim(hex){
40+
const n = parseInt(hex.slice(1), 16);
41+
const r = (n>>16)&255, g = (n>>8)&255, b = n&255;
42+
return `rgb(${(r*0.55)|0},${(g*0.55)|0},${(b*0.55)|0})`;
43+
}
44+
const DIM = {};
45+
function draw(ctx, cv, G, i){
46+
const F = G.frames, f = F[i];
47+
ensure(F, i);
48+
ctx.fillStyle = '#2b2f36'; // grey = untouched core (initial DAT)
49+
ctx.fillRect(0, 0, cw, ch);
50+
for(let a = 0; a < CORE; a++){
51+
const o = owner[a];
52+
if(o >= 0){ const c = COL[o]; rect(ctx, a, (DIM[c] || (DIM[c] = dim(c)))); }
53+
}
54+
(f.c || []).forEach(([a, o]) => rect(ctx, a, COL[o] || '#fff')); // recent activity, bright
55+
(f.p || []).forEach((addr, w) => { // program counters, white
56+
if(f.d && f.d[w]) rect(ctx, addr, '#ffffff');
57+
});
58+
}
59+
function side(G, i){
60+
const f = G.frames[i], ids = Object.keys(NAMES);
61+
return ids.map(id => {
62+
const w = +id, dead = f.d ? !f.d[w] : false, procs = f.n ? f.n[w] : 0;
63+
return `<div class="sn ${dead?'dead':''}"><span class="sw" style="background:${COL[w]}"></span>
64+
<span style="min-width:120px">${NAMES[id]}</span>
65+
<span>${dead ? '\\u2620 dead' : procs + ' proc' + (procs===1?'':'s')}</span></div>`;
66+
}).join('') + '<div class="row muted" style="font-size:12px">cell colour = owning warrior \\u00b7 '
67+
+ 'bright = just now \\u00b7 white = program counter</div>';
68+
}
69+
return {setup, draw, side};
70+
})();
71+
"""
72+
73+
74+
class CoreWarReplayer(ReplayRenderer):
75+
arena = "CoreWar"
76+
sim_glob = "sim_*.jsonl"
77+
DRAW_JS = DRAW_JS
78+
79+
def parse(self, raw: bytes, players=None) -> ReplayData:
80+
rows = [json.loads(line) for line in raw.decode().splitlines() if line.strip()]
81+
if not rows:
82+
return ReplayData(w=1, h=1, frames=[])
83+
header = rows[0]
84+
result = rows[-1] if isinstance(rows[-1], dict) and "winner" in rows[-1] else {}
85+
warriors = {str(k): v for k, v in header.get("warriors", {}).items()}
86+
colors = {int(k): PALETTE[i % len(PALETTE)] for i, k in enumerate(sorted(warriors, key=int))}
87+
88+
frames = []
89+
for r in rows:
90+
if isinstance(r, dict) and "t" in r:
91+
frames.append(
92+
{"turn": r["t"], "c": r.get("c", []), "p": r.get("p", []), "n": r.get("n", []), "d": r.get("d", [])}
93+
)
94+
95+
return ReplayData(
96+
w=header.get("w", 1),
97+
h=header.get("h", 1),
98+
frames=frames,
99+
winner=result.get("winner"),
100+
draw=result.get("draw", False),
101+
extra={
102+
"core": header.get("core", header.get("w", 1) * header.get("h", 1)),
103+
"colors": colors,
104+
"names": warriors,
105+
"starts": header.get("starts", []),
106+
},
107+
)
108+
109+
def peek_winner(self, raw: bytes, players=None) -> tuple[str | None, bool] | None:
110+
for line in reversed(raw.decode().splitlines()):
111+
line = line.strip()
112+
if not line:
113+
continue
114+
try:
115+
obj = json.loads(line)
116+
except ValueError:
117+
return None
118+
if isinstance(obj, dict) and "winner" in obj:
119+
return obj.get("winner"), obj.get("draw", False)
120+
return None
121+
return None

0 commit comments

Comments
 (0)