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)