Compare commits

..
7 Commits
Author SHA1 Message Date
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
86 changed files with 2817 additions and 539 deletions
+11 -7
View File
@@ -8,28 +8,32 @@ description: Defines lifecycle, succession, selection, and pause behavior for a
## 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.
The game begins unstarted. An administrator starts it by assigning the first Tyrant; the administrator may explicitly assign the first Vigilante or leave that role vacant for the blue arena challenge. 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.
When a player directly kills the Tyrant, that killer becomes the new Tyrant immediately. When no player is credited with the death, or when the Tyrant becomes inactive or relinquishes, the reign ends and the Tyrant role remains vacant for the shared arena challenge. The former Tyrant may compete again under the same rules as other opted-in participants.
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.
Every ended Tyrant reign clears all role-side assignments and resets reign progression. The shared arena presents the Tyrant challenge first with a red particle boundary. Its winner becomes Tyrant at level zero with one starting unlock choice; only then may a vacant Vigilante role open the blue challenge at the same location.
## 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.
Every Vigilante death ends that assignment regardless of cause. Vigilante inactivity and relinquishment also end the assignment, and every new Tyrant reign clears the previous assignment. Followers are cleared rather than transferred.
A Tyrant earns progression from the death only when the Tyrant personally killed the Vigilante.
While the game is running with an active Tyrant, no open Tyrant challenge, and no Vigilante, the administrator-configured arena opens its Vigilante challenge. One eligible opted-in non-Tyrant player at a time may challenge the arena boss. Defeating it assigns that player as Vigilante. Leaving, disconnecting, or dying resets the fight without preventing an immediate retry. Without an active Tyrant or configured arena, the role remains vacant and no boss exists.
A Tyrant earns progression from a Vigilante 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.
New random and pending role selections are not created. Legacy pending Tyrant or Vigilante selections are cleared into the applicable arena vacancy without granting a role. A Tyrant vacancy has priority, so the two role challenges cannot run concurrently.
## 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)
- [Handle inactivity and arena vacancies](../user-stories/us-010-handle-inactivity-and-selection.md)
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md)
- [Claim the Tyrant role in the arena](../user-stories/us-020-claim-tyrant-in-arena.md)
+4 -1
View File
@@ -6,7 +6,7 @@ description: Defines the durable state and timekeeping needed to restore a Tyran
# 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.
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, the current Tyrant reign and its claimed legacy armor slots, and the administrator-configured Vigilante arena center. Active Tyrant or Vigilante challenge bosses and challenger entities are transient and are reconstructed or reset from authoritative role and shared-arena state after restart.
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.
@@ -17,3 +17,6 @@ Elapsed-time rules use UTC instants. Paused time is excluded from timers. Saves
- [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)
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md)
- [Claim the Tyrant role in the arena](../user-stories/us-020-claim-tyrant-in-arena.md)
- [Claim Tyrant legacy armor](../user-stories/us-021-claim-tyrant-legacy-armor.md)
+8 -1
View File
@@ -8,7 +8,7 @@ description: Defines Tyrant-side classes, Vigilante-side membership, progression
## 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.
A running game has at most one Tyrant and at most one Vigilante. A vacant Tyrant role takes priority in the shared arena; after its winner becomes Tyrant, a vacant Vigilante role may use the same arena. 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.
@@ -23,6 +23,10 @@ The Tyrant starts each reign at level zero with one starting unlock choice. Pers
- Permanent Resistance
- Permanent Strength
## Legacy armor
After purchasing all six standard reign unlocks, the Tyrant may spend later choices on one each of four legacy armor slots. These named Netherite rewards are tracked only to prevent duplicate claims during the current reign; the resulting armor remains ordinary transferable equipment and survives role succession in player inventories.
## Proximity
Unless a more specific rule applies, "near the Tyrant" means in the same world and within a configurable 50-block three-dimensional distance.
@@ -36,3 +40,6 @@ Vigilante Follower scaling is capped by default at Strength V and Resistance IV,
- [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)
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md)
- [Claim the Tyrant role in the arena](../user-stories/us-020-claim-tyrant-in-arena.md)
- [Claim Tyrant legacy armor](../user-stories/us-021-claim-tyrant-legacy-armor.md)
+3
View File
@@ -12,6 +12,9 @@ This bundle documents the asymmetric Tyrant game, its role succession, class abi
## Explore
- [User stories](user-stories/index.md)
- [Vigilante arena challenge](user-stories/us-019-claim-vigilante-in-arena.md)
- [Tyrant arena challenge](user-stories/us-020-claim-tyrant-in-arena.md)
- [Tyrant legacy armor](user-stories/us-021-claim-tyrant-legacy-armor.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)
+48
View File
@@ -6,6 +6,54 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log
## 2026-09-04 — Tyrant legacy armor completed
- Added four once-per-reign Netherite legacy armor rewards after all six standard unlocks are purchased, with each successful claim consuming one choice.
- Each named reward identifies its Tyrant in custom lore and carries Protection V and Mending I while remaining ordinary tradable, retainable, and non-recoverable equipment.
- Added command and control-panel selection with confirmation, full-inventory safety, durable claimed-slot state, succession reset, and contextual completion.
- Verified progression, duplicate prevention, item specifications, inventory delivery, persistence, reign reset, UI policy, completion, 134 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`.
## 2026-09-04 — Administrative Tyrant point grant completed
- Added `/tyrantadmin grantpoint` so a permitted administrator can grant the current Tyrant exactly one level and one unspent unlock choice during a running game.
- Grants preserve purchases and unrelated state, persist immediately, report updated progression, reject vacancies and non-running games safely, and appear in contextual command completion.
- Verified progression and command behavior, permission enforcement, completion, 127 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`.
## 2026-09-04 — Vigilante arena victory kept private
- Removed the server-wide Vigilante arena victory announcement that exposed the new Vigilante's identity.
- The winning Vigilante now receives a private confirmation, while the publicly identifiable Tyrant victory announcement remains unchanged.
- Verified private Vigilante and public Tyrant messaging, 121 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`.
## 2026-09-04 — Arena winners safely extracted
- Corrected shared-role arena completion so a successful Tyrant or Vigilante challenger is moved safely outside immediately after assignment and barrier removal.
- Prevented the next challenge's containment enforcement from treating the previous winner as an unauthorized entrant inside the arena.
- Verified both role-victory paths, 121 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`.
## 2026-08-23 — Arena containment and idle boss corrected
- Corrected shared-arena containment with a validated 12-block barrier wall extending below the arena floor and active-fight block placement and breaking protection.
- Kept red or blue role particles visible at multiple wall heights during fights so challengers can see the otherwise invisible barrier from inside.
- Arena bosses now remain AI-disabled at the center until an eligible challenger enters, move normally within the arena during combat, and return healed and idle only on reset or boundary escape.
- Verified wall geometry, safe restoration, boundary visibility policy, build protection, idle/combat boss transitions, configuration validation, compiler warnings, 119 automated tests, and packaging with `./gradlew clean check jar`.
## 2026-08-23 — Shared Tyrant and Vigilante arena succession completed
- Completed US-020 and reopened related succession, inactivity, administration, configuration, and Vigilante-arena stories so non-player Tyrant replacement is earned rather than random.
- Environmental Tyrant death, inactivity, and relinquishment now clear the reign and open the shared one-player Tyrant boss challenge; a directly credited player killer still succeeds immediately.
- The shared arena prioritizes a vacant Tyrant with a red particle circle, then opens a vacant Vigilante challenge with a blue circle after a Tyrant becomes active; neutral particles remain while neither challenge is open.
- Both roles share the configured location, boss balance, barriers, containment, mob exclusion, reset behavior, notifications, and restart protections, while an active Tyrant remains unable to enter.
- Removed new random and pending role selection, retained safe migration of legacy pending state and the legacy maintenance-interval configuration key, and verified 115 automated tests plus `./gradlew clean check jar`.
## 2026-08-23 — Vigilante arena succession completed
- Completed US-019 and reopened related administration, Vigilante, inactivity, participation, persistence, control-panel, and completion stories to replace automatic Vigilante selection with an earned arena challenge.
- Vigilante vacancies caused by death, inactivity, relinquishment, initial startup, or a new Tyrant reign now open a configured one-player enhanced-zombie fight only while a Tyrant is active.
- Added a persistent administrator-set arena center, continuous particle boundary, temporary safe `BARRIER` wall, entrant and respawn containment, mob exclusion, sunlight-safe boss behavior, full fight reset, and boss-victory assignment.
- Opted-in players receive vacancy coordinates when the challenge opens or they join; opted-out players are excluded from notices and entry.
- Verified arena policy, geometry, succession, configuration persistence and validation, barrier restoration, command handling and completion, compiler warnings, 109 automated tests, and packaging with `./gradlew clean check jar`.
## 2026-08-21 — Contextual command completion completed
- Completed US-018 with registered completers for `/tyrant`, `/vigilante`, and `/tyrantadmin`, replacing Bukkit's unconditional online-player fallback.
+4 -1
View File
@@ -9,7 +9,7 @@
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)
10. [US-010: Handle inactivity and arena vacancies](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)
@@ -18,3 +18,6 @@
16. [US-016: Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
17. [US-017: Use bound role control items](us-017-use-bound-role-control-items.md)
18. [US-018: Complete Tyrant commands contextually](us-018-complete-commands-contextually.md)
19. [US-019: Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
20. [US-020: Claim the Tyrant role in the arena](us-020-claim-tyrant-in-arena.md)
21. [US-021: Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
@@ -12,15 +12,19 @@ As a **server operator**, I want to start, pause, resume, and inspect the game s
## 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] The administrator may assign the first Vigilante or leave the role vacant for the configured Vigilante arena challenge.
- [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] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, legacy pending selections, and the configured shared role arena location.
- [x] `/tyrantadmin grantpoint` requires administrative permission and grants the current Tyrant exactly one level and one unspent unlock choice.
- [x] A successful administrative point grant persists immediately and confirms the updated level and choice count to the administrator.
- [x] If the game is not running or has no current Tyrant, an administrative point grant changes nothing and reports a clear error.
- [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)
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
@@ -12,11 +12,11 @@ As a **player**, I want defeating the Tyrant to transfer power predictably so th
## Acceptance criteria
- [x] A player directly credited with killing the Tyrant becomes the new Tyrant.
- [x] A Tyrant death without a player killer selects an eligible opted-in player active within the preceding 24 hours at random.
- [x] The former Tyrant is excluded from random succession when another eligible candidate exists.
- [x] A Tyrant death without a player killer leaves the role vacant and opens the shared Tyrant arena challenge.
- [x] The former Tyrant may compete again under the same arena eligibility and reset rules as every other opted-in participant.
- [x] The former Tyrant's level, unlocks, unspent choices, and Tyrant ability cooldowns reset when the reign ends.
- [x] Every Assassin, Fixer, Tamer, Vigilante, and Follower assignment is cleared when the Tyrant dies.
- [x] A new Vigilante is selected under the normal candidate rules after succession.
- [x] After Tyrant succession, a vacant Vigilante role opens the shared arena's Vigilante challenge.
- [x] The new Tyrant receives exactly one starting unlock choice.
- [x] The Tyrant's displayed name is red and the Tyrant has a red glowing outline while the game is running.
- [x] Succession cannot assign the same player as both Tyrant and Vigilante.
@@ -25,3 +25,4 @@ As a **player**, I want defeating the Tyrant to transfer power predictably so th
- [Tyrant game state machine](../concepts/game-state-machine.md)
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
- [Claim the Tyrant role in the arena](us-020-claim-tyrant-in-arena.md)
@@ -12,6 +12,9 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
## Acceptance criteria
- [x] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante.
- [x] An administrator can grant the current Tyrant one level and one unlock choice without changing purchases or unrelated game state.
- [x] Each successful administrative grant adds exactly one level and one choice, including across repeated invocations.
- [x] After purchasing all six standard unlocks, the Tyrant may spend later choices on one of each legacy armor slot during the reign.
- [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.
@@ -29,3 +32,4 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
- [Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
@@ -11,7 +11,7 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
## Acceptance criteria
- [x] Exactly one active Vigilante exists while the game is running whenever an eligible player is available.
- [x] A running game has at most one active Vigilante; a vacancy remains open until an eligible player defeats the arena boss.
- [x] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
- [x] The Vigilante can dismiss Followers and Followers can leave voluntarily.
- [x] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks.
@@ -19,17 +19,18 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
- [x] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
- [x] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment.
- [x] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV.
- [x] Every Vigilante death ends the assignment and triggers random replacement regardless of cause.
- [x] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
- [x] Every Vigilante death ends the assignment and opens the one-player arena challenge while a Tyrant is active.
- [x] The deceased Vigilante may immediately compete again under the same arena eligibility and reset rules as every other participant.
- [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
- [x] The Tyrant is never eligible to be selected as Vigilante.
- [x] The Vigilante can review, invite, and dismiss Followers through the Vigilante control panel.
- [x] Control-panel and command-based Follower actions enforce identical eligibility and membership rules.
- [x] The control panel communicates the current nearby-Follower combat contribution and configured effect caps.
- [x] Relinquishing through the control panel follows normal Vigilante succession and Follower cleanup.
- [x] Relinquishing through the control panel clears Followers and opens the Vigilante arena challenge.
## Related
- [Role and class model](../concepts/role-and-class-model.md)
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
- [Handle inactivity and arena vacancies](us-010-handle-inactivity-and-selection.md)
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
@@ -1,28 +1,28 @@
---
type: User Story
title: "US-010: Handle inactivity and pending selections"
description: Replace absent or unconfirmed role holders using recently active eligible players.
title: "US-010: Handle inactivity and arena vacancies"
description: Replace inactive role holders through the shared arena without granting legacy pending selections.
status: done
---
# US-010: Handle inactivity and pending selections
# US-010: Handle inactivity and arena vacancies
As a **participant**, I want inactive central roles to be replaced fairly so that the game does not stall around absent players.
## Acceptance criteria
- [x] A Tyrant or Vigilante who has not logged in for 48 unpaused hours loses the position.
- [x] Random candidates are drawn from eligible opted-in players who logged in during the preceding 24 hours.
- [x] Tyrant and Vigilante vacancies use their shared arena challenges rather than random candidate selection.
- [x] The Tyrant is always excluded from Vigilante selection.
- [x] A previous role holder is excluded when another eligible candidate exists and may be reused only as a fallback.
- [x] Tyrant inactivity ends the reign, clears all assignments, and invokes normal random Tyrant succession followed by Vigilante selection.
- [x] Vigilante inactivity clears Followers and rerolls only the Vigilante position.
- [x] An offline randomly selected candidate must log in within 24 unpaused hours to confirm the assignment.
- [x] An unconfirmed selection expires and rerolls automatically under the same eligibility rules.
- [x] Selection remains vacant and retries periodically when no valid candidate exists.
- [x] Previous role holders may compete for their former role under the same arena eligibility and reset rules as other opted-in participants.
- [x] Tyrant inactivity ends the reign, clears all assignments, and opens the Tyrant arena challenge; the Vigilante challenge can open only after a Tyrant becomes active.
- [x] Vigilante inactivity clears Followers and opens the Vigilante arena challenge without changing the Tyrant.
- [x] Legacy pending role selections are cleared safely into the applicable arena vacancy without granting a role.
- [x] 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)
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
- [Claim the Tyrant role in the arena](us-020-claim-tyrant-in-arena.md)
@@ -15,6 +15,7 @@ As a **player**, I want to understand my status and control my eligibility so th
- [x] Eligible players are invited to opt out of Tyrant events without implying that an event is currently running.
- [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
- [x] An opted-out player is told when they may opt back in.
- [x] Opted-in players are told the Vigilante arena world and coordinates when its role opens and when they join during an open challenge; opted-out players receive no such notice.
- [x] An unassigned player can opt out through a command.
- [x] Opting out removes an ordinary class or Follower assignment immediately; the Tyrant and Vigilante must relinquish through a safe role command before opting out.
- [x] Opted-out players cannot be randomly selected, recruited, or assigned a class.
@@ -33,5 +34,6 @@ As a **player**, I want to understand my status and control my eligibility so th
## Related
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
- [Handle inactivity and arena vacancies](us-010-handle-inactivity-and-selection.md)
- [Persistence model](../concepts/persistence-model.md)
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
@@ -11,10 +11,10 @@ As a **server operator**, I want configurable and durable game behavior so that
## 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] Configuration covers ranges, durations, cooldowns, inactivity and maintenance intervals, effect levels and caps, mob restrictions, item materials and names, messages, timer behavior, shared-role-arena boss balance, and a validated barrier wall height defaulting to 12 blocks.
- [x] Defaults match the approved user stories, including a 50-block Tyrant range and Follower range, seven-day opt-out, 48-hour inactivity, and one-minute role maintenance.
- [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] UUID-keyed state stores lifecycle, current and pending roles, assignments, login history, participation, reign progression, purchases, choices, claimed legacy armor slots, cooldowns, paused time, item delivery, and captured mobs; the administrator-set arena center is durable across restarts.
- [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.
@@ -25,3 +25,6 @@ As a **server operator**, I want configurable and durable game behavior so that
- [Persistence model](../concepts/persistence-model.md)
- [Build, test, and release the plugin](us-013-build-test-and-release.md)
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
- [Claim the Tyrant role in the arena](us-020-claim-tyrant-in-arena.md)
- [Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
@@ -22,6 +22,7 @@ As the **Tyrant**, I want a control panel for my reign so that I can inspect pro
- [x] Purchased, available, unavailable, and cooldown-gated actions are visually distinguishable and explain why they cannot currently be used.
- [x] The Tyrant can navigate between overview, unlock, class-assignment, and ability screens and can close the panel without changing gameplay state.
- [x] The Tyrant can review and purchase an available unlock after confirming the selected unlock and its choice cost.
- [x] After all standard unlocks are purchased, the Tyrant can select and confirm an unclaimed legacy armor slot through the control panel.
- [x] The Tyrant can select a purchased class and assign or reassign it to an eligible online player.
- [x] The class player selector excludes the Tyrant and clearly identifies players who are opted out or otherwise ineligible.
- [x] Class reassignment identifies the current holder and requires confirmation before replacing that holder.
@@ -44,3 +45,4 @@ Automated tests verify command entry points, active-Tyrant authorization, author
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
- [Inform players and manage participation](us-011-inform-and-manage-participation.md)
- [Use bound role control items](us-017-use-bound-role-control-items.md)
- [Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
@@ -29,7 +29,7 @@ As the **Vigilante**, I want a control panel for managing Followers so that I ca
- [x] The Follower-management screen identifies current Followers and requires confirmation before dismissal.
- [x] Successful invitations and dismissals refresh the control panel from authoritative current state and notify affected players.
- [x] The role-control screen allows the Vigilante to relinquish the role only after explicit confirmation.
- [x] Relinquishing through the control panel uses normal Vigilante replacement and Follower-cleanup rules.
- [x] Relinquishing through the control panel clears Followers, opens the Vigilante arena challenge, and communicates that outcome accurately.
- [x] Every action revalidates the running lifecycle and active Vigilante before changing state.
- [x] Stale or repeated control-panel interactions cannot invite an ineligible player, duplicate an invitation, or dismiss the wrong Follower.
- [x] Existing `/vigilante invite`, `/vigilante accept`, `/vigilante dismiss`, `/vigilante leave`, and `/tyrant relinquish confirm` commands remain available.
@@ -42,7 +42,8 @@ Automated tests verify default and explicit command entry points, active-Vigilan
## Related
- [Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
- [Handle inactivity and arena vacancies](us-010-handle-inactivity-and-selection.md)
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
- [Role and class model](../concepts/role-and-class-model.md)
- [Use bound role control items](us-017-use-bound-role-control-items.md)
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
@@ -13,6 +13,7 @@ As a **player or administrator**, I want contextual command suggestions so that
- [x] `/tyrant` suggests its available subcommands instead of arbitrary player names.
- [x] `/tyrant buy` suggests Tyrant unlock names.
- [x] `/tyrant armor` suggests `helmet`, `chestplate`, `leggings`, and `boots` and accepts no further arguments.
- [x] `/tyrant assign` suggests `assassin`, `fixer`, and `tamer`, followed by eligible online players.
- [x] `/tyrant relinquish` suggests `confirm`.
- [x] `/vigilante` suggests its available subcommands.
@@ -21,6 +22,8 @@ As a **player or administrator**, I want contextual command suggestions so that
- [x] `/tyrantadmin` suggestions are visible only to senders with `spigottyrant.admin`.
- [x] `/tyrantadmin start` suggests online Tyrant and Vigilante candidates.
- [x] `/tyrantadmin reset` suggests `confirm`.
- [x] `/tyrantadmin arena` suggests `set` and `status`.
- [x] `/tyrantadmin` suggests `grantpoint`, which accepts no additional arguments.
- [x] Suggestions are filtered case-insensitively by the partially typed argument.
- [x] Suggestions never include syntactically invalid options for the current argument position.
- [x] Console completion works for administrative commands without exposing player-only commands as executable console actions.
@@ -28,7 +31,7 @@ As a **player or administrator**, I want contextual command suggestions so that
## Validation
Automated tests verify root syntax, unlocks, classes, confirmations, eligible recruits, current Followers, permission gating, online administrative candidates, argument positions, and case-insensitive prefix filtering. The complete `./gradlew clean check jar` lifecycle passes.
Automated tests verify root syntax, unlocks, legacy armor pieces, classes, confirmations, eligible recruits, current Followers, permission gating, online administrative candidates, the argument-free `grantpoint` command, argument positions, and case-insensitive prefix filtering. The complete `./gradlew clean check jar` lifecycle passes.
## Related
@@ -36,4 +39,6 @@ Automated tests verify root syntax, unlocks, classes, confirmations, eligible re
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
- [Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
- [Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
@@ -0,0 +1,41 @@
---
type: User Story
title: "US-019: Claim the Vigilante role in the arena"
description: Let one eligible player at a time defeat a protected arena boss to claim a vacant Vigilante role.
status: done
---
# US-019: Claim the Vigilante role in the arena
As an **opted-in participant**, I want a visible one-player boss challenge for a vacant Vigilante role so that succession is earned through an open contest.
## Acceptance criteria
- [x] An administrator can set and inspect a durable arena center, and configuration validates the arena radius and boss strength.
- [x] The configured arena has a continuously visible neutral boundary when no challenge is open and a blue particle boundary while the Vigilante challenge is open.
- [x] A vacant Vigilante role opens the challenge after death, inactivity, relinquishment, or a new Tyrant reign, but only while a Tyrant is active, no Tyrant challenge is open, and the game is running.
- [x] The challenge boss defaults to an armored, 80-health enhanced zombie, does not burn in sunlight, and remains within a default 10-block-radius arena.
- [x] All online opted-in players are told when the role opens and receive the arena world and coordinates; opted-in players receive the same notice when they log in while it remains open, and opted-out players do not.
- [x] Exactly one eligible opted-in non-Tyrant player may enter as challenger, including when entry is attempted by walking or teleportation.
- [x] Starting a fight builds temporary actual `BARRIER` blocks from one block below the arena floor through a default 12-block wall height, without permanently changing existing blocks.
- [x] Role-colored boundary particles remain visible from inside and outside an active fight at multiple wall heights.
- [x] Any non-challenger who enters or respawns inside the arena is moved safely outside it.
- [x] Existing mobs in the arena and mobs that spawn or enter it are removed while preserving the arena boss.
- [x] With no challenger, the boss remains AI-disabled at the arena center; entry enables its AI and target, while reset restores full health, center position, no target, and disabled AI.
- [x] The boss attacks only the active challenger, and only that challenger can damage the boss.
- [x] Challenger death, disconnect, or leaving by any mechanism ends the fight and resets the boss to full health at its spawn; death counts as leaving and the same player may retry immediately.
- [x] Block placement and breaking are denied inside the arena during an active fight so players cannot build over or modify the boundary.
- [x] Killing the boss assigns the challenger as Vigilante, removes the boss and barriers, and restores the passive particle boundary.
- [x] After winning, the new Vigilante is moved safely outside the arena before normal arena enforcement resumes.
- [x] The Vigilante winner receives a private confirmation, and no server-wide victory message names or reveals the new Vigilante.
- [x] Pausing, resetting, restarting, reloading, and repeated or concurrent events cannot duplicate the boss, barriers, challenger, or Vigilante assignment.
- [x] If the arena is not configured when a challenge should open, the Vigilante remains vacant and administrators receive a clear warning.
## Related
- [Start, pause, and administer the game](us-001-start-pause-and-administer.md)
- [Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
- [Handle inactivity and arena vacancies](us-010-handle-inactivity-and-selection.md)
- [Configure and persist game state](us-012-configure-and-persist-state.md)
- [Tyrant game state machine](../concepts/game-state-machine.md)
- [Claim the Tyrant role in the arena](us-020-claim-tyrant-in-arena.md)
@@ -0,0 +1,31 @@
---
type: User Story
title: "US-020: Claim the Tyrant role in the arena"
description: Use the shared one-player arena to replace a Tyrant who was not defeated by another player.
status: done
---
# US-020: Claim the Tyrant role in the arena
As an **opted-in participant**, I want to earn a vacant Tyrant role through the shared boss arena so that environmental death, inactivity, and relinquishment do not assign power randomly.
## Acceptance criteria
- [x] A player directly credited with killing the Tyrant still becomes the new Tyrant immediately without an arena fight.
- [x] Tyrant death without a player killer, Tyrant inactivity, and Tyrant relinquishment end the reign, clear all role-side assignments, and open the shared arena with no active or pending Tyrant.
- [x] The shared arena opens only one role challenge at a time: a vacant Tyrant takes priority, and the Vigilante challenge can open only after a Tyrant becomes active.
- [x] The passive arena boundary uses red particles for an open Tyrant challenge, blue particles for an open Vigilante challenge, and a neutral marker while neither role challenge is open.
- [x] The Tyrant challenge uses the existing configured arena, boss balance, tall visible one-challenger barrier, block-change protection, idle-center boss behavior, containment, reset, notification, and missing-location rules.
- [x] An eligible opted-in participant, including the former Tyrant, may challenge for the vacant Tyrant role; an active Tyrant cannot enter any arena fight.
- [x] Defeating the Tyrant challenge boss assigns the challenger as Tyrant with level zero, no purchases, and exactly one starting unlock choice.
- [x] After winning, the new Tyrant is moved safely outside before any subsequent Vigilante challenge opens.
- [x] Completing the Tyrant challenge immediately permits the blue Vigilante challenge to open at the same location when the Vigilante role is vacant.
- [x] Legacy pending Tyrant replacement state is cleared into an arena vacancy without granting a role.
- [x] Pausing, resetting, restarting, reloading, and repeated or concurrent events cannot duplicate either role boss, challenger, barrier, or assignment.
## Related
- [Transfer and reset the Tyrant role](us-002-transfer-and-reset-tyrant.md)
- [Handle inactivity and arena vacancies](us-010-handle-inactivity-and-selection.md)
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
- [Tyrant game state machine](../concepts/game-state-machine.md)
@@ -0,0 +1,33 @@
---
type: User Story
title: "US-021: Claim Tyrant legacy armor"
description: Let a fully unlocked Tyrant spend later choices on unique tradable armor rewards.
status: done
---
# US-021: Claim Tyrant legacy armor
As a **fully unlocked Tyrant**, I want to turn later progression choices into named armor so that continued victories create lasting trophies with ordinary player value.
## Acceptance criteria
- [x] Once all six standard reign unlocks are purchased, the active Tyrant may spend each additional unspent choice on one legacy armor piece.
- [x] Available rewards are a Netherite Helmet, Netherite Chestplate, Netherite Leggings, and Netherite Boots.
- [x] Each piece has Protection V and Mending I, a display name in the form `Tyrant's <player name> <piece>`, and lore identifying the named Tyrant and reign reward.
- [x] Each armor slot may be claimed only once during a reign, limiting a Tyrant to four legacy armor rewards per reign.
- [x] A successful claim consumes exactly one unspent choice and records the claimed slot durably.
- [x] Armor is delivered to normal inventory rather than auto-equipped; insufficient inventory space does not consume a choice or record a claim.
- [x] The armor is ordinary transferable equipment: it may be worn, repaired, enchanted, dropped, traded, stored, or retained after the reign ends.
- [x] Lost or transferred armor is not recoverable and does not permit that slot to be claimed again during the same reign.
- [x] Claimed-slot state survives logout and restart, resets when the reign ends, and loads missing legacy state as no claimed pieces.
- [x] Paused games, non-Tyrants, incomplete standard unlocks, exhausted choices, duplicate slots, malformed requests, and stale or repeated interactions cannot create or duplicate armor.
- [x] The Tyrant can select and confirm an available armor piece through the existing control panel, using the same domain and persistence rules as the command path.
- [x] `/tyrant armor <helmet|chestplate|leggings|boots>` remains available as an alternative and supports contextual completion.
## 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)
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
- [Complete Tyrant commands contextually](us-018-complete-commands-contextually.md)
- [Role and class model](../concepts/role-and-class-model.md)
@@ -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,10 @@
package games.dmg.spigottyrant;
public enum ArmorClaimStatus {
CLAIMED,
GAME_NOT_RUNNING,
NOT_TYRANT,
UNLOCKS_INCOMPLETE,
NO_CHOICES,
ALREADY_CLAIMED
}
@@ -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,22 @@
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(), "Armor metadata is unavailable");
meta.setDisplayName(ChatColor.GOLD + spec.displayName());
meta.setLore(spec.lore().stream().map(line -> ChatColor.GRAY + line).toList());
meta.addEnchant(Enchantment.PROTECTION, spec.protectionLevel(), true);
meta.addEnchant(Enchantment.MENDING, spec.mendingLevel(), true);
item.setItemMeta(meta);
return item;
}
}
@@ -52,6 +52,14 @@ public final class BukkitTyrantControlPanelRenderer
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 Armor", List.of(
model.allStandardUnlocksPurchased()
? "Choose a once-per-reign armor reward."
: "Purchase all six standard unlocks first.",
"Claimed: " + (model.claimedArmor().isEmpty()
? "none" : model.claimedArmor())
)));
player.openInventory(inventory);
}
@@ -83,6 +91,10 @@ public final class BukkitTyrantControlPanelRenderer
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()
);
}
}
@@ -93,6 +105,67 @@ public final class BukkitTyrantControlPanelRenderer
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 Armor");
TyrantArmorPiece[] pieces = TyrantArmorPiece.values();
int[] slots = {10, 12, 14, 16};
for (int index = 0; index < pieces.length; index++) {
TyrantArmorPiece piece = pieces[index];
String status = model.claimedArmor().contains(piece) ? "CLAIMED"
: model.armorAvailable(piece) ? "AVAILABLE" : "LOCKED";
inventory.setItem(slots[index], item(
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
ChatColor.GOLD + readable(piece.name()),
List.of("Status: " + status, "Cost: 1 choice")
));
}
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 = {10, 12, 14, 16};
for (int index = 0; index < slots.length; index++) {
if (slot == slots[index] && model(player).armorAvailable(pieces[index])) {
openArmorConfirmation(player, pieces[index]);
return;
}
}
}
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 Armor");
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()),
List.of("Protection V", "Mending I", "Cost: 1 choice")
));
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 armor " + piece.commandName());
openArmor(player);
}
}
@@ -386,7 +459,9 @@ public final class BukkitTyrantControlPanelRenderer
CLASSES,
ABILITIES,
PLAYERS,
ASSIGN_CONFIRMATION
ASSIGN_CONFIRMATION,
ARMOR,
ARMOR_CONFIRMATION
}
private static final class MenuHolder implements InventoryHolder {
@@ -394,6 +469,7 @@ public final class BukkitTyrantControlPanelRenderer
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) {
@@ -405,11 +481,22 @@ public final class BukkitTyrantControlPanelRenderer
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() {
@@ -428,6 +515,10 @@ public final class BukkitTyrantControlPanelRenderer
return targetId;
}
private TyrantArmorPiece armorPiece() {
return armorPiece;
}
@Override
public Inventory getInventory() {
return inventory;
@@ -262,7 +262,7 @@ public final class BukkitVigilanteControlPanelRenderer
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 a replacement selected.",
List.of("Followers will be cleared and the arena will open.",
"Click to review confirmation.")));
inventory.setItem(22, backItem());
player.openInventory(inventory);
@@ -276,7 +276,7 @@ public final class BukkitVigilanteControlPanelRenderer
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 rerolls the Vigilante.")));
List.of("This clears all Followers and opens the arena challenge.")));
inventory.setItem(15, confirmItem());
player.openInventory(inventory);
}
@@ -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()
);
}
@@ -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,
@@ -48,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");
@@ -93,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)),
@@ -227,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()) {
@@ -262,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) {
@@ -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().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())));
}
}
@@ -5,63 +5,31 @@ import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
public final class RoleMaintenanceService {
private final RoleCandidateSelector candidates;
private final TyrantSuccessionService succession;
private final Duration inactivity;
private final Duration activityWindow;
private final Duration pendingTimeout;
private final RandomGenerator random;
public RoleMaintenanceService(
RoleCandidateSelector candidates,
TyrantSuccessionService succession,
Duration inactivity,
Duration activityWindow,
Duration pendingTimeout,
RandomGenerator random
Duration inactivity
) {
this.candidates = candidates;
this.succession = succession;
this.inactivity = inactivity;
this.activityWindow = activityWindow;
this.pendingTimeout = pendingTimeout;
this.random = random;
}
public LifecycleState maintain(
PersistentState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
public LifecycleState maintain(PersistentState state, Instant now) {
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
return new LifecycleState(state.game(), state.players());
}
LifecycleState current = confirmPending(state, onlinePlayerIds, now);
LifecycleState current = clearLegacyPending(state);
if (isInactive(current.game().tyrantId(), current.players(), now)) {
return succession.succeed(
current.asPersistentState(), Optional.empty(), onlinePlayerIds, now
);
return succession.succeed(current.asPersistentState(), Optional.empty());
}
if (isInactive(current.game().vigilanteId(), current.players(), now)) {
current = rerollVigilante(
current.asPersistentState(), current.game().vigilanteId(), onlinePlayerIds, now
);
}
current = rerollExpiredTyrant(current, onlinePlayerIds, now);
current = rerollExpiredVigilante(current, onlinePlayerIds, now);
if (current.game().tyrantId().isEmpty() && current.game().pendingTyrant().isEmpty()) {
current = selectTyrant(current, Optional.empty(), onlinePlayerIds, now);
}
if (current.game().tyrantId().isPresent()
&& current.game().vigilanteId().isEmpty()
&& current.game().pendingVigilante().isEmpty()) {
current = rerollVigilante(
current.asPersistentState(), Optional.empty(), onlinePlayerIds, now
current.asPersistentState(), current.game().vigilanteId()
);
}
return current;
@@ -69,115 +37,22 @@ public final class RoleMaintenanceService {
public LifecycleState rerollVigilante(
PersistentState state,
Optional<UUID> previousVigilante,
Set<UUID> onlinePlayerIds,
Instant now
Optional<UUID> previousVigilante
) {
Map<UUID, PlayerState> players = clearFollowers(state.players(), previousVigilante);
Set<UUID> excluded = state.game().tyrantId().map(Set::of).orElseGet(Set::of);
Optional<UUID> selected = candidates.select(
players, excluded, previousVigilante, now, activityWindow, random
);
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
Optional<PendingSelection> pending = selected
.filter(playerId -> active.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
GameState game = copyRoles(
state.game(), state.game().tyrantId(), active,
state.game().pendingTyrant(), pending
state.game(), state.game().tyrantId(), Optional.empty(),
state.game().pendingTyrant(), Optional.empty()
);
return new LifecycleState(game, players);
}
private LifecycleState confirmPending(
PersistentState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
private static LifecycleState clearLegacyPending(PersistentState state) {
GameState game = state.game();
Optional<UUID> tyrant = game.tyrantId();
Optional<UUID> vigilante = game.vigilanteId();
Optional<PendingSelection> pendingTyrant = game.pendingTyrant();
Optional<PendingSelection> pendingVigilante = game.pendingVigilante();
if (pendingTyrant.isPresent()) {
PendingSelection pending = pendingTyrant.orElseThrow();
if (!now.isAfter(pending.expiresAt())
&& onlinePlayerIds.contains(pending.candidateId())
&& eligibleAtConfirmation(state.players().get(pending.candidateId()), now)
&& vigilante.filter(pending.candidateId()::equals).isEmpty()) {
tyrant = Optional.of(pending.candidateId());
pendingTyrant = Optional.empty();
}
}
if (pendingVigilante.isPresent()) {
PendingSelection pending = pendingVigilante.orElseThrow();
if (!now.isAfter(pending.expiresAt())
&& onlinePlayerIds.contains(pending.candidateId())
&& eligibleAtConfirmation(state.players().get(pending.candidateId()), now)
&& tyrant.filter(pending.candidateId()::equals).isEmpty()) {
vigilante = Optional.of(pending.candidateId());
pendingVigilante = Optional.empty();
}
}
return new LifecycleState(
copyRoles(game, tyrant, vigilante, pendingTyrant, pendingVigilante),
state.players()
);
}
private LifecycleState rerollExpiredTyrant(
LifecycleState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
Optional<PendingSelection> pending = state.game().pendingTyrant();
if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) {
return state;
}
return selectTyrant(
state,
Optional.of(pending.orElseThrow().candidateId()),
onlinePlayerIds,
now
);
}
private LifecycleState selectTyrant(
LifecycleState state,
Optional<UUID> previous,
Set<UUID> onlinePlayerIds,
Instant now
) {
Set<UUID> excluded = state.game().vigilanteId().map(Set::of).orElseGet(Set::of);
Optional<UUID> selected = candidates.select(
state.players(), excluded, previous, now, activityWindow, random
);
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
Optional<PendingSelection> pending = selected
.filter(playerId -> active.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
GameState game = copyRoles(
state.game(), active, state.game().vigilanteId(), pending,
state.game().pendingVigilante()
);
return new LifecycleState(game, state.players());
}
private LifecycleState rerollExpiredVigilante(
LifecycleState state,
Set<UUID> onlinePlayerIds,
Instant now
) {
Optional<PendingSelection> pending = state.game().pendingVigilante();
if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) {
return state;
}
return rerollVigilante(
state.asPersistentState(),
Optional.of(pending.orElseThrow().candidateId()),
onlinePlayerIds,
now
GameState cleared = copyRoles(
game, game.tyrantId(), game.vigilanteId(), Optional.empty(), Optional.empty()
);
return new LifecycleState(cleared, state.players());
}
private boolean isInactive(
@@ -194,10 +69,6 @@ public final class RoleMaintenanceService {
&& !player.lastLogin().orElseThrow().plus(inactivity).isAfter(now);
}
private static boolean eligibleAtConfirmation(PlayerState player, Instant now) {
return player != null && player.optedOutUntil().isEmpty();
}
private static Map<UUID, PlayerState> clearFollowers(
Map<UUID, PlayerState> players,
Optional<UUID> previousVigilante
@@ -228,7 +99,7 @@ public final class RoleMaintenanceService {
return new GameState(
game.lifecycle(), tyrant, vigilante, pendingTyrant, pendingVigilante,
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
game.unspentChoices(), game.purchases()
game.unspentChoices(), game.purchases(), game.claimedArmor()
);
}
}
@@ -1,40 +1,29 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.bukkit.Server;
import org.bukkit.entity.Player;
public final class RoleMaintenanceTask implements Runnable {
private final TyrantStateManager stateManager;
private final RoleMaintenanceService maintenance;
private final TyrantPresentation presentation;
private final Server server;
private final Clock clock;
public RoleMaintenanceTask(
TyrantStateManager stateManager,
RoleMaintenanceService maintenance,
TyrantPresentation presentation,
Server server,
Clock clock
) {
this.stateManager = stateManager;
this.maintenance = maintenance;
this.presentation = presentation;
this.server = server;
this.clock = clock;
}
@Override
public void run() {
PersistentState before = stateManager.snapshot();
Set<UUID> online = server.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(Collectors.toUnmodifiableSet());
LifecycleState after = maintenance.maintain(before, online, clock.instant());
LifecycleState after = maintenance.maintain(before, clock.instant());
if (!after.asPersistentState().equals(before)) {
stateManager.replaceState(after);
stateManager.saveIfDirty();
@@ -31,15 +31,13 @@ public final class RoleRelinquishmentService {
}
if (state.game().tyrantId().filter(playerId::equals).isPresent()) {
return new RelinquishmentResult(
succession.succeed(state, Optional.empty(), onlinePlayerIds, now),
succession.succeed(state, Optional.empty()),
RelinquishmentStatus.RELINQUISHED
);
}
if (state.game().vigilanteId().filter(playerId::equals).isPresent()) {
return new RelinquishmentResult(
maintenance.rerollVigilante(
state, Optional.of(playerId), onlinePlayerIds, now
),
maintenance.rerollVigilante(state, Optional.of(playerId)),
RelinquishmentStatus.RELINQUISHED
);
}
@@ -16,6 +16,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
private FixerEffectController fixerEffects;
private TyrantEffectController tyrantEffects;
private OptOutGlowController optOutGlow;
private RoleArenaController roleArena;
@Override
public void onEnable() {
@@ -33,22 +34,18 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
return;
}
ArenaLocationStore arenaLocations = new BukkitConfigArenaLocationStore(this);
try {
arenaLocations.location();
} catch (IllegalArgumentException exception) {
getLogger().log(Level.SEVERE, "Invalid shared role arena location", exception);
getServer().getPluginManager().disablePlugin(this);
return;
}
Clock clock = Clock.systemUTC();
RoleCandidateSelector candidateSelector = new RoleCandidateSelector();
java.util.random.RandomGenerator random = java.util.random.RandomGenerator.getDefault();
TyrantSuccessionService succession = new TyrantSuccessionService(
candidateSelector,
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
random
);
TyrantSuccessionService succession = new TyrantSuccessionService();
RoleMaintenanceService maintenance = new RoleMaintenanceService(
candidateSelector,
succession,
settings.roleInactivity(),
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
random
succession, settings.roleInactivity()
);
BukkitOnlinePlayerDirectory onlinePlayers = new BukkitOnlinePlayerDirectory();
FollowerService followers = new FollowerService();
@@ -131,10 +128,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
onlinePlayers,
managedEffects,
clock,
candidateSelector,
settings.candidateActivityWindow(),
settings.pendingSelectionTimeout(),
random
arenaLocations
));
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
.setTabCompleter(new TyrantTabCompleter(stateManager, onlinePlayers));
@@ -148,6 +142,10 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new PlayerJoinListener(stateManager, clock, tyrantPresentation),
this
);
roleArena = new RoleArenaController(
stateManager, getServer(), arenaLocations, settings
);
getServer().getPluginManager().registerEvents(roleArena, this);
getServer().getPluginManager().registerEvents(controlPanelRenderer, this);
getServer().getPluginManager().registerEvents(vigilanteControlPanelRenderer, this);
getServer().getPluginManager().registerEvents(
@@ -170,14 +168,14 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
);
getServer().getPluginManager().registerEvents(
new TyrantDeathListener(
stateManager, succession, tyrantPresentation, getServer(), clock
stateManager, succession, tyrantPresentation, getServer()
),
this
);
getServer().getPluginManager().registerEvents(
new VigilanteDeathListener(
stateManager, new TyrantProgressionService(), maintenance,
getServer(), clock
getServer()
),
this
);
@@ -228,12 +226,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
this
);
long maintenanceTicks = Math.max(
1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L)
1L, Math.multiplyExact(settings.roleMaintenanceInterval().toSeconds(), 20L)
);
getServer().getScheduler().runTaskTimer(
this,
new RoleMaintenanceTask(
stateManager, maintenance, tyrantPresentation, getServer(), clock
stateManager, maintenance, tyrantPresentation, clock
),
maintenanceTicks,
maintenanceTicks
@@ -243,6 +241,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, roleArena, 1L, 10L);
getServer().getScheduler().runTaskTimer(
this,
new AbilityItemRefreshTask(
@@ -272,6 +271,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
@Override
public void onDisable() {
if (roleArena != null) {
roleArena.close();
}
if (optOutGlow != null) {
optOutGlow.clearAll();
}
@@ -1,12 +1,8 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
@@ -20,10 +16,8 @@ public final class TyrantAdminCommand implements CommandExecutor {
private final OnlinePlayerDirectory onlinePlayers;
private final ManagedRoleEffects effects;
private final Clock clock;
private final RoleCandidateSelector candidateSelector;
private final Duration activityWindow;
private final Duration pendingTimeout;
private final RandomGenerator random;
private final ArenaLocationStore arenaLocations;
private final TyrantProgressionService progression = new TyrantProgressionService();
public TyrantAdminCommand(
TyrantStateManager stateManager,
@@ -34,8 +28,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
) {
this(
stateManager, lifecycle, onlinePlayers, effects, clock,
new RoleCandidateSelector(), Duration.ofHours(24), Duration.ofHours(24),
RandomGenerator.getDefault()
ArenaLocationStore.unavailable()
);
}
@@ -45,20 +38,14 @@ public final class TyrantAdminCommand implements CommandExecutor {
OnlinePlayerDirectory onlinePlayers,
ManagedRoleEffects effects,
Clock clock,
RoleCandidateSelector candidateSelector,
Duration activityWindow,
Duration pendingTimeout,
RandomGenerator random
ArenaLocationStore arenaLocations
) {
this.stateManager = stateManager;
this.lifecycle = lifecycle;
this.onlinePlayers = onlinePlayers;
this.effects = effects;
this.clock = clock;
this.candidateSelector = candidateSelector;
this.activityWindow = activityWindow;
this.pendingTimeout = pendingTimeout;
this.random = random;
this.arenaLocations = arenaLocations;
}
@Override
@@ -82,6 +69,8 @@ public final class TyrantAdminCommand implements CommandExecutor {
case "pause" -> pause(sender);
case "resume" -> resume(sender);
case "reset" -> reset(sender, arguments);
case "arena" -> arena(sender, arguments);
case "grantpoint" -> grantPoint(sender, arguments);
default -> usage(sender);
}
} catch (IllegalArgumentException | IllegalStateException exception) {
@@ -112,38 +101,17 @@ public final class TyrantAdminCommand implements CommandExecutor {
stateManager.player(tyrant.getUniqueId(), tyrant.getName());
vigilante.ifPresent(player -> stateManager.player(player.getUniqueId(), player.getName()));
InstantHolder now = new InstantHolder(clock.instant());
Optional<UUID> randomCandidate = vigilante.isEmpty()
? candidateSelector.select(
stateManager.players(), Set.of(tyrant.getUniqueId()), Optional.empty(),
now.value(), activityWindow, random
)
: Optional.empty();
Optional<UUID> activeVigilante = vigilante.map(Player::getUniqueId);
if (activeVigilante.isEmpty() && randomCandidate.isPresent()
&& onlinePlayers.findById(randomCandidate.orElseThrow()) != null) {
activeVigilante = randomCandidate;
}
LifecycleState started = lifecycle.start(
stateManager.snapshot(), tyrant.getUniqueId(), activeVigilante, now.value()
stateManager.snapshot(), tyrant.getUniqueId(),
vigilante.map(Player::getUniqueId), now.value()
);
if (activeVigilante.isEmpty() && randomCandidate.isPresent()) {
GameState game = started.game();
started = new LifecycleState(new GameState(
game.lifecycle(), game.tyrantId(), game.vigilanteId(), game.pendingTyrant(),
Optional.of(new PendingSelection(
randomCandidate.orElseThrow(), now.value().plus(pendingTimeout)
)),
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
game.unspentChoices(), game.purchases()
), started.players());
}
stateManager.replaceState(started);
stateManager.saveIfDirty();
effects.restoreAll();
sender.sendMessage(ChatColor.GREEN + "Tyrant game started.");
if (vigilante.isEmpty()) {
sender.sendMessage(ChatColor.YELLOW
+ "An eligible recently active Vigilante will be selected automatically.");
+ "The Vigilante arena challenge is open.");
}
}
@@ -175,6 +143,44 @@ public final class TyrantAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.GREEN + "Tyrant game reset.");
}
private void grantPoint(CommandSender sender, String[] arguments) {
if (arguments.length != 1) {
sender.sendMessage(ChatColor.YELLOW + "Usage: /tyrantadmin grantpoint");
return;
}
PersistentState snapshot = stateManager.snapshot();
GameState updated = progression.grantPoint(snapshot.game());
stateManager.replaceState(new LifecycleState(updated, snapshot.players()));
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Granted the Tyrant one point; now level "
+ updated.tyrantLevel() + " and " + updated.unspentChoices()
+ " unspent choices.");
}
private void arena(CommandSender sender, String[] arguments) {
if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) {
sender.sendMessage("Shared role arena: " + arenaLocations.location()
.map(ArenaLocation::display).orElse("not configured"));
return;
}
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("set")) {
sender.sendMessage(ChatColor.YELLOW
+ "Usage: /tyrantadmin arena <set|status>");
return;
}
if (!(sender instanceof Player player)) {
sender.sendMessage(ChatColor.RED + "Only a player can set the arena location.");
return;
}
org.bukkit.Location location = player.getLocation();
ArenaLocation arena = new ArenaLocation(
location.getWorld().getName(), location.getX(), location.getY(), location.getZ(),
location.getYaw(), location.getPitch()
);
arenaLocations.set(arena);
sender.sendMessage(ChatColor.GREEN + "Shared role arena set to " + arena.display() + ".");
}
private void showStatus(CommandSender sender) {
GameState game = stateManager.game();
sender.sendMessage(ChatColor.GOLD + "Lifecycle: " + game.lifecycle());
@@ -183,9 +189,12 @@ public final class TyrantAdminCommand implements CommandExecutor {
+ game.vigilanteId().map(Object::toString).orElse("none"));
sender.sendMessage("Level: " + game.tyrantLevel()
+ ", choices: " + game.unspentChoices()
+ ", purchases: " + game.purchases());
+ ", purchases: " + game.purchases()
+ ", claimed armor: " + game.claimedArmor());
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
sender.sendMessage("Shared role arena: " + arenaLocations.location()
.map(ArenaLocation::display).orElse("not configured"));
for (PlayerState player : stateManager.players().values()) {
boolean optedOut = player.optedOutUntil().isPresent();
sender.sendMessage(
@@ -202,6 +211,6 @@ public final class TyrantAdminCommand implements CommandExecutor {
private static void usage(CommandSender sender) {
sender.sendMessage(ChatColor.YELLOW
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm>");
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena set|grantpoint>");
}
}
@@ -11,7 +11,7 @@ import org.bukkit.entity.Player;
public final class TyrantAdminTabCompleter implements TabCompleter {
private static final String PERMISSION = "spigottyrant.admin";
private static final List<String> SUBCOMMANDS = List.of(
"status", "start", "pause", "resume", "reset"
"status", "start", "pause", "resume", "reset", "arena", "grantpoint"
);
private final OnlinePlayerDirectory onlinePlayers;
@@ -37,6 +37,9 @@ public final class TyrantAdminTabCompleter implements TabCompleter {
return switch (subcommand) {
case "start" -> TabSuggestions.matching(arguments[1], onlineNames(null));
case "reset" -> TabSuggestions.matching(arguments[1], List.of("confirm"));
case "arena" -> TabSuggestions.matching(
arguments[1], List.of("set", "status")
);
default -> List.of();
};
}
@@ -0,0 +1,21 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public final class TyrantArenaSuccessionService {
public LifecycleState claim(PersistentState state, UUID winnerId) {
PlayerState winner = state.players().get(winnerId);
if (!RoleArenaPolicy.isEligible(state.game(), ArenaRole.TYRANT, winner)) {
throw new IllegalStateException("player is not eligible to claim the Tyrant role");
}
GameState game = state.game();
GameState assigned = new GameState(
game.lifecycle(), Optional.of(winnerId), Optional.empty(), Optional.empty(),
Optional.empty(), game.pausedAt(), game.accumulatedPausedTime(),
0, 1, Set.of()
);
return new LifecycleState(assigned, state.players());
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
import org.bukkit.inventory.ItemStack;
@FunctionalInterface
public interface TyrantArmorItemFactory {
ItemStack create(TyrantArmorPiece piece, String tyrantName);
}
@@ -0,0 +1,22 @@
package games.dmg.spigottyrant;
public enum TyrantArmorPiece {
HELMET,
CHESTPLATE,
LEGGINGS,
BOOTS;
public static TyrantArmorPiece fromCommand(String value) {
return switch (value.toLowerCase(java.util.Locale.ROOT)) {
case "helmet" -> HELMET;
case "chestplate", "chest" -> CHESTPLATE;
case "leggings", "legs" -> LEGGINGS;
case "boots", "feet" -> BOOTS;
default -> throw new IllegalArgumentException("Unknown armor piece");
};
}
public String commandName() {
return name().toLowerCase(java.util.Locale.ROOT);
}
}
@@ -0,0 +1,38 @@
package games.dmg.spigottyrant;
import java.util.EnumSet;
import java.util.Set;
import java.util.UUID;
public final class TyrantArmorService {
public ArmorClaimResult claim(GameState state, UUID claimantId, TyrantArmorPiece piece) {
if (state.lifecycle() != GameLifecycle.RUNNING) {
return result(state, ArmorClaimStatus.GAME_NOT_RUNNING);
}
if (state.tyrantId().filter(claimantId::equals).isEmpty()) {
return result(state, ArmorClaimStatus.NOT_TYRANT);
}
if (!state.purchases().containsAll(EnumSet.allOf(TyrantUnlock.class))) {
return result(state, ArmorClaimStatus.UNLOCKS_INCOMPLETE);
}
if (state.unspentChoices() == 0) {
return result(state, ArmorClaimStatus.NO_CHOICES);
}
if (state.claimedArmor().contains(piece)) {
return result(state, ArmorClaimStatus.ALREADY_CLAIMED);
}
Set<TyrantArmorPiece> claimed = EnumSet.noneOf(TyrantArmorPiece.class);
claimed.addAll(state.claimedArmor());
claimed.add(piece);
GameState updated = new GameState(
state.lifecycle(), state.tyrantId(), state.vigilanteId(), state.pendingTyrant(),
state.pendingVigilante(), state.pausedAt(), state.accumulatedPausedTime(),
state.tyrantLevel(), state.unspentChoices() - 1, state.purchases(), claimed
);
return result(updated, ArmorClaimStatus.CLAIMED);
}
private static ArmorClaimResult result(GameState state, ArmorClaimStatus status) {
return new ArmorClaimResult(state, status);
}
}
@@ -0,0 +1,41 @@
package games.dmg.spigottyrant;
import java.util.List;
import org.bukkit.Material;
public record TyrantArmorSpec(
Material material,
String displayName,
List<String> lore,
int protectionLevel,
int mendingLevel
) {
public TyrantArmorSpec {
lore = List.copyOf(lore);
}
public static TyrantArmorSpec forPiece(TyrantArmorPiece piece, String tyrantName) {
Material material = switch (piece) {
case HELMET -> Material.NETHERITE_HELMET;
case CHESTPLATE -> Material.NETHERITE_CHESTPLATE;
case LEGGINGS -> Material.NETHERITE_LEGGINGS;
case BOOTS -> Material.NETHERITE_BOOTS;
};
String pieceName = readable(piece);
return new TyrantArmorSpec(
material,
"Tyrant's " + tyrantName + " " + pieceName,
List.of(
"Forged for Tyrant " + tyrantName + ".",
"A lasting reward from " + tyrantName + "'s reign."
),
5,
1
);
}
private static String readable(TyrantArmorPiece piece) {
String lower = piece.name().toLowerCase(java.util.Locale.ROOT);
return Character.toUpperCase(lower.charAt(0)) + lower.substring(1);
}
}
@@ -22,6 +22,8 @@ public final class TyrantCommand implements CommandExecutor {
private final TyrantAbilityService tyrantAbilities;
private final TyrantControlPanel controlPanel;
private final RoleControlItemService roleControlItems;
private final TyrantArmorService armor = new TyrantArmorService();
private final TyrantArmorItemFactory armorItems;
public TyrantCommand(
TyrantStateManager stateManager,
@@ -63,6 +65,20 @@ public final class TyrantCommand implements CommandExecutor {
);
}
TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
TyrantArmorItemFactory armorItems
) {
this(
stateManager, progression, new ClassAssignmentService(),
new BukkitOnlinePlayerDirectory(), Clock.systemUTC(),
new ParticipationService(Duration.ofDays(7)), null, null, null,
new TyrantAbilityService(Duration.ofHours(24)), TyrantCommand::unavailablePanel,
null, armorItems
);
}
TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
@@ -129,6 +145,28 @@ public final class TyrantCommand implements CommandExecutor {
TyrantAbilityService tyrantAbilities,
TyrantControlPanel controlPanel,
RoleControlItemService roleControlItems
) {
this(
stateManager, progression, assignments, onlinePlayers, clock, participation,
relinquishment, presentation, abilityItems, tyrantAbilities, controlPanel,
roleControlItems, new BukkitTyrantArmorItemFactory()
);
}
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
ClassAssignmentService assignments,
OnlinePlayerDirectory onlinePlayers,
Clock clock,
ParticipationService participation,
RoleRelinquishmentService relinquishment,
TyrantPresentation presentation,
AbilityItemService abilityItems,
TyrantAbilityService tyrantAbilities,
TyrantControlPanel controlPanel,
RoleControlItemService roleControlItems,
TyrantArmorItemFactory armorItems
) {
this.stateManager = stateManager;
this.progression = progression;
@@ -142,6 +180,7 @@ public final class TyrantCommand implements CommandExecutor {
this.tyrantAbilities = tyrantAbilities;
this.controlPanel = controlPanel;
this.roleControlItems = roleControlItems;
this.armorItems = armorItems;
}
@Override
@@ -168,6 +207,10 @@ public final class TyrantCommand implements CommandExecutor {
buy(player, arguments[1]);
return true;
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("armor")) {
claimArmor(player, arguments[1]);
return true;
}
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("assign")) {
assign(player, arguments[1], arguments[2]);
return true;
@@ -198,7 +241,7 @@ public final class TyrantCommand implements CommandExecutor {
}
player.sendMessage(
ChatColor.YELLOW
+ "Usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|"
+ "Usage: /tyrant <menu|status|choices|buy|armor|assign|item|intelligence|optout|optin|"
+ "relinquish confirm>"
);
return true;
@@ -220,6 +263,12 @@ public final class TyrantCommand implements CommandExecutor {
}
player.sendMessage(ChatColor.YELLOW + "[" + status + "] " + unlock.name());
}
if (state.purchases().size() == TyrantUnlock.values().length) {
player.sendMessage(ChatColor.GOLD + "Legacy armor claimed: "
+ (state.claimedArmor().isEmpty() ? "none" : state.claimedArmor()));
player.sendMessage(ChatColor.YELLOW
+ "Use /tyrant armor <helmet|chestplate|leggings|boots>.");
}
}
private void buy(Player player, String requestedUnlock) {
@@ -246,6 +295,36 @@ public final class TyrantCommand implements CommandExecutor {
}
}
private void claimArmor(Player player, String requestedPiece) {
TyrantArmorPiece piece;
try {
piece = TyrantArmorPiece.fromCommand(requestedPiece);
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED
+ "Unknown armor piece. Use helmet, chestplate, leggings, or boots.");
return;
}
PersistentState snapshot = stateManager.snapshot();
ArmorClaimResult result = armor.claim(snapshot.game(), player.getUniqueId(), piece);
if (result.status() != ArmorClaimStatus.CLAIMED) {
player.sendMessage(ChatColor.RED + "Could not claim " + piece.commandName()
+ ": " + readable(result.status()));
return;
}
int inventorySlot = player.getInventory().firstEmpty();
if (inventorySlot < 0) {
player.sendMessage(ChatColor.RED
+ "Your inventory is full; no choice was consumed.");
return;
}
org.bukkit.inventory.ItemStack item = armorItems.create(piece, player.getName());
stateManager.replaceState(new LifecycleState(result.state(), snapshot.players()));
player.getInventory().setItem(inventorySlot, item);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.GREEN + "Claimed " + piece.commandName()
+ " for one choice.");
}
private void assign(Player tyrant, String requestedClass, String targetName) {
TyrantClass tyrantClass;
try {
@@ -365,7 +444,7 @@ public final class TyrantCommand implements CommandExecutor {
private void relinquish(Player player, String[] arguments) {
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) {
player.sendMessage(ChatColor.RED
+ "Relinquishing rerolls your role. Use /tyrant relinquish confirm.");
+ "Relinquishing ends your role. Use /tyrant relinquish confirm.");
return;
}
if (relinquishment == null) {
@@ -10,11 +10,13 @@ public record TyrantControlPanelModel(
int level,
int unspentChoices,
Map<TyrantUnlock, UnlockAvailability> unlocks,
Set<TyrantArmorPiece> claimedArmor,
Map<TyrantClass, String> classHolders,
Duration intelligenceCooldown
) {
public TyrantControlPanelModel {
unlocks = Map.copyOf(unlocks);
claimedArmor = Set.copyOf(claimedArmor);
classHolders = Map.copyOf(classHolders);
}
@@ -25,6 +27,15 @@ public record TyrantControlPanelModel(
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
public boolean allStandardUnlocksPurchased() {
return purchasedUnlocks().size() == TyrantUnlock.values().length;
}
public boolean armorAvailable(TyrantArmorPiece piece) {
return allStandardUnlocksPurchased() && unspentChoices > 0
&& !claimedArmor.contains(piece);
}
public static TyrantControlPanelModel create(
GameState game,
PlayerState tyrant,
@@ -61,7 +72,8 @@ public record TyrantControlPanelModel(
? Duration.between(now, tyrant.cooldownEnds().get(Ability.ROSTER_INTELLIGENCE))
: Duration.ZERO;
return new TyrantControlPanelModel(
game.tyrantLevel(), game.unspentChoices(), unlocks, holders, cooldown
game.tyrantLevel(), game.unspentChoices(), unlocks, game.claimedArmor(),
holders, cooldown
);
}
}
@@ -1,10 +1,7 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.bukkit.ChatColor;
import org.bukkit.Server;
import org.bukkit.entity.Player;
@@ -18,20 +15,17 @@ public final class TyrantDeathListener implements Listener {
private final TyrantSuccessionService succession;
private final TyrantPresentation presentation;
private final Server server;
private final Clock clock;
public TyrantDeathListener(
TyrantStateManager stateManager,
TyrantSuccessionService succession,
TyrantPresentation presentation,
Server server,
Clock clock
Server server
) {
this.stateManager = stateManager;
this.succession = succession;
this.presentation = presentation;
this.server = server;
this.clock = clock;
}
@EventHandler(priority = EventPriority.MONITOR)
@@ -44,22 +38,18 @@ public final class TyrantDeathListener implements Listener {
}
Optional<UUID> killerId = Optional.ofNullable(event.getEntity().getKiller())
.map(Player::getUniqueId);
Set<UUID> online = server.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(Collectors.toUnmodifiableSet());
LifecycleState succeeded = succession.succeed(
stateManager.snapshot(), killerId, online, clock.instant()
stateManager.snapshot(), killerId
);
stateManager.replaceState(succeeded);
stateManager.saveIfDirty();
presentation.reconcile(succeeded.game().tyrantId());
String successor = succeeded.game().tyrantId()
.map(id -> {
String message = succeeded.game().tyrantId().map(id -> {
Player player = server.getPlayer(id);
return player == null ? id.toString() : player.getName();
})
.orElse("pending selection");
String message = ChatColor.RED + successor + " is the new Tyrant!";
String successor = player == null ? id.toString() : player.getName();
return ChatColor.RED + successor + " is the new Tyrant!";
}).orElse(ChatColor.RED
+ "The Tyrant role is vacant; the red arena challenge is now open.");
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
}
@@ -20,6 +20,25 @@ public final class TyrantProgressionService {
);
}
public GameState grantPoint(GameState state) {
if (state.lifecycle() != GameLifecycle.RUNNING) {
throw new IllegalStateException("Tyrant game must be running.");
}
if (state.tyrantId().isEmpty()) {
throw new IllegalStateException("There is no current Tyrant.");
}
if (state.tyrantLevel() == Integer.MAX_VALUE
|| state.unspentChoices() == Integer.MAX_VALUE) {
throw new IllegalStateException("Tyrant progression cannot be increased further.");
}
return copyProgress(
state,
state.tyrantLevel() + 1,
state.unspentChoices() + 1,
state.purchases()
);
}
public PurchaseResult purchase(
GameState state,
UUID buyerId,
@@ -50,7 +69,7 @@ public final class TyrantProgressionService {
}
public GameState resetReignProgression(GameState state) {
return copyProgress(state, 0, 0, java.util.Set.of());
return copyProgress(state, 0, 0, java.util.Set.of(), java.util.Set.of());
}
private static GameState copyProgress(
@@ -58,6 +77,16 @@ public final class TyrantProgressionService {
int level,
int choices,
java.util.Set<TyrantUnlock> purchases
) {
return copyProgress(state, level, choices, purchases, state.claimedArmor());
}
private static GameState copyProgress(
GameState state,
int level,
int choices,
java.util.Set<TyrantUnlock> purchases,
java.util.Set<TyrantArmorPiece> claimedArmor
) {
return new GameState(
state.lifecycle(),
@@ -69,7 +98,8 @@ public final class TyrantProgressionService {
state.accumulatedPausedTime(),
level,
choices,
purchases
purchases,
claimedArmor
);
}
}
@@ -1,83 +1,27 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
public final class TyrantSuccessionService {
private final RoleCandidateSelector candidates;
private final Duration activityWindow;
private final Duration pendingTimeout;
private final RandomGenerator random;
public TyrantSuccessionService(
RoleCandidateSelector candidates,
Duration activityWindow,
Duration pendingTimeout,
RandomGenerator random
) {
this.candidates = candidates;
this.activityWindow = activityWindow;
this.pendingTimeout = pendingTimeout;
this.random = random;
}
public LifecycleState succeed(
PersistentState state,
Optional<UUID> killerId,
Set<UUID> onlinePlayerIds,
Instant now
Optional<UUID> killerId
) {
if (state.game().lifecycle() != GameLifecycle.RUNNING
|| state.game().tyrantId().isEmpty()) {
throw new IllegalStateException("a running Tyrant reign is required");
}
UUID formerTyrant = state.game().tyrantId().orElseThrow();
Optional<UUID> formerVigilante = state.game().vigilanteId();
Map<UUID, PlayerState> clearedPlayers = clearAssignments(state.players());
Optional<UUID> selectedTyrant = killerId.isPresent()
? killerId
: candidates.select(
clearedPlayers,
Set.of(),
Optional.of(formerTyrant),
now,
activityWindow,
random
);
Optional<UUID> activeTyrant = selectedTyrant
.filter(playerId -> killerId.isPresent() || onlinePlayerIds.contains(playerId));
Optional<PendingSelection> pendingTyrant = selectedTyrant
.filter(playerId -> activeTyrant.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
Optional<UUID> selectedVigilante = activeTyrant.flatMap(tyrantId ->
candidates.select(
clearedPlayers,
Set.of(tyrantId),
formerVigilante,
now,
activityWindow,
random
)
);
Optional<UUID> activeVigilante = selectedVigilante
.filter(onlinePlayerIds::contains);
Optional<PendingSelection> pendingVigilante = selectedVigilante
.filter(playerId -> activeVigilante.isEmpty())
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
GameState game = new GameState(
GameLifecycle.RUNNING,
activeTyrant,
activeVigilante,
pendingTyrant,
pendingVigilante,
killerId,
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
state.game().accumulatedPausedTime(),
0,
@@ -12,13 +12,16 @@ import org.bukkit.entity.Player;
public final class TyrantTabCompleter implements TabCompleter {
private static final List<String> SUBCOMMANDS = List.of(
"menu", "status", "choices", "buy", "assign", "item", "intelligence",
"menu", "status", "choices", "buy", "armor", "assign", "item", "intelligence",
"optout", "optin", "relinquish"
);
private static final List<String> UNLOCKS = java.util.Arrays.stream(TyrantUnlock.values())
.map(value -> value.name().toLowerCase(java.util.Locale.ROOT))
.toList();
private static final List<String> CLASSES = List.of("assassin", "fixer", "tamer");
private static final List<String> ARMOR = java.util.Arrays.stream(TyrantArmorPiece.values())
.map(TyrantArmorPiece::commandName)
.toList();
private final TyrantStateManager stateManager;
private final OnlinePlayerDirectory onlinePlayers;
@@ -47,6 +50,7 @@ public final class TyrantTabCompleter implements TabCompleter {
if (arguments.length == 2) {
return switch (subcommand) {
case "buy" -> TabSuggestions.matching(arguments[1], UNLOCKS);
case "armor" -> TabSuggestions.matching(arguments[1], ARMOR);
case "assign" -> TabSuggestions.matching(arguments[1], CLASSES);
case "relinquish" -> TabSuggestions.matching(
arguments[1], List.of("confirm")
@@ -0,0 +1,22 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
public final class VigilanteArenaSuccessionService {
public LifecycleState claim(PersistentState state, UUID winnerId) {
PlayerState winner = state.players().get(winnerId);
if (!RoleArenaPolicy.isEligible(
state.game(), ArenaRole.VIGILANTE, winner
)) {
throw new IllegalStateException("player is not eligible to claim the Vigilante role");
}
GameState game = state.game();
GameState assigned = new GameState(
game.lifecycle(), game.tyrantId(), Optional.of(winnerId), game.pendingTyrant(),
Optional.empty(), game.pausedAt(), game.accumulatedPausedTime(),
game.tyrantLevel(), game.unspentChoices(), game.purchases(), game.claimedArmor()
);
return new LifecycleState(assigned, state.players());
}
}
@@ -1,8 +1,6 @@
package games.dmg.spigottyrant;
import java.time.Clock;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Server;
@@ -17,20 +15,17 @@ public final class VigilanteDeathListener implements Listener {
private final TyrantProgressionService progression;
private final RoleMaintenanceService maintenance;
private final Server server;
private final Clock clock;
public VigilanteDeathListener(
TyrantStateManager stateManager,
TyrantProgressionService progression,
RoleMaintenanceService maintenance,
Server server,
Clock clock
Server server
) {
this.stateManager = stateManager;
this.progression = progression;
this.maintenance = maintenance;
this.server = server;
this.clock = clock;
}
@EventHandler(priority = EventPriority.MONITOR)
@@ -47,16 +42,13 @@ public final class VigilanteDeathListener implements Listener {
PersistentState withProgress = new PersistentState(
progressed, stateManager.players()
);
Set<UUID> online = server.getOnlinePlayers().stream()
.map(Player::getUniqueId)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
LifecycleState rerolled = maintenance.rerollVigilante(
withProgress, Optional.of(victimId), online, clock.instant()
withProgress, Optional.of(victimId)
);
stateManager.replaceState(rerolled);
stateManager.saveIfDirty();
String message = ChatColor.YELLOW + "The Vigilante died; a replacement "
+ (rerolled.game().vigilanteId().isPresent() ? "has been selected." : "is pending.");
String message = ChatColor.YELLOW
+ "The Vigilante died; the arena challenge is now open.";
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
}
}
@@ -95,7 +95,8 @@ public final class YamlTyrantStateRepository {
Duration.ofSeconds(nonNegativeLong(yaml, "game.accumulated-paused-seconds")),
nonNegativeInt(yaml, "game.tyrant-level"),
nonNegativeInt(yaml, "game.unspent-choices"),
enumSet(TyrantUnlock.class, yaml.getStringList("game.purchases"))
enumSet(TyrantUnlock.class, yaml.getStringList("game.purchases")),
enumSet(TyrantArmorPiece.class, yaml.getStringList("game.claimed-armor"))
);
} catch (IllegalArgumentException exception) {
return GameState.empty();
@@ -192,6 +193,10 @@ public final class YamlTyrantStateRepository {
yaml.set("game.tyrant-level", game.tyrantLevel());
yaml.set("game.unspent-choices", game.unspentChoices());
yaml.set("game.purchases", game.purchases().stream().map(Enum::name).sorted().toList());
yaml.set(
"game.claimed-armor",
game.claimedArmor().stream().map(Enum::name).sorted().toList()
);
}
private static void savePlayer(YamlConfiguration yaml, PlayerState player) {
+10 -4
View File
@@ -2,12 +2,18 @@
tyrant-range-blocks: 50
follower-range-blocks: 50
# Participation, inactivity, and selection
# Shared Tyrant and Vigilante arena and boss
vigilante-arena-radius-blocks: 10
arena-barrier-height-blocks: 12
vigilante-boss-health: 80
vigilante-boss-damage: 8
vigilante-boss-armor: 10
# Set with /tyrantadmin arena set
# Participation and role inactivity
opt-out-seconds: 604800
role-inactivity-seconds: 172800
candidate-activity-window-seconds: 86400
pending-selection-timeout-seconds: 86400
selection-retry-seconds: 60
role-maintenance-seconds: 60
freeze-timers-while-paused: true
# Assassin
+2 -2
View File
@@ -7,13 +7,13 @@ author: dmg.games
commands:
tyrant:
description: View and use Spigot Tyrant game features.
usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|relinquish confirm>
usage: /tyrant <menu|status|choices|buy|armor <helmet|chestplate|leggings|boots>|assign|item|intelligence|optout|optin|relinquish confirm>
vigilante:
description: Manage Vigilante Followers.
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
tyrantadmin:
description: Administer the Spigot Tyrant game.
usage: /tyrantadmin <status|start|pause|resume|reset confirm>
usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena <set|status>|grantpoint>
permission: spigottyrant.admin
permissions:
spigottyrant.admin:
@@ -0,0 +1,35 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.entity.Zombie;
import org.junit.jupiter.api.Test;
final class ArenaBossStateTest {
@Test
void idleBossIsCenteredHealedUntargetedAndFrozen() {
Zombie boss = mock(Zombie.class);
Location center = mock(Location.class);
ArenaBossState.idle(boss, center, 80.0);
verify(boss).setAI(false);
verify(boss).setTarget(null);
verify(boss).setHealth(80.0);
verify(boss).teleport(center);
}
@Test
void challengerEntryEnablesBossAndSetsTarget() {
Zombie boss = mock(Zombie.class);
Player challenger = mock(Player.class);
ArenaBossState.engage(boss, challenger);
verify(boss).setAI(true);
verify(boss).setTarget(challenger);
}
}
@@ -0,0 +1,25 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
final class ArenaBuildProtectionTest {
private static final ArenaLocation CENTER = new ArenaLocation(
"world", 0.0, 64.0, 0.0, 0.0F, 0.0F
);
@Test
void protectsOnlyInsideConfiguredArenaDuringFight() {
assertTrue(ArenaBuildProtection.isProtected(
true, CENTER, 10.0, "world", 5.0, 70.0, 5.0
));
assertFalse(ArenaBuildProtection.isProtected(
false, CENTER, 10.0, "world", 5.0, 70.0, 5.0
));
assertFalse(ArenaBuildProtection.isProtected(
true, CENTER, 10.0, "world", 11.0, 70.0, 0.0
));
}
}
@@ -0,0 +1,27 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
final class ArenaGeometryTest {
private final ArenaLocation center = new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F);
@Test
void circleUsesHorizontalDistanceAndWorld() {
assertTrue(ArenaGeometry.contains(center, 10.0, "world", 16.0, 100.0, 3.0));
assertFalse(ArenaGeometry.contains(center, 10.0, "world", 21.0, 64.0, -5.0));
assertFalse(ArenaGeometry.contains(center, 10.0, "nether", 10.0, 64.0, -5.0));
}
@Test
void outsidePointProjectsBeyondNearestBoundary() {
ArenaPoint point = ArenaGeometry.outside(center, 10.0, 10.0, 64.0, -5.0);
assertEquals(21.0, point.x(), 0.001);
assertEquals(64.0, point.y(), 0.001);
assertEquals(-5.0, point.z(), 0.001);
}
}
@@ -0,0 +1,28 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
final class BukkitConfigArenaLocationStoreTest {
@Test
void arenaLocationRoundTripsThroughPluginConfiguration() {
JavaPlugin plugin = mock(JavaPlugin.class);
YamlConfiguration config = new YamlConfiguration();
when(plugin.getConfig()).thenReturn(config);
BukkitConfigArenaLocationStore store = new BukkitConfigArenaLocationStore(plugin);
ArenaLocation expected = new ArenaLocation(
"arena_world", 12.5, 70.0, -4.5, 90.0F, 5.0F
);
store.set(expected);
assertEquals(expected, store.location().orElseThrow());
verify(plugin).saveConfig();
}
}
@@ -20,6 +20,8 @@ final class DefaultConfigurationTest {
PluginSettings settings = PluginSettings.from(values);
assertEquals(50.0, settings.tyrantRangeBlocks());
assertEquals(Duration.ofDays(7), settings.optOutDuration());
assertEquals(Duration.ofMinutes(1), settings.roleMaintenanceInterval());
assertEquals(12, settings.arenaBarrierHeightBlocks());
assertEquals("FISHING_ROD", settings.tamerItem().material());
}
@@ -17,20 +17,30 @@ final class PluginSettingsTest {
assertEquals(50.0, settings.followerRangeBlocks());
assertEquals(Duration.ofDays(7), settings.optOutDuration());
assertEquals(Duration.ofHours(48), settings.roleInactivity());
assertEquals(Duration.ofHours(24), settings.candidateActivityWindow());
assertEquals(Duration.ofHours(24), settings.pendingSelectionTimeout());
assertEquals(Duration.ofMinutes(1), settings.roleMaintenanceInterval());
assertEquals(Duration.ofHours(1), settings.assassinCooldown());
assertEquals(Duration.ofMinutes(10), settings.assassinInvisibilityDuration());
assertEquals(Duration.ofHours(1), settings.fixerCooldown());
assertEquals(Duration.ofMinutes(10), settings.fixerEffectDuration());
assertEquals(Duration.ofHours(24), settings.rosterIntelligenceCooldown());
assertEquals(10.0, settings.vigilanteArenaRadiusBlocks());
assertEquals(12, settings.arenaBarrierHeightBlocks());
assertEquals(80.0, settings.vigilanteBossHealth());
assertEquals(8.0, settings.vigilanteBossDamage());
assertEquals(10.0, settings.vigilanteBossArmor());
}
@Test
void acceptsLegacySelectionRetryAsMaintenanceInterval() {
PluginSettings settings = PluginSettings.from(Map.of("selection-retry-seconds", 30));
assertEquals(Duration.ofSeconds(30), settings.roleMaintenanceInterval());
}
@Test
void approvedDefaultsCoverAbilitiesItemsMobsAndMessages() {
PluginSettings settings = PluginSettings.from(Map.of());
assertEquals(Duration.ofMinutes(1), settings.selectionRetryInterval());
assertEquals(Duration.ofSeconds(60), settings.assassinDoubleJumpCooldown());
assertEquals(Duration.ofSeconds(15), settings.assassinSpeedDuration());
assertEquals(Duration.ofSeconds(20), settings.assassinWeaknessDuration());
@@ -67,5 +77,25 @@ final class PluginSettingsTest {
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("role-inactivity-seconds", -1))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("role-maintenance-seconds", 0))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-arena-radius-blocks", 0))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("arena-barrier-height-blocks", 2))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-boss-health", Double.NaN))
);
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-boss-armor", 31))
);
}
}
@@ -0,0 +1,77 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class RoleArenaBarrierTest {
private static final ArenaLocation CENTER = new ArenaLocation(
"world", 0.0, 64.0, 0.0, 0.0F, 0.0F
);
@Test
void doesNotReplaceExistingNonAirBlocks() {
World world = mock(World.class);
Block solid = mock(Block.class);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(solid);
when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
when(solid.getType()).thenReturn(Material.CHEST);
new RoleArenaBarrier().build(world, CENTER, 10.0, 12);
verify(solid, never()).setType(Material.BARRIER, false);
}
@Test
void restoresAirReplacedByTemporaryBarrier() {
World world = mock(World.class);
Block air = mock(Block.class);
BlockData original = mock(BlockData.class);
BlockData snapshot = mock(BlockData.class);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(air);
when(world.getBlockAt(any(Location.class))).thenReturn(air);
when(air.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
when(air.getType()).thenReturn(Material.AIR);
when(air.getBlockData()).thenReturn(original);
when(original.clone()).thenReturn(snapshot);
RoleArenaBarrier barrier = new RoleArenaBarrier();
barrier.build(world, CENTER, 10.0, 12);
barrier.clear();
verify(air, atLeastOnce()).setType(Material.BARRIER, false);
verify(air).setBlockData(snapshot, false);
}
@Test
void wallExtendsOneBelowFloorForTwelveBlocks() {
World world = mock(World.class);
Block solid = mock(Block.class);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(solid);
when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
when(solid.getType()).thenReturn(Material.CHEST);
RoleArenaBarrier barrier = new RoleArenaBarrier();
barrier.build(world, CENTER, 10.0, 12);
ArgumentCaptor<Integer> y = ArgumentCaptor.forClass(Integer.class);
verify(world, atLeastOnce()).getBlockAt(anyInt(), y.capture(), anyInt());
List<Integer> values = y.getAllValues();
assertEquals(63, values.stream().mapToInt(Integer::intValue).min().orElseThrow());
assertEquals(74, values.stream().mapToInt(Integer::intValue).max().orElseThrow());
}
}
@@ -0,0 +1,188 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test;
final class RoleArenaControllerTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID PLAYER = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID OTHER = UUID.fromString("33333333-3333-3333-3333-333333333333");
@Test
void boundaryColorsIdentifyTheOpenRole() {
assertEquals(Color.RED, RoleArenaController.boundaryColor(ArenaRole.TYRANT));
assertEquals(Color.BLUE, RoleArenaController.boundaryColor(ArenaRole.VIGILANTE));
assertNull(RoleArenaController.boundaryColor(null));
assertArrayEquals(
new int[] {1}, RoleArenaController.boundaryHeightOffsets(false, 12)
);
assertArrayEquals(
new int[] {1, 4, 7, 10}, RoleArenaController.boundaryHeightOffsets(true, 12)
);
}
@Test
void tyrantArenaWinnerIsMovedOutsideAfterAssignment() {
assertWinnerMovedOutside(ArenaRole.TYRANT, tyrantVacancy());
}
@Test
void vigilanteArenaWinnerIsMovedOutsideAfterAssignment() {
assertWinnerMovedOutside(ArenaRole.VIGILANTE, openGame());
}
@Test
void optedInPlayerJoiningDuringVacancyReceivesArenaCoordinates() {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(PLAYER);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(openGame());
when(manager.players()).thenReturn(Map.of(PLAYER, PlayerState.newPlayer(PLAYER, "Player")));
ArenaLocationStore locations = mock(ArenaLocationStore.class);
when(locations.location()).thenReturn(Optional.of(
new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F)
));
RoleArenaController controller = new RoleArenaController(
manager, mock(Server.class), locations, PluginSettings.from(Map.of())
);
controller.onJoin(mockJoin(player));
verify(player).sendMessage(contains("world at 10, 64, -5"));
}
@Test
void optedInPlayerIsNotifiedOfTyrantVacancy() {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(PLAYER);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(tyrantVacancy());
when(manager.players()).thenReturn(Map.of(PLAYER, PlayerState.newPlayer(PLAYER, "Player")));
ArenaLocationStore locations = mock(ArenaLocationStore.class);
when(locations.location()).thenReturn(Optional.of(
new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F)
));
RoleArenaController controller = new RoleArenaController(
manager, mock(Server.class), locations, PluginSettings.from(Map.of())
);
controller.onJoin(mockJoin(player));
verify(player).sendMessage(contains("Tyrant position is open"));
}
@Test
void optedOutPlayerDoesNotReceiveVacancyNotice() {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(PLAYER);
PlayerState optedOut = new PlayerState(
PLAYER, "Player", Optional.empty(), Optional.of(Instant.parse("2026-08-24T00:00:00Z")),
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(openGame());
when(manager.players()).thenReturn(Map.of(PLAYER, optedOut));
ArenaLocationStore locations = mock(ArenaLocationStore.class);
when(locations.location()).thenReturn(Optional.of(
new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F)
));
RoleArenaController controller = new RoleArenaController(
manager, mock(Server.class), locations, PluginSettings.from(Map.of())
);
controller.onJoin(mockJoin(player));
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.anyString());
}
private static void assertWinnerMovedOutside(ArenaRole role, GameState game) {
ArenaLocation arena = new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F);
World world = mock(World.class);
when(world.getName()).thenReturn("world");
when(world.getMinHeight()).thenReturn(-64);
when(world.getMaxHeight()).thenReturn(320);
when(world.getHighestBlockYAt(org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt())).thenReturn(63);
when(world.getBlockAt(org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt())).thenReturn(mock(Block.class));
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(PLAYER);
when(player.getName()).thenReturn("Player");
when(player.getLocation()).thenReturn(new Location(world, arena.x(), arena.y(), arena.z()));
Player other = mock(Player.class);
when(other.getUniqueId()).thenReturn(OTHER);
PlayerState playerState = PlayerState.newPlayer(PLAYER, "Player");
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(game, Map.of(PLAYER, playerState)));
Server server = mock(Server.class);
when(server.getPlayer(PLAYER)).thenReturn(player);
when(server.getWorld("world")).thenReturn(world);
doReturn(Set.of(player, other)).when(server).getOnlinePlayers();
ArenaLocationStore locations = mock(ArenaLocationStore.class);
when(locations.location()).thenReturn(Optional.of(arena));
RoleArenaController controller = new RoleArenaController(
manager, server, locations, PluginSettings.from(Map.of())
);
controller.assignRole(PLAYER, role);
verify(manager).replaceState(org.mockito.ArgumentMatchers.any(LifecycleState.class));
verify(player).teleport(argThat((Location destination) ->
!ArenaGeometry.contains(arena, 10.0, "world",
destination.getX(), destination.getY(), destination.getZ())
), eq(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.PLUGIN));
if (role == ArenaRole.VIGILANTE) {
verify(player).sendMessage(contains(
"You defeated the arena boss and became the Vigilante"
));
verify(other, never()).sendMessage(contains("Vigilante"));
} else {
verify(other).sendMessage(contains("Player defeated the arena boss"));
}
}
private static PlayerJoinEvent mockJoin(Player player) {
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(player);
return event;
}
private static GameState tyrantVacancy() {
return new GameState(
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 1, Set.of()
);
}
private static GameState openGame() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
);
}
}
@@ -0,0 +1,78 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class RoleArenaPolicyTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID PLAYER = UUID.fromString("22222222-2222-2222-2222-222222222222");
@Test
void tyrantVacancyHasPriorityOverVigilanteVacancy() {
assertEquals(Optional.of(ArenaRole.TYRANT), RoleArenaPolicy.openRole(game(
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), Optional.empty()
)));
assertEquals(Optional.of(ArenaRole.VIGILANTE), RoleArenaPolicy.openRole(game(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(), Optional.empty()
)));
assertEquals(Optional.empty(), RoleArenaPolicy.openRole(game(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(PLAYER), Optional.empty()
)));
}
@Test
void pausedGameAndLegacyPendingTyrantDoNotOpenFight() {
assertEquals(Optional.empty(), RoleArenaPolicy.openRole(game(
GameLifecycle.PAUSED, Optional.empty(), Optional.empty(), Optional.empty()
)));
assertEquals(Optional.empty(), RoleArenaPolicy.openRole(game(
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(),
Optional.of(new PendingSelection(PLAYER, Instant.parse("2026-08-24T00:00:00Z")))
)));
}
@Test
void activeTyrantCannotChallengeAndOptedOutPlayersRemainIneligible() {
GameState vigilanteFight = game(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(), Optional.empty()
);
GameState tyrantFight = game(
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), Optional.empty()
);
PlayerState eligible = PlayerState.newPlayer(PLAYER, "Player");
PlayerState optedOut = new PlayerState(
PLAYER, "Player", Optional.empty(), Optional.of(Instant.parse("2026-08-24T12:00:00Z")),
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
assertTrue(RoleArenaPolicy.isEligible(vigilanteFight, ArenaRole.VIGILANTE, eligible));
assertTrue(RoleArenaPolicy.isEligible(tyrantFight, ArenaRole.TYRANT, eligible));
assertFalse(RoleArenaPolicy.isEligible(vigilanteFight, ArenaRole.VIGILANTE, optedOut));
assertFalse(RoleArenaPolicy.isEligible(
vigilanteFight, ArenaRole.VIGILANTE, PlayerState.newPlayer(TYRANT, "Tyrant")
));
}
private static GameState game(
GameLifecycle lifecycle,
Optional<UUID> tyrant,
Optional<UUID> vigilante,
Optional<PendingSelection> pendingTyrant
) {
return new GameState(
lifecycle, tyrant, vigilante, pendingTyrant, Optional.empty(),
lifecycle == GameLifecycle.PAUSED
? Optional.of(Instant.parse("2026-08-23T12:00:00Z")) : Optional.empty(),
Duration.ZERO, 0, 0, Set.of()
);
}
}
@@ -1,46 +0,0 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
import org.junit.jupiter.api.Test;
final class RoleCandidateSelectorTest {
@Test
void selectsOnlyRecentlyActiveEligibleNonExcludedPlayer() {
Instant now = Instant.parse("2026-08-14T12:00:00Z");
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID candidate = UUID.fromString("22222222-2222-2222-2222-222222222222");
UUID stale = UUID.fromString("33333333-3333-3333-3333-333333333333");
Map<UUID, PlayerState> players = Map.of(
tyrant, withLogin(PlayerState.newPlayer(tyrant, "Tyrant"), now),
candidate, withLogin(PlayerState.newPlayer(candidate, "Candidate"), now.minusSeconds(60)),
stale, withLogin(PlayerState.newPlayer(stale, "Stale"), now.minus(Duration.ofHours(25)))
);
Optional<UUID> selected = new RoleCandidateSelector().select(
players,
Set.of(tyrant),
Optional.empty(),
now,
Duration.ofHours(24),
RandomGenerator.of("L64X128MixRandom")
);
assertEquals(Optional.of(candidate), selected);
}
private static PlayerState withLogin(PlayerState player, Instant login) {
return new PlayerState(
player.playerId(), player.latestName(), Optional.of(login), player.optedOutUntil(),
player.tyrantClass(), player.followerOf(), player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -8,7 +8,6 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
import org.junit.jupiter.api.Test;
final class RoleMaintenanceServiceTest {
@@ -18,21 +17,27 @@ final class RoleMaintenanceServiceTest {
private static final UUID OTHER = UUID.fromString("44444444-4444-4444-4444-444444444444");
private static final UUID FOLLOWER = UUID.fromString("55555555-5555-5555-5555-555555555555");
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final RandomGenerator random = RandomGenerator.of("L64X128MixRandom");
private final RoleCandidateSelector selector = new RoleCandidateSelector();
private final RoleMaintenanceService service = new RoleMaintenanceService(
selector,
new TyrantSuccessionService(
selector, Duration.ofHours(24), Duration.ofHours(24), random
),
Duration.ofHours(48),
Duration.ofHours(24),
Duration.ofHours(24),
random
new TyrantSuccessionService(), Duration.ofHours(48)
);
@Test
void onlinePendingVigilanteConfirmsWithoutConflictingWithTyrant() {
void legacyPendingTyrantIsClearedIntoArenaVacancy() {
GameState game = game(
Optional.empty(), Optional.empty(),
Optional.of(new PendingSelection(CANDIDATE, NOW.plusSeconds(60))),
Optional.empty()
);
PersistentState state = new PersistentState(game, players(active(CANDIDATE)));
LifecycleState maintained = service.maintain(state, NOW);
assertEquals(Optional.empty(), maintained.game().tyrantId());
assertEquals(Optional.empty(), maintained.game().pendingTyrant());
}
@Test
void legacyPendingVigilanteIsClearedForArenaSuccession() {
GameState game = game(
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.of(new PendingSelection(CANDIDATE, NOW.plusSeconds(60)))
@@ -41,14 +46,14 @@ final class RoleMaintenanceServiceTest {
active(TYRANT), active(CANDIDATE)
));
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, CANDIDATE), NOW);
LifecycleState maintained = service.maintain(state, NOW);
assertEquals(Optional.of(CANDIDATE), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
}
@Test
void expiredPendingSelectionRerollsAwayFromPreviousCandidate() {
void expiredLegacyPendingVigilanteOpensArenaInsteadOfRerolling() {
GameState game = game(
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.of(new PendingSelection(CANDIDATE, NOW.minusSeconds(1)))
@@ -57,14 +62,14 @@ final class RoleMaintenanceServiceTest {
active(TYRANT), active(CANDIDATE), active(OTHER)
));
LifecycleState maintained = service.maintain(state, Set.of(TYRANT), NOW);
LifecycleState maintained = service.maintain(state, NOW);
assertEquals(OTHER,
maintained.game().pendingVigilante().orElseThrow().candidateId());
assertEquals(Optional.empty(), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
}
@Test
void inactiveVigilanteIsRerolledAndFollowersAreCleared() {
void inactiveVigilanteOpensArenaAndFollowersAreCleared() {
PlayerState follower = withFollower(
withLogin(active(FOLLOWER), NOW.minus(Duration.ofHours(25))),
VIGILANTE
@@ -78,15 +83,15 @@ final class RoleMaintenanceServiceTest {
FOLLOWER, follower, OTHER, active(OTHER)
));
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, OTHER), NOW);
LifecycleState maintained = service.maintain(state, NOW);
assertEquals(Optional.of(OTHER), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.game().vigilanteId());
assertEquals(Optional.empty(), maintained.players().get(FOLLOWER).followerOf());
assertEquals(Optional.of(TYRANT), maintained.game().tyrantId());
}
@Test
void inactiveTyrantEndsReignAndUsesNormalSuccession() {
void inactiveTyrantEndsReignAndOpensArena() {
PlayerState staleTyrant = withLogin(
active(TYRANT), NOW.minus(Duration.ofHours(48))
);
@@ -104,8 +109,10 @@ final class RoleMaintenanceServiceTest {
TYRANT, staleTyrant, VIGILANTE, active(VIGILANTE), OTHER, successor
));
LifecycleState maintained = service.maintain(state, Set.of(OTHER), NOW);
LifecycleState maintained = service.maintain(state, NOW);
assertEquals(Optional.empty(), maintained.game().tyrantId());
assertEquals(Optional.empty(), maintained.game().pendingTyrant());
assertEquals(0, maintained.game().tyrantLevel());
assertEquals(1, maintained.game().unspentChoices());
assertEquals(Set.of(), maintained.game().purchases());
@@ -8,24 +8,18 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
import org.junit.jupiter.api.Test;
final class RoleRelinquishmentServiceTest {
@Test
void vigilanteCanRelinquishAndTriggerNormalReroll() {
void vigilanteCanRelinquishAndOpenArenaChallenge() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
UUID candidate = UUID.fromString("33333333-3333-3333-3333-333333333333");
Instant now = Instant.parse("2026-08-14T12:00:00Z");
RoleCandidateSelector selector = new RoleCandidateSelector();
RandomGenerator random = RandomGenerator.of("L64X128MixRandom");
TyrantSuccessionService succession = new TyrantSuccessionService(
selector, Duration.ofHours(24), Duration.ofHours(24), random
);
TyrantSuccessionService succession = new TyrantSuccessionService();
RoleMaintenanceService maintenance = new RoleMaintenanceService(
selector, succession, Duration.ofHours(48), Duration.ofHours(24),
Duration.ofHours(24), random
succession, Duration.ofHours(48)
);
RoleRelinquishmentService service = new RoleRelinquishmentService(
succession, maintenance
@@ -49,7 +43,40 @@ final class RoleRelinquishmentServiceTest {
);
assertEquals(RelinquishmentStatus.RELINQUISHED, result.status());
assertEquals(Optional.of(candidate), result.state().game().vigilanteId());
assertEquals(Optional.empty(), result.state().game().vigilanteId());
assertEquals(Optional.empty(), result.state().game().pendingVigilante());
}
@Test
void tyrantRelinquishmentOpensTyrantArenaAndClearsReign() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
Instant now = Instant.parse("2026-08-14T12:00:00Z");
TyrantSuccessionService succession = new TyrantSuccessionService();
RoleRelinquishmentService service = new RoleRelinquishmentService(
succession, new RoleMaintenanceService(succession, Duration.ofHours(48))
);
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.of(vigilante),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
4, 0, Set.of(TyrantUnlock.STRENGTH)
);
RelinquishmentResult result = service.relinquish(
new PersistentState(game, Map.of(
tyrant, active(tyrant, now), vigilante, active(vigilante, now)
)),
tyrant,
Set.of(tyrant, vigilante),
now
);
assertEquals(RelinquishmentStatus.RELINQUISHED, result.status());
assertEquals(Optional.empty(), result.state().game().tyrantId());
assertEquals(Optional.empty(), result.state().game().pendingTyrant());
assertEquals(Optional.empty(), result.state().game().vigilanteId());
assertEquals(0, result.state().game().tyrantLevel());
assertEquals(1, result.state().game().unspentChoices());
}
private static PlayerState active(UUID id, Instant now) {
@@ -14,8 +14,11 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class TyrantAdminCommandTest {
@@ -59,6 +62,117 @@ final class TyrantAdminCommandTest {
verify(effects).suppressAll();
}
@Test
void grantPointAddsAndPersistsOneLevelAndChoice() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
2, 1, Set.of(TyrantUnlock.ASSASSIN)
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(
game, Map.of(tyrant, PlayerState.newPlayer(tyrant, "Tyrant"))
));
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
command(manager, mock(ManagedRoleEffects.class)).onCommand(
sender, mock(Command.class), "tyrantadmin", new String[] {"grantpoint"}
);
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state ->
state.game().tyrantLevel() == 3
&& state.game().unspentChoices() == 2
&& state.game().purchases().equals(Set.of(TyrantUnlock.ASSASSIN))
));
verify(manager).saveIfDirty();
verify(sender).sendMessage(contains("level 3 and 2 unspent choices"));
}
@Test
void grantPointRejectsPausedGameWithoutChangingState() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState paused = new GameState(
GameLifecycle.PAUSED, Optional.of(tyrant), Optional.empty(),
Optional.empty(), Optional.empty(),
Optional.of(Instant.parse("2026-08-14T12:00:00Z")), Duration.ZERO,
2, 1, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(
paused, Map.of(tyrant, PlayerState.newPlayer(tyrant, "Tyrant"))
));
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
command(manager, mock(ManagedRoleEffects.class)).onCommand(
sender, mock(Command.class), "tyrantadmin", new String[] {"grantpoint"}
);
verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any());
verify(sender).sendMessage(contains("must be running"));
}
@Test
void grantPointRejectsAdditionalArgumentsWithoutChangingState() {
TyrantStateManager manager = mock(TyrantStateManager.class);
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
command(manager, mock(ManagedRoleEffects.class)).onCommand(
sender, mock(Command.class), "tyrantadmin",
new String[] {"grantpoint", "unexpected"}
);
verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any());
verify(sender).sendMessage(contains("Usage: /tyrantadmin grantpoint"));
}
@Test
void grantPointRejectsTyrantVacancyWithoutChangingState() {
GameState vacant = new GameState(
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(vacant, Map.of()));
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
command(manager, mock(ManagedRoleEffects.class)).onCommand(
sender, mock(Command.class), "tyrantadmin", new String[] {"grantpoint"}
);
verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any());
verify(sender).sendMessage(contains("no current Tyrant"));
}
@Test
void playerCanPersistArenaAtCurrentLocation() {
TyrantStateManager manager = mock(TyrantStateManager.class);
Player sender = mock(Player.class);
World world = mock(World.class);
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
when(world.getName()).thenReturn("arena_world");
when(sender.getLocation()).thenReturn(new Location(world, 12.5, 70.0, -4.5, 90.0F, 0.0F));
ArenaLocationStore store = mock(ArenaLocationStore.class);
TyrantAdminCommand command = new TyrantAdminCommand(
manager, new GameLifecycleService(), mock(OnlinePlayerDirectory.class),
mock(ManagedRoleEffects.class),
Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC), store
);
command.onCommand(sender, mock(Command.class), "tyrantadmin", new String[] {"arena", "set"});
verify(store).set(org.mockito.ArgumentMatchers.argThat(location ->
location.worldName().equals("arena_world")
&& location.x() == 12.5 && location.y() == 70.0 && location.z() == -4.5
));
verify(sender).sendMessage(contains("Shared role arena set"));
}
@Test
void resetRequiresExplicitConfirmation() {
TyrantStateManager manager = mock(TyrantStateManager.class);
@@ -34,7 +34,9 @@ final class TyrantAdminTabCompleterTest {
));
assertTrue(completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {""}
).containsAll(java.util.List.of("status", "start", "pause", "resume", "reset")));
).containsAll(java.util.List.of(
"status", "start", "pause", "resume", "reset", "arena", "grantpoint"
)));
assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"start", ""}
));
@@ -44,6 +46,12 @@ final class TyrantAdminTabCompleterTest {
assertEquals(java.util.List.of("confirm"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"reset", "c"}
));
assertEquals(java.util.List.of("set", "status"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"arena", "s"}
));
assertEquals(java.util.List.of(), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"grantpoint", ""}
));
}
private static Player player(UUID id, String name) {
@@ -0,0 +1,36 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class TyrantArenaSuccessionServiceTest {
@Test
void eligibleBossWinnerClaimsVacantTyrantRoleWithFreshReign() {
UUID winner = UUID.fromString("22222222-2222-2222-2222-222222222222");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 1, Set.of()
);
PersistentState before = new PersistentState(
game, Map.of(winner, PlayerState.newPlayer(winner, "Winner"))
);
LifecycleState claimed = new TyrantArenaSuccessionService().claim(before, winner);
assertEquals(Optional.of(winner), claimed.game().tyrantId());
assertEquals(Optional.empty(), claimed.game().vigilanteId());
assertEquals(0, claimed.game().tyrantLevel());
assertEquals(1, claimed.game().unspentChoices());
assertEquals(Set.of(), claimed.game().purchases());
assertEquals(
Optional.of(ArenaRole.VIGILANTE),
RoleArenaPolicy.openRole(claimed.game())
);
}
}
@@ -0,0 +1,76 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.util.EnumSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class TyrantArmorServiceTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID OTHER = UUID.fromString("22222222-2222-2222-2222-222222222222");
private final TyrantArmorService service = new TyrantArmorService();
@Test
void fullyUnlockedTyrantSpendsOneChoiceOnAnUnclaimedPiece() {
GameState before = game(2, allUnlocks(), Set.of());
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.HELMET);
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
assertEquals(1, result.state().unspentChoices());
assertEquals(Set.of(TyrantArmorPiece.HELMET), result.state().claimedArmor());
assertEquals(before.purchases(), result.state().purchases());
}
@Test
void eachPieceCanBeClaimedOnlyOncePerReign() {
GameState before = game(2, allUnlocks(), Set.of(TyrantArmorPiece.BOOTS));
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.BOOTS);
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED, result.status());
assertEquals(before, result.state());
}
@Test
void rejectsIncompleteUnlocksNoChoicesNonTyrantsAndPausedGames() {
GameState complete = game(0, allUnlocks(), Set.of());
GameState incomplete = game(1, Set.of(TyrantUnlock.ASSASSIN), Set.of());
GameState paused = new GameState(
GameLifecycle.PAUSED, complete.tyrantId(), complete.vigilanteId(),
complete.pendingTyrant(), complete.pendingVigilante(),
Optional.of(java.time.Instant.parse("2026-09-04T00:00:00Z")), Duration.ZERO,
1, 1, allUnlocks(), Set.of()
);
assertEquals(ArmorClaimStatus.NO_CHOICES,
service.claim(complete, TYRANT, TyrantArmorPiece.HELMET).status());
assertEquals(ArmorClaimStatus.UNLOCKS_INCOMPLETE,
service.claim(incomplete, TYRANT, TyrantArmorPiece.HELMET).status());
assertEquals(ArmorClaimStatus.NOT_TYRANT,
service.claim(game(1, allUnlocks(), Set.of()), OTHER,
TyrantArmorPiece.HELMET).status());
assertEquals(ArmorClaimStatus.GAME_NOT_RUNNING,
service.claim(paused, TYRANT, TyrantArmorPiece.HELMET).status());
}
private static Set<TyrantUnlock> allUnlocks() {
return EnumSet.allOf(TyrantUnlock.class);
}
private static GameState game(
int choices,
Set<TyrantUnlock> unlocks,
Set<TyrantArmorPiece> claimed
) {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, choices, unlocks, claimed
);
}
}
@@ -0,0 +1,27 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
final class TyrantArmorSpecTest {
@Test
void everyPieceIsNetheriteWithNamedLoreAndApprovedEnchantments() {
assertEquals(Material.NETHERITE_HELMET,
TyrantArmorSpec.forPiece(TyrantArmorPiece.HELMET, "Alex").material());
assertEquals(Material.NETHERITE_CHESTPLATE,
TyrantArmorSpec.forPiece(TyrantArmorPiece.CHESTPLATE, "Alex").material());
assertEquals(Material.NETHERITE_LEGGINGS,
TyrantArmorSpec.forPiece(TyrantArmorPiece.LEGGINGS, "Alex").material());
assertEquals(Material.NETHERITE_BOOTS,
TyrantArmorSpec.forPiece(TyrantArmorPiece.BOOTS, "Alex").material());
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.HELMET, "Alex");
assertEquals("Tyrant's Alex Helmet", spec.displayName());
assertTrue(spec.lore().stream().anyMatch(line -> line.contains("Alex")));
assertEquals(5, spec.protectionLevel());
assertEquals(1, spec.mendingLevel());
}
}
@@ -10,12 +10,16 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -97,6 +101,74 @@ final class TyrantCommandTest {
&& message.contains("ASSASSIN")));
}
@Test
void fullyUnlockedTyrantCanClaimNamedArmorIntoInventory() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, 2, EnumSet.allOf(TyrantUnlock.class), Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(
game, Map.of(tyrantId, PlayerState.newPlayer(tyrantId, "Alex"))
));
PlayerInventory inventory = mock(PlayerInventory.class);
when(inventory.firstEmpty()).thenReturn(4);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
when(tyrant.getName()).thenReturn("Alex");
when(tyrant.getInventory()).thenReturn(inventory);
ItemStack reward = mock(ItemStack.class);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
when(factory.create(TyrantArmorPiece.HELMET, "Alex")).thenReturn(reward);
TyrantCommand command = new TyrantCommand(
manager, new TyrantProgressionService(), factory
);
command.onCommand(
tyrant, mock(Command.class), "tyrant", new String[] {"armor", "helmet"}
);
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state ->
state.game().unspentChoices() == 1
&& state.game().claimedArmor().equals(Set.of(TyrantArmorPiece.HELMET))
));
verify(inventory).setItem(4, reward);
verify(manager).saveIfDirty();
}
@Test
void fullInventoryDoesNotConsumeChoiceOrCreateArmor() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, 1, EnumSet.allOf(TyrantUnlock.class), Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(
game, Map.of(tyrantId, PlayerState.newPlayer(tyrantId, "Alex"))
));
PlayerInventory inventory = mock(PlayerInventory.class);
when(inventory.firstEmpty()).thenReturn(-1);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
when(tyrant.getInventory()).thenReturn(inventory);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
TyrantCommand command = new TyrantCommand(
manager, new TyrantProgressionService(), factory
);
command.onCommand(
tyrant, mock(Command.class), "tyrant", new String[] {"armor", "boots"}
);
verify(manager, org.mockito.Mockito.never()).replaceState(any());
verify(factory, org.mockito.Mockito.never()).create(any(), any());
verify(tyrant).sendMessage(org.mockito.ArgumentMatchers.contains("inventory is full"));
}
@Test
void assigningClassNotifiesTyrantAndNewHolder() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
@@ -1,9 +1,12 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -46,4 +49,24 @@ final class TyrantControlPanelModelTest {
assertEquals("Blade", model.classHolders().get(TyrantClass.ASSASSIN));
assertEquals(Duration.ofSeconds(90), model.intelligenceCooldown());
}
@Test
void armorAvailabilityRequiresAllUnlocksAChoiceAndAnUnclaimedSlot() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
Instant now = Instant.parse("2026-09-04T00:00:00Z");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, 1, EnumSet.allOf(TyrantUnlock.class), Set.of(TyrantArmorPiece.HELMET)
);
PlayerState tyrant = PlayerState.newPlayer(tyrantId, "Tyrant");
TyrantControlPanelModel model = TyrantControlPanelModel.create(
game, tyrant, Map.of(tyrantId, tyrant), now
);
assertTrue(model.allStandardUnlocksPurchased());
assertFalse(model.armorAvailable(TyrantArmorPiece.HELMET));
assertTrue(model.armorAvailable(TyrantArmorPiece.CHESTPLATE));
}
}
@@ -1,16 +1,12 @@
package games.dmg.spigottyrant;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -42,7 +38,7 @@ final class TyrantDeathListenerTest {
when(manager.game()).thenReturn(game);
when(manager.snapshot()).thenReturn(before);
TyrantSuccessionService succession = mock(TyrantSuccessionService.class);
when(succession.succeed(eq(before), eq(Optional.of(killerId)), any(), any()))
when(succession.succeed(eq(before), eq(Optional.of(killerId))))
.thenReturn(succeeded);
TyrantPresentation presentation = mock(TyrantPresentation.class);
Server server = mock(Server.class);
@@ -58,8 +54,7 @@ final class TyrantDeathListenerTest {
manager,
succession,
presentation,
server,
Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC)
server
);
listener.onDeath(event);
@@ -2,6 +2,7 @@ package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
@@ -47,6 +48,45 @@ final class TyrantProgressionServiceTest {
assertEquals(paused, service.recordVigilanteDeath(paused, Optional.of(TYRANT)));
}
@Test
void administrativePointGrantAddsOneLevelAndChoicePerInvocation() {
GameState base = runningState(2, 1, Set.of(TyrantUnlock.ASSASSIN));
GameState before = new GameState(
base.lifecycle(), base.tyrantId(), base.vigilanteId(), base.pendingTyrant(),
base.pendingVigilante(), base.pausedAt(), base.accumulatedPausedTime(),
base.tyrantLevel(), base.unspentChoices(), base.purchases(),
Set.of(TyrantArmorPiece.HELMET)
);
GameState first = service.grantPoint(before);
GameState second = service.grantPoint(first);
assertEquals(4, second.tyrantLevel());
assertEquals(3, second.unspentChoices());
assertEquals(before.purchases(), second.purchases());
assertEquals(before.tyrantId(), second.tyrantId());
assertEquals(before.vigilanteId(), second.vigilanteId());
assertEquals(before.claimedArmor(), second.claimedArmor());
}
@Test
void administrativePointGrantRequiresRunningGameAndCurrentTyrant() {
GameState running = runningState(0, 1, Set.of());
GameState paused = new GameState(
GameLifecycle.PAUSED, running.tyrantId(), running.vigilanteId(),
running.pendingTyrant(), running.pendingVigilante(),
Optional.of(java.time.Instant.parse("2026-08-14T12:00:00Z")),
Duration.ZERO, 0, 1, Set.of()
);
GameState vacant = new GameState(
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
);
assertThrows(IllegalStateException.class, () -> service.grantPoint(paused));
assertThrows(IllegalStateException.class, () -> service.grantPoint(vacant));
}
@Test
void purchaseConsumesOneChoiceAndCannotBeBoughtTwice() {
GameState before = runningState(1, 2, Set.of());
@@ -90,17 +130,24 @@ final class TyrantProgressionServiceTest {
@Test
void endingReignClearsLevelChoicesAndPurchases() {
GameState progressed = runningState(
GameState base = runningState(
4,
2,
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
);
GameState progressed = new GameState(
base.lifecycle(), base.tyrantId(), base.vigilanteId(), base.pendingTyrant(),
base.pendingVigilante(), base.pausedAt(), base.accumulatedPausedTime(),
base.tyrantLevel(), base.unspentChoices(), base.purchases(),
Set.of(TyrantArmorPiece.BOOTS)
);
GameState reset = service.resetReignProgression(progressed);
assertEquals(0, reset.tyrantLevel());
assertEquals(0, reset.unspentChoices());
assertEquals(Set.of(), reset.purchases());
assertEquals(Set.of(), reset.claimedArmor());
}
private static GameState runningState(
@@ -1,7 +1,6 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import java.time.Duration;
import java.time.Instant;
@@ -9,7 +8,6 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
import org.junit.jupiter.api.Test;
final class TyrantSuccessionServiceTest {
@@ -18,26 +16,26 @@ final class TyrantSuccessionServiceTest {
private static final UUID KILLER = UUID.fromString("33333333-3333-3333-3333-333333333333");
private static final UUID OTHER = UUID.fromString("44444444-4444-4444-4444-444444444444");
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final TyrantSuccessionService service = new TyrantSuccessionService(
new RoleCandidateSelector(),
Duration.ofHours(24),
Duration.ofHours(24),
RandomGenerator.of("L64X128MixRandom")
);
private final TyrantSuccessionService service = new TyrantSuccessionService();
@Test
void playerKillerBecomesTyrantAndEntireReignIsReset() {
PersistentState before = progressedState();
LifecycleState after = service.succeed(
before, Optional.of(KILLER), Set.of(KILLER, OTHER), NOW
before, Optional.of(KILLER)
);
assertEquals(Optional.of(KILLER), after.game().tyrantId());
assertEquals(0, after.game().tyrantLevel());
assertEquals(1, after.game().unspentChoices());
assertEquals(Set.of(), after.game().purchases());
assertNotEquals(after.game().tyrantId(), after.game().vigilanteId());
assertEquals(Set.of(), after.game().claimedArmor());
assertEquals(Optional.empty(), after.game().vigilanteId());
assertEquals(Optional.empty(), after.game().pendingVigilante());
assertEquals(
Optional.of(ArenaRole.VIGILANTE), RoleArenaPolicy.openRole(after.game())
);
for (PlayerState player : after.players().values()) {
assertEquals(TyrantClass.NONE, player.tyrantClass());
assertEquals(Optional.empty(), player.followerOf());
@@ -46,22 +44,22 @@ final class TyrantSuccessionServiceTest {
}
@Test
void environmentalDeathRandomlySelectsRecentPlayerInsteadOfFormerTyrant() {
void environmentalDeathOpensTyrantArenaWithoutPendingSelection() {
PersistentState before = progressedState();
LifecycleState after = service.succeed(before, Optional.empty(), Set.of(OTHER), NOW);
LifecycleState after = service.succeed(before, Optional.empty());
UUID selected = after.game().tyrantId().orElseGet(() ->
after.game().pendingTyrant().orElseThrow().candidateId()
);
assertNotEquals(OLD_TYRANT, selected);
assertEquals(Optional.empty(), after.game().tyrantId());
assertEquals(Optional.empty(), after.game().pendingTyrant());
assertEquals(Optional.empty(), after.game().vigilanteId());
}
private static PersistentState progressedState() {
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(OLD_TYRANT), Optional.of(OLD_VIGILANTE),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
5, 2, Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
5, 2, Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH),
Set.of(TyrantArmorPiece.CHESTPLATE)
);
Map<UUID, PlayerState> players = Map.of(
OLD_TYRANT, active(new PlayerState(
@@ -25,6 +25,12 @@ final class TyrantTabCompleterTest {
List<String> buy = completer.onTabComplete(
player, command, "tyrant", new String[] {"buy", "ro"}
);
List<String> armor = completer.onTabComplete(
player, command, "tyrant", new String[] {"armor", "ch"}
);
List<String> armorExtra = completer.onTabComplete(
player, command, "tyrant", new String[] {"armor", "helmet", ""}
);
List<String> assign = completer.onTabComplete(
player, command, "tyrant", new String[] {"assign", "f"}
);
@@ -32,9 +38,13 @@ final class TyrantTabCompleterTest {
player, command, "tyrant", new String[] {"relinquish", "c"}
);
assertTrue(root.containsAll(List.of("menu", "status", "buy", "assign", "item")));
assertTrue(root.containsAll(List.of(
"menu", "status", "buy", "armor", "assign", "item"
)));
assertFalse(root.contains("SomePlayer"));
assertEquals(List.of("roster_intelligence"), buy);
assertEquals(List.of("chestplate"), armor);
assertEquals(List.of(), armorExtra);
assertEquals(List.of("fixer"), assign);
assertEquals(List.of("confirm"), confirm);
}
@@ -0,0 +1,30 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class VigilanteArenaSuccessionServiceTest {
@Test
void eligibleBossWinnerClaimsVacantVigilanteRole() {
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID winner = UUID.fromString("22222222-2222-2222-2222-222222222222");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
);
PersistentState before = new PersistentState(
game, Map.of(winner, PlayerState.newPlayer(winner, "Winner"))
);
LifecycleState claimed = new VigilanteArenaSuccessionService().claim(before, winner);
assertEquals(Optional.of(winner), claimed.game().vigilanteId());
assertEquals(Optional.empty(), claimed.game().pendingVigilante());
}
}
@@ -33,7 +33,8 @@ final class YamlTyrantStateRepositoryTest {
Duration.ofMinutes(15),
3,
2,
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH),
Set.of(TyrantArmorPiece.HELMET, TyrantArmorPiece.BOOTS)
);
PlayerState player = new PlayerState(
tyrant,