Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
3 changed files with 283 additions and 1 deletions
Showing only changes of commit f9a96fc5f5 - Show all commits

View 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");
}
}

View File

@ -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);
}
}

View File

@ -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