feat(vigilante): add arena boss succession
This commit is contained in:
@@ -16,20 +16,23 @@ Pausing freezes gameplay progression, role inactivity checks, pending-selection
|
|||||||
|
|
||||||
When a player directly kills the Tyrant, that killer becomes the new Tyrant. When no player is credited with the death, an eligible opted-in player active during the preceding 24 hours is selected randomly. The former Tyrant is excluded when another candidate exists.
|
When a player directly kills the Tyrant, that killer becomes the new Tyrant. When no player is credited with the death, an eligible opted-in player active during the preceding 24 hours is selected randomly. The former Tyrant is excluded when another candidate exists.
|
||||||
|
|
||||||
Every Tyrant death ends the reign, clears all role-side assignments, resets the former Tyrant's reign progression, and requires a new Vigilante. The new Tyrant receives one starting unlock choice.
|
Every Tyrant death ends the reign, clears all role-side assignments, resets the former Tyrant's reign progression, and leaves the Vigilante role open for the arena challenge. The new Tyrant receives one starting unlock choice.
|
||||||
|
|
||||||
## Vigilante succession
|
## Vigilante succession
|
||||||
|
|
||||||
Every Vigilante death ends that assignment regardless of cause. A replacement is selected randomly from eligible opted-in players active during the preceding 24 hours. The Tyrant is never eligible. The previous Vigilante is excluded when another eligible non-Tyrant exists and may be selected again only as a fallback. Followers are cleared rather than transferred.
|
Every Vigilante death ends that assignment regardless of cause. Vigilante inactivity and relinquishment also end the assignment, and every new Tyrant reign clears the previous assignment. Followers are cleared rather than transferred.
|
||||||
|
|
||||||
A Tyrant earns progression from the death only when the Tyrant personally killed the Vigilante.
|
While the game is running with an active Tyrant and no Vigilante, an administrator-configured arena opens. One eligible opted-in non-Tyrant player at a time may challenge the arena boss. Defeating it assigns that player as Vigilante. Leaving, disconnecting, or dying resets the fight without preventing an immediate retry. Without an active Tyrant or configured arena, the role remains vacant and no boss exists.
|
||||||
|
|
||||||
|
A Tyrant earns progression from a Vigilante death only when the Tyrant personally killed the Vigilante.
|
||||||
|
|
||||||
## Pending selections
|
## Pending selections
|
||||||
|
|
||||||
An offline random selectee has 24 hours of unpaused time to log in and confirm the assignment. Failure causes another selection. When no eligible candidate exists, the position remains vacant and selection retries periodically without assigning the Tyrant to both roles.
|
An offline randomly selected Tyrant has 24 hours of unpaused time to log in and confirm the assignment. Failure causes another Tyrant selection. When no eligible Tyrant candidate exists, the position remains vacant and selection retries periodically. Vigilante vacancies use the arena rather than pending random selection.
|
||||||
|
|
||||||
## Related stories
|
## Related stories
|
||||||
|
|
||||||
- [Start, pause, and administer the game](../user-stories/us-001-start-pause-and-administer.md)
|
- [Start, pause, and administer the game](../user-stories/us-001-start-pause-and-administer.md)
|
||||||
- [Transfer and reset the Tyrant role](../user-stories/us-002-transfer-and-reset-tyrant.md)
|
- [Transfer and reset the Tyrant role](../user-stories/us-002-transfer-and-reset-tyrant.md)
|
||||||
- [Handle inactivity and pending selections](../user-stories/us-010-handle-inactivity-and-selection.md)
|
- [Handle inactivity and pending selections](../user-stories/us-010-handle-inactivity-and-selection.md)
|
||||||
|
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: Defines the durable state and timekeeping needed to restore a Tyran
|
|||||||
|
|
||||||
# Persistence Model
|
# Persistence Model
|
||||||
|
|
||||||
State is keyed by player UUID while retaining the latest known player name for display. Durable global state includes lifecycle status, current and pending role holders, selection deadlines, paused durations, and the current Tyrant reign.
|
State is keyed by player UUID while retaining the latest known player name for display. Durable global state includes lifecycle status, current and pending role holders, selection deadlines, paused durations, the current Tyrant reign, and the administrator-configured Vigilante arena center. Active boss and challenger entities are transient and are reconstructed or reset from authoritative role and arena state after restart.
|
||||||
|
|
||||||
Durable player state includes participation status, opt-out deadline, login history, role or class, Tyrant level, purchased unlocks, unspent choices, Follower relationship, ability cooldowns, daily intelligence use, bound-item delivery state, and captured mobs.
|
Durable player state includes participation status, opt-out deadline, login history, role or class, Tyrant level, purchased unlocks, unspent choices, Follower relationship, ability cooldowns, daily intelligence use, bound-item delivery state, and captured mobs.
|
||||||
|
|
||||||
@@ -17,3 +17,4 @@ Elapsed-time rules use UTC instants. Paused time is excluded from timers. Saves
|
|||||||
- [Inform players and manage participation](../user-stories/us-011-inform-and-manage-participation.md)
|
- [Inform players and manage participation](../user-stories/us-011-inform-and-manage-participation.md)
|
||||||
- [Configure and persist game state](../user-stories/us-012-configure-and-persist-state.md)
|
- [Configure and persist game state](../user-stories/us-012-configure-and-persist-state.md)
|
||||||
- [Use class ability items](../user-stories/us-014-use-class-ability-items.md)
|
- [Use class ability items](../user-stories/us-014-use-class-ability-items.md)
|
||||||
|
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: Defines Tyrant-side classes, Vigilante-side membership, progression
|
|||||||
|
|
||||||
## Roles and affiliations
|
## Roles and affiliations
|
||||||
|
|
||||||
A running game has one Tyrant and, when an eligible player is available, one Vigilante. The Vigilante may recruit Followers through invitations that require acceptance. The Tyrant may assign the Assassin, Fixer, and Tamer classes after unlocking them.
|
A running game has one active or pending Tyrant. It has at most one Vigilante, and a vacant Vigilante role remains open until an eligible player wins the configured arena challenge. The Vigilante may recruit Followers through invitations that require acceptance. The Tyrant may assign the Assassin, Fixer, and Tamer classes after unlocking them.
|
||||||
|
|
||||||
Role alignment and Tyrant-side class are independent. A Vigilante or Follower may simultaneously hold one Tyrant-side class, allowing divided loyalties. A player cannot simultaneously be the Tyrant, Vigilante, and/or a Follower, and cannot hold more than one Tyrant-side class. Each Tyrant-side class may be assigned to only one player at a time. Opted-out players are ineligible for every assignment.
|
Role alignment and Tyrant-side class are independent. A Vigilante or Follower may simultaneously hold one Tyrant-side class, allowing divided loyalties. A player cannot simultaneously be the Tyrant, Vigilante, and/or a Follower, and cannot hold more than one Tyrant-side class. Each Tyrant-side class may be assigned to only one player at a time. Opted-out players are ineligible for every assignment.
|
||||||
|
|
||||||
@@ -36,3 +36,4 @@ Vigilante Follower scaling is capped by default at Strength V and Resistance IV,
|
|||||||
- [Progress and purchase Tyrant unlocks](../user-stories/us-003-progress-and-purchase-unlocks.md)
|
- [Progress and purchase Tyrant unlocks](../user-stories/us-003-progress-and-purchase-unlocks.md)
|
||||||
- [Assign unlocked classes](../user-stories/us-004-assign-unlocked-classes.md)
|
- [Assign unlocked classes](../user-stories/us-004-assign-unlocked-classes.md)
|
||||||
- [Support the Vigilante and Followers](../user-stories/us-008-support-vigilante-and-followers.md)
|
- [Support the Vigilante and Followers](../user-stories/us-008-support-vigilante-and-followers.md)
|
||||||
|
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ This bundle documents the asymmetric Tyrant game, its role succession, class abi
|
|||||||
## Explore
|
## Explore
|
||||||
|
|
||||||
- [User stories](user-stories/index.md)
|
- [User stories](user-stories/index.md)
|
||||||
|
- [Vigilante arena challenge](user-stories/us-019-claim-vigilante-in-arena.md)
|
||||||
- [Game state machine](concepts/game-state-machine.md)
|
- [Game state machine](concepts/game-state-machine.md)
|
||||||
- [Role and class model](concepts/role-and-class-model.md)
|
- [Role and class model](concepts/role-and-class-model.md)
|
||||||
- [Persistence model](concepts/persistence-model.md)
|
- [Persistence model](concepts/persistence-model.md)
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ description: Chronological record of material decisions affecting the Spigot Tyr
|
|||||||
|
|
||||||
# Spigot Tyrant Design Log
|
# Spigot Tyrant Design Log
|
||||||
|
|
||||||
|
## 2026-08-23 — Vigilante arena succession completed
|
||||||
|
|
||||||
|
- Completed US-019 and reopened related administration, Vigilante, inactivity, participation, persistence, control-panel, and completion stories to replace automatic Vigilante selection with an earned arena challenge.
|
||||||
|
- Vigilante vacancies caused by death, inactivity, relinquishment, initial startup, or a new Tyrant reign now open a configured one-player enhanced-zombie fight only while a Tyrant is active.
|
||||||
|
- Added a persistent administrator-set arena center, continuous particle boundary, temporary safe `BARRIER` wall, entrant and respawn containment, mob exclusion, sunlight-safe boss behavior, full fight reset, and boss-victory assignment.
|
||||||
|
- Opted-in players receive vacancy coordinates when the challenge opens or they join; opted-out players are excluded from notices and entry.
|
||||||
|
- Verified arena policy, geometry, succession, configuration persistence and validation, barrier restoration, command handling and completion, compiler warnings, 109 automated tests, and packaging with `./gradlew clean check jar`.
|
||||||
|
|
||||||
## 2026-08-21 — Contextual command completion completed
|
## 2026-08-21 — Contextual command completion completed
|
||||||
|
|
||||||
- Completed US-018 with registered completers for `/tyrant`, `/vigilante`, and `/tyrantadmin`, replacing Bukkit's unconditional online-player fallback.
|
- Completed US-018 with registered completers for `/tyrant`, `/vigilante`, and `/tyrantadmin`, replacing Bukkit's unconditional online-player fallback.
|
||||||
|
|||||||
@@ -18,3 +18,4 @@
|
|||||||
16. [US-016: Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
16. [US-016: Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
||||||
17. [US-017: Use bound role control items](us-017-use-bound-role-control-items.md)
|
17. [US-017: Use bound role control items](us-017-use-bound-role-control-items.md)
|
||||||
18. [US-018: Complete Tyrant commands contextually](us-018-complete-commands-contextually.md)
|
18. [US-018: Complete Tyrant commands contextually](us-018-complete-commands-contextually.md)
|
||||||
|
19. [US-019: Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||||
|
|||||||
@@ -12,15 +12,16 @@ As a **server operator**, I want to start, pause, resume, and inspect the game s
|
|||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [x] An administrator can start the first game by assigning the initial Tyrant.
|
- [x] An administrator can start the first game by assigning the initial Tyrant.
|
||||||
- [x] The administrator may assign the first Vigilante or allow random selection from eligible recently active players.
|
- [x] The administrator may assign the first Vigilante or leave the role vacant for the configured Vigilante arena challenge.
|
||||||
- [x] An administrator can pause and resume the game without deleting game state.
|
- [x] 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] 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] 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] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default.
|
||||||
- [x] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, and pending selections.
|
- [x] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, pending selections, and the configured Vigilante arena location.
|
||||||
- [x] Destructive administrative operations require explicit confirmation.
|
- [x] Destructive administrative operations require explicit confirmation.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
||||||
- [Configure and persist game state](us-012-configure-and-persist-state.md)
|
- [Configure and persist game state](us-012-configure-and-persist-state.md)
|
||||||
|
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [x] Exactly one active Vigilante exists while the game is running whenever an eligible player is available.
|
- [x] A running game has at most one active Vigilante; a vacancy remains open until an eligible player defeats the arena boss.
|
||||||
- [x] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
|
- [x] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
|
||||||
- [x] The Vigilante can dismiss Followers and Followers can leave voluntarily.
|
- [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] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks.
|
||||||
@@ -19,17 +19,18 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
|
|||||||
- [x] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
|
- [x] 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] 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] 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] Every Vigilante death ends the assignment and opens the one-player arena challenge while a Tyrant is active.
|
||||||
- [x] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
|
- [x] The deceased Vigilante may immediately compete again under the same arena eligibility and reset rules as every other participant.
|
||||||
- [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
|
- [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
|
||||||
- [x] The Tyrant is never eligible to be selected as Vigilante.
|
- [x] The Tyrant is never eligible to be selected as Vigilante.
|
||||||
- [x] The Vigilante can review, invite, and dismiss Followers through the Vigilante control panel.
|
- [x] The Vigilante can review, invite, and dismiss Followers through the Vigilante control panel.
|
||||||
- [x] Control-panel and command-based Follower actions enforce identical eligibility and membership rules.
|
- [x] Control-panel and command-based Follower actions enforce identical eligibility and membership rules.
|
||||||
- [x] The control panel communicates the current nearby-Follower combat contribution and configured effect caps.
|
- [x] The control panel communicates the current nearby-Follower combat contribution and configured effect caps.
|
||||||
- [x] Relinquishing through the control panel follows normal Vigilante succession and Follower cleanup.
|
- [x] Relinquishing through the control panel clears Followers and opens the Vigilante arena challenge.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Role and class model](../concepts/role-and-class-model.md)
|
- [Role and class model](../concepts/role-and-class-model.md)
|
||||||
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
|
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
|
||||||
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
||||||
|
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||||
|
|||||||
@@ -15,14 +15,15 @@ As a **participant**, I want inactive central roles to be replaced fairly so tha
|
|||||||
- [x] Random candidates are drawn from eligible opted-in players who logged in during the preceding 24 hours.
|
- [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] 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] 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] Tyrant inactivity ends the reign, clears all assignments, invokes normal random Tyrant succession, and opens the Vigilante arena challenge once a Tyrant is active.
|
||||||
- [x] Vigilante inactivity clears Followers and rerolls only the Vigilante position.
|
- [x] Vigilante inactivity clears Followers and opens the Vigilante arena challenge without changing the Tyrant.
|
||||||
- [x] An offline randomly selected candidate must log in within 24 unpaused hours to confirm the assignment.
|
- [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] An unconfirmed Tyrant selection expires and rerolls automatically under the same eligibility rules.
|
||||||
- [x] Selection remains vacant and retries periodically when no valid candidate exists.
|
- [x] Tyrant 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.
|
- [x] Candidate selection cannot assign conflicting roles or choose an opted-out player, including during concurrent joins and deaths.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
||||||
- [Inform players and manage participation](us-011-inform-and-manage-participation.md)
|
- [Inform players and manage participation](us-011-inform-and-manage-participation.md)
|
||||||
|
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ As a **player**, I want to understand my status and control my eligibility so th
|
|||||||
- [x] Eligible players are invited to opt out of Tyrant events without implying that an event is currently running.
|
- [x] Eligible players are invited to opt out of Tyrant events without implying that an event is currently running.
|
||||||
- [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
|
- [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
|
||||||
- [x] An opted-out player is told when they may opt back in.
|
- [x] An opted-out player is told when they may opt back in.
|
||||||
|
- [x] Opted-in players are told the Vigilante arena world and coordinates when its role opens and when they join during an open challenge; opted-out players receive no such notice.
|
||||||
- [x] An unassigned player can opt out through a command.
|
- [x] 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] 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] Opted-out players cannot be randomly selected, recruited, or assigned a class.
|
||||||
@@ -35,3 +36,4 @@ As a **player**, I want to understand my status and control my eligibility so th
|
|||||||
|
|
||||||
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
|
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
|
||||||
- [Persistence model](../concepts/persistence-model.md)
|
- [Persistence model](../concepts/persistence-model.md)
|
||||||
|
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ As a **server operator**, I want configurable and durable game behavior so that
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [x] Configuration covers ranges, durations, cooldowns, inactivity periods, candidate windows, retry intervals, effect levels and caps, mob restrictions, item materials and names, messages, and timer behavior.
|
- [x] Configuration covers ranges, durations, cooldowns, inactivity periods, candidate windows, retry intervals, effect levels and caps, mob restrictions, item materials and names, messages, timer behavior, and Vigilante arena boss balance.
|
||||||
- [x] Defaults match the approved user stories, including a 50-block Tyrant range and Follower range, seven-day opt-out, 48-hour inactivity, and 24-hour candidate windows.
|
- [x] Defaults match the approved user stories, including a 50-block Tyrant range and Follower range, seven-day opt-out, 48-hour inactivity, and 24-hour candidate windows.
|
||||||
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
|
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
|
||||||
- [x] UUID-keyed state stores lifecycle, current and pending roles, assignments, login history, participation, reign progression, purchases, choices, cooldowns, paused time, item delivery, and captured mobs.
|
- [x] UUID-keyed state stores lifecycle, current and pending roles, assignments, login history, participation, reign progression, purchases, choices, cooldowns, paused time, item delivery, and captured mobs; the administrator-set arena center is durable across restarts.
|
||||||
- [x] Cooldowns and deadlines use UTC instants and exclude administratively paused time.
|
- [x] Cooldowns and deadlines use UTC instants and exclude administratively paused time.
|
||||||
- [x] State is saved using atomic replacement where supported so failed writes do not replace valid state with partial data.
|
- [x] State is saved using atomic replacement where supported so failed writes do not replace valid state with partial data.
|
||||||
- [x] Corrupt, unknown, or invalid records cannot silently grant progression, roles, powers, items, or duplicated mobs.
|
- [x] Corrupt, unknown, or invalid records cannot silently grant progression, roles, powers, items, or duplicated mobs.
|
||||||
@@ -25,3 +25,4 @@ As a **server operator**, I want configurable and durable game behavior so that
|
|||||||
|
|
||||||
- [Persistence model](../concepts/persistence-model.md)
|
- [Persistence model](../concepts/persistence-model.md)
|
||||||
- [Build, test, and release the plugin](us-013-build-test-and-release.md)
|
- [Build, test, and release the plugin](us-013-build-test-and-release.md)
|
||||||
|
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ As the **Vigilante**, I want a control panel for managing Followers so that I ca
|
|||||||
- [x] The Follower-management screen identifies current Followers and requires confirmation before dismissal.
|
- [x] The Follower-management screen identifies current Followers and requires confirmation before dismissal.
|
||||||
- [x] Successful invitations and dismissals refresh the control panel from authoritative current state and notify affected players.
|
- [x] Successful invitations and dismissals refresh the control panel from authoritative current state and notify affected players.
|
||||||
- [x] The role-control screen allows the Vigilante to relinquish the role only after explicit confirmation.
|
- [x] The role-control screen allows the Vigilante to relinquish the role only after explicit confirmation.
|
||||||
- [x] Relinquishing through the control panel uses normal Vigilante replacement and Follower-cleanup rules.
|
- [x] Relinquishing through the control panel clears Followers, opens the Vigilante arena challenge, and communicates that outcome accurately.
|
||||||
- [x] Every action revalidates the running lifecycle and active Vigilante before changing state.
|
- [x] Every action revalidates the running lifecycle and active Vigilante before changing state.
|
||||||
- [x] Stale or repeated control-panel interactions cannot invite an ineligible player, duplicate an invitation, or dismiss the wrong Follower.
|
- [x] Stale or repeated control-panel interactions cannot invite an ineligible player, duplicate an invitation, or dismiss the wrong Follower.
|
||||||
- [x] Existing `/vigilante invite`, `/vigilante accept`, `/vigilante dismiss`, `/vigilante leave`, and `/tyrant relinquish confirm` commands remain available.
|
- [x] Existing `/vigilante invite`, `/vigilante accept`, `/vigilante dismiss`, `/vigilante leave`, and `/tyrant relinquish confirm` commands remain available.
|
||||||
@@ -46,3 +46,4 @@ Automated tests verify default and explicit command entry points, active-Vigilan
|
|||||||
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
||||||
- [Role and class model](../concepts/role-and-class-model.md)
|
- [Role and class model](../concepts/role-and-class-model.md)
|
||||||
- [Use bound role control items](us-017-use-bound-role-control-items.md)
|
- [Use bound role control items](us-017-use-bound-role-control-items.md)
|
||||||
|
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ As a **player or administrator**, I want contextual command suggestions so that
|
|||||||
- [x] `/tyrantadmin` suggestions are visible only to senders with `spigottyrant.admin`.
|
- [x] `/tyrantadmin` suggestions are visible only to senders with `spigottyrant.admin`.
|
||||||
- [x] `/tyrantadmin start` suggests online Tyrant and Vigilante candidates.
|
- [x] `/tyrantadmin start` suggests online Tyrant and Vigilante candidates.
|
||||||
- [x] `/tyrantadmin reset` suggests `confirm`.
|
- [x] `/tyrantadmin reset` suggests `confirm`.
|
||||||
|
- [x] `/tyrantadmin arena` suggests `set` and `status`.
|
||||||
- [x] Suggestions are filtered case-insensitively by the partially typed argument.
|
- [x] Suggestions are filtered case-insensitively by the partially typed argument.
|
||||||
- [x] Suggestions never include syntactically invalid options for the current argument position.
|
- [x] Suggestions never include syntactically invalid options for the current argument position.
|
||||||
- [x] Console completion works for administrative commands without exposing player-only commands as executable console actions.
|
- [x] Console completion works for administrative commands without exposing player-only commands as executable console actions.
|
||||||
@@ -36,4 +37,5 @@ Automated tests verify root syntax, unlocks, classes, confirmations, eligible re
|
|||||||
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
|
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
|
||||||
- [Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
|
- [Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
|
||||||
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
||||||
|
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||||
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: "US-019: Claim the Vigilante role in the arena"
|
||||||
|
description: Let one eligible player at a time defeat a protected arena boss to claim a vacant Vigilante role.
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# US-019: Claim the Vigilante role in the arena
|
||||||
|
|
||||||
|
As an **opted-in participant**, I want a visible one-player boss challenge for a vacant Vigilante role so that succession is earned through an open contest.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] An administrator can set and inspect a durable arena center, and configuration validates the arena radius and boss strength.
|
||||||
|
- [x] The configured arena has a continuously visible particle boundary, including when no fight is active.
|
||||||
|
- [x] A vacant Vigilante role opens the challenge after death, inactivity, relinquishment, or a new Tyrant reign, but a boss exists only while a Tyrant is active and the game is running.
|
||||||
|
- [x] The challenge boss defaults to an armored, 80-health enhanced zombie, does not burn in sunlight, and remains within a default 10-block-radius arena.
|
||||||
|
- [x] All online opted-in players are told when the role opens and receive the arena world and coordinates; opted-in players receive the same notice when they log in while it remains open, and opted-out players do not.
|
||||||
|
- [x] Exactly one eligible opted-in non-Tyrant player may enter as challenger, including when entry is attempted by walking or teleportation.
|
||||||
|
- [x] Starting a fight replaces the passive particle boundary with temporary actual `BARRIER` blocks that are removed safely without permanently changing existing blocks.
|
||||||
|
- [x] Any non-challenger who enters or respawns inside the arena is moved safely outside it.
|
||||||
|
- [x] Existing mobs in the arena and mobs that spawn or enter it are removed while preserving the arena boss.
|
||||||
|
- [x] The boss attacks only the active challenger, and only that challenger can damage the boss.
|
||||||
|
- [x] Challenger death, disconnect, or leaving by any mechanism ends the fight and resets the boss to full health at its spawn; death counts as leaving and the same player may retry immediately.
|
||||||
|
- [x] Killing the boss assigns the challenger as Vigilante, removes the boss and barriers, and restores the passive particle boundary.
|
||||||
|
- [x] Pausing, resetting, restarting, reloading, and repeated or concurrent events cannot duplicate the boss, barriers, challenger, or Vigilante assignment.
|
||||||
|
- [x] If the arena is not configured when a challenge should open, the Vigilante remains vacant and administrators receive a clear warning.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Start, pause, and administer the game](us-001-start-pause-and-administer.md)
|
||||||
|
- [Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
|
||||||
|
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
|
||||||
|
- [Configure and persist game state](us-012-configure-and-persist-state.md)
|
||||||
|
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public final class ArenaGeometry {
|
||||||
|
private ArenaGeometry() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean contains(
|
||||||
|
ArenaLocation center,
|
||||||
|
double radius,
|
||||||
|
String worldName,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z
|
||||||
|
) {
|
||||||
|
if (!center.worldName().equals(worldName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
double deltaX = x - center.x();
|
||||||
|
double deltaZ = z - center.z();
|
||||||
|
return deltaX * deltaX + deltaZ * deltaZ <= radius * radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ArenaPoint outside(
|
||||||
|
ArenaLocation center,
|
||||||
|
double radius,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z
|
||||||
|
) {
|
||||||
|
double deltaX = x - center.x();
|
||||||
|
double deltaZ = z - center.z();
|
||||||
|
double length = Math.hypot(deltaX, deltaZ);
|
||||||
|
if (length < 0.0001) {
|
||||||
|
deltaX = 1.0;
|
||||||
|
deltaZ = 0.0;
|
||||||
|
length = 1.0;
|
||||||
|
}
|
||||||
|
double distance = radius + 1.0;
|
||||||
|
return new ArenaPoint(
|
||||||
|
center.x() + deltaX / length * distance,
|
||||||
|
y,
|
||||||
|
center.z() + deltaZ / length * distance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record ArenaLocation(
|
||||||
|
String worldName,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z,
|
||||||
|
float yaw,
|
||||||
|
float pitch
|
||||||
|
) {
|
||||||
|
public ArenaLocation {
|
||||||
|
if (worldName == null || worldName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("arena world must not be blank");
|
||||||
|
}
|
||||||
|
worldName = worldName.trim();
|
||||||
|
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|
||||||
|
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
|
||||||
|
throw new IllegalArgumentException("arena coordinates must be finite");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String display() {
|
||||||
|
return worldName + " at " + Math.round(x) + ", " + Math.round(y) + ", "
|
||||||
|
+ Math.round(z);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface ArenaLocationStore {
|
||||||
|
Optional<ArenaLocation> location();
|
||||||
|
|
||||||
|
void set(ArenaLocation location);
|
||||||
|
|
||||||
|
static ArenaLocationStore unavailable() {
|
||||||
|
return new ArenaLocationStore() {
|
||||||
|
@Override
|
||||||
|
public Optional<ArenaLocation> location() {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void set(ArenaLocation location) {
|
||||||
|
throw new IllegalStateException("arena location storage is unavailable");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record ArenaPoint(double x, double y, double z) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.bukkit.configuration.file.FileConfiguration;
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
|
public final class BukkitConfigArenaLocationStore implements ArenaLocationStore {
|
||||||
|
private static final String PATH = "vigilante-arena-location";
|
||||||
|
private final JavaPlugin plugin;
|
||||||
|
|
||||||
|
public BukkitConfigArenaLocationStore(JavaPlugin plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ArenaLocation> location() {
|
||||||
|
FileConfiguration config = plugin.getConfig();
|
||||||
|
if (!config.isConfigurationSection(PATH)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String world = config.getString(PATH + ".world");
|
||||||
|
if (world == null) {
|
||||||
|
throw new IllegalArgumentException(PATH + ".world must be configured");
|
||||||
|
}
|
||||||
|
return Optional.of(new ArenaLocation(
|
||||||
|
world,
|
||||||
|
finite(config, "x"),
|
||||||
|
finite(config, "y"),
|
||||||
|
finite(config, "z"),
|
||||||
|
(float) finite(config, "yaw"),
|
||||||
|
(float) finite(config, "pitch")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void set(ArenaLocation location) {
|
||||||
|
FileConfiguration config = plugin.getConfig();
|
||||||
|
config.set(PATH + ".world", location.worldName());
|
||||||
|
config.set(PATH + ".x", location.x());
|
||||||
|
config.set(PATH + ".y", location.y());
|
||||||
|
config.set(PATH + ".z", location.z());
|
||||||
|
config.set(PATH + ".yaw", location.yaw());
|
||||||
|
config.set(PATH + ".pitch", location.pitch());
|
||||||
|
plugin.saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double finite(FileConfiguration config, String key) {
|
||||||
|
String path = PATH + "." + key;
|
||||||
|
if (!config.isSet(path) || !(config.get(path) instanceof Number number)
|
||||||
|
|| !Double.isFinite(number.doubleValue())) {
|
||||||
|
throw new IllegalArgumentException(path + " must be finite and numeric");
|
||||||
|
}
|
||||||
|
return number.doubleValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -262,7 +262,7 @@ public final class BukkitVigilanteControlPanelRenderer
|
|||||||
Inventory inventory = create(holder, 27, TITLE + " — Role Control");
|
Inventory inventory = create(holder, 27, TITLE + " — Role Control");
|
||||||
inventory.setItem(13, item(Material.REDSTONE_TORCH,
|
inventory.setItem(13, item(Material.REDSTONE_TORCH,
|
||||||
ChatColor.RED + "Relinquish Vigilante Role",
|
ChatColor.RED + "Relinquish Vigilante Role",
|
||||||
List.of("Followers will be cleared and a replacement selected.",
|
List.of("Followers will be cleared and the arena will open.",
|
||||||
"Click to review confirmation.")));
|
"Click to review confirmation.")));
|
||||||
inventory.setItem(22, backItem());
|
inventory.setItem(22, backItem());
|
||||||
player.openInventory(inventory);
|
player.openInventory(inventory);
|
||||||
@@ -276,7 +276,7 @@ public final class BukkitVigilanteControlPanelRenderer
|
|||||||
Inventory inventory = create(holder, 27, TITLE + " — Confirm Relinquishment");
|
Inventory inventory = create(holder, 27, TITLE + " — Confirm Relinquishment");
|
||||||
inventory.setItem(11, cancelItem());
|
inventory.setItem(11, cancelItem());
|
||||||
inventory.setItem(13, item(Material.BARRIER, ChatColor.RED + "Relinquish Role",
|
inventory.setItem(13, item(Material.BARRIER, ChatColor.RED + "Relinquish Role",
|
||||||
List.of("This clears all Followers and rerolls the Vigilante.")));
|
List.of("This clears all Followers and opens the arena challenge.")));
|
||||||
inventory.setItem(15, confirmItem());
|
inventory.setItem(15, confirmItem());
|
||||||
player.openInventory(inventory);
|
player.openInventory(inventory);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import org.bukkit.configuration.ConfigurationSection;
|
|||||||
public record PluginSettings(
|
public record PluginSettings(
|
||||||
double tyrantRangeBlocks,
|
double tyrantRangeBlocks,
|
||||||
double followerRangeBlocks,
|
double followerRangeBlocks,
|
||||||
|
double vigilanteArenaRadiusBlocks,
|
||||||
|
double vigilanteBossHealth,
|
||||||
|
double vigilanteBossDamage,
|
||||||
|
double vigilanteBossArmor,
|
||||||
Duration optOutDuration,
|
Duration optOutDuration,
|
||||||
Duration roleInactivity,
|
Duration roleInactivity,
|
||||||
Duration candidateActivityWindow,
|
Duration candidateActivityWindow,
|
||||||
@@ -48,6 +52,12 @@ public record PluginSettings(
|
|||||||
public PluginSettings {
|
public PluginSettings {
|
||||||
requirePositiveFinite(tyrantRangeBlocks, "tyrant-range-blocks");
|
requirePositiveFinite(tyrantRangeBlocks, "tyrant-range-blocks");
|
||||||
requirePositiveFinite(followerRangeBlocks, "follower-range-blocks");
|
requirePositiveFinite(followerRangeBlocks, "follower-range-blocks");
|
||||||
|
requireFiniteRange(
|
||||||
|
vigilanteArenaRadiusBlocks, 1.0, 512.0, "vigilante-arena-radius-blocks"
|
||||||
|
);
|
||||||
|
requireFiniteRange(vigilanteBossHealth, 1.0, 1024.0, "vigilante-boss-health");
|
||||||
|
requireFiniteRange(vigilanteBossDamage, 0.1, 2048.0, "vigilante-boss-damage");
|
||||||
|
requireFiniteRange(vigilanteBossArmor, 0.0, 30.0, "vigilante-boss-armor");
|
||||||
requirePositive(optOutDuration, "opt-out-seconds");
|
requirePositive(optOutDuration, "opt-out-seconds");
|
||||||
requirePositive(roleInactivity, "role-inactivity-seconds");
|
requirePositive(roleInactivity, "role-inactivity-seconds");
|
||||||
requirePositive(candidateActivityWindow, "candidate-activity-window-seconds");
|
requirePositive(candidateActivityWindow, "candidate-activity-window-seconds");
|
||||||
@@ -93,6 +103,10 @@ public record PluginSettings(
|
|||||||
return new PluginSettings(
|
return new PluginSettings(
|
||||||
decimal(values, "tyrant-range-blocks", 50.0),
|
decimal(values, "tyrant-range-blocks", 50.0),
|
||||||
decimal(values, "follower-range-blocks", 50.0),
|
decimal(values, "follower-range-blocks", 50.0),
|
||||||
|
decimal(values, "vigilante-arena-radius-blocks", 10.0),
|
||||||
|
decimal(values, "vigilante-boss-health", 80.0),
|
||||||
|
decimal(values, "vigilante-boss-damage", 8.0),
|
||||||
|
decimal(values, "vigilante-boss-armor", 10.0),
|
||||||
duration(values, "opt-out-seconds", Duration.ofDays(7)),
|
duration(values, "opt-out-seconds", Duration.ofDays(7)),
|
||||||
duration(values, "role-inactivity-seconds", Duration.ofHours(48)),
|
duration(values, "role-inactivity-seconds", Duration.ofHours(48)),
|
||||||
duration(values, "candidate-activity-window-seconds", Duration.ofHours(24)),
|
duration(values, "candidate-activity-window-seconds", Duration.ofHours(24)),
|
||||||
@@ -227,6 +241,19 @@ public record PluginSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void requireFiniteRange(
|
||||||
|
double value,
|
||||||
|
double minimum,
|
||||||
|
double maximum,
|
||||||
|
String key
|
||||||
|
) {
|
||||||
|
if (!Double.isFinite(value) || value < minimum || value > maximum) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
key + " must be between " + minimum + " and " + maximum
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void requirePositive(Duration value, String key) {
|
private static void requirePositive(Duration value, String key) {
|
||||||
Objects.requireNonNull(value, key);
|
Objects.requireNonNull(value, key);
|
||||||
if (value.isZero() || value.isNegative()) {
|
if (value.isZero() || value.isNegative()) {
|
||||||
|
|||||||
@@ -53,17 +53,9 @@ public final class RoleMaintenanceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
current = rerollExpiredTyrant(current, onlinePlayerIds, now);
|
current = rerollExpiredTyrant(current, onlinePlayerIds, now);
|
||||||
current = rerollExpiredVigilante(current, onlinePlayerIds, now);
|
|
||||||
if (current.game().tyrantId().isEmpty() && current.game().pendingTyrant().isEmpty()) {
|
if (current.game().tyrantId().isEmpty() && current.game().pendingTyrant().isEmpty()) {
|
||||||
current = selectTyrant(current, Optional.empty(), onlinePlayerIds, now);
|
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;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,17 +66,9 @@ public final class RoleMaintenanceService {
|
|||||||
Instant now
|
Instant now
|
||||||
) {
|
) {
|
||||||
Map<UUID, PlayerState> players = clearFollowers(state.players(), previousVigilante);
|
Map<UUID, PlayerState> players = clearFollowers(state.players(), previousVigilante);
|
||||||
Set<UUID> excluded = state.game().tyrantId().map(Set::of).orElseGet(Set::of);
|
|
||||||
Optional<UUID> selected = candidates.select(
|
|
||||||
players, excluded, previousVigilante, now, activityWindow, random
|
|
||||||
);
|
|
||||||
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
|
|
||||||
Optional<PendingSelection> pending = selected
|
|
||||||
.filter(playerId -> active.isEmpty())
|
|
||||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
|
||||||
GameState game = copyRoles(
|
GameState game = copyRoles(
|
||||||
state.game(), state.game().tyrantId(), active,
|
state.game(), state.game().tyrantId(), Optional.empty(),
|
||||||
state.game().pendingTyrant(), pending
|
state.game().pendingTyrant(), Optional.empty()
|
||||||
);
|
);
|
||||||
return new LifecycleState(game, players);
|
return new LifecycleState(game, players);
|
||||||
}
|
}
|
||||||
@@ -109,16 +93,8 @@ public final class RoleMaintenanceService {
|
|||||||
pendingTyrant = Optional.empty();
|
pendingTyrant = Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pendingVigilante.isPresent()) {
|
// Legacy pending Vigilante selections become arena vacancies.
|
||||||
PendingSelection pending = pendingVigilante.orElseThrow();
|
pendingVigilante = Optional.empty();
|
||||||
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(
|
return new LifecycleState(
|
||||||
copyRoles(game, tyrant, vigilante, pendingTyrant, pendingVigilante),
|
copyRoles(game, tyrant, vigilante, pendingTyrant, pendingVigilante),
|
||||||
state.players()
|
state.players()
|
||||||
@@ -163,23 +139,6 @@ public final class RoleMaintenanceService {
|
|||||||
return new LifecycleState(game, state.players());
|
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(
|
private boolean isInactive(
|
||||||
Optional<UUID> roleHolder,
|
Optional<UUID> roleHolder,
|
||||||
Map<UUID, PlayerState> players,
|
Map<UUID, PlayerState> players,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
private FixerEffectController fixerEffects;
|
private FixerEffectController fixerEffects;
|
||||||
private TyrantEffectController tyrantEffects;
|
private TyrantEffectController tyrantEffects;
|
||||||
private OptOutGlowController optOutGlow;
|
private OptOutGlowController optOutGlow;
|
||||||
|
private VigilanteArenaController vigilanteArena;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -33,6 +34,14 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ArenaLocationStore arenaLocations = new BukkitConfigArenaLocationStore(this);
|
||||||
|
try {
|
||||||
|
arenaLocations.location();
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
getLogger().log(Level.SEVERE, "Invalid Vigilante arena location", exception);
|
||||||
|
getServer().getPluginManager().disablePlugin(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Clock clock = Clock.systemUTC();
|
Clock clock = Clock.systemUTC();
|
||||||
RoleCandidateSelector candidateSelector = new RoleCandidateSelector();
|
RoleCandidateSelector candidateSelector = new RoleCandidateSelector();
|
||||||
java.util.random.RandomGenerator random = java.util.random.RandomGenerator.getDefault();
|
java.util.random.RandomGenerator random = java.util.random.RandomGenerator.getDefault();
|
||||||
@@ -131,10 +140,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
onlinePlayers,
|
onlinePlayers,
|
||||||
managedEffects,
|
managedEffects,
|
||||||
clock,
|
clock,
|
||||||
candidateSelector,
|
arenaLocations
|
||||||
settings.candidateActivityWindow(),
|
|
||||||
settings.pendingSelectionTimeout(),
|
|
||||||
random
|
|
||||||
));
|
));
|
||||||
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
|
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
|
||||||
.setTabCompleter(new TyrantTabCompleter(stateManager, onlinePlayers));
|
.setTabCompleter(new TyrantTabCompleter(stateManager, onlinePlayers));
|
||||||
@@ -148,6 +154,10 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
new PlayerJoinListener(stateManager, clock, tyrantPresentation),
|
new PlayerJoinListener(stateManager, clock, tyrantPresentation),
|
||||||
this
|
this
|
||||||
);
|
);
|
||||||
|
vigilanteArena = new VigilanteArenaController(
|
||||||
|
stateManager, getServer(), arenaLocations, settings
|
||||||
|
);
|
||||||
|
getServer().getPluginManager().registerEvents(vigilanteArena, this);
|
||||||
getServer().getPluginManager().registerEvents(controlPanelRenderer, this);
|
getServer().getPluginManager().registerEvents(controlPanelRenderer, this);
|
||||||
getServer().getPluginManager().registerEvents(vigilanteControlPanelRenderer, this);
|
getServer().getPluginManager().registerEvents(vigilanteControlPanelRenderer, this);
|
||||||
getServer().getPluginManager().registerEvents(
|
getServer().getPluginManager().registerEvents(
|
||||||
@@ -243,6 +253,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
|
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
|
||||||
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
|
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
|
||||||
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
|
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
|
||||||
|
getServer().getScheduler().runTaskTimer(this, vigilanteArena, 1L, 10L);
|
||||||
getServer().getScheduler().runTaskTimer(
|
getServer().getScheduler().runTaskTimer(
|
||||||
this,
|
this,
|
||||||
new AbilityItemRefreshTask(
|
new AbilityItemRefreshTask(
|
||||||
@@ -272,6 +283,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
|
if (vigilanteArena != null) {
|
||||||
|
vigilanteArena.close();
|
||||||
|
}
|
||||||
if (optOutGlow != null) {
|
if (optOutGlow != null) {
|
||||||
optOutGlow.clearAll();
|
optOutGlow.clearAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
package games.dmg.spigottyrant;
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
import org.bukkit.ChatColor;
|
import org.bukkit.ChatColor;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
@@ -20,10 +16,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
private final OnlinePlayerDirectory onlinePlayers;
|
private final OnlinePlayerDirectory onlinePlayers;
|
||||||
private final ManagedRoleEffects effects;
|
private final ManagedRoleEffects effects;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
private final RoleCandidateSelector candidateSelector;
|
private final ArenaLocationStore arenaLocations;
|
||||||
private final Duration activityWindow;
|
|
||||||
private final Duration pendingTimeout;
|
|
||||||
private final RandomGenerator random;
|
|
||||||
|
|
||||||
public TyrantAdminCommand(
|
public TyrantAdminCommand(
|
||||||
TyrantStateManager stateManager,
|
TyrantStateManager stateManager,
|
||||||
@@ -34,8 +27,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
) {
|
) {
|
||||||
this(
|
this(
|
||||||
stateManager, lifecycle, onlinePlayers, effects, clock,
|
stateManager, lifecycle, onlinePlayers, effects, clock,
|
||||||
new RoleCandidateSelector(), Duration.ofHours(24), Duration.ofHours(24),
|
ArenaLocationStore.unavailable()
|
||||||
RandomGenerator.getDefault()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,20 +37,14 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
OnlinePlayerDirectory onlinePlayers,
|
OnlinePlayerDirectory onlinePlayers,
|
||||||
ManagedRoleEffects effects,
|
ManagedRoleEffects effects,
|
||||||
Clock clock,
|
Clock clock,
|
||||||
RoleCandidateSelector candidateSelector,
|
ArenaLocationStore arenaLocations
|
||||||
Duration activityWindow,
|
|
||||||
Duration pendingTimeout,
|
|
||||||
RandomGenerator random
|
|
||||||
) {
|
) {
|
||||||
this.stateManager = stateManager;
|
this.stateManager = stateManager;
|
||||||
this.lifecycle = lifecycle;
|
this.lifecycle = lifecycle;
|
||||||
this.onlinePlayers = onlinePlayers;
|
this.onlinePlayers = onlinePlayers;
|
||||||
this.effects = effects;
|
this.effects = effects;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
this.candidateSelector = candidateSelector;
|
this.arenaLocations = arenaLocations;
|
||||||
this.activityWindow = activityWindow;
|
|
||||||
this.pendingTimeout = pendingTimeout;
|
|
||||||
this.random = random;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -82,6 +68,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
case "pause" -> pause(sender);
|
case "pause" -> pause(sender);
|
||||||
case "resume" -> resume(sender);
|
case "resume" -> resume(sender);
|
||||||
case "reset" -> reset(sender, arguments);
|
case "reset" -> reset(sender, arguments);
|
||||||
|
case "arena" -> arena(sender, arguments);
|
||||||
default -> usage(sender);
|
default -> usage(sender);
|
||||||
}
|
}
|
||||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||||
@@ -112,38 +99,17 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
stateManager.player(tyrant.getUniqueId(), tyrant.getName());
|
stateManager.player(tyrant.getUniqueId(), tyrant.getName());
|
||||||
vigilante.ifPresent(player -> stateManager.player(player.getUniqueId(), player.getName()));
|
vigilante.ifPresent(player -> stateManager.player(player.getUniqueId(), player.getName()));
|
||||||
InstantHolder now = new InstantHolder(clock.instant());
|
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(
|
LifecycleState started = lifecycle.start(
|
||||||
stateManager.snapshot(), tyrant.getUniqueId(), activeVigilante, now.value()
|
stateManager.snapshot(), tyrant.getUniqueId(),
|
||||||
|
vigilante.map(Player::getUniqueId), now.value()
|
||||||
);
|
);
|
||||||
if (activeVigilante.isEmpty() && randomCandidate.isPresent()) {
|
|
||||||
GameState game = started.game();
|
|
||||||
started = new LifecycleState(new GameState(
|
|
||||||
game.lifecycle(), game.tyrantId(), game.vigilanteId(), game.pendingTyrant(),
|
|
||||||
Optional.of(new PendingSelection(
|
|
||||||
randomCandidate.orElseThrow(), now.value().plus(pendingTimeout)
|
|
||||||
)),
|
|
||||||
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
|
|
||||||
game.unspentChoices(), game.purchases()
|
|
||||||
), started.players());
|
|
||||||
}
|
|
||||||
stateManager.replaceState(started);
|
stateManager.replaceState(started);
|
||||||
stateManager.saveIfDirty();
|
stateManager.saveIfDirty();
|
||||||
effects.restoreAll();
|
effects.restoreAll();
|
||||||
sender.sendMessage(ChatColor.GREEN + "Tyrant game started.");
|
sender.sendMessage(ChatColor.GREEN + "Tyrant game started.");
|
||||||
if (vigilante.isEmpty()) {
|
if (vigilante.isEmpty()) {
|
||||||
sender.sendMessage(ChatColor.YELLOW
|
sender.sendMessage(ChatColor.YELLOW
|
||||||
+ "An eligible recently active Vigilante will be selected automatically.");
|
+ "The Vigilante arena challenge is open.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +141,30 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
sender.sendMessage(ChatColor.GREEN + "Tyrant game reset.");
|
sender.sendMessage(ChatColor.GREEN + "Tyrant game reset.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void arena(CommandSender sender, String[] arguments) {
|
||||||
|
if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) {
|
||||||
|
sender.sendMessage("Vigilante arena: " + arenaLocations.location()
|
||||||
|
.map(ArenaLocation::display).orElse("not configured"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("set")) {
|
||||||
|
sender.sendMessage(ChatColor.YELLOW
|
||||||
|
+ "Usage: /tyrantadmin arena <set|status>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
sender.sendMessage(ChatColor.RED + "Only a player can set the arena location.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
org.bukkit.Location location = player.getLocation();
|
||||||
|
ArenaLocation arena = new ArenaLocation(
|
||||||
|
location.getWorld().getName(), location.getX(), location.getY(), location.getZ(),
|
||||||
|
location.getYaw(), location.getPitch()
|
||||||
|
);
|
||||||
|
arenaLocations.set(arena);
|
||||||
|
sender.sendMessage(ChatColor.GREEN + "Vigilante arena set to " + arena.display() + ".");
|
||||||
|
}
|
||||||
|
|
||||||
private void showStatus(CommandSender sender) {
|
private void showStatus(CommandSender sender) {
|
||||||
GameState game = stateManager.game();
|
GameState game = stateManager.game();
|
||||||
sender.sendMessage(ChatColor.GOLD + "Lifecycle: " + game.lifecycle());
|
sender.sendMessage(ChatColor.GOLD + "Lifecycle: " + game.lifecycle());
|
||||||
@@ -186,6 +176,8 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
+ ", purchases: " + game.purchases());
|
+ ", purchases: " + game.purchases());
|
||||||
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
|
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
|
||||||
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
|
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
|
||||||
|
sender.sendMessage("Vigilante arena: " + arenaLocations.location()
|
||||||
|
.map(ArenaLocation::display).orElse("not configured"));
|
||||||
for (PlayerState player : stateManager.players().values()) {
|
for (PlayerState player : stateManager.players().values()) {
|
||||||
boolean optedOut = player.optedOutUntil().isPresent();
|
boolean optedOut = player.optedOutUntil().isPresent();
|
||||||
sender.sendMessage(
|
sender.sendMessage(
|
||||||
@@ -202,6 +194,6 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
|
|
||||||
private static void usage(CommandSender sender) {
|
private static void usage(CommandSender sender) {
|
||||||
sender.sendMessage(ChatColor.YELLOW
|
sender.sendMessage(ChatColor.YELLOW
|
||||||
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm>");
|
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena set>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import org.bukkit.entity.Player;
|
|||||||
public final class TyrantAdminTabCompleter implements TabCompleter {
|
public final class TyrantAdminTabCompleter implements TabCompleter {
|
||||||
private static final String PERMISSION = "spigottyrant.admin";
|
private static final String PERMISSION = "spigottyrant.admin";
|
||||||
private static final List<String> SUBCOMMANDS = List.of(
|
private static final List<String> SUBCOMMANDS = List.of(
|
||||||
"status", "start", "pause", "resume", "reset"
|
"status", "start", "pause", "resume", "reset", "arena"
|
||||||
);
|
);
|
||||||
private final OnlinePlayerDirectory onlinePlayers;
|
private final OnlinePlayerDirectory onlinePlayers;
|
||||||
|
|
||||||
@@ -37,6 +37,9 @@ public final class TyrantAdminTabCompleter implements TabCompleter {
|
|||||||
return switch (subcommand) {
|
return switch (subcommand) {
|
||||||
case "start" -> TabSuggestions.matching(arguments[1], onlineNames(null));
|
case "start" -> TabSuggestions.matching(arguments[1], onlineNames(null));
|
||||||
case "reset" -> TabSuggestions.matching(arguments[1], List.of("confirm"));
|
case "reset" -> TabSuggestions.matching(arguments[1], List.of("confirm"));
|
||||||
|
case "arena" -> TabSuggestions.matching(
|
||||||
|
arguments[1], List.of("set", "status")
|
||||||
|
);
|
||||||
default -> List.of();
|
default -> List.of();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ public final class TyrantCommand implements CommandExecutor {
|
|||||||
private void relinquish(Player player, String[] arguments) {
|
private void relinquish(Player player, String[] arguments) {
|
||||||
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) {
|
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) {
|
||||||
player.sendMessage(ChatColor.RED
|
player.sendMessage(ChatColor.RED
|
||||||
+ "Relinquishing rerolls your role. Use /tyrant relinquish confirm.");
|
+ "Relinquishing ends your role. Use /tyrant relinquish confirm.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (relinquishment == null) {
|
if (relinquishment == null) {
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ public final class TyrantSuccessionService {
|
|||||||
throw new IllegalStateException("a running Tyrant reign is required");
|
throw new IllegalStateException("a running Tyrant reign is required");
|
||||||
}
|
}
|
||||||
UUID formerTyrant = state.game().tyrantId().orElseThrow();
|
UUID formerTyrant = state.game().tyrantId().orElseThrow();
|
||||||
Optional<UUID> formerVigilante = state.game().vigilanteId();
|
|
||||||
Map<UUID, PlayerState> clearedPlayers = clearAssignments(state.players());
|
Map<UUID, PlayerState> clearedPlayers = clearAssignments(state.players());
|
||||||
Optional<UUID> selectedTyrant = killerId.isPresent()
|
Optional<UUID> selectedTyrant = killerId.isPresent()
|
||||||
? killerId
|
? killerId
|
||||||
@@ -56,28 +55,12 @@ public final class TyrantSuccessionService {
|
|||||||
.filter(playerId -> activeTyrant.isEmpty())
|
.filter(playerId -> activeTyrant.isEmpty())
|
||||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
.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(
|
GameState game = new GameState(
|
||||||
GameLifecycle.RUNNING,
|
GameLifecycle.RUNNING,
|
||||||
activeTyrant,
|
activeTyrant,
|
||||||
activeVigilante,
|
Optional.empty(),
|
||||||
pendingTyrant,
|
pendingTyrant,
|
||||||
pendingVigilante,
|
Optional.empty(),
|
||||||
Optional.empty(),
|
Optional.empty(),
|
||||||
state.game().accumulatedPausedTime(),
|
state.game().accumulatedPausedTime(),
|
||||||
0,
|
0,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.data.BlockData;
|
||||||
|
|
||||||
|
final class VigilanteArenaBarrier {
|
||||||
|
private final Map<Location, BlockData> replaced = new LinkedHashMap<>();
|
||||||
|
private boolean built;
|
||||||
|
|
||||||
|
void build(World world, ArenaLocation center, double radius) {
|
||||||
|
if (built) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
built = true;
|
||||||
|
int samples = Math.max(64, (int) Math.ceil(2.0 * Math.PI * radius * 2.0));
|
||||||
|
int baseY = (int) Math.floor(center.y());
|
||||||
|
for (int index = 0; index < samples; index++) {
|
||||||
|
double angle = 2.0 * Math.PI * index / samples;
|
||||||
|
int x = (int) Math.floor(center.x() + Math.cos(angle) * radius);
|
||||||
|
int z = (int) Math.floor(center.z() + Math.sin(angle) * radius);
|
||||||
|
for (int y = baseY; y < baseY + 3; y++) {
|
||||||
|
Block block = world.getBlockAt(x, y, z);
|
||||||
|
Location location = block.getLocation();
|
||||||
|
if (replaced.containsKey(location) || !isAir(block.getType())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
replaced.put(location, block.getBlockData().clone());
|
||||||
|
block.setType(Material.BARRIER, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
for (Map.Entry<Location, BlockData> entry : replaced.entrySet()) {
|
||||||
|
Location location = entry.getKey();
|
||||||
|
World world = location.getWorld();
|
||||||
|
if (world != null) {
|
||||||
|
world.getBlockAt(location).setBlockData(entry.getValue(), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replaced.clear();
|
||||||
|
built = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean active() {
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isAir(Material material) {
|
||||||
|
return material == Material.AIR
|
||||||
|
|| material == Material.CAVE_AIR
|
||||||
|
|| material == Material.VOID_AIR;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Particle;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.attribute.Attribute;
|
||||||
|
import org.bukkit.entity.Entity;
|
||||||
|
import org.bukkit.entity.LivingEntity;
|
||||||
|
import org.bukkit.entity.Mob;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.entity.Projectile;
|
||||||
|
import org.bukkit.entity.Zombie;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.entity.CreatureSpawnEvent;
|
||||||
|
import org.bukkit.event.entity.EntityCombustEvent;
|
||||||
|
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||||
|
import org.bukkit.event.entity.EntityDeathEvent;
|
||||||
|
import org.bukkit.event.entity.EntityTargetLivingEntityEvent;
|
||||||
|
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||||
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
|
import org.bukkit.event.player.PlayerMoveEvent;
|
||||||
|
import org.bukkit.event.player.PlayerQuitEvent;
|
||||||
|
import org.bukkit.event.player.PlayerRespawnEvent;
|
||||||
|
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||||
|
|
||||||
|
public final class VigilanteArenaController implements Listener, Runnable {
|
||||||
|
private static final String BOSS_TAG = "spigottyrant-vigilante-boss";
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final Server server;
|
||||||
|
private final ArenaLocationStore locations;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
private final VigilanteArenaSuccessionService succession =
|
||||||
|
new VigilanteArenaSuccessionService();
|
||||||
|
private final VigilanteArenaBarrier barrier = new VigilanteArenaBarrier();
|
||||||
|
private final Set<UUID> relocating = new HashSet<>();
|
||||||
|
private UUID bossId;
|
||||||
|
private UUID challengerId;
|
||||||
|
private boolean spawningBoss;
|
||||||
|
private boolean wasOpen;
|
||||||
|
private boolean warnedMissingLocation;
|
||||||
|
private ArenaLocation observedLocation;
|
||||||
|
|
||||||
|
public VigilanteArenaController(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
Server server,
|
||||||
|
ArenaLocationStore locations,
|
||||||
|
PluginSettings settings
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.server = server;
|
||||||
|
this.locations = locations;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
Optional<ArenaLocation> configured = locations.location();
|
||||||
|
if (configured.isEmpty()) {
|
||||||
|
closeFight();
|
||||||
|
observedLocation = null;
|
||||||
|
warnIfOpenWithoutLocation();
|
||||||
|
wasOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ArenaLocation arena = configured.orElseThrow();
|
||||||
|
if (observedLocation != null && !observedLocation.equals(arena)) {
|
||||||
|
closeFight();
|
||||||
|
}
|
||||||
|
observedLocation = arena;
|
||||||
|
World world = server.getWorld(arena.worldName());
|
||||||
|
if (world == null) {
|
||||||
|
closeFight();
|
||||||
|
warnIfOpenWithoutLocation();
|
||||||
|
wasOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
warnedMissingLocation = false;
|
||||||
|
drawBoundary(world, arena);
|
||||||
|
removeOtherMobs(world, arena);
|
||||||
|
boolean open = VigilanteArenaPolicy.isOpen(stateManager.game());
|
||||||
|
if (open && !wasOpen) {
|
||||||
|
announceVacancy(arena);
|
||||||
|
}
|
||||||
|
wasOpen = open;
|
||||||
|
if (!open) {
|
||||||
|
closeFight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Zombie boss = boss(world);
|
||||||
|
if (boss == null) {
|
||||||
|
boss = spawnBoss(world, arena);
|
||||||
|
}
|
||||||
|
boss.setFireTicks(0);
|
||||||
|
if (!contains(arena, boss.getLocation())) {
|
||||||
|
boss.teleport(toBukkit(world, arena));
|
||||||
|
}
|
||||||
|
Player challenger = challengerId == null ? null : server.getPlayer(challengerId);
|
||||||
|
if (challenger == null || challenger.isDead()
|
||||||
|
|| !contains(arena, challenger.getLocation())
|
||||||
|
|| !VigilanteArenaPolicy.isEligible(
|
||||||
|
stateManager.game(), playerState(challenger)
|
||||||
|
)) {
|
||||||
|
if (challengerId != null) {
|
||||||
|
resetFight();
|
||||||
|
}
|
||||||
|
boss.setTarget(null);
|
||||||
|
} else {
|
||||||
|
boss.setTarget(challenger);
|
||||||
|
}
|
||||||
|
for (Player player : world.getPlayers()) {
|
||||||
|
enforcePosition(player, null, player.getLocation());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
|
public void onMove(PlayerMoveEvent event) {
|
||||||
|
if (event.getTo() != null && movedHorizontally(event.getFrom(), event.getTo())
|
||||||
|
&& enforcePosition(event.getPlayer(), event.getFrom(), event.getTo())) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
|
public void onTeleport(PlayerTeleportEvent event) {
|
||||||
|
if (event.getTo() != null
|
||||||
|
&& enforcePosition(event.getPlayer(), event.getFrom(), event.getTo())) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST)
|
||||||
|
public void onRespawn(PlayerRespawnEvent event) {
|
||||||
|
Optional<ArenaLocation> configured = locations.location();
|
||||||
|
if (configured.isPresent() && contains(
|
||||||
|
configured.orElseThrow(), event.getRespawnLocation())) {
|
||||||
|
event.setRespawnLocation(outside(configured.orElseThrow(), event.getRespawnLocation()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onPlayerDeath(PlayerDeathEvent event) {
|
||||||
|
if (event.getEntity().getUniqueId().equals(challengerId)) {
|
||||||
|
resetFight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onQuit(PlayerQuitEvent event) {
|
||||||
|
if (event.getPlayer().getUniqueId().equals(challengerId)) {
|
||||||
|
resetFight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onJoin(PlayerJoinEvent event) {
|
||||||
|
if (!VigilanteArenaPolicy.isOpen(stateManager.game())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlayerState state = playerState(event.getPlayer());
|
||||||
|
locations.location().filter(location -> VigilanteArenaPolicy.isEligible(
|
||||||
|
stateManager.game(), state
|
||||||
|
)).ifPresent(location -> event.getPlayer().sendMessage(vacancyMessage(location)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
|
public void onCreatureSpawn(CreatureSpawnEvent event) {
|
||||||
|
Optional<ArenaLocation> configured = locations.location();
|
||||||
|
if (!spawningBoss && configured.isPresent()
|
||||||
|
&& contains(configured.orElseThrow(), event.getLocation())) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
|
public void onCombust(EntityCombustEvent event) {
|
||||||
|
if (event.getEntity().getUniqueId().equals(bossId)) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
|
public void onTarget(EntityTargetLivingEntityEvent event) {
|
||||||
|
if (!event.getEntity().getUniqueId().equals(bossId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Entity target = event.getTarget();
|
||||||
|
if (target != null && (challengerId == null
|
||||||
|
|| !target.getUniqueId().equals(challengerId))) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
|
public void onDamage(EntityDamageByEntityEvent event) {
|
||||||
|
if (!event.getEntity().getUniqueId().equals(bossId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Player attacker = attackingPlayer(event.getDamager());
|
||||||
|
if (attacker == null || challengerId == null
|
||||||
|
|| !attacker.getUniqueId().equals(challengerId)) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
|
public void onBossDeath(EntityDeathEvent event) {
|
||||||
|
if (!event.getEntity().getUniqueId().equals(bossId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UUID defeatedBoss = bossId;
|
||||||
|
bossId = null;
|
||||||
|
Player killer = event.getEntity().getKiller();
|
||||||
|
if (killer != null && challengerId != null
|
||||||
|
&& killer.getUniqueId().equals(challengerId)
|
||||||
|
&& VigilanteArenaPolicy.isEligible(
|
||||||
|
stateManager.game(), playerState(killer)
|
||||||
|
)) {
|
||||||
|
assignVigilante(challengerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bossId = defeatedBoss;
|
||||||
|
resetFight();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void close() {
|
||||||
|
closeFight();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean enforcePosition(Player player, Location from, Location to) {
|
||||||
|
Optional<ArenaLocation> configured = locations.location();
|
||||||
|
if (configured.isEmpty() || relocating.contains(player.getUniqueId())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ArenaLocation arena = configured.orElseThrow();
|
||||||
|
boolean inside = contains(arena, to);
|
||||||
|
if (player.getUniqueId().equals(challengerId)) {
|
||||||
|
if (!inside) {
|
||||||
|
resetFight();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!inside) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
PlayerState state = playerState(player);
|
||||||
|
if (challengerId == null && bossId != null
|
||||||
|
&& VigilanteArenaPolicy.isEligible(stateManager.game(), state)) {
|
||||||
|
challengerId = player.getUniqueId();
|
||||||
|
World world = server.getWorld(arena.worldName());
|
||||||
|
if (world != null) {
|
||||||
|
barrier.build(world, arena, settings.vigilanteArenaRadiusBlocks());
|
||||||
|
Zombie boss = boss(world);
|
||||||
|
if (boss != null) {
|
||||||
|
boss.setHealth(settings.vigilanteBossHealth());
|
||||||
|
boss.teleport(toBukkit(world, arena));
|
||||||
|
boss.setTarget(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.sendMessage(ChatColor.GOLD + "Defeat the arena boss to become Vigilante!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bounce(player, from, to, arena);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bounce(Player player, Location from, Location attempted, ArenaLocation arena) {
|
||||||
|
Location destination = from != null && !contains(arena, from)
|
||||||
|
? from.clone() : outside(arena, attempted);
|
||||||
|
relocating.add(player.getUniqueId());
|
||||||
|
try {
|
||||||
|
player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||||
|
} finally {
|
||||||
|
relocating.remove(player.getUniqueId());
|
||||||
|
}
|
||||||
|
player.sendMessage(ChatColor.RED + "Only the active challenger may enter the arena.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resetFight() {
|
||||||
|
challengerId = null;
|
||||||
|
barrier.clear();
|
||||||
|
Optional<ArenaLocation> configured = locations.location();
|
||||||
|
if (configured.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
World world = server.getWorld(configured.orElseThrow().worldName());
|
||||||
|
Zombie boss = world == null ? null : boss(world);
|
||||||
|
if (boss != null && !boss.isDead()) {
|
||||||
|
boss.setHealth(settings.vigilanteBossHealth());
|
||||||
|
boss.teleport(toBukkit(world, configured.orElseThrow()));
|
||||||
|
boss.setTarget(null);
|
||||||
|
} else {
|
||||||
|
bossId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeFight() {
|
||||||
|
challengerId = null;
|
||||||
|
barrier.clear();
|
||||||
|
if (bossId != null) {
|
||||||
|
Entity entity = server.getEntity(bossId);
|
||||||
|
if (entity != null) {
|
||||||
|
entity.remove();
|
||||||
|
}
|
||||||
|
bossId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Zombie spawnBoss(World world, ArenaLocation arena) {
|
||||||
|
for (Entity entity : world.getEntities()) {
|
||||||
|
if (entity.getScoreboardTags().contains(BOSS_TAG)) {
|
||||||
|
entity.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spawningBoss = true;
|
||||||
|
try {
|
||||||
|
Zombie boss = world.spawn(toBukkit(world, arena), Zombie.class, zombie -> {
|
||||||
|
zombie.setAdult();
|
||||||
|
zombie.setCustomName(ChatColor.DARK_RED + "Vigilante's Trial");
|
||||||
|
zombie.setCustomNameVisible(true);
|
||||||
|
zombie.setRemoveWhenFarAway(false);
|
||||||
|
zombie.setCanPickupItems(false);
|
||||||
|
zombie.addScoreboardTag(BOSS_TAG);
|
||||||
|
setAttribute(zombie, Attribute.MAX_HEALTH, settings.vigilanteBossHealth());
|
||||||
|
setAttribute(zombie, Attribute.ATTACK_DAMAGE, settings.vigilanteBossDamage());
|
||||||
|
setAttribute(zombie, Attribute.ARMOR, settings.vigilanteBossArmor());
|
||||||
|
zombie.setHealth(settings.vigilanteBossHealth());
|
||||||
|
});
|
||||||
|
bossId = boss.getUniqueId();
|
||||||
|
return boss;
|
||||||
|
} finally {
|
||||||
|
spawningBoss = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Zombie boss(World world) {
|
||||||
|
if (bossId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Entity entity = server.getEntity(bossId);
|
||||||
|
return entity instanceof Zombie zombie && entity.getWorld().equals(world)
|
||||||
|
&& !zombie.isDead() ? zombie : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assignVigilante(UUID playerId) {
|
||||||
|
LifecycleState assigned;
|
||||||
|
try {
|
||||||
|
assigned = succession.claim(stateManager.snapshot(), playerId);
|
||||||
|
} catch (IllegalStateException exception) {
|
||||||
|
resetFight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stateManager.replaceState(assigned);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
challengerId = null;
|
||||||
|
barrier.clear();
|
||||||
|
String name = Optional.ofNullable(server.getPlayer(playerId))
|
||||||
|
.map(Player::getName).orElse(playerId.toString());
|
||||||
|
String message = ChatColor.YELLOW + name + " defeated the arena boss and is the Vigilante!";
|
||||||
|
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void announceVacancy(ArenaLocation arena) {
|
||||||
|
String message = vacancyMessage(arena);
|
||||||
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
PlayerState state = playerState(player);
|
||||||
|
if (VigilanteArenaPolicy.isEligible(stateManager.game(), state)) {
|
||||||
|
player.sendMessage(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void warnIfOpenWithoutLocation() {
|
||||||
|
if (!VigilanteArenaPolicy.isOpen(stateManager.game()) || warnedMissingLocation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
warnedMissingLocation = true;
|
||||||
|
server.getConsoleSender().sendMessage(ChatColor.RED
|
||||||
|
+ "The Vigilante role is vacant, but no valid arena location is configured. "
|
||||||
|
+ "Use /tyrantadmin arena set.");
|
||||||
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
if (player.hasPermission("spigottyrant.admin")) {
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "The Vigilante arena location is not configured.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String vacancyMessage(ArenaLocation arena) {
|
||||||
|
return ChatColor.YELLOW + "The Vigilante position is open! Challenge the boss in "
|
||||||
|
+ arena.display() + ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeOtherMobs(World world, ArenaLocation arena) {
|
||||||
|
Location center = toBukkit(world, arena);
|
||||||
|
double radius = settings.vigilanteArenaRadiusBlocks();
|
||||||
|
for (Entity entity : world.getNearbyEntities(center, radius, world.getMaxHeight(), radius)) {
|
||||||
|
if (entity instanceof Mob && !entity.getUniqueId().equals(bossId)
|
||||||
|
&& contains(arena, entity.getLocation())) {
|
||||||
|
entity.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawBoundary(World world, ArenaLocation arena) {
|
||||||
|
if (barrier.active()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
double radius = settings.vigilanteArenaRadiusBlocks();
|
||||||
|
for (int index = 0; index < 48; index++) {
|
||||||
|
double angle = 2.0 * Math.PI * index / 48.0;
|
||||||
|
Location point = new Location(
|
||||||
|
world,
|
||||||
|
arena.x() + Math.cos(angle) * radius,
|
||||||
|
arena.y() + 1.0,
|
||||||
|
arena.z() + Math.sin(angle) * radius
|
||||||
|
);
|
||||||
|
world.spawnParticle(Particle.END_ROD, point, 1, 0.0, 0.0, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean contains(ArenaLocation arena, Location location) {
|
||||||
|
World world = location.getWorld();
|
||||||
|
return world != null && ArenaGeometry.contains(
|
||||||
|
arena, settings.vigilanteArenaRadiusBlocks(), world.getName(),
|
||||||
|
location.getX(), location.getY(), location.getZ()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Location outside(ArenaLocation arena, Location attempted) {
|
||||||
|
ArenaPoint point = ArenaGeometry.outside(
|
||||||
|
arena, settings.vigilanteArenaRadiusBlocks(), attempted.getX(),
|
||||||
|
attempted.getY(), attempted.getZ()
|
||||||
|
);
|
||||||
|
World world = server.getWorld(arena.worldName());
|
||||||
|
if (world == null) {
|
||||||
|
return attempted;
|
||||||
|
}
|
||||||
|
int x = (int) Math.floor(point.x());
|
||||||
|
int z = (int) Math.floor(point.z());
|
||||||
|
int preferredY = (int) Math.floor(point.y());
|
||||||
|
for (int offset = 0; offset <= 8; offset++) {
|
||||||
|
int[] candidates = offset == 0
|
||||||
|
? new int[] {preferredY} : new int[] {preferredY + offset, preferredY - offset};
|
||||||
|
for (int y : candidates) {
|
||||||
|
if (y > world.getMinHeight() && y + 1 < world.getMaxHeight()
|
||||||
|
&& world.getBlockAt(x, y, z).isPassable()
|
||||||
|
&& world.getBlockAt(x, y + 1, z).isPassable()
|
||||||
|
&& !world.getBlockAt(x, y - 1, z).isPassable()) {
|
||||||
|
return new Location(world, x + 0.5, y, z + 0.5,
|
||||||
|
attempted.getYaw(), attempted.getPitch());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int surfaceY = Math.min(
|
||||||
|
world.getMaxHeight() - 2,
|
||||||
|
Math.max(world.getMinHeight() + 1, world.getHighestBlockYAt(x, z) + 1)
|
||||||
|
);
|
||||||
|
return new Location(world, x + 0.5, surfaceY, z + 0.5,
|
||||||
|
attempted.getYaw(), attempted.getPitch());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlayerState playerState(Player player) {
|
||||||
|
PlayerState existing = stateManager.players().get(player.getUniqueId());
|
||||||
|
return existing == null
|
||||||
|
? stateManager.player(player.getUniqueId(), player.getName()) : existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Location toBukkit(World world, ArenaLocation arena) {
|
||||||
|
return new Location(
|
||||||
|
world, arena.x(), arena.y(), arena.z(), arena.yaw(), arena.pitch()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Player attackingPlayer(Entity damager) {
|
||||||
|
if (damager instanceof Player player) {
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
if (damager instanceof Projectile projectile
|
||||||
|
&& projectile.getShooter() instanceof Player player) {
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setAttribute(Zombie zombie, Attribute attribute, double value) {
|
||||||
|
if (zombie.getAttribute(attribute) != null) {
|
||||||
|
zombie.getAttribute(attribute).setBaseValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean movedHorizontally(Location from, Location to) {
|
||||||
|
return from.getWorld() != to.getWorld()
|
||||||
|
|| from.getX() != to.getX() || from.getZ() != to.getZ();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public final class VigilanteArenaPolicy {
|
||||||
|
private VigilanteArenaPolicy() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isOpen(GameState game) {
|
||||||
|
return game.lifecycle() == GameLifecycle.RUNNING
|
||||||
|
&& game.tyrantId().isPresent()
|
||||||
|
&& game.vigilanteId().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isEligible(GameState game, PlayerState player) {
|
||||||
|
return player != null
|
||||||
|
&& isOpen(game)
|
||||||
|
&& player.optedOutUntil().isEmpty()
|
||||||
|
&& game.tyrantId().filter(player.playerId()::equals).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class VigilanteArenaSuccessionService {
|
||||||
|
public LifecycleState claim(PersistentState state, UUID winnerId) {
|
||||||
|
PlayerState winner = state.players().get(winnerId);
|
||||||
|
if (!VigilanteArenaPolicy.isEligible(state.game(), winner)) {
|
||||||
|
throw new IllegalStateException("player is not eligible to claim the Vigilante role");
|
||||||
|
}
|
||||||
|
GameState game = state.game();
|
||||||
|
GameState assigned = new GameState(
|
||||||
|
game.lifecycle(), game.tyrantId(), Optional.of(winnerId), game.pendingTyrant(),
|
||||||
|
Optional.empty(), game.pausedAt(), game.accumulatedPausedTime(),
|
||||||
|
game.tyrantLevel(), game.unspentChoices(), game.purchases()
|
||||||
|
);
|
||||||
|
return new LifecycleState(assigned, state.players());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,8 +55,8 @@ public final class VigilanteDeathListener implements Listener {
|
|||||||
);
|
);
|
||||||
stateManager.replaceState(rerolled);
|
stateManager.replaceState(rerolled);
|
||||||
stateManager.saveIfDirty();
|
stateManager.saveIfDirty();
|
||||||
String message = ChatColor.YELLOW + "The Vigilante died; a replacement "
|
String message = ChatColor.YELLOW
|
||||||
+ (rerolled.game().vigilanteId().isPresent() ? "has been selected." : "is pending.");
|
+ "The Vigilante died; the arena challenge is now open.";
|
||||||
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
|
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
tyrant-range-blocks: 50
|
tyrant-range-blocks: 50
|
||||||
follower-range-blocks: 50
|
follower-range-blocks: 50
|
||||||
|
|
||||||
|
# Vigilante arena and boss
|
||||||
|
vigilante-arena-radius-blocks: 10
|
||||||
|
vigilante-boss-health: 80
|
||||||
|
vigilante-boss-damage: 8
|
||||||
|
vigilante-boss-armor: 10
|
||||||
|
# Set with /tyrantadmin arena set
|
||||||
|
|
||||||
# Participation, inactivity, and selection
|
# Participation, inactivity, and selection
|
||||||
opt-out-seconds: 604800
|
opt-out-seconds: 604800
|
||||||
role-inactivity-seconds: 172800
|
role-inactivity-seconds: 172800
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ commands:
|
|||||||
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
|
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
|
||||||
tyrantadmin:
|
tyrantadmin:
|
||||||
description: Administer the Spigot Tyrant game.
|
description: Administer the Spigot Tyrant game.
|
||||||
usage: /tyrantadmin <status|start|pause|resume|reset confirm>
|
usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena <set|status>>
|
||||||
permission: spigottyrant.admin
|
permission: spigottyrant.admin
|
||||||
permissions:
|
permissions:
|
||||||
spigottyrant.admin:
|
spigottyrant.admin:
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class ArenaGeometryTest {
|
||||||
|
private final ArenaLocation center = new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void circleUsesHorizontalDistanceAndWorld() {
|
||||||
|
assertTrue(ArenaGeometry.contains(center, 10.0, "world", 16.0, 100.0, 3.0));
|
||||||
|
assertFalse(ArenaGeometry.contains(center, 10.0, "world", 21.0, 64.0, -5.0));
|
||||||
|
assertFalse(ArenaGeometry.contains(center, 10.0, "nether", 10.0, 64.0, -5.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void outsidePointProjectsBeyondNearestBoundary() {
|
||||||
|
ArenaPoint point = ArenaGeometry.outside(center, 10.0, 10.0, 64.0, -5.0);
|
||||||
|
|
||||||
|
assertEquals(21.0, point.x(), 0.001);
|
||||||
|
assertEquals(64.0, point.y(), 0.001);
|
||||||
|
assertEquals(-5.0, point.z(), 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class BukkitConfigArenaLocationStoreTest {
|
||||||
|
@Test
|
||||||
|
void arenaLocationRoundTripsThroughPluginConfiguration() {
|
||||||
|
JavaPlugin plugin = mock(JavaPlugin.class);
|
||||||
|
YamlConfiguration config = new YamlConfiguration();
|
||||||
|
when(plugin.getConfig()).thenReturn(config);
|
||||||
|
BukkitConfigArenaLocationStore store = new BukkitConfigArenaLocationStore(plugin);
|
||||||
|
ArenaLocation expected = new ArenaLocation(
|
||||||
|
"arena_world", 12.5, 70.0, -4.5, 90.0F, 5.0F
|
||||||
|
);
|
||||||
|
|
||||||
|
store.set(expected);
|
||||||
|
|
||||||
|
assertEquals(expected, store.location().orElseThrow());
|
||||||
|
verify(plugin).saveConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ final class PluginSettingsTest {
|
|||||||
assertEquals(Duration.ofHours(1), settings.fixerCooldown());
|
assertEquals(Duration.ofHours(1), settings.fixerCooldown());
|
||||||
assertEquals(Duration.ofMinutes(10), settings.fixerEffectDuration());
|
assertEquals(Duration.ofMinutes(10), settings.fixerEffectDuration());
|
||||||
assertEquals(Duration.ofHours(24), settings.rosterIntelligenceCooldown());
|
assertEquals(Duration.ofHours(24), settings.rosterIntelligenceCooldown());
|
||||||
|
assertEquals(10.0, settings.vigilanteArenaRadiusBlocks());
|
||||||
|
assertEquals(80.0, settings.vigilanteBossHealth());
|
||||||
|
assertEquals(8.0, settings.vigilanteBossDamage());
|
||||||
|
assertEquals(10.0, settings.vigilanteBossArmor());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -67,5 +71,17 @@ final class PluginSettingsTest {
|
|||||||
IllegalArgumentException.class,
|
IllegalArgumentException.class,
|
||||||
() -> PluginSettings.from(Map.of("role-inactivity-seconds", -1))
|
() -> PluginSettings.from(Map.of("role-inactivity-seconds", -1))
|
||||||
);
|
);
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> PluginSettings.from(Map.of("vigilante-arena-radius-blocks", 0))
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> PluginSettings.from(Map.of("vigilante-boss-health", Double.NaN))
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> PluginSettings.from(Map.of("vigilante-boss-armor", 31))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ final class RoleMaintenanceServiceTest {
|
|||||||
);
|
);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void onlinePendingVigilanteConfirmsWithoutConflictingWithTyrant() {
|
void legacyPendingVigilanteIsClearedForArenaSuccession() {
|
||||||
GameState game = game(
|
GameState game = game(
|
||||||
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
|
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
|
||||||
Optional.of(new PendingSelection(CANDIDATE, NOW.plusSeconds(60)))
|
Optional.of(new PendingSelection(CANDIDATE, NOW.plusSeconds(60)))
|
||||||
@@ -43,12 +43,12 @@ final class RoleMaintenanceServiceTest {
|
|||||||
|
|
||||||
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, CANDIDATE), NOW);
|
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, CANDIDATE), NOW);
|
||||||
|
|
||||||
assertEquals(Optional.of(CANDIDATE), maintained.game().vigilanteId());
|
assertEquals(Optional.empty(), maintained.game().vigilanteId());
|
||||||
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
|
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void expiredPendingSelectionRerollsAwayFromPreviousCandidate() {
|
void expiredLegacyPendingVigilanteOpensArenaInsteadOfRerolling() {
|
||||||
GameState game = game(
|
GameState game = game(
|
||||||
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
|
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
|
||||||
Optional.of(new PendingSelection(CANDIDATE, NOW.minusSeconds(1)))
|
Optional.of(new PendingSelection(CANDIDATE, NOW.minusSeconds(1)))
|
||||||
@@ -59,12 +59,12 @@ final class RoleMaintenanceServiceTest {
|
|||||||
|
|
||||||
LifecycleState maintained = service.maintain(state, Set.of(TYRANT), NOW);
|
LifecycleState maintained = service.maintain(state, Set.of(TYRANT), NOW);
|
||||||
|
|
||||||
assertEquals(OTHER,
|
assertEquals(Optional.empty(), maintained.game().vigilanteId());
|
||||||
maintained.game().pendingVigilante().orElseThrow().candidateId());
|
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void inactiveVigilanteIsRerolledAndFollowersAreCleared() {
|
void inactiveVigilanteOpensArenaAndFollowersAreCleared() {
|
||||||
PlayerState follower = withFollower(
|
PlayerState follower = withFollower(
|
||||||
withLogin(active(FOLLOWER), NOW.minus(Duration.ofHours(25))),
|
withLogin(active(FOLLOWER), NOW.minus(Duration.ofHours(25))),
|
||||||
VIGILANTE
|
VIGILANTE
|
||||||
@@ -80,7 +80,7 @@ final class RoleMaintenanceServiceTest {
|
|||||||
|
|
||||||
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, OTHER), NOW);
|
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, OTHER), NOW);
|
||||||
|
|
||||||
assertEquals(Optional.of(OTHER), maintained.game().vigilanteId());
|
assertEquals(Optional.empty(), maintained.game().vigilanteId());
|
||||||
assertEquals(Optional.empty(), maintained.players().get(FOLLOWER).followerOf());
|
assertEquals(Optional.empty(), maintained.players().get(FOLLOWER).followerOf());
|
||||||
assertEquals(Optional.of(TYRANT), maintained.game().tyrantId());
|
assertEquals(Optional.of(TYRANT), maintained.game().tyrantId());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
|
|
||||||
final class RoleRelinquishmentServiceTest {
|
final class RoleRelinquishmentServiceTest {
|
||||||
@Test
|
@Test
|
||||||
void vigilanteCanRelinquishAndTriggerNormalReroll() {
|
void vigilanteCanRelinquishAndOpenArenaChallenge() {
|
||||||
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||||
UUID candidate = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
UUID candidate = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||||
@@ -49,7 +49,8 @@ final class RoleRelinquishmentServiceTest {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(RelinquishmentStatus.RELINQUISHED, result.status());
|
assertEquals(RelinquishmentStatus.RELINQUISHED, result.status());
|
||||||
assertEquals(Optional.of(candidate), result.state().game().vigilanteId());
|
assertEquals(Optional.empty(), result.state().game().vigilanteId());
|
||||||
|
assertEquals(Optional.empty(), result.state().game().pendingVigilante());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PlayerState active(UUID id, Instant now) {
|
private static PlayerState active(UUID id, Instant now) {
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.World;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
final class TyrantAdminCommandTest {
|
final class TyrantAdminCommandTest {
|
||||||
@@ -59,6 +62,30 @@ final class TyrantAdminCommandTest {
|
|||||||
verify(effects).suppressAll();
|
verify(effects).suppressAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void playerCanPersistArenaAtCurrentLocation() {
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
Player sender = mock(Player.class);
|
||||||
|
World world = mock(World.class);
|
||||||
|
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
|
||||||
|
when(world.getName()).thenReturn("arena_world");
|
||||||
|
when(sender.getLocation()).thenReturn(new Location(world, 12.5, 70.0, -4.5, 90.0F, 0.0F));
|
||||||
|
ArenaLocationStore store = mock(ArenaLocationStore.class);
|
||||||
|
TyrantAdminCommand command = new TyrantAdminCommand(
|
||||||
|
manager, new GameLifecycleService(), mock(OnlinePlayerDirectory.class),
|
||||||
|
mock(ManagedRoleEffects.class),
|
||||||
|
Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC), store
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(sender, mock(Command.class), "tyrantadmin", new String[] {"arena", "set"});
|
||||||
|
|
||||||
|
verify(store).set(org.mockito.ArgumentMatchers.argThat(location ->
|
||||||
|
location.worldName().equals("arena_world")
|
||||||
|
&& location.x() == 12.5 && location.y() == 70.0 && location.z() == -4.5
|
||||||
|
));
|
||||||
|
verify(sender).sendMessage(contains("Vigilante arena set"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void resetRequiresExplicitConfirmation() {
|
void resetRequiresExplicitConfirmation() {
|
||||||
TyrantStateManager manager = mock(TyrantStateManager.class);
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ final class TyrantAdminTabCompleterTest {
|
|||||||
));
|
));
|
||||||
assertTrue(completer.onTabComplete(
|
assertTrue(completer.onTabComplete(
|
||||||
admin, command, "tyrantadmin", new String[] {""}
|
admin, command, "tyrantadmin", new String[] {""}
|
||||||
).containsAll(java.util.List.of("status", "start", "pause", "resume", "reset")));
|
).containsAll(java.util.List.of(
|
||||||
|
"status", "start", "pause", "resume", "reset", "arena"
|
||||||
|
)));
|
||||||
assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
|
assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
|
||||||
admin, command, "tyrantadmin", new String[] {"start", ""}
|
admin, command, "tyrantadmin", new String[] {"start", ""}
|
||||||
));
|
));
|
||||||
@@ -44,6 +46,9 @@ final class TyrantAdminTabCompleterTest {
|
|||||||
assertEquals(java.util.List.of("confirm"), completer.onTabComplete(
|
assertEquals(java.util.List.of("confirm"), completer.onTabComplete(
|
||||||
admin, command, "tyrantadmin", new String[] {"reset", "c"}
|
admin, command, "tyrantadmin", new String[] {"reset", "c"}
|
||||||
));
|
));
|
||||||
|
assertEquals(java.util.List.of("set", "status"), completer.onTabComplete(
|
||||||
|
admin, command, "tyrantadmin", new String[] {"arena", "s"}
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Player player(UUID id, String name) {
|
private static Player player(UUID id, String name) {
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ final class TyrantSuccessionServiceTest {
|
|||||||
assertEquals(0, after.game().tyrantLevel());
|
assertEquals(0, after.game().tyrantLevel());
|
||||||
assertEquals(1, after.game().unspentChoices());
|
assertEquals(1, after.game().unspentChoices());
|
||||||
assertEquals(Set.of(), after.game().purchases());
|
assertEquals(Set.of(), after.game().purchases());
|
||||||
assertNotEquals(after.game().tyrantId(), after.game().vigilanteId());
|
assertEquals(Optional.empty(), after.game().vigilanteId());
|
||||||
|
assertEquals(Optional.empty(), after.game().pendingVigilante());
|
||||||
for (PlayerState player : after.players().values()) {
|
for (PlayerState player : after.players().values()) {
|
||||||
assertEquals(TyrantClass.NONE, player.tyrantClass());
|
assertEquals(TyrantClass.NONE, player.tyrantClass());
|
||||||
assertEquals(Optional.empty(), player.followerOf());
|
assertEquals(Optional.empty(), player.followerOf());
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.Mockito.atLeastOnce;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.block.data.BlockData;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class VigilanteArenaBarrierTest {
|
||||||
|
private static final ArenaLocation CENTER = new ArenaLocation(
|
||||||
|
"world", 0.0, 64.0, 0.0, 0.0F, 0.0F
|
||||||
|
);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotReplaceExistingNonAirBlocks() {
|
||||||
|
World world = mock(World.class);
|
||||||
|
Block solid = mock(Block.class);
|
||||||
|
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(solid);
|
||||||
|
when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
|
||||||
|
when(solid.getType()).thenReturn(Material.CHEST);
|
||||||
|
|
||||||
|
new VigilanteArenaBarrier().build(world, CENTER, 10.0);
|
||||||
|
|
||||||
|
verify(solid, never()).setType(Material.BARRIER, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restoresAirReplacedByTemporaryBarrier() {
|
||||||
|
World world = mock(World.class);
|
||||||
|
Block air = mock(Block.class);
|
||||||
|
BlockData original = mock(BlockData.class);
|
||||||
|
BlockData snapshot = mock(BlockData.class);
|
||||||
|
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(air);
|
||||||
|
when(world.getBlockAt(any(Location.class))).thenReturn(air);
|
||||||
|
when(air.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
|
||||||
|
when(air.getType()).thenReturn(Material.AIR);
|
||||||
|
when(air.getBlockData()).thenReturn(original);
|
||||||
|
when(original.clone()).thenReturn(snapshot);
|
||||||
|
VigilanteArenaBarrier barrier = new VigilanteArenaBarrier();
|
||||||
|
|
||||||
|
barrier.build(world, CENTER, 10.0);
|
||||||
|
barrier.clear();
|
||||||
|
|
||||||
|
verify(air, atLeastOnce()).setType(Material.BARRIER, false);
|
||||||
|
verify(air).setBlockData(snapshot, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class VigilanteArenaControllerTest {
|
||||||
|
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 optedInPlayerJoiningDuringVacancyReceivesArenaCoordinates() {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(PLAYER);
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.game()).thenReturn(openGame());
|
||||||
|
when(manager.players()).thenReturn(Map.of(PLAYER, PlayerState.newPlayer(PLAYER, "Player")));
|
||||||
|
ArenaLocationStore locations = mock(ArenaLocationStore.class);
|
||||||
|
when(locations.location()).thenReturn(Optional.of(
|
||||||
|
new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F)
|
||||||
|
));
|
||||||
|
VigilanteArenaController controller = new VigilanteArenaController(
|
||||||
|
manager, mock(Server.class), locations, PluginSettings.from(Map.of())
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.onJoin(mockJoin(player));
|
||||||
|
|
||||||
|
verify(player).sendMessage(contains("world at 10, 64, -5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optedOutPlayerDoesNotReceiveVacancyNotice() {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(PLAYER);
|
||||||
|
PlayerState optedOut = new PlayerState(
|
||||||
|
PLAYER, "Player", Optional.empty(), Optional.of(Instant.parse("2026-08-24T00:00:00Z")),
|
||||||
|
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
|
||||||
|
);
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.game()).thenReturn(openGame());
|
||||||
|
when(manager.players()).thenReturn(Map.of(PLAYER, optedOut));
|
||||||
|
ArenaLocationStore locations = mock(ArenaLocationStore.class);
|
||||||
|
when(locations.location()).thenReturn(Optional.of(
|
||||||
|
new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F)
|
||||||
|
));
|
||||||
|
VigilanteArenaController controller = new VigilanteArenaController(
|
||||||
|
manager, mock(Server.class), locations, PluginSettings.from(Map.of())
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.onJoin(mockJoin(player));
|
||||||
|
|
||||||
|
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerJoinEvent mockJoin(Player player) {
|
||||||
|
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
|
||||||
|
when(event.getPlayer()).thenReturn(player);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GameState openGame() {
|
||||||
|
return new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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 VigilanteArenaPolicyTest {
|
||||||
|
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 opensOnlyForRunningGameWithTyrantAndVacantVigilante() {
|
||||||
|
assertTrue(VigilanteArenaPolicy.isOpen(game(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty()
|
||||||
|
)));
|
||||||
|
assertFalse(VigilanteArenaPolicy.isOpen(game(
|
||||||
|
GameLifecycle.PAUSED, Optional.of(TYRANT), Optional.empty()
|
||||||
|
)));
|
||||||
|
assertFalse(VigilanteArenaPolicy.isOpen(game(
|
||||||
|
GameLifecycle.RUNNING, Optional.empty(), Optional.empty()
|
||||||
|
)));
|
||||||
|
assertFalse(VigilanteArenaPolicy.isOpen(game(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(PLAYER)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onlyOptedInNonTyrantMayChallenge() {
|
||||||
|
GameState game = game(GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty());
|
||||||
|
PlayerState eligible = PlayerState.newPlayer(PLAYER, "Player");
|
||||||
|
PlayerState optedOut = new PlayerState(
|
||||||
|
PLAYER, "Player", Optional.empty(), Optional.of(Instant.parse("2026-08-24T12:00:00Z")),
|
||||||
|
TyrantClass.NONE, Optional.empty(), Map.of(), Set.of(), java.util.List.of()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(VigilanteArenaPolicy.isEligible(game, eligible));
|
||||||
|
assertFalse(VigilanteArenaPolicy.isEligible(game, optedOut));
|
||||||
|
assertFalse(VigilanteArenaPolicy.isEligible(game,
|
||||||
|
PlayerState.newPlayer(TYRANT, "Tyrant")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GameState game(
|
||||||
|
GameLifecycle lifecycle,
|
||||||
|
Optional<UUID> tyrant,
|
||||||
|
Optional<UUID> vigilante
|
||||||
|
) {
|
||||||
|
return new GameState(
|
||||||
|
lifecycle, tyrant, vigilante, Optional.empty(), Optional.empty(),
|
||||||
|
lifecycle == GameLifecycle.PAUSED
|
||||||
|
? Optional.of(Instant.parse("2026-08-23T12:00:00Z")) : Optional.empty(),
|
||||||
|
Duration.ZERO, 0, 0, Set.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class VigilanteArenaSuccessionServiceTest {
|
||||||
|
@Test
|
||||||
|
void eligibleBossWinnerClaimsVacantVigilanteRole() {
|
||||||
|
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
UUID winner = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||||
|
GameState game = new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.empty(), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
|
||||||
|
);
|
||||||
|
PersistentState before = new PersistentState(
|
||||||
|
game, Map.of(winner, PlayerState.newPlayer(winner, "Winner"))
|
||||||
|
);
|
||||||
|
|
||||||
|
LifecycleState claimed = new VigilanteArenaSuccessionService().claim(before, winner);
|
||||||
|
|
||||||
|
assertEquals(Optional.of(winner), claimed.game().vigilanteId());
|
||||||
|
assertEquals(Optional.empty(), claimed.game().pendingVigilante());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user