Skip to content

Commit 87fd0b7

Browse files
committed
Add SCML arena
1 parent a66d63e commit 87fd0b7

14 files changed

Lines changed: 523 additions & 0 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ Critically, *LMs don't play the game directly*.
9595
Their code serves as their competitive proxy.
9696
The winner is the LM agent who wins the most rounds.
9797

98+
## 🧩 Available Arenas
99+
100+
CodeClash includes competitive programming games and simulation-backed arenas, including BattleSnake,
101+
CoreWar, Halite, HuskyBench, RoboCode, RobotRumble, and SCML.
102+
103+
SCML is a supply-chain negotiation arena based on the ANAC Supply Chain Management League OneShot
104+
track. Agents edit a Python `scml_agent.py` implementation and compete to maximize average profit
105+
across multiple simulated supply-chain worlds.
106+
98107
## 🚀 Get Involved
99108

100109
- Check out our [docs](https://docs.codeclash.ai/) for more details on running different arenas, configuring tournaments, etc.

codeclash/arenas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from codeclash.arenas.huskybench.huskybench import HuskyBenchArena
1616
from codeclash.arenas.robocode.robocode import RoboCodeArena
1717
from codeclash.arenas.robotrumble.robotrumble import RobotRumbleArena
18+
from codeclash.arenas.scml.scml import SCMLOneShotArena
1819

1920
ARENAS = [
2021
BattleCode23Arena,
@@ -33,6 +34,7 @@
3334
HuskyBenchArena,
3435
RoboCodeArena,
3536
RobotRumbleArena,
37+
SCMLOneShotArena,
3638
]
3739

3840

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+
ENV DEBIAN_FRONTEND=noninteractive \
4+
PYTHONDONTWRITEBYTECODE=1 \
5+
PIP_NO_CACHE_DIR=1
6+
7+
RUN apt-get update \
8+
&& apt-get install -y --no-install-recommends \
9+
ca-certificates git build-essential jq \
10+
&& rm -rf /var/lib/apt/lists/*
11+
12+
RUN python -m pip install --upgrade pip \
13+
&& python -m pip install scml==0.8.2
14+
15+
WORKDIR /workspace
16+
17+
COPY codeclash/arenas/scml/runtime/ /workspace/
18+
19+
RUN git init \
20+
&& git config user.email "player@codeclash.com" \
21+
&& git config user.name "Player" \
22+
&& git add . \
23+
&& git commit -m "Initial SCML workspace"

codeclash/arenas/scml/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
__pycache__/
2+
*.py[cod]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# SCML OneShot CodeClash Workspace
2+
3+
Edit `scml_agent.py`.
4+
5+
Your file must define `MyAgent`, an SCML OneShot agent class. A safe starting point is:
6+
7+
```python
8+
from scml.oneshot.agents import GreedySyncAgent
9+
10+
11+
class MyAgent(GreedySyncAgent):
12+
pass
13+
```
14+
15+
The arena runs multiple SCML2024 OneShot worlds and scores agents by average profit.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import argparse
2+
import importlib.util
3+
import json
4+
import random
5+
import re
6+
from pathlib import Path
7+
8+
import numpy as np
9+
from scml.oneshot import SCML2024OneShotWorld
10+
11+
12+
def safe_class_name(player_name: str) -> str:
13+
safe = re.sub(r"\W+", "_", player_name)
14+
if not safe or safe[0].isdigit():
15+
safe = f"player_{safe}"
16+
return f"CodeClash_{safe}"
17+
18+
19+
def load_agent_class(player_name: str, path: str):
20+
module_name = f"codeclash_scml_{safe_class_name(player_name).lower()}"
21+
spec = importlib.util.spec_from_file_location(module_name, path)
22+
if spec is None or spec.loader is None:
23+
raise RuntimeError(f"Could not load module spec from {path}")
24+
module = importlib.util.module_from_spec(spec)
25+
spec.loader.exec_module(module)
26+
if not hasattr(module, "MyAgent"):
27+
raise RuntimeError(f"{path} does not define MyAgent")
28+
base_class = module.MyAgent
29+
return type(safe_class_name(player_name), (base_class,), {"__module__": module.__name__})
30+
31+
32+
def run_world(agent_classes: dict[str, type], *, sim_idx: int, steps: int, lines: int) -> dict:
33+
seed = 1729 + sim_idx
34+
random.seed(seed)
35+
np.random.seed(seed)
36+
37+
player_names = list(agent_classes.keys())
38+
offset = sim_idx % len(player_names)
39+
ordered_names = player_names[offset:] + player_names[:offset]
40+
wrapped_classes = [agent_classes[name] for name in ordered_names]
41+
class_to_player = {cls.__name__: player for player, cls in agent_classes.items()}
42+
43+
config = SCML2024OneShotWorld.generate(
44+
agent_types=wrapped_classes,
45+
agent_processes=[0 for _ in wrapped_classes],
46+
n_steps=steps,
47+
n_processes=1,
48+
n_lines=lines,
49+
random_agent_types=False,
50+
)
51+
world = SCML2024OneShotWorld(
52+
**config,
53+
no_logs=True,
54+
compact=True,
55+
fast=True,
56+
agent_name_reveals_type=True,
57+
agent_name_reveals_position=True,
58+
)
59+
world.run()
60+
61+
raw_scores = world.scores()
62+
player_scores = {player: 0.0 for player in player_names}
63+
details = []
64+
for agent_id, score in raw_scores.items():
65+
world_agent = world.agents[agent_id]
66+
player = class_to_player.get(world_agent.short_type_name)
67+
if player is None:
68+
continue
69+
numeric_score = float(score)
70+
player_scores[player] = numeric_score
71+
details.append(
72+
{
73+
"sim": sim_idx,
74+
"player": player,
75+
"world_agent_id": agent_id,
76+
"score": numeric_score,
77+
}
78+
)
79+
80+
return {"scores": player_scores, "details": details}
81+
82+
83+
def parse_agent_arg(value: str) -> tuple[str, str]:
84+
if "=" not in value:
85+
raise argparse.ArgumentTypeError("--agent values must be NAME=/path/to/scml_agent.py")
86+
name, path = value.split("=", 1)
87+
if not name:
88+
raise argparse.ArgumentTypeError("agent name cannot be empty")
89+
if not Path(path).exists():
90+
raise argparse.ArgumentTypeError(f"agent path does not exist: {path}")
91+
return name, path
92+
93+
94+
def main() -> None:
95+
parser = argparse.ArgumentParser()
96+
parser.add_argument("--agent", action="append", type=parse_agent_arg, required=True)
97+
parser.add_argument("--sims", type=int, default=3)
98+
parser.add_argument("--steps", type=int, default=10)
99+
parser.add_argument("--lines", type=int, default=2)
100+
parser.add_argument("--output", required=True)
101+
args = parser.parse_args()
102+
103+
agent_classes = {name: load_agent_class(name, path) for name, path in args.agent}
104+
totals = {name: 0.0 for name in agent_classes}
105+
details = []
106+
107+
for sim_idx in range(args.sims):
108+
result = run_world(agent_classes, sim_idx=sim_idx, steps=args.steps, lines=args.lines)
109+
for player, score in result["scores"].items():
110+
totals[player] += score
111+
details.extend(result["details"])
112+
113+
averages = {player: score / args.sims for player, score in totals.items()}
114+
output = {
115+
"average_scores": averages,
116+
"total_scores": totals,
117+
"sims": args.sims,
118+
"details": [json.dumps(item, sort_keys=True) for item in details],
119+
}
120+
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
121+
Path(args.output).write_text(json.dumps(output, indent=2, sort_keys=True))
122+
123+
124+
if __name__ == "__main__":
125+
main()
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from scml.oneshot.agents import GreedySyncAgent
2+
3+
4+
class MyAgent(GreedySyncAgent):
5+
"""Baseline SCML OneShot agent.
6+
7+
Improve this class to negotiate better supply-chain contracts and maximize profit.
8+
"""
9+
10+
pass

codeclash/arenas/scml/scml.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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 = "scml_results.json"
11+
12+
13+
class SCMLOneShotArena(CodeArena):
14+
name: str = "SCML"
15+
submission: str = "scml_agent.py"
16+
description: str = """SCML OneShot is a supply-chain negotiation simulator based on the ANAC Supply Chain Management League.
17+
18+
Your bot is a Python file named `scml_agent.py` that defines a class named `MyAgent`.
19+
`MyAgent` should inherit from an SCML OneShot agent class, for example:
20+
21+
from scml.oneshot.agents import GreedySyncAgent
22+
23+
class MyAgent(GreedySyncAgent):
24+
...
25+
26+
Each round runs several SCML2024 OneShot worlds. Your agent negotiates with the other submitted
27+
agents to buy or sell goods in a simulated supply chain. The objective is to maximize profit. The
28+
arena score is your average SCML score across all worlds in the round.
29+
"""
30+
default_args: dict = {
31+
"sims_per_round": 3,
32+
"n_steps": 10,
33+
"n_lines": 2,
34+
"timeout": 180,
35+
}
36+
37+
def _game_arg(self, key: str):
38+
return self.game_config.get(key, self.default_args[key])
39+
40+
def validate_code(self, agent: Player) -> tuple[bool, str | None]:
41+
quoted_submission = shlex.quote(self.submission)
42+
file_check = agent.environment.execute(f"test -f {quoted_submission} && echo exists")
43+
if "exists" not in file_check["output"]:
44+
return False, f"Submission file `{self.submission}` not found in the workspace root"
45+
46+
content = agent.environment.execute(f"cat {quoted_submission}")["output"]
47+
if not content.strip():
48+
return False, f"`{self.submission}` is empty"
49+
50+
syntax_check = agent.environment.execute(f"python -m py_compile {quoted_submission}")
51+
if syntax_check["returncode"] != 0:
52+
return False, f"Python syntax error in `{self.submission}`:\n{syntax_check['output']}"
53+
54+
import_check = agent.environment.execute(
55+
"python - <<'PY'\n"
56+
"import importlib.util\n"
57+
f"spec = importlib.util.spec_from_file_location('submission_agent', {self.submission!r})\n"
58+
"module = importlib.util.module_from_spec(spec)\n"
59+
"spec.loader.exec_module(module)\n"
60+
"assert hasattr(module, 'MyAgent'), 'MyAgent class not found'\n"
61+
"from scml.oneshot.agent import OneShotAgent\n"
62+
"assert issubclass(module.MyAgent, OneShotAgent), 'MyAgent must inherit from an SCML OneShotAgent class'\n"
63+
"PY"
64+
)
65+
if import_check["returncode"] != 0:
66+
return False, f"Could not import `MyAgent` from `{self.submission}`:\n{import_check['output']}"
67+
68+
return True, None
69+
70+
def execute_round(self, agents: list[Player]) -> None:
71+
agent_args = []
72+
for agent in agents:
73+
agent_args.extend(["--agent", f"{agent.name}=/{agent.name}/{self.submission}"])
74+
75+
cmd = [
76+
"python",
77+
"run_scml.py",
78+
"--sims",
79+
str(self._game_arg("sims_per_round")),
80+
"--steps",
81+
str(self._game_arg("n_steps")),
82+
"--lines",
83+
str(self._game_arg("n_lines")),
84+
"--output",
85+
str(self.log_env / RESULTS_JSON),
86+
*agent_args,
87+
]
88+
full_cmd = " ".join(shlex.quote(part) for part in cmd)
89+
self.logger.info(f"Running game: {full_cmd}")
90+
try:
91+
response = self.environment.execute(full_cmd, timeout=int(self._game_arg("timeout")))
92+
except subprocess.TimeoutExpired as exc:
93+
raise RuntimeError("SCML round timed out") from exc
94+
assert_zero_exit_code(response, logger=self.logger)
95+
96+
def get_results(self, agents: list[Player], round_num: int, stats: RoundStats):
97+
result_file = self.log_round(round_num) / RESULTS_JSON
98+
if not result_file.exists():
99+
self.logger.error(f"Missing result file: {result_file}")
100+
stats.winner = RESULT_TIE
101+
for agent in agents:
102+
stats.scores[agent.name] = 0.0
103+
stats.player_stats[agent.name].score = 0.0
104+
return
105+
106+
with open(result_file) as f:
107+
result = json.load(f)
108+
109+
scores = {agent.name: 0.0 for agent in agents}
110+
for player, score in result.get("average_scores", {}).items():
111+
if player in scores:
112+
scores[player] = float(score)
113+
114+
stats.scores = scores
115+
stats.details = result.get("details", [])
116+
for player, score in scores.items():
117+
stats.player_stats[player].score = score
118+
119+
if not scores:
120+
stats.winner = RESULT_TIE
121+
return
122+
123+
top_score = max(scores.values())
124+
winners = [player for player, score in scores.items() if score == top_score]
125+
stats.winner = winners[0] if len(winners) == 1 else RESULT_TIE
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
tournament:
2+
rounds: 1
3+
game:
4+
name: SCML
5+
sims_per_round: 2
6+
n_steps: 5
7+
n_lines: 2
8+
timeout: 240
9+
players:
10+
- agent: dummy
11+
name: alpha
12+
- agent: dummy
13+
name: beta
14+
prompts:
15+
game_description: |-
16+
You are a software developer ({{player_id}}) competing in CodeClash's SCML OneShot arena.
17+
18+
The game is played in {{total_rounds}} rounds. For every round, you and your competitors edit
19+
code that controls an autonomous supply-chain negotiation agent. This is round {{round}}.
20+
21+
Your task: improve `scml_agent.py`, located in {{working_dir}}.
22+
All commands run from {{working_dir}}.
23+
24+
Your file must define `MyAgent`, an SCML OneShot agent class. A valid starting point is:
25+
26+
from scml.oneshot.agents import GreedySyncAgent
27+
28+
class MyAgent(GreedySyncAgent):
29+
pass
30+
31+
The arena runs multiple SCML2024 OneShot worlds. Your objective is to maximize average profit.

0 commit comments

Comments
 (0)