Skip to content

Commit 20f8cb2

Browse files
committed
WIP: Factored out tournament class from game class
1 parent b8427d4 commit 20f8cb2

11 files changed

Lines changed: 352 additions & 155 deletions

File tree

codeclash/agents/abstract.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from abc import ABC, abstractmethod
44

55
from dotenv import load_dotenv
6-
from minisweagent import Environment
6+
from minisweagent.environments.docker import DockerEnvironment
77

88
from codeclash.agents.utils import GameContext
99
from codeclash.constants import GH_ORG
@@ -17,7 +17,7 @@ class Player(ABC):
1717
def __init__(
1818
self,
1919
config: dict,
20-
environment: Environment,
20+
environment: DockerEnvironment,
2121
game_context: GameContext,
2222
) -> None:
2323
self.config = config

codeclash/games/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from pathlib import Path
2+
13
from codeclash.games.abstract import CodeGame
24
from codeclash.games.battlecode.main import BattleCodeGame
35
from codeclash.games.battlesnake.main import BattleSnakeGame
@@ -7,16 +9,17 @@
79

810

911
# might consider postponing imports to avoid loading things we don't need
10-
def get_game(config: dict) -> CodeGame:
12+
def get_game(config: dict, *, tournament_id: str, local_output_dir: Path) -> CodeGame:
1113
game = {
12-
x.name: x for x in [
14+
x.name: x
15+
for x in [
1316
BattleCodeGame,
1417
BattleSnakeGame,
1518
CoreWarGame,
1619
RoboCodeGame,
17-
RobotRumbleGame
20+
RobotRumbleGame,
1821
]
1922
}.get(config["game"]["name"])
2023
if game is None:
2124
raise ValueError(f"Unknown game: {config['game']['name']}")
22-
return game(config)
25+
return game(config, tournament_id=tournament_id, local_output_dir=local_output_dir)

codeclash/games/abstract.py

Lines changed: 52 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,52 @@
1-
import getpass
21
import json
32
import os
43
import subprocess
5-
import time
6-
import traceback
74
from abc import ABC, abstractmethod
85
from collections import Counter
96
from pathlib import Path
10-
from typing import Any
117

128
from minisweagent.environments.docker import DockerEnvironment
139

1410
from codeclash.agents.abstract import Player
1511
from codeclash.constants import DIR_LOGS, DIR_WORK, GH_ORG
16-
from codeclash.utils.environment import (
17-
assert_zero_exit_code,
18-
copy_between_containers,
19-
copy_file_from_container,
20-
)
12+
from codeclash.utils.environment import assert_zero_exit_code, copy_between_containers
2113
from codeclash.utils.log import get_logger
2214

2315

2416
class CodeGame(ABC):
2517
name: str
2618

27-
def __init__(self, config: dict):
19+
def __init__(self, config: dict, *, tournament_id: str, local_output_dir: Path):
20+
"""The CodeGame class is responsible for running games, i.e., taking a list of code
21+
from different agents/players and running them against each other.
22+
It also provides the environments for the game and agents to run in.
23+
24+
The central method is `run_round`, which takes a list of agents and returns the winner of the round.
25+
26+
At the end of the the tournament, run the `end` method to clean up the game and agents and write the metadata.
27+
"""
2828
self.url_gh: str = f"git@github.com:{GH_ORG}/{self.name}.git"
2929
self.artifacts: list[Path] = []
3030
"""Artifact objects that we might want to clean up after the game."""
3131
self.scoreboard: list[tuple[int, str]] = []
3232
"""List of (round number, winner (player id))"""
3333
self.game_config: dict = config["game"]
3434
self.config: dict = config
35-
self.rounds: int = self.game_config.get("rounds", 1)
36-
self.round: int = 0
37-
self.game_id: str = f"{self.name}{time.strftime('%y%m%d%H%M%S')}"
35+
self.game_id: str = tournament_id
3836
self.log_env: Path = (DIR_WORK / DIR_LOGS / self.game_id).resolve()
39-
self.log_local: Path = (DIR_LOGS / getpass.getuser() / self.game_id).resolve()
37+
self.log_local: Path = local_output_dir
4038
self.logger = get_logger(
4139
self.name, log_path=self.log_local / "game.log", emoji="🏓"
4240
)
4341
self.environment: DockerEnvironment = self.get_environment()
42+
"""The running docker environment for executing the game"""
4443
# assert len(config["players"]) >= 2, "At least two players are required"
44+
"""Total number of rounds to play"""
45+
self._metadata: dict = {
46+
"name": self.name,
47+
"config": self.config,
48+
"game_id": self.game_id,
49+
}
4550

4651
@property
4752
def image_name(self) -> str:
@@ -84,12 +89,7 @@ def get_metadata(self) -> dict:
8489
"""This is what we write to metadata.json.
8590
You can subclass extend this to add more details for specific games.
8691
"""
87-
return {
88-
"name": self.name,
89-
"scoreboard": self.scoreboard,
90-
"config": self.config,
91-
"game_id": self.game_id,
92-
}
92+
return self._metadata
9393

9494
def end(self, cleanup: bool = False):
9595
self.logger.info("Overall score: %s", Counter([x[1] for x in self.scoreboard]))
@@ -121,11 +121,7 @@ def get_environment(self, branch_name: str | None = None) -> DockerEnvironment:
121121
return environment
122122

123123
def _pre_round_setup(self, agents: list[Player]):
124-
"""Copy agent codebases into game's container and make round log file"""
125-
self.round += 1
126-
# Notify agents of round update
127-
self.logger.info(f"▶️ Running {self.name} round {self.round}...")
128-
124+
"""Copy agent codebases into game's container"""
129125
# Copy agent codebases into game's container
130126
for agent in agents:
131127
self.logger.debug(f"Copying {agent.name}'s codebase")
@@ -136,78 +132,55 @@ def _pre_round_setup(self, agents: list[Player]):
136132
dest_path=f"/{agent.name}",
137133
)
138134

139-
# Ensure the log path + file exists
135+
# Ensure the log directory exists
140136
assert_zero_exit_code(
141137
self.environment.execute(f"mkdir -p {self.log_env}"),
142138
logger=self.logger,
143139
)
144-
assert_zero_exit_code(
145-
self.environment.execute(f"touch {self.round_log_path}"), logger=self.logger
146-
)
147140

148141
@abstractmethod
149-
def determine_winner(self, agents: list[Player]) -> Any:
150-
"""Determine the winner of the game based on the round results,
151-
Should update self.scoreboard
142+
def determine_winner(
143+
self, result_output: str, agents: list[Player]
144+
) -> dict[str, str]:
145+
"""Determine the winner of the game based on the result output.
146+
147+
Args:
148+
result_output: The specific output containing winning information
149+
agents: List of agents participating in the round
150+
151+
Returns:
152+
Dictionary with key "winner" containing the winner's name
152153
"""
153154
pass
154155

155156
@abstractmethod
156-
def execute_round(self, agents: list[Player]):
157-
"""Subclasses implement their game-specific logic here, must write results to round_log_path.
157+
def execute_round(self, agents: list[Player]) -> dict[str, str]:
158+
"""Subclasses implement their game-specific logic here.
158159
This is the low level implementation, you probably want to use run_round instead, which
159160
includes the pre-round setup, post-round setup, and winner determination.
161+
162+
Returns:
163+
Dictionary with keys "log_output" and "result_output"
160164
"""
161165
pass
162166

163-
def _post_round_setup(self, agents: list[Player]):
164-
for agent in agents:
165-
try:
166-
copy_between_containers(
167-
self.environment,
168-
agent.environment,
169-
self.round_log_path,
170-
f"{agent.environment.config.cwd}/logs/round_{self.round}.log",
171-
)
172-
except Exception:
173-
self.logger.error(
174-
f"Error copying round log to {agent.name}'s container: {traceback.format_exc()}"
175-
)
176-
else:
177-
self.logger.info(f"Copied round log to {agent.name}'s container.")
178-
179-
try:
180-
copy_file_from_container(
181-
self.environment,
182-
self.round_log_path,
183-
self.log_local / self.round_log_path.name,
184-
)
185-
except Exception:
186-
self.logger.error(
187-
f"Error copying round log to {agent.name}'s container: {traceback.format_exc()}"
188-
)
189-
else:
190-
self.logger.info(
191-
f"Copied round log from {agent.name}'s container to local log dir."
192-
)
193-
self.logger.info(f"Round {self.round} completed.")
194-
195-
def run_round(self, agents: list[Player]):
167+
def run_round(self, agents: list[Player]) -> dict[str, str]:
196168
"""
197169
Run a single round of the game with the given agents.
198170
199-
Writes to directory containing logs and results of the round(s).
171+
Returns the log output, result output, and winner name. All bookkeeping should be
172+
handled by the tournament class.
200173
"""
201174
self._pre_round_setup(agents)
202-
self.execute_round(agents)
203-
self.determine_winner(agents)
204-
last_winner = self.scoreboard[-1][1]
205-
self.logger.info(f"Round {self.round} winner: {last_winner}")
206-
self._post_round_setup(agents)
175+
result = self.execute_round(agents)
176+
log_output = result["log_output"]
177+
result_output = result["result_output"]
207178

208-
@property
209-
def round_log_path(self) -> Path:
210-
"""
211-
Get the path to the current round's log file.
212-
"""
213-
return self.log_env / f"round_{self.round}.log"
179+
winner_result = self.determine_winner(result_output, agents)
180+
winner_name = winner_result["winner"]
181+
182+
return {
183+
"log_output": log_output,
184+
"result_output": result_output,
185+
"winner": winner_name,
186+
}

codeclash/games/battlecode/main.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import re
2+
from pathlib import Path
23
from typing import Any
34

45
from codeclash.constants import DIR_WORK, RESULT_TIE
@@ -8,8 +9,10 @@
89
class BattleCodeGame(CodeGame):
910
name: str = "BattleCode"
1011

11-
def __init__(self, config):
12-
super().__init__(config)
12+
def __init__(self, config, *, tournament_id: str, local_output_dir: Path):
13+
super().__init__(
14+
config, tournament_id=tournament_id, local_output_dir=local_output_dir
15+
)
1316
assert len(config["players"]) == 2, "BattleCode is a two-player game"
1417
self.run_cmd_round: str = "python run.py run"
1518
for arg, val in self.game_config.get("args", {}).items():
@@ -19,13 +22,27 @@ def __init__(self, config):
1922
else:
2023
self.run_cmd_round += f" --{arg} {val}"
2124

22-
def determine_winner(self, agents: list[Any]):
23-
response = self.environment.execute(f"tail -3 {self.round_log_path} | head -1")
24-
winner = re.search(r"\s\((.*)\)\swins\s\(", response["output"]).group(1)
25-
winner = {"A": agents[0].name, "B": agents[1].name}.get(winner, RESULT_TIE)
26-
self.scoreboard.append((self.round, winner))
25+
def determine_winner(self, result_output: str, agents: list[Any]) -> dict[str, str]:
26+
self.logger.debug(f"Determining winner from result output: {result_output}")
27+
lines = result_output.strip().split("\n")
28+
# Get the third-to-last line which contains the winner info
29+
winner_line = lines[-3] if len(lines) >= 3 else ""
30+
self.logger.debug(f"Winner line: {winner_line}")
31+
match = re.search(r"\s\((.*)\)\swins\s\(", winner_line)
32+
if match:
33+
winner_key = match.group(1)
34+
self.logger.debug(f"Winner key from match: {winner_key}")
35+
# Map A/B to actual agent names (much closer to original code)
36+
winner = {"A": agents[0].name, "B": agents[1].name}.get(
37+
winner_key, RESULT_TIE
38+
)
39+
self.logger.debug(f"Concluding winner: {winner}")
40+
return {"winner": winner}
41+
else:
42+
self.logger.debug("No winner match found, returning tie")
43+
return {"winner": RESULT_TIE}
2744

28-
def execute_round(self, agents: list[Any]):
45+
def execute_round(self, agents: list[Any]) -> dict[str, str]:
2946
for agent in agents:
3047
src, dest = f"/{agent.name}/src/mysubmission/", str(
3148
DIR_WORK / "src" / agent.name
@@ -35,7 +52,10 @@ def execute_round(self, agents: list[Any]):
3552
f"--p{idx+1}-dir src --p{idx+1} {agent.name}"
3653
for idx, agent in enumerate(agents)
3754
]
38-
cmd = f"{self.run_cmd_round} {' '.join(args)} > {self.round_log_path}"
55+
cmd = f"{self.run_cmd_round} {' '.join(args)}"
3956
self.logger.info(f"Running command: {cmd}")
4057
response = self.environment.execute(cmd)
4158
assert response["returncode"] == 0, response
59+
# For BattleCode, log_output and result_output are the same
60+
output = response["output"]
61+
return {"log_output": output, "result_output": output}
Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import time
3+
from pathlib import Path
34

45
from codeclash.agents.abstract import Player
56
from codeclash.games.abstract import CodeGame
@@ -9,8 +10,10 @@
910
class BattleSnakeGame(CodeGame):
1011
name: str = "BattleSnake"
1112

12-
def __init__(self, config):
13-
super().__init__(config)
13+
def __init__(self, config, *, tournament_id: str, local_output_dir: Path):
14+
super().__init__(
15+
config, tournament_id=tournament_id, local_output_dir=local_output_dir
16+
)
1417
self.run_cmd_round: str = "./battlesnake play"
1518
for arg, val in self.game_config.get("args", {}).items():
1619
if isinstance(val, bool):
@@ -19,14 +22,19 @@ def __init__(self, config):
1922
else:
2023
self.run_cmd_round += f" --{arg} {val}"
2124

22-
def determine_winner(self, agents: list[Player]):
23-
response = assert_zero_exit_code(
24-
self.environment.execute(f"tail -1 {self.round_log_path}")
25-
)
26-
winner = json.loads(response["output"].strip("\n"))["winnerName"]
27-
self.scoreboard.append((self.round, winner))
25+
def determine_winner(
26+
self, result_output: str, agents: list[Player]
27+
) -> dict[str, str]:
28+
self.logger.debug(f"Determining winner from result output: {result_output}")
29+
lines = result_output.strip().split("\n")
30+
# Get the last line which contains the game result
31+
last_line = lines[-1] if lines else ""
32+
self.logger.debug(f"Last line: {last_line}")
33+
winner = json.loads(last_line)["winnerName"]
34+
self.logger.debug(f"Concluding winner: {winner}")
35+
return {"winner": winner}
2836

29-
def execute_round(self, agents: list[Player]):
37+
def execute_round(self, agents: list[Player]) -> dict[str, str]:
3038
cmd = []
3139
for idx, agent in enumerate(agents):
3240
port = 8001 + idx
@@ -38,18 +46,27 @@ def execute_round(self, agents: list[Player]):
3846

3947
time.sleep(3) # Give servers time to start
4048

41-
cmd.append(f"-o {self.round_log_path}")
42-
cmd = " ".join(cmd)
43-
self.logger.info(f"Running command: {cmd}")
49+
# Create temporary output file for results
50+
output_file = f"battlesnake_output_{int(time.time())}.json"
51+
cmd_str = " ".join(cmd) + f" -o {output_file}"
52+
self.logger.info(f"Running command: {self.run_cmd_round} {cmd_str}")
4453

45-
# todo: should probably keep output somewhere?
4654
try:
47-
assert_zero_exit_code(
55+
response = assert_zero_exit_code(
4856
self.environment.execute(
49-
f"{self.run_cmd_round} {cmd}",
57+
f"{self.run_cmd_round} {cmd_str}",
5058
cwd=f"{self.environment.config.cwd}/game",
5159
)
5260
)
61+
62+
# Read the output file for result information
63+
result_response = self.environment.execute(f"cat game/{output_file}")
64+
result_output = result_response["output"]
65+
66+
# Clean up the output file
67+
self.environment.execute(f"rm -f game/{output_file}")
68+
69+
return {"log_output": response["output"], "result_output": result_output}
5370
finally:
5471
# Kill all python servers when done
5572
self.environment.execute("pkill -f 'python main.py' || true")

0 commit comments

Comments
 (0)