Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f670a7da |
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- [Start, pause, and administer the game](../user-stories/us-001-start-pause-and-administer.md)
|
||||
- [Transfer and reset the Tyrant role](../user-stories/us-002-transfer-and-reset-tyrant.md)
|
||||
- [Handle inactivity and pending selections](../user-stories/us-010-handle-inactivity-and-selection.md)
|
||||
- [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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
- [Configure and persist game state](../user-stories/us-012-configure-and-persist-state.md)
|
||||
- [Use class ability items](../user-stories/us-014-use-class-ability-items.md)
|
||||
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md)
|
||||
|
||||
@@ -8,7 +8,7 @@ description: Defines Tyrant-side classes, Vigilante-side membership, progression
|
||||
|
||||
## Roles and affiliations
|
||||
|
||||
A running game has one Tyrant and, when an eligible player is available, one Vigilante. The Vigilante may recruit Followers through invitations that require acceptance. The Tyrant may assign the Assassin, Fixer, and Tamer classes after unlocking them.
|
||||
A running game has 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.
|
||||
|
||||
@@ -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)
|
||||
- [Assign unlocked classes](../user-stories/us-004-assign-unlocked-classes.md)
|
||||
- [Support the Vigilante and Followers](../user-stories/us-008-support-vigilante-and-followers.md)
|
||||
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md)
|
||||
|
||||
@@ -12,6 +12,7 @@ This bundle documents the asymmetric Tyrant game, its role succession, class abi
|
||||
## Explore
|
||||
|
||||
- [User stories](user-stories/index.md)
|
||||
- [Vigilante arena challenge](user-stories/us-019-claim-vigilante-in-arena.md)
|
||||
- [Game state machine](concepts/game-state-machine.md)
|
||||
- [Role and class model](concepts/role-and-class-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
|
||||
|
||||
## 2026-08-23 — Vigilante arena succession completed
|
||||
|
||||
- Completed US-019 and reopened related administration, Vigilante, inactivity, participation, persistence, control-panel, and completion stories to replace automatic Vigilante selection with an earned arena challenge.
|
||||
- Vigilante vacancies caused by death, inactivity, relinquishment, initial startup, or a new Tyrant reign now open a configured one-player enhanced-zombie fight only while a Tyrant is active.
|
||||
- Added a persistent administrator-set arena center, continuous particle boundary, temporary safe `BARRIER` wall, entrant and respawn containment, mob exclusion, sunlight-safe boss behavior, full fight reset, and boss-victory assignment.
|
||||
- Opted-in players receive vacancy coordinates when the challenge opens or they join; opted-out players are excluded from notices and entry.
|
||||
- Verified arena policy, geometry, succession, configuration persistence and validation, barrier restoration, command handling and completion, compiler warnings, 109 automated tests, and packaging with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-21 — Contextual command completion completed
|
||||
|
||||
- Completed US-018 with registered completers for `/tyrant`, `/vigilante`, and `/tyrantadmin`, replacing Bukkit's unconditional online-player fallback.
|
||||
|
||||
@@ -18,3 +18,4 @@
|
||||
16. [US-016: Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
||||
17. [US-017: Use bound role control items](us-017-use-bound-role-control-items.md)
|
||||
18. [US-018: Complete Tyrant commands contextually](us-018-complete-commands-contextually.md)
|
||||
19. [US-019: Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||
|
||||
@@ -12,15 +12,16 @@ As a **server operator**, I want to start, pause, resume, and inspect the game s
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] An administrator can start the first game by assigning the initial Tyrant.
|
||||
- [x] The administrator may assign the first Vigilante or allow random selection from eligible recently active players.
|
||||
- [x] The administrator may assign the first Vigilante or leave the role vacant for the configured Vigilante arena challenge.
|
||||
- [x] An administrator can pause and resume the game without deleting game state.
|
||||
- [x] Pausing freezes progression, cooldowns, daily-use timers, inactivity checks, and pending-selection deadlines.
|
||||
- [x] Role and class effects are suppressed while paused and restored when play resumes.
|
||||
- [x] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default.
|
||||
- [x] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, and pending selections.
|
||||
- [x] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, pending selections, and the configured Vigilante arena location.
|
||||
- [x] Destructive administrative operations require explicit confirmation.
|
||||
|
||||
## Related
|
||||
|
||||
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
||||
- [Configure and persist game state](us-012-configure-and-persist-state.md)
|
||||
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||
|
||||
@@ -11,7 +11,7 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Exactly one active Vigilante exists while the game is running whenever an eligible player is available.
|
||||
- [x] A running game has at most one active Vigilante; a vacancy remains open until an eligible player defeats the arena boss.
|
||||
- [x] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
|
||||
- [x] The Vigilante can dismiss Followers and Followers can leave voluntarily.
|
||||
- [x] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks.
|
||||
@@ -19,17 +19,18 @@ As the **Vigilante**, I want nearby living Followers to strengthen me against th
|
||||
- [x] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
|
||||
- [x] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment.
|
||||
- [x] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV.
|
||||
- [x] Every Vigilante death ends the assignment and triggers random replacement regardless of cause.
|
||||
- [x] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
|
||||
- [x] Every Vigilante death ends the assignment and opens the one-player arena challenge while a Tyrant is active.
|
||||
- [x] The deceased Vigilante may immediately compete again under the same arena eligibility and reset rules as every other participant.
|
||||
- [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
|
||||
- [x] The Tyrant is never eligible to be selected as Vigilante.
|
||||
- [x] The Vigilante can review, invite, and dismiss Followers through the Vigilante control panel.
|
||||
- [x] Control-panel and command-based Follower actions enforce identical eligibility and membership rules.
|
||||
- [x] The control panel communicates the current nearby-Follower combat contribution and configured effect caps.
|
||||
- [x] Relinquishing through the control panel follows normal Vigilante succession and Follower cleanup.
|
||||
- [x] Relinquishing through the control panel clears Followers and opens the Vigilante arena challenge.
|
||||
|
||||
## Related
|
||||
|
||||
- [Role and class model](../concepts/role-and-class-model.md)
|
||||
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
|
||||
- [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] The Tyrant is always excluded from Vigilante selection.
|
||||
- [x] A previous role holder is excluded when another eligible candidate exists and may be reused only as a fallback.
|
||||
- [x] Tyrant inactivity ends the reign, clears all assignments, and invokes normal random Tyrant succession followed by Vigilante selection.
|
||||
- [x] Vigilante inactivity clears Followers and rerolls only the Vigilante position.
|
||||
- [x] 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 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 unconfirmed selection expires and rerolls automatically under the same eligibility rules.
|
||||
- [x] Selection remains vacant and retries periodically when no valid candidate exists.
|
||||
- [x] An unconfirmed Tyrant selection expires and rerolls automatically under the same eligibility rules.
|
||||
- [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.
|
||||
|
||||
## Related
|
||||
|
||||
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
||||
- [Inform players and manage participation](us-011-inform-and-manage-participation.md)
|
||||
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||
|
||||
@@ -15,6 +15,7 @@ As a **player**, I want to understand my status and control my eligibility so th
|
||||
- [x] Eligible players are invited to opt out of Tyrant events without implying that an event is currently running.
|
||||
- [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
|
||||
- [x] An opted-out player is told when they may opt back in.
|
||||
- [x] Opted-in players are told the Vigilante arena world and coordinates when its role opens and when they join during an open challenge; opted-out players receive no such notice.
|
||||
- [x] An unassigned player can opt out through a command.
|
||||
- [x] Opting out removes an ordinary class or Follower assignment immediately; the Tyrant and Vigilante must relinquish through a safe role command before opting out.
|
||||
- [x] Opted-out players cannot be randomly selected, recruited, or assigned a class.
|
||||
@@ -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)
|
||||
- [Persistence model](../concepts/persistence-model.md)
|
||||
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||
|
||||
@@ -11,10 +11,10 @@ As a **server operator**, I want configurable and durable game behavior so that
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Configuration covers ranges, durations, cooldowns, inactivity periods, candidate windows, retry intervals, effect levels and caps, mob restrictions, item materials and names, messages, and timer behavior.
|
||||
- [x] 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] 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] State is saved using atomic replacement where supported so failed writes do not replace valid state with partial data.
|
||||
- [x] Corrupt, unknown, or invalid records cannot silently grant progression, roles, powers, items, or duplicated mobs.
|
||||
@@ -25,3 +25,4 @@ As a **server operator**, I want configurable and durable game behavior so that
|
||||
|
||||
- [Persistence model](../concepts/persistence-model.md)
|
||||
- [Build, test, and release the plugin](us-013-build-test-and-release.md)
|
||||
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||
|
||||
@@ -29,7 +29,7 @@ As the **Vigilante**, I want a control panel for managing Followers so that I ca
|
||||
- [x] The Follower-management screen identifies current Followers and requires confirmation before dismissal.
|
||||
- [x] Successful invitations and dismissals refresh the control panel from authoritative current state and notify affected players.
|
||||
- [x] The role-control screen allows the Vigilante to relinquish the role only after explicit confirmation.
|
||||
- [x] Relinquishing through the control panel uses normal Vigilante replacement and Follower-cleanup rules.
|
||||
- [x] Relinquishing through the control panel clears Followers, opens the Vigilante arena challenge, and communicates that outcome accurately.
|
||||
- [x] Every action revalidates the running lifecycle and active Vigilante before changing state.
|
||||
- [x] Stale or repeated control-panel interactions cannot invite an ineligible player, duplicate an invitation, or dismiss the wrong Follower.
|
||||
- [x] Existing `/vigilante invite`, `/vigilante accept`, `/vigilante dismiss`, `/vigilante leave`, and `/tyrant relinquish confirm` commands remain available.
|
||||
@@ -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)
|
||||
- [Role and class model](../concepts/role-and-class-model.md)
|
||||
- [Use bound role control items](us-017-use-bound-role-control-items.md)
|
||||
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||
|
||||
@@ -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 start` suggests online Tyrant and Vigilante candidates.
|
||||
- [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 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.
|
||||
@@ -36,4 +37,5 @@ Automated tests verify root syntax, unlocks, classes, confirmations, eligible re
|
||||
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
|
||||
- [Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
|
||||
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
||||
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
||||
|
||||
@@ -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.setItem(13, item(Material.REDSTONE_TORCH,
|
||||
ChatColor.RED + "Relinquish Vigilante Role",
|
||||
List.of("Followers will be cleared and a replacement selected.",
|
||||
List.of("Followers will be cleared and the arena will open.",
|
||||
"Click to review confirmation.")));
|
||||
inventory.setItem(22, backItem());
|
||||
player.openInventory(inventory);
|
||||
@@ -276,7 +276,7 @@ public final class BukkitVigilanteControlPanelRenderer
|
||||
Inventory inventory = create(holder, 27, TITLE + " — Confirm Relinquishment");
|
||||
inventory.setItem(11, cancelItem());
|
||||
inventory.setItem(13, item(Material.BARRIER, ChatColor.RED + "Relinquish Role",
|
||||
List.of("This clears all Followers and rerolls the Vigilante.")));
|
||||
List.of("This clears all Followers and opens the arena challenge.")));
|
||||
inventory.setItem(15, confirmItem());
|
||||
player.openInventory(inventory);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import org.bukkit.configuration.ConfigurationSection;
|
||||
public record PluginSettings(
|
||||
double tyrantRangeBlocks,
|
||||
double followerRangeBlocks,
|
||||
double vigilanteArenaRadiusBlocks,
|
||||
double vigilanteBossHealth,
|
||||
double vigilanteBossDamage,
|
||||
double vigilanteBossArmor,
|
||||
Duration optOutDuration,
|
||||
Duration roleInactivity,
|
||||
Duration candidateActivityWindow,
|
||||
@@ -48,6 +52,12 @@ public record PluginSettings(
|
||||
public PluginSettings {
|
||||
requirePositiveFinite(tyrantRangeBlocks, "tyrant-range-blocks");
|
||||
requirePositiveFinite(followerRangeBlocks, "follower-range-blocks");
|
||||
requireFiniteRange(
|
||||
vigilanteArenaRadiusBlocks, 1.0, 512.0, "vigilante-arena-radius-blocks"
|
||||
);
|
||||
requireFiniteRange(vigilanteBossHealth, 1.0, 1024.0, "vigilante-boss-health");
|
||||
requireFiniteRange(vigilanteBossDamage, 0.1, 2048.0, "vigilante-boss-damage");
|
||||
requireFiniteRange(vigilanteBossArmor, 0.0, 30.0, "vigilante-boss-armor");
|
||||
requirePositive(optOutDuration, "opt-out-seconds");
|
||||
requirePositive(roleInactivity, "role-inactivity-seconds");
|
||||
requirePositive(candidateActivityWindow, "candidate-activity-window-seconds");
|
||||
@@ -93,6 +103,10 @@ public record PluginSettings(
|
||||
return new PluginSettings(
|
||||
decimal(values, "tyrant-range-blocks", 50.0),
|
||||
decimal(values, "follower-range-blocks", 50.0),
|
||||
decimal(values, "vigilante-arena-radius-blocks", 10.0),
|
||||
decimal(values, "vigilante-boss-health", 80.0),
|
||||
decimal(values, "vigilante-boss-damage", 8.0),
|
||||
decimal(values, "vigilante-boss-armor", 10.0),
|
||||
duration(values, "opt-out-seconds", Duration.ofDays(7)),
|
||||
duration(values, "role-inactivity-seconds", Duration.ofHours(48)),
|
||||
duration(values, "candidate-activity-window-seconds", Duration.ofHours(24)),
|
||||
@@ -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) {
|
||||
Objects.requireNonNull(value, key);
|
||||
if (value.isZero() || value.isNegative()) {
|
||||
|
||||
@@ -53,17 +53,9 @@ public final class RoleMaintenanceService {
|
||||
);
|
||||
}
|
||||
current = rerollExpiredTyrant(current, onlinePlayerIds, now);
|
||||
current = rerollExpiredVigilante(current, onlinePlayerIds, now);
|
||||
if (current.game().tyrantId().isEmpty() && current.game().pendingTyrant().isEmpty()) {
|
||||
current = selectTyrant(current, Optional.empty(), onlinePlayerIds, now);
|
||||
}
|
||||
if (current.game().tyrantId().isPresent()
|
||||
&& current.game().vigilanteId().isEmpty()
|
||||
&& current.game().pendingVigilante().isEmpty()) {
|
||||
current = rerollVigilante(
|
||||
current.asPersistentState(), Optional.empty(), onlinePlayerIds, now
|
||||
);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -74,17 +66,9 @@ public final class RoleMaintenanceService {
|
||||
Instant now
|
||||
) {
|
||||
Map<UUID, PlayerState> players = clearFollowers(state.players(), previousVigilante);
|
||||
Set<UUID> excluded = state.game().tyrantId().map(Set::of).orElseGet(Set::of);
|
||||
Optional<UUID> selected = candidates.select(
|
||||
players, excluded, previousVigilante, now, activityWindow, random
|
||||
);
|
||||
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
|
||||
Optional<PendingSelection> pending = selected
|
||||
.filter(playerId -> active.isEmpty())
|
||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
||||
GameState game = copyRoles(
|
||||
state.game(), state.game().tyrantId(), active,
|
||||
state.game().pendingTyrant(), pending
|
||||
state.game(), state.game().tyrantId(), Optional.empty(),
|
||||
state.game().pendingTyrant(), Optional.empty()
|
||||
);
|
||||
return new LifecycleState(game, players);
|
||||
}
|
||||
@@ -109,16 +93,8 @@ public final class RoleMaintenanceService {
|
||||
pendingTyrant = Optional.empty();
|
||||
}
|
||||
}
|
||||
if (pendingVigilante.isPresent()) {
|
||||
PendingSelection pending = pendingVigilante.orElseThrow();
|
||||
if (!now.isAfter(pending.expiresAt())
|
||||
&& onlinePlayerIds.contains(pending.candidateId())
|
||||
&& eligibleAtConfirmation(state.players().get(pending.candidateId()), now)
|
||||
&& tyrant.filter(pending.candidateId()::equals).isEmpty()) {
|
||||
vigilante = Optional.of(pending.candidateId());
|
||||
// Legacy pending Vigilante selections become arena vacancies.
|
||||
pendingVigilante = Optional.empty();
|
||||
}
|
||||
}
|
||||
return new LifecycleState(
|
||||
copyRoles(game, tyrant, vigilante, pendingTyrant, pendingVigilante),
|
||||
state.players()
|
||||
@@ -163,23 +139,6 @@ public final class RoleMaintenanceService {
|
||||
return new LifecycleState(game, state.players());
|
||||
}
|
||||
|
||||
private LifecycleState rerollExpiredVigilante(
|
||||
LifecycleState state,
|
||||
Set<UUID> onlinePlayerIds,
|
||||
Instant now
|
||||
) {
|
||||
Optional<PendingSelection> pending = state.game().pendingVigilante();
|
||||
if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) {
|
||||
return state;
|
||||
}
|
||||
return rerollVigilante(
|
||||
state.asPersistentState(),
|
||||
Optional.of(pending.orElseThrow().candidateId()),
|
||||
onlinePlayerIds,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isInactive(
|
||||
Optional<UUID> roleHolder,
|
||||
Map<UUID, PlayerState> players,
|
||||
|
||||
@@ -16,6 +16,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
||||
private FixerEffectController fixerEffects;
|
||||
private TyrantEffectController tyrantEffects;
|
||||
private OptOutGlowController optOutGlow;
|
||||
private VigilanteArenaController vigilanteArena;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -33,6 +34,14 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
||||
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();
|
||||
RoleCandidateSelector candidateSelector = new RoleCandidateSelector();
|
||||
java.util.random.RandomGenerator random = java.util.random.RandomGenerator.getDefault();
|
||||
@@ -131,10 +140,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
||||
onlinePlayers,
|
||||
managedEffects,
|
||||
clock,
|
||||
candidateSelector,
|
||||
settings.candidateActivityWindow(),
|
||||
settings.pendingSelectionTimeout(),
|
||||
random
|
||||
arenaLocations
|
||||
));
|
||||
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
|
||||
.setTabCompleter(new TyrantTabCompleter(stateManager, onlinePlayers));
|
||||
@@ -148,6 +154,10 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
||||
new PlayerJoinListener(stateManager, clock, tyrantPresentation),
|
||||
this
|
||||
);
|
||||
vigilanteArena = new VigilanteArenaController(
|
||||
stateManager, getServer(), arenaLocations, settings
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(vigilanteArena, this);
|
||||
getServer().getPluginManager().registerEvents(controlPanelRenderer, this);
|
||||
getServer().getPluginManager().registerEvents(vigilanteControlPanelRenderer, this);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
@@ -243,6 +253,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
||||
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
|
||||
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
|
||||
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
|
||||
getServer().getScheduler().runTaskTimer(this, vigilanteArena, 1L, 10L);
|
||||
getServer().getScheduler().runTaskTimer(
|
||||
this,
|
||||
new AbilityItemRefreshTask(
|
||||
@@ -272,6 +283,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (vigilanteArena != null) {
|
||||
vigilanteArena.close();
|
||||
}
|
||||
if (optOutGlow != null) {
|
||||
optOutGlow.clearAll();
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.random.RandomGenerator;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
@@ -20,10 +16,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
||||
private final OnlinePlayerDirectory onlinePlayers;
|
||||
private final ManagedRoleEffects effects;
|
||||
private final Clock clock;
|
||||
private final RoleCandidateSelector candidateSelector;
|
||||
private final Duration activityWindow;
|
||||
private final Duration pendingTimeout;
|
||||
private final RandomGenerator random;
|
||||
private final ArenaLocationStore arenaLocations;
|
||||
|
||||
public TyrantAdminCommand(
|
||||
TyrantStateManager stateManager,
|
||||
@@ -34,8 +27,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
||||
) {
|
||||
this(
|
||||
stateManager, lifecycle, onlinePlayers, effects, clock,
|
||||
new RoleCandidateSelector(), Duration.ofHours(24), Duration.ofHours(24),
|
||||
RandomGenerator.getDefault()
|
||||
ArenaLocationStore.unavailable()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,20 +37,14 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
||||
OnlinePlayerDirectory onlinePlayers,
|
||||
ManagedRoleEffects effects,
|
||||
Clock clock,
|
||||
RoleCandidateSelector candidateSelector,
|
||||
Duration activityWindow,
|
||||
Duration pendingTimeout,
|
||||
RandomGenerator random
|
||||
ArenaLocationStore arenaLocations
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.lifecycle = lifecycle;
|
||||
this.onlinePlayers = onlinePlayers;
|
||||
this.effects = effects;
|
||||
this.clock = clock;
|
||||
this.candidateSelector = candidateSelector;
|
||||
this.activityWindow = activityWindow;
|
||||
this.pendingTimeout = pendingTimeout;
|
||||
this.random = random;
|
||||
this.arenaLocations = arenaLocations;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -82,6 +68,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
||||
case "pause" -> pause(sender);
|
||||
case "resume" -> resume(sender);
|
||||
case "reset" -> reset(sender, arguments);
|
||||
case "arena" -> arena(sender, arguments);
|
||||
default -> usage(sender);
|
||||
}
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
@@ -112,38 +99,17 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
||||
stateManager.player(tyrant.getUniqueId(), tyrant.getName());
|
||||
vigilante.ifPresent(player -> stateManager.player(player.getUniqueId(), player.getName()));
|
||||
InstantHolder now = new InstantHolder(clock.instant());
|
||||
Optional<UUID> randomCandidate = vigilante.isEmpty()
|
||||
? candidateSelector.select(
|
||||
stateManager.players(), Set.of(tyrant.getUniqueId()), Optional.empty(),
|
||||
now.value(), activityWindow, random
|
||||
)
|
||||
: Optional.empty();
|
||||
Optional<UUID> activeVigilante = vigilante.map(Player::getUniqueId);
|
||||
if (activeVigilante.isEmpty() && randomCandidate.isPresent()
|
||||
&& onlinePlayers.findById(randomCandidate.orElseThrow()) != null) {
|
||||
activeVigilante = randomCandidate;
|
||||
}
|
||||
LifecycleState started = lifecycle.start(
|
||||
stateManager.snapshot(), tyrant.getUniqueId(), activeVigilante, now.value()
|
||||
stateManager.snapshot(), tyrant.getUniqueId(),
|
||||
vigilante.map(Player::getUniqueId), now.value()
|
||||
);
|
||||
if (activeVigilante.isEmpty() && randomCandidate.isPresent()) {
|
||||
GameState game = started.game();
|
||||
started = new LifecycleState(new GameState(
|
||||
game.lifecycle(), game.tyrantId(), game.vigilanteId(), game.pendingTyrant(),
|
||||
Optional.of(new PendingSelection(
|
||||
randomCandidate.orElseThrow(), now.value().plus(pendingTimeout)
|
||||
)),
|
||||
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
|
||||
game.unspentChoices(), game.purchases()
|
||||
), started.players());
|
||||
}
|
||||
stateManager.replaceState(started);
|
||||
stateManager.saveIfDirty();
|
||||
effects.restoreAll();
|
||||
sender.sendMessage(ChatColor.GREEN + "Tyrant game started.");
|
||||
if (vigilante.isEmpty()) {
|
||||
sender.sendMessage(ChatColor.YELLOW
|
||||
+ "An eligible recently active Vigilante will be selected automatically.");
|
||||
+ "The Vigilante arena challenge is open.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +141,30 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
||||
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) {
|
||||
GameState game = stateManager.game();
|
||||
sender.sendMessage(ChatColor.GOLD + "Lifecycle: " + game.lifecycle());
|
||||
@@ -186,6 +176,8 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
||||
+ ", purchases: " + game.purchases());
|
||||
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
|
||||
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
|
||||
sender.sendMessage("Vigilante arena: " + arenaLocations.location()
|
||||
.map(ArenaLocation::display).orElse("not configured"));
|
||||
for (PlayerState player : stateManager.players().values()) {
|
||||
boolean optedOut = player.optedOutUntil().isPresent();
|
||||
sender.sendMessage(
|
||||
@@ -202,6 +194,6 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
||||
|
||||
private static void usage(CommandSender sender) {
|
||||
sender.sendMessage(ChatColor.YELLOW
|
||||
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm>");
|
||||
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena set>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import org.bukkit.entity.Player;
|
||||
public final class TyrantAdminTabCompleter implements TabCompleter {
|
||||
private static final String PERMISSION = "spigottyrant.admin";
|
||||
private static final List<String> SUBCOMMANDS = List.of(
|
||||
"status", "start", "pause", "resume", "reset"
|
||||
"status", "start", "pause", "resume", "reset", "arena"
|
||||
);
|
||||
private final OnlinePlayerDirectory onlinePlayers;
|
||||
|
||||
@@ -37,6 +37,9 @@ public final class TyrantAdminTabCompleter implements TabCompleter {
|
||||
return switch (subcommand) {
|
||||
case "start" -> TabSuggestions.matching(arguments[1], onlineNames(null));
|
||||
case "reset" -> TabSuggestions.matching(arguments[1], List.of("confirm"));
|
||||
case "arena" -> TabSuggestions.matching(
|
||||
arguments[1], List.of("set", "status")
|
||||
);
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ public final class TyrantCommand implements CommandExecutor {
|
||||
private void relinquish(Player player, String[] arguments) {
|
||||
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Relinquishing rerolls your role. Use /tyrant relinquish confirm.");
|
||||
+ "Relinquishing ends your role. Use /tyrant relinquish confirm.");
|
||||
return;
|
||||
}
|
||||
if (relinquishment == null) {
|
||||
|
||||
@@ -38,7 +38,6 @@ public final class TyrantSuccessionService {
|
||||
throw new IllegalStateException("a running Tyrant reign is required");
|
||||
}
|
||||
UUID formerTyrant = state.game().tyrantId().orElseThrow();
|
||||
Optional<UUID> formerVigilante = state.game().vigilanteId();
|
||||
Map<UUID, PlayerState> clearedPlayers = clearAssignments(state.players());
|
||||
Optional<UUID> selectedTyrant = killerId.isPresent()
|
||||
? killerId
|
||||
@@ -56,28 +55,12 @@ public final class TyrantSuccessionService {
|
||||
.filter(playerId -> activeTyrant.isEmpty())
|
||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
||||
|
||||
Optional<UUID> selectedVigilante = activeTyrant.flatMap(tyrantId ->
|
||||
candidates.select(
|
||||
clearedPlayers,
|
||||
Set.of(tyrantId),
|
||||
formerVigilante,
|
||||
now,
|
||||
activityWindow,
|
||||
random
|
||||
)
|
||||
);
|
||||
Optional<UUID> activeVigilante = selectedVigilante
|
||||
.filter(onlinePlayerIds::contains);
|
||||
Optional<PendingSelection> pendingVigilante = selectedVigilante
|
||||
.filter(playerId -> activeVigilante.isEmpty())
|
||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
||||
|
||||
GameState game = new GameState(
|
||||
GameLifecycle.RUNNING,
|
||||
activeTyrant,
|
||||
activeVigilante,
|
||||
Optional.empty(),
|
||||
pendingTyrant,
|
||||
pendingVigilante,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
state.game().accumulatedPausedTime(),
|
||||
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.saveIfDirty();
|
||||
String message = ChatColor.YELLOW + "The Vigilante died; a replacement "
|
||||
+ (rerolled.game().vigilanteId().isPresent() ? "has been selected." : "is pending.");
|
||||
String message = ChatColor.YELLOW
|
||||
+ "The Vigilante died; the arena challenge is now open.";
|
||||
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
tyrant-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
|
||||
opt-out-seconds: 604800
|
||||
role-inactivity-seconds: 172800
|
||||
|
||||
@@ -13,7 +13,7 @@ commands:
|
||||
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
|
||||
tyrantadmin:
|
||||
description: Administer the Spigot Tyrant game.
|
||||
usage: /tyrantadmin <status|start|pause|resume|reset confirm>
|
||||
usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena <set|status>>
|
||||
permission: spigottyrant.admin
|
||||
permissions:
|
||||
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.ofMinutes(10), settings.fixerEffectDuration());
|
||||
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
|
||||
@@ -67,5 +71,17 @@ final class PluginSettingsTest {
|
||||
IllegalArgumentException.class,
|
||||
() -> 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
|
||||
void onlinePendingVigilanteConfirmsWithoutConflictingWithTyrant() {
|
||||
void legacyPendingVigilanteIsClearedForArenaSuccession() {
|
||||
GameState game = game(
|
||||
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
|
||||
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);
|
||||
|
||||
assertEquals(Optional.of(CANDIDATE), maintained.game().vigilanteId());
|
||||
assertEquals(Optional.empty(), maintained.game().vigilanteId());
|
||||
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredPendingSelectionRerollsAwayFromPreviousCandidate() {
|
||||
void expiredLegacyPendingVigilanteOpensArenaInsteadOfRerolling() {
|
||||
GameState game = game(
|
||||
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
|
||||
Optional.of(new PendingSelection(CANDIDATE, NOW.minusSeconds(1)))
|
||||
@@ -59,12 +59,12 @@ final class RoleMaintenanceServiceTest {
|
||||
|
||||
LifecycleState maintained = service.maintain(state, Set.of(TYRANT), NOW);
|
||||
|
||||
assertEquals(OTHER,
|
||||
maintained.game().pendingVigilante().orElseThrow().candidateId());
|
||||
assertEquals(Optional.empty(), maintained.game().vigilanteId());
|
||||
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
|
||||
}
|
||||
|
||||
@Test
|
||||
void inactiveVigilanteIsRerolledAndFollowersAreCleared() {
|
||||
void inactiveVigilanteOpensArenaAndFollowersAreCleared() {
|
||||
PlayerState follower = withFollower(
|
||||
withLogin(active(FOLLOWER), NOW.minus(Duration.ofHours(25))),
|
||||
VIGILANTE
|
||||
@@ -80,7 +80,7 @@ final class RoleMaintenanceServiceTest {
|
||||
|
||||
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.of(TYRANT), maintained.game().tyrantId());
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
final class RoleRelinquishmentServiceTest {
|
||||
@Test
|
||||
void vigilanteCanRelinquishAndTriggerNormalReroll() {
|
||||
void vigilanteCanRelinquishAndOpenArenaChallenge() {
|
||||
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
UUID candidate = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||
@@ -49,7 +49,8 @@ final class RoleRelinquishmentServiceTest {
|
||||
);
|
||||
|
||||
assertEquals(RelinquishmentStatus.RELINQUISHED, result.status());
|
||||
assertEquals(Optional.of(candidate), result.state().game().vigilanteId());
|
||||
assertEquals(Optional.empty(), result.state().game().vigilanteId());
|
||||
assertEquals(Optional.empty(), result.state().game().pendingVigilante());
|
||||
}
|
||||
|
||||
private static PlayerState active(UUID id, Instant now) {
|
||||
|
||||
@@ -14,8 +14,11 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class TyrantAdminCommandTest {
|
||||
@@ -59,6 +62,30 @@ final class TyrantAdminCommandTest {
|
||||
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
|
||||
void resetRequiresExplicitConfirmation() {
|
||||
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||
|
||||
@@ -34,7 +34,9 @@ final class TyrantAdminTabCompleterTest {
|
||||
));
|
||||
assertTrue(completer.onTabComplete(
|
||||
admin, command, "tyrantadmin", new String[] {""}
|
||||
).containsAll(java.util.List.of("status", "start", "pause", "resume", "reset")));
|
||||
).containsAll(java.util.List.of(
|
||||
"status", "start", "pause", "resume", "reset", "arena"
|
||||
)));
|
||||
assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
|
||||
admin, command, "tyrantadmin", new String[] {"start", ""}
|
||||
));
|
||||
@@ -44,6 +46,9 @@ final class TyrantAdminTabCompleterTest {
|
||||
assertEquals(java.util.List.of("confirm"), completer.onTabComplete(
|
||||
admin, command, "tyrantadmin", new String[] {"reset", "c"}
|
||||
));
|
||||
assertEquals(java.util.List.of("set", "status"), completer.onTabComplete(
|
||||
admin, command, "tyrantadmin", new String[] {"arena", "s"}
|
||||
));
|
||||
}
|
||||
|
||||
private static Player player(UUID id, String name) {
|
||||
|
||||
@@ -37,7 +37,8 @@ final class TyrantSuccessionServiceTest {
|
||||
assertEquals(0, after.game().tyrantLevel());
|
||||
assertEquals(1, after.game().unspentChoices());
|
||||
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()) {
|
||||
assertEquals(TyrantClass.NONE, player.tyrantClass());
|
||||
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