Compare commits

...
7 Commits
Author SHA1 Message Date
dmg ddd256802b feat(succession): transfer tyrant reign on death
Release / release (push) Successful in 2m19s
CI / build (push) Successful in 1m1s
2026-08-14 22:46:58 -04:00
dmg 58e6dec724 feat(admin): add game lifecycle controls
Release / release (push) Successful in 2m12s
CI / build (push) Successful in 57s
2026-08-14 22:42:25 -04:00
dmg 63dc5ff736 feat(classes): add tyrant class assignment
Release / release (push) Successful in 2m14s
CI / build (push) Successful in 57s
2026-08-14 22:37:09 -04:00
dmg 805efda8bb feat(progression): add tyrant unlock choices
Release / release (push) Successful in 2m9s
CI / build (push) Successful in 55s
2026-08-14 22:33:11 -04:00
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
73 changed files with 4198 additions and 57 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)
}
}
+64
View File
@@ -6,6 +6,70 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log
## 2026-08-14 — Tyrant succession completed
- Completed US-002 with player-killer transfer, recent-player environmental succession, former-holder avoidance, pending offline confirmation, and distinct replacement Vigilante selection.
- Tyrant death clears all classes, Followers, cooldowns, item readiness, purchases, levels, and choices before granting the successor one starting choice.
- Added death-event integration, server announcements, and a scoreboard-team-backed red Tyrant name and glowing outline that is restored on login and removed on pause or shutdown.
- Verified succession attribution, random exclusion, reign cleanup, role separation, death integration, and red presentation with the Gradle suite.
## 2026-08-14 — Tyrant succession implementation started
- US-002 begins with test-first killer and environmental succession, reign-wide cleanup, replacement Vigilante selection, and red Tyrant presentation.
## 2026-08-14 — Administrative lifecycle completed
- Completed US-001 with permission-gated `/tyrantadmin` start, status, pause, resume, and confirmed reset operations.
- Initial Vigilantes may be assigned explicitly or selected among eligible recently active players, with offline selections persisted as pending confirmations.
- Pause/resume preserves progression and assignments, shifts every cooldown, participation, inactivity, and pending-selection deadline, and invokes managed effect suppression/restoration.
- Added detailed operator status output and verified lifecycle, timer shifting, permissions, confirmation safety, candidate filtering, metadata, and effect hooks with the Gradle suite.
## 2026-08-14 — Administrative lifecycle implementation started
- US-001 begins with test-first game start, pause/resume deadline freezing, permission-gated administration, inspection, and confirmed reset behavior.
## 2026-08-14 — Tyrant class assignment completed
- Completed US-004 with `/tyrant assign <class> <player>`, purchased-class authorization, UUID state, one holder per class, and one Tyrant-side class per player.
- Preserved dual alignment so Vigilantes and Followers can hold a Tyrant-side class, while rejecting the Tyrant and opted-out targets.
- Added reassignment and reign cleanup for class cooldown/item state, online assignment/removal notices, and login control reminders.
- Verified assignment constraints, dual alignment, cleanup, commands, notifications, and reminders with the Gradle test suite.
## 2026-08-14 — Tyrant class assignment implementation started
- US-004 begins with test-first purchased-class authorization, one-holder constraints, dual alignment, reassignment cleanup, and reign-wide removal.
## 2026-08-14 — Tyrant progression completed
- Completed US-003 with exact Tyrant-on-Vigilante kill attribution, level and choice rewards, pause guards, one-time purchases, and reign reset behavior.
- Added `/tyrant choices` status output and `/tyrant buy <unlock>` with durable atomic state updates and clear failure feedback.
- Verified progression, duplicate purchase, authorization, paused-state, reset, command presentation, metadata, and persistence behavior with the Gradle test suite.
## 2026-08-14 — Tyrant progression implementation started
- US-003 begins with test-first kill attribution, one-time unlock purchases, and player-visible choice status.
## 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-001: Start, pause, and administer the game"
description: Give server operators safe control over the Tyrant game's lifecycle and state.
status: backlog
status: done
---
# US-001: Start, pause, and administer the game
@@ -11,14 +11,14 @@ As a **server operator**, I want to start, pause, resume, and inspect the game s
## Acceptance criteria
- [ ] An administrator can start the first game by assigning the initial Tyrant.
- [ ] The administrator may assign the first Vigilante or allow random selection from eligible recently active players.
- [ ] An administrator can pause and resume the game without deleting game state.
- [ ] Pausing freezes progression, cooldowns, daily-use timers, inactivity checks, and pending-selection deadlines.
- [ ] Role and class effects are suppressed while paused and restored when play resumes.
- [ ] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default.
- [ ] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, and pending selections.
- [ ] Destructive administrative operations require explicit confirmation.
- [x] An administrator can start the first game by assigning the initial Tyrant.
- [x] The administrator may assign the first Vigilante or allow random selection from eligible recently active players.
- [x] An administrator can pause and resume the game without deleting game state.
- [x] Pausing freezes progression, cooldowns, daily-use timers, inactivity checks, and pending-selection deadlines.
- [x] Role and class effects are suppressed while paused and restored when play resumes.
- [x] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default.
- [x] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, and pending selections.
- [x] Destructive administrative operations require explicit confirmation.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-002: Transfer and reset the Tyrant role"
description: End a Tyrant's reign cleanly and establish a valid successor after death.
status: backlog
status: done
---
# US-002: Transfer and reset the Tyrant role
@@ -11,15 +11,15 @@ As a **player**, I want defeating the Tyrant to transfer power predictably so th
## Acceptance criteria
- [ ] A player directly credited with killing the Tyrant becomes the new Tyrant.
- [ ] A Tyrant death without a player killer selects an eligible opted-in player active within the preceding 24 hours at random.
- [ ] The former Tyrant is excluded from random succession when another eligible candidate exists.
- [ ] The former Tyrant's level, unlocks, unspent choices, and Tyrant ability cooldowns reset when the reign ends.
- [ ] Every Assassin, Fixer, Tamer, Vigilante, and Follower assignment is cleared when the Tyrant dies.
- [ ] A new Vigilante is selected under the normal candidate rules after succession.
- [ ] The new Tyrant receives exactly one starting unlock choice.
- [ ] The Tyrant's displayed name is red and the Tyrant has a red glowing outline while the game is running.
- [ ] Succession cannot assign the same player as both Tyrant and Vigilante.
- [x] A player directly credited with killing the Tyrant becomes the new Tyrant.
- [x] A Tyrant death without a player killer selects an eligible opted-in player active within the preceding 24 hours at random.
- [x] The former Tyrant is excluded from random succession when another eligible candidate exists.
- [x] The former Tyrant's level, unlocks, unspent choices, and Tyrant ability cooldowns reset when the reign ends.
- [x] Every Assassin, Fixer, Tamer, Vigilante, and Follower assignment is cleared when the Tyrant dies.
- [x] A new Vigilante is selected under the normal candidate rules after succession.
- [x] The new Tyrant receives exactly one starting unlock choice.
- [x] The Tyrant's displayed name is red and the Tyrant has a red glowing outline while the game is running.
- [x] Succession cannot assign the same player as both Tyrant and Vigilante.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-003: Progress and purchase Tyrant unlocks"
description: Let the Tyrant earn and spend choices by personally defeating the Vigilante.
status: backlog
status: done
---
# US-003: Progress and purchase Tyrant unlocks
@@ -11,14 +11,14 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
## Acceptance criteria
- [ ] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante.
- [ ] A Vigilante death caused by another player, the environment, or the Vigilante does not level the Tyrant.
- [ ] Available purchases are Assassin, Fixer, Tamer, roster intelligence, permanent Resistance, and permanent Strength.
- [ ] Each class or ability can be purchased at most once during a reign.
- [ ] A menu or command clearly identifies purchased, available, and unavailable choices.
- [ ] A purchase consumes exactly one unspent choice and cannot be duplicated by retries or concurrent input.
- [ ] Progress cannot be earned or spent while the game is paused.
- [ ] Levels, purchases, and unspent choices survive logout and restart but reset when the reign ends.
- [x] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante.
- [x] A Vigilante death caused by another player, the environment, or the Vigilante does not level the Tyrant.
- [x] Available purchases are Assassin, Fixer, Tamer, roster intelligence, permanent Resistance, and permanent Strength.
- [x] Each class or ability can be purchased at most once during a reign.
- [x] A menu or command clearly identifies purchased, available, and unavailable choices.
- [x] A purchase consumes exactly one unspent choice and cannot be duplicated by retries or concurrent input.
- [x] Progress cannot be earned or spent while the game is paused.
- [x] Levels, purchases, and unspent choices survive logout and restart but reset when the reign ends.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-004: Assign unlocked classes"
description: Let the Tyrant assign each purchased class to one eligible participant.
status: backlog
status: done
---
# US-004: Assign unlocked classes
@@ -11,14 +11,14 @@ As the **Tyrant**, I want to assign unlocked specialist classes so that I can bu
## Acceptance criteria
- [ ] The Tyrant can assign only Assassin, Fixer, and Tamer classes purchased during the current reign.
- [ ] Each class can be assigned to at most one player at a time, and a player can hold at most one Tyrant-side class.
- [ ] Role alignment and Tyrant-side class are independent: the Vigilante or a Follower may simultaneously be the Assassin, Fixer, or Tamer.
- [ ] The Tyrant cannot assign a class to the Tyrant, an opted-out player, or another ineligible player.
- [ ] Reassignment removes the class and its bound items and effects from the previous holder before granting it to the new holder.
- [ ] Assignment and removal are stored by UUID and communicated clearly to affected players.
- [ ] A class holder is reminded of the assignment and its controls on login.
- [ ] All class assignments end when the Tyrant's reign ends.
- [x] The Tyrant can assign only Assassin, Fixer, and Tamer classes purchased during the current reign.
- [x] Each class can be assigned to at most one player at a time, and a player can hold at most one Tyrant-side class.
- [x] Role alignment and Tyrant-side class are independent: the Vigilante or a Follower may simultaneously be the Assassin, Fixer, or Tamer.
- [x] The Tyrant cannot assign a class to the Tyrant, an opted-out player, or another ineligible player.
- [x] Reassignment removes the class and its bound items and effects from the previous holder before granting it to the new holder.
- [x] Assignment and removal are stored by UUID and communicated clearly to affected players.
- [x] A class holder is reminded of the assignment and its controls on login.
- [x] All class assignments end when the Tyrant's reign ends.
## Related
@@ -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,28 @@
package games.dmg.spigottyrant;
public final class BukkitManagedRoleEffects implements ManagedRoleEffects {
private final TyrantStateManager stateManager;
private final TyrantPresentation presentation;
public BukkitManagedRoleEffects(
TyrantStateManager stateManager,
TyrantPresentation presentation
) {
this.stateManager = stateManager;
this.presentation = presentation;
}
@Override
public void suppressAll() {
presentation.clear();
}
@Override
public void restoreAll() {
if (stateManager.game().lifecycle() == GameLifecycle.RUNNING) {
presentation.reconcile(stateManager.game().tyrantId());
} else {
presentation.clear();
}
}
}
@@ -0,0 +1,17 @@
package games.dmg.spigottyrant;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
public final class BukkitOnlinePlayerDirectory implements OnlinePlayerDirectory {
@Override
public Player findByName(String name) {
return Bukkit.getPlayerExact(name);
}
@Override
public Player findById(UUID playerId) {
return Bukkit.getPlayer(playerId);
}
}
@@ -0,0 +1,55 @@
package games.dmg.spigottyrant;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.bukkit.scoreboard.Team;
public final class BukkitTyrantPresentation implements TyrantPresentation {
private static final String TEAM_NAME = "tyrant-red";
private final Server server;
private final Team team;
private Optional<UUID> presentedTyrant = Optional.empty();
public BukkitTyrantPresentation(Server server) {
this.server = server;
ScoreboardManager manager = Objects.requireNonNull(
server.getScoreboardManager(), "Scoreboard manager is unavailable"
);
Scoreboard scoreboard = manager.getMainScoreboard();
Team existing = scoreboard.getTeam(TEAM_NAME);
team = existing == null ? scoreboard.registerNewTeam(TEAM_NAME) : existing;
team.setColor(ChatColor.RED);
}
@Override
public void reconcile(Optional<UUID> tyrantId) {
clear();
presentedTyrant = tyrantId;
tyrantId.map(server::getPlayer).ifPresent(player -> {
team.addEntry(player.getName());
player.setGlowing(true);
player.setPlayerListName(ChatColor.RED + player.getName());
player.setDisplayName(ChatColor.RED + player.getName());
});
}
@Override
public void clear() {
presentedTyrant.map(server::getPlayer).ifPresent(player -> {
player.setGlowing(false);
player.setPlayerListName(player.getName());
player.setDisplayName(player.getName());
});
for (String entry : new HashSet<>(team.getEntries())) {
team.removeEntry(entry);
}
presentedTyrant = Optional.empty();
}
}
@@ -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,18 @@
package games.dmg.spigottyrant;
public final class ClassAssignmentMessages {
private ClassAssignmentMessages() {
}
public static String loginReminder(PlayerState player) {
return switch (player.tyrantClass()) {
case ASSASSIN -> "Class: ASSASSIN. Use your Assassin Cloak; double-jump while "
+ "airborne. Use /tyrant item to recover ready class items.";
case FIXER -> "Class: FIXER. Use your Fixer's Wrench to activate your boost. "
+ "Use /tyrant item to recover ready class items.";
case TAMER -> "Class: TAMER. Use your Tamer's Lead to capture mobs. "
+ "Use /tyrant item to recover ready class items.";
case NONE -> "";
};
}
}
@@ -0,0 +1,16 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public record ClassAssignmentResult(
Map<UUID, PlayerState> players,
ClassAssignmentStatus status,
Optional<UUID> previousHolderId
) {
public ClassAssignmentResult {
players = Map.copyOf(players);
previousHolderId = previousHolderId == null ? Optional.empty() : previousHolderId;
}
}
@@ -0,0 +1,129 @@
package games.dmg.spigottyrant;
import java.time.Instant;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public final class ClassAssignmentService {
public ClassAssignmentResult assign(
GameState game,
Map<UUID, PlayerState> players,
UUID assignerId,
UUID targetId,
TyrantClass tyrantClass,
Instant now
) {
ClassAssignmentStatus rejected = rejection(
game, players, assignerId, targetId, tyrantClass, now
);
if (rejected != null) {
return new ClassAssignmentResult(players, rejected, Optional.empty());
}
Map<UUID, PlayerState> updated = new HashMap<>(players);
Optional<UUID> previousHolder = players.values().stream()
.filter(player -> player.tyrantClass() == tyrantClass)
.map(PlayerState::playerId)
.filter(playerId -> !playerId.equals(targetId))
.findFirst();
previousHolder.ifPresent(playerId -> updated.put(
playerId,
withClass(updated.get(playerId), TyrantClass.NONE)
));
updated.put(targetId, withClass(updated.get(targetId), tyrantClass));
return new ClassAssignmentResult(
updated,
ClassAssignmentStatus.ASSIGNED,
previousHolder
);
}
public Map<UUID, PlayerState> clearAll(Map<UUID, PlayerState> players) {
Map<UUID, PlayerState> updated = new HashMap<>();
for (PlayerState player : players.values()) {
updated.put(player.playerId(), withClass(player, TyrantClass.NONE));
}
return Map.copyOf(updated);
}
private static ClassAssignmentStatus rejection(
GameState game,
Map<UUID, PlayerState> players,
UUID assignerId,
UUID targetId,
TyrantClass tyrantClass,
Instant now
) {
if (game.lifecycle() != GameLifecycle.RUNNING) {
return ClassAssignmentStatus.GAME_NOT_RUNNING;
}
if (game.tyrantId().isEmpty() || !game.tyrantId().orElseThrow().equals(assignerId)) {
return ClassAssignmentStatus.NOT_TYRANT;
}
if (tyrantClass == null || tyrantClass == TyrantClass.NONE) {
return ClassAssignmentStatus.INVALID_CLASS;
}
if (!game.purchases().contains(unlockFor(tyrantClass))) {
return ClassAssignmentStatus.CLASS_NOT_UNLOCKED;
}
PlayerState target = players.get(targetId);
if (target == null) {
return ClassAssignmentStatus.UNKNOWN_PLAYER;
}
if (targetId.equals(assignerId)) {
return ClassAssignmentStatus.TARGET_IS_TYRANT;
}
if (target.optedOutUntil().filter(until -> until.isAfter(now)).isPresent()) {
return ClassAssignmentStatus.TARGET_OPTED_OUT;
}
return null;
}
private static TyrantUnlock unlockFor(TyrantClass tyrantClass) {
return switch (tyrantClass) {
case ASSASSIN -> TyrantUnlock.ASSASSIN;
case FIXER -> TyrantUnlock.FIXER;
case TAMER -> TyrantUnlock.TAMER;
case NONE -> throw new IllegalArgumentException("NONE is not assignable");
};
}
private static PlayerState withClass(PlayerState player, TyrantClass tyrantClass) {
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
cooldowns.putAll(player.cooldownEnds());
Set<Ability> readyItems = EnumSet.noneOf(Ability.class);
readyItems.addAll(player.readyAbilityItems());
for (Ability ability : abilitiesFor(player.tyrantClass())) {
cooldowns.remove(ability);
readyItems.remove(ability);
}
return new PlayerState(
player.playerId(),
player.latestName(),
player.lastLogin(),
player.optedOutUntil(),
tyrantClass,
player.followerOf(),
cooldowns,
readyItems,
player.capturedMobs()
);
}
private static Set<Ability> abilitiesFor(TyrantClass tyrantClass) {
return switch (tyrantClass) {
case ASSASSIN -> Set.of(
Ability.ASSASSIN_INVISIBILITY,
Ability.ASSASSIN_DOUBLE_JUMP
);
case FIXER -> Set.of(Ability.FIXER_BOOST);
case TAMER -> Set.of(Ability.TAMER_CAPTURE);
case NONE -> Set.of();
};
}
}
@@ -0,0 +1,12 @@
package games.dmg.spigottyrant;
public enum ClassAssignmentStatus {
ASSIGNED,
GAME_NOT_RUNNING,
NOT_TYRANT,
CLASS_NOT_UNLOCKED,
INVALID_CLASS,
UNKNOWN_PLAYER,
TARGET_IS_TYRANT,
TARGET_OPTED_OUT
}
@@ -0,0 +1,7 @@
package games.dmg.spigottyrant;
public enum GameLifecycle {
UNSTARTED,
RUNNING,
PAUSED
}
@@ -0,0 +1,137 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public final class GameLifecycleService {
public LifecycleState start(
PersistentState state,
UUID tyrantId,
Optional<UUID> vigilanteId,
Instant now
) {
if (state.game().lifecycle() != GameLifecycle.UNSTARTED) {
throw new IllegalStateException("game has already been started");
}
if (vigilanteId.filter(tyrantId::equals).isPresent()) {
throw new IllegalArgumentException("Tyrant and Vigilante must be different players");
}
GameState game = new GameState(
GameLifecycle.RUNNING,
Optional.of(tyrantId),
vigilanteId,
Optional.empty(),
Optional.empty(),
Optional.empty(),
Duration.ZERO,
0,
1,
Set.of()
);
return new LifecycleState(game, state.players());
}
public LifecycleState pause(PersistentState state, Instant now) {
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
throw new IllegalStateException("only a running game can be paused");
}
GameState current = state.game();
GameState paused = new GameState(
GameLifecycle.PAUSED,
current.tyrantId(),
current.vigilanteId(),
current.pendingTyrant(),
current.pendingVigilante(),
Optional.of(now),
current.accumulatedPausedTime(),
current.tyrantLevel(),
current.unspentChoices(),
current.purchases()
);
return new LifecycleState(paused, state.players());
}
public LifecycleState resume(PersistentState state, Instant now) {
GameState current = state.game();
if (current.lifecycle() != GameLifecycle.PAUSED || current.pausedAt().isEmpty()) {
throw new IllegalStateException("only a paused game can be resumed");
}
Instant pausedAt = current.pausedAt().orElseThrow();
Duration pausedFor = Duration.between(pausedAt, now);
if (pausedFor.isNegative()) {
throw new IllegalArgumentException("resume time must not precede pause time");
}
GameState resumed = new GameState(
GameLifecycle.RUNNING,
current.tyrantId(),
current.vigilanteId(),
shiftSelection(current.pendingTyrant(), pausedFor),
shiftSelection(current.pendingVigilante(), pausedFor),
Optional.empty(),
current.accumulatedPausedTime().plus(pausedFor),
current.tyrantLevel(),
current.unspentChoices(),
current.purchases()
);
Map<UUID, PlayerState> players = new HashMap<>();
for (PlayerState player : state.players().values()) {
players.put(player.playerId(), shift(player, pausedFor));
}
return new LifecycleState(resumed, players);
}
public LifecycleState reset(PersistentState state) {
Map<UUID, PlayerState> players = new HashMap<>();
for (PlayerState player : state.players().values()) {
players.put(player.playerId(), new PlayerState(
player.playerId(),
player.latestName(),
player.lastLogin(),
player.optedOutUntil(),
TyrantClass.NONE,
Optional.empty(),
Map.of(),
Set.of(),
player.capturedMobs()
));
}
return new LifecycleState(GameState.empty(), players);
}
private static Optional<PendingSelection> shiftSelection(
Optional<PendingSelection> selection,
Duration pausedFor
) {
return selection.map(value -> new PendingSelection(
value.candidateId(), value.expiresAt().plus(pausedFor)
));
}
private static PlayerState shift(PlayerState player, Duration pausedFor) {
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
player.cooldownEnds().forEach((ability, deadline) ->
cooldowns.put(ability, deadline.plus(pausedFor))
);
return new PlayerState(
player.playerId(),
player.latestName(),
shiftInstant(player.lastLogin(), pausedFor),
shiftInstant(player.optedOutUntil(), pausedFor),
player.tyrantClass(),
player.followerOf(),
cooldowns,
player.readyAbilityItems(),
player.capturedMobs()
);
}
private static Optional<Instant> shiftInstant(Optional<Instant> value, Duration duration) {
return value.map(instant -> instant.plus(duration));
}
}
@@ -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,14 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.UUID;
public record LifecycleState(GameState game, Map<UUID, PlayerState> players) {
public LifecycleState {
players = Map.copyOf(players);
}
public PersistentState asPersistentState() {
return new PersistentState(game, players);
}
}
@@ -0,0 +1,7 @@
package games.dmg.spigottyrant;
public interface ManagedRoleEffects {
void suppressAll();
void restoreAll();
}
@@ -0,0 +1,13 @@
package games.dmg.spigottyrant;
public final class NoopManagedRoleEffects implements ManagedRoleEffects {
@Override
public void suppressAll() {
// No managed effects exist until their corresponding abilities are unlocked.
}
@Override
public void restoreAll() {
// No managed effects exist until their corresponding abilities are unlocked.
}
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
import java.util.UUID;
import org.bukkit.entity.Player;
public interface OnlinePlayerDirectory {
Player findByName(String name);
Player findById(UUID playerId);
}
@@ -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,49 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
public final class PlayerJoinListener implements Listener {
private final TyrantStateManager stateManager;
private final Clock clock;
private final TyrantPresentation presentation;
public PlayerJoinListener(TyrantStateManager stateManager, Clock clock) {
this(stateManager, clock, null);
}
public PlayerJoinListener(
TyrantStateManager stateManager,
Clock clock,
TyrantPresentation presentation
) {
this.stateManager = stateManager;
this.clock = clock;
this.presentation = presentation;
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
PlayerState player = stateManager.updatePlayer(
event.getPlayer().getUniqueId(),
event.getPlayer().getName(),
current -> new PlayerState(
current.playerId(), current.latestName(), java.util.Optional.of(clock.instant()),
current.optedOutUntil(), current.tyrantClass(), current.followerOf(),
current.cooldownEnds(), current.readyAbilityItems(), current.capturedMobs()
)
);
String reminder = ClassAssignmentMessages.loginReminder(player);
if (!reminder.isEmpty()) {
event.getPlayer().sendMessage(reminder);
}
if (presentation != null
&& stateManager.game().lifecycle() == GameLifecycle.RUNNING
&& stateManager.game().tyrantId()
.filter(event.getPlayer().getUniqueId()::equals).isPresent()) {
presentation.reconcile(stateManager.game().tyrantId());
}
}
}
@@ -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,10 @@
package games.dmg.spigottyrant;
import java.util.Objects;
public record PurchaseResult(GameState state, PurchaseStatus status) {
public PurchaseResult {
Objects.requireNonNull(state, "state");
Objects.requireNonNull(status, "status");
}
}
@@ -0,0 +1,9 @@
package games.dmg.spigottyrant;
public enum PurchaseStatus {
PURCHASED,
GAME_NOT_RUNNING,
NOT_TYRANT,
NO_CHOICES,
ALREADY_PURCHASED
}
@@ -0,0 +1,47 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
public final class RoleCandidateSelector {
public Optional<UUID> select(
Map<UUID, PlayerState> players,
Set<UUID> excluded,
Optional<UUID> previousHolder,
Instant now,
Duration activityWindow,
RandomGenerator random
) {
Instant cutoff = now.minus(activityWindow);
List<UUID> eligible = new ArrayList<>();
for (PlayerState player : players.values()) {
if (excluded.contains(player.playerId())) {
continue;
}
if (player.lastLogin().isEmpty()
|| player.lastLogin().orElseThrow().isBefore(cutoff)) {
continue;
}
if (player.optedOutUntil().filter(deadline -> deadline.isAfter(now)).isPresent()) {
continue;
}
eligible.add(player.playerId());
}
if (eligible.size() > 1) {
previousHolder.ifPresent(eligible::remove);
}
if (eligible.isEmpty()) {
return Optional.empty();
}
eligible.sort(Comparator.comparing(UUID::toString));
return Optional.of(eligible.get(random.nextInt(eligible.size())));
}
}
@@ -0,0 +1,96 @@
package games.dmg.spigottyrant;
import java.io.IOException;
import java.time.Clock;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotTyrantPlugin extends JavaPlugin {
private PluginSettings settings;
private TyrantStateManager stateManager;
private TyrantPresentation tyrantPresentation;
@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;
}
tyrantPresentation = new BukkitTyrantPresentation(getServer());
ManagedRoleEffects managedEffects = new BukkitManagedRoleEffects(
stateManager, tyrantPresentation
);
managedEffects.restoreAll();
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
.setExecutor(new TyrantCommand(stateManager, new TyrantProgressionService()));
Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata")
.setExecutor(new TyrantAdminCommand(
stateManager,
new GameLifecycleService(),
new BukkitOnlinePlayerDirectory(),
managedEffects,
Clock.systemUTC(),
new RoleCandidateSelector(),
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
java.util.random.RandomGenerator.getDefault()
));
getServer().getPluginManager().registerEvents(
new PlayerJoinListener(stateManager, Clock.systemUTC(), tyrantPresentation),
this
);
getServer().getPluginManager().registerEvents(
new TyrantDeathListener(
stateManager,
new TyrantSuccessionService(
new RoleCandidateSelector(),
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
java.util.random.RandomGenerator.getDefault()
),
tyrantPresentation,
getServer(),
Clock.systemUTC()
),
this
);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Tyrant enabled.");
}
@Override
public void onDisable() {
if (tyrantPresentation != null) {
tyrantPresentation.clear();
}
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,209 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public final class TyrantAdminCommand implements CommandExecutor {
private static final String PERMISSION = "spigottyrant.admin";
private final TyrantStateManager stateManager;
private final GameLifecycleService lifecycle;
private final OnlinePlayerDirectory onlinePlayers;
private final ManagedRoleEffects effects;
private final Clock clock;
private final RoleCandidateSelector candidateSelector;
private final Duration activityWindow;
private final Duration pendingTimeout;
private final RandomGenerator random;
public TyrantAdminCommand(
TyrantStateManager stateManager,
GameLifecycleService lifecycle,
OnlinePlayerDirectory onlinePlayers,
ManagedRoleEffects effects,
Clock clock
) {
this(
stateManager, lifecycle, onlinePlayers, effects, clock,
new RoleCandidateSelector(), Duration.ofHours(24), Duration.ofHours(24),
RandomGenerator.getDefault()
);
}
public TyrantAdminCommand(
TyrantStateManager stateManager,
GameLifecycleService lifecycle,
OnlinePlayerDirectory onlinePlayers,
ManagedRoleEffects effects,
Clock clock,
RoleCandidateSelector candidateSelector,
Duration activityWindow,
Duration pendingTimeout,
RandomGenerator random
) {
this.stateManager = stateManager;
this.lifecycle = lifecycle;
this.onlinePlayers = onlinePlayers;
this.effects = effects;
this.clock = clock;
this.candidateSelector = candidateSelector;
this.activityWindow = activityWindow;
this.pendingTimeout = pendingTimeout;
this.random = random;
}
@Override
public boolean onCommand(
CommandSender sender,
Command command,
String label,
String[] arguments
) {
if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage(ChatColor.RED + "You do not have permission to administer Tyrant.");
return true;
}
if (arguments.length == 0 || arguments[0].equalsIgnoreCase("status")) {
showStatus(sender);
return true;
}
try {
switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "start" -> start(sender, arguments);
case "pause" -> pause(sender);
case "resume" -> resume(sender);
case "reset" -> reset(sender, arguments);
default -> usage(sender);
}
} catch (IllegalArgumentException | IllegalStateException exception) {
sender.sendMessage(ChatColor.RED + exception.getMessage());
}
return true;
}
private void start(CommandSender sender, String[] arguments) {
if (arguments.length < 2 || arguments.length > 3) {
sender.sendMessage(ChatColor.YELLOW
+ "Usage: /tyrantadmin start <tyrant> [vigilante]");
return;
}
Player tyrant = onlinePlayers.findByName(arguments[1]);
if (tyrant == null) {
sender.sendMessage(ChatColor.RED + "The initial Tyrant must be online.");
return;
}
Optional<Player> vigilante = Optional.empty();
if (arguments.length == 3) {
vigilante = Optional.ofNullable(onlinePlayers.findByName(arguments[2]));
if (vigilante.isEmpty()) {
sender.sendMessage(ChatColor.RED + "The initial Vigilante must be online.");
return;
}
}
stateManager.player(tyrant.getUniqueId(), tyrant.getName());
vigilante.ifPresent(player -> stateManager.player(player.getUniqueId(), player.getName()));
InstantHolder now = new InstantHolder(clock.instant());
Optional<UUID> randomCandidate = vigilante.isEmpty()
? candidateSelector.select(
stateManager.players(), Set.of(tyrant.getUniqueId()), Optional.empty(),
now.value(), activityWindow, random
)
: Optional.empty();
Optional<UUID> activeVigilante = vigilante.map(Player::getUniqueId);
if (activeVigilante.isEmpty() && randomCandidate.isPresent()
&& onlinePlayers.findById(randomCandidate.orElseThrow()) != null) {
activeVigilante = randomCandidate;
}
LifecycleState started = lifecycle.start(
stateManager.snapshot(), tyrant.getUniqueId(), activeVigilante, now.value()
);
if (activeVigilante.isEmpty() && randomCandidate.isPresent()) {
GameState game = started.game();
started = new LifecycleState(new GameState(
game.lifecycle(), game.tyrantId(), game.vigilanteId(), game.pendingTyrant(),
Optional.of(new PendingSelection(
randomCandidate.orElseThrow(), now.value().plus(pendingTimeout)
)),
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
game.unspentChoices(), game.purchases()
), started.players());
}
stateManager.replaceState(started);
stateManager.saveIfDirty();
effects.restoreAll();
sender.sendMessage(ChatColor.GREEN + "Tyrant game started.");
if (vigilante.isEmpty()) {
sender.sendMessage(ChatColor.YELLOW
+ "An eligible recently active Vigilante will be selected automatically.");
}
}
private void pause(CommandSender sender) {
LifecycleState paused = lifecycle.pause(stateManager.snapshot(), clock.instant());
stateManager.replaceState(paused);
stateManager.saveIfDirty();
effects.suppressAll();
sender.sendMessage(ChatColor.GREEN + "Tyrant game paused.");
}
private void resume(CommandSender sender) {
LifecycleState resumed = lifecycle.resume(stateManager.snapshot(), clock.instant());
stateManager.replaceState(resumed);
stateManager.saveIfDirty();
effects.restoreAll();
sender.sendMessage(ChatColor.GREEN + "Tyrant game resumed.");
}
private void reset(CommandSender sender, String[] arguments) {
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) {
sender.sendMessage(ChatColor.RED
+ "This clears the game. Use /tyrantadmin reset confirm.");
return;
}
stateManager.replaceState(lifecycle.reset(stateManager.snapshot()));
stateManager.saveIfDirty();
effects.suppressAll();
sender.sendMessage(ChatColor.GREEN + "Tyrant game reset.");
}
private void showStatus(CommandSender sender) {
GameState game = stateManager.game();
sender.sendMessage(ChatColor.GOLD + "Lifecycle: " + game.lifecycle());
sender.sendMessage("Tyrant: " + game.tyrantId().map(Object::toString).orElse("none"));
sender.sendMessage("Vigilante: "
+ game.vigilanteId().map(Object::toString).orElse("none"));
sender.sendMessage("Level: " + game.tyrantLevel()
+ ", choices: " + game.unspentChoices()
+ ", purchases: " + game.purchases());
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
for (PlayerState player : stateManager.players().values()) {
boolean optedOut = player.optedOutUntil()
.filter(deadline -> deadline.isAfter(clock.instant()))
.isPresent();
sender.sendMessage(
player.latestName() + " (" + player.playerId() + "): class="
+ player.tyrantClass() + ", followerOf="
+ player.followerOf().map(Object::toString).orElse("none")
+ ", optedOut=" + optedOut + ", cooldowns=" + player.cooldownEnds()
);
}
}
private record InstantHolder(java.time.Instant value) {
}
private static void usage(CommandSender sender) {
sender.sendMessage(ChatColor.YELLOW
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm>");
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
public enum TyrantClass {
NONE,
ASSASSIN,
FIXER,
TAMER
}
@@ -0,0 +1,162 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Locale;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public final class TyrantCommand implements CommandExecutor {
private final TyrantStateManager stateManager;
private final TyrantProgressionService progression;
private final ClassAssignmentService assignments;
private final OnlinePlayerDirectory onlinePlayers;
private final Clock clock;
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression
) {
this(
stateManager,
progression,
new ClassAssignmentService(),
new BukkitOnlinePlayerDirectory(),
Clock.systemUTC()
);
}
TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
ClassAssignmentService assignments,
OnlinePlayerDirectory onlinePlayers,
Clock clock
) {
this.stateManager = stateManager;
this.progression = progression;
this.assignments = assignments;
this.onlinePlayers = onlinePlayers;
this.clock = clock;
}
@Override
public boolean onCommand(
CommandSender sender,
Command command,
String label,
String[] arguments
) {
if (!(sender instanceof Player player)) {
sender.sendMessage("This command must be used by a player.");
return true;
}
if (arguments.length == 0 || arguments[0].equalsIgnoreCase("choices")) {
showChoices(player);
return true;
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("buy")) {
buy(player, arguments[1]);
return true;
}
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("assign")) {
assign(player, arguments[1], arguments[2]);
return true;
}
player.sendMessage(
ChatColor.YELLOW
+ "Usage: /tyrant choices | /tyrant buy <unlock> | "
+ "/tyrant assign <class> <player>"
);
return true;
}
private void showChoices(Player player) {
GameState state = stateManager.game();
player.sendMessage(ChatColor.GOLD + "Tyrant unlock choices: " + state.unspentChoices());
for (TyrantUnlock unlock : TyrantUnlock.values()) {
String status;
if (state.purchases().contains(unlock)) {
status = "PURCHASED";
} else if (state.lifecycle() == GameLifecycle.RUNNING
&& state.tyrantId().filter(player.getUniqueId()::equals).isPresent()
&& state.unspentChoices() > 0) {
status = "AVAILABLE";
} else {
status = "LOCKED";
}
player.sendMessage(ChatColor.YELLOW + "[" + status + "] " + unlock.name());
}
}
private void buy(Player player, String requestedUnlock) {
TyrantUnlock unlock;
try {
unlock = TyrantUnlock.valueOf(requestedUnlock.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED + "Unknown unlock. Use /tyrant choices.");
return;
}
PurchaseResult[] result = new PurchaseResult[1];
stateManager.updateGame(current -> {
result[0] = progression.purchase(current, player.getUniqueId(), unlock);
return result[0].state();
});
if (result[0].status() == PurchaseStatus.PURCHASED) {
stateManager.saveIfDirty();
player.sendMessage(ChatColor.GREEN + "Purchased " + unlock.name() + ".");
} else {
player.sendMessage(
ChatColor.RED + "Could not purchase " + unlock.name() + ": "
+ readable(result[0].status())
);
}
}
private void assign(Player tyrant, String requestedClass, String targetName) {
TyrantClass tyrantClass;
try {
tyrantClass = TyrantClass.valueOf(requestedClass.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
tyrant.sendMessage(ChatColor.RED + "Unknown class. Use assassin, fixer, or tamer.");
return;
}
Player target = onlinePlayers.findByName(targetName);
if (target == null) {
tyrant.sendMessage(ChatColor.RED + "That player must be online.");
return;
}
stateManager.player(target.getUniqueId(), target.getName());
ClassAssignmentResult result = assignments.assign(
stateManager.game(),
stateManager.players(),
tyrant.getUniqueId(),
target.getUniqueId(),
tyrantClass,
clock.instant()
);
if (result.status() != ClassAssignmentStatus.ASSIGNED) {
tyrant.sendMessage(
ChatColor.RED + "Could not assign " + tyrantClass.name() + ": "
+ readable(result.status())
);
return;
}
stateManager.replacePlayers(result.players());
stateManager.saveIfDirty();
result.previousHolderId().map(onlinePlayers::findById).ifPresent(previous ->
previous.sendMessage(ChatColor.YELLOW + "You are no longer the "
+ tyrantClass.name() + ".")
);
target.sendMessage(ChatColor.GREEN + "You are now the " + tyrantClass.name() + ".");
tyrant.sendMessage(
ChatColor.GREEN + "Assigned " + tyrantClass.name() + " to " + target.getName() + "."
);
}
private static String readable(Enum<?> status) {
return status.name().toLowerCase(Locale.ROOT).replace('_', ' ');
}
}
@@ -0,0 +1,65 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.bukkit.ChatColor;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
public final class TyrantDeathListener implements Listener {
private final TyrantStateManager stateManager;
private final TyrantSuccessionService succession;
private final TyrantPresentation presentation;
private final Server server;
private final Clock clock;
public TyrantDeathListener(
TyrantStateManager stateManager,
TyrantSuccessionService succession,
TyrantPresentation presentation,
Server server,
Clock clock
) {
this.stateManager = stateManager;
this.succession = succession;
this.presentation = presentation;
this.server = server;
this.clock = clock;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onDeath(PlayerDeathEvent event) {
UUID victimId = event.getEntity().getUniqueId();
GameState game = stateManager.game();
if (game.lifecycle() != GameLifecycle.RUNNING
|| game.tyrantId().filter(victimId::equals).isEmpty()) {
return;
}
Optional<UUID> killerId = Optional.ofNullable(event.getEntity().getKiller())
.map(Player::getUniqueId);
Set<UUID> online = server.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(Collectors.toUnmodifiableSet());
LifecycleState succeeded = succession.succeed(
stateManager.snapshot(), killerId, online, clock.instant()
);
stateManager.replaceState(succeeded);
stateManager.saveIfDirty();
presentation.reconcile(succeeded.game().tyrantId());
String successor = succeeded.game().tyrantId()
.map(id -> {
Player player = server.getPlayer(id);
return player == null ? id.toString() : player.getName();
})
.orElse("pending selection");
String message = ChatColor.RED + successor + " is the new Tyrant!";
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
public interface TyrantPresentation {
void reconcile(Optional<UUID> tyrantId);
void clear();
}
@@ -0,0 +1,75 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
public final class TyrantProgressionService {
public GameState recordVigilanteDeath(GameState state, Optional<UUID> killerId) {
if (state.lifecycle() != GameLifecycle.RUNNING
|| state.tyrantId().isEmpty()
|| state.vigilanteId().isEmpty()
|| killerId.isEmpty()
|| !state.tyrantId().orElseThrow().equals(killerId.orElseThrow())) {
return state;
}
return copyProgress(
state,
state.tyrantLevel() + 1,
state.unspentChoices() + 1,
state.purchases()
);
}
public PurchaseResult purchase(
GameState state,
UUID buyerId,
TyrantUnlock unlock
) {
if (state.lifecycle() != GameLifecycle.RUNNING) {
return new PurchaseResult(state, PurchaseStatus.GAME_NOT_RUNNING);
}
if (state.tyrantId().isEmpty() || !state.tyrantId().orElseThrow().equals(buyerId)) {
return new PurchaseResult(state, PurchaseStatus.NOT_TYRANT);
}
if (state.purchases().contains(unlock)) {
return new PurchaseResult(state, PurchaseStatus.ALREADY_PURCHASED);
}
if (state.unspentChoices() == 0) {
return new PurchaseResult(state, PurchaseStatus.NO_CHOICES);
}
java.util.Set<TyrantUnlock> purchases = java.util.EnumSet.noneOf(TyrantUnlock.class);
purchases.addAll(state.purchases());
purchases.add(unlock);
GameState updated = copyProgress(
state,
state.tyrantLevel(),
state.unspentChoices() - 1,
purchases
);
return new PurchaseResult(updated, PurchaseStatus.PURCHASED);
}
public GameState resetReignProgression(GameState state) {
return copyProgress(state, 0, 0, java.util.Set.of());
}
private static GameState copyProgress(
GameState state,
int level,
int choices,
java.util.Set<TyrantUnlock> purchases
) {
return new GameState(
state.lifecycle(),
state.tyrantId(),
state.vigilanteId(),
state.pendingTyrant(),
state.pendingVigilante(),
state.pausedAt(),
state.accumulatedPausedTime(),
level,
choices,
purchases
);
}
}
@@ -0,0 +1,117 @@
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 void replacePlayers(Map<UUID, PlayerState> replacement) {
players.clear();
players.putAll(replacement);
dirty = true;
}
public GameState updateGame(UnaryOperator<GameState> update) {
game = update.apply(game);
dirty = true;
return game;
}
public void replaceState(LifecycleState replacement) {
game = replacement.game();
players.clear();
players.putAll(replacement.players());
dirty = true;
}
public PersistentState snapshot() {
return new PersistentState(game, players);
}
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,109 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
public final class TyrantSuccessionService {
private final RoleCandidateSelector candidates;
private final Duration activityWindow;
private final Duration pendingTimeout;
private final RandomGenerator random;
public TyrantSuccessionService(
RoleCandidateSelector candidates,
Duration activityWindow,
Duration pendingTimeout,
RandomGenerator random
) {
this.candidates = candidates;
this.activityWindow = activityWindow;
this.pendingTimeout = pendingTimeout;
this.random = random;
}
public LifecycleState succeed(
PersistentState state,
Optional<UUID> killerId,
Set<UUID> onlinePlayerIds,
Instant now
) {
if (state.game().lifecycle() != GameLifecycle.RUNNING
|| state.game().tyrantId().isEmpty()) {
throw new IllegalStateException("a running Tyrant reign is required");
}
UUID formerTyrant = state.game().tyrantId().orElseThrow();
Optional<UUID> formerVigilante = state.game().vigilanteId();
Map<UUID, PlayerState> clearedPlayers = clearAssignments(state.players());
Optional<UUID> selectedTyrant = killerId.isPresent()
? killerId
: candidates.select(
clearedPlayers,
Set.of(),
Optional.of(formerTyrant),
now,
activityWindow,
random
);
Optional<UUID> activeTyrant = selectedTyrant
.filter(playerId -> killerId.isPresent() || onlinePlayerIds.contains(playerId));
Optional<PendingSelection> pendingTyrant = selectedTyrant
.filter(playerId -> activeTyrant.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
Optional<UUID> selectedVigilante = activeTyrant.flatMap(tyrantId ->
candidates.select(
clearedPlayers,
Set.of(tyrantId),
formerVigilante,
now,
activityWindow,
random
)
);
Optional<UUID> activeVigilante = selectedVigilante
.filter(onlinePlayerIds::contains);
Optional<PendingSelection> pendingVigilante = selectedVigilante
.filter(playerId -> activeVigilante.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
GameState game = new GameState(
GameLifecycle.RUNNING,
activeTyrant,
activeVigilante,
pendingTyrant,
pendingVigilante,
Optional.empty(),
state.game().accumulatedPausedTime(),
0,
1,
Set.of()
);
return new LifecycleState(game, clearedPlayers);
}
private static Map<UUID, PlayerState> clearAssignments(
Map<UUID, PlayerState> players
) {
Map<UUID, PlayerState> cleared = new HashMap<>();
for (PlayerState player : players.values()) {
cleared.put(player.playerId(), new PlayerState(
player.playerId(),
player.latestName(),
player.lastLogin(),
player.optedOutUntil(),
TyrantClass.NONE,
Optional.empty(),
Map.of(),
Set.of(),
player.capturedMobs()
));
}
return Map.copyOf(cleared);
}
}
@@ -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.
+18
View File
@@ -0,0 +1,18 @@
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
commands:
tyrant:
description: View and use Spigot Tyrant game features.
usage: /tyrant [choices|buy <unlock>|assign <class> <player>]
tyrantadmin:
description: Administer the Spigot Tyrant game.
usage: /tyrantadmin <status|start|pause|resume|reset confirm>
permission: spigottyrant.admin
permissions:
spigottyrant.admin:
description: Allows administration of Spigot Tyrant.
default: op
@@ -0,0 +1,42 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.bukkit.scoreboard.Team;
import org.junit.jupiter.api.Test;
final class BukkitTyrantPresentationTest {
@Test
void givesCurrentTyrantRedNameAndGlowingOutline() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
Server server = mock(Server.class);
ScoreboardManager manager = mock(ScoreboardManager.class);
Scoreboard scoreboard = mock(Scoreboard.class);
Team team = mock(Team.class);
Player tyrant = mock(Player.class);
when(server.getScoreboardManager()).thenReturn(manager);
when(manager.getMainScoreboard()).thenReturn(scoreboard);
when(scoreboard.getTeam("tyrant-red")).thenReturn(null);
when(scoreboard.registerNewTeam("tyrant-red")).thenReturn(team);
when(server.getPlayer(tyrantId)).thenReturn(tyrant);
when(tyrant.getName()).thenReturn("TyrantPlayer");
BukkitTyrantPresentation presentation = new BukkitTyrantPresentation(server);
presentation.reconcile(Optional.of(tyrantId));
verify(team).setColor(ChatColor.RED);
verify(team).addEntry("TyrantPlayer");
verify(tyrant).setGlowing(true);
verify(tyrant).setPlayerListName(ChatColor.RED + "TyrantPlayer");
verify(tyrant).setDisplayName(ChatColor.RED + "TyrantPlayer");
}
}
@@ -0,0 +1,29 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class ClassAssignmentMessagesTest {
@Test
void loginReminderNamesClassAndControls() {
PlayerState assassin = new PlayerState(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
"Player",
java.util.Optional.empty(),
java.util.Optional.empty(),
TyrantClass.ASSASSIN,
java.util.Optional.empty(),
java.util.Map.of(),
java.util.Set.of(),
java.util.List.of()
);
assertEquals(
"Class: ASSASSIN. Use your Assassin Cloak; double-jump while airborne. "
+ "Use /tyrant item to recover ready class items.",
ClassAssignmentMessages.loginReminder(assassin)
);
}
}
@@ -0,0 +1,127 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class ClassAssignmentServiceTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID FOLLOWER = UUID.fromString("33333333-3333-3333-3333-333333333333");
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final ClassAssignmentService service = new ClassAssignmentService();
@Test
void purchasedClassCanBeAssignedToVigilanteOrFollower() {
GameState game = gameWith(TyrantUnlock.ASSASSIN);
PlayerState vigilante = PlayerState.newPlayer(VIGILANTE, "Vigilante");
PlayerState follower = withFollower(
PlayerState.newPlayer(FOLLOWER, "Follower"),
VIGILANTE
);
ClassAssignmentResult vigilanteResult = service.assign(
game,
Map.of(VIGILANTE, vigilante, FOLLOWER, follower),
TYRANT,
VIGILANTE,
TyrantClass.ASSASSIN,
NOW
);
ClassAssignmentResult followerResult = service.assign(
game,
vigilanteResult.players(),
TYRANT,
FOLLOWER,
TyrantClass.ASSASSIN,
NOW
);
assertEquals(ClassAssignmentStatus.ASSIGNED, vigilanteResult.status());
assertEquals(TyrantClass.ASSASSIN,
vigilanteResult.players().get(VIGILANTE).tyrantClass());
assertEquals(ClassAssignmentStatus.ASSIGNED, followerResult.status());
assertEquals(TyrantClass.NONE,
followerResult.players().get(VIGILANTE).tyrantClass());
assertEquals(TyrantClass.ASSASSIN,
followerResult.players().get(FOLLOWER).tyrantClass());
assertEquals(Optional.of(VIGILANTE), followerResult.previousHolderId());
}
@Test
void rejectsLockedSelfAndOptedOutAssignments() {
PlayerState target = PlayerState.newPlayer(FOLLOWER, "Target");
PlayerState optedOut = new PlayerState(
target.playerId(), target.latestName(), target.lastLogin(),
Optional.of(NOW.plusSeconds(60)), target.tyrantClass(), target.followerOf(),
target.cooldownEnds(), target.readyAbilityItems(), target.capturedMobs()
);
assertEquals(ClassAssignmentStatus.CLASS_NOT_UNLOCKED, service.assign(
gameWith(TyrantUnlock.FIXER), Map.of(FOLLOWER, target), TYRANT, FOLLOWER,
TyrantClass.ASSASSIN, NOW
).status());
assertEquals(ClassAssignmentStatus.TARGET_IS_TYRANT, service.assign(
gameWith(TyrantUnlock.ASSASSIN), Map.of(TYRANT,
PlayerState.newPlayer(TYRANT, "Tyrant")), TYRANT, TYRANT,
TyrantClass.ASSASSIN, NOW
).status());
assertEquals(ClassAssignmentStatus.TARGET_OPTED_OUT, service.assign(
gameWith(TyrantUnlock.ASSASSIN), Map.of(FOLLOWER, optedOut), TYRANT,
FOLLOWER, TyrantClass.ASSASSIN, NOW
).status());
}
@Test
void reassignmentAndReignEndClearClassAbilityState() {
Instant cooldown = NOW.plusSeconds(3600);
PlayerState holder = new PlayerState(
VIGILANTE, "Holder", Optional.empty(), Optional.empty(),
TyrantClass.ASSASSIN, Optional.empty(),
Map.of(Ability.ASSASSIN_INVISIBILITY, cooldown),
Set.of(Ability.ASSASSIN_INVISIBILITY), java.util.List.of()
);
PlayerState replacement = PlayerState.newPlayer(FOLLOWER, "Replacement");
ClassAssignmentResult reassigned = service.assign(
gameWith(TyrantUnlock.ASSASSIN),
Map.of(VIGILANTE, holder, FOLLOWER, replacement),
TYRANT, FOLLOWER, TyrantClass.ASSASSIN, NOW
);
PlayerState former = reassigned.players().get(VIGILANTE);
Map<UUID, PlayerState> cleared = service.clearAll(reassigned.players());
assertEquals(Map.of(), former.cooldownEnds());
assertEquals(Set.of(), former.readyAbilityItems());
assertEquals(TyrantClass.NONE, cleared.get(FOLLOWER).tyrantClass());
}
private static GameState gameWith(TyrantUnlock unlock) {
return new GameState(
GameLifecycle.RUNNING,
Optional.of(TYRANT),
Optional.of(VIGILANTE),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Duration.ZERO,
1,
0,
Set.of(unlock)
);
}
private static PlayerState withFollower(PlayerState player, UUID vigilanteId) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), Optional.of(vigilanteId), player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -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,65 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class GameLifecycleServiceTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final Instant START = Instant.parse("2026-08-14T12:00:00Z");
private final GameLifecycleService service = new GameLifecycleService();
@Test
void startsFirstGameWithInitialRolesAndChoice() {
LifecycleState started = service.start(
PersistentState.empty(), TYRANT, Optional.of(VIGILANTE), START
);
assertEquals(GameLifecycle.RUNNING, started.game().lifecycle());
assertEquals(Optional.of(TYRANT), started.game().tyrantId());
assertEquals(Optional.of(VIGILANTE), started.game().vigilanteId());
assertEquals(1, started.game().unspentChoices());
}
@Test
void pauseAndResumePreserveStateAndShiftEveryElapsedDeadline() {
Instant cooldown = START.plusSeconds(600);
PlayerState player = new PlayerState(
TYRANT, "Tyrant", Optional.of(START.minusSeconds(60)),
Optional.of(START.plusSeconds(1200)), TyrantClass.ASSASSIN,
Optional.empty(), Map.of(Ability.ASSASSIN_INVISIBILITY, cooldown),
Set.of(), java.util.List.of()
);
GameState running = new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(VIGILANTE),
Optional.empty(), Optional.of(new PendingSelection(VIGILANTE, cooldown)),
Optional.empty(), Duration.ofMinutes(2), 2, 1,
Set.of(TyrantUnlock.ASSASSIN)
);
LifecycleState paused = service.pause(
new PersistentState(running, Map.of(TYRANT, player)), START
);
Instant resumedAt = START.plusSeconds(300);
LifecycleState resumed = service.resume(paused.asPersistentState(), resumedAt);
assertEquals(GameLifecycle.RUNNING, resumed.game().lifecycle());
assertEquals(Duration.ofMinutes(7), resumed.game().accumulatedPausedTime());
assertEquals(cooldown.plusSeconds(300),
resumed.game().pendingVigilante().orElseThrow().expiresAt());
PlayerState shifted = resumed.players().get(TYRANT);
assertEquals(cooldown.plusSeconds(300),
shifted.cooldownEnds().get(Ability.ASSASSIN_INVISIBILITY));
assertEquals(START.plusSeconds(1500), shifted.optedOutUntil().orElseThrow());
assertEquals(START.plusSeconds(240), shifted.lastLogin().orElseThrow());
assertTrue(shifted.tyrantClass() == TyrantClass.ASSASSIN);
}
}
@@ -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,39 @@
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"));
Map<?, ?> commands = (Map<?, ?>) plugin.get("commands");
assertNotNull(commands);
assertNotNull(commands.get("tyrant"));
assertNotNull(commands.get("tyrantadmin"));
Map<?, ?> permissions = (Map<?, ?>) plugin.get("permissions");
assertNotNull(permissions);
Map<?, ?> admin = (Map<?, ?>) permissions.get("spigottyrant.admin");
assertEquals("op", admin.get("default"));
}
@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,46 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
import org.junit.jupiter.api.Test;
final class RoleCandidateSelectorTest {
@Test
void selectsOnlyRecentlyActiveEligibleNonExcludedPlayer() {
Instant now = Instant.parse("2026-08-14T12:00:00Z");
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID candidate = UUID.fromString("22222222-2222-2222-2222-222222222222");
UUID stale = UUID.fromString("33333333-3333-3333-3333-333333333333");
Map<UUID, PlayerState> players = Map.of(
tyrant, withLogin(PlayerState.newPlayer(tyrant, "Tyrant"), now),
candidate, withLogin(PlayerState.newPlayer(candidate, "Candidate"), now.minusSeconds(60)),
stale, withLogin(PlayerState.newPlayer(stale, "Stale"), now.minus(Duration.ofHours(25)))
);
Optional<UUID> selected = new RoleCandidateSelector().select(
players,
Set.of(tyrant),
Optional.empty(),
now,
Duration.ofHours(24),
RandomGenerator.of("L64X128MixRandom")
);
assertEquals(Optional.of(candidate), selected);
}
private static PlayerState withLogin(PlayerState player, Instant login) {
return new PlayerState(
player.playerId(), player.latestName(), Optional.of(login), player.optedOutUntil(),
player.tyrantClass(), player.followerOf(), player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -0,0 +1,87 @@
package games.dmg.spigottyrant;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.junit.jupiter.api.Test;
final class TyrantAdminCommandTest {
@Test
void rejectsSenderWithoutAdminPermission() {
TyrantStateManager manager = mock(TyrantStateManager.class);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(false);
TyrantAdminCommand command = command(manager, mock(ManagedRoleEffects.class));
command.onCommand(sender, mock(Command.class), "tyrantadmin", new String[] {"pause"});
verify(sender).sendMessage(contains("permission"));
verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any());
}
@Test
void pausePreservesStateAndSuppressesManagedEffects() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
2, 1, Set.of(TyrantUnlock.STRENGTH)
);
PersistentState snapshot = new PersistentState(
game, Map.of(tyrant, PlayerState.newPlayer(tyrant, "Tyrant"))
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(snapshot);
ManagedRoleEffects effects = mock(ManagedRoleEffects.class);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
TyrantAdminCommand command = command(manager, effects);
command.onCommand(sender, mock(Command.class), "tyrantadmin", new String[] {"pause"});
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(
state -> state.game().lifecycle() == GameLifecycle.PAUSED
&& state.game().tyrantLevel() == 2
));
verify(effects).suppressAll();
}
@Test
void resetRequiresExplicitConfirmation() {
TyrantStateManager manager = mock(TyrantStateManager.class);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
TyrantAdminCommand command = command(manager, mock(ManagedRoleEffects.class));
command.onCommand(sender, mock(Command.class), "tyrantadmin", new String[] {"reset"});
verify(sender).sendMessage(contains("reset confirm"));
verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any());
}
private static TyrantAdminCommand command(
TyrantStateManager manager,
ManagedRoleEffects effects
) {
return new TyrantAdminCommand(
manager,
new GameLifecycleService(),
mock(OnlinePlayerDirectory.class),
effects,
Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC)
);
}
}
@@ -0,0 +1,95 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class TyrantCommandTest {
@Test
void choicesClearlyIdentifyPurchasedAvailableAndUnavailableUnlocks() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState state = new GameState(
GameLifecycle.RUNNING,
Optional.of(tyrantId),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Duration.ZERO,
1,
1,
Set.of(TyrantUnlock.STRENGTH)
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(state);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(tyrantId);
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService());
command.onCommand(player, mock(Command.class), "tyrant", new String[] {"choices"});
ArgumentCaptor<String> messages = ArgumentCaptor.forClass(String.class);
verify(player, org.mockito.Mockito.atLeastOnce()).sendMessage(messages.capture());
List<String> all = messages.getAllValues();
assertTrue(all.stream().anyMatch(message -> message.contains("PURCHASED")
&& message.contains("STRENGTH")));
assertTrue(all.stream().anyMatch(message -> message.contains("AVAILABLE")
&& message.contains("ASSASSIN")));
}
@Test
void assigningClassNotifiesTyrantAndNewHolder() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID targetId = UUID.fromString("22222222-2222-2222-2222-222222222222");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of(TyrantUnlock.FIXER)
);
PlayerState targetState = PlayerState.newPlayer(targetId, "Target");
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
when(manager.player(targetId, "Target")).thenReturn(targetState);
when(manager.players()).thenReturn(java.util.Map.of(targetId, targetState));
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
Player target = mock(Player.class);
when(target.getUniqueId()).thenReturn(targetId);
when(target.getName()).thenReturn("Target");
OnlinePlayerDirectory directory = mock(OnlinePlayerDirectory.class);
when(directory.findByName("Target")).thenReturn(target);
TyrantCommand command = new TyrantCommand(
manager,
new TyrantProgressionService(),
new ClassAssignmentService(),
directory,
Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC)
);
command.onCommand(
tyrant,
mock(Command.class),
"tyrant",
new String[] {"assign", "fixer", "Target"}
);
verify(manager).replacePlayers(any());
verify(target).sendMessage(org.mockito.ArgumentMatchers.contains("now the FIXER"));
verify(tyrant).sendMessage(org.mockito.ArgumentMatchers.contains("Assigned FIXER"));
}
}
@@ -0,0 +1,71 @@
package games.dmg.spigottyrant;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.junit.jupiter.api.Test;
final class TyrantDeathListenerTest {
@Test
void tyrantDeathAppliesSuccessionAndPresentation() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID killerId = UUID.fromString("22222222-2222-2222-2222-222222222222");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 1, Set.of()
);
PersistentState before = new PersistentState(game, Map.of());
GameState succeededGame = new GameState(
GameLifecycle.RUNNING, Optional.of(killerId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 1, Set.of()
);
LifecycleState succeeded = new LifecycleState(succeededGame, Map.of());
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
when(manager.snapshot()).thenReturn(before);
TyrantSuccessionService succession = mock(TyrantSuccessionService.class);
when(succession.succeed(eq(before), eq(Optional.of(killerId)), any(), any()))
.thenReturn(succeeded);
TyrantPresentation presentation = mock(TyrantPresentation.class);
Server server = mock(Server.class);
Player victim = mock(Player.class);
when(victim.getUniqueId()).thenReturn(tyrantId);
Player killer = mock(Player.class);
when(killer.getUniqueId()).thenReturn(killerId);
when(victim.getKiller()).thenReturn(killer);
doReturn(List.of(victim, killer)).when(server).getOnlinePlayers();
PlayerDeathEvent event = mock(PlayerDeathEvent.class);
when(event.getEntity()).thenReturn(victim);
TyrantDeathListener listener = new TyrantDeathListener(
manager,
succession,
presentation,
server,
Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC)
);
listener.onDeath(event);
verify(manager).replaceState(succeeded);
verify(presentation).reconcile(Optional.of(killerId));
verify(killer).sendMessage(org.mockito.ArgumentMatchers.contains("new Tyrant"));
}
}
@@ -0,0 +1,124 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class TyrantProgressionServiceTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID OTHER = UUID.fromString("33333333-3333-3333-3333-333333333333");
private final TyrantProgressionService service = new TyrantProgressionService();
@Test
void tyrantPersonallyKillingCurrentVigilanteEarnsOneLevelAndChoice() {
GameState before = runningState(0, 1, Set.of());
GameState after = service.recordVigilanteDeath(before, Optional.of(TYRANT));
assertEquals(1, after.tyrantLevel());
assertEquals(2, after.unspentChoices());
}
@Test
void otherEnvironmentalAndPausedDeathsDoNotEarnProgress() {
GameState running = runningState(2, 0, Set.of());
GameState paused = new GameState(
GameLifecycle.PAUSED,
running.tyrantId(),
running.vigilanteId(),
running.pendingTyrant(),
running.pendingVigilante(),
Optional.of(java.time.Instant.parse("2026-08-14T12:00:00Z")),
Duration.ZERO,
2,
0,
Set.of()
);
assertEquals(running, service.recordVigilanteDeath(running, Optional.of(OTHER)));
assertEquals(running, service.recordVigilanteDeath(running, Optional.empty()));
assertEquals(paused, service.recordVigilanteDeath(paused, Optional.of(TYRANT)));
}
@Test
void purchaseConsumesOneChoiceAndCannotBeBoughtTwice() {
GameState before = runningState(1, 2, Set.of());
PurchaseResult first = service.purchase(before, TYRANT, TyrantUnlock.ASSASSIN);
PurchaseResult duplicate = service.purchase(
first.state(), TYRANT, TyrantUnlock.ASSASSIN
);
assertEquals(PurchaseStatus.PURCHASED, first.status());
assertEquals(1, first.state().unspentChoices());
assertTrue(first.state().purchases().contains(TyrantUnlock.ASSASSIN));
assertEquals(PurchaseStatus.ALREADY_PURCHASED, duplicate.status());
assertEquals(first.state(), duplicate.state());
}
@Test
void nonTyrantPausedAndNoChoicePurchasesAreRejected() {
GameState noChoices = runningState(0, 0, Set.of());
GameState paused = new GameState(
GameLifecycle.PAUSED,
noChoices.tyrantId(),
noChoices.vigilanteId(),
noChoices.pendingTyrant(),
noChoices.pendingVigilante(),
Optional.of(java.time.Instant.parse("2026-08-14T12:00:00Z")),
Duration.ZERO,
0,
1,
Set.of()
);
assertEquals(PurchaseStatus.NO_CHOICES,
service.purchase(noChoices, TYRANT, TyrantUnlock.FIXER).status());
assertEquals(PurchaseStatus.NOT_TYRANT,
service.purchase(runningState(0, 1, Set.of()), OTHER, TyrantUnlock.FIXER).status());
PurchaseResult pausedResult = service.purchase(paused, TYRANT, TyrantUnlock.FIXER);
assertEquals(PurchaseStatus.GAME_NOT_RUNNING, pausedResult.status());
assertFalse(pausedResult.state().purchases().contains(TyrantUnlock.FIXER));
}
@Test
void endingReignClearsLevelChoicesAndPurchases() {
GameState progressed = runningState(
4,
2,
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
);
GameState reset = service.resetReignProgression(progressed);
assertEquals(0, reset.tyrantLevel());
assertEquals(0, reset.unspentChoices());
assertEquals(Set.of(), reset.purchases());
}
private static GameState runningState(
int level,
int choices,
Set<TyrantUnlock> purchases
) {
return new GameState(
GameLifecycle.RUNNING,
Optional.of(TYRANT),
Optional.of(VIGILANTE),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Duration.ZERO,
level,
choices,
purchases
);
}
}
@@ -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,95 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
import org.junit.jupiter.api.Test;
final class TyrantSuccessionServiceTest {
private static final UUID OLD_TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID OLD_VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID KILLER = UUID.fromString("33333333-3333-3333-3333-333333333333");
private static final UUID OTHER = UUID.fromString("44444444-4444-4444-4444-444444444444");
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final TyrantSuccessionService service = new TyrantSuccessionService(
new RoleCandidateSelector(),
Duration.ofHours(24),
Duration.ofHours(24),
RandomGenerator.of("L64X128MixRandom")
);
@Test
void playerKillerBecomesTyrantAndEntireReignIsReset() {
PersistentState before = progressedState();
LifecycleState after = service.succeed(
before, Optional.of(KILLER), Set.of(KILLER, OTHER), NOW
);
assertEquals(Optional.of(KILLER), after.game().tyrantId());
assertEquals(0, after.game().tyrantLevel());
assertEquals(1, after.game().unspentChoices());
assertEquals(Set.of(), after.game().purchases());
assertNotEquals(after.game().tyrantId(), after.game().vigilanteId());
for (PlayerState player : after.players().values()) {
assertEquals(TyrantClass.NONE, player.tyrantClass());
assertEquals(Optional.empty(), player.followerOf());
assertEquals(Map.of(), player.cooldownEnds());
}
}
@Test
void environmentalDeathRandomlySelectsRecentPlayerInsteadOfFormerTyrant() {
PersistentState before = progressedState();
LifecycleState after = service.succeed(before, Optional.empty(), Set.of(OTHER), NOW);
UUID selected = after.game().tyrantId().orElseGet(() ->
after.game().pendingTyrant().orElseThrow().candidateId()
);
assertNotEquals(OLD_TYRANT, selected);
}
private static PersistentState progressedState() {
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(OLD_TYRANT), Optional.of(OLD_VIGILANTE),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
5, 2, Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
);
Map<UUID, PlayerState> players = Map.of(
OLD_TYRANT, active(new PlayerState(
OLD_TYRANT, "OldTyrant", Optional.empty(), Optional.empty(),
TyrantClass.NONE, Optional.empty(),
Map.of(Ability.ROSTER_INTELLIGENCE, NOW.plusSeconds(20)),
Set.of(), java.util.List.of()
)),
OLD_VIGILANTE, active(new PlayerState(
OLD_VIGILANTE, "OldVigilante", Optional.empty(), Optional.empty(),
TyrantClass.ASSASSIN, Optional.empty(),
Map.of(Ability.ASSASSIN_INVISIBILITY, NOW.plusSeconds(20)),
Set.of(Ability.ASSASSIN_INVISIBILITY), java.util.List.of()
)),
KILLER, active(PlayerState.newPlayer(KILLER, "Killer")),
OTHER, active(new PlayerState(
OTHER, "Other", Optional.empty(), Optional.empty(), TyrantClass.FIXER,
Optional.of(OLD_VIGILANTE), Map.of(), Set.of(), java.util.List.of()
))
);
return new PersistentState(game, players);
}
private static PlayerState active(PlayerState player) {
return new PlayerState(
player.playerId(), player.latestName(), Optional.of(NOW.minusSeconds(60)),
player.optedOutUntil(), player.tyrantClass(), player.followerOf(),
player.cooldownEnds(), player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -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());
}
}