feat(backend): add a compact board for searching moves
Position keeps the board as 14 stone counts and plays the Mangala rules on it without allocating. Tested against MancalaGame with the recorded games and random games.
This commit is contained in:
parent
91577c0898
commit
f9a96fc5f5
176
apps/backend/src/main/java/app/playmancala/engine/Position.java
Normal file
176
apps/backend/src/main/java/app/playmancala/engine/Position.java
Normal file
@ -0,0 +1,176 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
// A board as 14 stone counts and whose turn it is, with the rules of CommonMancalaGame played on it directly. Fast
|
||||
// enough for searching millions of moves: playing a move allocates nothing. Kept equal to MancalaGame by
|
||||
// PositionTest; MancalaGame stays the engine for live games, since it also records the steps clients animate.
|
||||
public final class Position {
|
||||
public static final int SIZE = 14;
|
||||
public static final int PIT_COUNT = 6;
|
||||
public static final int PLAYER1_BANK = 6;
|
||||
public static final int PLAYER2_BANK = 13;
|
||||
private static final int INITIAL_STONES_IN_PIT = 4;
|
||||
|
||||
// Player 1's pits 0-5 and bank 6; player 2's pits 7-12 and bank 13, as in CommonBoard.
|
||||
private final int[] stones;
|
||||
private boolean player1ToMove;
|
||||
|
||||
private Position(int[] stones, boolean player1ToMove) {
|
||||
this.stones = stones;
|
||||
this.player1ToMove = player1ToMove;
|
||||
}
|
||||
|
||||
public static Position initial(boolean player1ToMove) {
|
||||
int[] stones = new int[SIZE];
|
||||
for (int pit = 0; pit < PIT_COUNT; pit++) {
|
||||
stones[pit] = INITIAL_STONES_IN_PIT;
|
||||
stones[PLAYER1_BANK + 1 + pit] = INITIAL_STONES_IN_PIT;
|
||||
}
|
||||
return new Position(stones, player1ToMove);
|
||||
}
|
||||
|
||||
public static Position of(int[] stones, boolean player1ToMove) {
|
||||
if (stones.length != SIZE) throw new IllegalArgumentException("A board has " + SIZE + " pits: " + stones.length);
|
||||
return new Position(stones.clone(), player1ToMove);
|
||||
}
|
||||
|
||||
public static Position of(MancalaGame game) {
|
||||
return of(game.getBoard().getStoneArray(), game.isTurnPlayer1());
|
||||
}
|
||||
|
||||
public Position copy() {
|
||||
return new Position(stones.clone(), player1ToMove);
|
||||
}
|
||||
|
||||
public int[] stones() {
|
||||
return stones.clone();
|
||||
}
|
||||
|
||||
public int stonesAt(int index) {
|
||||
return stones[index];
|
||||
}
|
||||
|
||||
public boolean isPlayer1ToMove() {
|
||||
return player1ToMove;
|
||||
}
|
||||
|
||||
// Writes the mover's playable pits (0-5) into `into` and returns how many there are.
|
||||
public int legalPits(int[] into) {
|
||||
int first = firstPitIndex(player1ToMove);
|
||||
int count = 0;
|
||||
for (int pit = 0; pit < PIT_COUNT; pit++) {
|
||||
if (stones[first + pit] > 0) into[count++] = pit;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Plays the mover's pit (0-5) into `into`, which may be this position. Returns true if the mover plays again.
|
||||
public boolean play(int pit, Position into) {
|
||||
if (pit < 0 || pit >= PIT_COUNT) throw new IllegalArgumentException("No such pit: " + pit);
|
||||
int index = firstPitIndex(player1ToMove) + pit;
|
||||
int stoneCount = stones[index];
|
||||
if (stoneCount <= 0) throw new IllegalArgumentException("Pit " + pit + " is empty");
|
||||
int[] board = into.stones;
|
||||
if (into != this) System.arraycopy(stones, 0, board, 0, SIZE);
|
||||
boolean player1 = player1ToMove;
|
||||
int ownBank = player1 ? PLAYER1_BANK : PLAYER2_BANK;
|
||||
int opponentBank = player1 ? PLAYER2_BANK : PLAYER1_BANK;
|
||||
|
||||
// Sowing: a single stone goes to the next pit; otherwise the first stone goes back into the played pit.
|
||||
board[index] = 0;
|
||||
int current = stoneCount == 1 ? next(index) : index;
|
||||
int last = current;
|
||||
for (int dropped = 0; dropped < stoneCount; current = next(current)) {
|
||||
if (current == opponentBank) continue;
|
||||
board[current]++;
|
||||
last = current;
|
||||
dropped++;
|
||||
}
|
||||
|
||||
// The last stone in an own empty pit takes the stones opposite, if there are any.
|
||||
if (board[last] == 1 && isOwnPit(last, player1)) {
|
||||
int opposite = 12 - last;
|
||||
if (board[opposite] > 0) {
|
||||
board[ownBank] += 1 + board[opposite];
|
||||
board[opposite] = 0;
|
||||
board[last] = 0;
|
||||
}
|
||||
}
|
||||
// The last stone making an opponent's pit even takes them.
|
||||
if (board[last] % 2 == 0 && isOwnPit(last, !player1)) {
|
||||
board[ownBank] += board[last];
|
||||
board[last] = 0;
|
||||
}
|
||||
boolean extraTurn = last == ownBank;
|
||||
into.player1ToMove = extraTurn == player1;
|
||||
|
||||
// When a side is empty, its player gets the stones left on the other side.
|
||||
int player1Stones = sumPits(board, 0);
|
||||
int player2Stones = sumPits(board, PLAYER1_BANK + 1);
|
||||
if (player1Stones == 0) {
|
||||
board[PLAYER1_BANK] += player2Stones;
|
||||
Arrays.fill(board, PLAYER1_BANK + 1, PLAYER2_BANK, 0);
|
||||
} else if (player2Stones == 0) {
|
||||
board[PLAYER2_BANK] += player1Stones;
|
||||
Arrays.fill(board, 0, PLAYER1_BANK, 0);
|
||||
}
|
||||
return extraTurn;
|
||||
}
|
||||
|
||||
// One side is empty, so the game has ended.
|
||||
public boolean isOver() {
|
||||
return sumPits(stones, 0) == 0 || sumPits(stones, PLAYER1_BANK + 1) == 0;
|
||||
}
|
||||
|
||||
// The mover's bank minus the opponent's.
|
||||
public int bankDifference() {
|
||||
int difference = stones[PLAYER1_BANK] - stones[PLAYER2_BANK];
|
||||
return player1ToMove ? difference : -difference;
|
||||
}
|
||||
|
||||
// The mover's stones in pits minus the opponent's.
|
||||
public int pitStoneDifference() {
|
||||
int difference = sumPits(stones, 0) - sumPits(stones, PLAYER1_BANK + 1);
|
||||
return player1ToMove ? difference : -difference;
|
||||
}
|
||||
|
||||
// Board index of the mover's pit (0-5).
|
||||
public int boardIndex(int pit) {
|
||||
return firstPitIndex(player1ToMove) + pit;
|
||||
}
|
||||
|
||||
private static int firstPitIndex(boolean player1) {
|
||||
return player1 ? 0 : PLAYER1_BANK + 1;
|
||||
}
|
||||
|
||||
private static boolean isOwnPit(int index, boolean player1) {
|
||||
return player1 ? index < PLAYER1_BANK : index > PLAYER1_BANK && index < PLAYER2_BANK;
|
||||
}
|
||||
|
||||
private static int next(int index) {
|
||||
return index == SIZE - 1 ? 0 : index + 1;
|
||||
}
|
||||
|
||||
private static int sumPits(int[] board, int first) {
|
||||
int sum = 0;
|
||||
for (int index = first; index < first + PIT_COUNT; index++) sum += board[index];
|
||||
return sum;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return other instanceof Position position && player1ToMove == position.player1ToMove
|
||||
&& Arrays.equals(stones, position.stones);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return 31 * Arrays.hashCode(stones) + Boolean.hashCode(player1ToMove);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return Arrays.toString(stones) + (player1ToMove ? " player 1 to move" : " player 2 to move");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,106 @@
|
||||
package app.playmancala.engine;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.SplittableRandom;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.model.Json;
|
||||
|
||||
// Position must play exactly like MancalaGame: the recorded games from the TypeScript engine and random games are
|
||||
// played on both, move by move.
|
||||
class PositionTest {
|
||||
|
||||
@Test
|
||||
void startsWithFourStonesInEveryPit() {
|
||||
Position position = Position.initial(true);
|
||||
assertArrayEquals(new CommonMancalaGame("g", "a", "b").getBoard().getStoneArray(), position.stones());
|
||||
assertTrue(position.isPlayer1ToMove());
|
||||
int[] pits = new int[Position.PIT_COUNT];
|
||||
assertEquals(6, position.legalPits(pits));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastStoneInOwnBankGivesAnExtraTurn() {
|
||||
Position position = Position.initial(true);
|
||||
// Pit 3 with 4 stones: 3, 4, 5 and the bank.
|
||||
assertTrue(position.play(3, position));
|
||||
assertArrayEquals(new int[] { 4, 4, 4, 1, 5, 5, 1, 4, 4, 4, 4, 4, 4, 0 }, position.stones());
|
||||
assertTrue(position.isPlayer1ToMove());
|
||||
}
|
||||
|
||||
@Test
|
||||
void playingIntoAnotherPositionLeavesThisOneAlone() {
|
||||
Position position = Position.initial(false);
|
||||
Position next = Position.initial(true);
|
||||
assertFalse(position.play(0, next));
|
||||
assertEquals(Position.initial(false), position);
|
||||
assertTrue(next.isPlayer1ToMove());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesEmptyPitsAndBanks() {
|
||||
Position position = Position.of(new int[] { 0, 1, 0, 0, 0, 0, 20, 1, 0, 0, 0, 0, 0, 26 }, true);
|
||||
assertThrows(IllegalArgumentException.class, () -> position.play(0, position));
|
||||
assertThrows(IllegalArgumentException.class, () -> position.play(6, position));
|
||||
}
|
||||
|
||||
@Test
|
||||
void playsTheRecordedGamesLikeTheEngine() throws IOException {
|
||||
JsonNode games;
|
||||
try (InputStream in = getClass().getResourceAsStream("/engine/recorded-games.json")) {
|
||||
games = Json.MAPPER.readTree(in).get("games");
|
||||
}
|
||||
for (JsonNode recorded : games) {
|
||||
String player1 = recorded.get("player1Id").asText();
|
||||
String player2 = recorded.get("player2Id").asText();
|
||||
MancalaGame game = new CommonMancalaGame("g", player1, player2);
|
||||
game.setTurnPlayerId(recorded.get("turnPlayerId").asText());
|
||||
int number = 0;
|
||||
for (JsonNode move : recorded.get("moves")) {
|
||||
number++;
|
||||
// Refused moves, and moves after the end, which the engine ignores.
|
||||
if (move.has("error") || game.getState() == GameState.ENDED) continue;
|
||||
String player = move.get("playerId").asText();
|
||||
playOnBoth(game, player, move.get("pitIndex").asInt(), recorded.get("id").asText() + " move " + number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void playsRandomGamesLikeTheEngine() {
|
||||
SplittableRandom random = new SplittableRandom(7);
|
||||
int[] pits = new int[Position.PIT_COUNT];
|
||||
for (int gameNumber = 0; gameNumber < 3000; gameNumber++) {
|
||||
MancalaGame game = new CommonMancalaGame("g", "p1", "p2");
|
||||
game.setTurnPlayerId(random.nextBoolean() ? "p1" : "p2");
|
||||
int moves = 0;
|
||||
while (game.getState() != GameState.ENDED) {
|
||||
Position position = Position.of(game);
|
||||
int count = position.legalPits(pits);
|
||||
int pit = pits[random.nextInt(count)];
|
||||
playOnBoth(game, game.getTurnPlayerId(), pit, "game " + gameNumber + " move " + ++moves);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void playOnBoth(MancalaGame game, String player, int pit, String where) {
|
||||
Position position = Position.of(game);
|
||||
assertEquals(game.isTurnPlayer1(), position.isPlayer1ToMove(), where);
|
||||
boolean extraTurn = position.play(pit, position);
|
||||
game.moveByPlayerPit(player, pit);
|
||||
assertArrayEquals(game.getBoard().getStoneArray(), position.stones(), where);
|
||||
assertEquals(game.isTurnPlayer1(), position.isPlayer1ToMove(), where);
|
||||
assertEquals(player.equals(game.getTurnPlayerId()), extraTurn, where);
|
||||
assertEquals(game.checkGameIsEnded(), position.isOver(), where);
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed.
|
||||
|
||||
- [x] 1. **Spec**: requirements, design and tasks.
|
||||
- [ ] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity
|
||||
- [x] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity
|
||||
games and random games.
|
||||
- [ ] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit
|
||||
tests; `BotTournamentTest` (tag `calibration`) and its quick version; tune the evaluation weights and the
|
||||
|
||||
Loading…
Reference in New Issue
Block a user