feat(backend): add the Quarkus project with a Java port of the engine

Quarkus 3.40 on Java 25, built with the Maven wrapper. The engine is a
line-by-line port of packages/mancala.js; a parity test replays games
recorded with the TypeScript engine, including illegal moves, and
compares every board, turn, error and the game JSON.
This commit is contained in:
Halit Aksoy 2026-10-16 11:06:43 +03:00
parent ec833b4ec5
commit f0ffd6ba87
27 changed files with 1703 additions and 1 deletions

9
apps/backend-quarkus/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
target/
.idea
*.iml
.vscode
.factorypath
# Written at runtime
access*.log
crashes/

View File

@ -0,0 +1,4 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
distributionSha256Sum=5af3b743dd8b876b5c45da33b676251e5f1687712644abb4ee519ca56e1d89ce

295
apps/backend-quarkus/mvnw vendored Executable file
View File

@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
apps/backend-quarkus/mvnw.cmd vendored Normal file
View File

@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>app.playmancala</groupId>
<artifactId>mancala-backend</artifactId>
<version>0.5.0</version>
<packaging>quarkus</packaging>
<properties>
<compiler-plugin.version>3.15.0</compiler-plugin.version>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.version>3.40.1</quarkus.platform.version>
<surefire-plugin.version>3.5.6</surefire-plugin.version>
<!-- Quarkus dev mode listens for a debugger on 5005 by default, which is the backend's own port. -->
<debug>5006</debug>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>${quarkus.platform.artifact-id}</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets-next</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-agroal</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-redis-client</artifactId>
</dependency>
<dependency>
<groupId>org.bitbucket.b_c</groupId>
<artifactId>jose4j</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<configuration>
<argLine>@{argLine}</argLine>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,61 @@
// Plays seeded random games with the TypeScript engine (packages/mancala.js) and writes every move and its outcome,
// so EngineParityTest can check that the Java engine plays exactly the same. Run it again after changing the rules:
//
// yarn --cwd ../../packages/mancala.js build && node scripts/record-engine-games.js
//
// Moves include illegal ones (the wrong player, an empty pit, a store, out of range) and moves after the end.
const fs = require("fs");
const path = require("path");
const { CommonMancalaGame } = require("../../../packages/mancala.js");
const GAME_COUNT = 15;
const MAX_MOVES = 400;
const OUTPUT = path.join(__dirname, "../src/test/resources/engine/recorded-games.json");
// mulberry32
function random(seed) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function recordGame(number) {
const next = random(number + 1);
const pick = (items) => items[Math.floor(next() * items.length)];
const game = new CommonMancalaGame(`game-${number}`, `kc:player1-${number}`, `kc:player2-${number}`);
game.turnPlayerId = next() < 0.5 ? game.player1Id : game.player2Id;
const recorded = {
id: game.id, player1Id: game.player1Id, player2Id: game.player2Id, turnPlayerId: game.turnPlayerId, moves: [],
};
let movesAfterEnd = 0;
for (let i = 0; i < MAX_MOVES && movesAfterEnd < 2; i++) {
const roll = next();
const opponent = game.turnPlayerId === game.player1Id ? game.player2Id : game.player1Id;
const playerId = roll < 0.93 ? game.turnPlayerId : opponent;
const pitsWithStones = [0, 1, 2, 3, 4, 5].filter((pit) =>
game.board.pits[game.getBoardIndexByPlayerId(playerId, pit)].stoneCount > 0);
const pitIndex = roll < 0.85 && pitsWithStones.length > 0 ? pick(pitsWithStones)
: roll < 0.97 ? Math.floor(next() * 6) : pick([-1, 6, 7, 13, 14]);
const move = { playerId, pitIndex };
try {
game.moveByPlayerPit(playerId, pitIndex);
} catch (err) {
move.error = err.message;
}
Object.assign(move, { board: game.board.getStoneArray(), turnPlayerId: game.turnPlayerId, state: game.state });
recorded.moves.push(move);
if (game.state === "ended") movesAfterEnd++;
}
recorded.wonPlayerId = game.getWonPlayerId() ?? null;
recorded.game = JSON.parse(JSON.stringify(game));
return recorded;
}
const games = Array.from({ length: GAME_COUNT }, (_, number) => recordGame(number));
fs.writeFileSync(OUTPUT, JSON.stringify({ games }) + "\n");
const moves = games.reduce((sum, game) => sum + game.moves.length, 0);
console.log(`Recorded ${games.length} games, ${moves} moves, to ${path.relative(process.cwd(), OUTPUT)}`);

View File

@ -0,0 +1,15 @@
package app.playmancala.engine;
import com.fasterxml.jackson.annotation.JsonIgnore;
public class Bank extends Pit {
public Bank(int index, int stoneCount) {
super(index, stoneCount);
}
@Override
@JsonIgnore
public boolean isBank() {
return true;
}
}

View File

@ -0,0 +1,221 @@
package app.playmancala.engine;
import java.util.ArrayList;
import java.util.List;
import java.util.function.IntConsumer;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// Port of packages/mancala.js/src/core/Board.ts. Pits are indexed counterclockwise: player 1's pits, player 1's bank,
// player 2's pits, player 2's bank.
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonPropertyOrder({ "pits", "playerPitCount", "initialStoneCountInPits" })
public class Board {
private static final int BANK_COUNT = 2;
private final List<Pit> pits;
private final int playerPitCount;
private final int initialStoneCountInPits;
@JsonIgnore
IntConsumer onGameMoveStart = index -> { };
@JsonIgnore
IntConsumer onGameMove = index -> { };
@JsonIgnore
IntConsumer onGameMoveEnd = index -> { };
public Board(int playerPitCount, int initialStoneCountInPits) {
this(playerPitCount, initialStoneCountInPits, null);
}
public Board(int playerPitCount, int initialStoneCountInPits, List<Pit> pits) {
this.playerPitCount = playerPitCount;
this.initialStoneCountInPits = initialStoneCountInPits;
this.pits = pits != null ? pits : createPits(initialStoneCountInPits);
}
private List<Pit> createPits(int initialStoneCountInPits) {
List<Pit> pitList = new ArrayList<>(playerPitCount * 2 + BANK_COUNT);
for (int index = 0; index < totalPitCount(); index++) {
PitType pitType = getPitTypeByIndex(index);
pitList.add(pitType.isBank() ? new Bank(index, 0) : new Pit(index, initialStoneCountInPits));
}
return pitList;
}
public List<Pit> getPits() {
return pits;
}
public int getPlayerPitCount() {
return playerPitCount;
}
public int getInitialStoneCountInPits() {
return initialStoneCountInPits;
}
public int player1PitStartIndex() {
return 0;
}
public int player1PitStopIndex() {
return player1PitStartIndex() + playerPitCount - 1;
}
public int player1BankIndex() {
return player1PitStopIndex() + 1;
}
public int player2PitStartIndex() {
return player1BankIndex() + 1;
}
public int player2PitStopIndex() {
return player2PitStartIndex() + playerPitCount - 1;
}
public int player2BankIndex() {
return player2PitStopIndex() + 1;
}
public int totalPitCount() {
return player2BankIndex() + 1;
}
public boolean checkIndex(int index) {
return index >= player1PitStartIndex() && index <= player2BankIndex();
}
void checkIndexAndMaybeThrowError(int index) {
if (!checkIndex(index)) {
throw new IllegalStateException("IndexOutOfRange => index : " + index + " [start, stop] = ["
+ player1PitStartIndex() + ", " + player2BankIndex() + "]");
}
}
public PitType getPitTypeByIndex(int index) {
checkIndexAndMaybeThrowError(index);
if (index >= player1PitStartIndex() && index <= player1PitStopIndex()) {
return PitType.PLAYER1_PIT;
} else if (index == player1BankIndex()) {
return PitType.PLAYER1_BANK;
} else if (index <= player2PitStopIndex()) {
return PitType.PLAYER2_PIT;
} else {
return PitType.PLAYER2_BANK;
}
}
public boolean checkPitTypeIsNormalPitByIndex(int index) {
return getPitTypeByIndex(index).isNormalPit();
}
public boolean checkPitTypeIsBankByIndex(int index) {
return getPitTypeByIndex(index).isBank();
}
public void move(int index) {
checkIndexAndMaybeThrowError(index);
PitType pitType = getPitTypeByIndex(index);
if (pitType.isBank()) {
throw new IllegalStateException("InCorrectPit => index : " + index + " pitType = " + pitTypeName(pitType));
}
Pit pit = pits.get(index);
if (pit.getStoneCount() <= 0) {
throw new IllegalStateException("StoneNotFound => index : " + index + " stoneCount = " + pit.getStoneCount());
}
List<Integer> path = getSowingPath(index);
pit.setStoneCount(0);
onGameMoveStart.accept(path.getFirst());
path.forEach(this::onGameMoveStep);
onGameMoveEnd.accept(path.getLast());
}
public List<Integer> getSowingPath(int index) {
return getSowingPath(index, pits.get(index).getStoneCount());
}
// Indexes the stones of the pit are dropped into, in order. A single stone goes to the next pit; otherwise the
// first stone goes back into the played pit. The player's own bank gets a stone, the opponent's bank is skipped.
// The last index is where the last stone lands. Empty for a bank or an empty pit.
public List<Integer> getSowingPath(int index, int stoneCount) {
checkIndexAndMaybeThrowError(index);
if (stoneCount <= 0 || checkPitTypeIsBankByIndex(index)) return List.of();
int opponentBankIndex = getPitTypeByIndex(index) == PitType.PLAYER1_PIT ? player2BankIndex() : player1BankIndex();
List<Integer> path = new ArrayList<>(stoneCount);
int current = stoneCount == 1 ? getNextPitIndexCircularly(index) : index;
while (path.size() < stoneCount) {
if (current != opponentBankIndex) path.add(current);
current = getNextPitIndexCircularly(current);
}
return path;
}
public void onGameMoveStep(int index) {
pits.get(getPitIndexCircularly(index)).increaseStone();
onGameMove.accept(index);
}
public int getNextPitIndexCircularly(int currentPitIndex) {
checkIndexAndMaybeThrowError(currentPitIndex);
return getPitIndexCircularly(currentPitIndex + 1);
}
public Pit getPitCircularly(int currentPitIndex) {
return pits.get(getPitIndexCircularly(currentPitIndex));
}
public int getPitIndexCircularly(int currentPitIndex) {
return currentPitIndex % totalPitCount();
}
public int[] getStoneArray() {
return pits.stream().mapToInt(Pit::getStoneCount).toArray();
}
public List<Pit> player1Pits() {
return pits.subList(player1PitStartIndex(), player1BankIndex());
}
public List<Pit> player2Pits() {
return pits.subList(player2PitStartIndex(), player2BankIndex());
}
public Pit player1Bank() {
return pits.get(player1BankIndex());
}
public Pit player2Bank() {
return pits.get(player2BankIndex());
}
public int getOppositePitIndex(int index) {
if (index > player1BankIndex()) {
return player2PitStopIndex() - index;
} else {
return player1BankIndex() + playerPitCount - index;
}
}
public void clearPlayer1Pits() {
player1Pits().forEach(pit -> pit.setStoneCount(0));
}
public void clearPlayer2Pits() {
player2Pits().forEach(pit -> pit.setStoneCount(0));
}
// The TypeScript name of a pit type, for error messages.
static String pitTypeName(PitType pitType) {
return switch (pitType) {
case PLAYER1_PIT -> "player1Pit";
case PLAYER1_BANK -> "player1Bank";
case PLAYER2_PIT -> "player2Pit";
case PLAYER2_BANK -> "player2Bank";
};
}
}

View File

@ -0,0 +1,10 @@
package app.playmancala.engine;
public class CommonBoard extends Board {
private static final int PIT_COUNT_BY_USER = 6;
private static final int INITIAL_STONE_COUNT_IN_PIT = 4;
public CommonBoard() {
super(PIT_COUNT_BY_USER, INITIAL_STONE_COUNT_IN_PIT);
}
}

View File

@ -0,0 +1,18 @@
package app.playmancala.engine;
import java.util.List;
import app.playmancala.engine.rules.GRClearBoardAtEnd;
import app.playmancala.engine.rules.GRDoubleStoneInPit;
import app.playmancala.engine.rules.GRLastStoneInBank;
import app.playmancala.engine.rules.GRLastStoneInEmptyPit;
// The Turkish Mangala rules. The order of the rules matters: each sees the board as the previous one left it.
public class CommonMancalaGame extends MancalaGame {
public CommonMancalaGame(String id, String player1Id, String player2Id) {
super(id, new CommonBoard(), player1Id, player2Id, player1Id,
List.of(new GRLastStoneInEmptyPit(), new GRDoubleStoneInPit(), new GRLastStoneInBank(),
new GRClearBoardAtEnd()),
List.of());
}
}

View File

@ -0,0 +1,12 @@
package app.playmancala.engine;
public interface GameRule {
default void onGameMoveStart(MancalaGame game, int index) {
}
default void onGameMove(MancalaGame game, int index) {
}
default void onGameMoveEnd(MancalaGame game, int index) {
}
}

View File

@ -0,0 +1,9 @@
package app.playmancala.engine;
import com.fasterxml.jackson.annotation.JsonProperty;
public enum GameState {
@JsonProperty("initial") INITIAL,
@JsonProperty("playing") PLAYING,
@JsonProperty("ended") ENDED
}

View File

@ -0,0 +1,13 @@
package app.playmancala.engine;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// One thing that happened during a move, for clients to animate. `data` depends on the type and is null for most.
@JsonPropertyOrder({ "index", "type", "data" })
@JsonInclude(JsonInclude.Include.ALWAYS)
public record GameStep(int index, String type, Object data) {
public GameStep(int index, String type) {
this(index, type, null);
}
}

View File

@ -0,0 +1,213 @@
package app.playmancala.engine;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// Port of packages/mancala.js/src/core/MancalaGame.ts. Its JSON is what JSON.stringify makes of the TypeScript object,
// because clients rebuild the game from it with MancalaGame.createFromMancalaGame.
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonPropertyOrder({ "id", "board", "player1Id", "player2Id", "turnPlayerId", "state", "gameRules", "history",
"currentHistoryItem" })
@JsonInclude(JsonInclude.Include.ALWAYS)
public class MancalaGame {
public static final String GAME_STEP_GAME_MOVE = "GAME_STEP_GAME_MOVE";
private final String id;
private final Board board;
private final String player1Id;
private final String player2Id;
private String turnPlayerId;
private GameState state;
@JsonIgnore
private final List<GameRule> rules;
private final List<MoveHistoryItem> history;
private MoveHistoryItem currentHistoryItem;
public MancalaGame(String id, Board board, String player1Id, String player2Id, String turnPlayerId,
List<GameRule> gameRules, List<MoveHistoryItem> history) {
this(id, board, player1Id, player2Id, turnPlayerId, gameRules, history, GameState.INITIAL);
}
public MancalaGame(String id, Board board, String player1Id, String player2Id, String turnPlayerId,
List<GameRule> gameRules, List<MoveHistoryItem> history, GameState state) {
this.id = id;
this.board = board;
this.player1Id = player1Id;
this.player2Id = player2Id;
this.turnPlayerId = turnPlayerId;
this.state = state;
this.rules = List.copyOf(gameRules);
this.history = new ArrayList<>(history);
listenBoardMoveEvents();
}
private void listenBoardMoveEvents() {
board.onGameMoveStart = index -> rules.forEach(gameRule -> gameRule.onGameMoveStart(this, index));
board.onGameMove = index -> {
addGameStep(new GameStep(index, GAME_STEP_GAME_MOVE));
rules.forEach(gameRule -> gameRule.onGameMove(this, index));
};
board.onGameMoveEnd = index -> rules.forEach(gameRule -> gameRule.onGameMoveEnd(this, index));
}
public String getId() {
return id;
}
public Board getBoard() {
return board;
}
public String getPlayer1Id() {
return player1Id;
}
public String getPlayer2Id() {
return player2Id;
}
public String getTurnPlayerId() {
return turnPlayerId;
}
public void setTurnPlayerId(String turnPlayerId) {
this.turnPlayerId = turnPlayerId;
}
public GameState getState() {
return state;
}
public void setState(GameState state) {
this.state = state;
}
public List<MoveHistoryItem> getHistory() {
return history;
}
public MoveHistoryItem getCurrentHistoryItem() {
return currentHistoryItem;
}
// Rules have no state; the TypeScript objects serialise as {}.
@JsonProperty("gameRules")
private List<Map<String, Object>> gameRulesJson() {
return rules.stream().map(rule -> Map.<String, Object>of()).toList();
}
public void changePlayerTurn() {
turnPlayerId = turnPlayerId.equals(player1Id) ? player2Id : player1Id;
}
public boolean isTurnPlayer1() {
return player1Id.equals(turnPlayerId);
}
public boolean isTurnPlayer2() {
return player2Id.equals(turnPlayerId);
}
public String getPlayerIdByPitType(PitType pitType) {
return switch (pitType) {
case PLAYER1_PIT, PLAYER1_BANK -> player1Id;
case PLAYER2_PIT, PLAYER2_BANK -> player2Id;
};
}
public String getPlayerIdByIndex(int index) {
return getPlayerIdByPitType(board.getPitTypeByIndex(index));
}
public boolean checkIsPlayerTurnByIndex(int index) {
return checkIsPlayerTurn(getPlayerIdByIndex(index));
}
public int getBoardIndexByPlayerId(String playerId, int pitIndex) {
if (player1Id.equals(playerId)) {
return board.player1PitStartIndex() + pitIndex;
} else if (player2Id.equals(playerId)) {
return board.player2PitStartIndex() + pitIndex;
} else {
return -1;
}
}
public boolean checkIsPlayerTurn(String playerId) {
return playerId.equals(turnPlayerId);
}
public boolean checkPitIndexForPlayerId(String playerId, int pitIndex) {
return playerId.equals(getPlayerIdByIndex(getBoardIndexByPlayerId(playerId, pitIndex)));
}
public boolean checkIsPitIndexBank(String playerId, int pitIndex) {
return board.getPitTypeByIndex(getBoardIndexByPlayerId(playerId, pitIndex)).isBank();
}
public boolean canPlayerMove(String playerId, int pitIndex) {
boolean isPitIndexCorrect = checkPitIndexForPlayerId(playerId, pitIndex);
boolean isPitIndexBank = checkIsPitIndexBank(playerId, pitIndex);
boolean isPlayerTurn = checkIsPlayerTurn(playerId);
return isPitIndexCorrect && !isPitIndexBank && isPlayerTurn;
}
public void moveByPlayerPit(String playerId, int pitIndex) {
if (state == GameState.ENDED) return;
if (state == GameState.INITIAL) {
state = GameState.PLAYING;
}
if (canPlayerMove(playerId, pitIndex)) {
int moveIndex = getBoardIndexByPlayerId(playerId, pitIndex);
currentHistoryItem = new MoveHistoryItem(playerId, moveIndex, new int[0], List.of());
board.move(moveIndex);
currentHistoryItem.setBoardSnapshot(board.getStoneArray());
history.add(currentHistoryItem);
if (checkGameIsEnded()) {
state = GameState.ENDED;
}
} else {
boolean isPitIndexCorrect = checkPitIndexForPlayerId(playerId, pitIndex);
boolean isPlayerTurn = checkIsPlayerTurn(playerId);
throw new IllegalStateException("Player cannot move reason : isPitIndexCorrect = " + isPitIndexCorrect
+ " isPlayerTurn = " + isPlayerTurn);
}
}
public int getPlayer1StoneCountInPits() {
return getPlayerStoneCountInPits(board.player1PitStartIndex(), board.player1BankIndex());
}
public int getPlayer2StoneCountInPits() {
return getPlayerStoneCountInPits(board.player2PitStartIndex(), board.player2BankIndex());
}
private int getPlayerStoneCountInPits(int playerPitStartIndex, int playerBankIndex) {
return board.getPits().subList(playerPitStartIndex, playerBankIndex).stream()
.mapToInt(Pit::getStoneCount).sum();
}
public boolean checkGameIsEnded() {
return getPlayer1StoneCountInPits() == 0 || getPlayer2StoneCountInPits() == 0;
}
// The winner once the game has ended; null for a draw or a game still being played.
public String getWonPlayerId() {
int player1StoneCount = board.player1Bank().getStoneCount();
int player2StoneCount = board.player2Bank().getStoneCount();
if (!checkGameIsEnded() || player1StoneCount == player2StoneCount) return null;
return player1StoneCount > player2StoneCount ? player1Id : player2Id;
}
public void addGameStep(GameStep gameStep) {
if (currentHistoryItem != null) currentHistoryItem.getGameSteps().add(gameStep);
}
}

View File

@ -0,0 +1,49 @@
package app.playmancala.engine;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// A played move. The property order is that of the TypeScript MoveHistoryItem, whose base class sets the board first.
@JsonPropertyOrder({ "boardSnapshot", "gameSteps", "playerId", "moveIndex" })
public class MoveHistoryItem {
private int[] boardSnapshot;
private final List<GameStep> gameSteps;
private final String playerId;
private final int moveIndex;
@JsonCreator
public MoveHistoryItem(
@JsonProperty("playerId") String playerId,
@JsonProperty("moveIndex") int moveIndex,
@JsonProperty("boardSnapshot") int[] boardSnapshot,
@JsonProperty("gameSteps") List<GameStep> gameSteps) {
this.playerId = playerId;
this.moveIndex = moveIndex;
this.boardSnapshot = boardSnapshot == null ? new int[0] : boardSnapshot;
this.gameSteps = gameSteps == null ? new ArrayList<>() : new ArrayList<>(gameSteps);
}
public int[] getBoardSnapshot() {
return boardSnapshot;
}
void setBoardSnapshot(int[] boardSnapshot) {
this.boardSnapshot = boardSnapshot;
}
public List<GameStep> getGameSteps() {
return gameSteps;
}
public String getPlayerId() {
return playerId;
}
public int getMoveIndex() {
return moveIndex;
}
}

View File

@ -0,0 +1,40 @@
package app.playmancala.engine;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
@JsonPropertyOrder({ "index", "stoneCount" })
public class Pit {
private final int index;
private int stoneCount;
public Pit(int index, int stoneCount) {
this.index = index;
this.stoneCount = stoneCount;
}
public int getIndex() {
return index;
}
public int getStoneCount() {
return stoneCount;
}
public void setStoneCount(int stoneCount) {
this.stoneCount = stoneCount;
}
@JsonIgnore
public boolean isBank() {
return false;
}
public void increaseStone() {
stoneCount++;
}
public void addStones(int count) {
stoneCount += count;
}
}

View File

@ -0,0 +1,16 @@
package app.playmancala.engine;
public enum PitType {
PLAYER1_PIT,
PLAYER1_BANK,
PLAYER2_PIT,
PLAYER2_BANK;
public boolean isBank() {
return this == PLAYER1_BANK || this == PLAYER2_BANK;
}
public boolean isNormalPit() {
return this == PLAYER1_PIT || this == PLAYER2_PIT;
}
}

View File

@ -0,0 +1,44 @@
package app.playmancala.engine.rules;
import java.util.ArrayList;
import java.util.List;
import app.playmancala.engine.Board;
import app.playmancala.engine.GameRule;
import app.playmancala.engine.GameStep;
import app.playmancala.engine.MancalaGame;
// When a move empties one side, the player whose side is empty gets the stones left on the other side.
public class GRClearBoardAtEnd implements GameRule {
public static final String GAME_STEP_BOARD_CLEARED = "GAME_STEP_BOARD_CLEARED";
public record ClearBoardAtEndData(List<Integer> pitIndexesThatHasStone) {
}
@Override
public void onGameMoveEnd(MancalaGame game, int index) {
Board board = game.getBoard();
if (game.getPlayer1StoneCountInPits() == 0) {
ClearBoardAtEndData data = new ClearBoardAtEndData(getPitIndexesThatHasStone(board));
board.player1Bank().addStones(game.getPlayer2StoneCountInPits());
board.clearPlayer2Pits();
game.addGameStep(new GameStep(index, GAME_STEP_BOARD_CLEARED, data));
} else if (game.getPlayer2StoneCountInPits() == 0) {
ClearBoardAtEndData data = new ClearBoardAtEndData(getPitIndexesThatHasStone(board));
board.player2Bank().addStones(game.getPlayer1StoneCountInPits());
board.clearPlayer1Pits();
game.addGameStep(new GameStep(index, GAME_STEP_BOARD_CLEARED, data));
}
}
private List<Integer> getPitIndexesThatHasStone(Board board) {
List<Integer> indexList = new ArrayList<>();
int[] stones = board.getStoneArray();
for (int index = 0; index < stones.length; index++) {
if (stones[index] > 0 && board.checkPitTypeIsNormalPitByIndex(index)) {
indexList.add(index);
}
}
return indexList;
}
}

View File

@ -0,0 +1,37 @@
package app.playmancala.engine.rules;
import app.playmancala.engine.Board;
import app.playmancala.engine.GameRule;
import app.playmancala.engine.GameStep;
import app.playmancala.engine.MancalaGame;
import app.playmancala.engine.Pit;
import app.playmancala.engine.PitType;
// When the last stone makes the number of stones in an opponent's pit even, the mover takes them.
public class GRDoubleStoneInPit implements GameRule {
public static final String GAME_STEP_DOUBLE_STONE_IN_PIT = "GAME_STEP_DOUBLE_STONE_IN_PIT";
public record DoubleStoneInPitData(int pitIndex, int bankIndex) {
}
@Override
public void onGameMoveEnd(MancalaGame game, int index) {
Board board = game.getBoard();
index = board.getPitIndexCircularly(index);
Pit pit = board.getPits().get(index);
PitType pitType = board.getPitTypeByIndex(index);
if (pit.getStoneCount() % 2 != 0) return;
if (pitType == PitType.PLAYER1_PIT && game.isTurnPlayer2()) {
capture(game, index, board.player2Bank(), board.player2BankIndex());
} else if (pitType == PitType.PLAYER2_PIT && game.isTurnPlayer1()) {
capture(game, index, board.player1Bank(), board.player1BankIndex());
}
}
private void capture(MancalaGame game, int pitIndex, Pit bank, int bankIndex) {
Pit pit = game.getBoard().getPitCircularly(pitIndex);
bank.addStones(pit.getStoneCount());
pit.setStoneCount(0);
game.addGameStep(new GameStep(pitIndex, GAME_STEP_DOUBLE_STONE_IN_PIT, new DoubleStoneInPitData(pitIndex, bankIndex)));
}
}

View File

@ -0,0 +1,23 @@
package app.playmancala.engine.rules;
import app.playmancala.engine.GameRule;
import app.playmancala.engine.GameStep;
import app.playmancala.engine.MancalaGame;
import app.playmancala.engine.PitType;
// When the last stone lands in the mover's bank, the mover plays again; otherwise the turn passes.
public class GRLastStoneInBank implements GameRule {
public static final String GAME_STEP_LAST_STONE_IN_BANK = "GAME_STEP_LAST_STONE_IN_BANK";
@Override
public void onGameMoveEnd(MancalaGame game, int index) {
index = game.getBoard().getPitIndexCircularly(index);
PitType pitType = game.getBoard().getPitTypeByIndex(index);
if ((pitType == PitType.PLAYER1_BANK && game.isTurnPlayer1())
|| (pitType == PitType.PLAYER2_BANK && game.isTurnPlayer2())) {
game.addGameStep(new GameStep(index, GAME_STEP_LAST_STONE_IN_BANK));
} else {
game.changePlayerTurn();
}
}
}

View File

@ -0,0 +1,40 @@
package app.playmancala.engine.rules;
import app.playmancala.engine.Board;
import app.playmancala.engine.GameRule;
import app.playmancala.engine.GameStep;
import app.playmancala.engine.MancalaGame;
import app.playmancala.engine.Pit;
import app.playmancala.engine.PitType;
// When the last stone lands in an empty pit of the mover's own, the mover takes it and the stones opposite.
public class GRLastStoneInEmptyPit implements GameRule {
public static final String GAME_STEP_LAST_STONE_IN_EMPTY_PIT = "GAME_STEP_LAST_STONE_IN_EMPTY_PIT";
public record LastStoneInEmptyPitData(int oppositeIndex) {
}
@Override
public void onGameMoveEnd(MancalaGame game, int index) {
Board board = game.getBoard();
index = board.getPitIndexCircularly(index);
Pit pit = board.getPits().get(index);
PitType pitType = board.getPitTypeByIndex(index);
if (pit.getStoneCount() != 1) return;
if (pitType == PitType.PLAYER1_PIT && game.isTurnPlayer1()) {
capture(game, index, pit, board.player1Bank());
} else if (pitType == PitType.PLAYER2_PIT && game.isTurnPlayer2()) {
capture(game, index, pit, board.player2Bank());
}
}
private void capture(MancalaGame game, int index, Pit pit, Pit bank) {
int oppositeIndex = game.getBoard().getOppositePitIndex(index);
Pit oppositePit = game.getBoard().getPits().get(oppositeIndex);
if (oppositePit.getStoneCount() <= 0) return;
bank.addStones(1 + oppositePit.getStoneCount());
oppositePit.setStoneCount(0);
pit.setStoneCount(0);
game.addGameStep(new GameStep(index, GAME_STEP_LAST_STONE_IN_EMPTY_PIT, new LastStoneInEmptyPitData(oppositeIndex)));
}
}

View File

@ -0,0 +1,33 @@
package app.playmancala.model;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
// The JSON mapper for messages, snapshots, records and the cache. Unknown properties are ignored, so data written by
// a newer or older backend can still be read.
public final class Json {
public static final ObjectMapper MAPPER = new ObjectMapper()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
private Json() {
}
public static String write(Object value) {
try {
return MAPPER.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Cannot serialise " + value.getClass().getSimpleName(), e);
}
}
public static JsonNode tree(Object value) {
return MAPPER.valueToTree(value);
}
// Throws JsonProcessingException for text that is not JSON.
public static JsonNode parse(String text) throws JsonProcessingException {
return MAPPER.readTree(text);
}
}

View File

@ -0,0 +1,58 @@
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.assertNull;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import com.fasterxml.jackson.databind.JsonNode;
import app.playmancala.model.Json;
// Replays games recorded with the TypeScript engine (scripts/record-engine-games.js) and checks that every move
// has the same outcome, and that the finished game serialises to the same JSON clients received from the Node backend.
class EngineParityTest {
@TestFactory
Stream<DynamicTest> playsLikeTheTypeScriptEngine() throws IOException {
JsonNode games;
try (InputStream in = getClass().getResourceAsStream("/engine/recorded-games.json")) {
games = Json.MAPPER.readTree(in).get("games");
}
List<DynamicTest> tests = new ArrayList<>();
games.forEach(recorded -> tests.add(DynamicTest.dynamicTest(recorded.get("id").asText(), () -> replay(recorded))));
return tests.stream();
}
private void replay(JsonNode recorded) {
MancalaGame game = new CommonMancalaGame(recorded.get("id").asText(), recorded.get("player1Id").asText(),
recorded.get("player2Id").asText());
game.setTurnPlayerId(recorded.get("turnPlayerId").asText());
int number = 0;
for (JsonNode move : recorded.get("moves")) {
String where = "move " + ++number;
String error = null;
try {
game.moveByPlayerPit(move.get("playerId").asText(), move.get("pitIndex").asInt());
} catch (IllegalStateException e) {
error = e.getMessage();
}
assertEquals(move.has("error") ? move.get("error").asText() : null, error, where);
assertArrayEquals(Json.MAPPER.convertValue(move.get("board"), int[].class), game.getBoard().getStoneArray(), where);
assertEquals(move.get("turnPlayerId").asText(), game.getTurnPlayerId(), where);
assertEquals(move.get("state"), Json.tree(game.getState()), where);
}
JsonNode wonPlayerId = recorded.get("wonPlayerId");
if (wonPlayerId.isNull()) assertNull(game.getWonPlayerId());
else assertEquals(wonPlayerId.asText(), game.getWonPlayerId());
assertEquals(recorded.get("game"), Json.tree(game));
}
}

View File

@ -0,0 +1,184 @@
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import app.playmancala.engine.rules.GRClearBoardAtEnd;
import app.playmancala.engine.rules.GRDoubleStoneInPit;
import app.playmancala.engine.rules.GRLastStoneInBank;
import app.playmancala.engine.rules.GRLastStoneInEmptyPit;
import app.playmancala.model.Json;
// Ported from packages/mancala.js/tests; EngineParityTest covers whole games.
class MancalaGameTest {
private static final String P1 = "0";
private static final String P2 = "1";
private static MancalaGame createGame() {
return new CommonMancalaGame("0", P1, P2);
}
private static void setStones(MancalaGame game, int... stones) {
for (int i = 0; i < stones.length; i++) game.getBoard().getPits().get(i).setStoneCount(stones[i]);
}
private static GameStep move(int index) {
return new GameStep(index, MancalaGame.GAME_STEP_GAME_MOVE);
}
@Test
void pitTypesAndIndexes() {
Board board = new Board(6, 4);
assertEquals(PitType.PLAYER1_PIT, board.getPitTypeByIndex(5));
assertEquals(PitType.PLAYER1_BANK, board.getPitTypeByIndex(6));
assertEquals(PitType.PLAYER2_PIT, board.getPitTypeByIndex(7));
assertEquals(PitType.PLAYER2_BANK, board.getPitTypeByIndex(13));
assertThrows(IllegalStateException.class, () -> board.getPitTypeByIndex(-1));
assertThrows(IllegalStateException.class, () -> board.getPitTypeByIndex(14));
assertEquals(1, board.getPitIndexCircularly(15));
assertEquals(12, board.getOppositePitIndex(0));
assertEquals(5, board.getOppositePitIndex(7));
assertEquals(0, board.getOppositePitIndex(12));
assertTrue(board.getPits().get(6).isBank());
assertFalse(board.getPits().get(0).isBank());
}
@Test
void sowingPathSkipsTheOpponentsStore() {
Board board = new Board(6, 4);
assertEquals(List.of(1), board.getSowingPath(0, 1));
assertEquals(List.of(5, 6), board.getSowingPath(5, 2));
assertEquals(List.of(5, 6, 7, 8, 9, 10, 11, 12, 0), board.getSowingPath(5, 9));
assertEquals(List.of(12, 13, 0), board.getSowingPath(12, 3));
assertEquals(List.of(11, 12, 13, 0, 1, 2, 3, 4, 5, 7), board.getSowingPath(11, 10));
assertEquals(List.of(), board.getSowingPath(0, 0));
assertEquals(List.of(), board.getSowingPath(6));
assertEquals(List.of(), board.getSowingPath(13));
}
@Test
void boardMove() {
Board board = new Board(6, 4);
board.move(0);
assertArrayEquals(new int[] { 1, 5, 5, 5, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0 }, board.getStoneArray());
board.move(0);
assertArrayEquals(new int[] { 0, 6, 5, 5, 4, 4, 0, 4, 4, 4, 4, 4, 4, 0 }, board.getStoneArray());
board.move(1);
assertArrayEquals(new int[] { 0, 1, 6, 6, 5, 5, 1, 4, 4, 4, 4, 4, 4, 0 }, board.getStoneArray());
assertEquals("StoneNotFound => index : 0 stoneCount = 0",
assertThrows(IllegalStateException.class, () -> board.move(0)).getMessage());
assertEquals("InCorrectPit => index : 6 pitType = player1Bank",
assertThrows(IllegalStateException.class, () -> board.move(6)).getMessage());
}
@Test
void canPlayerMove() {
MancalaGame game = createGame();
for (int pit = 0; pit < 6; pit++) assertTrue(game.canPlayerMove(P1, pit));
assertFalse(game.canPlayerMove(P1, 6));
assertFalse(game.canPlayerMove(P2, 0));
assertEquals(P1, game.getPlayerIdByIndex(6));
assertEquals(P2, game.getPlayerIdByIndex(7));
}
@Test
void turnsAndState() {
MancalaGame game = createGame();
assertEquals(GameState.INITIAL, game.getState());
game.moveByPlayerPit(P1, 0);
assertEquals(GameState.PLAYING, game.getState());
assertEquals(P2, game.getTurnPlayerId());
game.moveByPlayerPit(P2, 0);
assertEquals(P1, game.getTurnPlayerId());
IllegalStateException error = assertThrows(IllegalStateException.class, () -> game.moveByPlayerPit(P2, 1));
assertEquals("Player cannot move reason : isPitIndexCorrect = true isPlayerTurn = false", error.getMessage());
}
@Test
void lastStoneInBankGivesAnotherTurn() {
MancalaGame game = createGame();
game.moveByPlayerPit(P1, 3);
assertEquals(P1, game.getTurnPlayerId());
assertEquals(new GameStep(6, GRLastStoneInBank.GAME_STEP_LAST_STONE_IN_BANK),
game.getHistory().getFirst().getGameSteps().getLast());
}
@Test
void lastStoneInEmptyPitCapturesTheOppositePit() {
MancalaGame game = createGame();
game.getBoard().getPits().get(0).setStoneCount(1);
game.getBoard().getPits().get(1).setStoneCount(0);
game.moveByPlayerPit(P1, 0);
assertEquals(P2, game.getTurnPlayerId());
assertArrayEquals(new int[] { 0, 0, 4, 4, 4, 4, 5, 4, 4, 4, 4, 0, 4, 0 }, game.getBoard().getStoneArray());
assertEquals(List.of(move(1), new GameStep(1, GRLastStoneInEmptyPit.GAME_STEP_LAST_STONE_IN_EMPTY_PIT,
new GRLastStoneInEmptyPit.LastStoneInEmptyPitData(11))), game.getHistory().getFirst().getGameSteps());
}
@Test
void doubleStoneInPitCapturesAnEvenOpponentPit() {
MancalaGame game = createGame();
setStones(game, 3, 4, 4, 4, 4, 3, 0, 3, 4, 4, 4, 4, 3, 0);
game.moveByPlayerPit(P1, 5);
assertArrayEquals(new int[] { 3, 4, 4, 4, 4, 1, 5, 0, 4, 4, 4, 4, 3, 0 }, game.getBoard().getStoneArray());
assertEquals(List.of(move(5), move(6), move(7), new GameStep(7, GRDoubleStoneInPit.GAME_STEP_DOUBLE_STONE_IN_PIT,
new GRDoubleStoneInPit.DoubleStoneInPitData(7, 6))), game.getHistory().getFirst().getGameSteps());
game.moveByPlayerPit(P2, 5);
assertArrayEquals(new int[] { 0, 4, 4, 4, 4, 1, 5, 0, 4, 4, 4, 4, 1, 5 }, game.getBoard().getStoneArray());
assertEquals(List.of(move(12), move(13), move(0), new GameStep(0, GRDoubleStoneInPit.GAME_STEP_DOUBLE_STONE_IN_PIT,
new GRDoubleStoneInPit.DoubleStoneInPitData(0, 13))), game.getHistory().getLast().getGameSteps());
}
@Test
void clearBoardAtEndGivesTheRemainingStonesToThePlayerWhoseSideIsEmpty() {
MancalaGame game = createGame();
setStones(game, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0);
game.moveByPlayerPit(P1, 5);
assertArrayEquals(new int[] { 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0 }, game.getBoard().getStoneArray());
assertEquals(GameState.ENDED, game.getState());
assertEquals(List.of(move(6), new GameStep(6, GRLastStoneInBank.GAME_STEP_LAST_STONE_IN_BANK),
new GameStep(6, GRClearBoardAtEnd.GAME_STEP_BOARD_CLEARED,
new GRClearBoardAtEnd.ClearBoardAtEndData(List.of(12)))),
game.getHistory().getFirst().getGameSteps());
assertEquals(P1, game.getWonPlayerId());
// Moves after the end are ignored.
game.moveByPlayerPit(P2, 0);
assertEquals(1, game.getHistory().size());
}
@Test
void wonPlayerId() {
MancalaGame game = createGame();
assertNull(game.getWonPlayerId());
setStones(game, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0);
assertEquals(P1, game.getWonPlayerId());
setStones(game, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1);
assertEquals(P2, game.getWonPlayerId());
setStones(game, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
assertNull(game.getWonPlayerId());
}
@Test
// Same properties, in the same order, as JSON.stringify of a TypeScript MoveHistoryItem; null data is kept.
void historyJson() {
MancalaGame game = createGame();
game.moveByPlayerPit(P1, 0);
String json = Json.write(game.getHistory().getFirst());
assertTrue(json.startsWith("{\"boardSnapshot\":[1,5,5,5,4,4,0,4,4,4,4,4,4,0],\"gameSteps\":[{\"index\":0,\"type\":\"GAME_STEP_GAME_MOVE\",\"data\":null}"), json);
assertTrue(json.endsWith("\"playerId\":\"0\",\"moveIndex\":0}"), json);
MoveHistoryItem read = Json.MAPPER.convertValue(Json.tree(game.getHistory().getFirst()), MoveHistoryItem.class);
assertEquals(Json.tree(game.getHistory().getFirst()), Json.tree(read));
}
}

File diff suppressed because one or more lines are too long

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. **Project and engine**: Maven project in `apps/backend-quarkus` (Quarkus 3.40, Java 25); the engine port
- [x] 2. **Project and engine**: Maven project in `apps/backend-quarkus` (Quarkus 3.40, Java 25); the engine port
with unit tests; `scripts/record-engine-games.js` and the parity test (R2).
- [ ] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`,
`ManualScheduler`; `KeycloakTokenVerifier` with unit tests.