55
66from codeclash .agents .player import Player
77from codeclash .arenas .arena import CodeArena , RoundStats
8+ from codeclash .arenas .corewar .trace import distill_trace
89from codeclash .constants import RESULT_TIE
910
1011COREWAR_LOG = "sim_{idx}.log"
12+ TRACE_RAW = "trace_raw.txt"
1113
1214
1315class 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
0 commit comments