feat(ignis): establish durable progression foundations
This commit is contained in:
commit
7a481f366c
27 files changed
+1213
No files matched your search
@@ -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
|
||||
@@ -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
@@ -0,0 +1,10 @@
|
||||
.gradle/
|
||||
build/
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
server/
|
||||
*.log
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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) }
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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
@@ -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%
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user