Compare commits

...
29 Commits
Author SHA1 Message Date
dmg 58ffad6748 chore(knowledge): move canonical docs to shared SoMC wiki
CI / build (push) Successful in 1m41s
Release / release (push) Successful in 2m38s
2026-09-09 23:18:35 -04:00
dmg afd8a03f1b feat(gear): add final unbreakable Elytra reward
Release / release (push) Successful in 2m51s
CI / build (push) Successful in 1m10s
2026-09-09 22:28:40 -04:00
dmg 0c4a95801a feat(gear): add Tyrant legacy tools and weapons
Release / release (push) Successful in 2m56s
CI / build (push) Successful in 1m12s
2026-09-09 22:02:59 -04:00
dmg 553db666e1 feat(armor): add Tyrant legacy rewards
Release / release (push) Successful in 2m39s
CI / build (push) Successful in 1m10s
2026-09-04 22:54:11 -04:00
dmg e5de6b76e7 feat(admin): add Tyrant point grant command
Release / release (push) Successful in 2m27s
CI / build (push) Successful in 1m4s
2026-09-04 22:12:47 -04:00
dmg 4b6c709157 fix(arena): keep vigilante victory private
Release / release (push) Successful in 4m34s
CI / build (push) Successful in 1m16s
2026-09-04 22:05:28 -04:00
dmg 17a5c66575 fix(arena): move winners outside after victory 2026-09-04 22:01:52 -04:00
dmg f0dbde17b8 fix(arena): improve containment and boss idle
Release / release (push) Successful in 2m31s
CI / build (push) Successful in 1m8s
2026-08-23 18:13:24 -04:00
dmg 9ed0eed832 feat(tyrant): add arena boss succession
Release / release (push) Successful in 2m29s
CI / build (push) Successful in 1m7s
2026-08-23 17:55:14 -04:00
dmg 10f670a7da feat(vigilante): add arena boss succession
CI / build (push) Successful in 1m3s
Release / release (push) Successful in 2m44s
2026-08-23 17:31:42 -04:00
dmg 7318d4afa6 feat(commands): add contextual tab completion
Release / release (push) Successful in 2m35s
CI / build (push) Successful in 1m21s
2026-08-21 22:58:58 -04:00
dmg 80810b3ba1 feat(tamer): limit custody to one mob
Release / release (push) Successful in 2m42s
CI / build (push) Successful in 1m11s
2026-08-21 21:47:42 -04:00
dmg 34f32cd32d feat(tamer): release mobs after class loss
Release / release (push) Successful in 2m40s
CI / build (push) Successful in 1m13s
2026-08-21 21:37:09 -04:00
dmg 0b202f588d feat(items): add bound role control items 2026-08-21 21:28:32 -04:00
dmg ec7d50c2b5 feat(vigilante): add follower control panel 2026-08-21 21:19:29 -04:00
dmg 62f6352633 feat(tyrant): add reign control panel
CI / build (push) Successful in 1m10s
Release / release (push) Successful in 2m42s
2026-08-21 20:47:33 -04:00
dmg 3d65576010 fix(assassin): preserve external potion effects
Release / release (push) Successful in 2m46s
CI / build (push) Successful in 1m8s
2026-08-18 23:50:15 -04:00
dmg 323d177573 feat(participation): highlight opted-out players
Release / release (push) Successful in 2m40s
CI / build (push) Successful in 1m8s
2026-08-15 09:13:34 -04:00
dmg ea267ff867 fix(status): simplify Tyrant join messaging
Release / release (push) Successful in 2m36s
CI / build (push) Successful in 1m5s
2026-08-15 09:09:06 -04:00
dmg 24c54a7281 feat(participation): isolate opted-out combat
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m4s
2026-08-14 23:33:24 -04:00
dmg fe07c202f9 feat(items): secure bound ability inventory
Release / release (push) Successful in 2m7s
CI / build (push) Successful in 1m0s
2026-08-14 23:21:05 -04:00
dmg 777cbc6b4b feat(tamer): capture and relocate mobs
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m16s
2026-08-14 23:18:26 -04:00
dmg cd38e87487 feat(tyrant): add permanent powers and intelligence
Release / release (push) Successful in 2m15s
CI / build (push) Successful in 1m3s
2026-08-14 23:14:26 -04:00
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
200 changed files with 11183 additions and 818 deletions
+5 -49
View File
@@ -1,53 +1,9 @@
# Repository Agent Guidance
# spigot-tyrant agent entrypoint
## User-story-driven development
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
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 work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-tyrant/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-tyrant/` stories. Also follow the parent workspace `AGENTS.md` when present.
Before changing behavior:
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-tyrant/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
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.
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
-35
View File
@@ -1,35 +0,0 @@
---
type: Design Concept
title: Tyrant Game State Machine
description: Defines lifecycle, succession, selection, and pause behavior for a Tyrant game.
---
# Tyrant Game State Machine
## Lifecycle
The game begins unstarted. An administrator starts it by assigning the first Tyrant; the administrator may explicitly assign the first Vigilante or allow selection from eligible recently active players. A running game may be paused without deleting its state and later resumed.
Pausing freezes gameplay progression, role inactivity checks, pending-selection deadlines, ability cooldowns, and daily-use timers. Role and class effects are suppressed while paused and restored on resume.
## Tyrant succession
When a player directly kills the Tyrant, that killer becomes the new Tyrant. When no player is credited with the death, an eligible opted-in player active during the preceding 24 hours is selected randomly. The former Tyrant is excluded when another candidate exists.
Every Tyrant death ends the reign, clears all role-side assignments, resets the former Tyrant's reign progression, and requires a new Vigilante. The new Tyrant receives one starting unlock choice.
## Vigilante succession
Every Vigilante death ends that assignment regardless of cause. A replacement is selected randomly from eligible opted-in players active during the preceding 24 hours. The Tyrant is never eligible. The previous Vigilante is excluded when another eligible non-Tyrant exists and may be selected again only as a fallback. Followers are cleared rather than transferred.
A Tyrant earns progression from the death only when the Tyrant personally killed the Vigilante.
## Pending selections
An offline random selectee has 24 hours of unpaused time to log in and confirm the assignment. Failure causes another selection. When no eligible candidate exists, the position remains vacant and selection retries periodically without assigning the Tyrant to both roles.
## Related stories
- [Start, pause, and administer the game](../user-stories/us-001-start-pause-and-administer.md)
- [Transfer and reset the Tyrant role](../user-stories/us-002-transfer-and-reset-tyrant.md)
- [Handle inactivity and pending selections](../user-stories/us-010-handle-inactivity-and-selection.md)
-5
View File
@@ -1,5 +0,0 @@
# Design Concepts
- [Game state machine](game-state-machine.md) - Running, paused, succession, and pending-selection states.
- [Role and class model](role-and-class-model.md) - Role relationships, eligibility, assignments, and unlocks.
- [Persistence model](persistence-model.md) - Durable game and player state requirements.
-19
View File
@@ -1,19 +0,0 @@
---
type: Design Concept
title: Persistence Model
description: Defines the durable state and timekeeping needed to restore a Tyrant game safely.
---
# Persistence Model
State is keyed by player UUID while retaining the latest known player name for display. Durable global state includes lifecycle status, current and pending role holders, selection deadlines, paused durations, and the current Tyrant reign.
Durable player state includes participation status, opt-out deadline, login history, role or class, Tyrant level, purchased unlocks, unspent choices, Follower relationship, ability cooldowns, daily intelligence use, bound-item delivery state, and captured mobs.
Elapsed-time rules use UTC instants. Paused time is excluded from timers. Saves use atomic replacement where supported, preserve unknown fields where practical, and reject corrupt records rather than granting powers, assignments, or duplicated entities.
## Related stories
- [Inform players and manage participation](../user-stories/us-011-inform-and-manage-participation.md)
- [Configure and persist game state](../user-stories/us-012-configure-and-persist-state.md)
- [Use class ability items](../user-stories/us-014-use-class-ability-items.md)
-38
View File
@@ -1,38 +0,0 @@
---
type: Design Concept
title: Role and Class Model
description: Defines Tyrant-side classes, Vigilante-side membership, progression, and assignment constraints.
---
# Role and Class Model
## Roles and affiliations
A running game has one Tyrant and, when an eligible player is available, one Vigilante. The Vigilante may recruit Followers through invitations that require acceptance. The Tyrant may assign the Assassin, Fixer, and Tamer classes after unlocking them.
Role alignment and Tyrant-side class are independent. A Vigilante or Follower may simultaneously hold one Tyrant-side class, allowing divided loyalties. A player cannot simultaneously be the Tyrant, Vigilante, and/or a Follower, and cannot hold more than one Tyrant-side class. Each Tyrant-side class may be assigned to only one player at a time. Opted-out players are ineligible for every assignment.
## Tyrant progression
The Tyrant starts each reign at level zero with one starting unlock choice. Personally killing the Vigilante grants one level and one additional choice. Each of these unlocks can be purchased once per reign:
- Assassin class
- Fixer class
- Tamer class
- Vigilante roster intelligence
- Permanent Resistance
- Permanent Strength
## Proximity
Unless a more specific rule applies, "near the Tyrant" means in the same world and within a configurable 50-block three-dimensional distance.
## Effect safety
Vigilante Follower scaling is capped by default at Strength V and Resistance IV, with validated configurable caps. Permanent and conditional class effects are reapplied after ordinary Minecraft removal while the game is running and the assignment remains valid.
## Related stories
- [Progress and purchase Tyrant unlocks](../user-stories/us-003-progress-and-purchase-unlocks.md)
- [Assign unlocked classes](../user-stories/us-004-assign-unlocked-classes.md)
- [Support the Vigilante and Followers](../user-stories/us-008-support-vigilante-and-followers.md)
-18
View File
@@ -1,18 +0,0 @@
---
type: Index
title: Spigot Tyrant Design
description: Entry point for the Spigot Tyrant OKF knowledge bundle.
okf_version: "0.1"
---
# Spigot Tyrant Design
This bundle documents the asymmetric Tyrant game, its role succession, class abilities, participation rules, persistence, administration, and delivery requirements.
## Explore
- [User stories](user-stories/index.md)
- [Game state machine](concepts/game-state-machine.md)
- [Role and class model](concepts/role-and-class-model.md)
- [Persistence model](concepts/persistence-model.md)
- [Design log](log.md)
-72
View File
@@ -1,72 +0,0 @@
---
type: Log
title: Spigot Tyrant Design Log
description: Chronological record of material decisions affecting the Spigot Tyrant design.
---
# Spigot Tyrant Design Log
## 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.
- A player who kills the Tyrant becomes the next Tyrant; a Tyrant death without a player killer causes random succession among recently active eligible players.
- Every Tyrant death clears all class and Follower assignments and resets the former Tyrant's reign progression.
- Every Vigilante death rerolls the role among eligible players active in the preceding 24 hours. The previous Vigilante is reused only when no other eligible non-Tyrant player exists.
- A Tyrant levels and earns one unlock choice only by personally killing the Vigilante.
- Class abilities use named bound items where appropriate. Consumed cooldown items return when ready, and `/tyrant item` recovers missing ready items.
- The Tamer has no cooldown and uses a named fishing rod plus a separate captured-mob item.
- Game timers freeze while administratively paused.
- The project will follow the Java 17, Gradle, testing, Gitea CI, and semantic-release structure demonstrated by `../spigot-base/`.
-16
View File
@@ -1,16 +0,0 @@
# Spigot Tyrant User Stories
1. [US-001: Start, pause, and administer the game](us-001-start-pause-and-administer.md)
2. [US-002: Transfer and reset the Tyrant role](us-002-transfer-and-reset-tyrant.md)
3. [US-003: Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
4. [US-004: Assign unlocked classes](us-004-assign-unlocked-classes.md)
5. [US-005: Use Assassin abilities](us-005-use-assassin-abilities.md)
6. [US-006: Use Fixer abilities](us-006-use-fixer-abilities.md)
7. [US-007: Capture and place mobs as a Tamer](us-007-capture-and-place-mobs.md)
8. [US-008: Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
9. [US-009: Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
10. [US-010: Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
11. [US-011: Inform players and manage participation](us-011-inform-and-manage-participation.md)
12. [US-012: Configure and persist game state](us-012-configure-and-persist-state.md)
13. [US-013: Build, test, and release the plugin](us-013-build-test-and-release.md)
14. [US-014: Use class ability items](us-014-use-class-ability-items.md)
@@ -1,26 +0,0 @@
---
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: done
---
# US-001: Start, pause, and administer the game
As a **server operator**, I want to start, pause, resume, and inspect the game so that it can coexist safely with maintenance and other server events.
## Acceptance criteria
- [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
- [Tyrant game state machine](../concepts/game-state-machine.md)
- [Configure and persist game state](us-012-configure-and-persist-state.md)
@@ -1,27 +0,0 @@
---
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
---
# US-002: Transfer and reset the Tyrant role
As a **player**, I want defeating the Tyrant to transfer power predictably so that the central conflict always has meaningful consequences.
## 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.
## Related
- [Tyrant game state machine](../concepts/game-state-machine.md)
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
@@ -1,27 +0,0 @@
---
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: done
---
# US-003: Progress and purchase Tyrant unlocks
As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigilante so that each reign develops differently.
## Acceptance criteria
- [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
- [Role and class model](../concepts/role-and-class-model.md)
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
@@ -1,26 +0,0 @@
---
type: User Story
title: "US-004: Assign unlocked classes"
description: Let the Tyrant assign each purchased class to one eligible participant.
status: done
---
# US-004: Assign unlocked classes
As the **Tyrant**, I want to assign unlocked specialist classes so that I can build a small team suited to my reign.
## Acceptance criteria
- [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
- [Role and class model](../concepts/role-and-class-model.md)
- [Use class ability items](us-014-use-class-ability-items.md)
@@ -1,27 +0,0 @@
---
type: User Story
title: "US-005: Use Assassin abilities"
description: Give the Assassin timed stealth and a risky burst-mobility ability.
status: backlog
---
# US-005: Use Assassin abilities
As the **Assassin**, I want stealth and burst mobility so that I can ambush opponents while accepting a combat penalty afterward.
## 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.
## Related
- [Use class ability items](us-014-use-class-ability-items.md)
- [Configure and persist game state](us-012-configure-and-persist-state.md)
@@ -1,25 +0,0 @@
---
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
---
# US-006: Use Fixer abilities
As the **Fixer**, I want a substantial temporary combat boost so that I can protect the Tyrant during important fights.
## 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.
## Related
- [Use class ability items](us-014-use-class-ability-items.md)
- [Configure and persist game state](us-012-configure-and-persist-state.md)
@@ -1,28 +0,0 @@
---
type: User Story
title: "US-007: Capture and place mobs as a Tamer"
description: Let the Tamer move supported mobs without losing or duplicating them.
status: backlog
---
# US-007: Capture and place mobs as a Tamer
As the **Tamer**, I want to capture a mob and release it elsewhere so that I can relocate creatures safely.
## Acceptance criteria
- [ ] The Tamer can use a named fishing rod on a supported targeted mob to capture it with no cooldown.
- [ ] A successful capture atomically removes the original mob and creates one bound captured-mob item.
- [ ] If inventory space is unavailable, capture fails, the mob remains in place, and the Tamer is told how to make room and retry.
- [ ] Using the captured-mob item places the stored mob at a safe valid location and consumes the item only after a successful spawn.
- [ ] Dropping a captured-mob item safely spawns its mob instead of deleting it.
- [ ] Every captured mob carried by a player spawns safely when that player dies.
- [ ] Ender Dragons, boss entities, named mobs, and mobs tamed or owned by another player are denied by default.
- [ ] Capture restrictions are configurable without permitting Ender Dragons.
- [ ] Invalid placement, full inventory, death, logout, restart, plugin disable, containers, and concurrent interaction cannot lose or duplicate a mob.
- [ ] Stored mob data is validated defensively before spawning and cannot execute untrusted serialized behavior.
## Related
- [Use class ability items](us-014-use-class-ability-items.md)
- [Persistence model](../concepts/persistence-model.md)
@@ -1,30 +0,0 @@
---
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
---
# US-008: Support the Vigilante and Followers
As the **Vigilante**, I want nearby living Followers to strengthen me against the Tyrant so that coordinated resistance is rewarded.
## 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.
## Related
- [Role and class model](../concepts/role-and-class-model.md)
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
@@ -1,26 +0,0 @@
---
type: User Story
title: "US-009: Purchase and use Tyrant abilities"
description: Give the Tyrant persistent combat upgrades and limited intelligence about the opposition.
status: backlog
---
# US-009: Purchase and use Tyrant abilities
As the **Tyrant**, I want permanent upgrades and limited roster intelligence so that I can shape my reign and assess the resistance.
## Acceptance criteria
- [ ] Purchasing permanent Strength applies the configured Strength level throughout the current reign while the game is running.
- [ ] Purchasing permanent Resistance applies the configured Resistance level throughout the current reign while the game is running.
- [ ] Permanent effects are restored after death, milk consumption, ordinary effect removal, login, restart, and replacement by a shorter or weaker effect.
- [ ] Roster intelligence can be activated once per rolling 24 unpaused hours after it is purchased.
- [ ] Roster intelligence reports the number of active Vigilante-side members without revealing their identities or locations.
- [ ] Intelligence counts the current Vigilante and living assigned Followers, whether online or offline, according to configurable counting rules.
- [ ] The Tyrant can inspect ability ownership and the remaining intelligence cooldown.
- [ ] Purchases and cooldowns survive restart and end with the Tyrant's reign.
## Related
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
- [Configure and persist game state](us-012-configure-and-persist-state.md)
@@ -1,28 +0,0 @@
---
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
---
# US-010: Handle inactivity and pending selections
As a **participant**, I want inactive central roles to be replaced fairly so that the game does not stall around absent players.
## 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.
## Related
- [Tyrant game state machine](../concepts/game-state-machine.md)
- [Inform players and manage participation](us-011-inform-and-manage-participation.md)
@@ -1,27 +0,0 @@
---
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
---
# US-011: Inform players and manage participation
As a **player**, I want to understand my status and control my eligibility so that participation is informed and voluntary.
## 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.
## Related
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
- [Persistence model](../concepts/persistence-model.md)
@@ -1,27 +0,0 @@
---
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: done
---
# US-012: Configure and persist game state
As a **server operator**, I want configurable and durable game behavior so that the plugin remains predictable across restarts and balance changes.
## Acceptance criteria
- [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
- [Persistence model](../concepts/persistence-model.md)
- [Build, test, and release the plugin](us-013-build-test-and-release.md)
@@ -1,26 +0,0 @@
---
type: User Story
title: "US-013: Build, test, and release the plugin"
description: Give maintainers repeatable builds, automated verification, and versioned Gitea releases.
status: done
---
# US-013: Build, test, and release the plugin
As a **plugin maintainer**, I want automated builds and releases modeled on Spigot Base so that tested, correctly versioned artifacts can be distributed consistently.
## Acceptance criteria
- [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
- [Configure and persist game state](us-012-configure-and-persist-state.md)
@@ -1,30 +0,0 @@
---
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
---
# US-014: Use class ability items
As a **class holder**, I want a recognizable inventory item for my ability so that activation is fast and understandable during play.
## Acceptance criteria
- [ ] Each item-triggered class ability uses a uniquely named standard Minecraft item with hidden persistent ownership and ability metadata.
- [ ] Right-clicking or swinging the appropriate item activates the ability without also performing an unsafe vanilla action.
- [ ] Another player cannot activate, retain, craft with, place, store, frame, dispense, or otherwise transfer a bound ability item.
- [ ] A cooldown ability item disappears only after successful activation and is restored when the cooldown finishes.
- [ ] The Tamer's named fishing rod has no cooldown and remains available after successful capture or placement.
- [ ] A dropped class ability item vanishes immediately; a dropped captured-mob item instead attempts to spawn its mob safely.
- [ ] `/tyrant item` restores currently available missing class items but cannot bypass cooldowns or create duplicates.
- [ ] Missing ready items are restored on login and cooldown completion when inventory space is available.
- [ ] When delivery fails because the inventory is full, no item is dropped and the player is told both why and how to use `/tyrant item` after making room.
- [ ] Bound class items are removed when a player loses the class or the Tyrant's reign ends.
- [ ] Death, inventory movement, server restart, repeated commands, and concurrent events cannot duplicate a bound item or its captured mob.
## Related
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
- [Capture and place mobs as a Tamer](us-007-capture-and-place-mobs.md)
- [Persistence model](../concepts/persistence-model.md)
@@ -2,8 +2,13 @@ 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,49 @@
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;
private final CapturedMobInventoryService capturedMobInventory;
public AbilityItemRefreshTask(
TyrantStateManager stateManager,
AbilityReadinessService readiness,
AbilityItemService items,
CapturedMobInventoryService capturedMobInventory,
Server server,
Clock clock
) {
this.stateManager = stateManager;
this.readiness = readiness;
this.items = items;
this.capturedMobInventory = capturedMobInventory;
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);
capturedMobInventory.reconcile(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,22 @@
package games.dmg.spigottyrant;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.entity.Zombie;
final class ArenaBossState {
private ArenaBossState() {
}
static void idle(Zombie boss, Location center, double health) {
boss.setAI(false);
boss.setTarget(null);
boss.setHealth(health);
boss.teleport(center);
}
static void engage(Zombie boss, Player challenger) {
boss.setAI(true);
boss.setTarget(challenger);
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigottyrant;
final class ArenaBuildProtection {
private ArenaBuildProtection() {
}
static boolean isProtected(
boolean fightActive,
ArenaLocation center,
double radius,
String worldName,
double x,
double y,
double z
) {
return fightActive && ArenaGeometry.contains(
center, radius, worldName, x, y, z
);
}
}
@@ -0,0 +1,45 @@
package games.dmg.spigottyrant;
public final class ArenaGeometry {
private ArenaGeometry() {
}
public static boolean contains(
ArenaLocation center,
double radius,
String worldName,
double x,
double y,
double z
) {
if (!center.worldName().equals(worldName)) {
return false;
}
double deltaX = x - center.x();
double deltaZ = z - center.z();
return deltaX * deltaX + deltaZ * deltaZ <= radius * radius;
}
public static ArenaPoint outside(
ArenaLocation center,
double radius,
double x,
double y,
double z
) {
double deltaX = x - center.x();
double deltaZ = z - center.z();
double length = Math.hypot(deltaX, deltaZ);
if (length < 0.0001) {
deltaX = 1.0;
deltaZ = 0.0;
length = 1.0;
}
double distance = radius + 1.0;
return new ArenaPoint(
center.x() + deltaX / length * distance,
y,
center.z() + deltaZ / length * distance
);
}
}
@@ -0,0 +1,26 @@
package games.dmg.spigottyrant;
public record ArenaLocation(
String worldName,
double x,
double y,
double z,
float yaw,
float pitch
) {
public ArenaLocation {
if (worldName == null || worldName.isBlank()) {
throw new IllegalArgumentException("arena world must not be blank");
}
worldName = worldName.trim();
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
throw new IllegalArgumentException("arena coordinates must be finite");
}
}
public String display() {
return worldName + " at " + Math.round(x) + ", " + Math.round(y) + ", "
+ Math.round(z);
}
}
@@ -0,0 +1,23 @@
package games.dmg.spigottyrant;
import java.util.Optional;
public interface ArenaLocationStore {
Optional<ArenaLocation> location();
void set(ArenaLocation location);
static ArenaLocationStore unavailable() {
return new ArenaLocationStore() {
@Override
public Optional<ArenaLocation> location() {
return Optional.empty();
}
@Override
public void set(ArenaLocation location) {
throw new IllegalStateException("arena location storage is unavailable");
}
};
}
}
@@ -0,0 +1,4 @@
package games.dmg.spigottyrant;
public record ArenaPoint(double x, double y, double z) {
}
@@ -0,0 +1,6 @@
package games.dmg.spigottyrant;
public enum ArenaRole {
TYRANT,
VIGILANTE
}
@@ -0,0 +1,4 @@
package games.dmg.spigottyrant;
public record ArmorClaimResult(GameState state, ArmorClaimStatus status) {
}
@@ -0,0 +1,11 @@
package games.dmg.spigottyrant;
public enum ArmorClaimStatus {
CLAIMED,
GAME_NOT_RUNNING,
NOT_TYRANT,
UNLOCKS_INCOMPLETE,
COLLECTION_INCOMPLETE,
NO_CHOICES,
ALREADY_CLAIMED
}
@@ -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,142 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Instant;
import java.util.HashSet;
import java.util.Objects;
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 EffectApplier effectApplier;
private final Set<UUID> grantedFlight = new HashSet<>();
public AssassinEffectController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings,
Clock clock
) {
this(stateManager, server, settings, clock, new BukkitEffectApplier());
}
AssassinEffectController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings,
Clock clock,
EffectApplier effectApplier
) {
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
this.server = Objects.requireNonNull(server, "server");
this.settings = Objects.requireNonNull(settings, "settings");
this.clock = Objects.requireNonNull(clock, "clock");
this.effectApplier = Objects.requireNonNull(effectApplier, "effectApplier");
}
@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,
EffectKind.INVISIBILITY, 0, false
);
applyWhileActive(
player, state, Ability.ASSASSIN_SPEED_ACTIVE,
EffectKind.SPEED, 0, true
);
applyWhileActive(
player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE,
EffectKind.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,
EffectKind effect,
int amplifier,
boolean particles
) {
if (state.cooldownEnds().getOrDefault(ability, Instant.MIN).isAfter(clock.instant())) {
effectApplier.apply(player, effect, amplifier, particles);
}
}
private void clear(Player player) {
if (grantedFlight.remove(player.getUniqueId()) && isSurvivalLike(player)) {
player.setAllowFlight(false);
player.setFlying(false);
}
}
enum EffectKind {
INVISIBILITY,
SPEED,
WEAKNESS
}
@FunctionalInterface
interface EffectApplier {
void apply(Player player, EffectKind effect, int amplifier, boolean particles);
}
private static final class BukkitEffectApplier implements EffectApplier {
@Override
public void apply(
Player player,
EffectKind effect,
int amplifier,
boolean particles
) {
PotionEffectType type = switch (effect) {
case INVISIBILITY -> PotionEffectType.INVISIBILITY;
case SPEED -> PotionEffectType.SPEED;
case WEAKNESS -> PotionEffectType.WEAKNESS;
};
player.addPotionEffect(new PotionEffect(
type, EFFECT_TICKS, amplifier, false, particles, true
));
}
}
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,83 @@
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.isShiftClick()
|| 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,158 @@
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();
java.util.Set<Ability> retained = java.util.EnumSet.noneOf(Ability.class);
for (int index = 0; index < contents.length; index++) {
ItemStack item = contents[index];
if (!isBoundAbilityItem(item)) {
continue;
}
Optional<Ability> itemAbility = ability(item);
boolean valid = owner(item).filter(state.playerId()::equals).isPresent()
&& itemAbility.filter(state.readyAbilityItems()::contains).isPresent()
&& itemAbility.filter(retained::add).isPresent();
if (!valid) {
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,79 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
public final class BukkitCapturedMobItemService implements CapturedMobItemService {
private final NamespacedKey ownerKey;
private final NamespacedKey captureKey;
private final NamespacedKey typeKey;
private final PluginSettings settings;
public BukkitCapturedMobItemService(Plugin plugin, PluginSettings settings) {
ownerKey = new NamespacedKey(plugin, "captured-owner");
captureKey = new NamespacedKey(plugin, "captured-id");
typeKey = new NamespacedKey(plugin, "captured-type");
this.settings = settings;
}
@Override
public ItemStack create(UUID ownerId, UUID captureId, String entityType) {
Material material = Material.matchMaterial(settings.capturedMobItem().material());
if (material == null) {
throw new IllegalStateException("Captured mob item material is unavailable");
}
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName(ChatColor.GOLD + settings.capturedMobItem().name()
+ ": " + entityType);
meta.getPersistentDataContainer().set(
ownerKey, PersistentDataType.STRING, ownerId.toString()
);
meta.getPersistentDataContainer().set(
captureKey, PersistentDataType.STRING, captureId.toString()
);
meta.getPersistentDataContainer().set(
typeKey, PersistentDataType.STRING, entityType
);
item.setItemMeta(meta);
return item;
}
@Override
public boolean isCapturedMob(ItemStack item) {
return owner(item).isPresent() && captureId(item).isPresent();
}
@Override
public Optional<UUID> owner(ItemStack item) {
return uuid(item, ownerKey);
}
@Override
public Optional<UUID> captureId(ItemStack item) {
return uuid(item, captureKey);
}
private static Optional<UUID> uuid(ItemStack item, NamespacedKey key) {
if (item == null || !item.hasItemMeta()) {
return Optional.empty();
}
String value = item.getItemMeta().getPersistentDataContainer()
.get(key, PersistentDataType.STRING);
if (value == null) {
return Optional.empty();
}
try {
return Optional.of(UUID.fromString(value));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
}
@@ -0,0 +1,47 @@
package games.dmg.spigottyrant;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.entity.EntitySnapshot;
import org.bukkit.entity.Player;
public final class BukkitCapturedMobSpawner implements CapturedMobSpawner {
private final Server server;
private final PluginSettings settings;
public BukkitCapturedMobSpawner(Server server, PluginSettings settings) {
this.server = server;
this.settings = settings;
}
@Override
public boolean spawn(Player player, CapturedMob mob) {
Location location = player.getLocation();
if (!safe(location)
|| "ENDER_DRAGON".equals(mob.entityType())
|| settings.deniedMobTypes().contains(mob.entityType())) {
return false;
}
String serialized = mob.data().get("snapshot");
if (serialized == null || serialized.isBlank()) {
return false;
}
try {
EntitySnapshot snapshot = server.getEntityFactory().createEntitySnapshot(serialized);
if (!snapshot.getEntityType().name().equals(mob.entityType())) {
return false;
}
snapshot.createEntity(location);
return true;
} catch (IllegalArgumentException | IllegalStateException exception) {
return false;
}
}
private static boolean safe(Location location) {
return location.getWorld() != null
&& location.getBlock().isPassable()
&& location.clone().add(0.0, 1.0, 0.0).getBlock().isPassable()
&& location.getWorld().getWorldBorder().isInside(location);
}
}
@@ -0,0 +1,55 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
public final class BukkitConfigArenaLocationStore implements ArenaLocationStore {
private static final String PATH = "vigilante-arena-location";
private final JavaPlugin plugin;
public BukkitConfigArenaLocationStore(JavaPlugin plugin) {
this.plugin = plugin;
}
@Override
public Optional<ArenaLocation> location() {
FileConfiguration config = plugin.getConfig();
if (!config.isConfigurationSection(PATH)) {
return Optional.empty();
}
String world = config.getString(PATH + ".world");
if (world == null) {
throw new IllegalArgumentException(PATH + ".world must be configured");
}
return Optional.of(new ArenaLocation(
world,
finite(config, "x"),
finite(config, "y"),
finite(config, "z"),
(float) finite(config, "yaw"),
(float) finite(config, "pitch")
));
}
@Override
public void set(ArenaLocation location) {
FileConfiguration config = plugin.getConfig();
config.set(PATH + ".world", location.worldName());
config.set(PATH + ".x", location.x());
config.set(PATH + ".y", location.y());
config.set(PATH + ".z", location.z());
config.set(PATH + ".yaw", location.yaw());
config.set(PATH + ".pitch", location.pitch());
plugin.saveConfig();
}
private double finite(FileConfiguration config, String key) {
String path = PATH + "." + key;
if (!config.isSet(path) || !(config.get(path) instanceof Number number)
|| !Double.isFinite(number.doubleValue())) {
throw new IllegalArgumentException(path + " must be finite and numeric");
}
return number.doubleValue();
}
}
@@ -0,0 +1,38 @@
package games.dmg.spigottyrant;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Player;
public final class BukkitFollowerPresenceProvider implements FollowerPresenceProvider {
private final Server server;
private final double rangeSquared;
public BukkitFollowerPresenceProvider(Server server, double rangeBlocks) {
this.server = server;
this.rangeSquared = rangeBlocks * rangeBlocks;
}
@Override
public Map<UUID, FollowerPresence> inspect(
Player vigilante,
Map<UUID, PlayerState> players
) {
Map<UUID, FollowerPresence> result = new HashMap<>();
for (PlayerState state : players.values()) {
Player follower = server.getPlayer(state.playerId());
boolean online = follower != null && follower.isOnline();
boolean living = online && !follower.isDead();
boolean sameWorld = living && follower.getWorld().equals(vigilante.getWorld());
boolean withinRange = sameWorld && follower.getLocation()
.distanceSquared(vigilante.getLocation()) <= rangeSquared;
result.put(
state.playerId(),
new FollowerPresence(online, living, sameWorld, withinRange)
);
}
return Map.copyOf(result);
}
}
@@ -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();
}
}
}
@@ -14,4 +14,11 @@ public final class BukkitOnlinePlayerDirectory implements OnlinePlayerDirectory
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,146 @@
package games.dmg.spigottyrant;
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 BukkitRoleControlItemService implements RoleControlItemService {
private final NamespacedKey roleKey;
private final NamespacedKey ownerKey;
private final PluginSettings settings;
public BukkitRoleControlItemService(Plugin plugin, PluginSettings settings) {
roleKey = new NamespacedKey(plugin, "role-control");
ownerKey = new NamespacedKey(plugin, "role-control-owner");
this.settings = settings;
}
@Override
public void recover(Player player, GameState game) {
Optional<RoleControl> required = RoleControlItemPolicy.requiredRole(
game, player.getUniqueId()
);
if (required.isEmpty() || contains(player, required.orElseThrow())) {
return;
}
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(
create(player.getUniqueId(), required.orElseThrow())
);
if (!leftovers.isEmpty()) {
String command = required.orElseThrow() == RoleControl.TYRANT
? "/tyrant item" : "/vigilante item";
player.sendMessage(ChatColor.RED
+ "Your inventory is full. Make room and use " + command + ".");
}
}
@Override
public boolean isBoundRoleControlItem(ItemStack item) {
return role(item).isPresent() && owner(item).isPresent();
}
@Override
public Optional<RoleControl> role(ItemStack item) {
String value = metadata(item, roleKey);
if (value == null) {
return Optional.empty();
}
try {
return Optional.of(RoleControl.valueOf(value));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
@Override
public Optional<UUID> owner(ItemStack item) {
String value = metadata(item, ownerKey);
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, GameState game) {
Optional<RoleControl> required = RoleControlItemPolicy.requiredRole(
game, player.getUniqueId()
);
boolean retained = false;
ItemStack[] contents = player.getInventory().getContents();
for (int index = 0; index < contents.length; index++) {
ItemStack item = contents[index];
if (!isBoundRoleControlItem(item)) {
continue;
}
boolean valid = !retained
&& owner(item).filter(player.getUniqueId()::equals).isPresent()
&& role(item).equals(required);
if (valid) {
retained = true;
} else {
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 (isBoundRoleControlItem(contents[index])) {
player.getInventory().setItem(index, null);
}
}
}
private ItemStack create(UUID ownerId, RoleControl role) {
PluginSettings.AbilityItemSettings configured = role == RoleControl.TYRANT
? settings.tyrantControlItem() : settings.vigilanteControlItem();
Material material = Material.matchMaterial(configured.material());
if (material == null) {
throw new IllegalStateException("Configured role control material is unavailable");
}
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName(ChatColor.GOLD + configured.name());
meta.getPersistentDataContainer().set(
roleKey, PersistentDataType.STRING, role.name()
);
meta.getPersistentDataContainer().set(
ownerKey, PersistentDataType.STRING, ownerId.toString()
);
item.setItemMeta(meta);
return item;
}
private boolean contains(Player player, RoleControl role) {
for (ItemStack item : player.getInventory().getContents()) {
if (owner(item).filter(player.getUniqueId()::equals).isPresent()
&& role(item).filter(role::equals).isPresent()) {
return true;
}
}
return false;
}
private static String metadata(ItemStack item, NamespacedKey key) {
if (item == null || !item.hasItemMeta()) {
return null;
}
return item.getItemMeta().getPersistentDataContainer()
.get(key, PersistentDataType.STRING);
}
}
@@ -0,0 +1,33 @@
package games.dmg.spigottyrant;
import java.util.Objects;
import org.bukkit.ChatColor;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
public final class BukkitTyrantArmorItemFactory implements TyrantArmorItemFactory {
@Override
public ItemStack create(TyrantArmorPiece piece, String tyrantName) {
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(piece, tyrantName);
ItemStack item = new ItemStack(spec.material());
ItemMeta meta = Objects.requireNonNull(item.getItemMeta(), "Legacy reward metadata is unavailable");
meta.setDisplayName(ChatColor.GOLD + spec.displayName());
meta.setLore(spec.lore().stream().map(line -> ChatColor.GRAY + line).toList());
if (spec.protectionLevel() > 0) {
meta.addEnchant(Enchantment.PROTECTION, spec.protectionLevel(), true);
}
if (spec.sharpnessLevel() > 0) {
meta.addEnchant(Enchantment.SHARPNESS, spec.sharpnessLevel(), true);
}
if (spec.efficiencyLevel() > 0) {
meta.addEnchant(Enchantment.EFFICIENCY, spec.efficiencyLevel(), true);
}
if (spec.mendingLevel() > 0) {
meta.addEnchant(Enchantment.MENDING, spec.mendingLevel(), true);
}
meta.setUnbreakable(spec.unbreakable());
item.setItemMeta(meta);
return item;
}
}
@@ -0,0 +1,530 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
public final class BukkitTyrantControlPanelRenderer
implements TyrantControlPanelRenderer, Listener {
private static final String TITLE = ChatColor.DARK_RED + "Tyrant Control Panel";
private final Server server;
private final TyrantStateManager stateManager;
private final Clock clock;
public BukkitTyrantControlPanelRenderer(
Server server,
TyrantStateManager stateManager,
Clock clock
) {
this.server = server;
this.stateManager = stateManager;
this.clock = clock;
}
@Override
public void openOverview(Player player, TyrantControlPanelModel model) {
MenuHolder holder = new MenuHolder(MenuView.OVERVIEW, null, null);
Inventory inventory = create(holder, 27, TITLE);
inventory.setItem(10, item(Material.NETHER_STAR, ChatColor.GOLD + "Reign Overview", List.of(
"Level: " + model.level(),
"Unspent choices: " + model.unspentChoices(),
"Purchased: " + (model.purchasedUnlocks().isEmpty()
? "none" : model.purchasedUnlocks())
)));
inventory.setItem(12, item(Material.ENCHANTED_BOOK, ChatColor.YELLOW + "Unlocks",
List.of("Review and purchase reign unlocks.")));
inventory.setItem(14, item(Material.ARMOR_STAND, ChatColor.AQUA + "Class Assignments",
classSummary(model)));
String intelligence = intelligenceText(model);
inventory.setItem(16, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE + "Roster Intelligence",
List.of(intelligence, "Click to activate when ready.")));
inventory.setItem(22, item(Material.NETHERITE_CHESTPLATE,
ChatColor.GOLD + "Legacy Rewards", List.of(
model.allStandardUnlocksPurchased()
? "Choose legacy gear; complete the collection for Elytra."
: "Purchase all six standard unlocks first.",
"Claimed: " + (model.claimedArmor().isEmpty()
? "none" : model.claimedArmor())
)));
player.openInventory(inventory);
}
@EventHandler
public void onClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)
|| !(event.getView().getTopInventory().getHolder() instanceof MenuHolder holder)) {
return;
}
event.setCancelled(true);
if (event.getRawSlot() < 0
|| event.getRawSlot() >= event.getView().getTopInventory().getSize()) {
return;
}
if (!isActiveTyrant(player)) {
player.closeInventory();
player.sendMessage(ChatColor.RED + "This Tyrant control panel is no longer active.");
return;
}
switch (holder.view()) {
case OVERVIEW -> clickOverview(player, event.getRawSlot());
case UNLOCKS -> clickUnlocks(player, event.getRawSlot());
case PURCHASE_CONFIRMATION -> clickPurchaseConfirmation(
player, event.getRawSlot(), holder.unlock()
);
case CLASSES -> clickClasses(player, event.getRawSlot());
case ABILITIES -> clickAbilities(player, event.getRawSlot());
case PLAYERS -> clickPlayers(player, event.getRawSlot(), holder.tyrantClass());
case ASSIGN_CONFIRMATION -> clickAssignmentConfirmation(
player, event.getRawSlot(), holder.tyrantClass(), holder.targetId()
);
case ARMOR -> clickArmor(player, event.getRawSlot());
case ARMOR_CONFIRMATION -> clickArmorConfirmation(
player, event.getRawSlot(), holder.armorPiece()
);
}
}
private void clickOverview(Player player, int slot) {
if (slot == 12) {
openUnlocks(player);
} else if (slot == 14) {
openClasses(player);
} else if (slot == 16) {
openAbilities(player);
} else if (slot == 22) {
openArmor(player);
}
}
private void openArmor(Player player) {
TyrantControlPanelModel model = model(player);
MenuHolder holder = new MenuHolder(MenuView.ARMOR, null, null);
Inventory inventory = create(holder, 27, TITLE + " — Legacy Rewards");
TyrantArmorPiece[] pieces = TyrantArmorPiece.values();
int[] slots = legacyRewardSlots();
for (int index = 0; index < pieces.length; index++) {
TyrantArmorPiece piece = pieces[index];
inventory.setItem(slots[index], item(
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
ChatColor.GOLD + readable(piece.name()),
model.armorLore(piece)
));
}
inventory.setItem(22, backItem());
player.openInventory(inventory);
}
private void clickArmor(Player player, int slot) {
if (slot == 22) {
refresh(player);
return;
}
TyrantArmorPiece[] pieces = TyrantArmorPiece.values();
int[] slots = legacyRewardSlots();
for (int index = 0; index < slots.length; index++) {
if (slot == slots[index] && model(player).armorAvailable(pieces[index])) {
openArmorConfirmation(player, pieces[index]);
return;
}
}
}
static int[] legacyRewardSlots() {
return new int[] {9, 10, 11, 12, 13, 14, 15, 16, 17, 4};
}
private void openArmorConfirmation(Player player, TyrantArmorPiece piece) {
MenuHolder holder = new MenuHolder(
MenuView.ARMOR_CONFIRMATION, null, null, null, piece
);
Inventory inventory = create(holder, 27, TITLE + " — Confirm Reward");
inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of()));
inventory.setItem(13, item(
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
ChatColor.GOLD + "Tyrant's " + player.getName() + " " + readable(piece.name()),
TyrantArmorSpec.forPiece(piece, player.getName()).confirmationLore()
));
inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of()));
player.openInventory(inventory);
}
private void clickArmorConfirmation(Player player, int slot, TyrantArmorPiece piece) {
if (slot == 11) {
openArmor(player);
} else if (slot == 15 && piece != null) {
player.performCommand("tyrant " + (piece.isArmor() ? "armor " : "gear ")
+ piece.commandName());
openArmor(player);
}
}
private void openAbilities(Player player) {
TyrantControlPanelModel model = model(player);
MenuHolder holder = new MenuHolder(MenuView.ABILITIES, null, null);
Inventory inventory = create(holder, 27, TITLE + " — Abilities");
inventory.setItem(11, item(Material.BLAZE_POWDER, ChatColor.RED + "Permanent Strength",
List.of(model.purchasedUnlocks().contains(TyrantUnlock.STRENGTH)
? "Purchased and active." : "Not purchased.")));
inventory.setItem(13, item(Material.IRON_CHESTPLATE,
ChatColor.BLUE + "Permanent Resistance",
List.of(model.purchasedUnlocks().contains(TyrantUnlock.RESISTANCE)
? "Purchased and active." : "Not purchased.")));
inventory.setItem(15, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE
+ "Roster Intelligence", List.of(
intelligenceText(model), "Click to activate when ready."
)));
inventory.setItem(22, backItem());
player.openInventory(inventory);
}
private void clickAbilities(Player player, int slot) {
if (slot == 22) {
refresh(player);
} else if (slot == 15) {
player.performCommand("tyrant intelligence");
openAbilities(player);
}
}
private void openUnlocks(Player player) {
TyrantControlPanelModel model = model(player);
MenuHolder holder = new MenuHolder(MenuView.UNLOCKS, null, null);
Inventory inventory = create(holder, 27, TITLE + " — Unlocks");
int[] slots = {10, 11, 12, 14, 15, 16};
TyrantUnlock[] unlocks = TyrantUnlock.values();
for (int index = 0; index < unlocks.length; index++) {
TyrantUnlock unlock = unlocks[index];
UnlockAvailability availability = model.unlocks().get(unlock);
Material material = switch (availability) {
case PURCHASED -> Material.LIME_DYE;
case AVAILABLE -> Material.GOLD_INGOT;
case UNAVAILABLE -> Material.GRAY_DYE;
};
inventory.setItem(slots[index], item(material,
ChatColor.YELLOW + readable(unlock.name()),
List.of("Status: " + availability, "Cost: 1 choice")));
}
inventory.setItem(22, backItem());
player.openInventory(inventory);
}
private void clickUnlocks(Player player, int slot) {
if (slot == 22) {
refresh(player);
return;
}
int[] slots = {10, 11, 12, 14, 15, 16};
TyrantUnlock[] unlocks = TyrantUnlock.values();
for (int index = 0; index < slots.length; index++) {
if (slot == slots[index]
&& model(player).unlocks().get(unlocks[index])
== UnlockAvailability.AVAILABLE) {
openPurchaseConfirmation(player, unlocks[index]);
return;
}
}
}
private void openPurchaseConfirmation(Player player, TyrantUnlock unlock) {
MenuHolder holder = new MenuHolder(MenuView.PURCHASE_CONFIRMATION, unlock, null);
Inventory inventory = create(holder, 27, TITLE + " — Confirm Purchase");
inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of()));
inventory.setItem(13, item(Material.ENCHANTED_BOOK, ChatColor.YELLOW + readable(unlock.name()),
List.of("Cost: 1 choice")));
inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of()));
player.openInventory(inventory);
}
private void clickPurchaseConfirmation(Player player, int slot, TyrantUnlock unlock) {
if (slot == 11) {
openUnlocks(player);
} else if (slot == 15 && unlock != null) {
player.performCommand("tyrant buy " + unlock.name().toLowerCase(Locale.ROOT));
openUnlocks(player);
}
}
private void openClasses(Player player) {
TyrantControlPanelModel model = model(player);
MenuHolder holder = new MenuHolder(MenuView.CLASSES, null, null);
Inventory inventory = create(holder, 27, TITLE + " — Classes");
TyrantClass[] classes = {TyrantClass.ASSASSIN, TyrantClass.FIXER, TyrantClass.TAMER};
int[] slots = {11, 13, 15};
for (int index = 0; index < classes.length; index++) {
TyrantClass tyrantClass = classes[index];
boolean purchased = stateManager.game().purchases().contains(unlockFor(tyrantClass));
inventory.setItem(slots[index], item(
purchased ? Material.ARMOR_STAND : Material.BARRIER,
ChatColor.AQUA + readable(tyrantClass.name()),
List.of("Holder: " + model.classHolders().get(tyrantClass),
purchased ? "Click to assign." : "Unlock not purchased.")));
}
inventory.setItem(22, backItem());
player.openInventory(inventory);
}
private void clickClasses(Player player, int slot) {
if (slot == 22) {
refresh(player);
return;
}
TyrantClass tyrantClass = switch (slot) {
case 11 -> TyrantClass.ASSASSIN;
case 13 -> TyrantClass.FIXER;
case 15 -> TyrantClass.TAMER;
default -> null;
};
if (tyrantClass != null
&& stateManager.game().purchases().contains(unlockFor(tyrantClass))) {
openPlayers(player, tyrantClass);
}
}
private void openPlayers(Player player, TyrantClass tyrantClass) {
MenuHolder holder = new MenuHolder(MenuView.PLAYERS, null, tyrantClass);
Inventory inventory = create(holder, 54, TITLE + " — Select Player");
int slot = 0;
for (Player candidate : server.getOnlinePlayers()) {
if (slot >= 45 || candidate.getUniqueId().equals(player.getUniqueId())) {
continue;
}
PlayerState state = stateManager.player(candidate.getUniqueId(), candidate.getName());
String eligibility = state.optedOutUntil().isPresent()
? ChatColor.RED + "Ineligible: opted out"
: state.tyrantClass() != TyrantClass.NONE
&& state.tyrantClass() != tyrantClass
? ChatColor.RED + "Ineligible: already " + state.tyrantClass()
: ChatColor.GREEN + "Eligible";
inventory.setItem(slot, item(Material.PLAYER_HEAD, candidate.getName(),
List.of(eligibility, candidate.getUniqueId().toString())));
slot++;
}
inventory.setItem(49, backItem());
player.openInventory(inventory);
}
private void clickPlayers(Player player, int slot, TyrantClass tyrantClass) {
if (slot == 49) {
openClasses(player);
return;
}
ItemStack clicked = player.getOpenInventory().getTopInventory().getItem(slot);
if (clicked == null || clicked.getType() != Material.PLAYER_HEAD) {
return;
}
List<String> lore = clicked.getItemMeta().getLore();
if (lore == null || lore.size() < 2 || !ChatColor.stripColor(lore.get(0)).equals("Eligible")) {
return;
}
try {
openAssignmentConfirmation(
player, tyrantClass, UUID.fromString(ChatColor.stripColor(lore.get(1)))
);
} catch (IllegalArgumentException ignored) {
player.sendMessage(ChatColor.RED + "That player selection is no longer valid.");
}
}
private void openAssignmentConfirmation(
Player player,
TyrantClass tyrantClass,
UUID targetId
) {
Player target = server.getPlayer(targetId);
if (target == null) {
player.sendMessage(ChatColor.RED + "That player is no longer online.");
openPlayers(player, tyrantClass);
return;
}
MenuHolder holder = new MenuHolder(
MenuView.ASSIGN_CONFIRMATION, null, tyrantClass, targetId
);
Inventory inventory = create(holder, 27, TITLE + " — Confirm Assignment");
String current = model(player).classHolders().get(tyrantClass);
inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of()));
inventory.setItem(13, item(Material.ARMOR_STAND,
ChatColor.YELLOW + target.getName(),
List.of("Class: " + tyrantClass, "Current holder: " + current)));
inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of()));
player.openInventory(inventory);
}
private void clickAssignmentConfirmation(
Player player,
int slot,
TyrantClass tyrantClass,
UUID targetId
) {
if (slot == 11) {
openPlayers(player, tyrantClass);
} else if (slot == 15 && tyrantClass != null && targetId != null) {
Player target = server.getPlayer(targetId);
if (target == null) {
player.sendMessage(ChatColor.RED + "That player is no longer online.");
} else {
player.performCommand("tyrant assign "
+ tyrantClass.name().toLowerCase(Locale.ROOT) + " " + target.getName());
}
openClasses(player);
}
}
private boolean isActiveTyrant(Player player) {
GameState game = stateManager.game();
return game.lifecycle() == GameLifecycle.RUNNING
&& game.tyrantId().filter(player.getUniqueId()::equals).isPresent();
}
private TyrantControlPanelModel model(Player player) {
return TyrantControlPanelModel.create(
stateManager.game(),
stateManager.player(player.getUniqueId(), player.getName()),
stateManager.players(),
clock.instant()
);
}
private void refresh(Player player) {
openOverview(player, model(player));
}
private Inventory create(MenuHolder holder, int size, String title) {
Inventory inventory = server.createInventory(holder, size, title);
holder.inventory = inventory;
return inventory;
}
private static ItemStack item(Material material, String name, List<String> lore) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName(name);
meta.setLore(lore.stream().map(line -> ChatColor.GRAY + line).toList());
item.setItemMeta(meta);
return item;
}
private static ItemStack backItem() {
return item(Material.ARROW, ChatColor.YELLOW + "Back", List.of());
}
private static List<String> classSummary(TyrantControlPanelModel model) {
List<String> summary = new ArrayList<>();
for (TyrantClass tyrantClass : new TyrantClass[] {
TyrantClass.ASSASSIN, TyrantClass.FIXER, TyrantClass.TAMER
}) {
summary.add(readable(tyrantClass.name()) + ": "
+ model.classHolders().get(tyrantClass));
}
return summary;
}
private static String intelligenceText(TyrantControlPanelModel model) {
if (model.unlocks().get(TyrantUnlock.ROSTER_INTELLIGENCE)
!= UnlockAvailability.PURCHASED) {
return "Unavailable: unlock not purchased.";
}
Duration cooldown = model.intelligenceCooldown();
return cooldown.isZero() ? "Ready" : "Cooldown: " + cooldown.toSeconds() + "s";
}
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 String readable(String value) {
String lower = value.toLowerCase(Locale.ROOT).replace('_', ' ');
return Character.toUpperCase(lower.charAt(0)) + lower.substring(1);
}
private enum MenuView {
OVERVIEW,
UNLOCKS,
PURCHASE_CONFIRMATION,
CLASSES,
ABILITIES,
PLAYERS,
ASSIGN_CONFIRMATION,
ARMOR,
ARMOR_CONFIRMATION
}
private static final class MenuHolder implements InventoryHolder {
private final MenuView view;
private final TyrantUnlock unlock;
private final TyrantClass tyrantClass;
private final UUID targetId;
private final TyrantArmorPiece armorPiece;
private Inventory inventory;
private MenuHolder(MenuView view, TyrantUnlock unlock, TyrantClass tyrantClass) {
this(view, unlock, tyrantClass, null);
}
private MenuHolder(
MenuView view,
TyrantUnlock unlock,
TyrantClass tyrantClass,
UUID targetId
) {
this(view, unlock, tyrantClass, targetId, null);
}
private MenuHolder(
MenuView view,
TyrantUnlock unlock,
TyrantClass tyrantClass,
UUID targetId,
TyrantArmorPiece armorPiece
) {
this.view = view;
this.unlock = unlock;
this.tyrantClass = tyrantClass;
this.targetId = targetId;
this.armorPiece = armorPiece;
}
private MenuView view() {
return view;
}
private TyrantUnlock unlock() {
return unlock;
}
private TyrantClass tyrantClass() {
return tyrantClass;
}
private UUID targetId() {
return targetId;
}
private TyrantArmorPiece armorPiece() {
return armorPiece;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
}
@@ -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,409 @@
package games.dmg.spigottyrant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
public final class BukkitVigilanteControlPanelRenderer
implements VigilanteControlPanelRenderer, Listener {
private static final String TITLE = ChatColor.DARK_BLUE + "Vigilante Control Panel";
private final Server server;
private final TyrantStateManager stateManager;
private final FollowerService followers;
private final FollowerPresenceProvider presenceProvider;
private final int strengthCap;
private final int resistanceCap;
public BukkitVigilanteControlPanelRenderer(
Server server,
TyrantStateManager stateManager,
FollowerService followers,
FollowerPresenceProvider presenceProvider,
int strengthCap,
int resistanceCap
) {
this.server = server;
this.stateManager = stateManager;
this.followers = followers;
this.presenceProvider = presenceProvider;
this.strengthCap = strengthCap;
this.resistanceCap = resistanceCap;
}
@Override
public void openOverview(Player player, VigilanteControlPanelModel model) {
MenuHolder holder = new MenuHolder(MenuView.OVERVIEW, null);
Inventory inventory = create(holder, 27, TITLE);
inventory.setItem(10, item(Material.SHIELD, ChatColor.BLUE + "Resistance Overview",
List.of(
"Followers: " + model.followers().size(),
"Pending invitations: " + model.pendingInvitations().size(),
"Nearby contributors: " + model.nearbyEligibleFollowers(),
"Strength: " + model.strengthLevel() + "/" + model.strengthCap(),
"Resistance: " + model.resistanceLevel() + "/" + model.resistanceCap()
)));
inventory.setItem(12, item(Material.WRITABLE_BOOK, ChatColor.GREEN + "Recruitment",
List.of("Invite eligible online players.")));
inventory.setItem(14, item(Material.PLAYER_HEAD, ChatColor.AQUA + "Manage Followers",
followerSummary(model)));
inventory.setItem(16, item(Material.REDSTONE_TORCH, ChatColor.RED + "Role Control",
List.of("Relinquish the Vigilante role.")));
player.openInventory(inventory);
}
@EventHandler
public void onClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)
|| !(event.getView().getTopInventory().getHolder() instanceof MenuHolder holder)) {
return;
}
event.setCancelled(true);
if (event.getRawSlot() < 0
|| event.getRawSlot() >= event.getView().getTopInventory().getSize()) {
return;
}
if (!isActiveVigilante(player)) {
player.closeInventory();
player.sendMessage(ChatColor.RED
+ "This Vigilante control panel is no longer active.");
return;
}
switch (holder.view()) {
case OVERVIEW -> clickOverview(player, event.getRawSlot());
case RECRUITMENT -> clickRecruitment(player, event.getRawSlot());
case INVITE_CONFIRMATION -> clickInviteConfirmation(
player, event.getRawSlot(), holder.targetId()
);
case FOLLOWERS -> clickFollowers(player, event.getRawSlot());
case DISMISS_CONFIRMATION -> clickDismissConfirmation(
player, event.getRawSlot(), holder.targetId()
);
case ROLE -> clickRole(player, event.getRawSlot());
case RELINQUISH_CONFIRMATION -> clickRelinquishConfirmation(
player, event.getRawSlot()
);
}
}
private void clickOverview(Player player, int slot) {
if (slot == 12) {
openRecruitment(player);
} else if (slot == 14) {
openFollowers(player);
} else if (slot == 16) {
openRole(player);
}
}
private void openRecruitment(Player player) {
MenuHolder holder = new MenuHolder(MenuView.RECRUITMENT, null);
Inventory inventory = create(holder, 54, TITLE + " — Recruitment");
Set<UUID> pending = followers.invitedPlayerIds(player.getUniqueId());
int slot = 0;
for (Player candidate : server.getOnlinePlayers()) {
if (slot >= 45 || candidate.getUniqueId().equals(player.getUniqueId())) {
continue;
}
PlayerState state = stateManager.player(candidate.getUniqueId(), candidate.getName());
String eligibility = recruitmentEligibility(candidate.getUniqueId(), state, pending);
inventory.setItem(slot, playerItem(candidate.getName(), candidate.getUniqueId(),
eligibility));
slot++;
}
inventory.setItem(49, backItem());
player.openInventory(inventory);
}
private String recruitmentEligibility(
UUID candidateId,
PlayerState state,
Set<UUID> pending
) {
GameState game = stateManager.game();
if (pending.contains(candidateId)) {
return ChatColor.YELLOW + "Pending invitation";
}
if (state.optedOutUntil().isPresent()) {
return ChatColor.RED + "Ineligible: opted out";
}
if (game.tyrantId().filter(candidateId::equals).isPresent()) {
return ChatColor.RED + "Ineligible: Tyrant";
}
if (game.vigilanteId().filter(candidateId::equals).isPresent()) {
return ChatColor.RED + "Ineligible: Vigilante";
}
if (state.followerOf().isPresent()) {
return ChatColor.RED + "Ineligible: already a Follower";
}
return ChatColor.GREEN + "Eligible";
}
private void clickRecruitment(Player player, int slot) {
if (slot == 49) {
refresh(player);
return;
}
selectedPlayer(player, slot, "Eligible")
.ifPresent(targetId -> openInviteConfirmation(player, targetId));
}
private void openInviteConfirmation(Player player, UUID targetId) {
Player target = server.getPlayer(targetId);
if (target == null) {
player.sendMessage(ChatColor.RED + "That player is no longer online.");
openRecruitment(player);
return;
}
MenuHolder holder = new MenuHolder(MenuView.INVITE_CONFIRMATION, targetId);
Inventory inventory = create(holder, 27, TITLE + " — Confirm Invitation");
inventory.setItem(11, cancelItem());
inventory.setItem(13, item(Material.WRITABLE_BOOK, ChatColor.YELLOW + target.getName(),
List.of("Invite this player to become a Follower?")));
inventory.setItem(15, confirmItem());
player.openInventory(inventory);
}
private void clickInviteConfirmation(Player player, int slot, UUID targetId) {
if (slot == 11) {
openRecruitment(player);
} else if (slot == 15 && targetId != null) {
Player target = server.getPlayer(targetId);
if (target == null) {
player.sendMessage(ChatColor.RED + "That player is no longer online.");
} else {
player.performCommand("vigilante invite " + target.getName());
}
openRecruitment(player);
}
}
private void openFollowers(Player player) {
VigilanteControlPanelModel model = model(player);
MenuHolder holder = new MenuHolder(MenuView.FOLLOWERS, null);
Inventory inventory = create(holder, 54, TITLE + " — Followers");
int slot = 0;
for (VigilanteFollowerView follower : model.followers()) {
if (slot >= 45) {
break;
}
FollowerPresence presence = follower.presence();
List<String> status = List.of(
presence.online() ? "Online" : "Offline",
presence.living() ? "Living" : "Dead or unavailable",
presence.sameWorld() ? "Same world" : "Different world or unavailable",
presence.withinRange() ? "Within range" : "Outside range or unavailable",
follower.optedOut() ? "Opted out" : "Participating",
presence.online() ? ChatColor.GREEN + "Click to dismiss."
: ChatColor.RED + "Must be online to dismiss."
);
inventory.setItem(slot, playerItem(
follower.name(), follower.playerId(), status.toString()
));
slot++;
}
inventory.setItem(49, backItem());
player.openInventory(inventory);
}
private void clickFollowers(Player player, int slot) {
if (slot == 49) {
refresh(player);
return;
}
selectedPlayer(player, slot, "Online")
.filter(targetId -> server.getPlayer(targetId) != null)
.ifPresent(targetId -> openDismissConfirmation(player, targetId));
}
private void openDismissConfirmation(Player player, UUID targetId) {
Player target = server.getPlayer(targetId);
if (target == null) {
player.sendMessage(ChatColor.RED + "That Follower is no longer online.");
openFollowers(player);
return;
}
MenuHolder holder = new MenuHolder(MenuView.DISMISS_CONFIRMATION, targetId);
Inventory inventory = create(holder, 27, TITLE + " — Confirm Dismissal");
inventory.setItem(11, cancelItem());
inventory.setItem(13, item(Material.PLAYER_HEAD, ChatColor.YELLOW + target.getName(),
List.of("Dismiss this Follower?")));
inventory.setItem(15, confirmItem());
player.openInventory(inventory);
}
private void clickDismissConfirmation(Player player, int slot, UUID targetId) {
if (slot == 11) {
openFollowers(player);
} else if (slot == 15 && targetId != null) {
Player target = server.getPlayer(targetId);
if (target == null) {
player.sendMessage(ChatColor.RED + "That Follower is no longer online.");
} else {
player.performCommand("vigilante dismiss " + target.getName());
}
openFollowers(player);
}
}
private void openRole(Player player) {
MenuHolder holder = new MenuHolder(MenuView.ROLE, null);
Inventory inventory = create(holder, 27, TITLE + " — Role Control");
inventory.setItem(13, item(Material.REDSTONE_TORCH,
ChatColor.RED + "Relinquish Vigilante Role",
List.of("Followers will be cleared and the arena will open.",
"Click to review confirmation.")));
inventory.setItem(22, backItem());
player.openInventory(inventory);
}
private void clickRole(Player player, int slot) {
if (slot == 22) {
refresh(player);
} else if (slot == 13) {
MenuHolder holder = new MenuHolder(MenuView.RELINQUISH_CONFIRMATION, null);
Inventory inventory = create(holder, 27, TITLE + " — Confirm Relinquishment");
inventory.setItem(11, cancelItem());
inventory.setItem(13, item(Material.BARRIER, ChatColor.RED + "Relinquish Role",
List.of("This clears all Followers and opens the arena challenge.")));
inventory.setItem(15, confirmItem());
player.openInventory(inventory);
}
}
private void clickRelinquishConfirmation(Player player, int slot) {
if (slot == 11) {
openRole(player);
} else if (slot == 15) {
player.closeInventory();
player.performCommand("tyrant relinquish confirm");
}
}
private java.util.Optional<UUID> selectedPlayer(
Player player,
int slot,
String requiredStatus
) {
ItemStack clicked = player.getOpenInventory().getTopInventory().getItem(slot);
if (clicked == null || clicked.getType() != Material.PLAYER_HEAD) {
return java.util.Optional.empty();
}
List<String> lore = clicked.getItemMeta().getLore();
if (lore == null || lore.size() < 2
|| !ChatColor.stripColor(lore.get(0)).contains(requiredStatus)) {
return java.util.Optional.empty();
}
try {
return java.util.Optional.of(UUID.fromString(
ChatColor.stripColor(lore.get(lore.size() - 1))
));
} catch (IllegalArgumentException ignored) {
player.sendMessage(ChatColor.RED + "That player selection is no longer valid.");
return java.util.Optional.empty();
}
}
private boolean isActiveVigilante(Player player) {
GameState game = stateManager.game();
return game.lifecycle() == GameLifecycle.RUNNING
&& game.vigilanteId().filter(player.getUniqueId()::equals).isPresent();
}
private VigilanteControlPanelModel model(Player player) {
Map<UUID, PlayerState> players = stateManager.players();
return VigilanteControlPanelModel.create(
stateManager.game(), players, followers.invitedPlayerIds(player.getUniqueId()),
presenceProvider.inspect(player, players), strengthCap, resistanceCap
);
}
private void refresh(Player player) {
openOverview(player, model(player));
}
private Inventory create(MenuHolder holder, int size, String title) {
Inventory inventory = server.createInventory(holder, size, title);
holder.inventory = inventory;
return inventory;
}
private static ItemStack playerItem(String name, UUID playerId, String status) {
return item(Material.PLAYER_HEAD, name, List.of(status, playerId.toString()));
}
private static ItemStack item(Material material, String name, List<String> lore) {
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
meta.setDisplayName(name);
meta.setLore(lore.stream().map(line -> ChatColor.GRAY + line).toList());
item.setItemMeta(meta);
return item;
}
private static ItemStack backItem() {
return item(Material.ARROW, ChatColor.YELLOW + "Back", List.of());
}
private static ItemStack cancelItem() {
return item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of());
}
private static ItemStack confirmItem() {
return item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of());
}
private static List<String> followerSummary(VigilanteControlPanelModel model) {
if (model.followers().isEmpty()) {
return List.of("No Followers assigned.");
}
return model.followers().stream()
.map(follower -> follower.name() + (follower.contributes() ? " — contributing" : ""))
.toList();
}
private enum MenuView {
OVERVIEW,
RECRUITMENT,
INVITE_CONFIRMATION,
FOLLOWERS,
DISMISS_CONFIRMATION,
ROLE,
RELINQUISH_CONFIRMATION
}
private static final class MenuHolder implements InventoryHolder {
private final MenuView view;
private final UUID targetId;
private Inventory inventory;
private MenuHolder(MenuView view, UUID targetId) {
this.view = view;
this.targetId = targetId;
}
private MenuView view() {
return view;
}
private UUID targetId() {
return targetId;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
}
@@ -0,0 +1,9 @@
package games.dmg.spigottyrant;
import org.bukkit.entity.Player;
public interface CapturedMobCustodyRelease {
PlayerState releaseAll(Player player, PlayerState state);
PlayerState releaseExcess(Player player, PlayerState state, int custodyLimit);
}
@@ -0,0 +1,61 @@
package games.dmg.spigottyrant;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
public final class CapturedMobInventoryService {
private final CapturedMobItemService items;
private final PluginSettings settings;
public CapturedMobInventoryService(
CapturedMobItemService items,
PluginSettings settings
) {
this.items = items;
this.settings = settings;
}
public void reconcile(Player player, PlayerState state) {
Map<UUID, CapturedMob> stored = new java.util.HashMap<>();
for (CapturedMob mob : state.capturedMobs()) {
try {
UUID captureId = UUID.fromString(mob.data().get("capture-id"));
stored.putIfAbsent(captureId, mob);
} catch (IllegalArgumentException | NullPointerException ignored) {
// Invalid custody records are not materialized as items.
}
}
Set<UUID> retained = new HashSet<>();
ItemStack[] contents = player.getInventory().getContents();
for (int index = 0; index < contents.length; index++) {
ItemStack item = contents[index];
if (!items.isCapturedMob(item)) {
continue;
}
UUID captureId = items.captureId(item).orElseThrow();
boolean valid = items.owner(item).filter(state.playerId()::equals).isPresent()
&& stored.containsKey(captureId) && retained.add(captureId);
if (!valid) {
player.getInventory().setItem(index, null);
}
}
for (Map.Entry<UUID, CapturedMob> entry : stored.entrySet()) {
if (retained.contains(entry.getKey())) {
continue;
}
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(
items.create(
state.playerId(), entry.getKey(), entry.getValue().entityType()
)
);
if (!leftovers.isEmpty()) {
player.sendMessage(ChatColor.RED + settings.inventoryFullMessage());
}
}
}
}
@@ -0,0 +1,15 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.inventory.ItemStack;
public interface CapturedMobItemService {
ItemStack create(UUID ownerId, UUID captureId, String entityType);
boolean isCapturedMob(ItemStack item);
Optional<UUID> owner(ItemStack item);
Optional<UUID> captureId(ItemStack item);
}
@@ -0,0 +1,58 @@
package games.dmg.spigottyrant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public final class CapturedMobService {
public PlayerState capture(
PlayerState player,
UUID captureId,
String entityType,
String snapshot
) {
if (player.tyrantClass() != TyrantClass.TAMER) {
throw new IllegalStateException("only a Tamer can capture mobs");
}
if (!player.capturedMobs().isEmpty()) {
throw new IllegalStateException(
"release the currently captured mob before capturing another"
);
}
if ("ENDER_DRAGON".equalsIgnoreCase(entityType)) {
throw new IllegalArgumentException("Ender Dragons cannot be captured");
}
List<CapturedMob> mobs = new ArrayList<>(player.capturedMobs());
mobs.add(new CapturedMob(entityType, Map.of(
"capture-id", captureId.toString(),
"snapshot", snapshot
)));
return copy(player, mobs);
}
public Optional<CapturedMob> find(PlayerState player, UUID captureId) {
return player.capturedMobs().stream()
.filter(mob -> captureId.toString().equals(mob.data().get("capture-id")))
.findFirst();
}
public PlayerState release(PlayerState player, UUID captureId) {
if (find(player, captureId).isEmpty()) {
return player;
}
List<CapturedMob> mobs = player.capturedMobs().stream()
.filter(mob -> !captureId.toString().equals(mob.data().get("capture-id")))
.toList();
return copy(player, mobs);
}
private static PlayerState copy(PlayerState player, List<CapturedMob> mobs) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), player.followerOf(), player.cooldownEnds(),
player.readyAbilityItems(), mobs
);
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
import org.bukkit.entity.Player;
@FunctionalInterface
public interface CapturedMobSpawner {
boolean spawn(Player player, CapturedMob mob);
}
@@ -78,7 +78,7 @@ public final class ClassAssignmentService {
if (targetId.equals(assignerId)) {
return ClassAssignmentStatus.TARGET_IS_TYRANT;
}
if (target.optedOutUntil().filter(until -> until.isAfter(now)).isPresent()) {
if (target.optedOutUntil().isPresent()) {
return ClassAssignmentStatus.TARGET_OPTED_OUT;
}
return null;
@@ -102,6 +102,7 @@ public final class ClassAssignmentService {
cooldowns.remove(ability);
readyItems.remove(ability);
}
readyItemFor(tyrantClass).ifPresent(readyItems::add);
return new PlayerState(
player.playerId(),
player.latestName(),
@@ -115,6 +116,15 @@ public final class ClassAssignmentService {
);
}
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(
@@ -0,0 +1,45 @@
package games.dmg.spigottyrant;
import java.util.ArrayList;
import java.util.List;
import org.bukkit.entity.Player;
public final class DefaultCapturedMobCustodyRelease implements CapturedMobCustodyRelease {
private final CapturedMobSpawner spawner;
public DefaultCapturedMobCustodyRelease(CapturedMobSpawner spawner) {
this.spawner = spawner;
}
@Override
public PlayerState releaseAll(Player player, PlayerState state) {
return releaseExcess(player, state, 0);
}
@Override
public PlayerState releaseExcess(
Player player,
PlayerState state,
int custodyLimit
) {
if (custodyLimit < 0) {
throw new IllegalArgumentException("custodyLimit must not be negative");
}
List<CapturedMob> retained = new ArrayList<>();
List<CapturedMob> custody = state.capturedMobs();
for (int index = 0; index < custody.size(); index++) {
CapturedMob mob = custody.get(index);
if (index < custodyLimit || !spawner.spawn(player, mob)) {
retained.add(mob);
}
}
if (retained.equals(custody)) {
return state;
}
return new PlayerState(
state.playerId(), state.latestName(), state.lastLogin(), state.optedOutUntil(),
state.tyrantClass(), state.followerOf(), state.cooldownEnds(),
state.readyAbilityItems(), retained
);
}
}
@@ -0,0 +1,57 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
public final class DefaultTyrantControlPanel implements TyrantControlPanel {
private final TyrantStateManager stateManager;
private final TyrantControlPanelRenderer renderer;
private final Clock clock;
private final Set<UUID> openedSessions = new HashSet<>();
public DefaultTyrantControlPanel(
TyrantStateManager stateManager,
TyrantControlPanelRenderer renderer,
Clock clock
) {
this.stateManager = stateManager;
this.renderer = renderer;
this.clock = clock;
}
@Override
public void open(Player player) {
GameState game = stateManager.game();
if (game.lifecycle() != GameLifecycle.RUNNING
|| game.tyrantId().filter(player.getUniqueId()::equals).isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Only the active Tyrant can open the control panel while the event is running.");
return;
}
PlayerState tyrant = stateManager.player(player.getUniqueId(), player.getName());
renderer.openOverview(
player,
TyrantControlPanelModel.create(game, tyrant, stateManager.players(), clock.instant())
);
openedSessions.add(player.getUniqueId());
}
@Override
public void beginSession(UUID playerId) {
openedSessions.remove(playerId);
}
@Override
public void endSession(UUID playerId) {
openedSessions.remove(playerId);
}
@Override
public boolean openedThisSession(UUID playerId) {
return openedSessions.contains(playerId);
}
}
@@ -0,0 +1,73 @@
package games.dmg.spigottyrant;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
public final class DefaultVigilanteControlPanel implements VigilanteControlPanel {
private final TyrantStateManager stateManager;
private final FollowerService followers;
private final VigilanteControlPanelRenderer renderer;
private final FollowerPresenceProvider presenceProvider;
private final int strengthCap;
private final int resistanceCap;
private final Set<UUID> openedSessions = new HashSet<>();
public DefaultVigilanteControlPanel(
TyrantStateManager stateManager,
FollowerService followers,
VigilanteControlPanelRenderer renderer,
FollowerPresenceProvider presenceProvider,
int strengthCap,
int resistanceCap
) {
this.stateManager = stateManager;
this.followers = followers;
this.renderer = renderer;
this.presenceProvider = presenceProvider;
this.strengthCap = strengthCap;
this.resistanceCap = resistanceCap;
}
@Override
public void open(Player player) {
GameState game = stateManager.game();
if (game.lifecycle() != GameLifecycle.RUNNING
|| game.vigilanteId().filter(player.getUniqueId()::equals).isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Only the active Vigilante can open the control panel while the event is running.");
return;
}
Map<UUID, PlayerState> players = stateManager.players();
renderer.openOverview(
player,
VigilanteControlPanelModel.create(
game,
players,
followers.invitedPlayerIds(player.getUniqueId()),
presenceProvider.inspect(player, players),
strengthCap,
resistanceCap
)
);
openedSessions.add(player.getUniqueId());
}
@Override
public void beginSession(UUID playerId) {
openedSessions.remove(playerId);
}
@Override
public void endSession(UUID playerId) {
openedSessions.remove(playerId);
}
@Override
public boolean openedThisSession(UUID playerId) {
return openedSessions.contains(playerId);
}
}
@@ -0,0 +1,6 @@
package games.dmg.spigottyrant;
@FunctionalInterface
public interface DelayedTaskScheduler {
void schedule(Runnable task, long delayTicks);
}
@@ -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,87 @@
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.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;
private final Set<UUID> managedPlayers = new HashSet<>();
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) {
if (managedPlayers.remove(player.getUniqueId())) {
clear(player);
}
continue;
}
managedPlayers.add(player.getUniqueId());
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() {
for (UUID playerId : Set.copyOf(managedPlayers)) {
Player player = server.getPlayer(playerId);
if (player != null) {
clear(player);
}
}
managedPlayers.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,12 @@
package games.dmg.spigottyrant;
public record FollowerPresence(
boolean online,
boolean living,
boolean sameWorld,
boolean withinRange
) {
public boolean contributes() {
return online && living && sameWorld && withinRange;
}
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.UUID;
import org.bukkit.entity.Player;
@FunctionalInterface
public interface FollowerPresenceProvider {
Map<UUID, FollowerPresence> inspect(Player vigilante, Map<UUID, PlayerState> players);
}
@@ -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,122 @@
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()
|| target.followerOf().isPresent()
|| game.tyrantId().filter(targetId::equals).isPresent()
|| game.vigilanteId().filter(targetId::equals).isPresent()) {
return result(players, FollowerStatus.INELIGIBLE);
}
if (vigilanteId.equals(invitations.get(targetId))) {
return result(players, FollowerStatus.ALREADY_INVITED);
}
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 java.util.Set<UUID> invitedPlayerIds(UUID vigilanteId) {
return invitations.entrySet().stream()
.filter(entry -> entry.getValue().equals(vigilanteId))
.map(Map.Entry::getKey)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
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,14 @@
package games.dmg.spigottyrant;
public enum FollowerStatus {
INVITED,
ALREADY_INVITED,
JOINED,
DISMISSED,
LEFT,
GAME_NOT_RUNNING,
NOT_VIGILANTE,
INELIGIBLE,
NO_INVITATION,
NOT_FOLLOWER
}
@@ -0,0 +1,64 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import org.bukkit.Server;
import org.bukkit.entity.Player;
public final class FormerTamerReleaseTask implements Runnable {
private final TyrantStateManager stateManager;
private final Server server;
private final CapturedMobCustodyRelease release;
private final CapturedMobInventoryService inventory;
public FormerTamerReleaseTask(
TyrantStateManager stateManager,
Server server,
CapturedMobCustodyRelease release
) {
this(stateManager, server, release, null);
}
public FormerTamerReleaseTask(
TyrantStateManager stateManager,
Server server,
CapturedMobCustodyRelease release,
CapturedMobInventoryService inventory
) {
this.stateManager = stateManager;
this.server = server;
this.release = release;
this.inventory = inventory;
}
@Override
public void run() {
Map<UUID, Player> online = server.getOnlinePlayers().stream()
.collect(Collectors.toMap(Player::getUniqueId, player -> player));
for (PlayerState state : stateManager.players().values()) {
if (state.capturedMobs().isEmpty()
|| state.tyrantClass() == TyrantClass.TAMER
&& state.capturedMobs().size() == 1) {
continue;
}
Player player = online.get(state.playerId());
if (player == null || player.isDead()) {
continue;
}
PlayerState released = state.tyrantClass() == TyrantClass.TAMER
? release.releaseExcess(player, state, 1)
: release.releaseAll(player, state);
if (released.equals(state)) {
continue;
}
stateManager.updatePlayer(
state.playerId(), state.latestName(), current -> released
);
stateManager.saveIfDirty();
if (inventory != null) {
inventory.reconcile(player, released);
}
}
}
}
@@ -52,7 +52,8 @@ public final class GameLifecycleService {
current.accumulatedPausedTime(),
current.tyrantLevel(),
current.unspentChoices(),
current.purchases()
current.purchases(),
current.claimedArmor()
);
return new LifecycleState(paused, state.players());
}
@@ -77,7 +78,8 @@ public final class GameLifecycleService {
current.accumulatedPausedTime().plus(pausedFor),
current.tyrantLevel(),
current.unspentChoices(),
current.purchases()
current.purchases(),
current.claimedArmor()
);
Map<UUID, PlayerState> players = new HashMap<>();
for (PlayerState player : state.players().values()) {
@@ -16,7 +16,8 @@ public record GameState(
Duration accumulatedPausedTime,
int tyrantLevel,
int unspentChoices,
Set<TyrantUnlock> purchases
Set<TyrantUnlock> purchases,
Set<TyrantArmorPiece> claimedArmor
) {
public GameState {
lifecycle = lifecycle == null ? GameLifecycle.UNSTARTED : lifecycle;
@@ -28,6 +29,7 @@ public record GameState(
accumulatedPausedTime = accumulatedPausedTime == null
? Duration.ZERO : accumulatedPausedTime;
purchases = purchases == null ? Set.of() : Set.copyOf(purchases);
claimedArmor = claimedArmor == null ? Set.of() : Set.copyOf(claimedArmor);
if (tyrantId.isPresent() && tyrantId.equals(vigilanteId)) {
throw new IllegalArgumentException("Tyrant and Vigilante must be different players");
}
@@ -42,6 +44,24 @@ public record GameState(
}
}
public 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
) {
this(
lifecycle, tyrantId, vigilanteId, pendingTyrant, pendingVigilante, pausedAt,
accumulatedPausedTime, tyrantLevel, unspentChoices, purchases, Set.of()
);
}
public static GameState empty() {
return new GameState(
GameLifecycle.UNSTARTED,
@@ -53,6 +73,7 @@ public record GameState(
Duration.ZERO,
0,
0,
Set.of(),
Set.of()
);
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
public record IntelligenceResult(
PlayerState tyrant,
AbilityUseStatus status,
int memberCount
) {
}
@@ -7,4 +7,6 @@ public interface OnlinePlayerDirectory {
Player findByName(String name);
Player findById(UUID playerId);
java.util.Set<UUID> onlinePlayerIds();
}
@@ -0,0 +1,127 @@
package games.dmg.spigottyrant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
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.player.PlayerQuitEvent;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.bukkit.scoreboard.Team;
public final class OptOutGlowController implements Runnable, Listener {
private static final String TEAM_NAME = "tyrant-optout";
private final TyrantStateManager stateManager;
private final Server server;
private final PluginSettings settings;
private final Scoreboard scoreboard;
private final Team greenTeam;
private final Map<UUID, PreviousPresentation> managed = new HashMap<>();
public OptOutGlowController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings
) {
this.stateManager = stateManager;
this.server = server;
this.settings = settings;
ScoreboardManager manager = Objects.requireNonNull(
server.getScoreboardManager(), "Scoreboard manager is unavailable"
);
scoreboard = manager.getMainScoreboard();
Team existing = scoreboard.getTeam(TEAM_NAME);
greenTeam = existing == null ? scoreboard.registerNewTeam(TEAM_NAME) : existing;
greenTeam.setColor(ChatColor.GREEN);
}
@Override
public void run() {
GameState game = stateManager.game();
Player tyrant = game.tyrantId().map(server::getPlayer).orElse(null);
boolean tyrantAvailable = tyrant != null && !tyrant.isDead();
Set<UUID> online = new HashSet<>();
for (Player player : server.getOnlinePlayers()) {
online.add(player.getUniqueId());
PlayerState state = stateManager.players().get(player.getUniqueId());
boolean sameWorld = tyrantAvailable && !player.isDead()
&& player.getWorld().equals(tyrant.getWorld());
double distanceSquared = sameWorld
? player.getLocation().distanceSquared(tyrant.getLocation())
: Double.POSITIVE_INFINITY;
boolean shouldGlow = state != null && OptOutGlowPolicy.shouldGlow(
game, state, tyrantAvailable, sameWorld, distanceSquared,
settings.tyrantRangeBlocks()
);
if (shouldGlow && !managed.containsKey(player.getUniqueId())) {
apply(player);
} else if (!shouldGlow && managed.containsKey(player.getUniqueId())) {
restore(player.getUniqueId(), player);
}
}
for (UUID playerId : Set.copyOf(managed.keySet())) {
if (!online.contains(playerId) && server.getPlayer(playerId) == null) {
restore(playerId, null);
}
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
if (managed.containsKey(event.getPlayer().getUniqueId())) {
restore(event.getPlayer().getUniqueId(), event.getPlayer());
}
}
public void clearAll() {
for (UUID playerId : Set.copyOf(managed.keySet())) {
restore(playerId, server.getPlayer(playerId));
}
}
private void apply(Player player) {
Team previousTeam = scoreboard.getEntryTeam(player.getName());
if (previousTeam == greenTeam) {
previousTeam = null;
}
managed.put(
player.getUniqueId(),
new PreviousPresentation(player.getName(), previousTeam, player.isGlowing())
);
greenTeam.addEntry(player.getName());
player.setGlowing(true);
}
private void restore(UUID playerId, Player player) {
PreviousPresentation previous = managed.remove(playerId);
if (previous == null) {
return;
}
greenTeam.removeEntry(previous.playerName());
if (previous.team() != null) {
try {
previous.team().addEntry(previous.playerName());
} catch (IllegalStateException ignored) {
// The prior team was removed while this plugin managed the entry.
}
}
if (player != null) {
player.setGlowing(previous.glowing());
}
}
private record PreviousPresentation(
String playerName,
Team team,
boolean glowing
) {
}
}
@@ -0,0 +1,22 @@
package games.dmg.spigottyrant;
public final class OptOutGlowPolicy {
private OptOutGlowPolicy() {
}
public static boolean shouldGlow(
GameState game,
PlayerState player,
boolean tyrantAvailable,
boolean playerAvailableInTyrantWorld,
double distanceSquared,
double rangeBlocks
) {
return game.lifecycle() == GameLifecycle.RUNNING
&& game.tyrantId().isPresent()
&& player.optedOutUntil().isPresent()
&& tyrantAvailable
&& playerAvailableInTyrantWorld
&& distanceSquared <= rangeBlocks * rangeBlocks;
}
}
@@ -0,0 +1,64 @@
package games.dmg.spigottyrant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
public final class ParticipationCombatListener implements Listener {
public static final String OPTED_OUT_MESSAGE =
"That player has chosen not to take part in the Tyrant event.";
private final TyrantStateManager stateManager;
private final ParticipationCombatService combat;
public ParticipationCombatListener(
TyrantStateManager stateManager,
ParticipationCombatService combat
) {
this.stateManager = stateManager;
this.combat = combat;
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onDamage(EntityDamageByEntityEvent event) {
if (!(event.getEntity() instanceof Player target)) {
return;
}
Optional<Player> attacker = attackingPlayer(event);
if (attacker.isEmpty()) {
return;
}
Player source = attacker.orElseThrow();
Map<UUID, PlayerState> players = stateManager.players();
PlayerState attackerState = players.getOrDefault(
source.getUniqueId(),
PlayerState.newPlayer(source.getUniqueId(), source.getName())
);
PlayerState targetState = players.getOrDefault(
target.getUniqueId(),
PlayerState.newPlayer(target.getUniqueId(), target.getName())
);
if (combat.blocks(stateManager.game(), attackerState, targetState)) {
event.setCancelled(true);
source.sendMessage(ChatColor.YELLOW + OPTED_OUT_MESSAGE);
}
}
private static Optional<Player> attackingPlayer(EntityDamageByEntityEvent event) {
if (event.getDamager() instanceof Player player) {
return Optional.of(player);
}
if (event.getDamager() instanceof Projectile projectile
&& projectile.getShooter() instanceof Player player) {
return Optional.of(player);
}
return Optional.empty();
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigottyrant;
public final class ParticipationCombatService {
public boolean blocks(
GameState game,
PlayerState attacker,
PlayerState target
) {
if (game.lifecycle() != GameLifecycle.RUNNING) {
return false;
}
boolean attackerOptedOut = attacker.optedOutUntil().isPresent();
boolean targetOptedOut = target.optedOutUntil().isPresent();
boolean attackerTyrantSide = isTyrantSide(game, attacker);
boolean targetTyrantSide = isTyrantSide(game, target);
return (attackerTyrantSide && targetOptedOut)
|| (attackerOptedOut && targetTyrantSide);
}
private static boolean isTyrantSide(GameState game, PlayerState player) {
return game.tyrantId().filter(player.playerId()::equals).isPresent()
|| player.tyrantClass() != TyrantClass.NONE;
}
}
@@ -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
}
@@ -8,10 +8,20 @@ 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
@@ -25,9 +35,16 @@ public final class PlayerJoinListener implements Listener {
current.cooldownEnds(), current.readyAbilityItems(), current.capturedMobs()
)
);
String reminder = ClassAssignmentMessages.loginReminder(player);
if (!reminder.isEmpty()) {
event.getPlayer().sendMessage(reminder);
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,52 @@
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<>();
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("Use /tyrant optout if you do not want to participate "
+ "in Tyrant events.");
}
return List.copyOf(messages);
}
}
@@ -12,11 +12,14 @@ import org.bukkit.configuration.ConfigurationSection;
public record PluginSettings(
double tyrantRangeBlocks,
double followerRangeBlocks,
double vigilanteArenaRadiusBlocks,
int arenaBarrierHeightBlocks,
double vigilanteBossHealth,
double vigilanteBossDamage,
double vigilanteBossArmor,
Duration optOutDuration,
Duration roleInactivity,
Duration candidateActivityWindow,
Duration pendingSelectionTimeout,
Duration selectionRetryInterval,
Duration roleMaintenanceInterval,
Duration assassinCooldown,
Duration assassinInvisibilityDuration,
Duration assassinDoubleJumpCooldown,
@@ -28,6 +31,7 @@ public record PluginSettings(
int fixerStrengthLevel,
int fixerNormalHeartRows,
int fixerNearTyrantHeartRows,
Duration vigilanteCombatDuration,
Duration rosterIntelligenceCooldown,
int tyrantStrengthLevel,
int tyrantResistanceLevel,
@@ -37,6 +41,9 @@ public record PluginSettings(
AbilityItemSettings assassinItem,
AbilityItemSettings fixerItem,
AbilityItemSettings tamerItem,
AbilityItemSettings capturedMobItem,
AbilityItemSettings tyrantControlItem,
AbilityItemSettings vigilanteControlItem,
String recoveryCommand,
String inventoryFullMessage,
boolean freezeTimersWhilePaused
@@ -44,11 +51,20 @@ public record PluginSettings(
public PluginSettings {
requirePositiveFinite(tyrantRangeBlocks, "tyrant-range-blocks");
requirePositiveFinite(followerRangeBlocks, "follower-range-blocks");
requireFiniteRange(
vigilanteArenaRadiusBlocks, 1.0, 512.0, "vigilante-arena-radius-blocks"
);
if (arenaBarrierHeightBlocks < 3 || arenaBarrierHeightBlocks > 128) {
throw new IllegalArgumentException(
"arena-barrier-height-blocks must be between 3 and 128"
);
}
requireFiniteRange(vigilanteBossHealth, 1.0, 1024.0, "vigilante-boss-health");
requireFiniteRange(vigilanteBossDamage, 0.1, 2048.0, "vigilante-boss-damage");
requireFiniteRange(vigilanteBossArmor, 0.0, 30.0, "vigilante-boss-armor");
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(roleMaintenanceInterval, "role-maintenance-seconds");
requirePositive(assassinCooldown, "assassin-cooldown-seconds");
requirePositive(assassinInvisibilityDuration, "assassin-invisibility-seconds");
requirePositive(assassinDoubleJumpCooldown, "assassin-double-jump-cooldown-seconds");
@@ -65,6 +81,7 @@ public record PluginSettings(
"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");
@@ -74,6 +91,11 @@ public record PluginSettings(
assassinItem = Objects.requireNonNull(assassinItem, "assassinItem");
fixerItem = Objects.requireNonNull(fixerItem, "fixerItem");
tamerItem = Objects.requireNonNull(tamerItem, "tamerItem");
capturedMobItem = Objects.requireNonNull(capturedMobItem, "capturedMobItem");
tyrantControlItem = Objects.requireNonNull(tyrantControlItem, "tyrantControlItem");
vigilanteControlItem = Objects.requireNonNull(
vigilanteControlItem, "vigilanteControlItem"
);
recoveryCommand = requireText(recoveryCommand, "recovery-command");
inventoryFullMessage = requireText(inventoryFullMessage, "inventory-full-message");
}
@@ -83,11 +105,17 @@ public record PluginSettings(
return new PluginSettings(
decimal(values, "tyrant-range-blocks", 50.0),
decimal(values, "follower-range-blocks", 50.0),
decimal(values, "vigilante-arena-radius-blocks", 10.0),
integer(values, "arena-barrier-height-blocks", 12),
decimal(values, "vigilante-boss-health", 80.0),
decimal(values, "vigilante-boss-damage", 8.0),
decimal(values, "vigilante-boss-armor", 10.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)),
durationAlias(
values, "role-maintenance-seconds", "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)),
@@ -99,6 +127,7 @@ public record PluginSettings(
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),
@@ -108,6 +137,15 @@ public record PluginSettings(
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")),
item(values, "captured-mob-item", new AbilityItemSettings("PAPER", "Captured Mob")),
item(
values, "tyrant-control-item",
new AbilityItemSettings("NETHER_STAR", "Tyrant Control")
),
item(
values, "vigilante-control-item",
new AbilityItemSettings("COMPASS", "Vigilante Control")
),
string(values, "recovery-command", "/tyrant item"),
string(
values,
@@ -207,6 +245,19 @@ public record PluginSettings(
}
}
private static void requireFiniteRange(
double value,
double minimum,
double maximum,
String key
) {
if (!Double.isFinite(value) || value < minimum || value > maximum) {
throw new IllegalArgumentException(
key + " must be between " + minimum + " and " + maximum
);
}
}
private static void requirePositive(Duration value, String key) {
Objects.requireNonNull(value, key);
if (value.isZero() || value.isNegative()) {
@@ -242,6 +293,18 @@ public record PluginSettings(
return value.trim();
}
private static Duration durationAlias(
Map<String, ?> values,
String key,
String legacyKey,
Duration defaultValue
) {
if (values.containsKey(key)) {
return duration(values, key, defaultValue);
}
return duration(values, legacyKey, defaultValue);
}
private static Duration duration(Map<String, ?> values, String key, Duration defaultValue) {
Object raw = values.get(key);
if (raw == null) {
@@ -11,6 +11,9 @@ public final class PluginSettingsValidator {
validateItem(settings.assassinItem(), "assassin-item");
validateItem(settings.fixerItem(), "fixer-item");
validateItem(settings.tamerItem(), "tamer-item");
validateItem(settings.capturedMobItem(), "captured-mob-item");
validateItem(settings.tyrantControlItem(), "tyrant-control-item");
validateItem(settings.vigilanteControlItem(), "vigilante-control-item");
for (String entityType : settings.deniedMobTypes()) {
try {
EntityType.valueOf(entityType);
@@ -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,64 @@
package games.dmg.spigottyrant;
import java.util.LinkedHashMap;
import java.util.Map;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
final class RoleArenaBarrier {
private final Map<Location, BlockData> replaced = new LinkedHashMap<>();
private boolean built;
void build(
World world,
ArenaLocation center,
double radius,
int wallHeight
) {
if (built) {
return;
}
built = true;
int samples = Math.max(64, (int) Math.ceil(2.0 * Math.PI * radius * 2.0));
int baseY = (int) Math.floor(center.y()) - 1;
for (int index = 0; index < samples; index++) {
double angle = 2.0 * Math.PI * index / samples;
int x = (int) Math.floor(center.x() + Math.cos(angle) * radius);
int z = (int) Math.floor(center.z() + Math.sin(angle) * radius);
for (int y = baseY; y < baseY + wallHeight; y++) {
Block block = world.getBlockAt(x, y, z);
Location location = block.getLocation();
if (replaced.containsKey(location) || !isAir(block.getType())) {
continue;
}
replaced.put(location, block.getBlockData().clone());
block.setType(Material.BARRIER, false);
}
}
}
void clear() {
for (Map.Entry<Location, BlockData> entry : replaced.entrySet()) {
Location location = entry.getKey();
World world = location.getWorld();
if (world != null) {
world.getBlockAt(location).setBlockData(entry.getValue(), false);
}
}
replaced.clear();
built = false;
}
boolean active() {
return built;
}
private static boolean isAir(Material material) {
return material == Material.AIR
|| material == Material.CAVE_AIR
|| material == Material.VOID_AIR;
}
}
@@ -0,0 +1,639 @@
package games.dmg.spigottyrant;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Entity;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.entity.Zombie;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.event.entity.EntityCombustEvent;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.EntityTargetLivingEntityEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
public final class RoleArenaController implements Listener, Runnable {
private static final String BOSS_TAG = "spigottyrant-role-arena-boss";
private final TyrantStateManager stateManager;
private final Server server;
private final ArenaLocationStore locations;
private final PluginSettings settings;
private final VigilanteArenaSuccessionService vigilanteSuccession =
new VigilanteArenaSuccessionService();
private final TyrantArenaSuccessionService tyrantSuccession =
new TyrantArenaSuccessionService();
private final RoleArenaBarrier barrier = new RoleArenaBarrier();
private final Set<UUID> relocating = new HashSet<>();
private UUID bossId;
private UUID challengerId;
private boolean spawningBoss;
private ArenaRole previousOpenRole;
private ArenaRole bossRole;
private ArenaRole warnedMissingRole;
private ArenaLocation observedLocation;
public RoleArenaController(
TyrantStateManager stateManager,
Server server,
ArenaLocationStore locations,
PluginSettings settings
) {
this.stateManager = stateManager;
this.server = server;
this.locations = locations;
this.settings = settings;
}
@Override
public void run() {
Optional<ArenaRole> openRole = RoleArenaPolicy.openRole(stateManager.game());
Optional<ArenaLocation> configured = locations.location();
if (configured.isEmpty()) {
closeFight();
observedLocation = null;
warnIfOpenWithoutLocation(openRole);
previousOpenRole = null;
return;
}
ArenaLocation arena = configured.orElseThrow();
if (observedLocation != null && !observedLocation.equals(arena)) {
closeFight();
}
observedLocation = arena;
World world = server.getWorld(arena.worldName());
if (world == null) {
closeFight();
warnIfOpenWithoutLocation(openRole);
previousOpenRole = null;
return;
}
warnedMissingRole = null;
ArenaRole role = openRole.orElse(null);
if (role != previousOpenRole) {
closeFight();
if (role != null) {
announceVacancy(arena, role);
}
}
previousOpenRole = role;
drawBoundary(world, arena, role);
removeOtherMobs(world, arena);
if (role == null) {
closeFight();
return;
}
Zombie boss = boss(world);
if (boss == null || bossRole != role) {
closeFight();
boss = spawnBoss(world, arena, role);
}
boss.setFireTicks(0);
if (!contains(arena, boss.getLocation())) {
boss.teleport(toBukkit(world, arena));
}
Player challenger = challengerId == null ? null : server.getPlayer(challengerId);
if (challenger == null || challenger.isDead()
|| !contains(arena, challenger.getLocation())
|| !RoleArenaPolicy.isEligible(
stateManager.game(), role, playerState(challenger)
)) {
if (challengerId != null) {
resetFight();
} else {
ArenaBossState.idle(
boss, toBukkit(world, arena), settings.vigilanteBossHealth()
);
}
} else {
ArenaBossState.engage(boss, challenger);
}
for (Player player : world.getPlayers()) {
enforcePosition(player, null, player.getLocation());
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
if (event.getTo() != null && movedHorizontally(event.getFrom(), event.getTo())
&& enforcePosition(event.getPlayer(), event.getFrom(), event.getTo())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onTeleport(PlayerTeleportEvent event) {
if (event.getTo() != null
&& enforcePosition(event.getPlayer(), event.getFrom(), event.getTo())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onRespawn(PlayerRespawnEvent event) {
Optional<ArenaLocation> configured = locations.location();
if (configured.isPresent() && contains(
configured.orElseThrow(), event.getRespawnLocation())) {
event.setRespawnLocation(outside(configured.orElseThrow(), event.getRespawnLocation()));
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerDeath(PlayerDeathEvent event) {
if (event.getEntity().getUniqueId().equals(challengerId)) {
resetFight();
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
if (event.getPlayer().getUniqueId().equals(challengerId)) {
resetFight();
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) {
Optional<ArenaRole> role = RoleArenaPolicy.openRole(stateManager.game());
if (role.isEmpty()) {
return;
}
PlayerState state = playerState(event.getPlayer());
locations.location().filter(location -> RoleArenaPolicy.isEligible(
stateManager.game(), role.orElseThrow(), state
)).ifPresent(location -> event.getPlayer().sendMessage(
vacancyMessage(location, role.orElseThrow())
));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCreatureSpawn(CreatureSpawnEvent event) {
Optional<ArenaLocation> configured = locations.location();
if (!spawningBoss && configured.isPresent()
&& contains(configured.orElseThrow(), event.getLocation())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockPlace(BlockPlaceEvent event) {
if (protects(event.getBlock().getLocation())) {
event.setCancelled(true);
event.getPlayer().sendMessage(ChatColor.RED
+ "Blocks cannot be changed during an arena fight.");
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
if (protects(event.getBlock().getLocation())) {
event.setCancelled(true);
event.getPlayer().sendMessage(ChatColor.RED
+ "Blocks cannot be changed during an arena fight.");
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCombust(EntityCombustEvent event) {
if (event.getEntity().getUniqueId().equals(bossId)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onTarget(EntityTargetLivingEntityEvent event) {
if (!event.getEntity().getUniqueId().equals(bossId)) {
return;
}
Entity target = event.getTarget();
if (target != null && (challengerId == null
|| !target.getUniqueId().equals(challengerId))) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onDamage(EntityDamageByEntityEvent event) {
if (!event.getEntity().getUniqueId().equals(bossId)) {
return;
}
Player attacker = attackingPlayer(event.getDamager());
if (attacker == null || challengerId == null
|| !attacker.getUniqueId().equals(challengerId)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onBossDeath(EntityDeathEvent event) {
if (!event.getEntity().getUniqueId().equals(bossId)) {
return;
}
UUID defeatedBoss = bossId;
ArenaRole defeatedRole = bossRole;
bossId = null;
bossRole = null;
Player killer = event.getEntity().getKiller();
if (killer != null && challengerId != null && defeatedRole != null
&& killer.getUniqueId().equals(challengerId)
&& RoleArenaPolicy.isEligible(
stateManager.game(), defeatedRole, playerState(killer)
)) {
assignRole(challengerId, defeatedRole);
return;
}
bossId = defeatedBoss;
bossRole = defeatedRole;
resetFight();
}
public void close() {
closeFight();
}
private boolean enforcePosition(Player player, Location from, Location to) {
Optional<ArenaLocation> configured = locations.location();
if (configured.isEmpty() || relocating.contains(player.getUniqueId())) {
return false;
}
ArenaLocation arena = configured.orElseThrow();
boolean inside = contains(arena, to);
if (player.getUniqueId().equals(challengerId)) {
if (!inside) {
resetFight();
}
return false;
}
if (!inside) {
return false;
}
PlayerState state = playerState(player);
ArenaRole role = bossRole;
if (challengerId == null && bossId != null && role != null
&& RoleArenaPolicy.isEligible(stateManager.game(), role, state)) {
challengerId = player.getUniqueId();
World world = server.getWorld(arena.worldName());
if (world != null) {
barrier.build(
world, arena, settings.vigilanteArenaRadiusBlocks(),
settings.arenaBarrierHeightBlocks()
);
Zombie boss = boss(world);
if (boss != null) {
boss.setHealth(settings.vigilanteBossHealth());
boss.teleport(toBukkit(world, arena));
ArenaBossState.engage(boss, player);
}
}
player.sendMessage(ChatColor.GOLD + "Defeat the arena boss to become "
+ readable(role) + "!");
return false;
}
bounce(player, from, to, arena);
return true;
}
private void bounce(Player player, Location from, Location attempted, ArenaLocation arena) {
Location destination = from != null && !contains(arena, from)
? from.clone() : outside(arena, attempted);
relocating.add(player.getUniqueId());
try {
player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
} finally {
relocating.remove(player.getUniqueId());
}
player.sendMessage(ChatColor.RED + "Only the active challenger may enter the arena.");
}
private void resetFight() {
challengerId = null;
barrier.clear();
Optional<ArenaLocation> configured = locations.location();
if (configured.isEmpty()) {
return;
}
World world = server.getWorld(configured.orElseThrow().worldName());
Zombie boss = world == null ? null : boss(world);
if (boss != null && !boss.isDead()) {
ArenaBossState.idle(
boss, toBukkit(world, configured.orElseThrow()),
settings.vigilanteBossHealth()
);
} else {
bossId = null;
}
}
private void closeFight() {
challengerId = null;
barrier.clear();
if (bossId != null) {
Entity entity = server.getEntity(bossId);
if (entity != null) {
entity.remove();
}
bossId = null;
}
bossRole = null;
}
private Zombie spawnBoss(World world, ArenaLocation arena, ArenaRole role) {
for (Entity entity : world.getEntities()) {
if (entity.getScoreboardTags().contains(BOSS_TAG)) {
entity.remove();
}
}
spawningBoss = true;
try {
Zombie boss = world.spawn(toBukkit(world, arena), Zombie.class, zombie -> {
zombie.setAdult();
zombie.setCustomName(role == ArenaRole.TYRANT
? ChatColor.RED + "Tyrant's Trial"
: ChatColor.BLUE + "Vigilante's Trial");
zombie.setCustomNameVisible(true);
zombie.setRemoveWhenFarAway(false);
zombie.setCanPickupItems(false);
zombie.setAI(false);
zombie.addScoreboardTag(BOSS_TAG);
setAttribute(zombie, Attribute.MAX_HEALTH, settings.vigilanteBossHealth());
setAttribute(zombie, Attribute.ATTACK_DAMAGE, settings.vigilanteBossDamage());
setAttribute(zombie, Attribute.ARMOR, settings.vigilanteBossArmor());
zombie.setHealth(settings.vigilanteBossHealth());
});
bossId = boss.getUniqueId();
bossRole = role;
return boss;
} finally {
spawningBoss = false;
}
}
private Zombie boss(World world) {
if (bossId == null) {
return null;
}
Entity entity = server.getEntity(bossId);
return entity instanceof Zombie zombie && entity.getWorld().equals(world)
&& !zombie.isDead() ? zombie : null;
}
void assignRole(UUID playerId, ArenaRole role) {
LifecycleState assigned;
try {
assigned = role == ArenaRole.TYRANT
? tyrantSuccession.claim(stateManager.snapshot(), playerId)
: vigilanteSuccession.claim(stateManager.snapshot(), playerId);
} catch (IllegalStateException exception) {
resetFight();
return;
}
stateManager.replaceState(assigned);
stateManager.saveIfDirty();
challengerId = null;
barrier.clear();
Player winner = server.getPlayer(playerId);
moveWinnerOutside(winner);
if (role == ArenaRole.VIGILANTE) {
if (winner != null) {
winner.sendMessage(ChatColor.YELLOW
+ "You defeated the arena boss and became the Vigilante!");
}
return;
}
String name = Optional.ofNullable(winner)
.map(Player::getName).orElse(playerId.toString());
String message = ChatColor.YELLOW + name + " defeated the arena boss and is the "
+ readable(role) + "!";
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
private void moveWinnerOutside(Player winner) {
Optional<ArenaLocation> configured = locations.location();
if (winner == null || configured.isEmpty()) {
return;
}
ArenaLocation arena = configured.orElseThrow();
Location winnerLocation = winner.getLocation();
if (!contains(arena, winnerLocation)) {
return;
}
relocating.add(winner.getUniqueId());
try {
winner.teleport(
outside(arena, winnerLocation), PlayerTeleportEvent.TeleportCause.PLUGIN
);
} finally {
relocating.remove(winner.getUniqueId());
}
}
private void announceVacancy(ArenaLocation arena, ArenaRole role) {
String message = vacancyMessage(arena, role);
for (Player player : server.getOnlinePlayers()) {
PlayerState state = playerState(player);
if (RoleArenaPolicy.isEligible(stateManager.game(), role, state)) {
player.sendMessage(message);
}
}
}
private void warnIfOpenWithoutLocation(Optional<ArenaRole> openRole) {
if (openRole.isEmpty()) {
warnedMissingRole = null;
return;
}
ArenaRole role = openRole.orElseThrow();
if (role == warnedMissingRole) {
return;
}
warnedMissingRole = role;
server.getConsoleSender().sendMessage(ChatColor.RED
+ "The " + readable(role)
+ " role is vacant, but no valid arena location is configured. "
+ "Use /tyrantadmin arena set.");
for (Player player : server.getOnlinePlayers()) {
if (player.hasPermission("spigottyrant.admin")) {
player.sendMessage(ChatColor.RED
+ "The shared role arena location is not configured.");
}
}
}
private String vacancyMessage(ArenaLocation arena, ArenaRole role) {
return ChatColor.YELLOW + "The " + readable(role)
+ " position is open! Challenge the boss in " + arena.display() + ".";
}
private void removeOtherMobs(World world, ArenaLocation arena) {
Location center = toBukkit(world, arena);
double radius = settings.vigilanteArenaRadiusBlocks();
for (Entity entity : world.getNearbyEntities(center, radius, world.getMaxHeight(), radius)) {
if (entity instanceof Mob && !entity.getUniqueId().equals(bossId)
&& contains(arena, entity.getLocation())) {
entity.remove();
}
}
}
private void drawBoundary(World world, ArenaLocation arena, ArenaRole role) {
double radius = settings.vigilanteArenaRadiusBlocks();
Color color = boundaryColor(role);
Particle.DustOptions dust = color == null
? null : new Particle.DustOptions(color, 1.25F);
int[] heightOffsets = boundaryHeightOffsets(
barrier.active(), settings.arenaBarrierHeightBlocks()
);
for (int heightOffset : heightOffsets) {
for (int index = 0; index < 48; index++) {
double angle = 2.0 * Math.PI * index / 48.0;
Location point = new Location(
world,
arena.x() + Math.cos(angle) * radius,
arena.y() + heightOffset,
arena.z() + Math.sin(angle) * radius
);
if (dust == null) {
world.spawnParticle(
Particle.END_ROD, point, 1, 0.0, 0.0, 0.0, 0.0
);
} else {
world.spawnParticle(
Particle.DUST, point, 1, 0.0, 0.0, 0.0, 0.0, dust
);
}
}
}
}
private boolean protects(Location location) {
Optional<ArenaLocation> configured = locations.location();
World world = location.getWorld();
return configured.isPresent() && world != null
&& ArenaBuildProtection.isProtected(
barrier.active(), configured.orElseThrow(),
settings.vigilanteArenaRadiusBlocks(), world.getName(),
location.getX(), location.getY(), location.getZ()
);
}
private boolean contains(ArenaLocation arena, Location location) {
World world = location.getWorld();
return world != null && ArenaGeometry.contains(
arena, settings.vigilanteArenaRadiusBlocks(), world.getName(),
location.getX(), location.getY(), location.getZ()
);
}
private Location outside(ArenaLocation arena, Location attempted) {
ArenaPoint point = ArenaGeometry.outside(
arena, settings.vigilanteArenaRadiusBlocks(), attempted.getX(),
attempted.getY(), attempted.getZ()
);
World world = server.getWorld(arena.worldName());
if (world == null) {
return attempted;
}
int x = (int) Math.floor(point.x());
int z = (int) Math.floor(point.z());
int preferredY = (int) Math.floor(point.y());
for (int offset = 0; offset <= 8; offset++) {
int[] candidates = offset == 0
? new int[] {preferredY} : new int[] {preferredY + offset, preferredY - offset};
for (int y : candidates) {
if (y > world.getMinHeight() && y + 1 < world.getMaxHeight()
&& world.getBlockAt(x, y, z).isPassable()
&& world.getBlockAt(x, y + 1, z).isPassable()
&& !world.getBlockAt(x, y - 1, z).isPassable()) {
return new Location(world, x + 0.5, y, z + 0.5,
attempted.getYaw(), attempted.getPitch());
}
}
}
int surfaceY = Math.min(
world.getMaxHeight() - 2,
Math.max(world.getMinHeight() + 1, world.getHighestBlockYAt(x, z) + 1)
);
return new Location(world, x + 0.5, surfaceY, z + 0.5,
attempted.getYaw(), attempted.getPitch());
}
private PlayerState playerState(Player player) {
PlayerState existing = stateManager.players().get(player.getUniqueId());
return existing == null
? stateManager.player(player.getUniqueId(), player.getName()) : existing;
}
private static Location toBukkit(World world, ArenaLocation arena) {
return new Location(
world, arena.x(), arena.y(), arena.z(), arena.yaw(), arena.pitch()
);
}
private static Player attackingPlayer(Entity damager) {
if (damager instanceof Player player) {
return player;
}
if (damager instanceof Projectile projectile
&& projectile.getShooter() instanceof Player player) {
return player;
}
return null;
}
private static void setAttribute(Zombie zombie, Attribute attribute, double value) {
if (zombie.getAttribute(attribute) != null) {
zombie.getAttribute(attribute).setBaseValue(value);
}
}
static int[] boundaryHeightOffsets(boolean fightActive, int wallHeight) {
if (!fightActive) {
return new int[] {1};
}
int count = (wallHeight - 2) / 3 + 1;
int[] offsets = new int[count];
for (int index = 0; index < count; index++) {
offsets[index] = 1 + index * 3;
}
return offsets;
}
static Color boundaryColor(ArenaRole role) {
if (role == null) {
return null;
}
return role == ArenaRole.TYRANT ? Color.RED : Color.BLUE;
}
private static String readable(ArenaRole role) {
return role == ArenaRole.TYRANT ? "Tyrant" : "Vigilante";
}
private static boolean movedHorizontally(Location from, Location to) {
return from.getWorld() != to.getWorld()
|| from.getX() != to.getX() || from.getZ() != to.getZ();
}
}
@@ -0,0 +1,31 @@
package games.dmg.spigottyrant;
import java.util.Optional;
public final class RoleArenaPolicy {
private RoleArenaPolicy() {
}
public static Optional<ArenaRole> openRole(GameState game) {
if (game.lifecycle() != GameLifecycle.RUNNING) {
return Optional.empty();
}
if (game.tyrantId().isEmpty()) {
return game.pendingTyrant().isEmpty()
? Optional.of(ArenaRole.TYRANT) : Optional.empty();
}
return game.vigilanteId().isEmpty()
? Optional.of(ArenaRole.VIGILANTE) : Optional.empty();
}
public static boolean isEligible(
GameState game,
ArenaRole role,
PlayerState player
) {
return player != null
&& openRole(game).filter(role::equals).isPresent()
&& player.optedOutUntil().isEmpty()
&& game.tyrantId().filter(player.playerId()::equals).isEmpty();
}
}
@@ -1,47 +0,0 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
public final class RoleCandidateSelector {
public Optional<UUID> select(
Map<UUID, PlayerState> players,
Set<UUID> excluded,
Optional<UUID> previousHolder,
Instant now,
Duration activityWindow,
RandomGenerator random
) {
Instant cutoff = now.minus(activityWindow);
List<UUID> eligible = new ArrayList<>();
for (PlayerState player : players.values()) {
if (excluded.contains(player.playerId())) {
continue;
}
if (player.lastLogin().isEmpty()
|| player.lastLogin().orElseThrow().isBefore(cutoff)) {
continue;
}
if (player.optedOutUntil().filter(deadline -> deadline.isAfter(now)).isPresent()) {
continue;
}
eligible.add(player.playerId());
}
if (eligible.size() > 1) {
previousHolder.ifPresent(eligible::remove);
}
if (eligible.isEmpty()) {
return Optional.empty();
}
eligible.sort(Comparator.comparing(UUID::toString));
return Optional.of(eligible.get(random.nextInt(eligible.size())));
}
}
@@ -0,0 +1,6 @@
package games.dmg.spigottyrant;
public enum RoleControl {
TYRANT,
VIGILANTE
}
@@ -0,0 +1,146 @@
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.Action;
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.inventory.InventoryDragEvent;
import org.bukkit.event.inventory.InventoryMoveItemEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.inventory.ItemStack;
public final class RoleControlItemListener implements Listener {
private final TyrantStateManager stateManager;
private final RoleControlItemService items;
private final TyrantControlPanel tyrantPanel;
private final VigilanteControlPanel vigilantePanel;
public RoleControlItemListener(
TyrantStateManager stateManager,
RoleControlItemService items,
TyrantControlPanel tyrantPanel,
VigilanteControlPanel vigilantePanel
) {
this.stateManager = stateManager;
this.items = items;
this.tyrantPanel = tyrantPanel;
this.vigilantePanel = vigilantePanel;
}
@EventHandler
public void onInteract(PlayerInteractEvent event) {
ItemStack item = event.getItem();
if (!items.isBoundRoleControlItem(item)
|| event.getAction() != Action.RIGHT_CLICK_AIR
&& event.getAction() != Action.RIGHT_CLICK_BLOCK) {
return;
}
event.setCancelled(true);
Player player = event.getPlayer();
GameState game = stateManager.game();
java.util.Optional<RoleControl> required = RoleControlItemPolicy.requiredRole(
game, player.getUniqueId()
);
boolean valid = items.owner(item).filter(player.getUniqueId()::equals).isPresent()
&& items.role(item).equals(required)
&& game.lifecycle() == GameLifecycle.RUNNING;
if (!valid) {
items.removeInvalid(player, game);
player.sendMessage(org.bukkit.ChatColor.RED
+ "That role control item is no longer valid.");
return;
}
if (required.orElseThrow() == RoleControl.TYRANT) {
tyrantPanel.open(player);
} else {
vigilantePanel.open(player);
}
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
items.removeInvalid(event.getPlayer(), stateManager.game());
items.recover(event.getPlayer(), stateManager.game());
}
@EventHandler
public void onDrop(PlayerDropItemEvent event) {
if (items.isBoundRoleControlItem(event.getItemDrop().getItemStack())) {
event.getItemDrop().remove();
}
}
@EventHandler
public void onPickup(EntityPickupItemEvent event) {
if (items.isBoundRoleControlItem(event.getItem().getItemStack())) {
event.setCancelled(true);
event.getItem().remove();
}
}
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (items.isBoundRoleControlItem(event.getCurrentItem())
|| items.isBoundRoleControlItem(event.getCursor())) {
if (!(event.getWhoClicked() instanceof Player player)
|| event.isShiftClick()
|| event.getClickedInventory() == null
|| !event.getClickedInventory().equals(player.getInventory())) {
event.setCancelled(true);
}
}
}
@EventHandler
public void onInventoryDrag(InventoryDragEvent event) {
if (items.isBoundRoleControlItem(event.getOldCursor())
|| event.getNewItems().values().stream()
.anyMatch(items::isBoundRoleControlItem)) {
event.setCancelled(true);
}
}
@EventHandler
public void onInventoryMove(InventoryMoveItemEvent event) {
if (items.isBoundRoleControlItem(event.getItem())) {
event.setCancelled(true);
}
}
@EventHandler
public void onPlace(BlockPlaceEvent event) {
if (items.isBoundRoleControlItem(event.getItemInHand())) {
event.setCancelled(true);
}
}
@EventHandler
public void onDispense(BlockDispenseEvent event) {
if (items.isBoundRoleControlItem(event.getItem())) {
event.setCancelled(true);
}
}
@EventHandler
public void onFrame(PlayerInteractEntityEvent event) {
if (event.getRightClicked() instanceof ItemFrame
&& items.isBoundRoleControlItem(event.getPlayer().getInventory()
.getItem(event.getHand()))) {
event.setCancelled(true);
}
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
event.getDrops().removeIf(items::isBoundRoleControlItem);
}
}
@@ -0,0 +1,19 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
public final class RoleControlItemPolicy {
private RoleControlItemPolicy() {
}
public static Optional<RoleControl> requiredRole(GameState game, UUID playerId) {
if (game.tyrantId().filter(playerId::equals).isPresent()) {
return Optional.of(RoleControl.TYRANT);
}
if (game.vigilanteId().filter(playerId::equals).isPresent()) {
return Optional.of(RoleControl.VIGILANTE);
}
return Optional.empty();
}
}
@@ -0,0 +1,31 @@
package games.dmg.spigottyrant;
import org.bukkit.Server;
import org.bukkit.entity.Player;
public final class RoleControlItemRefreshTask implements Runnable {
private final TyrantStateManager stateManager;
private final RoleControlItemService items;
private final Server server;
public RoleControlItemRefreshTask(
TyrantStateManager stateManager,
RoleControlItemService items,
Server server
) {
this.stateManager = stateManager;
this.items = items;
this.server = server;
}
@Override
public void run() {
GameState game = stateManager.game();
for (Player player : server.getOnlinePlayers()) {
items.removeInvalid(player, game);
if (!player.isDead()) {
items.recover(player, game);
}
}
}
}
@@ -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 RoleControlItemService {
void recover(Player player, GameState game);
boolean isBoundRoleControlItem(ItemStack item);
Optional<RoleControl> role(ItemStack item);
Optional<UUID> owner(ItemStack item);
void removeInvalid(Player player, GameState game);
void removeAll(Player player);
}

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