hexo 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hexo/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ Expose the public API for the Hexo engine package.
3
+
4
+ This module configures package-level logging and re-exports the primary engine
5
+ types so users can import them directly from `hexo` instead of internal modules.
6
+ """
7
+
8
+ import logging
9
+ from pedros.logger import setup_logging
10
+
11
+ from hexo.engine import Hexo
12
+ from hexo.errors import HexoError, IllegalTurnError
13
+ from hexo.geometry import hex_distance
14
+ from hexo.types import AXES, Coord, EngineConfig, GameStatus, Player, State, TurnRecord
15
+
16
+ setup_logging(level=logging.INFO)
17
+
18
+ __all__ = [
19
+ "Coord",
20
+ "EngineConfig",
21
+ "GameStatus",
22
+ "Hexo",
23
+ "HexoError",
24
+ "IllegalTurnError",
25
+ "AXES",
26
+ "Player",
27
+ "State",
28
+ "TurnRecord",
29
+ "hex_distance",
30
+ ]
hexo/engine.py ADDED
@@ -0,0 +1,428 @@
1
+ """
2
+ Implement the core Hexo game state and rule validation logic.
3
+
4
+ This module provides the single public `Hexo` class and state transition logic.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+
11
+ from hexo.errors import IllegalTurnError
12
+ from hexo.geometry import hex_distance
13
+ from hexo.types import (
14
+ AXES,
15
+ Coord,
16
+ EngineConfig,
17
+ GameStatus,
18
+ Player,
19
+ State,
20
+ TurnRecord,
21
+ UndoSnapshot,
22
+ )
23
+
24
+
25
+ class Hexo:
26
+ """
27
+ Single public API for Hexo.
28
+
29
+ The opening stone for P1 is automatically placed at (0, 0) on init.
30
+
31
+ :param config:
32
+ Optional engine configuration. When omitted, defaults are used.
33
+ """
34
+
35
+ def __init__(self, config: EngineConfig | None = None) -> None:
36
+ """
37
+ Initialize a new Hexo game with the opening center pre-applied.
38
+
39
+ :param config:
40
+ Optional engine configuration. When omitted, defaults are used.
41
+ """
42
+ self.config = config or EngineConfig()
43
+ self._board: dict[Coord, Player] = {self.config.opening_center: Player.P1}
44
+ self._stones_by_player: dict[Player, set[Coord]] = {
45
+ Player.P1: {self.config.opening_center},
46
+ Player.P2: set(),
47
+ }
48
+ self._turn_history: list[TurnRecord] = []
49
+ self._undo_stack: list[UndoSnapshot] = []
50
+ self._pending: list[Coord] = []
51
+ self._winner: Player | None = None
52
+ self._to_move = Player.P2
53
+ self._legal_moves_cache: set[Coord] = set()
54
+ self._legal_moves_tuple_cache: tuple[Coord, ...] | None = None
55
+ self._expand_legal_cache_from(self.config.opening_center)
56
+
57
+ @classmethod
58
+ def new(cls, config: EngineConfig | None = None) -> Hexo:
59
+ """
60
+ Create a new game instance.
61
+
62
+ :param config:
63
+ Optional engine configuration. When omitted, defaults are used.
64
+ :return:
65
+ A newly initialized `Hexo` game object.
66
+ """
67
+ return cls(config=config)
68
+
69
+ @classmethod
70
+ def from_state(cls, state: State, config: EngineConfig | None = None) -> Hexo:
71
+ """
72
+ Rebuild a game from serialized move history.
73
+
74
+ :param state:
75
+ Serialized state with `turns` and optional `pending` arrays.
76
+ :param config:
77
+ Optional engine configuration to use for validation.
78
+ :return:
79
+ A reconstructed `Hexo` game after replaying all serialized moves.
80
+ """
81
+ game = cls.new(config=config)
82
+ for raw_turn in state.get("turns", []):
83
+ if len(raw_turn) not in (1, 2):
84
+ raise IllegalTurnError(
85
+ "state contains invalid turn size; each turn must have 1 or 2 coordinates"
86
+ )
87
+ for raw_coord in raw_turn:
88
+ game.push((int(raw_coord[0]), int(raw_coord[1])))
89
+ for raw_coord in state.get("pending", []):
90
+ game.push((int(raw_coord[0]), int(raw_coord[1])))
91
+ return game
92
+
93
+ def to_state(self) -> State:
94
+ """
95
+ Serialize the current game into a portable dictionary representation.
96
+
97
+ :return:
98
+ A state dictionary containing replayable turn history and pending placements.
99
+ """
100
+ turns = [
101
+ [[coord[0], coord[1]] for coord in record.placements]
102
+ for record in self._turn_history
103
+ ]
104
+ pending = [[coord[0], coord[1]] for coord in self._pending]
105
+ return {"turns": turns, "pending": pending}
106
+
107
+ def turn(self) -> Player:
108
+ """
109
+ Return the player expected to move next.
110
+
111
+ :return:
112
+ The current side to move.
113
+ """
114
+ return self._to_move
115
+
116
+ def moves_left_in_turn(self) -> int:
117
+ """
118
+ Return how many placements remain for the current player's turn.
119
+
120
+ :return:
121
+ Number of stones left to place in the current turn.
122
+ """
123
+ return 2 - len(self._pending)
124
+
125
+ def pending_moves(self) -> tuple[Coord, ...]:
126
+ """
127
+ Return coordinates already placed in the current unfinished turn.
128
+
129
+ :return:
130
+ Tuple containing zero or one coordinates for the active turn.
131
+ """
132
+ return tuple(self._pending)
133
+
134
+ def status(self) -> GameStatus:
135
+ """
136
+ Report whether the game is ongoing or won.
137
+
138
+ :return:
139
+ Current game status.
140
+ """
141
+ if self._winner is Player.P1:
142
+ return GameStatus.P1_WON
143
+ if self._winner is Player.P2:
144
+ return GameStatus.P2_WON
145
+ return GameStatus.ONGOING
146
+
147
+ def is_legal(self, move: Sequence[Coord]) -> tuple[bool, str | None]:
148
+ """
149
+ Validate whether a two-stone turn is currently legal.
150
+
151
+ :param move:
152
+ Candidate move containing exactly two coordinates.
153
+ :return:
154
+ Tuple `(is_legal, reason)` where `reason` is `None` when legal.
155
+ """
156
+ if self._winner is not None:
157
+ return False, "game is over"
158
+
159
+ if self._pending:
160
+ return False, "cannot play a full move while a partial turn is in progress"
161
+
162
+ if len(move) != 2:
163
+ return False, "each turn must place exactly 2 stones"
164
+
165
+ a, b = move[0], move[1]
166
+ if a == b:
167
+ return False, "duplicate coordinates in same turn"
168
+ if a in self._board:
169
+ return False, f"occupied cell: {a}"
170
+ if b in self._board:
171
+ return False, f"occupied cell: {b}"
172
+ if a not in self._legal_moves_cache:
173
+ return (
174
+ False,
175
+ f"placement {a} has no occupied cell within distance <= {self.config.placement_radius}",
176
+ )
177
+ if (
178
+ b not in self._legal_moves_cache
179
+ and hex_distance(a, b) > self.config.placement_radius
180
+ ):
181
+ return (
182
+ False,
183
+ f"placement {b} has no occupied cell within distance <= {self.config.placement_radius}",
184
+ )
185
+
186
+ return True, None
187
+
188
+ def is_legal_move(self, coord: Coord) -> tuple[bool, str | None]:
189
+ """
190
+ Validate a single placement for submove-based play.
191
+
192
+ :param coord:
193
+ Candidate coordinate to place for the current player.
194
+ :return:
195
+ Tuple `(is_legal, reason)` where `reason` is `None` when legal.
196
+ """
197
+ if self._winner is not None:
198
+ return False, "game is over"
199
+ if len(self._pending) >= 2:
200
+ return False, "current turn already has two placements"
201
+ if coord in self._board:
202
+ return False, f"occupied cell: {coord}"
203
+ if coord in self._legal_moves_cache:
204
+ return True, None
205
+ return (
206
+ False,
207
+ f"placement {coord} has no occupied cell within distance <= {self.config.placement_radius}",
208
+ )
209
+
210
+ def play(self, move: Sequence[Coord]) -> TurnRecord:
211
+ """
212
+ Apply a legal move and update game state.
213
+
214
+ :param move:
215
+ Sequence containing exactly two coordinates for the current player.
216
+ :return:
217
+ Immutable record describing the applied turn.
218
+ """
219
+ legal, reason = self.is_legal(move)
220
+ if not legal:
221
+ raise IllegalTurnError(reason or "illegal move")
222
+
223
+ self.push(move[0])
224
+ if self._winner is not None:
225
+ return self._turn_history[-1]
226
+ self.push(move[1])
227
+ return self._turn_history[-1]
228
+
229
+ def push(self, coord: Coord) -> TurnRecord | None:
230
+ """
231
+ Place one stone for the current player as a submove.
232
+
233
+ This method enables search workflows that evaluate positions between the
234
+ first and second placement of a turn.
235
+
236
+ :param coord:
237
+ Coordinate to place for the current player.
238
+ :return:
239
+ `TurnRecord` when a turn is completed (or wins early), otherwise `None`.
240
+ """
241
+ legal, reason = self.is_legal_move(coord)
242
+ if not legal:
243
+ raise IllegalTurnError(reason or "illegal placement")
244
+
245
+ prev_to_move = self._to_move
246
+ prev_winner = self._winner
247
+ prev_pending = tuple(self._pending)
248
+ prev_history_len = len(self._turn_history)
249
+ removed_from_cache = coord in self._legal_moves_cache
250
+
251
+ self._board[coord] = self._to_move
252
+ self._stones_by_player[self._to_move].add(coord)
253
+ self._legal_moves_cache.discard(coord)
254
+ added_legal = self._expand_legal_cache_from(coord)
255
+ self._legal_moves_tuple_cache = None
256
+ self._pending.append(coord)
257
+ self._undo_stack.append(
258
+ (
259
+ coord,
260
+ prev_to_move,
261
+ prev_winner,
262
+ prev_pending,
263
+ prev_history_len,
264
+ removed_from_cache,
265
+ tuple(added_legal),
266
+ )
267
+ )
268
+
269
+ won = self._has_winning_line(self._to_move, (coord, coord))
270
+ if won:
271
+ record = TurnRecord(
272
+ player=self._to_move, placements=tuple(self._pending), won=True
273
+ )
274
+ self._winner = self._to_move
275
+ self._turn_history.append(record)
276
+ self._pending.clear()
277
+ return record
278
+
279
+ if len(self._pending) == 2:
280
+ record = TurnRecord(
281
+ player=self._to_move, placements=tuple(self._pending), won=False
282
+ )
283
+ self._turn_history.append(record)
284
+ self._pending.clear()
285
+ self._to_move = self._to_move.opponent
286
+ return record
287
+
288
+ return None
289
+
290
+ def undo(self) -> TurnRecord:
291
+ """
292
+ Revert the last applied placement.
293
+
294
+ :return:
295
+ Record of the affected turn before the undo.
296
+ """
297
+ if not self._undo_stack:
298
+ raise IllegalTurnError("cannot undo: no placement has been played")
299
+
300
+ affected = (
301
+ self._turn_history[-1]
302
+ if self._turn_history
303
+ else TurnRecord(self._to_move, tuple(self._pending), False)
304
+ )
305
+ (
306
+ coord,
307
+ prev_to_move,
308
+ prev_winner,
309
+ prev_pending,
310
+ prev_history_len,
311
+ removed_from_cache,
312
+ added_legal,
313
+ ) = self._undo_stack.pop()
314
+ self._board.pop(coord, None)
315
+ self._stones_by_player[prev_to_move].discard(coord)
316
+ self._to_move = prev_to_move
317
+ self._winner = prev_winner
318
+ self._pending = list(prev_pending)
319
+ if removed_from_cache:
320
+ self._legal_moves_cache.add(coord)
321
+ for added in added_legal:
322
+ self._legal_moves_cache.discard(added)
323
+ self._legal_moves_tuple_cache = None
324
+ if len(self._turn_history) > prev_history_len:
325
+ self._turn_history = self._turn_history[:prev_history_len]
326
+ return affected
327
+
328
+ def at(self, coord: Coord) -> Player | None:
329
+ """
330
+ Return the occupant of a specific coordinate.
331
+
332
+ :param coord:
333
+ Coordinate to inspect.
334
+ :return:
335
+ Occupying player or `None` if the cell is empty.
336
+ """
337
+ return self._board.get(coord)
338
+
339
+ def board(self) -> dict[Coord, Player]:
340
+ """
341
+ Return a snapshot of current board occupancy.
342
+
343
+ :return:
344
+ New dictionary mapping occupied coordinates to owning players.
345
+ """
346
+ return dict(self._board)
347
+
348
+ def legal_moves(self) -> tuple[Coord, ...]:
349
+ """
350
+ Return all currently legal single-placement candidates.
351
+
352
+ :return:
353
+ Tuple of legal coordinates.
354
+ """
355
+ if self._winner is not None:
356
+ return ()
357
+ if self._legal_moves_tuple_cache is None:
358
+ self._legal_moves_tuple_cache = tuple(self._legal_moves_cache)
359
+ return self._legal_moves_tuple_cache
360
+
361
+ def _expand_legal_cache_from(self, anchor: Coord) -> set[Coord]:
362
+ """
363
+ Expand legal move cache from one occupied anchor coordinate.
364
+
365
+ :param anchor:
366
+ Occupied coordinate used to generate new reachable empty cells.
367
+ :return:
368
+ Coordinates that were newly introduced in the legal-move cache.
369
+ """
370
+ radius = self.config.placement_radius
371
+ aq, ar = anchor
372
+ added: set[Coord] = set()
373
+ for dq in range(-radius, radius + 1):
374
+ dr_min = max(-radius, -dq - radius)
375
+ dr_max = min(radius, -dq + radius)
376
+ for dr in range(dr_min, dr_max + 1):
377
+ coord = (aq + dq, ar + dr)
378
+ if coord in self._board:
379
+ continue
380
+ if coord not in self._legal_moves_cache:
381
+ self._legal_moves_cache.add(coord)
382
+ added.add(coord)
383
+ return added
384
+
385
+ def _has_winning_line(self, player: Player, newly_placed: Sequence[Coord]) -> bool:
386
+ """
387
+ Determine whether the latest move creates a winning alignment.
388
+
389
+ :param player:
390
+ Player whose board alignment should be evaluated.
391
+ :param newly_placed:
392
+ Two coordinates placed by `player` in the current turn.
393
+ :return:
394
+ `True` if the move creates a line meeting the win length.
395
+ """
396
+ needed = self.config.win_length
397
+ player_cells = self._stones_by_player[player]
398
+ for center in newly_placed:
399
+ for axis in AXES:
400
+ run = (
401
+ 1
402
+ + self._count_direction(player_cells, center, axis)
403
+ + self._count_direction(player_cells, center, (-axis[0], -axis[1]))
404
+ )
405
+ if run >= needed:
406
+ return True
407
+ return False
408
+
409
+ @staticmethod
410
+ def _count_direction(cells: set[Coord], origin: Coord, step: Coord) -> int:
411
+ """
412
+ Count contiguous stones in one direction from an origin.
413
+
414
+ :param cells:
415
+ Set of coordinates occupied by one player.
416
+ :param origin:
417
+ Starting coordinate for the directional scan.
418
+ :param step:
419
+ Axis step increment used for scanning.
420
+ :return:
421
+ Number of contiguous cells encountered along the direction.
422
+ """
423
+ count = 0
424
+ cur = (origin[0] + step[0], origin[1] + step[1])
425
+ while cur in cells:
426
+ count += 1
427
+ cur = (cur[0] + step[0], cur[1] + step[1])
428
+ return count
hexo/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ """
2
+ Define exception types used by the Hexo engine.
3
+ """
4
+
5
+
6
+ class HexoError(Exception):
7
+ """
8
+ Represent the base exception for all Hexo engine failures.
9
+
10
+ This exception is used as the root class for domain-specific errors raised
11
+ by the package so callers can catch all Hexo-related exceptions with one type.
12
+ """
13
+
14
+
15
+ class IllegalTurnError(HexoError):
16
+ """
17
+ Signal that a move or turn violates Hexo game rules.
18
+
19
+ This exception is raised when validation fails during state reconstruction,
20
+ move application, or other rule-checked operations.
21
+ """
hexo/geometry.py ADDED
@@ -0,0 +1,21 @@
1
+ """
2
+ Provide geometry helpers for the axial hex grid used by Hexo.
3
+ """
4
+
5
+ from hexo.types import Coord
6
+
7
+
8
+ def hex_distance(a: Coord, b: Coord) -> int:
9
+ """
10
+ Compute hex-grid distance between two axial coordinates.
11
+
12
+ :param a:
13
+ First coordinate as `(q, r)`.
14
+ :param b:
15
+ Second coordinate as `(q, r)`.
16
+ :return:
17
+ Hex distance between `a` and `b`.
18
+ """
19
+ dq = a[0] - b[0]
20
+ dr = a[1] - b[1]
21
+ return (abs(dq) + abs(dr) + abs(dq + dr)) // 2
hexo/py.typed ADDED
@@ -0,0 +1 @@
1
+
hexo/types.py ADDED
@@ -0,0 +1,109 @@
1
+ """
2
+ Provide core type definitions used by the Hexo engine.
3
+
4
+ This module centralizes enums, coordinate aliases, and immutable records that
5
+ describe game configuration and turn history.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from enum import Enum, auto
12
+ from typing import Any
13
+
14
+ Coord = tuple[int, int]
15
+ """Represent an axial hex-grid coordinate as `(q, r)` integers."""
16
+
17
+ State = dict[str, Any]
18
+ """Represent a serialized Hexo game state."""
19
+
20
+ AXES: tuple[Coord, ...] = ((1, 0), (0, 1), (1, -1))
21
+ """Define the three principal axes used for straight-line win detection."""
22
+
23
+
24
+ class Player(Enum):
25
+ """
26
+ Identify one of the two players in a Hexo game.
27
+ """
28
+
29
+ P1 = auto()
30
+ P2 = auto()
31
+
32
+ @property
33
+ def opponent(self) -> Player:
34
+ """
35
+ Return the opponent of the current player.
36
+
37
+ :return:
38
+ `Player.P2` when called on `Player.P1`, otherwise `Player.P1`.
39
+ """
40
+ return Player.P1 if self is Player.P2 else Player.P2
41
+
42
+
43
+ class GameStatus(Enum):
44
+ """
45
+ Describe the global game state from a terminal-condition perspective.
46
+ """
47
+
48
+ ONGOING = auto()
49
+ P1_WON = auto()
50
+ P2_WON = auto()
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class EngineConfig:
55
+ """
56
+ Store immutable configuration values for a Hexo game instance.
57
+
58
+ :param win_length:
59
+ Number of aligned stones required for a win.
60
+ :param placement_radius:
61
+ Maximum hex distance from an occupied cell allowed for a new placement.
62
+ :param opening_center:
63
+ Fixed coordinate of the pre-placed opening stone for `P1`.
64
+ """
65
+
66
+ win_length: int = 6
67
+ placement_radius: int = 8
68
+ opening_center: Coord = (0, 0)
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class TurnRecord:
73
+ """
74
+ Record the outcome of a single executed turn.
75
+
76
+ :param player:
77
+ Player who performed the turn.
78
+ :param placements:
79
+ Coordinates placed during the turn, in application order.
80
+ :param won:
81
+ Whether this turn produced a winning line for `player`.
82
+ """
83
+
84
+ player: Player
85
+ placements: tuple[Coord, ...]
86
+ won: bool
87
+
88
+
89
+ UndoSnapshot = tuple[
90
+ Coord,
91
+ Player,
92
+ Player | None,
93
+ tuple[Coord, ...],
94
+ int,
95
+ bool,
96
+ tuple[Coord, ...],
97
+ ]
98
+ """
99
+ Capture all state required to undo one `push` operation.
100
+
101
+ Fields:
102
+ - pushed coordinate
103
+ - previous player to move
104
+ - previous winner
105
+ - previous pending moves
106
+ - previous turn-history length
107
+ - whether pushed coordinate was removed from legal cache
108
+ - coordinates newly added to legal cache by the push
109
+ """
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: hexo
3
+ Version: 0.1.0
4
+ Summary: A fast, reusable Python engine for Hexo on an infinite hex grid.
5
+ Author: Pierre LAPOLLA
6
+ License: MIT License
7
+
8
+ Copyright (c) [2025] [Pierre LAPOLLA]
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Keywords: ai,board-game,connect-six,game-engine,hex-grid,hexo
29
+ Requires-Python: >=3.10
30
+ Requires-Dist: pedros>=0.12.3
31
+ Description-Content-Type: text/markdown
32
+
33
+ ![Ruff](https://img.shields.io/badge/ruff-enabled-brightgreen)
34
+ ![License](https://img.shields.io/badge/license-MIT-green)
35
+
36
+ # Hexo Engine
37
+
38
+ Hexo is a Python engine for the Hexo game played on an infinite hex grid.
39
+
40
+ The goal of this repository is to provide a clean, reusable engine API (similar in spirit to `python-chess`) so others can build bots, analysis tools, and frontends without re-implementing game logic.
41
+
42
+ Game rules are documented in [RULES.md](RULES.md).
43
+ Agent handoff context is documented in [AGENT_CONTEXT.md](AGENT_CONTEXT.md).
44
+
45
+ ## Features
46
+
47
+ - Deterministic game engine with strict rule validation
48
+ - Infinite hex-grid coordinate model
49
+ - Opening and turn rules enforced by the engine
50
+ - Win detection (connect 6 on any hex axis)
51
+ - Undo support for search algorithms
52
+
53
+ ## Installation
54
+
55
+ ### Requirements
56
+
57
+ - [UV](https://docs.astral.sh/uv/) package manager
58
+
59
+ ### Clone the repository
60
+
61
+ ```bash
62
+ git clone https://github.com/<your-org>/hexo.git
63
+ cd hexo
64
+ ```
65
+
66
+ ### Initialize your environment
67
+
68
+ ```bash
69
+ uv sync
70
+ uv run pre-commit install
71
+ ```
72
+
73
+ ## API
74
+
75
+ Public entry point is the `Hexo` class.
76
+
77
+ - `Hexo.new(config=None) -> Hexo`: create a new game with `P1` already placed at `(0, 0)`.
78
+ - `Hexo.from_state(state, config=None) -> Hexo`: restore a game from serialized state.
79
+ - `game.to_state() -> dict`: serialize committed turns and any in-progress partial turn.
80
+ - `game.turn() -> Player`: return the player currently placing stones.
81
+ - `game.status() -> GameStatus`: return `ONGOING`, `P1_WON`, or `P2_WON`.
82
+ - `game.moves_left_in_turn() -> int`: return remaining moves in current turn (`2` or `1`).
83
+ - `game.pending_moves() -> tuple[Coord, ...]`: return moves already made in the current turn.
84
+ - `game.is_legal_move(coord) -> tuple[bool, str | None]`: validate one submove.
85
+ - `game.legal_moves() -> tuple[Coord, ...]`: return legal single-move candidates.
86
+ - `game.push(coord) -> TurnRecord | None`: play one move; returns `None` if turn is still partial, or `TurnRecord` when the turn completes (or wins early).
87
+ - `game.is_legal(move) -> tuple[bool, str | None]`: validate a full 2-stone move (only when no partial turn is active).
88
+ - `game.play(move) -> TurnRecord`: convenience wrapper that places two stones in sequence.
89
+ - `game.undo() -> TurnRecord`: undo the last placement (works for both partial and completed turns).
90
+ - `game.at(coord) -> Player | None`: inspect occupancy at a coordinate.
91
+ - `game.board() -> dict[Coord, Player]`: get a snapshot of all occupied coordinates.
92
+
93
+ Notes:
94
+
95
+ - `Hexo.new()` starts with `P1` already placed at `(0, 0)`.
96
+ - Every played turn is a 2-stone move.
97
+
98
+ ## Quickstart
99
+
100
+ ```python
101
+ from hexo import Hexo
102
+
103
+ game = Hexo.new()
104
+ print(game.turn()) # Player.P2
105
+
106
+ legal, reason = game.is_legal_move((1, 0))
107
+ if not legal:
108
+ raise ValueError(reason)
109
+ game.push((1, 0))
110
+
111
+ # Hook point for engines: run a search after first placement.
112
+ # ... search code here ...
113
+
114
+ record = game.push((0, 1)) # turn completes here
115
+ print(record) # TurnRecord
116
+ print(game.moves_left_in_turn()) # 2
117
+
118
+ state = game.to_state()
119
+ restored = Hexo.from_state(state)
120
+ print(restored.turn())
121
+ ```
122
+
123
+ ## Tests, linting and formatting
124
+
125
+ ```bash
126
+ uv run pytest
127
+ ```
128
+
129
+ ```bash
130
+ uvx ruff check . --fix
131
+ ```
132
+
133
+ ```bash
134
+ uvx ruff format .
135
+ ```
136
+
137
+ Run all hooks manually:
138
+
139
+ ```bash
140
+ uv run pre-commit run --all-files
141
+ ```
142
+
143
+ ## Benchmarks
144
+
145
+ Benchmarking uses `pytest-benchmark` and is kept separate from normal tests.
146
+
147
+ Run benchmarks:
148
+
149
+ ```bash
150
+ uv run pytest benchmarks --benchmark-only
151
+ ```
152
+
153
+ Save a baseline:
154
+
155
+ ```bash
156
+ uv run pytest benchmarks --benchmark-only --benchmark-save=baseline
157
+ ```
158
+
159
+ Compare with a saved baseline:
160
+
161
+ ```bash
162
+ uv run pytest benchmarks --benchmark-only --benchmark-compare=baseline
163
+ ```
164
+
165
+ ## Contributing
166
+
167
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for:
168
+
169
+ - issue reporting guidelines
170
+ - feature proposal flow
171
+ - pull request requirements
172
+ - code style and testing expectations
173
+
174
+ ## License
175
+
176
+ This project is licensed under the MIT [LICENSE](LICENSE)
@@ -0,0 +1,10 @@
1
+ hexo/__init__.py,sha256=RUHHdinuCNCwa6-Zb26-nPAyg_kt07k9c1dST6PJD0Q,716
2
+ hexo/engine.py,sha256=EvjFsST6Pkee20_FbycciFNBl6vabk-ObMFiItDFeus,14290
3
+ hexo/errors.py,sha256=3JfVy-JbtKguGAs31ICdRPkB6ZUxAGLLudmemzLDNEU,575
4
+ hexo/geometry.py,sha256=lFBFalxU6ZEPL29mBFB5-KmOsZxb227D8Bv4CMD8wfw,478
5
+ hexo/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
+ hexo/types.py,sha256=aVoiv_-sLSwgqbVc5K2wgSCEvvtVitYkVI0PuzaNRwI,2546
7
+ hexo-0.1.0.dist-info/METADATA,sha256=fTuF20nnnqX8lUjsRokU6Lzcb2hgArs8dAw_zi1SpDM,5490
8
+ hexo-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ hexo-0.1.0.dist-info/licenses/LICENSE,sha256=nn79lpyhivZG3JTP7Se7d53Pbv3hKpfGC35BM4GkR2c,1075
10
+ hexo-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) [2025] [Pierre LAPOLLA]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.