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 +30 -0
- hexo/engine.py +428 -0
- hexo/errors.py +21 -0
- hexo/geometry.py +21 -0
- hexo/py.typed +1 -0
- hexo/types.py +109 -0
- hexo-0.1.0.dist-info/METADATA +176 -0
- hexo-0.1.0.dist-info/RECORD +10 -0
- hexo-0.1.0.dist-info/WHEEL +4 -0
- hexo-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+

|
|
34
|
+

|
|
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,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.
|