feat(ignis): establish durable progression foundations
CI / build (push) Successful in 3m10s
Release / release (push) Failing after 3m50s

This commit is contained in:
dmg committed 2026-09-10 20:15:11 -04:00
commit 7a481f366c
27 files changed
+1213

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
name: CI
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 25
cache: gradle
- name: Validate conventional commits
if: github.event_name == 'pull_request'
run: |
npx -y \
-p @commitlint/cli \
-p @commitlint/config-conventional \
commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.sha }}" \
--extends @commitlint/config-conventional
- name: Build and test
run: ./gradlew clean check jar
- name: Upload development artifact
uses: actions/upload-artifact@v3
with:
name: spigot-ignis-${{ github.sha }}
path: build/libs/*.jar
if-no-files-found: error
+137
View File
@@ -0,0 +1,137 @@
name: Release
on:
push:
branches: [main]
concurrency:
group: ignis-release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
persist-credentials: false
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 25
cache: gradle
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Build and test before release
run: ./gradlew clean check
- name: Resolve semantic version and tag
id: release
env:
IGNIS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
test -n "$IGNIS_RELEASE_TOKEN" || { echo 'RELEASE_TOKEN is required'; exit 1; }
test "$GITEA_SERVER_URL" = 'https://git.garvis.dev'
test "$REPOSITORY" = 'dmg/spigot-ignis'
# Token stays in environment/stdout to Git's protected askpass pipe, not a URL or argv.
askpass=$(mktemp)
trap 'rm -f "$askpass"' EXIT
chmod 700 "$askpass"
cat > "$askpass" <<'SH'
#!/bin/sh
case "$1" in
*Username*) printf '%s\n' 'x-access-token' ;;
*Password*) printf '%s\n' "$IGNIS_RELEASE_TOKEN" ;;
*) exit 1 ;;
esac
SH
export GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0
git config credential.helper ''
git remote set-url origin "${GITEA_SERVER_URL}/${REPOSITORY}.git"
previous=$(git describe --tags --abbrev=0 2>/dev/null || true)
npx -y \
-p semantic-release@24.2.9 \
-p @semantic-release/commit-analyzer@13.0.1 \
-p @semantic-release/release-notes-generator@14.1.0 \
semantic-release --branches main \
--repository-url "${GITEA_SERVER_URL}/${REPOSITORY}.git" \
--plugins @semantic-release/commit-analyzer,@semantic-release/release-notes-generator
current=$(git describe --tags --exact-match HEAD 2>/dev/null || true)
if [ -n "$current" ] && [ "$current" != "$previous" ]; then
[[ "$current" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
echo 'created=true' >> "$GITHUB_OUTPUT"
echo "tag=$current" >> "$GITHUB_OUTPUT"
echo "version=${current#v}" >> "$GITHUB_OUTPUT"
echo "previous=$previous" >> "$GITHUB_OUTPUT"
else
echo 'created=false' >> "$GITHUB_OUTPUT"
fi
- name: Build versioned plugin
if: steps.release.outputs.created == 'true'
env:
VERSION: ${{ steps.release.outputs.version }}
run: ./gradlew clean jar -PreleaseVersion="$VERSION"
- name: Upload release workflow artifact
if: steps.release.outputs.created == 'true'
uses: actions/upload-artifact@v3
with:
name: spigot-ignis-${{ steps.release.outputs.version }}
path: build/libs/spigot-ignis-${{ steps.release.outputs.version }}.jar
if-no-files-found: error
- name: Publish versioned Gitea asset
if: steps.release.outputs.created == 'true'
env:
IGNIS_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
TAG: ${{ steps.release.outputs.tag }}
VERSION: ${{ steps.release.outputs.version }}
PREVIOUS_TAG: ${{ steps.release.outputs.previous }}
shell: bash
run: |
set -euo pipefail
test "$GITEA_SERVER_URL" = 'https://git.garvis.dev'
test "$REPOSITORY" = 'dmg/spigot-ignis'
api_url="${GITEA_SERVER_URL}/api/v1/repos/${REPOSITORY}/releases"
jar="build/libs/spigot-ignis-${VERSION}.jar"
payload=$(mktemp)
response=$(mktemp)
trap 'rm -f "$payload" "$response"' EXIT
export RELEASE_BODY
if [ -n "$PREVIOUS_TAG" ]; then
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
else
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' HEAD)
fi
node -e 'process.stdout.write(JSON.stringify({tag_name: process.env.TAG, name: process.env.TAG,
body: process.env.RELEASE_BODY, draft: false, prerelease: false}))' > "$payload"
# printf is a shell builtin. Curl reads the authorization header from stdin, never argv.
printf 'Authorization: token %s\n' "$IGNIS_RELEASE_TOKEN" | \
curl --fail --silent --show-error --max-time 60 --proto '=https' \
--header @- --header 'Content-Type: application/json' \
--data-binary "@$payload" --output "$response" "$api_url"
release_id=$(node -e 'const fs=require("node:fs"); const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
if(!Number.isSafeInteger(r.id)||r.id<=0) process.exit(1); process.stdout.write(String(r.id));' "$response")
printf 'Authorization: token %s\n' "$IGNIS_RELEASE_TOKEN" | \
curl --fail --silent --show-error --max-time 120 --proto '=https' \
--header @- --header 'Content-Type: application/octet-stream' \
--data-binary "@$jar" --output /dev/null \
"${api_url}/${release_id}/assets?name=spigot-ignis-${VERSION}.jar"
+10
View File
@@ -0,0 +1,10 @@
.gradle/
build/
.idea/
*.iml
.DS_Store
.env
.env.*
!.env.example
server/
*.log
+9
View File
@@ -0,0 +1,9 @@
# Spigot Ignis agent entrypoint
The canonical knowledge bundle is the sibling `../somc-okf/` repository, remotely at https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md.
Before changes read its root index, `processes/development.md`, `runbooks/development-cycle.md`, `projects/spigot-ignis/index.md`, `projects/spigot-ignis/engineering.md`, and the relevant `user-stories/spigot-ignis/` documents. Follow the parent workspace instructions. Do not create a local knowledge/design bundle.
Use Java 25 and the declared Purpur API. Test behavior first with meaningful failures, implement minimally, then run `./gradlew clean check jar`. Never mark server/client behavior verified through domain tests alone.
Source/wiki publication, local-only GitOps preparation, and explicitly approved production release are separate boundaries. Never push GitOps during development. Do not expose credentials or place them in process arguments. Repository creation does not authorize credential provisioning.
+42
View File
@@ -0,0 +1,42 @@
# Spigot Ignis
Server-wide earned shadow progression for Social Minecraft. **Foundation work only; no player abilities or commands are implemented yet. Do not deploy this foundation as a gameplay release.**
## Build
Requires Java 17+ to launch Gradle; the wrapper resolves a Java 25 toolchain for compilation/testing. Toolchain downloads require network access when no compatible cache is available.
```sh
./gradlew clean check jar
./gradlew clean jar -PreleaseVersion=0.1.0
```
Targets Purpur `26.2.build.2618-stable`. The API is not bundled. Source builds do not require the private wiki.
## Knowledge and delivery
The [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-ignis/index.md) and stories live in the shared private SoMC wiki. There is no repository-local knowledge bundle.
CI/release workflows are scaffolded but not remotely verified. Release publication requires the repository Actions secret `RELEASE_TOKEN`, securely provisioned separately before the first main push. The release workflow uses Git askpass and curl stdin for authentication rather than embedding a release credential in URLs or process arguments. Never enable tracing. GitOps and production release need separate approvals.
## Foundation interfaces and state
- `StateService.open(repository)` loads on a dedicated persistence worker and completes only when ready. Failed/corrupt loads do not substitute empty state. No caller may use gameplay state before successful initialization.
- `snapshot()` provides an immutable, last-successfully-saved view. `update(pureChange)` serializes mutations, saves first, then publishes/acknowledges. Mutation functions run off-thread and must not access Bukkit or perform gameplay side effects. Marshal downstream gameplay work back to the server thread.
- `configure(invisibilityMillis, pearls, darknessMillis)` validates positive thresholds and atomically saves limits with resulting unlocks. Defaults are 43,200,000 ms (12 h), 313 pearls, and 21,600,000 ms (6 h). Lower limits unlock satisfied tracks during evaluation; higher limits do not revoke unlocks. VOID is objective-based and is excluded from numeric threshold grants.
- UUID state includes per-track progress, unlock/activation flags, invalidation generations, and pending reward identifiers. This is storage infrastructure, **not implemented reward delivery, item revocation, or challenge tracking**. Issuance/transfer provenance remains a later design decision.
- `close()` rejects new mutations and lets already queued writes drain without blocking the caller. Hard process termination can lose unacknowledged updates. The worker does not force Bukkit/world access from its thread.
The foundation bootstrap reads only `plugins/SpigotIgnis/state.bin`; it does not read or migrate Stealth data. No commands, crafting, reset handlers, event counters, restart challenge resets, or rendering hooks are registered. Runtime settings currently have a Java service API only, not a config-file editor or admin command.
### Recovery and format
`state.bin` is a versioned binary data format (not Java object serialization): magic/version, positive thresholds, UUID player maps, named track states, and UUID pending rewards. Invalid versions, counts, duplicate keys, malformed flags, negative counters, truncated input, and trailing bytes are rejected. Record counts are bounded at 100,000 players and 100,000 pending rewards per player.
Writes use a same-directory temporary file, flush it to disk, and require atomic replacement. Unsupported atomic moves or write failures retain the prior active snapshot and fail the mutation; there is no non-atomic fallback. Abandoned temporary files are ignored, never auto-promoted. This does not guarantee survival of arbitrary storage hardware or power failures.
If loading fails, preserve the original file for investigation. Under an approved maintenance plan, stop the server, back up current data, restore a verified matching-version backup, and restart. Do not delete state to recover: that loses earned progress and invalidation records. Do not hand-edit the binary file or import another plugin's state. Restoring an old backup can also restore older revocation/progress state; review that consequence before doing it.
## Verification limits
JUnit tests exercise real temporary-file persistence, serialized writes, fault injection, threshold policies, metadata, and static workflow guardrails. YAML/shell parsing is not remote CI verification. No Purpur startup/shutdown, live gameplay, client presentation, command registration, or actual release publishing has been tested yet.
+28
View File
@@ -0,0 +1,28 @@
plugins { java }
group = "games.dmg"
version = providers.gradleProperty("releaseVersion").orElse("0.1.0-SNAPSHOT").get()
repositories {
mavenCentral()
maven("https://repo.purpurmc.org/snapshots/")
}
java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
}
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test { useJUnitPlatform() }
val pluginVersion = version
tasks.processResources {
filesMatching("plugin.yml") { expand("version" to pluginVersion) }
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed 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
#
# https://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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
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
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+2
View File
@@ -0,0 +1,2 @@
plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" }
rootProject.name = "spigot-ignis"
@@ -0,0 +1,125 @@
package games.dmg.spigotignis;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/** Versioned data, never Java object deserialization. Call from the persistence worker only. */
public final class FileStateRepository implements StateRepository {
private static final int MAGIC = 0x49474e49;
private static final int VERSION = 1;
private static final int MAX_RECORDS = 100_000;
private final Path path;
public FileStateRepository(Path path) { this.path = path.toAbsolutePath(); }
@Override
public IgnisState load() throws IOException {
if (Files.notExists(path)) return IgnisState.empty();
try (var input = new DataInputStream(new BufferedInputStream(Files.newInputStream(path)))) {
if (input.readInt() != MAGIC || input.readInt() != VERSION) {
throw new IOException("Unsupported Ignis state header or version.");
}
var limits = new ProgressLimits(input.readLong(), input.readLong(), input.readLong());
Map<UUID, PlayerState> players = new HashMap<>();
int count = count(input, MAX_RECORDS);
for (int i = 0; i < count; i++) {
UUID id = uuid(input);
Map<Track, TrackState> tracks = new HashMap<>();
int trackCount = count(input, Track.values().length);
for (int j = 0; j < trackCount; j++) {
Track track = Track.valueOf(input.readUTF());
var state = new TrackState(input.readLong(), flag(input),
flag(input), input.readLong());
if (tracks.putIfAbsent(track, state) != null) throw new IOException("Duplicate track.");
}
Map<UUID, Track> pending = new HashMap<>();
int rewards = count(input, MAX_RECORDS);
for (int j = 0; j < rewards; j++) {
if (pending.putIfAbsent(uuid(input), Track.valueOf(input.readUTF())) != null) {
throw new IOException("Duplicate pending reward.");
}
}
if (players.putIfAbsent(id, new PlayerState(tracks, pending)) != null) {
throw new IOException("Duplicate player.");
}
}
if (input.read() != -1) throw new IOException("Trailing Ignis state data.");
return new IgnisState(limits, players);
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid Ignis state; original file retained.", exception);
}
}
@Override
public void save(IgnisState state) throws IOException {
if (state.players().size() > MAX_RECORDS
|| state.players().values().stream().anyMatch(p -> p.pendingRewards().size() > MAX_RECORDS)) {
throw new IOException("Ignis state exceeds supported record count.");
}
Files.createDirectories(path.getParent());
Path temporary = Files.createTempFile(path.getParent(), ".ignis-", ".tmp");
try {
try (var output = new DataOutputStream(new BufferedOutputStream(Files.newOutputStream(temporary)))) {
output.writeInt(MAGIC);
output.writeInt(VERSION);
output.writeLong(state.limits().invisibilityMillis());
output.writeLong(state.limits().pearls());
output.writeLong(state.limits().darknessMillis());
output.writeInt(state.players().size());
for (var entry : state.players().entrySet()) {
uuid(output, entry.getKey());
output.writeInt(entry.getValue().tracks().size());
for (var track : entry.getValue().tracks().entrySet()) {
output.writeUTF(track.getKey().name());
output.writeLong(track.getValue().progress());
output.writeBoolean(track.getValue().unlocked());
output.writeBoolean(track.getValue().activated());
output.writeLong(track.getValue().generation());
}
output.writeInt(entry.getValue().pendingRewards().size());
for (var reward : entry.getValue().pendingRewards().entrySet()) {
uuid(output, reward.getKey());
output.writeUTF(reward.getValue().name());
}
}
}
try (var file = FileChannel.open(temporary, StandardOpenOption.WRITE)) { file.force(true); }
// Deliberately fail rather than fall back to a partial non-atomic replacement.
Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(temporary);
}
}
private static boolean flag(DataInputStream input) throws IOException {
int value = input.readUnsignedByte();
if (value > 1) throw new IOException("Invalid state flag.");
return value == 1;
}
private static int count(DataInputStream input, int maximum) throws IOException {
int result = input.readInt();
if (result < 0 || result > maximum) throw new IOException("Invalid record count.");
return result;
}
private static UUID uuid(DataInputStream input) throws IOException {
return new UUID(input.readLong(), input.readLong());
}
private static void uuid(DataOutputStream output, UUID id) throws IOException {
output.writeLong(id.getMostSignificantBits());
output.writeLong(id.getLeastSignificantBits());
}
}
@@ -0,0 +1,39 @@
package games.dmg.spigotignis;
import java.util.Map;
import java.util.HashMap;
import java.util.Objects;
import java.util.UUID;
public record IgnisState(ProgressLimits limits, Map<UUID, PlayerState> players) {
public IgnisState {
Objects.requireNonNull(limits);
players = Map.copyOf(players);
}
public static IgnisState empty() { return new IgnisState(ProgressLimits.defaults(), Map.of()); }
public IgnisState evaluate(ProgressLimits nextLimits) {
Objects.requireNonNull(nextLimits);
Map<UUID, PlayerState> evaluated = new HashMap<>();
players.forEach((id, player) -> {
Map<Track, TrackState> tracks = new HashMap<>(player.tracks());
for (Track track : Track.values()) {
if (track == Track.VOID) continue;
TrackState previous = player.track(track);
long threshold = switch (track) {
case MASK -> nextLimits.invisibilityMillis();
case DAGGER -> nextLimits.pearls();
case HEART -> nextLimits.darknessMillis();
case VOID -> throw new IllegalStateException("Void is objective-based.");
};
if (previous.progress() >= threshold && !previous.unlocked()) {
tracks.put(track, new TrackState(previous.progress(), true,
previous.activated(), previous.generation()));
}
}
evaluated.put(id, new PlayerState(tracks, player.pendingRewards()));
});
return new IgnisState(nextLimits, evaluated);
}
}
@@ -0,0 +1,15 @@
package games.dmg.spigotignis;
import java.util.Map;
import java.util.UUID;
/** Pending reward identifiers are durable; gameplay delivery is a later adapter. */
public record PlayerState(Map<Track, TrackState> tracks, Map<UUID, Track> pendingRewards) {
public PlayerState {
tracks = Map.copyOf(tracks);
pendingRewards = Map.copyOf(pendingRewards);
}
public static PlayerState empty() { return new PlayerState(Map.of(), Map.of()); }
public TrackState track(Track track) { return tracks.getOrDefault(track, TrackState.empty()); }
}
@@ -0,0 +1,14 @@
package games.dmg.spigotignis;
/** Thresholds use milliseconds for online time and units for consumed pearls. */
public record ProgressLimits(long invisibilityMillis, long pearls, long darknessMillis) {
public ProgressLimits {
if (invisibilityMillis <= 0 || pearls <= 0 || darknessMillis <= 0) {
throw new IllegalArgumentException("All progression thresholds must be positive.");
}
}
public static ProgressLimits defaults() {
return new ProgressLimits(12 * 60 * 60 * 1000L, 313, 6 * 60 * 60 * 1000L);
}
}
@@ -0,0 +1,26 @@
package games.dmg.spigotignis;
import java.util.concurrent.CompletableFuture;
import org.bukkit.plugin.java.JavaPlugin;
/** Foundation bootstrap only: no gameplay listeners or player/admin commands are registered. */
public final class SpigotIgnisPlugin extends JavaPlugin {
private CompletableFuture<StateService> initialization;
@Override
public void onEnable() {
initialization = StateService.open(new FileStateRepository(getDataFolder().toPath().resolve("state.bin")));
initialization.whenComplete((service, error) -> {
if (error != null) {
getLogger().severe("Ignis state unavailable; original data retained. Restore a verified backup before retrying.");
} else {
getLogger().info("Ignis foundation loaded. No gameplay abilities or commands are enabled.");
}
});
}
@Override
public void onDisable() {
if (initialization != null) initialization.thenAccept(StateService::close);
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigotignis;
import java.io.IOException;
public interface StateRepository {
IgnisState load() throws IOException;
void save(IgnisState state) throws IOException;
}
@@ -0,0 +1,64 @@
package games.dmg.spigotignis;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.function.UnaryOperator;
/** Serialized persistence: acknowledge and publish only after a successful atomic save. */
public final class StateService implements AutoCloseable {
private final StateRepository repository;
private final ExecutorService worker;
private volatile IgnisState current;
private StateService(StateRepository repository, ExecutorService worker, IgnisState current) {
this.repository = repository;
this.worker = worker;
this.current = current;
}
public static CompletableFuture<StateService> open(StateRepository repository) {
Objects.requireNonNull(repository);
ExecutorService worker = Executors.newSingleThreadExecutor(task -> new Thread(task, "ignis-persistence"));
return CompletableFuture.supplyAsync(() -> {
try {
return new StateService(repository, worker, repository.load());
} catch (IOException | RuntimeException exception) {
worker.shutdown();
throw new java.util.concurrent.CompletionException(exception);
}
}, worker);
}
public IgnisState snapshot() { return current; }
public CompletableFuture<IgnisState> configure(long invisibilityMillis, long pearls, long darknessMillis) {
return update(state -> state.evaluate(new ProgressLimits(invisibilityMillis, pearls, darknessMillis)));
}
/** Changes must be pure and must not access Bukkit or cause gameplay side effects. */
public CompletableFuture<IgnisState> update(UnaryOperator<IgnisState> change) {
Objects.requireNonNull(change);
try {
return CompletableFuture.supplyAsync(() -> {
IgnisState next = Objects.requireNonNull(change.apply(current));
try {
repository.save(next);
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
current = next;
return next;
}, worker);
} catch (RejectedExecutionException exception) {
return CompletableFuture.failedFuture(exception);
}
}
/** Non-blocking; already queued mutations finish, and further mutations are rejected. */
@Override public void close() { worker.shutdown(); }
}
@@ -0,0 +1,3 @@
package games.dmg.spigotignis;
public enum Track { VOID, MASK, DAGGER, HEART }
@@ -0,0 +1,12 @@
package games.dmg.spigotignis;
/** Immutable durable foundation; time counters are online milliseconds. */
public record TrackState(long progress, boolean unlocked, boolean activated, long generation) {
public TrackState {
if (progress < 0 || generation < 0 || activated && !unlocked) {
throw new IllegalArgumentException("Invalid progression state.");
}
}
public static TrackState empty() { return new TrackState(0, false, false, 0); }
}
+5
View File
@@ -0,0 +1,5 @@
name: SpigotIgnis
version: '${version}'
main: games.dmg.spigotignis.SpigotIgnisPlugin
api-version: '1.21'
description: Ignis progression foundation; gameplay is not implemented yet.
@@ -0,0 +1,80 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class FileStateRepositoryTest {
@TempDir Path directory;
@Test
void missingStateStartsEmptyAndInterruptedTemporaryFilesAreNeverPromoted() throws Exception {
java.nio.file.Files.writeString(directory.resolve(".ignis-interrupted.tmp"), "incomplete");
assertEquals(IgnisState.empty(), new FileStateRepository(directory.resolve("state.bin")).load());
}
@Test
void malformedTruncatedFutureVersionAndTrailingDataAreRejectedWithoutRewriting() throws Exception {
Path path = directory.resolve("state.bin");
var repository = new FileStateRepository(path);
repository.save(IgnisState.empty());
byte[] valid = java.nio.file.Files.readAllBytes(path);
byte[] future = valid.clone();
future[7] = 2;
byte[] negativeCount = valid.clone();
java.util.Arrays.fill(negativeCount, 32, 36, (byte) 0xff);
for (byte[] invalid : java.util.List.of(new byte[0], java.util.Arrays.copyOf(valid, 12),
future, negativeCount, java.util.Arrays.copyOf(valid, valid.length + 1))) {
java.nio.file.Files.write(path, invalid);
assertThrows(java.io.IOException.class, repository::load);
assertArrayEquals(invalid, java.nio.file.Files.readAllBytes(path));
}
}
@Test
void malformedBooleanCannotSilentlyGrantAnUnlock() throws Exception {
Path path = directory.resolve("state.bin");
var repository = new FileStateRepository(path);
repository.save(new IgnisState(ProgressLimits.defaults(), Map.of(UUID.randomUUID(),
new PlayerState(Map.of(Track.MASK, TrackState.empty()), Map.of()))));
byte[] bytes = java.nio.file.Files.readAllBytes(path);
// Locate the schema's MASK tag, then corrupt the unlocked flag after the 8-byte counter.
byte[] marker = "MASK".getBytes(java.nio.charset.StandardCharsets.UTF_8);
int position = -1;
for (int i = 0; i <= bytes.length - marker.length; i++) {
if (java.util.Arrays.equals(marker, java.util.Arrays.copyOfRange(bytes, i, i + marker.length))) {
position = i + marker.length + Long.BYTES;
break;
}
}
assertTrue(position > 0);
bytes[position] = 2;
java.nio.file.Files.write(path, bytes);
assertThrows(java.io.IOException.class, repository::load);
}
@Test
void invalidDestinationFailsWithoutLeavingTemporaryFiles() throws Exception {
Path destination = directory.resolve("state.bin");
java.nio.file.Files.createDirectory(destination);
java.nio.file.Files.writeString(destination.resolve("preserve"), "unchanged");
assertThrows(java.io.IOException.class, () -> new FileStateRepository(destination).save(IgnisState.empty()));
assertEquals("unchanged", java.nio.file.Files.readString(destination.resolve("preserve")));
try (var files = java.nio.file.Files.list(directory)) { assertEquals(1, files.count()); }
}
@Test
void progressActivationPendingRewardsAndInvalidationSurviveRepositoryReopen() throws Exception {
var path = directory.resolve("ignis/state.bin");
var expected = new IgnisState(new ProgressLimits(120, 313, 60), Map.of(UUID.randomUUID(),
new PlayerState(Map.of(
Track.VOID, new TrackState(1, true, true, 8),
Track.MASK, new TrackState(119, false, false, 3)),
Map.of(UUID.randomUUID(), Track.HEART))));
new FileStateRepository(path).save(expected);
assertEquals(expected, new FileStateRepository(path).load());
}
}
@@ -0,0 +1,21 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class PluginPackagingTest {
@Test
void packagedDescriptorHasExpandedVersionAndCorrectEntrypoint() throws Exception {
try (var input = getClass().getResourceAsStream("/plugin.yml")) {
assertNotNull(input, "A deployable plugin requires a descriptor");
String descriptor = new String(input.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(descriptor.contains("name: SpigotIgnis"));
assertTrue(descriptor.contains("main: games.dmg.spigotignis.SpigotIgnisPlugin"));
assertFalse(descriptor.contains("${version}"));
assertTrue(org.bukkit.plugin.java.JavaPlugin.class.isAssignableFrom(
Class.forName("games.dmg.spigotignis.SpigotIgnisPlugin", false, getClass().getClassLoader())));
assertFalse(descriptor.contains("commands:"), "Do not advertise unimplemented commands");
}
}
}
@@ -0,0 +1,21 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class ProgressLimitsTest {
@Test
void rejectsNonPositiveThresholdsBeforeTheyCanBecomeActive() {
assertThrows(IllegalArgumentException.class, () -> new ProgressLimits(0, 313, 1));
assertThrows(IllegalArgumentException.class, () -> new ProgressLimits(1, -1, 1));
assertThrows(IllegalArgumentException.class, () -> new ProgressLimits(1, 1, 0));
}
@Test
void defaultsUseApprovedTwelveHour313PearlAndSixHourThresholds() {
assertEquals(new ProgressLimits(Duration.ofHours(12).toMillis(), 313,
Duration.ofHours(6).toMillis()), ProgressLimits.defaults());
}
}
@@ -0,0 +1,23 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
/** Static guardrails, not proof of remote CI or credential validity. */
class ReleaseWorkflowTest {
@Test
void releaseScaffoldUsesVersionedAssetsAndProtectedCredentialInput() throws Exception {
Path path = Path.of(".gitea/workflows/release.yml");
assertTrue(Files.exists(path), "Release workflow must exist");
String workflow = Files.readString(path);
assertTrue(workflow.contains("spigot-ignis-${VERSION}.jar"));
assertTrue(workflow.contains("--header @-"));
assertTrue(workflow.contains("GIT_ASKPASS"));
assertTrue(workflow.contains("persist-credentials: false"));
assertFalse(workflow.contains("-H \"Authorization:"));
assertFalse(workflow.contains("GITHUB_TOKEN:"), "Do not pass a token for semantic-release to embed into git URLs");
assertFalse(workflow.contains("spigot-base"));
}
}
@@ -0,0 +1,101 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class StateServiceTest {
@TempDir Path directory;
@Test
void failedWriteLeavesPreviousSnapshotAndAllowsRetryOffCallingThread() throws Exception {
var disk = new FileStateRepository(directory.resolve("state.bin"));
var fail = new java.util.concurrent.atomic.AtomicBoolean(true);
Thread caller = Thread.currentThread();
StateRepository boundary = new StateRepository() {
@Override public IgnisState load() throws java.io.IOException {
assertNotSame(caller, Thread.currentThread());
return disk.load();
}
@Override public void save(IgnisState state) throws java.io.IOException {
assertNotSame(caller, Thread.currentThread());
if (fail.get()) throw new java.io.IOException("Simulated disk failure");
disk.save(state);
}
};
try (var service = StateService.open(boundary).get(3, TimeUnit.SECONDS)) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> service.configure(100, 200, 300).get(3, TimeUnit.SECONDS));
assertEquals(IgnisState.empty(), service.snapshot());
assertEquals(IgnisState.empty(), disk.load());
fail.set(false);
service.configure(100, 200, 300).get(3, TimeUnit.SECONDS);
assertEquals(service.snapshot(), disk.load());
}
}
@Test
void concurrentQueuedUpdatesDoNotLoseProgressAndCloseRejectsNewWrites() throws Exception {
UUID id = UUID.randomUUID();
var repository = new FileStateRepository(directory.resolve("state.bin"));
var service = StateService.open(repository).get(3, TimeUnit.SECONDS);
try (service) {
var writes = new java.util.ArrayList<java.util.concurrent.CompletableFuture<IgnisState>>();
for (int i = 0; i < 30; i++) {
writes.add(service.update(state -> {
var player = state.players().getOrDefault(id, PlayerState.empty());
long count = player.track(Track.DAGGER).progress();
return new IgnisState(state.limits(), Map.of(id, new PlayerState(
Map.of(Track.DAGGER, new TrackState(count + 1, false, false, 0)), Map.of())));
}));
}
java.util.concurrent.CompletableFuture.allOf(writes.toArray(java.util.concurrent.CompletableFuture[]::new))
.get(5, TimeUnit.SECONDS);
assertEquals(30, repository.load().players().get(id).track(Track.DAGGER).progress());
}
assertThrows(java.util.concurrent.ExecutionException.class,
() -> service.configure(1, 1, 1).get(3, TimeUnit.SECONDS));
}
@Test
void malformedExistingStateFailsInitializationInsteadOfGrantingBlankState() throws Exception {
Path path = directory.resolve("state.bin");
java.nio.file.Files.writeString(path, "broken");
assertThrows(java.util.concurrent.ExecutionException.class,
() -> StateService.open(new FileStateRepository(path)).get(3, TimeUnit.SECONDS));
assertEquals("broken", java.nio.file.Files.readString(path));
}
@Test
void configurationValidatesBeforeChangingAnyDurableOrActiveSetting() throws Exception {
var repository = new FileStateRepository(directory.resolve("state.bin"));
try (var service = StateService.open(repository).get(3, TimeUnit.SECONDS)) {
assertThrows(java.util.concurrent.ExecutionException.class,
() -> service.configure(100, 0, 100).get(3, TimeUnit.SECONDS));
assertEquals(ProgressLimits.defaults(), service.snapshot().limits());
var limits = new ProgressLimits(100, 200, 300);
service.configure(100, 200, 300).get(3, TimeUnit.SECONDS);
assertEquals(limits, service.snapshot().limits());
assertEquals(limits, repository.load().limits());
}
}
@Test
void acknowledgedUpdatesAreDurableAcrossReopen() throws Exception {
var repository = new FileStateRepository(directory.resolve("state.bin"));
UUID id = UUID.randomUUID();
var expected = new IgnisState(ProgressLimits.defaults(), Map.of(id,
new PlayerState(Map.of(Track.DAGGER, new TrackState(12, false, false, 0)), Map.of())));
try (var service = StateService.open(repository).get(3, TimeUnit.SECONDS)) {
assertEquals(expected, service.update(ignored -> expected).get(3, TimeUnit.SECONDS));
assertEquals(expected, service.snapshot());
}
try (var reopened = StateService.open(repository).get(3, TimeUnit.SECONDS)) {
assertEquals(expected, reopened.snapshot());
}
}
}
@@ -0,0 +1,51 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class ThresholdEvaluationTest {
@Test
void evaluationIsIdempotentAndHigherLimitsDoNotRevokeEarnedUnlocks() {
UUID id = UUID.randomUUID();
var state = new IgnisState(new ProgressLimits(1, 1, 1), Map.of(id,
new PlayerState(Map.of(
Track.MASK, new TrackState(1, true, true, 5),
Track.DAGGER, new TrackState(312, false, false, 0),
Track.HEART, new TrackState(100, false, false, 0),
Track.VOID, new TrackState(100, false, false, 0)), Map.of())));
var next = state.evaluate(new ProgressLimits(200, 313, 100));
assertTrue(next.players().get(id).track(Track.MASK).activated());
assertFalse(next.players().get(id).track(Track.DAGGER).unlocked());
assertTrue(next.players().get(id).track(Track.HEART).unlocked());
assertFalse(next.players().get(id).track(Track.VOID).unlocked());
assertEquals(next, next.evaluate(next.limits()));
}
@Test
void negativeStateAndMutableCallerCollectionsCannotCorruptSnapshots() {
assertThrows(IllegalArgumentException.class, () -> new TrackState(-1, false, false, 0));
assertThrows(IllegalArgumentException.class, () -> new TrackState(0, false, true, 0));
assertThrows(IllegalArgumentException.class, () -> new TrackState(0, false, false, -1));
var tracks = new java.util.HashMap<Track, TrackState>();
tracks.put(Track.MASK, TrackState.empty());
var player = new PlayerState(tracks, Map.of());
tracks.clear();
assertEquals(1, player.tracks().size());
assertThrows(UnsupportedOperationException.class, () -> player.tracks().clear());
}
@Test
void loweredThresholdUnlocksOnEvaluationWithoutChangingProgressOrRewards() {
UUID player = UUID.randomUUID();
UUID reward = UUID.randomUUID();
var before = new IgnisState(ProgressLimits.defaults(), Map.of(player,
new PlayerState(Map.of(Track.MASK, new TrackState(100, false, false, 2)),
Map.of(reward, Track.VOID))));
var after = before.evaluate(new ProgressLimits(100, 313, 1000));
assertEquals(new TrackState(100, true, false, 2), after.players().get(player).track(Track.MASK));
assertEquals(before.players().get(player).pendingRewards(), after.players().get(player).pendingRewards());
assertFalse(before.players().get(player).track(Track.MASK).unlocked());
}
}