ai.pyΒΆ

Part of Bloom.

  1"""Bloom AI: a fair opponent. Pure logic, no graphics.
  2
  3The AI plays the same legal moves through the same ``resolve_placement`` the
  4human uses, with no hidden information. Difficulty changes search depth only.
  5"""
  6
  7import random
  8
  9from board import Board, Owner, board_neighbours, opponent, resolve_placement
 10from hex_grid import Hex
 11
 12MATERIAL_W = 1.0
 13POSITION_W = 0.15
 14TERMINAL = 1_000_000.0
 15AI_SEED = 0xB100
 16
 17# Difficulty -> negamax search depth in plies.
 18DEPTHS = {"calm": 0, "sharp": 1, "ruthless": 2}
 19DIFFICULTY_BLURB = {
 20    "calm": "Calm: grabs the biggest capture each turn.",
 21    "sharp": "Sharp: looks one move ahead.",
 22    "ruthless": "Ruthless: searches two moves deep.",
 23}
 24
 25
 26def _position_weights(board: Board) -> dict[Hex, float]:
 27    """Per-cell positional value: edges and corners are worth holding."""
 28    weights: dict[Hex, float] = {}
 29    r = board.radius
 30    for h in board.cells:
 31        ring = max(abs(h.q), abs(h.r), abs(h.s))
 32        if ring == r:
 33            # Corners have 3 on-board neighbours; rim cells have 3-4.
 34            weights[h] = 2.0 if len(board_neighbours(board, h)) <= 3 else 1.0
 35        elif ring == r - 1:
 36            weights[h] = 0.3
 37        else:
 38            weights[h] = 0.0
 39    return weights
 40
 41
 42def value(board: Board, player: Owner, weights: dict[Hex, float]) -> float:
 43    """Heuristic from ``player``'s point of view (higher is better)."""
 44    foe = opponent(player)
 45    material = 0
 46    position = 0.0
 47    for h, o in board.cells.items():
 48        if o is player:
 49            material += 1
 50            position += weights[h]
 51        elif o is foe:
 52            material -= 1
 53            position -= weights[h]
 54    return MATERIAL_W * material + POSITION_W * position
 55
 56
 57def place_simulated(board: Board, where: Hex, player: Owner) -> Board:
 58    """Return a copy of ``board`` with ``player`` placed at ``where``. Never
 59    mutates the input."""
 60    b = board.copy()
 61    resolve_placement(b, where, player)
 62    return b
 63
 64
 65def _negamax(board: Board, player: Owner, depth: int, alpha: float, beta: float, weights: dict[Hex, float]) -> float:
 66    if board.is_full():
 67        warm, cool = board.score()
 68        lead = (warm - cool) if player is Owner.WARM else (cool - warm)
 69        return TERMINAL if lead > 0 else -TERMINAL
 70    if depth == 0:
 71        return value(board, player, weights)
 72    best = -float("inf")
 73    foe = opponent(player)
 74    for mv in board.legal_moves():
 75        child = place_simulated(board, mv, player)
 76        score = -_negamax(child, foe, depth - 1, -beta, -alpha, weights)
 77        if score > best:
 78            best = score
 79        if best > alpha:
 80            alpha = best
 81        if alpha >= beta:
 82            break
 83    return best
 84
 85
 86def choose_move(board: Board, player: Owner, depth: int, rng: random.Random) -> Hex:
 87    """Pick a move. Equal-best moves are broken by ``rng`` for variety while
 88    staying reproducible from a fixed seed."""
 89    weights = _position_weights(board)
 90    foe = opponent(player)
 91    best_score = -float("inf")
 92    best_moves: list[Hex] = []
 93    for mv in board.legal_moves():
 94        child = place_simulated(board, mv, player)
 95        if depth <= 0:
 96            score = value(child, player, weights)
 97        else:
 98            score = -_negamax(child, foe, depth - 1, -float("inf"), float("inf"), weights)
 99        if score > best_score + 1e-9:
100            best_score = score
101            best_moves = [mv]
102        elif abs(score - best_score) <= 1e-9:
103            best_moves.append(mv)
104    return rng.choice(best_moves)