Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
553db666e1 | ||
|
|
e5de6b76e7 | ||
|
|
4b6c709157 | ||
|
|
17a5c66575 | ||
|
|
f0dbde17b8 | ||
|
|
9ed0eed832 | ||
|
|
10f670a7da | ||
|
|
7318d4afa6 | ||
|
|
80810b3ba1 | ||
|
|
34f32cd32d | ||
|
|
0b202f588d | ||
|
|
ec7d50c2b5 | ||
|
|
62f6352633 | ||
|
|
3d65576010 | ||
|
|
323d177573 | ||
|
|
ea267ff867 | ||
|
|
24c54a7281 | ||
|
|
fe07c202f9 | ||
|
|
777cbc6b4b | ||
|
|
cd38e87487 | ||
|
|
a82d20c7e6 | ||
|
|
1642d46f1b | ||
|
|
0371337a3e | ||
|
|
96c0c0014c | ||
|
|
a195658be4 | ||
|
|
ddd256802b | ||
|
|
58e6dec724 | ||
|
|
63dc5ff736 |
@@ -8,28 +8,32 @@ description: Defines lifecycle, succession, selection, and pause behavior for a
|
|||||||
|
|
||||||
## Lifecycle
|
## 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.
|
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
|
## 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
|
## 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
|
## 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
|
## Related stories
|
||||||
|
|
||||||
- [Start, pause, and administer the game](../user-stories/us-001-start-pause-and-administer.md)
|
- [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)
|
- [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)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: Defines the durable state and timekeeping needed to restore a Tyran
|
|||||||
|
|
||||||
# Persistence Model
|
# 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.
|
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)
|
- [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)
|
- [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)
|
- [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,7 +8,7 @@ description: Defines Tyrant-side classes, Vigilante-side membership, progression
|
|||||||
|
|
||||||
## Roles and affiliations
|
## 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.
|
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 Resistance
|
||||||
- Permanent Strength
|
- 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
|
## Proximity
|
||||||
|
|
||||||
Unless a more specific rule applies, "near the Tyrant" means in the same world and within a configurable 50-block three-dimensional distance.
|
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)
|
- [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)
|
- [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)
|
- [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)
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ This bundle documents the asymmetric Tyrant game, its role succession, class abi
|
|||||||
## Explore
|
## Explore
|
||||||
|
|
||||||
- [User stories](user-stories/index.md)
|
- [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)
|
- [Game state machine](concepts/game-state-machine.md)
|
||||||
- [Role and class model](concepts/role-and-class-model.md)
|
- [Role and class model](concepts/role-and-class-model.md)
|
||||||
- [Persistence model](concepts/persistence-model.md)
|
- [Persistence model](concepts/persistence-model.md)
|
||||||
|
|||||||
+305
@@ -6,6 +6,311 @@ description: Chronological record of material decisions affecting the Spigot Tyr
|
|||||||
|
|
||||||
# Spigot Tyrant Design Log
|
# 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.
|
||||||
|
- Suggestions now follow argument position, filter case-insensitively, expose eligible assignment and recruitment targets, identify current Followers for dismissal, and gate administration by permission.
|
||||||
|
- Administrative completion remains available to permitted console senders, while player-only commands return no console suggestions.
|
||||||
|
- Verified syntax, eligibility, membership, permissions, prefix filtering, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-21 — Contextual command completion started
|
||||||
|
|
||||||
|
- US-018 begins a test-first replacement of Bukkit's player-name fallback with syntax-aware Tyrant, Vigilante, and administrative command suggestions.
|
||||||
|
- Player arguments will be filtered by the applicable assignment or membership rules, while administrative suggestions remain permission-gated.
|
||||||
|
|
||||||
|
## 2026-08-21 — Single-mob Tamer custody completed
|
||||||
|
|
||||||
|
- Completed US-007 with an authoritative one-mob custody limit that rejects a second capture before inventory, entity, or state mutation and tells the Tamer to release the held mob first.
|
||||||
|
- Releasing or dropping custody permits another capture immediately, while durable state preserves the limit across logout, restart, recovery, and repeated interaction.
|
||||||
|
- Legacy excess custody retains the oldest mob and safely releases extras; offline or failed releases remain durable for periodic retry.
|
||||||
|
- Verified domain rejection, listener ordering, legacy reconciliation, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-21 — Single-mob Tamer custody started
|
||||||
|
|
||||||
|
- US-007 begins a test-first one-mob custody limit for active Tamers.
|
||||||
|
- New captures will be rejected before mutation while custody exists, and legacy excess custody will be released safely with failed releases retained for retry.
|
||||||
|
|
||||||
|
## 2026-08-21 — Former Tamer automatic release completed
|
||||||
|
|
||||||
|
- Completed US-004 and US-007 so every captured mob is released automatically after its holder loses the Tamer class, regardless of the class-removal path.
|
||||||
|
- Online former Tamers release at a validated safe location; offline, dead, unsafe, malformed, or otherwise unspawnable custody remains durable and retries periodically after login or movement.
|
||||||
|
- Custody records and captured-mob items are removed only for successful spawns, while ordinary inventory remains untouched.
|
||||||
|
- Verified former-Tamer filtering, offline deferral, partial success, custody preservation, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-21 — Former Tamer automatic release started
|
||||||
|
|
||||||
|
- US-004 and US-007 begin a test-first implementation that automatically releases captured mobs when a player loses the Tamer class.
|
||||||
|
- Release will preserve ordinary inventory, remove custody only after successful spawning, and defer safely for offline players or unsafe locations before retrying.
|
||||||
|
|
||||||
|
## 2026-08-21 — Bound role control items completed
|
||||||
|
|
||||||
|
- Completed US-017 and the reopened US-015 and US-016 criteria with configurable owner-bound Tyrant and Vigilante control items that open their respective panels.
|
||||||
|
- Role items are reconciled on login and periodically after role transitions, deduplicated, removed after role loss, and recoverable through `/tyrant item` or `/vigilante item` without dropping overflow.
|
||||||
|
- Drop, pickup, container, drag, hopper, placement, dispenser, item-frame, death, stale-owner, and stale-role paths prevent transfer or remove invalid items.
|
||||||
|
- Added default `Tyrant Control` Nether Star and `Vigilante Control` Compass settings and validation.
|
||||||
|
- Verified role policy, delayed reconciliation, panel interaction, command recovery, settings defaults, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-21 — Bound role control item implementation started
|
||||||
|
|
||||||
|
- US-015, US-016, and US-017 begin a test-first implementation of configurable owner-bound Tyrant and Vigilante control items.
|
||||||
|
- Delivery, command recovery, interaction, role validation, transfer prevention, role-loss cleanup, and duplicate resistance will build on the existing bound-item patterns.
|
||||||
|
|
||||||
|
## 2026-08-21 — Bound role control items planned
|
||||||
|
|
||||||
|
- Added US-017 for secure owner-bound Tyrant and Vigilante inventory items that open the corresponding control panels.
|
||||||
|
- The Tyrant item defaults to a Nether Star named `Tyrant Control`, and the Vigilante item defaults to a Compass named `Vigilante Control`; both material and name are configurable.
|
||||||
|
- Planned automatic role-assignment and login delivery, command recovery, full transfer prevention, removal on role loss or inventory departure, and duplicate resistance.
|
||||||
|
- Reopened US-015 and US-016 in the backlog to track control-panel access through the new role items.
|
||||||
|
|
||||||
|
## 2026-08-21 — Vigilante control panel completed
|
||||||
|
|
||||||
|
- Completed US-016 and the reopened US-008 criteria with resistance overview, recruitment, Follower management, and confirmed role-relinquishment screens.
|
||||||
|
- `/vigilante` and `/vigilante menu` open the panel, while existing invitation, acceptance, dismissal, leave, and relinquishment commands remain available.
|
||||||
|
- Added a 100-tick login delay with online, lifecycle, role, and already-opened session checks so only the current Vigilante receives one automatic panel per login.
|
||||||
|
- Pending invitations are visible and idempotent, existing Followers cannot be reinvited, and all menu mutations delegate to authoritative command and domain rules.
|
||||||
|
- Verified command routing, authorization, overview state, invitation rules, delayed opening safeguards, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-21 — Vigilante control panel implementation started
|
||||||
|
|
||||||
|
- US-008 and US-016 begin a test-first implementation of the Vigilante control panel, delayed login opening, resistance overview, recruitment, dismissal, and role relinquishment.
|
||||||
|
- The panel will reuse authoritative Follower and relinquishment rules while preserving all existing command alternatives.
|
||||||
|
|
||||||
|
## 2026-08-21 — Vigilante control panel planned
|
||||||
|
|
||||||
|
- Added US-016 for a graphical Vigilante control panel covering resistance overview, recruitment, Follower management, and confirmed role relinquishment while preserving command alternatives.
|
||||||
|
- Planned automatic control-panel opening five seconds after the active Vigilante joins a running event, with lifecycle, role, disconnect, and duplicate-opening safeguards.
|
||||||
|
- Reopened US-008 in the backlog to track the approved control-panel interactions and nearby-Follower combat contribution display.
|
||||||
|
|
||||||
|
## 2026-08-21 — Tyrant control panel completed
|
||||||
|
|
||||||
|
- Completed US-015 and the reopened US-003, US-004, and US-009 control-panel criteria with overview, unlock purchase confirmation, class assignment, and ability screens.
|
||||||
|
- The active Tyrant can open the panel with `/tyrant` or `/tyrant menu`; existing command alternatives remain available and provide the authoritative mutations behind menu actions.
|
||||||
|
- Added a 100-tick login delay with online, lifecycle, role, and already-opened session checks so only the current Tyrant receives one automatic panel per login.
|
||||||
|
- Verified command routing, authorization, overview state, delayed opening safeguards, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-21 — Tyrant control panel implementation started
|
||||||
|
|
||||||
|
- US-003, US-004, US-009, and US-015 begin a test-first implementation of the Tyrant control panel and its delayed login opening.
|
||||||
|
- The installed Spigot API does not expose a native dialog API, so the control panel will use a server-rendered inventory menu while preserving command alternatives.
|
||||||
|
|
||||||
|
## 2026-08-21 — Tyrant control panel planned
|
||||||
|
|
||||||
|
- Added US-015 for a graphical Tyrant control panel covering reign overview, unlock purchases, class assignments, and roster intelligence while retaining command alternatives.
|
||||||
|
- Planned automatic control-panel opening five seconds after the active Tyrant joins a running event, with lifecycle, role, disconnect, and duplicate-opening safeguards.
|
||||||
|
- Reopened US-003, US-004, and US-009 in the backlog to track their approved control-panel interactions.
|
||||||
|
|
||||||
|
## 2026-08-18 — Preserve ordinary potion effects
|
||||||
|
|
||||||
|
- Corrected Assassin effect maintenance so it refreshes active Tyrant abilities without removing invisibility, Speed, or Weakness supplied by potions, commands, or other plugins.
|
||||||
|
- Expired or inapplicable Tyrant-managed effects stop refreshing and expire naturally within their short 30-tick maintenance duration, while double-jump flight cleanup remains immediate.
|
||||||
|
- Reopened and completed US-005 with regression coverage; verified `./gradlew clean check jar`.
|
||||||
|
|
||||||
|
## 2026-08-14 — Opt-out proximity glow completed
|
||||||
|
|
||||||
|
- Extended US-011 with a globally visible green outline for opted-out players in the Tyrant's world and configured range while the event is running.
|
||||||
|
- A periodic controller removes the glow when eligibility, proximity, world, lifecycle, or player availability changes and clears managed state on logout and shutdown.
|
||||||
|
- Scoreboard presentation records and restores each player's previous team and glowing state instead of permanently replacing unrelated presentation.
|
||||||
|
- Verified inclusive range behavior, lifecycle and availability gating, green presentation, restoration, and the full Gradle build.
|
||||||
|
|
||||||
|
## 2026-08-14 — Opt-out proximity glow started
|
||||||
|
|
||||||
|
- US-011 is reopened for a test-first green outline on opted-out players near the Tyrant during a running event, using the existing configurable Tyrant range.
|
||||||
|
- Cleanup will cover range, world, participation, lifecycle, availability, shutdown, and restoration of pre-existing scoreboard-team membership.
|
||||||
|
|
||||||
|
## 2026-08-14 — Join messaging refinement completed
|
||||||
|
|
||||||
|
- Updated US-011 so join output no longer announces running, paused, or unstarted lifecycle state.
|
||||||
|
- Eligible unassigned players now see the neutral prompt, `Use /tyrant optout if you do not want to participate in Tyrant events.`
|
||||||
|
- Role, class, ability, cooldown, and existing opt-out details remain unchanged.
|
||||||
|
- Verified both assigned and unassigned join output with automated tests.
|
||||||
|
|
||||||
|
## 2026-08-14 — Join messaging refinement started
|
||||||
|
|
||||||
|
- US-011 is reopened to remove lifecycle announcements from join output and make the opt-out prompt refer generally to Tyrant events.
|
||||||
|
|
||||||
|
## 2026-08-14 — Opt-out combat protection completed
|
||||||
|
|
||||||
|
- Extended US-011 with bidirectional combat isolation between opted-out players and the Tyrant, Assassin, Fixer, or Tamer while the event is running.
|
||||||
|
- Direct player attacks and player-fired projectiles are cancelled at highest event priority, and the attacker is told that the target has chosen not to participate in the Tyrant event.
|
||||||
|
- Paused, unstarted, and unrelated combat remains ordinary Minecraft gameplay.
|
||||||
|
- Verified both attack directions, every Tyrant-side class, projectile attribution, lifecycle gating, and unrelated combat with automated tests.
|
||||||
|
|
||||||
|
## 2026-08-14 — Opt-out combat protection started
|
||||||
|
|
||||||
|
- US-011 is reopened for test-first running-event combat isolation between opted-out players and the Tyrant or Tyrant-side class holders.
|
||||||
|
- Paused and unstarted gameplay remains ordinary Minecraft combat; blocked direct and projectile attackers receive a concise Tyrant event participation message.
|
||||||
|
|
||||||
|
## 2026-08-14 — Tamer completed
|
||||||
|
|
||||||
|
- Completed US-007 with the no-cooldown owner-bound Tamer's Lead, server-generated entity snapshots, durable capture IDs, and one bound Captured Mob item per entity.
|
||||||
|
- Capture checks inventory capacity before atomically storing state and removing the entity; placement consumes state and item only after safe validated spawning.
|
||||||
|
- Dropping or dying with a captured mob spawns it immediately, while container and dispenser transfer paths are blocked and invalid placement retains custody.
|
||||||
|
- Ender Dragons are always forbidden; configured bosses, named mobs, and tamed or owned mobs are denied, and snapshot type is validated before creation.
|
||||||
|
- Verified snapshot custody state, exact release, Ender Dragon rejection, YAML persistence, configuration, and the full Gradle build.
|
||||||
|
|
||||||
|
## 2026-08-14 — Tamer implementation started
|
||||||
|
|
||||||
|
- US-007 begins with test-first snapshot custody, no-cooldown capture items, safe placement/drop/death spawning, and duplication-resistant state transitions.
|
||||||
|
|
||||||
|
## 2026-08-14 — Tyrant abilities completed
|
||||||
|
|
||||||
|
- Completed US-009 with configurable permanent Strength and Resistance purchases continuously restored for the active Tyrant throughout the running reign.
|
||||||
|
- Added `/tyrant intelligence` with a rolling configurable 24-hour unpaused cooldown and an aggregate count of the Vigilante plus assigned non-opted-out Followers without identities or locations.
|
||||||
|
- Ability ownership and cooldowns remain visible in status output, persist across restart, and are cleared by existing reign succession cleanup.
|
||||||
|
- Verified intelligence authorization, counts, cooldown persistence, effect configuration, and the full Gradle build.
|
||||||
|
|
||||||
|
## 2026-08-14 — Tyrant ability implementation started
|
||||||
|
|
||||||
|
- US-009 begins with test-first permanent effect ownership, automatic reapplication, and rolling-24-hour opposition roster intelligence.
|
||||||
|
|
||||||
|
## 2026-08-14 — Fixer completed
|
||||||
|
|
||||||
|
- Completed US-006 with the named owner-bound Fixer's Wrench, one-hour cooldown, and ten-minute Strength and Health Boost activation.
|
||||||
|
- Activation records a fixed normal two-row or near-Tyrant three-row heart tier using same-world configurable proximity at use time.
|
||||||
|
- Continuously refreshed effects survive death, login, milk, and ordinary removal; expiry and class loss remove effects and clamp health safely to the resulting maximum.
|
||||||
|
- Verified activation tiers, active and cooldown deadlines, item consumption/readiness, persistence, and the full Gradle build.
|
||||||
|
|
||||||
|
## 2026-08-14 — Fixer implementation started
|
||||||
|
|
||||||
|
- US-006 begins with test-first bound-item combat boosts, activation-time proximity tiers, persistent cooldowns, and safe health cleanup.
|
||||||
|
|
||||||
|
## 2026-08-14 — Assassin completed
|
||||||
|
|
||||||
|
- Completed US-005 with named owner-bound Assassin Cloak activation, one-hour cooldown, particle-free ten-minute invisibility, and activation-time doubling within the Tyrant's configurable range.
|
||||||
|
- Added 60-second double-jump gating, upward launch, 15-second Speed, 20-second Weakness III, and grounded readiness restoration.
|
||||||
|
- Active effects and cooldowns persist as UTC deadlines and are continuously restored after death, login, milk, or ordinary removal while valid.
|
||||||
|
- Verified duration tiers, cooldown consumption and refresh, jump effect deadlines, class constraints, persistence, and the full Gradle build.
|
||||||
|
|
||||||
|
## 2026-08-14 — Assassin implementation started
|
||||||
|
|
||||||
|
- US-005 begins with test-first bound-item invisibility, near-Tyrant duration doubling, and cooldown-gated double-jump effects.
|
||||||
|
|
||||||
|
## 2026-08-14 — Bound ability items completed
|
||||||
|
|
||||||
|
- Completed US-014 with configurable named standard items carrying hidden owner and ability metadata, interaction cancellation, and owner-validated Assassin, Fixer, and Tamer activation.
|
||||||
|
- Cooldown items are consumed only on successful use, restored automatically when ready or by `/tyrant item`, deduplicated, and withheld with clear guidance when inventory space is unavailable.
|
||||||
|
- Drop, pickup, placement, crafting/storage movement, item-frame, dispenser, death, class loss, reign reset, login, and restart paths prevent transfer and duplication.
|
||||||
|
- Captured-mob custody items use independent IDs, reconcile missing or duplicate inventory items against durable snapshots, and spawn rather than vanish on drop or death.
|
||||||
|
- Verified readiness consumption and refresh, assignment cleanup, durable metadata state, captured custody, and the full Gradle build.
|
||||||
|
|
||||||
|
## 2026-08-14 — Bound ability item implementation started
|
||||||
|
|
||||||
|
- US-014 begins with test-first item readiness and recovery, owner metadata, transfer prevention, cooldown redelivery, and Tamer item permanence.
|
||||||
|
|
||||||
|
## 2026-08-14 — Vigilante and Followers completed
|
||||||
|
|
||||||
|
- Completed US-008 with `/vigilante` invitation, acceptance, dismissal, and leave flows while preserving independent Tyrant-side class assignments.
|
||||||
|
- Added Tyrant/Vigilante combat tracking and continuously refreshed Strength and Resistance based on living same-world Followers within the configurable 50-block range, capped at Strength V and Resistance IV by default.
|
||||||
|
- Every Vigilante death now awards progression only to the Tyrant killer, clears Followers, and applies normal recently-active replacement with previous-holder fallback.
|
||||||
|
- Verified membership, dual alignment, scaling and caps, role maintenance, command metadata, and death/progression components with the Gradle suite.
|
||||||
|
|
||||||
|
## 2026-08-14 — Vigilante and Follower implementation started
|
||||||
|
|
||||||
|
- US-008 begins with test-first invitation membership, death reroll and Tyrant reward integration, combat tracking, and proximity-scaled capped effects.
|
||||||
|
|
||||||
|
## 2026-08-14 — Participation completed
|
||||||
|
|
||||||
|
- Completed US-011 with `/tyrant status`, `optout`, `optin`, and confirmed `relinquish` flows plus comprehensive join messages for lifecycle, roles, classes, abilities, and cooldowns.
|
||||||
|
- Opt-out clears ordinary classes and Follower membership, persists until the player explicitly opts back in, and enforces the configurable seven-day unpaused waiting period.
|
||||||
|
- Central role holders must safely relinquish first; Tyrant relinquishment performs full succession and Vigilante relinquishment performs a normal Vigilante reroll.
|
||||||
|
- Updated all assignment and candidate paths to exclude opted-out players and verified status, persistence, cleanup, cooldown, and relinquishment behavior with the Gradle suite.
|
||||||
|
|
||||||
|
## 2026-08-14 — Participation implementation started
|
||||||
|
|
||||||
|
- US-011 begins with test-first opt-out/opt-in state, seven-day re-entry gating, safe role relinquishment guidance, and complete join status messages.
|
||||||
|
|
||||||
|
## 2026-08-14 — Role maintenance completed
|
||||||
|
|
||||||
|
- Completed US-010 with configurable periodic role maintenance, exact 48-hour inactivity handling, and 24-hour recently-active candidate filtering.
|
||||||
|
- Added online confirmation for pending roles, 24-hour expiry and reroll, former-holder fallback rules, vacant-role retries, opt-out filtering, and Tyrant/Vigilante conflict prevention.
|
||||||
|
- Tyrant inactivity uses full reign succession cleanup; Vigilante inactivity clears only that role's Followers before replacement.
|
||||||
|
- Verified confirmation, expiry reroll, Follower cleanup, exact inactivity boundary, full Tyrant succession, and pause-safe scheduler integration with the Gradle suite.
|
||||||
|
|
||||||
|
## 2026-08-14 — Role maintenance implementation started
|
||||||
|
|
||||||
|
- US-010 begins with test-first inactivity replacement, pending confirmation and expiry, conflict-safe candidate retries, and pause awareness.
|
||||||
|
|
||||||
|
## 2026-08-14 — Tyrant succession completed
|
||||||
|
|
||||||
|
- Completed US-002 with player-killer transfer, recent-player environmental succession, former-holder avoidance, pending offline confirmation, and distinct replacement Vigilante selection.
|
||||||
|
- Tyrant death clears all classes, Followers, cooldowns, item readiness, purchases, levels, and choices before granting the successor one starting choice.
|
||||||
|
- Added death-event integration, server announcements, and a scoreboard-team-backed red Tyrant name and glowing outline that is restored on login and removed on pause or shutdown.
|
||||||
|
- Verified succession attribution, random exclusion, reign cleanup, role separation, death integration, and red presentation with the Gradle suite.
|
||||||
|
|
||||||
|
## 2026-08-14 — Tyrant succession implementation started
|
||||||
|
|
||||||
|
- US-002 begins with test-first killer and environmental succession, reign-wide cleanup, replacement Vigilante selection, and red Tyrant presentation.
|
||||||
|
|
||||||
|
## 2026-08-14 — Administrative lifecycle completed
|
||||||
|
|
||||||
|
- Completed US-001 with permission-gated `/tyrantadmin` start, status, pause, resume, and confirmed reset operations.
|
||||||
|
- Initial Vigilantes may be assigned explicitly or selected among eligible recently active players, with offline selections persisted as pending confirmations.
|
||||||
|
- Pause/resume preserves progression and assignments, shifts every cooldown, participation, inactivity, and pending-selection deadline, and invokes managed effect suppression/restoration.
|
||||||
|
- Added detailed operator status output and verified lifecycle, timer shifting, permissions, confirmation safety, candidate filtering, metadata, and effect hooks with the Gradle suite.
|
||||||
|
|
||||||
|
## 2026-08-14 — Administrative lifecycle implementation started
|
||||||
|
|
||||||
|
- US-001 begins with test-first game start, pause/resume deadline freezing, permission-gated administration, inspection, and confirmed reset behavior.
|
||||||
|
|
||||||
|
## 2026-08-14 — Tyrant class assignment completed
|
||||||
|
|
||||||
|
- Completed US-004 with `/tyrant assign <class> <player>`, purchased-class authorization, UUID state, one holder per class, and one Tyrant-side class per player.
|
||||||
|
- Preserved dual alignment so Vigilantes and Followers can hold a Tyrant-side class, while rejecting the Tyrant and opted-out targets.
|
||||||
|
- Added reassignment and reign cleanup for class cooldown/item state, online assignment/removal notices, and login control reminders.
|
||||||
|
- Verified assignment constraints, dual alignment, cleanup, commands, notifications, and reminders with the Gradle test suite.
|
||||||
|
|
||||||
|
## 2026-08-14 — Tyrant class assignment implementation started
|
||||||
|
|
||||||
|
- US-004 begins with test-first purchased-class authorization, one-holder constraints, dual alignment, reassignment cleanup, and reign-wide removal.
|
||||||
|
|
||||||
## 2026-08-14 — Tyrant progression completed
|
## 2026-08-14 — Tyrant progression completed
|
||||||
|
|
||||||
- Completed US-003 with exact Tyrant-on-Vigilante kill attribution, level and choice rewards, pause guards, one-time purchases, and reign reset behavior.
|
- Completed US-003 with exact Tyrant-on-Vigilante kill attribution, level and choice rewards, pause guards, one-time purchases, and reign reset behavior.
|
||||||
|
|||||||
@@ -9,8 +9,15 @@
|
|||||||
7. [US-007: Capture and place mobs as a Tamer](us-007-capture-and-place-mobs.md)
|
7. [US-007: Capture and place mobs as a Tamer](us-007-capture-and-place-mobs.md)
|
||||||
8. [US-008: Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
|
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)
|
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)
|
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)
|
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)
|
13. [US-013: Build, test, and release the plugin](us-013-build-test-and-release.md)
|
||||||
14. [US-014: Use class ability items](us-014-use-class-ability-items.md)
|
14. [US-014: Use class ability items](us-014-use-class-ability-items.md)
|
||||||
|
15. [US-015: Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
||||||
|
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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-001: Start, pause, and administer the game"
|
title: "US-001: Start, pause, and administer the game"
|
||||||
description: Give server operators safe control over the Tyrant game's lifecycle and state.
|
description: Give server operators safe control over the Tyrant game's lifecycle and state.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-001: Start, pause, and administer the game
|
# US-001: Start, pause, and administer the game
|
||||||
@@ -11,16 +11,20 @@ As a **server operator**, I want to start, pause, resume, and inspect the game s
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] An administrator can start the first game by assigning the initial Tyrant.
|
- [x] An administrator can start the first game by assigning the initial Tyrant.
|
||||||
- [ ] The administrator may assign the first Vigilante or allow random selection from eligible recently active players.
|
- [x] The administrator may assign the first Vigilante or leave the role vacant for the configured Vigilante arena challenge.
|
||||||
- [ ] An administrator can pause and resume the game without deleting game state.
|
- [x] An administrator can pause and resume the game without deleting game state.
|
||||||
- [ ] Pausing freezes progression, cooldowns, daily-use timers, inactivity checks, and pending-selection deadlines.
|
- [x] Pausing freezes progression, cooldowns, daily-use timers, inactivity checks, and pending-selection deadlines.
|
||||||
- [ ] Role and class effects are suppressed while paused and restored when play resumes.
|
- [x] Role and class effects are suppressed while paused and restored when play resumes.
|
||||||
- [ ] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default.
|
- [x] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default.
|
||||||
- [ ] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, and pending selections.
|
- [x] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, legacy pending selections, and the configured shared role arena location.
|
||||||
- [ ] Destructive administrative operations require explicit confirmation.
|
- [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
|
## Related
|
||||||
|
|
||||||
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
||||||
- [Configure and persist game state](us-012-configure-and-persist-state.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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-002: Transfer and reset the Tyrant role"
|
title: "US-002: Transfer and reset the Tyrant role"
|
||||||
description: End a Tyrant's reign cleanly and establish a valid successor after death.
|
description: End a Tyrant's reign cleanly and establish a valid successor after death.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-002: Transfer and reset the Tyrant role
|
# US-002: Transfer and reset the Tyrant role
|
||||||
@@ -11,17 +11,18 @@ As a **player**, I want defeating the Tyrant to transfer power predictably so th
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] A player directly credited with killing the Tyrant becomes the new Tyrant.
|
- [x] A player directly credited with killing the Tyrant becomes the new Tyrant.
|
||||||
- [ ] A Tyrant death without a player killer selects an eligible opted-in player active within the preceding 24 hours at random.
|
- [x] A Tyrant death without a player killer leaves the role vacant and opens the shared Tyrant arena challenge.
|
||||||
- [ ] The former Tyrant is excluded from random succession when another eligible candidate exists.
|
- [x] The former Tyrant may compete again under the same arena eligibility and reset rules as every other opted-in participant.
|
||||||
- [ ] The former Tyrant's level, unlocks, unspent choices, and Tyrant ability cooldowns reset when the reign ends.
|
- [x] The former Tyrant's level, unlocks, unspent choices, and Tyrant ability cooldowns reset when the reign ends.
|
||||||
- [ ] Every Assassin, Fixer, Tamer, Vigilante, and Follower assignment is cleared when the Tyrant dies.
|
- [x] Every Assassin, Fixer, Tamer, Vigilante, and Follower assignment is cleared when the Tyrant dies.
|
||||||
- [ ] A new Vigilante is selected under the normal candidate rules after succession.
|
- [x] After Tyrant succession, a vacant Vigilante role opens the shared arena's Vigilante challenge.
|
||||||
- [ ] The new Tyrant receives exactly one starting unlock choice.
|
- [x] The new Tyrant receives exactly one starting unlock choice.
|
||||||
- [ ] The Tyrant's displayed name is red and the Tyrant has a red glowing outline while the game is running.
|
- [x] The Tyrant's displayed name is red and the Tyrant has a red glowing outline while the game is running.
|
||||||
- [ ] Succession cannot assign the same player as both Tyrant and Vigilante.
|
- [x] Succession cannot assign the same player as both Tyrant and Vigilante.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
||||||
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.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
|
## Acceptance criteria
|
||||||
|
|
||||||
- [x] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante.
|
- [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] 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] Available purchases are Assassin, Fixer, Tamer, roster intelligence, permanent Resistance, and permanent Strength.
|
||||||
- [x] Each class or ability can be purchased at most once during a reign.
|
- [x] Each class or ability can be purchased at most once during a reign.
|
||||||
@@ -19,9 +22,14 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
|
|||||||
- [x] A purchase consumes exactly one unspent choice and cannot be duplicated by retries or concurrent input.
|
- [x] A purchase consumes exactly one unspent choice and cannot be duplicated by retries or concurrent input.
|
||||||
- [x] Progress cannot be earned or spent while the game is paused.
|
- [x] Progress cannot be earned or spent while the game is paused.
|
||||||
- [x] Levels, purchases, and unspent choices survive logout and restart but reset when the reign ends.
|
- [x] Levels, purchases, and unspent choices survive logout and restart but reset when the reign ends.
|
||||||
|
- [x] The Tyrant can review and purchase unlocks through the Tyrant control panel.
|
||||||
|
- [x] A confirmation displays the selected unlock and choice cost before purchase.
|
||||||
|
- [x] The control panel refreshes the available choices immediately after a successful purchase.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Role and class model](../concepts/role-and-class-model.md)
|
- [Role and class model](../concepts/role-and-class-model.md)
|
||||||
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
|
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
|
||||||
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-004: Assign unlocked classes"
|
title: "US-004: Assign unlocked classes"
|
||||||
description: Let the Tyrant assign each purchased class to one eligible participant.
|
description: Let the Tyrant assign each purchased class to one eligible participant.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-004: Assign unlocked classes
|
# US-004: Assign unlocked classes
|
||||||
@@ -11,16 +11,21 @@ As the **Tyrant**, I want to assign unlocked specialist classes so that I can bu
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] The Tyrant can assign only Assassin, Fixer, and Tamer classes purchased during the current reign.
|
- [x] The Tyrant can assign only Assassin, Fixer, and Tamer classes purchased during the current reign.
|
||||||
- [ ] Each class can be assigned to at most one player at a time, and a player can hold at most one Tyrant-side class.
|
- [x] Each class can be assigned to at most one player at a time, and a player can hold at most one Tyrant-side class.
|
||||||
- [ ] Role alignment and Tyrant-side class are independent: the Vigilante or a Follower may simultaneously be the Assassin, Fixer, or Tamer.
|
- [x] Role alignment and Tyrant-side class are independent: the Vigilante or a Follower may simultaneously be the Assassin, Fixer, or Tamer.
|
||||||
- [ ] The Tyrant cannot assign a class to the Tyrant, an opted-out player, or another ineligible player.
|
- [x] The Tyrant cannot assign a class to the Tyrant, an opted-out player, or another ineligible player.
|
||||||
- [ ] Reassignment removes the class and its bound items and effects from the previous holder before granting it to the new holder.
|
- [x] Reassignment removes the class and its bound items and effects from the previous holder before granting it to the new holder.
|
||||||
- [ ] Assignment and removal are stored by UUID and communicated clearly to affected players.
|
- [x] Assignment and removal are stored by UUID and communicated clearly to affected players.
|
||||||
- [ ] A class holder is reminded of the assignment and its controls on login.
|
- [x] A class holder is reminded of the assignment and its controls on login.
|
||||||
- [ ] All class assignments end when the Tyrant's reign ends.
|
- [x] All class assignments end when the Tyrant's reign ends.
|
||||||
|
- [x] The Tyrant can assign and reassign purchased classes through the Tyrant control panel.
|
||||||
|
- [x] The control panel's player selector communicates eligibility and identifies the current class holder before confirmation.
|
||||||
|
- [x] Control-panel and command-based assignments enforce identical rules.
|
||||||
|
- [x] Removing or reassigning the Tamer class automatically releases the former holder's captured mobs while leaving ordinary inventory untouched.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Role and class model](../concepts/role-and-class-model.md)
|
- [Role and class model](../concepts/role-and-class-model.md)
|
||||||
- [Use class ability items](us-014-use-class-ability-items.md)
|
- [Use class ability items](us-014-use-class-ability-items.md)
|
||||||
|
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-005: Use Assassin abilities"
|
title: "US-005: Use Assassin abilities"
|
||||||
description: Give the Assassin timed stealth and a risky burst-mobility ability.
|
description: Give the Assassin timed stealth and a risky burst-mobility ability.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-005: Use Assassin abilities
|
# US-005: Use Assassin abilities
|
||||||
@@ -11,15 +11,22 @@ As the **Assassin**, I want stealth and burst mobility so that I can ambush oppo
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] The Assassin can activate ten minutes of invisibility once per rolling hour using the named Assassin ability item.
|
- [x] The Assassin can activate ten minutes of invisibility once per rolling hour using the named Assassin ability item.
|
||||||
- [ ] Invisibility displays no potion particles.
|
- [x] Invisibility displays no potion particles.
|
||||||
- [ ] Activation while in the same world and within the configured Tyrant range grants twenty minutes instead.
|
- [x] Activation while in the same world and within the configured Tyrant range grants twenty minutes instead.
|
||||||
- [ ] Proximity is evaluated when the ability is activated and does not alter the active duration afterward.
|
- [x] Proximity is evaluated when the ability is activated and does not alter the active duration afterward.
|
||||||
- [ ] The Assassin can double-jump once every 60 seconds without requiring an item.
|
- [x] The Assassin can double-jump once every 60 seconds without requiring an item.
|
||||||
- [ ] A successful double-jump grants Speed for 15 seconds and Weakness III for 20 seconds.
|
- [x] A successful double-jump grants Speed for 15 seconds and Weakness III for 20 seconds.
|
||||||
- [ ] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed.
|
- [x] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed.
|
||||||
- [ ] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart.
|
- [x] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart.
|
||||||
- [ ] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable.
|
- [x] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable.
|
||||||
|
- [x] Tyrant removes only Assassin potion effects that Tyrant itself applied.
|
||||||
|
- [x] Invisibility from potions, commands, or other plugins remains active for Assassins and non-Assassins.
|
||||||
|
- [x] Tyrant-managed Assassin effects are still removed when abilities expire, roles change, games stop, or the plugin disables.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Automated controller tests verify that inactive Tyrant state does not alter ordinary potion effects, active Assassin invisibility is refreshed, and refresh stops at expiry so the short managed effect expires naturally. The complete `./gradlew clean check jar` lifecycle passes.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-006: Use Fixer abilities"
|
title: "US-006: Use Fixer abilities"
|
||||||
description: Give the Fixer a periodic offensive and health enhancement strengthened near the Tyrant.
|
description: Give the Fixer a periodic offensive and health enhancement strengthened near the Tyrant.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-006: Use Fixer abilities
|
# US-006: Use Fixer abilities
|
||||||
@@ -11,13 +11,13 @@ As the **Fixer**, I want a substantial temporary combat boost so that I can prot
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] The Fixer can activate ten minutes of Strength and Health Boost once per rolling hour using the named Fixer ability item.
|
- [x] The Fixer can activate ten minutes of Strength and Health Boost once per rolling hour using the named Fixer ability item.
|
||||||
- [ ] Normal activation provides two total rows of hearts when combined with the player's normal maximum health.
|
- [x] Normal activation provides two total rows of hearts when combined with the player's normal maximum health.
|
||||||
- [ ] Activation in the same world and within the configured Tyrant range provides three total rows of hearts.
|
- [x] Activation in the same world and within the configured Tyrant range provides three total rows of hearts.
|
||||||
- [ ] Proximity is evaluated when the ability is activated and does not alter the active tier afterward.
|
- [x] Proximity is evaluated when the ability is activated and does not alter the active tier afterward.
|
||||||
- [ ] Health is safely clamped when the boost expires, is suppressed, or changes tier.
|
- [x] Health is safely clamped when the boost expires, is suppressed, or changes tier.
|
||||||
- [ ] The cooldown uses unpaused elapsed time and survives death, logout, and restart.
|
- [x] The cooldown uses unpaused elapsed time and survives death, logout, and restart.
|
||||||
- [ ] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable.
|
- [x] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-007: Capture and place mobs as a Tamer"
|
title: "US-007: Capture and place mobs as a Tamer"
|
||||||
description: Let the Tamer move supported mobs without losing or duplicating them.
|
description: Let the Tamer move supported mobs without losing or duplicating them.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-007: Capture and place mobs as a Tamer
|
# US-007: Capture and place mobs as a Tamer
|
||||||
@@ -11,16 +11,33 @@ As the **Tamer**, I want to capture a mob and release it elsewhere so that I can
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] The Tamer can use a named fishing rod on a supported targeted mob to capture it with no cooldown.
|
- [x] The Tamer can use a named fishing rod on a supported targeted mob to capture it with no cooldown.
|
||||||
- [ ] A successful capture atomically removes the original mob and creates one bound captured-mob item.
|
- [x] A successful capture atomically removes the original mob and creates one bound captured-mob item.
|
||||||
- [ ] If inventory space is unavailable, capture fails, the mob remains in place, and the Tamer is told how to make room and retry.
|
- [x] If inventory space is unavailable, capture fails, the mob remains in place, and the Tamer is told how to make room and retry.
|
||||||
- [ ] Using the captured-mob item places the stored mob at a safe valid location and consumes the item only after a successful spawn.
|
- [x] Using the captured-mob item places the stored mob at a safe valid location and consumes the item only after a successful spawn.
|
||||||
- [ ] Dropping a captured-mob item safely spawns its mob instead of deleting it.
|
- [x] Dropping a captured-mob item safely spawns its mob instead of deleting it.
|
||||||
- [ ] Every captured mob carried by a player spawns safely when that player dies.
|
- [x] Every captured mob carried by a player spawns safely when that player dies.
|
||||||
- [ ] Ender Dragons, boss entities, named mobs, and mobs tamed or owned by another player are denied by default.
|
- [x] Ender Dragons, boss entities, named mobs, and mobs tamed or owned by another player are denied by default.
|
||||||
- [ ] Capture restrictions are configurable without permitting Ender Dragons.
|
- [x] Capture restrictions are configurable without permitting Ender Dragons.
|
||||||
- [ ] Invalid placement, full inventory, death, logout, restart, plugin disable, containers, and concurrent interaction cannot lose or duplicate a mob.
|
- [x] Invalid placement, full inventory, death, logout, restart, plugin disable, containers, and concurrent interaction cannot lose or duplicate a mob.
|
||||||
- [ ] Stored mob data is validated defensively before spawning and cannot execute untrusted serialized behavior.
|
- [x] Stored mob data is validated defensively before spawning and cannot execute untrusted serialized behavior.
|
||||||
|
- [x] When a player loses the Tamer class, every captured mob in their custody is automatically released at the former Tamer's location.
|
||||||
|
- [x] Ordinary inventory items are not dropped or changed during automatic release.
|
||||||
|
- [x] Each captured-mob item and custody record is removed only after its mob spawns successfully.
|
||||||
|
- [x] Multiple captured mobs are released without duplicating entities or custody records.
|
||||||
|
- [x] If the former Tamer is offline or no safe release location is available, release is deferred without losing the captured mob.
|
||||||
|
- [x] A deferred release is retried when the former Tamer next logs in or reaches a safe location.
|
||||||
|
- [x] Tyrant death, class reassignment, opt-out, administration, and other Tamer-removal paths use the same release behavior.
|
||||||
|
- [x] A Tamer can hold custody of at most one captured mob at a time.
|
||||||
|
- [x] Attempting to capture another mob while one is held is rejected before changing either mob.
|
||||||
|
- [x] The Tamer is told to release the currently captured mob before capturing another.
|
||||||
|
- [x] Releasing or dropping the held mob immediately allows another capture.
|
||||||
|
- [x] The one-mob limit survives logout, restart, item recovery, and repeated or concurrent interaction.
|
||||||
|
- [x] If legacy state contains multiple captured mobs, extras are safely released when possible; custody is retained until release succeeds so no mob is lost.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Automated tests verify that only former Tamers are processed, offline custody remains deferred, successful releases remove only their corresponding custody records, and failed releases remain available for retry. Additional tests verify pre-mutation rejection of a second capture, clear player guidance, and safe reduction of legacy excess custody. The complete `./gradlew clean check jar` lifecycle passes.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-008: Support the Vigilante and Followers"
|
title: "US-008: Support the Vigilante and Followers"
|
||||||
description: Give the Vigilante a replaceable role and combat strength derived from nearby Followers.
|
description: Give the Vigilante a replaceable role and combat strength derived from nearby Followers.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-008: Support the Vigilante and Followers
|
# US-008: Support the Vigilante and Followers
|
||||||
@@ -11,20 +11,26 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] 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.
|
||||||
- [ ] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
|
- [x] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
|
||||||
- [ ] The Vigilante can dismiss Followers and Followers can leave voluntarily.
|
- [x] The Vigilante can dismiss Followers and Followers can leave voluntarily.
|
||||||
- [ ] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks.
|
- [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.
|
||||||
- [ ] Offline, dead, opted-out, dismissed, or out-of-range Followers do not count.
|
- [x] Offline, dead, opted-out, dismissed, or out-of-range Followers do not count.
|
||||||
- [ ] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
|
- [x] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
|
||||||
- [ ] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment.
|
- [x] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment.
|
||||||
- [ ] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV.
|
- [x] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV.
|
||||||
- [ ] Every Vigilante death ends the assignment and triggers random replacement regardless of cause.
|
- [x] Every Vigilante death ends the assignment and opens the one-player arena challenge while a Tyrant is active.
|
||||||
- [ ] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
|
- [x] The deceased Vigilante may immediately compete again under the same arena eligibility and reset rules as every other participant.
|
||||||
- [ ] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
|
- [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
|
||||||
- [ ] The Tyrant is never eligible to be selected as Vigilante.
|
- [x] 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 clears Followers and opens the Vigilante arena challenge.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Role and class model](../concepts/role-and-class-model.md)
|
- [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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-009: Purchase and use Tyrant abilities"
|
title: "US-009: Purchase and use Tyrant abilities"
|
||||||
description: Give the Tyrant persistent combat upgrades and limited intelligence about the opposition.
|
description: Give the Tyrant persistent combat upgrades and limited intelligence about the opposition.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-009: Purchase and use Tyrant abilities
|
# US-009: Purchase and use Tyrant abilities
|
||||||
@@ -11,16 +11,19 @@ As the **Tyrant**, I want permanent upgrades and limited roster intelligence so
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Purchasing permanent Strength applies the configured Strength level throughout the current reign while the game is running.
|
- [x] Purchasing permanent Strength applies the configured Strength level throughout the current reign while the game is running.
|
||||||
- [ ] Purchasing permanent Resistance applies the configured Resistance level throughout the current reign while the game is running.
|
- [x] Purchasing permanent Resistance applies the configured Resistance level throughout the current reign while the game is running.
|
||||||
- [ ] Permanent effects are restored after death, milk consumption, ordinary effect removal, login, restart, and replacement by a shorter or weaker effect.
|
- [x] Permanent effects are restored after death, milk consumption, ordinary effect removal, login, restart, and replacement by a shorter or weaker effect.
|
||||||
- [ ] Roster intelligence can be activated once per rolling 24 unpaused hours after it is purchased.
|
- [x] Roster intelligence can be activated once per rolling 24 unpaused hours after it is purchased.
|
||||||
- [ ] Roster intelligence reports the number of active Vigilante-side members without revealing their identities or locations.
|
- [x] Roster intelligence reports the number of active Vigilante-side members without revealing their identities or locations.
|
||||||
- [ ] Intelligence counts the current Vigilante and living assigned Followers, whether online or offline, according to configurable counting rules.
|
- [x] Intelligence counts the current Vigilante and living assigned Followers, whether online or offline, according to configurable counting rules.
|
||||||
- [ ] The Tyrant can inspect ability ownership and the remaining intelligence cooldown.
|
- [x] The Tyrant can inspect ability ownership and the remaining intelligence cooldown.
|
||||||
- [ ] Purchases and cooldowns survive restart and end with the Tyrant's reign.
|
- [x] Purchases and cooldowns survive restart and end with the Tyrant's reign.
|
||||||
|
- [x] The Tyrant control panel shows purchased passive upgrades and the roster-intelligence readiness or remaining cooldown.
|
||||||
|
- [x] The Tyrant can activate ready roster intelligence from the control panel and receive the result without revealing identities or locations.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
|
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
|
||||||
- [Configure and persist game state](us-012-configure-and-persist-state.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)
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
---
|
---
|
||||||
type: User Story
|
type: User Story
|
||||||
title: "US-010: Handle inactivity and pending selections"
|
title: "US-010: Handle inactivity and arena vacancies"
|
||||||
description: Replace absent or unconfirmed role holders using recently active eligible players.
|
description: Replace inactive role holders through the shared arena without granting legacy pending selections.
|
||||||
status: backlog
|
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.
|
As a **participant**, I want inactive central roles to be replaced fairly so that the game does not stall around absent players.
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] A Tyrant or Vigilante who has not logged in for 48 unpaused hours loses the position.
|
- [x] A Tyrant or Vigilante who has not logged in for 48 unpaused hours loses the position.
|
||||||
- [ ] Random candidates are drawn from eligible opted-in players who logged in during the preceding 24 hours.
|
- [x] Tyrant and Vigilante vacancies use their shared arena challenges rather than random candidate selection.
|
||||||
- [ ] The Tyrant is always excluded from Vigilante selection.
|
- [x] The Tyrant is always excluded from Vigilante selection.
|
||||||
- [ ] A previous role holder is excluded when another eligible candidate exists and may be reused only as a fallback.
|
- [x] Previous role holders may compete for their former role under the same arena eligibility and reset rules as other opted-in participants.
|
||||||
- [ ] Tyrant inactivity ends the reign, clears all assignments, and invokes normal random Tyrant succession followed by Vigilante selection.
|
- [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.
|
||||||
- [ ] Vigilante inactivity clears Followers and rerolls only the Vigilante position.
|
- [x] Vigilante inactivity clears Followers and opens the Vigilante arena challenge without changing the Tyrant.
|
||||||
- [ ] An offline randomly selected candidate must log in within 24 unpaused hours to confirm the assignment.
|
- [x] Legacy pending role selections are cleared safely into the applicable arena vacancy without granting a role.
|
||||||
- [ ] An unconfirmed selection expires and rerolls automatically under the same eligibility rules.
|
- [x] Candidate selection cannot assign conflicting roles or choose an opted-out player, including during concurrent joins and deaths.
|
||||||
- [ ] Selection remains vacant and retries periodically when no valid candidate exists.
|
|
||||||
- [ ] Candidate selection cannot assign conflicting roles or choose an opted-out player, including during concurrent joins and deaths.
|
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
||||||
- [Inform players and manage participation](us-011-inform-and-manage-participation.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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-011: Inform players and manage participation"
|
title: "US-011: Inform players and manage participation"
|
||||||
description: Tell players their current place in the game and let unassigned players opt out.
|
description: Tell players their current place in the game and let unassigned players opt out.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-011: Inform players and manage participation
|
# US-011: Inform players and manage participation
|
||||||
@@ -11,17 +11,29 @@ As a **player**, I want to understand my status and control my eligibility so th
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] On join, a player is told the game status and their role, class, subclass, abilities, and relevant cooldowns.
|
- [x] On join, lifecycle state is omitted while role, class, subclass, ability, and relevant cooldown information remains available when applicable.
|
||||||
- [ ] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
|
- [x] Eligible players are invited to opt out of Tyrant events without implying that an event is currently running.
|
||||||
- [ ] An opted-out player is told when they may opt back in.
|
- [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
|
||||||
- [ ] An unassigned player can opt out through a command.
|
- [x] An opted-out player is told when they may opt back in.
|
||||||
- [ ] 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-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.
|
||||||
- [ ] Opted-out players cannot be randomly selected, recruited, or assigned a class.
|
- [x] An unassigned player can opt out through a command.
|
||||||
- [ ] A player cannot opt back in until seven days have elapsed by default.
|
- [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.
|
||||||
- [ ] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart.
|
- [x] Opted-out players cannot be randomly selected, recruited, or assigned a class.
|
||||||
- [ ] Participation commands are idempotent and clearly report current status and remaining time.
|
- [x] A player cannot opt back in until seven days have elapsed by default.
|
||||||
|
- [x] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart.
|
||||||
|
- [x] Participation commands are idempotent and clearly report current status and remaining time.
|
||||||
|
- [x] While the event is running, damage is cancelled when the Tyrant or an Assassin, Fixer, or Tamer attacks an opted-out player.
|
||||||
|
- [x] While the event is running, damage is cancelled when an opted-out player attacks the Tyrant or an Assassin, Fixer, or Tamer.
|
||||||
|
- [x] Cancelled attackers are told that the target has chosen not to take part in the Tyrant event.
|
||||||
|
- [x] Protection covers direct player attacks and player-fired projectiles without changing unrelated combat.
|
||||||
|
- [x] Participation combat protection does not apply while the event is paused or not started.
|
||||||
|
- [x] While the event is running, an opted-out player in the same world and within the configured Tyrant range has a green glowing outline visible to everyone.
|
||||||
|
- [x] The opt-out glow is removed when proximity, participation, player availability, Tyrant availability, or running-event requirements stop being satisfied.
|
||||||
|
- [x] Existing scoreboard-team membership is restored after the opt-out glow ends, and plugin shutdown safely clears managed glow state.
|
||||||
|
- [x] Opt-out proximity glow does not apply while the event is paused or not started.
|
||||||
|
|
||||||
## Related
|
## 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)
|
- [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
|
## 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] 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 24-hour candidate windows.
|
- [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] 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] 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] State is saved using atomic replacement where supported so failed writes do not replace valid state with partial data.
|
||||||
- [x] Corrupt, unknown, or invalid records cannot silently grant progression, roles, powers, items, or duplicated mobs.
|
- [x] 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)
|
- [Persistence model](../concepts/persistence-model.md)
|
||||||
- [Build, test, and release the plugin](us-013-build-test-and-release.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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-014: Use class ability items"
|
title: "US-014: Use class ability items"
|
||||||
description: Give class holders named bound items that activate abilities without enabling transfer or cooldown bypasses.
|
description: Give class holders named bound items that activate abilities without enabling transfer or cooldown bypasses.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-014: Use class ability items
|
# US-014: Use class ability items
|
||||||
@@ -11,17 +11,17 @@ As a **class holder**, I want a recognizable inventory item for my ability so th
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Each item-triggered class ability uses a uniquely named standard Minecraft item with hidden persistent ownership and ability metadata.
|
- [x] Each item-triggered class ability uses a uniquely named standard Minecraft item with hidden persistent ownership and ability metadata.
|
||||||
- [ ] Right-clicking or swinging the appropriate item activates the ability without also performing an unsafe vanilla action.
|
- [x] Right-clicking or swinging the appropriate item activates the ability without also performing an unsafe vanilla action.
|
||||||
- [ ] Another player cannot activate, retain, craft with, place, store, frame, dispense, or otherwise transfer a bound ability item.
|
- [x] Another player cannot activate, retain, craft with, place, store, frame, dispense, or otherwise transfer a bound ability item.
|
||||||
- [ ] A cooldown ability item disappears only after successful activation and is restored when the cooldown finishes.
|
- [x] A cooldown ability item disappears only after successful activation and is restored when the cooldown finishes.
|
||||||
- [ ] The Tamer's named fishing rod has no cooldown and remains available after successful capture or placement.
|
- [x] The Tamer's named fishing rod has no cooldown and remains available after successful capture or placement.
|
||||||
- [ ] A dropped class ability item vanishes immediately; a dropped captured-mob item instead attempts to spawn its mob safely.
|
- [x] A dropped class ability item vanishes immediately; a dropped captured-mob item instead attempts to spawn its mob safely.
|
||||||
- [ ] `/tyrant item` restores currently available missing class items but cannot bypass cooldowns or create duplicates.
|
- [x] `/tyrant item` restores currently available missing class items but cannot bypass cooldowns or create duplicates.
|
||||||
- [ ] Missing ready items are restored on login and cooldown completion when inventory space is available.
|
- [x] Missing ready items are restored on login and cooldown completion when inventory space is available.
|
||||||
- [ ] When delivery fails because the inventory is full, no item is dropped and the player is told both why and how to use `/tyrant item` after making room.
|
- [x] When delivery fails because the inventory is full, no item is dropped and the player is told both why and how to use `/tyrant item` after making room.
|
||||||
- [ ] Bound class items are removed when a player loses the class or the Tyrant's reign ends.
|
- [x] Bound class items are removed when a player loses the class or the Tyrant's reign ends.
|
||||||
- [ ] Death, inventory movement, server restart, repeated commands, and concurrent events cannot duplicate a bound item or its captured mob.
|
- [x] Death, inventory movement, server restart, repeated commands, and concurrent events cannot duplicate a bound item or its captured mob.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: "US-015: Manage a reign through the Tyrant control panel"
|
||||||
|
description: Give the active Tyrant a graphical control panel for inspecting and managing the current reign.
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# US-015: Manage a reign through the Tyrant control panel
|
||||||
|
|
||||||
|
As the **Tyrant**, I want a control panel for my reign so that I can inspect progress, purchase unlocks, assign classes, and use abilities without memorizing commands.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] The active Tyrant can open the control panel with `/tyrant menu`, and `/tyrant` with no arguments opens the same panel.
|
||||||
|
- [x] A player who is not the active Tyrant cannot open or operate the control panel.
|
||||||
|
- [x] When the active Tyrant joins while the event is running, the control panel opens automatically five seconds after login.
|
||||||
|
- [x] Automatic opening is cancelled if the Tyrant disconnects, loses the Tyrant role, or the event pauses or stops during the five-second delay.
|
||||||
|
- [x] Other players and pending Tyrant candidates do not receive the automatically opened control panel.
|
||||||
|
- [x] Closing an automatically opened control panel does not cause it to reopen during the same login session.
|
||||||
|
- [x] The Tyrant may open the control panel manually during the five-second delay without causing a second panel to open automatically.
|
||||||
|
- [x] The main screen shows the Tyrant's level, unspent choices, purchased unlocks, class assignments, and roster-intelligence cooldown.
|
||||||
|
- [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.
|
||||||
|
- [x] The Tyrant can activate roster intelligence from the panel when the ability is purchased and ready.
|
||||||
|
- [x] A successful or rejected action refreshes the panel from authoritative current state and communicates the result clearly.
|
||||||
|
- [x] Panel actions use the same domain rules and persistence behavior as their command equivalents.
|
||||||
|
- [x] Stale, repeated, or concurrent panel interactions cannot duplicate purchases, bypass cooldowns, or create conflicting assignments.
|
||||||
|
- [x] Panel actions become unavailable immediately if the game pauses, the reign ends, or the player is no longer the Tyrant.
|
||||||
|
- [x] Existing `/tyrant choices`, `/tyrant buy`, `/tyrant assign`, and `/tyrant intelligence` commands remain available as alternatives.
|
||||||
|
- [x] The active Tyrant can open the control panel by using the bound Tyrant control item.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Automated tests verify command entry points, active-Tyrant authorization, authoritative overview state, the exact 100-tick login delay, lifecycle cancellation, and duplicate-opening prevention. The complete `./gradlew clean check jar` lifecycle passes.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
|
||||||
|
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
|
||||||
|
- [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)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: "US-016: Manage Followers through the Vigilante control panel"
|
||||||
|
description: Give the active Vigilante a graphical control panel for reviewing and managing the resistance.
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# US-016: Manage Followers through the Vigilante control panel
|
||||||
|
|
||||||
|
As the **Vigilante**, I want a control panel for managing Followers so that I can coordinate the resistance without memorizing commands.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] The active Vigilante can open the control panel with `/vigilante menu`, and `/vigilante` with no arguments opens the same panel.
|
||||||
|
- [x] A player who is not the active Vigilante cannot open or operate the control panel.
|
||||||
|
- [x] The control panel opens automatically five seconds after the active Vigilante joins a running event.
|
||||||
|
- [x] Automatic opening is cancelled if the player disconnects, loses the Vigilante role, or the event pauses or stops during the five-second delay.
|
||||||
|
- [x] Pending Vigilante candidates and other players do not receive the automatically opened control panel.
|
||||||
|
- [x] Manually opening the control panel during the delay prevents a duplicate automatic opening.
|
||||||
|
- [x] Closing the automatically opened control panel does not cause it to reopen during the same login session.
|
||||||
|
- [x] The overview shows current Followers, pending invitations, and the number of nearby Followers currently eligible to contribute combat effects.
|
||||||
|
- [x] Each Follower's online, living, same-world, proximity, and participation status is communicated without exposing information unavailable to the Vigilante.
|
||||||
|
- [x] The control panel shows the current Strength and Resistance contribution and configured caps.
|
||||||
|
- [x] The Vigilante can navigate between overview, recruitment, Follower-management, and role-control screens.
|
||||||
|
- [x] The recruitment screen lists eligible online players and explains why opted-out, Tyrant, Vigilante, or otherwise ineligible players cannot be invited.
|
||||||
|
- [x] The Vigilante can invite an eligible player after confirmation.
|
||||||
|
- [x] Pending outgoing invitations are identified and cannot be duplicated through repeated or concurrent interaction.
|
||||||
|
- [x] Invited players receive the existing invitation notice and may continue using `/vigilante accept`.
|
||||||
|
- [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 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.
|
||||||
|
- [x] The active Vigilante can open the control panel by using the bound Vigilante control item.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Automated tests verify default and explicit command entry points, active-Vigilante authorization, overview state, invitation deduplication and eligibility, the exact 100-tick login delay, lifecycle cancellation, and duplicate-opening prevention. The complete `./gradlew clean check jar` lifecycle passes.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Support the Vigilante and Followers](us-008-support-vigilante-and-followers.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)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: "US-017: Use bound role control items"
|
||||||
|
description: Give the active Tyrant and Vigilante secure inventory items that open their control panels.
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# US-017: Use bound role control items
|
||||||
|
|
||||||
|
As a **Tyrant or Vigilante**, I want a bound role item that opens my control panel so that I can access role controls without typing a command.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A player receives the appropriate control item when becoming the active Tyrant or Vigilante.
|
||||||
|
- [x] The Tyrant control item opens the Tyrant control panel when used.
|
||||||
|
- [x] The Vigilante control item opens the Vigilante control panel when used.
|
||||||
|
- [x] The Tyrant control item defaults to a Nether Star named `Tyrant Control`.
|
||||||
|
- [x] The Vigilante control item defaults to a Compass named `Vigilante Control`.
|
||||||
|
- [x] Each item's material and name are configurable, and hidden metadata identifies its owner, associated role, and the plugin.
|
||||||
|
- [x] Only the current role holder identified by the item's metadata can use the item.
|
||||||
|
- [x] A former role holder or another player cannot use or retain the item.
|
||||||
|
- [x] `/tyrant item` restores a missing Tyrant control item in addition to existing eligible class items.
|
||||||
|
- [x] `/vigilante item` restores a missing Vigilante control item.
|
||||||
|
- [x] Recovery commands cannot create duplicate control items.
|
||||||
|
- [x] Using a recovery command while the inventory is full does not drop the item and explains how to retry.
|
||||||
|
- [x] A role control item cannot be placed, crafted with, stored, framed, dispensed, transferred, or moved into another inventory.
|
||||||
|
- [x] Dropping a role control item removes it immediately rather than creating a transferable dropped item.
|
||||||
|
- [x] If a role control item leaves its owner's inventory during death or another inventory event, it is removed and may be recovered after respawn.
|
||||||
|
- [x] A missing role control item is restored automatically on login or role assignment when inventory space is available.
|
||||||
|
- [x] Every copy is removed immediately when the player loses the associated role.
|
||||||
|
- [x] Repeated commands, role transitions, death, logout, restart, and concurrent inventory events cannot duplicate role control items.
|
||||||
|
- [x] Existing `/tyrant`, `/tyrant menu`, `/vigilante`, and `/vigilante menu` control-panel access remains available.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Automated tests verify role eligibility, paused-role retention, reconciliation and recovery, panel activation, role-specific commands, and configuration defaults. Inventory listeners cover transfer and departure paths, and the complete `./gradlew clean check jar` lifecycle passes.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
||||||
|
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
||||||
|
- [Use class ability items](us-014-use-class-ability-items.md)
|
||||||
|
- [Configure and persist game state](us-012-configure-and-persist-state.md)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: "US-018: Complete Tyrant commands contextually"
|
||||||
|
description: Replace Bukkit's player-name fallback with valid context-aware command suggestions.
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# US-018: Complete Tyrant commands contextually
|
||||||
|
|
||||||
|
As a **player or administrator**, I want contextual command suggestions so that I can discover valid Tyrant commands and arguments.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
- [x] `/vigilante invite` suggests eligible online recruitment candidates.
|
||||||
|
- [x] `/vigilante dismiss` suggests current online Followers.
|
||||||
|
- [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.
|
||||||
|
- [x] Existing command execution behavior remains unchanged.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- [Start, pause, and administer the game](us-001-start-pause-and-administer.md)
|
||||||
|
- [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)
|
||||||
@@ -2,8 +2,13 @@ package games.dmg.spigottyrant;
|
|||||||
|
|
||||||
public enum Ability {
|
public enum Ability {
|
||||||
ASSASSIN_INVISIBILITY,
|
ASSASSIN_INVISIBILITY,
|
||||||
|
ASSASSIN_INVISIBILITY_ACTIVE,
|
||||||
ASSASSIN_DOUBLE_JUMP,
|
ASSASSIN_DOUBLE_JUMP,
|
||||||
|
ASSASSIN_SPEED_ACTIVE,
|
||||||
|
ASSASSIN_WEAKNESS_ACTIVE,
|
||||||
FIXER_BOOST,
|
FIXER_BOOST,
|
||||||
|
FIXER_BOOST_ACTIVE,
|
||||||
|
FIXER_NEAR_TYRANT_ACTIVE,
|
||||||
TAMER_CAPTURE,
|
TAMER_CAPTURE,
|
||||||
ROSTER_INTELLIGENCE
|
ROSTER_INTELLIGENCE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public final class AbilityItemRefreshTask implements Runnable {
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final AbilityReadinessService readiness;
|
||||||
|
private final AbilityItemService items;
|
||||||
|
private final Server server;
|
||||||
|
private final Clock clock;
|
||||||
|
private final CapturedMobInventoryService capturedMobInventory;
|
||||||
|
|
||||||
|
public AbilityItemRefreshTask(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
AbilityReadinessService readiness,
|
||||||
|
AbilityItemService items,
|
||||||
|
CapturedMobInventoryService capturedMobInventory,
|
||||||
|
Server server,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.readiness = readiness;
|
||||||
|
this.items = items;
|
||||||
|
this.capturedMobInventory = capturedMobInventory;
|
||||||
|
this.server = server;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
PlayerState before = stateManager.player(player.getUniqueId(), player.getName());
|
||||||
|
PlayerState after = readiness.refresh(before, clock.instant());
|
||||||
|
if (!after.equals(before)) {
|
||||||
|
stateManager.updatePlayer(
|
||||||
|
player.getUniqueId(), player.getName(), current -> after
|
||||||
|
);
|
||||||
|
}
|
||||||
|
items.removeInvalid(player, after);
|
||||||
|
items.recover(player, after);
|
||||||
|
capturedMobInventory.reconcile(player, after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
public interface AbilityItemService {
|
||||||
|
void recover(Player player, PlayerState state);
|
||||||
|
|
||||||
|
boolean isBoundAbilityItem(ItemStack item);
|
||||||
|
|
||||||
|
Optional<Ability> ability(ItemStack item);
|
||||||
|
|
||||||
|
Optional<UUID> owner(ItemStack item);
|
||||||
|
|
||||||
|
void removeInvalid(Player player, PlayerState state);
|
||||||
|
|
||||||
|
void removeAll(Player player);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public final class AbilityReadinessService {
|
||||||
|
public PlayerState consume(
|
||||||
|
PlayerState player,
|
||||||
|
Ability ability,
|
||||||
|
Instant now,
|
||||||
|
Duration cooldown
|
||||||
|
) {
|
||||||
|
if (!player.readyAbilityItems().contains(ability)) {
|
||||||
|
throw new IllegalStateException("ability item is not ready");
|
||||||
|
}
|
||||||
|
if (ability == Ability.TAMER_CAPTURE && cooldown.isZero()) {
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
Set<Ability> ready = EnumSet.noneOf(Ability.class);
|
||||||
|
ready.addAll(player.readyAbilityItems());
|
||||||
|
ready.remove(ability);
|
||||||
|
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
|
||||||
|
cooldowns.putAll(player.cooldownEnds());
|
||||||
|
cooldowns.put(ability, now.plus(cooldown));
|
||||||
|
return copy(player, cooldowns, ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerState refresh(PlayerState player, Instant now) {
|
||||||
|
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
|
||||||
|
cooldowns.putAll(player.cooldownEnds());
|
||||||
|
Set<Ability> ready = EnumSet.noneOf(Ability.class);
|
||||||
|
ready.addAll(player.readyAbilityItems());
|
||||||
|
for (Map.Entry<Ability, Instant> entry : player.cooldownEnds().entrySet()) {
|
||||||
|
if (!entry.getValue().isAfter(now)) {
|
||||||
|
cooldowns.remove(entry.getKey());
|
||||||
|
if (isItemAbilityForClass(entry.getKey(), player.tyrantClass())) {
|
||||||
|
ready.add(entry.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copy(player, cooldowns, ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isItemAbilityForClass(Ability ability, TyrantClass tyrantClass) {
|
||||||
|
return switch (tyrantClass) {
|
||||||
|
case ASSASSIN -> ability == Ability.ASSASSIN_INVISIBILITY;
|
||||||
|
case FIXER -> ability == Ability.FIXER_BOOST;
|
||||||
|
case TAMER -> ability == Ability.TAMER_CAPTURE;
|
||||||
|
case NONE -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerState copy(
|
||||||
|
PlayerState player,
|
||||||
|
Map<Ability, Instant> cooldowns,
|
||||||
|
Set<Ability> ready
|
||||||
|
) {
|
||||||
|
return new PlayerState(
|
||||||
|
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
|
||||||
|
player.tyrantClass(), player.followerOf(), cooldowns, ready,
|
||||||
|
player.capturedMobs()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record AbilityUseResult(PlayerState player, AbilityUseStatus status) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public enum AbilityUseStatus {
|
||||||
|
ACTIVATED,
|
||||||
|
WRONG_CLASS,
|
||||||
|
NOT_READY,
|
||||||
|
COOLDOWN,
|
||||||
|
GAME_NOT_RUNNING,
|
||||||
|
WRONG_OWNER
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.entity.Zombie;
|
||||||
|
|
||||||
|
final class ArenaBossState {
|
||||||
|
private ArenaBossState() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static void idle(Zombie boss, Location center, double health) {
|
||||||
|
boss.setAI(false);
|
||||||
|
boss.setTarget(null);
|
||||||
|
boss.setHealth(health);
|
||||||
|
boss.teleport(center);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void engage(Zombie boss, Player challenger) {
|
||||||
|
boss.setAI(true);
|
||||||
|
boss.setTarget(challenger);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
final class ArenaBuildProtection {
|
||||||
|
private ArenaBuildProtection() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isProtected(
|
||||||
|
boolean fightActive,
|
||||||
|
ArenaLocation center,
|
||||||
|
double radius,
|
||||||
|
String worldName,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z
|
||||||
|
) {
|
||||||
|
return fightActive && ArenaGeometry.contains(
|
||||||
|
center, radius, worldName, x, y, z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public final class ArenaGeometry {
|
||||||
|
private ArenaGeometry() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean contains(
|
||||||
|
ArenaLocation center,
|
||||||
|
double radius,
|
||||||
|
String worldName,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z
|
||||||
|
) {
|
||||||
|
if (!center.worldName().equals(worldName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
double deltaX = x - center.x();
|
||||||
|
double deltaZ = z - center.z();
|
||||||
|
return deltaX * deltaX + deltaZ * deltaZ <= radius * radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ArenaPoint outside(
|
||||||
|
ArenaLocation center,
|
||||||
|
double radius,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z
|
||||||
|
) {
|
||||||
|
double deltaX = x - center.x();
|
||||||
|
double deltaZ = z - center.z();
|
||||||
|
double length = Math.hypot(deltaX, deltaZ);
|
||||||
|
if (length < 0.0001) {
|
||||||
|
deltaX = 1.0;
|
||||||
|
deltaZ = 0.0;
|
||||||
|
length = 1.0;
|
||||||
|
}
|
||||||
|
double distance = radius + 1.0;
|
||||||
|
return new ArenaPoint(
|
||||||
|
center.x() + deltaX / length * distance,
|
||||||
|
y,
|
||||||
|
center.z() + deltaZ / length * distance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record ArenaLocation(
|
||||||
|
String worldName,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z,
|
||||||
|
float yaw,
|
||||||
|
float pitch
|
||||||
|
) {
|
||||||
|
public ArenaLocation {
|
||||||
|
if (worldName == null || worldName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("arena world must not be blank");
|
||||||
|
}
|
||||||
|
worldName = worldName.trim();
|
||||||
|
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|
||||||
|
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
|
||||||
|
throw new IllegalArgumentException("arena coordinates must be finite");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String display() {
|
||||||
|
return worldName + " at " + Math.round(x) + ", " + Math.round(y) + ", "
|
||||||
|
+ Math.round(z);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface ArenaLocationStore {
|
||||||
|
Optional<ArenaLocation> location();
|
||||||
|
|
||||||
|
void set(ArenaLocation location);
|
||||||
|
|
||||||
|
static ArenaLocationStore unavailable() {
|
||||||
|
return new ArenaLocationStore() {
|
||||||
|
@Override
|
||||||
|
public Optional<ArenaLocation> location() {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void set(ArenaLocation location) {
|
||||||
|
throw new IllegalStateException("arena location storage is unavailable");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record ArenaPoint(double x, double y, double z) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public enum ArenaRole {
|
||||||
|
TYRANT,
|
||||||
|
VIGILANTE
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record ArmorClaimResult(GameState state, ArmorClaimStatus status) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public enum ArmorClaimStatus {
|
||||||
|
CLAIMED,
|
||||||
|
GAME_NOT_RUNNING,
|
||||||
|
NOT_TYRANT,
|
||||||
|
UNLOCKS_INCOMPLETE,
|
||||||
|
NO_CHOICES,
|
||||||
|
ALREADY_CLAIMED
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class AssassinAbilityService {
|
||||||
|
private final AbilityReadinessService readiness;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
|
||||||
|
public AssassinAbilityService(
|
||||||
|
AbilityReadinessService readiness,
|
||||||
|
PluginSettings settings
|
||||||
|
) {
|
||||||
|
this.readiness = readiness;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AbilityUseResult activateInvisibility(
|
||||||
|
PlayerState player,
|
||||||
|
Instant now,
|
||||||
|
boolean nearTyrant
|
||||||
|
) {
|
||||||
|
if (player.tyrantClass() != TyrantClass.ASSASSIN) {
|
||||||
|
return new AbilityUseResult(player, AbilityUseStatus.WRONG_CLASS);
|
||||||
|
}
|
||||||
|
if (!player.readyAbilityItems().contains(Ability.ASSASSIN_INVISIBILITY)) {
|
||||||
|
return new AbilityUseResult(player, AbilityUseStatus.NOT_READY);
|
||||||
|
}
|
||||||
|
PlayerState used = readiness.consume(
|
||||||
|
player, Ability.ASSASSIN_INVISIBILITY, now, settings.assassinCooldown()
|
||||||
|
);
|
||||||
|
Instant activeUntil = now.plus(
|
||||||
|
nearTyrant
|
||||||
|
? settings.assassinInvisibilityDuration().multipliedBy(2)
|
||||||
|
: settings.assassinInvisibilityDuration()
|
||||||
|
);
|
||||||
|
return new AbilityUseResult(
|
||||||
|
withDeadline(used, Ability.ASSASSIN_INVISIBILITY_ACTIVE, activeUntil),
|
||||||
|
AbilityUseStatus.ACTIVATED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AbilityUseResult doubleJump(PlayerState player, Instant now) {
|
||||||
|
if (player.tyrantClass() != TyrantClass.ASSASSIN) {
|
||||||
|
return new AbilityUseResult(player, AbilityUseStatus.WRONG_CLASS);
|
||||||
|
}
|
||||||
|
if (player.cooldownEnds().getOrDefault(
|
||||||
|
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
|
||||||
|
).isAfter(now)) {
|
||||||
|
return new AbilityUseResult(player, AbilityUseStatus.COOLDOWN);
|
||||||
|
}
|
||||||
|
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
|
||||||
|
deadlines.putAll(player.cooldownEnds());
|
||||||
|
deadlines.put(
|
||||||
|
Ability.ASSASSIN_DOUBLE_JUMP,
|
||||||
|
now.plus(settings.assassinDoubleJumpCooldown())
|
||||||
|
);
|
||||||
|
deadlines.put(
|
||||||
|
Ability.ASSASSIN_SPEED_ACTIVE,
|
||||||
|
now.plus(settings.assassinSpeedDuration())
|
||||||
|
);
|
||||||
|
deadlines.put(
|
||||||
|
Ability.ASSASSIN_WEAKNESS_ACTIVE,
|
||||||
|
now.plus(settings.assassinWeaknessDuration())
|
||||||
|
);
|
||||||
|
return new AbilityUseResult(copy(player, deadlines), AbilityUseStatus.ACTIVATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerState withDeadline(
|
||||||
|
PlayerState player,
|
||||||
|
Ability ability,
|
||||||
|
Instant deadline
|
||||||
|
) {
|
||||||
|
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
|
||||||
|
deadlines.putAll(player.cooldownEnds());
|
||||||
|
deadlines.put(ability, deadline);
|
||||||
|
return copy(player, deadlines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerState copy(PlayerState player, Map<Ability, Instant> deadlines) {
|
||||||
|
return new PlayerState(
|
||||||
|
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
|
||||||
|
player.tyrantClass(), player.followerOf(), deadlines,
|
||||||
|
player.readyAbilityItems(), player.capturedMobs()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.GameMode;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.potion.PotionEffect;
|
||||||
|
import org.bukkit.potion.PotionEffectType;
|
||||||
|
|
||||||
|
public final class AssassinEffectController implements Runnable {
|
||||||
|
private static final int EFFECT_TICKS = 30;
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final Server server;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
private final Clock clock;
|
||||||
|
private final EffectApplier effectApplier;
|
||||||
|
private final Set<UUID> grantedFlight = new HashSet<>();
|
||||||
|
|
||||||
|
public AssassinEffectController(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
Server server,
|
||||||
|
PluginSettings settings,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this(stateManager, server, settings, clock, new BukkitEffectApplier());
|
||||||
|
}
|
||||||
|
|
||||||
|
AssassinEffectController(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
Server server,
|
||||||
|
PluginSettings settings,
|
||||||
|
Clock clock,
|
||||||
|
EffectApplier effectApplier
|
||||||
|
) {
|
||||||
|
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
|
||||||
|
this.server = Objects.requireNonNull(server, "server");
|
||||||
|
this.settings = Objects.requireNonNull(settings, "settings");
|
||||||
|
this.clock = Objects.requireNonNull(clock, "clock");
|
||||||
|
this.effectApplier = Objects.requireNonNull(effectApplier, "effectApplier");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
Instant now = clock.instant();
|
||||||
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||||
|
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING
|
||||||
|
|| state.tyrantClass() != TyrantClass.ASSASSIN) {
|
||||||
|
clear(player);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
applyWhileActive(
|
||||||
|
player, state, Ability.ASSASSIN_INVISIBILITY_ACTIVE,
|
||||||
|
EffectKind.INVISIBILITY, 0, false
|
||||||
|
);
|
||||||
|
applyWhileActive(
|
||||||
|
player, state, Ability.ASSASSIN_SPEED_ACTIVE,
|
||||||
|
EffectKind.SPEED, 0, true
|
||||||
|
);
|
||||||
|
applyWhileActive(
|
||||||
|
player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE,
|
||||||
|
EffectKind.WEAKNESS, settings.assassinWeaknessLevel() - 1, true
|
||||||
|
);
|
||||||
|
boolean jumpReady = !state.cooldownEnds().getOrDefault(
|
||||||
|
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
|
||||||
|
).isAfter(now);
|
||||||
|
if (jumpReady && isGrounded(player) && isSurvivalLike(player)) {
|
||||||
|
player.setAllowFlight(true);
|
||||||
|
grantedFlight.add(player.getUniqueId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearAll() {
|
||||||
|
server.getOnlinePlayers().forEach(this::clear);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyWhileActive(
|
||||||
|
Player player,
|
||||||
|
PlayerState state,
|
||||||
|
Ability ability,
|
||||||
|
EffectKind effect,
|
||||||
|
int amplifier,
|
||||||
|
boolean particles
|
||||||
|
) {
|
||||||
|
if (state.cooldownEnds().getOrDefault(ability, Instant.MIN).isAfter(clock.instant())) {
|
||||||
|
effectApplier.apply(player, effect, amplifier, particles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clear(Player player) {
|
||||||
|
if (grantedFlight.remove(player.getUniqueId()) && isSurvivalLike(player)) {
|
||||||
|
player.setAllowFlight(false);
|
||||||
|
player.setFlying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EffectKind {
|
||||||
|
INVISIBILITY,
|
||||||
|
SPEED,
|
||||||
|
WEAKNESS
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
interface EffectApplier {
|
||||||
|
void apply(Player player, EffectKind effect, int amplifier, boolean particles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class BukkitEffectApplier implements EffectApplier {
|
||||||
|
@Override
|
||||||
|
public void apply(
|
||||||
|
Player player,
|
||||||
|
EffectKind effect,
|
||||||
|
int amplifier,
|
||||||
|
boolean particles
|
||||||
|
) {
|
||||||
|
PotionEffectType type = switch (effect) {
|
||||||
|
case INVISIBILITY -> PotionEffectType.INVISIBILITY;
|
||||||
|
case SPEED -> PotionEffectType.SPEED;
|
||||||
|
case WEAKNESS -> PotionEffectType.WEAKNESS;
|
||||||
|
};
|
||||||
|
player.addPotionEffect(new PotionEffect(
|
||||||
|
type, EFFECT_TICKS, amplifier, false, particles, true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isGrounded(Player player) {
|
||||||
|
return !player.getLocation().clone().subtract(0.0, 0.1, 0.0)
|
||||||
|
.getBlock().isPassable();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSurvivalLike(Player player) {
|
||||||
|
return player.getGameMode() != GameMode.CREATIVE
|
||||||
|
&& player.getGameMode() != GameMode.SPECTATOR;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.block.Action;
|
||||||
|
import org.bukkit.event.player.PlayerInteractEvent;
|
||||||
|
|
||||||
|
public final class AssassinItemListener implements Listener {
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final AbilityItemService items;
|
||||||
|
private final AssassinAbilityService abilities;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public AssassinItemListener(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
AbilityItemService items,
|
||||||
|
AssassinAbilityService abilities,
|
||||||
|
PluginSettings settings,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.items = items;
|
||||||
|
this.abilities = abilities;
|
||||||
|
this.settings = settings;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH)
|
||||||
|
public void onInteract(PlayerInteractEvent event) {
|
||||||
|
Action action = event.getAction();
|
||||||
|
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK
|
||||||
|
&& action != Action.LEFT_CLICK_AIR && action != Action.LEFT_CLICK_BLOCK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (items.ability(event.getItem())
|
||||||
|
.filter(Ability.ASSASSIN_INVISIBILITY::equals).isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.setCancelled(true);
|
||||||
|
UUID playerId = event.getPlayer().getUniqueId();
|
||||||
|
if (items.owner(event.getItem()).filter(playerId::equals).isEmpty()) {
|
||||||
|
event.getPlayer().getInventory().setItem(event.getHand(), null);
|
||||||
|
event.getPlayer().sendMessage(ChatColor.RED + "That ability item is not yours.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) {
|
||||||
|
event.getPlayer().sendMessage(ChatColor.RED + "The Tyrant game is not running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlayerState player = stateManager.player(playerId, event.getPlayer().getName());
|
||||||
|
AbilityUseResult result = abilities.activateInvisibility(
|
||||||
|
player, clock.instant(), isNearTyrant(event.getPlayer())
|
||||||
|
);
|
||||||
|
if (result.status() == AbilityUseStatus.ACTIVATED) {
|
||||||
|
stateManager.updatePlayer(playerId, event.getPlayer().getName(), current -> result.player());
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
event.getPlayer().getInventory().setItem(event.getHand(), null);
|
||||||
|
event.getPlayer().sendMessage(ChatColor.GREEN + "Invisibility activated.");
|
||||||
|
} else {
|
||||||
|
event.getPlayer().sendMessage(ChatColor.RED + "Ability unavailable: "
|
||||||
|
+ result.status().name().toLowerCase(java.util.Locale.ROOT) + ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNearTyrant(org.bukkit.entity.Player player) {
|
||||||
|
Optional<org.bukkit.entity.Player> tyrant = stateManager.game().tyrantId()
|
||||||
|
.map(org.bukkit.Bukkit::getPlayer);
|
||||||
|
return tyrant.isPresent()
|
||||||
|
&& tyrant.orElseThrow().getWorld().equals(player.getWorld())
|
||||||
|
&& tyrant.orElseThrow().getLocation().distanceSquared(player.getLocation())
|
||||||
|
<= settings.tyrantRangeBlocks() * settings.tyrantRangeBlocks();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.GameMode;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.player.PlayerToggleFlightEvent;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
|
|
||||||
|
public final class AssassinJumpListener implements Listener {
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final AssassinAbilityService abilities;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public AssassinJumpListener(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
AssassinAbilityService abilities,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.abilities = abilities;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onToggleFlight(PlayerToggleFlightEvent event) {
|
||||||
|
if (event.getPlayer().getGameMode() == GameMode.CREATIVE
|
||||||
|
|| event.getPlayer().getGameMode() == GameMode.SPECTATOR) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlayerState state = stateManager.player(
|
||||||
|
event.getPlayer().getUniqueId(), event.getPlayer().getName()
|
||||||
|
);
|
||||||
|
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING
|
||||||
|
|| state.tyrantClass() != TyrantClass.ASSASSIN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.setCancelled(true);
|
||||||
|
event.getPlayer().setFlying(false);
|
||||||
|
event.getPlayer().setAllowFlight(false);
|
||||||
|
AbilityUseResult result = abilities.doubleJump(state, clock.instant());
|
||||||
|
if (result.status() != AbilityUseStatus.ACTIVATED) {
|
||||||
|
event.getPlayer().sendMessage(ChatColor.RED + "Double jump is cooling down.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stateManager.updatePlayer(
|
||||||
|
event.getPlayer().getUniqueId(), event.getPlayer().getName(),
|
||||||
|
current -> result.player()
|
||||||
|
);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
Vector velocity = event.getPlayer().getVelocity();
|
||||||
|
event.getPlayer().setVelocity(velocity.setY(Math.max(0.8, velocity.getY())));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import org.bukkit.entity.ItemFrame;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.block.BlockDispenseEvent;
|
||||||
|
import org.bukkit.event.block.BlockPlaceEvent;
|
||||||
|
import org.bukkit.event.entity.EntityPickupItemEvent;
|
||||||
|
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||||
|
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||||
|
import org.bukkit.event.player.PlayerDropItemEvent;
|
||||||
|
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
public final class BoundItemListener implements Listener {
|
||||||
|
private final AbilityItemService items;
|
||||||
|
|
||||||
|
public BoundItemListener(AbilityItemService items) {
|
||||||
|
this.items = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onDrop(PlayerDropItemEvent event) {
|
||||||
|
if (items.isBoundAbilityItem(event.getItemDrop().getItemStack())) {
|
||||||
|
event.getItemDrop().remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onPickup(EntityPickupItemEvent event) {
|
||||||
|
if (!(event.getEntity() instanceof Player player)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ItemStack item = event.getItem().getItemStack();
|
||||||
|
if (items.isBoundAbilityItem(item)
|
||||||
|
&& items.owner(item).filter(player.getUniqueId()::equals).isEmpty()) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
event.getItem().remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInventoryClick(InventoryClickEvent event) {
|
||||||
|
if (items.isBoundAbilityItem(event.getCurrentItem())
|
||||||
|
|| items.isBoundAbilityItem(event.getCursor())) {
|
||||||
|
if (!(event.getWhoClicked() instanceof Player player)
|
||||||
|
|| event.isShiftClick()
|
||||||
|
|| event.getClickedInventory() == null
|
||||||
|
|| !event.getClickedInventory().equals(player.getInventory())) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onPlace(BlockPlaceEvent event) {
|
||||||
|
if (items.isBoundAbilityItem(event.getItemInHand())) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onDispense(BlockDispenseEvent event) {
|
||||||
|
if (items.isBoundAbilityItem(event.getItem())) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onFrame(PlayerInteractEntityEvent event) {
|
||||||
|
if (event.getRightClicked() instanceof ItemFrame
|
||||||
|
&& items.isBoundAbilityItem(event.getPlayer().getInventory()
|
||||||
|
.getItem(event.getHand()))) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onDeath(PlayerDeathEvent event) {
|
||||||
|
event.getDrops().removeIf(items::isBoundAbilityItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
public final class BukkitAbilityItemService implements AbilityItemService {
|
||||||
|
private final NamespacedKey abilityKey;
|
||||||
|
private final NamespacedKey ownerKey;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
|
||||||
|
public BukkitAbilityItemService(Plugin plugin, PluginSettings settings) {
|
||||||
|
abilityKey = new NamespacedKey(plugin, "ability");
|
||||||
|
ownerKey = new NamespacedKey(plugin, "owner");
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void recover(Player player, PlayerState state) {
|
||||||
|
for (Ability ability : state.readyAbilityItems()) {
|
||||||
|
if (!isItemAbilityForClass(ability, state.tyrantClass())
|
||||||
|
|| contains(player, state.playerId(), ability)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ItemStack item = create(state.playerId(), ability);
|
||||||
|
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(item);
|
||||||
|
if (!leftovers.isEmpty()) {
|
||||||
|
player.sendMessage(ChatColor.RED + settings.inventoryFullMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isBoundAbilityItem(ItemStack item) {
|
||||||
|
return ability(item).isPresent() && owner(item).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Ability> ability(ItemStack item) {
|
||||||
|
if (item == null || !item.hasItemMeta()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String value = item.getItemMeta().getPersistentDataContainer()
|
||||||
|
.get(abilityKey, PersistentDataType.STRING);
|
||||||
|
if (value == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Optional.of(Ability.valueOf(value));
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<UUID> owner(ItemStack item) {
|
||||||
|
if (item == null || !item.hasItemMeta()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String value = item.getItemMeta().getPersistentDataContainer()
|
||||||
|
.get(ownerKey, PersistentDataType.STRING);
|
||||||
|
if (value == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Optional.of(UUID.fromString(value));
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removeInvalid(Player player, PlayerState state) {
|
||||||
|
ItemStack[] contents = player.getInventory().getContents();
|
||||||
|
java.util.Set<Ability> retained = java.util.EnumSet.noneOf(Ability.class);
|
||||||
|
for (int index = 0; index < contents.length; index++) {
|
||||||
|
ItemStack item = contents[index];
|
||||||
|
if (!isBoundAbilityItem(item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Optional<Ability> itemAbility = ability(item);
|
||||||
|
boolean valid = owner(item).filter(state.playerId()::equals).isPresent()
|
||||||
|
&& itemAbility.filter(state.readyAbilityItems()::contains).isPresent()
|
||||||
|
&& itemAbility.filter(retained::add).isPresent();
|
||||||
|
if (!valid) {
|
||||||
|
player.getInventory().setItem(index, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removeAll(Player player) {
|
||||||
|
ItemStack[] contents = player.getInventory().getContents();
|
||||||
|
for (int index = 0; index < contents.length; index++) {
|
||||||
|
if (isBoundAbilityItem(contents[index])) {
|
||||||
|
player.getInventory().setItem(index, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ItemStack create(UUID ownerId, Ability ability) {
|
||||||
|
PluginSettings.AbilityItemSettings configured = settingsFor(ability);
|
||||||
|
Material material = Material.matchMaterial(configured.material());
|
||||||
|
if (material == null) {
|
||||||
|
throw new IllegalStateException("Configured item material is unavailable");
|
||||||
|
}
|
||||||
|
ItemStack item = new ItemStack(material);
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
meta.setDisplayName(ChatColor.GOLD + configured.name());
|
||||||
|
meta.getPersistentDataContainer().set(
|
||||||
|
abilityKey, PersistentDataType.STRING, ability.name()
|
||||||
|
);
|
||||||
|
meta.getPersistentDataContainer().set(
|
||||||
|
ownerKey, PersistentDataType.STRING, ownerId.toString()
|
||||||
|
);
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean contains(Player player, UUID ownerId, Ability ability) {
|
||||||
|
for (ItemStack item : player.getInventory().getContents()) {
|
||||||
|
if (owner(item).filter(ownerId::equals).isPresent()
|
||||||
|
&& ability(item).filter(ability::equals).isPresent()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PluginSettings.AbilityItemSettings settingsFor(Ability ability) {
|
||||||
|
return switch (ability) {
|
||||||
|
case ASSASSIN_INVISIBILITY -> settings.assassinItem();
|
||||||
|
case FIXER_BOOST -> settings.fixerItem();
|
||||||
|
case TAMER_CAPTURE -> settings.tamerItem();
|
||||||
|
default -> throw new IllegalArgumentException(
|
||||||
|
ability.name().toLowerCase(Locale.ROOT) + " has no class item"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isItemAbilityForClass(Ability ability, TyrantClass tyrantClass) {
|
||||||
|
return switch (tyrantClass) {
|
||||||
|
case ASSASSIN -> ability == Ability.ASSASSIN_INVISIBILITY;
|
||||||
|
case FIXER -> ability == Ability.FIXER_BOOST;
|
||||||
|
case TAMER -> ability == Ability.TAMER_CAPTURE;
|
||||||
|
case NONE -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
public final class BukkitCapturedMobItemService implements CapturedMobItemService {
|
||||||
|
private final NamespacedKey ownerKey;
|
||||||
|
private final NamespacedKey captureKey;
|
||||||
|
private final NamespacedKey typeKey;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
|
||||||
|
public BukkitCapturedMobItemService(Plugin plugin, PluginSettings settings) {
|
||||||
|
ownerKey = new NamespacedKey(plugin, "captured-owner");
|
||||||
|
captureKey = new NamespacedKey(plugin, "captured-id");
|
||||||
|
typeKey = new NamespacedKey(plugin, "captured-type");
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ItemStack create(UUID ownerId, UUID captureId, String entityType) {
|
||||||
|
Material material = Material.matchMaterial(settings.capturedMobItem().material());
|
||||||
|
if (material == null) {
|
||||||
|
throw new IllegalStateException("Captured mob item material is unavailable");
|
||||||
|
}
|
||||||
|
ItemStack item = new ItemStack(material);
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
meta.setDisplayName(ChatColor.GOLD + settings.capturedMobItem().name()
|
||||||
|
+ ": " + entityType);
|
||||||
|
meta.getPersistentDataContainer().set(
|
||||||
|
ownerKey, PersistentDataType.STRING, ownerId.toString()
|
||||||
|
);
|
||||||
|
meta.getPersistentDataContainer().set(
|
||||||
|
captureKey, PersistentDataType.STRING, captureId.toString()
|
||||||
|
);
|
||||||
|
meta.getPersistentDataContainer().set(
|
||||||
|
typeKey, PersistentDataType.STRING, entityType
|
||||||
|
);
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isCapturedMob(ItemStack item) {
|
||||||
|
return owner(item).isPresent() && captureId(item).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<UUID> owner(ItemStack item) {
|
||||||
|
return uuid(item, ownerKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<UUID> captureId(ItemStack item) {
|
||||||
|
return uuid(item, captureKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<UUID> uuid(ItemStack item, NamespacedKey key) {
|
||||||
|
if (item == null || !item.hasItemMeta()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String value = item.getItemMeta().getPersistentDataContainer()
|
||||||
|
.get(key, PersistentDataType.STRING);
|
||||||
|
if (value == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Optional.of(UUID.fromString(value));
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.EntitySnapshot;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public final class BukkitCapturedMobSpawner implements CapturedMobSpawner {
|
||||||
|
private final Server server;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
|
||||||
|
public BukkitCapturedMobSpawner(Server server, PluginSettings settings) {
|
||||||
|
this.server = server;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean spawn(Player player, CapturedMob mob) {
|
||||||
|
Location location = player.getLocation();
|
||||||
|
if (!safe(location)
|
||||||
|
|| "ENDER_DRAGON".equals(mob.entityType())
|
||||||
|
|| settings.deniedMobTypes().contains(mob.entityType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String serialized = mob.data().get("snapshot");
|
||||||
|
if (serialized == null || serialized.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
EntitySnapshot snapshot = server.getEntityFactory().createEntitySnapshot(serialized);
|
||||||
|
if (!snapshot.getEntityType().name().equals(mob.entityType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
snapshot.createEntity(location);
|
||||||
|
return true;
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean safe(Location location) {
|
||||||
|
return location.getWorld() != null
|
||||||
|
&& location.getBlock().isPassable()
|
||||||
|
&& location.clone().add(0.0, 1.0, 0.0).getBlock().isPassable()
|
||||||
|
&& location.getWorld().getWorldBorder().isInside(location);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.bukkit.configuration.file.FileConfiguration;
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
|
public final class BukkitConfigArenaLocationStore implements ArenaLocationStore {
|
||||||
|
private static final String PATH = "vigilante-arena-location";
|
||||||
|
private final JavaPlugin plugin;
|
||||||
|
|
||||||
|
public BukkitConfigArenaLocationStore(JavaPlugin plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ArenaLocation> location() {
|
||||||
|
FileConfiguration config = plugin.getConfig();
|
||||||
|
if (!config.isConfigurationSection(PATH)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String world = config.getString(PATH + ".world");
|
||||||
|
if (world == null) {
|
||||||
|
throw new IllegalArgumentException(PATH + ".world must be configured");
|
||||||
|
}
|
||||||
|
return Optional.of(new ArenaLocation(
|
||||||
|
world,
|
||||||
|
finite(config, "x"),
|
||||||
|
finite(config, "y"),
|
||||||
|
finite(config, "z"),
|
||||||
|
(float) finite(config, "yaw"),
|
||||||
|
(float) finite(config, "pitch")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void set(ArenaLocation location) {
|
||||||
|
FileConfiguration config = plugin.getConfig();
|
||||||
|
config.set(PATH + ".world", location.worldName());
|
||||||
|
config.set(PATH + ".x", location.x());
|
||||||
|
config.set(PATH + ".y", location.y());
|
||||||
|
config.set(PATH + ".z", location.z());
|
||||||
|
config.set(PATH + ".yaw", location.yaw());
|
||||||
|
config.set(PATH + ".pitch", location.pitch());
|
||||||
|
plugin.saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double finite(FileConfiguration config, String key) {
|
||||||
|
String path = PATH + "." + key;
|
||||||
|
if (!config.isSet(path) || !(config.get(path) instanceof Number number)
|
||||||
|
|| !Double.isFinite(number.doubleValue())) {
|
||||||
|
throw new IllegalArgumentException(path + " must be finite and numeric");
|
||||||
|
}
|
||||||
|
return number.doubleValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public final class BukkitFollowerPresenceProvider implements FollowerPresenceProvider {
|
||||||
|
private final Server server;
|
||||||
|
private final double rangeSquared;
|
||||||
|
|
||||||
|
public BukkitFollowerPresenceProvider(Server server, double rangeBlocks) {
|
||||||
|
this.server = server;
|
||||||
|
this.rangeSquared = rangeBlocks * rangeBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<UUID, FollowerPresence> inspect(
|
||||||
|
Player vigilante,
|
||||||
|
Map<UUID, PlayerState> players
|
||||||
|
) {
|
||||||
|
Map<UUID, FollowerPresence> result = new HashMap<>();
|
||||||
|
for (PlayerState state : players.values()) {
|
||||||
|
Player follower = server.getPlayer(state.playerId());
|
||||||
|
boolean online = follower != null && follower.isOnline();
|
||||||
|
boolean living = online && !follower.isDead();
|
||||||
|
boolean sameWorld = living && follower.getWorld().equals(vigilante.getWorld());
|
||||||
|
boolean withinRange = sameWorld && follower.getLocation()
|
||||||
|
.distanceSquared(vigilante.getLocation()) <= rangeSquared;
|
||||||
|
result.put(
|
||||||
|
state.playerId(),
|
||||||
|
new FollowerPresence(online, living, sameWorld, withinRange)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Map.copyOf(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public final class BukkitManagedRoleEffects implements ManagedRoleEffects {
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final TyrantPresentation presentation;
|
||||||
|
|
||||||
|
public BukkitManagedRoleEffects(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
TyrantPresentation presentation
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.presentation = presentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void suppressAll() {
|
||||||
|
presentation.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void restoreAll() {
|
||||||
|
if (stateManager.game().lifecycle() == GameLifecycle.RUNNING) {
|
||||||
|
presentation.reconcile(stateManager.game().tyrantId());
|
||||||
|
} else {
|
||||||
|
presentation.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public final class BukkitOnlinePlayerDirectory implements OnlinePlayerDirectory {
|
||||||
|
@Override
|
||||||
|
public Player findByName(String name) {
|
||||||
|
return Bukkit.getPlayerExact(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Player findById(UUID playerId) {
|
||||||
|
return Bukkit.getPlayer(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.util.Set<UUID> onlinePlayerIds() {
|
||||||
|
return Bukkit.getOnlinePlayers().stream()
|
||||||
|
.map(Player::getUniqueId)
|
||||||
|
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.bukkit.persistence.PersistentDataType;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
public final class BukkitRoleControlItemService implements RoleControlItemService {
|
||||||
|
private final NamespacedKey roleKey;
|
||||||
|
private final NamespacedKey ownerKey;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
|
||||||
|
public BukkitRoleControlItemService(Plugin plugin, PluginSettings settings) {
|
||||||
|
roleKey = new NamespacedKey(plugin, "role-control");
|
||||||
|
ownerKey = new NamespacedKey(plugin, "role-control-owner");
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void recover(Player player, GameState game) {
|
||||||
|
Optional<RoleControl> required = RoleControlItemPolicy.requiredRole(
|
||||||
|
game, player.getUniqueId()
|
||||||
|
);
|
||||||
|
if (required.isEmpty() || contains(player, required.orElseThrow())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(
|
||||||
|
create(player.getUniqueId(), required.orElseThrow())
|
||||||
|
);
|
||||||
|
if (!leftovers.isEmpty()) {
|
||||||
|
String command = required.orElseThrow() == RoleControl.TYRANT
|
||||||
|
? "/tyrant item" : "/vigilante item";
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "Your inventory is full. Make room and use " + command + ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isBoundRoleControlItem(ItemStack item) {
|
||||||
|
return role(item).isPresent() && owner(item).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<RoleControl> role(ItemStack item) {
|
||||||
|
String value = metadata(item, roleKey);
|
||||||
|
if (value == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Optional.of(RoleControl.valueOf(value));
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<UUID> owner(ItemStack item) {
|
||||||
|
String value = metadata(item, ownerKey);
|
||||||
|
if (value == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Optional.of(UUID.fromString(value));
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removeInvalid(Player player, GameState game) {
|
||||||
|
Optional<RoleControl> required = RoleControlItemPolicy.requiredRole(
|
||||||
|
game, player.getUniqueId()
|
||||||
|
);
|
||||||
|
boolean retained = false;
|
||||||
|
ItemStack[] contents = player.getInventory().getContents();
|
||||||
|
for (int index = 0; index < contents.length; index++) {
|
||||||
|
ItemStack item = contents[index];
|
||||||
|
if (!isBoundRoleControlItem(item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean valid = !retained
|
||||||
|
&& owner(item).filter(player.getUniqueId()::equals).isPresent()
|
||||||
|
&& role(item).equals(required);
|
||||||
|
if (valid) {
|
||||||
|
retained = true;
|
||||||
|
} else {
|
||||||
|
player.getInventory().setItem(index, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void removeAll(Player player) {
|
||||||
|
ItemStack[] contents = player.getInventory().getContents();
|
||||||
|
for (int index = 0; index < contents.length; index++) {
|
||||||
|
if (isBoundRoleControlItem(contents[index])) {
|
||||||
|
player.getInventory().setItem(index, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ItemStack create(UUID ownerId, RoleControl role) {
|
||||||
|
PluginSettings.AbilityItemSettings configured = role == RoleControl.TYRANT
|
||||||
|
? settings.tyrantControlItem() : settings.vigilanteControlItem();
|
||||||
|
Material material = Material.matchMaterial(configured.material());
|
||||||
|
if (material == null) {
|
||||||
|
throw new IllegalStateException("Configured role control material is unavailable");
|
||||||
|
}
|
||||||
|
ItemStack item = new ItemStack(material);
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
meta.setDisplayName(ChatColor.GOLD + configured.name());
|
||||||
|
meta.getPersistentDataContainer().set(
|
||||||
|
roleKey, PersistentDataType.STRING, role.name()
|
||||||
|
);
|
||||||
|
meta.getPersistentDataContainer().set(
|
||||||
|
ownerKey, PersistentDataType.STRING, ownerId.toString()
|
||||||
|
);
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean contains(Player player, RoleControl role) {
|
||||||
|
for (ItemStack item : player.getInventory().getContents()) {
|
||||||
|
if (owner(item).filter(player.getUniqueId()::equals).isPresent()
|
||||||
|
&& role(item).filter(role::equals).isPresent()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String metadata(ItemStack item, NamespacedKey key) {
|
||||||
|
if (item == null || !item.hasItemMeta()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return item.getItemMeta().getPersistentDataContainer()
|
||||||
|
.get(key, PersistentDataType.STRING);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.InventoryHolder;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
|
||||||
|
public final class BukkitTyrantControlPanelRenderer
|
||||||
|
implements TyrantControlPanelRenderer, Listener {
|
||||||
|
private static final String TITLE = ChatColor.DARK_RED + "Tyrant Control Panel";
|
||||||
|
private final Server server;
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public BukkitTyrantControlPanelRenderer(
|
||||||
|
Server server,
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this.server = server;
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void openOverview(Player player, TyrantControlPanelModel model) {
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.OVERVIEW, null, null);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE);
|
||||||
|
inventory.setItem(10, item(Material.NETHER_STAR, ChatColor.GOLD + "Reign Overview", List.of(
|
||||||
|
"Level: " + model.level(),
|
||||||
|
"Unspent choices: " + model.unspentChoices(),
|
||||||
|
"Purchased: " + (model.purchasedUnlocks().isEmpty()
|
||||||
|
? "none" : model.purchasedUnlocks())
|
||||||
|
)));
|
||||||
|
inventory.setItem(12, item(Material.ENCHANTED_BOOK, ChatColor.YELLOW + "Unlocks",
|
||||||
|
List.of("Review and purchase reign unlocks.")));
|
||||||
|
inventory.setItem(14, item(Material.ARMOR_STAND, ChatColor.AQUA + "Class Assignments",
|
||||||
|
classSummary(model)));
|
||||||
|
String intelligence = intelligenceText(model);
|
||||||
|
inventory.setItem(16, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE + "Roster Intelligence",
|
||||||
|
List.of(intelligence, "Click to activate when ready.")));
|
||||||
|
inventory.setItem(22, item(Material.NETHERITE_CHESTPLATE,
|
||||||
|
ChatColor.GOLD + "Legacy 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onClick(InventoryClickEvent event) {
|
||||||
|
if (!(event.getWhoClicked() instanceof Player player)
|
||||||
|
|| !(event.getView().getTopInventory().getHolder() instanceof MenuHolder holder)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.setCancelled(true);
|
||||||
|
if (event.getRawSlot() < 0
|
||||||
|
|| event.getRawSlot() >= event.getView().getTopInventory().getSize()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isActiveTyrant(player)) {
|
||||||
|
player.closeInventory();
|
||||||
|
player.sendMessage(ChatColor.RED + "This Tyrant control panel is no longer active.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (holder.view()) {
|
||||||
|
case OVERVIEW -> clickOverview(player, event.getRawSlot());
|
||||||
|
case UNLOCKS -> clickUnlocks(player, event.getRawSlot());
|
||||||
|
case PURCHASE_CONFIRMATION -> clickPurchaseConfirmation(
|
||||||
|
player, event.getRawSlot(), holder.unlock()
|
||||||
|
);
|
||||||
|
case CLASSES -> clickClasses(player, event.getRawSlot());
|
||||||
|
case ABILITIES -> clickAbilities(player, event.getRawSlot());
|
||||||
|
case PLAYERS -> clickPlayers(player, event.getRawSlot(), holder.tyrantClass());
|
||||||
|
case ASSIGN_CONFIRMATION -> clickAssignmentConfirmation(
|
||||||
|
player, event.getRawSlot(), holder.tyrantClass(), holder.targetId()
|
||||||
|
);
|
||||||
|
case ARMOR -> clickArmor(player, event.getRawSlot());
|
||||||
|
case ARMOR_CONFIRMATION -> clickArmorConfirmation(
|
||||||
|
player, event.getRawSlot(), holder.armorPiece()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickOverview(Player player, int slot) {
|
||||||
|
if (slot == 12) {
|
||||||
|
openUnlocks(player);
|
||||||
|
} else if (slot == 14) {
|
||||||
|
openClasses(player);
|
||||||
|
} else if (slot == 16) {
|
||||||
|
openAbilities(player);
|
||||||
|
} else if (slot == 22) {
|
||||||
|
openArmor(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openArmor(Player player) {
|
||||||
|
TyrantControlPanelModel model = model(player);
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.ARMOR, null, null);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE + " — Legacy 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openAbilities(Player player) {
|
||||||
|
TyrantControlPanelModel model = model(player);
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.ABILITIES, null, null);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE + " — Abilities");
|
||||||
|
inventory.setItem(11, item(Material.BLAZE_POWDER, ChatColor.RED + "Permanent Strength",
|
||||||
|
List.of(model.purchasedUnlocks().contains(TyrantUnlock.STRENGTH)
|
||||||
|
? "Purchased and active." : "Not purchased.")));
|
||||||
|
inventory.setItem(13, item(Material.IRON_CHESTPLATE,
|
||||||
|
ChatColor.BLUE + "Permanent Resistance",
|
||||||
|
List.of(model.purchasedUnlocks().contains(TyrantUnlock.RESISTANCE)
|
||||||
|
? "Purchased and active." : "Not purchased.")));
|
||||||
|
inventory.setItem(15, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE
|
||||||
|
+ "Roster Intelligence", List.of(
|
||||||
|
intelligenceText(model), "Click to activate when ready."
|
||||||
|
)));
|
||||||
|
inventory.setItem(22, backItem());
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickAbilities(Player player, int slot) {
|
||||||
|
if (slot == 22) {
|
||||||
|
refresh(player);
|
||||||
|
} else if (slot == 15) {
|
||||||
|
player.performCommand("tyrant intelligence");
|
||||||
|
openAbilities(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openUnlocks(Player player) {
|
||||||
|
TyrantControlPanelModel model = model(player);
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.UNLOCKS, null, null);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE + " — Unlocks");
|
||||||
|
int[] slots = {10, 11, 12, 14, 15, 16};
|
||||||
|
TyrantUnlock[] unlocks = TyrantUnlock.values();
|
||||||
|
for (int index = 0; index < unlocks.length; index++) {
|
||||||
|
TyrantUnlock unlock = unlocks[index];
|
||||||
|
UnlockAvailability availability = model.unlocks().get(unlock);
|
||||||
|
Material material = switch (availability) {
|
||||||
|
case PURCHASED -> Material.LIME_DYE;
|
||||||
|
case AVAILABLE -> Material.GOLD_INGOT;
|
||||||
|
case UNAVAILABLE -> Material.GRAY_DYE;
|
||||||
|
};
|
||||||
|
inventory.setItem(slots[index], item(material,
|
||||||
|
ChatColor.YELLOW + readable(unlock.name()),
|
||||||
|
List.of("Status: " + availability, "Cost: 1 choice")));
|
||||||
|
}
|
||||||
|
inventory.setItem(22, backItem());
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickUnlocks(Player player, int slot) {
|
||||||
|
if (slot == 22) {
|
||||||
|
refresh(player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int[] slots = {10, 11, 12, 14, 15, 16};
|
||||||
|
TyrantUnlock[] unlocks = TyrantUnlock.values();
|
||||||
|
for (int index = 0; index < slots.length; index++) {
|
||||||
|
if (slot == slots[index]
|
||||||
|
&& model(player).unlocks().get(unlocks[index])
|
||||||
|
== UnlockAvailability.AVAILABLE) {
|
||||||
|
openPurchaseConfirmation(player, unlocks[index]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openPurchaseConfirmation(Player player, TyrantUnlock unlock) {
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.PURCHASE_CONFIRMATION, unlock, null);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE + " — Confirm Purchase");
|
||||||
|
inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of()));
|
||||||
|
inventory.setItem(13, item(Material.ENCHANTED_BOOK, ChatColor.YELLOW + readable(unlock.name()),
|
||||||
|
List.of("Cost: 1 choice")));
|
||||||
|
inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of()));
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickPurchaseConfirmation(Player player, int slot, TyrantUnlock unlock) {
|
||||||
|
if (slot == 11) {
|
||||||
|
openUnlocks(player);
|
||||||
|
} else if (slot == 15 && unlock != null) {
|
||||||
|
player.performCommand("tyrant buy " + unlock.name().toLowerCase(Locale.ROOT));
|
||||||
|
openUnlocks(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openClasses(Player player) {
|
||||||
|
TyrantControlPanelModel model = model(player);
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.CLASSES, null, null);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE + " — Classes");
|
||||||
|
TyrantClass[] classes = {TyrantClass.ASSASSIN, TyrantClass.FIXER, TyrantClass.TAMER};
|
||||||
|
int[] slots = {11, 13, 15};
|
||||||
|
for (int index = 0; index < classes.length; index++) {
|
||||||
|
TyrantClass tyrantClass = classes[index];
|
||||||
|
boolean purchased = stateManager.game().purchases().contains(unlockFor(tyrantClass));
|
||||||
|
inventory.setItem(slots[index], item(
|
||||||
|
purchased ? Material.ARMOR_STAND : Material.BARRIER,
|
||||||
|
ChatColor.AQUA + readable(tyrantClass.name()),
|
||||||
|
List.of("Holder: " + model.classHolders().get(tyrantClass),
|
||||||
|
purchased ? "Click to assign." : "Unlock not purchased.")));
|
||||||
|
}
|
||||||
|
inventory.setItem(22, backItem());
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickClasses(Player player, int slot) {
|
||||||
|
if (slot == 22) {
|
||||||
|
refresh(player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TyrantClass tyrantClass = switch (slot) {
|
||||||
|
case 11 -> TyrantClass.ASSASSIN;
|
||||||
|
case 13 -> TyrantClass.FIXER;
|
||||||
|
case 15 -> TyrantClass.TAMER;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
if (tyrantClass != null
|
||||||
|
&& stateManager.game().purchases().contains(unlockFor(tyrantClass))) {
|
||||||
|
openPlayers(player, tyrantClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openPlayers(Player player, TyrantClass tyrantClass) {
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.PLAYERS, null, tyrantClass);
|
||||||
|
Inventory inventory = create(holder, 54, TITLE + " — Select Player");
|
||||||
|
int slot = 0;
|
||||||
|
for (Player candidate : server.getOnlinePlayers()) {
|
||||||
|
if (slot >= 45 || candidate.getUniqueId().equals(player.getUniqueId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PlayerState state = stateManager.player(candidate.getUniqueId(), candidate.getName());
|
||||||
|
String eligibility = state.optedOutUntil().isPresent()
|
||||||
|
? ChatColor.RED + "Ineligible: opted out"
|
||||||
|
: state.tyrantClass() != TyrantClass.NONE
|
||||||
|
&& state.tyrantClass() != tyrantClass
|
||||||
|
? ChatColor.RED + "Ineligible: already " + state.tyrantClass()
|
||||||
|
: ChatColor.GREEN + "Eligible";
|
||||||
|
inventory.setItem(slot, item(Material.PLAYER_HEAD, candidate.getName(),
|
||||||
|
List.of(eligibility, candidate.getUniqueId().toString())));
|
||||||
|
slot++;
|
||||||
|
}
|
||||||
|
inventory.setItem(49, backItem());
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickPlayers(Player player, int slot, TyrantClass tyrantClass) {
|
||||||
|
if (slot == 49) {
|
||||||
|
openClasses(player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ItemStack clicked = player.getOpenInventory().getTopInventory().getItem(slot);
|
||||||
|
if (clicked == null || clicked.getType() != Material.PLAYER_HEAD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> lore = clicked.getItemMeta().getLore();
|
||||||
|
if (lore == null || lore.size() < 2 || !ChatColor.stripColor(lore.get(0)).equals("Eligible")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
openAssignmentConfirmation(
|
||||||
|
player, tyrantClass, UUID.fromString(ChatColor.stripColor(lore.get(1)))
|
||||||
|
);
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
player.sendMessage(ChatColor.RED + "That player selection is no longer valid.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openAssignmentConfirmation(
|
||||||
|
Player player,
|
||||||
|
TyrantClass tyrantClass,
|
||||||
|
UUID targetId
|
||||||
|
) {
|
||||||
|
Player target = server.getPlayer(targetId);
|
||||||
|
if (target == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "That player is no longer online.");
|
||||||
|
openPlayers(player, tyrantClass);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MenuHolder holder = new MenuHolder(
|
||||||
|
MenuView.ASSIGN_CONFIRMATION, null, tyrantClass, targetId
|
||||||
|
);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE + " — Confirm Assignment");
|
||||||
|
String current = model(player).classHolders().get(tyrantClass);
|
||||||
|
inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of()));
|
||||||
|
inventory.setItem(13, item(Material.ARMOR_STAND,
|
||||||
|
ChatColor.YELLOW + target.getName(),
|
||||||
|
List.of("Class: " + tyrantClass, "Current holder: " + current)));
|
||||||
|
inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of()));
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickAssignmentConfirmation(
|
||||||
|
Player player,
|
||||||
|
int slot,
|
||||||
|
TyrantClass tyrantClass,
|
||||||
|
UUID targetId
|
||||||
|
) {
|
||||||
|
if (slot == 11) {
|
||||||
|
openPlayers(player, tyrantClass);
|
||||||
|
} else if (slot == 15 && tyrantClass != null && targetId != null) {
|
||||||
|
Player target = server.getPlayer(targetId);
|
||||||
|
if (target == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "That player is no longer online.");
|
||||||
|
} else {
|
||||||
|
player.performCommand("tyrant assign "
|
||||||
|
+ tyrantClass.name().toLowerCase(Locale.ROOT) + " " + target.getName());
|
||||||
|
}
|
||||||
|
openClasses(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isActiveTyrant(Player player) {
|
||||||
|
GameState game = stateManager.game();
|
||||||
|
return game.lifecycle() == GameLifecycle.RUNNING
|
||||||
|
&& game.tyrantId().filter(player.getUniqueId()::equals).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TyrantControlPanelModel model(Player player) {
|
||||||
|
return TyrantControlPanelModel.create(
|
||||||
|
stateManager.game(),
|
||||||
|
stateManager.player(player.getUniqueId(), player.getName()),
|
||||||
|
stateManager.players(),
|
||||||
|
clock.instant()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refresh(Player player) {
|
||||||
|
openOverview(player, model(player));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Inventory create(MenuHolder holder, int size, String title) {
|
||||||
|
Inventory inventory = server.createInventory(holder, size, title);
|
||||||
|
holder.inventory = inventory;
|
||||||
|
return inventory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ItemStack item(Material material, String name, List<String> lore) {
|
||||||
|
ItemStack item = new ItemStack(material);
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
meta.setDisplayName(name);
|
||||||
|
meta.setLore(lore.stream().map(line -> ChatColor.GRAY + line).toList());
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ItemStack backItem() {
|
||||||
|
return item(Material.ARROW, ChatColor.YELLOW + "Back", List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> classSummary(TyrantControlPanelModel model) {
|
||||||
|
List<String> summary = new ArrayList<>();
|
||||||
|
for (TyrantClass tyrantClass : new TyrantClass[] {
|
||||||
|
TyrantClass.ASSASSIN, TyrantClass.FIXER, TyrantClass.TAMER
|
||||||
|
}) {
|
||||||
|
summary.add(readable(tyrantClass.name()) + ": "
|
||||||
|
+ model.classHolders().get(tyrantClass));
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String intelligenceText(TyrantControlPanelModel model) {
|
||||||
|
if (model.unlocks().get(TyrantUnlock.ROSTER_INTELLIGENCE)
|
||||||
|
!= UnlockAvailability.PURCHASED) {
|
||||||
|
return "Unavailable: unlock not purchased.";
|
||||||
|
}
|
||||||
|
Duration cooldown = model.intelligenceCooldown();
|
||||||
|
return cooldown.isZero() ? "Ready" : "Cooldown: " + cooldown.toSeconds() + "s";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TyrantUnlock unlockFor(TyrantClass tyrantClass) {
|
||||||
|
return switch (tyrantClass) {
|
||||||
|
case ASSASSIN -> TyrantUnlock.ASSASSIN;
|
||||||
|
case FIXER -> TyrantUnlock.FIXER;
|
||||||
|
case TAMER -> TyrantUnlock.TAMER;
|
||||||
|
case NONE -> throw new IllegalArgumentException("NONE is not assignable");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readable(String value) {
|
||||||
|
String lower = value.toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||||
|
return Character.toUpperCase(lower.charAt(0)) + lower.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum MenuView {
|
||||||
|
OVERVIEW,
|
||||||
|
UNLOCKS,
|
||||||
|
PURCHASE_CONFIRMATION,
|
||||||
|
CLASSES,
|
||||||
|
ABILITIES,
|
||||||
|
PLAYERS,
|
||||||
|
ASSIGN_CONFIRMATION,
|
||||||
|
ARMOR,
|
||||||
|
ARMOR_CONFIRMATION
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class MenuHolder implements InventoryHolder {
|
||||||
|
private final MenuView view;
|
||||||
|
private final TyrantUnlock unlock;
|
||||||
|
private final TyrantClass tyrantClass;
|
||||||
|
private final UUID targetId;
|
||||||
|
private final TyrantArmorPiece armorPiece;
|
||||||
|
private Inventory inventory;
|
||||||
|
|
||||||
|
private MenuHolder(MenuView view, TyrantUnlock unlock, TyrantClass tyrantClass) {
|
||||||
|
this(view, unlock, tyrantClass, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MenuHolder(
|
||||||
|
MenuView view,
|
||||||
|
TyrantUnlock unlock,
|
||||||
|
TyrantClass tyrantClass,
|
||||||
|
UUID targetId
|
||||||
|
) {
|
||||||
|
this(view, unlock, tyrantClass, targetId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MenuHolder(
|
||||||
|
MenuView view,
|
||||||
|
TyrantUnlock unlock,
|
||||||
|
TyrantClass tyrantClass,
|
||||||
|
UUID targetId,
|
||||||
|
TyrantArmorPiece armorPiece
|
||||||
|
) {
|
||||||
|
this.view = view;
|
||||||
|
this.unlock = unlock;
|
||||||
|
this.tyrantClass = tyrantClass;
|
||||||
|
this.targetId = targetId;
|
||||||
|
this.armorPiece = armorPiece;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MenuView view() {
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TyrantUnlock unlock() {
|
||||||
|
return unlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TyrantClass tyrantClass() {
|
||||||
|
return tyrantClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID targetId() {
|
||||||
|
return targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TyrantArmorPiece armorPiece() {
|
||||||
|
return armorPiece;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Inventory getInventory() {
|
||||||
|
return inventory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.scoreboard.Scoreboard;
|
||||||
|
import org.bukkit.scoreboard.ScoreboardManager;
|
||||||
|
import org.bukkit.scoreboard.Team;
|
||||||
|
|
||||||
|
public final class BukkitTyrantPresentation implements TyrantPresentation {
|
||||||
|
private static final String TEAM_NAME = "tyrant-red";
|
||||||
|
private final Server server;
|
||||||
|
private final Team team;
|
||||||
|
private Optional<UUID> presentedTyrant = Optional.empty();
|
||||||
|
|
||||||
|
public BukkitTyrantPresentation(Server server) {
|
||||||
|
this.server = server;
|
||||||
|
ScoreboardManager manager = Objects.requireNonNull(
|
||||||
|
server.getScoreboardManager(), "Scoreboard manager is unavailable"
|
||||||
|
);
|
||||||
|
Scoreboard scoreboard = manager.getMainScoreboard();
|
||||||
|
Team existing = scoreboard.getTeam(TEAM_NAME);
|
||||||
|
team = existing == null ? scoreboard.registerNewTeam(TEAM_NAME) : existing;
|
||||||
|
team.setColor(ChatColor.RED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void reconcile(Optional<UUID> tyrantId) {
|
||||||
|
clear();
|
||||||
|
presentedTyrant = tyrantId;
|
||||||
|
tyrantId.map(server::getPlayer).ifPresent(player -> {
|
||||||
|
team.addEntry(player.getName());
|
||||||
|
player.setGlowing(true);
|
||||||
|
player.setPlayerListName(ChatColor.RED + player.getName());
|
||||||
|
player.setDisplayName(ChatColor.RED + player.getName());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void clear() {
|
||||||
|
presentedTyrant.map(server::getPlayer).ifPresent(player -> {
|
||||||
|
player.setGlowing(false);
|
||||||
|
player.setPlayerListName(player.getName());
|
||||||
|
player.setDisplayName(player.getName());
|
||||||
|
});
|
||||||
|
for (String entry : new HashSet<>(team.getEntries())) {
|
||||||
|
team.removeEntry(entry);
|
||||||
|
}
|
||||||
|
presentedTyrant = Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.InventoryHolder;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
|
||||||
|
public final class BukkitVigilanteControlPanelRenderer
|
||||||
|
implements VigilanteControlPanelRenderer, Listener {
|
||||||
|
private static final String TITLE = ChatColor.DARK_BLUE + "Vigilante Control Panel";
|
||||||
|
private final Server server;
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final FollowerService followers;
|
||||||
|
private final FollowerPresenceProvider presenceProvider;
|
||||||
|
private final int strengthCap;
|
||||||
|
private final int resistanceCap;
|
||||||
|
|
||||||
|
public BukkitVigilanteControlPanelRenderer(
|
||||||
|
Server server,
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
FollowerService followers,
|
||||||
|
FollowerPresenceProvider presenceProvider,
|
||||||
|
int strengthCap,
|
||||||
|
int resistanceCap
|
||||||
|
) {
|
||||||
|
this.server = server;
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.followers = followers;
|
||||||
|
this.presenceProvider = presenceProvider;
|
||||||
|
this.strengthCap = strengthCap;
|
||||||
|
this.resistanceCap = resistanceCap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void openOverview(Player player, VigilanteControlPanelModel model) {
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.OVERVIEW, null);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE);
|
||||||
|
inventory.setItem(10, item(Material.SHIELD, ChatColor.BLUE + "Resistance Overview",
|
||||||
|
List.of(
|
||||||
|
"Followers: " + model.followers().size(),
|
||||||
|
"Pending invitations: " + model.pendingInvitations().size(),
|
||||||
|
"Nearby contributors: " + model.nearbyEligibleFollowers(),
|
||||||
|
"Strength: " + model.strengthLevel() + "/" + model.strengthCap(),
|
||||||
|
"Resistance: " + model.resistanceLevel() + "/" + model.resistanceCap()
|
||||||
|
)));
|
||||||
|
inventory.setItem(12, item(Material.WRITABLE_BOOK, ChatColor.GREEN + "Recruitment",
|
||||||
|
List.of("Invite eligible online players.")));
|
||||||
|
inventory.setItem(14, item(Material.PLAYER_HEAD, ChatColor.AQUA + "Manage Followers",
|
||||||
|
followerSummary(model)));
|
||||||
|
inventory.setItem(16, item(Material.REDSTONE_TORCH, ChatColor.RED + "Role Control",
|
||||||
|
List.of("Relinquish the Vigilante role.")));
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onClick(InventoryClickEvent event) {
|
||||||
|
if (!(event.getWhoClicked() instanceof Player player)
|
||||||
|
|| !(event.getView().getTopInventory().getHolder() instanceof MenuHolder holder)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.setCancelled(true);
|
||||||
|
if (event.getRawSlot() < 0
|
||||||
|
|| event.getRawSlot() >= event.getView().getTopInventory().getSize()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isActiveVigilante(player)) {
|
||||||
|
player.closeInventory();
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "This Vigilante control panel is no longer active.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (holder.view()) {
|
||||||
|
case OVERVIEW -> clickOverview(player, event.getRawSlot());
|
||||||
|
case RECRUITMENT -> clickRecruitment(player, event.getRawSlot());
|
||||||
|
case INVITE_CONFIRMATION -> clickInviteConfirmation(
|
||||||
|
player, event.getRawSlot(), holder.targetId()
|
||||||
|
);
|
||||||
|
case FOLLOWERS -> clickFollowers(player, event.getRawSlot());
|
||||||
|
case DISMISS_CONFIRMATION -> clickDismissConfirmation(
|
||||||
|
player, event.getRawSlot(), holder.targetId()
|
||||||
|
);
|
||||||
|
case ROLE -> clickRole(player, event.getRawSlot());
|
||||||
|
case RELINQUISH_CONFIRMATION -> clickRelinquishConfirmation(
|
||||||
|
player, event.getRawSlot()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickOverview(Player player, int slot) {
|
||||||
|
if (slot == 12) {
|
||||||
|
openRecruitment(player);
|
||||||
|
} else if (slot == 14) {
|
||||||
|
openFollowers(player);
|
||||||
|
} else if (slot == 16) {
|
||||||
|
openRole(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openRecruitment(Player player) {
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.RECRUITMENT, null);
|
||||||
|
Inventory inventory = create(holder, 54, TITLE + " — Recruitment");
|
||||||
|
Set<UUID> pending = followers.invitedPlayerIds(player.getUniqueId());
|
||||||
|
int slot = 0;
|
||||||
|
for (Player candidate : server.getOnlinePlayers()) {
|
||||||
|
if (slot >= 45 || candidate.getUniqueId().equals(player.getUniqueId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PlayerState state = stateManager.player(candidate.getUniqueId(), candidate.getName());
|
||||||
|
String eligibility = recruitmentEligibility(candidate.getUniqueId(), state, pending);
|
||||||
|
inventory.setItem(slot, playerItem(candidate.getName(), candidate.getUniqueId(),
|
||||||
|
eligibility));
|
||||||
|
slot++;
|
||||||
|
}
|
||||||
|
inventory.setItem(49, backItem());
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String recruitmentEligibility(
|
||||||
|
UUID candidateId,
|
||||||
|
PlayerState state,
|
||||||
|
Set<UUID> pending
|
||||||
|
) {
|
||||||
|
GameState game = stateManager.game();
|
||||||
|
if (pending.contains(candidateId)) {
|
||||||
|
return ChatColor.YELLOW + "Pending invitation";
|
||||||
|
}
|
||||||
|
if (state.optedOutUntil().isPresent()) {
|
||||||
|
return ChatColor.RED + "Ineligible: opted out";
|
||||||
|
}
|
||||||
|
if (game.tyrantId().filter(candidateId::equals).isPresent()) {
|
||||||
|
return ChatColor.RED + "Ineligible: Tyrant";
|
||||||
|
}
|
||||||
|
if (game.vigilanteId().filter(candidateId::equals).isPresent()) {
|
||||||
|
return ChatColor.RED + "Ineligible: Vigilante";
|
||||||
|
}
|
||||||
|
if (state.followerOf().isPresent()) {
|
||||||
|
return ChatColor.RED + "Ineligible: already a Follower";
|
||||||
|
}
|
||||||
|
return ChatColor.GREEN + "Eligible";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickRecruitment(Player player, int slot) {
|
||||||
|
if (slot == 49) {
|
||||||
|
refresh(player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedPlayer(player, slot, "Eligible")
|
||||||
|
.ifPresent(targetId -> openInviteConfirmation(player, targetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openInviteConfirmation(Player player, UUID targetId) {
|
||||||
|
Player target = server.getPlayer(targetId);
|
||||||
|
if (target == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "That player is no longer online.");
|
||||||
|
openRecruitment(player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.INVITE_CONFIRMATION, targetId);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE + " — Confirm Invitation");
|
||||||
|
inventory.setItem(11, cancelItem());
|
||||||
|
inventory.setItem(13, item(Material.WRITABLE_BOOK, ChatColor.YELLOW + target.getName(),
|
||||||
|
List.of("Invite this player to become a Follower?")));
|
||||||
|
inventory.setItem(15, confirmItem());
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickInviteConfirmation(Player player, int slot, UUID targetId) {
|
||||||
|
if (slot == 11) {
|
||||||
|
openRecruitment(player);
|
||||||
|
} else if (slot == 15 && targetId != null) {
|
||||||
|
Player target = server.getPlayer(targetId);
|
||||||
|
if (target == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "That player is no longer online.");
|
||||||
|
} else {
|
||||||
|
player.performCommand("vigilante invite " + target.getName());
|
||||||
|
}
|
||||||
|
openRecruitment(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openFollowers(Player player) {
|
||||||
|
VigilanteControlPanelModel model = model(player);
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.FOLLOWERS, null);
|
||||||
|
Inventory inventory = create(holder, 54, TITLE + " — Followers");
|
||||||
|
int slot = 0;
|
||||||
|
for (VigilanteFollowerView follower : model.followers()) {
|
||||||
|
if (slot >= 45) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
FollowerPresence presence = follower.presence();
|
||||||
|
List<String> status = List.of(
|
||||||
|
presence.online() ? "Online" : "Offline",
|
||||||
|
presence.living() ? "Living" : "Dead or unavailable",
|
||||||
|
presence.sameWorld() ? "Same world" : "Different world or unavailable",
|
||||||
|
presence.withinRange() ? "Within range" : "Outside range or unavailable",
|
||||||
|
follower.optedOut() ? "Opted out" : "Participating",
|
||||||
|
presence.online() ? ChatColor.GREEN + "Click to dismiss."
|
||||||
|
: ChatColor.RED + "Must be online to dismiss."
|
||||||
|
);
|
||||||
|
inventory.setItem(slot, playerItem(
|
||||||
|
follower.name(), follower.playerId(), status.toString()
|
||||||
|
));
|
||||||
|
slot++;
|
||||||
|
}
|
||||||
|
inventory.setItem(49, backItem());
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickFollowers(Player player, int slot) {
|
||||||
|
if (slot == 49) {
|
||||||
|
refresh(player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedPlayer(player, slot, "Online")
|
||||||
|
.filter(targetId -> server.getPlayer(targetId) != null)
|
||||||
|
.ifPresent(targetId -> openDismissConfirmation(player, targetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openDismissConfirmation(Player player, UUID targetId) {
|
||||||
|
Player target = server.getPlayer(targetId);
|
||||||
|
if (target == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "That Follower is no longer online.");
|
||||||
|
openFollowers(player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.DISMISS_CONFIRMATION, targetId);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE + " — Confirm Dismissal");
|
||||||
|
inventory.setItem(11, cancelItem());
|
||||||
|
inventory.setItem(13, item(Material.PLAYER_HEAD, ChatColor.YELLOW + target.getName(),
|
||||||
|
List.of("Dismiss this Follower?")));
|
||||||
|
inventory.setItem(15, confirmItem());
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickDismissConfirmation(Player player, int slot, UUID targetId) {
|
||||||
|
if (slot == 11) {
|
||||||
|
openFollowers(player);
|
||||||
|
} else if (slot == 15 && targetId != null) {
|
||||||
|
Player target = server.getPlayer(targetId);
|
||||||
|
if (target == null) {
|
||||||
|
player.sendMessage(ChatColor.RED + "That Follower is no longer online.");
|
||||||
|
} else {
|
||||||
|
player.performCommand("vigilante dismiss " + target.getName());
|
||||||
|
}
|
||||||
|
openFollowers(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openRole(Player player) {
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.ROLE, null);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE + " — Role Control");
|
||||||
|
inventory.setItem(13, item(Material.REDSTONE_TORCH,
|
||||||
|
ChatColor.RED + "Relinquish Vigilante Role",
|
||||||
|
List.of("Followers will be cleared and the arena will open.",
|
||||||
|
"Click to review confirmation.")));
|
||||||
|
inventory.setItem(22, backItem());
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickRole(Player player, int slot) {
|
||||||
|
if (slot == 22) {
|
||||||
|
refresh(player);
|
||||||
|
} else if (slot == 13) {
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.RELINQUISH_CONFIRMATION, null);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE + " — Confirm Relinquishment");
|
||||||
|
inventory.setItem(11, cancelItem());
|
||||||
|
inventory.setItem(13, item(Material.BARRIER, ChatColor.RED + "Relinquish Role",
|
||||||
|
List.of("This clears all Followers and opens the arena challenge.")));
|
||||||
|
inventory.setItem(15, confirmItem());
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickRelinquishConfirmation(Player player, int slot) {
|
||||||
|
if (slot == 11) {
|
||||||
|
openRole(player);
|
||||||
|
} else if (slot == 15) {
|
||||||
|
player.closeInventory();
|
||||||
|
player.performCommand("tyrant relinquish confirm");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private java.util.Optional<UUID> selectedPlayer(
|
||||||
|
Player player,
|
||||||
|
int slot,
|
||||||
|
String requiredStatus
|
||||||
|
) {
|
||||||
|
ItemStack clicked = player.getOpenInventory().getTopInventory().getItem(slot);
|
||||||
|
if (clicked == null || clicked.getType() != Material.PLAYER_HEAD) {
|
||||||
|
return java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
List<String> lore = clicked.getItemMeta().getLore();
|
||||||
|
if (lore == null || lore.size() < 2
|
||||||
|
|| !ChatColor.stripColor(lore.get(0)).contains(requiredStatus)) {
|
||||||
|
return java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return java.util.Optional.of(UUID.fromString(
|
||||||
|
ChatColor.stripColor(lore.get(lore.size() - 1))
|
||||||
|
));
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
player.sendMessage(ChatColor.RED + "That player selection is no longer valid.");
|
||||||
|
return java.util.Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isActiveVigilante(Player player) {
|
||||||
|
GameState game = stateManager.game();
|
||||||
|
return game.lifecycle() == GameLifecycle.RUNNING
|
||||||
|
&& game.vigilanteId().filter(player.getUniqueId()::equals).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private VigilanteControlPanelModel model(Player player) {
|
||||||
|
Map<UUID, PlayerState> players = stateManager.players();
|
||||||
|
return VigilanteControlPanelModel.create(
|
||||||
|
stateManager.game(), players, followers.invitedPlayerIds(player.getUniqueId()),
|
||||||
|
presenceProvider.inspect(player, players), strengthCap, resistanceCap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refresh(Player player) {
|
||||||
|
openOverview(player, model(player));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Inventory create(MenuHolder holder, int size, String title) {
|
||||||
|
Inventory inventory = server.createInventory(holder, size, title);
|
||||||
|
holder.inventory = inventory;
|
||||||
|
return inventory;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ItemStack playerItem(String name, UUID playerId, String status) {
|
||||||
|
return item(Material.PLAYER_HEAD, name, List.of(status, playerId.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ItemStack item(Material material, String name, List<String> lore) {
|
||||||
|
ItemStack item = new ItemStack(material);
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
meta.setDisplayName(name);
|
||||||
|
meta.setLore(lore.stream().map(line -> ChatColor.GRAY + line).toList());
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ItemStack backItem() {
|
||||||
|
return item(Material.ARROW, ChatColor.YELLOW + "Back", List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ItemStack cancelItem() {
|
||||||
|
return item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ItemStack confirmItem() {
|
||||||
|
return item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> followerSummary(VigilanteControlPanelModel model) {
|
||||||
|
if (model.followers().isEmpty()) {
|
||||||
|
return List.of("No Followers assigned.");
|
||||||
|
}
|
||||||
|
return model.followers().stream()
|
||||||
|
.map(follower -> follower.name() + (follower.contributes() ? " — contributing" : ""))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum MenuView {
|
||||||
|
OVERVIEW,
|
||||||
|
RECRUITMENT,
|
||||||
|
INVITE_CONFIRMATION,
|
||||||
|
FOLLOWERS,
|
||||||
|
DISMISS_CONFIRMATION,
|
||||||
|
ROLE,
|
||||||
|
RELINQUISH_CONFIRMATION
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class MenuHolder implements InventoryHolder {
|
||||||
|
private final MenuView view;
|
||||||
|
private final UUID targetId;
|
||||||
|
private Inventory inventory;
|
||||||
|
|
||||||
|
private MenuHolder(MenuView view, UUID targetId) {
|
||||||
|
this.view = view;
|
||||||
|
this.targetId = targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MenuView view() {
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID targetId() {
|
||||||
|
return targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Inventory getInventory() {
|
||||||
|
return inventory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public interface CapturedMobCustodyRelease {
|
||||||
|
PlayerState releaseAll(Player player, PlayerState state);
|
||||||
|
|
||||||
|
PlayerState releaseExcess(Player player, PlayerState state, int custodyLimit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
public final class CapturedMobInventoryService {
|
||||||
|
private final CapturedMobItemService items;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
|
||||||
|
public CapturedMobInventoryService(
|
||||||
|
CapturedMobItemService items,
|
||||||
|
PluginSettings settings
|
||||||
|
) {
|
||||||
|
this.items = items;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reconcile(Player player, PlayerState state) {
|
||||||
|
Map<UUID, CapturedMob> stored = new java.util.HashMap<>();
|
||||||
|
for (CapturedMob mob : state.capturedMobs()) {
|
||||||
|
try {
|
||||||
|
UUID captureId = UUID.fromString(mob.data().get("capture-id"));
|
||||||
|
stored.putIfAbsent(captureId, mob);
|
||||||
|
} catch (IllegalArgumentException | NullPointerException ignored) {
|
||||||
|
// Invalid custody records are not materialized as items.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Set<UUID> retained = new HashSet<>();
|
||||||
|
ItemStack[] contents = player.getInventory().getContents();
|
||||||
|
for (int index = 0; index < contents.length; index++) {
|
||||||
|
ItemStack item = contents[index];
|
||||||
|
if (!items.isCapturedMob(item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
UUID captureId = items.captureId(item).orElseThrow();
|
||||||
|
boolean valid = items.owner(item).filter(state.playerId()::equals).isPresent()
|
||||||
|
&& stored.containsKey(captureId) && retained.add(captureId);
|
||||||
|
if (!valid) {
|
||||||
|
player.getInventory().setItem(index, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Map.Entry<UUID, CapturedMob> entry : stored.entrySet()) {
|
||||||
|
if (retained.contains(entry.getKey())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(
|
||||||
|
items.create(
|
||||||
|
state.playerId(), entry.getKey(), entry.getValue().entityType()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!leftovers.isEmpty()) {
|
||||||
|
player.sendMessage(ChatColor.RED + settings.inventoryFullMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
public interface CapturedMobItemService {
|
||||||
|
ItemStack create(UUID ownerId, UUID captureId, String entityType);
|
||||||
|
|
||||||
|
boolean isCapturedMob(ItemStack item);
|
||||||
|
|
||||||
|
Optional<UUID> owner(ItemStack item);
|
||||||
|
|
||||||
|
Optional<UUID> captureId(ItemStack item);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class CapturedMobService {
|
||||||
|
public PlayerState capture(
|
||||||
|
PlayerState player,
|
||||||
|
UUID captureId,
|
||||||
|
String entityType,
|
||||||
|
String snapshot
|
||||||
|
) {
|
||||||
|
if (player.tyrantClass() != TyrantClass.TAMER) {
|
||||||
|
throw new IllegalStateException("only a Tamer can capture mobs");
|
||||||
|
}
|
||||||
|
if (!player.capturedMobs().isEmpty()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"release the currently captured mob before capturing another"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ("ENDER_DRAGON".equalsIgnoreCase(entityType)) {
|
||||||
|
throw new IllegalArgumentException("Ender Dragons cannot be captured");
|
||||||
|
}
|
||||||
|
List<CapturedMob> mobs = new ArrayList<>(player.capturedMobs());
|
||||||
|
mobs.add(new CapturedMob(entityType, Map.of(
|
||||||
|
"capture-id", captureId.toString(),
|
||||||
|
"snapshot", snapshot
|
||||||
|
)));
|
||||||
|
return copy(player, mobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<CapturedMob> find(PlayerState player, UUID captureId) {
|
||||||
|
return player.capturedMobs().stream()
|
||||||
|
.filter(mob -> captureId.toString().equals(mob.data().get("capture-id")))
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerState release(PlayerState player, UUID captureId) {
|
||||||
|
if (find(player, captureId).isEmpty()) {
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
List<CapturedMob> mobs = player.capturedMobs().stream()
|
||||||
|
.filter(mob -> !captureId.toString().equals(mob.data().get("capture-id")))
|
||||||
|
.toList();
|
||||||
|
return copy(player, mobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerState copy(PlayerState player, List<CapturedMob> mobs) {
|
||||||
|
return new PlayerState(
|
||||||
|
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
|
||||||
|
player.tyrantClass(), player.followerOf(), player.cooldownEnds(),
|
||||||
|
player.readyAbilityItems(), mobs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface CapturedMobSpawner {
|
||||||
|
boolean spawn(Player player, CapturedMob mob);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public final class ClassAssignmentMessages {
|
||||||
|
private ClassAssignmentMessages() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String loginReminder(PlayerState player) {
|
||||||
|
return switch (player.tyrantClass()) {
|
||||||
|
case ASSASSIN -> "Class: ASSASSIN. Use your Assassin Cloak; double-jump while "
|
||||||
|
+ "airborne. Use /tyrant item to recover ready class items.";
|
||||||
|
case FIXER -> "Class: FIXER. Use your Fixer's Wrench to activate your boost. "
|
||||||
|
+ "Use /tyrant item to recover ready class items.";
|
||||||
|
case TAMER -> "Class: TAMER. Use your Tamer's Lead to capture mobs. "
|
||||||
|
+ "Use /tyrant item to recover ready class items.";
|
||||||
|
case NONE -> "";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record ClassAssignmentResult(
|
||||||
|
Map<UUID, PlayerState> players,
|
||||||
|
ClassAssignmentStatus status,
|
||||||
|
Optional<UUID> previousHolderId
|
||||||
|
) {
|
||||||
|
public ClassAssignmentResult {
|
||||||
|
players = Map.copyOf(players);
|
||||||
|
previousHolderId = previousHolderId == null ? Optional.empty() : previousHolderId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class ClassAssignmentService {
|
||||||
|
public ClassAssignmentResult assign(
|
||||||
|
GameState game,
|
||||||
|
Map<UUID, PlayerState> players,
|
||||||
|
UUID assignerId,
|
||||||
|
UUID targetId,
|
||||||
|
TyrantClass tyrantClass,
|
||||||
|
Instant now
|
||||||
|
) {
|
||||||
|
ClassAssignmentStatus rejected = rejection(
|
||||||
|
game, players, assignerId, targetId, tyrantClass, now
|
||||||
|
);
|
||||||
|
if (rejected != null) {
|
||||||
|
return new ClassAssignmentResult(players, rejected, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<UUID, PlayerState> updated = new HashMap<>(players);
|
||||||
|
Optional<UUID> previousHolder = players.values().stream()
|
||||||
|
.filter(player -> player.tyrantClass() == tyrantClass)
|
||||||
|
.map(PlayerState::playerId)
|
||||||
|
.filter(playerId -> !playerId.equals(targetId))
|
||||||
|
.findFirst();
|
||||||
|
previousHolder.ifPresent(playerId -> updated.put(
|
||||||
|
playerId,
|
||||||
|
withClass(updated.get(playerId), TyrantClass.NONE)
|
||||||
|
));
|
||||||
|
updated.put(targetId, withClass(updated.get(targetId), tyrantClass));
|
||||||
|
return new ClassAssignmentResult(
|
||||||
|
updated,
|
||||||
|
ClassAssignmentStatus.ASSIGNED,
|
||||||
|
previousHolder
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<UUID, PlayerState> clearAll(Map<UUID, PlayerState> players) {
|
||||||
|
Map<UUID, PlayerState> updated = new HashMap<>();
|
||||||
|
for (PlayerState player : players.values()) {
|
||||||
|
updated.put(player.playerId(), withClass(player, TyrantClass.NONE));
|
||||||
|
}
|
||||||
|
return Map.copyOf(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClassAssignmentStatus rejection(
|
||||||
|
GameState game,
|
||||||
|
Map<UUID, PlayerState> players,
|
||||||
|
UUID assignerId,
|
||||||
|
UUID targetId,
|
||||||
|
TyrantClass tyrantClass,
|
||||||
|
Instant now
|
||||||
|
) {
|
||||||
|
if (game.lifecycle() != GameLifecycle.RUNNING) {
|
||||||
|
return ClassAssignmentStatus.GAME_NOT_RUNNING;
|
||||||
|
}
|
||||||
|
if (game.tyrantId().isEmpty() || !game.tyrantId().orElseThrow().equals(assignerId)) {
|
||||||
|
return ClassAssignmentStatus.NOT_TYRANT;
|
||||||
|
}
|
||||||
|
if (tyrantClass == null || tyrantClass == TyrantClass.NONE) {
|
||||||
|
return ClassAssignmentStatus.INVALID_CLASS;
|
||||||
|
}
|
||||||
|
if (!game.purchases().contains(unlockFor(tyrantClass))) {
|
||||||
|
return ClassAssignmentStatus.CLASS_NOT_UNLOCKED;
|
||||||
|
}
|
||||||
|
PlayerState target = players.get(targetId);
|
||||||
|
if (target == null) {
|
||||||
|
return ClassAssignmentStatus.UNKNOWN_PLAYER;
|
||||||
|
}
|
||||||
|
if (targetId.equals(assignerId)) {
|
||||||
|
return ClassAssignmentStatus.TARGET_IS_TYRANT;
|
||||||
|
}
|
||||||
|
if (target.optedOutUntil().isPresent()) {
|
||||||
|
return ClassAssignmentStatus.TARGET_OPTED_OUT;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TyrantUnlock unlockFor(TyrantClass tyrantClass) {
|
||||||
|
return switch (tyrantClass) {
|
||||||
|
case ASSASSIN -> TyrantUnlock.ASSASSIN;
|
||||||
|
case FIXER -> TyrantUnlock.FIXER;
|
||||||
|
case TAMER -> TyrantUnlock.TAMER;
|
||||||
|
case NONE -> throw new IllegalArgumentException("NONE is not assignable");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerState withClass(PlayerState player, TyrantClass tyrantClass) {
|
||||||
|
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
|
||||||
|
cooldowns.putAll(player.cooldownEnds());
|
||||||
|
Set<Ability> readyItems = EnumSet.noneOf(Ability.class);
|
||||||
|
readyItems.addAll(player.readyAbilityItems());
|
||||||
|
for (Ability ability : abilitiesFor(player.tyrantClass())) {
|
||||||
|
cooldowns.remove(ability);
|
||||||
|
readyItems.remove(ability);
|
||||||
|
}
|
||||||
|
readyItemFor(tyrantClass).ifPresent(readyItems::add);
|
||||||
|
return new PlayerState(
|
||||||
|
player.playerId(),
|
||||||
|
player.latestName(),
|
||||||
|
player.lastLogin(),
|
||||||
|
player.optedOutUntil(),
|
||||||
|
tyrantClass,
|
||||||
|
player.followerOf(),
|
||||||
|
cooldowns,
|
||||||
|
readyItems,
|
||||||
|
player.capturedMobs()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<Ability> readyItemFor(TyrantClass tyrantClass) {
|
||||||
|
return switch (tyrantClass) {
|
||||||
|
case ASSASSIN -> Optional.of(Ability.ASSASSIN_INVISIBILITY);
|
||||||
|
case FIXER -> Optional.of(Ability.FIXER_BOOST);
|
||||||
|
case TAMER -> Optional.of(Ability.TAMER_CAPTURE);
|
||||||
|
case NONE -> Optional.empty();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<Ability> abilitiesFor(TyrantClass tyrantClass) {
|
||||||
|
return switch (tyrantClass) {
|
||||||
|
case ASSASSIN -> Set.of(
|
||||||
|
Ability.ASSASSIN_INVISIBILITY,
|
||||||
|
Ability.ASSASSIN_DOUBLE_JUMP
|
||||||
|
);
|
||||||
|
case FIXER -> Set.of(Ability.FIXER_BOOST);
|
||||||
|
case TAMER -> Set.of(Ability.TAMER_CAPTURE);
|
||||||
|
case NONE -> Set.of();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public enum ClassAssignmentStatus {
|
||||||
|
ASSIGNED,
|
||||||
|
GAME_NOT_RUNNING,
|
||||||
|
NOT_TYRANT,
|
||||||
|
CLASS_NOT_UNLOCKED,
|
||||||
|
INVALID_CLASS,
|
||||||
|
UNKNOWN_PLAYER,
|
||||||
|
TARGET_IS_TYRANT,
|
||||||
|
TARGET_OPTED_OUT
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public final class DefaultCapturedMobCustodyRelease implements CapturedMobCustodyRelease {
|
||||||
|
private final CapturedMobSpawner spawner;
|
||||||
|
|
||||||
|
public DefaultCapturedMobCustodyRelease(CapturedMobSpawner spawner) {
|
||||||
|
this.spawner = spawner;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PlayerState releaseAll(Player player, PlayerState state) {
|
||||||
|
return releaseExcess(player, state, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PlayerState releaseExcess(
|
||||||
|
Player player,
|
||||||
|
PlayerState state,
|
||||||
|
int custodyLimit
|
||||||
|
) {
|
||||||
|
if (custodyLimit < 0) {
|
||||||
|
throw new IllegalArgumentException("custodyLimit must not be negative");
|
||||||
|
}
|
||||||
|
List<CapturedMob> retained = new ArrayList<>();
|
||||||
|
List<CapturedMob> custody = state.capturedMobs();
|
||||||
|
for (int index = 0; index < custody.size(); index++) {
|
||||||
|
CapturedMob mob = custody.get(index);
|
||||||
|
if (index < custodyLimit || !spawner.spawn(player, mob)) {
|
||||||
|
retained.add(mob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (retained.equals(custody)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return new PlayerState(
|
||||||
|
state.playerId(), state.latestName(), state.lastLogin(), state.optedOutUntil(),
|
||||||
|
state.tyrantClass(), state.followerOf(), state.cooldownEnds(),
|
||||||
|
state.readyAbilityItems(), retained
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public final class DefaultTyrantControlPanel implements TyrantControlPanel {
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final TyrantControlPanelRenderer renderer;
|
||||||
|
private final Clock clock;
|
||||||
|
private final Set<UUID> openedSessions = new HashSet<>();
|
||||||
|
|
||||||
|
public DefaultTyrantControlPanel(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
TyrantControlPanelRenderer renderer,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.renderer = renderer;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void open(Player player) {
|
||||||
|
GameState game = stateManager.game();
|
||||||
|
if (game.lifecycle() != GameLifecycle.RUNNING
|
||||||
|
|| game.tyrantId().filter(player.getUniqueId()::equals).isEmpty()) {
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "Only the active Tyrant can open the control panel while the event is running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlayerState tyrant = stateManager.player(player.getUniqueId(), player.getName());
|
||||||
|
renderer.openOverview(
|
||||||
|
player,
|
||||||
|
TyrantControlPanelModel.create(game, tyrant, stateManager.players(), clock.instant())
|
||||||
|
);
|
||||||
|
openedSessions.add(player.getUniqueId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beginSession(UUID playerId) {
|
||||||
|
openedSessions.remove(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void endSession(UUID playerId) {
|
||||||
|
openedSessions.remove(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean openedThisSession(UUID playerId) {
|
||||||
|
return openedSessions.contains(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public final class DefaultVigilanteControlPanel implements VigilanteControlPanel {
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final FollowerService followers;
|
||||||
|
private final VigilanteControlPanelRenderer renderer;
|
||||||
|
private final FollowerPresenceProvider presenceProvider;
|
||||||
|
private final int strengthCap;
|
||||||
|
private final int resistanceCap;
|
||||||
|
private final Set<UUID> openedSessions = new HashSet<>();
|
||||||
|
|
||||||
|
public DefaultVigilanteControlPanel(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
FollowerService followers,
|
||||||
|
VigilanteControlPanelRenderer renderer,
|
||||||
|
FollowerPresenceProvider presenceProvider,
|
||||||
|
int strengthCap,
|
||||||
|
int resistanceCap
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.followers = followers;
|
||||||
|
this.renderer = renderer;
|
||||||
|
this.presenceProvider = presenceProvider;
|
||||||
|
this.strengthCap = strengthCap;
|
||||||
|
this.resistanceCap = resistanceCap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void open(Player player) {
|
||||||
|
GameState game = stateManager.game();
|
||||||
|
if (game.lifecycle() != GameLifecycle.RUNNING
|
||||||
|
|| game.vigilanteId().filter(player.getUniqueId()::equals).isEmpty()) {
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "Only the active Vigilante can open the control panel while the event is running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<UUID, PlayerState> players = stateManager.players();
|
||||||
|
renderer.openOverview(
|
||||||
|
player,
|
||||||
|
VigilanteControlPanelModel.create(
|
||||||
|
game,
|
||||||
|
players,
|
||||||
|
followers.invitedPlayerIds(player.getUniqueId()),
|
||||||
|
presenceProvider.inspect(player, players),
|
||||||
|
strengthCap,
|
||||||
|
resistanceCap
|
||||||
|
)
|
||||||
|
);
|
||||||
|
openedSessions.add(player.getUniqueId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void beginSession(UUID playerId) {
|
||||||
|
openedSessions.remove(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void endSession(UUID playerId) {
|
||||||
|
openedSessions.remove(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean openedThisSession(UUID playerId) {
|
||||||
|
return openedSessions.contains(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface DelayedTaskScheduler {
|
||||||
|
void schedule(Runnable task, long delayTicks);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class FixerAbilityService {
|
||||||
|
private final AbilityReadinessService readiness;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
|
||||||
|
public FixerAbilityService(
|
||||||
|
AbilityReadinessService readiness,
|
||||||
|
PluginSettings settings
|
||||||
|
) {
|
||||||
|
this.readiness = readiness;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FixerUseResult activate(PlayerState player, Instant now, boolean nearTyrant) {
|
||||||
|
if (player.tyrantClass() != TyrantClass.FIXER) {
|
||||||
|
return new FixerUseResult(player, AbilityUseStatus.WRONG_CLASS, 0);
|
||||||
|
}
|
||||||
|
if (!player.readyAbilityItems().contains(Ability.FIXER_BOOST)) {
|
||||||
|
return new FixerUseResult(player, AbilityUseStatus.NOT_READY, 0);
|
||||||
|
}
|
||||||
|
PlayerState used = readiness.consume(
|
||||||
|
player, Ability.FIXER_BOOST, now, settings.fixerCooldown()
|
||||||
|
);
|
||||||
|
Instant activeUntil = now.plus(settings.fixerEffectDuration());
|
||||||
|
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
|
||||||
|
deadlines.putAll(used.cooldownEnds());
|
||||||
|
deadlines.put(Ability.FIXER_BOOST_ACTIVE, activeUntil);
|
||||||
|
if (nearTyrant) {
|
||||||
|
deadlines.put(Ability.FIXER_NEAR_TYRANT_ACTIVE, activeUntil);
|
||||||
|
}
|
||||||
|
PlayerState updated = new PlayerState(
|
||||||
|
used.playerId(), used.latestName(), used.lastLogin(), used.optedOutUntil(),
|
||||||
|
used.tyrantClass(), used.followerOf(), deadlines, used.readyAbilityItems(),
|
||||||
|
used.capturedMobs()
|
||||||
|
);
|
||||||
|
int rows = nearTyrant
|
||||||
|
? settings.fixerNearTyrantHeartRows() : settings.fixerNormalHeartRows();
|
||||||
|
return new FixerUseResult(updated, AbilityUseStatus.ACTIVATED, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.attribute.Attribute;
|
||||||
|
import org.bukkit.attribute.AttributeInstance;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.potion.PotionEffect;
|
||||||
|
import org.bukkit.potion.PotionEffectType;
|
||||||
|
|
||||||
|
public final class FixerEffectController implements Runnable {
|
||||||
|
private static final int EFFECT_TICKS = 30;
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final Server server;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
private final Clock clock;
|
||||||
|
private final Set<UUID> managedPlayers = new HashSet<>();
|
||||||
|
|
||||||
|
public FixerEffectController(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
Server server,
|
||||||
|
PluginSettings settings,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.server = server;
|
||||||
|
this.settings = settings;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
Instant now = clock.instant();
|
||||||
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||||
|
boolean active = stateManager.game().lifecycle() == GameLifecycle.RUNNING
|
||||||
|
&& state.tyrantClass() == TyrantClass.FIXER
|
||||||
|
&& state.cooldownEnds().getOrDefault(
|
||||||
|
Ability.FIXER_BOOST_ACTIVE, Instant.MIN
|
||||||
|
).isAfter(now);
|
||||||
|
if (!active) {
|
||||||
|
if (managedPlayers.remove(player.getUniqueId())) {
|
||||||
|
clear(player);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
managedPlayers.add(player.getUniqueId());
|
||||||
|
boolean nearTier = state.cooldownEnds().getOrDefault(
|
||||||
|
Ability.FIXER_NEAR_TYRANT_ACTIVE, Instant.MIN
|
||||||
|
).isAfter(now);
|
||||||
|
int rows = nearTier
|
||||||
|
? settings.fixerNearTyrantHeartRows() : settings.fixerNormalHeartRows();
|
||||||
|
int healthBoostAmplifier = (rows - 1) * 5 - 1;
|
||||||
|
player.addPotionEffect(new PotionEffect(
|
||||||
|
PotionEffectType.STRENGTH, EFFECT_TICKS,
|
||||||
|
settings.fixerStrengthLevel() - 1, false, true, true
|
||||||
|
));
|
||||||
|
player.addPotionEffect(new PotionEffect(
|
||||||
|
PotionEffectType.HEALTH_BOOST, EFFECT_TICKS,
|
||||||
|
healthBoostAmplifier, false, true, true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearAll() {
|
||||||
|
for (UUID playerId : Set.copyOf(managedPlayers)) {
|
||||||
|
Player player = server.getPlayer(playerId);
|
||||||
|
if (player != null) {
|
||||||
|
clear(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
managedPlayers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void clear(Player player) {
|
||||||
|
player.removePotionEffect(PotionEffectType.STRENGTH);
|
||||||
|
player.removePotionEffect(PotionEffectType.HEALTH_BOOST);
|
||||||
|
AttributeInstance maximumHealth = player.getAttribute(Attribute.MAX_HEALTH);
|
||||||
|
if (maximumHealth != null && player.getHealth() > maximumHealth.getValue()) {
|
||||||
|
player.setHealth(maximumHealth.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.block.Action;
|
||||||
|
import org.bukkit.event.player.PlayerInteractEvent;
|
||||||
|
|
||||||
|
public final class FixerItemListener implements Listener {
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final AbilityItemService items;
|
||||||
|
private final FixerAbilityService abilities;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public FixerItemListener(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
AbilityItemService items,
|
||||||
|
FixerAbilityService abilities,
|
||||||
|
PluginSettings settings,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.items = items;
|
||||||
|
this.abilities = abilities;
|
||||||
|
this.settings = settings;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGH)
|
||||||
|
public void onInteract(PlayerInteractEvent event) {
|
||||||
|
Action action = event.getAction();
|
||||||
|
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK
|
||||||
|
&& action != Action.LEFT_CLICK_AIR && action != Action.LEFT_CLICK_BLOCK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (items.ability(event.getItem()).filter(Ability.FIXER_BOOST::equals).isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.setCancelled(true);
|
||||||
|
UUID playerId = event.getPlayer().getUniqueId();
|
||||||
|
if (items.owner(event.getItem()).filter(playerId::equals).isEmpty()) {
|
||||||
|
event.getPlayer().getInventory().setItem(event.getHand(), null);
|
||||||
|
event.getPlayer().sendMessage(ChatColor.RED + "That ability item is not yours.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) {
|
||||||
|
event.getPlayer().sendMessage(ChatColor.RED + "The Tyrant game is not running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlayerState player = stateManager.player(playerId, event.getPlayer().getName());
|
||||||
|
FixerUseResult result = abilities.activate(
|
||||||
|
player, clock.instant(), isNearTyrant(event.getPlayer())
|
||||||
|
);
|
||||||
|
if (result.status() == AbilityUseStatus.ACTIVATED) {
|
||||||
|
stateManager.updatePlayer(playerId, event.getPlayer().getName(), current -> result.player());
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
event.getPlayer().getInventory().setItem(event.getHand(), null);
|
||||||
|
event.getPlayer().sendMessage(ChatColor.GREEN + "Fixer boost activated with "
|
||||||
|
+ result.heartRows() + " rows of hearts.");
|
||||||
|
} else {
|
||||||
|
event.getPlayer().sendMessage(ChatColor.RED + "Ability unavailable: "
|
||||||
|
+ result.status().name().toLowerCase(java.util.Locale.ROOT) + ".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNearTyrant(org.bukkit.entity.Player player) {
|
||||||
|
Optional<org.bukkit.entity.Player> tyrant = stateManager.game().tyrantId()
|
||||||
|
.map(org.bukkit.Bukkit::getPlayer);
|
||||||
|
return tyrant.isPresent()
|
||||||
|
&& tyrant.orElseThrow().getWorld().equals(player.getWorld())
|
||||||
|
&& tyrant.orElseThrow().getLocation().distanceSquared(player.getLocation())
|
||||||
|
<= settings.tyrantRangeBlocks() * settings.tyrantRangeBlocks();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record FixerUseResult(
|
||||||
|
PlayerState player,
|
||||||
|
AbilityUseStatus status,
|
||||||
|
int heartRows
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record FollowerPresence(
|
||||||
|
boolean online,
|
||||||
|
boolean living,
|
||||||
|
boolean sameWorld,
|
||||||
|
boolean withinRange
|
||||||
|
) {
|
||||||
|
public boolean contributes() {
|
||||||
|
return online && living && sameWorld && withinRange;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface FollowerPresenceProvider {
|
||||||
|
Map<UUID, FollowerPresence> inspect(Player vigilante, Map<UUID, PlayerState> players);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record FollowerResult(Map<UUID, PlayerState> players, FollowerStatus status) {
|
||||||
|
public FollowerResult {
|
||||||
|
players = Map.copyOf(players);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class FollowerService {
|
||||||
|
private final Map<UUID, UUID> invitations = new HashMap<>();
|
||||||
|
|
||||||
|
public FollowerResult invite(
|
||||||
|
GameState game,
|
||||||
|
Map<UUID, PlayerState> players,
|
||||||
|
UUID vigilanteId,
|
||||||
|
UUID targetId
|
||||||
|
) {
|
||||||
|
if (game.lifecycle() != GameLifecycle.RUNNING) {
|
||||||
|
return result(players, FollowerStatus.GAME_NOT_RUNNING);
|
||||||
|
}
|
||||||
|
if (game.vigilanteId().filter(vigilanteId::equals).isEmpty()) {
|
||||||
|
return result(players, FollowerStatus.NOT_VIGILANTE);
|
||||||
|
}
|
||||||
|
PlayerState target = players.get(targetId);
|
||||||
|
if (target == null || target.optedOutUntil().isPresent()
|
||||||
|
|| target.followerOf().isPresent()
|
||||||
|
|| game.tyrantId().filter(targetId::equals).isPresent()
|
||||||
|
|| game.vigilanteId().filter(targetId::equals).isPresent()) {
|
||||||
|
return result(players, FollowerStatus.INELIGIBLE);
|
||||||
|
}
|
||||||
|
if (vigilanteId.equals(invitations.get(targetId))) {
|
||||||
|
return result(players, FollowerStatus.ALREADY_INVITED);
|
||||||
|
}
|
||||||
|
invitations.put(targetId, vigilanteId);
|
||||||
|
return result(players, FollowerStatus.INVITED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FollowerResult accept(
|
||||||
|
GameState game,
|
||||||
|
Map<UUID, PlayerState> players,
|
||||||
|
UUID targetId
|
||||||
|
) {
|
||||||
|
UUID invitedBy = invitations.get(targetId);
|
||||||
|
if (invitedBy == null
|
||||||
|
|| game.vigilanteId().filter(invitedBy::equals).isEmpty()) {
|
||||||
|
return result(players, FollowerStatus.NO_INVITATION);
|
||||||
|
}
|
||||||
|
PlayerState target = players.get(targetId);
|
||||||
|
if (target == null || target.optedOutUntil().isPresent()) {
|
||||||
|
invitations.remove(targetId);
|
||||||
|
return result(players, FollowerStatus.INELIGIBLE);
|
||||||
|
}
|
||||||
|
invitations.remove(targetId);
|
||||||
|
return updated(players, withFollower(target, Optional.of(invitedBy)), FollowerStatus.JOINED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FollowerResult dismiss(
|
||||||
|
GameState game,
|
||||||
|
Map<UUID, PlayerState> players,
|
||||||
|
UUID vigilanteId,
|
||||||
|
UUID targetId
|
||||||
|
) {
|
||||||
|
if (game.vigilanteId().filter(vigilanteId::equals).isEmpty()) {
|
||||||
|
return result(players, FollowerStatus.NOT_VIGILANTE);
|
||||||
|
}
|
||||||
|
PlayerState target = players.get(targetId);
|
||||||
|
if (target == null || target.followerOf().filter(vigilanteId::equals).isEmpty()) {
|
||||||
|
return result(players, FollowerStatus.NOT_FOLLOWER);
|
||||||
|
}
|
||||||
|
return updated(players, withFollower(target, Optional.empty()), FollowerStatus.DISMISSED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FollowerResult leave(
|
||||||
|
GameState game,
|
||||||
|
Map<UUID, PlayerState> players,
|
||||||
|
UUID targetId
|
||||||
|
) {
|
||||||
|
PlayerState target = players.get(targetId);
|
||||||
|
if (target == null || target.followerOf().isEmpty()) {
|
||||||
|
return result(players, FollowerStatus.NOT_FOLLOWER);
|
||||||
|
}
|
||||||
|
return updated(players, withFollower(target, Optional.empty()), FollowerStatus.LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public java.util.Set<UUID> invitedPlayerIds(UUID vigilanteId) {
|
||||||
|
return invitations.entrySet().stream()
|
||||||
|
.filter(entry -> entry.getValue().equals(vigilanteId))
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearInvitations() {
|
||||||
|
invitations.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FollowerResult updated(
|
||||||
|
Map<UUID, PlayerState> players,
|
||||||
|
PlayerState updated,
|
||||||
|
FollowerStatus status
|
||||||
|
) {
|
||||||
|
Map<UUID, PlayerState> replacement = new HashMap<>(players);
|
||||||
|
replacement.put(updated.playerId(), updated);
|
||||||
|
return result(replacement, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FollowerResult result(
|
||||||
|
Map<UUID, PlayerState> players,
|
||||||
|
FollowerStatus status
|
||||||
|
) {
|
||||||
|
return new FollowerResult(players, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerState withFollower(
|
||||||
|
PlayerState player,
|
||||||
|
Optional<UUID> followerOf
|
||||||
|
) {
|
||||||
|
return new PlayerState(
|
||||||
|
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
|
||||||
|
player.tyrantClass(), followerOf, player.cooldownEnds(),
|
||||||
|
player.readyAbilityItems(), player.capturedMobs()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public enum FollowerStatus {
|
||||||
|
INVITED,
|
||||||
|
ALREADY_INVITED,
|
||||||
|
JOINED,
|
||||||
|
DISMISSED,
|
||||||
|
LEFT,
|
||||||
|
GAME_NOT_RUNNING,
|
||||||
|
NOT_VIGILANTE,
|
||||||
|
INELIGIBLE,
|
||||||
|
NO_INVITATION,
|
||||||
|
NOT_FOLLOWER
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public final class FormerTamerReleaseTask implements Runnable {
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final Server server;
|
||||||
|
private final CapturedMobCustodyRelease release;
|
||||||
|
private final CapturedMobInventoryService inventory;
|
||||||
|
|
||||||
|
public FormerTamerReleaseTask(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
Server server,
|
||||||
|
CapturedMobCustodyRelease release
|
||||||
|
) {
|
||||||
|
this(stateManager, server, release, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public FormerTamerReleaseTask(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
Server server,
|
||||||
|
CapturedMobCustodyRelease release,
|
||||||
|
CapturedMobInventoryService inventory
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.server = server;
|
||||||
|
this.release = release;
|
||||||
|
this.inventory = inventory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
Map<UUID, Player> online = server.getOnlinePlayers().stream()
|
||||||
|
.collect(Collectors.toMap(Player::getUniqueId, player -> player));
|
||||||
|
for (PlayerState state : stateManager.players().values()) {
|
||||||
|
if (state.capturedMobs().isEmpty()
|
||||||
|
|| state.tyrantClass() == TyrantClass.TAMER
|
||||||
|
&& state.capturedMobs().size() == 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Player player = online.get(state.playerId());
|
||||||
|
if (player == null || player.isDead()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PlayerState released = state.tyrantClass() == TyrantClass.TAMER
|
||||||
|
? release.releaseExcess(player, state, 1)
|
||||||
|
: release.releaseAll(player, state);
|
||||||
|
if (released.equals(state)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stateManager.updatePlayer(
|
||||||
|
state.playerId(), state.latestName(), current -> released
|
||||||
|
);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
if (inventory != null) {
|
||||||
|
inventory.reconcile(player, released);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class GameLifecycleService {
|
||||||
|
public LifecycleState start(
|
||||||
|
PersistentState state,
|
||||||
|
UUID tyrantId,
|
||||||
|
Optional<UUID> vigilanteId,
|
||||||
|
Instant now
|
||||||
|
) {
|
||||||
|
if (state.game().lifecycle() != GameLifecycle.UNSTARTED) {
|
||||||
|
throw new IllegalStateException("game has already been started");
|
||||||
|
}
|
||||||
|
if (vigilanteId.filter(tyrantId::equals).isPresent()) {
|
||||||
|
throw new IllegalArgumentException("Tyrant and Vigilante must be different players");
|
||||||
|
}
|
||||||
|
GameState game = new GameState(
|
||||||
|
GameLifecycle.RUNNING,
|
||||||
|
Optional.of(tyrantId),
|
||||||
|
vigilanteId,
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Optional.empty(),
|
||||||
|
Duration.ZERO,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
Set.of()
|
||||||
|
);
|
||||||
|
return new LifecycleState(game, state.players());
|
||||||
|
}
|
||||||
|
|
||||||
|
public LifecycleState pause(PersistentState state, Instant now) {
|
||||||
|
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
|
||||||
|
throw new IllegalStateException("only a running game can be paused");
|
||||||
|
}
|
||||||
|
GameState current = state.game();
|
||||||
|
GameState paused = new GameState(
|
||||||
|
GameLifecycle.PAUSED,
|
||||||
|
current.tyrantId(),
|
||||||
|
current.vigilanteId(),
|
||||||
|
current.pendingTyrant(),
|
||||||
|
current.pendingVigilante(),
|
||||||
|
Optional.of(now),
|
||||||
|
current.accumulatedPausedTime(),
|
||||||
|
current.tyrantLevel(),
|
||||||
|
current.unspentChoices(),
|
||||||
|
current.purchases(),
|
||||||
|
current.claimedArmor()
|
||||||
|
);
|
||||||
|
return new LifecycleState(paused, state.players());
|
||||||
|
}
|
||||||
|
|
||||||
|
public LifecycleState resume(PersistentState state, Instant now) {
|
||||||
|
GameState current = state.game();
|
||||||
|
if (current.lifecycle() != GameLifecycle.PAUSED || current.pausedAt().isEmpty()) {
|
||||||
|
throw new IllegalStateException("only a paused game can be resumed");
|
||||||
|
}
|
||||||
|
Instant pausedAt = current.pausedAt().orElseThrow();
|
||||||
|
Duration pausedFor = Duration.between(pausedAt, now);
|
||||||
|
if (pausedFor.isNegative()) {
|
||||||
|
throw new IllegalArgumentException("resume time must not precede pause time");
|
||||||
|
}
|
||||||
|
GameState resumed = new GameState(
|
||||||
|
GameLifecycle.RUNNING,
|
||||||
|
current.tyrantId(),
|
||||||
|
current.vigilanteId(),
|
||||||
|
shiftSelection(current.pendingTyrant(), pausedFor),
|
||||||
|
shiftSelection(current.pendingVigilante(), pausedFor),
|
||||||
|
Optional.empty(),
|
||||||
|
current.accumulatedPausedTime().plus(pausedFor),
|
||||||
|
current.tyrantLevel(),
|
||||||
|
current.unspentChoices(),
|
||||||
|
current.purchases(),
|
||||||
|
current.claimedArmor()
|
||||||
|
);
|
||||||
|
Map<UUID, PlayerState> players = new HashMap<>();
|
||||||
|
for (PlayerState player : state.players().values()) {
|
||||||
|
players.put(player.playerId(), shift(player, pausedFor));
|
||||||
|
}
|
||||||
|
return new LifecycleState(resumed, players);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LifecycleState reset(PersistentState state) {
|
||||||
|
Map<UUID, PlayerState> players = new HashMap<>();
|
||||||
|
for (PlayerState player : state.players().values()) {
|
||||||
|
players.put(player.playerId(), new PlayerState(
|
||||||
|
player.playerId(),
|
||||||
|
player.latestName(),
|
||||||
|
player.lastLogin(),
|
||||||
|
player.optedOutUntil(),
|
||||||
|
TyrantClass.NONE,
|
||||||
|
Optional.empty(),
|
||||||
|
Map.of(),
|
||||||
|
Set.of(),
|
||||||
|
player.capturedMobs()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return new LifecycleState(GameState.empty(), players);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<PendingSelection> shiftSelection(
|
||||||
|
Optional<PendingSelection> selection,
|
||||||
|
Duration pausedFor
|
||||||
|
) {
|
||||||
|
return selection.map(value -> new PendingSelection(
|
||||||
|
value.candidateId(), value.expiresAt().plus(pausedFor)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerState shift(PlayerState player, Duration pausedFor) {
|
||||||
|
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
|
||||||
|
player.cooldownEnds().forEach((ability, deadline) ->
|
||||||
|
cooldowns.put(ability, deadline.plus(pausedFor))
|
||||||
|
);
|
||||||
|
return new PlayerState(
|
||||||
|
player.playerId(),
|
||||||
|
player.latestName(),
|
||||||
|
shiftInstant(player.lastLogin(), pausedFor),
|
||||||
|
shiftInstant(player.optedOutUntil(), pausedFor),
|
||||||
|
player.tyrantClass(),
|
||||||
|
player.followerOf(),
|
||||||
|
cooldowns,
|
||||||
|
player.readyAbilityItems(),
|
||||||
|
player.capturedMobs()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<Instant> shiftInstant(Optional<Instant> value, Duration duration) {
|
||||||
|
return value.map(instant -> instant.plus(duration));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ public record GameState(
|
|||||||
Duration accumulatedPausedTime,
|
Duration accumulatedPausedTime,
|
||||||
int tyrantLevel,
|
int tyrantLevel,
|
||||||
int unspentChoices,
|
int unspentChoices,
|
||||||
Set<TyrantUnlock> purchases
|
Set<TyrantUnlock> purchases,
|
||||||
|
Set<TyrantArmorPiece> claimedArmor
|
||||||
) {
|
) {
|
||||||
public GameState {
|
public GameState {
|
||||||
lifecycle = lifecycle == null ? GameLifecycle.UNSTARTED : lifecycle;
|
lifecycle = lifecycle == null ? GameLifecycle.UNSTARTED : lifecycle;
|
||||||
@@ -28,6 +29,7 @@ public record GameState(
|
|||||||
accumulatedPausedTime = accumulatedPausedTime == null
|
accumulatedPausedTime = accumulatedPausedTime == null
|
||||||
? Duration.ZERO : accumulatedPausedTime;
|
? Duration.ZERO : accumulatedPausedTime;
|
||||||
purchases = purchases == null ? Set.of() : Set.copyOf(purchases);
|
purchases = purchases == null ? Set.of() : Set.copyOf(purchases);
|
||||||
|
claimedArmor = claimedArmor == null ? Set.of() : Set.copyOf(claimedArmor);
|
||||||
if (tyrantId.isPresent() && tyrantId.equals(vigilanteId)) {
|
if (tyrantId.isPresent() && tyrantId.equals(vigilanteId)) {
|
||||||
throw new IllegalArgumentException("Tyrant and Vigilante must be different players");
|
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() {
|
public static GameState empty() {
|
||||||
return new GameState(
|
return new GameState(
|
||||||
GameLifecycle.UNSTARTED,
|
GameLifecycle.UNSTARTED,
|
||||||
@@ -53,6 +73,7 @@ public record GameState(
|
|||||||
Duration.ZERO,
|
Duration.ZERO,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
|
Set.of(),
|
||||||
Set.of()
|
Set.of()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record IntelligenceResult(
|
||||||
|
PlayerState tyrant,
|
||||||
|
AbilityUseStatus status,
|
||||||
|
int memberCount
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record LifecycleState(GameState game, Map<UUID, PlayerState> players) {
|
||||||
|
public LifecycleState {
|
||||||
|
players = Map.copyOf(players);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PersistentState asPersistentState() {
|
||||||
|
return new PersistentState(game, players);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public interface ManagedRoleEffects {
|
||||||
|
void suppressAll();
|
||||||
|
|
||||||
|
void restoreAll();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public final class NoopManagedRoleEffects implements ManagedRoleEffects {
|
||||||
|
@Override
|
||||||
|
public void suppressAll() {
|
||||||
|
// No managed effects exist until their corresponding abilities are unlocked.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void restoreAll() {
|
||||||
|
// No managed effects exist until their corresponding abilities are unlocked.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public interface OnlinePlayerDirectory {
|
||||||
|
Player findByName(String name);
|
||||||
|
|
||||||
|
Player findById(UUID playerId);
|
||||||
|
|
||||||
|
java.util.Set<UUID> onlinePlayerIds();
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.player.PlayerQuitEvent;
|
||||||
|
import org.bukkit.scoreboard.Scoreboard;
|
||||||
|
import org.bukkit.scoreboard.ScoreboardManager;
|
||||||
|
import org.bukkit.scoreboard.Team;
|
||||||
|
|
||||||
|
public final class OptOutGlowController implements Runnable, Listener {
|
||||||
|
private static final String TEAM_NAME = "tyrant-optout";
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final Server server;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
private final Scoreboard scoreboard;
|
||||||
|
private final Team greenTeam;
|
||||||
|
private final Map<UUID, PreviousPresentation> managed = new HashMap<>();
|
||||||
|
|
||||||
|
public OptOutGlowController(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
Server server,
|
||||||
|
PluginSettings settings
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.server = server;
|
||||||
|
this.settings = settings;
|
||||||
|
ScoreboardManager manager = Objects.requireNonNull(
|
||||||
|
server.getScoreboardManager(), "Scoreboard manager is unavailable"
|
||||||
|
);
|
||||||
|
scoreboard = manager.getMainScoreboard();
|
||||||
|
Team existing = scoreboard.getTeam(TEAM_NAME);
|
||||||
|
greenTeam = existing == null ? scoreboard.registerNewTeam(TEAM_NAME) : existing;
|
||||||
|
greenTeam.setColor(ChatColor.GREEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
GameState game = stateManager.game();
|
||||||
|
Player tyrant = game.tyrantId().map(server::getPlayer).orElse(null);
|
||||||
|
boolean tyrantAvailable = tyrant != null && !tyrant.isDead();
|
||||||
|
Set<UUID> online = new HashSet<>();
|
||||||
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
online.add(player.getUniqueId());
|
||||||
|
PlayerState state = stateManager.players().get(player.getUniqueId());
|
||||||
|
boolean sameWorld = tyrantAvailable && !player.isDead()
|
||||||
|
&& player.getWorld().equals(tyrant.getWorld());
|
||||||
|
double distanceSquared = sameWorld
|
||||||
|
? player.getLocation().distanceSquared(tyrant.getLocation())
|
||||||
|
: Double.POSITIVE_INFINITY;
|
||||||
|
boolean shouldGlow = state != null && OptOutGlowPolicy.shouldGlow(
|
||||||
|
game, state, tyrantAvailable, sameWorld, distanceSquared,
|
||||||
|
settings.tyrantRangeBlocks()
|
||||||
|
);
|
||||||
|
if (shouldGlow && !managed.containsKey(player.getUniqueId())) {
|
||||||
|
apply(player);
|
||||||
|
} else if (!shouldGlow && managed.containsKey(player.getUniqueId())) {
|
||||||
|
restore(player.getUniqueId(), player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (UUID playerId : Set.copyOf(managed.keySet())) {
|
||||||
|
if (!online.contains(playerId) && server.getPlayer(playerId) == null) {
|
||||||
|
restore(playerId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onQuit(PlayerQuitEvent event) {
|
||||||
|
if (managed.containsKey(event.getPlayer().getUniqueId())) {
|
||||||
|
restore(event.getPlayer().getUniqueId(), event.getPlayer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void clearAll() {
|
||||||
|
for (UUID playerId : Set.copyOf(managed.keySet())) {
|
||||||
|
restore(playerId, server.getPlayer(playerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void apply(Player player) {
|
||||||
|
Team previousTeam = scoreboard.getEntryTeam(player.getName());
|
||||||
|
if (previousTeam == greenTeam) {
|
||||||
|
previousTeam = null;
|
||||||
|
}
|
||||||
|
managed.put(
|
||||||
|
player.getUniqueId(),
|
||||||
|
new PreviousPresentation(player.getName(), previousTeam, player.isGlowing())
|
||||||
|
);
|
||||||
|
greenTeam.addEntry(player.getName());
|
||||||
|
player.setGlowing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void restore(UUID playerId, Player player) {
|
||||||
|
PreviousPresentation previous = managed.remove(playerId);
|
||||||
|
if (previous == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
greenTeam.removeEntry(previous.playerName());
|
||||||
|
if (previous.team() != null) {
|
||||||
|
try {
|
||||||
|
previous.team().addEntry(previous.playerName());
|
||||||
|
} catch (IllegalStateException ignored) {
|
||||||
|
// The prior team was removed while this plugin managed the entry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (player != null) {
|
||||||
|
player.setGlowing(previous.glowing());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record PreviousPresentation(
|
||||||
|
String playerName,
|
||||||
|
Team team,
|
||||||
|
boolean glowing
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public final class OptOutGlowPolicy {
|
||||||
|
private OptOutGlowPolicy() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean shouldGlow(
|
||||||
|
GameState game,
|
||||||
|
PlayerState player,
|
||||||
|
boolean tyrantAvailable,
|
||||||
|
boolean playerAvailableInTyrantWorld,
|
||||||
|
double distanceSquared,
|
||||||
|
double rangeBlocks
|
||||||
|
) {
|
||||||
|
return game.lifecycle() == GameLifecycle.RUNNING
|
||||||
|
&& game.tyrantId().isPresent()
|
||||||
|
&& player.optedOutUntil().isPresent()
|
||||||
|
&& tyrantAvailable
|
||||||
|
&& playerAvailableInTyrantWorld
|
||||||
|
&& distanceSquared <= rangeBlocks * rangeBlocks;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.entity.Projectile;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||||
|
|
||||||
|
public final class ParticipationCombatListener implements Listener {
|
||||||
|
public static final String OPTED_OUT_MESSAGE =
|
||||||
|
"That player has chosen not to take part in the Tyrant event.";
|
||||||
|
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final ParticipationCombatService combat;
|
||||||
|
|
||||||
|
public ParticipationCombatListener(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
ParticipationCombatService combat
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.combat = combat;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
|
public void onDamage(EntityDamageByEntityEvent event) {
|
||||||
|
if (!(event.getEntity() instanceof Player target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Optional<Player> attacker = attackingPlayer(event);
|
||||||
|
if (attacker.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Player source = attacker.orElseThrow();
|
||||||
|
Map<UUID, PlayerState> players = stateManager.players();
|
||||||
|
PlayerState attackerState = players.getOrDefault(
|
||||||
|
source.getUniqueId(),
|
||||||
|
PlayerState.newPlayer(source.getUniqueId(), source.getName())
|
||||||
|
);
|
||||||
|
PlayerState targetState = players.getOrDefault(
|
||||||
|
target.getUniqueId(),
|
||||||
|
PlayerState.newPlayer(target.getUniqueId(), target.getName())
|
||||||
|
);
|
||||||
|
if (combat.blocks(stateManager.game(), attackerState, targetState)) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
source.sendMessage(ChatColor.YELLOW + OPTED_OUT_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<Player> attackingPlayer(EntityDamageByEntityEvent event) {
|
||||||
|
if (event.getDamager() instanceof Player player) {
|
||||||
|
return Optional.of(player);
|
||||||
|
}
|
||||||
|
if (event.getDamager() instanceof Projectile projectile
|
||||||
|
&& projectile.getShooter() instanceof Player player) {
|
||||||
|
return Optional.of(player);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public final class ParticipationCombatService {
|
||||||
|
public boolean blocks(
|
||||||
|
GameState game,
|
||||||
|
PlayerState attacker,
|
||||||
|
PlayerState target
|
||||||
|
) {
|
||||||
|
if (game.lifecycle() != GameLifecycle.RUNNING) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
boolean attackerOptedOut = attacker.optedOutUntil().isPresent();
|
||||||
|
boolean targetOptedOut = target.optedOutUntil().isPresent();
|
||||||
|
boolean attackerTyrantSide = isTyrantSide(game, attacker);
|
||||||
|
boolean targetTyrantSide = isTyrantSide(game, target);
|
||||||
|
return (attackerTyrantSide && targetOptedOut)
|
||||||
|
|| (attackerOptedOut && targetTyrantSide);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isTyrantSide(GameState game, PlayerState player) {
|
||||||
|
return game.tyrantId().filter(player.playerId()::equals).isPresent()
|
||||||
|
|| player.tyrantClass() != TyrantClass.NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record ParticipationResult(PlayerState player, ParticipationStatus status) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public final class ParticipationService {
|
||||||
|
private final Duration optOutDuration;
|
||||||
|
|
||||||
|
public ParticipationService(Duration optOutDuration) {
|
||||||
|
if (optOutDuration.isNegative() || optOutDuration.isZero()) {
|
||||||
|
throw new IllegalArgumentException("optOutDuration must be positive");
|
||||||
|
}
|
||||||
|
this.optOutDuration = optOutDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ParticipationResult optOut(GameState game, PlayerState player, Instant now) {
|
||||||
|
if (game.tyrantId().filter(player.playerId()::equals).isPresent()
|
||||||
|
|| game.vigilanteId().filter(player.playerId()::equals).isPresent()) {
|
||||||
|
return new ParticipationResult(
|
||||||
|
player, ParticipationStatus.MUST_RELINQUISH_ROLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (player.optedOutUntil().isPresent()) {
|
||||||
|
return new ParticipationResult(player, ParticipationStatus.ALREADY_OPTED_OUT);
|
||||||
|
}
|
||||||
|
PlayerState updated = new PlayerState(
|
||||||
|
player.playerId(),
|
||||||
|
player.latestName(),
|
||||||
|
player.lastLogin(),
|
||||||
|
Optional.of(now.plus(optOutDuration)),
|
||||||
|
TyrantClass.NONE,
|
||||||
|
Optional.empty(),
|
||||||
|
Map.of(),
|
||||||
|
Set.of(),
|
||||||
|
player.capturedMobs()
|
||||||
|
);
|
||||||
|
return new ParticipationResult(updated, ParticipationStatus.OPTED_OUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ParticipationResult optIn(PlayerState player, Instant now) {
|
||||||
|
if (player.optedOutUntil().isEmpty()) {
|
||||||
|
return new ParticipationResult(player, ParticipationStatus.ALREADY_OPTED_IN);
|
||||||
|
}
|
||||||
|
if (now.isBefore(player.optedOutUntil().orElseThrow())) {
|
||||||
|
return new ParticipationResult(player, ParticipationStatus.COOLDOWN_ACTIVE);
|
||||||
|
}
|
||||||
|
PlayerState updated = new PlayerState(
|
||||||
|
player.playerId(),
|
||||||
|
player.latestName(),
|
||||||
|
player.lastLogin(),
|
||||||
|
Optional.empty(),
|
||||||
|
player.tyrantClass(),
|
||||||
|
player.followerOf(),
|
||||||
|
player.cooldownEnds(),
|
||||||
|
player.readyAbilityItems(),
|
||||||
|
player.capturedMobs()
|
||||||
|
);
|
||||||
|
return new ParticipationResult(updated, ParticipationStatus.OPTED_IN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ParticipationStatus status(PlayerState player, Instant now) {
|
||||||
|
return player.optedOutUntil().isPresent()
|
||||||
|
? ParticipationStatus.OPTED_OUT : ParticipationStatus.OPTED_IN;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public enum ParticipationStatus {
|
||||||
|
OPTED_IN,
|
||||||
|
OPTED_OUT,
|
||||||
|
COOLDOWN_ACTIVE,
|
||||||
|
MUST_RELINQUISH_ROLE,
|
||||||
|
ALREADY_OPTED_IN,
|
||||||
|
ALREADY_OPTED_OUT
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
|
|
||||||
|
public final class PlayerJoinListener implements Listener {
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final Clock clock;
|
||||||
|
private final TyrantPresentation presentation;
|
||||||
|
|
||||||
|
public PlayerJoinListener(TyrantStateManager stateManager, Clock clock) {
|
||||||
|
this(stateManager, clock, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerJoinListener(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
Clock clock,
|
||||||
|
TyrantPresentation presentation
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.clock = clock;
|
||||||
|
this.presentation = presentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onJoin(PlayerJoinEvent event) {
|
||||||
|
PlayerState player = stateManager.updatePlayer(
|
||||||
|
event.getPlayer().getUniqueId(),
|
||||||
|
event.getPlayer().getName(),
|
||||||
|
current -> new PlayerState(
|
||||||
|
current.playerId(), current.latestName(), java.util.Optional.of(clock.instant()),
|
||||||
|
current.optedOutUntil(), current.tyrantClass(), current.followerOf(),
|
||||||
|
current.cooldownEnds(), current.readyAbilityItems(), current.capturedMobs()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (String message : PlayerStatusMessages.forJoin(
|
||||||
|
stateManager.game(), player, clock.instant()
|
||||||
|
)) {
|
||||||
|
event.getPlayer().sendMessage(message);
|
||||||
|
}
|
||||||
|
if (presentation != null
|
||||||
|
&& stateManager.game().lifecycle() == GameLifecycle.RUNNING
|
||||||
|
&& stateManager.game().tyrantId()
|
||||||
|
.filter(event.getPlayer().getUniqueId()::equals).isPresent()) {
|
||||||
|
presentation.reconcile(stateManager.game().tyrantId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class PlayerStatusMessages {
|
||||||
|
private PlayerStatusMessages() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> forJoin(
|
||||||
|
GameState game,
|
||||||
|
PlayerState player,
|
||||||
|
Instant now
|
||||||
|
) {
|
||||||
|
List<String> messages = new ArrayList<>();
|
||||||
|
boolean hasRole = false;
|
||||||
|
if (game.tyrantId().filter(player.playerId()::equals).isPresent()) {
|
||||||
|
messages.add("Role: TYRANT. Level " + game.tyrantLevel() + ", choices "
|
||||||
|
+ game.unspentChoices() + ", abilities " + game.purchases() + ".");
|
||||||
|
hasRole = true;
|
||||||
|
}
|
||||||
|
if (game.vigilanteId().filter(player.playerId()::equals).isPresent()) {
|
||||||
|
messages.add("Role: VIGILANTE.");
|
||||||
|
hasRole = true;
|
||||||
|
} else if (player.followerOf().isPresent()) {
|
||||||
|
messages.add("Role: FOLLOWER of " + player.followerOf().orElseThrow() + ".");
|
||||||
|
hasRole = true;
|
||||||
|
}
|
||||||
|
if (player.tyrantClass() != TyrantClass.NONE) {
|
||||||
|
messages.add(ClassAssignmentMessages.loginReminder(player));
|
||||||
|
hasRole = true;
|
||||||
|
}
|
||||||
|
player.cooldownEnds().entrySet().stream()
|
||||||
|
.sorted(java.util.Map.Entry.comparingByKey())
|
||||||
|
.forEach(entry -> {
|
||||||
|
long seconds = Math.max(
|
||||||
|
0L, Duration.between(now, entry.getValue()).toSeconds()
|
||||||
|
);
|
||||||
|
messages.add("Cooldown: " + entry.getKey() + " " + seconds + "s remaining.");
|
||||||
|
});
|
||||||
|
if (player.optedOutUntil().isPresent()) {
|
||||||
|
messages.add("Participation: OPTED OUT. You may use /tyrant optin at "
|
||||||
|
+ player.optedOutUntil().orElseThrow() + ".");
|
||||||
|
} else if (!hasRole) {
|
||||||
|
messages.add("Use /tyrant optout if you do not want to participate "
|
||||||
|
+ "in Tyrant events.");
|
||||||
|
}
|
||||||
|
return List.copyOf(messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,14 @@ import org.bukkit.configuration.ConfigurationSection;
|
|||||||
public record PluginSettings(
|
public record PluginSettings(
|
||||||
double tyrantRangeBlocks,
|
double tyrantRangeBlocks,
|
||||||
double followerRangeBlocks,
|
double followerRangeBlocks,
|
||||||
|
double vigilanteArenaRadiusBlocks,
|
||||||
|
int arenaBarrierHeightBlocks,
|
||||||
|
double vigilanteBossHealth,
|
||||||
|
double vigilanteBossDamage,
|
||||||
|
double vigilanteBossArmor,
|
||||||
Duration optOutDuration,
|
Duration optOutDuration,
|
||||||
Duration roleInactivity,
|
Duration roleInactivity,
|
||||||
Duration candidateActivityWindow,
|
Duration roleMaintenanceInterval,
|
||||||
Duration pendingSelectionTimeout,
|
|
||||||
Duration selectionRetryInterval,
|
|
||||||
Duration assassinCooldown,
|
Duration assassinCooldown,
|
||||||
Duration assassinInvisibilityDuration,
|
Duration assassinInvisibilityDuration,
|
||||||
Duration assassinDoubleJumpCooldown,
|
Duration assassinDoubleJumpCooldown,
|
||||||
@@ -28,6 +31,7 @@ public record PluginSettings(
|
|||||||
int fixerStrengthLevel,
|
int fixerStrengthLevel,
|
||||||
int fixerNormalHeartRows,
|
int fixerNormalHeartRows,
|
||||||
int fixerNearTyrantHeartRows,
|
int fixerNearTyrantHeartRows,
|
||||||
|
Duration vigilanteCombatDuration,
|
||||||
Duration rosterIntelligenceCooldown,
|
Duration rosterIntelligenceCooldown,
|
||||||
int tyrantStrengthLevel,
|
int tyrantStrengthLevel,
|
||||||
int tyrantResistanceLevel,
|
int tyrantResistanceLevel,
|
||||||
@@ -37,6 +41,9 @@ public record PluginSettings(
|
|||||||
AbilityItemSettings assassinItem,
|
AbilityItemSettings assassinItem,
|
||||||
AbilityItemSettings fixerItem,
|
AbilityItemSettings fixerItem,
|
||||||
AbilityItemSettings tamerItem,
|
AbilityItemSettings tamerItem,
|
||||||
|
AbilityItemSettings capturedMobItem,
|
||||||
|
AbilityItemSettings tyrantControlItem,
|
||||||
|
AbilityItemSettings vigilanteControlItem,
|
||||||
String recoveryCommand,
|
String recoveryCommand,
|
||||||
String inventoryFullMessage,
|
String inventoryFullMessage,
|
||||||
boolean freezeTimersWhilePaused
|
boolean freezeTimersWhilePaused
|
||||||
@@ -44,11 +51,20 @@ public record PluginSettings(
|
|||||||
public PluginSettings {
|
public PluginSettings {
|
||||||
requirePositiveFinite(tyrantRangeBlocks, "tyrant-range-blocks");
|
requirePositiveFinite(tyrantRangeBlocks, "tyrant-range-blocks");
|
||||||
requirePositiveFinite(followerRangeBlocks, "follower-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(optOutDuration, "opt-out-seconds");
|
||||||
requirePositive(roleInactivity, "role-inactivity-seconds");
|
requirePositive(roleInactivity, "role-inactivity-seconds");
|
||||||
requirePositive(candidateActivityWindow, "candidate-activity-window-seconds");
|
requirePositive(roleMaintenanceInterval, "role-maintenance-seconds");
|
||||||
requirePositive(pendingSelectionTimeout, "pending-selection-timeout-seconds");
|
|
||||||
requirePositive(selectionRetryInterval, "selection-retry-seconds");
|
|
||||||
requirePositive(assassinCooldown, "assassin-cooldown-seconds");
|
requirePositive(assassinCooldown, "assassin-cooldown-seconds");
|
||||||
requirePositive(assassinInvisibilityDuration, "assassin-invisibility-seconds");
|
requirePositive(assassinInvisibilityDuration, "assassin-invisibility-seconds");
|
||||||
requirePositive(assassinDoubleJumpCooldown, "assassin-double-jump-cooldown-seconds");
|
requirePositive(assassinDoubleJumpCooldown, "assassin-double-jump-cooldown-seconds");
|
||||||
@@ -65,6 +81,7 @@ public record PluginSettings(
|
|||||||
"fixer-near-tyrant-heart-rows must be at least fixer-normal-heart-rows"
|
"fixer-near-tyrant-heart-rows must be at least fixer-normal-heart-rows"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
requirePositive(vigilanteCombatDuration, "vigilante-combat-seconds");
|
||||||
requirePositive(rosterIntelligenceCooldown, "roster-intelligence-cooldown-seconds");
|
requirePositive(rosterIntelligenceCooldown, "roster-intelligence-cooldown-seconds");
|
||||||
requireEffectLevel(tyrantStrengthLevel, "tyrant-strength-level");
|
requireEffectLevel(tyrantStrengthLevel, "tyrant-strength-level");
|
||||||
requireEffectLevel(tyrantResistanceLevel, "tyrant-resistance-level");
|
requireEffectLevel(tyrantResistanceLevel, "tyrant-resistance-level");
|
||||||
@@ -74,6 +91,11 @@ public record PluginSettings(
|
|||||||
assassinItem = Objects.requireNonNull(assassinItem, "assassinItem");
|
assassinItem = Objects.requireNonNull(assassinItem, "assassinItem");
|
||||||
fixerItem = Objects.requireNonNull(fixerItem, "fixerItem");
|
fixerItem = Objects.requireNonNull(fixerItem, "fixerItem");
|
||||||
tamerItem = Objects.requireNonNull(tamerItem, "tamerItem");
|
tamerItem = Objects.requireNonNull(tamerItem, "tamerItem");
|
||||||
|
capturedMobItem = Objects.requireNonNull(capturedMobItem, "capturedMobItem");
|
||||||
|
tyrantControlItem = Objects.requireNonNull(tyrantControlItem, "tyrantControlItem");
|
||||||
|
vigilanteControlItem = Objects.requireNonNull(
|
||||||
|
vigilanteControlItem, "vigilanteControlItem"
|
||||||
|
);
|
||||||
recoveryCommand = requireText(recoveryCommand, "recovery-command");
|
recoveryCommand = requireText(recoveryCommand, "recovery-command");
|
||||||
inventoryFullMessage = requireText(inventoryFullMessage, "inventory-full-message");
|
inventoryFullMessage = requireText(inventoryFullMessage, "inventory-full-message");
|
||||||
}
|
}
|
||||||
@@ -83,11 +105,17 @@ public record PluginSettings(
|
|||||||
return new PluginSettings(
|
return new PluginSettings(
|
||||||
decimal(values, "tyrant-range-blocks", 50.0),
|
decimal(values, "tyrant-range-blocks", 50.0),
|
||||||
decimal(values, "follower-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, "opt-out-seconds", Duration.ofDays(7)),
|
||||||
duration(values, "role-inactivity-seconds", Duration.ofHours(48)),
|
duration(values, "role-inactivity-seconds", Duration.ofHours(48)),
|
||||||
duration(values, "candidate-activity-window-seconds", Duration.ofHours(24)),
|
durationAlias(
|
||||||
duration(values, "pending-selection-timeout-seconds", Duration.ofHours(24)),
|
values, "role-maintenance-seconds", "selection-retry-seconds",
|
||||||
duration(values, "selection-retry-seconds", Duration.ofMinutes(1)),
|
Duration.ofMinutes(1)
|
||||||
|
),
|
||||||
duration(values, "assassin-cooldown-seconds", Duration.ofHours(1)),
|
duration(values, "assassin-cooldown-seconds", Duration.ofHours(1)),
|
||||||
duration(values, "assassin-invisibility-seconds", Duration.ofMinutes(10)),
|
duration(values, "assassin-invisibility-seconds", Duration.ofMinutes(10)),
|
||||||
duration(values, "assassin-double-jump-cooldown-seconds", Duration.ofSeconds(60)),
|
duration(values, "assassin-double-jump-cooldown-seconds", Duration.ofSeconds(60)),
|
||||||
@@ -99,6 +127,7 @@ public record PluginSettings(
|
|||||||
integer(values, "fixer-strength-level", 1),
|
integer(values, "fixer-strength-level", 1),
|
||||||
integer(values, "fixer-normal-heart-rows", 2),
|
integer(values, "fixer-normal-heart-rows", 2),
|
||||||
integer(values, "fixer-near-tyrant-heart-rows", 3),
|
integer(values, "fixer-near-tyrant-heart-rows", 3),
|
||||||
|
duration(values, "vigilante-combat-seconds", Duration.ofSeconds(15)),
|
||||||
duration(values, "roster-intelligence-cooldown-seconds", Duration.ofHours(24)),
|
duration(values, "roster-intelligence-cooldown-seconds", Duration.ofHours(24)),
|
||||||
integer(values, "tyrant-strength-level", 1),
|
integer(values, "tyrant-strength-level", 1),
|
||||||
integer(values, "tyrant-resistance-level", 1),
|
integer(values, "tyrant-resistance-level", 1),
|
||||||
@@ -108,6 +137,15 @@ public record PluginSettings(
|
|||||||
item(values, "assassin-item", new AbilityItemSettings("STICK", "Assassin Cloak")),
|
item(values, "assassin-item", new AbilityItemSettings("STICK", "Assassin Cloak")),
|
||||||
item(values, "fixer-item", new AbilityItemSettings("STICK", "Fixer's Wrench")),
|
item(values, "fixer-item", new AbilityItemSettings("STICK", "Fixer's Wrench")),
|
||||||
item(values, "tamer-item", new AbilityItemSettings("FISHING_ROD", "Tamer's Lead")),
|
item(values, "tamer-item", new AbilityItemSettings("FISHING_ROD", "Tamer's Lead")),
|
||||||
|
item(values, "captured-mob-item", new AbilityItemSettings("PAPER", "Captured Mob")),
|
||||||
|
item(
|
||||||
|
values, "tyrant-control-item",
|
||||||
|
new AbilityItemSettings("NETHER_STAR", "Tyrant Control")
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
values, "vigilante-control-item",
|
||||||
|
new AbilityItemSettings("COMPASS", "Vigilante Control")
|
||||||
|
),
|
||||||
string(values, "recovery-command", "/tyrant item"),
|
string(values, "recovery-command", "/tyrant item"),
|
||||||
string(
|
string(
|
||||||
values,
|
values,
|
||||||
@@ -207,6 +245,19 @@ public record PluginSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void requireFiniteRange(
|
||||||
|
double value,
|
||||||
|
double minimum,
|
||||||
|
double maximum,
|
||||||
|
String key
|
||||||
|
) {
|
||||||
|
if (!Double.isFinite(value) || value < minimum || value > maximum) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
key + " must be between " + minimum + " and " + maximum
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void requirePositive(Duration value, String key) {
|
private static void requirePositive(Duration value, String key) {
|
||||||
Objects.requireNonNull(value, key);
|
Objects.requireNonNull(value, key);
|
||||||
if (value.isZero() || value.isNegative()) {
|
if (value.isZero() || value.isNegative()) {
|
||||||
@@ -242,6 +293,18 @@ public record PluginSettings(
|
|||||||
return value.trim();
|
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) {
|
private static Duration duration(Map<String, ?> values, String key, Duration defaultValue) {
|
||||||
Object raw = values.get(key);
|
Object raw = values.get(key);
|
||||||
if (raw == null) {
|
if (raw == null) {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ public final class PluginSettingsValidator {
|
|||||||
validateItem(settings.assassinItem(), "assassin-item");
|
validateItem(settings.assassinItem(), "assassin-item");
|
||||||
validateItem(settings.fixerItem(), "fixer-item");
|
validateItem(settings.fixerItem(), "fixer-item");
|
||||||
validateItem(settings.tamerItem(), "tamer-item");
|
validateItem(settings.tamerItem(), "tamer-item");
|
||||||
|
validateItem(settings.capturedMobItem(), "captured-mob-item");
|
||||||
|
validateItem(settings.tyrantControlItem(), "tyrant-control-item");
|
||||||
|
validateItem(settings.vigilanteControlItem(), "vigilante-control-item");
|
||||||
for (String entityType : settings.deniedMobTypes()) {
|
for (String entityType : settings.deniedMobTypes()) {
|
||||||
try {
|
try {
|
||||||
EntityType.valueOf(entityType);
|
EntityType.valueOf(entityType);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record RelinquishmentResult(
|
||||||
|
LifecycleState state,
|
||||||
|
RelinquishmentStatus status
|
||||||
|
) {
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user