diff --git a/README.md b/README.md index ea218c2..1c86571 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Public entry point is the `Hexo` class. - `game.moves_left_in_turn() -> int`: return remaining moves in current turn (`2` or `1`). - `game.pending_moves() -> tuple[Coord, ...]`: return moves already made in the current turn. - `game.is_legal_move(coord) -> tuple[bool, str | None]`: validate one submove. -- `game.legal_moves() -> tuple[Coord, ...]`: return legal single-move candidates. +- `game.legal_moves -> Collection[Coord]`: property that returns legal single-move candidates as a live iterable view. - `game.push(coord) -> TurnRecord | None`: play one move; returns `None` if turn is still partial, or `TurnRecord` when the turn completes (or wins early). - `game.is_legal(move) -> tuple[bool, str | None]`: validate a full 2-stone move (only when no partial turn is active). - `game.play(move) -> TurnRecord`: convenience wrapper that places two stones in sequence. diff --git a/RULES.md b/RULES.md index 40ac865..696ea14 100644 --- a/RULES.md +++ b/RULES.md @@ -54,13 +54,3 @@ Because `P1` starts with `(0, 0)`, there is always at least one occupied cell be - The game ends as soon as a win condition is met. - If both stones of a turn are placed and both create winning lines for the same player, the result is still a single win for that player. - -## 7. Notes for Engine API - -- Keep rule validation deterministic and side-effect free. -- Recommended checks for a turn: - - Correct number of stones for that turn stage. - - No duplicate cells in the move. - - All cells empty. - - Distance rule for each placed stone. - - Win detection after applying the full turn. diff --git a/benchmarks/test_engine_benchmarks.py b/benchmarks/test_engine_benchmarks.py index 83509ec..c1f59cd 100644 --- a/benchmarks/test_engine_benchmarks.py +++ b/benchmarks/test_engine_benchmarks.py @@ -1,6 +1,8 @@ from __future__ import annotations -from hexo import Coord, Hexo +from hexo import Hexo + +Coord = tuple[int, int] def _coord_key(coord: Coord) -> tuple[int, int, int]: @@ -16,7 +18,7 @@ def _build_position(plies: int = 80) -> Hexo: for _ in range(plies): if game.status().name != "ONGOING": break - legal = game.legal_moves() + legal = game.legal_moves if not legal: break game.push(min(legal, key=_coord_key)) @@ -25,18 +27,18 @@ def _build_position(plies: int = 80) -> Hexo: def test_benchmark_legal_moves(benchmark) -> None: game = _build_position() - benchmark(game.legal_moves) + benchmark(lambda: tuple(game.legal_moves)) def test_benchmark_is_legal_move(benchmark) -> None: game = Hexo.new() - target = min(game.legal_moves(), key=_coord_key) + target = min(game.legal_moves, key=_coord_key) benchmark(lambda: game.is_legal_move(target)) def test_benchmark_push_undo_cycle(benchmark) -> None: game = Hexo.new() - target = min(game.legal_moves(), key=_coord_key) + target = min(game.legal_moves, key=_coord_key) def _action() -> None: game.push(target) diff --git a/examples/random_match.py b/examples/random_match.py index 1adb468..deefa9f 100644 --- a/examples/random_match.py +++ b/examples/random_match.py @@ -4,14 +4,16 @@ from pedros import get_logger, timed -from hexo import Coord, GameStatus, Hexo +from hexo import GameStatus, Hexo + +Coord = tuple[int, int] def random_engine(game: Hexo, rng: random.Random) -> Coord: - candidates = game.legal_moves() + candidates = game.legal_moves if not candidates: raise RuntimeError("no legal candidate placements found") - return rng.choice(candidates) + return rng.choice(tuple(candidates)) def play_turn_random(game: Hexo, rng: random.Random) -> tuple[Coord, ...]: diff --git a/pyproject.toml b/pyproject.toml index f5faa7d..05f500c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hexo" -version = "0.1.0" +version = "0.2.0" description = "A fast, reusable Python engine for Hexo on an infinite hex grid." readme = "README.md" license = {file = "LICENSE"} diff --git a/src/hexo/__init__.py b/src/hexo/__init__.py index 4178c59..50bac5e 100644 --- a/src/hexo/__init__.py +++ b/src/hexo/__init__.py @@ -1,30 +1,19 @@ """ Expose the public API for the Hexo engine package. -This module configures package-level logging and re-exports the primary engine -types so users can import them directly from `hexo` instead of internal modules. +This module re-exports the primary engine types so users can import them +directly from `hexo` instead of internal modules. """ -import logging -from pedros.logger import setup_logging - from hexo.engine import Hexo -from hexo.errors import HexoError, IllegalTurnError -from hexo.geometry import hex_distance -from hexo.types import AXES, Coord, EngineConfig, GameStatus, Player, State, TurnRecord - -setup_logging(level=logging.INFO) +from hexo.errors import IllegalTurnError +from hexo.types import EngineConfig, GameStatus, Player, TurnRecord __all__ = [ - "Coord", "EngineConfig", "GameStatus", "Hexo", - "HexoError", "IllegalTurnError", - "AXES", "Player", - "State", "TurnRecord", - "hex_distance", ] diff --git a/src/hexo/engine.py b/src/hexo/engine.py index 74744b0..2bc322c 100644 --- a/src/hexo/engine.py +++ b/src/hexo/engine.py @@ -1,12 +1,6 @@ -""" -Implement the core Hexo game state and rule validation logic. - -This module provides the single public `Hexo` class and state transition logic. -""" - from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Collection, Iterator, Sequence from hexo.errors import IllegalTurnError from hexo.geometry import hex_distance @@ -22,6 +16,59 @@ ) +class LegalMovesView(Collection[Coord]): + """ + Provide a lightweight live view over an engine's legal move set. + + :param engine: + Engine instance whose legal moves should be exposed. + """ + + def __init__(self, engine: Hexo) -> None: + """ + Initialize a legal moves view for one engine. + + :param engine: + Engine instance whose legal move cache is accessed. + """ + self._engine = engine + + def __iter__(self) -> Iterator[Coord]: + """ + Iterate over current legal moves. + + :return: + Iterator over legal coordinates. + """ + if self._engine._winner is not None: + return iter(()) + return iter(self._engine._legal_moves_cache) + + def __len__(self) -> int: + """ + Return number of current legal moves. + + :return: + Number of legal coordinates. + """ + if self._engine._winner is not None: + return 0 + return len(self._engine._legal_moves_cache) + + def __contains__(self, coord: object) -> bool: + """ + Check membership in the legal move set. + + :param coord: + Candidate coordinate object. + :return: + `True` if `coord` is a legal move. + """ + if self._engine._winner is not None: + return False + return coord in self._engine._legal_moves_cache + + class Hexo: """ Single public API for Hexo. @@ -51,7 +98,8 @@ def __init__(self, config: EngineConfig | None = None) -> None: self._winner: Player | None = None self._to_move = Player.P2 self._legal_moves_cache: set[Coord] = set() - self._legal_moves_tuple_cache: tuple[Coord, ...] | None = None + self._legal_moves_view = LegalMovesView(self) + self._radius_offsets = self._build_radius_offsets(self.config.placement_radius) self._expand_legal_cache_from(self.config.opening_center) @classmethod @@ -252,7 +300,6 @@ def push(self, coord: Coord) -> TurnRecord | None: self._stones_by_player[self._to_move].add(coord) self._legal_moves_cache.discard(coord) added_legal = self._expand_legal_cache_from(coord) - self._legal_moves_tuple_cache = None self._pending.append(coord) self._undo_stack.append( ( @@ -266,7 +313,7 @@ def push(self, coord: Coord) -> TurnRecord | None: ) ) - won = self._has_winning_line(self._to_move, (coord, coord)) + won = self._has_winning_line(self._to_move, (coord,)) if won: record = TurnRecord( player=self._to_move, placements=tuple(self._pending), won=True @@ -320,7 +367,6 @@ def undo(self) -> TurnRecord: self._legal_moves_cache.add(coord) for added in added_legal: self._legal_moves_cache.discard(added) - self._legal_moves_tuple_cache = None if len(self._turn_history) > prev_history_len: self._turn_history = self._turn_history[:prev_history_len] return affected @@ -345,18 +391,15 @@ def board(self) -> dict[Coord, Player]: """ return dict(self._board) - def legal_moves(self) -> tuple[Coord, ...]: + @property + def legal_moves(self) -> Collection[Coord]: """ - Return all currently legal single-placement candidates. + Return a live view of all currently legal single-move candidates. :return: - Tuple of legal coordinates. + Collection view of legal coordinates. """ - if self._winner is not None: - return () - if self._legal_moves_tuple_cache is None: - self._legal_moves_tuple_cache = tuple(self._legal_moves_cache) - return self._legal_moves_tuple_cache + return self._legal_moves_view def _expand_legal_cache_from(self, anchor: Coord) -> set[Coord]: """ @@ -367,20 +410,34 @@ def _expand_legal_cache_from(self, anchor: Coord) -> set[Coord]: :return: Coordinates that were newly introduced in the legal-move cache. """ - radius = self.config.placement_radius aq, ar = anchor added: set[Coord] = set() + for dq, dr in self._radius_offsets: + coord = (aq + dq, ar + dr) + if coord in self._board: + continue + if coord not in self._legal_moves_cache: + self._legal_moves_cache.add(coord) + added.add(coord) + return added + + @staticmethod + def _build_radius_offsets(radius: int) -> tuple[Coord, ...]: + """ + Build axial offsets for a hex disc of a given radius. + + :param radius: + Radius in hex distance. + :return: + Tuple of `(dq, dr)` offsets within the radius. + """ + offsets: list[Coord] = [] for dq in range(-radius, radius + 1): dr_min = max(-radius, -dq - radius) dr_max = min(radius, -dq + radius) for dr in range(dr_min, dr_max + 1): - coord = (aq + dq, ar + dr) - if coord in self._board: - continue - if coord not in self._legal_moves_cache: - self._legal_moves_cache.add(coord) - added.add(coord) - return added + offsets.append((dq, dr)) + return tuple(offsets) def _has_winning_line(self, player: Player, newly_placed: Sequence[Coord]) -> bool: """ diff --git a/src/hexo/errors.py b/src/hexo/errors.py index c6271f0..f9df344 100644 --- a/src/hexo/errors.py +++ b/src/hexo/errors.py @@ -1,8 +1,3 @@ -""" -Define exception types used by the Hexo engine. -""" - - class HexoError(Exception): """ Represent the base exception for all Hexo engine failures. diff --git a/src/hexo/geometry.py b/src/hexo/geometry.py index 7078b0e..347284b 100644 --- a/src/hexo/geometry.py +++ b/src/hexo/geometry.py @@ -1,7 +1,3 @@ -""" -Provide geometry helpers for the axial hex grid used by Hexo. -""" - from hexo.types import Coord diff --git a/tests/test_hexo/test_hexo_api.py b/tests/test_hexo/test_hexo_api.py index 14f1854..2e9049c 100644 --- a/tests/test_hexo/test_hexo_api.py +++ b/tests/test_hexo/test_hexo_api.py @@ -52,7 +52,7 @@ def test_submoves_allow_partial_turn_search_state() -> None: def test_legal_moves_produces_candidates() -> None: game = Hexo.new() - moves = game.legal_moves() + moves = game.legal_moves assert len(moves) > 0 assert (0, 0) not in moves for coord in moves: @@ -63,16 +63,16 @@ def test_legal_moves_produces_candidates() -> None: def test_legal_moves_cache_updates_on_push_and_undo() -> None: game = Hexo.new() - before = set(game.legal_moves()) + before = set(game.legal_moves) move = (1, 0) assert move in before game.push(move) - during = set(game.legal_moves()) + during = set(game.legal_moves) assert move not in during game.undo() - after = set(game.legal_moves()) + after = set(game.legal_moves) assert after == before diff --git a/uv.lock b/uv.lock index 5d095ca..c39193f 100644 --- a/uv.lock +++ b/uv.lock @@ -156,7 +156,7 @@ wheels = [ [[package]] name = "hexo" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "pedros" },