Skip to content

Commit 93b0319

Browse files
committed
Add BattleCode24 arena implementation
1 parent 7fb9f18 commit 93b0319

3 files changed

Lines changed: 395 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
FROM eclipse-temurin:8-jdk
2+
3+
ENV JAVA_HOME=/opt/java/openjdk
4+
RUN apt-get update && apt-get install -y --no-install-recommends \
5+
git curl unzip && \
6+
rm -rf /var/lib/apt/lists/*
7+
8+
RUN git clone https://github.com/CodeClash-ai/BattleCode2024.git /workspace \
9+
&& cd /workspace \
10+
&& git remote set-url origin https://github.com/CodeClash-ai/BattleCode2024.git
11+
WORKDIR /workspace
12+
13+
RUN chmod +x gradlew && ./gradlew update
14+

codeclash/arenas/battlecode24/__init__.py

Whitespace-only changes.
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
import re
2+
import subprocess
3+
from collections import defaultdict
4+
from concurrent.futures import ThreadPoolExecutor, as_completed
5+
from dataclasses import dataclass, field
6+
from typing import Literal
7+
8+
from tqdm.auto import tqdm
9+
10+
from codeclash.agents.player import Player
11+
from codeclash.arenas.arena import CodeArena, RoundStats
12+
from codeclash.constants import DIR_WORK, RESULT_TIE
13+
14+
BC24_LOG = "sim_{idx}.log"
15+
BC24_FOLDER = "mysubmission"
16+
BC24_TIE = "Reason: The winning team won arbitrarily (coin flip)."
17+
18+
19+
@dataclass
20+
class SimulationMeta:
21+
"""Metadata for a single simulation, storing team assignments explicitly."""
22+
idx: int
23+
team_a: str
24+
team_b: str
25+
log_file: str
26+
27+
28+
@dataclass
29+
class RoundResult:
30+
"""Result of execute_round, used to communicate status to get_results."""
31+
status: Literal["completed", "auto_win", "no_contest"]
32+
winner: str | None = None
33+
loser: str | None = None
34+
reason: str = ""
35+
simulations: list[SimulationMeta] = field(default_factory=list)
36+
37+
38+
class BattleCode24Arena(CodeArena):
39+
"""BattleCode24 arena implementation.
40+
41+
Lifecycle:
42+
1. validate_code() - Source-level structural checks only (in agent container)
43+
2. execute_round() - Compile and run simulations (in game container)
44+
3. get_results() - Parse logs and determine winner
45+
46+
Failure handling:
47+
- If one agent fails to compile, the other wins automatically
48+
- If both fail to compile, round is a no-contest (tie)
49+
- Individual simulation failures don't count toward either player
50+
"""
51+
52+
name: str = "BattleCode24"
53+
description: str = """Battlecode 2024: Breadwars is a real-time strategy game where your Java bot controls a team of robots competing to capture the opponent's flags.
54+
Your mission: capture all 3 of the opponent's flags before they capture yours. Robots can attack, heal, build traps, dig/fill terrain, and specialize in different skills through experience.
55+
The game features a setup phase (first 200 rounds) where teams are separated by a dam, followed by open combat. Robots gain experience and level up their attack, build, and heal specializations."""
56+
default_args: dict = {
57+
"maps": "DefaultSmall",
58+
}
59+
submission: str = "src/mysubmission"
60+
61+
def __init__(self, config, **kwargs):
62+
super().__init__(config, **kwargs)
63+
assert len(config["players"]) == 2, "BattleCode24 is a two-player game"
64+
65+
# Build base run command
66+
self.run_cmd_base: str = "./gradlew --no-daemon run"
67+
for arg, val in self.game_config.get("args", self.default_args).items():
68+
if isinstance(val, bool):
69+
if val:
70+
self.run_cmd_base += f" -P{arg}=true"
71+
else:
72+
self.run_cmd_base += f" -P{arg}={val}"
73+
74+
# Round state (set by execute_round, used by get_results)
75+
self._round_result: RoundResult | None = None
76+
77+
78+
def validate_code(self, agent: Player) -> tuple[bool, str | None]:
79+
"""Validate source structure. No compilation - that happens in execute_round.
80+
81+
Checks:
82+
1. src/mysubmission/ directory exists
83+
2. RobotPlayer.java file exists
84+
3. run(RobotController rc) method signature present
85+
4. Correct package declaration
86+
"""
87+
# Check for mysubmission directory
88+
ls_output = agent.environment.execute("ls src")["output"]
89+
if BC24_FOLDER not in ls_output:
90+
return False, f"There should be a `src/{BC24_FOLDER}/` directory"
91+
92+
# Check for RobotPlayer.java file
93+
ls_mysubmission = agent.environment.execute(f"ls src/{BC24_FOLDER}")["output"]
94+
if "RobotPlayer.java" not in ls_mysubmission:
95+
return False, f"There should be a `src/{BC24_FOLDER}/RobotPlayer.java` file"
96+
97+
# Check for run(RobotController rc) method
98+
robot_player_content = agent.environment.execute(f"cat src/{BC24_FOLDER}/RobotPlayer.java")["output"]
99+
if "public static void run(RobotController" not in robot_player_content:
100+
return False, f"There should be a `run(RobotController rc)` method implemented in `src/{BC24_FOLDER}/RobotPlayer.java`"
101+
102+
# Check for correct package declaration
103+
if f"package {BC24_FOLDER};" not in robot_player_content:
104+
return False, f"The package declaration should be `package {BC24_FOLDER};` in `src/{BC24_FOLDER}/RobotPlayer.java`"
105+
106+
return True, None
107+
108+
109+
def _compile_agent(self, agent: Player, idx: int) -> str | None:
110+
"""Compile an agent's code in the game container.
111+
112+
Args:
113+
agent: The agent to compile
114+
idx: Index for naming the output directory
115+
116+
Returns:
117+
Path to compiled classes directory, or None if compilation failed
118+
"""
119+
# Copy agent code to workspace
120+
src = f"/{agent.name}/src/{BC24_FOLDER}/"
121+
dest = str(DIR_WORK / "src" / BC24_FOLDER)
122+
self.environment.execute(f"rm -rf {dest}; mkdir -p {dest}; cp -r {src}* {dest}/")
123+
124+
# Compile (use clean to ensure fresh compilation, avoiding stale cache)
125+
compile_result = self.environment.execute("./gradlew clean compileJava", timeout=120)
126+
if compile_result["returncode"] != 0:
127+
self.logger.warning(
128+
f"Failed to compile agent {agent.name}:\n{compile_result['output'][-1000:]}"
129+
)
130+
return None
131+
132+
# Save compiled classes outside build/ (gradle clean deletes build/)
133+
classes_dir = f"/tmp/agent{idx}_classes"
134+
self.environment.execute(
135+
f"rm -rf {classes_dir}; mkdir -p {classes_dir}; cp -r build/classes/* {classes_dir}/"
136+
)
137+
138+
self.logger.info(f"Successfully compiled {agent.name}")
139+
return classes_dir
140+
141+
142+
def _run_simulation(
143+
self,
144+
sim_meta: SimulationMeta,
145+
agents: list[Player],
146+
agent_classes: dict[str, str],
147+
) -> None:
148+
"""Run a single simulation.
149+
150+
Args:
151+
sim_meta: Simulation metadata with team assignments
152+
agents: List of agents (for name lookup)
153+
agent_classes: Map of agent name -> compiled classes path
154+
"""
155+
cmd = (
156+
f"{self.run_cmd_base} "
157+
f"-PteamA={sim_meta.team_a} "
158+
f"-PteamB={sim_meta.team_b} "
159+
f"-PpackageNameA=mysubmission "
160+
f"-PpackageNameB=mysubmission "
161+
f"-PclassLocationA={agent_classes[sim_meta.team_a]} "
162+
f"-PclassLocationB={agent_classes[sim_meta.team_b]}"
163+
)
164+
165+
try:
166+
response = self.environment.execute(
167+
cmd + f" > {self.log_env / sim_meta.log_file} 2>&1",
168+
timeout=120,
169+
)
170+
except subprocess.TimeoutExpired:
171+
self.logger.warning(f"Simulation {sim_meta.idx} timed out")
172+
return
173+
174+
if response["returncode"] != 0:
175+
self.logger.warning(
176+
f"Simulation {sim_meta.idx} failed with exit code {response['returncode']}"
177+
)
178+
179+
def execute_round(self, agents: list[Player]):
180+
"""Execute a round: compile all agents, then run simulations.
181+
182+
Handles failures gracefully:
183+
- If one agent fails to compile, the other wins automatically
184+
- If both fail, round is a no-contest
185+
"""
186+
# Phase 1: Compile all agents
187+
agent_classes: dict[str, str | None] = {}
188+
for idx, agent in enumerate(agents):
189+
classes_path = self._compile_agent(agent, idx)
190+
agent_classes[agent.name] = classes_path
191+
192+
# Check compilation results
193+
compiled_agents = [a for a in agents if agent_classes[a.name] is not None]
194+
failed_agents = [a for a in agents if agent_classes[a.name] is None]
195+
196+
if len(compiled_agents) == 0:
197+
self.logger.error("All agents failed to compile - no contest")
198+
self._round_result = RoundResult(
199+
status="no_contest",
200+
reason="all agents failed to compile",
201+
)
202+
return
203+
204+
if len(compiled_agents) == 1:
205+
winner = compiled_agents[0]
206+
loser = failed_agents[0]
207+
self.logger.info(
208+
f"Only {winner.name} compiled successfully (opponent {loser.name} failed) - automatic win"
209+
)
210+
self._round_result = RoundResult(
211+
status="auto_win",
212+
winner=winner.name,
213+
loser=loser.name,
214+
reason=f"{loser.name} failed to compile",
215+
)
216+
return
217+
218+
# Phase 2: Build simulation metadata with alternating team positions
219+
num_sims = self.game_config["sims_per_round"]
220+
simulations: list[SimulationMeta] = []
221+
222+
for idx in range(num_sims):
223+
# Alternate team positions for fairness
224+
if idx % 2 == 0:
225+
team_a, team_b = agents[0].name, agents[1].name
226+
else:
227+
team_a, team_b = agents[1].name, agents[0].name
228+
229+
simulations.append(SimulationMeta(
230+
idx=idx,
231+
team_a=team_a,
232+
team_b=team_b,
233+
log_file=BC24_LOG.format(idx=idx),
234+
))
235+
236+
# Phase 3: Run simulations in parallel
237+
self.logger.info(f"Running {num_sims} simulations with alternating team positions")
238+
239+
# Filter to only compiled agents' classes
240+
valid_classes = {name: path for name, path in agent_classes.items() if path is not None}
241+
242+
with ThreadPoolExecutor(5) as executor:
243+
futures = [
244+
executor.submit(self._run_simulation, sim, agents, valid_classes)
245+
for sim in simulations
246+
]
247+
for future in tqdm(as_completed(futures), total=len(futures), desc="Simulations"):
248+
try:
249+
future.result()
250+
except Exception as e:
251+
self.logger.error(f"Simulation raised unexpected exception: {e}")
252+
253+
self._round_result = RoundResult(
254+
status="completed",
255+
simulations=simulations,
256+
)
257+
258+
def _parse_simulation_log(self, log_path, sim_meta: SimulationMeta) -> str | None:
259+
"""Parse a single simulation log to determine the winner.
260+
261+
Args:
262+
log_path: Path to the log file
263+
sim_meta: Simulation metadata with team assignments
264+
265+
Returns:
266+
Winner agent name, RESULT_TIE, or None if parsing failed
267+
"""
268+
if not log_path.exists():
269+
self.logger.debug(f"Simulation {sim_meta.idx}: log file missing")
270+
return None
271+
272+
with open(log_path) as f:
273+
content = f.read().strip()
274+
275+
lines = content.split("\n")
276+
if len(lines) < 2:
277+
self.logger.debug(f"Simulation {sim_meta.idx}: log too short (game crashed?)")
278+
return None
279+
280+
# Find the winner line (contains "wins" and "[server]")
281+
winner_line = None
282+
reason_line = None
283+
for i, line in enumerate(lines):
284+
if "wins" in line and "[server]" in line:
285+
winner_line = line
286+
if i + 1 < len(lines):
287+
reason_line = lines[i + 1]
288+
break
289+
290+
if not winner_line:
291+
self.logger.debug(f"Simulation {sim_meta.idx}: no winner line found")
292+
return RESULT_TIE
293+
294+
# Extract A or B from winner line: "mysubmission (A) wins" or "mysubmission (B) wins"
295+
match = re.search(r"\(([AB])\)\s+wins", winner_line)
296+
if not match:
297+
self.logger.debug(f"Simulation {sim_meta.idx}: could not parse winner from line")
298+
return RESULT_TIE
299+
300+
winner_key = match.group(1)
301+
302+
# Check for coin flip tie
303+
if reason_line and BC24_TIE in reason_line:
304+
return RESULT_TIE
305+
306+
# Map A/B to agent names using stored metadata (no recalculation needed)
307+
if winner_key == "A":
308+
return sim_meta.team_a
309+
else:
310+
return sim_meta.team_b
311+
312+
def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
313+
"""Parse simulation results and determine the round winner."""
314+
315+
# Handle early termination cases
316+
if self._round_result is None:
317+
self.logger.error("get_results called but execute_round didn't set _round_result")
318+
stats.winner = RESULT_TIE
319+
return
320+
321+
if self._round_result.status == "no_contest":
322+
self.logger.info(f"Round ended in no-contest: {self._round_result.reason}")
323+
stats.winner = RESULT_TIE
324+
# Split points evenly
325+
points = self.game_config["sims_per_round"] / len(agents)
326+
for agent in agents:
327+
stats.scores[agent.name] = points
328+
stats.player_stats[agent.name].score = points
329+
stats.player_stats[agent.name].valid_submit = False
330+
stats.player_stats[agent.name].invalid_reason = "Compilation failed (no contest)"
331+
return
332+
333+
if self._round_result.status == "auto_win":
334+
winner = self._round_result.winner
335+
loser = self._round_result.loser
336+
self.logger.info(f"Round auto-win: {winner} ({self._round_result.reason})")
337+
stats.winner = winner
338+
stats.scores[winner] = self.game_config["sims_per_round"]
339+
stats.player_stats[winner].score = self.game_config["sims_per_round"]
340+
if loser and loser in stats.player_stats:
341+
stats.player_stats[loser].valid_submit = False
342+
stats.player_stats[loser].invalid_reason = f"Compilation failed: {self._round_result.reason}"
343+
return
344+
345+
# Normal case: parse simulation logs
346+
scores = defaultdict(int)
347+
348+
tie_count = 0
349+
for sim in self._round_result.simulations:
350+
log_path = self.log_round(round_num) / sim.log_file
351+
winner = self._parse_simulation_log(log_path, sim)
352+
353+
if winner is None:
354+
pass
355+
elif winner == RESULT_TIE:
356+
tie_count += 1
357+
else:
358+
scores[winner] += 1
359+
360+
if tie_count > 0:
361+
self.logger.info(f"{tie_count} simulation(s) ended in tie")
362+
363+
# Determine overall winner
364+
if scores:
365+
# Find max score, check for ties
366+
max_score = max(scores.values())
367+
leaders = [name for name, score in scores.items() if score == max_score]
368+
369+
if len(leaders) == 1:
370+
stats.winner = leaders[0]
371+
else:
372+
stats.winner = RESULT_TIE
373+
else:
374+
# All simulations failed
375+
self.logger.warning("All simulations failed to produce results")
376+
stats.winner = RESULT_TIE
377+
378+
for player, score in scores.items():
379+
stats.scores[player] = score
380+
if player != RESULT_TIE:
381+
stats.player_stats[player].score = score

0 commit comments

Comments
 (0)