board.pyΒΆ
Part of Bloom.
1"""Bloom board: pure game logic. No graphics, no input, no randomness.
2
3The capture resolver here is the single source of truth shared by the human
4turn, the cascade animation, and the AI search. It is fully deterministic and
5unit-testable headlessly.
6"""
7
8from enum import IntEnum
9
10from hex_grid import DIRECTIONS, Hex, hex_in_range
11
12# --- tunable rules ----------------------------------------------------------
13BOARD_RADIUS = 4 # 61 cells (3R(R+1)+1); odd => no draw possible
14CENTRE = Hex(0, 0)
15WARM_START = Hex(0, -BOARD_RADIUS)
16COOL_START = Hex(0, BOARD_RADIUS) # exact mirror of WARM_START through centre
17OUTNUMBER_MARGIN = 1 # strict: attackers must exceed friends by this
18
19
20class Owner(IntEnum):
21 EMPTY = 0
22 WARM = 1 # amber. Solo: "You". Hotseat: "Player 1". First mover.
23 COOL = 2 # azure. Solo: the AI. Hotseat: "Player 2".
24
25
26def opponent(o: Owner) -> Owner:
27 """Swap WARM<->COOL. Never called on EMPTY by the resolver."""
28 return Owner.COOL if o is Owner.WARM else Owner.WARM
29
30
31class Board:
32 """Mutable hex board: one :class:`Owner` per cell plus ``last_placed``."""
33
34 __slots__ = ("cells", "last_placed", "radius")
35
36 def __init__(self, radius: int = BOARD_RADIUS):
37 self.radius = radius
38 self.cells: dict[Hex, Owner] = {}
39 self.last_placed: Hex | None = None
40
41 # --- construction -------------------------------------------------------
42 def reset(self) -> None:
43 """Lay out the deterministic mirrored opening."""
44 self.cells = dict.fromkeys(hex_in_range(CENTRE, self.radius), Owner.EMPTY)
45 assert len(self.cells) % 2 == 1, "board must have an odd cell count (no draws)"
46 self.cells[WARM_START] = Owner.WARM
47 self.cells[COOL_START] = Owner.COOL
48 self.last_placed = None
49
50 def copy(self) -> Board:
51 b = Board(self.radius)
52 b.cells = dict(self.cells)
53 b.last_placed = self.last_placed
54 return b
55
56 # --- queries ------------------------------------------------------------
57 def legal_moves(self) -> list[Hex]:
58 """Empty cells in canonical (q, r) order. Always non-empty until full."""
59 return sorted((h for h, o in self.cells.items() if o is Owner.EMPTY), key=lambda h: (h.q, h.r))
60
61 def is_full(self) -> bool:
62 return not any(o is Owner.EMPTY for o in self.cells.values())
63
64 def score(self) -> tuple[int, int]:
65 """(warm_count, cool_count)."""
66 warm = sum(1 for o in self.cells.values() if o is Owner.WARM)
67 cool = sum(1 for o in self.cells.values() if o is Owner.COOL)
68 return warm, cool
69
70 def winner(self) -> Owner:
71 """The majority owner. Draws are impossible on an odd board."""
72 warm, cool = self.score()
73 return Owner.WARM if warm > cool else Owner.COOL
74
75
76def board_neighbours(board: Board, h: Hex) -> list[Hex]:
77 """On-board neighbours only. Off-board cells are never anyone's neighbour,
78 which is what makes rim and corner cells defensible."""
79 return [h + d for d in DIRECTIONS if (h + d) in board.cells]
80
81
82def resolve_placement(board: Board, where: Hex, player: Owner) -> list[list[Hex]]:
83 """Place ``player`` at ``where`` and run the outnumber cascade.
84
85 Mutates ``board`` and returns the captured cells grouped by ring:
86 ring 0 = direct captures triggered by the seed, ring 1 = captures triggered
87 by ring-0 flips, and so on. ``where`` itself is not in the returned list.
88
89 Precondition: ``board.cells[where] is Owner.EMPTY``.
90 """
91 assert board.cells.get(where) is Owner.EMPTY, "resolve_placement requires an empty target"
92 cells = board.cells
93 cells[where] = player
94 board.last_placed = where
95 foe = opponent(player)
96
97 def is_captured(c: Hex) -> bool:
98 attackers = friends = 0
99 for n in board_neighbours(board, c):
100 o = cells[n]
101 if o is player:
102 attackers += 1
103 elif o is foe:
104 friends += 1
105 return attackers - friends >= OUTNUMBER_MARGIN
106
107 flips_by_ring: list[list[Hex]] = []
108 flipped: set[Hex] = set()
109 frontier = [where]
110 while frontier:
111 # Candidate enemy cells adjacent to the current frontier, deduped and
112 # processed in canonical (q, r) order for determinism.
113 candidates: set[Hex] = set()
114 for f in frontier:
115 for n in board_neighbours(board, f):
116 if n not in flipped and cells[n] is foe:
117 candidates.add(n)
118 ring = [c for c in sorted(candidates, key=lambda h: (h.q, h.r)) if is_captured(c)]
119 if not ring:
120 break
121 for c in ring: # flip in-order; later cells see earlier flips
122 cells[c] = player
123 flipped.add(c)
124 flips_by_ring.append(ring)
125 frontier = ring
126 return flips_by_ring
127
128
129def build_board(radius: int = BOARD_RADIUS) -> Board:
130 b = Board(radius)
131 b.reset()
132 return b