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
48 changes: 33 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,26 +66,44 @@ Notes:
## Quickstart

```python
from hexo import Hexo
from hexo import GameStatus, Hexo

game = Hexo.new()
print(game.turn()) # Player.P2

legal, reason = game.is_legal_move((1, 0))
if not legal:
raise ValueError(reason)
game.push((1, 0))
def move_key(coord: tuple[int, int]) -> tuple[int, int, int]:
# Deterministic "closest to center" move ordering.
return (
abs(coord[0]) + abs(coord[1]) + abs(coord[0] + coord[1]),
coord[0],
coord[1],
)

# Hook point for engines: run a search after first placement.
# ... search code here ...

record = game.push((0, 1)) # turn completes here
print(record) # TurnRecord
print(game.moves_left_in_turn()) # 2
def play_turn(game: Hexo):
first = min(game.legal_moves, key=move_key)
record = game.push(first)
if record is not None: # win can happen on first placement
return record

state = game.to_state()
restored = Hexo.from_state(state)
print(restored.turn())
second = min(game.legal_moves, key=move_key)
return game.push(second)


game = Hexo.new() # P1 center stone is pre-applied at (0, 0)

for turn_no in range(1, 6):
if game.status() is not GameStatus.ONGOING:
break
record = play_turn(game)
print(f"turn {turn_no}: {record.player.name} {record.placements} won={record.won}")

print("stones on board:", len(game.board()))
print("next player:", game.turn().name)

# Useful for bot search trees: snapshot, restore, undo
snapshot = game.to_state()
restored = Hexo.from_state(snapshot)
affected = restored.undo()
print("undo affected turn:", affected)
```

## Tests, linting and formatting
Expand Down
23 changes: 5 additions & 18 deletions examples/random_match.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,16 @@

from hexo import GameStatus, Hexo

Coord = tuple[int, int]


def random_engine(game: Hexo, rng: random.Random) -> Coord:
candidates = game.legal_moves
if not candidates:
raise RuntimeError("no legal candidate placements found")
return rng.choice(tuple(candidates))


def play_turn_random(game: Hexo, rng: random.Random) -> tuple[Coord, ...]:
chosen: list[Coord] = []
def play_turn_random(game: Hexo, rng: random.Random):
player = game.turn()
while game.turn() is player and game.status() is GameStatus.ONGOING:
coord = random_engine(game, rng)
game.push(coord)
chosen.append(coord)
return tuple(chosen)
while game.turn() is player:
move = rng.choice(tuple(game.legal_moves))
game.push(move)


@timed
def run_demo_match(seed: int = 42, max_turns: int = 100) -> GameStatus:
def run_demo_match(seed: int = 42, max_turns: int = 2000):
logger = get_logger()
rng = random.Random(seed)
game = Hexo.new()
Expand All @@ -51,7 +39,6 @@ def run_demo_match(seed: int = 42, max_turns: int = 100) -> GameStatus:
)
else:
logger.warning(f"Reached max turns ({max_turns}) with status={status.name}")
return status


if __name__ == "__main__":
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.2.0"
version = "0.2.1"
description = "A fast, reusable Python engine for Hexo on an infinite hex grid."
readme = "README.md"
license = {file = "LICENSE"}
Expand Down
103 changes: 103 additions & 0 deletions src/hexo/_engine_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from __future__ import annotations

from collections.abc import Sequence

from hexo.types import AXES, Coord, Player

AXIS_PAIRS: tuple[tuple[Coord, Coord], ...] = tuple(
(axis, (-axis[0], -axis[1])) for axis in AXES
)


def hex_distance(a: Coord, b: Coord) -> int:
"""
Compute hex-grid distance between two axial coordinates.

:param a:
First coordinate as `(q, r)`.
:param b:
Second coordinate as `(q, r)`.
:return:
Hex distance between `a` and `b`.
"""
aq, ar = a
bq, br = b
dq = aq - bq
dr = ar - br
return (abs(dq) + abs(dr) + abs(dq + dr)) // 2


def build_radius_offsets(radius: int) -> tuple[Coord, ...]:
"""
Return all axial offsets within a hex-disc 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):
offsets.append((dq, dr))
return tuple(offsets)


def expand_legal_cache_from(
anchor: Coord,
radius_offsets: Sequence[Coord],
board: dict[Coord, Player],
legal_moves_cache: set[Coord],
) -> tuple[Coord, ...]:
"""
Expand legal moves around an occupied anchor and return only newly-added cells.
"""
aq, ar = anchor
added: list[Coord] = []
for dq, dr in radius_offsets:
candidate = (aq + dq, ar + dr)
if candidate in board or candidate in legal_moves_cache:
continue
legal_moves_cache.add(candidate)
added.append(candidate)
return tuple(added)


def has_winning_line(
player_cells: set[Coord], newly_placed: Sequence[Coord], win_length: int
) -> bool:
"""
Check whether any newly placed stone completes a line of at least `win_length`.
"""
for center in newly_placed:
if has_winning_line_at(player_cells, center, win_length):
return True
return False


def has_winning_line_at(
player_cells: set[Coord], center: Coord, win_length: int
) -> bool:
"""
Check whether one anchor coordinate completes a line of at least `win_length`.
"""
contains = player_cells.__contains__
center_q, center_r = center
for (fdq, fdr), (bdq, bdr) in AXIS_PAIRS:
run = 1

q = center_q + fdq
r = center_r + fdr
while contains((q, r)):
run += 1
q += fdq
r += fdr
if run >= win_length:
return True

q = center_q + bdq
r = center_r + bdr
while contains((q, r)):
run += 1
q += bdq
r += bdr
if run >= win_length:
return True
return False
Loading
Loading