Compare commits

...
14 Commits
Author SHA1 Message Date
dmg 7318d4afa6 feat(commands): add contextual tab completion
Release / release (push) Successful in 2m35s
CI / build (push) Successful in 1m21s
2026-08-21 22:58:58 -04:00
dmg 80810b3ba1 feat(tamer): limit custody to one mob
Release / release (push) Successful in 2m42s
CI / build (push) Successful in 1m11s
2026-08-21 21:47:42 -04:00
dmg 34f32cd32d feat(tamer): release mobs after class loss
Release / release (push) Successful in 2m40s
CI / build (push) Successful in 1m13s
2026-08-21 21:37:09 -04:00
dmg 0b202f588d feat(items): add bound role control items 2026-08-21 21:28:32 -04:00
dmg ec7d50c2b5 feat(vigilante): add follower control panel 2026-08-21 21:19:29 -04:00
dmg 62f6352633 feat(tyrant): add reign control panel
CI / build (push) Successful in 1m10s
Release / release (push) Successful in 2m42s
2026-08-21 20:47:33 -04:00
dmg 3d65576010 fix(assassin): preserve external potion effects
Release / release (push) Successful in 2m46s
CI / build (push) Successful in 1m8s
2026-08-18 23:50:15 -04:00
dmg 323d177573 feat(participation): highlight opted-out players
Release / release (push) Successful in 2m40s
CI / build (push) Successful in 1m8s
2026-08-15 09:13:34 -04:00
dmg ea267ff867 fix(status): simplify Tyrant join messaging
Release / release (push) Successful in 2m36s
CI / build (push) Successful in 1m5s
2026-08-15 09:09:06 -04:00
dmg 24c54a7281 feat(participation): isolate opted-out combat
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m4s
2026-08-14 23:33:24 -04:00
dmg fe07c202f9 feat(items): secure bound ability inventory
Release / release (push) Successful in 2m7s
CI / build (push) Successful in 1m0s
2026-08-14 23:21:05 -04:00
dmg 777cbc6b4b feat(tamer): capture and relocate mobs
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m16s
2026-08-14 23:18:26 -04:00
dmg cd38e87487 feat(tyrant): add permanent powers and intelligence
Release / release (push) Successful in 2m15s
CI / build (push) Successful in 1m3s
2026-08-14 23:14:26 -04:00
dmg a82d20c7e6 feat(fixer): add timed combat boost
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 1m2s
2026-08-14 23:11:16 -04:00
107 changed files with 5825 additions and 80 deletions
+176
View File
@@ -6,6 +6,174 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log # Spigot Tyrant Design Log
## 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 ## 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. - 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.
@@ -17,6 +185,14 @@ description: Chronological record of material decisions affecting the Spigot Tyr
- US-005 begins with test-first bound-item invisibility, near-Tyrant duration doubling, and cooldown-gated double-jump effects. - 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 ## 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. - US-014 begins with test-first item readiness and recovery, owner metadata, transfer prevention, cooldown redelivery, and Tamer item permanence.
+4
View File
@@ -14,3 +14,7 @@
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,9 +19,13 @@ 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)
@@ -19,8 +19,13 @@ As the **Tyrant**, I want to assign unlocked specialist classes so that I can bu
- [x] 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.
- [x] 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.
- [x] 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)
@@ -20,6 +20,13 @@ As the **Assassin**, I want stealth and burst mobility so that I can ambush oppo
- [x] 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.
- [x] 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.
- [x] 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
@@ -23,8 +23,13 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
- [x] 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 is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
- [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position. - [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
- [x] The Tyrant is never eligible to be selected as Vigilante. - [x] The Tyrant is never eligible to be selected as Vigilante.
- [x] The Vigilante can review, invite, and dismiss Followers through the Vigilante control panel.
- [x] Control-panel and command-based Follower actions enforce identical eligibility and membership rules.
- [x] The control panel communicates the current nearby-Follower combat contribution and configured effect caps.
- [x] Relinquishing through the control panel follows normal Vigilante succession and Follower cleanup.
## 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 pending selections](us-010-handle-inactivity-and-selection.md)
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.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)
@@ -11,7 +11,8 @@ As a **player**, I want to understand my status and control my eligibility so th
## Acceptance criteria ## Acceptance criteria
- [x] 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.
- [x] Eligible players are invited to opt out of Tyrant events without implying that an event is currently running.
- [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command. - [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
- [x] An opted-out player is told when they may opt back in. - [x] An opted-out player is told when they may opt back in.
- [x] An unassigned player can opt out through a command. - [x] An unassigned player can opt out through a command.
@@ -20,6 +21,15 @@ As a **player**, I want to understand my status and control my eligibility so th
- [x] A player cannot opt back in until seven days have elapsed by default. - [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] 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] 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
@@ -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: in-progress 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,46 @@
---
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] 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)
@@ -0,0 +1,48 @@
---
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 uses normal Vigilante replacement and Follower-cleanup rules.
- [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 pending selections](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)
@@ -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,39 @@
---
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 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] 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, classes, confirmations, eligible recruits, current Followers, permission gating, online administrative candidates, argument positions, and case-insensitive prefix filtering. The complete `./gradlew clean check jar` lifecycle passes.
## 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)
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
@@ -8,6 +8,7 @@ public enum Ability {
ASSASSIN_WEAKNESS_ACTIVE, ASSASSIN_WEAKNESS_ACTIVE,
FIXER_BOOST, FIXER_BOOST,
FIXER_BOOST_ACTIVE, FIXER_BOOST_ACTIVE,
FIXER_NEAR_TYRANT_ACTIVE,
TAMER_CAPTURE, TAMER_CAPTURE,
ROSTER_INTELLIGENCE ROSTER_INTELLIGENCE
} }
@@ -10,17 +10,20 @@ public final class AbilityItemRefreshTask implements Runnable {
private final AbilityItemService items; private final AbilityItemService items;
private final Server server; private final Server server;
private final Clock clock; private final Clock clock;
private final CapturedMobInventoryService capturedMobInventory;
public AbilityItemRefreshTask( public AbilityItemRefreshTask(
TyrantStateManager stateManager, TyrantStateManager stateManager,
AbilityReadinessService readiness, AbilityReadinessService readiness,
AbilityItemService items, AbilityItemService items,
CapturedMobInventoryService capturedMobInventory,
Server server, Server server,
Clock clock Clock clock
) { ) {
this.stateManager = stateManager; this.stateManager = stateManager;
this.readiness = readiness; this.readiness = readiness;
this.items = items; this.items = items;
this.capturedMobInventory = capturedMobInventory;
this.server = server; this.server = server;
this.clock = clock; this.clock = clock;
} }
@@ -40,6 +43,7 @@ public final class AbilityItemRefreshTask implements Runnable {
} }
items.removeInvalid(player, after); items.removeInvalid(player, after);
items.recover(player, after); items.recover(player, after);
capturedMobInventory.reconcile(player, after);
} }
} }
} }
@@ -3,6 +3,7 @@ package games.dmg.spigottyrant;
import java.time.Clock; import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.util.HashSet; import java.util.HashSet;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import org.bukkit.GameMode; import org.bukkit.GameMode;
@@ -17,6 +18,7 @@ public final class AssassinEffectController implements Runnable {
private final Server server; private final Server server;
private final PluginSettings settings; private final PluginSettings settings;
private final Clock clock; private final Clock clock;
private final EffectApplier effectApplier;
private final Set<UUID> grantedFlight = new HashSet<>(); private final Set<UUID> grantedFlight = new HashSet<>();
public AssassinEffectController( public AssassinEffectController(
@@ -25,10 +27,21 @@ public final class AssassinEffectController implements Runnable {
PluginSettings settings, PluginSettings settings,
Clock clock Clock clock
) { ) {
this.stateManager = stateManager; this(stateManager, server, settings, clock, new BukkitEffectApplier());
this.server = server; }
this.settings = settings;
this.clock = clock; 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 @Override
@@ -43,15 +56,15 @@ public final class AssassinEffectController implements Runnable {
} }
applyWhileActive( applyWhileActive(
player, state, Ability.ASSASSIN_INVISIBILITY_ACTIVE, player, state, Ability.ASSASSIN_INVISIBILITY_ACTIVE,
PotionEffectType.INVISIBILITY, 0, false EffectKind.INVISIBILITY, 0, false
); );
applyWhileActive( applyWhileActive(
player, state, Ability.ASSASSIN_SPEED_ACTIVE, player, state, Ability.ASSASSIN_SPEED_ACTIVE,
PotionEffectType.SPEED, 0, true EffectKind.SPEED, 0, true
); );
applyWhileActive( applyWhileActive(
player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE, player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE,
PotionEffectType.WEAKNESS, settings.assassinWeaknessLevel() - 1, true EffectKind.WEAKNESS, settings.assassinWeaknessLevel() - 1, true
); );
boolean jumpReady = !state.cooldownEnds().getOrDefault( boolean jumpReady = !state.cooldownEnds().getOrDefault(
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
@@ -71,29 +84,52 @@ public final class AssassinEffectController implements Runnable {
Player player, Player player,
PlayerState state, PlayerState state,
Ability ability, Ability ability,
PotionEffectType type, EffectKind effect,
int amplifier, int amplifier,
boolean particles boolean particles
) { ) {
if (state.cooldownEnds().getOrDefault(ability, Instant.MIN).isAfter(clock.instant())) { if (state.cooldownEnds().getOrDefault(ability, Instant.MIN).isAfter(clock.instant())) {
player.addPotionEffect(new PotionEffect( effectApplier.apply(player, effect, amplifier, particles);
type, EFFECT_TICKS, amplifier, false, particles, true
));
} else {
player.removePotionEffect(type);
} }
} }
private void clear(Player player) { private void clear(Player player) {
player.removePotionEffect(PotionEffectType.INVISIBILITY);
player.removePotionEffect(PotionEffectType.SPEED);
player.removePotionEffect(PotionEffectType.WEAKNESS);
if (grantedFlight.remove(player.getUniqueId()) && isSurvivalLike(player)) { if (grantedFlight.remove(player.getUniqueId()) && isSurvivalLike(player)) {
player.setAllowFlight(false); player.setAllowFlight(false);
player.setFlying(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) { private static boolean isGrounded(Player player) {
return !player.getLocation().clone().subtract(0.0, 0.1, 0.0) return !player.getLocation().clone().subtract(0.0, 0.1, 0.0)
.getBlock().isPassable(); .getBlock().isPassable();
@@ -45,6 +45,7 @@ public final class BoundItemListener implements Listener {
if (items.isBoundAbilityItem(event.getCurrentItem()) if (items.isBoundAbilityItem(event.getCurrentItem())
|| items.isBoundAbilityItem(event.getCursor())) { || items.isBoundAbilityItem(event.getCursor())) {
if (!(event.getWhoClicked() instanceof Player player) if (!(event.getWhoClicked() instanceof Player player)
|| event.isShiftClick()
|| event.getClickedInventory() == null || event.getClickedInventory() == null
|| !event.getClickedInventory().equals(player.getInventory())) { || !event.getClickedInventory().equals(player.getInventory())) {
event.setCancelled(true); event.setCancelled(true);
@@ -81,11 +81,17 @@ public final class BukkitAbilityItemService implements AbilityItemService {
@Override @Override
public void removeInvalid(Player player, PlayerState state) { public void removeInvalid(Player player, PlayerState state) {
ItemStack[] contents = player.getInventory().getContents(); ItemStack[] contents = player.getInventory().getContents();
java.util.Set<Ability> retained = java.util.EnumSet.noneOf(Ability.class);
for (int index = 0; index < contents.length; index++) { for (int index = 0; index < contents.length; index++) {
ItemStack item = contents[index]; ItemStack item = contents[index];
if (isBoundAbilityItem(item) if (!isBoundAbilityItem(item)) {
&& (owner(item).filter(state.playerId()::equals).isEmpty() continue;
|| ability(item).filter(state.readyAbilityItems()::contains).isEmpty())) { }
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); player.getInventory().setItem(index, null);
} }
} }
@@ -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,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,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,436 @@
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.")));
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()
);
}
}
private void clickOverview(Player player, int slot) {
if (slot == 12) {
openUnlocks(player);
} else if (slot == 14) {
openClasses(player);
} else if (slot == 16) {
openAbilities(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
}
private static final class MenuHolder implements InventoryHolder {
private final MenuView view;
private final TyrantUnlock unlock;
private final TyrantClass tyrantClass;
private final UUID targetId;
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 = view;
this.unlock = unlock;
this.tyrantClass = tyrantClass;
this.targetId = targetId;
}
private MenuView view() {
return view;
}
private TyrantUnlock unlock() {
return unlock;
}
private TyrantClass tyrantClass() {
return tyrantClass;
}
private UUID targetId() {
return targetId;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
}
@@ -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 a replacement selected.",
"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 rerolls the Vigilante.")));
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,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);
}
@@ -22,10 +22,14 @@ public final class FollowerService {
} }
PlayerState target = players.get(targetId); PlayerState target = players.get(targetId);
if (target == null || target.optedOutUntil().isPresent() if (target == null || target.optedOutUntil().isPresent()
|| target.followerOf().isPresent()
|| game.tyrantId().filter(targetId::equals).isPresent() || game.tyrantId().filter(targetId::equals).isPresent()
|| game.vigilanteId().filter(targetId::equals).isPresent()) { || game.vigilanteId().filter(targetId::equals).isPresent()) {
return result(players, FollowerStatus.INELIGIBLE); return result(players, FollowerStatus.INELIGIBLE);
} }
if (vigilanteId.equals(invitations.get(targetId))) {
return result(players, FollowerStatus.ALREADY_INVITED);
}
invitations.put(targetId, vigilanteId); invitations.put(targetId, vigilanteId);
return result(players, FollowerStatus.INVITED); return result(players, FollowerStatus.INVITED);
} }
@@ -77,6 +81,13 @@ public final class FollowerService {
return updated(players, withFollower(target, Optional.empty()), FollowerStatus.LEFT); 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() { public void clearInvitations() {
invitations.clear(); invitations.clear();
} }
@@ -2,6 +2,7 @@ package games.dmg.spigottyrant;
public enum FollowerStatus { public enum FollowerStatus {
INVITED, INVITED,
ALREADY_INVITED,
JOINED, JOINED,
DISMISSED, DISMISSED,
LEFT, LEFT,
@@ -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,8 @@
package games.dmg.spigottyrant;
public record IntelligenceResult(
PlayerState tyrant,
AbilityUseStatus status,
int memberCount
) {
}
@@ -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;
}
}
@@ -15,7 +15,6 @@ public final class PlayerStatusMessages {
Instant now Instant now
) { ) {
List<String> messages = new ArrayList<>(); List<String> messages = new ArrayList<>();
messages.add("Game: " + game.lifecycle());
boolean hasRole = false; boolean hasRole = false;
if (game.tyrantId().filter(player.playerId()::equals).isPresent()) { if (game.tyrantId().filter(player.playerId()::equals).isPresent()) {
messages.add("Role: TYRANT. Level " + game.tyrantLevel() + ", choices " messages.add("Role: TYRANT. Level " + game.tyrantLevel() + ", choices "
@@ -45,8 +44,8 @@ public final class PlayerStatusMessages {
messages.add("Participation: OPTED OUT. You may use /tyrant optin at " messages.add("Participation: OPTED OUT. You may use /tyrant optin at "
+ player.optedOutUntil().orElseThrow() + "."); + player.optedOutUntil().orElseThrow() + ".");
} else if (!hasRole) { } else if (!hasRole) {
messages.add("You currently have no role or class. Use /tyrant optout " messages.add("Use /tyrant optout if you do not want to participate "
+ "if you do not want to participate."); + "in Tyrant events.");
} }
return List.copyOf(messages); return List.copyOf(messages);
} }
@@ -38,6 +38,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
@@ -76,6 +79,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");
} }
@@ -111,6 +119,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,
@@ -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,6 @@
package games.dmg.spigottyrant;
public enum RoleControl {
TYRANT,
VIGILANTE
}
@@ -0,0 +1,146 @@
package games.dmg.spigottyrant;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockDispenseEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.event.inventory.InventoryMoveItemEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.inventory.ItemStack;
public final class RoleControlItemListener implements Listener {
private final TyrantStateManager stateManager;
private final RoleControlItemService items;
private final TyrantControlPanel tyrantPanel;
private final VigilanteControlPanel vigilantePanel;
public RoleControlItemListener(
TyrantStateManager stateManager,
RoleControlItemService items,
TyrantControlPanel tyrantPanel,
VigilanteControlPanel vigilantePanel
) {
this.stateManager = stateManager;
this.items = items;
this.tyrantPanel = tyrantPanel;
this.vigilantePanel = vigilantePanel;
}
@EventHandler
public void onInteract(PlayerInteractEvent event) {
ItemStack item = event.getItem();
if (!items.isBoundRoleControlItem(item)
|| event.getAction() != Action.RIGHT_CLICK_AIR
&& event.getAction() != Action.RIGHT_CLICK_BLOCK) {
return;
}
event.setCancelled(true);
Player player = event.getPlayer();
GameState game = stateManager.game();
java.util.Optional<RoleControl> required = RoleControlItemPolicy.requiredRole(
game, player.getUniqueId()
);
boolean valid = items.owner(item).filter(player.getUniqueId()::equals).isPresent()
&& items.role(item).equals(required)
&& game.lifecycle() == GameLifecycle.RUNNING;
if (!valid) {
items.removeInvalid(player, game);
player.sendMessage(org.bukkit.ChatColor.RED
+ "That role control item is no longer valid.");
return;
}
if (required.orElseThrow() == RoleControl.TYRANT) {
tyrantPanel.open(player);
} else {
vigilantePanel.open(player);
}
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
items.removeInvalid(event.getPlayer(), stateManager.game());
items.recover(event.getPlayer(), stateManager.game());
}
@EventHandler
public void onDrop(PlayerDropItemEvent event) {
if (items.isBoundRoleControlItem(event.getItemDrop().getItemStack())) {
event.getItemDrop().remove();
}
}
@EventHandler
public void onPickup(EntityPickupItemEvent event) {
if (items.isBoundRoleControlItem(event.getItem().getItemStack())) {
event.setCancelled(true);
event.getItem().remove();
}
}
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (items.isBoundRoleControlItem(event.getCurrentItem())
|| items.isBoundRoleControlItem(event.getCursor())) {
if (!(event.getWhoClicked() instanceof Player player)
|| event.isShiftClick()
|| event.getClickedInventory() == null
|| !event.getClickedInventory().equals(player.getInventory())) {
event.setCancelled(true);
}
}
}
@EventHandler
public void onInventoryDrag(InventoryDragEvent event) {
if (items.isBoundRoleControlItem(event.getOldCursor())
|| event.getNewItems().values().stream()
.anyMatch(items::isBoundRoleControlItem)) {
event.setCancelled(true);
}
}
@EventHandler
public void onInventoryMove(InventoryMoveItemEvent event) {
if (items.isBoundRoleControlItem(event.getItem())) {
event.setCancelled(true);
}
}
@EventHandler
public void onPlace(BlockPlaceEvent event) {
if (items.isBoundRoleControlItem(event.getItemInHand())) {
event.setCancelled(true);
}
}
@EventHandler
public void onDispense(BlockDispenseEvent event) {
if (items.isBoundRoleControlItem(event.getItem())) {
event.setCancelled(true);
}
}
@EventHandler
public void onFrame(PlayerInteractEntityEvent event) {
if (event.getRightClicked() instanceof ItemFrame
&& items.isBoundRoleControlItem(event.getPlayer().getInventory()
.getItem(event.getHand()))) {
event.setCancelled(true);
}
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
event.getDrops().removeIf(items::isBoundRoleControlItem);
}
}
@@ -0,0 +1,19 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
public final class RoleControlItemPolicy {
private RoleControlItemPolicy() {
}
public static Optional<RoleControl> requiredRole(GameState game, UUID playerId) {
if (game.tyrantId().filter(playerId::equals).isPresent()) {
return Optional.of(RoleControl.TYRANT);
}
if (game.vigilanteId().filter(playerId::equals).isPresent()) {
return Optional.of(RoleControl.VIGILANTE);
}
return Optional.empty();
}
}
@@ -0,0 +1,31 @@
package games.dmg.spigottyrant;
import org.bukkit.Server;
import org.bukkit.entity.Player;
public final class RoleControlItemRefreshTask implements Runnable {
private final TyrantStateManager stateManager;
private final RoleControlItemService items;
private final Server server;
public RoleControlItemRefreshTask(
TyrantStateManager stateManager,
RoleControlItemService items,
Server server
) {
this.stateManager = stateManager;
this.items = items;
this.server = server;
}
@Override
public void run() {
GameState game = stateManager.game();
for (Player player : server.getOnlinePlayers()) {
items.removeInvalid(player, game);
if (!player.isDead()) {
items.recover(player, game);
}
}
}
}
@@ -0,0 +1,20 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
public interface RoleControlItemService {
void recover(Player player, GameState game);
boolean isBoundRoleControlItem(ItemStack item);
Optional<RoleControl> role(ItemStack item);
Optional<UUID> owner(ItemStack item);
void removeInvalid(Player player, GameState game);
void removeAll(Player player);
}
@@ -13,6 +13,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
private TyrantPresentation tyrantPresentation; private TyrantPresentation tyrantPresentation;
private VigilanteEffectController vigilanteEffects; private VigilanteEffectController vigilanteEffects;
private AssassinEffectController assassinEffects; private AssassinEffectController assassinEffects;
private FixerEffectController fixerEffects;
private TyrantEffectController tyrantEffects;
private OptOutGlowController optOutGlow;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -51,12 +54,30 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
FollowerService followers = new FollowerService(); FollowerService followers = new FollowerService();
AbilityReadinessService readiness = new AbilityReadinessService(); AbilityReadinessService readiness = new AbilityReadinessService();
AbilityItemService abilityItems = new BukkitAbilityItemService(this, settings); AbilityItemService abilityItems = new BukkitAbilityItemService(this, settings);
RoleControlItemService roleControlItems = new BukkitRoleControlItemService(
this, settings
);
CapturedMobItemService capturedMobItems = new BukkitCapturedMobItemService(
this, settings
);
CapturedMobService capturedMobs = new CapturedMobService();
CapturedMobInventoryService capturedMobInventory = new CapturedMobInventoryService(
capturedMobItems, settings
);
CapturedMobCustodyRelease formerTamerRelease =
new DefaultCapturedMobCustodyRelease(
new BukkitCapturedMobSpawner(getServer(), settings)
);
AssassinAbilityService assassinAbilities = new AssassinAbilityService( AssassinAbilityService assassinAbilities = new AssassinAbilityService(
readiness, settings readiness, settings
); );
assassinEffects = new AssassinEffectController( assassinEffects = new AssassinEffectController(
stateManager, getServer(), settings, clock stateManager, getServer(), settings, clock
); );
FixerAbilityService fixerAbilities = new FixerAbilityService(readiness, settings);
fixerEffects = new FixerEffectController(stateManager, getServer(), settings, clock);
tyrantEffects = new TyrantEffectController(stateManager, getServer(), settings);
optOutGlow = new OptOutGlowController(stateManager, getServer(), settings);
VigilanteCombatTracker combatTracker = new VigilanteCombatTracker(); VigilanteCombatTracker combatTracker = new VigilanteCombatTracker();
vigilanteEffects = new VigilanteEffectController( vigilanteEffects = new VigilanteEffectController(
stateManager, getServer(), combatTracker, settings, clock stateManager, getServer(), combatTracker, settings, clock
@@ -66,6 +87,23 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
stateManager, tyrantPresentation stateManager, tyrantPresentation
); );
managedEffects.restoreAll(); managedEffects.restoreAll();
BukkitTyrantControlPanelRenderer controlPanelRenderer =
new BukkitTyrantControlPanelRenderer(getServer(), stateManager, clock);
TyrantControlPanel controlPanel = new DefaultTyrantControlPanel(
stateManager, controlPanelRenderer, clock
);
FollowerPresenceProvider followerPresence = new BukkitFollowerPresenceProvider(
getServer(), settings.followerRangeBlocks()
);
BukkitVigilanteControlPanelRenderer vigilanteControlPanelRenderer =
new BukkitVigilanteControlPanelRenderer(
getServer(), stateManager, followers, followerPresence,
settings.followerStrengthCap(), settings.followerResistanceCap()
);
VigilanteControlPanel vigilanteControlPanel = new DefaultVigilanteControlPanel(
stateManager, followers, vigilanteControlPanelRenderer, followerPresence,
settings.followerStrengthCap(), settings.followerResistanceCap()
);
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata") Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
.setExecutor(new TyrantCommand( .setExecutor(new TyrantCommand(
stateManager, stateManager,
@@ -76,10 +114,16 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new ParticipationService(settings.optOutDuration()), new ParticipationService(settings.optOutDuration()),
new RoleRelinquishmentService(succession, maintenance), new RoleRelinquishmentService(succession, maintenance),
tyrantPresentation, tyrantPresentation,
abilityItems abilityItems,
new TyrantAbilityService(settings.rosterIntelligenceCooldown()),
controlPanel,
roleControlItems
)); ));
Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata") Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata")
.setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers)); .setExecutor(new VigilanteCommand(
stateManager, followers, onlinePlayers, vigilanteControlPanel,
roleControlItems
));
Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata") Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata")
.setExecutor(new TyrantAdminCommand( .setExecutor(new TyrantAdminCommand(
stateManager, stateManager,
@@ -92,10 +136,38 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
settings.pendingSelectionTimeout(), settings.pendingSelectionTimeout(),
random random
)); ));
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
.setTabCompleter(new TyrantTabCompleter(stateManager, onlinePlayers));
Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata")
.setTabCompleter(new VigilanteTabCompleter(
stateManager, followers, onlinePlayers
));
Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata")
.setTabCompleter(new TyrantAdminTabCompleter(onlinePlayers));
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new PlayerJoinListener(stateManager, clock, tyrantPresentation), new PlayerJoinListener(stateManager, clock, tyrantPresentation),
this this
); );
getServer().getPluginManager().registerEvents(controlPanelRenderer, this);
getServer().getPluginManager().registerEvents(vigilanteControlPanelRenderer, this);
getServer().getPluginManager().registerEvents(
new TyrantControlPanelJoinListener(
stateManager,
controlPanel,
(task, delayTicks) -> getServer().getScheduler()
.runTaskLater(this, task, delayTicks)
),
this
);
getServer().getPluginManager().registerEvents(
new VigilanteControlPanelJoinListener(
stateManager,
vigilanteControlPanel,
(task, delayTicks) -> getServer().getScheduler()
.runTaskLater(this, task, delayTicks)
),
this
);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new TyrantDeathListener( new TyrantDeathListener(
stateManager, succession, tyrantPresentation, getServer(), clock stateManager, succession, tyrantPresentation, getServer(), clock
@@ -109,6 +181,13 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
), ),
this this
); );
getServer().getPluginManager().registerEvents(
new ParticipationCombatListener(
stateManager, new ParticipationCombatService()
),
this
);
getServer().getPluginManager().registerEvents(optOutGlow, this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new VigilanteCombatListener( new VigilanteCombatListener(
stateManager, combatTracker, clock, settings.vigilanteCombatDuration() stateManager, combatTracker, clock, settings.vigilanteCombatDuration()
@@ -119,6 +198,19 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new BoundItemListener(abilityItems), new BoundItemListener(abilityItems),
this this
); );
getServer().getPluginManager().registerEvents(
new RoleControlItemListener(
stateManager, roleControlItems, controlPanel, vigilanteControlPanel
),
this
);
getServer().getPluginManager().registerEvents(
new TamerListener(
stateManager, abilityItems, capturedMobItems, capturedMobs,
settings, getServer()
),
this
);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new AssassinItemListener( new AssassinItemListener(
stateManager, abilityItems, assassinAbilities, settings, clock stateManager, abilityItems, assassinAbilities, settings, clock
@@ -129,6 +221,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
new AssassinJumpListener(stateManager, assassinAbilities, clock), new AssassinJumpListener(stateManager, assassinAbilities, clock),
this this
); );
getServer().getPluginManager().registerEvents(
new FixerItemListener(
stateManager, abilityItems, fixerAbilities, settings, clock
),
this
);
long maintenanceTicks = Math.max( long maintenanceTicks = Math.max(
1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L) 1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L)
); );
@@ -142,20 +240,47 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
); );
getServer().getScheduler().runTaskTimer(this, vigilanteEffects, 10L, 10L); getServer().getScheduler().runTaskTimer(this, vigilanteEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, assassinEffects, 10L, 10L); getServer().getScheduler().runTaskTimer(this, assassinEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
getServer().getScheduler().runTaskTimer( getServer().getScheduler().runTaskTimer(
this, this,
new AbilityItemRefreshTask( new AbilityItemRefreshTask(
stateManager, readiness, abilityItems, getServer(), clock stateManager, readiness, abilityItems, capturedMobInventory,
getServer(), clock
), ),
20L, 20L,
20L 20L
); );
getServer().getScheduler().runTaskTimer(
this,
new RoleControlItemRefreshTask(stateManager, roleControlItems, getServer()),
1L,
10L
);
getServer().getScheduler().runTaskTimer(
this,
new FormerTamerReleaseTask(
stateManager, getServer(), formerTamerRelease, capturedMobInventory
),
1L,
10L
);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L); getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Tyrant enabled."); getLogger().info("Spigot Tyrant enabled.");
} }
@Override @Override
public void onDisable() { public void onDisable() {
if (optOutGlow != null) {
optOutGlow.clearAll();
}
if (tyrantEffects != null) {
tyrantEffects.clearAll();
}
if (fixerEffects != null) {
fixerEffects.clearAll();
}
if (assassinEffects != null) { if (assassinEffects != null) {
assassinEffects.clearAll(); assassinEffects.clearAll();
} }
@@ -0,0 +1,19 @@
package games.dmg.spigottyrant;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
public final class TabSuggestions {
private TabSuggestions() {
}
public static List<String> matching(String prefix, Collection<String> candidates) {
String normalized = prefix == null ? "" : prefix.toLowerCase(Locale.ROOT);
return candidates.stream()
.filter(candidate -> candidate.toLowerCase(Locale.ROOT).startsWith(normalized))
.distinct()
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
}
@@ -0,0 +1,254 @@
package games.dmg.spigottyrant;
import java.util.Iterator;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntitySnapshot;
import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Tameable;
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.block.BlockDispenseEvent;
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.event.player.PlayerInteractEvent;
import org.bukkit.inventory.ItemStack;
public final class TamerListener implements Listener {
private final TyrantStateManager stateManager;
private final AbilityItemService abilityItems;
private final CapturedMobItemService capturedItems;
private final CapturedMobService capturedMobs;
private final PluginSettings settings;
private final Server server;
public TamerListener(
TyrantStateManager stateManager,
AbilityItemService abilityItems,
CapturedMobItemService capturedItems,
CapturedMobService capturedMobs,
PluginSettings settings,
Server server
) {
this.stateManager = stateManager;
this.abilityItems = abilityItems;
this.capturedItems = capturedItems;
this.capturedMobs = capturedMobs;
this.settings = settings;
this.server = server;
}
@EventHandler(priority = EventPriority.HIGH)
public void onCapture(PlayerInteractEntityEvent event) {
ItemStack held = event.getPlayer().getInventory().getItem(event.getHand());
if (abilityItems.ability(held).filter(Ability.TAMER_CAPTURE::equals).isEmpty()) {
return;
}
event.setCancelled(true);
Player player = event.getPlayer();
if (abilityItems.owner(held).filter(player.getUniqueId()::equals).isEmpty()) {
player.getInventory().setItem(event.getHand(), null);
return;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (!state.capturedMobs().isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Release the currently captured mob before capturing another.");
return;
}
Entity target = event.getRightClicked();
if (!canCapture(state, target)) {
player.sendMessage(ChatColor.RED + "That mob cannot be captured.");
return;
}
EntitySnapshot snapshot = target.createSnapshot();
if (snapshot == null) {
player.sendMessage(ChatColor.RED + "That mob could not be captured safely.");
return;
}
UUID captureId = UUID.randomUUID();
ItemStack capturedItem = capturedItems.create(
player.getUniqueId(), captureId, target.getType().name()
);
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(capturedItem);
if (!leftovers.isEmpty()) {
player.sendMessage(ChatColor.RED + settings.inventoryFullMessage());
return;
}
PlayerState updated = capturedMobs.capture(
state, captureId, target.getType().name(), snapshot.getAsString()
);
stateManager.updatePlayer(
player.getUniqueId(), player.getName(), current -> updated
);
stateManager.saveIfDirty();
target.remove();
player.sendMessage(ChatColor.GREEN + "Captured " + target.getType().name() + ".");
}
@EventHandler(priority = EventPriority.HIGH)
public void onPlace(PlayerInteractEvent event) {
Action action = event.getAction();
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) {
return;
}
if (!capturedItems.isCapturedMob(event.getItem())) {
return;
}
event.setCancelled(true);
UUID captureId = capturedItems.captureId(event.getItem()).orElseThrow();
if (capturedItems.owner(event.getItem())
.filter(event.getPlayer().getUniqueId()::equals).isEmpty()) {
event.getPlayer().getInventory().setItem(event.getHand(), null);
return;
}
Location location = event.getClickedBlock() == null
? event.getPlayer().getLocation().add(
event.getPlayer().getLocation().getDirection().normalize().multiply(2.0)
)
: event.getClickedBlock().getRelative(event.getBlockFace())
.getLocation().add(0.5, 0.0, 0.5);
if (spawnAndRelease(event.getPlayer(), captureId, location)) {
event.getPlayer().getInventory().setItem(event.getHand(), null);
} else {
event.getPlayer().sendMessage(ChatColor.RED
+ "The mob cannot be placed safely there.");
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onDrop(PlayerDropItemEvent event) {
ItemStack item = event.getItemDrop().getItemStack();
if (!capturedItems.isCapturedMob(item)) {
return;
}
UUID captureId = capturedItems.captureId(item).orElseThrow();
if (capturedItems.owner(item).filter(event.getPlayer().getUniqueId()::equals).isEmpty()
|| !spawnAndRelease(
event.getPlayer(), captureId, event.getItemDrop().getLocation()
)) {
event.setCancelled(true);
return;
}
event.getItemDrop().remove();
}
@EventHandler(priority = EventPriority.HIGH)
public void onDeath(PlayerDeathEvent event) {
Iterator<ItemStack> drops = event.getDrops().iterator();
while (drops.hasNext()) {
ItemStack item = drops.next();
if (!capturedItems.isCapturedMob(item)) {
continue;
}
UUID captureId = capturedItems.captureId(item).orElseThrow();
if (spawnAndRelease(event.getEntity(), captureId, event.getEntity().getLocation())) {
drops.remove();
}
}
}
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (capturedItems.isCapturedMob(event.getCurrentItem())
|| capturedItems.isCapturedMob(event.getCursor())) {
if (!(event.getWhoClicked() instanceof Player player)
|| event.isShiftClick()
|| event.getClickedInventory() == null
|| !event.getClickedInventory().equals(player.getInventory())) {
event.setCancelled(true);
}
}
}
@EventHandler
public void onFrame(PlayerInteractEntityEvent event) {
if (event.getRightClicked() instanceof ItemFrame
&& capturedItems.isCapturedMob(
event.getPlayer().getInventory().getItem(event.getHand())
)) {
event.setCancelled(true);
}
}
@EventHandler
public void onDispense(BlockDispenseEvent event) {
if (capturedItems.isCapturedMob(event.getItem())) {
event.setCancelled(true);
}
}
@EventHandler
public void onPickup(EntityPickupItemEvent event) {
if (!(event.getEntity() instanceof Player player)
|| !capturedItems.isCapturedMob(event.getItem().getItemStack())) {
return;
}
if (capturedItems.owner(event.getItem().getItemStack())
.filter(player.getUniqueId()::equals).isEmpty()) {
event.setCancelled(true);
}
}
private boolean canCapture(PlayerState player, Entity target) {
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING
|| player.tyrantClass() != TyrantClass.TAMER
|| !(target instanceof LivingEntity)
|| target instanceof Player
|| target.getType().name().equals("ENDER_DRAGON")
|| settings.deniedMobTypes().contains(target.getType().name())
|| target.getCustomName() != null) {
return false;
}
return !(target instanceof Tameable tameable) || !tameable.isTamed();
}
private boolean spawnAndRelease(Player player, UUID captureId, Location location) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
Optional<CapturedMob> stored = capturedMobs.find(state, captureId);
if (stored.isEmpty() || !safe(location)) {
return false;
}
CapturedMob mob = stored.orElseThrow();
if ("ENDER_DRAGON".equals(mob.entityType())
|| settings.deniedMobTypes().contains(mob.entityType())) {
return false;
}
try {
EntitySnapshot snapshot = server.getEntityFactory().createEntitySnapshot(
mob.data().get("snapshot")
);
if (!snapshot.getEntityType().name().equals(mob.entityType())) {
return false;
}
snapshot.createEntity(location);
} catch (IllegalArgumentException | IllegalStateException exception) {
return false;
}
PlayerState released = capturedMobs.release(state, captureId);
stateManager.updatePlayer(
player.getUniqueId(), player.getName(), current -> released
);
stateManager.saveIfDirty();
return true;
}
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,53 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumMap;
import java.util.Map;
public final class TyrantAbilityService {
private final Duration intelligenceCooldown;
public TyrantAbilityService(Duration intelligenceCooldown) {
this.intelligenceCooldown = intelligenceCooldown;
}
public IntelligenceResult useIntelligence(
GameState game,
PlayerState tyrant,
Map<java.util.UUID, PlayerState> players,
Instant now
) {
if (game.lifecycle() != GameLifecycle.RUNNING) {
return new IntelligenceResult(tyrant, AbilityUseStatus.GAME_NOT_RUNNING, 0);
}
if (game.tyrantId().filter(tyrant.playerId()::equals).isEmpty()) {
return new IntelligenceResult(tyrant, AbilityUseStatus.WRONG_OWNER, 0);
}
if (!game.purchases().contains(TyrantUnlock.ROSTER_INTELLIGENCE)) {
return new IntelligenceResult(tyrant, AbilityUseStatus.NOT_READY, 0);
}
if (tyrant.cooldownEnds().getOrDefault(
Ability.ROSTER_INTELLIGENCE, Instant.MIN
).isAfter(now)) {
return new IntelligenceResult(tyrant, AbilityUseStatus.COOLDOWN, 0);
}
int count = game.vigilanteId().isPresent() ? 1 : 0;
if (game.vigilanteId().isPresent()) {
java.util.UUID vigilante = game.vigilanteId().orElseThrow();
count += (int) players.values().stream()
.filter(player -> player.followerOf().filter(vigilante::equals).isPresent())
.filter(player -> player.optedOutUntil().isEmpty())
.count();
}
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
cooldowns.putAll(tyrant.cooldownEnds());
cooldowns.put(Ability.ROSTER_INTELLIGENCE, now.plus(intelligenceCooldown));
PlayerState updated = new PlayerState(
tyrant.playerId(), tyrant.latestName(), tyrant.lastLogin(),
tyrant.optedOutUntil(), tyrant.tyrantClass(), tyrant.followerOf(), cooldowns,
tyrant.readyAbilityItems(), tyrant.capturedMobs()
);
return new IntelligenceResult(updated, AbilityUseStatus.ACTIVATED, count);
}
}
@@ -0,0 +1,60 @@
package games.dmg.spigottyrant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
public final class TyrantAdminTabCompleter implements TabCompleter {
private static final String PERMISSION = "spigottyrant.admin";
private static final List<String> SUBCOMMANDS = List.of(
"status", "start", "pause", "resume", "reset"
);
private final OnlinePlayerDirectory onlinePlayers;
public TyrantAdminTabCompleter(OnlinePlayerDirectory onlinePlayers) {
this.onlinePlayers = onlinePlayers;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (!sender.hasPermission(PERMISSION) || arguments.length == 0) {
return List.of();
}
if (arguments.length == 1) {
return TabSuggestions.matching(arguments[0], SUBCOMMANDS);
}
String subcommand = arguments[0].toLowerCase(java.util.Locale.ROOT);
if (arguments.length == 2) {
return switch (subcommand) {
case "start" -> TabSuggestions.matching(arguments[1], onlineNames(null));
case "reset" -> TabSuggestions.matching(arguments[1], List.of("confirm"));
default -> List.of();
};
}
if (arguments.length == 3 && subcommand.equals("start")) {
return TabSuggestions.matching(arguments[2], onlineNames(arguments[1]));
}
return List.of();
}
private List<String> onlineNames(String excludedName) {
List<String> names = new ArrayList<>();
for (UUID playerId : onlinePlayers.onlinePlayerIds()) {
Player player = onlinePlayers.findById(playerId);
if (player != null && (excludedName == null
|| !player.getName().equalsIgnoreCase(excludedName))) {
names.add(player.getName());
}
}
return List.copyOf(names);
}
}
@@ -19,6 +19,9 @@ public final class TyrantCommand implements CommandExecutor {
private final RoleRelinquishmentService relinquishment; private final RoleRelinquishmentService relinquishment;
private final TyrantPresentation presentation; private final TyrantPresentation presentation;
private final AbilityItemService abilityItems; private final AbilityItemService abilityItems;
private final TyrantAbilityService tyrantAbilities;
private final TyrantControlPanel controlPanel;
private final RoleControlItemService roleControlItems;
public TyrantCommand( public TyrantCommand(
TyrantStateManager stateManager, TyrantStateManager stateManager,
@@ -33,6 +36,33 @@ public final class TyrantCommand implements CommandExecutor {
); );
} }
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
TyrantControlPanel controlPanel
) {
this(
stateManager, progression, new ClassAssignmentService(),
new BukkitOnlinePlayerDirectory(), Clock.systemUTC(),
new ParticipationService(Duration.ofDays(7)), null, null, null,
new TyrantAbilityService(Duration.ofHours(24)), controlPanel, null
);
}
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
TyrantControlPanel controlPanel,
RoleControlItemService roleControlItems
) {
this(
stateManager, progression, new ClassAssignmentService(),
new BukkitOnlinePlayerDirectory(), Clock.systemUTC(),
new ParticipationService(Duration.ofDays(7)), null, null, null,
new TyrantAbilityService(Duration.ofHours(24)), controlPanel, roleControlItems
);
}
TyrantCommand( TyrantCommand(
TyrantStateManager stateManager, TyrantStateManager stateManager,
TyrantProgressionService progression, TyrantProgressionService progression,
@@ -42,7 +72,9 @@ public final class TyrantCommand implements CommandExecutor {
) { ) {
this( this(
stateManager, progression, assignments, onlinePlayers, clock, stateManager, progression, assignments, onlinePlayers, clock,
new ParticipationService(Duration.ofDays(7)), null, null, null new ParticipationService(Duration.ofDays(7)), null, null, null,
new TyrantAbilityService(Duration.ofHours(24)), TyrantCommand::unavailablePanel,
null
); );
} }
@@ -55,7 +87,48 @@ public final class TyrantCommand implements CommandExecutor {
ParticipationService participation, ParticipationService participation,
RoleRelinquishmentService relinquishment, RoleRelinquishmentService relinquishment,
TyrantPresentation presentation, TyrantPresentation presentation,
AbilityItemService abilityItems AbilityItemService abilityItems,
TyrantAbilityService tyrantAbilities
) {
this(
stateManager, progression, assignments, onlinePlayers, clock, participation,
relinquishment, presentation, abilityItems, tyrantAbilities,
TyrantCommand::unavailablePanel, null
);
}
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
ClassAssignmentService assignments,
OnlinePlayerDirectory onlinePlayers,
Clock clock,
ParticipationService participation,
RoleRelinquishmentService relinquishment,
TyrantPresentation presentation,
AbilityItemService abilityItems,
TyrantAbilityService tyrantAbilities,
TyrantControlPanel controlPanel
) {
this(
stateManager, progression, assignments, onlinePlayers, clock, participation,
relinquishment, presentation, abilityItems, tyrantAbilities, controlPanel, null
);
}
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
ClassAssignmentService assignments,
OnlinePlayerDirectory onlinePlayers,
Clock clock,
ParticipationService participation,
RoleRelinquishmentService relinquishment,
TyrantPresentation presentation,
AbilityItemService abilityItems,
TyrantAbilityService tyrantAbilities,
TyrantControlPanel controlPanel,
RoleControlItemService roleControlItems
) { ) {
this.stateManager = stateManager; this.stateManager = stateManager;
this.progression = progression; this.progression = progression;
@@ -66,6 +139,9 @@ public final class TyrantCommand implements CommandExecutor {
this.relinquishment = relinquishment; this.relinquishment = relinquishment;
this.presentation = presentation; this.presentation = presentation;
this.abilityItems = abilityItems; this.abilityItems = abilityItems;
this.tyrantAbilities = tyrantAbilities;
this.controlPanel = controlPanel;
this.roleControlItems = roleControlItems;
} }
@Override @Override
@@ -79,7 +155,12 @@ public final class TyrantCommand implements CommandExecutor {
sender.sendMessage("This command must be used by a player."); sender.sendMessage("This command must be used by a player.");
return true; return true;
} }
if (arguments.length == 0 || arguments[0].equalsIgnoreCase("choices")) { if (arguments.length == 0
|| arguments.length == 1 && arguments[0].equalsIgnoreCase("menu")) {
controlPanel.open(player);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("choices")) {
showChoices(player); showChoices(player);
return true; return true;
} }
@@ -111,9 +192,13 @@ public final class TyrantCommand implements CommandExecutor {
recoverItems(player); recoverItems(player);
return true; return true;
} }
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("intelligence")) {
useIntelligence(player);
return true;
}
player.sendMessage( player.sendMessage(
ChatColor.YELLOW ChatColor.YELLOW
+ "Usage: /tyrant <status|choices|buy|assign|item|optout|optin|" + "Usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|"
+ "relinquish confirm>" + "relinquish confirm>"
); );
return true; return true;
@@ -241,15 +326,40 @@ public final class TyrantCommand implements CommandExecutor {
+ readable(result[0].status()) + "."); + readable(result[0].status()) + ".");
} }
private void useIntelligence(Player player) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
IntelligenceResult result = tyrantAbilities.useIntelligence(
stateManager.game(), state, stateManager.players(), clock.instant()
);
if (result.status() == AbilityUseStatus.ACTIVATED) {
stateManager.updatePlayer(
player.getUniqueId(), player.getName(), current -> result.tyrant()
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Vigilante-side members: "
+ result.memberCount() + ".");
} else {
player.sendMessage(ChatColor.RED + "Intelligence unavailable: "
+ readable(result.status()) + ".");
}
}
private void recoverItems(Player player) { private void recoverItems(Player player) {
if (abilityItems == null) { if (abilityItems == null && roleControlItems == null) {
player.sendMessage(ChatColor.RED + "Ability item recovery is unavailable."); player.sendMessage(ChatColor.RED + "Item recovery is unavailable.");
return; return;
} }
if (abilityItems != null) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
abilityItems.recover(player, state); abilityItems.recover(player, state);
}
GameState game = stateManager.game();
if (roleControlItems != null
&& game.tyrantId().filter(player.getUniqueId()::equals).isPresent()) {
roleControlItems.recover(player, game);
}
player.sendMessage(ChatColor.YELLOW player.sendMessage(ChatColor.YELLOW
+ "Recovered every currently ready missing class item that could fit."); + "Recovered every currently available missing Tyrant item that could fit.");
} }
private void relinquish(Player player, String[] arguments) { private void relinquish(Player player, String[] arguments) {
@@ -277,6 +387,10 @@ public final class TyrantCommand implements CommandExecutor {
+ readable(result.status()) + "."); + readable(result.status()) + ".");
} }
private static void unavailablePanel(Player player) {
player.sendMessage(ChatColor.RED + "The Tyrant control panel is unavailable.");
}
private static String readable(Enum<?> status) { private static String readable(Enum<?> status) {
return status.name().toLowerCase(Locale.ROOT).replace('_', ' '); return status.name().toLowerCase(Locale.ROOT).replace('_', ' ');
} }
@@ -0,0 +1,19 @@
package games.dmg.spigottyrant;
import java.util.UUID;
import org.bukkit.entity.Player;
@FunctionalInterface
public interface TyrantControlPanel {
void open(Player player);
default void beginSession(UUID playerId) {
}
default void endSession(UUID playerId) {
}
default boolean openedThisSession(UUID playerId) {
return false;
}
}
@@ -0,0 +1,48 @@
package games.dmg.spigottyrant;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
public final class TyrantControlPanelJoinListener implements Listener {
private static final long OPEN_DELAY_TICKS = 100L;
private final TyrantStateManager stateManager;
private final TyrantControlPanel controlPanel;
private final DelayedTaskScheduler scheduler;
public TyrantControlPanelJoinListener(
TyrantStateManager stateManager,
TyrantControlPanel controlPanel,
DelayedTaskScheduler scheduler
) {
this.stateManager = stateManager;
this.controlPanel = controlPanel;
this.scheduler = scheduler;
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
UUID playerId = player.getUniqueId();
controlPanel.beginSession(playerId);
scheduler.schedule(() -> openIfStillTyrant(player), OPEN_DELAY_TICKS);
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
controlPanel.endSession(event.getPlayer().getUniqueId());
}
private void openIfStillTyrant(Player player) {
GameState game = stateManager.game();
if (player.isOnline()
&& game.lifecycle() == GameLifecycle.RUNNING
&& game.tyrantId().filter(player.getUniqueId()::equals).isPresent()
&& !controlPanel.openedThisSession(player.getUniqueId())) {
controlPanel.open(player);
}
}
}
@@ -0,0 +1,67 @@
package games.dmg.spigottyrant;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumMap;
import java.util.Map;
import java.util.Set;
public record TyrantControlPanelModel(
int level,
int unspentChoices,
Map<TyrantUnlock, UnlockAvailability> unlocks,
Map<TyrantClass, String> classHolders,
Duration intelligenceCooldown
) {
public TyrantControlPanelModel {
unlocks = Map.copyOf(unlocks);
classHolders = Map.copyOf(classHolders);
}
public Set<TyrantUnlock> purchasedUnlocks() {
return unlocks.entrySet().stream()
.filter(entry -> entry.getValue() == UnlockAvailability.PURCHASED)
.map(Map.Entry::getKey)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
public static TyrantControlPanelModel create(
GameState game,
PlayerState tyrant,
Map<java.util.UUID, PlayerState> players,
Instant now
) {
Map<TyrantUnlock, UnlockAvailability> unlocks = new EnumMap<>(TyrantUnlock.class);
for (TyrantUnlock unlock : TyrantUnlock.values()) {
UnlockAvailability availability;
if (game.purchases().contains(unlock)) {
availability = UnlockAvailability.PURCHASED;
} else if (game.lifecycle() == GameLifecycle.RUNNING
&& game.unspentChoices() > 0) {
availability = UnlockAvailability.AVAILABLE;
} else {
availability = UnlockAvailability.UNAVAILABLE;
}
unlocks.put(unlock, availability);
}
Map<TyrantClass, String> holders = new EnumMap<>(TyrantClass.class);
for (TyrantClass tyrantClass : new TyrantClass[] {
TyrantClass.ASSASSIN, TyrantClass.FIXER, TyrantClass.TAMER
}) {
String holder = players.values().stream()
.filter(player -> player.tyrantClass() == tyrantClass)
.map(PlayerState::latestName)
.findFirst()
.orElse("Unassigned");
holders.put(tyrantClass, holder);
}
Duration cooldown = tyrant.cooldownEnds().getOrDefault(
Ability.ROSTER_INTELLIGENCE, now
).isAfter(now)
? Duration.between(now, tyrant.cooldownEnds().get(Ability.ROSTER_INTELLIGENCE))
: Duration.ZERO;
return new TyrantControlPanelModel(
game.tyrantLevel(), game.unspentChoices(), unlocks, holders, cooldown
);
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
import org.bukkit.entity.Player;
@FunctionalInterface
public interface TyrantControlPanelRenderer {
void openOverview(Player player, TyrantControlPanelModel model);
}
@@ -0,0 +1,77 @@
package games.dmg.spigottyrant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
public final class TyrantEffectController implements Runnable {
private static final int EFFECT_TICKS = 30;
private final TyrantStateManager stateManager;
private final Server server;
private final PluginSettings settings;
private Optional<UUID> previousTyrant = Optional.empty();
public TyrantEffectController(
TyrantStateManager stateManager,
Server server,
PluginSettings settings
) {
this.stateManager = stateManager;
this.server = server;
this.settings = settings;
}
@Override
public void run() {
GameState game = stateManager.game();
if (!previousTyrant.equals(game.tyrantId())) {
previousTyrant.map(server::getPlayer).ifPresent(TyrantEffectController::clear);
previousTyrant = game.tyrantId();
}
Player tyrant = game.tyrantId().map(server::getPlayer).orElse(null);
if (tyrant == null || game.lifecycle() != GameLifecycle.RUNNING) {
if (tyrant != null) {
clear(tyrant);
}
return;
}
apply(
tyrant, PotionEffectType.STRENGTH,
game.purchases().contains(TyrantUnlock.STRENGTH),
settings.tyrantStrengthLevel()
);
apply(
tyrant, PotionEffectType.RESISTANCE,
game.purchases().contains(TyrantUnlock.RESISTANCE),
settings.tyrantResistanceLevel()
);
}
public void clearAll() {
previousTyrant.map(server::getPlayer).ifPresent(TyrantEffectController::clear);
previousTyrant = Optional.empty();
}
private static void apply(
Player player,
PotionEffectType type,
boolean purchased,
int level
) {
if (purchased) {
player.addPotionEffect(new PotionEffect(
type, EFFECT_TICKS, level - 1, false, true, true
));
} else {
player.removePotionEffect(type);
}
}
private static void clear(Player player) {
player.removePotionEffect(PotionEffectType.STRENGTH);
player.removePotionEffect(PotionEffectType.RESISTANCE);
}
}
@@ -0,0 +1,84 @@
package games.dmg.spigottyrant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
public final class TyrantTabCompleter implements TabCompleter {
private static final List<String> SUBCOMMANDS = List.of(
"menu", "status", "choices", "buy", "assign", "item", "intelligence",
"optout", "optin", "relinquish"
);
private static final List<String> UNLOCKS = java.util.Arrays.stream(TyrantUnlock.values())
.map(value -> value.name().toLowerCase(java.util.Locale.ROOT))
.toList();
private static final List<String> CLASSES = List.of("assassin", "fixer", "tamer");
private final TyrantStateManager stateManager;
private final OnlinePlayerDirectory onlinePlayers;
public TyrantTabCompleter(
TyrantStateManager stateManager,
OnlinePlayerDirectory onlinePlayers
) {
this.stateManager = stateManager;
this.onlinePlayers = onlinePlayers;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (!(sender instanceof Player player) || arguments.length == 0) {
return List.of();
}
if (arguments.length == 1) {
return TabSuggestions.matching(arguments[0], SUBCOMMANDS);
}
String subcommand = arguments[0].toLowerCase(java.util.Locale.ROOT);
if (arguments.length == 2) {
return switch (subcommand) {
case "buy" -> TabSuggestions.matching(arguments[1], UNLOCKS);
case "assign" -> TabSuggestions.matching(arguments[1], CLASSES);
case "relinquish" -> TabSuggestions.matching(
arguments[1], List.of("confirm")
);
default -> List.of();
};
}
if (arguments.length == 3 && subcommand.equals("assign")) {
return TabSuggestions.matching(arguments[2], eligibleAssignees(player));
}
return List.of();
}
private List<String> eligibleAssignees(Player tyrant) {
Map<UUID, PlayerState> states = stateManager.players();
GameState game = stateManager.game();
Set<UUID> online = onlinePlayers.onlinePlayerIds();
List<String> names = new ArrayList<>();
for (UUID playerId : online) {
if (playerId.equals(tyrant.getUniqueId())
|| game != null && game.tyrantId().filter(playerId::equals).isPresent()) {
continue;
}
PlayerState state = states.get(playerId);
if (state != null && state.optedOutUntil().isPresent()) {
continue;
}
Player candidate = onlinePlayers.findById(playerId);
if (candidate != null) {
names.add(candidate.getName());
}
}
return List.copyOf(names);
}
}
@@ -0,0 +1,7 @@
package games.dmg.spigottyrant;
public enum UnlockAvailability {
PURCHASED,
AVAILABLE,
UNAVAILABLE
}
@@ -11,15 +11,43 @@ public final class VigilanteCommand implements CommandExecutor {
private final TyrantStateManager stateManager; private final TyrantStateManager stateManager;
private final FollowerService followers; private final FollowerService followers;
private final OnlinePlayerDirectory onlinePlayers; private final OnlinePlayerDirectory onlinePlayers;
private final VigilanteControlPanel controlPanel;
private final RoleControlItemService roleControlItems;
public VigilanteCommand( public VigilanteCommand(
TyrantStateManager stateManager, TyrantStateManager stateManager,
FollowerService followers, FollowerService followers,
OnlinePlayerDirectory onlinePlayers OnlinePlayerDirectory onlinePlayers
) {
this(
stateManager, followers, onlinePlayers,
player -> player.sendMessage(ChatColor.RED
+ "The Vigilante control panel is unavailable."),
null
);
}
public VigilanteCommand(
TyrantStateManager stateManager,
FollowerService followers,
OnlinePlayerDirectory onlinePlayers,
VigilanteControlPanel controlPanel
) {
this(stateManager, followers, onlinePlayers, controlPanel, null);
}
public VigilanteCommand(
TyrantStateManager stateManager,
FollowerService followers,
OnlinePlayerDirectory onlinePlayers,
VigilanteControlPanel controlPanel,
RoleControlItemService roleControlItems
) { ) {
this.stateManager = stateManager; this.stateManager = stateManager;
this.followers = followers; this.followers = followers;
this.onlinePlayers = onlinePlayers; this.onlinePlayers = onlinePlayers;
this.controlPanel = controlPanel;
this.roleControlItems = roleControlItems;
} }
@Override @Override
@@ -33,6 +61,21 @@ public final class VigilanteCommand implements CommandExecutor {
sender.sendMessage("This command must be used by a player."); sender.sendMessage("This command must be used by a player.");
return true; return true;
} }
if (arguments.length == 0
|| arguments.length == 1 && arguments[0].equalsIgnoreCase("menu")) {
controlPanel.open(player);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("item")) {
if (roleControlItems == null) {
player.sendMessage(ChatColor.RED + "Role item recovery is unavailable.");
} else {
roleControlItems.recover(player, stateManager.game());
player.sendMessage(ChatColor.YELLOW
+ "Recovered the missing Vigilante control item if it could fit.");
}
return true;
}
FollowerResult result; FollowerResult result;
Player target = null; Player target = null;
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("accept")) { if (arguments.length == 1 && arguments[0].equalsIgnoreCase("accept")) {
@@ -61,7 +104,7 @@ public final class VigilanteCommand implements CommandExecutor {
); );
} else { } else {
player.sendMessage(ChatColor.YELLOW player.sendMessage(ChatColor.YELLOW
+ "Usage: /vigilante <invite <player>|accept|dismiss <player>|leave>"); + "Usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>");
return true; return true;
} }
if (result.status() == FollowerStatus.JOINED if (result.status() == FollowerStatus.JOINED
@@ -0,0 +1,19 @@
package games.dmg.spigottyrant;
import java.util.UUID;
import org.bukkit.entity.Player;
@FunctionalInterface
public interface VigilanteControlPanel {
void open(Player player);
default void beginSession(UUID playerId) {
}
default void endSession(UUID playerId) {
}
default boolean openedThisSession(UUID playerId) {
return false;
}
}
@@ -0,0 +1,48 @@
package games.dmg.spigottyrant;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
public final class VigilanteControlPanelJoinListener implements Listener {
private static final long OPEN_DELAY_TICKS = 100L;
private final TyrantStateManager stateManager;
private final VigilanteControlPanel controlPanel;
private final DelayedTaskScheduler scheduler;
public VigilanteControlPanelJoinListener(
TyrantStateManager stateManager,
VigilanteControlPanel controlPanel,
DelayedTaskScheduler scheduler
) {
this.stateManager = stateManager;
this.controlPanel = controlPanel;
this.scheduler = scheduler;
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
UUID playerId = player.getUniqueId();
controlPanel.beginSession(playerId);
scheduler.schedule(() -> openIfStillVigilante(player), OPEN_DELAY_TICKS);
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
controlPanel.endSession(event.getPlayer().getUniqueId());
}
private void openIfStillVigilante(Player player) {
GameState game = stateManager.game();
if (player.isOnline()
&& game.lifecycle() == GameLifecycle.RUNNING
&& game.vigilanteId().filter(player.getUniqueId()::equals).isPresent()
&& !controlPanel.openedThisSession(player.getUniqueId())) {
controlPanel.open(player);
}
}
}
@@ -0,0 +1,59 @@
package games.dmg.spigottyrant;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
public record VigilanteControlPanelModel(
List<VigilanteFollowerView> followers,
Set<String> pendingInvitations,
int nearbyEligibleFollowers,
int strengthLevel,
int resistanceLevel,
int strengthCap,
int resistanceCap
) {
public VigilanteControlPanelModel {
followers = List.copyOf(followers);
pendingInvitations = Set.copyOf(pendingInvitations);
}
public static VigilanteControlPanelModel create(
GameState game,
Map<UUID, PlayerState> players,
Set<UUID> pendingInvitationIds,
Map<UUID, FollowerPresence> presence,
int strengthCap,
int resistanceCap
) {
UUID vigilanteId = game.vigilanteId().orElseThrow();
List<VigilanteFollowerView> followers = players.values().stream()
.filter(player -> player.followerOf().filter(vigilanteId::equals).isPresent())
.map(player -> new VigilanteFollowerView(
player.playerId(), player.latestName(), player.optedOutUntil().isPresent(),
presence.getOrDefault(
player.playerId(), new FollowerPresence(false, false, false, false)
)
))
.sorted(Comparator.comparing(VigilanteFollowerView::name))
.toList();
Set<String> pending = pendingInvitationIds.stream()
.map(players::get)
.filter(java.util.Objects::nonNull)
.map(PlayerState::latestName)
.collect(Collectors.toUnmodifiableSet());
int nearby = (int) followers.stream()
.filter(VigilanteFollowerView::contributes)
.count();
VigilantePower.Levels levels = VigilantePower.forFollowers(
nearby, strengthCap, resistanceCap
);
return new VigilanteControlPanelModel(
followers, pending, nearby, levels.strength(), levels.resistance(),
strengthCap, resistanceCap
);
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
import org.bukkit.entity.Player;
@FunctionalInterface
public interface VigilanteControlPanelRenderer {
void openOverview(Player player, VigilanteControlPanelModel model);
}
@@ -0,0 +1,14 @@
package games.dmg.spigottyrant;
import java.util.UUID;
public record VigilanteFollowerView(
UUID playerId,
String name,
boolean optedOut,
FollowerPresence presence
) {
public boolean contributes() {
return !optedOut && presence.contributes();
}
}
@@ -0,0 +1,96 @@
package games.dmg.spigottyrant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
public final class VigilanteTabCompleter implements TabCompleter {
private static final List<String> SUBCOMMANDS = List.of(
"menu", "item", "invite", "accept", "dismiss", "leave"
);
private final TyrantStateManager stateManager;
private final FollowerService followers;
private final OnlinePlayerDirectory onlinePlayers;
public VigilanteTabCompleter(
TyrantStateManager stateManager,
FollowerService followers,
OnlinePlayerDirectory onlinePlayers
) {
this.stateManager = stateManager;
this.followers = followers;
this.onlinePlayers = onlinePlayers;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (!(sender instanceof Player player) || arguments.length == 0) {
return List.of();
}
if (arguments.length == 1) {
return TabSuggestions.matching(arguments[0], SUBCOMMANDS);
}
if (arguments.length != 2) {
return List.of();
}
return switch (arguments[0].toLowerCase(java.util.Locale.ROOT)) {
case "invite" -> TabSuggestions.matching(
arguments[1], eligibleInvitees(player)
);
case "dismiss" -> TabSuggestions.matching(
arguments[1], currentFollowers(player)
);
default -> List.of();
};
}
private List<String> eligibleInvitees(Player vigilante) {
GameState game = stateManager.game();
Map<UUID, PlayerState> states = stateManager.players();
Set<UUID> pending = followers.invitedPlayerIds(vigilante.getUniqueId());
List<String> names = new ArrayList<>();
for (UUID playerId : onlinePlayers.onlinePlayerIds()) {
PlayerState state = states.get(playerId);
boolean assignedRole = game.tyrantId().filter(playerId::equals).isPresent()
|| game.vigilanteId().filter(playerId::equals).isPresent();
boolean ineligibleState = state != null
&& (state.optedOutUntil().isPresent() || state.followerOf().isPresent());
if (assignedRole || ineligibleState || pending.contains(playerId)) {
continue;
}
Player candidate = onlinePlayers.findById(playerId);
if (candidate != null) {
names.add(candidate.getName());
}
}
return List.copyOf(names);
}
private List<String> currentFollowers(Player vigilante) {
Map<UUID, PlayerState> states = stateManager.players();
List<String> names = new ArrayList<>();
for (UUID playerId : onlinePlayers.onlinePlayerIds()) {
PlayerState state = states.get(playerId);
if (state == null
|| state.followerOf().filter(vigilante.getUniqueId()::equals).isEmpty()) {
continue;
}
Player candidate = onlinePlayers.findById(playerId);
if (candidate != null) {
names.add(candidate.getName());
}
}
return List.copyOf(names);
}
}
+11
View File
@@ -35,6 +35,9 @@ fixer-item:
tamer-item: tamer-item:
material: FISHING_ROD material: FISHING_ROD
name: Tamer's Lead name: Tamer's Lead
captured-mob-item:
material: PAPER
name: Captured Mob
denied-mob-types: denied-mob-types:
- ENDER_DRAGON - ENDER_DRAGON
- WITHER - WITHER
@@ -47,6 +50,14 @@ tyrant-resistance-level: 1
follower-strength-cap: 5 follower-strength-cap: 5
follower-resistance-cap: 4 follower-resistance-cap: 4
# Role control items
tyrant-control-item:
material: NETHER_STAR
name: Tyrant Control
vigilante-control-item:
material: COMPASS
name: Vigilante Control
# Bound item recovery and delivery messages # Bound item recovery and delivery messages
recovery-command: /tyrant item recovery-command: /tyrant item
inventory-full-message: Your inventory is full. Make room and use /tyrant item. inventory-full-message: Your inventory is full. Make room and use /tyrant item.
+2 -2
View File
@@ -7,10 +7,10 @@ author: dmg.games
commands: commands:
tyrant: tyrant:
description: View and use Spigot Tyrant game features. description: View and use Spigot Tyrant game features.
usage: /tyrant <status|choices|buy|assign|optout|optin|relinquish confirm> usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|relinquish confirm>
vigilante: vigilante:
description: Manage Vigilante Followers. description: Manage Vigilante Followers.
usage: /vigilante <invite <player>|accept|dismiss <player>|leave> usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
tyrantadmin: tyrantadmin:
description: Administer the Spigot Tyrant game. description: Administer the Spigot Tyrant game.
usage: /tyrantadmin <status|start|pause|resume|reset confirm> usage: /tyrantadmin <status|start|pause|resume|reset confirm>
@@ -0,0 +1,145 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class AssassinEffectControllerTest {
private static final Instant NOW = Instant.parse("2026-08-18T12:00:00Z");
private static final UUID PLAYER_ID = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
@TempDir
Path temporaryDirectory;
@Test
void doesNotRemovePotionEffectsItDidNotApply() throws Exception {
TyrantStateManager stateManager = manager();
Player player = player();
Server server = server(player);
AssassinEffectController.EffectApplier effects = mock(
AssassinEffectController.EffectApplier.class
);
AssassinEffectController controller = controller(stateManager, server, effects);
controller.run();
controller.clearAll();
verifyNoInteractions(effects);
}
@Test
void stopsRefreshingManagedInvisibilityAfterItsDeadline() throws Exception {
TyrantStateManager stateManager = manager();
stateManager.updateGame(ignored -> runningGame());
stateManager.updatePlayer(PLAYER_ID, "Assassin", ignored -> assassin(Map.of(
Ability.ASSASSIN_INVISIBILITY_ACTIVE, NOW.plusSeconds(60),
Ability.ASSASSIN_DOUBLE_JUMP, NOW.plusSeconds(60)
)));
Player player = player();
Server server = server(player);
AssassinEffectController.EffectApplier effects = mock(
AssassinEffectController.EffectApplier.class
);
AssassinEffectController controller = controller(stateManager, server, effects);
controller.run();
verify(effects).apply(
player,
AssassinEffectController.EffectKind.INVISIBILITY,
0,
false
);
stateManager.updatePlayer(PLAYER_ID, "Assassin", ignored -> assassin(Map.of(
Ability.ASSASSIN_DOUBLE_JUMP, NOW.plusSeconds(60)
)));
clearInvocations(player, effects);
controller.run();
verifyNoInteractions(effects);
}
private TyrantStateManager manager() throws Exception {
return new TyrantStateManager(
new YamlTyrantStateRepository(temporaryDirectory.resolve("state.yml")),
Logger.getLogger("test")
);
}
private static AssassinEffectController controller(
TyrantStateManager stateManager,
Server server,
AssassinEffectController.EffectApplier effects
) {
return new AssassinEffectController(
stateManager,
server,
PluginSettings.from(Map.of()),
Clock.fixed(NOW, ZoneOffset.UTC),
effects
);
}
private static Server server(Player player) {
Server server = mock(Server.class);
doReturn(List.of(player)).when(server).getOnlinePlayers();
return server;
}
private static Player player() {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(PLAYER_ID);
when(player.getName()).thenReturn("Assassin");
return player;
}
private static PlayerState assassin(Map<Ability, Instant> cooldowns) {
return new PlayerState(
PLAYER_ID,
"Assassin",
Optional.empty(),
Optional.empty(),
TyrantClass.ASSASSIN,
Optional.empty(),
cooldowns,
Set.of(),
List.of()
);
}
private static GameState runningGame() {
return new GameState(
GameLifecycle.RUNNING,
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
java.time.Duration.ZERO,
0,
0,
Set.of()
);
}
}
@@ -0,0 +1,61 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class CapturedMobServiceTest {
private final CapturedMobService service = new CapturedMobService();
@Test
void captureAddsSnapshotAndSuccessfulReleaseRemovesExactlyThatMob() {
UUID captureId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
PlayerState tamer = tamer();
PlayerState captured = service.capture(
tamer, captureId, "ZOMBIE", "{id:\"minecraft:zombie\"}"
);
CapturedMob mob = service.find(captured, captureId).orElseThrow();
PlayerState released = service.release(captured, captureId);
assertEquals("ZOMBIE", mob.entityType());
assertEquals("{id:\"minecraft:zombie\"}", mob.data().get("snapshot"));
assertEquals(java.util.List.of(), released.capturedMobs());
}
@Test
void tamerMustReleaseHeldMobBeforeCapturingAnother() {
PlayerState holding = service.capture(
tamer(), UUID.randomUUID(), "COW", "cow-snapshot"
);
IllegalStateException exception = assertThrows(IllegalStateException.class, () ->
service.capture(holding, UUID.randomUUID(), "SHEEP", "sheep-snapshot")
);
assertEquals("release the currently captured mob before capturing another",
exception.getMessage());
assertEquals(1, holding.capturedMobs().size());
}
@Test
void enderDragonCanNeverBeCapturedAndMissingReleaseDoesNotMutate() {
assertThrows(IllegalArgumentException.class, () -> service.capture(
tamer(), UUID.randomUUID(), "ENDER_DRAGON", "snapshot"
));
assertEquals(tamer(), service.release(tamer(), UUID.randomUUID()));
}
private static PlayerState tamer() {
return new PlayerState(
UUID.fromString("11111111-1111-1111-1111-111111111111"), "Tamer",
Optional.empty(), Optional.empty(), TyrantClass.TAMER, Optional.empty(),
Map.of(), Set.of(Ability.TAMER_CAPTURE), java.util.List.of()
);
}
}
@@ -0,0 +1,63 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class DefaultCapturedMobCustodyReleaseTest {
@Test
void legacyExcessReleaseRetainsOneAndAnyFailedExtra() {
UUID playerId = UUID.fromString("33333333-3333-3333-3333-333333333333");
CapturedMob retained = mob("COW");
CapturedMob releasedExtra = mob("SHEEP");
CapturedMob deferredExtra = mob("PIG");
PlayerState state = new PlayerState(
playerId, "Tamer", Optional.empty(), Optional.empty(), TyrantClass.TAMER,
Optional.empty(), Map.of(), java.util.Set.of(),
List.of(retained, releasedExtra, deferredExtra)
);
CapturedMobSpawner spawner = mock(CapturedMobSpawner.class);
Player player = mock(Player.class);
when(spawner.spawn(player, releasedExtra)).thenReturn(true);
when(spawner.spawn(player, deferredExtra)).thenReturn(false);
DefaultCapturedMobCustodyRelease service = new DefaultCapturedMobCustodyRelease(spawner);
PlayerState result = service.releaseExcess(player, state, 1);
assertEquals(List.of(retained, deferredExtra), result.capturedMobs());
}
@Test
void removesOnlyCustodyThatSpawnedSuccessfully() {
UUID playerId = UUID.fromString("33333333-3333-3333-3333-333333333333");
CapturedMob released = mob("COW");
CapturedMob deferred = mob("SHEEP");
PlayerState state = new PlayerState(
playerId, "Former", Optional.empty(), Optional.empty(), TyrantClass.NONE,
Optional.empty(), Map.of(), java.util.Set.of(), List.of(released, deferred)
);
CapturedMobSpawner spawner = mock(CapturedMobSpawner.class);
Player player = mock(Player.class);
when(spawner.spawn(player, released)).thenReturn(true);
when(spawner.spawn(player, deferred)).thenReturn(false);
DefaultCapturedMobCustodyRelease service = new DefaultCapturedMobCustodyRelease(spawner);
PlayerState result = service.releaseAll(player, state);
assertEquals(List.of(deferred), result.capturedMobs());
assertEquals(TyrantClass.NONE, result.tyrantClass());
}
private static CapturedMob mob(String type) {
return new CapturedMob(type, Map.of(
"capture-id", UUID.randomUUID().toString(), "snapshot", "snapshot-" + type
));
}
}
@@ -0,0 +1,72 @@
package games.dmg.spigottyrant;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class DefaultTyrantControlPanelTest {
@Test
void nonTyrantCannotRenderControlPanel() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID otherId = UUID.fromString("22222222-2222-2222-2222-222222222222");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
Player other = mock(Player.class);
when(other.getUniqueId()).thenReturn(otherId);
TyrantControlPanelRenderer renderer = mock(TyrantControlPanelRenderer.class);
DefaultTyrantControlPanel panel = new DefaultTyrantControlPanel(
manager, renderer,
Clock.fixed(Instant.parse("2026-08-21T12:00:00Z"), ZoneOffset.UTC)
);
panel.open(other);
verify(renderer, never()).openOverview(any(), any());
verify(other).sendMessage(org.mockito.ArgumentMatchers.contains("active Tyrant"));
}
@Test
void openingAsActiveTyrantRendersAuthoritativeOverview() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
2, 1, Set.of(TyrantUnlock.STRENGTH)
);
PlayerState tyrantState = PlayerState.newPlayer(tyrantId, "Tyrant");
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
when(manager.player(tyrantId, "Tyrant")).thenReturn(tyrantState);
when(manager.players()).thenReturn(Map.of(tyrantId, tyrantState));
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
when(tyrant.getName()).thenReturn("Tyrant");
TyrantControlPanelRenderer renderer = mock(TyrantControlPanelRenderer.class);
DefaultTyrantControlPanel panel = new DefaultTyrantControlPanel(
manager, renderer,
Clock.fixed(Instant.parse("2026-08-21T12:00:00Z"), ZoneOffset.UTC)
);
panel.open(tyrant);
verify(renderer).openOverview(org.mockito.ArgumentMatchers.eq(tyrant), any());
}
}
@@ -0,0 +1,67 @@
package games.dmg.spigottyrant;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class DefaultVigilanteControlPanelTest {
@Test
void activeVigilanteOpensAuthoritativeOverview() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222");
GameState game = game(tyrantId, vigilanteId);
PlayerState vigilanteState = PlayerState.newPlayer(vigilanteId, "Vigilante");
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
when(manager.players()).thenReturn(Map.of(vigilanteId, vigilanteState));
Player vigilante = mock(Player.class);
when(vigilante.getUniqueId()).thenReturn(vigilanteId);
VigilanteControlPanelRenderer renderer = mock(VigilanteControlPanelRenderer.class);
FollowerPresenceProvider presence = mock(FollowerPresenceProvider.class);
when(presence.inspect(any(), any())).thenReturn(Map.of());
DefaultVigilanteControlPanel panel = new DefaultVigilanteControlPanel(
manager, new FollowerService(), renderer, presence, 5, 4
);
panel.open(vigilante);
verify(renderer).openOverview(org.mockito.ArgumentMatchers.eq(vigilante), any());
}
@Test
void nonVigilanteCannotRenderControlPanel() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222");
Player other = mock(Player.class);
when(other.getUniqueId()).thenReturn(UUID.randomUUID());
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game(tyrantId, vigilanteId));
VigilanteControlPanelRenderer renderer = mock(VigilanteControlPanelRenderer.class);
DefaultVigilanteControlPanel panel = new DefaultVigilanteControlPanel(
manager, new FollowerService(), renderer, mock(FollowerPresenceProvider.class), 5, 4
);
panel.open(other);
verify(renderer, never()).openOverview(any(), any());
verify(other).sendMessage(org.mockito.ArgumentMatchers.contains("active Vigilante"));
}
private static GameState game(UUID tyrantId, UUID vigilanteId) {
return new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.of(vigilanteId),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
}
}
@@ -0,0 +1,37 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class FixerAbilityServiceTest {
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final FixerAbilityService service = new FixerAbilityService(
new AbilityReadinessService(), PluginSettings.from(Map.of())
);
@Test
void boostConsumesItemAndRecordsNormalOrNearTyrantHeartTier() {
PlayerState ready = new PlayerState(
UUID.fromString("11111111-1111-1111-1111-111111111111"), "Fixer",
Optional.empty(), Optional.empty(), TyrantClass.FIXER, Optional.empty(),
Map.of(), Set.of(Ability.FIXER_BOOST), java.util.List.of()
);
FixerUseResult normal = service.activate(ready, NOW, false);
FixerUseResult near = service.activate(ready, NOW, true);
assertEquals(AbilityUseStatus.ACTIVATED, normal.status());
assertEquals(2, normal.heartRows());
assertEquals(3, near.heartRows());
assertEquals(NOW.plusSeconds(600), normal.player().cooldownEnds()
.get(Ability.FIXER_BOOST_ACTIVE));
assertEquals(NOW.plusSeconds(3600), normal.player().cooldownEnds()
.get(Ability.FIXER_BOOST));
}
}
@@ -1,6 +1,7 @@
package games.dmg.spigottyrant; package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration; import java.time.Duration;
import java.util.Map; import java.util.Map;
@@ -34,6 +35,32 @@ final class FollowerServiceTest {
assertEquals(TyrantClass.FIXER, accepted.players().get(FOLLOWER).tyrantClass()); assertEquals(TyrantClass.FIXER, accepted.players().get(FOLLOWER).tyrantClass());
} }
@Test
void repeatedInvitationIsRejectedAndPendingInvitationCanBeInspected() {
GameState game = game();
PlayerState target = PlayerState.newPlayer(FOLLOWER, "Follower");
Map<UUID, PlayerState> players = Map.of(FOLLOWER, target);
FollowerResult first = service.invite(game, players, VIGILANTE, FOLLOWER);
FollowerResult repeated = service.invite(game, players, VIGILANTE, FOLLOWER);
assertEquals(FollowerStatus.INVITED, first.status());
assertEquals(FollowerStatus.ALREADY_INVITED, repeated.status());
assertTrue(service.invitedPlayerIds(VIGILANTE).contains(FOLLOWER));
}
@Test
void existingFollowerCannotReceiveAnotherInvitation() {
GameState game = game();
PlayerState follower = withFollower(PlayerState.newPlayer(FOLLOWER, "Follower"));
FollowerResult result = service.invite(
game, Map.of(FOLLOWER, follower), VIGILANTE, FOLLOWER
);
assertEquals(FollowerStatus.INELIGIBLE, result.status());
}
@Test @Test
void vigilanteCanDismissAndFollowerCanLeave() { void vigilanteCanDismissAndFollowerCanLeave() {
GameState game = game(); GameState game = game();
@@ -0,0 +1,102 @@
package games.dmg.spigottyrant;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class FormerTamerReleaseTaskTest {
@Test
void offlineFormerTamerCustodyRemainsDeferred() {
UUID formerId = UUID.fromString("33333333-3333-3333-3333-333333333333");
PlayerState former = withMob(PlayerState.newPlayer(formerId, "Former"));
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.players()).thenReturn(Map.of(formerId, former));
Server server = mock(Server.class);
doReturn(Set.of()).when(server).getOnlinePlayers();
CapturedMobCustodyRelease release = mock(CapturedMobCustodyRelease.class);
new FormerTamerReleaseTask(manager, server, release).run();
verify(release, never()).releaseAll(any(), any());
}
@Test
void safelyReleasesLegacyCustodyAboveOneForActiveTamer() {
UUID tamerId = UUID.fromString("44444444-4444-4444-4444-444444444444");
PlayerState once = withMob(withClass(
PlayerState.newPlayer(tamerId, "Tamer"), TyrantClass.TAMER
));
PlayerState legacy = withMob(once);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.players()).thenReturn(Map.of(tamerId, legacy));
Player tamer = mock(Player.class);
when(tamer.getUniqueId()).thenReturn(tamerId);
Server server = mock(Server.class);
doReturn(Set.of(tamer)).when(server).getOnlinePlayers();
CapturedMobCustodyRelease release = mock(CapturedMobCustodyRelease.class);
when(release.releaseExcess(tamer, legacy, 1)).thenReturn(legacy);
new FormerTamerReleaseTask(manager, server, release).run();
verify(release).releaseExcess(tamer, legacy, 1);
verify(release, never()).releaseAll(any(), any());
}
@Test
void releasesCustodyOnlyAfterPlayerLosesTamerClass() {
UUID formerId = UUID.fromString("33333333-3333-3333-3333-333333333333");
UUID tamerId = UUID.fromString("44444444-4444-4444-4444-444444444444");
PlayerState former = withMob(PlayerState.newPlayer(formerId, "Former"));
PlayerState tamer = withMob(withClass(
PlayerState.newPlayer(tamerId, "Tamer"), TyrantClass.TAMER
));
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.players()).thenReturn(Map.of(formerId, former, tamerId, tamer));
Player formerPlayer = mock(Player.class);
when(formerPlayer.getUniqueId()).thenReturn(formerId);
Player tamerPlayer = mock(Player.class);
when(tamerPlayer.getUniqueId()).thenReturn(tamerId);
Server server = mock(Server.class);
doReturn(Set.of(formerPlayer, tamerPlayer)).when(server).getOnlinePlayers();
CapturedMobCustodyRelease release = mock(CapturedMobCustodyRelease.class);
when(release.releaseAll(formerPlayer, former)).thenReturn(former);
FormerTamerReleaseTask task = new FormerTamerReleaseTask(manager, server, release);
task.run();
verify(release).releaseAll(formerPlayer, former);
verify(release, never()).releaseAll(tamerPlayer, tamer);
}
private static PlayerState withMob(PlayerState player) {
CapturedMob mob = new CapturedMob("COW", Map.of(
"capture-id", UUID.randomUUID().toString(), "snapshot", "snapshot"
));
java.util.List<CapturedMob> custody = new java.util.ArrayList<>(player.capturedMobs());
custody.add(mob);
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
player.tyrantClass(), player.followerOf(), player.cooldownEnds(),
player.readyAbilityItems(), custody
);
}
private static PlayerState withClass(PlayerState player, TyrantClass tyrantClass) {
return new PlayerState(
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
tyrantClass, player.followerOf(), player.cooldownEnds(),
player.readyAbilityItems(), player.capturedMobs()
);
}
}
@@ -0,0 +1,97 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.bukkit.scoreboard.Team;
import org.junit.jupiter.api.Test;
final class OptOutGlowControllerTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID OUT = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
@Test
void appliesGreenGlowAndRestoresPreviousTeamAfterLeavingRange() {
TyrantStateManager stateManager = mock(TyrantStateManager.class);
Server server = mock(Server.class);
PluginSettings settings = mock(PluginSettings.class);
ScoreboardManager manager = mock(ScoreboardManager.class);
Scoreboard scoreboard = mock(Scoreboard.class);
Team green = mock(Team.class);
Team previous = mock(Team.class);
Player tyrant = player(TYRANT, "Tyrant");
Player optedOut = player(OUT, "Out");
World world = mock(World.class);
Location tyrantLocation = mock(Location.class);
Location outLocation = mock(Location.class);
when(server.getScoreboardManager()).thenReturn(manager);
when(manager.getMainScoreboard()).thenReturn(scoreboard);
when(scoreboard.getTeam("tyrant-optout")).thenReturn(null);
when(scoreboard.registerNewTeam("tyrant-optout")).thenReturn(green);
when(scoreboard.getEntryTeam("Out")).thenReturn(previous);
when(server.getPlayer(TYRANT)).thenReturn(tyrant);
doReturn(Set.of(tyrant, optedOut)).when(server).getOnlinePlayers();
when(settings.tyrantRangeBlocks()).thenReturn(50.0);
when(tyrant.getWorld()).thenReturn(world);
when(optedOut.getWorld()).thenReturn(world);
when(tyrant.getLocation()).thenReturn(tyrantLocation);
when(optedOut.getLocation()).thenReturn(outLocation);
when(outLocation.distanceSquared(tyrantLocation)).thenReturn(25.0, 2601.0);
when(stateManager.game()).thenReturn(running());
when(stateManager.players()).thenReturn(Map.of(OUT, optedOut()));
OptOutGlowController controller = new OptOutGlowController(
stateManager, server, settings
);
controller.run();
controller.run();
verify(green).setColor(ChatColor.GREEN);
verify(green).addEntry("Out");
verify(optedOut).setGlowing(true);
verify(green).removeEntry("Out");
verify(previous).addEntry("Out");
verify(optedOut).setGlowing(false);
}
private static Player player(UUID id, String name) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn(name);
return player;
}
private static PlayerState optedOut() {
return new PlayerState(
OUT, "Out", Optional.empty(), Optional.of(Instant.MAX), TyrantClass.NONE,
Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static GameState running() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
}
}
@@ -0,0 +1,76 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class OptOutGlowPolicyTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID PLAYER = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
@Test
void optedOutPlayerGlowsWithinInclusiveRunningRange() {
assertTrue(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), true, true, 2500.0, 50.0
));
}
@Test
void requiresRunningEventAvailablePlayersSameWorldAndRange() {
assertFalse(OptOutGlowPolicy.shouldGlow(
GameState.empty(), optedOut(), true, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
paused(), optedOut(), true, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedIn(), true, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), false, true, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), true, false, 1.0, 50.0
));
assertFalse(OptOutGlowPolicy.shouldGlow(
running(), optedOut(), true, true, 2500.01, 50.0
));
}
private static PlayerState optedOut() {
return new PlayerState(
PLAYER, "Player", Optional.empty(), Optional.of(Instant.MAX),
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static PlayerState optedIn() {
return PlayerState.newPlayer(PLAYER, "Player");
}
private static GameState running() {
return game(GameLifecycle.RUNNING, Optional.empty());
}
private static GameState paused() {
return game(GameLifecycle.PAUSED, Optional.of(Instant.EPOCH));
}
private static GameState game(GameLifecycle lifecycle, Optional<Instant> pausedAt) {
return new GameState(
lifecycle, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.empty(), pausedAt, Duration.ZERO, 0, 0, Set.of()
);
}
}
@@ -0,0 +1,75 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.junit.jupiter.api.Test;
final class ParticipationCombatListenerTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID OUT = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
@Test
void cancelsPlayerProjectileAndMessagesTheAttacker() {
TyrantStateManager manager = mock(TyrantStateManager.class);
Player attacker = player(TYRANT, "Tyrant");
Player target = player(OUT, "Out");
Projectile projectile = mock(Projectile.class);
EntityDamageByEntityEvent event = mock(EntityDamageByEntityEvent.class);
when(projectile.getShooter()).thenReturn(attacker);
when(event.getDamager()).thenReturn(projectile);
when(event.getEntity()).thenReturn(target);
when(manager.game()).thenReturn(running());
when(manager.players()).thenReturn(Map.of(
TYRANT, state(TYRANT, TyrantClass.NONE, false),
OUT, state(OUT, TyrantClass.NONE, true)
));
ParticipationCombatListener listener = new ParticipationCombatListener(
manager, new ParticipationCombatService()
);
listener.onDamage(event);
verify(event).setCancelled(true);
verify(attacker).sendMessage(ChatColor.YELLOW
+ ParticipationCombatListener.OPTED_OUT_MESSAGE);
}
private static Player player(UUID id, String name) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn(name);
return player;
}
private static PlayerState state(UUID id, TyrantClass role, boolean optedOut) {
return new PlayerState(
id, id.toString(), Optional.empty(),
optedOut ? Optional.of(Instant.parse("2026-08-21T00:00:00Z")) : Optional.empty(),
role, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static GameState running() {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
}
}
@@ -0,0 +1,90 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class ParticipationCombatServiceTest {
private static final UUID TYRANT = UUID.fromString(
"11111111-1111-1111-1111-111111111111"
);
private static final UUID OUTSIDER = UUID.fromString(
"22222222-2222-2222-2222-222222222222"
);
private static final UUID SUPPORTER = UUID.fromString(
"33333333-3333-3333-3333-333333333333"
);
private final ParticipationCombatService service = new ParticipationCombatService();
@Test
void blocksDamageInBothDirectionsBetweenOptedOutPlayerAndTyrantSide() {
PlayerState optedOut = player(OUTSIDER, TyrantClass.NONE, true);
PlayerState tyrant = player(TYRANT, TyrantClass.NONE, false);
PlayerState supporter = player(SUPPORTER, TyrantClass.ASSASSIN, false);
assertTrue(service.blocks(running(), tyrant, optedOut));
assertTrue(service.blocks(running(), optedOut, tyrant));
assertTrue(service.blocks(running(), supporter, optedOut));
assertTrue(service.blocks(running(), optedOut, supporter));
}
@Test
void recognizesEveryTyrantSideClassAsSupport() {
PlayerState optedOut = player(OUTSIDER, TyrantClass.NONE, true);
assertTrue(service.blocks(
running(), player(SUPPORTER, TyrantClass.ASSASSIN, false), optedOut
));
assertTrue(service.blocks(
running(), player(SUPPORTER, TyrantClass.FIXER, false), optedOut
));
assertTrue(service.blocks(
running(), player(SUPPORTER, TyrantClass.TAMER, false), optedOut
));
}
@Test
void leavesPausedUnstartedAndUnrelatedCombatAlone() {
PlayerState optedOut = player(OUTSIDER, TyrantClass.NONE, true);
PlayerState tyrant = player(TYRANT, TyrantClass.NONE, false);
PlayerState ordinary = player(SUPPORTER, TyrantClass.NONE, false);
assertFalse(service.blocks(paused(), tyrant, optedOut));
assertFalse(service.blocks(GameState.empty(), tyrant, optedOut));
assertFalse(service.blocks(running(), tyrant, ordinary));
assertFalse(service.blocks(running(), ordinary, optedOut));
}
private static PlayerState player(UUID id, TyrantClass role, boolean optedOut) {
return new PlayerState(
id, id.toString(), Optional.empty(),
optedOut ? Optional.of(Instant.parse("2026-08-21T00:00:00Z")) : Optional.empty(),
role, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
);
}
private static GameState running() {
return game(GameLifecycle.RUNNING, Optional.empty());
}
private static GameState paused() {
return game(
GameLifecycle.PAUSED,
Optional.of(Instant.parse("2026-08-14T00:00:00Z"))
);
}
private static GameState game(GameLifecycle lifecycle, Optional<Instant> pausedAt) {
return new GameState(
lifecycle, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
Optional.empty(), pausedAt, Duration.ZERO, 0, 0, Set.of()
);
}
}
@@ -1,5 +1,6 @@
package games.dmg.spigottyrant; package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration; import java.time.Duration;
@@ -29,7 +30,8 @@ final class PlayerStatusMessagesTest {
List<String> messages = PlayerStatusMessages.forJoin(game, player, NOW); List<String> messages = PlayerStatusMessages.forJoin(game, player, NOW);
assertTrue(messages.stream().anyMatch(message -> message.contains("Game: RUNNING"))); assertFalse(messages.stream().anyMatch(message -> message.startsWith("Game:")));
assertFalse(messages.stream().anyMatch(message -> message.startsWith("Tyrant event:")));
assertTrue(messages.stream().anyMatch(message -> message.contains("Role: VIGILANTE"))); assertTrue(messages.stream().anyMatch(message -> message.contains("Role: VIGILANTE")));
assertTrue(messages.stream().anyMatch(message -> message.contains("Class: ASSASSIN"))); assertTrue(messages.stream().anyMatch(message -> message.contains("Class: ASSASSIN")));
assertTrue(messages.stream().anyMatch(message -> message.contains("ASSASSIN_INVISIBILITY") assertTrue(messages.stream().anyMatch(message -> message.contains("ASSASSIN_INVISIBILITY")
@@ -42,6 +44,8 @@ final class PlayerStatusMessagesTest {
GameState.empty(), PlayerState.newPlayer(PLAYER, "Player"), NOW GameState.empty(), PlayerState.newPlayer(PLAYER, "Player"), NOW
); );
assertTrue(messages.stream().anyMatch(message -> message.contains("/tyrant optout"))); assertTrue(messages.contains(
"Use /tyrant optout if you do not want to participate in Tyrant events."
));
} }
} }
@@ -43,6 +43,10 @@ final class PluginSettingsTest {
assertEquals("STICK", settings.assassinItem().material()); assertEquals("STICK", settings.assassinItem().material());
assertEquals("Assassin Cloak", settings.assassinItem().name()); assertEquals("Assassin Cloak", settings.assassinItem().name());
assertEquals("FISHING_ROD", settings.tamerItem().material()); assertEquals("FISHING_ROD", settings.tamerItem().material());
assertEquals("NETHER_STAR", settings.tyrantControlItem().material());
assertEquals("Tyrant Control", settings.tyrantControlItem().name());
assertEquals("COMPASS", settings.vigilanteControlItem().material());
assertEquals("Vigilante Control", settings.vigilanteControlItem().name());
assertEquals("/tyrant item", settings.recoveryCommand()); assertEquals("/tyrant item", settings.recoveryCommand());
assertEquals("Your inventory is full. Make room and use /tyrant item.", assertEquals("Your inventory is full. Make room and use /tyrant item.",
settings.inventoryFullMessage()); settings.inventoryFullMessage());
@@ -0,0 +1,89 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.entity.Item;
import org.bukkit.entity.Player;
import org.bukkit.event.block.Action;
import org.bukkit.event.inventory.InventoryMoveItemEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.Test;
final class RoleControlItemListenerTest {
@Test
void droppedRoleControlItemVanishes() {
ItemStack item = mock(ItemStack.class);
Item dropped = mock(Item.class);
when(dropped.getItemStack()).thenReturn(item);
PlayerDropItemEvent event = mock(PlayerDropItemEvent.class);
when(event.getItemDrop()).thenReturn(dropped);
RoleControlItemService items = mock(RoleControlItemService.class);
when(items.isBoundRoleControlItem(item)).thenReturn(true);
RoleControlItemListener listener = listener(items);
listener.onDrop(event);
verify(dropped).remove();
}
@Test
void automatedInventoryTransferIsBlocked() {
ItemStack item = mock(ItemStack.class);
InventoryMoveItemEvent event = mock(InventoryMoveItemEvent.class);
when(event.getItem()).thenReturn(item);
RoleControlItemService items = mock(RoleControlItemService.class);
when(items.isBoundRoleControlItem(item)).thenReturn(true);
RoleControlItemListener listener = listener(items);
listener.onInventoryMove(event);
verify(event).setCancelled(true);
}
@Test
void currentTyrantUsesBoundItemToOpenControlPanel() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
ItemStack item = mock(ItemStack.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
when(event.getPlayer()).thenReturn(tyrant);
when(event.getItem()).thenReturn(item);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
RoleControlItemService items = mock(RoleControlItemService.class);
when(items.isBoundRoleControlItem(item)).thenReturn(true);
when(items.owner(item)).thenReturn(Optional.of(tyrantId));
when(items.role(item)).thenReturn(Optional.of(RoleControl.TYRANT));
TyrantControlPanel tyrantPanel = mock(TyrantControlPanel.class);
RoleControlItemListener listener = new RoleControlItemListener(
manager, items, tyrantPanel, mock(VigilanteControlPanel.class)
);
listener.onInteract(event);
verify(event).setCancelled(true);
verify(tyrantPanel).open(tyrant);
}
private static RoleControlItemListener listener(RoleControlItemService items) {
return new RoleControlItemListener(
mock(TyrantStateManager.class), items, mock(TyrantControlPanel.class),
mock(VigilanteControlPanel.class)
);
}
}
@@ -0,0 +1,41 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class RoleControlItemPolicyTest {
@Test
void runningRoleHoldersRequireOnlyTheirOwnControlItem() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.of(vigilanteId),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
assertEquals(Optional.of(RoleControl.TYRANT),
RoleControlItemPolicy.requiredRole(game, tyrantId));
assertEquals(Optional.of(RoleControl.VIGILANTE),
RoleControlItemPolicy.requiredRole(game, vigilanteId));
assertEquals(Optional.empty(), RoleControlItemPolicy.requiredRole(game, UUID.randomUUID()));
}
@Test
void pausedRoleHolderRetainsControlItem() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.PAUSED, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.of(java.time.Instant.EPOCH),
Duration.ZERO, 0, 0, Set.of()
);
assertEquals(Optional.of(RoleControl.TYRANT),
RoleControlItemPolicy.requiredRole(game, tyrantId));
}
}
@@ -0,0 +1,37 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class RoleControlItemRefreshTaskTest {
@Test
void reconciliationRemovesInvalidCopiesAndRecoversRequiredRoleItem() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
Player tyrant = mock(Player.class);
Server server = mock(Server.class);
doReturn(Set.of(tyrant)).when(server).getOnlinePlayers();
RoleControlItemService items = mock(RoleControlItemService.class);
new RoleControlItemRefreshTask(manager, items, server).run();
verify(items).removeInvalid(tyrant, game);
verify(items).recover(tyrant, game);
}
}
@@ -0,0 +1,66 @@
package games.dmg.spigottyrant;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class TamerListenerTest {
@Test
void heldMobRejectsAnotherCaptureBeforeTargetOrInventoryChanges() {
UUID playerId = UUID.fromString("33333333-3333-3333-3333-333333333333");
CapturedMob heldMob = new CapturedMob("COW", Map.of(
"capture-id", UUID.randomUUID().toString(), "snapshot", "cow"
));
PlayerState state = new PlayerState(
playerId, "Tamer", Optional.empty(), Optional.empty(), TyrantClass.TAMER,
Optional.empty(), Map.of(), Set.of(Ability.TAMER_CAPTURE),
java.util.List.of(heldMob)
);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Tamer");
PlayerInventory inventory = mock(PlayerInventory.class);
when(player.getInventory()).thenReturn(inventory);
ItemStack lead = mock(ItemStack.class);
when(inventory.getItem(org.bukkit.inventory.EquipmentSlot.HAND)).thenReturn(lead);
Entity target = mock(Entity.class);
PlayerInteractEntityEvent event = mock(PlayerInteractEntityEvent.class);
when(event.getPlayer()).thenReturn(player);
when(event.getHand()).thenReturn(org.bukkit.inventory.EquipmentSlot.HAND);
when(event.getRightClicked()).thenReturn(target);
AbilityItemService abilityItems = mock(AbilityItemService.class);
when(abilityItems.ability(lead)).thenReturn(Optional.of(Ability.TAMER_CAPTURE));
when(abilityItems.owner(lead)).thenReturn(Optional.of(playerId));
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.player(playerId, "Tamer")).thenReturn(state);
CapturedMobItemService capturedItems = mock(CapturedMobItemService.class);
TamerListener listener = new TamerListener(
manager, abilityItems, capturedItems, new CapturedMobService(),
PluginSettings.from(Map.of()), mock(Server.class)
);
listener.onCapture(event);
verify(player).sendMessage(org.mockito.ArgumentMatchers.contains(
"Release the currently captured mob"
));
verify(target, never()).createSnapshot();
verify(capturedItems, never()).create(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.anyString()
);
}
}
@@ -0,0 +1,49 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class TyrantAbilityServiceTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID FOLLOWER = UUID.fromString("33333333-3333-3333-3333-333333333333");
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
private final TyrantAbilityService service = new TyrantAbilityService(
Duration.ofHours(24)
);
@Test
void intelligenceCountsVigilanteSideWithoutIdentitiesAndStartsCooldown() {
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(VIGILANTE),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
1, 0, Set.of(TyrantUnlock.ROSTER_INTELLIGENCE)
);
PlayerState tyrant = PlayerState.newPlayer(TYRANT, "Tyrant");
PlayerState follower = new PlayerState(
FOLLOWER, "Follower", Optional.empty(), Optional.empty(), TyrantClass.NONE,
Optional.of(VIGILANTE), Map.of(), Set.of(), java.util.List.of()
);
IntelligenceResult used = service.useIntelligence(
game, tyrant, Map.of(TYRANT, tyrant, FOLLOWER, follower), NOW
);
IntelligenceResult blocked = service.useIntelligence(
game, used.tyrant(), Map.of(TYRANT, used.tyrant(), FOLLOWER, follower),
NOW.plusSeconds(1)
);
assertEquals(AbilityUseStatus.ACTIVATED, used.status());
assertEquals(2, used.memberCount());
assertEquals(NOW.plus(Duration.ofHours(24)), used.tyrant().cooldownEnds()
.get(Ability.ROSTER_INTELLIGENCE));
assertEquals(AbilityUseStatus.COOLDOWN, blocked.status());
}
}
@@ -0,0 +1,55 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Set;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class TyrantAdminTabCompleterTest {
@Test
void permissionGatesAdministrativeSyntaxAndOnlineCandidates() {
UUID alphaId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID betaId = UUID.fromString("22222222-2222-2222-2222-222222222222");
Player alpha = player(alphaId, "Alpha");
Player beta = player(betaId, "Beta");
OnlinePlayerDirectory directory = mock(OnlinePlayerDirectory.class);
when(directory.onlinePlayerIds()).thenReturn(Set.of(alphaId, betaId));
when(directory.findById(alphaId)).thenReturn(alpha);
when(directory.findById(betaId)).thenReturn(beta);
TyrantAdminTabCompleter completer = new TyrantAdminTabCompleter(directory);
Command command = mock(Command.class);
CommandSender denied = mock(CommandSender.class);
CommandSender admin = mock(CommandSender.class);
when(admin.hasPermission("spigottyrant.admin")).thenReturn(true);
assertEquals(java.util.List.of(), completer.onTabComplete(
denied, command, "tyrantadmin", new String[] {""}
));
assertTrue(completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {""}
).containsAll(java.util.List.of("status", "start", "pause", "resume", "reset")));
assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"start", ""}
));
assertEquals(java.util.List.of("Beta"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"start", "Alpha", ""}
));
assertEquals(java.util.List.of("confirm"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"reset", "c"}
));
}
private static Player player(UUID id, String name) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn(name);
return player;
}
}
@@ -20,6 +20,51 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
final class TyrantCommandTest { final class TyrantCommandTest {
@Test
void activeTyrantCanOpenControlPanelWithNoArgumentsOrMenuSubcommand() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 1, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
TyrantControlPanel controlPanel = mock(TyrantControlPanel.class);
TyrantCommand command = new TyrantCommand(
manager, new TyrantProgressionService(), controlPanel
);
command.onCommand(tyrant, mock(Command.class), "tyrant", new String[0]);
command.onCommand(tyrant, mock(Command.class), "tyrant", new String[] {"menu"});
verify(controlPanel, org.mockito.Mockito.times(2)).open(tyrant);
}
@Test
void itemCommandRecoversTyrantRoleControlItem() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(game);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
RoleControlItemService roleItems = mock(RoleControlItemService.class);
TyrantCommand command = new TyrantCommand(
manager, new TyrantProgressionService(), mock(TyrantControlPanel.class), roleItems
);
command.onCommand(tyrant, mock(Command.class), "tyrant", new String[] {"item"});
verify(roleItems).recover(tyrant, game);
}
@Test @Test
void choicesClearlyIdentifyPurchasedAvailableAndUnavailableUnlocks() { void choicesClearlyIdentifyPurchasedAvailableAndUnavailableUnlocks() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");

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