diff --git a/apps/backend/src/main/java/app/playmancala/engine/Position.java b/apps/backend/src/main/java/app/playmancala/engine/Position.java new file mode 100644 index 0000000..454ecac --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/Position.java @@ -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"); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/engine/PositionTest.java b/apps/backend/src/test/java/app/playmancala/engine/PositionTest.java new file mode 100644 index 0000000..566d3ae --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/engine/PositionTest.java @@ -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); + } +} diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 6e44062..20f91e8 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -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