Skip to content

Commit d148180

Browse files
committed
Merge branch 'main' into john/abclash
2 parents 37f1ba3 + 5acbedb commit d148180

37 files changed

Lines changed: 3531 additions & 610 deletions

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ The winner is the LM agent who wins the most rounds.
9898
## 🧩 Available Arenas
9999

100100
CodeClash includes competitive programming games and simulation-backed arenas, including BattleSnake,
101-
CoreWar, CybORG, Halite, HuskyBench, RoboCode, RobotRumble, and SCML.
101+
Bomberland, CoreWar, CybORG, Halite, HuskyBench, RoboCode, RobotRumble, and SCML.
102+
103+
Bomberland is a Bomberman-style grid arena based on Coder One's Bomberland competition. Agents edit
104+
a Python `bomberland_agent.py` implementation and compete to maximize average score across seeded
105+
simulations through survival, damage, kills, and destructible-block control.
102106

103107
SCML is a supply-chain negotiation arena based on the ANAC Supply Chain Management League OneShot
104108
track. Agents edit a Python `scml_agent.py` implementation and compete to maximize average profit

codeclash/agents/player.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def __init__(
3535
log_path=self.game_context.log_local / "players" / self.name / "player.log",
3636
emoji="👤",
3737
)
38+
self._branch_name = config.get("branch", f"{self.game_context.id}.{self.name}")
3839
self._metadata = {
3940
"name": self.name,
4041
"player_unique_id": self._player_unique_id,
@@ -46,10 +47,6 @@ def __init__(
4647
"agent_stats": {}, # mapping round -> agent stats
4748
}
4849

49-
if branch := config.get("branch_init"):
50-
self.logger.info(f"Checking out branch {branch}")
51-
assert_zero_exit_code(self.environment.execute(f"git checkout {branch}"), logger=self.logger)
52-
5350
if self.push:
5451
self.logger.info("Will push agent gameplay as branch to remote repository after each round")
5552
token = os.getenv("GITHUB_TOKEN")
@@ -61,6 +58,33 @@ def __init__(
6158
]:
6259
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)
6360

61+
# Handle branch initialization
62+
if branch_init := config.get("branch_init"):
63+
# Fetch from remote first (handles branches pushed in previous tournaments)
64+
# Then checkout - git will create tracking branch if needed
65+
assert_zero_exit_code(
66+
self.environment.execute(f"git fetch origin && git checkout {branch_init}"),
67+
logger=self.logger,
68+
)
69+
self.logger.info(f"Checked out initial branch {branch_init}")
70+
71+
if self._branch_name != branch_init:
72+
self.logger.info(f"Switching to branch {self._branch_name} for pushing changes")
73+
# First fetch to see if the branch exists on remote
74+
assert_zero_exit_code(
75+
self.environment.execute("git fetch origin"),
76+
logger=self.logger,
77+
)
78+
# Try to checkout the branch - git will track remote if it exists there
79+
checkout_result = self.environment.execute(f"git checkout {self._branch_name}")
80+
if checkout_result.get("returncode", 0) != 0:
81+
# Branch doesn't exist locally or remotely, create it
82+
self.logger.info(f"Branch {self._branch_name} doesn't exist, creating it")
83+
assert_zero_exit_code(
84+
self.environment.execute(f"git checkout -b {self._branch_name}"),
85+
logger=self.logger,
86+
)
87+
6488
# --- Main methods ---
6589

6690
def pre_run_hook(self, *, new_round: int) -> None:
@@ -104,7 +128,7 @@ def post_run_hook(self, *, round: int) -> None:
104128

105129
if self.push:
106130
for cmd in [
107-
f"git push origin {self._branch_name}",
131+
f"git push -u origin {self._branch_name}",
108132
"git push origin --tags",
109133
]:
110134
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)
@@ -155,11 +179,6 @@ def _tag_round(self, round: int) -> None:
155179
)
156180
self._metadata["round_tags"][round] = tag
157181

158-
@property
159-
def _branch_name(self) -> str:
160-
"""Get the branch name for the agent's codebase."""
161-
return f"{self.game_context.id}.{self.name}"
162-
163182
def _get_round_tag_name(self, round: int) -> str:
164183
"""Get git tag name for the version of the codebase at the given round."""
165184
return f"{self._player_unique_id}-round-{round}"

codeclash/arenas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from codeclash.arenas.battlecode24.battlecode24 import BattleCode24Arena
44
from codeclash.arenas.battlecode25.battlecode25 import BattleCode25Arena
55
from codeclash.arenas.battlesnake.battlesnake import BattleSnakeArena
6+
from codeclash.arenas.bomberland.bomberland import BomberlandArena
67
from codeclash.arenas.bridge.bridge import BridgeArena
78
from codeclash.arenas.chess.chess import ChessArena
89
from codeclash.arenas.corewar.corewar import CoreWarArena
@@ -23,6 +24,7 @@
2324
BattleCode24Arena,
2425
BattleCode25Arena,
2526
BattleSnakeArena,
27+
BomberlandArena,
2628
BridgeArena,
2729
ChessArena,
2830
CoreWarArena,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
FROM python:3.11-slim-bookworm
2+
3+
ARG BOMBERLAND_COMMIT=8b6b7a1c013d96feb0a5468a7a59a63a7c59dadc
4+
ENV BOMBERLAND_UPSTREAM_COMMIT=${BOMBERLAND_COMMIT}
5+
6+
RUN apt-get update \
7+
&& apt-get install -y --no-install-recommends git ca-certificates \
8+
&& rm -rf /var/lib/apt/lists/*
9+
10+
# Keep a pinned copy of the upstream competition source for provenance and
11+
# agent authors who want to inspect the original starter-kit shape.
12+
RUN git clone https://github.com/CoderOneHQ/bomberland.git /opt/bomberland \
13+
&& cd /opt/bomberland \
14+
&& git checkout ${BOMBERLAND_COMMIT}
15+
16+
WORKDIR /workspace
17+
COPY codeclash/arenas/bomberland/runtime/ /workspace/
18+
19+
RUN git init \
20+
&& git config user.email "arena@codeclash.com" \
21+
&& git config user.name "CodeClash Arena" \
22+
&& git add . \
23+
&& git commit -m "Initialize Bomberland runtime"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from codeclash.arenas.bomberland.bomberland import BomberlandArena
2+
3+
__all__ = ["BomberlandArena"]
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import json
2+
import shlex
3+
import subprocess
4+
5+
from codeclash.agents.player import Player
6+
from codeclash.arenas.arena import CodeArena, RoundStats
7+
from codeclash.constants import RESULT_TIE
8+
from codeclash.utils.environment import assert_zero_exit_code
9+
10+
RESULTS_JSON = "bomberland_results.json"
11+
CRASH_SCORE = -1_000_000.0
12+
13+
14+
class BomberlandArena(CodeArena):
15+
name: str = "Bomberland"
16+
submission: str = "bomberland_agent.py"
17+
description: str = """Bomberland is a Bomberman-style multi-agent arena based on Coder One's Bomberland competition.
18+
19+
Your bot is a Python file named `bomberland_agent.py` that defines a callable named `next_actions`.
20+
The callable receives a game-state dictionary and should return a dictionary mapping unit ids to actions:
21+
22+
def next_actions(game_state):
23+
return {"unit_0": "up"}
24+
25+
Valid actions are `up`, `down`, `left`, `right`, `bomb`, `stay`, and `detonate` (to blow up one of
26+
your own bombs early, e.g. the string `"detonate:x,y"` or `{"type": "detonate", "coordinates": [x, y]}`;
27+
bombs also explode automatically after their timer). Each round runs several deterministic seeded
28+
games. Your units move on a destructible grid, place bombs, destroy blocks, damage opposing units,
29+
and score by survival, damage, kills, and block destruction. Bomb blasts (`x` entities) stay active
30+
briefly and damage any unit standing on or moving into them.
31+
"""
32+
default_args: dict = {
33+
"sims_per_round": 4,
34+
"ticks": 80,
35+
"width": 11,
36+
"height": 11,
37+
"unit_count": 3,
38+
"agent_timeout": 0.25,
39+
"validation_timeout": 5,
40+
"timeout": 180,
41+
}
42+
43+
def __init__(self, config: dict, **kwargs):
44+
player_count = len(config.get("players", []))
45+
if player_count != 2:
46+
raise ValueError("Bomberland requires exactly two players")
47+
game_config = config.get("game", {})
48+
game_args = game_config.get("args", {})
49+
sims_per_round = int(
50+
game_args.get("sims_per_round", game_config.get("sims_per_round", self.default_args["sims_per_round"]))
51+
)
52+
if sims_per_round % 2 != 0:
53+
raise ValueError("Bomberland requires an even sims_per_round so both players get paired starting sides")
54+
super().__init__(config, **kwargs)
55+
56+
def _game_arg(self, key: str):
57+
nested_args = self.game_config.get("args", {})
58+
return nested_args.get(key, self.game_config.get(key, self.default_args[key]))
59+
60+
def _sims_per_round(self) -> int:
61+
return int(self._game_arg("sims_per_round"))
62+
63+
def validate_code(self, agent: Player) -> tuple[bool, str | None]:
64+
quoted_submission = shlex.quote(self.submission)
65+
file_check = agent.environment.execute(f"test -f {quoted_submission} && echo exists")
66+
if "exists" not in file_check["output"]:
67+
return False, f"Submission file `{self.submission}` not found in the workspace root"
68+
69+
content = agent.environment.execute(f"cat {quoted_submission}")["output"]
70+
if not content.strip():
71+
return False, f"`{self.submission}` is empty"
72+
73+
syntax_check = agent.environment.execute(f"python -m py_compile {quoted_submission}")
74+
if syntax_check["returncode"] != 0:
75+
return False, f"Python syntax error in `{self.submission}`:\n{syntax_check['output']}"
76+
77+
validation_timeout = int(self._game_arg("validation_timeout"))
78+
try:
79+
import_check = agent.environment.execute(
80+
"python - <<'PY'\n"
81+
"import importlib.util\n"
82+
f"spec = importlib.util.spec_from_file_location('submission_agent', {self.submission!r})\n"
83+
"module = importlib.util.module_from_spec(spec)\n"
84+
"spec.loader.exec_module(module)\n"
85+
"assert hasattr(module, 'next_actions'), 'next_actions callable not found'\n"
86+
"assert callable(module.next_actions), 'next_actions must be callable'\n"
87+
"state = {\n"
88+
" 'connection': {'agent_id': 'Alice'},\n"
89+
" 'agents': {'Alice': {'unit_ids': ['u0']}},\n"
90+
" 'unit_state': {'u0': {'agent_id': 'Alice', 'hp': 3, 'coordinates': [1, 1]}},\n"
91+
" 'entities': [],\n"
92+
" 'world': {'width': 5, 'height': 5},\n"
93+
" 'tick': 0,\n"
94+
"}\n"
95+
"result = module.next_actions(state)\n"
96+
"assert result is None or isinstance(result, dict), 'next_actions must return a dict or None'\n"
97+
"PY",
98+
timeout=validation_timeout,
99+
)
100+
except subprocess.TimeoutExpired:
101+
return False, f"`next_actions` validation exceeded {validation_timeout}s timeout"
102+
if import_check["returncode"] != 0:
103+
return False, f"Could not import or call `next_actions` from `{self.submission}`:\n{import_check['output']}"
104+
105+
return True, None
106+
107+
def execute_round(self, agents: list[Player]) -> None:
108+
agent_args = []
109+
for agent in agents:
110+
agent_args.extend(["--agent", f"{agent.name}=/{agent.name}/{self.submission}"])
111+
112+
cmd = [
113+
"python",
114+
"run_bomberland.py",
115+
"--sims",
116+
str(self._sims_per_round()),
117+
"--ticks",
118+
str(self._game_arg("ticks")),
119+
"--width",
120+
str(self._game_arg("width")),
121+
"--height",
122+
str(self._game_arg("height")),
123+
"--unit-count",
124+
str(self._game_arg("unit_count")),
125+
"--agent-timeout",
126+
str(self._game_arg("agent_timeout")),
127+
"--output",
128+
str(self.log_env / RESULTS_JSON),
129+
*agent_args,
130+
]
131+
full_cmd = " ".join(shlex.quote(part) for part in cmd)
132+
self.logger.info(f"Running game: {full_cmd}")
133+
try:
134+
response = self.environment.execute(full_cmd, timeout=int(self._game_arg("timeout")))
135+
except subprocess.TimeoutExpired as exc:
136+
raise RuntimeError("Bomberland round timed out") from exc
137+
assert_zero_exit_code(response, logger=self.logger)
138+
139+
def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
140+
result_file = self.log_round(round_num) / RESULTS_JSON
141+
if not result_file.exists():
142+
self.logger.error(f"Missing result file: {result_file}")
143+
stats.winner = RESULT_TIE
144+
for agent in agents:
145+
stats.scores[agent.name] = CRASH_SCORE
146+
stats.player_stats[agent.name].score = CRASH_SCORE
147+
stats.details.append(
148+
json.dumps(
149+
{
150+
"player": agent.name,
151+
"score": CRASH_SCORE,
152+
"status": "error",
153+
"error": f"missing Bomberland result file: {result_file}",
154+
},
155+
sort_keys=True,
156+
)
157+
)
158+
return
159+
160+
with open(result_file) as f:
161+
result = json.load(f)
162+
163+
scores = {agent.name: CRASH_SCORE for agent in agents}
164+
for player, score in result.get("average_scores", {}).items():
165+
if player in scores:
166+
scores[player] = float(score)
167+
168+
stats.scores = scores
169+
stats.details = result.get("details", [])
170+
for player, score in scores.items():
171+
stats.player_stats[player].score = score
172+
173+
if not scores:
174+
stats.winner = RESULT_TIE
175+
return
176+
177+
top_score = max(scores.values())
178+
winners = [player for player, score in scores.items() if score == top_score]
179+
stats.winner = winners[0] if len(winners) == 1 else RESULT_TIE
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__pycache__/
2+
*.pyc
3+
bomberland_results.json
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Bomberland CodeClash Runtime
2+
3+
This runtime adapts the Coder One Bomberland competition format into a compact,
4+
deterministic CodeClash arena. The Docker image keeps a pinned checkout of
5+
`CoderOneHQ/bomberland` at `/opt/bomberland` for provenance and starter-kit
6+
reference, while `run_bomberland.py` provides the runtime used by CodeClash.
7+
8+
Submissions must provide `bomberland_agent.py` with:
9+
10+
```python
11+
def next_actions(game_state):
12+
return {"unit_0": "up"}
13+
```
14+
15+
Valid string actions are `up`, `down`, `left`, `right`, `bomb`, `stay`, and
16+
`detonate` (blow up one of your own bombs early, e.g. `"detonate:x,y"` or
17+
`{"type": "detonate", "coordinates": [x, y]}`; bombs also explode on their timer).
18+
The game-state dictionary follows the upstream starter-kit shape where possible:
19+
`connection.agent_id` identifies the player, `agents[player].unit_ids` lists the
20+
controlled units, `unit_state` contains unit coordinates and health, and
21+
`entities` contains walls, destructible blocks, bombs, and blast tiles (`x`).
22+
Blast tiles stay active briefly and damage any unit that stands on or moves onto
23+
them, so avoid walking into fire.
24+
25+
Round simulation counts must be even so each player receives both starting sides.
26+
27+
Smoke command from the repository root:
28+
29+
```bash
30+
uv run python main.py configs/examples/Bomberland__dummy__r1__s2.yaml -o /tmp/codeclash-bomberland-smoke
31+
```
32+
33+
Use a fresh `-o` directory when rerunning the smoke check. Expected output:
34+
the command exits with status 0, both players pass validation, each round
35+
summary contains floating-point scores, and the output directory contains
36+
`metadata.json`, `game.log`, `tournament.log`, and compressed round logs.
37+
38+
Expected result shape:
39+
40+
```json
41+
{
42+
"average_scores": {"player_a": 330.0, "player_b": 330.0},
43+
"total_scores": {"player_a": 660.0, "player_b": 660.0},
44+
"sims": 2,
45+
"details": ["... per-simulation JSON strings ..."]
46+
}
47+
```
48+
49+
Each detail entry is a JSON string with `scores`, `stats`, `alive_units`,
50+
`alive_hp`, `ticks`, and `winner` fields. Per-player `stats` include
51+
`agent_errors` and `invalid_actions`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
def next_actions(game_state):
2+
agent_id = game_state["connection"]["agent_id"]
3+
unit_ids = game_state["agents"].get(agent_id, {}).get("unit_ids", [])
4+
unit_state = game_state.get("unit_state", {})
5+
return {unit_id: "stay" for unit_id in unit_ids if unit_state.get(unit_id, {}).get("hp", 0) > 0}

0 commit comments

Comments
 (0)