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