BitBully 0.0.78
A fast, perfect-play Connect-4 engine in modern C++
Loading...
Searching...
No Matches
BitBully.h
Go to the documentation of this file.
1
14#ifndef BITBULLY__BITBULLY_H_
15#define BITBULLY__BITBULLY_H_
16
17#include <filesystem>
18#include <iostream>
19#include <vector>
20
21#include "Board.h"
22#include "MoveList.h"
23#include "OpeningBook.h"
24#include "TranspositionTable.h"
25
26namespace BitBully {
45class BitBully {
46 private:
47 unsigned long long int nodeCounter;
49 static bool constexpr USE_TRANSPOSITION_TABLE = true;
51 static auto constexpr DEFAULT_LOG_TRANSPOSITION_SIZE = 22;
52
53 TranspositionTable transpositionTable;
54 std::unique_ptr<OpeningBook> m_openingBook;
55
56 public:
57 // MoveList sortMoves(Board::TBitBoard moves); // implemented in Board.cpp
58
64 explicit BitBully(const std::filesystem::path& bookPath = "")
65 : nodeCounter{0},
66 transpositionTable{
67 USE_TRANSPOSITION_TABLE ? DEFAULT_LOG_TRANSPOSITION_SIZE : 0} {
68 loadBook(bookPath); // will not do anything if path is empty
69 };
70
72 inline bool isBookLoaded() const { return m_openingBook != nullptr; }
73
75 inline void resetBook() { m_openingBook.reset(); }
76
87 inline bool loadBook(const std::filesystem::path& bookPath = "") {
88 if (isBookLoaded()) {
89 return false;
90 }
91 if (!bookPath.empty()) {
92 m_openingBook = std::make_unique<OpeningBook>(bookPath);
93 assert(isBookLoaded());
94 }
95 return isBookLoaded();
96 }
97
115 static int scoreToMovesLeft(const int score, const Board& b) noexcept {
116 if (score == 0) {
117 return b.movesLeft();
118 }
119 const int p = (b.movesLeft() + 1) % 2; // 1 -> yellow, 0 -> red
120 const int sgnScore = score < 0 ? 1 : 0;
121 const int absScore = score < 0 ? -score : score;
122 const int mvFinalLeft = 2 * (absScore - 1) + (sgnScore ^ p);
123 return b.movesLeft() - mvFinalLeft;
124 }
125
139 static int rollout(Board b) noexcept {
140 int ply = 0;
141
142 while (true) {
143 if (b.canWin()) {
144 const int score = (b.movesLeft() + 1) / 2;
145 return (ply % 2 == 0) ? score : -score;
146 }
147
148 if (!b.movesLeft()) {
149 return 0;
150 }
151
152 auto moves = b.generateNonLosingMoves();
153 if (!moves) {
154 const int score = -(b.movesLeft() / 2);
155 return (ply % 2 == 0) ? score : -score;
156 }
157
158 const auto mv = Board::nextMove(moves);
159
160 // TODO: probably faster to do the move directly on the current
161 // board instead of copying it first and then doing the move on the copy.
162 // However, this would require some changes to the Board class (e.g., a
163 // playMoveFast() method which does not check for legality of the move
164 // since we already know that it is legal).
165 b = b.playBitMaskOnCopy(mv);
166 ply++;
167 }
168 }
169
188 int mtdf(const Board& b, const int firstGuess,
189 const int maxDepth = -1) noexcept {
190 // MTD(f) algorithm by Aske Plaat: Plaat, Aske; Jonathan Schaeffer; Wim
191 // Pijls; Arie de Bruin (November 1996). "Best-first Fixed-depth Minimax
192 // Algorithms". Artificial Intelligence. 87 (1–2): 255–293.
193 // doi:10.1016/0004-3702(95)00126-3
194 auto g = firstGuess;
195 int upperBound = INT32_MAX;
196 int lowerBound = INT32_MIN;
197
198 while (lowerBound < upperBound) {
199 const auto beta = std::max(g, lowerBound + 1);
200 g = negamax(b, beta - 1, beta, 0, maxDepth);
201 if (g < beta) {
202 upperBound = g;
203 } else {
204 lowerBound = g;
205 }
206 }
207 return g;
208 }
209
221 int nullWindow(const Board& b, const int maxDepth = -1) noexcept {
222 int min = -b.movesLeft() / 2;
223 int max = (b.movesLeft() + 1) / 2;
224
225 while (min < max) {
226 int mid = min + (max - min) / 2;
227 if (mid <= 0 && min / 2 < mid)
228 mid = min / 2;
229 else if (mid >= 0 && max / 2 > mid)
230 mid = max / 2;
231 int r = negamax(b, mid, mid + 1, 0, maxDepth);
232 if (r <= mid) {
233 max = r;
234 } else {
235 min = r;
236 }
237 }
238 return min;
239 }
240
243 transpositionTable = TranspositionTable{
244 USE_TRANSPOSITION_TABLE ? DEFAULT_LOG_TRANSPOSITION_SIZE : 0};
245 }
246
248 [[nodiscard]] auto getNodeCounter() const { return nodeCounter; }
249
251 void resetNodeCounter() { nodeCounter = 0ULL; }
252
274 int negamax(Board b, int alpha, int beta, const int depth,
275 const int maxDepth = -1) noexcept {
276 // In several aspects inspired by Pascal's code
277 assert(alpha < beta);
278 nodeCounter++;
279
280 if (maxDepth >= 0 && depth >= maxDepth) {
281 return rollout(b);
282 }
283
284 const int8_t remainingBudget =
285 (maxDepth < 0) ? INT8_MAX : static_cast<int8_t>(maxDepth - depth);
286
287 if (isBookLoaded() && b.countTokens() == m_openingBook->getNPly()) {
288 return m_openingBook->getBoardValue(b);
289 }
290
291 // It appears as if this check is not necessary. Below we check, if we
292 // have any non-losing moves left. If not, we return with a negative
293 // score.
294 // TODO: move this outside negamax:
295 if (!depth && b.canWin()) {
296 return (b.movesLeft() + 1) / 2;
297 }
298
299 if (alpha >= (b.movesLeft() + 1) / 2) {
300 // We cannot get better than this (alpha) anymore (with every additional
301 // move, our potential score gets lower since we have a later win).
302 return alpha;
303 }
304
305 // lower bound of score as opponent cannot win next move:
306 if (const int min = -b.movesLeft() / 2; alpha < min) {
307 alpha = min;
308 if (alpha >= beta) return alpha;
309 }
310 if (const int max = (b.movesLeft() - 1) / 2; beta > max) {
311 beta = max;
312 if (alpha >= beta) return beta;
313 }
314
315 if (!b.movesLeft()) {
316 assert(!b.legalMovesMask());
318 return 0;
319 }
320
321 int oldAlpha = alpha;
322
323 auto moves = b.generateNonLosingMoves();
324 if (!moves) {
325 return -b.movesLeft() / 2;
326 }
327
328 assert(uint64_t_popcnt(moves) <= Board::N_COLUMNS);
329 assert(uint64_t_popcnt(moves) > 0);
330
331 if (depth < 20 && b.doubleThreat(moves)) {
332 return (b.movesLeft() - 1) / 2;
333 }
334
335 // Transposition cutoff: TODO: Pretty ugly...
336 TranspositionTable::Entry* ttEntry = nullptr;
337 if constexpr (USE_TRANSPOSITION_TABLE) {
338 if (b.movesLeft() > 6 && b.movesLeft() % 2 == 0) {
339 ttEntry = transpositionTable.get(b);
340 if (ttEntry && ttEntry->b == b.uid() &&
341 ttEntry->searchDepth >= remainingBudget) {
342 if (ttEntry->flag == TranspositionTable::Entry::EXACT) {
343 return ttEntry->value;
344 } else if (ttEntry->flag == TranspositionTable::Entry::LOWER) {
345 alpha = std::max(alpha, ttEntry->value);
346 } else if (ttEntry->flag == TranspositionTable::Entry::UPPER) {
347 beta = std::min(beta, ttEntry->value);
348 }
349 if (alpha >= beta) {
350 return ttEntry->value;
351 }
352 }
353 }
354 // Enhanced Transposition Cutoff
355 else if (depth < 22 && b.movesLeft() % 2) {
356 auto etcMoves = b.legalMovesMask();
357 while (etcMoves) {
358 auto mv = b.nextMove(etcMoves);
359 assert(uint64_t_popcnt(mv) == 1);
360 auto bETC = b.playBitMaskOnCopy(mv);
361 auto etcEntry = transpositionTable.get(bETC);
362
363 if (etcEntry->b == bETC.uid() &&
364 etcEntry->searchDepth >= remainingBudget &&
365 etcEntry->flag != TranspositionTable::Entry::LOWER &&
366 -etcEntry->value >= beta) {
367 return -etcEntry->value;
368 }
369
370 etcMoves ^= mv;
371 }
372 }
373
374 // Check symmetric positions
375 // Symmetries get rare at some point in the game, so do not check them
376 // on almost-full boards
377 if (b.movesLeft() > 20) {
378 const auto bMirror = b.mirror();
379 auto ttEntryMirror = transpositionTable.get(bMirror);
380 if (ttEntryMirror && ttEntryMirror->b == bMirror.uid() &&
381 ttEntryMirror->searchDepth >= remainingBudget) {
382 if (ttEntryMirror->flag == TranspositionTable::Entry::EXACT) {
383 return ttEntryMirror->value;
384 } else if (ttEntryMirror->flag == TranspositionTable::Entry::LOWER) {
385 alpha = std::max(alpha, ttEntryMirror->value);
386 } else if (ttEntryMirror->flag == TranspositionTable::Entry::UPPER) {
387 beta = std::min(beta, ttEntryMirror->value);
388 }
389 if (alpha >= beta) {
390 return ttEntryMirror->value;
391 }
392 }
393 }
394 }
395
396 /*
397 if (alpha >= (b.movesLeft() + 1) / 2) {
398 // We cannot get better than this any more (with every additional move,
399 // our potential score gets lower since we have a later win).
400 return alpha;
401 }
402 */
403
404 int value = -(1 << 10);
405 if (depth < 20) {
406 auto mvList = b.sortMoves(moves);
407
408 // while (const auto mv = mvList.getNext() && alpha < beta) {
409 auto mv = mvList.pop();
410 for (; mv && alpha < beta; mv = mvList.pop()) {
411 // const auto mv = (threats ? b.nextMove(threats) : b.nextMove(moves));
412 assert(uint64_t_popcnt(mv) == 1);
413 auto moveValue = -negamax(b.playBitMaskOnCopy(mv), -beta, -alpha,
414 depth + 1, maxDepth);
415 value = std::max(value, moveValue);
416 alpha = std::max(alpha, value);
417 }
418 } else {
419 auto threats = depth < 22 ? b.findThreats(moves) : UINT64_C(0);
420 assert((threats & moves) == threats);
421
422 // int value = -(1 << 10);
423 while (moves && alpha < beta) {
424 // auto mvList = (movesFirst ? movesFirst : moves);
425 const auto mv = (threats ? b.nextMove(threats) : b.nextMove(moves));
426 assert(uint64_t_popcnt(mv) == 1);
427 auto moveValue = -negamax(b.playBitMaskOnCopy(mv), -beta, -alpha,
428 depth + 1, maxDepth);
429 value = std::max(value, moveValue);
430 alpha = std::max(alpha, value);
431 threats &= ~mv;
432 moves ^= mv;
433 }
434 }
435
436 if constexpr (USE_TRANSPOSITION_TABLE) {
437 if (!ttEntry) return value;
438 assert(ttEntry != nullptr);
439 // Do not allow high-depth nodes to override low-depth nodes (low-depth
440 // nodes achieve higher cut-offs): Does not help!
441 // if ( ttEntry->flag == TranspositionTable::Entry::EXACT &&
442 // ttEntry->b.movesLeft() < 42 &&
443 // ttEntry->b.movesLeft() > b.movesLeft() + 16)
444 // return value;
445
446 // Store node result in Transposition value
447 ttEntry->b = b.uid();
448 ttEntry->value = value;
449 ttEntry->searchDepth = remainingBudget;
450
451 if (value <= oldAlpha) {
453 } else if (value >= beta) {
455 } else {
457 }
458 }
459 return value;
460 }
461
476 auto scoreMove(const Board& b, const int column, const int firstGuess,
477 const int maxDepth = -1) {
478 int score = -1000;
479 if (auto afterB = b; afterB.play(column)) {
480 if (afterB.hasWin()) {
481 return (afterB.movesLeft()) / 2 + 1;
482 }
483 // TODO: Get first guess from hash table if possible
484 score = -mtdf(afterB, firstGuess, maxDepth);
485 }
486 return score;
487 }
488
499 auto scoreMoves(const Board& b, const int maxDepth = -1) {
500 std::vector scores(Board::N_COLUMNS, -1000);
501 for (auto col = 0UL; col < scores.size(); col++) {
502 /*
503 if (auto afterB = b; afterB.play(col)) {
504 if (afterB.hasWin()) {
505 scores[col] = (afterB.movesLeft()) / 2 + 1;
506 continue;
507 }
508 // TODO: Get first guess from hash table if possible
509 scores[col] = -mtdf(afterB, !col ? 0 : scores.at(col - 1));
510 }
511 */
512 // TODO: Get first guess from hash table if possible
513 scores[col] = scoreMove(b, static_cast<int>(col),
514 !col ? 0 : scores.at(col - 1), maxDepth);
515 }
516
517 return scores;
518 }
519}; // class BitBully
520} // namespace BitBully
521
522#endif // BITBULLY__BITBULLY_H_
Bitboard-based representation of a Connect-4 position.
#define uint64_t_popcnt
Portable fallback popcount when no compiler intrinsic is available.
Definition Board.h:50
Tiny priority queue used by the engine for move ordering.
Read-only access to BitBully's pre-computed Connect-4 opening books.
Direct-mapped, replacement-free transposition table used by the solver.
bool loadBook(const std::filesystem::path &bookPath="")
Load an opening book from disk.
Definition BitBully.h:87
bool isBookLoaded() const
Was an opening book successfully loaded?
Definition BitBully.h:72
void resetNodeCounter()
Reset the visited-node counter to zero.
Definition BitBully.h:251
static int scoreToMovesLeft(const int score, const Board &b) noexcept
Convert a solver score to the number of moves until the game ends.
Definition BitBully.h:115
int mtdf(const Board &b, const int firstGuess, const int maxDepth=-1) noexcept
Solve a position using the MTD(f) driver.
Definition BitBully.h:188
void resetTranspositionTable()
Discard the contents of the transposition table.
Definition BitBully.h:242
auto scoreMove(const Board &b, const int column, const int firstGuess, const int maxDepth=-1)
Evaluate the move that drops a stone in column.
Definition BitBully.h:476
auto getNodeCounter() const
Number of nodes visited since resetNodeCounter().
Definition BitBully.h:248
int nullWindow(const Board &b, const int maxDepth=-1) noexcept
Alternative driver based on a binary search of zero-width windows.
Definition BitBully.h:221
BitBully(const std::filesystem::path &bookPath="")
Construct a solver, optionally loading an opening book.
Definition BitBully.h:64
auto scoreMoves(const Board &b, const int maxDepth=-1)
Evaluate every column move from b.
Definition BitBully.h:499
int negamax(Board b, int alpha, int beta, const int depth, const int maxDepth=-1) noexcept
Negamax search with alpha-beta pruning, transposition table and opening book consultation.
Definition BitBully.h:274
static int rollout(Board b) noexcept
Cheap evaluation: play out the game using safe heuristic moves.
Definition BitBully.h:139
void resetBook()
Discard the currently loaded opening book (if any).
Definition BitBully.h:75
Connect-4 position represented as a pair of 64-bit bitboards.
Definition Board.h:204
MoveList sortMoves(TBitBoard moves) const
Sort candidate moves into a MoveList using a threat-based heuristic.
Definition Board.cpp:285
static constexpr int N_ROWS
Number of rows in a Connect-4 grid.
Definition Board.h:213
TBitBoard legalMovesMask() const
Bitboard whose set bits mark the next reachable cell of every non-full column.
Definition Board.cpp:418
TMovesCounter countTokens() const
Number of stones currently placed on the board.
Definition Board.h:506
TBitBoard doubleThreat(const TBitBoard moves) const
Identify moves that create a "double threat" (forced win).
Definition Board.h:619
bool canWin() const
Does the player to move have an immediately winning move?
Definition Board.cpp:407
bool play(int column)
Drop a stone of the player to move into column.
Definition Board.cpp:137
Board playBitMaskOnCopy(const TBitBoard mv) const
Apply a move (already encoded as a single-bit mask) on a copy of this board.
Definition Board.h:245
static constexpr int N_COLUMNS
Number of columns in a Connect-4 grid.
Definition Board.h:211
static TBitBoard nextMove(TBitBoard allMoves)
Pick the best-ranked move from a candidate set using a fixed priority order.
Definition Board.h:376
TBitBoard generateNonLosingMoves() const
Compute moves that do not lose immediately.
Definition Board.h:591
Board mirror() const
Mirror the board around its central column.
Definition Board.cpp:501
static constexpr int popCountBoard(uint64_t x)
Software popcount fallback used when no intrinsic is available.
Definition Board.h:298
TBitBoard findThreats(TBitBoard moves)
Identify candidate moves that yield a tactical advantage.
Definition Board.cpp:313
TMovesCounter movesLeft() const
Number of plies remaining until the board is full.
Definition Board.h:503
uint64_t uid() const
Unique 64-bit identifier of the position.
Definition Board.h:350
TBitBoard pop()
Remove and return the highest-priority move.
Definition MoveList.h:51
Power-of-two-sized direct-mapped cache for negamax search results.
Top-level namespace for the BitBully Connect-4 engine.
Definition BitBully.h:26
One transposition-table slot.
NodeType flag
Type of bound stored in value.
int8_t searchDepth
Remaining search budget when the entry was stored.
uint64_t b
Position UID (see Board::uid()).
@ LOWER
Lower bound (fail-high cut node).
@ UPPER
Upper bound (fail-low all node).