Compare commits

..
3 Commits
Author SHA1 Message Date
dmg d604dca129 feat(state): add validated persistent game state
Release / release (push) Successful in 2m13s
CI / build (push) Successful in 53s
2026-08-14 22:29:49 -04:00
dmg 7bfdf01d10 build(project): scaffold plugin delivery
CI / build (push) Successful in 3m20s
Release / release (push) Successful in 4m10s
2026-08-14 22:20:22 -04:00
dmg 235cdce071 docs(agent): add repository guidance 2026-08-14 22:15:12 -04:00
37 changed files with 2055 additions and 20 deletions
+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: 17
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-tyrant-${{ github.sha }}
path: build/libs/*.jar
if-no-files-found: error
+141
View File
@@ -0,0 +1,141 @@
name: Release
on:
push:
branches:
- main
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
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: Capture previous tag
id: previous_tag
run: |
if git describe --tags --abbrev=0 >/dev/null 2>&1; then
echo "value=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT"
else
echo "value=" >> "$GITHUB_OUTPUT"
fi
- name: Run semantic-release
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
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 \
--plugins @semantic-release/commit-analyzer,@semantic-release/release-notes-generator
- name: Capture current tag
id: current_tag
run: |
if git describe --tags --exact-match HEAD >/dev/null 2>&1; then
echo "value=$(git describe --tags --exact-match HEAD)" >> "$GITHUB_OUTPUT"
else
echo "value=" >> "$GITHUB_OUTPUT"
fi
- name: Resolve release
id: release
env:
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
CURRENT_TAG: ${{ steps.current_tag.outputs.value }}
run: |
if [ -n "$CURRENT_TAG" ] && [ "$CURRENT_TAG" != "$PREVIOUS_TAG" ]; then
echo "created=true" >> "$GITHUB_OUTPUT"
echo "tag=$CURRENT_TAG" >> "$GITHUB_OUTPUT"
echo "version=${CURRENT_TAG#v}" >> "$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-tyrant-${{ steps.release.outputs.version }}
path: build/libs/spigot-tyrant-${{ steps.release.outputs.version }}.jar
if-no-files-found: error
- name: Create Gitea release and upload plugin
if: steps.release.outputs.created == 'true'
env:
GITEA_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.previous_tag.outputs.value }}
run: |
api_url="${GITEA_SERVER_URL}/api/v1"
jar="build/libs/spigot-tyrant-${VERSION}.jar"
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
payload=$(node -e '
const payload = {
tag_name: process.env.TAG,
name: process.env.TAG,
body: process.env.RELEASE_BODY,
draft: false,
prerelease: false
};
process.stdout.write(JSON.stringify(payload));
')
response=$(curl --fail-with-body --silent --show-error \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${api_url}/repos/${REPOSITORY}/releases" \
--data "$payload")
release_id=$(printf '%s' "$response" | node -e '
let input = "";
process.stdin.on("data", chunk => input += chunk);
process.stdin.on("end", () => {
const response = JSON.parse(input);
if (!response.id) process.exit(1);
process.stdout.write(String(response.id));
});
')
curl --fail-with-body --silent --show-error \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${jar}" \
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-tyrant-${VERSION}.jar"
+4
View File
@@ -0,0 +1,4 @@
.gradle/
build/
.idea/
*.iml
+53
View File
@@ -0,0 +1,53 @@
# Repository Agent Guidance
## User-story-driven development
The `design/` directory is the OKF v0.1 product record for this repository. Use user stories to plan, implement, verify, and track all behavior.
Before changing behavior:
1. Read `design/index.md` and every story related to the requested behavior.
2. Draft updates to an existing story or create a new `design/user-stories/us-NNN-short-name.md` story before implementation.
3. Define observable acceptance criteria using user or operator language.
4. Present the relevant new or updated stories and acceptance criteria to the user for review, and wait for explicit confirmation before changing implementation code.
5. Incorporate requested story changes before proceeding.
6. Set story status to `in-progress` while approved implementation is incomplete.
While implementing:
1. Work in vertical slices against the documented acceptance criteria.
2. Add tests for important behavior before implementation when practical.
3. Keep implementation references and related-story links current.
4. Do not mark an acceptance criterion complete until the behavior exists and has been validated.
Before completing or committing:
1. Set a completed story status to `done` only when all acceptance criteria are complete and verified.
2. Check completed acceptance criteria and record validation evidence where appropriate.
3. Update `design/index.md` and `design/user-stories/index.md` whenever stories are added, renamed, moved, or materially reclassified.
4. Add a high-level entry to `design/log.md` under the verified current date.
5. Run OKF validation along with relevant Gradle tests, lint checks, and builds.
## OKF conventions
- Every non-reserved Markdown file in `design/` must have YAML frontmatter with a non-empty `type`.
- User stories use `type: User Story` and include `status`, `title`, and `description`.
- Allowed story statuses are `backlog`, `in-progress`, and `done`.
- Every user story includes an acceptance-criteria section using Markdown task-list items.
- `design/index.md` and `design/log.md` are reserved OKF files and follow the OKF index/log structures.
- Store user stories in `design/user-stories/` and keep their catalog current.
- Use standard Markdown links and keep repository-local links valid when files move.
- Preserve unknown frontmatter extensions.
## Java and Spigot development
- Use the Java version and Spigot API version declared by the Gradle build.
- Treat compiler warnings as errors.
- Prefer test-first development for domain rules and state transitions when practical.
- Run `./gradlew clean check jar` before completing implementation work.
- Keep Bukkit event handlers thin and move testable game rules into focused domain services.
- Do not perform blocking file or network operations on the server tick thread.
## Timestamps
Always run `date +%Y-%m-%d` before adding or updating dates in stories or the design log. Use RFC 3339 UTC timestamps (`YYYY-MM-DDTHH:MM:SSZ`) when a date-time is required. Never guess dates.
+46
View File
@@ -0,0 +1,46 @@
plugins {
java
}
group = "games.dmg"
version = providers.gradleProperty("releaseVersion")
.orElse("0.1.0-SNAPSHOT")
.get()
repositories {
mavenCentral()
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/")
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
}
dependencies {
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0")
testImplementation("org.yaml:snakeyaml:2.4")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}
val pluginVersion = version
tasks.processResources {
filesMatching("plugin.yml") {
expand("version" to pluginVersion)
}
}
+21
View File
@@ -6,6 +6,27 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log
## 2026-08-14 — Configuration and persistence completed
- Completed US-012 with validated defaults for ranges, timers, effects, items, messages, and mob restrictions, including runtime Bukkit section parsing.
- Added immutable lifecycle and player state for roles, dual-alignment classes, progression, participation, cooldowns, bound items, and captured mobs.
- Added RFC 3339 UTC timestamp persistence, paused deadline shifting, atomic YAML replacement, invalid-record rejection, unknown-field preservation, periodic saves, and shutdown saves.
- Verified all state, settings, bundled configuration, corruption, forward-compatibility, and pause-time tests with `./gradlew clean check jar`.
## 2026-08-14 — Configuration and persistence implementation started
- US-012 begins with test-first validated defaults, immutable UUID-keyed domain state, paused-time-aware clocks, and defensive atomic YAML persistence.
## 2026-08-14 — Build and release foundation completed
- Completed US-013 with a Java 17 Gradle build, Spigot 26.2 API, strict compiler linting, JUnit 5 and Mockito dependencies, plugin metadata, and a minimal plugin entrypoint.
- Added Gitea push and pull-request CI, conventional-commit validation, development artifacts, semantic versioning, and versioned Gitea release assets modeled on `../spigot-base/`.
- Verified `./gradlew clean check jar`, a release-version `1.2.3` JAR and embedded plugin version, workflow delivery settings, and OKF conformance.
## 2026-08-14 — Build and release implementation started
- Approved implementation begins with US-013 and a test-driven Java 17, Gradle, Spigot, and Gitea Actions foundation modeled on `../spigot-base/`.
## 2026-08-14 — Initial game design
- The game has one Tyrant and one Vigilante, with Followers aligned to the Vigilante and Assassin, Fixer, and Tamer classes assigned by the Tyrant.
@@ -2,7 +2,7 @@
type: User Story
title: "US-012: Configure and persist game state"
description: Give operators validated configuration and durable defensive storage for all Tyrant behavior.
status: backlog
status: done
---
# US-012: Configure and persist game state
@@ -11,15 +11,15 @@ As a **server operator**, I want configurable and durable game behavior so that
## Acceptance criteria
- [ ] Configuration covers ranges, durations, cooldowns, inactivity periods, candidate windows, retry intervals, effect levels and caps, mob restrictions, item materials and names, messages, and timer behavior.
- [ ] Defaults match the approved user stories, including a 50-block Tyrant range and Follower range, seven-day opt-out, 48-hour inactivity, and 24-hour candidate windows.
- [ ] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
- [ ] UUID-keyed state stores lifecycle, current and pending roles, assignments, login history, participation, reign progression, purchases, choices, cooldowns, paused time, item delivery, and captured mobs.
- [ ] Cooldowns and deadlines use UTC instants and exclude administratively paused time.
- [ ] State is saved using atomic replacement where supported so failed writes do not replace valid state with partial data.
- [ ] Corrupt, unknown, or invalid records cannot silently grant progression, roles, powers, items, or duplicated mobs.
- [ ] Unknown forward-compatible configuration and state fields are preserved where practical.
- [ ] Plugin disable removes transient effects and presentation safely while preserving durable state.
- [x] Configuration covers ranges, durations, cooldowns, inactivity periods, candidate windows, retry intervals, effect levels and caps, mob restrictions, item materials and names, messages, and timer behavior.
- [x] Defaults match the approved user stories, including a 50-block Tyrant range and Follower range, seven-day opt-out, 48-hour inactivity, and 24-hour candidate windows.
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
- [x] UUID-keyed state stores lifecycle, current and pending roles, assignments, login history, participation, reign progression, purchases, choices, cooldowns, paused time, item delivery, and captured mobs.
- [x] Cooldowns and deadlines use UTC instants and exclude administratively paused time.
- [x] State is saved using atomic replacement where supported so failed writes do not replace valid state with partial data.
- [x] Corrupt, unknown, or invalid records cannot silently grant progression, roles, powers, items, or duplicated mobs.
- [x] Unknown forward-compatible configuration and state fields are preserved where practical.
- [x] Plugin disable removes transient effects and presentation safely while preserving durable state.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-013: Build, test, and release the plugin"
description: Give maintainers repeatable builds, automated verification, and versioned Gitea releases.
status: backlog
status: done
---
# US-013: Build, test, and release the plugin
@@ -11,15 +11,15 @@ As a **plugin maintainer**, I want automated builds and releases modeled on Spig
## Acceptance criteria
- [ ] The Gradle Kotlin DSL project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 toolchain.
- [ ] Compiler lint warnings fail the build.
- [ ] Automated JUnit 5 and Mockito tests run as part of the Gradle check lifecycle.
- [ ] Pushes and pull requests build and test the plugin in Gitea Actions.
- [ ] Pull requests validate conventional commit messages.
- [ ] CI stores a `spigot-tyrant` development JAR as a workflow artifact.
- [ ] Main-branch conventional commits drive semantic versioning.
- [ ] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
- [ ] Build files, Gradle wrapper, workflows, and release behavior follow `../spigot-base/` where applicable while using Spigot Tyrant names and identifiers.
- [x] The Gradle Kotlin DSL project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 toolchain.
- [x] Compiler lint warnings fail the build.
- [x] Automated JUnit 5 and Mockito tests run as part of the Gradle check lifecycle.
- [x] Pushes and pull requests build and test the plugin in Gitea Actions.
- [x] Pull requests validate conventional commit messages.
- [x] CI stores a `spigot-tyrant` development JAR as a workflow artifact.
- [x] Main-branch conventional commits drive semantic versioning.
- [x] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
- [x] Build files, Gradle wrapper, workflows, and release behavior follow `../spigot-base/` where applicable while using Spigot Tyrant names and identifiers.
## Related
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%
+1
View File
@@ -0,0 +1 @@
rootProject.name = "spigot-tyrant"
@@ -0,0 +1,9 @@
package games.dmg.spigottyrant;
public enum Ability {
ASSASSIN_INVISIBILITY,
ASSASSIN_DOUBLE_JUMP,
FIXER_BOOST,
TAMER_CAPTURE,
ROSTER_INTELLIGENCE
}
@@ -0,0 +1,14 @@
package games.dmg.spigottyrant;
import java.util.Locale;
import java.util.Map;
public record CapturedMob(String entityType, Map<String, String> data) {
public CapturedMob {
if (entityType == null || entityType.isBlank()) {
throw new IllegalArgumentException("entityType must not be blank");
}
entityType = entityType.trim().toUpperCase(Locale.ROOT);
data = data == null ? Map.of() : Map.copyOf(data);
}
}
@@ -0,0 +1,7 @@
package games.dmg.spigottyrant;
public enum GameLifecycle {
UNSTARTED,
RUNNING,
PAUSED
}
@@ -0,0 +1,59 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public record GameState(
GameLifecycle lifecycle,
Optional<UUID> tyrantId,
Optional<UUID> vigilanteId,
Optional<PendingSelection> pendingTyrant,
Optional<PendingSelection> pendingVigilante,
Optional<Instant> pausedAt,
Duration accumulatedPausedTime,
int tyrantLevel,
int unspentChoices,
Set<TyrantUnlock> purchases
) {
public GameState {
lifecycle = lifecycle == null ? GameLifecycle.UNSTARTED : lifecycle;
tyrantId = tyrantId == null ? Optional.empty() : tyrantId;
vigilanteId = vigilanteId == null ? Optional.empty() : vigilanteId;
pendingTyrant = pendingTyrant == null ? Optional.empty() : pendingTyrant;
pendingVigilante = pendingVigilante == null ? Optional.empty() : pendingVigilante;
pausedAt = pausedAt == null ? Optional.empty() : pausedAt;
accumulatedPausedTime = accumulatedPausedTime == null
? Duration.ZERO : accumulatedPausedTime;
purchases = purchases == null ? Set.of() : Set.copyOf(purchases);
if (tyrantId.isPresent() && tyrantId.equals(vigilanteId)) {
throw new IllegalArgumentException("Tyrant and Vigilante must be different players");
}
if (tyrantLevel < 0 || unspentChoices < 0) {
throw new IllegalArgumentException("progression values must not be negative");
}
if (accumulatedPausedTime.isNegative()) {
throw new IllegalArgumentException("accumulated paused time must not be negative");
}
if ((lifecycle == GameLifecycle.PAUSED) != pausedAt.isPresent()) {
throw new IllegalArgumentException("pausedAt must be present exactly while paused");
}
}
public static GameState empty() {
return new GameState(
GameLifecycle.UNSTARTED,
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Duration.ZERO,
0,
0,
Set.of()
);
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
public final class PauseAwareTime {
private PauseAwareTime() {
}
public static Instant shiftDeadline(Instant deadline, Instant pausedAt, Instant resumedAt) {
Objects.requireNonNull(deadline, "deadline");
Objects.requireNonNull(pausedAt, "pausedAt");
Objects.requireNonNull(resumedAt, "resumedAt");
if (resumedAt.isBefore(pausedAt)) {
throw new IllegalArgumentException("resumedAt must not precede pausedAt");
}
return deadline.plus(Duration.between(pausedAt, resumedAt));
}
}
@@ -0,0 +1,12 @@
package games.dmg.spigottyrant;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
public record PendingSelection(UUID candidateId, Instant expiresAt) {
public PendingSelection {
Objects.requireNonNull(candidateId, "candidateId");
Objects.requireNonNull(expiresAt, "expiresAt");
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.UUID;
public record PersistentState(GameState game, Map<UUID, PlayerState> players) {
public PersistentState {
game = game == null ? GameState.empty() : game;
players = players == null ? Map.of() : Map.copyOf(players);
for (Map.Entry<UUID, PlayerState> entry : players.entrySet()) {
if (!entry.getKey().equals(entry.getValue().playerId())) {
throw new IllegalArgumentException("player map key must match player state ID");
}
}
}
public static PersistentState empty() {
return new PersistentState(GameState.empty(), Map.of());
}
}
@@ -0,0 +1,51 @@
package games.dmg.spigottyrant;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public record PlayerState(
UUID playerId,
String latestName,
Optional<Instant> lastLogin,
Optional<Instant> optedOutUntil,
TyrantClass tyrantClass,
Optional<UUID> followerOf,
Map<Ability, Instant> cooldownEnds,
Set<Ability> readyAbilityItems,
List<CapturedMob> capturedMobs
) {
public PlayerState {
if (playerId == null) {
throw new IllegalArgumentException("playerId must not be null");
}
if (latestName == null || latestName.isBlank()) {
throw new IllegalArgumentException("latestName must not be blank");
}
latestName = latestName.trim();
lastLogin = lastLogin == null ? Optional.empty() : lastLogin;
optedOutUntil = optedOutUntil == null ? Optional.empty() : optedOutUntil;
tyrantClass = tyrantClass == null ? TyrantClass.NONE : tyrantClass;
followerOf = followerOf == null ? Optional.empty() : followerOf;
cooldownEnds = cooldownEnds == null ? Map.of() : Map.copyOf(cooldownEnds);
readyAbilityItems = readyAbilityItems == null ? Set.of() : Set.copyOf(readyAbilityItems);
capturedMobs = capturedMobs == null ? List.of() : List.copyOf(capturedMobs);
}
public static PlayerState newPlayer(UUID playerId, String latestName) {
return new PlayerState(
playerId,
latestName,
Optional.empty(),
Optional.empty(),
TyrantClass.NONE,
Optional.empty(),
Map.of(),
Set.of(),
List.of()
);
}
}
@@ -0,0 +1,266 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.bukkit.configuration.ConfigurationSection;
public record PluginSettings(
double tyrantRangeBlocks,
double followerRangeBlocks,
Duration optOutDuration,
Duration roleInactivity,
Duration candidateActivityWindow,
Duration pendingSelectionTimeout,
Duration selectionRetryInterval,
Duration assassinCooldown,
Duration assassinInvisibilityDuration,
Duration assassinDoubleJumpCooldown,
Duration assassinSpeedDuration,
Duration assassinWeaknessDuration,
int assassinWeaknessLevel,
Duration fixerCooldown,
Duration fixerEffectDuration,
int fixerStrengthLevel,
int fixerNormalHeartRows,
int fixerNearTyrantHeartRows,
Duration rosterIntelligenceCooldown,
int tyrantStrengthLevel,
int tyrantResistanceLevel,
int followerStrengthCap,
int followerResistanceCap,
Set<String> deniedMobTypes,
AbilityItemSettings assassinItem,
AbilityItemSettings fixerItem,
AbilityItemSettings tamerItem,
String recoveryCommand,
String inventoryFullMessage,
boolean freezeTimersWhilePaused
) {
public PluginSettings {
requirePositiveFinite(tyrantRangeBlocks, "tyrant-range-blocks");
requirePositiveFinite(followerRangeBlocks, "follower-range-blocks");
requirePositive(optOutDuration, "opt-out-seconds");
requirePositive(roleInactivity, "role-inactivity-seconds");
requirePositive(candidateActivityWindow, "candidate-activity-window-seconds");
requirePositive(pendingSelectionTimeout, "pending-selection-timeout-seconds");
requirePositive(selectionRetryInterval, "selection-retry-seconds");
requirePositive(assassinCooldown, "assassin-cooldown-seconds");
requirePositive(assassinInvisibilityDuration, "assassin-invisibility-seconds");
requirePositive(assassinDoubleJumpCooldown, "assassin-double-jump-cooldown-seconds");
requirePositive(assassinSpeedDuration, "assassin-speed-seconds");
requirePositive(assassinWeaknessDuration, "assassin-weakness-seconds");
requireEffectLevel(assassinWeaknessLevel, "assassin-weakness-level");
requirePositive(fixerCooldown, "fixer-cooldown-seconds");
requirePositive(fixerEffectDuration, "fixer-effect-seconds");
requireEffectLevel(fixerStrengthLevel, "fixer-strength-level");
requireHeartRows(fixerNormalHeartRows, "fixer-normal-heart-rows");
requireHeartRows(fixerNearTyrantHeartRows, "fixer-near-tyrant-heart-rows");
if (fixerNearTyrantHeartRows < fixerNormalHeartRows) {
throw new IllegalArgumentException(
"fixer-near-tyrant-heart-rows must be at least fixer-normal-heart-rows"
);
}
requirePositive(rosterIntelligenceCooldown, "roster-intelligence-cooldown-seconds");
requireEffectLevel(tyrantStrengthLevel, "tyrant-strength-level");
requireEffectLevel(tyrantResistanceLevel, "tyrant-resistance-level");
requireEffectLevel(followerStrengthCap, "follower-strength-cap");
requireEffectLevel(followerResistanceCap, "follower-resistance-cap");
deniedMobTypes = normalizedSet(deniedMobTypes, "denied-mob-types");
assassinItem = Objects.requireNonNull(assassinItem, "assassinItem");
fixerItem = Objects.requireNonNull(fixerItem, "fixerItem");
tamerItem = Objects.requireNonNull(tamerItem, "tamerItem");
recoveryCommand = requireText(recoveryCommand, "recovery-command");
inventoryFullMessage = requireText(inventoryFullMessage, "inventory-full-message");
}
public static PluginSettings from(Map<String, ?> values) {
Objects.requireNonNull(values, "values");
return new PluginSettings(
decimal(values, "tyrant-range-blocks", 50.0),
decimal(values, "follower-range-blocks", 50.0),
duration(values, "opt-out-seconds", Duration.ofDays(7)),
duration(values, "role-inactivity-seconds", Duration.ofHours(48)),
duration(values, "candidate-activity-window-seconds", Duration.ofHours(24)),
duration(values, "pending-selection-timeout-seconds", Duration.ofHours(24)),
duration(values, "selection-retry-seconds", Duration.ofMinutes(1)),
duration(values, "assassin-cooldown-seconds", Duration.ofHours(1)),
duration(values, "assassin-invisibility-seconds", Duration.ofMinutes(10)),
duration(values, "assassin-double-jump-cooldown-seconds", Duration.ofSeconds(60)),
duration(values, "assassin-speed-seconds", Duration.ofSeconds(15)),
duration(values, "assassin-weakness-seconds", Duration.ofSeconds(20)),
integer(values, "assassin-weakness-level", 3),
duration(values, "fixer-cooldown-seconds", Duration.ofHours(1)),
duration(values, "fixer-effect-seconds", Duration.ofMinutes(10)),
integer(values, "fixer-strength-level", 1),
integer(values, "fixer-normal-heart-rows", 2),
integer(values, "fixer-near-tyrant-heart-rows", 3),
duration(values, "roster-intelligence-cooldown-seconds", Duration.ofHours(24)),
integer(values, "tyrant-strength-level", 1),
integer(values, "tyrant-resistance-level", 1),
integer(values, "follower-strength-cap", 5),
integer(values, "follower-resistance-cap", 4),
stringSet(values, "denied-mob-types", Set.of("ENDER_DRAGON", "WITHER")),
item(values, "assassin-item", new AbilityItemSettings("STICK", "Assassin Cloak")),
item(values, "fixer-item", new AbilityItemSettings("STICK", "Fixer's Wrench")),
item(values, "tamer-item", new AbilityItemSettings("FISHING_ROD", "Tamer's Lead")),
string(values, "recovery-command", "/tyrant item"),
string(
values,
"inventory-full-message",
"Your inventory is full. Make room and use /tyrant item."
),
bool(values, "freeze-timers-while-paused", true)
);
}
private static AbilityItemSettings item(
Map<String, ?> values,
String key,
AbilityItemSettings defaultValue
) {
Object raw = values.get(key);
if (raw == null) {
return defaultValue;
}
Map<?, ?> map;
if (raw instanceof ConfigurationSection section) {
map = section.getValues(false);
} else if (raw instanceof Map<?, ?> itemValues) {
map = itemValues;
} else {
throw new IllegalArgumentException(key + " must be a section");
}
Object material = map.get("material");
Object name = map.get("name");
return new AbilityItemSettings(
material == null ? defaultValue.material() : material.toString(),
name == null ? defaultValue.name() : name.toString()
);
}
private static double decimal(Map<String, ?> values, String key, double defaultValue) {
Object raw = values.get(key);
if (raw == null) {
return defaultValue;
}
if (!(raw instanceof Number number)) {
throw new IllegalArgumentException(key + " must be numeric");
}
return number.doubleValue();
}
private static int integer(Map<String, ?> values, String key, int defaultValue) {
Object raw = values.get(key);
if (raw == null) {
return defaultValue;
}
if (!(raw instanceof Number number)) {
throw new IllegalArgumentException(key + " must be an integer");
}
double value = number.doubleValue();
if (!Double.isFinite(value) || value != Math.rint(value)
|| value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) {
throw new IllegalArgumentException(key + " must be a 32-bit integer");
}
return number.intValue();
}
private static String string(Map<String, ?> values, String key, String defaultValue) {
Object raw = values.get(key);
return raw == null ? defaultValue : raw.toString();
}
private static boolean bool(Map<String, ?> values, String key, boolean defaultValue) {
Object raw = values.get(key);
if (raw == null) {
return defaultValue;
}
if (!(raw instanceof Boolean value)) {
throw new IllegalArgumentException(key + " must be true or false");
}
return value;
}
private static Set<String> stringSet(
Map<String, ?> values,
String key,
Set<String> defaultValue
) {
Object raw = values.get(key);
if (raw == null) {
return defaultValue;
}
if (!(raw instanceof List<?> list)) {
throw new IllegalArgumentException(key + " must be a list");
}
return list.stream().map(Object::toString).collect(Collectors.toUnmodifiableSet());
}
private static void requirePositiveFinite(double value, String key) {
if (!Double.isFinite(value) || value <= 0.0) {
throw new IllegalArgumentException(key + " must be positive and finite");
}
}
private static void requirePositive(Duration value, String key) {
Objects.requireNonNull(value, key);
if (value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(key + " must be positive");
}
}
private static void requireEffectLevel(int value, String key) {
if (value < 1 || value > 255) {
throw new IllegalArgumentException(key + " must be between 1 and 255");
}
}
private static void requireHeartRows(int value, String key) {
if (value < 1 || value > 100) {
throw new IllegalArgumentException(key + " must be between 1 and 100");
}
}
private static Set<String> normalizedSet(Set<String> values, String key) {
if (values == null || values.isEmpty()) {
throw new IllegalArgumentException(key + " must not be empty");
}
return values.stream()
.map(value -> requireText(value, key).toUpperCase(Locale.ROOT))
.collect(Collectors.toUnmodifiableSet());
}
private static String requireText(String value, String key) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(key + " must not be blank");
}
return value.trim();
}
private static Duration duration(Map<String, ?> values, String key, Duration defaultValue) {
Object raw = values.get(key);
if (raw == null) {
return defaultValue;
}
if (!(raw instanceof Number number)) {
throw new IllegalArgumentException(key + " must be an integer number of seconds");
}
double seconds = number.doubleValue();
if (!Double.isFinite(seconds) || seconds != Math.rint(seconds)) {
throw new IllegalArgumentException(key + " must be an integer number of seconds");
}
return Duration.ofSeconds(number.longValue());
}
public record AbilityItemSettings(String material, String name) {
public AbilityItemSettings {
material = requireText(material, "item material").toUpperCase(Locale.ROOT);
name = requireText(name, "item name");
}
}
}
@@ -0,0 +1,36 @@
package games.dmg.spigottyrant;
import org.bukkit.Material;
import org.bukkit.entity.EntityType;
public final class PluginSettingsValidator {
private PluginSettingsValidator() {
}
public static PluginSettings validate(PluginSettings settings) {
validateItem(settings.assassinItem(), "assassin-item");
validateItem(settings.fixerItem(), "fixer-item");
validateItem(settings.tamerItem(), "tamer-item");
for (String entityType : settings.deniedMobTypes()) {
try {
EntityType.valueOf(entityType);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException(
entityType + " in denied-mob-types is not a known entity type",
exception
);
}
}
if (!settings.deniedMobTypes().contains("ENDER_DRAGON")) {
throw new IllegalArgumentException("denied-mob-types must include ENDER_DRAGON");
}
return settings;
}
private static void validateItem(PluginSettings.AbilityItemSettings item, String key) {
Material material = Material.matchMaterial(item.material());
if (material == null || material == Material.AIR) {
throw new IllegalArgumentException(key + " material must identify an item");
}
}
}
@@ -0,0 +1,52 @@
package games.dmg.spigottyrant;
import java.io.IOException;
import java.util.Map;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotTyrantPlugin extends JavaPlugin {
private PluginSettings settings;
private TyrantStateManager stateManager;
@Override
public void onEnable() {
saveDefaultConfig();
try {
Map<String, Object> values = getConfig().getValues(false);
settings = PluginSettingsValidator.validate(PluginSettings.from(values));
stateManager = new TyrantStateManager(
new YamlTyrantStateRepository(getDataFolder().toPath().resolve("state.yml")),
getLogger()
);
} catch (IllegalArgumentException | IOException exception) {
getLogger().log(Level.SEVERE, "Could not initialize Spigot Tyrant", exception);
getServer().getPluginManager().disablePlugin(this);
return;
}
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Tyrant enabled.");
}
@Override
public void onDisable() {
if (stateManager != null) {
stateManager.saveIfDirty();
}
}
PluginSettings settings() {
if (settings == null) {
throw new IllegalStateException("Plugin settings are unavailable");
}
return settings;
}
TyrantStateManager stateManager() {
if (stateManager == null) {
throw new IllegalStateException("State manager is unavailable");
}
return stateManager;
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
public enum TyrantClass {
NONE,
ASSASSIN,
FIXER,
TAMER
}
@@ -0,0 +1,100 @@
package games.dmg.spigottyrant;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.UnaryOperator;
import java.util.logging.Level;
import java.util.logging.Logger;
public final class TyrantStateManager {
private final YamlTyrantStateRepository repository;
private final Logger logger;
private final Map<UUID, PlayerState> players;
private GameState game;
private boolean dirty;
public TyrantStateManager(
YamlTyrantStateRepository repository,
Logger logger
) throws IOException {
this.repository = repository;
this.logger = logger;
PersistentState state = repository.load();
game = state.game();
players = new HashMap<>(state.players());
}
public GameState game() {
return game;
}
public Map<UUID, PlayerState> players() {
return Map.copyOf(players);
}
public PlayerState player(UUID playerId, String latestName) {
PlayerState current = players.get(playerId);
if (current == null) {
current = PlayerState.newPlayer(playerId, latestName);
players.put(playerId, current);
dirty = true;
return current;
}
if (!current.latestName().equals(latestName)) {
current = copyWithName(current, latestName);
players.put(playerId, current);
dirty = true;
}
return current;
}
public PlayerState updatePlayer(
UUID playerId,
String latestName,
UnaryOperator<PlayerState> update
) {
PlayerState changed = update.apply(player(playerId, latestName));
if (!playerId.equals(changed.playerId())) {
throw new IllegalArgumentException("updated player ID cannot change");
}
players.put(playerId, changed);
dirty = true;
return changed;
}
public GameState updateGame(UnaryOperator<GameState> update) {
game = update.apply(game);
dirty = true;
return game;
}
public boolean saveIfDirty() {
if (!dirty) {
return true;
}
try {
repository.save(new PersistentState(game, players));
dirty = false;
return true;
} catch (IOException exception) {
logger.log(Level.SEVERE, "Could not save Spigot Tyrant state", exception);
return false;
}
}
private static PlayerState copyWithName(PlayerState player, String latestName) {
return new PlayerState(
player.playerId(),
latestName,
player.lastLogin(),
player.optedOutUntil(),
player.tyrantClass(),
player.followerOf(),
player.cooldownEnds(),
player.readyAbilityItems(),
player.capturedMobs()
);
}
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
public enum TyrantUnlock {
ASSASSIN,
FIXER,
TAMER,
ROSTER_INTELLIGENCE,
RESISTANCE,
STRENGTH
}
@@ -0,0 +1,335 @@
package games.dmg.spigottyrant;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.EntityType;
public final class YamlTyrantStateRepository {
private static final String PLAYERS = "players";
private final Path stateFile;
public YamlTyrantStateRepository(Path stateFile) {
this.stateFile = stateFile;
}
public PersistentState load() throws IOException {
if (!Files.exists(stateFile)) {
return PersistentState.empty();
}
YamlConfiguration yaml = loadYaml();
return new PersistentState(loadGameSafely(yaml), loadPlayers(yaml));
}
public void save(PersistentState state) throws IOException {
Path parent = stateFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
YamlConfiguration yaml = Files.exists(stateFile) ? loadYaml() : new YamlConfiguration();
saveGame(yaml, state.game());
removeAbsentPlayers(yaml, state.players().keySet());
for (PlayerState player : state.players().values()) {
savePlayer(yaml, player);
}
Path temporary = Files.createTempFile(parent, "spigot-tyrant-state-", ".yml");
try {
yaml.save(temporary.toFile());
try {
Files.move(
temporary,
stateFile,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
);
} catch (IOException atomicMoveFailure) {
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
}
private YamlConfiguration loadYaml() throws IOException {
YamlConfiguration yaml = new YamlConfiguration();
try {
yaml.load(stateFile.toFile());
return yaml;
} catch (InvalidConfigurationException exception) {
throw new IOException("state file is not valid YAML", exception);
}
}
private static GameState loadGameSafely(YamlConfiguration yaml) {
try {
GameLifecycle lifecycle = enumValue(
GameLifecycle.class,
yaml.getString("game.lifecycle"),
GameLifecycle.UNSTARTED
);
return new GameState(
lifecycle,
uuid(yaml.getString("game.tyrant-id")),
uuid(yaml.getString("game.vigilante-id")),
pending(yaml, "game.pending-tyrant"),
pending(yaml, "game.pending-vigilante"),
instant(yaml.getString("game.paused-at")),
Duration.ofSeconds(nonNegativeLong(yaml, "game.accumulated-paused-seconds")),
nonNegativeInt(yaml, "game.tyrant-level"),
nonNegativeInt(yaml, "game.unspent-choices"),
enumSet(TyrantUnlock.class, yaml.getStringList("game.purchases"))
);
} catch (IllegalArgumentException exception) {
return GameState.empty();
}
}
private static Map<UUID, PlayerState> loadPlayers(YamlConfiguration yaml) {
Map<UUID, PlayerState> players = new HashMap<>();
ConfigurationSection section = yaml.getConfigurationSection(PLAYERS);
if (section == null) {
return players;
}
for (String key : section.getKeys(false)) {
try {
UUID playerId = UUID.fromString(key);
String path = PLAYERS + "." + key;
String name = requireText(yaml.getString(path + ".name"), "player name");
PlayerState player = new PlayerState(
playerId,
name,
instant(yaml.getString(path + ".last-login")),
instant(yaml.getString(path + ".opted-out-until")),
enumValue(
TyrantClass.class,
yaml.getString(path + ".tyrant-class"),
TyrantClass.NONE
),
uuid(yaml.getString(path + ".follower-of")),
loadCooldowns(yaml, path + ".cooldowns"),
enumSet(Ability.class, yaml.getStringList(path + ".ready-ability-items")),
loadCapturedMobs(yaml.getMapList(path + ".captured-mobs"))
);
players.put(playerId, player);
} catch (IllegalArgumentException ignored) {
// Invalid player records grant no state.
}
}
return players;
}
private static Map<Ability, Instant> loadCooldowns(YamlConfiguration yaml, String path) {
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
ConfigurationSection section = yaml.getConfigurationSection(path);
if (section == null) {
return cooldowns;
}
for (String key : section.getKeys(false)) {
Ability ability = enumValue(Ability.class, key, null);
Optional<Instant> end = instant(yaml.getString(path + "." + key));
if (ability != null && end.isPresent()) {
cooldowns.put(ability, end.orElseThrow());
}
}
return cooldowns;
}
private static List<CapturedMob> loadCapturedMobs(List<Map<?, ?>> rawMobs) {
List<CapturedMob> mobs = new ArrayList<>();
for (Map<?, ?> raw : rawMobs) {
try {
String type = requireText(
raw.get("entity-type") == null ? null : raw.get("entity-type").toString(),
"captured mob entity type"
);
EntityType entityType = EntityType.valueOf(type.toUpperCase(java.util.Locale.ROOT));
if (entityType == EntityType.ENDER_DRAGON) {
continue;
}
Map<String, String> data = new HashMap<>();
Object rawData = raw.get("data");
if (rawData instanceof Map<?, ?> values) {
for (Map.Entry<?, ?> entry : values.entrySet()) {
if (entry.getKey() != null && entry.getValue() != null) {
data.put(entry.getKey().toString(), entry.getValue().toString());
}
}
}
mobs.add(new CapturedMob(entityType.name(), data));
} catch (IllegalArgumentException ignored) {
// Unknown or malformed entities are not restored.
}
}
return List.copyOf(mobs);
}
private static void saveGame(YamlConfiguration yaml, GameState game) {
yaml.set("game.lifecycle", game.lifecycle().name());
yaml.set("game.tyrant-id", text(game.tyrantId()));
yaml.set("game.vigilante-id", text(game.vigilanteId()));
savePending(yaml, "game.pending-tyrant", game.pendingTyrant());
savePending(yaml, "game.pending-vigilante", game.pendingVigilante());
yaml.set("game.paused-at", instantText(game.pausedAt()));
yaml.set("game.accumulated-paused-seconds", game.accumulatedPausedTime().toSeconds());
yaml.set("game.tyrant-level", game.tyrantLevel());
yaml.set("game.unspent-choices", game.unspentChoices());
yaml.set("game.purchases", game.purchases().stream().map(Enum::name).sorted().toList());
}
private static void savePlayer(YamlConfiguration yaml, PlayerState player) {
String path = PLAYERS + "." + player.playerId();
yaml.set(path + ".name", player.latestName());
yaml.set(path + ".last-login", instantText(player.lastLogin()));
yaml.set(path + ".opted-out-until", instantText(player.optedOutUntil()));
yaml.set(path + ".tyrant-class", player.tyrantClass().name());
yaml.set(path + ".follower-of", text(player.followerOf()));
yaml.set(path + ".cooldowns", null);
for (Map.Entry<Ability, Instant> cooldown : player.cooldownEnds().entrySet()) {
yaml.set(path + ".cooldowns." + cooldown.getKey().name(), cooldown.getValue().toString());
}
yaml.set(
path + ".ready-ability-items",
player.readyAbilityItems().stream().map(Enum::name).sorted().toList()
);
List<Map<String, Object>> mobs = new ArrayList<>();
for (CapturedMob mob : player.capturedMobs()) {
Map<String, Object> serialized = new LinkedHashMap<>();
serialized.put("entity-type", mob.entityType());
serialized.put("data", mob.data());
mobs.add(serialized);
}
yaml.set(path + ".captured-mobs", mobs);
}
private static void removeAbsentPlayers(YamlConfiguration yaml, Set<UUID> retained) {
ConfigurationSection section = yaml.getConfigurationSection(PLAYERS);
if (section == null) {
return;
}
for (String key : section.getKeys(false)) {
try {
if (!retained.contains(UUID.fromString(key))) {
yaml.set(PLAYERS + "." + key, null);
}
} catch (IllegalArgumentException exception) {
yaml.set(PLAYERS + "." + key, null);
}
}
}
private static Optional<PendingSelection> pending(YamlConfiguration yaml, String path) {
Optional<UUID> candidate = uuid(yaml.getString(path + ".candidate-id"));
Optional<Instant> expiresAt = instant(yaml.getString(path + ".expires-at"));
if (candidate.isEmpty() && expiresAt.isEmpty()) {
return Optional.empty();
}
if (candidate.isEmpty() || expiresAt.isEmpty()) {
throw new IllegalArgumentException("incomplete pending selection");
}
return Optional.of(new PendingSelection(candidate.orElseThrow(), expiresAt.orElseThrow()));
}
private static void savePending(
YamlConfiguration yaml,
String path,
Optional<PendingSelection> pending
) {
yaml.set(path, null);
pending.ifPresent(selection -> {
yaml.set(path + ".candidate-id", selection.candidateId().toString());
yaml.set(path + ".expires-at", selection.expiresAt().toString());
});
}
private static Optional<UUID> uuid(String value) {
if (value == null || value.isBlank()) {
return Optional.empty();
}
return Optional.of(UUID.fromString(value));
}
private static Optional<Instant> instant(String value) {
if (value == null || value.isBlank()) {
return Optional.empty();
}
try {
return Optional.of(Instant.parse(value));
} catch (DateTimeParseException exception) {
throw new IllegalArgumentException("invalid RFC 3339 instant", exception);
}
}
private static String instantText(Optional<Instant> value) {
return value.map(Instant::toString).orElse(null);
}
private static String text(Optional<UUID> value) {
return value.map(UUID::toString).orElse(null);
}
private static int nonNegativeInt(YamlConfiguration yaml, String path) {
if (!yaml.isInt(path)) {
return 0;
}
int value = yaml.getInt(path);
if (value < 0) {
throw new IllegalArgumentException(path + " must not be negative");
}
return value;
}
private static long nonNegativeLong(YamlConfiguration yaml, String path) {
if (!yaml.isLong(path) && !yaml.isInt(path)) {
return 0L;
}
long value = yaml.getLong(path);
if (value < 0L) {
throw new IllegalArgumentException(path + " must not be negative");
}
return value;
}
private static <E extends Enum<E>> E enumValue(
Class<E> type,
String value,
E defaultValue
) {
if (value == null || value.isBlank()) {
return defaultValue;
}
return Enum.valueOf(type, value.trim().toUpperCase(java.util.Locale.ROOT));
}
private static <E extends Enum<E>> Set<E> enumSet(Class<E> type, List<String> values) {
Set<E> result = EnumSet.noneOf(type);
for (String value : values) {
result.add(enumValue(type, value, null));
}
return Set.copyOf(result);
}
private static String requireText(String value, String description) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(description + " must not be blank");
}
return value.trim();
}
}
+51
View File
@@ -0,0 +1,51 @@
# Proximity ranges
tyrant-range-blocks: 50
follower-range-blocks: 50
# Participation, inactivity, and selection
opt-out-seconds: 604800
role-inactivity-seconds: 172800
candidate-activity-window-seconds: 86400
pending-selection-timeout-seconds: 86400
selection-retry-seconds: 60
freeze-timers-while-paused: true
# Assassin
assassin-cooldown-seconds: 3600
assassin-invisibility-seconds: 600
assassin-double-jump-cooldown-seconds: 60
assassin-speed-seconds: 15
assassin-weakness-seconds: 20
assassin-weakness-level: 3
assassin-item:
material: STICK
name: Assassin Cloak
# Fixer
fixer-cooldown-seconds: 3600
fixer-effect-seconds: 600
fixer-strength-level: 1
fixer-normal-heart-rows: 2
fixer-near-tyrant-heart-rows: 3
fixer-item:
material: STICK
name: Fixer's Wrench
# Tamer
tamer-item:
material: FISHING_ROD
name: Tamer's Lead
denied-mob-types:
- ENDER_DRAGON
- WITHER
# Tyrant and Vigilante effects
roster-intelligence-cooldown-seconds: 86400
tyrant-strength-level: 1
tyrant-resistance-level: 1
follower-strength-cap: 5
follower-resistance-cap: 4
# Bound item recovery and delivery messages
recovery-command: /tyrant item
inventory-full-message: Your inventory is full. Make room and use /tyrant item.
+6
View File
@@ -0,0 +1,6 @@
name: SpigotTyrant
version: ${version}
main: games.dmg.spigottyrant.SpigotTyrantPlugin
api-version: "1.20"
description: Asymmetric Tyrant, Vigilante, Follower, and specialist class gameplay.
author: dmg.games
@@ -0,0 +1,39 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.InputStream;
import java.time.Duration;
import java.util.Map;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
final class DefaultConfigurationTest {
@Test
void bundledConfigurationIsCompleteAndValid() {
InputStream stream = getClass().getClassLoader().getResourceAsStream("config.yml");
assertNotNull(stream);
Map<String, ?> values = new Yaml().load(stream);
PluginSettings settings = PluginSettings.from(values);
assertEquals(50.0, settings.tyrantRangeBlocks());
assertEquals(Duration.ofDays(7), settings.optOutDuration());
assertEquals("FISHING_ROD", settings.tamerItem().material());
}
@Test
void acceptsNestedSectionsAsBukkitProvidesThem() throws Exception {
InputStream stream = getClass().getClassLoader().getResourceAsStream("config.yml");
assertNotNull(stream);
String yamlText = new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
YamlConfiguration yaml = new YamlConfiguration();
yaml.loadFromString(yamlText);
PluginSettings settings = PluginSettings.from(yaml.getValues(false));
assertEquals("Assassin Cloak", settings.assassinItem().name());
assertEquals("Fixer's Wrench", settings.fixerItem().name());
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.Test;
final class PauseAwareTimeTest {
@Test
void shiftsDeadlineByTimeSpentPaused() {
Instant pausedAt = Instant.parse("2026-08-14T12:00:00Z");
Instant resumedAt = pausedAt.plus(Duration.ofHours(3));
Instant originalDeadline = pausedAt.plus(Duration.ofMinutes(30));
Instant shifted = PauseAwareTime.shiftDeadline(originalDeadline, pausedAt, resumedAt);
assertEquals(originalDeadline.plus(Duration.ofHours(3)), shifted);
}
}
@@ -0,0 +1,31 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.InputStream;
import java.util.Map;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
final class PluginMetadataTest {
@Test
void declaresTyrantPluginMetadata() {
InputStream stream = getClass().getClassLoader().getResourceAsStream("plugin.yml");
assertNotNull(stream);
Map<?, ?> plugin = new Yaml().load(stream);
assertEquals("SpigotTyrant", plugin.get("name"));
assertEquals("games.dmg.spigottyrant.SpigotTyrantPlugin", plugin.get("main"));
assertEquals("1.20", plugin.get("api-version"));
}
@Test
void declaredEntrypointIsAJavaPlugin() throws ClassNotFoundException {
Class<?> entrypoint = Class.forName("games.dmg.spigottyrant.SpigotTyrantPlugin");
assertTrue(JavaPlugin.class.isAssignableFrom(entrypoint));
}
}
@@ -0,0 +1,67 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.Duration;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
final class PluginSettingsTest {
@Test
void approvedDefaultsCoverCoreGameTimingAndRanges() {
PluginSettings settings = PluginSettings.from(Map.of());
assertEquals(50.0, settings.tyrantRangeBlocks());
assertEquals(50.0, settings.followerRangeBlocks());
assertEquals(Duration.ofDays(7), settings.optOutDuration());
assertEquals(Duration.ofHours(48), settings.roleInactivity());
assertEquals(Duration.ofHours(24), settings.candidateActivityWindow());
assertEquals(Duration.ofHours(24), settings.pendingSelectionTimeout());
assertEquals(Duration.ofHours(1), settings.assassinCooldown());
assertEquals(Duration.ofMinutes(10), settings.assassinInvisibilityDuration());
assertEquals(Duration.ofHours(1), settings.fixerCooldown());
assertEquals(Duration.ofMinutes(10), settings.fixerEffectDuration());
assertEquals(Duration.ofHours(24), settings.rosterIntelligenceCooldown());
}
@Test
void approvedDefaultsCoverAbilitiesItemsMobsAndMessages() {
PluginSettings settings = PluginSettings.from(Map.of());
assertEquals(Duration.ofMinutes(1), settings.selectionRetryInterval());
assertEquals(Duration.ofSeconds(60), settings.assassinDoubleJumpCooldown());
assertEquals(Duration.ofSeconds(15), settings.assassinSpeedDuration());
assertEquals(Duration.ofSeconds(20), settings.assassinWeaknessDuration());
assertEquals(3, settings.assassinWeaknessLevel());
assertEquals(5, settings.followerStrengthCap());
assertEquals(4, settings.followerResistanceCap());
assertEquals(2, settings.fixerNormalHeartRows());
assertEquals(3, settings.fixerNearTyrantHeartRows());
assertEquals(Set.of("ENDER_DRAGON", "WITHER"), settings.deniedMobTypes());
assertEquals("STICK", settings.assassinItem().material());
assertEquals("Assassin Cloak", settings.assassinItem().name());
assertEquals("FISHING_ROD", settings.tamerItem().material());
assertEquals("/tyrant item", settings.recoveryCommand());
assertEquals("Your inventory is full. Make room and use /tyrant item.",
settings.inventoryFullMessage());
assertEquals(true, settings.freezeTimersWhilePaused());
}
@Test
void rejectsUnsafeRangesAndDurations() {
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("tyrant-range-blocks", 0))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("follower-range-blocks", Double.NaN))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("role-inactivity-seconds", -1))
);
}
}
@@ -0,0 +1,32 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Map;
import org.junit.jupiter.api.Test;
final class PluginSettingsValidatorTest {
@Test
void acceptsBundledItemMaterialsAndKnownEntityTypes() {
PluginSettings settings = PluginSettings.from(Map.of());
assertSame(settings, PluginSettingsValidator.validate(settings));
}
@Test
void rejectsInvalidItemMaterialAndEntityType() {
assertThrows(
IllegalArgumentException.class,
() -> PluginSettingsValidator.validate(PluginSettings.from(Map.of(
"assassin-item", Map.of("material", "NOT_REAL", "name", "Cloak")
)))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettingsValidator.validate(PluginSettings.from(Map.of(
"denied-mob-types", java.util.List.of("NOT_REAL")
)))
);
}
}
@@ -0,0 +1,45 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
import java.util.UUID;
import java.util.logging.Logger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class TyrantStateManagerTest {
@TempDir
Path temporaryDirectory;
@Test
void persistsPlayerNamesAndGameUpdatesWhenDirty() throws Exception {
Path stateFile = temporaryDirectory.resolve("state.yml");
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(stateFile);
TyrantStateManager manager = new TyrantStateManager(
repository,
Logger.getLogger("test")
);
UUID playerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
manager.player(playerId, "FirstName");
manager.player(playerId, "LatestName");
manager.updateGame(current -> new GameState(
GameLifecycle.RUNNING,
java.util.Optional.of(playerId),
java.util.Optional.empty(),
java.util.Optional.empty(),
java.util.Optional.empty(),
java.util.Optional.empty(),
java.time.Duration.ZERO,
0,
1,
java.util.Set.of()
));
manager.saveIfDirty();
PersistentState restored = repository.load();
assertEquals("LatestName", restored.players().get(playerId).latestName());
assertEquals(GameLifecycle.RUNNING, restored.game().lifecycle());
}
}
@@ -0,0 +1,102 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class YamlTyrantStateRepositoryTest {
@TempDir
Path temporaryDirectory;
@Test
void roundTripsLifecycleRolesProgressionPlayersCooldownsItemsAndMobs() throws Exception {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
Instant now = Instant.parse("2026-08-14T12:00:00Z");
GameState game = new GameState(
GameLifecycle.PAUSED,
Optional.of(tyrant),
Optional.of(vigilante),
Optional.empty(),
Optional.of(new PendingSelection(vigilante, now.plusSeconds(3600))),
Optional.of(now),
Duration.ofMinutes(15),
3,
2,
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
);
PlayerState player = new PlayerState(
tyrant,
"TyrantPlayer",
Optional.of(now.minusSeconds(10)),
Optional.of(now.plusSeconds(600)),
TyrantClass.ASSASSIN,
Optional.of(vigilante),
Map.of(Ability.ASSASSIN_INVISIBILITY, now.plusSeconds(3600)),
Set.of(Ability.ASSASSIN_INVISIBILITY),
List.of(new CapturedMob("ZOMBIE", Map.of("custom-name", "Bob")))
);
PersistentState expected = new PersistentState(game, Map.of(tyrant, player));
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(
temporaryDirectory.resolve("state.yml")
);
repository.save(expected);
assertEquals(expected, repository.load());
}
@Test
void preservesUnknownFieldsForRetainedState() throws Exception {
UUID playerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
future-root: retained
players:
11111111-1111-1111-1111-111111111111:
name: Player
future-player-field: retained-too
""");
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(stateFile);
PersistentState loaded = repository.load();
repository.save(loaded);
String saved = Files.readString(stateFile);
assertEquals(true, saved.contains("future-root: retained"));
assertEquals(true, saved.contains("future-player-field: retained-too"));
assertEquals(playerId, loaded.players().get(playerId).playerId());
}
@Test
void invalidRecordsCannotRestoreProgressOrCapturedDragons() throws Exception {
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
game:
lifecycle: RUNNING
tyrant-level: -10
players:
11111111-1111-1111-1111-111111111111:
name: Player
captured-mobs:
- entity-type: ENDER_DRAGON
- entity-type: NOT_REAL
""");
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(stateFile);
PersistentState loaded = repository.load();
assertEquals(GameState.empty(), loaded.game());
assertEquals(List.of(), loaded.players().values().iterator().next().capturedMobs());
}
}