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:
parent
ec833b4ec5
commit
f0ffd6ba87
9
apps/backend-quarkus/.gitignore
vendored
Normal file
9
apps/backend-quarkus/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
target/
|
||||||
|
.idea
|
||||||
|
*.iml
|
||||||
|
.vscode
|
||||||
|
.factorypath
|
||||||
|
|
||||||
|
# Written at runtime
|
||||||
|
access*.log
|
||||||
|
crashes/
|
||||||
4
apps/backend-quarkus/.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
4
apps/backend-quarkus/.mvn/wrapper/maven-wrapper.properties
vendored
Normal 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
295
apps/backend-quarkus/mvnw
vendored
Executable 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
189
apps/backend-quarkus/mvnw.cmd
vendored
Normal 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"
|
||||||
108
apps/backend-quarkus/pom.xml
Normal file
108
apps/backend-quarkus/pom.xml
Normal 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>
|
||||||
61
apps/backend-quarkus/scripts/record-engine-games.js
Normal file
61
apps/backend-quarkus/scripts/record-engine-games.js
Normal 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)}`);
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
@ -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. **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).
|
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`,
|
- [ ] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`,
|
||||||
`ManualScheduler`; `KeycloakTokenVerifier` with unit tests.
|
`ManualScheduler`; `KeycloakTokenVerifier` with unit tests.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user