Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
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.
|
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.
|
- [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.
|
games and random games.
|
||||||
- [ ] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit
|
- [ ] 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
|
tests; `BotTournamentTest` (tag `calibration`) and its quick version; tune the evaluation weights and the
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user