Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d65576010 | ||
|
|
323d177573 | ||
|
|
ea267ff867 | ||
|
|
24c54a7281 | ||
|
|
fe07c202f9 | ||
|
|
777cbc6b4b | ||
|
|
cd38e87487 | ||
|
|
a82d20c7e6 | ||
|
|
1642d46f1b | ||
|
|
0371337a3e | ||
|
|
96c0c0014c | ||
|
|
a195658be4 | ||
|
|
ddd256802b | ||
|
|
58e6dec724 | ||
|
|
63dc5ff736 |
+164
@@ -6,6 +6,170 @@ description: Chronological record of material decisions affecting the Spigot Tyr
|
||||
|
||||
# Spigot Tyrant Design Log
|
||||
|
||||
## 2026-08-18 — Preserve ordinary potion effects
|
||||
|
||||
- Corrected Assassin effect maintenance so it refreshes active Tyrant abilities without removing invisibility, Speed, or Weakness supplied by potions, commands, or other plugins.
|
||||
- Expired or inapplicable Tyrant-managed effects stop refreshing and expire naturally within their short 30-tick maintenance duration, while double-jump flight cleanup remains immediate.
|
||||
- Reopened and completed US-005 with regression coverage; verified `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-14 — Opt-out proximity glow completed
|
||||
|
||||
- Extended US-011 with a globally visible green outline for opted-out players in the Tyrant's world and configured range while the event is running.
|
||||
- A periodic controller removes the glow when eligibility, proximity, world, lifecycle, or player availability changes and clears managed state on logout and shutdown.
|
||||
- Scoreboard presentation records and restores each player's previous team and glowing state instead of permanently replacing unrelated presentation.
|
||||
- Verified inclusive range behavior, lifecycle and availability gating, green presentation, restoration, and the full Gradle build.
|
||||
|
||||
## 2026-08-14 — Opt-out proximity glow started
|
||||
|
||||
- US-011 is reopened for a test-first green outline on opted-out players near the Tyrant during a running event, using the existing configurable Tyrant range.
|
||||
- Cleanup will cover range, world, participation, lifecycle, availability, shutdown, and restoration of pre-existing scoreboard-team membership.
|
||||
|
||||
## 2026-08-14 — Join messaging refinement completed
|
||||
|
||||
- Updated US-011 so join output no longer announces running, paused, or unstarted lifecycle state.
|
||||
- Eligible unassigned players now see the neutral prompt, `Use /tyrant optout if you do not want to participate in Tyrant events.`
|
||||
- Role, class, ability, cooldown, and existing opt-out details remain unchanged.
|
||||
- Verified both assigned and unassigned join output with automated tests.
|
||||
|
||||
## 2026-08-14 — Join messaging refinement started
|
||||
|
||||
- US-011 is reopened to remove lifecycle announcements from join output and make the opt-out prompt refer generally to Tyrant events.
|
||||
|
||||
## 2026-08-14 — Opt-out combat protection completed
|
||||
|
||||
- Extended US-011 with bidirectional combat isolation between opted-out players and the Tyrant, Assassin, Fixer, or Tamer while the event is running.
|
||||
- Direct player attacks and player-fired projectiles are cancelled at highest event priority, and the attacker is told that the target has chosen not to participate in the Tyrant event.
|
||||
- Paused, unstarted, and unrelated combat remains ordinary Minecraft gameplay.
|
||||
- Verified both attack directions, every Tyrant-side class, projectile attribution, lifecycle gating, and unrelated combat with automated tests.
|
||||
|
||||
## 2026-08-14 — Opt-out combat protection started
|
||||
|
||||
- US-011 is reopened for test-first running-event combat isolation between opted-out players and the Tyrant or Tyrant-side class holders.
|
||||
- Paused and unstarted gameplay remains ordinary Minecraft combat; blocked direct and projectile attackers receive a concise Tyrant event participation message.
|
||||
|
||||
## 2026-08-14 — Tamer completed
|
||||
|
||||
- Completed US-007 with the no-cooldown owner-bound Tamer's Lead, server-generated entity snapshots, durable capture IDs, and one bound Captured Mob item per entity.
|
||||
- Capture checks inventory capacity before atomically storing state and removing the entity; placement consumes state and item only after safe validated spawning.
|
||||
- Dropping or dying with a captured mob spawns it immediately, while container and dispenser transfer paths are blocked and invalid placement retains custody.
|
||||
- Ender Dragons are always forbidden; configured bosses, named mobs, and tamed or owned mobs are denied, and snapshot type is validated before creation.
|
||||
- Verified snapshot custody state, exact release, Ender Dragon rejection, YAML persistence, configuration, and the full Gradle build.
|
||||
|
||||
## 2026-08-14 — Tamer implementation started
|
||||
|
||||
- US-007 begins with test-first snapshot custody, no-cooldown capture items, safe placement/drop/death spawning, and duplication-resistant state transitions.
|
||||
|
||||
## 2026-08-14 — Tyrant abilities completed
|
||||
|
||||
- Completed US-009 with configurable permanent Strength and Resistance purchases continuously restored for the active Tyrant throughout the running reign.
|
||||
- Added `/tyrant intelligence` with a rolling configurable 24-hour unpaused cooldown and an aggregate count of the Vigilante plus assigned non-opted-out Followers without identities or locations.
|
||||
- Ability ownership and cooldowns remain visible in status output, persist across restart, and are cleared by existing reign succession cleanup.
|
||||
- Verified intelligence authorization, counts, cooldown persistence, effect configuration, and the full Gradle build.
|
||||
|
||||
## 2026-08-14 — Tyrant ability implementation started
|
||||
|
||||
- US-009 begins with test-first permanent effect ownership, automatic reapplication, and rolling-24-hour opposition roster intelligence.
|
||||
|
||||
## 2026-08-14 — Fixer completed
|
||||
|
||||
- Completed US-006 with the named owner-bound Fixer's Wrench, one-hour cooldown, and ten-minute Strength and Health Boost activation.
|
||||
- Activation records a fixed normal two-row or near-Tyrant three-row heart tier using same-world configurable proximity at use time.
|
||||
- Continuously refreshed effects survive death, login, milk, and ordinary removal; expiry and class loss remove effects and clamp health safely to the resulting maximum.
|
||||
- Verified activation tiers, active and cooldown deadlines, item consumption/readiness, persistence, and the full Gradle build.
|
||||
|
||||
## 2026-08-14 — Fixer implementation started
|
||||
|
||||
- US-006 begins with test-first bound-item combat boosts, activation-time proximity tiers, persistent cooldowns, and safe health cleanup.
|
||||
|
||||
## 2026-08-14 — Assassin completed
|
||||
|
||||
- Completed US-005 with named owner-bound Assassin Cloak activation, one-hour cooldown, particle-free ten-minute invisibility, and activation-time doubling within the Tyrant's configurable range.
|
||||
- Added 60-second double-jump gating, upward launch, 15-second Speed, 20-second Weakness III, and grounded readiness restoration.
|
||||
- Active effects and cooldowns persist as UTC deadlines and are continuously restored after death, login, milk, or ordinary removal while valid.
|
||||
- Verified duration tiers, cooldown consumption and refresh, jump effect deadlines, class constraints, persistence, and the full Gradle build.
|
||||
|
||||
## 2026-08-14 — Assassin implementation started
|
||||
|
||||
- US-005 begins with test-first bound-item invisibility, near-Tyrant duration doubling, and cooldown-gated double-jump effects.
|
||||
|
||||
## 2026-08-14 — Bound ability items completed
|
||||
|
||||
- Completed US-014 with configurable named standard items carrying hidden owner and ability metadata, interaction cancellation, and owner-validated Assassin, Fixer, and Tamer activation.
|
||||
- Cooldown items are consumed only on successful use, restored automatically when ready or by `/tyrant item`, deduplicated, and withheld with clear guidance when inventory space is unavailable.
|
||||
- Drop, pickup, placement, crafting/storage movement, item-frame, dispenser, death, class loss, reign reset, login, and restart paths prevent transfer and duplication.
|
||||
- Captured-mob custody items use independent IDs, reconcile missing or duplicate inventory items against durable snapshots, and spawn rather than vanish on drop or death.
|
||||
- Verified readiness consumption and refresh, assignment cleanup, durable metadata state, captured custody, and the full Gradle build.
|
||||
|
||||
## 2026-08-14 — Bound ability item implementation started
|
||||
|
||||
- US-014 begins with test-first item readiness and recovery, owner metadata, transfer prevention, cooldown redelivery, and Tamer item permanence.
|
||||
|
||||
## 2026-08-14 — Vigilante and Followers completed
|
||||
|
||||
- Completed US-008 with `/vigilante` invitation, acceptance, dismissal, and leave flows while preserving independent Tyrant-side class assignments.
|
||||
- Added Tyrant/Vigilante combat tracking and continuously refreshed Strength and Resistance based on living same-world Followers within the configurable 50-block range, capped at Strength V and Resistance IV by default.
|
||||
- Every Vigilante death now awards progression only to the Tyrant killer, clears Followers, and applies normal recently-active replacement with previous-holder fallback.
|
||||
- Verified membership, dual alignment, scaling and caps, role maintenance, command metadata, and death/progression components with the Gradle suite.
|
||||
|
||||
## 2026-08-14 — Vigilante and Follower implementation started
|
||||
|
||||
- US-008 begins with test-first invitation membership, death reroll and Tyrant reward integration, combat tracking, and proximity-scaled capped effects.
|
||||
|
||||
## 2026-08-14 — Participation completed
|
||||
|
||||
- Completed US-011 with `/tyrant status`, `optout`, `optin`, and confirmed `relinquish` flows plus comprehensive join messages for lifecycle, roles, classes, abilities, and cooldowns.
|
||||
- Opt-out clears ordinary classes and Follower membership, persists until the player explicitly opts back in, and enforces the configurable seven-day unpaused waiting period.
|
||||
- Central role holders must safely relinquish first; Tyrant relinquishment performs full succession and Vigilante relinquishment performs a normal Vigilante reroll.
|
||||
- Updated all assignment and candidate paths to exclude opted-out players and verified status, persistence, cleanup, cooldown, and relinquishment behavior with the Gradle suite.
|
||||
|
||||
## 2026-08-14 — Participation implementation started
|
||||
|
||||
- US-011 begins with test-first opt-out/opt-in state, seven-day re-entry gating, safe role relinquishment guidance, and complete join status messages.
|
||||
|
||||
## 2026-08-14 — Role maintenance completed
|
||||
|
||||
- Completed US-010 with configurable periodic role maintenance, exact 48-hour inactivity handling, and 24-hour recently-active candidate filtering.
|
||||
- Added online confirmation for pending roles, 24-hour expiry and reroll, former-holder fallback rules, vacant-role retries, opt-out filtering, and Tyrant/Vigilante conflict prevention.
|
||||
- Tyrant inactivity uses full reign succession cleanup; Vigilante inactivity clears only that role's Followers before replacement.
|
||||
- Verified confirmation, expiry reroll, Follower cleanup, exact inactivity boundary, full Tyrant succession, and pause-safe scheduler integration with the Gradle suite.
|
||||
|
||||
## 2026-08-14 — Role maintenance implementation started
|
||||
|
||||
- US-010 begins with test-first inactivity replacement, pending confirmation and expiry, conflict-safe candidate retries, and pause awareness.
|
||||
|
||||
## 2026-08-14 — Tyrant succession completed
|
||||
|
||||
- Completed US-002 with player-killer transfer, recent-player environmental succession, former-holder avoidance, pending offline confirmation, and distinct replacement Vigilante selection.
|
||||
- Tyrant death clears all classes, Followers, cooldowns, item readiness, purchases, levels, and choices before granting the successor one starting choice.
|
||||
- Added death-event integration, server announcements, and a scoreboard-team-backed red Tyrant name and glowing outline that is restored on login and removed on pause or shutdown.
|
||||
- Verified succession attribution, random exclusion, reign cleanup, role separation, death integration, and red presentation with the Gradle suite.
|
||||
|
||||
## 2026-08-14 — Tyrant succession implementation started
|
||||
|
||||
- US-002 begins with test-first killer and environmental succession, reign-wide cleanup, replacement Vigilante selection, and red Tyrant presentation.
|
||||
|
||||
## 2026-08-14 — Administrative lifecycle completed
|
||||
|
||||
- Completed US-001 with permission-gated `/tyrantadmin` start, status, pause, resume, and confirmed reset operations.
|
||||
- Initial Vigilantes may be assigned explicitly or selected among eligible recently active players, with offline selections persisted as pending confirmations.
|
||||
- Pause/resume preserves progression and assignments, shifts every cooldown, participation, inactivity, and pending-selection deadline, and invokes managed effect suppression/restoration.
|
||||
- Added detailed operator status output and verified lifecycle, timer shifting, permissions, confirmation safety, candidate filtering, metadata, and effect hooks with the Gradle suite.
|
||||
|
||||
## 2026-08-14 — Administrative lifecycle implementation started
|
||||
|
||||
- US-001 begins with test-first game start, pause/resume deadline freezing, permission-gated administration, inspection, and confirmed reset behavior.
|
||||
|
||||
## 2026-08-14 — Tyrant class assignment completed
|
||||
|
||||
- Completed US-004 with `/tyrant assign <class> <player>`, purchased-class authorization, UUID state, one holder per class, and one Tyrant-side class per player.
|
||||
- Preserved dual alignment so Vigilantes and Followers can hold a Tyrant-side class, while rejecting the Tyrant and opted-out targets.
|
||||
- Added reassignment and reign cleanup for class cooldown/item state, online assignment/removal notices, and login control reminders.
|
||||
- Verified assignment constraints, dual alignment, cleanup, commands, notifications, and reminders with the Gradle test suite.
|
||||
|
||||
## 2026-08-14 — Tyrant class assignment implementation started
|
||||
|
||||
- US-004 begins with test-first purchased-class authorization, one-holder constraints, dual alignment, reassignment cleanup, and reign-wide removal.
|
||||
|
||||
## 2026-08-14 — Tyrant progression completed
|
||||
|
||||
- Completed US-003 with exact Tyrant-on-Vigilante kill attribution, level and choice rewards, pause guards, one-time purchases, and reign reset behavior.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-001: Start, pause, and administer the game"
|
||||
description: Give server operators safe control over the Tyrant game's lifecycle and state.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-001: Start, pause, and administer the game
|
||||
@@ -11,14 +11,14 @@ As a **server operator**, I want to start, pause, resume, and inspect the game s
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] An administrator can start the first game by assigning the initial Tyrant.
|
||||
- [ ] The administrator may assign the first Vigilante or allow random selection from eligible recently active players.
|
||||
- [ ] An administrator can pause and resume the game without deleting game state.
|
||||
- [ ] Pausing freezes progression, cooldowns, daily-use timers, inactivity checks, and pending-selection deadlines.
|
||||
- [ ] Role and class effects are suppressed while paused and restored when play resumes.
|
||||
- [ ] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default.
|
||||
- [ ] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, and pending selections.
|
||||
- [ ] Destructive administrative operations require explicit confirmation.
|
||||
- [x] An administrator can start the first game by assigning the initial Tyrant.
|
||||
- [x] The administrator may assign the first Vigilante or allow random selection from eligible recently active players.
|
||||
- [x] An administrator can pause and resume the game without deleting game state.
|
||||
- [x] Pausing freezes progression, cooldowns, daily-use timers, inactivity checks, and pending-selection deadlines.
|
||||
- [x] Role and class effects are suppressed while paused and restored when play resumes.
|
||||
- [x] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default.
|
||||
- [x] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, and pending selections.
|
||||
- [x] Destructive administrative operations require explicit confirmation.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-002: Transfer and reset the Tyrant role"
|
||||
description: End a Tyrant's reign cleanly and establish a valid successor after death.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-002: Transfer and reset the Tyrant role
|
||||
@@ -11,15 +11,15 @@ As a **player**, I want defeating the Tyrant to transfer power predictably so th
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] A player directly credited with killing the Tyrant becomes the new Tyrant.
|
||||
- [ ] A Tyrant death without a player killer selects an eligible opted-in player active within the preceding 24 hours at random.
|
||||
- [ ] The former Tyrant is excluded from random succession when another eligible candidate exists.
|
||||
- [ ] The former Tyrant's level, unlocks, unspent choices, and Tyrant ability cooldowns reset when the reign ends.
|
||||
- [ ] Every Assassin, Fixer, Tamer, Vigilante, and Follower assignment is cleared when the Tyrant dies.
|
||||
- [ ] A new Vigilante is selected under the normal candidate rules after succession.
|
||||
- [ ] The new Tyrant receives exactly one starting unlock choice.
|
||||
- [ ] The Tyrant's displayed name is red and the Tyrant has a red glowing outline while the game is running.
|
||||
- [ ] Succession cannot assign the same player as both Tyrant and Vigilante.
|
||||
- [x] A player directly credited with killing the Tyrant becomes the new Tyrant.
|
||||
- [x] A Tyrant death without a player killer selects an eligible opted-in player active within the preceding 24 hours at random.
|
||||
- [x] The former Tyrant is excluded from random succession when another eligible candidate exists.
|
||||
- [x] The former Tyrant's level, unlocks, unspent choices, and Tyrant ability cooldowns reset when the reign ends.
|
||||
- [x] Every Assassin, Fixer, Tamer, Vigilante, and Follower assignment is cleared when the Tyrant dies.
|
||||
- [x] A new Vigilante is selected under the normal candidate rules after succession.
|
||||
- [x] The new Tyrant receives exactly one starting unlock choice.
|
||||
- [x] The Tyrant's displayed name is red and the Tyrant has a red glowing outline while the game is running.
|
||||
- [x] Succession cannot assign the same player as both Tyrant and Vigilante.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-004: Assign unlocked classes"
|
||||
description: Let the Tyrant assign each purchased class to one eligible participant.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-004: Assign unlocked classes
|
||||
@@ -11,14 +11,14 @@ As the **Tyrant**, I want to assign unlocked specialist classes so that I can bu
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The Tyrant can assign only Assassin, Fixer, and Tamer classes purchased during the current reign.
|
||||
- [ ] Each class can be assigned to at most one player at a time, and a player can hold at most one Tyrant-side class.
|
||||
- [ ] Role alignment and Tyrant-side class are independent: the Vigilante or a Follower may simultaneously be the Assassin, Fixer, or Tamer.
|
||||
- [ ] The Tyrant cannot assign a class to the Tyrant, an opted-out player, or another ineligible player.
|
||||
- [ ] Reassignment removes the class and its bound items and effects from the previous holder before granting it to the new holder.
|
||||
- [ ] Assignment and removal are stored by UUID and communicated clearly to affected players.
|
||||
- [ ] A class holder is reminded of the assignment and its controls on login.
|
||||
- [ ] All class assignments end when the Tyrant's reign ends.
|
||||
- [x] The Tyrant can assign only Assassin, Fixer, and Tamer classes purchased during the current reign.
|
||||
- [x] Each class can be assigned to at most one player at a time, and a player can hold at most one Tyrant-side class.
|
||||
- [x] Role alignment and Tyrant-side class are independent: the Vigilante or a Follower may simultaneously be the Assassin, Fixer, or Tamer.
|
||||
- [x] The Tyrant cannot assign a class to the Tyrant, an opted-out player, or another ineligible player.
|
||||
- [x] Reassignment removes the class and its bound items and effects from the previous holder before granting it to the new holder.
|
||||
- [x] Assignment and removal are stored by UUID and communicated clearly to affected players.
|
||||
- [x] A class holder is reminded of the assignment and its controls on login.
|
||||
- [x] All class assignments end when the Tyrant's reign ends.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-005: Use Assassin abilities"
|
||||
description: Give the Assassin timed stealth and a risky burst-mobility ability.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-005: Use Assassin abilities
|
||||
@@ -11,15 +11,22 @@ As the **Assassin**, I want stealth and burst mobility so that I can ambush oppo
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The Assassin can activate ten minutes of invisibility once per rolling hour using the named Assassin ability item.
|
||||
- [ ] Invisibility displays no potion particles.
|
||||
- [ ] Activation while in the same world and within the configured Tyrant range grants twenty minutes instead.
|
||||
- [ ] Proximity is evaluated when the ability is activated and does not alter the active duration afterward.
|
||||
- [ ] The Assassin can double-jump once every 60 seconds without requiring an item.
|
||||
- [ ] A successful double-jump grants Speed for 15 seconds and Weakness III for 20 seconds.
|
||||
- [ ] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed.
|
||||
- [ ] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart.
|
||||
- [ ] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable.
|
||||
- [x] The Assassin can activate ten minutes of invisibility once per rolling hour using the named Assassin ability item.
|
||||
- [x] Invisibility displays no potion particles.
|
||||
- [x] Activation while in the same world and within the configured Tyrant range grants twenty minutes instead.
|
||||
- [x] Proximity is evaluated when the ability is activated and does not alter the active duration afterward.
|
||||
- [x] The Assassin can double-jump once every 60 seconds without requiring an item.
|
||||
- [x] A successful double-jump grants Speed for 15 seconds and Weakness III for 20 seconds.
|
||||
- [x] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed.
|
||||
- [x] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart.
|
||||
- [x] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable.
|
||||
- [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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-006: Use Fixer abilities"
|
||||
description: Give the Fixer a periodic offensive and health enhancement strengthened near the Tyrant.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-006: Use Fixer abilities
|
||||
@@ -11,13 +11,13 @@ As the **Fixer**, I want a substantial temporary combat boost so that I can prot
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The Fixer can activate ten minutes of Strength and Health Boost once per rolling hour using the named Fixer ability item.
|
||||
- [ ] Normal activation provides two total rows of hearts when combined with the player's normal maximum health.
|
||||
- [ ] Activation in the same world and within the configured Tyrant range provides three total rows of hearts.
|
||||
- [ ] Proximity is evaluated when the ability is activated and does not alter the active tier afterward.
|
||||
- [ ] Health is safely clamped when the boost expires, is suppressed, or changes tier.
|
||||
- [ ] The cooldown uses unpaused elapsed time and survives death, logout, and restart.
|
||||
- [ ] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable.
|
||||
- [x] The Fixer can activate ten minutes of Strength and Health Boost once per rolling hour using the named Fixer ability item.
|
||||
- [x] Normal activation provides two total rows of hearts when combined with the player's normal maximum health.
|
||||
- [x] Activation in the same world and within the configured Tyrant range provides three total rows of hearts.
|
||||
- [x] Proximity is evaluated when the ability is activated and does not alter the active tier afterward.
|
||||
- [x] Health is safely clamped when the boost expires, is suppressed, or changes tier.
|
||||
- [x] The cooldown uses unpaused elapsed time and survives death, logout, and restart.
|
||||
- [x] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-007: Capture and place mobs as a Tamer"
|
||||
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
|
||||
@@ -11,16 +11,16 @@ As the **Tamer**, I want to capture a mob and release it elsewhere so that I can
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The Tamer can use a named fishing rod on a supported targeted mob to capture it with no cooldown.
|
||||
- [ ] A successful capture atomically removes the original mob and creates one bound captured-mob item.
|
||||
- [ ] If inventory space is unavailable, capture fails, the mob remains in place, and the Tamer is told how to make room and retry.
|
||||
- [ ] Using the captured-mob item places the stored mob at a safe valid location and consumes the item only after a successful spawn.
|
||||
- [ ] Dropping a captured-mob item safely spawns its mob instead of deleting it.
|
||||
- [ ] Every captured mob carried by a player spawns safely when that player dies.
|
||||
- [ ] Ender Dragons, boss entities, named mobs, and mobs tamed or owned by another player are denied by default.
|
||||
- [ ] Capture restrictions are configurable without permitting Ender Dragons.
|
||||
- [ ] Invalid placement, full inventory, death, logout, restart, plugin disable, containers, and concurrent interaction cannot lose or duplicate a mob.
|
||||
- [ ] Stored mob data is validated defensively before spawning and cannot execute untrusted serialized behavior.
|
||||
- [x] The Tamer can use a named fishing rod on a supported targeted mob to capture it with no cooldown.
|
||||
- [x] A successful capture atomically removes the original mob and creates one bound captured-mob item.
|
||||
- [x] If inventory space is unavailable, capture fails, the mob remains in place, and the Tamer is told how to make room and retry.
|
||||
- [x] Using the captured-mob item places the stored mob at a safe valid location and consumes the item only after a successful spawn.
|
||||
- [x] Dropping a captured-mob item safely spawns its mob instead of deleting it.
|
||||
- [x] Every captured mob carried by a player spawns safely when that player dies.
|
||||
- [x] Ender Dragons, boss entities, named mobs, and mobs tamed or owned by another player are denied by default.
|
||||
- [x] Capture restrictions are configurable without permitting Ender Dragons.
|
||||
- [x] Invalid placement, full inventory, death, logout, restart, plugin disable, containers, and concurrent interaction cannot lose or duplicate a mob.
|
||||
- [x] Stored mob data is validated defensively before spawning and cannot execute untrusted serialized behavior.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-008: Support the Vigilante and Followers"
|
||||
description: Give the Vigilante a replaceable role and combat strength derived from nearby Followers.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-008: Support the Vigilante and Followers
|
||||
@@ -11,18 +11,18 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Exactly one active Vigilante exists while the game is running whenever an eligible player is available.
|
||||
- [ ] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
|
||||
- [ ] The Vigilante can dismiss Followers and Followers can leave voluntarily.
|
||||
- [ ] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks.
|
||||
- [ ] Offline, dead, opted-out, dismissed, or out-of-range Followers do not count.
|
||||
- [ ] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
|
||||
- [ ] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment.
|
||||
- [ ] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV.
|
||||
- [ ] Every Vigilante death ends the assignment and triggers random replacement regardless of cause.
|
||||
- [ ] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
|
||||
- [ ] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
|
||||
- [ ] The Tyrant is never eligible to be selected as Vigilante.
|
||||
- [x] Exactly one active Vigilante exists while the game is running whenever an eligible player is available.
|
||||
- [x] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
|
||||
- [x] The Vigilante can dismiss Followers and Followers can leave voluntarily.
|
||||
- [x] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks.
|
||||
- [x] Offline, dead, opted-out, dismissed, or out-of-range Followers do not count.
|
||||
- [x] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
|
||||
- [x] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment.
|
||||
- [x] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV.
|
||||
- [x] Every Vigilante death ends the assignment and triggers random replacement regardless of cause.
|
||||
- [x] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
|
||||
- [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
|
||||
- [x] The Tyrant is never eligible to be selected as Vigilante.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-009: Purchase and use Tyrant abilities"
|
||||
description: Give the Tyrant persistent combat upgrades and limited intelligence about the opposition.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-009: Purchase and use Tyrant abilities
|
||||
@@ -11,14 +11,14 @@ As the **Tyrant**, I want permanent upgrades and limited roster intelligence so
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Purchasing permanent Strength applies the configured Strength level throughout the current reign while the game is running.
|
||||
- [ ] Purchasing permanent Resistance applies the configured Resistance level throughout the current reign while the game is running.
|
||||
- [ ] Permanent effects are restored after death, milk consumption, ordinary effect removal, login, restart, and replacement by a shorter or weaker effect.
|
||||
- [ ] Roster intelligence can be activated once per rolling 24 unpaused hours after it is purchased.
|
||||
- [ ] Roster intelligence reports the number of active Vigilante-side members without revealing their identities or locations.
|
||||
- [ ] Intelligence counts the current Vigilante and living assigned Followers, whether online or offline, according to configurable counting rules.
|
||||
- [ ] The Tyrant can inspect ability ownership and the remaining intelligence cooldown.
|
||||
- [ ] Purchases and cooldowns survive restart and end with the Tyrant's reign.
|
||||
- [x] Purchasing permanent Strength applies the configured Strength 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.
|
||||
- [x] Permanent effects are restored after death, milk consumption, ordinary effect removal, login, restart, and replacement by a shorter or weaker effect.
|
||||
- [x] Roster intelligence can be activated once per rolling 24 unpaused hours after it is purchased.
|
||||
- [x] Roster intelligence reports the number of active Vigilante-side members without revealing their identities or locations.
|
||||
- [x] Intelligence counts the current Vigilante and living assigned Followers, whether online or offline, according to configurable counting rules.
|
||||
- [x] The Tyrant can inspect ability ownership and the remaining intelligence cooldown.
|
||||
- [x] Purchases and cooldowns survive restart and end with the Tyrant's reign.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-010: Handle inactivity and pending selections"
|
||||
description: Replace absent or unconfirmed role holders using recently active eligible players.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-010: Handle inactivity and pending selections
|
||||
@@ -11,16 +11,16 @@ As a **participant**, I want inactive central roles to be replaced fairly so tha
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] A Tyrant or Vigilante who has not logged in for 48 unpaused hours loses the position.
|
||||
- [ ] Random candidates are drawn from eligible opted-in players who logged in during the preceding 24 hours.
|
||||
- [ ] The Tyrant is always excluded from Vigilante selection.
|
||||
- [ ] A previous role holder is excluded when another eligible candidate exists and may be reused only as a fallback.
|
||||
- [ ] Tyrant inactivity ends the reign, clears all assignments, and invokes normal random Tyrant succession followed by Vigilante selection.
|
||||
- [ ] Vigilante inactivity clears Followers and rerolls only the Vigilante position.
|
||||
- [ ] An offline randomly selected candidate must log in within 24 unpaused hours to confirm the assignment.
|
||||
- [ ] An unconfirmed selection expires and rerolls automatically under the same eligibility rules.
|
||||
- [ ] Selection remains vacant and retries periodically when no valid candidate exists.
|
||||
- [ ] Candidate selection cannot assign conflicting roles or choose an opted-out player, including during concurrent joins and deaths.
|
||||
- [x] A Tyrant or Vigilante who has not logged in for 48 unpaused hours loses the position.
|
||||
- [x] Random candidates are drawn from eligible opted-in players who logged in during the preceding 24 hours.
|
||||
- [x] The Tyrant is always excluded from Vigilante selection.
|
||||
- [x] A previous role holder is excluded when another eligible candidate exists and may be reused only as a fallback.
|
||||
- [x] Tyrant inactivity ends the reign, clears all assignments, and invokes normal random Tyrant succession followed by Vigilante selection.
|
||||
- [x] Vigilante inactivity clears Followers and rerolls only the Vigilante position.
|
||||
- [x] An offline randomly selected candidate must log in within 24 unpaused hours to confirm the assignment.
|
||||
- [x] An unconfirmed selection expires and rerolls automatically under the same eligibility rules.
|
||||
- [x] Selection remains vacant and retries periodically when no valid candidate exists.
|
||||
- [x] Candidate selection cannot assign conflicting roles or choose an opted-out player, including during concurrent joins and deaths.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-011: Inform players and manage participation"
|
||||
description: Tell players their current place in the game and let unassigned players opt out.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-011: Inform players and manage participation
|
||||
@@ -11,15 +11,25 @@ As a **player**, I want to understand my status and control my eligibility so th
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] On join, a player is told the game status and their role, class, subclass, abilities, and relevant cooldowns.
|
||||
- [ ] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
|
||||
- [ ] An opted-out player is told when they may opt back in.
|
||||
- [ ] An unassigned player can opt out through a command.
|
||||
- [ ] Opting out removes an ordinary class or Follower assignment immediately; the Tyrant and Vigilante must relinquish through a safe role command before opting out.
|
||||
- [ ] Opted-out players cannot be randomly selected, recruited, or assigned a class.
|
||||
- [ ] A player cannot opt back in until seven days have elapsed by default.
|
||||
- [ ] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart.
|
||||
- [ ] Participation commands are idempotent and clearly report current status and remaining time.
|
||||
- [x] On join, 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 opted-out player is told when they may opt back in.
|
||||
- [x] An unassigned player can opt out through a command.
|
||||
- [x] Opting out removes an ordinary class or Follower assignment immediately; the Tyrant and Vigilante must relinquish through a safe role command before opting out.
|
||||
- [x] Opted-out players cannot be randomly selected, recruited, or assigned a class.
|
||||
- [x] A player cannot opt back in until seven days have elapsed by default.
|
||||
- [x] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart.
|
||||
- [x] Participation commands are idempotent and clearly report current status and remaining time.
|
||||
- [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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-014: Use class ability items"
|
||||
description: Give class holders named bound items that activate abilities without enabling transfer or cooldown bypasses.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
- [ ] Each item-triggered class ability uses a uniquely named standard Minecraft item with hidden persistent ownership and ability metadata.
|
||||
- [ ] Right-clicking or swinging the appropriate item activates the ability without also performing an unsafe vanilla action.
|
||||
- [ ] Another player cannot activate, retain, craft with, place, store, frame, dispense, or otherwise transfer a bound ability item.
|
||||
- [ ] A cooldown ability item disappears only after successful activation and is restored when the cooldown finishes.
|
||||
- [ ] The Tamer's named fishing rod has no cooldown and remains available after successful capture or placement.
|
||||
- [ ] A dropped class ability item vanishes immediately; a dropped captured-mob item instead attempts to spawn its mob safely.
|
||||
- [ ] `/tyrant item` restores currently available missing class items but cannot bypass cooldowns or create duplicates.
|
||||
- [ ] Missing ready items are restored on login and cooldown completion when inventory space is available.
|
||||
- [ ] When delivery fails because the inventory is full, no item is dropped and the player is told both why and how to use `/tyrant item` after making room.
|
||||
- [ ] Bound class items are removed when a player loses the class or the Tyrant's reign ends.
|
||||
- [ ] Death, inventory movement, server restart, repeated commands, and concurrent events cannot duplicate a bound item or its captured mob.
|
||||
- [x] Each item-triggered class ability uses a uniquely named standard Minecraft item with hidden persistent ownership and ability metadata.
|
||||
- [x] Right-clicking or swinging the appropriate item activates the ability without also performing an unsafe vanilla action.
|
||||
- [x] Another player cannot activate, retain, craft with, place, store, frame, dispense, or otherwise transfer a bound ability item.
|
||||
- [x] A cooldown ability item disappears only after successful activation and is restored when the cooldown finishes.
|
||||
- [x] The Tamer's named fishing rod has no cooldown and remains available after successful capture or placement.
|
||||
- [x] A dropped class ability item vanishes immediately; a dropped captured-mob item instead attempts to spawn its mob safely.
|
||||
- [x] `/tyrant item` restores currently available missing class items but cannot bypass cooldowns or create duplicates.
|
||||
- [x] Missing ready items are restored on login and cooldown completion when inventory space is available.
|
||||
- [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.
|
||||
- [x] Bound class items are removed when a player loses the class or the Tyrant's reign ends.
|
||||
- [x] Death, inventory movement, server restart, repeated commands, and concurrent events cannot duplicate a bound item or its captured mob.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -2,8 +2,13 @@ package games.dmg.spigottyrant;
|
||||
|
||||
public enum Ability {
|
||||
ASSASSIN_INVISIBILITY,
|
||||
ASSASSIN_INVISIBILITY_ACTIVE,
|
||||
ASSASSIN_DOUBLE_JUMP,
|
||||
ASSASSIN_SPEED_ACTIVE,
|
||||
ASSASSIN_WEAKNESS_ACTIVE,
|
||||
FIXER_BOOST,
|
||||
FIXER_BOOST_ACTIVE,
|
||||
FIXER_NEAR_TYRANT_ACTIVE,
|
||||
TAMER_CAPTURE,
|
||||
ROSTER_INTELLIGENCE
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
public final class AbilityItemRefreshTask implements Runnable {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final AbilityReadinessService readiness;
|
||||
private final AbilityItemService items;
|
||||
private final Server server;
|
||||
private final Clock clock;
|
||||
private final CapturedMobInventoryService capturedMobInventory;
|
||||
|
||||
public AbilityItemRefreshTask(
|
||||
TyrantStateManager stateManager,
|
||||
AbilityReadinessService readiness,
|
||||
AbilityItemService items,
|
||||
CapturedMobInventoryService capturedMobInventory,
|
||||
Server server,
|
||||
Clock clock
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.readiness = readiness;
|
||||
this.items = items;
|
||||
this.capturedMobInventory = capturedMobInventory;
|
||||
this.server = server;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) {
|
||||
return;
|
||||
}
|
||||
for (Player player : server.getOnlinePlayers()) {
|
||||
PlayerState before = stateManager.player(player.getUniqueId(), player.getName());
|
||||
PlayerState after = readiness.refresh(before, clock.instant());
|
||||
if (!after.equals(before)) {
|
||||
stateManager.updatePlayer(
|
||||
player.getUniqueId(), player.getName(), current -> after
|
||||
);
|
||||
}
|
||||
items.removeInvalid(player, after);
|
||||
items.recover(player, after);
|
||||
capturedMobInventory.reconcile(player, after);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
public interface AbilityItemService {
|
||||
void recover(Player player, PlayerState state);
|
||||
|
||||
boolean isBoundAbilityItem(ItemStack item);
|
||||
|
||||
Optional<Ability> ability(ItemStack item);
|
||||
|
||||
Optional<UUID> owner(ItemStack item);
|
||||
|
||||
void removeInvalid(Player player, PlayerState state);
|
||||
|
||||
void removeAll(Player player);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumMap;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public final class AbilityReadinessService {
|
||||
public PlayerState consume(
|
||||
PlayerState player,
|
||||
Ability ability,
|
||||
Instant now,
|
||||
Duration cooldown
|
||||
) {
|
||||
if (!player.readyAbilityItems().contains(ability)) {
|
||||
throw new IllegalStateException("ability item is not ready");
|
||||
}
|
||||
if (ability == Ability.TAMER_CAPTURE && cooldown.isZero()) {
|
||||
return player;
|
||||
}
|
||||
Set<Ability> ready = EnumSet.noneOf(Ability.class);
|
||||
ready.addAll(player.readyAbilityItems());
|
||||
ready.remove(ability);
|
||||
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
|
||||
cooldowns.putAll(player.cooldownEnds());
|
||||
cooldowns.put(ability, now.plus(cooldown));
|
||||
return copy(player, cooldowns, ready);
|
||||
}
|
||||
|
||||
public PlayerState refresh(PlayerState player, Instant now) {
|
||||
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
|
||||
cooldowns.putAll(player.cooldownEnds());
|
||||
Set<Ability> ready = EnumSet.noneOf(Ability.class);
|
||||
ready.addAll(player.readyAbilityItems());
|
||||
for (Map.Entry<Ability, Instant> entry : player.cooldownEnds().entrySet()) {
|
||||
if (!entry.getValue().isAfter(now)) {
|
||||
cooldowns.remove(entry.getKey());
|
||||
if (isItemAbilityForClass(entry.getKey(), player.tyrantClass())) {
|
||||
ready.add(entry.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
return copy(player, cooldowns, ready);
|
||||
}
|
||||
|
||||
private static boolean isItemAbilityForClass(Ability ability, TyrantClass tyrantClass) {
|
||||
return switch (tyrantClass) {
|
||||
case ASSASSIN -> ability == Ability.ASSASSIN_INVISIBILITY;
|
||||
case FIXER -> ability == Ability.FIXER_BOOST;
|
||||
case TAMER -> ability == Ability.TAMER_CAPTURE;
|
||||
case NONE -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static PlayerState copy(
|
||||
PlayerState player,
|
||||
Map<Ability, Instant> cooldowns,
|
||||
Set<Ability> ready
|
||||
) {
|
||||
return new PlayerState(
|
||||
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
|
||||
player.tyrantClass(), player.followerOf(), cooldowns, ready,
|
||||
player.capturedMobs()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public record AbilityUseResult(PlayerState player, AbilityUseStatus status) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public enum AbilityUseStatus {
|
||||
ACTIVATED,
|
||||
WRONG_CLASS,
|
||||
NOT_READY,
|
||||
COOLDOWN,
|
||||
GAME_NOT_RUNNING,
|
||||
WRONG_OWNER
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class AssassinAbilityService {
|
||||
private final AbilityReadinessService readiness;
|
||||
private final PluginSettings settings;
|
||||
|
||||
public AssassinAbilityService(
|
||||
AbilityReadinessService readiness,
|
||||
PluginSettings settings
|
||||
) {
|
||||
this.readiness = readiness;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public AbilityUseResult activateInvisibility(
|
||||
PlayerState player,
|
||||
Instant now,
|
||||
boolean nearTyrant
|
||||
) {
|
||||
if (player.tyrantClass() != TyrantClass.ASSASSIN) {
|
||||
return new AbilityUseResult(player, AbilityUseStatus.WRONG_CLASS);
|
||||
}
|
||||
if (!player.readyAbilityItems().contains(Ability.ASSASSIN_INVISIBILITY)) {
|
||||
return new AbilityUseResult(player, AbilityUseStatus.NOT_READY);
|
||||
}
|
||||
PlayerState used = readiness.consume(
|
||||
player, Ability.ASSASSIN_INVISIBILITY, now, settings.assassinCooldown()
|
||||
);
|
||||
Instant activeUntil = now.plus(
|
||||
nearTyrant
|
||||
? settings.assassinInvisibilityDuration().multipliedBy(2)
|
||||
: settings.assassinInvisibilityDuration()
|
||||
);
|
||||
return new AbilityUseResult(
|
||||
withDeadline(used, Ability.ASSASSIN_INVISIBILITY_ACTIVE, activeUntil),
|
||||
AbilityUseStatus.ACTIVATED
|
||||
);
|
||||
}
|
||||
|
||||
public AbilityUseResult doubleJump(PlayerState player, Instant now) {
|
||||
if (player.tyrantClass() != TyrantClass.ASSASSIN) {
|
||||
return new AbilityUseResult(player, AbilityUseStatus.WRONG_CLASS);
|
||||
}
|
||||
if (player.cooldownEnds().getOrDefault(
|
||||
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
|
||||
).isAfter(now)) {
|
||||
return new AbilityUseResult(player, AbilityUseStatus.COOLDOWN);
|
||||
}
|
||||
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
|
||||
deadlines.putAll(player.cooldownEnds());
|
||||
deadlines.put(
|
||||
Ability.ASSASSIN_DOUBLE_JUMP,
|
||||
now.plus(settings.assassinDoubleJumpCooldown())
|
||||
);
|
||||
deadlines.put(
|
||||
Ability.ASSASSIN_SPEED_ACTIVE,
|
||||
now.plus(settings.assassinSpeedDuration())
|
||||
);
|
||||
deadlines.put(
|
||||
Ability.ASSASSIN_WEAKNESS_ACTIVE,
|
||||
now.plus(settings.assassinWeaknessDuration())
|
||||
);
|
||||
return new AbilityUseResult(copy(player, deadlines), AbilityUseStatus.ACTIVATED);
|
||||
}
|
||||
|
||||
private static PlayerState withDeadline(
|
||||
PlayerState player,
|
||||
Ability ability,
|
||||
Instant deadline
|
||||
) {
|
||||
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
|
||||
deadlines.putAll(player.cooldownEnds());
|
||||
deadlines.put(ability, deadline);
|
||||
return copy(player, deadlines);
|
||||
}
|
||||
|
||||
private static PlayerState copy(PlayerState player, Map<Ability, Instant> deadlines) {
|
||||
return new PlayerState(
|
||||
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
|
||||
player.tyrantClass(), player.followerOf(), deadlines,
|
||||
player.readyAbilityItems(), player.capturedMobs()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
public final class AssassinEffectController implements Runnable {
|
||||
private static final int EFFECT_TICKS = 30;
|
||||
private final TyrantStateManager stateManager;
|
||||
private final Server server;
|
||||
private final PluginSettings settings;
|
||||
private final Clock clock;
|
||||
private final EffectApplier effectApplier;
|
||||
private final Set<UUID> grantedFlight = new HashSet<>();
|
||||
|
||||
public AssassinEffectController(
|
||||
TyrantStateManager stateManager,
|
||||
Server server,
|
||||
PluginSettings settings,
|
||||
Clock clock
|
||||
) {
|
||||
this(stateManager, server, settings, clock, new BukkitEffectApplier());
|
||||
}
|
||||
|
||||
AssassinEffectController(
|
||||
TyrantStateManager stateManager,
|
||||
Server server,
|
||||
PluginSettings settings,
|
||||
Clock clock,
|
||||
EffectApplier effectApplier
|
||||
) {
|
||||
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
|
||||
this.server = Objects.requireNonNull(server, "server");
|
||||
this.settings = Objects.requireNonNull(settings, "settings");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.effectApplier = Objects.requireNonNull(effectApplier, "effectApplier");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
Instant now = clock.instant();
|
||||
for (Player player : server.getOnlinePlayers()) {
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING
|
||||
|| state.tyrantClass() != TyrantClass.ASSASSIN) {
|
||||
clear(player);
|
||||
continue;
|
||||
}
|
||||
applyWhileActive(
|
||||
player, state, Ability.ASSASSIN_INVISIBILITY_ACTIVE,
|
||||
EffectKind.INVISIBILITY, 0, false
|
||||
);
|
||||
applyWhileActive(
|
||||
player, state, Ability.ASSASSIN_SPEED_ACTIVE,
|
||||
EffectKind.SPEED, 0, true
|
||||
);
|
||||
applyWhileActive(
|
||||
player, state, Ability.ASSASSIN_WEAKNESS_ACTIVE,
|
||||
EffectKind.WEAKNESS, settings.assassinWeaknessLevel() - 1, true
|
||||
);
|
||||
boolean jumpReady = !state.cooldownEnds().getOrDefault(
|
||||
Ability.ASSASSIN_DOUBLE_JUMP, Instant.MIN
|
||||
).isAfter(now);
|
||||
if (jumpReady && isGrounded(player) && isSurvivalLike(player)) {
|
||||
player.setAllowFlight(true);
|
||||
grantedFlight.add(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void clearAll() {
|
||||
server.getOnlinePlayers().forEach(this::clear);
|
||||
}
|
||||
|
||||
private void applyWhileActive(
|
||||
Player player,
|
||||
PlayerState state,
|
||||
Ability ability,
|
||||
EffectKind effect,
|
||||
int amplifier,
|
||||
boolean particles
|
||||
) {
|
||||
if (state.cooldownEnds().getOrDefault(ability, Instant.MIN).isAfter(clock.instant())) {
|
||||
effectApplier.apply(player, effect, amplifier, particles);
|
||||
}
|
||||
}
|
||||
|
||||
private void clear(Player player) {
|
||||
if (grantedFlight.remove(player.getUniqueId()) && isSurvivalLike(player)) {
|
||||
player.setAllowFlight(false);
|
||||
player.setFlying(false);
|
||||
}
|
||||
}
|
||||
|
||||
enum EffectKind {
|
||||
INVISIBILITY,
|
||||
SPEED,
|
||||
WEAKNESS
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface EffectApplier {
|
||||
void apply(Player player, EffectKind effect, int amplifier, boolean particles);
|
||||
}
|
||||
|
||||
private static final class BukkitEffectApplier implements EffectApplier {
|
||||
@Override
|
||||
public void apply(
|
||||
Player player,
|
||||
EffectKind effect,
|
||||
int amplifier,
|
||||
boolean particles
|
||||
) {
|
||||
PotionEffectType type = switch (effect) {
|
||||
case INVISIBILITY -> PotionEffectType.INVISIBILITY;
|
||||
case SPEED -> PotionEffectType.SPEED;
|
||||
case WEAKNESS -> PotionEffectType.WEAKNESS;
|
||||
};
|
||||
player.addPotionEffect(new PotionEffect(
|
||||
type, EFFECT_TICKS, amplifier, false, particles, true
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isGrounded(Player player) {
|
||||
return !player.getLocation().clone().subtract(0.0, 0.1, 0.0)
|
||||
.getBlock().isPassable();
|
||||
}
|
||||
|
||||
private static boolean isSurvivalLike(Player player) {
|
||||
return player.getGameMode() != GameMode.CREATIVE
|
||||
&& player.getGameMode() != GameMode.SPECTATOR;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
|
||||
public final class AssassinItemListener implements Listener {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final AbilityItemService items;
|
||||
private final AssassinAbilityService abilities;
|
||||
private final PluginSettings settings;
|
||||
private final Clock clock;
|
||||
|
||||
public AssassinItemListener(
|
||||
TyrantStateManager stateManager,
|
||||
AbilityItemService items,
|
||||
AssassinAbilityService abilities,
|
||||
PluginSettings settings,
|
||||
Clock clock
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.items = items;
|
||||
this.abilities = abilities;
|
||||
this.settings = settings;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH)
|
||||
public void onInteract(PlayerInteractEvent event) {
|
||||
Action action = event.getAction();
|
||||
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK
|
||||
&& action != Action.LEFT_CLICK_AIR && action != Action.LEFT_CLICK_BLOCK) {
|
||||
return;
|
||||
}
|
||||
if (items.ability(event.getItem())
|
||||
.filter(Ability.ASSASSIN_INVISIBILITY::equals).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
event.setCancelled(true);
|
||||
UUID playerId = event.getPlayer().getUniqueId();
|
||||
if (items.owner(event.getItem()).filter(playerId::equals).isEmpty()) {
|
||||
event.getPlayer().getInventory().setItem(event.getHand(), null);
|
||||
event.getPlayer().sendMessage(ChatColor.RED + "That ability item is not yours.");
|
||||
return;
|
||||
}
|
||||
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING) {
|
||||
event.getPlayer().sendMessage(ChatColor.RED + "The Tyrant game is not running.");
|
||||
return;
|
||||
}
|
||||
PlayerState player = stateManager.player(playerId, event.getPlayer().getName());
|
||||
AbilityUseResult result = abilities.activateInvisibility(
|
||||
player, clock.instant(), isNearTyrant(event.getPlayer())
|
||||
);
|
||||
if (result.status() == AbilityUseStatus.ACTIVATED) {
|
||||
stateManager.updatePlayer(playerId, event.getPlayer().getName(), current -> result.player());
|
||||
stateManager.saveIfDirty();
|
||||
event.getPlayer().getInventory().setItem(event.getHand(), null);
|
||||
event.getPlayer().sendMessage(ChatColor.GREEN + "Invisibility activated.");
|
||||
} else {
|
||||
event.getPlayer().sendMessage(ChatColor.RED + "Ability unavailable: "
|
||||
+ result.status().name().toLowerCase(java.util.Locale.ROOT) + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isNearTyrant(org.bukkit.entity.Player player) {
|
||||
Optional<org.bukkit.entity.Player> tyrant = stateManager.game().tyrantId()
|
||||
.map(org.bukkit.Bukkit::getPlayer);
|
||||
return tyrant.isPresent()
|
||||
&& tyrant.orElseThrow().getWorld().equals(player.getWorld())
|
||||
&& tyrant.orElseThrow().getLocation().distanceSquared(player.getLocation())
|
||||
<= settings.tyrantRangeBlocks() * settings.tyrantRangeBlocks();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerToggleFlightEvent;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
public final class AssassinJumpListener implements Listener {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final AssassinAbilityService abilities;
|
||||
private final Clock clock;
|
||||
|
||||
public AssassinJumpListener(
|
||||
TyrantStateManager stateManager,
|
||||
AssassinAbilityService abilities,
|
||||
Clock clock
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.abilities = abilities;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onToggleFlight(PlayerToggleFlightEvent event) {
|
||||
if (event.getPlayer().getGameMode() == GameMode.CREATIVE
|
||||
|| event.getPlayer().getGameMode() == GameMode.SPECTATOR) {
|
||||
return;
|
||||
}
|
||||
PlayerState state = stateManager.player(
|
||||
event.getPlayer().getUniqueId(), event.getPlayer().getName()
|
||||
);
|
||||
if (stateManager.game().lifecycle() != GameLifecycle.RUNNING
|
||||
|| state.tyrantClass() != TyrantClass.ASSASSIN) {
|
||||
return;
|
||||
}
|
||||
event.setCancelled(true);
|
||||
event.getPlayer().setFlying(false);
|
||||
event.getPlayer().setAllowFlight(false);
|
||||
AbilityUseResult result = abilities.doubleJump(state, clock.instant());
|
||||
if (result.status() != AbilityUseStatus.ACTIVATED) {
|
||||
event.getPlayer().sendMessage(ChatColor.RED + "Double jump is cooling down.");
|
||||
return;
|
||||
}
|
||||
stateManager.updatePlayer(
|
||||
event.getPlayer().getUniqueId(), event.getPlayer().getName(),
|
||||
current -> result.player()
|
||||
);
|
||||
stateManager.saveIfDirty();
|
||||
Vector velocity = event.getPlayer().getVelocity();
|
||||
event.getPlayer().setVelocity(velocity.setY(Math.max(0.8, velocity.getY())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import org.bukkit.entity.ItemFrame;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.BlockDispenseEvent;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.event.entity.EntityPickupItemEvent;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.player.PlayerDropItemEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
public final class BoundItemListener implements Listener {
|
||||
private final AbilityItemService items;
|
||||
|
||||
public BoundItemListener(AbilityItemService items) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onDrop(PlayerDropItemEvent event) {
|
||||
if (items.isBoundAbilityItem(event.getItemDrop().getItemStack())) {
|
||||
event.getItemDrop().remove();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPickup(EntityPickupItemEvent event) {
|
||||
if (!(event.getEntity() instanceof Player player)) {
|
||||
return;
|
||||
}
|
||||
ItemStack item = event.getItem().getItemStack();
|
||||
if (items.isBoundAbilityItem(item)
|
||||
&& items.owner(item).filter(player.getUniqueId()::equals).isEmpty()) {
|
||||
event.setCancelled(true);
|
||||
event.getItem().remove();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onInventoryClick(InventoryClickEvent event) {
|
||||
if (items.isBoundAbilityItem(event.getCurrentItem())
|
||||
|| items.isBoundAbilityItem(event.getCursor())) {
|
||||
if (!(event.getWhoClicked() instanceof Player player)
|
||||
|| event.isShiftClick()
|
||||
|| event.getClickedInventory() == null
|
||||
|| !event.getClickedInventory().equals(player.getInventory())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlace(BlockPlaceEvent event) {
|
||||
if (items.isBoundAbilityItem(event.getItemInHand())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onDispense(BlockDispenseEvent event) {
|
||||
if (items.isBoundAbilityItem(event.getItem())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onFrame(PlayerInteractEntityEvent event) {
|
||||
if (event.getRightClicked() instanceof ItemFrame
|
||||
&& items.isBoundAbilityItem(event.getPlayer().getInventory()
|
||||
.getItem(event.getHand()))) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onDeath(PlayerDeathEvent event) {
|
||||
event.getDrops().removeIf(items::isBoundAbilityItem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
public final class BukkitAbilityItemService implements AbilityItemService {
|
||||
private final NamespacedKey abilityKey;
|
||||
private final NamespacedKey ownerKey;
|
||||
private final PluginSettings settings;
|
||||
|
||||
public BukkitAbilityItemService(Plugin plugin, PluginSettings settings) {
|
||||
abilityKey = new NamespacedKey(plugin, "ability");
|
||||
ownerKey = new NamespacedKey(plugin, "owner");
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void recover(Player player, PlayerState state) {
|
||||
for (Ability ability : state.readyAbilityItems()) {
|
||||
if (!isItemAbilityForClass(ability, state.tyrantClass())
|
||||
|| contains(player, state.playerId(), ability)) {
|
||||
continue;
|
||||
}
|
||||
ItemStack item = create(state.playerId(), ability);
|
||||
Map<Integer, ItemStack> leftovers = player.getInventory().addItem(item);
|
||||
if (!leftovers.isEmpty()) {
|
||||
player.sendMessage(ChatColor.RED + settings.inventoryFullMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isBoundAbilityItem(ItemStack item) {
|
||||
return ability(item).isPresent() && owner(item).isPresent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Ability> ability(ItemStack item) {
|
||||
if (item == null || !item.hasItemMeta()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String value = item.getItemMeta().getPersistentDataContainer()
|
||||
.get(abilityKey, PersistentDataType.STRING);
|
||||
if (value == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(Ability.valueOf(value));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<UUID> owner(ItemStack item) {
|
||||
if (item == null || !item.hasItemMeta()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String value = item.getItemMeta().getPersistentDataContainer()
|
||||
.get(ownerKey, PersistentDataType.STRING);
|
||||
if (value == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(UUID.fromString(value));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeInvalid(Player player, PlayerState state) {
|
||||
ItemStack[] contents = player.getInventory().getContents();
|
||||
java.util.Set<Ability> retained = java.util.EnumSet.noneOf(Ability.class);
|
||||
for (int index = 0; index < contents.length; index++) {
|
||||
ItemStack item = contents[index];
|
||||
if (!isBoundAbilityItem(item)) {
|
||||
continue;
|
||||
}
|
||||
Optional<Ability> itemAbility = ability(item);
|
||||
boolean valid = owner(item).filter(state.playerId()::equals).isPresent()
|
||||
&& itemAbility.filter(state.readyAbilityItems()::contains).isPresent()
|
||||
&& itemAbility.filter(retained::add).isPresent();
|
||||
if (!valid) {
|
||||
player.getInventory().setItem(index, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAll(Player player) {
|
||||
ItemStack[] contents = player.getInventory().getContents();
|
||||
for (int index = 0; index < contents.length; index++) {
|
||||
if (isBoundAbilityItem(contents[index])) {
|
||||
player.getInventory().setItem(index, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ItemStack create(UUID ownerId, Ability ability) {
|
||||
PluginSettings.AbilityItemSettings configured = settingsFor(ability);
|
||||
Material material = Material.matchMaterial(configured.material());
|
||||
if (material == null) {
|
||||
throw new IllegalStateException("Configured item material is unavailable");
|
||||
}
|
||||
ItemStack item = new ItemStack(material);
|
||||
ItemMeta meta = item.getItemMeta();
|
||||
meta.setDisplayName(ChatColor.GOLD + configured.name());
|
||||
meta.getPersistentDataContainer().set(
|
||||
abilityKey, PersistentDataType.STRING, ability.name()
|
||||
);
|
||||
meta.getPersistentDataContainer().set(
|
||||
ownerKey, PersistentDataType.STRING, ownerId.toString()
|
||||
);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
private boolean contains(Player player, UUID ownerId, Ability ability) {
|
||||
for (ItemStack item : player.getInventory().getContents()) {
|
||||
if (owner(item).filter(ownerId::equals).isPresent()
|
||||
&& ability(item).filter(ability::equals).isPresent()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private PluginSettings.AbilityItemSettings settingsFor(Ability ability) {
|
||||
return switch (ability) {
|
||||
case ASSASSIN_INVISIBILITY -> settings.assassinItem();
|
||||
case FIXER_BOOST -> settings.fixerItem();
|
||||
case TAMER_CAPTURE -> settings.tamerItem();
|
||||
default -> throw new IllegalArgumentException(
|
||||
ability.name().toLowerCase(Locale.ROOT) + " has no class item"
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean isItemAbilityForClass(Ability ability, TyrantClass tyrantClass) {
|
||||
return switch (tyrantClass) {
|
||||
case ASSASSIN -> ability == Ability.ASSASSIN_INVISIBILITY;
|
||||
case FIXER -> ability == Ability.FIXER_BOOST;
|
||||
case TAMER -> ability == Ability.TAMER_CAPTURE;
|
||||
case NONE -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
public final class BukkitCapturedMobItemService implements CapturedMobItemService {
|
||||
private final NamespacedKey ownerKey;
|
||||
private final NamespacedKey captureKey;
|
||||
private final NamespacedKey typeKey;
|
||||
private final PluginSettings settings;
|
||||
|
||||
public BukkitCapturedMobItemService(Plugin plugin, PluginSettings settings) {
|
||||
ownerKey = new NamespacedKey(plugin, "captured-owner");
|
||||
captureKey = new NamespacedKey(plugin, "captured-id");
|
||||
typeKey = new NamespacedKey(plugin, "captured-type");
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ItemStack create(UUID ownerId, UUID captureId, String entityType) {
|
||||
Material material = Material.matchMaterial(settings.capturedMobItem().material());
|
||||
if (material == null) {
|
||||
throw new IllegalStateException("Captured mob item material is unavailable");
|
||||
}
|
||||
ItemStack item = new ItemStack(material);
|
||||
ItemMeta meta = item.getItemMeta();
|
||||
meta.setDisplayName(ChatColor.GOLD + settings.capturedMobItem().name()
|
||||
+ ": " + entityType);
|
||||
meta.getPersistentDataContainer().set(
|
||||
ownerKey, PersistentDataType.STRING, ownerId.toString()
|
||||
);
|
||||
meta.getPersistentDataContainer().set(
|
||||
captureKey, PersistentDataType.STRING, captureId.toString()
|
||||
);
|
||||
meta.getPersistentDataContainer().set(
|
||||
typeKey, PersistentDataType.STRING, entityType
|
||||
);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCapturedMob(ItemStack item) {
|
||||
return owner(item).isPresent() && captureId(item).isPresent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<UUID> owner(ItemStack item) {
|
||||
return uuid(item, ownerKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<UUID> captureId(ItemStack item) {
|
||||
return uuid(item, captureKey);
|
||||
}
|
||||
|
||||
private static Optional<UUID> uuid(ItemStack item, NamespacedKey key) {
|
||||
if (item == null || !item.hasItemMeta()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String value = item.getItemMeta().getPersistentDataContainer()
|
||||
.get(key, PersistentDataType.STRING);
|
||||
if (value == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(UUID.fromString(value));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public final class BukkitManagedRoleEffects implements ManagedRoleEffects {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final TyrantPresentation presentation;
|
||||
|
||||
public BukkitManagedRoleEffects(
|
||||
TyrantStateManager stateManager,
|
||||
TyrantPresentation presentation
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.presentation = presentation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void suppressAll() {
|
||||
presentation.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void restoreAll() {
|
||||
if (stateManager.game().lifecycle() == GameLifecycle.RUNNING) {
|
||||
presentation.reconcile(stateManager.game().tyrantId());
|
||||
} else {
|
||||
presentation.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
public final class BukkitOnlinePlayerDirectory implements OnlinePlayerDirectory {
|
||||
@Override
|
||||
public Player findByName(String name) {
|
||||
return Bukkit.getPlayerExact(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Player findById(UUID playerId) {
|
||||
return Bukkit.getPlayer(playerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Set<UUID> onlinePlayerIds() {
|
||||
return Bukkit.getOnlinePlayers().stream()
|
||||
.map(Player::getUniqueId)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scoreboard.Scoreboard;
|
||||
import org.bukkit.scoreboard.ScoreboardManager;
|
||||
import org.bukkit.scoreboard.Team;
|
||||
|
||||
public final class BukkitTyrantPresentation implements TyrantPresentation {
|
||||
private static final String TEAM_NAME = "tyrant-red";
|
||||
private final Server server;
|
||||
private final Team team;
|
||||
private Optional<UUID> presentedTyrant = Optional.empty();
|
||||
|
||||
public BukkitTyrantPresentation(Server server) {
|
||||
this.server = server;
|
||||
ScoreboardManager manager = Objects.requireNonNull(
|
||||
server.getScoreboardManager(), "Scoreboard manager is unavailable"
|
||||
);
|
||||
Scoreboard scoreboard = manager.getMainScoreboard();
|
||||
Team existing = scoreboard.getTeam(TEAM_NAME);
|
||||
team = existing == null ? scoreboard.registerNewTeam(TEAM_NAME) : existing;
|
||||
team.setColor(ChatColor.RED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reconcile(Optional<UUID> tyrantId) {
|
||||
clear();
|
||||
presentedTyrant = tyrantId;
|
||||
tyrantId.map(server::getPlayer).ifPresent(player -> {
|
||||
team.addEntry(player.getName());
|
||||
player.setGlowing(true);
|
||||
player.setPlayerListName(ChatColor.RED + player.getName());
|
||||
player.setDisplayName(ChatColor.RED + player.getName());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
presentedTyrant.map(server::getPlayer).ifPresent(player -> {
|
||||
player.setGlowing(false);
|
||||
player.setPlayerListName(player.getName());
|
||||
player.setDisplayName(player.getName());
|
||||
});
|
||||
for (String entry : new HashSet<>(team.getEntries())) {
|
||||
team.removeEntry(entry);
|
||||
}
|
||||
presentedTyrant = Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,53 @@
|
||||
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 ("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,18 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public final class ClassAssignmentMessages {
|
||||
private ClassAssignmentMessages() {
|
||||
}
|
||||
|
||||
public static String loginReminder(PlayerState player) {
|
||||
return switch (player.tyrantClass()) {
|
||||
case ASSASSIN -> "Class: ASSASSIN. Use your Assassin Cloak; double-jump while "
|
||||
+ "airborne. Use /tyrant item to recover ready class items.";
|
||||
case FIXER -> "Class: FIXER. Use your Fixer's Wrench to activate your boost. "
|
||||
+ "Use /tyrant item to recover ready class items.";
|
||||
case TAMER -> "Class: TAMER. Use your Tamer's Lead to capture mobs. "
|
||||
+ "Use /tyrant item to recover ready class items.";
|
||||
case NONE -> "";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ClassAssignmentResult(
|
||||
Map<UUID, PlayerState> players,
|
||||
ClassAssignmentStatus status,
|
||||
Optional<UUID> previousHolderId
|
||||
) {
|
||||
public ClassAssignmentResult {
|
||||
players = Map.copyOf(players);
|
||||
previousHolderId = previousHolderId == null ? Optional.empty() : previousHolderId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.EnumMap;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class ClassAssignmentService {
|
||||
public ClassAssignmentResult assign(
|
||||
GameState game,
|
||||
Map<UUID, PlayerState> players,
|
||||
UUID assignerId,
|
||||
UUID targetId,
|
||||
TyrantClass tyrantClass,
|
||||
Instant now
|
||||
) {
|
||||
ClassAssignmentStatus rejected = rejection(
|
||||
game, players, assignerId, targetId, tyrantClass, now
|
||||
);
|
||||
if (rejected != null) {
|
||||
return new ClassAssignmentResult(players, rejected, Optional.empty());
|
||||
}
|
||||
|
||||
Map<UUID, PlayerState> updated = new HashMap<>(players);
|
||||
Optional<UUID> previousHolder = players.values().stream()
|
||||
.filter(player -> player.tyrantClass() == tyrantClass)
|
||||
.map(PlayerState::playerId)
|
||||
.filter(playerId -> !playerId.equals(targetId))
|
||||
.findFirst();
|
||||
previousHolder.ifPresent(playerId -> updated.put(
|
||||
playerId,
|
||||
withClass(updated.get(playerId), TyrantClass.NONE)
|
||||
));
|
||||
updated.put(targetId, withClass(updated.get(targetId), tyrantClass));
|
||||
return new ClassAssignmentResult(
|
||||
updated,
|
||||
ClassAssignmentStatus.ASSIGNED,
|
||||
previousHolder
|
||||
);
|
||||
}
|
||||
|
||||
public Map<UUID, PlayerState> clearAll(Map<UUID, PlayerState> players) {
|
||||
Map<UUID, PlayerState> updated = new HashMap<>();
|
||||
for (PlayerState player : players.values()) {
|
||||
updated.put(player.playerId(), withClass(player, TyrantClass.NONE));
|
||||
}
|
||||
return Map.copyOf(updated);
|
||||
}
|
||||
|
||||
private static ClassAssignmentStatus rejection(
|
||||
GameState game,
|
||||
Map<UUID, PlayerState> players,
|
||||
UUID assignerId,
|
||||
UUID targetId,
|
||||
TyrantClass tyrantClass,
|
||||
Instant now
|
||||
) {
|
||||
if (game.lifecycle() != GameLifecycle.RUNNING) {
|
||||
return ClassAssignmentStatus.GAME_NOT_RUNNING;
|
||||
}
|
||||
if (game.tyrantId().isEmpty() || !game.tyrantId().orElseThrow().equals(assignerId)) {
|
||||
return ClassAssignmentStatus.NOT_TYRANT;
|
||||
}
|
||||
if (tyrantClass == null || tyrantClass == TyrantClass.NONE) {
|
||||
return ClassAssignmentStatus.INVALID_CLASS;
|
||||
}
|
||||
if (!game.purchases().contains(unlockFor(tyrantClass))) {
|
||||
return ClassAssignmentStatus.CLASS_NOT_UNLOCKED;
|
||||
}
|
||||
PlayerState target = players.get(targetId);
|
||||
if (target == null) {
|
||||
return ClassAssignmentStatus.UNKNOWN_PLAYER;
|
||||
}
|
||||
if (targetId.equals(assignerId)) {
|
||||
return ClassAssignmentStatus.TARGET_IS_TYRANT;
|
||||
}
|
||||
if (target.optedOutUntil().isPresent()) {
|
||||
return ClassAssignmentStatus.TARGET_OPTED_OUT;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TyrantUnlock unlockFor(TyrantClass tyrantClass) {
|
||||
return switch (tyrantClass) {
|
||||
case ASSASSIN -> TyrantUnlock.ASSASSIN;
|
||||
case FIXER -> TyrantUnlock.FIXER;
|
||||
case TAMER -> TyrantUnlock.TAMER;
|
||||
case NONE -> throw new IllegalArgumentException("NONE is not assignable");
|
||||
};
|
||||
}
|
||||
|
||||
private static PlayerState withClass(PlayerState player, TyrantClass tyrantClass) {
|
||||
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
|
||||
cooldowns.putAll(player.cooldownEnds());
|
||||
Set<Ability> readyItems = EnumSet.noneOf(Ability.class);
|
||||
readyItems.addAll(player.readyAbilityItems());
|
||||
for (Ability ability : abilitiesFor(player.tyrantClass())) {
|
||||
cooldowns.remove(ability);
|
||||
readyItems.remove(ability);
|
||||
}
|
||||
readyItemFor(tyrantClass).ifPresent(readyItems::add);
|
||||
return new PlayerState(
|
||||
player.playerId(),
|
||||
player.latestName(),
|
||||
player.lastLogin(),
|
||||
player.optedOutUntil(),
|
||||
tyrantClass,
|
||||
player.followerOf(),
|
||||
cooldowns,
|
||||
readyItems,
|
||||
player.capturedMobs()
|
||||
);
|
||||
}
|
||||
|
||||
private static Optional<Ability> readyItemFor(TyrantClass tyrantClass) {
|
||||
return switch (tyrantClass) {
|
||||
case ASSASSIN -> Optional.of(Ability.ASSASSIN_INVISIBILITY);
|
||||
case FIXER -> Optional.of(Ability.FIXER_BOOST);
|
||||
case TAMER -> Optional.of(Ability.TAMER_CAPTURE);
|
||||
case NONE -> Optional.empty();
|
||||
};
|
||||
}
|
||||
|
||||
private static Set<Ability> abilitiesFor(TyrantClass tyrantClass) {
|
||||
return switch (tyrantClass) {
|
||||
case ASSASSIN -> Set.of(
|
||||
Ability.ASSASSIN_INVISIBILITY,
|
||||
Ability.ASSASSIN_DOUBLE_JUMP
|
||||
);
|
||||
case FIXER -> Set.of(Ability.FIXER_BOOST);
|
||||
case TAMER -> Set.of(Ability.TAMER_CAPTURE);
|
||||
case NONE -> Set.of();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public enum ClassAssignmentStatus {
|
||||
ASSIGNED,
|
||||
GAME_NOT_RUNNING,
|
||||
NOT_TYRANT,
|
||||
CLASS_NOT_UNLOCKED,
|
||||
INVALID_CLASS,
|
||||
UNKNOWN_PLAYER,
|
||||
TARGET_IS_TYRANT,
|
||||
TARGET_OPTED_OUT
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
public final class FixerAbilityService {
|
||||
private final AbilityReadinessService readiness;
|
||||
private final PluginSettings settings;
|
||||
|
||||
public FixerAbilityService(
|
||||
AbilityReadinessService readiness,
|
||||
PluginSettings settings
|
||||
) {
|
||||
this.readiness = readiness;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public FixerUseResult activate(PlayerState player, Instant now, boolean nearTyrant) {
|
||||
if (player.tyrantClass() != TyrantClass.FIXER) {
|
||||
return new FixerUseResult(player, AbilityUseStatus.WRONG_CLASS, 0);
|
||||
}
|
||||
if (!player.readyAbilityItems().contains(Ability.FIXER_BOOST)) {
|
||||
return new FixerUseResult(player, AbilityUseStatus.NOT_READY, 0);
|
||||
}
|
||||
PlayerState used = readiness.consume(
|
||||
player, Ability.FIXER_BOOST, now, settings.fixerCooldown()
|
||||
);
|
||||
Instant activeUntil = now.plus(settings.fixerEffectDuration());
|
||||
Map<Ability, Instant> deadlines = new EnumMap<>(Ability.class);
|
||||
deadlines.putAll(used.cooldownEnds());
|
||||
deadlines.put(Ability.FIXER_BOOST_ACTIVE, activeUntil);
|
||||
if (nearTyrant) {
|
||||
deadlines.put(Ability.FIXER_NEAR_TYRANT_ACTIVE, activeUntil);
|
||||
}
|
||||
PlayerState updated = new PlayerState(
|
||||
used.playerId(), used.latestName(), used.lastLogin(), used.optedOutUntil(),
|
||||
used.tyrantClass(), used.followerOf(), deadlines, used.readyAbilityItems(),
|
||||
used.capturedMobs()
|
||||
);
|
||||
int rows = nearTyrant
|
||||
? settings.fixerNearTyrantHeartRows() : settings.fixerNormalHeartRows();
|
||||
return new FixerUseResult(updated, AbilityUseStatus.ACTIVATED, rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,10 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record FollowerResult(Map<UUID, PlayerState> players, FollowerStatus status) {
|
||||
public FollowerResult {
|
||||
players = Map.copyOf(players);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class FollowerService {
|
||||
private final Map<UUID, UUID> invitations = new HashMap<>();
|
||||
|
||||
public FollowerResult invite(
|
||||
GameState game,
|
||||
Map<UUID, PlayerState> players,
|
||||
UUID vigilanteId,
|
||||
UUID targetId
|
||||
) {
|
||||
if (game.lifecycle() != GameLifecycle.RUNNING) {
|
||||
return result(players, FollowerStatus.GAME_NOT_RUNNING);
|
||||
}
|
||||
if (game.vigilanteId().filter(vigilanteId::equals).isEmpty()) {
|
||||
return result(players, FollowerStatus.NOT_VIGILANTE);
|
||||
}
|
||||
PlayerState target = players.get(targetId);
|
||||
if (target == null || target.optedOutUntil().isPresent()
|
||||
|| game.tyrantId().filter(targetId::equals).isPresent()
|
||||
|| game.vigilanteId().filter(targetId::equals).isPresent()) {
|
||||
return result(players, FollowerStatus.INELIGIBLE);
|
||||
}
|
||||
invitations.put(targetId, vigilanteId);
|
||||
return result(players, FollowerStatus.INVITED);
|
||||
}
|
||||
|
||||
public FollowerResult accept(
|
||||
GameState game,
|
||||
Map<UUID, PlayerState> players,
|
||||
UUID targetId
|
||||
) {
|
||||
UUID invitedBy = invitations.get(targetId);
|
||||
if (invitedBy == null
|
||||
|| game.vigilanteId().filter(invitedBy::equals).isEmpty()) {
|
||||
return result(players, FollowerStatus.NO_INVITATION);
|
||||
}
|
||||
PlayerState target = players.get(targetId);
|
||||
if (target == null || target.optedOutUntil().isPresent()) {
|
||||
invitations.remove(targetId);
|
||||
return result(players, FollowerStatus.INELIGIBLE);
|
||||
}
|
||||
invitations.remove(targetId);
|
||||
return updated(players, withFollower(target, Optional.of(invitedBy)), FollowerStatus.JOINED);
|
||||
}
|
||||
|
||||
public FollowerResult dismiss(
|
||||
GameState game,
|
||||
Map<UUID, PlayerState> players,
|
||||
UUID vigilanteId,
|
||||
UUID targetId
|
||||
) {
|
||||
if (game.vigilanteId().filter(vigilanteId::equals).isEmpty()) {
|
||||
return result(players, FollowerStatus.NOT_VIGILANTE);
|
||||
}
|
||||
PlayerState target = players.get(targetId);
|
||||
if (target == null || target.followerOf().filter(vigilanteId::equals).isEmpty()) {
|
||||
return result(players, FollowerStatus.NOT_FOLLOWER);
|
||||
}
|
||||
return updated(players, withFollower(target, Optional.empty()), FollowerStatus.DISMISSED);
|
||||
}
|
||||
|
||||
public FollowerResult leave(
|
||||
GameState game,
|
||||
Map<UUID, PlayerState> players,
|
||||
UUID targetId
|
||||
) {
|
||||
PlayerState target = players.get(targetId);
|
||||
if (target == null || target.followerOf().isEmpty()) {
|
||||
return result(players, FollowerStatus.NOT_FOLLOWER);
|
||||
}
|
||||
return updated(players, withFollower(target, Optional.empty()), FollowerStatus.LEFT);
|
||||
}
|
||||
|
||||
public void clearInvitations() {
|
||||
invitations.clear();
|
||||
}
|
||||
|
||||
private static FollowerResult updated(
|
||||
Map<UUID, PlayerState> players,
|
||||
PlayerState updated,
|
||||
FollowerStatus status
|
||||
) {
|
||||
Map<UUID, PlayerState> replacement = new HashMap<>(players);
|
||||
replacement.put(updated.playerId(), updated);
|
||||
return result(replacement, status);
|
||||
}
|
||||
|
||||
private static FollowerResult result(
|
||||
Map<UUID, PlayerState> players,
|
||||
FollowerStatus status
|
||||
) {
|
||||
return new FollowerResult(players, status);
|
||||
}
|
||||
|
||||
private static PlayerState withFollower(
|
||||
PlayerState player,
|
||||
Optional<UUID> followerOf
|
||||
) {
|
||||
return new PlayerState(
|
||||
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
|
||||
player.tyrantClass(), followerOf, player.cooldownEnds(),
|
||||
player.readyAbilityItems(), player.capturedMobs()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public enum FollowerStatus {
|
||||
INVITED,
|
||||
JOINED,
|
||||
DISMISSED,
|
||||
LEFT,
|
||||
GAME_NOT_RUNNING,
|
||||
NOT_VIGILANTE,
|
||||
INELIGIBLE,
|
||||
NO_INVITATION,
|
||||
NOT_FOLLOWER
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class GameLifecycleService {
|
||||
public LifecycleState start(
|
||||
PersistentState state,
|
||||
UUID tyrantId,
|
||||
Optional<UUID> vigilanteId,
|
||||
Instant now
|
||||
) {
|
||||
if (state.game().lifecycle() != GameLifecycle.UNSTARTED) {
|
||||
throw new IllegalStateException("game has already been started");
|
||||
}
|
||||
if (vigilanteId.filter(tyrantId::equals).isPresent()) {
|
||||
throw new IllegalArgumentException("Tyrant and Vigilante must be different players");
|
||||
}
|
||||
GameState game = new GameState(
|
||||
GameLifecycle.RUNNING,
|
||||
Optional.of(tyrantId),
|
||||
vigilanteId,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Duration.ZERO,
|
||||
0,
|
||||
1,
|
||||
Set.of()
|
||||
);
|
||||
return new LifecycleState(game, state.players());
|
||||
}
|
||||
|
||||
public LifecycleState pause(PersistentState state, Instant now) {
|
||||
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
|
||||
throw new IllegalStateException("only a running game can be paused");
|
||||
}
|
||||
GameState current = state.game();
|
||||
GameState paused = new GameState(
|
||||
GameLifecycle.PAUSED,
|
||||
current.tyrantId(),
|
||||
current.vigilanteId(),
|
||||
current.pendingTyrant(),
|
||||
current.pendingVigilante(),
|
||||
Optional.of(now),
|
||||
current.accumulatedPausedTime(),
|
||||
current.tyrantLevel(),
|
||||
current.unspentChoices(),
|
||||
current.purchases()
|
||||
);
|
||||
return new LifecycleState(paused, state.players());
|
||||
}
|
||||
|
||||
public LifecycleState resume(PersistentState state, Instant now) {
|
||||
GameState current = state.game();
|
||||
if (current.lifecycle() != GameLifecycle.PAUSED || current.pausedAt().isEmpty()) {
|
||||
throw new IllegalStateException("only a paused game can be resumed");
|
||||
}
|
||||
Instant pausedAt = current.pausedAt().orElseThrow();
|
||||
Duration pausedFor = Duration.between(pausedAt, now);
|
||||
if (pausedFor.isNegative()) {
|
||||
throw new IllegalArgumentException("resume time must not precede pause time");
|
||||
}
|
||||
GameState resumed = new GameState(
|
||||
GameLifecycle.RUNNING,
|
||||
current.tyrantId(),
|
||||
current.vigilanteId(),
|
||||
shiftSelection(current.pendingTyrant(), pausedFor),
|
||||
shiftSelection(current.pendingVigilante(), pausedFor),
|
||||
Optional.empty(),
|
||||
current.accumulatedPausedTime().plus(pausedFor),
|
||||
current.tyrantLevel(),
|
||||
current.unspentChoices(),
|
||||
current.purchases()
|
||||
);
|
||||
Map<UUID, PlayerState> players = new HashMap<>();
|
||||
for (PlayerState player : state.players().values()) {
|
||||
players.put(player.playerId(), shift(player, pausedFor));
|
||||
}
|
||||
return new LifecycleState(resumed, players);
|
||||
}
|
||||
|
||||
public LifecycleState reset(PersistentState state) {
|
||||
Map<UUID, PlayerState> players = new HashMap<>();
|
||||
for (PlayerState player : state.players().values()) {
|
||||
players.put(player.playerId(), new PlayerState(
|
||||
player.playerId(),
|
||||
player.latestName(),
|
||||
player.lastLogin(),
|
||||
player.optedOutUntil(),
|
||||
TyrantClass.NONE,
|
||||
Optional.empty(),
|
||||
Map.of(),
|
||||
Set.of(),
|
||||
player.capturedMobs()
|
||||
));
|
||||
}
|
||||
return new LifecycleState(GameState.empty(), players);
|
||||
}
|
||||
|
||||
private static Optional<PendingSelection> shiftSelection(
|
||||
Optional<PendingSelection> selection,
|
||||
Duration pausedFor
|
||||
) {
|
||||
return selection.map(value -> new PendingSelection(
|
||||
value.candidateId(), value.expiresAt().plus(pausedFor)
|
||||
));
|
||||
}
|
||||
|
||||
private static PlayerState shift(PlayerState player, Duration pausedFor) {
|
||||
Map<Ability, Instant> cooldowns = new EnumMap<>(Ability.class);
|
||||
player.cooldownEnds().forEach((ability, deadline) ->
|
||||
cooldowns.put(ability, deadline.plus(pausedFor))
|
||||
);
|
||||
return new PlayerState(
|
||||
player.playerId(),
|
||||
player.latestName(),
|
||||
shiftInstant(player.lastLogin(), pausedFor),
|
||||
shiftInstant(player.optedOutUntil(), pausedFor),
|
||||
player.tyrantClass(),
|
||||
player.followerOf(),
|
||||
cooldowns,
|
||||
player.readyAbilityItems(),
|
||||
player.capturedMobs()
|
||||
);
|
||||
}
|
||||
|
||||
private static Optional<Instant> shiftInstant(Optional<Instant> value, Duration duration) {
|
||||
return value.map(instant -> instant.plus(duration));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public record IntelligenceResult(
|
||||
PlayerState tyrant,
|
||||
AbilityUseStatus status,
|
||||
int memberCount
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public record LifecycleState(GameState game, Map<UUID, PlayerState> players) {
|
||||
public LifecycleState {
|
||||
players = Map.copyOf(players);
|
||||
}
|
||||
|
||||
public PersistentState asPersistentState() {
|
||||
return new PersistentState(game, players);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public interface ManagedRoleEffects {
|
||||
void suppressAll();
|
||||
|
||||
void restoreAll();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public final class NoopManagedRoleEffects implements ManagedRoleEffects {
|
||||
@Override
|
||||
public void suppressAll() {
|
||||
// No managed effects exist until their corresponding abilities are unlocked.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void restoreAll() {
|
||||
// No managed effects exist until their corresponding abilities are unlocked.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
public interface OnlinePlayerDirectory {
|
||||
Player findByName(String name);
|
||||
|
||||
Player findById(UUID playerId);
|
||||
|
||||
java.util.Set<UUID> onlinePlayerIds();
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.scoreboard.Scoreboard;
|
||||
import org.bukkit.scoreboard.ScoreboardManager;
|
||||
import org.bukkit.scoreboard.Team;
|
||||
|
||||
public final class OptOutGlowController implements Runnable, Listener {
|
||||
private static final String TEAM_NAME = "tyrant-optout";
|
||||
private final TyrantStateManager stateManager;
|
||||
private final Server server;
|
||||
private final PluginSettings settings;
|
||||
private final Scoreboard scoreboard;
|
||||
private final Team greenTeam;
|
||||
private final Map<UUID, PreviousPresentation> managed = new HashMap<>();
|
||||
|
||||
public OptOutGlowController(
|
||||
TyrantStateManager stateManager,
|
||||
Server server,
|
||||
PluginSettings settings
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.server = server;
|
||||
this.settings = settings;
|
||||
ScoreboardManager manager = Objects.requireNonNull(
|
||||
server.getScoreboardManager(), "Scoreboard manager is unavailable"
|
||||
);
|
||||
scoreboard = manager.getMainScoreboard();
|
||||
Team existing = scoreboard.getTeam(TEAM_NAME);
|
||||
greenTeam = existing == null ? scoreboard.registerNewTeam(TEAM_NAME) : existing;
|
||||
greenTeam.setColor(ChatColor.GREEN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
GameState game = stateManager.game();
|
||||
Player tyrant = game.tyrantId().map(server::getPlayer).orElse(null);
|
||||
boolean tyrantAvailable = tyrant != null && !tyrant.isDead();
|
||||
Set<UUID> online = new HashSet<>();
|
||||
for (Player player : server.getOnlinePlayers()) {
|
||||
online.add(player.getUniqueId());
|
||||
PlayerState state = stateManager.players().get(player.getUniqueId());
|
||||
boolean sameWorld = tyrantAvailable && !player.isDead()
|
||||
&& player.getWorld().equals(tyrant.getWorld());
|
||||
double distanceSquared = sameWorld
|
||||
? player.getLocation().distanceSquared(tyrant.getLocation())
|
||||
: Double.POSITIVE_INFINITY;
|
||||
boolean shouldGlow = state != null && OptOutGlowPolicy.shouldGlow(
|
||||
game, state, tyrantAvailable, sameWorld, distanceSquared,
|
||||
settings.tyrantRangeBlocks()
|
||||
);
|
||||
if (shouldGlow && !managed.containsKey(player.getUniqueId())) {
|
||||
apply(player);
|
||||
} else if (!shouldGlow && managed.containsKey(player.getUniqueId())) {
|
||||
restore(player.getUniqueId(), player);
|
||||
}
|
||||
}
|
||||
for (UUID playerId : Set.copyOf(managed.keySet())) {
|
||||
if (!online.contains(playerId) && server.getPlayer(playerId) == null) {
|
||||
restore(playerId, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onQuit(PlayerQuitEvent event) {
|
||||
if (managed.containsKey(event.getPlayer().getUniqueId())) {
|
||||
restore(event.getPlayer().getUniqueId(), event.getPlayer());
|
||||
}
|
||||
}
|
||||
|
||||
public void clearAll() {
|
||||
for (UUID playerId : Set.copyOf(managed.keySet())) {
|
||||
restore(playerId, server.getPlayer(playerId));
|
||||
}
|
||||
}
|
||||
|
||||
private void apply(Player player) {
|
||||
Team previousTeam = scoreboard.getEntryTeam(player.getName());
|
||||
if (previousTeam == greenTeam) {
|
||||
previousTeam = null;
|
||||
}
|
||||
managed.put(
|
||||
player.getUniqueId(),
|
||||
new PreviousPresentation(player.getName(), previousTeam, player.isGlowing())
|
||||
);
|
||||
greenTeam.addEntry(player.getName());
|
||||
player.setGlowing(true);
|
||||
}
|
||||
|
||||
private void restore(UUID playerId, Player player) {
|
||||
PreviousPresentation previous = managed.remove(playerId);
|
||||
if (previous == null) {
|
||||
return;
|
||||
}
|
||||
greenTeam.removeEntry(previous.playerName());
|
||||
if (previous.team() != null) {
|
||||
try {
|
||||
previous.team().addEntry(previous.playerName());
|
||||
} catch (IllegalStateException ignored) {
|
||||
// The prior team was removed while this plugin managed the entry.
|
||||
}
|
||||
}
|
||||
if (player != null) {
|
||||
player.setGlowing(previous.glowing());
|
||||
}
|
||||
}
|
||||
|
||||
private record PreviousPresentation(
|
||||
String playerName,
|
||||
Team team,
|
||||
boolean glowing
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public final class OptOutGlowPolicy {
|
||||
private OptOutGlowPolicy() {
|
||||
}
|
||||
|
||||
public static boolean shouldGlow(
|
||||
GameState game,
|
||||
PlayerState player,
|
||||
boolean tyrantAvailable,
|
||||
boolean playerAvailableInTyrantWorld,
|
||||
double distanceSquared,
|
||||
double rangeBlocks
|
||||
) {
|
||||
return game.lifecycle() == GameLifecycle.RUNNING
|
||||
&& game.tyrantId().isPresent()
|
||||
&& player.optedOutUntil().isPresent()
|
||||
&& tyrantAvailable
|
||||
&& playerAvailableInTyrantWorld
|
||||
&& distanceSquared <= rangeBlocks * rangeBlocks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
|
||||
public final class ParticipationCombatListener implements Listener {
|
||||
public static final String OPTED_OUT_MESSAGE =
|
||||
"That player has chosen not to take part in the Tyrant event.";
|
||||
|
||||
private final TyrantStateManager stateManager;
|
||||
private final ParticipationCombatService combat;
|
||||
|
||||
public ParticipationCombatListener(
|
||||
TyrantStateManager stateManager,
|
||||
ParticipationCombatService combat
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.combat = combat;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onDamage(EntityDamageByEntityEvent event) {
|
||||
if (!(event.getEntity() instanceof Player target)) {
|
||||
return;
|
||||
}
|
||||
Optional<Player> attacker = attackingPlayer(event);
|
||||
if (attacker.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Player source = attacker.orElseThrow();
|
||||
Map<UUID, PlayerState> players = stateManager.players();
|
||||
PlayerState attackerState = players.getOrDefault(
|
||||
source.getUniqueId(),
|
||||
PlayerState.newPlayer(source.getUniqueId(), source.getName())
|
||||
);
|
||||
PlayerState targetState = players.getOrDefault(
|
||||
target.getUniqueId(),
|
||||
PlayerState.newPlayer(target.getUniqueId(), target.getName())
|
||||
);
|
||||
if (combat.blocks(stateManager.game(), attackerState, targetState)) {
|
||||
event.setCancelled(true);
|
||||
source.sendMessage(ChatColor.YELLOW + OPTED_OUT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<Player> attackingPlayer(EntityDamageByEntityEvent event) {
|
||||
if (event.getDamager() instanceof Player player) {
|
||||
return Optional.of(player);
|
||||
}
|
||||
if (event.getDamager() instanceof Projectile projectile
|
||||
&& projectile.getShooter() instanceof Player player) {
|
||||
return Optional.of(player);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public final class ParticipationCombatService {
|
||||
public boolean blocks(
|
||||
GameState game,
|
||||
PlayerState attacker,
|
||||
PlayerState target
|
||||
) {
|
||||
if (game.lifecycle() != GameLifecycle.RUNNING) {
|
||||
return false;
|
||||
}
|
||||
boolean attackerOptedOut = attacker.optedOutUntil().isPresent();
|
||||
boolean targetOptedOut = target.optedOutUntil().isPresent();
|
||||
boolean attackerTyrantSide = isTyrantSide(game, attacker);
|
||||
boolean targetTyrantSide = isTyrantSide(game, target);
|
||||
return (attackerTyrantSide && targetOptedOut)
|
||||
|| (attackerOptedOut && targetTyrantSide);
|
||||
}
|
||||
|
||||
private static boolean isTyrantSide(GameState game, PlayerState player) {
|
||||
return game.tyrantId().filter(player.playerId()::equals).isPresent()
|
||||
|| player.tyrantClass() != TyrantClass.NONE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public record ParticipationResult(PlayerState player, ParticipationStatus status) {
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public final class ParticipationService {
|
||||
private final Duration optOutDuration;
|
||||
|
||||
public ParticipationService(Duration optOutDuration) {
|
||||
if (optOutDuration.isNegative() || optOutDuration.isZero()) {
|
||||
throw new IllegalArgumentException("optOutDuration must be positive");
|
||||
}
|
||||
this.optOutDuration = optOutDuration;
|
||||
}
|
||||
|
||||
public ParticipationResult optOut(GameState game, PlayerState player, Instant now) {
|
||||
if (game.tyrantId().filter(player.playerId()::equals).isPresent()
|
||||
|| game.vigilanteId().filter(player.playerId()::equals).isPresent()) {
|
||||
return new ParticipationResult(
|
||||
player, ParticipationStatus.MUST_RELINQUISH_ROLE
|
||||
);
|
||||
}
|
||||
if (player.optedOutUntil().isPresent()) {
|
||||
return new ParticipationResult(player, ParticipationStatus.ALREADY_OPTED_OUT);
|
||||
}
|
||||
PlayerState updated = new PlayerState(
|
||||
player.playerId(),
|
||||
player.latestName(),
|
||||
player.lastLogin(),
|
||||
Optional.of(now.plus(optOutDuration)),
|
||||
TyrantClass.NONE,
|
||||
Optional.empty(),
|
||||
Map.of(),
|
||||
Set.of(),
|
||||
player.capturedMobs()
|
||||
);
|
||||
return new ParticipationResult(updated, ParticipationStatus.OPTED_OUT);
|
||||
}
|
||||
|
||||
public ParticipationResult optIn(PlayerState player, Instant now) {
|
||||
if (player.optedOutUntil().isEmpty()) {
|
||||
return new ParticipationResult(player, ParticipationStatus.ALREADY_OPTED_IN);
|
||||
}
|
||||
if (now.isBefore(player.optedOutUntil().orElseThrow())) {
|
||||
return new ParticipationResult(player, ParticipationStatus.COOLDOWN_ACTIVE);
|
||||
}
|
||||
PlayerState updated = new PlayerState(
|
||||
player.playerId(),
|
||||
player.latestName(),
|
||||
player.lastLogin(),
|
||||
Optional.empty(),
|
||||
player.tyrantClass(),
|
||||
player.followerOf(),
|
||||
player.cooldownEnds(),
|
||||
player.readyAbilityItems(),
|
||||
player.capturedMobs()
|
||||
);
|
||||
return new ParticipationResult(updated, ParticipationStatus.OPTED_IN);
|
||||
}
|
||||
|
||||
public ParticipationStatus status(PlayerState player, Instant now) {
|
||||
return player.optedOutUntil().isPresent()
|
||||
? ParticipationStatus.OPTED_OUT : ParticipationStatus.OPTED_IN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public enum ParticipationStatus {
|
||||
OPTED_IN,
|
||||
OPTED_OUT,
|
||||
COOLDOWN_ACTIVE,
|
||||
MUST_RELINQUISH_ROLE,
|
||||
ALREADY_OPTED_IN,
|
||||
ALREADY_OPTED_OUT
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
|
||||
public final class PlayerJoinListener implements Listener {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final Clock clock;
|
||||
private final TyrantPresentation presentation;
|
||||
|
||||
public PlayerJoinListener(TyrantStateManager stateManager, Clock clock) {
|
||||
this(stateManager, clock, null);
|
||||
}
|
||||
|
||||
public PlayerJoinListener(
|
||||
TyrantStateManager stateManager,
|
||||
Clock clock,
|
||||
TyrantPresentation presentation
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.clock = clock;
|
||||
this.presentation = presentation;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
PlayerState player = stateManager.updatePlayer(
|
||||
event.getPlayer().getUniqueId(),
|
||||
event.getPlayer().getName(),
|
||||
current -> new PlayerState(
|
||||
current.playerId(), current.latestName(), java.util.Optional.of(clock.instant()),
|
||||
current.optedOutUntil(), current.tyrantClass(), current.followerOf(),
|
||||
current.cooldownEnds(), current.readyAbilityItems(), current.capturedMobs()
|
||||
)
|
||||
);
|
||||
for (String message : PlayerStatusMessages.forJoin(
|
||||
stateManager.game(), player, clock.instant()
|
||||
)) {
|
||||
event.getPlayer().sendMessage(message);
|
||||
}
|
||||
if (presentation != null
|
||||
&& stateManager.game().lifecycle() == GameLifecycle.RUNNING
|
||||
&& stateManager.game().tyrantId()
|
||||
.filter(event.getPlayer().getUniqueId()::equals).isPresent()) {
|
||||
presentation.reconcile(stateManager.game().tyrantId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class PlayerStatusMessages {
|
||||
private PlayerStatusMessages() {
|
||||
}
|
||||
|
||||
public static List<String> forJoin(
|
||||
GameState game,
|
||||
PlayerState player,
|
||||
Instant now
|
||||
) {
|
||||
List<String> messages = new ArrayList<>();
|
||||
boolean hasRole = false;
|
||||
if (game.tyrantId().filter(player.playerId()::equals).isPresent()) {
|
||||
messages.add("Role: TYRANT. Level " + game.tyrantLevel() + ", choices "
|
||||
+ game.unspentChoices() + ", abilities " + game.purchases() + ".");
|
||||
hasRole = true;
|
||||
}
|
||||
if (game.vigilanteId().filter(player.playerId()::equals).isPresent()) {
|
||||
messages.add("Role: VIGILANTE.");
|
||||
hasRole = true;
|
||||
} else if (player.followerOf().isPresent()) {
|
||||
messages.add("Role: FOLLOWER of " + player.followerOf().orElseThrow() + ".");
|
||||
hasRole = true;
|
||||
}
|
||||
if (player.tyrantClass() != TyrantClass.NONE) {
|
||||
messages.add(ClassAssignmentMessages.loginReminder(player));
|
||||
hasRole = true;
|
||||
}
|
||||
player.cooldownEnds().entrySet().stream()
|
||||
.sorted(java.util.Map.Entry.comparingByKey())
|
||||
.forEach(entry -> {
|
||||
long seconds = Math.max(
|
||||
0L, Duration.between(now, entry.getValue()).toSeconds()
|
||||
);
|
||||
messages.add("Cooldown: " + entry.getKey() + " " + seconds + "s remaining.");
|
||||
});
|
||||
if (player.optedOutUntil().isPresent()) {
|
||||
messages.add("Participation: OPTED OUT. You may use /tyrant optin at "
|
||||
+ player.optedOutUntil().orElseThrow() + ".");
|
||||
} else if (!hasRole) {
|
||||
messages.add("Use /tyrant optout if you do not want to participate "
|
||||
+ "in Tyrant events.");
|
||||
}
|
||||
return List.copyOf(messages);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public record PluginSettings(
|
||||
int fixerStrengthLevel,
|
||||
int fixerNormalHeartRows,
|
||||
int fixerNearTyrantHeartRows,
|
||||
Duration vigilanteCombatDuration,
|
||||
Duration rosterIntelligenceCooldown,
|
||||
int tyrantStrengthLevel,
|
||||
int tyrantResistanceLevel,
|
||||
@@ -37,6 +38,7 @@ public record PluginSettings(
|
||||
AbilityItemSettings assassinItem,
|
||||
AbilityItemSettings fixerItem,
|
||||
AbilityItemSettings tamerItem,
|
||||
AbilityItemSettings capturedMobItem,
|
||||
String recoveryCommand,
|
||||
String inventoryFullMessage,
|
||||
boolean freezeTimersWhilePaused
|
||||
@@ -65,6 +67,7 @@ public record PluginSettings(
|
||||
"fixer-near-tyrant-heart-rows must be at least fixer-normal-heart-rows"
|
||||
);
|
||||
}
|
||||
requirePositive(vigilanteCombatDuration, "vigilante-combat-seconds");
|
||||
requirePositive(rosterIntelligenceCooldown, "roster-intelligence-cooldown-seconds");
|
||||
requireEffectLevel(tyrantStrengthLevel, "tyrant-strength-level");
|
||||
requireEffectLevel(tyrantResistanceLevel, "tyrant-resistance-level");
|
||||
@@ -74,6 +77,7 @@ public record PluginSettings(
|
||||
assassinItem = Objects.requireNonNull(assassinItem, "assassinItem");
|
||||
fixerItem = Objects.requireNonNull(fixerItem, "fixerItem");
|
||||
tamerItem = Objects.requireNonNull(tamerItem, "tamerItem");
|
||||
capturedMobItem = Objects.requireNonNull(capturedMobItem, "capturedMobItem");
|
||||
recoveryCommand = requireText(recoveryCommand, "recovery-command");
|
||||
inventoryFullMessage = requireText(inventoryFullMessage, "inventory-full-message");
|
||||
}
|
||||
@@ -99,6 +103,7 @@ public record PluginSettings(
|
||||
integer(values, "fixer-strength-level", 1),
|
||||
integer(values, "fixer-normal-heart-rows", 2),
|
||||
integer(values, "fixer-near-tyrant-heart-rows", 3),
|
||||
duration(values, "vigilante-combat-seconds", Duration.ofSeconds(15)),
|
||||
duration(values, "roster-intelligence-cooldown-seconds", Duration.ofHours(24)),
|
||||
integer(values, "tyrant-strength-level", 1),
|
||||
integer(values, "tyrant-resistance-level", 1),
|
||||
@@ -108,6 +113,7 @@ public record PluginSettings(
|
||||
item(values, "assassin-item", new AbilityItemSettings("STICK", "Assassin Cloak")),
|
||||
item(values, "fixer-item", new AbilityItemSettings("STICK", "Fixer's Wrench")),
|
||||
item(values, "tamer-item", new AbilityItemSettings("FISHING_ROD", "Tamer's Lead")),
|
||||
item(values, "captured-mob-item", new AbilityItemSettings("PAPER", "Captured Mob")),
|
||||
string(values, "recovery-command", "/tyrant item"),
|
||||
string(
|
||||
values,
|
||||
|
||||
@@ -11,6 +11,7 @@ public final class PluginSettingsValidator {
|
||||
validateItem(settings.assassinItem(), "assassin-item");
|
||||
validateItem(settings.fixerItem(), "fixer-item");
|
||||
validateItem(settings.tamerItem(), "tamer-item");
|
||||
validateItem(settings.capturedMobItem(), "captured-mob-item");
|
||||
for (String entityType : settings.deniedMobTypes()) {
|
||||
try {
|
||||
EntityType.valueOf(entityType);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public record RelinquishmentResult(
|
||||
LifecycleState state,
|
||||
RelinquishmentStatus status
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public enum RelinquishmentStatus {
|
||||
RELINQUISHED,
|
||||
NO_CENTRAL_ROLE,
|
||||
GAME_NOT_RUNNING
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.random.RandomGenerator;
|
||||
|
||||
public final class RoleCandidateSelector {
|
||||
public Optional<UUID> select(
|
||||
Map<UUID, PlayerState> players,
|
||||
Set<UUID> excluded,
|
||||
Optional<UUID> previousHolder,
|
||||
Instant now,
|
||||
Duration activityWindow,
|
||||
RandomGenerator random
|
||||
) {
|
||||
Instant cutoff = now.minus(activityWindow);
|
||||
List<UUID> eligible = new ArrayList<>();
|
||||
for (PlayerState player : players.values()) {
|
||||
if (excluded.contains(player.playerId())) {
|
||||
continue;
|
||||
}
|
||||
if (player.lastLogin().isEmpty()
|
||||
|| player.lastLogin().orElseThrow().isBefore(cutoff)) {
|
||||
continue;
|
||||
}
|
||||
if (player.optedOutUntil().isPresent()) {
|
||||
continue;
|
||||
}
|
||||
eligible.add(player.playerId());
|
||||
}
|
||||
if (eligible.size() > 1) {
|
||||
previousHolder.ifPresent(eligible::remove);
|
||||
}
|
||||
if (eligible.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
eligible.sort(Comparator.comparing(UUID::toString));
|
||||
return Optional.of(eligible.get(random.nextInt(eligible.size())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.random.RandomGenerator;
|
||||
|
||||
public final class RoleMaintenanceService {
|
||||
private final RoleCandidateSelector candidates;
|
||||
private final TyrantSuccessionService succession;
|
||||
private final Duration inactivity;
|
||||
private final Duration activityWindow;
|
||||
private final Duration pendingTimeout;
|
||||
private final RandomGenerator random;
|
||||
|
||||
public RoleMaintenanceService(
|
||||
RoleCandidateSelector candidates,
|
||||
TyrantSuccessionService succession,
|
||||
Duration inactivity,
|
||||
Duration activityWindow,
|
||||
Duration pendingTimeout,
|
||||
RandomGenerator random
|
||||
) {
|
||||
this.candidates = candidates;
|
||||
this.succession = succession;
|
||||
this.inactivity = inactivity;
|
||||
this.activityWindow = activityWindow;
|
||||
this.pendingTimeout = pendingTimeout;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
public LifecycleState maintain(
|
||||
PersistentState state,
|
||||
Set<UUID> onlinePlayerIds,
|
||||
Instant now
|
||||
) {
|
||||
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
|
||||
return new LifecycleState(state.game(), state.players());
|
||||
}
|
||||
LifecycleState current = confirmPending(state, onlinePlayerIds, now);
|
||||
if (isInactive(current.game().tyrantId(), current.players(), now)) {
|
||||
return succession.succeed(
|
||||
current.asPersistentState(), Optional.empty(), onlinePlayerIds, now
|
||||
);
|
||||
}
|
||||
if (isInactive(current.game().vigilanteId(), current.players(), now)) {
|
||||
current = rerollVigilante(
|
||||
current.asPersistentState(), current.game().vigilanteId(), onlinePlayerIds, now
|
||||
);
|
||||
}
|
||||
current = rerollExpiredTyrant(current, onlinePlayerIds, now);
|
||||
current = rerollExpiredVigilante(current, onlinePlayerIds, now);
|
||||
if (current.game().tyrantId().isEmpty() && current.game().pendingTyrant().isEmpty()) {
|
||||
current = selectTyrant(current, Optional.empty(), onlinePlayerIds, now);
|
||||
}
|
||||
if (current.game().tyrantId().isPresent()
|
||||
&& current.game().vigilanteId().isEmpty()
|
||||
&& current.game().pendingVigilante().isEmpty()) {
|
||||
current = rerollVigilante(
|
||||
current.asPersistentState(), Optional.empty(), onlinePlayerIds, now
|
||||
);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
public LifecycleState rerollVigilante(
|
||||
PersistentState state,
|
||||
Optional<UUID> previousVigilante,
|
||||
Set<UUID> onlinePlayerIds,
|
||||
Instant now
|
||||
) {
|
||||
Map<UUID, PlayerState> players = clearFollowers(state.players(), previousVigilante);
|
||||
Set<UUID> excluded = state.game().tyrantId().map(Set::of).orElseGet(Set::of);
|
||||
Optional<UUID> selected = candidates.select(
|
||||
players, excluded, previousVigilante, now, activityWindow, random
|
||||
);
|
||||
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
|
||||
Optional<PendingSelection> pending = selected
|
||||
.filter(playerId -> active.isEmpty())
|
||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
||||
GameState game = copyRoles(
|
||||
state.game(), state.game().tyrantId(), active,
|
||||
state.game().pendingTyrant(), pending
|
||||
);
|
||||
return new LifecycleState(game, players);
|
||||
}
|
||||
|
||||
private LifecycleState confirmPending(
|
||||
PersistentState state,
|
||||
Set<UUID> onlinePlayerIds,
|
||||
Instant now
|
||||
) {
|
||||
GameState game = state.game();
|
||||
Optional<UUID> tyrant = game.tyrantId();
|
||||
Optional<UUID> vigilante = game.vigilanteId();
|
||||
Optional<PendingSelection> pendingTyrant = game.pendingTyrant();
|
||||
Optional<PendingSelection> pendingVigilante = game.pendingVigilante();
|
||||
if (pendingTyrant.isPresent()) {
|
||||
PendingSelection pending = pendingTyrant.orElseThrow();
|
||||
if (!now.isAfter(pending.expiresAt())
|
||||
&& onlinePlayerIds.contains(pending.candidateId())
|
||||
&& eligibleAtConfirmation(state.players().get(pending.candidateId()), now)
|
||||
&& vigilante.filter(pending.candidateId()::equals).isEmpty()) {
|
||||
tyrant = Optional.of(pending.candidateId());
|
||||
pendingTyrant = Optional.empty();
|
||||
}
|
||||
}
|
||||
if (pendingVigilante.isPresent()) {
|
||||
PendingSelection pending = pendingVigilante.orElseThrow();
|
||||
if (!now.isAfter(pending.expiresAt())
|
||||
&& onlinePlayerIds.contains(pending.candidateId())
|
||||
&& eligibleAtConfirmation(state.players().get(pending.candidateId()), now)
|
||||
&& tyrant.filter(pending.candidateId()::equals).isEmpty()) {
|
||||
vigilante = Optional.of(pending.candidateId());
|
||||
pendingVigilante = Optional.empty();
|
||||
}
|
||||
}
|
||||
return new LifecycleState(
|
||||
copyRoles(game, tyrant, vigilante, pendingTyrant, pendingVigilante),
|
||||
state.players()
|
||||
);
|
||||
}
|
||||
|
||||
private LifecycleState rerollExpiredTyrant(
|
||||
LifecycleState state,
|
||||
Set<UUID> onlinePlayerIds,
|
||||
Instant now
|
||||
) {
|
||||
Optional<PendingSelection> pending = state.game().pendingTyrant();
|
||||
if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) {
|
||||
return state;
|
||||
}
|
||||
return selectTyrant(
|
||||
state,
|
||||
Optional.of(pending.orElseThrow().candidateId()),
|
||||
onlinePlayerIds,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
private LifecycleState selectTyrant(
|
||||
LifecycleState state,
|
||||
Optional<UUID> previous,
|
||||
Set<UUID> onlinePlayerIds,
|
||||
Instant now
|
||||
) {
|
||||
Set<UUID> excluded = state.game().vigilanteId().map(Set::of).orElseGet(Set::of);
|
||||
Optional<UUID> selected = candidates.select(
|
||||
state.players(), excluded, previous, now, activityWindow, random
|
||||
);
|
||||
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
|
||||
Optional<PendingSelection> pending = selected
|
||||
.filter(playerId -> active.isEmpty())
|
||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
||||
GameState game = copyRoles(
|
||||
state.game(), active, state.game().vigilanteId(), pending,
|
||||
state.game().pendingVigilante()
|
||||
);
|
||||
return new LifecycleState(game, state.players());
|
||||
}
|
||||
|
||||
private LifecycleState rerollExpiredVigilante(
|
||||
LifecycleState state,
|
||||
Set<UUID> onlinePlayerIds,
|
||||
Instant now
|
||||
) {
|
||||
Optional<PendingSelection> pending = state.game().pendingVigilante();
|
||||
if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) {
|
||||
return state;
|
||||
}
|
||||
return rerollVigilante(
|
||||
state.asPersistentState(),
|
||||
Optional.of(pending.orElseThrow().candidateId()),
|
||||
onlinePlayerIds,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isInactive(
|
||||
Optional<UUID> roleHolder,
|
||||
Map<UUID, PlayerState> players,
|
||||
Instant now
|
||||
) {
|
||||
if (roleHolder.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
PlayerState player = players.get(roleHolder.orElseThrow());
|
||||
return player != null
|
||||
&& player.lastLogin().isPresent()
|
||||
&& !player.lastLogin().orElseThrow().plus(inactivity).isAfter(now);
|
||||
}
|
||||
|
||||
private static boolean eligibleAtConfirmation(PlayerState player, Instant now) {
|
||||
return player != null && player.optedOutUntil().isEmpty();
|
||||
}
|
||||
|
||||
private static Map<UUID, PlayerState> clearFollowers(
|
||||
Map<UUID, PlayerState> players,
|
||||
Optional<UUID> previousVigilante
|
||||
) {
|
||||
if (previousVigilante.isEmpty()) {
|
||||
return players;
|
||||
}
|
||||
Map<UUID, PlayerState> updated = new HashMap<>();
|
||||
UUID previous = previousVigilante.orElseThrow();
|
||||
for (PlayerState player : players.values()) {
|
||||
Optional<UUID> followerOf = player.followerOf().filter(id -> !id.equals(previous));
|
||||
updated.put(player.playerId(), new PlayerState(
|
||||
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
|
||||
player.tyrantClass(), followerOf, player.cooldownEnds(),
|
||||
player.readyAbilityItems(), player.capturedMobs()
|
||||
));
|
||||
}
|
||||
return Map.copyOf(updated);
|
||||
}
|
||||
|
||||
private static GameState copyRoles(
|
||||
GameState game,
|
||||
Optional<UUID> tyrant,
|
||||
Optional<UUID> vigilante,
|
||||
Optional<PendingSelection> pendingTyrant,
|
||||
Optional<PendingSelection> pendingVigilante
|
||||
) {
|
||||
return new GameState(
|
||||
game.lifecycle(), tyrant, vigilante, pendingTyrant, pendingVigilante,
|
||||
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
|
||||
game.unspentChoices(), game.purchases()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
public final class RoleMaintenanceTask implements Runnable {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final RoleMaintenanceService maintenance;
|
||||
private final TyrantPresentation presentation;
|
||||
private final Server server;
|
||||
private final Clock clock;
|
||||
|
||||
public RoleMaintenanceTask(
|
||||
TyrantStateManager stateManager,
|
||||
RoleMaintenanceService maintenance,
|
||||
TyrantPresentation presentation,
|
||||
Server server,
|
||||
Clock clock
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.maintenance = maintenance;
|
||||
this.presentation = presentation;
|
||||
this.server = server;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
PersistentState before = stateManager.snapshot();
|
||||
Set<UUID> online = server.getOnlinePlayers().stream()
|
||||
.map(Player::getUniqueId)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
LifecycleState after = maintenance.maintain(before, online, clock.instant());
|
||||
if (!after.asPersistentState().equals(before)) {
|
||||
stateManager.replaceState(after);
|
||||
stateManager.saveIfDirty();
|
||||
presentation.reconcile(after.game().tyrantId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class RoleRelinquishmentService {
|
||||
private final TyrantSuccessionService succession;
|
||||
private final RoleMaintenanceService maintenance;
|
||||
|
||||
public RoleRelinquishmentService(
|
||||
TyrantSuccessionService succession,
|
||||
RoleMaintenanceService maintenance
|
||||
) {
|
||||
this.succession = succession;
|
||||
this.maintenance = maintenance;
|
||||
}
|
||||
|
||||
public RelinquishmentResult relinquish(
|
||||
PersistentState state,
|
||||
UUID playerId,
|
||||
Set<UUID> onlinePlayerIds,
|
||||
Instant now
|
||||
) {
|
||||
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
|
||||
return new RelinquishmentResult(
|
||||
new LifecycleState(state.game(), state.players()),
|
||||
RelinquishmentStatus.GAME_NOT_RUNNING
|
||||
);
|
||||
}
|
||||
if (state.game().tyrantId().filter(playerId::equals).isPresent()) {
|
||||
return new RelinquishmentResult(
|
||||
succession.succeed(state, Optional.empty(), onlinePlayerIds, now),
|
||||
RelinquishmentStatus.RELINQUISHED
|
||||
);
|
||||
}
|
||||
if (state.game().vigilanteId().filter(playerId::equals).isPresent()) {
|
||||
return new RelinquishmentResult(
|
||||
maintenance.rerollVigilante(
|
||||
state, Optional.of(playerId), onlinePlayerIds, now
|
||||
),
|
||||
RelinquishmentStatus.RELINQUISHED
|
||||
);
|
||||
}
|
||||
return new RelinquishmentResult(
|
||||
new LifecycleState(state.game(), state.players()),
|
||||
RelinquishmentStatus.NO_CENTRAL_ROLE
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
@@ -9,6 +10,12 @@ import org.bukkit.plugin.java.JavaPlugin;
|
||||
public final class SpigotTyrantPlugin extends JavaPlugin {
|
||||
private PluginSettings settings;
|
||||
private TyrantStateManager stateManager;
|
||||
private TyrantPresentation tyrantPresentation;
|
||||
private VigilanteEffectController vigilanteEffects;
|
||||
private AssassinEffectController assassinEffects;
|
||||
private FixerEffectController fixerEffects;
|
||||
private TyrantEffectController tyrantEffects;
|
||||
private OptOutGlowController optOutGlow;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -26,14 +33,186 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
Clock clock = Clock.systemUTC();
|
||||
RoleCandidateSelector candidateSelector = new RoleCandidateSelector();
|
||||
java.util.random.RandomGenerator random = java.util.random.RandomGenerator.getDefault();
|
||||
TyrantSuccessionService succession = new TyrantSuccessionService(
|
||||
candidateSelector,
|
||||
settings.candidateActivityWindow(),
|
||||
settings.pendingSelectionTimeout(),
|
||||
random
|
||||
);
|
||||
RoleMaintenanceService maintenance = new RoleMaintenanceService(
|
||||
candidateSelector,
|
||||
succession,
|
||||
settings.roleInactivity(),
|
||||
settings.candidateActivityWindow(),
|
||||
settings.pendingSelectionTimeout(),
|
||||
random
|
||||
);
|
||||
BukkitOnlinePlayerDirectory onlinePlayers = new BukkitOnlinePlayerDirectory();
|
||||
FollowerService followers = new FollowerService();
|
||||
AbilityReadinessService readiness = new AbilityReadinessService();
|
||||
AbilityItemService abilityItems = new BukkitAbilityItemService(this, settings);
|
||||
CapturedMobItemService capturedMobItems = new BukkitCapturedMobItemService(
|
||||
this, settings
|
||||
);
|
||||
CapturedMobService capturedMobs = new CapturedMobService();
|
||||
CapturedMobInventoryService capturedMobInventory = new CapturedMobInventoryService(
|
||||
capturedMobItems, settings
|
||||
);
|
||||
AssassinAbilityService assassinAbilities = new AssassinAbilityService(
|
||||
readiness, settings
|
||||
);
|
||||
assassinEffects = new AssassinEffectController(
|
||||
stateManager, getServer(), settings, clock
|
||||
);
|
||||
FixerAbilityService fixerAbilities = new FixerAbilityService(readiness, settings);
|
||||
fixerEffects = new FixerEffectController(stateManager, getServer(), settings, clock);
|
||||
tyrantEffects = new TyrantEffectController(stateManager, getServer(), settings);
|
||||
optOutGlow = new OptOutGlowController(stateManager, getServer(), settings);
|
||||
VigilanteCombatTracker combatTracker = new VigilanteCombatTracker();
|
||||
vigilanteEffects = new VigilanteEffectController(
|
||||
stateManager, getServer(), combatTracker, settings, clock
|
||||
);
|
||||
tyrantPresentation = new BukkitTyrantPresentation(getServer());
|
||||
ManagedRoleEffects managedEffects = new BukkitManagedRoleEffects(
|
||||
stateManager, tyrantPresentation
|
||||
);
|
||||
managedEffects.restoreAll();
|
||||
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
|
||||
.setExecutor(new TyrantCommand(stateManager, new TyrantProgressionService()));
|
||||
.setExecutor(new TyrantCommand(
|
||||
stateManager,
|
||||
new TyrantProgressionService(),
|
||||
new ClassAssignmentService(),
|
||||
onlinePlayers,
|
||||
clock,
|
||||
new ParticipationService(settings.optOutDuration()),
|
||||
new RoleRelinquishmentService(succession, maintenance),
|
||||
tyrantPresentation,
|
||||
abilityItems,
|
||||
new TyrantAbilityService(settings.rosterIntelligenceCooldown())
|
||||
));
|
||||
Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata")
|
||||
.setExecutor(new VigilanteCommand(stateManager, followers, onlinePlayers));
|
||||
Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata")
|
||||
.setExecutor(new TyrantAdminCommand(
|
||||
stateManager,
|
||||
new GameLifecycleService(),
|
||||
onlinePlayers,
|
||||
managedEffects,
|
||||
clock,
|
||||
candidateSelector,
|
||||
settings.candidateActivityWindow(),
|
||||
settings.pendingSelectionTimeout(),
|
||||
random
|
||||
));
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new PlayerJoinListener(stateManager, clock, tyrantPresentation),
|
||||
this
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new TyrantDeathListener(
|
||||
stateManager, succession, tyrantPresentation, getServer(), clock
|
||||
),
|
||||
this
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new VigilanteDeathListener(
|
||||
stateManager, new TyrantProgressionService(), maintenance,
|
||||
getServer(), clock
|
||||
),
|
||||
this
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new ParticipationCombatListener(
|
||||
stateManager, new ParticipationCombatService()
|
||||
),
|
||||
this
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(optOutGlow, this);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new VigilanteCombatListener(
|
||||
stateManager, combatTracker, clock, settings.vigilanteCombatDuration()
|
||||
),
|
||||
this
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new BoundItemListener(abilityItems),
|
||||
this
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new TamerListener(
|
||||
stateManager, abilityItems, capturedMobItems, capturedMobs,
|
||||
settings, getServer()
|
||||
),
|
||||
this
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new AssassinItemListener(
|
||||
stateManager, abilityItems, assassinAbilities, settings, clock
|
||||
),
|
||||
this
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new AssassinJumpListener(stateManager, assassinAbilities, clock),
|
||||
this
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new FixerItemListener(
|
||||
stateManager, abilityItems, fixerAbilities, settings, clock
|
||||
),
|
||||
this
|
||||
);
|
||||
long maintenanceTicks = Math.max(
|
||||
1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L)
|
||||
);
|
||||
getServer().getScheduler().runTaskTimer(
|
||||
this,
|
||||
new RoleMaintenanceTask(
|
||||
stateManager, maintenance, tyrantPresentation, getServer(), clock
|
||||
),
|
||||
maintenanceTicks,
|
||||
maintenanceTicks
|
||||
);
|
||||
getServer().getScheduler().runTaskTimer(this, vigilanteEffects, 10L, 10L);
|
||||
getServer().getScheduler().runTaskTimer(this, assassinEffects, 10L, 10L);
|
||||
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
|
||||
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
|
||||
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
|
||||
getServer().getScheduler().runTaskTimer(
|
||||
this,
|
||||
new AbilityItemRefreshTask(
|
||||
stateManager, readiness, abilityItems, capturedMobInventory,
|
||||
getServer(), clock
|
||||
),
|
||||
20L,
|
||||
20L
|
||||
);
|
||||
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
|
||||
getLogger().info("Spigot Tyrant enabled.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (optOutGlow != null) {
|
||||
optOutGlow.clearAll();
|
||||
}
|
||||
if (tyrantEffects != null) {
|
||||
tyrantEffects.clearAll();
|
||||
}
|
||||
if (fixerEffects != null) {
|
||||
fixerEffects.clearAll();
|
||||
}
|
||||
if (assassinEffects != null) {
|
||||
assassinEffects.clearAll();
|
||||
}
|
||||
if (vigilanteEffects != null) {
|
||||
vigilanteEffects.clear();
|
||||
}
|
||||
if (tyrantPresentation != null) {
|
||||
tyrantPresentation.clear();
|
||||
}
|
||||
if (stateManager != null) {
|
||||
stateManager.saveIfDirty();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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());
|
||||
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,207 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.random.RandomGenerator;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
public final class TyrantAdminCommand implements CommandExecutor {
|
||||
private static final String PERMISSION = "spigottyrant.admin";
|
||||
private final TyrantStateManager stateManager;
|
||||
private final GameLifecycleService lifecycle;
|
||||
private final OnlinePlayerDirectory onlinePlayers;
|
||||
private final ManagedRoleEffects effects;
|
||||
private final Clock clock;
|
||||
private final RoleCandidateSelector candidateSelector;
|
||||
private final Duration activityWindow;
|
||||
private final Duration pendingTimeout;
|
||||
private final RandomGenerator random;
|
||||
|
||||
public TyrantAdminCommand(
|
||||
TyrantStateManager stateManager,
|
||||
GameLifecycleService lifecycle,
|
||||
OnlinePlayerDirectory onlinePlayers,
|
||||
ManagedRoleEffects effects,
|
||||
Clock clock
|
||||
) {
|
||||
this(
|
||||
stateManager, lifecycle, onlinePlayers, effects, clock,
|
||||
new RoleCandidateSelector(), Duration.ofHours(24), Duration.ofHours(24),
|
||||
RandomGenerator.getDefault()
|
||||
);
|
||||
}
|
||||
|
||||
public TyrantAdminCommand(
|
||||
TyrantStateManager stateManager,
|
||||
GameLifecycleService lifecycle,
|
||||
OnlinePlayerDirectory onlinePlayers,
|
||||
ManagedRoleEffects effects,
|
||||
Clock clock,
|
||||
RoleCandidateSelector candidateSelector,
|
||||
Duration activityWindow,
|
||||
Duration pendingTimeout,
|
||||
RandomGenerator random
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.lifecycle = lifecycle;
|
||||
this.onlinePlayers = onlinePlayers;
|
||||
this.effects = effects;
|
||||
this.clock = clock;
|
||||
this.candidateSelector = candidateSelector;
|
||||
this.activityWindow = activityWindow;
|
||||
this.pendingTimeout = pendingTimeout;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(
|
||||
CommandSender sender,
|
||||
Command command,
|
||||
String label,
|
||||
String[] arguments
|
||||
) {
|
||||
if (!sender.hasPermission(PERMISSION)) {
|
||||
sender.sendMessage(ChatColor.RED + "You do not have permission to administer Tyrant.");
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 0 || arguments[0].equalsIgnoreCase("status")) {
|
||||
showStatus(sender);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
||||
case "start" -> start(sender, arguments);
|
||||
case "pause" -> pause(sender);
|
||||
case "resume" -> resume(sender);
|
||||
case "reset" -> reset(sender, arguments);
|
||||
default -> usage(sender);
|
||||
}
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
sender.sendMessage(ChatColor.RED + exception.getMessage());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void start(CommandSender sender, String[] arguments) {
|
||||
if (arguments.length < 2 || arguments.length > 3) {
|
||||
sender.sendMessage(ChatColor.YELLOW
|
||||
+ "Usage: /tyrantadmin start <tyrant> [vigilante]");
|
||||
return;
|
||||
}
|
||||
Player tyrant = onlinePlayers.findByName(arguments[1]);
|
||||
if (tyrant == null) {
|
||||
sender.sendMessage(ChatColor.RED + "The initial Tyrant must be online.");
|
||||
return;
|
||||
}
|
||||
Optional<Player> vigilante = Optional.empty();
|
||||
if (arguments.length == 3) {
|
||||
vigilante = Optional.ofNullable(onlinePlayers.findByName(arguments[2]));
|
||||
if (vigilante.isEmpty()) {
|
||||
sender.sendMessage(ChatColor.RED + "The initial Vigilante must be online.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
stateManager.player(tyrant.getUniqueId(), tyrant.getName());
|
||||
vigilante.ifPresent(player -> stateManager.player(player.getUniqueId(), player.getName()));
|
||||
InstantHolder now = new InstantHolder(clock.instant());
|
||||
Optional<UUID> randomCandidate = vigilante.isEmpty()
|
||||
? candidateSelector.select(
|
||||
stateManager.players(), Set.of(tyrant.getUniqueId()), Optional.empty(),
|
||||
now.value(), activityWindow, random
|
||||
)
|
||||
: Optional.empty();
|
||||
Optional<UUID> activeVigilante = vigilante.map(Player::getUniqueId);
|
||||
if (activeVigilante.isEmpty() && randomCandidate.isPresent()
|
||||
&& onlinePlayers.findById(randomCandidate.orElseThrow()) != null) {
|
||||
activeVigilante = randomCandidate;
|
||||
}
|
||||
LifecycleState started = lifecycle.start(
|
||||
stateManager.snapshot(), tyrant.getUniqueId(), activeVigilante, now.value()
|
||||
);
|
||||
if (activeVigilante.isEmpty() && randomCandidate.isPresent()) {
|
||||
GameState game = started.game();
|
||||
started = new LifecycleState(new GameState(
|
||||
game.lifecycle(), game.tyrantId(), game.vigilanteId(), game.pendingTyrant(),
|
||||
Optional.of(new PendingSelection(
|
||||
randomCandidate.orElseThrow(), now.value().plus(pendingTimeout)
|
||||
)),
|
||||
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
|
||||
game.unspentChoices(), game.purchases()
|
||||
), started.players());
|
||||
}
|
||||
stateManager.replaceState(started);
|
||||
stateManager.saveIfDirty();
|
||||
effects.restoreAll();
|
||||
sender.sendMessage(ChatColor.GREEN + "Tyrant game started.");
|
||||
if (vigilante.isEmpty()) {
|
||||
sender.sendMessage(ChatColor.YELLOW
|
||||
+ "An eligible recently active Vigilante will be selected automatically.");
|
||||
}
|
||||
}
|
||||
|
||||
private void pause(CommandSender sender) {
|
||||
LifecycleState paused = lifecycle.pause(stateManager.snapshot(), clock.instant());
|
||||
stateManager.replaceState(paused);
|
||||
stateManager.saveIfDirty();
|
||||
effects.suppressAll();
|
||||
sender.sendMessage(ChatColor.GREEN + "Tyrant game paused.");
|
||||
}
|
||||
|
||||
private void resume(CommandSender sender) {
|
||||
LifecycleState resumed = lifecycle.resume(stateManager.snapshot(), clock.instant());
|
||||
stateManager.replaceState(resumed);
|
||||
stateManager.saveIfDirty();
|
||||
effects.restoreAll();
|
||||
sender.sendMessage(ChatColor.GREEN + "Tyrant game resumed.");
|
||||
}
|
||||
|
||||
private void reset(CommandSender sender, String[] arguments) {
|
||||
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) {
|
||||
sender.sendMessage(ChatColor.RED
|
||||
+ "This clears the game. Use /tyrantadmin reset confirm.");
|
||||
return;
|
||||
}
|
||||
stateManager.replaceState(lifecycle.reset(stateManager.snapshot()));
|
||||
stateManager.saveIfDirty();
|
||||
effects.suppressAll();
|
||||
sender.sendMessage(ChatColor.GREEN + "Tyrant game reset.");
|
||||
}
|
||||
|
||||
private void showStatus(CommandSender sender) {
|
||||
GameState game = stateManager.game();
|
||||
sender.sendMessage(ChatColor.GOLD + "Lifecycle: " + game.lifecycle());
|
||||
sender.sendMessage("Tyrant: " + game.tyrantId().map(Object::toString).orElse("none"));
|
||||
sender.sendMessage("Vigilante: "
|
||||
+ game.vigilanteId().map(Object::toString).orElse("none"));
|
||||
sender.sendMessage("Level: " + game.tyrantLevel()
|
||||
+ ", choices: " + game.unspentChoices()
|
||||
+ ", purchases: " + game.purchases());
|
||||
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
|
||||
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
|
||||
for (PlayerState player : stateManager.players().values()) {
|
||||
boolean optedOut = player.optedOutUntil().isPresent();
|
||||
sender.sendMessage(
|
||||
player.latestName() + " (" + player.playerId() + "): class="
|
||||
+ player.tyrantClass() + ", followerOf="
|
||||
+ player.followerOf().map(Object::toString).orElse("none")
|
||||
+ ", optedOut=" + optedOut + ", cooldowns=" + player.cooldownEnds()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private record InstantHolder(java.time.Instant value) {
|
||||
}
|
||||
|
||||
private static void usage(CommandSender sender) {
|
||||
sender.sendMessage(ChatColor.YELLOW
|
||||
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm>");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
@@ -10,13 +12,64 @@ import org.bukkit.entity.Player;
|
||||
public final class TyrantCommand implements CommandExecutor {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final TyrantProgressionService progression;
|
||||
private final ClassAssignmentService assignments;
|
||||
private final OnlinePlayerDirectory onlinePlayers;
|
||||
private final Clock clock;
|
||||
private final ParticipationService participation;
|
||||
private final RoleRelinquishmentService relinquishment;
|
||||
private final TyrantPresentation presentation;
|
||||
private final AbilityItemService abilityItems;
|
||||
private final TyrantAbilityService tyrantAbilities;
|
||||
|
||||
public TyrantCommand(
|
||||
TyrantStateManager stateManager,
|
||||
TyrantProgressionService progression
|
||||
) {
|
||||
this(
|
||||
stateManager,
|
||||
progression,
|
||||
new ClassAssignmentService(),
|
||||
new BukkitOnlinePlayerDirectory(),
|
||||
Clock.systemUTC()
|
||||
);
|
||||
}
|
||||
|
||||
TyrantCommand(
|
||||
TyrantStateManager stateManager,
|
||||
TyrantProgressionService progression,
|
||||
ClassAssignmentService assignments,
|
||||
OnlinePlayerDirectory onlinePlayers,
|
||||
Clock clock
|
||||
) {
|
||||
this(
|
||||
stateManager, progression, assignments, onlinePlayers, clock,
|
||||
new ParticipationService(Duration.ofDays(7)), null, null, null,
|
||||
new TyrantAbilityService(Duration.ofHours(24))
|
||||
);
|
||||
}
|
||||
|
||||
public TyrantCommand(
|
||||
TyrantStateManager stateManager,
|
||||
TyrantProgressionService progression,
|
||||
ClassAssignmentService assignments,
|
||||
OnlinePlayerDirectory onlinePlayers,
|
||||
Clock clock,
|
||||
ParticipationService participation,
|
||||
RoleRelinquishmentService relinquishment,
|
||||
TyrantPresentation presentation,
|
||||
AbilityItemService abilityItems,
|
||||
TyrantAbilityService tyrantAbilities
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.progression = progression;
|
||||
this.assignments = assignments;
|
||||
this.onlinePlayers = onlinePlayers;
|
||||
this.clock = clock;
|
||||
this.participation = participation;
|
||||
this.relinquishment = relinquishment;
|
||||
this.presentation = presentation;
|
||||
this.abilityItems = abilityItems;
|
||||
this.tyrantAbilities = tyrantAbilities;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -38,7 +91,39 @@ public final class TyrantCommand implements CommandExecutor {
|
||||
buy(player, arguments[1]);
|
||||
return true;
|
||||
}
|
||||
player.sendMessage(ChatColor.YELLOW + "Usage: /tyrant choices | /tyrant buy <unlock>");
|
||||
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("assign")) {
|
||||
assign(player, arguments[1], arguments[2]);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) {
|
||||
showPlayerStatus(player);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("optout")) {
|
||||
changeParticipation(player, false);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("optin")) {
|
||||
changeParticipation(player, true);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length >= 1 && arguments[0].equalsIgnoreCase("relinquish")) {
|
||||
relinquish(player, arguments);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("item")) {
|
||||
recoverItems(player);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("intelligence")) {
|
||||
useIntelligence(player);
|
||||
return true;
|
||||
}
|
||||
player.sendMessage(
|
||||
ChatColor.YELLOW
|
||||
+ "Usage: /tyrant <status|choices|buy|assign|item|intelligence|optout|optin|"
|
||||
+ "relinquish confirm>"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -79,8 +164,146 @@ public final class TyrantCommand implements CommandExecutor {
|
||||
} else {
|
||||
player.sendMessage(
|
||||
ChatColor.RED + "Could not purchase " + unlock.name() + ": "
|
||||
+ result[0].status().name().toLowerCase(Locale.ROOT).replace('_', ' ')
|
||||
+ readable(result[0].status())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void assign(Player tyrant, String requestedClass, String targetName) {
|
||||
TyrantClass tyrantClass;
|
||||
try {
|
||||
tyrantClass = TyrantClass.valueOf(requestedClass.toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
tyrant.sendMessage(ChatColor.RED + "Unknown class. Use assassin, fixer, or tamer.");
|
||||
return;
|
||||
}
|
||||
Player target = onlinePlayers.findByName(targetName);
|
||||
if (target == null) {
|
||||
tyrant.sendMessage(ChatColor.RED + "That player must be online.");
|
||||
return;
|
||||
}
|
||||
stateManager.player(target.getUniqueId(), target.getName());
|
||||
ClassAssignmentResult result = assignments.assign(
|
||||
stateManager.game(),
|
||||
stateManager.players(),
|
||||
tyrant.getUniqueId(),
|
||||
target.getUniqueId(),
|
||||
tyrantClass,
|
||||
clock.instant()
|
||||
);
|
||||
if (result.status() != ClassAssignmentStatus.ASSIGNED) {
|
||||
tyrant.sendMessage(
|
||||
ChatColor.RED + "Could not assign " + tyrantClass.name() + ": "
|
||||
+ readable(result.status())
|
||||
);
|
||||
return;
|
||||
}
|
||||
stateManager.replacePlayers(result.players());
|
||||
stateManager.saveIfDirty();
|
||||
result.previousHolderId().map(onlinePlayers::findById).ifPresent(previous -> {
|
||||
if (abilityItems != null) {
|
||||
abilityItems.removeAll(previous);
|
||||
}
|
||||
previous.sendMessage(ChatColor.YELLOW + "You are no longer the "
|
||||
+ tyrantClass.name() + ".");
|
||||
});
|
||||
if (abilityItems != null) {
|
||||
abilityItems.removeInvalid(target, result.players().get(target.getUniqueId()));
|
||||
abilityItems.recover(target, result.players().get(target.getUniqueId()));
|
||||
}
|
||||
target.sendMessage(ChatColor.GREEN + "You are now the " + tyrantClass.name() + ".");
|
||||
tyrant.sendMessage(
|
||||
ChatColor.GREEN + "Assigned " + tyrantClass.name() + " to " + target.getName() + "."
|
||||
);
|
||||
}
|
||||
|
||||
private void showPlayerStatus(Player player) {
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
PlayerStatusMessages.forJoin(stateManager.game(), state, clock.instant())
|
||||
.forEach(player::sendMessage);
|
||||
}
|
||||
|
||||
private void changeParticipation(Player player, boolean optIn) {
|
||||
ParticipationResult[] result = new ParticipationResult[1];
|
||||
stateManager.updatePlayer(player.getUniqueId(), player.getName(), current -> {
|
||||
result[0] = optIn
|
||||
? participation.optIn(current, clock.instant())
|
||||
: participation.optOut(stateManager.game(), current, clock.instant());
|
||||
return result[0].player();
|
||||
});
|
||||
if (result[0].status() == ParticipationStatus.OPTED_IN
|
||||
|| result[0].status() == ParticipationStatus.OPTED_OUT) {
|
||||
stateManager.saveIfDirty();
|
||||
}
|
||||
if (result[0].status() == ParticipationStatus.MUST_RELINQUISH_ROLE) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Use /tyrant relinquish confirm before opting out.");
|
||||
return;
|
||||
}
|
||||
if (result[0].status() == ParticipationStatus.COOLDOWN_ACTIVE) {
|
||||
player.sendMessage(ChatColor.RED + "You may opt in at "
|
||||
+ result[0].player().optedOutUntil().orElseThrow() + ".");
|
||||
return;
|
||||
}
|
||||
player.sendMessage(ChatColor.YELLOW + "Participation: "
|
||||
+ readable(result[0].status()) + ".");
|
||||
}
|
||||
|
||||
private void 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) {
|
||||
if (abilityItems == null) {
|
||||
player.sendMessage(ChatColor.RED + "Ability item recovery is unavailable.");
|
||||
return;
|
||||
}
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
abilityItems.recover(player, state);
|
||||
player.sendMessage(ChatColor.YELLOW
|
||||
+ "Recovered every currently ready missing class item that could fit.");
|
||||
}
|
||||
|
||||
private void relinquish(Player player, String[] arguments) {
|
||||
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Relinquishing rerolls your role. Use /tyrant relinquish confirm.");
|
||||
return;
|
||||
}
|
||||
if (relinquishment == null) {
|
||||
player.sendMessage(ChatColor.RED + "Role relinquishment is unavailable.");
|
||||
return;
|
||||
}
|
||||
RelinquishmentResult result = relinquishment.relinquish(
|
||||
stateManager.snapshot(), player.getUniqueId(), onlinePlayers.onlinePlayerIds(),
|
||||
clock.instant()
|
||||
);
|
||||
if (result.status() == RelinquishmentStatus.RELINQUISHED) {
|
||||
stateManager.replaceState(result.state());
|
||||
stateManager.saveIfDirty();
|
||||
if (presentation != null) {
|
||||
presentation.reconcile(result.state().game().tyrantId());
|
||||
}
|
||||
}
|
||||
player.sendMessage(ChatColor.YELLOW + "Relinquishment: "
|
||||
+ readable(result.status()) + ".");
|
||||
}
|
||||
|
||||
private static String readable(Enum<?> status) {
|
||||
return status.name().toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
|
||||
public final class TyrantDeathListener implements Listener {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final TyrantSuccessionService succession;
|
||||
private final TyrantPresentation presentation;
|
||||
private final Server server;
|
||||
private final Clock clock;
|
||||
|
||||
public TyrantDeathListener(
|
||||
TyrantStateManager stateManager,
|
||||
TyrantSuccessionService succession,
|
||||
TyrantPresentation presentation,
|
||||
Server server,
|
||||
Clock clock
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.succession = succession;
|
||||
this.presentation = presentation;
|
||||
this.server = server;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onDeath(PlayerDeathEvent event) {
|
||||
UUID victimId = event.getEntity().getUniqueId();
|
||||
GameState game = stateManager.game();
|
||||
if (game.lifecycle() != GameLifecycle.RUNNING
|
||||
|| game.tyrantId().filter(victimId::equals).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Optional<UUID> killerId = Optional.ofNullable(event.getEntity().getKiller())
|
||||
.map(Player::getUniqueId);
|
||||
Set<UUID> online = server.getOnlinePlayers().stream()
|
||||
.map(Player::getUniqueId)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
LifecycleState succeeded = succession.succeed(
|
||||
stateManager.snapshot(), killerId, online, clock.instant()
|
||||
);
|
||||
stateManager.replaceState(succeeded);
|
||||
stateManager.saveIfDirty();
|
||||
presentation.reconcile(succeeded.game().tyrantId());
|
||||
String successor = succeeded.game().tyrantId()
|
||||
.map(id -> {
|
||||
Player player = server.getPlayer(id);
|
||||
return player == null ? id.toString() : player.getName();
|
||||
})
|
||||
.orElse("pending selection");
|
||||
String message = ChatColor.RED + successor + " is the new Tyrant!";
|
||||
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,10 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface TyrantPresentation {
|
||||
void reconcile(Optional<UUID> tyrantId);
|
||||
|
||||
void clear();
|
||||
}
|
||||
@@ -64,12 +64,29 @@ public final class TyrantStateManager {
|
||||
return changed;
|
||||
}
|
||||
|
||||
public void replacePlayers(Map<UUID, PlayerState> replacement) {
|
||||
players.clear();
|
||||
players.putAll(replacement);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
public GameState updateGame(UnaryOperator<GameState> update) {
|
||||
game = update.apply(game);
|
||||
dirty = true;
|
||||
return game;
|
||||
}
|
||||
|
||||
public void replaceState(LifecycleState replacement) {
|
||||
game = replacement.game();
|
||||
players.clear();
|
||||
players.putAll(replacement.players());
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
public PersistentState snapshot() {
|
||||
return new PersistentState(game, players);
|
||||
}
|
||||
|
||||
public boolean saveIfDirty() {
|
||||
if (!dirty) {
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.random.RandomGenerator;
|
||||
|
||||
public final class TyrantSuccessionService {
|
||||
private final RoleCandidateSelector candidates;
|
||||
private final Duration activityWindow;
|
||||
private final Duration pendingTimeout;
|
||||
private final RandomGenerator random;
|
||||
|
||||
public TyrantSuccessionService(
|
||||
RoleCandidateSelector candidates,
|
||||
Duration activityWindow,
|
||||
Duration pendingTimeout,
|
||||
RandomGenerator random
|
||||
) {
|
||||
this.candidates = candidates;
|
||||
this.activityWindow = activityWindow;
|
||||
this.pendingTimeout = pendingTimeout;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
public LifecycleState succeed(
|
||||
PersistentState state,
|
||||
Optional<UUID> killerId,
|
||||
Set<UUID> onlinePlayerIds,
|
||||
Instant now
|
||||
) {
|
||||
if (state.game().lifecycle() != GameLifecycle.RUNNING
|
||||
|| state.game().tyrantId().isEmpty()) {
|
||||
throw new IllegalStateException("a running Tyrant reign is required");
|
||||
}
|
||||
UUID formerTyrant = state.game().tyrantId().orElseThrow();
|
||||
Optional<UUID> formerVigilante = state.game().vigilanteId();
|
||||
Map<UUID, PlayerState> clearedPlayers = clearAssignments(state.players());
|
||||
Optional<UUID> selectedTyrant = killerId.isPresent()
|
||||
? killerId
|
||||
: candidates.select(
|
||||
clearedPlayers,
|
||||
Set.of(),
|
||||
Optional.of(formerTyrant),
|
||||
now,
|
||||
activityWindow,
|
||||
random
|
||||
);
|
||||
Optional<UUID> activeTyrant = selectedTyrant
|
||||
.filter(playerId -> killerId.isPresent() || onlinePlayerIds.contains(playerId));
|
||||
Optional<PendingSelection> pendingTyrant = selectedTyrant
|
||||
.filter(playerId -> activeTyrant.isEmpty())
|
||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
||||
|
||||
Optional<UUID> selectedVigilante = activeTyrant.flatMap(tyrantId ->
|
||||
candidates.select(
|
||||
clearedPlayers,
|
||||
Set.of(tyrantId),
|
||||
formerVigilante,
|
||||
now,
|
||||
activityWindow,
|
||||
random
|
||||
)
|
||||
);
|
||||
Optional<UUID> activeVigilante = selectedVigilante
|
||||
.filter(onlinePlayerIds::contains);
|
||||
Optional<PendingSelection> pendingVigilante = selectedVigilante
|
||||
.filter(playerId -> activeVigilante.isEmpty())
|
||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
||||
|
||||
GameState game = new GameState(
|
||||
GameLifecycle.RUNNING,
|
||||
activeTyrant,
|
||||
activeVigilante,
|
||||
pendingTyrant,
|
||||
pendingVigilante,
|
||||
Optional.empty(),
|
||||
state.game().accumulatedPausedTime(),
|
||||
0,
|
||||
1,
|
||||
Set.of()
|
||||
);
|
||||
return new LifecycleState(game, clearedPlayers);
|
||||
}
|
||||
|
||||
private static Map<UUID, PlayerState> clearAssignments(
|
||||
Map<UUID, PlayerState> players
|
||||
) {
|
||||
Map<UUID, PlayerState> cleared = new HashMap<>();
|
||||
for (PlayerState player : players.values()) {
|
||||
cleared.put(player.playerId(), new PlayerState(
|
||||
player.playerId(),
|
||||
player.latestName(),
|
||||
player.lastLogin(),
|
||||
player.optedOutUntil(),
|
||||
TyrantClass.NONE,
|
||||
Optional.empty(),
|
||||
Map.of(),
|
||||
Set.of(),
|
||||
player.capturedMobs()
|
||||
));
|
||||
}
|
||||
return Map.copyOf(cleared);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
|
||||
public final class VigilanteCombatListener implements Listener {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final VigilanteCombatTracker tracker;
|
||||
private final Clock clock;
|
||||
private final Duration combatDuration;
|
||||
|
||||
public VigilanteCombatListener(
|
||||
TyrantStateManager stateManager,
|
||||
VigilanteCombatTracker tracker,
|
||||
Clock clock,
|
||||
Duration combatDuration
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.tracker = tracker;
|
||||
this.clock = clock;
|
||||
this.combatDuration = combatDuration;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDamage(EntityDamageByEntityEvent event) {
|
||||
if (!(event.getEntity() instanceof Player damaged)
|
||||
|| !(event.getDamager() instanceof Player attacker)) {
|
||||
return;
|
||||
}
|
||||
GameState game = stateManager.game();
|
||||
if (game.lifecycle() != GameLifecycle.RUNNING
|
||||
|| game.tyrantId().isEmpty() || game.vigilanteId().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
UUID first = damaged.getUniqueId();
|
||||
UUID second = attacker.getUniqueId();
|
||||
UUID tyrant = game.tyrantId().orElseThrow();
|
||||
UUID vigilante = game.vigilanteId().orElseThrow();
|
||||
if ((first.equals(tyrant) && second.equals(vigilante))
|
||||
|| (first.equals(vigilante) && second.equals(tyrant))) {
|
||||
tracker.markCombat(clock.instant(), combatDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class VigilanteCombatTracker {
|
||||
private Optional<Instant> activeUntil = Optional.empty();
|
||||
|
||||
public void markCombat(Instant now, Duration duration) {
|
||||
activeUntil = Optional.of(now.plus(duration));
|
||||
}
|
||||
|
||||
public boolean isActive(Instant now) {
|
||||
return activeUntil.filter(deadline -> deadline.isAfter(now)).isPresent();
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
activeUntil = Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.Locale;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
public final class VigilanteCommand implements CommandExecutor {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final FollowerService followers;
|
||||
private final OnlinePlayerDirectory onlinePlayers;
|
||||
|
||||
public VigilanteCommand(
|
||||
TyrantStateManager stateManager,
|
||||
FollowerService followers,
|
||||
OnlinePlayerDirectory onlinePlayers
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.followers = followers;
|
||||
this.onlinePlayers = onlinePlayers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(
|
||||
CommandSender sender,
|
||||
Command command,
|
||||
String label,
|
||||
String[] arguments
|
||||
) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("This command must be used by a player.");
|
||||
return true;
|
||||
}
|
||||
FollowerResult result;
|
||||
Player target = null;
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("accept")) {
|
||||
result = followers.accept(stateManager.game(), stateManager.players(),
|
||||
player.getUniqueId());
|
||||
} else if (arguments.length == 1 && arguments[0].equalsIgnoreCase("leave")) {
|
||||
result = followers.leave(stateManager.game(), stateManager.players(),
|
||||
player.getUniqueId());
|
||||
} else if (arguments.length == 2
|
||||
&& (arguments[0].equalsIgnoreCase("invite")
|
||||
|| arguments[0].equalsIgnoreCase("dismiss"))) {
|
||||
target = onlinePlayers.findByName(arguments[1]);
|
||||
if (target == null) {
|
||||
player.sendMessage(ChatColor.RED + "That player must be online.");
|
||||
return true;
|
||||
}
|
||||
stateManager.player(target.getUniqueId(), target.getName());
|
||||
result = arguments[0].equalsIgnoreCase("invite")
|
||||
? followers.invite(
|
||||
stateManager.game(), stateManager.players(), player.getUniqueId(),
|
||||
target.getUniqueId()
|
||||
)
|
||||
: followers.dismiss(
|
||||
stateManager.game(), stateManager.players(), player.getUniqueId(),
|
||||
target.getUniqueId()
|
||||
);
|
||||
} else {
|
||||
player.sendMessage(ChatColor.YELLOW
|
||||
+ "Usage: /vigilante <invite <player>|accept|dismiss <player>|leave>");
|
||||
return true;
|
||||
}
|
||||
if (result.status() == FollowerStatus.JOINED
|
||||
|| result.status() == FollowerStatus.DISMISSED
|
||||
|| result.status() == FollowerStatus.LEFT) {
|
||||
stateManager.replacePlayers(result.players());
|
||||
stateManager.saveIfDirty();
|
||||
}
|
||||
player.sendMessage(ChatColor.YELLOW + "Follower action: "
|
||||
+ result.status().name().toLowerCase(Locale.ROOT).replace('_', ' ') + ".");
|
||||
if (target != null && result.status() == FollowerStatus.INVITED) {
|
||||
target.sendMessage(ChatColor.YELLOW + player.getName()
|
||||
+ " invited you to become a Follower. Use /vigilante accept.");
|
||||
} else if (target != null && result.status() == FollowerStatus.DISMISSED) {
|
||||
target.sendMessage(ChatColor.YELLOW + "You are no longer a Follower.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
|
||||
public final class VigilanteDeathListener implements Listener {
|
||||
private final TyrantStateManager stateManager;
|
||||
private final TyrantProgressionService progression;
|
||||
private final RoleMaintenanceService maintenance;
|
||||
private final Server server;
|
||||
private final Clock clock;
|
||||
|
||||
public VigilanteDeathListener(
|
||||
TyrantStateManager stateManager,
|
||||
TyrantProgressionService progression,
|
||||
RoleMaintenanceService maintenance,
|
||||
Server server,
|
||||
Clock clock
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.progression = progression;
|
||||
this.maintenance = maintenance;
|
||||
this.server = server;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onDeath(PlayerDeathEvent event) {
|
||||
UUID victimId = event.getEntity().getUniqueId();
|
||||
GameState game = stateManager.game();
|
||||
if (game.lifecycle() != GameLifecycle.RUNNING
|
||||
|| game.vigilanteId().filter(victimId::equals).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Optional<UUID> killerId = Optional.ofNullable(event.getEntity().getKiller())
|
||||
.map(Player::getUniqueId);
|
||||
GameState progressed = progression.recordVigilanteDeath(game, killerId);
|
||||
PersistentState withProgress = new PersistentState(
|
||||
progressed, stateManager.players()
|
||||
);
|
||||
Set<UUID> online = server.getOnlinePlayers().stream()
|
||||
.map(Player::getUniqueId)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
LifecycleState rerolled = maintenance.rerollVigilante(
|
||||
withProgress, Optional.of(victimId), online, clock.instant()
|
||||
);
|
||||
stateManager.replaceState(rerolled);
|
||||
stateManager.saveIfDirty();
|
||||
String message = ChatColor.YELLOW + "The Vigilante died; a replacement "
|
||||
+ (rerolled.game().vigilanteId().isPresent() ? "has been selected." : "is pending.");
|
||||
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
public final class VigilanteEffectController implements Runnable {
|
||||
private static final int EFFECT_TICKS = 30;
|
||||
private final TyrantStateManager stateManager;
|
||||
private final Server server;
|
||||
private final VigilanteCombatTracker tracker;
|
||||
private final PluginSettings settings;
|
||||
private final Clock clock;
|
||||
private Optional<UUID> previousVigilante = Optional.empty();
|
||||
|
||||
public VigilanteEffectController(
|
||||
TyrantStateManager stateManager,
|
||||
Server server,
|
||||
VigilanteCombatTracker tracker,
|
||||
PluginSettings settings,
|
||||
Clock clock
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.server = server;
|
||||
this.tracker = tracker;
|
||||
this.settings = settings;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
GameState game = stateManager.game();
|
||||
if (!previousVigilante.equals(game.vigilanteId())) {
|
||||
previousVigilante.map(server::getPlayer).ifPresent(this::removeManagedEffects);
|
||||
previousVigilante = game.vigilanteId();
|
||||
}
|
||||
Player vigilante = game.vigilanteId().map(server::getPlayer).orElse(null);
|
||||
if (vigilante == null || game.lifecycle() != GameLifecycle.RUNNING
|
||||
|| !tracker.isActive(clock.instant()) || vigilante.isDead()) {
|
||||
if (vigilante != null) {
|
||||
removeManagedEffects(vigilante);
|
||||
}
|
||||
return;
|
||||
}
|
||||
int followers = nearbyLivingFollowers(game.vigilanteId().orElseThrow(), vigilante);
|
||||
VigilantePower.Levels levels = VigilantePower.forFollowers(
|
||||
followers, settings.followerStrengthCap(), settings.followerResistanceCap()
|
||||
);
|
||||
apply(vigilante, PotionEffectType.STRENGTH, levels.strength());
|
||||
apply(vigilante, PotionEffectType.RESISTANCE, levels.resistance());
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
previousVigilante.map(server::getPlayer).ifPresent(this::removeManagedEffects);
|
||||
previousVigilante = Optional.empty();
|
||||
tracker.clear();
|
||||
}
|
||||
|
||||
private int nearbyLivingFollowers(UUID vigilanteId, Player vigilante) {
|
||||
int count = 0;
|
||||
double rangeSquared = settings.followerRangeBlocks()
|
||||
* settings.followerRangeBlocks();
|
||||
for (PlayerState state : stateManager.players().values()) {
|
||||
if (state.followerOf().filter(vigilanteId::equals).isEmpty()
|
||||
|| state.optedOutUntil().isPresent()) {
|
||||
continue;
|
||||
}
|
||||
Player follower = server.getPlayer(state.playerId());
|
||||
if (follower == null || follower.isDead()
|
||||
|| !follower.getWorld().equals(vigilante.getWorld())) {
|
||||
continue;
|
||||
}
|
||||
if (follower.getLocation().distanceSquared(vigilante.getLocation()) <= rangeSquared) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void apply(Player player, PotionEffectType type, int level) {
|
||||
if (level == 0) {
|
||||
player.removePotionEffect(type);
|
||||
return;
|
||||
}
|
||||
player.addPotionEffect(new PotionEffect(
|
||||
type, EFFECT_TICKS, level - 1, false, true, true
|
||||
));
|
||||
}
|
||||
|
||||
private void removeManagedEffects(Player player) {
|
||||
player.removePotionEffect(PotionEffectType.STRENGTH);
|
||||
player.removePotionEffect(PotionEffectType.RESISTANCE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public final class VigilantePower {
|
||||
private VigilantePower() {
|
||||
}
|
||||
|
||||
public static Levels forFollowers(
|
||||
int followerCount,
|
||||
int strengthCap,
|
||||
int resistanceCap
|
||||
) {
|
||||
if (followerCount < 0 || strengthCap < 1 || resistanceCap < 1) {
|
||||
throw new IllegalArgumentException("follower count and caps are invalid");
|
||||
}
|
||||
return new Levels(
|
||||
Math.min(followerCount, strengthCap),
|
||||
Math.min(followerCount, resistanceCap)
|
||||
);
|
||||
}
|
||||
|
||||
public record Levels(int strength, int resistance) {
|
||||
}
|
||||
}
|
||||
@@ -35,11 +35,15 @@ fixer-item:
|
||||
tamer-item:
|
||||
material: FISHING_ROD
|
||||
name: Tamer's Lead
|
||||
captured-mob-item:
|
||||
material: PAPER
|
||||
name: Captured Mob
|
||||
denied-mob-types:
|
||||
- ENDER_DRAGON
|
||||
- WITHER
|
||||
|
||||
# Tyrant and Vigilante effects
|
||||
vigilante-combat-seconds: 15
|
||||
roster-intelligence-cooldown-seconds: 86400
|
||||
tyrant-strength-level: 1
|
||||
tyrant-resistance-level: 1
|
||||
|
||||
@@ -7,4 +7,15 @@ author: dmg.games
|
||||
commands:
|
||||
tyrant:
|
||||
description: View and use Spigot Tyrant game features.
|
||||
usage: /tyrant [choices|buy <unlock>]
|
||||
usage: /tyrant <status|choices|buy|assign|item|intelligence|optout|optin|relinquish confirm>
|
||||
vigilante:
|
||||
description: Manage Vigilante Followers.
|
||||
usage: /vigilante <invite <player>|accept|dismiss <player>|leave>
|
||||
tyrantadmin:
|
||||
description: Administer the Spigot Tyrant game.
|
||||
usage: /tyrantadmin <status|start|pause|resume|reset confirm>
|
||||
permission: spigottyrant.admin
|
||||
permissions:
|
||||
spigottyrant.admin:
|
||||
description: Allows administration of Spigot Tyrant.
|
||||
default: op
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class AbilityReadinessServiceTest {
|
||||
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
|
||||
private final AbilityReadinessService service = new AbilityReadinessService();
|
||||
|
||||
@Test
|
||||
void successfulCooldownActivationConsumesItemAndRefreshRestoresIt() {
|
||||
PlayerState ready = player(
|
||||
TyrantClass.ASSASSIN, Map.of(), Set.of(Ability.ASSASSIN_INVISIBILITY)
|
||||
);
|
||||
|
||||
PlayerState used = service.consume(
|
||||
ready, Ability.ASSASSIN_INVISIBILITY, NOW, Duration.ofHours(1)
|
||||
);
|
||||
PlayerState refreshed = service.refresh(used, NOW.plus(Duration.ofHours(1)));
|
||||
|
||||
assertEquals(Set.of(), used.readyAbilityItems());
|
||||
assertEquals(NOW.plus(Duration.ofHours(1)),
|
||||
used.cooldownEnds().get(Ability.ASSASSIN_INVISIBILITY));
|
||||
assertEquals(Set.of(Ability.ASSASSIN_INVISIBILITY),
|
||||
refreshed.readyAbilityItems());
|
||||
assertEquals(Map.of(), refreshed.cooldownEnds());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamerItemRemainsReadyWithoutCooldown() {
|
||||
PlayerState tamer = player(
|
||||
TyrantClass.TAMER, Map.of(), Set.of(Ability.TAMER_CAPTURE)
|
||||
);
|
||||
|
||||
PlayerState after = service.consume(
|
||||
tamer, Ability.TAMER_CAPTURE, NOW, Duration.ZERO
|
||||
);
|
||||
|
||||
assertEquals(tamer, after);
|
||||
}
|
||||
|
||||
private static PlayerState player(
|
||||
TyrantClass tyrantClass,
|
||||
Map<Ability, Instant> cooldowns,
|
||||
Set<Ability> ready
|
||||
) {
|
||||
return new PlayerState(
|
||||
UUID.fromString("11111111-1111-1111-1111-111111111111"), "Player",
|
||||
Optional.empty(), Optional.empty(), tyrantClass, Optional.empty(),
|
||||
cooldowns, ready, java.util.List.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class AssassinAbilityServiceTest {
|
||||
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
|
||||
private final PluginSettings settings = PluginSettings.from(Map.of());
|
||||
private final AssassinAbilityService service = new AssassinAbilityService(
|
||||
new AbilityReadinessService(), settings
|
||||
);
|
||||
|
||||
@Test
|
||||
void invisibilityConsumesItemAndDoublesDurationNearTyrant() {
|
||||
PlayerState ready = assassin(Set.of(Ability.ASSASSIN_INVISIBILITY), Map.of());
|
||||
|
||||
AbilityUseResult normal = service.activateInvisibility(ready, NOW, false);
|
||||
AbilityUseResult near = service.activateInvisibility(ready, NOW, true);
|
||||
|
||||
assertEquals(AbilityUseStatus.ACTIVATED, normal.status());
|
||||
assertEquals(NOW.plusSeconds(600), normal.player().cooldownEnds()
|
||||
.get(Ability.ASSASSIN_INVISIBILITY_ACTIVE));
|
||||
assertEquals(NOW.plusSeconds(1200), near.player().cooldownEnds()
|
||||
.get(Ability.ASSASSIN_INVISIBILITY_ACTIVE));
|
||||
assertEquals(NOW.plusSeconds(3600), normal.player().cooldownEnds()
|
||||
.get(Ability.ASSASSIN_INVISIBILITY));
|
||||
assertEquals(Set.of(), normal.player().readyAbilityItems());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doubleJumpAddsSpeedWeaknessAndSixtySecondCooldown() {
|
||||
PlayerState assassin = assassin(Set.of(), Map.of());
|
||||
|
||||
AbilityUseResult result = service.doubleJump(assassin, NOW);
|
||||
AbilityUseResult blocked = service.doubleJump(result.player(), NOW.plusSeconds(30));
|
||||
|
||||
assertEquals(AbilityUseStatus.ACTIVATED, result.status());
|
||||
assertEquals(NOW.plusSeconds(60), result.player().cooldownEnds()
|
||||
.get(Ability.ASSASSIN_DOUBLE_JUMP));
|
||||
assertEquals(NOW.plusSeconds(15), result.player().cooldownEnds()
|
||||
.get(Ability.ASSASSIN_SPEED_ACTIVE));
|
||||
assertEquals(NOW.plusSeconds(20), result.player().cooldownEnds()
|
||||
.get(Ability.ASSASSIN_WEAKNESS_ACTIVE));
|
||||
assertEquals(AbilityUseStatus.COOLDOWN, blocked.status());
|
||||
}
|
||||
|
||||
private static PlayerState assassin(Set<Ability> ready, Map<Ability, Instant> cooldowns) {
|
||||
return new PlayerState(
|
||||
UUID.fromString("11111111-1111-1111-1111-111111111111"), "Assassin",
|
||||
Optional.empty(), Optional.empty(), TyrantClass.ASSASSIN, Optional.empty(),
|
||||
cooldowns, ready, java.util.List.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,42 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scoreboard.Scoreboard;
|
||||
import org.bukkit.scoreboard.ScoreboardManager;
|
||||
import org.bukkit.scoreboard.Team;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BukkitTyrantPresentationTest {
|
||||
@Test
|
||||
void givesCurrentTyrantRedNameAndGlowingOutline() {
|
||||
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
Server server = mock(Server.class);
|
||||
ScoreboardManager manager = mock(ScoreboardManager.class);
|
||||
Scoreboard scoreboard = mock(Scoreboard.class);
|
||||
Team team = mock(Team.class);
|
||||
Player tyrant = mock(Player.class);
|
||||
when(server.getScoreboardManager()).thenReturn(manager);
|
||||
when(manager.getMainScoreboard()).thenReturn(scoreboard);
|
||||
when(scoreboard.getTeam("tyrant-red")).thenReturn(null);
|
||||
when(scoreboard.registerNewTeam("tyrant-red")).thenReturn(team);
|
||||
when(server.getPlayer(tyrantId)).thenReturn(tyrant);
|
||||
when(tyrant.getName()).thenReturn("TyrantPlayer");
|
||||
BukkitTyrantPresentation presentation = new BukkitTyrantPresentation(server);
|
||||
|
||||
presentation.reconcile(Optional.of(tyrantId));
|
||||
|
||||
verify(team).setColor(ChatColor.RED);
|
||||
verify(team).addEntry("TyrantPlayer");
|
||||
verify(tyrant).setGlowing(true);
|
||||
verify(tyrant).setPlayerListName(ChatColor.RED + "TyrantPlayer");
|
||||
verify(tyrant).setDisplayName(ChatColor.RED + "TyrantPlayer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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 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,29 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class ClassAssignmentMessagesTest {
|
||||
@Test
|
||||
void loginReminderNamesClassAndControls() {
|
||||
PlayerState assassin = new PlayerState(
|
||||
UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||||
"Player",
|
||||
java.util.Optional.empty(),
|
||||
java.util.Optional.empty(),
|
||||
TyrantClass.ASSASSIN,
|
||||
java.util.Optional.empty(),
|
||||
java.util.Map.of(),
|
||||
java.util.Set.of(),
|
||||
java.util.List.of()
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
"Class: ASSASSIN. Use your Assassin Cloak; double-jump while airborne. "
|
||||
+ "Use /tyrant item to recover ready class items.",
|
||||
ClassAssignmentMessages.loginReminder(assassin)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class ClassAssignmentServiceTest {
|
||||
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
private static final UUID VIGILANTE = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
private static final UUID FOLLOWER = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
|
||||
private final ClassAssignmentService service = new ClassAssignmentService();
|
||||
|
||||
@Test
|
||||
void purchasedClassCanBeAssignedToVigilanteOrFollower() {
|
||||
GameState game = gameWith(TyrantUnlock.ASSASSIN);
|
||||
PlayerState vigilante = PlayerState.newPlayer(VIGILANTE, "Vigilante");
|
||||
PlayerState follower = withFollower(
|
||||
PlayerState.newPlayer(FOLLOWER, "Follower"),
|
||||
VIGILANTE
|
||||
);
|
||||
|
||||
ClassAssignmentResult vigilanteResult = service.assign(
|
||||
game,
|
||||
Map.of(VIGILANTE, vigilante, FOLLOWER, follower),
|
||||
TYRANT,
|
||||
VIGILANTE,
|
||||
TyrantClass.ASSASSIN,
|
||||
NOW
|
||||
);
|
||||
ClassAssignmentResult followerResult = service.assign(
|
||||
game,
|
||||
vigilanteResult.players(),
|
||||
TYRANT,
|
||||
FOLLOWER,
|
||||
TyrantClass.ASSASSIN,
|
||||
NOW
|
||||
);
|
||||
|
||||
assertEquals(ClassAssignmentStatus.ASSIGNED, vigilanteResult.status());
|
||||
assertEquals(TyrantClass.ASSASSIN,
|
||||
vigilanteResult.players().get(VIGILANTE).tyrantClass());
|
||||
assertEquals(Set.of(Ability.ASSASSIN_INVISIBILITY),
|
||||
vigilanteResult.players().get(VIGILANTE).readyAbilityItems());
|
||||
assertEquals(ClassAssignmentStatus.ASSIGNED, followerResult.status());
|
||||
assertEquals(TyrantClass.NONE,
|
||||
followerResult.players().get(VIGILANTE).tyrantClass());
|
||||
assertEquals(TyrantClass.ASSASSIN,
|
||||
followerResult.players().get(FOLLOWER).tyrantClass());
|
||||
assertEquals(Optional.of(VIGILANTE), followerResult.previousHolderId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsLockedSelfAndOptedOutAssignments() {
|
||||
PlayerState target = PlayerState.newPlayer(FOLLOWER, "Target");
|
||||
PlayerState optedOut = new PlayerState(
|
||||
target.playerId(), target.latestName(), target.lastLogin(),
|
||||
Optional.of(NOW.plusSeconds(60)), target.tyrantClass(), target.followerOf(),
|
||||
target.cooldownEnds(), target.readyAbilityItems(), target.capturedMobs()
|
||||
);
|
||||
|
||||
assertEquals(ClassAssignmentStatus.CLASS_NOT_UNLOCKED, service.assign(
|
||||
gameWith(TyrantUnlock.FIXER), Map.of(FOLLOWER, target), TYRANT, FOLLOWER,
|
||||
TyrantClass.ASSASSIN, NOW
|
||||
).status());
|
||||
assertEquals(ClassAssignmentStatus.TARGET_IS_TYRANT, service.assign(
|
||||
gameWith(TyrantUnlock.ASSASSIN), Map.of(TYRANT,
|
||||
PlayerState.newPlayer(TYRANT, "Tyrant")), TYRANT, TYRANT,
|
||||
TyrantClass.ASSASSIN, NOW
|
||||
).status());
|
||||
assertEquals(ClassAssignmentStatus.TARGET_OPTED_OUT, service.assign(
|
||||
gameWith(TyrantUnlock.ASSASSIN), Map.of(FOLLOWER, optedOut), TYRANT,
|
||||
FOLLOWER, TyrantClass.ASSASSIN, NOW
|
||||
).status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reassignmentAndReignEndClearClassAbilityState() {
|
||||
Instant cooldown = NOW.plusSeconds(3600);
|
||||
PlayerState holder = new PlayerState(
|
||||
VIGILANTE, "Holder", Optional.empty(), Optional.empty(),
|
||||
TyrantClass.ASSASSIN, Optional.empty(),
|
||||
Map.of(Ability.ASSASSIN_INVISIBILITY, cooldown),
|
||||
Set.of(Ability.ASSASSIN_INVISIBILITY), java.util.List.of()
|
||||
);
|
||||
PlayerState replacement = PlayerState.newPlayer(FOLLOWER, "Replacement");
|
||||
|
||||
ClassAssignmentResult reassigned = service.assign(
|
||||
gameWith(TyrantUnlock.ASSASSIN),
|
||||
Map.of(VIGILANTE, holder, FOLLOWER, replacement),
|
||||
TYRANT, FOLLOWER, TyrantClass.ASSASSIN, NOW
|
||||
);
|
||||
PlayerState former = reassigned.players().get(VIGILANTE);
|
||||
Map<UUID, PlayerState> cleared = service.clearAll(reassigned.players());
|
||||
|
||||
assertEquals(Map.of(), former.cooldownEnds());
|
||||
assertEquals(Set.of(), former.readyAbilityItems());
|
||||
assertEquals(TyrantClass.NONE, cleared.get(FOLLOWER).tyrantClass());
|
||||
}
|
||||
|
||||
private static GameState gameWith(TyrantUnlock unlock) {
|
||||
return new GameState(
|
||||
GameLifecycle.RUNNING,
|
||||
Optional.of(TYRANT),
|
||||
Optional.of(VIGILANTE),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Duration.ZERO,
|
||||
1,
|
||||
0,
|
||||
Set.of(unlock)
|
||||
);
|
||||
}
|
||||
|
||||
private static PlayerState withFollower(PlayerState player, UUID vigilanteId) {
|
||||
return new PlayerState(
|
||||
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
|
||||
player.tyrantClass(), Optional.of(vigilanteId), player.cooldownEnds(),
|
||||
player.readyAbilityItems(), player.capturedMobs()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class FollowerServiceTest {
|
||||
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 final FollowerService service = new FollowerService();
|
||||
|
||||
@Test
|
||||
void invitationRequiresAcceptanceAndPreservesTyrantClass() {
|
||||
GameState game = game();
|
||||
PlayerState target = new PlayerState(
|
||||
FOLLOWER, "Follower", Optional.empty(), Optional.empty(), TyrantClass.FIXER,
|
||||
Optional.empty(), Map.of(), Set.of(), java.util.List.of()
|
||||
);
|
||||
Map<UUID, PlayerState> players = Map.of(FOLLOWER, target);
|
||||
|
||||
FollowerResult invited = service.invite(game, players, VIGILANTE, FOLLOWER);
|
||||
FollowerResult accepted = service.accept(game, invited.players(), FOLLOWER);
|
||||
|
||||
assertEquals(FollowerStatus.INVITED, invited.status());
|
||||
assertEquals(Optional.empty(), invited.players().get(FOLLOWER).followerOf());
|
||||
assertEquals(FollowerStatus.JOINED, accepted.status());
|
||||
assertEquals(Optional.of(VIGILANTE), accepted.players().get(FOLLOWER).followerOf());
|
||||
assertEquals(TyrantClass.FIXER, accepted.players().get(FOLLOWER).tyrantClass());
|
||||
}
|
||||
|
||||
@Test
|
||||
void vigilanteCanDismissAndFollowerCanLeave() {
|
||||
GameState game = game();
|
||||
PlayerState follower = withFollower(PlayerState.newPlayer(FOLLOWER, "Follower"));
|
||||
|
||||
FollowerResult dismissed = service.dismiss(
|
||||
game, Map.of(FOLLOWER, follower), VIGILANTE, FOLLOWER
|
||||
);
|
||||
FollowerResult left = service.leave(
|
||||
game, Map.of(FOLLOWER, follower), FOLLOWER
|
||||
);
|
||||
|
||||
assertEquals(FollowerStatus.DISMISSED, dismissed.status());
|
||||
assertEquals(Optional.empty(), dismissed.players().get(FOLLOWER).followerOf());
|
||||
assertEquals(FollowerStatus.LEFT, left.status());
|
||||
assertEquals(Optional.empty(), left.players().get(FOLLOWER).followerOf());
|
||||
}
|
||||
|
||||
private static GameState game() {
|
||||
return new GameState(
|
||||
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(VIGILANTE),
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||
0, 0, Set.of()
|
||||
);
|
||||
}
|
||||
|
||||
private static PlayerState withFollower(PlayerState player) {
|
||||
return new PlayerState(
|
||||
player.playerId(), player.latestName(), player.lastLogin(), player.optedOutUntil(),
|
||||
player.tyrantClass(), Optional.of(VIGILANTE), player.cooldownEnds(),
|
||||
player.readyAbilityItems(), player.capturedMobs()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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 GameLifecycleServiceTest {
|
||||
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 Instant START = Instant.parse("2026-08-14T12:00:00Z");
|
||||
private final GameLifecycleService service = new GameLifecycleService();
|
||||
|
||||
@Test
|
||||
void startsFirstGameWithInitialRolesAndChoice() {
|
||||
LifecycleState started = service.start(
|
||||
PersistentState.empty(), TYRANT, Optional.of(VIGILANTE), START
|
||||
);
|
||||
|
||||
assertEquals(GameLifecycle.RUNNING, started.game().lifecycle());
|
||||
assertEquals(Optional.of(TYRANT), started.game().tyrantId());
|
||||
assertEquals(Optional.of(VIGILANTE), started.game().vigilanteId());
|
||||
assertEquals(1, started.game().unspentChoices());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pauseAndResumePreserveStateAndShiftEveryElapsedDeadline() {
|
||||
Instant cooldown = START.plusSeconds(600);
|
||||
PlayerState player = new PlayerState(
|
||||
TYRANT, "Tyrant", Optional.of(START.minusSeconds(60)),
|
||||
Optional.of(START.plusSeconds(1200)), TyrantClass.ASSASSIN,
|
||||
Optional.empty(), Map.of(Ability.ASSASSIN_INVISIBILITY, cooldown),
|
||||
Set.of(), java.util.List.of()
|
||||
);
|
||||
GameState running = new GameState(
|
||||
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(VIGILANTE),
|
||||
Optional.empty(), Optional.of(new PendingSelection(VIGILANTE, cooldown)),
|
||||
Optional.empty(), Duration.ofMinutes(2), 2, 1,
|
||||
Set.of(TyrantUnlock.ASSASSIN)
|
||||
);
|
||||
LifecycleState paused = service.pause(
|
||||
new PersistentState(running, Map.of(TYRANT, player)), START
|
||||
);
|
||||
Instant resumedAt = START.plusSeconds(300);
|
||||
|
||||
LifecycleState resumed = service.resume(paused.asPersistentState(), resumedAt);
|
||||
|
||||
assertEquals(GameLifecycle.RUNNING, resumed.game().lifecycle());
|
||||
assertEquals(Duration.ofMinutes(7), resumed.game().accumulatedPausedTime());
|
||||
assertEquals(cooldown.plusSeconds(300),
|
||||
resumed.game().pendingVigilante().orElseThrow().expiresAt());
|
||||
PlayerState shifted = resumed.players().get(TYRANT);
|
||||
assertEquals(cooldown.plusSeconds(300),
|
||||
shifted.cooldownEnds().get(Ability.ASSASSIN_INVISIBILITY));
|
||||
assertEquals(START.plusSeconds(1500), shifted.optedOutUntil().orElseThrow());
|
||||
assertEquals(START.plusSeconds(240), shifted.lastLogin().orElseThrow());
|
||||
assertTrue(shifted.tyrantClass() == TyrantClass.ASSASSIN);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 ParticipationServiceTest {
|
||||
private static final UUID PLAYER = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
private static final UUID TYRANT = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
|
||||
private final ParticipationService service = new ParticipationService(Duration.ofDays(7));
|
||||
|
||||
@Test
|
||||
void optingOutClearsOrdinaryClassAndFollowerButKeepsPlayerOutAfterCooldown() {
|
||||
PlayerState assigned = new PlayerState(
|
||||
PLAYER, "Player", Optional.of(NOW), Optional.empty(), TyrantClass.ASSASSIN,
|
||||
Optional.of(UUID.randomUUID()),
|
||||
Map.of(Ability.ASSASSIN_INVISIBILITY, NOW.plusSeconds(30)),
|
||||
Set.of(Ability.ASSASSIN_INVISIBILITY), java.util.List.of()
|
||||
);
|
||||
GameState game = runningGame();
|
||||
|
||||
ParticipationResult result = service.optOut(game, assigned, NOW);
|
||||
|
||||
assertEquals(ParticipationStatus.OPTED_OUT, result.status());
|
||||
assertEquals(Optional.of(NOW.plus(Duration.ofDays(7))),
|
||||
result.player().optedOutUntil());
|
||||
assertEquals(TyrantClass.NONE, result.player().tyrantClass());
|
||||
assertEquals(Optional.empty(), result.player().followerOf());
|
||||
assertEquals(Map.of(), result.player().cooldownEnds());
|
||||
assertEquals(ParticipationStatus.OPTED_OUT,
|
||||
service.status(result.player(), NOW.plus(Duration.ofDays(8))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void optingBackInIsBlockedUntilSevenDaysAndThenClearsOptOut() {
|
||||
PlayerState player = service.optOut(
|
||||
runningGame(), PlayerState.newPlayer(PLAYER, "Player"), NOW
|
||||
).player();
|
||||
|
||||
ParticipationResult early = service.optIn(player, NOW.plus(Duration.ofDays(6)));
|
||||
ParticipationResult ready = service.optIn(player, NOW.plus(Duration.ofDays(7)));
|
||||
|
||||
assertEquals(ParticipationStatus.COOLDOWN_ACTIVE, early.status());
|
||||
assertEquals(ParticipationStatus.OPTED_IN, ready.status());
|
||||
assertEquals(Optional.empty(), ready.player().optedOutUntil());
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentCentralRoleMustRelinquishBeforeOptingOut() {
|
||||
GameState game = new GameState(
|
||||
GameLifecycle.RUNNING, Optional.of(PLAYER), Optional.empty(), Optional.empty(),
|
||||
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 1, Set.of()
|
||||
);
|
||||
|
||||
ParticipationResult result = service.optOut(
|
||||
game, PlayerState.newPlayer(PLAYER, "Tyrant"), NOW
|
||||
);
|
||||
|
||||
assertEquals(ParticipationStatus.MUST_RELINQUISH_ROLE, result.status());
|
||||
assertEquals(Optional.empty(), result.player().optedOutUntil());
|
||||
}
|
||||
|
||||
private static GameState runningGame() {
|
||||
return new GameState(
|
||||
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
|
||||
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 1, Set.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class PlayerStatusMessagesTest {
|
||||
private static final UUID PLAYER = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
|
||||
|
||||
@Test
|
||||
void assignedPlayerSeesGameRoleClassAbilitiesAndCooldowns() {
|
||||
GameState game = new GameState(
|
||||
GameLifecycle.RUNNING, Optional.empty(), Optional.of(PLAYER), Optional.empty(),
|
||||
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
|
||||
);
|
||||
PlayerState player = new PlayerState(
|
||||
PLAYER, "Player", Optional.of(NOW), Optional.empty(), TyrantClass.ASSASSIN,
|
||||
Optional.empty(), Map.of(Ability.ASSASSIN_INVISIBILITY, NOW.plusSeconds(60)),
|
||||
Set.of(), List.of()
|
||||
);
|
||||
|
||||
List<String> messages = PlayerStatusMessages.forJoin(game, player, NOW);
|
||||
|
||||
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("Class: ASSASSIN")));
|
||||
assertTrue(messages.stream().anyMatch(message -> message.contains("ASSASSIN_INVISIBILITY")
|
||||
&& message.contains("60s")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unassignedPlayerIsToldHowToOptOut() {
|
||||
List<String> messages = PlayerStatusMessages.forJoin(
|
||||
GameState.empty(), PlayerState.newPlayer(PLAYER, "Player"), NOW
|
||||
);
|
||||
|
||||
assertTrue(messages.contains(
|
||||
"Use /tyrant optout if you do not want to participate in Tyrant events."
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,12 @@ final class PluginMetadataTest {
|
||||
Map<?, ?> commands = (Map<?, ?>) plugin.get("commands");
|
||||
assertNotNull(commands);
|
||||
assertNotNull(commands.get("tyrant"));
|
||||
assertNotNull(commands.get("tyrantadmin"));
|
||||
assertNotNull(commands.get("vigilante"));
|
||||
Map<?, ?> permissions = (Map<?, ?>) plugin.get("permissions");
|
||||
assertNotNull(permissions);
|
||||
Map<?, ?> admin = (Map<?, ?>) permissions.get("spigottyrant.admin");
|
||||
assertEquals("op", admin.get("default"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user