Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 0 additions & 10 deletions RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 7 additions & 5 deletions benchmarks/test_engine_benchmarks.py
Original file line number Diff line number Diff line change
@@ -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]:
Expand All @@ -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))
Expand All @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions examples/random_match.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...]:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"}
Expand Down
19 changes: 4 additions & 15 deletions src/hexo/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
111 changes: 84 additions & 27 deletions src/hexo/engine.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]:
"""
Expand All @@ -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:
"""
Expand Down
5 changes: 0 additions & 5 deletions src/hexo/errors.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
"""
Define exception types used by the Hexo engine.
"""


class HexoError(Exception):
"""
Represent the base exception for all Hexo engine failures.
Expand Down
4 changes: 0 additions & 4 deletions src/hexo/geometry.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
"""
Provide geometry helpers for the axial hex grid used by Hexo.
"""

from hexo.types import Coord


Expand Down
8 changes: 4 additions & 4 deletions tests/test_hexo/test_hexo_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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


Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading