Introduce maximum number of turns

bram/mute
Bram van den Heuvel 2026-07-01 22:45:13 +02:00
parent d7ac20a6dd
commit 194f962746
3 changed files with 77 additions and 22 deletions

1
elo.py
View File

@ -24,6 +24,7 @@ def main() -> int:
tracker.start_periodic_matches( tracker.start_periodic_matches(
game=RoyalGameOfUr.empty(), game=RoyalGameOfUr.empty(),
interval_seconds=9 * 60, interval_seconds=9 * 60,
max_turns=1_500,
player_count=2, player_count=2,
) )

View File

@ -14,7 +14,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Sequence from typing import Any, Sequence
import agents import agents.remote
import pyclient import pyclient
import time import time
@ -39,7 +39,7 @@ class PlayerIdentifier:
return (self.name, self.url, self.version) return (self.name, self.url, self.version)
@classmethod @classmethod
def from_server_agent(cls, agent : agents.RemoteAgent) -> "PlayerIdentifier": def from_server_agent(cls, agent : agents.remote.RemoteAgent) -> "PlayerIdentifier":
""" """
Gain a player identifier from an agent. Gain a player identifier from an agent.
""" """
@ -177,7 +177,7 @@ class Match:
@classmethod @classmethod
def from_replay( def from_replay(
cls, cls,
players : list[agents.RemoteAgent], players : list[agents.remote.RemoteAgent],
replay : pyclient.GameReplay, replay : pyclient.GameReplay,
timestamp : str | None, timestamp : str | None,
) -> "Match": ) -> "Match":
@ -291,7 +291,7 @@ class EloTracker:
# Thread-unsafe variables # Thread-unsafe variables
# Please use a lock while doing CRUD operations on them # Please use a lock while doing CRUD operations on them
self.players: list[agents.RemoteAgent] = [] self.players: list[agents.remote.RemoteAgent] = []
self.__matches: list[Match] = [] self.__matches: list[Match] = []
self.__stats: dict[str, dict[PlayerIdentifier, EloStat]] = {} self.__stats: dict[str, dict[PlayerIdentifier, EloStat]] = {}
@ -436,6 +436,7 @@ class EloTracker:
game: Game, game: Game,
interval_seconds: float, interval_seconds: float,
player_count: int, player_count: int,
max_turns : int | None = None,
) -> None: ) -> None:
""" """
Perform a schedule in which you play a random game. Perform a schedule in which you play a random game.
@ -446,6 +447,8 @@ class EloTracker:
:type interval_seconds: float :type interval_seconds: float
:param player_count: The number of players that are supposed to participate :param player_count: The number of players that are supposed to participate
:type player_count: int :type player_count: int
:param max_turns: Optional maximum number of turns to allow
:type max_turns: int | None
""" """
while not self.__scheduler_stop.is_set(): while not self.__scheduler_stop.is_set():
try: try:
@ -468,7 +471,11 @@ class EloTracker:
self.__debug( self.__debug(
f"Playing a new scheduled match of {game.game_name()}" f"Playing a new scheduled match of {game.game_name()}"
) )
self.play_random_match(game=game, player_count=player_count) self.play_random_match(
game=game,
max_turns=max_turns,
player_count=player_count,
)
except Exception as exc: except Exception as exc:
self.__debug(f"Scheduled match failed: {exc}") self.__debug(f"Scheduled match failed: {exc}")
# raise exc # raise exc
@ -582,13 +589,13 @@ class EloTracker:
"Expected `players` field to be a list of strings." "Expected `players` field to be a list of strings."
) )
players : list[agents.RemoteAgent] = [] players : list[agents.remote.RemoteAgent] = []
for url in urls: for url in urls:
if not isinstance(url, str): if not isinstance(url, str):
continue continue
try: try:
agent = agents.RemoteAgent(url=url) agent = agents.remote.RemoteAgent(url=url)
except ValueError: except ValueError:
pass # Not an available player right now pass # Not an available player right now
else: else:
@ -604,41 +611,59 @@ class EloTracker:
player_id=PlayerIdentifier.from_server_agent(agent) player_id=PlayerIdentifier.from_server_agent(agent)
) )
def play_match(self, players: list[str], game: Game) -> pyclient.GameReplay: def play_match(
self,
players: list[str],
game: Game,
max_turns : int | None = None,
) -> pyclient.GameReplay | None:
""" """
Play a single match with appointed players. Play a single match with appointed players.
:param players: List of URLs that participate. :param players: List of URLs that participate.
:type players: list[str] :type players: list[str]
:param game: The game to start playing
:type game: Game
:param max_turns: Optional maximum number of turns to allow
:type max_turns: int | None
:return: A summary of the game. :return: A summary of the game.
:rtype: pyclient.GameReplay :rtype: pyclient.GameReplay
:raises ValueError: One of the URLs could not be accessed. :raises ValueError: One of the URLs could not be accessed.
""" """
ags : list[Any] = [ agents.RemoteAgent(url=url) for url in players ] ags : list[Any] = [ agents.remote.RemoteAgent(url=url) for url in players ]
replay = pyclient.PyClient(debug=self.debug).play_game( try:
players=ags, replay = pyclient.PyClient(debug=self.debug).play_game(
start=game, players=ags,
) start=game,
max_turns=max_turns,
)
m = Match.from_replay(players=ags, replay=replay, timestamp=Match.now()) except ValueError:
return None # Exceeded turn limit
else:
m = Match.from_replay(players=ags, replay=replay, timestamp=Match.now())
# Record match # Record match
m.log(self.game_file_name) m.log(self.game_file_name)
self.__register_match(m) self.__register_match(m)
return replay return replay
def play_random_match( def play_random_match(
self, self,
game: Game, game: Game,
max_turns : int | None = None,
player_count: int | None = None, player_count: int | None = None,
) -> pyclient.GameReplay: ) -> pyclient.GameReplay | None:
""" """
Play a game with any known players. Play a game with any known players.
:param game: The game to start playing :param game: The game to start playing
:type game: Game :type game: Game
:param max_turns: Optional maximum number of turns to allow
:type max_turns: int | None
:param player_count: Optional number of players to select. :param player_count: Optional number of players to select.
:type player_count: int | None :type player_count: int | None
:raises ValueError: One of the randomly chosen URLs could not be accessed. :raises ValueError: One of the randomly chosen URLs could not be accessed.
@ -657,6 +682,7 @@ class EloTracker:
self, self,
game: Game, game: Game,
interval_seconds: float = 300, interval_seconds: float = 300,
max_turns : int | None = None,
player_count: int = 2, player_count: int = 2,
) -> None: ) -> None:
""" """
@ -666,6 +692,8 @@ class EloTracker:
:type game: pyclient.Game :type game: pyclient.Game
:param interval_seconds: Number of seconds to sleep between games :param interval_seconds: Number of seconds to sleep between games
:type interval_seconds: float :type interval_seconds: float
:param max_turns: Optional maximum number of turns to allow
:type max_turns: int | None
:param player_count: The number of players that are supposed to participate :param player_count: The number of players that are supposed to participate
:type player_count: int :type player_count: int
""" """
@ -679,6 +707,7 @@ class EloTracker:
thread = threading.Thread( thread = threading.Thread(
target=self.__scheduler_loop, target=self.__scheduler_loop,
args=(game, interval_seconds, player_count), args=(game, interval_seconds, player_count),
kwargs={"max_turns": max_turns},
daemon=True, daemon=True,
) )
self.__scheduler_threads.append(thread) self.__scheduler_threads.append(thread)

View File

@ -19,7 +19,12 @@ class PyClient:
debug : bool debug : bool
def gen_game(self, players : list[Agent], start : Game) -> Generator[Turn, None, None]: def gen_game(
self,
players : list[Agent],
start : Game,
max_turns : int | None = None,
) -> Generator[Turn, None, None]:
""" """
Generate a game by polling the players. Generate a game by polling the players.
@ -27,12 +32,22 @@ class PyClient:
:type players: list[Agent] :type players: list[Agent]
:param start: The start state of the game. :param start: The start state of the game.
:type start: Game :type start: Game
:param max_turns: The maximum number of turns a game may take
:type max_turns: int
:return: A generator that yields turns. :return: A generator that yields turns.
:rtype: Generator[Turn, None, None] :rtype: Generator[Turn, None, None]
:raises ValueError: The game is taking too long.
""" """
current_state = start current_state = start
turns_taken = 0
while current_state.winner() is None: while current_state.winner() is None:
if max_turns is not None and turns_taken >= max_turns:
raise ValueError(
f"Turn limit exceeded: played for {turns_taken} turns and still needed more."
)
turns_taken += 1
player = current_state.player_to_move() player = current_state.player_to_move()
if len(players) < player: if len(players) < player:
@ -61,7 +76,12 @@ class PyClient:
yield Turn(action=payload, player=player, state=current_state) yield Turn(action=payload, player=player, state=current_state)
def play_game(self, players : list[Agent], start : Game) -> GameReplay: def play_game(
self,
players : list[Agent],
start : Game,
max_turns : int | None = None,
) -> GameReplay:
""" """
Generate a game by polling the players. Collect all moves in a Generate a game by polling the players. Collect all moves in a
summary. summary.
@ -70,11 +90,16 @@ class PyClient:
:type players: list[Agent] :type players: list[Agent]
:param start: The start state of the game. :param start: The start state of the game.
:type start: Game :type start: Game
:param max_turns: The maximum number of turns a game may take
:type max_turns: int
:return: Summary describing how the game went. :return: Summary describing how the game went.
:rtype: GameReplay :rtype: GameReplay
:raises ValueError: The game is taking too long.
""" """
return GameReplay( return GameReplay(
game_name=start.game_name(), game_name=start.game_name(),
start=start, start=start,
turns=list(self.gen_game(players=players, start=start)), turns=list(self.gen_game(
players=players, start=start, max_turns=max_turns
)),
) )