Compare commits

...
12 Commits
Author SHA1 Message Date
dmg a82d20c7e6 feat(fixer): add timed combat boost
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m2s
2026-08-14 23:11:16 -04:00
dmg 1642d46f1b feat(assassin): add stealth and double jump
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m1s
2026-08-14 23:08:18 -04:00
dmg 0371337a3e feat(vigilante): add followers and combat scaling
Release / release (push) Successful in 3m7s
CI / build (push) Successful in 1m15s
2026-08-14 23:01:10 -04:00
dmg 96c0c0014c feat(participation): add opt-out and role status
Release / release (push) Successful in 2m15s
CI / build (push) Successful in 58s
2026-08-14 22:55:43 -04:00
dmg a195658be4 feat(roles): maintain inactive and pending roles
Release / release (push) Successful in 2m19s
CI / build (push) Successful in 1m0s
2026-08-14 22:50:43 -04:00
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
121 changed files with 7012 additions and 110 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)
}
}
+123
View File
@@ -6,6 +6,129 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log
## 2026-08-14 — Fixer completed
- Completed US-006 with the named owner-bound Fixer's Wrench, one-hour cooldown, and ten-minute Strength and Health Boost activation.
- Activation records a fixed normal two-row or near-Tyrant three-row heart tier using same-world configurable proximity at use time.
- Continuously refreshed effects survive death, login, milk, and ordinary removal; expiry and class loss remove effects and clamp health safely to the resulting maximum.
- Verified activation tiers, active and cooldown deadlines, item consumption/readiness, persistence, and the full Gradle build.
## 2026-08-14 — Fixer implementation started
- US-006 begins with test-first bound-item combat boosts, activation-time proximity tiers, persistent cooldowns, and safe health cleanup.
## 2026-08-14 — Assassin completed
- Completed US-005 with named owner-bound Assassin Cloak activation, one-hour cooldown, particle-free ten-minute invisibility, and activation-time doubling within the Tyrant's configurable range.
- Added 60-second double-jump gating, upward launch, 15-second Speed, 20-second Weakness III, and grounded readiness restoration.
- Active effects and cooldowns persist as UTC deadlines and are continuously restored after death, login, milk, or ordinary removal while valid.
- Verified duration tiers, cooldown consumption and refresh, jump effect deadlines, class constraints, persistence, and the full Gradle build.
## 2026-08-14 — Assassin implementation started
- US-005 begins with test-first bound-item invisibility, near-Tyrant duration doubling, and cooldown-gated double-jump effects.
## 2026-08-14 — Bound ability item implementation started
- US-014 begins with test-first item readiness and recovery, owner metadata, transfer prevention, cooldown redelivery, and Tamer item permanence.
## 2026-08-14 — Vigilante and Followers completed
- Completed US-008 with `/vigilante` invitation, acceptance, dismissal, and leave flows while preserving independent Tyrant-side class assignments.
- Added Tyrant/Vigilante combat tracking and continuously refreshed Strength and Resistance based on living same-world Followers within the configurable 50-block range, capped at Strength V and Resistance IV by default.
- Every Vigilante death now awards progression only to the Tyrant killer, clears Followers, and applies normal recently-active replacement with previous-holder fallback.
- Verified membership, dual alignment, scaling and caps, role maintenance, command metadata, and death/progression components with the Gradle suite.
## 2026-08-14 — Vigilante and Follower implementation started
- US-008 begins with test-first invitation membership, death reroll and Tyrant reward integration, combat tracking, and proximity-scaled capped effects.
## 2026-08-14 — Participation completed
- Completed US-011 with `/tyrant status`, `optout`, `optin`, and confirmed `relinquish` flows plus comprehensive join messages for lifecycle, roles, classes, abilities, and cooldowns.
- Opt-out clears ordinary classes and Follower membership, persists until the player explicitly opts back in, and enforces the configurable seven-day unpaused waiting period.
- Central role holders must safely relinquish first; Tyrant relinquishment performs full succession and Vigilante relinquishment performs a normal Vigilante reroll.
- Updated all assignment and candidate paths to exclude opted-out players and verified status, persistence, cleanup, cooldown, and relinquishment behavior with the Gradle suite.
## 2026-08-14 — Participation implementation started
- US-011 begins with test-first opt-out/opt-in state, seven-day re-entry gating, safe role relinquishment guidance, and complete join status messages.
## 2026-08-14 — Role maintenance completed
- Completed US-010 with configurable periodic role maintenance, exact 48-hour inactivity handling, and 24-hour recently-active candidate filtering.
- Added online confirmation for pending roles, 24-hour expiry and reroll, former-holder fallback rules, vacant-role retries, opt-out filtering, and Tyrant/Vigilante conflict prevention.
- Tyrant inactivity uses full reign succession cleanup; Vigilante inactivity clears only that role's Followers before replacement.
- Verified confirmation, expiry reroll, Follower cleanup, exact inactivity boundary, full Tyrant succession, and pause-safe scheduler integration with the Gradle suite.
## 2026-08-14 — Role maintenance implementation started
- US-010 begins with test-first inactivity replacement, pending confirmation and expiry, conflict-safe candidate retries, and pause awareness.
## 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-005: Use Assassin abilities"
description: Give the Assassin timed stealth and a risky burst-mobility ability.
status: backlog
status: done
---
# US-005: Use Assassin abilities
@@ -11,15 +11,15 @@ As the **Assassin**, I want stealth and burst mobility so that I can ambush oppo
## Acceptance criteria
- [ ] The Assassin can activate ten minutes of invisibility once per rolling hour using the named Assassin ability item.
- [ ] Invisibility displays no potion particles.
- [ ] Activation while in the same world and within the configured Tyrant range grants twenty minutes instead.
- [ ] Proximity is evaluated when the ability is activated and does not alter the active duration afterward.
- [ ] The Assassin can double-jump once every 60 seconds without requiring an item.
- [ ] A successful double-jump grants Speed for 15 seconds and Weakness III for 20 seconds.
- [ ] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed.
- [ ] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart.
- [ ] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable.
- [x] The Assassin can activate ten minutes of invisibility once per rolling hour using the named Assassin ability item.
- [x] Invisibility displays no potion particles.
- [x] Activation while in the same world and within the configured Tyrant range grants twenty minutes instead.
- [x] Proximity is evaluated when the ability is activated and does not alter the active duration afterward.
- [x] The Assassin can double-jump once every 60 seconds without requiring an item.
- [x] A successful double-jump grants Speed for 15 seconds and Weakness III for 20 seconds.
- [x] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed.
- [x] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart.
- [x] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-006: Use Fixer abilities"
description: Give the Fixer a periodic offensive and health enhancement strengthened near the Tyrant.
status: backlog
status: done
---
# US-006: Use Fixer abilities
@@ -11,13 +11,13 @@ As the **Fixer**, I want a substantial temporary combat boost so that I can prot
## Acceptance criteria
- [ ] The Fixer can activate ten minutes of Strength and Health Boost once per rolling hour using the named Fixer ability item.
- [ ] Normal activation provides two total rows of hearts when combined with the player's normal maximum health.
- [ ] Activation in the same world and within the configured Tyrant range provides three total rows of hearts.
- [ ] Proximity is evaluated when the ability is activated and does not alter the active tier afterward.
- [ ] Health is safely clamped when the boost expires, is suppressed, or changes tier.
- [ ] The cooldown uses unpaused elapsed time and survives death, logout, and restart.
- [ ] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable.
- [x] The Fixer can activate ten minutes of Strength and Health Boost once per rolling hour using the named Fixer ability item.
- [x] Normal activation provides two total rows of hearts when combined with the player's normal maximum health.
- [x] Activation in the same world and within the configured Tyrant range provides three total rows of hearts.
- [x] Proximity is evaluated when the ability is activated and does not alter the active tier afterward.
- [x] Health is safely clamped when the boost expires, is suppressed, or changes tier.
- [x] The cooldown uses unpaused elapsed time and survives death, logout, and restart.
- [x] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-008: Support the Vigilante and Followers"
description: Give the Vigilante a replaceable role and combat strength derived from nearby Followers.
status: backlog
status: done
---
# US-008: Support the Vigilante and Followers
@@ -11,18 +11,18 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
## Acceptance criteria
- [ ] Exactly one active Vigilante exists while the game is running whenever an eligible player is available.
- [ ] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
- [ ] The Vigilante can dismiss Followers and Followers can leave voluntarily.
- [ ] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks.
- [ ] Offline, dead, opted-out, dismissed, or out-of-range Followers do not count.
- [ ] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
- [ ] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment.
- [ ] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV.
- [ ] Every Vigilante death ends the assignment and triggers random replacement regardless of cause.
- [ ] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
- [ ] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
- [ ] The Tyrant is never eligible to be selected as Vigilante.
- [x] Exactly one active Vigilante exists while the game is running whenever an eligible player is available.
- [x] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
- [x] The Vigilante can dismiss Followers and Followers can leave voluntarily.
- [x] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks.
- [x] Offline, dead, opted-out, dismissed, or out-of-range Followers do not count.
- [x] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
- [x] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment.
- [x] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV.
- [x] Every Vigilante death ends the assignment and triggers random replacement regardless of cause.
- [x] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
- [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
- [x] The Tyrant is never eligible to be selected as Vigilante.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-010: Handle inactivity and pending selections"
description: Replace absent or unconfirmed role holders using recently active eligible players.
status: backlog
status: done
---
# US-010: Handle inactivity and pending selections
@@ -11,16 +11,16 @@ As a **participant**, I want inactive central roles to be replaced fairly so tha
## Acceptance criteria
- [ ] A Tyrant or Vigilante who has not logged in for 48 unpaused hours loses the position.
- [ ] Random candidates are drawn from eligible opted-in players who logged in during the preceding 24 hours.
- [ ] The Tyrant is always excluded from Vigilante selection.
- [ ] A previous role holder is excluded when another eligible candidate exists and may be reused only as a fallback.
- [ ] Tyrant inactivity ends the reign, clears all assignments, and invokes normal random Tyrant succession followed by Vigilante selection.
- [ ] Vigilante inactivity clears Followers and rerolls only the Vigilante position.
- [ ] An offline randomly selected candidate must log in within 24 unpaused hours to confirm the assignment.
- [ ] An unconfirmed selection expires and rerolls automatically under the same eligibility rules.
- [ ] Selection remains vacant and retries periodically when no valid candidate exists.
- [ ] Candidate selection cannot assign conflicting roles or choose an opted-out player, including during concurrent joins and deaths.
- [x] A Tyrant or Vigilante who has not logged in for 48 unpaused hours loses the position.
- [x] Random candidates are drawn from eligible opted-in players who logged in during the preceding 24 hours.
- [x] The Tyrant is always excluded from Vigilante selection.
- [x] A previous role holder is excluded when another eligible candidate exists and may be reused only as a fallback.
- [x] Tyrant inactivity ends the reign, clears all assignments, and invokes normal random Tyrant succession followed by Vigilante selection.
- [x] Vigilante inactivity clears Followers and rerolls only the Vigilante position.
- [x] An offline randomly selected candidate must log in within 24 unpaused hours to confirm the assignment.
- [x] An unconfirmed selection expires and rerolls automatically under the same eligibility rules.
- [x] Selection remains vacant and retries periodically when no valid candidate exists.
- [x] Candidate selection cannot assign conflicting roles or choose an opted-out player, including during concurrent joins and deaths.
## Related
@@ -2,7 +2,7 @@
type: User Story
title: "US-011: Inform players and manage participation"
description: Tell players their current place in the game and let unassigned players opt out.
status: backlog
status: done
---
# US-011: Inform players and manage participation
@@ -11,15 +11,15 @@ As a **player**, I want to understand my status and control my eligibility so th
## Acceptance criteria
- [ ] On join, a player is told the game status and their role, class, subclass, abilities, and relevant cooldowns.
- [ ] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
- [ ] An opted-out player is told when they may opt back in.
- [ ] An unassigned player can opt out through a command.
- [ ] Opting out removes an ordinary class or Follower assignment immediately; the Tyrant and Vigilante must relinquish through a safe role command before opting out.
- [ ] Opted-out players cannot be randomly selected, recruited, or assigned a class.
- [ ] A player cannot opt back in until seven days have elapsed by default.
- [ ] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart.
- [ ] Participation commands are idempotent and clearly report current status and remaining time.
- [x] On join, a player is told the game status and their role, class, subclass, abilities, and relevant cooldowns.
- [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
- [x] An opted-out player is told when they may opt back in.
- [x] An unassigned player can opt out through a command.
- [x] Opting out removes an ordinary class or Follower assignment immediately; the Tyrant and Vigilante must relinquish through a safe role command before opting out.
- [x] Opted-out players cannot be randomly selected, recruited, or assigned a class.
- [x] A player cannot opt back in until seven days have elapsed by default.
- [x] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart.
- [x] Participation commands are idempotent and clearly report current status and remaining time.
## 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
@@ -2,7 +2,7 @@
type: User Story
title: "US-014: Use class ability items"
description: Give class holders named bound items that activate abilities without enabling transfer or cooldown bypasses.
status: backlog
status: in-progress
---
# US-014: Use class ability items
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,14 @@
package games.dmg.spigottyrant;
public enum Ability {
ASSASSIN_INVISIBILITY,
ASSASSIN_INVISIBILITY_ACTIVE,
ASSASSIN_DOUBLE_JUMP,
ASSASSIN_SPEED_ACTIVE,
ASSASSIN_WEAKNESS_ACTIVE,
FIXER_BOOST,
FIXER_BOOST_ACTIVE,
FIXER_NEAR_TYRANT_ACTIVE,
TAMER_CAPTURE,
ROSTER_INTELLIGENCE
}
@@ -0,0 +1,45 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import org.bukkit.Server;
import org.bukkit.entity.Player;
public final class AbilityItemRefreshTask implements Runnable {
private final TyrantStateManager stateManager;
private final AbilityReadinessService readiness;
private final AbilityItemService items;
private final Server server;
private final Clock clock;
public AbilityItemRefreshTask(
TyrantStateManager stateManager,
AbilityReadinessService readiness,
AbilityItemService items,
Server server,
Clock clock
) {
this.stateManager = stateManager;
this.readiness = readiness;
this.items = items;
this.server = server;
this.clock = clock;
}
@Override
public void run() {
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) {
return;
}
for (Player player : server.getOnlinePlayers()) {
PlayerState before = stateManager.player(player.getUniqueId(), player.getName());
PlayerState after = readiness.refresh(before, clock.instant());
if (!after.equals(before)) {
stateManager.updatePlayer(
player.getUniqueId(), player.getName(), current -> after
);
}
items.removeInvalid(player, after);
items.recover(player, after);
}
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
public interface AbilityItemService {
void recover(Player player, PlayerState state);
boolean isBoundAbilityItem(ItemStack item);
Optional<Ability> ability(ItemStack item);
Optional<UUID> owner(ItemStack item);
void removeInvalid(Player player, PlayerState state);
void removeAll(Player player);
}
@@ -0,0 +1,68 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
public final class AbilityReadinessService {
public PlayerState consume(
PlayerState player,
Ability ability,
Instant now,
Duration cooldown
) {
if (!player.readyAbilityItems().contains(ability)) {
throw new IllegalStateException("ability item is not ready");
}
if (ability == Ability.TAMER_CAPTURE && cooldown.isZero()) {
return player;
}
Set<Ability> ready = EnumSet.noneOf(Ability.class);
ready.addAll(player.readyAbilityItems());
ready.remove(ability);
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
cooldowns.putAll(player.cooldownEnds());
cooldowns.put(ability, now.plus(cooldown));
return copy(player, cooldowns, ready);
}
public PlayerState refresh(PlayerState player, Instant now) {
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
cooldowns.putAll(player.cooldownEnds());
Set<Ability> ready = EnumSet.noneOf(Ability.class);
ready.addAll(player.readyAbilityItems());
for (Map.Entry<Ability, Instant> entry : player.cooldownEnds().entrySet()) {
if (!entry.getValue().isAfter(now)) {
cooldowns.remove(entry.getKey());
if (isItemAbilityForClass(entry.getKey(), player.tyrantClass())) {
ready.add(entry.getKey());
}
}
}
return copy(player, cooldowns, ready);
}
private static boolean isItemAbilityForClass(Ability ability, TyrantClass tyrantClass) {
return switch (tyrantClass) {
case ASSASSIN -> ability == Ability.ASSASSIN_INVISIBILITY;
case FIXER -> ability == Ability.FIXER_BOOST;
case TAMER -> ability == Ability.TAMER_CAPTURE;
case NONE -> false;
};
}
private static PlayerState copy(
PlayerState player,
Map<Ability, Instant> cooldowns,
Set<Ability> ready
) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), player.followerOf(), cooldowns, ready,
player.capturedMobs()
);
}
}
@@ -0,0 +1,4 @@
package games.dmg.spigottyrant;
public record AbilityUseResult(PlayerState player, AbilityUseStatus status) {
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
public enum AbilityUseStatus {
ACTIVATED,
WRONG_CLASS,
NOT_READY,
COOLDOWN,
GAME_NOT_RUNNING,
WRONG_OWNER
}
@@ -0,0 +1,88 @@
package games.dmg.spigottyrant;
import java.time.Instant;
import java.util.EnumMap;
import java.util.Map;
public final class AssassinAbilityService {
private final AbilityReadinessService readiness;
private final PluginSettings settings;
public AssassinAbilityService(
AbilityReadinessService readiness,
PluginSettings settings
) {
this.readiness = readiness;
this.settings = settings;
}
public AbilityUseResult activateInvisibility(
PlayerState player,
Instant now,
boolean nearTyrant
) {
if (player.tyrantClass() != TyrantClass.ASSASSIN) {
return new AbilityUseResult(player, AbilityUseStatus.WRONG_CLASS);
}
if (!player.readyAbilityItems().contains(Ability.ASSASSIN_INVISIBILITY)) {
return new AbilityUseResult(player, AbilityUseStatus.NOT_READY);
}
PlayerState used = readiness.consume(
player, Ability.ASSASSIN_INVISIBILITY, now, settings.assassinCooldown()
);
Instant activeUntil = now.plus(
nearTyrant
? settings.assassinInvisibilityDuration().multipliedBy(2)
: settings.assassinInvisibilityDuration()
);
return new AbilityUseResult(
withDeadline(used, Ability.ASSASSIN_INVISIBILITY_ACTIVE, activeUntil),
AbilityUseStatus.ACTIVATED
);
}
public AbilityUseResult doubleJump(PlayerState player, Instant now) {
if (player.tyrantClass() != TyrantClass.ASSASSIN) {
return new AbilityUseResult(player, AbilityUseStatus.WRONG_CLASS);
}
if (player.cooldownEnds().getOrDefault(
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
).isAfter(now)) {
return new AbilityUseResult(player, AbilityUseStatus.COOLDOWN);
}
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
deadlines.putAll(player.cooldownEnds());
deadlines.put(
Ability.ASSASSIN_DOUBLE_JUMP,
now.plus(settings.assassinDoubleJumpCooldown())
);
deadlines.put(
Ability.ASSASSIN_SPEED_ACTIVE,
now.plus(settings.assassinSpeedDuration())
);
deadlines.put(
Ability.ASSASSIN_WEAKNESS_ACTIVE,
now.plus(settings.assassinWeaknessDuration())
);
return new AbilityUseResult(copy(player, deadlines), AbilityUseStatus.ACTIVATED);
}
private static PlayerState withDeadline(
PlayerState player,
Ability ability,
Instant deadline
) {
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
deadlines.putAll(player.cooldownEnds());
deadlines.put(ability, deadline);
return copy(player, deadlines);
}
private static PlayerState copy(PlayerState player, Map<Ability, Instant> deadlines) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), player.followerOf(), deadlines,
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -0,0 +1,106 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import org.bukkit.GameMode;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
public final class AssassinEffectController implements Runnable {
private static final int EFFECT_TICKS = 30;
private final TyrantStateManager stateManager;
private final Server server;
private final PluginSettings settings;
private final Clock clock;
private final Set<UUID> grantedFlight = new HashSet<>();
public AssassinEffectController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.server = server;
this.settings = settings;
this.clock = clock;
}
@Override
public void run() {
Instant now = clock.instant();
for (Player player : server.getOnlinePlayers()) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING
|| state.tyrantClass() != TyrantClass.ASSASSIN) {
clear(player);
continue;
}
applyWhileActive(
player, state, Ability.ASSASSIN_INVISIBILITY_ACTIVE,
PotionEffectType.INVISIBILITY, 0, false
);
applyWhileActive(
player, state, Ability.ASSASSIN_SPEED_ACTIVE,
PotionEffectType.SPEED, 0, true
);
applyWhileActive(
player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE,
PotionEffectType.WEAKNESS, settings.assassinWeaknessLevel() - 1, true
);
boolean jumpReady = !state.cooldownEnds().getOrDefault(
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
).isAfter(now);
if (jumpReady && isGrounded(player) && isSurvivalLike(player)) {
player.setAllowFlight(true);
grantedFlight.add(player.getUniqueId());
}
}
}
public void clearAll() {
server.getOnlinePlayers().forEach(this::clear);
}
private void applyWhileActive(
Player player,
PlayerState state,
Ability ability,
PotionEffectType type,
int amplifier,
boolean particles
) {
if (state.cooldownEnds().getOrDefault(ability, Instant.MIN).isAfter(clock.instant())) {
player.addPotionEffect(new PotionEffect(
type, EFFECT_TICKS, amplifier, false, particles, true
));
} else {
player.removePotionEffect(type);
}
}
private void clear(Player player) {
player.removePotionEffect(PotionEffectType.INVISIBILITY);
player.removePotionEffect(PotionEffectType.SPEED);
player.removePotionEffect(PotionEffectType.WEAKNESS);
if (grantedFlight.remove(player.getUniqueId()) && isSurvivalLike(player)) {
player.setAllowFlight(false);
player.setFlying(false);
}
}
private static boolean isGrounded(Player player) {
return !player.getLocation().clone().subtract(0.0, 0.1, 0.0)
.getBlock().isPassable();
}
private static boolean isSurvivalLike(Player player) {
return player.getGameMode() != GameMode.CREATIVE
&& player.getGameMode() != GameMode.SPECTATOR;
}
}
@@ -0,0 +1,79 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
public final class AssassinItemListener implements Listener {
private final TyrantStateManager stateManager;
private final AbilityItemService items;
private final AssassinAbilityService abilities;
private final PluginSettings settings;
private final Clock clock;
public AssassinItemListener(
TyrantStateManager stateManager,
AbilityItemService items,
AssassinAbilityService abilities,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.items = items;
this.abilities = abilities;
this.settings = settings;
this.clock = clock;
}
@EventHandler(priority = EventPriority.HIGH)
public void onInteract(PlayerInteractEvent event) {
Action action = event.getAction();
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK
&& action != Action.LEFT_CLICK_AIR && action != Action.LEFT_CLICK_BLOCK) {
return;
}
if (items.ability(event.getItem())
.filter(Ability.ASSASSIN_INVISIBILITY::equals).isEmpty()) {
return;
}
event.setCancelled(true);
UUID playerId = event.getPlayer().getUniqueId();
if (items.owner(event.getItem()).filter(playerId::equals).isEmpty()) {
event.getPlayer().getInventory().setItem(event.getHand(), null);
event.getPlayer().sendMessage(ChatColor.RED + "That ability item is not yours.");
return;
}
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) {
event.getPlayer().sendMessage(ChatColor.RED + "The Tyrant game is not running.");
return;
}
PlayerState player = stateManager.player(playerId, event.getPlayer().getName());
AbilityUseResult result = abilities.activateInvisibility(
player, clock.instant(), isNearTyrant(event.getPlayer())
);
if (result.status() == AbilityUseStatus.ACTIVATED) {
stateManager.updatePlayer(playerId, event.getPlayer().getName(), current -> result.player());
stateManager.saveIfDirty();
event.getPlayer().getInventory().setItem(event.getHand(), null);
event.getPlayer().sendMessage(ChatColor.GREEN + "Invisibility activated.");
} else {
event.getPlayer().sendMessage(ChatColor.RED + "Ability unavailable: "
+ result.status().name().toLowerCase(java.util.Locale.ROOT) + ".");
}
}
private boolean isNearTyrant(org.bukkit.entity.Player player) {
Optional<org.bukkit.entity.Player> tyrant = stateManager.game().tyrantId()
.map(org.bukkit.Bukkit::getPlayer);
return tyrant.isPresent()
&& tyrant.orElseThrow().getWorld().equals(player.getWorld())
&& tyrant.orElseThrow().getLocation().distanceSquared(player.getLocation())
<= settings.tyrantRangeBlocks() * settings.tyrantRangeBlocks();
}
}
@@ -0,0 +1,55 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import org.bukkit.ChatColor;
import org.bukkit.GameMode;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerToggleFlightEvent;
import org.bukkit.util.Vector;
public final class AssassinJumpListener implements Listener {
private final TyrantStateManager stateManager;
private final AssassinAbilityService abilities;
private final Clock clock;
public AssassinJumpListener(
TyrantStateManager stateManager,
AssassinAbilityService abilities,
Clock clock
) {
this.stateManager = stateManager;
this.abilities = abilities;
this.clock = clock;
}
@EventHandler
public void onToggleFlight(PlayerToggleFlightEvent event) {
if (event.getPlayer().getGameMode() == GameMode.CREATIVE
|| event.getPlayer().getGameMode() == GameMode.SPECTATOR) {
return;
}
PlayerState state = stateManager.player(
event.getPlayer().getUniqueId(), event.getPlayer().getName()
);
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING
|| state.tyrantClass() != TyrantClass.ASSASSIN) {
return;
}
event.setCancelled(true);
event.getPlayer().setFlying(false);
event.getPlayer().setAllowFlight(false);
AbilityUseResult result = abilities.doubleJump(state, clock.instant());
if (result.status() != AbilityUseStatus.ACTIVATED) {
event.getPlayer().sendMessage(ChatColor.RED + "Double jump is cooling down.");
return;
}
stateManager.updatePlayer(
event.getPlayer().getUniqueId(), event.getPlayer().getName(),
current -> result.player()
);
stateManager.saveIfDirty();
Vector velocity = event.getPlayer().getVelocity();
event.getPlayer().setVelocity(velocity.setY(Math.max(0.8, velocity.getY())));
}
}
@@ -0,0 +1,82 @@
package games.dmg.spigottyrant;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockDispenseEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.inventory.ItemStack;
public final class BoundItemListener implements Listener {
private final AbilityItemService items;
public BoundItemListener(AbilityItemService items) {
this.items = items;
}
@EventHandler
public void onDrop(PlayerDropItemEvent event) {
if (items.isBoundAbilityItem(event.getItemDrop().getItemStack())) {
event.getItemDrop().remove();
}
}
@EventHandler
public void onPickup(EntityPickupItemEvent event) {
if (!(event.getEntity() instanceof Player player)) {
return;
}
ItemStack item = event.getItem().getItemStack();
if (items.isBoundAbilityItem(item)
&& items.owner(item).filter(player.getUniqueId()::equals).isEmpty()) {
event.setCancelled(true);
event.getItem().remove();
}
}
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (items.isBoundAbilityItem(event.getCurrentItem())
|| items.isBoundAbilityItem(event.getCursor())) {
if (!(event.getWhoClicked() instanceof Player player)
|| event.getClickedInventory() == null
|| !event.getClickedInventory().equals(player.getInventory())) {
event.setCancelled(true);
}
}
}
@EventHandler
public void onPlace(BlockPlaceEvent event) {
if (items.isBoundAbilityItem(event.getItemInHand())) {
event.setCancelled(true);
}
}
@EventHandler
public void onDispense(BlockDispenseEvent event) {
if (items.isBoundAbilityItem(event.getItem())) {
event.setCancelled(true);
}
}
@EventHandler
public void onFrame(PlayerInteractEntityEvent event) {
if (event.getRightClicked() instanceof ItemFrame
&& items.isBoundAbilityItem(event.getPlayer().getInventory()
.getItem(event.getHand()))) {
event.setCancelled(true);
}
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
event.getDrops().removeIf(items::isBoundAbilityItem);
}
}
@@ -0,0 +1,152 @@
package games.dmg.spigottyrant;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
public final class BukkitAbilityItemService implements AbilityItemService {
private final NamespacedKey abilityKey;
private final NamespacedKey ownerKey;
private final PluginSettings settings;
public BukkitAbilityItemService(Plugin plugin, PluginSettings settings) {
abilityKey = new NamespacedKey(plugin, "ability");
ownerKey = new NamespacedKey(plugin, "owner");
this.settings = settings;
}
@Override
public void recover(Player player, PlayerState state) {
for (Ability ability : state.readyAbilityItems()) {
if (!isItemAbilityForClass(ability, state.tyrantClass())
|| contains(player, state.playerId(), ability)) {
continue;
}
ItemStack item = create(state.playerId(), ability);
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(item);
if (!leftovers.isEmpty()) {
player.sendMessage(ChatColor.RED + settings.inventoryFullMessage());
}
}
}
@Override
public boolean isBoundAbilityItem(ItemStack item) {
return ability(item).isPresent() && owner(item).isPresent();
}
@Override
public Optional<Ability> ability(ItemStack item) {
if (item == null || !item.hasItemMeta()) {
return Optional.empty();
}
String value = item.getItemMeta().getPersistentDataContainer()
.get(abilityKey, PersistentDataType.STRING);
if (value == null) {
return Optional.empty();
}
try {
return Optional.of(Ability.valueOf(value));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
@Override
public Optional<UUID> owner(ItemStack item) {
if (item == null || !item.hasItemMeta()) {
return Optional.empty();
}
String value = item.getItemMeta().getPersistentDataContainer()
.get(ownerKey, PersistentDataType.STRING);
if (value == null) {
return Optional.empty();
}
try {
return Optional.of(UUID.fromString(value));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
@Override
public void removeInvalid(Player player, PlayerState state) {
ItemStack[] contents = player.getInventory().getContents();
for (int index = 0; index < contents.length; index++) {
ItemStack item = contents[index];
if (isBoundAbilityItem(item)
&& (owner(item).filter(state.playerId()::equals).isEmpty()
|| ability(item).filter(state.readyAbilityItems()::contains).isEmpty())) {
player.getInventory().setItem(index, null);
}
}
}
@Override
public void removeAll(Player player) {
ItemStack[] contents = player.getInventory().getContents();
for (int index = 0; index < contents.length; index++) {
if (isBoundAbilityItem(contents[index])) {
player.getInventory().setItem(index, null);
}
}
}
private ItemStack create(UUID ownerId, Ability ability) {
PluginSettings.AbilityItemSettings configured = settingsFor(ability);
Material material = Material.matchMaterial(configured.material());
if (material == null) {
throw new IllegalStateException("Configured item material is unavailable");
}
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName(ChatColor.GOLD + configured.name());
meta.getPersistentDataContainer().set(
abilityKey, PersistentDataType.STRING, ability.name()
);
meta.getPersistentDataContainer().set(
ownerKey, PersistentDataType.STRING, ownerId.toString()
);
item.setItemMeta(meta);
return item;
}
private boolean contains(Player player, UUID ownerId, Ability ability) {
for (ItemStack item : player.getInventory().getContents()) {
if (owner(item).filter(ownerId::equals).isPresent()
&& ability(item).filter(ability::equals).isPresent()) {
return true;
}
}
return false;
}
private PluginSettings.AbilityItemSettings settingsFor(Ability ability) {
return switch (ability) {
case ASSASSIN_INVISIBILITY -> settings.assassinItem();
case FIXER_BOOST -> settings.fixerItem();
case TAMER_CAPTURE -> settings.tamerItem();
default -> throw new IllegalArgumentException(
ability.name().toLowerCase(Locale.ROOT) + " has no class item"
);
};
}
private static boolean isItemAbilityForClass(Ability ability, TyrantClass tyrantClass) {
return switch (tyrantClass) {
case ASSASSIN -> ability == Ability.ASSASSIN_INVISIBILITY;
case FIXER -> ability == Ability.FIXER_BOOST;
case TAMER -> ability == Ability.TAMER_CAPTURE;
case NONE -> false;
};
}
}
@@ -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,24 @@
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);
}
@Override
public java.util.Set<UUID> onlinePlayerIds() {
return Bukkit.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
}
@@ -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,139 @@
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().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);
}
readyItemFor(tyrantClass).ifPresent(readyItems::add);
return new PlayerState(
player.playerId(),
player.latestName(),
player.lastLogin(),
player.optedOutUntil(),
tyrantClass,
player.followerOf(),
cooldowns,
readyItems,
player.capturedMobs()
);
}
private static Optional<Ability> readyItemFor(TyrantClass tyrantClass) {
return switch (tyrantClass) {
case ASSASSIN -> Optional.of(Ability.ASSASSIN_INVISIBILITY);
case FIXER -> Optional.of(Ability.FIXER_BOOST);
case TAMER -> Optional.of(Ability.TAMER_CAPTURE);
case NONE -> Optional.empty();
};
}
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,45 @@
package games.dmg.spigottyrant;
import java.time.Instant;
import java.util.EnumMap;
import java.util.Map;
public final class FixerAbilityService {
private final AbilityReadinessService readiness;
private final PluginSettings settings;
public FixerAbilityService(
AbilityReadinessService readiness,
PluginSettings settings
) {
this.readiness = readiness;
this.settings = settings;
}
public FixerUseResult activate(PlayerState player, Instant now, boolean nearTyrant) {
if (player.tyrantClass() != TyrantClass.FIXER) {
return new FixerUseResult(player, AbilityUseStatus.WRONG_CLASS, 0);
}
if (!player.readyAbilityItems().contains(Ability.FIXER_BOOST)) {
return new FixerUseResult(player, AbilityUseStatus.NOT_READY, 0);
}
PlayerState used = readiness.consume(
player, Ability.FIXER_BOOST, now, settings.fixerCooldown()
);
Instant activeUntil = now.plus(settings.fixerEffectDuration());
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
deadlines.putAll(used.cooldownEnds());
deadlines.put(Ability.FIXER_BOOST_ACTIVE, activeUntil);
if (nearTyrant) {
deadlines.put(Ability.FIXER_NEAR_TYRANT_ACTIVE, activeUntil);
}
PlayerState updated = new PlayerState(
used.playerId(), used.latestName(), used.lastLogin(), used.optedOutUntil(),
used.tyrantClass(), used.followerOf(), deadlines, used.readyAbilityItems(),
used.capturedMobs()
);
int rows = nearTyrant
? settings.fixerNearTyrantHeartRows() : settings.fixerNormalHeartRows();
return new FixerUseResult(updated, AbilityUseStatus.ACTIVATED, rows);
}
}
@@ -0,0 +1,74 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Instant;
import org.bukkit.Server;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
public final class FixerEffectController implements Runnable {
private static final int EFFECT_TICKS = 30;
private final TyrantStateManager stateManager;
private final Server server;
private final PluginSettings settings;
private final Clock clock;
public FixerEffectController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.server = server;
this.settings = settings;
this.clock = clock;
}
@Override
public void run() {
Instant now = clock.instant();
for (Player player : server.getOnlinePlayers()) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
boolean active = stateManager.game().lifecycle() == GameLifecycle.RUNNING
&& state.tyrantClass() == TyrantClass.FIXER
&& state.cooldownEnds().getOrDefault(
Ability.FIXER_BOOST_ACTIVE, Instant.MIN
).isAfter(now);
if (!active) {
clear(player);
continue;
}
boolean nearTier = state.cooldownEnds().getOrDefault(
Ability.FIXER_NEAR_TYRANT_ACTIVE, Instant.MIN
).isAfter(now);
int rows = nearTier
? settings.fixerNearTyrantHeartRows() : settings.fixerNormalHeartRows();
int healthBoostAmplifier = (rows - 1) * 5 - 1;
player.addPotionEffect(new PotionEffect(
PotionEffectType.STRENGTH, EFFECT_TICKS,
settings.fixerStrengthLevel() - 1, false, true, true
));
player.addPotionEffect(new PotionEffect(
PotionEffectType.HEALTH_BOOST, EFFECT_TICKS,
healthBoostAmplifier, false, true, true
));
}
}
public void clearAll() {
server.getOnlinePlayers().forEach(FixerEffectController::clear);
}
private static void clear(Player player) {
player.removePotionEffect(PotionEffectType.STRENGTH);
player.removePotionEffect(PotionEffectType.HEALTH_BOOST);
AttributeInstance maximumHealth = player.getAttribute(Attribute.MAX_HEALTH);
if (maximumHealth != null && player.getHealth() > maximumHealth.getValue()) {
player.setHealth(maximumHealth.getValue());
}
}
}
@@ -0,0 +1,79 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
public final class FixerItemListener implements Listener {
private final TyrantStateManager stateManager;
private final AbilityItemService items;
private final FixerAbilityService abilities;
private final PluginSettings settings;
private final Clock clock;
public FixerItemListener(
TyrantStateManager stateManager,
AbilityItemService items,
FixerAbilityService abilities,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.items = items;
this.abilities = abilities;
this.settings = settings;
this.clock = clock;
}
@EventHandler(priority = EventPriority.HIGH)
public void onInteract(PlayerInteractEvent event) {
Action action = event.getAction();
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK
&& action != Action.LEFT_CLICK_AIR && action != Action.LEFT_CLICK_BLOCK) {
return;
}
if (items.ability(event.getItem()).filter(Ability.FIXER_BOOST::equals).isEmpty()) {
return;
}
event.setCancelled(true);
UUID playerId = event.getPlayer().getUniqueId();
if (items.owner(event.getItem()).filter(playerId::equals).isEmpty()) {
event.getPlayer().getInventory().setItem(event.getHand(), null);
event.getPlayer().sendMessage(ChatColor.RED + "That ability item is not yours.");
return;
}
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) {
event.getPlayer().sendMessage(ChatColor.RED + "The Tyrant game is not running.");
return;
}
PlayerState player = stateManager.player(playerId, event.getPlayer().getName());
FixerUseResult result = abilities.activate(
player, clock.instant(), isNearTyrant(event.getPlayer())
);
if (result.status() == AbilityUseStatus.ACTIVATED) {
stateManager.updatePlayer(playerId, event.getPlayer().getName(), current -> result.player());
stateManager.saveIfDirty();
event.getPlayer().getInventory().setItem(event.getHand(), null);
event.getPlayer().sendMessage(ChatColor.GREEN + "Fixer boost activated with "
+ result.heartRows() + " rows of hearts.");
} else {
event.getPlayer().sendMessage(ChatColor.RED + "Ability unavailable: "
+ result.status().name().toLowerCase(java.util.Locale.ROOT) + ".");
}
}
private boolean isNearTyrant(org.bukkit.entity.Player player) {
Optional<org.bukkit.entity.Player> tyrant = stateManager.game().tyrantId()
.map(org.bukkit.Bukkit::getPlayer);
return tyrant.isPresent()
&& tyrant.orElseThrow().getWorld().equals(player.getWorld())
&& tyrant.orElseThrow().getLocation().distanceSquared(player.getLocation())
<= settings.tyrantRangeBlocks() * settings.tyrantRangeBlocks();
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
public record FixerUseResult(
PlayerState player,
AbilityUseStatus status,
int heartRows
) {
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.UUID;
public record FollowerResult(Map<UUID, PlayerState> players, FollowerStatus status) {
public FollowerResult {
players = Map.copyOf(players);
}
}
@@ -0,0 +1,111 @@
package games.dmg.spigottyrant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public final class FollowerService {
private final Map<UUID, UUID> invitations = new HashMap<>();
public FollowerResult invite(
GameState game,
Map<UUID, PlayerState> players,
UUID vigilanteId,
UUID targetId
) {
if (game.lifecycle() != GameLifecycle.RUNNING) {
return result(players, FollowerStatus.GAME_NOT_RUNNING);
}
if (game.vigilanteId().filter(vigilanteId::equals).isEmpty()) {
return result(players, FollowerStatus.NOT_VIGILANTE);
}
PlayerState target = players.get(targetId);
if (target == null || target.optedOutUntil().isPresent()
|| game.tyrantId().filter(targetId::equals).isPresent()
|| game.vigilanteId().filter(targetId::equals).isPresent()) {
return result(players, FollowerStatus.INELIGIBLE);
}
invitations.put(targetId, vigilanteId);
return result(players, FollowerStatus.INVITED);
}
public FollowerResult accept(
GameState game,
Map<UUID, PlayerState> players,
UUID targetId
) {
UUID invitedBy = invitations.get(targetId);
if (invitedBy == null
|| game.vigilanteId().filter(invitedBy::equals).isEmpty()) {
return result(players, FollowerStatus.NO_INVITATION);
}
PlayerState target = players.get(targetId);
if (target == null || target.optedOutUntil().isPresent()) {
invitations.remove(targetId);
return result(players, FollowerStatus.INELIGIBLE);
}
invitations.remove(targetId);
return updated(players, withFollower(target, Optional.of(invitedBy)), FollowerStatus.JOINED);
}
public FollowerResult dismiss(
GameState game,
Map<UUID, PlayerState> players,
UUID vigilanteId,
UUID targetId
) {
if (game.vigilanteId().filter(vigilanteId::equals).isEmpty()) {
return result(players, FollowerStatus.NOT_VIGILANTE);
}
PlayerState target = players.get(targetId);
if (target == null || target.followerOf().filter(vigilanteId::equals).isEmpty()) {
return result(players, FollowerStatus.NOT_FOLLOWER);
}
return updated(players, withFollower(target, Optional.empty()), FollowerStatus.DISMISSED);
}
public FollowerResult leave(
GameState game,
Map<UUID, PlayerState> players,
UUID targetId
) {
PlayerState target = players.get(targetId);
if (target == null || target.followerOf().isEmpty()) {
return result(players, FollowerStatus.NOT_FOLLOWER);
}
return updated(players, withFollower(target, Optional.empty()), FollowerStatus.LEFT);
}
public void clearInvitations() {
invitations.clear();
}
private static FollowerResult updated(
Map<UUID, PlayerState> players,
PlayerState updated,
FollowerStatus status
) {
Map<UUID, PlayerState> replacement = new HashMap<>(players);
replacement.put(updated.playerId(), updated);
return result(replacement, status);
}
private static FollowerResult result(
Map<UUID, PlayerState> players,
FollowerStatus status
) {
return new FollowerResult(players, status);
}
private static PlayerState withFollower(
PlayerState player,
Optional<UUID> followerOf
) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), followerOf, player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -0,0 +1,13 @@
package games.dmg.spigottyrant;
public enum FollowerStatus {
INVITED,
JOINED,
DISMISSED,
LEFT,
GAME_NOT_RUNNING,
NOT_VIGILANTE,
INELIGIBLE,
NO_INVITATION,
NOT_FOLLOWER
}
@@ -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,12 @@
package games.dmg.spigottyrant;
import java.util.UUID;
import org.bukkit.entity.Player;
public interface OnlinePlayerDirectory {
Player findByName(String name);
Player findById(UUID playerId);
java.util.Set<UUID> onlinePlayerIds();
}
@@ -0,0 +1,4 @@
package games.dmg.spigottyrant;
public record ParticipationResult(PlayerState player, ParticipationStatus status) {
}
@@ -0,0 +1,68 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
public final class ParticipationService {
private final Duration optOutDuration;
public ParticipationService(Duration optOutDuration) {
if (optOutDuration.isNegative() || optOutDuration.isZero()) {
throw new IllegalArgumentException("optOutDuration must be positive");
}
this.optOutDuration = optOutDuration;
}
public ParticipationResult optOut(GameState game, PlayerState player, Instant now) {
if (game.tyrantId().filter(player.playerId()::equals).isPresent()
|| game.vigilanteId().filter(player.playerId()::equals).isPresent()) {
return new ParticipationResult(
player, ParticipationStatus.MUST_RELINQUISH_ROLE
);
}
if (player.optedOutUntil().isPresent()) {
return new ParticipationResult(player, ParticipationStatus.ALREADY_OPTED_OUT);
}
PlayerState updated = new PlayerState(
player.playerId(),
player.latestName(),
player.lastLogin(),
Optional.of(now.plus(optOutDuration)),
TyrantClass.NONE,
Optional.empty(),
Map.of(),
Set.of(),
player.capturedMobs()
);
return new ParticipationResult(updated, ParticipationStatus.OPTED_OUT);
}
public ParticipationResult optIn(PlayerState player, Instant now) {
if (player.optedOutUntil().isEmpty()) {
return new ParticipationResult(player, ParticipationStatus.ALREADY_OPTED_IN);
}
if (now.isBefore(player.optedOutUntil().orElseThrow())) {
return new ParticipationResult(player, ParticipationStatus.COOLDOWN_ACTIVE);
}
PlayerState updated = new PlayerState(
player.playerId(),
player.latestName(),
player.lastLogin(),
Optional.empty(),
player.tyrantClass(),
player.followerOf(),
player.cooldownEnds(),
player.readyAbilityItems(),
player.capturedMobs()
);
return new ParticipationResult(updated, ParticipationStatus.OPTED_IN);
}
public ParticipationStatus status(PlayerState player, Instant now) {
return player.optedOutUntil().isPresent()
? ParticipationStatus.OPTED_OUT : ParticipationStatus.OPTED_IN;
}
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
public enum ParticipationStatus {
OPTED_IN,
OPTED_OUT,
COOLDOWN_ACTIVE,
MUST_RELINQUISH_ROLE,
ALREADY_OPTED_IN,
ALREADY_OPTED_OUT
}
@@ -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,50 @@
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()
)
);
for (String message : PlayerStatusMessages.forJoin(
stateManager.game(), player, clock.instant()
)) {
event.getPlayer().sendMessage(message);
}
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,53 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
public final class PlayerStatusMessages {
private PlayerStatusMessages() {
}
public static List<String> forJoin(
GameState game,
PlayerState player,
Instant now
) {
List<String> messages = new ArrayList<>();
messages.add("Game: " + game.lifecycle());
boolean hasRole = false;
if (game.tyrantId().filter(player.playerId()::equals).isPresent()) {
messages.add("Role: TYRANT. Level " + game.tyrantLevel() + ", choices "
+ game.unspentChoices() + ", abilities " + game.purchases() + ".");
hasRole = true;
}
if (game.vigilanteId().filter(player.playerId()::equals).isPresent()) {
messages.add("Role: VIGILANTE.");
hasRole = true;
} else if (player.followerOf().isPresent()) {
messages.add("Role: FOLLOWER of " + player.followerOf().orElseThrow() + ".");
hasRole = true;
}
if (player.tyrantClass() != TyrantClass.NONE) {
messages.add(ClassAssignmentMessages.loginReminder(player));
hasRole = true;
}
player.cooldownEnds().entrySet().stream()
.sorted(java.util.Map.Entry.comparingByKey())
.forEach(entry -> {
long seconds = Math.max(
0L, Duration.between(now, entry.getValue()).toSeconds()
);
messages.add("Cooldown: " + entry.getKey() + " " + seconds + "s remaining.");
});
if (player.optedOutUntil().isPresent()) {
messages.add("Participation: OPTED OUT. You may use /tyrant optin at "
+ player.optedOutUntil().orElseThrow() + ".");
} else if (!hasRole) {
messages.add("You currently have no role or class. Use /tyrant optout "
+ "if you do not want to participate.");
}
return List.copyOf(messages);
}
}
@@ -0,0 +1,269 @@
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 vigilanteCombatDuration,
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(vigilanteCombatDuration, "vigilante-combat-seconds");
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, "vigilante-combat-seconds", Duration.ofSeconds(15)),
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,7 @@
package games.dmg.spigottyrant;
public record RelinquishmentResult(
LifecycleState state,
RelinquishmentStatus status
) {
}
@@ -0,0 +1,7 @@
package games.dmg.spigottyrant;
public enum RelinquishmentStatus {
RELINQUISHED,
NO_CENTRAL_ROLE,
GAME_NOT_RUNNING
}
@@ -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().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,234 @@
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 RoleMaintenanceService {
private final RoleCandidateSelector candidates;
private final TyrantSuccessionService succession;
private final Duration inactivity;
private final Duration activityWindow;
private final Duration pendingTimeout;
private final RandomGenerator random;
public RoleMaintenanceService(
RoleCandidateSelector candidates,
TyrantSuccessionService succession,
Duration inactivity,
Duration activityWindow,
Duration pendingTimeout,
RandomGenerator random
) {
this.candidates = candidates;
this.succession = succession;
this.inactivity = inactivity;
this.activityWindow = activityWindow;
this.pendingTimeout = pendingTimeout;
this.random = random;
}
public LifecycleState maintain(
PersistentState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
return new LifecycleState(state.game(), state.players());
}
LifecycleState current = confirmPending(state, onlinePlayerIds, now);
if (isInactive(current.game().tyrantId(), current.players(), now)) {
return succession.succeed(
current.asPersistentState(), Optional.empty(), onlinePlayerIds, now
);
}
if (isInactive(current.game().vigilanteId(), current.players(), now)) {
current = rerollVigilante(
current.asPersistentState(), current.game().vigilanteId(), onlinePlayerIds, now
);
}
current = rerollExpiredTyrant(current, onlinePlayerIds, now);
current = rerollExpiredVigilante(current, onlinePlayerIds, now);
if (current.game().tyrantId().isEmpty() && current.game().pendingTyrant().isEmpty()) {
current = selectTyrant(current, Optional.empty(), onlinePlayerIds, now);
}
if (current.game().tyrantId().isPresent()
&& current.game().vigilanteId().isEmpty()
&& current.game().pendingVigilante().isEmpty()) {
current = rerollVigilante(
current.asPersistentState(), Optional.empty(), onlinePlayerIds, now
);
}
return current;
}
public LifecycleState rerollVigilante(
PersistentState state,
Optional<UUID> previousVigilante,
Set<UUID> onlinePlayerIds,
Instant now
) {
Map<UUID, PlayerState> players = clearFollowers(state.players(), previousVigilante);
Set<UUID> excluded = state.game().tyrantId().map(Set::of).orElseGet(Set::of);
Optional<UUID> selected = candidates.select(
players, excluded, previousVigilante, now, activityWindow, random
);
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
Optional<PendingSelection> pending = selected
.filter(playerId -> active.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
GameState game = copyRoles(
state.game(), state.game().tyrantId(), active,
state.game().pendingTyrant(), pending
);
return new LifecycleState(game, players);
}
private LifecycleState confirmPending(
PersistentState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
GameState game = state.game();
Optional<UUID> tyrant = game.tyrantId();
Optional<UUID> vigilante = game.vigilanteId();
Optional<PendingSelection> pendingTyrant = game.pendingTyrant();
Optional<PendingSelection> pendingVigilante = game.pendingVigilante();
if (pendingTyrant.isPresent()) {
PendingSelection pending = pendingTyrant.orElseThrow();
if (!now.isAfter(pending.expiresAt())
&& onlinePlayerIds.contains(pending.candidateId())
&& eligibleAtConfirmation(state.players().get(pending.candidateId()), now)
&& vigilante.filter(pending.candidateId()::equals).isEmpty()) {
tyrant = Optional.of(pending.candidateId());
pendingTyrant = Optional.empty();
}
}
if (pendingVigilante.isPresent()) {
PendingSelection pending = pendingVigilante.orElseThrow();
if (!now.isAfter(pending.expiresAt())
&& onlinePlayerIds.contains(pending.candidateId())
&& eligibleAtConfirmation(state.players().get(pending.candidateId()), now)
&& tyrant.filter(pending.candidateId()::equals).isEmpty()) {
vigilante = Optional.of(pending.candidateId());
pendingVigilante = Optional.empty();
}
}
return new LifecycleState(
copyRoles(game, tyrant, vigilante, pendingTyrant, pendingVigilante),
state.players()
);
}
private LifecycleState rerollExpiredTyrant(
LifecycleState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
Optional<PendingSelection> pending = state.game().pendingTyrant();
if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) {
return state;
}
return selectTyrant(
state,
Optional.of(pending.orElseThrow().candidateId()),
onlinePlayerIds,
now
);
}
private LifecycleState selectTyrant(
LifecycleState state,
Optional<UUID> previous,
Set<UUID> onlinePlayerIds,
Instant now
) {
Set<UUID> excluded = state.game().vigilanteId().map(Set::of).orElseGet(Set::of);
Optional<UUID> selected = candidates.select(
state.players(), excluded, previous, now, activityWindow, random
);
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
Optional<PendingSelection> pending = selected
.filter(playerId -> active.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
GameState game = copyRoles(
state.game(), active, state.game().vigilanteId(), pending,
state.game().pendingVigilante()
);
return new LifecycleState(game, state.players());
}
private LifecycleState rerollExpiredVigilante(
LifecycleState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
Optional<PendingSelection> pending = state.game().pendingVigilante();
if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) {
return state;
}
return rerollVigilante(
state.asPersistentState(),
Optional.of(pending.orElseThrow().candidateId()),
onlinePlayerIds,
now
);
}
private boolean isInactive(
Optional<UUID> roleHolder,
Map<UUID, PlayerState> players,
Instant now
) {
if (roleHolder.isEmpty()) {
return false;
}
PlayerState player = players.get(roleHolder.orElseThrow());
return player != null
&& player.lastLogin().isPresent()
&& !player.lastLogin().orElseThrow().plus(inactivity).isAfter(now);
}
private static boolean eligibleAtConfirmation(PlayerState player, Instant now) {
return player != null && player.optedOutUntil().isEmpty();
}
private static Map<UUID, PlayerState> clearFollowers(
Map<UUID, PlayerState> players,
Optional<UUID> previousVigilante
) {
if (previousVigilante.isEmpty()) {
return players;
}
Map<UUID, PlayerState> updated = new HashMap<>();
UUID previous = previousVigilante.orElseThrow();
for (PlayerState player : players.values()) {
Optional<UUID> followerOf = player.followerOf().filter(id -> !id.equals(previous));
updated.put(player.playerId(), new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), followerOf, player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
));
}
return Map.copyOf(updated);
}
private static GameState copyRoles(
GameState game,
Optional<UUID> tyrant,
Optional<UUID> vigilante,
Optional<PendingSelection> pendingTyrant,
Optional<PendingSelection> pendingVigilante
) {
return new GameState(
game.lifecycle(), tyrant, vigilante, pendingTyrant, pendingVigilante,
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
game.unspentChoices(), game.purchases()
);
}
}
@@ -0,0 +1,44 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.bukkit.Server;
import org.bukkit.entity.Player;
public final class RoleMaintenanceTask implements Runnable {
private final TyrantStateManager stateManager;
private final RoleMaintenanceService maintenance;
private final TyrantPresentation presentation;
private final Server server;
private final Clock clock;
public RoleMaintenanceTask(
TyrantStateManager stateManager,
RoleMaintenanceService maintenance,
TyrantPresentation presentation,
Server server,
Clock clock
) {
this.stateManager = stateManager;
this.maintenance = maintenance;
this.presentation = presentation;
this.server = server;
this.clock = clock;
}
@Override
public void run() {
PersistentState before = stateManager.snapshot();
Set<UUID> online = server.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(Collectors.toUnmodifiableSet());
LifecycleState after = maintenance.maintain(before, online, clock.instant());
if (!after.asPersistentState().equals(before)) {
stateManager.replaceState(after);
stateManager.saveIfDirty();
presentation.reconcile(after.game().tyrantId());
}
}
}
@@ -0,0 +1,51 @@
package games.dmg.spigottyrant;
import java.time.Instant;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public final class RoleRelinquishmentService {
private final TyrantSuccessionService succession;
private final RoleMaintenanceService maintenance;
public RoleRelinquishmentService(
TyrantSuccessionService succession,
RoleMaintenanceService maintenance
) {
this.succession = succession;
this.maintenance = maintenance;
}
public RelinquishmentResult relinquish(
PersistentState state,
UUID playerId,
Set<UUID> onlinePlayerIds,
Instant now
) {
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
return new RelinquishmentResult(
new LifecycleState(state.game(), state.players()),
RelinquishmentStatus.GAME_NOT_RUNNING
);
}
if (state.game().tyrantId().filter(playerId::equals).isPresent()) {
return new RelinquishmentResult(
succession.succeed(state, Optional.empty(), onlinePlayerIds, now),
RelinquishmentStatus.RELINQUISHED
);
}
if (state.game().vigilanteId().filter(playerId::equals).isPresent()) {
return new RelinquishmentResult(
maintenance.rerollVigilante(
state, Optional.of(playerId), onlinePlayerIds, now
),
RelinquishmentStatus.RELINQUISHED
);
}
return new RelinquishmentResult(
new LifecycleState(state.game(), state.players()),
RelinquishmentStatus.NO_CENTRAL_ROLE
);
}
}
@@ -0,0 +1,199 @@
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;
private VigilanteEffectController vigilanteEffects;
private AssassinEffectController assassinEffects;
private FixerEffectController fixerEffects;
@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;
}
Clock clock = Clock.systemUTC();
RoleCandidateSelector candidateSelector = new RoleCandidateSelector();
java.util.random.RandomGenerator random = java.util.random.RandomGenerator.getDefault();
TyrantSuccessionService succession = new TyrantSuccessionService(
candidateSelector,
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
random
);
RoleMaintenanceService maintenance = new RoleMaintenanceService(
candidateSelector,
succession,
settings.roleInactivity(),
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
random
);
BukkitOnlinePlayerDirectory onlinePlayers = new BukkitOnlinePlayerDirectory();
FollowerService followers = new FollowerService();
AbilityReadinessService readiness = new AbilityReadinessService();
AbilityItemService abilityItems = new BukkitAbilityItemService(this, settings);
AssassinAbilityService assassinAbilities = new AssassinAbilityService(
readiness, settings
);
assassinEffects = new AssassinEffectController(
stateManager, getServer(), settings, clock
);
FixerAbilityService fixerAbilities = new FixerAbilityService(readiness, settings);
fixerEffects = new FixerEffectController(stateManager, getServer(), settings, clock);
VigilanteCombatTracker combatTracker = new VigilanteCombatTracker();
vigilanteEffects = new VigilanteEffectController(
stateManager, getServer(), combatTracker, settings, clock
);
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(),
new ClassAssignmentService(),
onlinePlayers,
clock,
new ParticipationService(settings.optOutDuration()),
new RoleRelinquishmentService(succession, maintenance),
tyrantPresentation,
abilityItems
));
Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata")
.setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers));
Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata")
.setExecutor(new TyrantAdminCommand(
stateManager,
new GameLifecycleService(),
onlinePlayers,
managedEffects,
clock,
candidateSelector,
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
random
));
getServer().getPluginManager().registerEvents(
new PlayerJoinListener(stateManager, clock, tyrantPresentation),
this
);
getServer().getPluginManager().registerEvents(
new TyrantDeathListener(
stateManager, succession, tyrantPresentation, getServer(), clock
),
this
);
getServer().getPluginManager().registerEvents(
new VigilanteDeathListener(
stateManager, new TyrantProgressionService(), maintenance,
getServer(), clock
),
this
);
getServer().getPluginManager().registerEvents(
new VigilanteCombatListener(
stateManager, combatTracker, clock, settings.vigilanteCombatDuration()
),
this
);
getServer().getPluginManager().registerEvents(
new BoundItemListener(abilityItems),
this
);
getServer().getPluginManager().registerEvents(
new AssassinItemListener(
stateManager, abilityItems, assassinAbilities, settings, clock
),
this
);
getServer().getPluginManager().registerEvents(
new AssassinJumpListener(stateManager, assassinAbilities, clock),
this
);
getServer().getPluginManager().registerEvents(
new FixerItemListener(
stateManager, abilityItems, fixerAbilities, settings, clock
),
this
);
long maintenanceTicks = Math.max(
1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L)
);
getServer().getScheduler().runTaskTimer(
this,
new RoleMaintenanceTask(
stateManager, maintenance, tyrantPresentation, getServer(), clock
),
maintenanceTicks,
maintenanceTicks
);
getServer().getScheduler().runTaskTimer(this, vigilanteEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, assassinEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(
this,
new AbilityItemRefreshTask(
stateManager, readiness, abilityItems, getServer(), clock
),
20L,
20L
);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Tyrant enabled.");
}
@Override
public void onDisable() {
if (fixerEffects != null) {
fixerEffects.clearAll();
}
if (assassinEffects != null) {
assassinEffects.clearAll();
}
if (vigilanteEffects != null) {
vigilanteEffects.clear();
}
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,207 @@
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().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,283 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Duration;
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;
private final ParticipationService participation;
private final RoleRelinquishmentService relinquishment;
private final TyrantPresentation presentation;
private final AbilityItemService abilityItems;
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, progression, assignments, onlinePlayers, clock,
new ParticipationService(Duration.ofDays(7)), null, null, null
);
}
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
ClassAssignmentService assignments,
OnlinePlayerDirectory onlinePlayers,
Clock clock,
ParticipationService participation,
RoleRelinquishmentService relinquishment,
TyrantPresentation presentation,
AbilityItemService abilityItems
) {
this.stateManager = stateManager;
this.progression = progression;
this.assignments = assignments;
this.onlinePlayers = onlinePlayers;
this.clock = clock;
this.participation = participation;
this.relinquishment = relinquishment;
this.presentation = presentation;
this.abilityItems = abilityItems;
}
@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;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) {
showPlayerStatus(player);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("optout")) {
changeParticipation(player, false);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("optin")) {
changeParticipation(player, true);
return true;
}
if (arguments.length >= 1 && arguments[0].equalsIgnoreCase("relinquish")) {
relinquish(player, arguments);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("item")) {
recoverItems(player);
return true;
}
player.sendMessage(
ChatColor.YELLOW
+ "Usage: /tyrant <status|choices|buy|assign|item|optout|optin|"
+ "relinquish confirm>"
);
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 -> {
if (abilityItems != null) {
abilityItems.removeAll(previous);
}
previous.sendMessage(ChatColor.YELLOW + "You are no longer the "
+ tyrantClass.name() + ".");
});
if (abilityItems != null) {
abilityItems.removeInvalid(target, result.players().get(target.getUniqueId()));
abilityItems.recover(target, result.players().get(target.getUniqueId()));
}
target.sendMessage(ChatColor.GREEN + "You are now the " + tyrantClass.name() + ".");
tyrant.sendMessage(
ChatColor.GREEN + "Assigned " + tyrantClass.name() + " to " + target.getName() + "."
);
}
private void showPlayerStatus(Player player) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
PlayerStatusMessages.forJoin(stateManager.game(), state, clock.instant())
.forEach(player::sendMessage);
}
private void changeParticipation(Player player, boolean optIn) {
ParticipationResult[] result = new ParticipationResult[1];
stateManager.updatePlayer(player.getUniqueId(), player.getName(), current -> {
result[0] = optIn
? participation.optIn(current, clock.instant())
: participation.optOut(stateManager.game(), current, clock.instant());
return result[0].player();
});
if (result[0].status() == ParticipationStatus.OPTED_IN
|| result[0].status() == ParticipationStatus.OPTED_OUT) {
stateManager.saveIfDirty();
}
if (result[0].status() == ParticipationStatus.MUST_RELINQUISH_ROLE) {
player.sendMessage(ChatColor.RED
+ "Use /tyrant relinquish confirm before opting out.");
return;
}
if (result[0].status() == ParticipationStatus.COOLDOWN_ACTIVE) {
player.sendMessage(ChatColor.RED + "You may opt in at "
+ result[0].player().optedOutUntil().orElseThrow() + ".");
return;
}
player.sendMessage(ChatColor.YELLOW + "Participation: "
+ readable(result[0].status()) + ".");
}
private void recoverItems(Player player) {
if (abilityItems == null) {
player.sendMessage(ChatColor.RED + "Ability item recovery is unavailable.");
return;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
abilityItems.recover(player, state);
player.sendMessage(ChatColor.YELLOW
+ "Recovered every currently ready missing class item that could fit.");
}
private void relinquish(Player player, String[] arguments) {
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) {
player.sendMessage(ChatColor.RED
+ "Relinquishing rerolls your role. Use /tyrant relinquish confirm.");
return;
}
if (relinquishment == null) {
player.sendMessage(ChatColor.RED + "Role relinquishment is unavailable.");
return;
}
RelinquishmentResult result = relinquishment.relinquish(
stateManager.snapshot(), player.getUniqueId(), onlinePlayers.onlinePlayerIds(),
clock.instant()
);
if (result.status() == RelinquishmentStatus.RELINQUISHED) {
stateManager.replaceState(result.state());
stateManager.saveIfDirty();
if (presentation != null) {
presentation.reconcile(result.state().game().tyrantId());
}
}
player.sendMessage(ChatColor.YELLOW + "Relinquishment: "
+ readable(result.status()) + ".");
}
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,50 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Duration;
import java.util.UUID;
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.EntityDamageByEntityEvent;
public final class VigilanteCombatListener implements Listener {
private final TyrantStateManager stateManager;
private final VigilanteCombatTracker tracker;
private final Clock clock;
private final Duration combatDuration;
public VigilanteCombatListener(
TyrantStateManager stateManager,
VigilanteCombatTracker tracker,
Clock clock,
Duration combatDuration
) {
this.stateManager = stateManager;
this.tracker = tracker;
this.clock = clock;
this.combatDuration = combatDuration;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageByEntityEvent event) {
if (!(event.getEntity() instanceof Player damaged)
|| !(event.getDamager() instanceof Player attacker)) {
return;
}
GameState game = stateManager.game();
if (game.lifecycle() != GameLifecycle.RUNNING
|| game.tyrantId().isEmpty() || game.vigilanteId().isEmpty()) {
return;
}
UUID first = damaged.getUniqueId();
UUID second = attacker.getUniqueId();
UUID tyrant = game.tyrantId().orElseThrow();
UUID vigilante = game.vigilanteId().orElseThrow();
if ((first.equals(tyrant) && second.equals(vigilante))
|| (first.equals(vigilante) && second.equals(tyrant))) {
tracker.markCombat(clock.instant(), combatDuration);
}
}
}
@@ -0,0 +1,21 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
public final class VigilanteCombatTracker {
private Optional<Instant> activeUntil = Optional.empty();
public void markCombat(Instant now, Duration duration) {
activeUntil = Optional.of(now.plus(duration));
}
public boolean isActive(Instant now) {
return activeUntil.filter(deadline -> deadline.isAfter(now)).isPresent();
}
public void clear() {
activeUntil = Optional.empty();
}
}
@@ -0,0 +1,83 @@
package games.dmg.spigottyrant;
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 VigilanteCommand implements CommandExecutor {
private final TyrantStateManager stateManager;
private final FollowerService followers;
private final OnlinePlayerDirectory onlinePlayers;
public VigilanteCommand(
TyrantStateManager stateManager,
FollowerService followers,
OnlinePlayerDirectory onlinePlayers
) {
this.stateManager = stateManager;
this.followers = followers;
this.onlinePlayers = onlinePlayers;
}
@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;
}
FollowerResult result;
Player target = null;
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("accept")) {
result = followers.accept(stateManager.game(), stateManager.players(),
player.getUniqueId());
} else if (arguments.length == 1 && arguments[0].equalsIgnoreCase("leave")) {
result = followers.leave(stateManager.game(), stateManager.players(),
player.getUniqueId());
} else if (arguments.length == 2
&& (arguments[0].equalsIgnoreCase("invite")
|| arguments[0].equalsIgnoreCase("dismiss"))) {
target = onlinePlayers.findByName(arguments[1]);
if (target == null) {
player.sendMessage(ChatColor.RED + "That player must be online.");
return true;
}
stateManager.player(target.getUniqueId(), target.getName());
result = arguments[0].equalsIgnoreCase("invite")
? followers.invite(
stateManager.game(), stateManager.players(), player.getUniqueId(),
target.getUniqueId()
)
: followers.dismiss(
stateManager.game(), stateManager.players(), player.getUniqueId(),
target.getUniqueId()
);
} else {
player.sendMessage(ChatColor.YELLOW
+ "Usage: /vigilante <invite <player>|accept|dismiss <player>|leave>");
return true;
}
if (result.status() == FollowerStatus.JOINED
|| result.status() == FollowerStatus.DISMISSED
|| result.status() == FollowerStatus.LEFT) {
stateManager.replacePlayers(result.players());
stateManager.saveIfDirty();
}
player.sendMessage(ChatColor.YELLOW + "Follower action: "
+ result.status().name().toLowerCase(Locale.ROOT).replace('_', ' ') + ".");
if (target != null && result.status() == FollowerStatus.INVITED) {
target.sendMessage(ChatColor.YELLOW + player.getName()
+ " invited you to become a Follower. Use /vigilante accept.");
} else if (target != null && result.status() == FollowerStatus.DISMISSED) {
target.sendMessage(ChatColor.YELLOW + "You are no longer a Follower.");
}
return true;
}
}
@@ -0,0 +1,62 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
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 VigilanteDeathListener implements Listener {
private final TyrantStateManager stateManager;
private final TyrantProgressionService progression;
private final RoleMaintenanceService maintenance;
private final Server server;
private final Clock clock;
public VigilanteDeathListener(
TyrantStateManager stateManager,
TyrantProgressionService progression,
RoleMaintenanceService maintenance,
Server server,
Clock clock
) {
this.stateManager = stateManager;
this.progression = progression;
this.maintenance = maintenance;
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.vigilanteId().filter(victimId::equals).isEmpty()) {
return;
}
Optional<UUID> killerId = Optional.ofNullable(event.getEntity().getKiller())
.map(Player::getUniqueId);
GameState progressed = progression.recordVigilanteDeath(game, killerId);
PersistentState withProgress = new PersistentState(
progressed, stateManager.players()
);
Set<UUID> online = server.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
LifecycleState rerolled = maintenance.rerollVigilante(
withProgress, Optional.of(victimId), online, clock.instant()
);
stateManager.replaceState(rerolled);
stateManager.saveIfDirty();
String message = ChatColor.YELLOW + "The Vigilante died; a replacement "
+ (rerolled.game().vigilanteId().isPresent() ? "has been selected." : "is pending.");
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
}
@@ -0,0 +1,98 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
public final class VigilanteEffectController implements Runnable {
private static final int EFFECT_TICKS = 30;
private final TyrantStateManager stateManager;
private final Server server;
private final VigilanteCombatTracker tracker;
private final PluginSettings settings;
private final Clock clock;
private Optional<UUID> previousVigilante = Optional.empty();
public VigilanteEffectController(
TyrantStateManager stateManager,
Server server,
VigilanteCombatTracker tracker,
PluginSettings settings,
Clock clock
) {
this.stateManager = stateManager;
this.server = server;
this.tracker = tracker;
this.settings = settings;
this.clock = clock;
}
@Override
public void run() {
GameState game = stateManager.game();
if (!previousVigilante.equals(game.vigilanteId())) {
previousVigilante.map(server::getPlayer).ifPresent(this::removeManagedEffects);
previousVigilante = game.vigilanteId();
}
Player vigilante = game.vigilanteId().map(server::getPlayer).orElse(null);
if (vigilante == null || game.lifecycle() != GameLifecycle.RUNNING
|| !tracker.isActive(clock.instant()) || vigilante.isDead()) {
if (vigilante != null) {
removeManagedEffects(vigilante);
}
return;
}
int followers = nearbyLivingFollowers(game.vigilanteId().orElseThrow(), vigilante);
VigilantePower.Levels levels = VigilantePower.forFollowers(
followers, settings.followerStrengthCap(), settings.followerResistanceCap()
);
apply(vigilante, PotionEffectType.STRENGTH, levels.strength());
apply(vigilante, PotionEffectType.RESISTANCE, levels.resistance());
}
public void clear() {
previousVigilante.map(server::getPlayer).ifPresent(this::removeManagedEffects);
previousVigilante = Optional.empty();
tracker.clear();
}
private int nearbyLivingFollowers(UUID vigilanteId, Player vigilante) {
int count = 0;
double rangeSquared = settings.followerRangeBlocks()
* settings.followerRangeBlocks();
for (PlayerState state : stateManager.players().values()) {
if (state.followerOf().filter(vigilanteId::equals).isEmpty()
|| state.optedOutUntil().isPresent()) {
continue;
}
Player follower = server.getPlayer(state.playerId());
if (follower == null || follower.isDead()
|| !follower.getWorld().equals(vigilante.getWorld())) {
continue;
}
if (follower.getLocation().distanceSquared(vigilante.getLocation()) <= rangeSquared) {
count++;
}
}
return count;
}
private static void apply(Player player, PotionEffectType type, int level) {
if (level == 0) {
player.removePotionEffect(type);
return;
}
player.addPotionEffect(new PotionEffect(
type, EFFECT_TICKS, level - 1, false, true, true
));
}
private void removeManagedEffects(Player player) {
player.removePotionEffect(PotionEffectType.STRENGTH);
player.removePotionEffect(PotionEffectType.RESISTANCE);
}
}
@@ -0,0 +1,23 @@
package games.dmg.spigottyrant;
public final class VigilantePower {
private VigilantePower() {
}
public static Levels forFollowers(
int followerCount,
int strengthCap,
int resistanceCap
) {
if (followerCount < 0 || strengthCap < 1 || resistanceCap < 1) {
throw new IllegalArgumentException("follower count and caps are invalid");
}
return new Levels(
Math.min(followerCount, strengthCap),
Math.min(followerCount, resistanceCap)
);
}
public record Levels(int strength, int resistance) {
}
}
@@ -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();
}
}
+52
View File
@@ -0,0 +1,52 @@
# 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
vigilante-combat-seconds: 15
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.
+21
View File
@@ -0,0 +1,21 @@
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 <status|choices|buy|assign|optout|optin|relinquish confirm>
vigilante:
description: Manage Vigilante Followers.
usage: /vigilante <invite <player>|accept|dismiss <player>|leave>
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,60 @@
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 AbilityReadinessServiceTest {
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final AbilityReadinessService service = new AbilityReadinessService();
@Test
void successfulCooldownActivationConsumesItemAndRefreshRestoresIt() {
PlayerState ready = player(
TyrantClass.ASSASSIN, Map.of(), Set.of(Ability.ASSASSIN_INVISIBILITY)
);
PlayerState used = service.consume(
ready, Ability.ASSASSIN_INVISIBILITY, NOW, Duration.ofHours(1)
);
PlayerState refreshed = service.refresh(used, NOW.plus(Duration.ofHours(1)));
assertEquals(Set.of(), used.readyAbilityItems());
assertEquals(NOW.plus(Duration.ofHours(1)),
used.cooldownEnds().get(Ability.ASSASSIN_INVISIBILITY));
assertEquals(Set.of(Ability.ASSASSIN_INVISIBILITY),
refreshed.readyAbilityItems());
assertEquals(Map.of(), refreshed.cooldownEnds());
}
@Test
void tamerItemRemainsReadyWithoutCooldown() {
PlayerState tamer = player(
TyrantClass.TAMER, Map.of(), Set.of(Ability.TAMER_CAPTURE)
);
PlayerState after = service.consume(
tamer, Ability.TAMER_CAPTURE, NOW, Duration.ZERO
);
assertEquals(tamer, after);
}
private static PlayerState player(
TyrantClass tyrantClass,
Map<Ability, Instant> cooldowns,
Set<Ability> ready
) {
return new PlayerState(
UUID.fromString("11111111-1111-1111-1111-111111111111"), "Player",
Optional.empty(), Optional.empty(), tyrantClass, Optional.empty(),
cooldowns, ready, java.util.List.of()
);
}
}
@@ -0,0 +1,60 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
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 AssassinAbilityServiceTest {
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final PluginSettings settings = PluginSettings.from(Map.of());
private final AssassinAbilityService service = new AssassinAbilityService(
new AbilityReadinessService(), settings
);
@Test
void invisibilityConsumesItemAndDoublesDurationNearTyrant() {
PlayerState ready = assassin(Set.of(Ability.ASSASSIN_INVISIBILITY), Map.of());
AbilityUseResult normal = service.activateInvisibility(ready, NOW, false);
AbilityUseResult near = service.activateInvisibility(ready, NOW, true);
assertEquals(AbilityUseStatus.ACTIVATED, normal.status());
assertEquals(NOW.plusSeconds(600), normal.player().cooldownEnds()
.get(Ability.ASSASSIN_INVISIBILITY_ACTIVE));
assertEquals(NOW.plusSeconds(1200), near.player().cooldownEnds()
.get(Ability.ASSASSIN_INVISIBILITY_ACTIVE));
assertEquals(NOW.plusSeconds(3600), normal.player().cooldownEnds()
.get(Ability.ASSASSIN_INVISIBILITY));
assertEquals(Set.of(), normal.player().readyAbilityItems());
}
@Test
void doubleJumpAddsSpeedWeaknessAndSixtySecondCooldown() {
PlayerState assassin = assassin(Set.of(), Map.of());
AbilityUseResult result = service.doubleJump(assassin, NOW);
AbilityUseResult blocked = service.doubleJump(result.player(), NOW.plusSeconds(30));
assertEquals(AbilityUseStatus.ACTIVATED, result.status());
assertEquals(NOW.plusSeconds(60), result.player().cooldownEnds()
.get(Ability.ASSASSIN_DOUBLE_JUMP));
assertEquals(NOW.plusSeconds(15), result.player().cooldownEnds()
.get(Ability.ASSASSIN_SPEED_ACTIVE));
assertEquals(NOW.plusSeconds(20), result.player().cooldownEnds()
.get(Ability.ASSASSIN_WEAKNESS_ACTIVE));
assertEquals(AbilityUseStatus.COOLDOWN, blocked.status());
}
private static PlayerState assassin(Set<Ability> ready, Map<Ability, Instant> cooldowns) {
return new PlayerState(
UUID.fromString("11111111-1111-1111-1111-111111111111"), "Assassin",
Optional.empty(), Optional.empty(), TyrantClass.ASSASSIN, Optional.empty(),
cooldowns, ready, java.util.List.of()
);
}
}
@@ -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,129 @@
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(Set.of(Ability.ASSASSIN_INVISIBILITY),
vigilanteResult.players().get(VIGILANTE).readyAbilityItems());
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()
);
}
}

Some files were not shown because too many files have changed in this diff Show More