Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58ffad6748 | ||
|
|
afd8a03f1b | ||
|
|
0c4a95801a | ||
|
|
553db666e1 | ||
|
|
e5de6b76e7 | ||
|
|
4b6c709157 | ||
|
|
17a5c66575 | ||
|
|
f0dbde17b8 | ||
|
|
9ed0eed832 | ||
|
|
10f670a7da | ||
|
|
7318d4afa6 |
@@ -1,53 +1,9 @@
|
|||||||
# Repository Agent Guidance
|
# spigot-tyrant agent entrypoint
|
||||||
|
|
||||||
## User-story-driven development
|
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||||
|
|
||||||
The `design/` directory is the OKF v0.1 product record for this repository. Use user stories to plan, implement, verify, and track all behavior.
|
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-tyrant/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-tyrant/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||||
|
|
||||||
Before changing behavior:
|
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-tyrant/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
|
||||||
|
|
||||||
1. Read `design/index.md` and every story related to the requested behavior.
|
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
|
||||||
2. Draft updates to an existing story or create a new `design/user-stories/us-NNN-short-name.md` story before implementation.
|
|
||||||
3. Define observable acceptance criteria using user or operator language.
|
|
||||||
4. Present the relevant new or updated stories and acceptance criteria to the user for review, and wait for explicit confirmation before changing implementation code.
|
|
||||||
5. Incorporate requested story changes before proceeding.
|
|
||||||
6. Set story status to `in-progress` while approved implementation is incomplete.
|
|
||||||
|
|
||||||
While implementing:
|
|
||||||
|
|
||||||
1. Work in vertical slices against the documented acceptance criteria.
|
|
||||||
2. Add tests for important behavior before implementation when practical.
|
|
||||||
3. Keep implementation references and related-story links current.
|
|
||||||
4. Do not mark an acceptance criterion complete until the behavior exists and has been validated.
|
|
||||||
|
|
||||||
Before completing or committing:
|
|
||||||
|
|
||||||
1. Set a completed story status to `done` only when all acceptance criteria are complete and verified.
|
|
||||||
2. Check completed acceptance criteria and record validation evidence where appropriate.
|
|
||||||
3. Update `design/index.md` and `design/user-stories/index.md` whenever stories are added, renamed, moved, or materially reclassified.
|
|
||||||
4. Add a high-level entry to `design/log.md` under the verified current date.
|
|
||||||
5. Run OKF validation along with relevant Gradle tests, lint checks, and builds.
|
|
||||||
|
|
||||||
## OKF conventions
|
|
||||||
|
|
||||||
- Every non-reserved Markdown file in `design/` must have YAML frontmatter with a non-empty `type`.
|
|
||||||
- User stories use `type: User Story` and include `status`, `title`, and `description`.
|
|
||||||
- Allowed story statuses are `backlog`, `in-progress`, and `done`.
|
|
||||||
- Every user story includes an acceptance-criteria section using Markdown task-list items.
|
|
||||||
- `design/index.md` and `design/log.md` are reserved OKF files and follow the OKF index/log structures.
|
|
||||||
- Store user stories in `design/user-stories/` and keep their catalog current.
|
|
||||||
- Use standard Markdown links and keep repository-local links valid when files move.
|
|
||||||
- Preserve unknown frontmatter extensions.
|
|
||||||
|
|
||||||
## Java and Spigot development
|
|
||||||
|
|
||||||
- Use the Java version and Spigot API version declared by the Gradle build.
|
|
||||||
- Treat compiler warnings as errors.
|
|
||||||
- Prefer test-first development for domain rules and state transitions when practical.
|
|
||||||
- Run `./gradlew clean check jar` before completing implementation work.
|
|
||||||
- Keep Bukkit event handlers thin and move testable game rules into focused domain services.
|
|
||||||
- Do not perform blocking file or network operations on the server tick thread.
|
|
||||||
|
|
||||||
## Timestamps
|
|
||||||
|
|
||||||
Always run `date +%Y-%m-%d` before adding or updating dates in stories or the design log. Use RFC 3339 UTC timestamps (`YYYY-MM-DDTHH:MM:SSZ`) when a date-time is required. Never guess dates.
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
type: Design Concept
|
|
||||||
title: Tyrant Game State Machine
|
|
||||||
description: Defines lifecycle, succession, selection, and pause behavior for a Tyrant game.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Tyrant Game State Machine
|
|
||||||
|
|
||||||
## Lifecycle
|
|
||||||
|
|
||||||
The game begins unstarted. An administrator starts it by assigning the first Tyrant; the administrator may explicitly assign the first Vigilante or allow selection from eligible recently active players. A running game may be paused without deleting its state and later resumed.
|
|
||||||
|
|
||||||
Pausing freezes gameplay progression, role inactivity checks, pending-selection deadlines, ability cooldowns, and daily-use timers. Role and class effects are suppressed while paused and restored on resume.
|
|
||||||
|
|
||||||
## Tyrant succession
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
A Tyrant earns progression from the 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.
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Design Concepts
|
|
||||||
|
|
||||||
- [Game state machine](game-state-machine.md) - Running, paused, succession, and pending-selection states.
|
|
||||||
- [Role and class model](role-and-class-model.md) - Role relationships, eligibility, assignments, and unlocks.
|
|
||||||
- [Persistence model](persistence-model.md) - Durable game and player state requirements.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
type: Design Concept
|
|
||||||
title: Persistence Model
|
|
||||||
description: Defines the durable state and timekeeping needed to restore a Tyrant game safely.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Elapsed-time rules use UTC instants. Paused time is excluded from timers. Saves use atomic replacement where supported, preserve unknown fields where practical, and reject corrupt records rather than granting powers, assignments, or duplicated entities.
|
|
||||||
|
|
||||||
## Related stories
|
|
||||||
|
|
||||||
- [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)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
type: Design Concept
|
|
||||||
title: Role and Class Model
|
|
||||||
description: Defines Tyrant-side classes, Vigilante-side membership, progression, and assignment constraints.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Role and Class Model
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Tyrant progression
|
|
||||||
|
|
||||||
The Tyrant starts each reign at level zero with one starting unlock choice. Personally killing the Vigilante grants one level and one additional choice. Each of these unlocks can be purchased once per reign:
|
|
||||||
|
|
||||||
- Assassin class
|
|
||||||
- Fixer class
|
|
||||||
- Tamer class
|
|
||||||
- Vigilante roster intelligence
|
|
||||||
- Permanent Resistance
|
|
||||||
- Permanent Strength
|
|
||||||
|
|
||||||
## Proximity
|
|
||||||
|
|
||||||
Unless a more specific rule applies, "near the Tyrant" means in the same world and within a configurable 50-block three-dimensional distance.
|
|
||||||
|
|
||||||
## Effect safety
|
|
||||||
|
|
||||||
Vigilante Follower scaling is capped by default at Strength V and Resistance IV, with validated configurable caps. Permanent and conditional class effects are reapplied after ordinary Minecraft removal while the game is running and the assignment remains valid.
|
|
||||||
|
|
||||||
## Related stories
|
|
||||||
|
|
||||||
- [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)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
type: Index
|
|
||||||
title: Spigot Tyrant Design
|
|
||||||
description: Entry point for the Spigot Tyrant OKF knowledge bundle.
|
|
||||||
okf_version: "0.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Spigot Tyrant Design
|
|
||||||
|
|
||||||
This bundle documents the asymmetric Tyrant game, its role succession, class abilities, participation rules, persistence, administration, and delivery requirements.
|
|
||||||
|
|
||||||
## Explore
|
|
||||||
|
|
||||||
- [User stories](user-stories/index.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)
|
|
||||||
- [Design log](log.md)
|
|
||||||
-295
@@ -1,295 +0,0 @@
|
|||||||
---
|
|
||||||
type: Log
|
|
||||||
title: Spigot Tyrant Design Log
|
|
||||||
description: Chronological record of material decisions affecting the Spigot Tyrant design.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Spigot Tyrant Design Log
|
|
||||||
|
|
||||||
## 2026-08-21 — Single-mob Tamer custody completed
|
|
||||||
|
|
||||||
- Completed US-007 with an authoritative one-mob custody limit that rejects a second capture before inventory, entity, or state mutation and tells the Tamer to release the held mob first.
|
|
||||||
- Releasing or dropping custody permits another capture immediately, while durable state preserves the limit across logout, restart, recovery, and repeated interaction.
|
|
||||||
- Legacy excess custody retains the oldest mob and safely releases extras; offline or failed releases remain durable for periodic retry.
|
|
||||||
- Verified domain rejection, listener ordering, legacy reconciliation, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
|
|
||||||
|
|
||||||
## 2026-08-21 — Single-mob Tamer custody started
|
|
||||||
|
|
||||||
- US-007 begins a test-first one-mob custody limit for active Tamers.
|
|
||||||
- New captures will be rejected before mutation while custody exists, and legacy excess custody will be released safely with failed releases retained for retry.
|
|
||||||
|
|
||||||
## 2026-08-21 — Former Tamer automatic release completed
|
|
||||||
|
|
||||||
- Completed US-004 and US-007 so every captured mob is released automatically after its holder loses the Tamer class, regardless of the class-removal path.
|
|
||||||
- Online former Tamers release at a validated safe location; offline, dead, unsafe, malformed, or otherwise unspawnable custody remains durable and retries periodically after login or movement.
|
|
||||||
- Custody records and captured-mob items are removed only for successful spawns, while ordinary inventory remains untouched.
|
|
||||||
- Verified former-Tamer filtering, offline deferral, partial success, custody preservation, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
|
|
||||||
|
|
||||||
## 2026-08-21 — Former Tamer automatic release started
|
|
||||||
|
|
||||||
- US-004 and US-007 begin a test-first implementation that automatically releases captured mobs when a player loses the Tamer class.
|
|
||||||
- Release will preserve ordinary inventory, remove custody only after successful spawning, and defer safely for offline players or unsafe locations before retrying.
|
|
||||||
|
|
||||||
## 2026-08-21 — Bound role control items completed
|
|
||||||
|
|
||||||
- Completed US-017 and the reopened US-015 and US-016 criteria with configurable owner-bound Tyrant and Vigilante control items that open their respective panels.
|
|
||||||
- Role items are reconciled on login and periodically after role transitions, deduplicated, removed after role loss, and recoverable through `/tyrant item` or `/vigilante item` without dropping overflow.
|
|
||||||
- Drop, pickup, container, drag, hopper, placement, dispenser, item-frame, death, stale-owner, and stale-role paths prevent transfer or remove invalid items.
|
|
||||||
- Added default `Tyrant Control` Nether Star and `Vigilante Control` Compass settings and validation.
|
|
||||||
- Verified role policy, delayed reconciliation, panel interaction, command recovery, settings defaults, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
|
|
||||||
|
|
||||||
## 2026-08-21 — Bound role control item implementation started
|
|
||||||
|
|
||||||
- US-015, US-016, and US-017 begin a test-first implementation of configurable owner-bound Tyrant and Vigilante control items.
|
|
||||||
- Delivery, command recovery, interaction, role validation, transfer prevention, role-loss cleanup, and duplicate resistance will build on the existing bound-item patterns.
|
|
||||||
|
|
||||||
## 2026-08-21 — Bound role control items planned
|
|
||||||
|
|
||||||
- Added US-017 for secure owner-bound Tyrant and Vigilante inventory items that open the corresponding control panels.
|
|
||||||
- The Tyrant item defaults to a Nether Star named `Tyrant Control`, and the Vigilante item defaults to a Compass named `Vigilante Control`; both material and name are configurable.
|
|
||||||
- Planned automatic role-assignment and login delivery, command recovery, full transfer prevention, removal on role loss or inventory departure, and duplicate resistance.
|
|
||||||
- Reopened US-015 and US-016 in the backlog to track control-panel access through the new role items.
|
|
||||||
|
|
||||||
## 2026-08-21 — Vigilante control panel completed
|
|
||||||
|
|
||||||
- Completed US-016 and the reopened US-008 criteria with resistance overview, recruitment, Follower management, and confirmed role-relinquishment screens.
|
|
||||||
- `/vigilante` and `/vigilante menu` open the panel, while existing invitation, acceptance, dismissal, leave, and relinquishment commands remain available.
|
|
||||||
- Added a 100-tick login delay with online, lifecycle, role, and already-opened session checks so only the current Vigilante receives one automatic panel per login.
|
|
||||||
- Pending invitations are visible and idempotent, existing Followers cannot be reinvited, and all menu mutations delegate to authoritative command and domain rules.
|
|
||||||
- Verified command routing, authorization, overview state, invitation rules, delayed opening safeguards, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
|
|
||||||
|
|
||||||
## 2026-08-21 — Vigilante control panel implementation started
|
|
||||||
|
|
||||||
- US-008 and US-016 begin a test-first implementation of the Vigilante control panel, delayed login opening, resistance overview, recruitment, dismissal, and role relinquishment.
|
|
||||||
- The panel will reuse authoritative Follower and relinquishment rules while preserving all existing command alternatives.
|
|
||||||
|
|
||||||
## 2026-08-21 — Vigilante control panel planned
|
|
||||||
|
|
||||||
- Added US-016 for a graphical Vigilante control panel covering resistance overview, recruitment, Follower management, and confirmed role relinquishment while preserving command alternatives.
|
|
||||||
- Planned automatic control-panel opening five seconds after the active Vigilante joins a running event, with lifecycle, role, disconnect, and duplicate-opening safeguards.
|
|
||||||
- Reopened US-008 in the backlog to track the approved control-panel interactions and nearby-Follower combat contribution display.
|
|
||||||
|
|
||||||
## 2026-08-21 — Tyrant control panel completed
|
|
||||||
|
|
||||||
- Completed US-015 and the reopened US-003, US-004, and US-009 control-panel criteria with overview, unlock purchase confirmation, class assignment, and ability screens.
|
|
||||||
- The active Tyrant can open the panel with `/tyrant` or `/tyrant menu`; existing command alternatives remain available and provide the authoritative mutations behind menu actions.
|
|
||||||
- Added a 100-tick login delay with online, lifecycle, role, and already-opened session checks so only the current Tyrant receives one automatic panel per login.
|
|
||||||
- Verified command routing, authorization, overview state, delayed opening safeguards, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
|
|
||||||
|
|
||||||
## 2026-08-21 — Tyrant control panel implementation started
|
|
||||||
|
|
||||||
- US-003, US-004, US-009, and US-015 begin a test-first implementation of the Tyrant control panel and its delayed login opening.
|
|
||||||
- The installed Spigot API does not expose a native dialog API, so the control panel will use a server-rendered inventory menu while preserving command alternatives.
|
|
||||||
|
|
||||||
## 2026-08-21 — Tyrant control panel planned
|
|
||||||
|
|
||||||
- Added US-015 for a graphical Tyrant control panel covering reign overview, unlock purchases, class assignments, and roster intelligence while retaining command alternatives.
|
|
||||||
- Planned automatic control-panel opening five seconds after the active Tyrant joins a running event, with lifecycle, role, disconnect, and duplicate-opening safeguards.
|
|
||||||
- Reopened US-003, US-004, and US-009 in the backlog to track their approved control-panel interactions.
|
|
||||||
|
|
||||||
## 2026-08-18 — Preserve ordinary potion effects
|
|
||||||
|
|
||||||
- Corrected Assassin effect maintenance so it refreshes active Tyrant abilities without removing invisibility, Speed, or Weakness supplied by potions, commands, or other plugins.
|
|
||||||
- Expired or inapplicable Tyrant-managed effects stop refreshing and expire naturally within their short 30-tick maintenance duration, while double-jump flight cleanup remains immediate.
|
|
||||||
- Reopened and completed US-005 with regression coverage; verified `./gradlew clean check jar`.
|
|
||||||
|
|
||||||
## 2026-08-14 — Opt-out proximity glow completed
|
|
||||||
|
|
||||||
- Extended US-011 with a globally visible green outline for opted-out players in the Tyrant's world and configured range while the event is running.
|
|
||||||
- A periodic controller removes the glow when eligibility, proximity, world, lifecycle, or player availability changes and clears managed state on logout and shutdown.
|
|
||||||
- Scoreboard presentation records and restores each player's previous team and glowing state instead of permanently replacing unrelated presentation.
|
|
||||||
- Verified inclusive range behavior, lifecycle and availability gating, green presentation, restoration, and the full Gradle build.
|
|
||||||
|
|
||||||
## 2026-08-14 — Opt-out proximity glow started
|
|
||||||
|
|
||||||
- US-011 is reopened for a test-first green outline on opted-out players near the Tyrant during a running event, using the existing configurable Tyrant range.
|
|
||||||
- Cleanup will cover range, world, participation, lifecycle, availability, shutdown, and restoration of pre-existing scoreboard-team membership.
|
|
||||||
|
|
||||||
## 2026-08-14 — Join messaging refinement completed
|
|
||||||
|
|
||||||
- Updated US-011 so join output no longer announces running, paused, or unstarted lifecycle state.
|
|
||||||
- Eligible unassigned players now see the neutral prompt, `Use /tyrant optout if you do not want to participate in Tyrant events.`
|
|
||||||
- Role, class, ability, cooldown, and existing opt-out details remain unchanged.
|
|
||||||
- Verified both assigned and unassigned join output with automated tests.
|
|
||||||
|
|
||||||
## 2026-08-14 — Join messaging refinement started
|
|
||||||
|
|
||||||
- US-011 is reopened to remove lifecycle announcements from join output and make the opt-out prompt refer generally to Tyrant events.
|
|
||||||
|
|
||||||
## 2026-08-14 — Opt-out combat protection completed
|
|
||||||
|
|
||||||
- Extended US-011 with bidirectional combat isolation between opted-out players and the Tyrant, Assassin, Fixer, or Tamer while the event is running.
|
|
||||||
- Direct player attacks and player-fired projectiles are cancelled at highest event priority, and the attacker is told that the target has chosen not to participate in the Tyrant event.
|
|
||||||
- Paused, unstarted, and unrelated combat remains ordinary Minecraft gameplay.
|
|
||||||
- Verified both attack directions, every Tyrant-side class, projectile attribution, lifecycle gating, and unrelated combat with automated tests.
|
|
||||||
|
|
||||||
## 2026-08-14 — Opt-out combat protection started
|
|
||||||
|
|
||||||
- US-011 is reopened for test-first running-event combat isolation between opted-out players and the Tyrant or Tyrant-side class holders.
|
|
||||||
- Paused and unstarted gameplay remains ordinary Minecraft combat; blocked direct and projectile attackers receive a concise Tyrant event participation message.
|
|
||||||
|
|
||||||
## 2026-08-14 — Tamer completed
|
|
||||||
|
|
||||||
- Completed US-007 with the no-cooldown owner-bound Tamer's Lead, server-generated entity snapshots, durable capture IDs, and one bound Captured Mob item per entity.
|
|
||||||
- Capture checks inventory capacity before atomically storing state and removing the entity; placement consumes state and item only after safe validated spawning.
|
|
||||||
- Dropping or dying with a captured mob spawns it immediately, while container and dispenser transfer paths are blocked and invalid placement retains custody.
|
|
||||||
- Ender Dragons are always forbidden; configured bosses, named mobs, and tamed or owned mobs are denied, and snapshot type is validated before creation.
|
|
||||||
- Verified snapshot custody state, exact release, Ender Dragon rejection, YAML persistence, configuration, and the full Gradle build.
|
|
||||||
|
|
||||||
## 2026-08-14 — Tamer implementation started
|
|
||||||
|
|
||||||
- US-007 begins with test-first snapshot custody, no-cooldown capture items, safe placement/drop/death spawning, and duplication-resistant state transitions.
|
|
||||||
|
|
||||||
## 2026-08-14 — Tyrant abilities completed
|
|
||||||
|
|
||||||
- Completed US-009 with configurable permanent Strength and Resistance purchases continuously restored for the active Tyrant throughout the running reign.
|
|
||||||
- Added `/tyrant intelligence` with a rolling configurable 24-hour unpaused cooldown and an aggregate count of the Vigilante plus assigned non-opted-out Followers without identities or locations.
|
|
||||||
- Ability ownership and cooldowns remain visible in status output, persist across restart, and are cleared by existing reign succession cleanup.
|
|
||||||
- Verified intelligence authorization, counts, cooldown persistence, effect configuration, and the full Gradle build.
|
|
||||||
|
|
||||||
## 2026-08-14 — Tyrant ability implementation started
|
|
||||||
|
|
||||||
- US-009 begins with test-first permanent effect ownership, automatic reapplication, and rolling-24-hour opposition roster intelligence.
|
|
||||||
|
|
||||||
## 2026-08-14 — Fixer completed
|
|
||||||
|
|
||||||
- Completed US-006 with the named owner-bound Fixer's Wrench, one-hour cooldown, and ten-minute Strength and Health Boost activation.
|
|
||||||
- Activation records a fixed normal two-row or near-Tyrant three-row heart tier using same-world configurable proximity at use time.
|
|
||||||
- Continuously refreshed effects survive death, login, milk, and ordinary removal; expiry and class loss remove effects and clamp health safely to the resulting maximum.
|
|
||||||
- Verified activation tiers, active and cooldown deadlines, item consumption/readiness, persistence, and the full Gradle build.
|
|
||||||
|
|
||||||
## 2026-08-14 — Fixer implementation started
|
|
||||||
|
|
||||||
- US-006 begins with test-first bound-item combat boosts, activation-time proximity tiers, persistent cooldowns, and safe health cleanup.
|
|
||||||
|
|
||||||
## 2026-08-14 — Assassin completed
|
|
||||||
|
|
||||||
- Completed US-005 with named owner-bound Assassin Cloak activation, one-hour cooldown, particle-free ten-minute invisibility, and activation-time doubling within the Tyrant's configurable range.
|
|
||||||
- Added 60-second double-jump gating, upward launch, 15-second Speed, 20-second Weakness III, and grounded readiness restoration.
|
|
||||||
- Active effects and cooldowns persist as UTC deadlines and are continuously restored after death, login, milk, or ordinary removal while valid.
|
|
||||||
- Verified duration tiers, cooldown consumption and refresh, jump effect deadlines, class constraints, persistence, and the full Gradle build.
|
|
||||||
|
|
||||||
## 2026-08-14 — Assassin implementation started
|
|
||||||
|
|
||||||
- US-005 begins with test-first bound-item invisibility, near-Tyrant duration doubling, and cooldown-gated double-jump effects.
|
|
||||||
|
|
||||||
## 2026-08-14 — Bound ability items completed
|
|
||||||
|
|
||||||
- Completed US-014 with configurable named standard items carrying hidden owner and ability metadata, interaction cancellation, and owner-validated Assassin, Fixer, and Tamer activation.
|
|
||||||
- Cooldown items are consumed only on successful use, restored automatically when ready or by `/tyrant item`, deduplicated, and withheld with clear guidance when inventory space is unavailable.
|
|
||||||
- Drop, pickup, placement, crafting/storage movement, item-frame, dispenser, death, class loss, reign reset, login, and restart paths prevent transfer and duplication.
|
|
||||||
- Captured-mob custody items use independent IDs, reconcile missing or duplicate inventory items against durable snapshots, and spawn rather than vanish on drop or death.
|
|
||||||
- Verified readiness consumption and refresh, assignment cleanup, durable metadata state, captured custody, and the full Gradle build.
|
|
||||||
|
|
||||||
## 2026-08-14 — Bound ability item implementation started
|
|
||||||
|
|
||||||
- US-014 begins with test-first item readiness and recovery, owner metadata, transfer prevention, cooldown redelivery, and Tamer item permanence.
|
|
||||||
|
|
||||||
## 2026-08-14 — Vigilante and Followers completed
|
|
||||||
|
|
||||||
- Completed US-008 with `/vigilante` invitation, acceptance, dismissal, and leave flows while preserving independent Tyrant-side class assignments.
|
|
||||||
- Added Tyrant/Vigilante combat tracking and continuously refreshed Strength and Resistance based on living same-world Followers within the configurable 50-block range, capped at Strength V and Resistance IV by default.
|
|
||||||
- Every Vigilante death now awards progression only to the Tyrant killer, clears Followers, and applies normal recently-active replacement with previous-holder fallback.
|
|
||||||
- Verified membership, dual alignment, scaling and caps, role maintenance, command metadata, and death/progression components with the Gradle suite.
|
|
||||||
|
|
||||||
## 2026-08-14 — Vigilante and Follower implementation started
|
|
||||||
|
|
||||||
- US-008 begins with test-first invitation membership, death reroll and Tyrant reward integration, combat tracking, and proximity-scaled capped effects.
|
|
||||||
|
|
||||||
## 2026-08-14 — Participation completed
|
|
||||||
|
|
||||||
- Completed US-011 with `/tyrant status`, `optout`, `optin`, and confirmed `relinquish` flows plus comprehensive join messages for lifecycle, roles, classes, abilities, and cooldowns.
|
|
||||||
- Opt-out clears ordinary classes and Follower membership, persists until the player explicitly opts back in, and enforces the configurable seven-day unpaused waiting period.
|
|
||||||
- Central role holders must safely relinquish first; Tyrant relinquishment performs full succession and Vigilante relinquishment performs a normal Vigilante reroll.
|
|
||||||
- Updated all assignment and candidate paths to exclude opted-out players and verified status, persistence, cleanup, cooldown, and relinquishment behavior with the Gradle suite.
|
|
||||||
|
|
||||||
## 2026-08-14 — Participation implementation started
|
|
||||||
|
|
||||||
- US-011 begins with test-first opt-out/opt-in state, seven-day re-entry gating, safe role relinquishment guidance, and complete join status messages.
|
|
||||||
|
|
||||||
## 2026-08-14 — Role maintenance completed
|
|
||||||
|
|
||||||
- Completed US-010 with configurable periodic role maintenance, exact 48-hour inactivity handling, and 24-hour recently-active candidate filtering.
|
|
||||||
- Added online confirmation for pending roles, 24-hour expiry and reroll, former-holder fallback rules, vacant-role retries, opt-out filtering, and Tyrant/Vigilante conflict prevention.
|
|
||||||
- Tyrant inactivity uses full reign succession cleanup; Vigilante inactivity clears only that role's Followers before replacement.
|
|
||||||
- Verified confirmation, expiry reroll, Follower cleanup, exact inactivity boundary, full Tyrant succession, and pause-safe scheduler integration with the Gradle suite.
|
|
||||||
|
|
||||||
## 2026-08-14 — Role maintenance implementation started
|
|
||||||
|
|
||||||
- US-010 begins with test-first inactivity replacement, pending confirmation and expiry, conflict-safe candidate retries, and pause awareness.
|
|
||||||
|
|
||||||
## 2026-08-14 — Tyrant succession completed
|
|
||||||
|
|
||||||
- Completed US-002 with player-killer transfer, recent-player environmental succession, former-holder avoidance, pending offline confirmation, and distinct replacement Vigilante selection.
|
|
||||||
- Tyrant death clears all classes, Followers, cooldowns, item readiness, purchases, levels, and choices before granting the successor one starting choice.
|
|
||||||
- Added death-event integration, server announcements, and a scoreboard-team-backed red Tyrant name and glowing outline that is restored on login and removed on pause or shutdown.
|
|
||||||
- Verified succession attribution, random exclusion, reign cleanup, role separation, death integration, and red presentation with the Gradle suite.
|
|
||||||
|
|
||||||
## 2026-08-14 — Tyrant succession implementation started
|
|
||||||
|
|
||||||
- US-002 begins with test-first killer and environmental succession, reign-wide cleanup, replacement Vigilante selection, and red Tyrant presentation.
|
|
||||||
|
|
||||||
## 2026-08-14 — Administrative lifecycle completed
|
|
||||||
|
|
||||||
- Completed US-001 with permission-gated `/tyrantadmin` start, status, pause, resume, and confirmed reset operations.
|
|
||||||
- Initial Vigilantes may be assigned explicitly or selected among eligible recently active players, with offline selections persisted as pending confirmations.
|
|
||||||
- Pause/resume preserves progression and assignments, shifts every cooldown, participation, inactivity, and pending-selection deadline, and invokes managed effect suppression/restoration.
|
|
||||||
- Added detailed operator status output and verified lifecycle, timer shifting, permissions, confirmation safety, candidate filtering, metadata, and effect hooks with the Gradle suite.
|
|
||||||
|
|
||||||
## 2026-08-14 — Administrative lifecycle implementation started
|
|
||||||
|
|
||||||
- US-001 begins with test-first game start, pause/resume deadline freezing, permission-gated administration, inspection, and confirmed reset behavior.
|
|
||||||
|
|
||||||
## 2026-08-14 — Tyrant class assignment completed
|
|
||||||
|
|
||||||
- Completed US-004 with `/tyrant assign <class> <player>`, purchased-class authorization, UUID state, one holder per class, and one Tyrant-side class per player.
|
|
||||||
- Preserved dual alignment so Vigilantes and Followers can hold a Tyrant-side class, while rejecting the Tyrant and opted-out targets.
|
|
||||||
- Added reassignment and reign cleanup for class cooldown/item state, online assignment/removal notices, and login control reminders.
|
|
||||||
- Verified assignment constraints, dual alignment, cleanup, commands, notifications, and reminders with the Gradle test suite.
|
|
||||||
|
|
||||||
## 2026-08-14 — Tyrant class assignment implementation started
|
|
||||||
|
|
||||||
- US-004 begins with test-first purchased-class authorization, one-holder constraints, dual alignment, reassignment cleanup, and reign-wide removal.
|
|
||||||
|
|
||||||
## 2026-08-14 — Tyrant progression completed
|
|
||||||
|
|
||||||
- Completed US-003 with exact Tyrant-on-Vigilante kill attribution, level and choice rewards, pause guards, one-time purchases, and reign reset behavior.
|
|
||||||
- Added `/tyrant choices` status output and `/tyrant buy <unlock>` with durable atomic state updates and clear failure feedback.
|
|
||||||
- Verified progression, duplicate purchase, authorization, paused-state, reset, command presentation, metadata, and persistence behavior with the Gradle test suite.
|
|
||||||
|
|
||||||
## 2026-08-14 — Tyrant progression implementation started
|
|
||||||
|
|
||||||
- US-003 begins with test-first kill attribution, one-time unlock purchases, and player-visible choice status.
|
|
||||||
|
|
||||||
## 2026-08-14 — Configuration and persistence completed
|
|
||||||
|
|
||||||
- Completed US-012 with validated defaults for ranges, timers, effects, items, messages, and mob restrictions, including runtime Bukkit section parsing.
|
|
||||||
- Added immutable lifecycle and player state for roles, dual-alignment classes, progression, participation, cooldowns, bound items, and captured mobs.
|
|
||||||
- Added RFC 3339 UTC timestamp persistence, paused deadline shifting, atomic YAML replacement, invalid-record rejection, unknown-field preservation, periodic saves, and shutdown saves.
|
|
||||||
- Verified all state, settings, bundled configuration, corruption, forward-compatibility, and pause-time tests with `./gradlew clean check jar`.
|
|
||||||
|
|
||||||
## 2026-08-14 — Configuration and persistence implementation started
|
|
||||||
|
|
||||||
- US-012 begins with test-first validated defaults, immutable UUID-keyed domain state, paused-time-aware clocks, and defensive atomic YAML persistence.
|
|
||||||
|
|
||||||
## 2026-08-14 — Build and release foundation completed
|
|
||||||
|
|
||||||
- Completed US-013 with a Java 17 Gradle build, Spigot 26.2 API, strict compiler linting, JUnit 5 and Mockito dependencies, plugin metadata, and a minimal plugin entrypoint.
|
|
||||||
- Added Gitea push and pull-request CI, conventional-commit validation, development artifacts, semantic versioning, and versioned Gitea release assets modeled on `../spigot-base/`.
|
|
||||||
- Verified `./gradlew clean check jar`, a release-version `1.2.3` JAR and embedded plugin version, workflow delivery settings, and OKF conformance.
|
|
||||||
|
|
||||||
## 2026-08-14 — Build and release implementation started
|
|
||||||
|
|
||||||
- Approved implementation begins with US-013 and a test-driven Java 17, Gradle, Spigot, and Gitea Actions foundation modeled on `../spigot-base/`.
|
|
||||||
|
|
||||||
## 2026-08-14 — Initial game design
|
|
||||||
|
|
||||||
- The game has one Tyrant and one Vigilante, with Followers aligned to the Vigilante and Assassin, Fixer, and Tamer classes assigned by the Tyrant.
|
|
||||||
- A player who kills the Tyrant becomes the next Tyrant; a Tyrant death without a player killer causes random succession among recently active eligible players.
|
|
||||||
- Every Tyrant death clears all class and Follower assignments and resets the former Tyrant's reign progression.
|
|
||||||
- Every Vigilante death rerolls the role among eligible players active in the preceding 24 hours. The previous Vigilante is reused only when no other eligible non-Tyrant player exists.
|
|
||||||
- A Tyrant levels and earns one unlock choice only by personally killing the Vigilante.
|
|
||||||
- Class abilities use named bound items where appropriate. Consumed cooldown items return when ready, and `/tyrant item` recovers missing ready items.
|
|
||||||
- The Tamer has no cooldown and uses a named fishing rod plus a separate captured-mob item.
|
|
||||||
- Game timers freeze while administratively paused.
|
|
||||||
- The project will follow the Java 17, Gradle, testing, Gitea CI, and semantic-release structure demonstrated by `../spigot-base/`.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Spigot Tyrant User Stories
|
|
||||||
|
|
||||||
1. [US-001: Start, pause, and administer the game](us-001-start-pause-and-administer.md)
|
|
||||||
2. [US-002: Transfer and reset the Tyrant role](us-002-transfer-and-reset-tyrant.md)
|
|
||||||
3. [US-003: Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
|
|
||||||
4. [US-004: Assign unlocked classes](us-004-assign-unlocked-classes.md)
|
|
||||||
5. [US-005: Use Assassin abilities](us-005-use-assassin-abilities.md)
|
|
||||||
6. [US-006: Use Fixer abilities](us-006-use-fixer-abilities.md)
|
|
||||||
7. [US-007: Capture and place mobs as a Tamer](us-007-capture-and-place-mobs.md)
|
|
||||||
8. [US-008: Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
|
|
||||||
9. [US-009: Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
|
|
||||||
10. [US-010: Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
|
|
||||||
11. [US-011: Inform players and manage participation](us-011-inform-and-manage-participation.md)
|
|
||||||
12. [US-012: Configure and persist game state](us-012-configure-and-persist-state.md)
|
|
||||||
13. [US-013: Build, test, and release the plugin](us-013-build-test-and-release.md)
|
|
||||||
14. [US-014: Use class ability items](us-014-use-class-ability-items.md)
|
|
||||||
15. [US-015: Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
|
||||||
16. [US-016: Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
|
||||||
17. [US-017: Use bound role control items](us-017-use-bound-role-control-items.md)
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-001: Start, pause, and administer the game"
|
|
||||||
description: Give server operators safe control over the Tyrant game's lifecycle and state.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-001: Start, pause, and administer the game
|
|
||||||
|
|
||||||
As a **server operator**, I want to start, pause, resume, and inspect the game so that it can coexist safely with maintenance and other server events.
|
|
||||||
|
|
||||||
## 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] An administrator can pause and resume the game without deleting game state.
|
|
||||||
- [x] Pausing freezes progression, cooldowns, daily-use timers, inactivity checks, and pending-selection deadlines.
|
|
||||||
- [x] Role and class effects are suppressed while paused and restored when play resumes.
|
|
||||||
- [x] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default.
|
|
||||||
- [x] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, and pending selections.
|
|
||||||
- [x] Destructive administrative operations require explicit confirmation.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
|
||||||
- [Configure and persist game state](us-012-configure-and-persist-state.md)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-002: Transfer and reset the Tyrant role"
|
|
||||||
description: End a Tyrant's reign cleanly and establish a valid successor after death.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-002: Transfer and reset the Tyrant role
|
|
||||||
|
|
||||||
As a **player**, I want defeating the Tyrant to transfer power predictably so that the central conflict always has meaningful consequences.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] A player directly credited with killing the Tyrant becomes the new Tyrant.
|
|
||||||
- [x] A Tyrant death without a player killer selects an eligible opted-in player active within the preceding 24 hours at random.
|
|
||||||
- [x] The former Tyrant is excluded from random succession when another eligible candidate exists.
|
|
||||||
- [x] The former Tyrant's level, unlocks, unspent choices, and Tyrant ability cooldowns reset when the reign ends.
|
|
||||||
- [x] Every Assassin, Fixer, Tamer, Vigilante, and Follower assignment is cleared when the Tyrant dies.
|
|
||||||
- [x] A new Vigilante is selected under the normal candidate rules after succession.
|
|
||||||
- [x] The new Tyrant receives exactly one starting unlock choice.
|
|
||||||
- [x] The Tyrant's displayed name is red and the Tyrant has a red glowing outline while the game is running.
|
|
||||||
- [x] Succession cannot assign the same player as both Tyrant and Vigilante.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
|
||||||
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-003: Progress and purchase Tyrant unlocks"
|
|
||||||
description: Let the Tyrant earn and spend choices by personally defeating the Vigilante.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-003: Progress and purchase Tyrant unlocks
|
|
||||||
|
|
||||||
As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigilante so that each reign develops differently.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante.
|
|
||||||
- [x] A Vigilante death caused by another player, the environment, or the Vigilante does not level the Tyrant.
|
|
||||||
- [x] Available purchases are Assassin, Fixer, Tamer, roster intelligence, permanent Resistance, and permanent Strength.
|
|
||||||
- [x] Each class or ability can be purchased at most once during a reign.
|
|
||||||
- [x] A menu or command clearly identifies purchased, available, and unavailable choices.
|
|
||||||
- [x] A purchase consumes exactly one unspent choice and cannot be duplicated by retries or concurrent input.
|
|
||||||
- [x] Progress cannot be earned or spent while the game is paused.
|
|
||||||
- [x] Levels, purchases, and unspent choices survive logout and restart but reset when the reign ends.
|
|
||||||
- [x] The Tyrant can review and purchase unlocks through the Tyrant control panel.
|
|
||||||
- [x] A confirmation displays the selected unlock and choice cost before purchase.
|
|
||||||
- [x] The control panel refreshes the available choices immediately after a successful purchase.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Role and class model](../concepts/role-and-class-model.md)
|
|
||||||
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
|
|
||||||
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
|
|
||||||
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-004: Assign unlocked classes"
|
|
||||||
description: Let the Tyrant assign each purchased class to one eligible participant.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-004: Assign unlocked classes
|
|
||||||
|
|
||||||
As the **Tyrant**, I want to assign unlocked specialist classes so that I can build a small team suited to my reign.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] The Tyrant can assign only Assassin, Fixer, and Tamer classes purchased during the current reign.
|
|
||||||
- [x] Each class can be assigned to at most one player at a time, and a player can hold at most one Tyrant-side class.
|
|
||||||
- [x] Role alignment and Tyrant-side class are independent: the Vigilante or a Follower may simultaneously be the Assassin, Fixer, or Tamer.
|
|
||||||
- [x] The Tyrant cannot assign a class to the Tyrant, an opted-out player, or another ineligible player.
|
|
||||||
- [x] Reassignment removes the class and its bound items and effects from the previous holder before granting it to the new holder.
|
|
||||||
- [x] Assignment and removal are stored by UUID and communicated clearly to affected players.
|
|
||||||
- [x] A class holder is reminded of the assignment and its controls on login.
|
|
||||||
- [x] All class assignments end when the Tyrant's reign ends.
|
|
||||||
- [x] The Tyrant can assign and reassign purchased classes through the Tyrant control panel.
|
|
||||||
- [x] The control panel's player selector communicates eligibility and identifies the current class holder before confirmation.
|
|
||||||
- [x] Control-panel and command-based assignments enforce identical rules.
|
|
||||||
- [x] Removing or reassigning the Tamer class automatically releases the former holder's captured mobs while leaving ordinary inventory untouched.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Role and class model](../concepts/role-and-class-model.md)
|
|
||||||
- [Use class ability items](us-014-use-class-ability-items.md)
|
|
||||||
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-005: Use Assassin abilities"
|
|
||||||
description: Give the Assassin timed stealth and a risky burst-mobility ability.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-005: Use Assassin abilities
|
|
||||||
|
|
||||||
As the **Assassin**, I want stealth and burst mobility so that I can ambush opponents while accepting a combat penalty afterward.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] The Assassin can activate ten minutes of invisibility once per rolling hour using the named Assassin ability item.
|
|
||||||
- [x] Invisibility displays no potion particles.
|
|
||||||
- [x] Activation while in the same world and within the configured Tyrant range grants twenty minutes instead.
|
|
||||||
- [x] Proximity is evaluated when the ability is activated and does not alter the active duration afterward.
|
|
||||||
- [x] The Assassin can double-jump once every 60 seconds without requiring an item.
|
|
||||||
- [x] A successful double-jump grants Speed for 15 seconds and Weakness III for 20 seconds.
|
|
||||||
- [x] Grounding and supported movement reset jump availability only after the 60-second cooldown has elapsed.
|
|
||||||
- [x] Ability cooldowns use unpaused elapsed time and survive death, logout, and restart.
|
|
||||||
- [x] The player can inspect remaining cooldowns and receives clear feedback when an activation is unavailable.
|
|
||||||
- [x] Tyrant removes only Assassin potion effects that Tyrant itself applied.
|
|
||||||
- [x] Invisibility from potions, commands, or other plugins remains active for Assassins and non-Assassins.
|
|
||||||
- [x] Tyrant-managed Assassin effects are still removed when abilities expire, roles change, games stop, or the plugin disables.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Automated controller tests verify that inactive Tyrant state does not alter ordinary potion effects, active Assassin invisibility is refreshed, and refresh stops at expiry so the short managed effect expires naturally. The complete `./gradlew clean check jar` lifecycle passes.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Use class ability items](us-014-use-class-ability-items.md)
|
|
||||||
- [Configure and persist game state](us-012-configure-and-persist-state.md)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-006: Use Fixer abilities"
|
|
||||||
description: Give the Fixer a periodic offensive and health enhancement strengthened near the Tyrant.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-006: Use Fixer abilities
|
|
||||||
|
|
||||||
As the **Fixer**, I want a substantial temporary combat boost so that I can protect the Tyrant during important fights.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] The Fixer can activate ten minutes of Strength and Health Boost once per rolling hour using the named Fixer ability item.
|
|
||||||
- [x] Normal activation provides two total rows of hearts when combined with the player's normal maximum health.
|
|
||||||
- [x] Activation in the same world and within the configured Tyrant range provides three total rows of hearts.
|
|
||||||
- [x] Proximity is evaluated when the ability is activated and does not alter the active tier afterward.
|
|
||||||
- [x] Health is safely clamped when the boost expires, is suppressed, or changes tier.
|
|
||||||
- [x] The cooldown uses unpaused elapsed time and survives death, logout, and restart.
|
|
||||||
- [x] The player can inspect the remaining cooldown and receives clear feedback when activation is unavailable.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Use class ability items](us-014-use-class-ability-items.md)
|
|
||||||
- [Configure and persist game state](us-012-configure-and-persist-state.md)
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-007: Capture and place mobs as a Tamer"
|
|
||||||
description: Let the Tamer move supported mobs without losing or duplicating them.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-007: Capture and place mobs as a Tamer
|
|
||||||
|
|
||||||
As the **Tamer**, I want to capture a mob and release it elsewhere so that I can relocate creatures safely.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] The Tamer can use a named fishing rod on a supported targeted mob to capture it with no cooldown.
|
|
||||||
- [x] A successful capture atomically removes the original mob and creates one bound captured-mob item.
|
|
||||||
- [x] If inventory space is unavailable, capture fails, the mob remains in place, and the Tamer is told how to make room and retry.
|
|
||||||
- [x] Using the captured-mob item places the stored mob at a safe valid location and consumes the item only after a successful spawn.
|
|
||||||
- [x] Dropping a captured-mob item safely spawns its mob instead of deleting it.
|
|
||||||
- [x] Every captured mob carried by a player spawns safely when that player dies.
|
|
||||||
- [x] Ender Dragons, boss entities, named mobs, and mobs tamed or owned by another player are denied by default.
|
|
||||||
- [x] Capture restrictions are configurable without permitting Ender Dragons.
|
|
||||||
- [x] Invalid placement, full inventory, death, logout, restart, plugin disable, containers, and concurrent interaction cannot lose or duplicate a mob.
|
|
||||||
- [x] Stored mob data is validated defensively before spawning and cannot execute untrusted serialized behavior.
|
|
||||||
- [x] When a player loses the Tamer class, every captured mob in their custody is automatically released at the former Tamer's location.
|
|
||||||
- [x] Ordinary inventory items are not dropped or changed during automatic release.
|
|
||||||
- [x] Each captured-mob item and custody record is removed only after its mob spawns successfully.
|
|
||||||
- [x] Multiple captured mobs are released without duplicating entities or custody records.
|
|
||||||
- [x] If the former Tamer is offline or no safe release location is available, release is deferred without losing the captured mob.
|
|
||||||
- [x] A deferred release is retried when the former Tamer next logs in or reaches a safe location.
|
|
||||||
- [x] Tyrant death, class reassignment, opt-out, administration, and other Tamer-removal paths use the same release behavior.
|
|
||||||
- [x] A Tamer can hold custody of at most one captured mob at a time.
|
|
||||||
- [x] Attempting to capture another mob while one is held is rejected before changing either mob.
|
|
||||||
- [x] The Tamer is told to release the currently captured mob before capturing another.
|
|
||||||
- [x] Releasing or dropping the held mob immediately allows another capture.
|
|
||||||
- [x] The one-mob limit survives logout, restart, item recovery, and repeated or concurrent interaction.
|
|
||||||
- [x] If legacy state contains multiple captured mobs, extras are safely released when possible; custody is retained until release succeeds so no mob is lost.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Automated tests verify that only former Tamers are processed, offline custody remains deferred, successful releases remove only their corresponding custody records, and failed releases remain available for retry. Additional tests verify pre-mutation rejection of a second capture, clear player guidance, and safe reduction of legacy excess custody. The complete `./gradlew clean check jar` lifecycle passes.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Use class ability items](us-014-use-class-ability-items.md)
|
|
||||||
- [Persistence model](../concepts/persistence-model.md)
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-008: Support the Vigilante and Followers"
|
|
||||||
description: Give the Vigilante a replaceable role and combat strength derived from nearby Followers.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-008: Support the Vigilante and Followers
|
|
||||||
|
|
||||||
As the **Vigilante**, I want nearby living Followers to strengthen me against the Tyrant so that coordinated resistance is rewarded.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Exactly one active Vigilante exists while the game is running whenever an eligible player is available.
|
|
||||||
- [x] The Vigilante can invite eligible opted-in players to become Followers, and recruitment requires acceptance.
|
|
||||||
- [x] The Vigilante can dismiss Followers and Followers can leave voluntarily.
|
|
||||||
- [x] While actively fighting the Tyrant, the Vigilante gains one potion level each of Strength and Resistance per living Follower in the same world within 50 blocks.
|
|
||||||
- [x] Offline, dead, opted-out, dismissed, or out-of-range Followers do not count.
|
|
||||||
- [x] A Vigilante or Follower may simultaneously hold one Tyrant-side class without losing Vigilante-side membership.
|
|
||||||
- [x] Effect levels update promptly as combat starts or ends and as Followers move, die, respawn, disconnect, or change assignment.
|
|
||||||
- [x] Scaling is capped by validated configuration, defaulting to Strength V and Resistance IV.
|
|
||||||
- [x] Every Vigilante death ends the assignment and triggers random replacement regardless of cause.
|
|
||||||
- [x] The deceased Vigilante is excluded when another eligible non-Tyrant candidate exists and is reused only when no such candidate exists.
|
|
||||||
- [x] Followers are cleared rather than transferred when their Vigilante dies or loses the position.
|
|
||||||
- [x] The Tyrant is never eligible to be selected as Vigilante.
|
|
||||||
- [x] The Vigilante can review, invite, and dismiss Followers through the Vigilante control panel.
|
|
||||||
- [x] Control-panel and command-based Follower actions enforce identical eligibility and membership rules.
|
|
||||||
- [x] The control panel communicates the current nearby-Follower combat contribution and configured effect caps.
|
|
||||||
- [x] Relinquishing through the control panel follows normal Vigilante succession and Follower cleanup.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [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)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-009: Purchase and use Tyrant abilities"
|
|
||||||
description: Give the Tyrant persistent combat upgrades and limited intelligence about the opposition.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-009: Purchase and use Tyrant abilities
|
|
||||||
|
|
||||||
As the **Tyrant**, I want permanent upgrades and limited roster intelligence so that I can shape my reign and assess the resistance.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Purchasing permanent Strength applies the configured Strength level throughout the current reign while the game is running.
|
|
||||||
- [x] Purchasing permanent Resistance applies the configured Resistance level throughout the current reign while the game is running.
|
|
||||||
- [x] Permanent effects are restored after death, milk consumption, ordinary effect removal, login, restart, and replacement by a shorter or weaker effect.
|
|
||||||
- [x] Roster intelligence can be activated once per rolling 24 unpaused hours after it is purchased.
|
|
||||||
- [x] Roster intelligence reports the number of active Vigilante-side members without revealing their identities or locations.
|
|
||||||
- [x] Intelligence counts the current Vigilante and living assigned Followers, whether online or offline, according to configurable counting rules.
|
|
||||||
- [x] The Tyrant can inspect ability ownership and the remaining intelligence cooldown.
|
|
||||||
- [x] Purchases and cooldowns survive restart and end with the Tyrant's reign.
|
|
||||||
- [x] The Tyrant control panel shows purchased passive upgrades and the roster-intelligence readiness or remaining cooldown.
|
|
||||||
- [x] The Tyrant can activate ready roster intelligence from the control panel and receive the result without revealing identities or locations.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
|
|
||||||
- [Configure and persist game state](us-012-configure-and-persist-state.md)
|
|
||||||
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-010: Handle inactivity and pending selections"
|
|
||||||
description: Replace absent or unconfirmed role holders using recently active eligible players.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-010: Handle inactivity and pending selections
|
|
||||||
|
|
||||||
As a **participant**, I want inactive central roles to be replaced fairly so that the game does not stall around absent players.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] A Tyrant or Vigilante who has not logged in for 48 unpaused hours loses the position.
|
|
||||||
- [x] Random candidates are drawn from eligible opted-in players who logged in during the preceding 24 hours.
|
|
||||||
- [x] The Tyrant is always excluded from Vigilante selection.
|
|
||||||
- [x] A previous role holder is excluded when another eligible candidate exists and may be reused only as a fallback.
|
|
||||||
- [x] Tyrant inactivity ends the reign, clears all assignments, and invokes normal random Tyrant succession followed by Vigilante selection.
|
|
||||||
- [x] Vigilante inactivity clears Followers and rerolls only the Vigilante position.
|
|
||||||
- [x] An offline randomly selected candidate must log in within 24 unpaused hours to confirm the assignment.
|
|
||||||
- [x] An unconfirmed selection expires and rerolls automatically under the same eligibility rules.
|
|
||||||
- [x] Selection remains vacant and retries periodically when no valid candidate exists.
|
|
||||||
- [x] Candidate selection cannot assign conflicting roles or choose an opted-out player, including during concurrent joins and deaths.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Tyrant game state machine](../concepts/game-state-machine.md)
|
|
||||||
- [Inform players and manage participation](us-011-inform-and-manage-participation.md)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-011: Inform players and manage participation"
|
|
||||||
description: Tell players their current place in the game and let unassigned players opt out.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-011: Inform players and manage participation
|
|
||||||
|
|
||||||
As a **player**, I want to understand my status and control my eligibility so that participation is informed and voluntary.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] On join, lifecycle state is omitted while role, class, subclass, ability, and relevant cooldown information remains available when applicable.
|
|
||||||
- [x] Eligible players are invited to opt out of Tyrant events without implying that an event is currently running.
|
|
||||||
- [x] An unassigned opted-in player is told that they are eligible and shown the opt-out command.
|
|
||||||
- [x] An opted-out player is told when they may opt back in.
|
|
||||||
- [x] An unassigned player can opt out through a command.
|
|
||||||
- [x] Opting out removes an ordinary class or Follower assignment immediately; the Tyrant and Vigilante must relinquish through a safe role command before opting out.
|
|
||||||
- [x] Opted-out players cannot be randomly selected, recruited, or assigned a class.
|
|
||||||
- [x] A player cannot opt back in until seven days have elapsed by default.
|
|
||||||
- [x] The opt-out duration is configurable, uses unpaused elapsed time, and survives restart.
|
|
||||||
- [x] Participation commands are idempotent and clearly report current status and remaining time.
|
|
||||||
- [x] While the event is running, damage is cancelled when the Tyrant or an Assassin, Fixer, or Tamer attacks an opted-out player.
|
|
||||||
- [x] While the event is running, damage is cancelled when an opted-out player attacks the Tyrant or an Assassin, Fixer, or Tamer.
|
|
||||||
- [x] Cancelled attackers are told that the target has chosen not to take part in the Tyrant event.
|
|
||||||
- [x] Protection covers direct player attacks and player-fired projectiles without changing unrelated combat.
|
|
||||||
- [x] Participation combat protection does not apply while the event is paused or not started.
|
|
||||||
- [x] While the event is running, an opted-out player in the same world and within the configured Tyrant range has a green glowing outline visible to everyone.
|
|
||||||
- [x] The opt-out glow is removed when proximity, participation, player availability, Tyrant availability, or running-event requirements stop being satisfied.
|
|
||||||
- [x] Existing scoreboard-team membership is restored after the opt-out glow ends, and plugin shutdown safely clears managed glow state.
|
|
||||||
- [x] Opt-out proximity glow does not apply while the event is paused or not started.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
|
|
||||||
- [Persistence model](../concepts/persistence-model.md)
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-012: Configure and persist game state"
|
|
||||||
description: Give operators validated configuration and durable defensive storage for all Tyrant behavior.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-012: Configure and persist game state
|
|
||||||
|
|
||||||
As a **server operator**, I want configurable and durable game behavior so that the plugin remains predictable across restarts and balance changes.
|
|
||||||
|
|
||||||
## 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] 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] 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.
|
|
||||||
- [x] Unknown forward-compatible configuration and state fields are preserved where practical.
|
|
||||||
- [x] Plugin disable removes transient effects and presentation safely while preserving durable state.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Persistence model](../concepts/persistence-model.md)
|
|
||||||
- [Build, test, and release the plugin](us-013-build-test-and-release.md)
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-013: Build, test, and release the plugin"
|
|
||||||
description: Give maintainers repeatable builds, automated verification, and versioned Gitea releases.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-013: Build, test, and release the plugin
|
|
||||||
|
|
||||||
As a **plugin maintainer**, I want automated builds and releases modeled on Spigot Base so that tested, correctly versioned artifacts can be distributed consistently.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] The Gradle Kotlin DSL project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 toolchain.
|
|
||||||
- [x] Compiler lint warnings fail the build.
|
|
||||||
- [x] Automated JUnit 5 and Mockito tests run as part of the Gradle check lifecycle.
|
|
||||||
- [x] Pushes and pull requests build and test the plugin in Gitea Actions.
|
|
||||||
- [x] Pull requests validate conventional commit messages.
|
|
||||||
- [x] CI stores a `spigot-tyrant` development JAR as a workflow artifact.
|
|
||||||
- [x] Main-branch conventional commits drive semantic versioning.
|
|
||||||
- [x] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
|
|
||||||
- [x] Build files, Gradle wrapper, workflows, and release behavior follow `../spigot-base/` where applicable while using Spigot Tyrant names and identifiers.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Configure and persist game state](us-012-configure-and-persist-state.md)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-014: Use class ability items"
|
|
||||||
description: Give class holders named bound items that activate abilities without enabling transfer or cooldown bypasses.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-014: Use class ability items
|
|
||||||
|
|
||||||
As a **class holder**, I want a recognizable inventory item for my ability so that activation is fast and understandable during play.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] Each item-triggered class ability uses a uniquely named standard Minecraft item with hidden persistent ownership and ability metadata.
|
|
||||||
- [x] Right-clicking or swinging the appropriate item activates the ability without also performing an unsafe vanilla action.
|
|
||||||
- [x] Another player cannot activate, retain, craft with, place, store, frame, dispense, or otherwise transfer a bound ability item.
|
|
||||||
- [x] A cooldown ability item disappears only after successful activation and is restored when the cooldown finishes.
|
|
||||||
- [x] The Tamer's named fishing rod has no cooldown and remains available after successful capture or placement.
|
|
||||||
- [x] A dropped class ability item vanishes immediately; a dropped captured-mob item instead attempts to spawn its mob safely.
|
|
||||||
- [x] `/tyrant item` restores currently available missing class items but cannot bypass cooldowns or create duplicates.
|
|
||||||
- [x] Missing ready items are restored on login and cooldown completion when inventory space is available.
|
|
||||||
- [x] When delivery fails because the inventory is full, no item is dropped and the player is told both why and how to use `/tyrant item` after making room.
|
|
||||||
- [x] Bound class items are removed when a player loses the class or the Tyrant's reign ends.
|
|
||||||
- [x] Death, inventory movement, server restart, repeated commands, and concurrent events cannot duplicate a bound item or its captured mob.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
|
|
||||||
- [Capture and place mobs as a Tamer](us-007-capture-and-place-mobs.md)
|
|
||||||
- [Persistence model](../concepts/persistence-model.md)
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-015: Manage a reign through the Tyrant control panel"
|
|
||||||
description: Give the active Tyrant a graphical control panel for inspecting and managing the current reign.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-015: Manage a reign through the Tyrant control panel
|
|
||||||
|
|
||||||
As the **Tyrant**, I want a control panel for my reign so that I can inspect progress, purchase unlocks, assign classes, and use abilities without memorizing commands.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] The active Tyrant can open the control panel with `/tyrant menu`, and `/tyrant` with no arguments opens the same panel.
|
|
||||||
- [x] A player who is not the active Tyrant cannot open or operate the control panel.
|
|
||||||
- [x] When the active Tyrant joins while the event is running, the control panel opens automatically five seconds after login.
|
|
||||||
- [x] Automatic opening is cancelled if the Tyrant disconnects, loses the Tyrant role, or the event pauses or stops during the five-second delay.
|
|
||||||
- [x] Other players and pending Tyrant candidates do not receive the automatically opened control panel.
|
|
||||||
- [x] Closing an automatically opened control panel does not cause it to reopen during the same login session.
|
|
||||||
- [x] The Tyrant may open the control panel manually during the five-second delay without causing a second panel to open automatically.
|
|
||||||
- [x] The main screen shows the Tyrant's level, unspent choices, purchased unlocks, class assignments, and roster-intelligence cooldown.
|
|
||||||
- [x] Purchased, available, unavailable, and cooldown-gated actions are visually distinguishable and explain why they cannot currently be used.
|
|
||||||
- [x] The Tyrant can navigate between overview, unlock, class-assignment, and ability screens and can close the panel without changing gameplay state.
|
|
||||||
- [x] The Tyrant can review and purchase an available unlock after confirming the selected unlock and its choice cost.
|
|
||||||
- [x] The Tyrant can select a purchased class and assign or reassign it to an eligible online player.
|
|
||||||
- [x] The class player selector excludes the Tyrant and clearly identifies players who are opted out or otherwise ineligible.
|
|
||||||
- [x] Class reassignment identifies the current holder and requires confirmation before replacing that holder.
|
|
||||||
- [x] The Tyrant can activate roster intelligence from the panel when the ability is purchased and ready.
|
|
||||||
- [x] A successful or rejected action refreshes the panel from authoritative current state and communicates the result clearly.
|
|
||||||
- [x] Panel actions use the same domain rules and persistence behavior as their command equivalents.
|
|
||||||
- [x] Stale, repeated, or concurrent panel interactions cannot duplicate purchases, bypass cooldowns, or create conflicting assignments.
|
|
||||||
- [x] Panel actions become unavailable immediately if the game pauses, the reign ends, or the player is no longer the Tyrant.
|
|
||||||
- [x] Existing `/tyrant choices`, `/tyrant buy`, `/tyrant assign`, and `/tyrant intelligence` commands remain available as alternatives.
|
|
||||||
- [x] The active Tyrant can open the control panel by using the bound Tyrant control item.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Automated tests verify command entry points, active-Tyrant authorization, authoritative overview state, the exact 100-tick login delay, lifecycle cancellation, and duplicate-opening prevention. The complete `./gradlew clean check jar` lifecycle passes.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
|
|
||||||
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
|
|
||||||
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
|
|
||||||
- [Inform players and manage participation](us-011-inform-and-manage-participation.md)
|
|
||||||
- [Use bound role control items](us-017-use-bound-role-control-items.md)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-016: Manage Followers through the Vigilante control panel"
|
|
||||||
description: Give the active Vigilante a graphical control panel for reviewing and managing the resistance.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-016: Manage Followers through the Vigilante control panel
|
|
||||||
|
|
||||||
As the **Vigilante**, I want a control panel for managing Followers so that I can coordinate the resistance without memorizing commands.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] The active Vigilante can open the control panel with `/vigilante menu`, and `/vigilante` with no arguments opens the same panel.
|
|
||||||
- [x] A player who is not the active Vigilante cannot open or operate the control panel.
|
|
||||||
- [x] The control panel opens automatically five seconds after the active Vigilante joins a running event.
|
|
||||||
- [x] Automatic opening is cancelled if the player disconnects, loses the Vigilante role, or the event pauses or stops during the five-second delay.
|
|
||||||
- [x] Pending Vigilante candidates and other players do not receive the automatically opened control panel.
|
|
||||||
- [x] Manually opening the control panel during the delay prevents a duplicate automatic opening.
|
|
||||||
- [x] Closing the automatically opened control panel does not cause it to reopen during the same login session.
|
|
||||||
- [x] The overview shows current Followers, pending invitations, and the number of nearby Followers currently eligible to contribute combat effects.
|
|
||||||
- [x] Each Follower's online, living, same-world, proximity, and participation status is communicated without exposing information unavailable to the Vigilante.
|
|
||||||
- [x] The control panel shows the current Strength and Resistance contribution and configured caps.
|
|
||||||
- [x] The Vigilante can navigate between overview, recruitment, Follower-management, and role-control screens.
|
|
||||||
- [x] The recruitment screen lists eligible online players and explains why opted-out, Tyrant, Vigilante, or otherwise ineligible players cannot be invited.
|
|
||||||
- [x] The Vigilante can invite an eligible player after confirmation.
|
|
||||||
- [x] Pending outgoing invitations are identified and cannot be duplicated through repeated or concurrent interaction.
|
|
||||||
- [x] Invited players receive the existing invitation notice and may continue using `/vigilante accept`.
|
|
||||||
- [x] The Follower-management screen identifies current Followers and requires confirmation before dismissal.
|
|
||||||
- [x] Successful invitations and dismissals refresh the control panel from authoritative current state and notify affected players.
|
|
||||||
- [x] The role-control screen allows the Vigilante to relinquish the role only after explicit confirmation.
|
|
||||||
- [x] Relinquishing through the control panel uses normal Vigilante replacement and Follower-cleanup rules.
|
|
||||||
- [x] Every action revalidates the running lifecycle and active Vigilante before changing state.
|
|
||||||
- [x] Stale or repeated control-panel interactions cannot invite an ineligible player, duplicate an invitation, or dismiss the wrong Follower.
|
|
||||||
- [x] Existing `/vigilante invite`, `/vigilante accept`, `/vigilante dismiss`, `/vigilante leave`, and `/tyrant relinquish confirm` commands remain available.
|
|
||||||
- [x] The active Vigilante can open the control panel by using the bound Vigilante control item.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Automated tests verify default and explicit command entry points, active-Vigilante authorization, overview state, invitation deduplication and eligibility, the exact 100-tick login delay, lifecycle cancellation, and duplicate-opening prevention. The complete `./gradlew clean check jar` lifecycle passes.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
|
|
||||||
- [Handle inactivity and pending selections](us-010-handle-inactivity-and-selection.md)
|
|
||||||
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
|
||||||
- [Role and class model](../concepts/role-and-class-model.md)
|
|
||||||
- [Use bound role control items](us-017-use-bound-role-control-items.md)
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: "US-017: Use bound role control items"
|
|
||||||
description: Give the active Tyrant and Vigilante secure inventory items that open their control panels.
|
|
||||||
status: done
|
|
||||||
---
|
|
||||||
|
|
||||||
# US-017: Use bound role control items
|
|
||||||
|
|
||||||
As a **Tyrant or Vigilante**, I want a bound role item that opens my control panel so that I can access role controls without typing a command.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] A player receives the appropriate control item when becoming the active Tyrant or Vigilante.
|
|
||||||
- [x] The Tyrant control item opens the Tyrant control panel when used.
|
|
||||||
- [x] The Vigilante control item opens the Vigilante control panel when used.
|
|
||||||
- [x] The Tyrant control item defaults to a Nether Star named `Tyrant Control`.
|
|
||||||
- [x] The Vigilante control item defaults to a Compass named `Vigilante Control`.
|
|
||||||
- [x] Each item's material and name are configurable, and hidden metadata identifies its owner, associated role, and the plugin.
|
|
||||||
- [x] Only the current role holder identified by the item's metadata can use the item.
|
|
||||||
- [x] A former role holder or another player cannot use or retain the item.
|
|
||||||
- [x] `/tyrant item` restores a missing Tyrant control item in addition to existing eligible class items.
|
|
||||||
- [x] `/vigilante item` restores a missing Vigilante control item.
|
|
||||||
- [x] Recovery commands cannot create duplicate control items.
|
|
||||||
- [x] Using a recovery command while the inventory is full does not drop the item and explains how to retry.
|
|
||||||
- [x] A role control item cannot be placed, crafted with, stored, framed, dispensed, transferred, or moved into another inventory.
|
|
||||||
- [x] Dropping a role control item removes it immediately rather than creating a transferable dropped item.
|
|
||||||
- [x] If a role control item leaves its owner's inventory during death or another inventory event, it is removed and may be recovered after respawn.
|
|
||||||
- [x] A missing role control item is restored automatically on login or role assignment when inventory space is available.
|
|
||||||
- [x] Every copy is removed immediately when the player loses the associated role.
|
|
||||||
- [x] Repeated commands, role transitions, death, logout, restart, and concurrent inventory events cannot duplicate role control items.
|
|
||||||
- [x] Existing `/tyrant`, `/tyrant menu`, `/vigilante`, and `/vigilante menu` control-panel access remains available.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Automated tests verify role eligibility, paused-role retention, reconciliation and recovery, panel activation, role-specific commands, and configuration defaults. Inventory listeners cover transfer and departure paths, and the complete `./gradlew clean check jar` lifecycle passes.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
|
||||||
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
|
||||||
- [Use class ability items](us-014-use-class-ability-items.md)
|
|
||||||
- [Configure and persist game state](us-012-configure-and-persist-state.md)
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.entity.Zombie;
|
||||||
|
|
||||||
|
final class ArenaBossState {
|
||||||
|
private ArenaBossState() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static void idle(Zombie boss, Location center, double health) {
|
||||||
|
boss.setAI(false);
|
||||||
|
boss.setTarget(null);
|
||||||
|
boss.setHealth(health);
|
||||||
|
boss.teleport(center);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void engage(Zombie boss, Player challenger) {
|
||||||
|
boss.setAI(true);
|
||||||
|
boss.setTarget(challenger);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
final class ArenaBuildProtection {
|
||||||
|
private ArenaBuildProtection() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isProtected(
|
||||||
|
boolean fightActive,
|
||||||
|
ArenaLocation center,
|
||||||
|
double radius,
|
||||||
|
String worldName,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z
|
||||||
|
) {
|
||||||
|
return fightActive && ArenaGeometry.contains(
|
||||||
|
center, radius, worldName, x, y, z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public final class ArenaGeometry {
|
||||||
|
private ArenaGeometry() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean contains(
|
||||||
|
ArenaLocation center,
|
||||||
|
double radius,
|
||||||
|
String worldName,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z
|
||||||
|
) {
|
||||||
|
if (!center.worldName().equals(worldName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
double deltaX = x - center.x();
|
||||||
|
double deltaZ = z - center.z();
|
||||||
|
return deltaX * deltaX + deltaZ * deltaZ <= radius * radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ArenaPoint outside(
|
||||||
|
ArenaLocation center,
|
||||||
|
double radius,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z
|
||||||
|
) {
|
||||||
|
double deltaX = x - center.x();
|
||||||
|
double deltaZ = z - center.z();
|
||||||
|
double length = Math.hypot(deltaX, deltaZ);
|
||||||
|
if (length < 0.0001) {
|
||||||
|
deltaX = 1.0;
|
||||||
|
deltaZ = 0.0;
|
||||||
|
length = 1.0;
|
||||||
|
}
|
||||||
|
double distance = radius + 1.0;
|
||||||
|
return new ArenaPoint(
|
||||||
|
center.x() + deltaX / length * distance,
|
||||||
|
y,
|
||||||
|
center.z() + deltaZ / length * distance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record ArenaLocation(
|
||||||
|
String worldName,
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double z,
|
||||||
|
float yaw,
|
||||||
|
float pitch
|
||||||
|
) {
|
||||||
|
public ArenaLocation {
|
||||||
|
if (worldName == null || worldName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("arena world must not be blank");
|
||||||
|
}
|
||||||
|
worldName = worldName.trim();
|
||||||
|
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|
||||||
|
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
|
||||||
|
throw new IllegalArgumentException("arena coordinates must be finite");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String display() {
|
||||||
|
return worldName + " at " + Math.round(x) + ", " + Math.round(y) + ", "
|
||||||
|
+ Math.round(z);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface ArenaLocationStore {
|
||||||
|
Optional<ArenaLocation> location();
|
||||||
|
|
||||||
|
void set(ArenaLocation location);
|
||||||
|
|
||||||
|
static ArenaLocationStore unavailable() {
|
||||||
|
return new ArenaLocationStore() {
|
||||||
|
@Override
|
||||||
|
public Optional<ArenaLocation> location() {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void set(ArenaLocation location) {
|
||||||
|
throw new IllegalStateException("arena location storage is unavailable");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record ArenaPoint(double x, double y, double z) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public enum ArenaRole {
|
||||||
|
TYRANT,
|
||||||
|
VIGILANTE
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public record ArmorClaimResult(GameState state, ArmorClaimStatus status) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
public enum ArmorClaimStatus {
|
||||||
|
CLAIMED,
|
||||||
|
GAME_NOT_RUNNING,
|
||||||
|
NOT_TYRANT,
|
||||||
|
UNLOCKS_INCOMPLETE,
|
||||||
|
COLLECTION_INCOMPLETE,
|
||||||
|
NO_CHOICES,
|
||||||
|
ALREADY_CLAIMED
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.bukkit.configuration.file.FileConfiguration;
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
|
public final class BukkitConfigArenaLocationStore implements ArenaLocationStore {
|
||||||
|
private static final String PATH = "vigilante-arena-location";
|
||||||
|
private final JavaPlugin plugin;
|
||||||
|
|
||||||
|
public BukkitConfigArenaLocationStore(JavaPlugin plugin) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ArenaLocation> location() {
|
||||||
|
FileConfiguration config = plugin.getConfig();
|
||||||
|
if (!config.isConfigurationSection(PATH)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
String world = config.getString(PATH + ".world");
|
||||||
|
if (world == null) {
|
||||||
|
throw new IllegalArgumentException(PATH + ".world must be configured");
|
||||||
|
}
|
||||||
|
return Optional.of(new ArenaLocation(
|
||||||
|
world,
|
||||||
|
finite(config, "x"),
|
||||||
|
finite(config, "y"),
|
||||||
|
finite(config, "z"),
|
||||||
|
(float) finite(config, "yaw"),
|
||||||
|
(float) finite(config, "pitch")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void set(ArenaLocation location) {
|
||||||
|
FileConfiguration config = plugin.getConfig();
|
||||||
|
config.set(PATH + ".world", location.worldName());
|
||||||
|
config.set(PATH + ".x", location.x());
|
||||||
|
config.set(PATH + ".y", location.y());
|
||||||
|
config.set(PATH + ".z", location.z());
|
||||||
|
config.set(PATH + ".yaw", location.yaw());
|
||||||
|
config.set(PATH + ".pitch", location.pitch());
|
||||||
|
plugin.saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double finite(FileConfiguration config, String key) {
|
||||||
|
String path = PATH + "." + key;
|
||||||
|
if (!config.isSet(path) || !(config.get(path) instanceof Number number)
|
||||||
|
|| !Double.isFinite(number.doubleValue())) {
|
||||||
|
throw new IllegalArgumentException(path + " must be finite and numeric");
|
||||||
|
}
|
||||||
|
return number.doubleValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.enchantments.Enchantment;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
|
||||||
|
public final class BukkitTyrantArmorItemFactory implements TyrantArmorItemFactory {
|
||||||
|
@Override
|
||||||
|
public ItemStack create(TyrantArmorPiece piece, String tyrantName) {
|
||||||
|
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(piece, tyrantName);
|
||||||
|
ItemStack item = new ItemStack(spec.material());
|
||||||
|
ItemMeta meta = Objects.requireNonNull(item.getItemMeta(), "Legacy reward metadata is unavailable");
|
||||||
|
meta.setDisplayName(ChatColor.GOLD + spec.displayName());
|
||||||
|
meta.setLore(spec.lore().stream().map(line -> ChatColor.GRAY + line).toList());
|
||||||
|
if (spec.protectionLevel() > 0) {
|
||||||
|
meta.addEnchant(Enchantment.PROTECTION, spec.protectionLevel(), true);
|
||||||
|
}
|
||||||
|
if (spec.sharpnessLevel() > 0) {
|
||||||
|
meta.addEnchant(Enchantment.SHARPNESS, spec.sharpnessLevel(), true);
|
||||||
|
}
|
||||||
|
if (spec.efficiencyLevel() > 0) {
|
||||||
|
meta.addEnchant(Enchantment.EFFICIENCY, spec.efficiencyLevel(), true);
|
||||||
|
}
|
||||||
|
if (spec.mendingLevel() > 0) {
|
||||||
|
meta.addEnchant(Enchantment.MENDING, spec.mendingLevel(), true);
|
||||||
|
}
|
||||||
|
meta.setUnbreakable(spec.unbreakable());
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,14 @@ public final class BukkitTyrantControlPanelRenderer
|
|||||||
String intelligence = intelligenceText(model);
|
String intelligence = intelligenceText(model);
|
||||||
inventory.setItem(16, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE + "Roster Intelligence",
|
inventory.setItem(16, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE + "Roster Intelligence",
|
||||||
List.of(intelligence, "Click to activate when ready.")));
|
List.of(intelligence, "Click to activate when ready.")));
|
||||||
|
inventory.setItem(22, item(Material.NETHERITE_CHESTPLATE,
|
||||||
|
ChatColor.GOLD + "Legacy Rewards", List.of(
|
||||||
|
model.allStandardUnlocksPurchased()
|
||||||
|
? "Choose legacy gear; complete the collection for Elytra."
|
||||||
|
: "Purchase all six standard unlocks first.",
|
||||||
|
"Claimed: " + (model.claimedArmor().isEmpty()
|
||||||
|
? "none" : model.claimedArmor())
|
||||||
|
)));
|
||||||
player.openInventory(inventory);
|
player.openInventory(inventory);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +91,10 @@ public final class BukkitTyrantControlPanelRenderer
|
|||||||
case ASSIGN_CONFIRMATION -> clickAssignmentConfirmation(
|
case ASSIGN_CONFIRMATION -> clickAssignmentConfirmation(
|
||||||
player, event.getRawSlot(), holder.tyrantClass(), holder.targetId()
|
player, event.getRawSlot(), holder.tyrantClass(), holder.targetId()
|
||||||
);
|
);
|
||||||
|
case ARMOR -> clickArmor(player, event.getRawSlot());
|
||||||
|
case ARMOR_CONFIRMATION -> clickArmorConfirmation(
|
||||||
|
player, event.getRawSlot(), holder.armorPiece()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +105,70 @@ public final class BukkitTyrantControlPanelRenderer
|
|||||||
openClasses(player);
|
openClasses(player);
|
||||||
} else if (slot == 16) {
|
} else if (slot == 16) {
|
||||||
openAbilities(player);
|
openAbilities(player);
|
||||||
|
} else if (slot == 22) {
|
||||||
|
openArmor(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openArmor(Player player) {
|
||||||
|
TyrantControlPanelModel model = model(player);
|
||||||
|
MenuHolder holder = new MenuHolder(MenuView.ARMOR, null, null);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE + " — Legacy Rewards");
|
||||||
|
TyrantArmorPiece[] pieces = TyrantArmorPiece.values();
|
||||||
|
int[] slots = legacyRewardSlots();
|
||||||
|
for (int index = 0; index < pieces.length; index++) {
|
||||||
|
TyrantArmorPiece piece = pieces[index];
|
||||||
|
inventory.setItem(slots[index], item(
|
||||||
|
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
|
||||||
|
ChatColor.GOLD + readable(piece.name()),
|
||||||
|
model.armorLore(piece)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
inventory.setItem(22, backItem());
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickArmor(Player player, int slot) {
|
||||||
|
if (slot == 22) {
|
||||||
|
refresh(player);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TyrantArmorPiece[] pieces = TyrantArmorPiece.values();
|
||||||
|
int[] slots = legacyRewardSlots();
|
||||||
|
for (int index = 0; index < slots.length; index++) {
|
||||||
|
if (slot == slots[index] && model(player).armorAvailable(pieces[index])) {
|
||||||
|
openArmorConfirmation(player, pieces[index]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int[] legacyRewardSlots() {
|
||||||
|
return new int[] {9, 10, 11, 12, 13, 14, 15, 16, 17, 4};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openArmorConfirmation(Player player, TyrantArmorPiece piece) {
|
||||||
|
MenuHolder holder = new MenuHolder(
|
||||||
|
MenuView.ARMOR_CONFIRMATION, null, null, null, piece
|
||||||
|
);
|
||||||
|
Inventory inventory = create(holder, 27, TITLE + " — Confirm Reward");
|
||||||
|
inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of()));
|
||||||
|
inventory.setItem(13, item(
|
||||||
|
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
|
||||||
|
ChatColor.GOLD + "Tyrant's " + player.getName() + " " + readable(piece.name()),
|
||||||
|
TyrantArmorSpec.forPiece(piece, player.getName()).confirmationLore()
|
||||||
|
));
|
||||||
|
inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of()));
|
||||||
|
player.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clickArmorConfirmation(Player player, int slot, TyrantArmorPiece piece) {
|
||||||
|
if (slot == 11) {
|
||||||
|
openArmor(player);
|
||||||
|
} else if (slot == 15 && piece != null) {
|
||||||
|
player.performCommand("tyrant " + (piece.isArmor() ? "armor " : "gear ")
|
||||||
|
+ piece.commandName());
|
||||||
|
openArmor(player);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +462,9 @@ public final class BukkitTyrantControlPanelRenderer
|
|||||||
CLASSES,
|
CLASSES,
|
||||||
ABILITIES,
|
ABILITIES,
|
||||||
PLAYERS,
|
PLAYERS,
|
||||||
ASSIGN_CONFIRMATION
|
ASSIGN_CONFIRMATION,
|
||||||
|
ARMOR,
|
||||||
|
ARMOR_CONFIRMATION
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class MenuHolder implements InventoryHolder {
|
private static final class MenuHolder implements InventoryHolder {
|
||||||
@@ -394,6 +472,7 @@ public final class BukkitTyrantControlPanelRenderer
|
|||||||
private final TyrantUnlock unlock;
|
private final TyrantUnlock unlock;
|
||||||
private final TyrantClass tyrantClass;
|
private final TyrantClass tyrantClass;
|
||||||
private final UUID targetId;
|
private final UUID targetId;
|
||||||
|
private final TyrantArmorPiece armorPiece;
|
||||||
private Inventory inventory;
|
private Inventory inventory;
|
||||||
|
|
||||||
private MenuHolder(MenuView view, TyrantUnlock unlock, TyrantClass tyrantClass) {
|
private MenuHolder(MenuView view, TyrantUnlock unlock, TyrantClass tyrantClass) {
|
||||||
@@ -405,11 +484,22 @@ public final class BukkitTyrantControlPanelRenderer
|
|||||||
TyrantUnlock unlock,
|
TyrantUnlock unlock,
|
||||||
TyrantClass tyrantClass,
|
TyrantClass tyrantClass,
|
||||||
UUID targetId
|
UUID targetId
|
||||||
|
) {
|
||||||
|
this(view, unlock, tyrantClass, targetId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MenuHolder(
|
||||||
|
MenuView view,
|
||||||
|
TyrantUnlock unlock,
|
||||||
|
TyrantClass tyrantClass,
|
||||||
|
UUID targetId,
|
||||||
|
TyrantArmorPiece armorPiece
|
||||||
) {
|
) {
|
||||||
this.view = view;
|
this.view = view;
|
||||||
this.unlock = unlock;
|
this.unlock = unlock;
|
||||||
this.tyrantClass = tyrantClass;
|
this.tyrantClass = tyrantClass;
|
||||||
this.targetId = targetId;
|
this.targetId = targetId;
|
||||||
|
this.armorPiece = armorPiece;
|
||||||
}
|
}
|
||||||
|
|
||||||
private MenuView view() {
|
private MenuView view() {
|
||||||
@@ -428,6 +518,10 @@ public final class BukkitTyrantControlPanelRenderer
|
|||||||
return targetId;
|
return targetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TyrantArmorPiece armorPiece() {
|
||||||
|
return armorPiece;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Inventory getInventory() {
|
public Inventory getInventory() {
|
||||||
return inventory;
|
return inventory;
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ public final class BukkitVigilanteControlPanelRenderer
|
|||||||
Inventory inventory = create(holder, 27, TITLE + " — Role Control");
|
Inventory inventory = create(holder, 27, TITLE + " — Role Control");
|
||||||
inventory.setItem(13, item(Material.REDSTONE_TORCH,
|
inventory.setItem(13, item(Material.REDSTONE_TORCH,
|
||||||
ChatColor.RED + "Relinquish Vigilante Role",
|
ChatColor.RED + "Relinquish Vigilante Role",
|
||||||
List.of("Followers will be cleared and a replacement selected.",
|
List.of("Followers will be cleared and the arena will open.",
|
||||||
"Click to review confirmation.")));
|
"Click to review confirmation.")));
|
||||||
inventory.setItem(22, backItem());
|
inventory.setItem(22, backItem());
|
||||||
player.openInventory(inventory);
|
player.openInventory(inventory);
|
||||||
@@ -276,7 +276,7 @@ public final class BukkitVigilanteControlPanelRenderer
|
|||||||
Inventory inventory = create(holder, 27, TITLE + " — Confirm Relinquishment");
|
Inventory inventory = create(holder, 27, TITLE + " — Confirm Relinquishment");
|
||||||
inventory.setItem(11, cancelItem());
|
inventory.setItem(11, cancelItem());
|
||||||
inventory.setItem(13, item(Material.BARRIER, ChatColor.RED + "Relinquish Role",
|
inventory.setItem(13, item(Material.BARRIER, ChatColor.RED + "Relinquish Role",
|
||||||
List.of("This clears all Followers and rerolls the Vigilante.")));
|
List.of("This clears all Followers and opens the arena challenge.")));
|
||||||
inventory.setItem(15, confirmItem());
|
inventory.setItem(15, confirmItem());
|
||||||
player.openInventory(inventory);
|
player.openInventory(inventory);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ public final class GameLifecycleService {
|
|||||||
current.accumulatedPausedTime(),
|
current.accumulatedPausedTime(),
|
||||||
current.tyrantLevel(),
|
current.tyrantLevel(),
|
||||||
current.unspentChoices(),
|
current.unspentChoices(),
|
||||||
current.purchases()
|
current.purchases(),
|
||||||
|
current.claimedArmor()
|
||||||
);
|
);
|
||||||
return new LifecycleState(paused, state.players());
|
return new LifecycleState(paused, state.players());
|
||||||
}
|
}
|
||||||
@@ -77,7 +78,8 @@ public final class GameLifecycleService {
|
|||||||
current.accumulatedPausedTime().plus(pausedFor),
|
current.accumulatedPausedTime().plus(pausedFor),
|
||||||
current.tyrantLevel(),
|
current.tyrantLevel(),
|
||||||
current.unspentChoices(),
|
current.unspentChoices(),
|
||||||
current.purchases()
|
current.purchases(),
|
||||||
|
current.claimedArmor()
|
||||||
);
|
);
|
||||||
Map<UUID, PlayerState> players = new HashMap<>();
|
Map<UUID, PlayerState> players = new HashMap<>();
|
||||||
for (PlayerState player : state.players().values()) {
|
for (PlayerState player : state.players().values()) {
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public record GameState(
|
|||||||
Duration accumulatedPausedTime,
|
Duration accumulatedPausedTime,
|
||||||
int tyrantLevel,
|
int tyrantLevel,
|
||||||
int unspentChoices,
|
int unspentChoices,
|
||||||
Set<TyrantUnlock> purchases
|
Set<TyrantUnlock> purchases,
|
||||||
|
Set<TyrantArmorPiece> claimedArmor
|
||||||
) {
|
) {
|
||||||
public GameState {
|
public GameState {
|
||||||
lifecycle = lifecycle == null ? GameLifecycle.UNSTARTED : lifecycle;
|
lifecycle = lifecycle == null ? GameLifecycle.UNSTARTED : lifecycle;
|
||||||
@@ -28,6 +29,7 @@ public record GameState(
|
|||||||
accumulatedPausedTime = accumulatedPausedTime == null
|
accumulatedPausedTime = accumulatedPausedTime == null
|
||||||
? Duration.ZERO : accumulatedPausedTime;
|
? Duration.ZERO : accumulatedPausedTime;
|
||||||
purchases = purchases == null ? Set.of() : Set.copyOf(purchases);
|
purchases = purchases == null ? Set.of() : Set.copyOf(purchases);
|
||||||
|
claimedArmor = claimedArmor == null ? Set.of() : Set.copyOf(claimedArmor);
|
||||||
if (tyrantId.isPresent() && tyrantId.equals(vigilanteId)) {
|
if (tyrantId.isPresent() && tyrantId.equals(vigilanteId)) {
|
||||||
throw new IllegalArgumentException("Tyrant and Vigilante must be different players");
|
throw new IllegalArgumentException("Tyrant and Vigilante must be different players");
|
||||||
}
|
}
|
||||||
@@ -42,6 +44,24 @@ public record GameState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public GameState(
|
||||||
|
GameLifecycle lifecycle,
|
||||||
|
Optional<UUID> tyrantId,
|
||||||
|
Optional<UUID> vigilanteId,
|
||||||
|
Optional<PendingSelection> pendingTyrant,
|
||||||
|
Optional<PendingSelection> pendingVigilante,
|
||||||
|
Optional<Instant> pausedAt,
|
||||||
|
Duration accumulatedPausedTime,
|
||||||
|
int tyrantLevel,
|
||||||
|
int unspentChoices,
|
||||||
|
Set<TyrantUnlock> purchases
|
||||||
|
) {
|
||||||
|
this(
|
||||||
|
lifecycle, tyrantId, vigilanteId, pendingTyrant, pendingVigilante, pausedAt,
|
||||||
|
accumulatedPausedTime, tyrantLevel, unspentChoices, purchases, Set.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static GameState empty() {
|
public static GameState empty() {
|
||||||
return new GameState(
|
return new GameState(
|
||||||
GameLifecycle.UNSTARTED,
|
GameLifecycle.UNSTARTED,
|
||||||
@@ -53,6 +73,7 @@ public record GameState(
|
|||||||
Duration.ZERO,
|
Duration.ZERO,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
|
Set.of(),
|
||||||
Set.of()
|
Set.of()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ import org.bukkit.configuration.ConfigurationSection;
|
|||||||
public record PluginSettings(
|
public record PluginSettings(
|
||||||
double tyrantRangeBlocks,
|
double tyrantRangeBlocks,
|
||||||
double followerRangeBlocks,
|
double followerRangeBlocks,
|
||||||
|
double vigilanteArenaRadiusBlocks,
|
||||||
|
int arenaBarrierHeightBlocks,
|
||||||
|
double vigilanteBossHealth,
|
||||||
|
double vigilanteBossDamage,
|
||||||
|
double vigilanteBossArmor,
|
||||||
Duration optOutDuration,
|
Duration optOutDuration,
|
||||||
Duration roleInactivity,
|
Duration roleInactivity,
|
||||||
Duration candidateActivityWindow,
|
Duration roleMaintenanceInterval,
|
||||||
Duration pendingSelectionTimeout,
|
|
||||||
Duration selectionRetryInterval,
|
|
||||||
Duration assassinCooldown,
|
Duration assassinCooldown,
|
||||||
Duration assassinInvisibilityDuration,
|
Duration assassinInvisibilityDuration,
|
||||||
Duration assassinDoubleJumpCooldown,
|
Duration assassinDoubleJumpCooldown,
|
||||||
@@ -48,11 +51,20 @@ public record PluginSettings(
|
|||||||
public PluginSettings {
|
public PluginSettings {
|
||||||
requirePositiveFinite(tyrantRangeBlocks, "tyrant-range-blocks");
|
requirePositiveFinite(tyrantRangeBlocks, "tyrant-range-blocks");
|
||||||
requirePositiveFinite(followerRangeBlocks, "follower-range-blocks");
|
requirePositiveFinite(followerRangeBlocks, "follower-range-blocks");
|
||||||
|
requireFiniteRange(
|
||||||
|
vigilanteArenaRadiusBlocks, 1.0, 512.0, "vigilante-arena-radius-blocks"
|
||||||
|
);
|
||||||
|
if (arenaBarrierHeightBlocks < 3 || arenaBarrierHeightBlocks > 128) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"arena-barrier-height-blocks must be between 3 and 128"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
requireFiniteRange(vigilanteBossHealth, 1.0, 1024.0, "vigilante-boss-health");
|
||||||
|
requireFiniteRange(vigilanteBossDamage, 0.1, 2048.0, "vigilante-boss-damage");
|
||||||
|
requireFiniteRange(vigilanteBossArmor, 0.0, 30.0, "vigilante-boss-armor");
|
||||||
requirePositive(optOutDuration, "opt-out-seconds");
|
requirePositive(optOutDuration, "opt-out-seconds");
|
||||||
requirePositive(roleInactivity, "role-inactivity-seconds");
|
requirePositive(roleInactivity, "role-inactivity-seconds");
|
||||||
requirePositive(candidateActivityWindow, "candidate-activity-window-seconds");
|
requirePositive(roleMaintenanceInterval, "role-maintenance-seconds");
|
||||||
requirePositive(pendingSelectionTimeout, "pending-selection-timeout-seconds");
|
|
||||||
requirePositive(selectionRetryInterval, "selection-retry-seconds");
|
|
||||||
requirePositive(assassinCooldown, "assassin-cooldown-seconds");
|
requirePositive(assassinCooldown, "assassin-cooldown-seconds");
|
||||||
requirePositive(assassinInvisibilityDuration, "assassin-invisibility-seconds");
|
requirePositive(assassinInvisibilityDuration, "assassin-invisibility-seconds");
|
||||||
requirePositive(assassinDoubleJumpCooldown, "assassin-double-jump-cooldown-seconds");
|
requirePositive(assassinDoubleJumpCooldown, "assassin-double-jump-cooldown-seconds");
|
||||||
@@ -93,11 +105,17 @@ public record PluginSettings(
|
|||||||
return new PluginSettings(
|
return new PluginSettings(
|
||||||
decimal(values, "tyrant-range-blocks", 50.0),
|
decimal(values, "tyrant-range-blocks", 50.0),
|
||||||
decimal(values, "follower-range-blocks", 50.0),
|
decimal(values, "follower-range-blocks", 50.0),
|
||||||
|
decimal(values, "vigilante-arena-radius-blocks", 10.0),
|
||||||
|
integer(values, "arena-barrier-height-blocks", 12),
|
||||||
|
decimal(values, "vigilante-boss-health", 80.0),
|
||||||
|
decimal(values, "vigilante-boss-damage", 8.0),
|
||||||
|
decimal(values, "vigilante-boss-armor", 10.0),
|
||||||
duration(values, "opt-out-seconds", Duration.ofDays(7)),
|
duration(values, "opt-out-seconds", Duration.ofDays(7)),
|
||||||
duration(values, "role-inactivity-seconds", Duration.ofHours(48)),
|
duration(values, "role-inactivity-seconds", Duration.ofHours(48)),
|
||||||
duration(values, "candidate-activity-window-seconds", Duration.ofHours(24)),
|
durationAlias(
|
||||||
duration(values, "pending-selection-timeout-seconds", Duration.ofHours(24)),
|
values, "role-maintenance-seconds", "selection-retry-seconds",
|
||||||
duration(values, "selection-retry-seconds", Duration.ofMinutes(1)),
|
Duration.ofMinutes(1)
|
||||||
|
),
|
||||||
duration(values, "assassin-cooldown-seconds", Duration.ofHours(1)),
|
duration(values, "assassin-cooldown-seconds", Duration.ofHours(1)),
|
||||||
duration(values, "assassin-invisibility-seconds", Duration.ofMinutes(10)),
|
duration(values, "assassin-invisibility-seconds", Duration.ofMinutes(10)),
|
||||||
duration(values, "assassin-double-jump-cooldown-seconds", Duration.ofSeconds(60)),
|
duration(values, "assassin-double-jump-cooldown-seconds", Duration.ofSeconds(60)),
|
||||||
@@ -227,6 +245,19 @@ public record PluginSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void requireFiniteRange(
|
||||||
|
double value,
|
||||||
|
double minimum,
|
||||||
|
double maximum,
|
||||||
|
String key
|
||||||
|
) {
|
||||||
|
if (!Double.isFinite(value) || value < minimum || value > maximum) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
key + " must be between " + minimum + " and " + maximum
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void requirePositive(Duration value, String key) {
|
private static void requirePositive(Duration value, String key) {
|
||||||
Objects.requireNonNull(value, key);
|
Objects.requireNonNull(value, key);
|
||||||
if (value.isZero() || value.isNegative()) {
|
if (value.isZero() || value.isNegative()) {
|
||||||
@@ -262,6 +293,18 @@ public record PluginSettings(
|
|||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Duration durationAlias(
|
||||||
|
Map<String, ?> values,
|
||||||
|
String key,
|
||||||
|
String legacyKey,
|
||||||
|
Duration defaultValue
|
||||||
|
) {
|
||||||
|
if (values.containsKey(key)) {
|
||||||
|
return duration(values, key, defaultValue);
|
||||||
|
}
|
||||||
|
return duration(values, legacyKey, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
private static Duration duration(Map<String, ?> values, String key, Duration defaultValue) {
|
private static Duration duration(Map<String, ?> values, String key, Duration defaultValue) {
|
||||||
Object raw = values.get(key);
|
Object raw = values.get(key);
|
||||||
if (raw == null) {
|
if (raw == null) {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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 RoleArenaBarrier {
|
||||||
|
private final Map<Location, BlockData> replaced = new LinkedHashMap<>();
|
||||||
|
private boolean built;
|
||||||
|
|
||||||
|
void build(
|
||||||
|
World world,
|
||||||
|
ArenaLocation center,
|
||||||
|
double radius,
|
||||||
|
int wallHeight
|
||||||
|
) {
|
||||||
|
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()) - 1;
|
||||||
|
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 + wallHeight; 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,639 @@
|
|||||||
|
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.Color;
|
||||||
|
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.block.BlockBreakEvent;
|
||||||
|
import org.bukkit.event.block.BlockPlaceEvent;
|
||||||
|
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 RoleArenaController implements Listener, Runnable {
|
||||||
|
private static final String BOSS_TAG = "spigottyrant-role-arena-boss";
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final Server server;
|
||||||
|
private final ArenaLocationStore locations;
|
||||||
|
private final PluginSettings settings;
|
||||||
|
private final VigilanteArenaSuccessionService vigilanteSuccession =
|
||||||
|
new VigilanteArenaSuccessionService();
|
||||||
|
private final TyrantArenaSuccessionService tyrantSuccession =
|
||||||
|
new TyrantArenaSuccessionService();
|
||||||
|
private final RoleArenaBarrier barrier = new RoleArenaBarrier();
|
||||||
|
private final Set<UUID> relocating = new HashSet<>();
|
||||||
|
private UUID bossId;
|
||||||
|
private UUID challengerId;
|
||||||
|
private boolean spawningBoss;
|
||||||
|
private ArenaRole previousOpenRole;
|
||||||
|
private ArenaRole bossRole;
|
||||||
|
private ArenaRole warnedMissingRole;
|
||||||
|
private ArenaLocation observedLocation;
|
||||||
|
|
||||||
|
public RoleArenaController(
|
||||||
|
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<ArenaRole> openRole = RoleArenaPolicy.openRole(stateManager.game());
|
||||||
|
Optional<ArenaLocation> configured = locations.location();
|
||||||
|
if (configured.isEmpty()) {
|
||||||
|
closeFight();
|
||||||
|
observedLocation = null;
|
||||||
|
warnIfOpenWithoutLocation(openRole);
|
||||||
|
previousOpenRole = null;
|
||||||
|
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(openRole);
|
||||||
|
previousOpenRole = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
warnedMissingRole = null;
|
||||||
|
ArenaRole role = openRole.orElse(null);
|
||||||
|
if (role != previousOpenRole) {
|
||||||
|
closeFight();
|
||||||
|
if (role != null) {
|
||||||
|
announceVacancy(arena, role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousOpenRole = role;
|
||||||
|
drawBoundary(world, arena, role);
|
||||||
|
removeOtherMobs(world, arena);
|
||||||
|
if (role == null) {
|
||||||
|
closeFight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Zombie boss = boss(world);
|
||||||
|
if (boss == null || bossRole != role) {
|
||||||
|
closeFight();
|
||||||
|
boss = spawnBoss(world, arena, role);
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
|| !RoleArenaPolicy.isEligible(
|
||||||
|
stateManager.game(), role, playerState(challenger)
|
||||||
|
)) {
|
||||||
|
if (challengerId != null) {
|
||||||
|
resetFight();
|
||||||
|
} else {
|
||||||
|
ArenaBossState.idle(
|
||||||
|
boss, toBukkit(world, arena), settings.vigilanteBossHealth()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ArenaBossState.engage(boss, 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) {
|
||||||
|
Optional<ArenaRole> role = RoleArenaPolicy.openRole(stateManager.game());
|
||||||
|
if (role.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlayerState state = playerState(event.getPlayer());
|
||||||
|
locations.location().filter(location -> RoleArenaPolicy.isEligible(
|
||||||
|
stateManager.game(), role.orElseThrow(), state
|
||||||
|
)).ifPresent(location -> event.getPlayer().sendMessage(
|
||||||
|
vacancyMessage(location, role.orElseThrow())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 onBlockPlace(BlockPlaceEvent event) {
|
||||||
|
if (protects(event.getBlock().getLocation())) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
event.getPlayer().sendMessage(ChatColor.RED
|
||||||
|
+ "Blocks cannot be changed during an arena fight.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
|
public void onBlockBreak(BlockBreakEvent event) {
|
||||||
|
if (protects(event.getBlock().getLocation())) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
event.getPlayer().sendMessage(ChatColor.RED
|
||||||
|
+ "Blocks cannot be changed during an arena fight.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
ArenaRole defeatedRole = bossRole;
|
||||||
|
bossId = null;
|
||||||
|
bossRole = null;
|
||||||
|
Player killer = event.getEntity().getKiller();
|
||||||
|
if (killer != null && challengerId != null && defeatedRole != null
|
||||||
|
&& killer.getUniqueId().equals(challengerId)
|
||||||
|
&& RoleArenaPolicy.isEligible(
|
||||||
|
stateManager.game(), defeatedRole, playerState(killer)
|
||||||
|
)) {
|
||||||
|
assignRole(challengerId, defeatedRole);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bossId = defeatedBoss;
|
||||||
|
bossRole = defeatedRole;
|
||||||
|
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);
|
||||||
|
ArenaRole role = bossRole;
|
||||||
|
if (challengerId == null && bossId != null && role != null
|
||||||
|
&& RoleArenaPolicy.isEligible(stateManager.game(), role, state)) {
|
||||||
|
challengerId = player.getUniqueId();
|
||||||
|
World world = server.getWorld(arena.worldName());
|
||||||
|
if (world != null) {
|
||||||
|
barrier.build(
|
||||||
|
world, arena, settings.vigilanteArenaRadiusBlocks(),
|
||||||
|
settings.arenaBarrierHeightBlocks()
|
||||||
|
);
|
||||||
|
Zombie boss = boss(world);
|
||||||
|
if (boss != null) {
|
||||||
|
boss.setHealth(settings.vigilanteBossHealth());
|
||||||
|
boss.teleport(toBukkit(world, arena));
|
||||||
|
ArenaBossState.engage(boss, player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.sendMessage(ChatColor.GOLD + "Defeat the arena boss to become "
|
||||||
|
+ readable(role) + "!");
|
||||||
|
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()) {
|
||||||
|
ArenaBossState.idle(
|
||||||
|
boss, toBukkit(world, configured.orElseThrow()),
|
||||||
|
settings.vigilanteBossHealth()
|
||||||
|
);
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
bossRole = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Zombie spawnBoss(World world, ArenaLocation arena, ArenaRole role) {
|
||||||
|
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(role == ArenaRole.TYRANT
|
||||||
|
? ChatColor.RED + "Tyrant's Trial"
|
||||||
|
: ChatColor.BLUE + "Vigilante's Trial");
|
||||||
|
zombie.setCustomNameVisible(true);
|
||||||
|
zombie.setRemoveWhenFarAway(false);
|
||||||
|
zombie.setCanPickupItems(false);
|
||||||
|
zombie.setAI(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();
|
||||||
|
bossRole = role;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
void assignRole(UUID playerId, ArenaRole role) {
|
||||||
|
LifecycleState assigned;
|
||||||
|
try {
|
||||||
|
assigned = role == ArenaRole.TYRANT
|
||||||
|
? tyrantSuccession.claim(stateManager.snapshot(), playerId)
|
||||||
|
: vigilanteSuccession.claim(stateManager.snapshot(), playerId);
|
||||||
|
} catch (IllegalStateException exception) {
|
||||||
|
resetFight();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stateManager.replaceState(assigned);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
challengerId = null;
|
||||||
|
barrier.clear();
|
||||||
|
Player winner = server.getPlayer(playerId);
|
||||||
|
moveWinnerOutside(winner);
|
||||||
|
if (role == ArenaRole.VIGILANTE) {
|
||||||
|
if (winner != null) {
|
||||||
|
winner.sendMessage(ChatColor.YELLOW
|
||||||
|
+ "You defeated the arena boss and became the Vigilante!");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String name = Optional.ofNullable(winner)
|
||||||
|
.map(Player::getName).orElse(playerId.toString());
|
||||||
|
String message = ChatColor.YELLOW + name + " defeated the arena boss and is the "
|
||||||
|
+ readable(role) + "!";
|
||||||
|
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void moveWinnerOutside(Player winner) {
|
||||||
|
Optional<ArenaLocation> configured = locations.location();
|
||||||
|
if (winner == null || configured.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ArenaLocation arena = configured.orElseThrow();
|
||||||
|
Location winnerLocation = winner.getLocation();
|
||||||
|
if (!contains(arena, winnerLocation)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
relocating.add(winner.getUniqueId());
|
||||||
|
try {
|
||||||
|
winner.teleport(
|
||||||
|
outside(arena, winnerLocation), PlayerTeleportEvent.TeleportCause.PLUGIN
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
relocating.remove(winner.getUniqueId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void announceVacancy(ArenaLocation arena, ArenaRole role) {
|
||||||
|
String message = vacancyMessage(arena, role);
|
||||||
|
for (Player player : server.getOnlinePlayers()) {
|
||||||
|
PlayerState state = playerState(player);
|
||||||
|
if (RoleArenaPolicy.isEligible(stateManager.game(), role, state)) {
|
||||||
|
player.sendMessage(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void warnIfOpenWithoutLocation(Optional<ArenaRole> openRole) {
|
||||||
|
if (openRole.isEmpty()) {
|
||||||
|
warnedMissingRole = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ArenaRole role = openRole.orElseThrow();
|
||||||
|
if (role == warnedMissingRole) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
warnedMissingRole = role;
|
||||||
|
server.getConsoleSender().sendMessage(ChatColor.RED
|
||||||
|
+ "The " + readable(role)
|
||||||
|
+ " 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 shared role arena location is not configured.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String vacancyMessage(ArenaLocation arena, ArenaRole role) {
|
||||||
|
return ChatColor.YELLOW + "The " + readable(role)
|
||||||
|
+ " 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, ArenaRole role) {
|
||||||
|
double radius = settings.vigilanteArenaRadiusBlocks();
|
||||||
|
Color color = boundaryColor(role);
|
||||||
|
Particle.DustOptions dust = color == null
|
||||||
|
? null : new Particle.DustOptions(color, 1.25F);
|
||||||
|
int[] heightOffsets = boundaryHeightOffsets(
|
||||||
|
barrier.active(), settings.arenaBarrierHeightBlocks()
|
||||||
|
);
|
||||||
|
for (int heightOffset : heightOffsets) {
|
||||||
|
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() + heightOffset,
|
||||||
|
arena.z() + Math.sin(angle) * radius
|
||||||
|
);
|
||||||
|
if (dust == null) {
|
||||||
|
world.spawnParticle(
|
||||||
|
Particle.END_ROD, point, 1, 0.0, 0.0, 0.0, 0.0
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
world.spawnParticle(
|
||||||
|
Particle.DUST, point, 1, 0.0, 0.0, 0.0, 0.0, dust
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean protects(Location location) {
|
||||||
|
Optional<ArenaLocation> configured = locations.location();
|
||||||
|
World world = location.getWorld();
|
||||||
|
return configured.isPresent() && world != null
|
||||||
|
&& ArenaBuildProtection.isProtected(
|
||||||
|
barrier.active(), configured.orElseThrow(),
|
||||||
|
settings.vigilanteArenaRadiusBlocks(), world.getName(),
|
||||||
|
location.getX(), location.getY(), location.getZ()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int[] boundaryHeightOffsets(boolean fightActive, int wallHeight) {
|
||||||
|
if (!fightActive) {
|
||||||
|
return new int[] {1};
|
||||||
|
}
|
||||||
|
int count = (wallHeight - 2) / 3 + 1;
|
||||||
|
int[] offsets = new int[count];
|
||||||
|
for (int index = 0; index < count; index++) {
|
||||||
|
offsets[index] = 1 + index * 3;
|
||||||
|
}
|
||||||
|
return offsets;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color boundaryColor(ArenaRole role) {
|
||||||
|
if (role == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return role == ArenaRole.TYRANT ? Color.RED : Color.BLUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readable(ArenaRole role) {
|
||||||
|
return role == ArenaRole.TYRANT ? "Tyrant" : "Vigilante";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean movedHorizontally(Location from, Location to) {
|
||||||
|
return from.getWorld() != to.getWorld()
|
||||||
|
|| from.getX() != to.getX() || from.getZ() != to.getZ();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public final class RoleArenaPolicy {
|
||||||
|
private RoleArenaPolicy() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Optional<ArenaRole> openRole(GameState game) {
|
||||||
|
if (game.lifecycle() != GameLifecycle.RUNNING) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (game.tyrantId().isEmpty()) {
|
||||||
|
return game.pendingTyrant().isEmpty()
|
||||||
|
? Optional.of(ArenaRole.TYRANT) : Optional.empty();
|
||||||
|
}
|
||||||
|
return game.vigilanteId().isEmpty()
|
||||||
|
? Optional.of(ArenaRole.VIGILANTE) : Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isEligible(
|
||||||
|
GameState game,
|
||||||
|
ArenaRole role,
|
||||||
|
PlayerState player
|
||||||
|
) {
|
||||||
|
return player != null
|
||||||
|
&& openRole(game).filter(role::equals).isPresent()
|
||||||
|
&& player.optedOutUntil().isEmpty()
|
||||||
|
&& game.tyrantId().filter(player.playerId()::equals).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package games.dmg.spigottyrant;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
|
|
||||||
public final class RoleCandidateSelector {
|
|
||||||
public Optional<UUID> select(
|
|
||||||
Map<UUID, PlayerState> players,
|
|
||||||
Set<UUID> excluded,
|
|
||||||
Optional<UUID> previousHolder,
|
|
||||||
Instant now,
|
|
||||||
Duration activityWindow,
|
|
||||||
RandomGenerator random
|
|
||||||
) {
|
|
||||||
Instant cutoff = now.minus(activityWindow);
|
|
||||||
List<UUID> eligible = new ArrayList<>();
|
|
||||||
for (PlayerState player : players.values()) {
|
|
||||||
if (excluded.contains(player.playerId())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (player.lastLogin().isEmpty()
|
|
||||||
|| player.lastLogin().orElseThrow().isBefore(cutoff)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (player.optedOutUntil().isPresent()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
eligible.add(player.playerId());
|
|
||||||
}
|
|
||||||
if (eligible.size() > 1) {
|
|
||||||
previousHolder.ifPresent(eligible::remove);
|
|
||||||
}
|
|
||||||
if (eligible.isEmpty()) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
eligible.sort(Comparator.comparing(UUID::toString));
|
|
||||||
return Optional.of(eligible.get(random.nextInt(eligible.size())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,63 +5,31 @@ import java.time.Instant;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
|
|
||||||
public final class RoleMaintenanceService {
|
public final class RoleMaintenanceService {
|
||||||
private final RoleCandidateSelector candidates;
|
|
||||||
private final TyrantSuccessionService succession;
|
private final TyrantSuccessionService succession;
|
||||||
private final Duration inactivity;
|
private final Duration inactivity;
|
||||||
private final Duration activityWindow;
|
|
||||||
private final Duration pendingTimeout;
|
|
||||||
private final RandomGenerator random;
|
|
||||||
|
|
||||||
public RoleMaintenanceService(
|
public RoleMaintenanceService(
|
||||||
RoleCandidateSelector candidates,
|
|
||||||
TyrantSuccessionService succession,
|
TyrantSuccessionService succession,
|
||||||
Duration inactivity,
|
Duration inactivity
|
||||||
Duration activityWindow,
|
|
||||||
Duration pendingTimeout,
|
|
||||||
RandomGenerator random
|
|
||||||
) {
|
) {
|
||||||
this.candidates = candidates;
|
|
||||||
this.succession = succession;
|
this.succession = succession;
|
||||||
this.inactivity = inactivity;
|
this.inactivity = inactivity;
|
||||||
this.activityWindow = activityWindow;
|
|
||||||
this.pendingTimeout = pendingTimeout;
|
|
||||||
this.random = random;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public LifecycleState maintain(
|
public LifecycleState maintain(PersistentState state, Instant now) {
|
||||||
PersistentState state,
|
|
||||||
Set<UUID> onlinePlayerIds,
|
|
||||||
Instant now
|
|
||||||
) {
|
|
||||||
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
|
if (state.game().lifecycle() != GameLifecycle.RUNNING) {
|
||||||
return new LifecycleState(state.game(), state.players());
|
return new LifecycleState(state.game(), state.players());
|
||||||
}
|
}
|
||||||
LifecycleState current = confirmPending(state, onlinePlayerIds, now);
|
LifecycleState current = clearLegacyPending(state);
|
||||||
if (isInactive(current.game().tyrantId(), current.players(), now)) {
|
if (isInactive(current.game().tyrantId(), current.players(), now)) {
|
||||||
return succession.succeed(
|
return succession.succeed(current.asPersistentState(), Optional.empty());
|
||||||
current.asPersistentState(), Optional.empty(), onlinePlayerIds, now
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (isInactive(current.game().vigilanteId(), current.players(), now)) {
|
if (isInactive(current.game().vigilanteId(), current.players(), now)) {
|
||||||
current = rerollVigilante(
|
current = rerollVigilante(
|
||||||
current.asPersistentState(), current.game().vigilanteId(), onlinePlayerIds, now
|
current.asPersistentState(), current.game().vigilanteId()
|
||||||
);
|
|
||||||
}
|
|
||||||
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;
|
return current;
|
||||||
@@ -69,115 +37,22 @@ public final class RoleMaintenanceService {
|
|||||||
|
|
||||||
public LifecycleState rerollVigilante(
|
public LifecycleState rerollVigilante(
|
||||||
PersistentState state,
|
PersistentState state,
|
||||||
Optional<UUID> previousVigilante,
|
Optional<UUID> previousVigilante
|
||||||
Set<UUID> onlinePlayerIds,
|
|
||||||
Instant now
|
|
||||||
) {
|
) {
|
||||||
Map<UUID, PlayerState> players = clearFollowers(state.players(), previousVigilante);
|
Map<UUID, PlayerState> players = clearFollowers(state.players(), previousVigilante);
|
||||||
Set<UUID> excluded = state.game().tyrantId().map(Set::of).orElseGet(Set::of);
|
|
||||||
Optional<UUID> selected = candidates.select(
|
|
||||||
players, excluded, previousVigilante, now, activityWindow, random
|
|
||||||
);
|
|
||||||
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
|
|
||||||
Optional<PendingSelection> pending = selected
|
|
||||||
.filter(playerId -> active.isEmpty())
|
|
||||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
|
||||||
GameState game = copyRoles(
|
GameState game = copyRoles(
|
||||||
state.game(), state.game().tyrantId(), active,
|
state.game(), state.game().tyrantId(), Optional.empty(),
|
||||||
state.game().pendingTyrant(), pending
|
state.game().pendingTyrant(), Optional.empty()
|
||||||
);
|
);
|
||||||
return new LifecycleState(game, players);
|
return new LifecycleState(game, players);
|
||||||
}
|
}
|
||||||
|
|
||||||
private LifecycleState confirmPending(
|
private static LifecycleState clearLegacyPending(PersistentState state) {
|
||||||
PersistentState state,
|
|
||||||
Set<UUID> onlinePlayerIds,
|
|
||||||
Instant now
|
|
||||||
) {
|
|
||||||
GameState game = state.game();
|
GameState game = state.game();
|
||||||
Optional<UUID> tyrant = game.tyrantId();
|
GameState cleared = copyRoles(
|
||||||
Optional<UUID> vigilante = game.vigilanteId();
|
game, game.tyrantId(), game.vigilanteId(), Optional.empty(), Optional.empty()
|
||||||
Optional<PendingSelection> pendingTyrant = game.pendingTyrant();
|
|
||||||
Optional<PendingSelection> pendingVigilante = game.pendingVigilante();
|
|
||||||
if (pendingTyrant.isPresent()) {
|
|
||||||
PendingSelection pending = pendingTyrant.orElseThrow();
|
|
||||||
if (!now.isAfter(pending.expiresAt())
|
|
||||||
&& onlinePlayerIds.contains(pending.candidateId())
|
|
||||||
&& eligibleAtConfirmation(state.players().get(pending.candidateId()), now)
|
|
||||||
&& vigilante.filter(pending.candidateId()::equals).isEmpty()) {
|
|
||||||
tyrant = Optional.of(pending.candidateId());
|
|
||||||
pendingTyrant = Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (pendingVigilante.isPresent()) {
|
|
||||||
PendingSelection pending = pendingVigilante.orElseThrow();
|
|
||||||
if (!now.isAfter(pending.expiresAt())
|
|
||||||
&& onlinePlayerIds.contains(pending.candidateId())
|
|
||||||
&& eligibleAtConfirmation(state.players().get(pending.candidateId()), now)
|
|
||||||
&& tyrant.filter(pending.candidateId()::equals).isEmpty()) {
|
|
||||||
vigilante = Optional.of(pending.candidateId());
|
|
||||||
pendingVigilante = Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new LifecycleState(
|
|
||||||
copyRoles(game, tyrant, vigilante, pendingTyrant, pendingVigilante),
|
|
||||||
state.players()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private LifecycleState rerollExpiredTyrant(
|
|
||||||
LifecycleState state,
|
|
||||||
Set<UUID> onlinePlayerIds,
|
|
||||||
Instant now
|
|
||||||
) {
|
|
||||||
Optional<PendingSelection> pending = state.game().pendingTyrant();
|
|
||||||
if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return selectTyrant(
|
|
||||||
state,
|
|
||||||
Optional.of(pending.orElseThrow().candidateId()),
|
|
||||||
onlinePlayerIds,
|
|
||||||
now
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private LifecycleState selectTyrant(
|
|
||||||
LifecycleState state,
|
|
||||||
Optional<UUID> previous,
|
|
||||||
Set<UUID> onlinePlayerIds,
|
|
||||||
Instant now
|
|
||||||
) {
|
|
||||||
Set<UUID> excluded = state.game().vigilanteId().map(Set::of).orElseGet(Set::of);
|
|
||||||
Optional<UUID> selected = candidates.select(
|
|
||||||
state.players(), excluded, previous, now, activityWindow, random
|
|
||||||
);
|
|
||||||
Optional<UUID> active = selected.filter(onlinePlayerIds::contains);
|
|
||||||
Optional<PendingSelection> pending = selected
|
|
||||||
.filter(playerId -> active.isEmpty())
|
|
||||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
|
||||||
GameState game = copyRoles(
|
|
||||||
state.game(), active, state.game().vigilanteId(), pending,
|
|
||||||
state.game().pendingVigilante()
|
|
||||||
);
|
|
||||||
return new LifecycleState(game, state.players());
|
|
||||||
}
|
|
||||||
|
|
||||||
private LifecycleState rerollExpiredVigilante(
|
|
||||||
LifecycleState state,
|
|
||||||
Set<UUID> onlinePlayerIds,
|
|
||||||
Instant now
|
|
||||||
) {
|
|
||||||
Optional<PendingSelection> pending = state.game().pendingVigilante();
|
|
||||||
if (pending.isEmpty() || !now.isAfter(pending.orElseThrow().expiresAt())) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return rerollVigilante(
|
|
||||||
state.asPersistentState(),
|
|
||||||
Optional.of(pending.orElseThrow().candidateId()),
|
|
||||||
onlinePlayerIds,
|
|
||||||
now
|
|
||||||
);
|
);
|
||||||
|
return new LifecycleState(cleared, state.players());
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isInactive(
|
private boolean isInactive(
|
||||||
@@ -194,10 +69,6 @@ public final class RoleMaintenanceService {
|
|||||||
&& !player.lastLogin().orElseThrow().plus(inactivity).isAfter(now);
|
&& !player.lastLogin().orElseThrow().plus(inactivity).isAfter(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean eligibleAtConfirmation(PlayerState player, Instant now) {
|
|
||||||
return player != null && player.optedOutUntil().isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<UUID, PlayerState> clearFollowers(
|
private static Map<UUID, PlayerState> clearFollowers(
|
||||||
Map<UUID, PlayerState> players,
|
Map<UUID, PlayerState> players,
|
||||||
Optional<UUID> previousVigilante
|
Optional<UUID> previousVigilante
|
||||||
@@ -228,7 +99,7 @@ public final class RoleMaintenanceService {
|
|||||||
return new GameState(
|
return new GameState(
|
||||||
game.lifecycle(), tyrant, vigilante, pendingTyrant, pendingVigilante,
|
game.lifecycle(), tyrant, vigilante, pendingTyrant, pendingVigilante,
|
||||||
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
|
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
|
||||||
game.unspentChoices(), game.purchases()
|
game.unspentChoices(), game.purchases(), game.claimedArmor()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,29 @@
|
|||||||
package games.dmg.spigottyrant;
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import org.bukkit.Server;
|
|
||||||
import org.bukkit.entity.Player;
|
|
||||||
|
|
||||||
public final class RoleMaintenanceTask implements Runnable {
|
public final class RoleMaintenanceTask implements Runnable {
|
||||||
private final TyrantStateManager stateManager;
|
private final TyrantStateManager stateManager;
|
||||||
private final RoleMaintenanceService maintenance;
|
private final RoleMaintenanceService maintenance;
|
||||||
private final TyrantPresentation presentation;
|
private final TyrantPresentation presentation;
|
||||||
private final Server server;
|
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
public RoleMaintenanceTask(
|
public RoleMaintenanceTask(
|
||||||
TyrantStateManager stateManager,
|
TyrantStateManager stateManager,
|
||||||
RoleMaintenanceService maintenance,
|
RoleMaintenanceService maintenance,
|
||||||
TyrantPresentation presentation,
|
TyrantPresentation presentation,
|
||||||
Server server,
|
|
||||||
Clock clock
|
Clock clock
|
||||||
) {
|
) {
|
||||||
this.stateManager = stateManager;
|
this.stateManager = stateManager;
|
||||||
this.maintenance = maintenance;
|
this.maintenance = maintenance;
|
||||||
this.presentation = presentation;
|
this.presentation = presentation;
|
||||||
this.server = server;
|
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
PersistentState before = stateManager.snapshot();
|
PersistentState before = stateManager.snapshot();
|
||||||
Set<UUID> online = server.getOnlinePlayers().stream()
|
LifecycleState after = maintenance.maintain(before, clock.instant());
|
||||||
.map(Player::getUniqueId)
|
|
||||||
.collect(Collectors.toUnmodifiableSet());
|
|
||||||
LifecycleState after = maintenance.maintain(before, online, clock.instant());
|
|
||||||
if (!after.asPersistentState().equals(before)) {
|
if (!after.asPersistentState().equals(before)) {
|
||||||
stateManager.replaceState(after);
|
stateManager.replaceState(after);
|
||||||
stateManager.saveIfDirty();
|
stateManager.saveIfDirty();
|
||||||
|
|||||||
@@ -31,15 +31,13 @@ public final class RoleRelinquishmentService {
|
|||||||
}
|
}
|
||||||
if (state.game().tyrantId().filter(playerId::equals).isPresent()) {
|
if (state.game().tyrantId().filter(playerId::equals).isPresent()) {
|
||||||
return new RelinquishmentResult(
|
return new RelinquishmentResult(
|
||||||
succession.succeed(state, Optional.empty(), onlinePlayerIds, now),
|
succession.succeed(state, Optional.empty()),
|
||||||
RelinquishmentStatus.RELINQUISHED
|
RelinquishmentStatus.RELINQUISHED
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (state.game().vigilanteId().filter(playerId::equals).isPresent()) {
|
if (state.game().vigilanteId().filter(playerId::equals).isPresent()) {
|
||||||
return new RelinquishmentResult(
|
return new RelinquishmentResult(
|
||||||
maintenance.rerollVigilante(
|
maintenance.rerollVigilante(state, Optional.of(playerId)),
|
||||||
state, Optional.of(playerId), onlinePlayerIds, now
|
|
||||||
),
|
|
||||||
RelinquishmentStatus.RELINQUISHED
|
RelinquishmentStatus.RELINQUISHED
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
private FixerEffectController fixerEffects;
|
private FixerEffectController fixerEffects;
|
||||||
private TyrantEffectController tyrantEffects;
|
private TyrantEffectController tyrantEffects;
|
||||||
private OptOutGlowController optOutGlow;
|
private OptOutGlowController optOutGlow;
|
||||||
|
private RoleArenaController roleArena;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -33,22 +34,18 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ArenaLocationStore arenaLocations = new BukkitConfigArenaLocationStore(this);
|
||||||
|
try {
|
||||||
|
arenaLocations.location();
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
getLogger().log(Level.SEVERE, "Invalid shared role arena location", exception);
|
||||||
|
getServer().getPluginManager().disablePlugin(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Clock clock = Clock.systemUTC();
|
Clock clock = Clock.systemUTC();
|
||||||
RoleCandidateSelector candidateSelector = new RoleCandidateSelector();
|
TyrantSuccessionService succession = new TyrantSuccessionService();
|
||||||
java.util.random.RandomGenerator random = java.util.random.RandomGenerator.getDefault();
|
|
||||||
TyrantSuccessionService succession = new TyrantSuccessionService(
|
|
||||||
candidateSelector,
|
|
||||||
settings.candidateActivityWindow(),
|
|
||||||
settings.pendingSelectionTimeout(),
|
|
||||||
random
|
|
||||||
);
|
|
||||||
RoleMaintenanceService maintenance = new RoleMaintenanceService(
|
RoleMaintenanceService maintenance = new RoleMaintenanceService(
|
||||||
candidateSelector,
|
succession, settings.roleInactivity()
|
||||||
succession,
|
|
||||||
settings.roleInactivity(),
|
|
||||||
settings.candidateActivityWindow(),
|
|
||||||
settings.pendingSelectionTimeout(),
|
|
||||||
random
|
|
||||||
);
|
);
|
||||||
BukkitOnlinePlayerDirectory onlinePlayers = new BukkitOnlinePlayerDirectory();
|
BukkitOnlinePlayerDirectory onlinePlayers = new BukkitOnlinePlayerDirectory();
|
||||||
FollowerService followers = new FollowerService();
|
FollowerService followers = new FollowerService();
|
||||||
@@ -131,15 +128,24 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
onlinePlayers,
|
onlinePlayers,
|
||||||
managedEffects,
|
managedEffects,
|
||||||
clock,
|
clock,
|
||||||
candidateSelector,
|
arenaLocations
|
||||||
settings.candidateActivityWindow(),
|
|
||||||
settings.pendingSelectionTimeout(),
|
|
||||||
random
|
|
||||||
));
|
));
|
||||||
|
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
|
||||||
|
.setTabCompleter(new TyrantTabCompleter(stateManager, onlinePlayers));
|
||||||
|
Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata")
|
||||||
|
.setTabCompleter(new VigilanteTabCompleter(
|
||||||
|
stateManager, followers, onlinePlayers
|
||||||
|
));
|
||||||
|
Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata")
|
||||||
|
.setTabCompleter(new TyrantAdminTabCompleter(onlinePlayers));
|
||||||
getServer().getPluginManager().registerEvents(
|
getServer().getPluginManager().registerEvents(
|
||||||
new PlayerJoinListener(stateManager, clock, tyrantPresentation),
|
new PlayerJoinListener(stateManager, clock, tyrantPresentation),
|
||||||
this
|
this
|
||||||
);
|
);
|
||||||
|
roleArena = new RoleArenaController(
|
||||||
|
stateManager, getServer(), arenaLocations, settings
|
||||||
|
);
|
||||||
|
getServer().getPluginManager().registerEvents(roleArena, this);
|
||||||
getServer().getPluginManager().registerEvents(controlPanelRenderer, this);
|
getServer().getPluginManager().registerEvents(controlPanelRenderer, this);
|
||||||
getServer().getPluginManager().registerEvents(vigilanteControlPanelRenderer, this);
|
getServer().getPluginManager().registerEvents(vigilanteControlPanelRenderer, this);
|
||||||
getServer().getPluginManager().registerEvents(
|
getServer().getPluginManager().registerEvents(
|
||||||
@@ -162,14 +168,14 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
);
|
);
|
||||||
getServer().getPluginManager().registerEvents(
|
getServer().getPluginManager().registerEvents(
|
||||||
new TyrantDeathListener(
|
new TyrantDeathListener(
|
||||||
stateManager, succession, tyrantPresentation, getServer(), clock
|
stateManager, succession, tyrantPresentation, getServer()
|
||||||
),
|
),
|
||||||
this
|
this
|
||||||
);
|
);
|
||||||
getServer().getPluginManager().registerEvents(
|
getServer().getPluginManager().registerEvents(
|
||||||
new VigilanteDeathListener(
|
new VigilanteDeathListener(
|
||||||
stateManager, new TyrantProgressionService(), maintenance,
|
stateManager, new TyrantProgressionService(), maintenance,
|
||||||
getServer(), clock
|
getServer()
|
||||||
),
|
),
|
||||||
this
|
this
|
||||||
);
|
);
|
||||||
@@ -220,12 +226,12 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
this
|
this
|
||||||
);
|
);
|
||||||
long maintenanceTicks = Math.max(
|
long maintenanceTicks = Math.max(
|
||||||
1L, Math.multiplyExact(settings.selectionRetryInterval().toSeconds(), 20L)
|
1L, Math.multiplyExact(settings.roleMaintenanceInterval().toSeconds(), 20L)
|
||||||
);
|
);
|
||||||
getServer().getScheduler().runTaskTimer(
|
getServer().getScheduler().runTaskTimer(
|
||||||
this,
|
this,
|
||||||
new RoleMaintenanceTask(
|
new RoleMaintenanceTask(
|
||||||
stateManager, maintenance, tyrantPresentation, getServer(), clock
|
stateManager, maintenance, tyrantPresentation, clock
|
||||||
),
|
),
|
||||||
maintenanceTicks,
|
maintenanceTicks,
|
||||||
maintenanceTicks
|
maintenanceTicks
|
||||||
@@ -235,6 +241,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
|
getServer().getScheduler().runTaskTimer(this, fixerEffects, 10L, 10L);
|
||||||
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
|
getServer().getScheduler().runTaskTimer(this, tyrantEffects, 10L, 10L);
|
||||||
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
|
getServer().getScheduler().runTaskTimer(this, optOutGlow, 10L, 10L);
|
||||||
|
getServer().getScheduler().runTaskTimer(this, roleArena, 1L, 10L);
|
||||||
getServer().getScheduler().runTaskTimer(
|
getServer().getScheduler().runTaskTimer(
|
||||||
this,
|
this,
|
||||||
new AbilityItemRefreshTask(
|
new AbilityItemRefreshTask(
|
||||||
@@ -264,6 +271,9 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
|
if (roleArena != null) {
|
||||||
|
roleArena.close();
|
||||||
|
}
|
||||||
if (optOutGlow != null) {
|
if (optOutGlow != null) {
|
||||||
optOutGlow.clearAll();
|
optOutGlow.clearAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public final class TabSuggestions {
|
||||||
|
private TabSuggestions() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> matching(String prefix, Collection<String> candidates) {
|
||||||
|
String normalized = prefix == null ? "" : prefix.toLowerCase(Locale.ROOT);
|
||||||
|
return candidates.stream()
|
||||||
|
.filter(candidate -> candidate.toLowerCase(Locale.ROOT).startsWith(normalized))
|
||||||
|
.distinct()
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
package games.dmg.spigottyrant;
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
import org.bukkit.ChatColor;
|
import org.bukkit.ChatColor;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandExecutor;
|
import org.bukkit.command.CommandExecutor;
|
||||||
@@ -20,10 +16,8 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
private final OnlinePlayerDirectory onlinePlayers;
|
private final OnlinePlayerDirectory onlinePlayers;
|
||||||
private final ManagedRoleEffects effects;
|
private final ManagedRoleEffects effects;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
private final RoleCandidateSelector candidateSelector;
|
private final ArenaLocationStore arenaLocations;
|
||||||
private final Duration activityWindow;
|
private final TyrantProgressionService progression = new TyrantProgressionService();
|
||||||
private final Duration pendingTimeout;
|
|
||||||
private final RandomGenerator random;
|
|
||||||
|
|
||||||
public TyrantAdminCommand(
|
public TyrantAdminCommand(
|
||||||
TyrantStateManager stateManager,
|
TyrantStateManager stateManager,
|
||||||
@@ -34,8 +28,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
) {
|
) {
|
||||||
this(
|
this(
|
||||||
stateManager, lifecycle, onlinePlayers, effects, clock,
|
stateManager, lifecycle, onlinePlayers, effects, clock,
|
||||||
new RoleCandidateSelector(), Duration.ofHours(24), Duration.ofHours(24),
|
ArenaLocationStore.unavailable()
|
||||||
RandomGenerator.getDefault()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,20 +38,14 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
OnlinePlayerDirectory onlinePlayers,
|
OnlinePlayerDirectory onlinePlayers,
|
||||||
ManagedRoleEffects effects,
|
ManagedRoleEffects effects,
|
||||||
Clock clock,
|
Clock clock,
|
||||||
RoleCandidateSelector candidateSelector,
|
ArenaLocationStore arenaLocations
|
||||||
Duration activityWindow,
|
|
||||||
Duration pendingTimeout,
|
|
||||||
RandomGenerator random
|
|
||||||
) {
|
) {
|
||||||
this.stateManager = stateManager;
|
this.stateManager = stateManager;
|
||||||
this.lifecycle = lifecycle;
|
this.lifecycle = lifecycle;
|
||||||
this.onlinePlayers = onlinePlayers;
|
this.onlinePlayers = onlinePlayers;
|
||||||
this.effects = effects;
|
this.effects = effects;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
this.candidateSelector = candidateSelector;
|
this.arenaLocations = arenaLocations;
|
||||||
this.activityWindow = activityWindow;
|
|
||||||
this.pendingTimeout = pendingTimeout;
|
|
||||||
this.random = random;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -82,6 +69,8 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
case "pause" -> pause(sender);
|
case "pause" -> pause(sender);
|
||||||
case "resume" -> resume(sender);
|
case "resume" -> resume(sender);
|
||||||
case "reset" -> reset(sender, arguments);
|
case "reset" -> reset(sender, arguments);
|
||||||
|
case "arena" -> arena(sender, arguments);
|
||||||
|
case "grantpoint" -> grantPoint(sender, arguments);
|
||||||
default -> usage(sender);
|
default -> usage(sender);
|
||||||
}
|
}
|
||||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||||
@@ -112,38 +101,17 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
stateManager.player(tyrant.getUniqueId(), tyrant.getName());
|
stateManager.player(tyrant.getUniqueId(), tyrant.getName());
|
||||||
vigilante.ifPresent(player -> stateManager.player(player.getUniqueId(), player.getName()));
|
vigilante.ifPresent(player -> stateManager.player(player.getUniqueId(), player.getName()));
|
||||||
InstantHolder now = new InstantHolder(clock.instant());
|
InstantHolder now = new InstantHolder(clock.instant());
|
||||||
Optional<UUID> randomCandidate = vigilante.isEmpty()
|
|
||||||
? candidateSelector.select(
|
|
||||||
stateManager.players(), Set.of(tyrant.getUniqueId()), Optional.empty(),
|
|
||||||
now.value(), activityWindow, random
|
|
||||||
)
|
|
||||||
: Optional.empty();
|
|
||||||
Optional<UUID> activeVigilante = vigilante.map(Player::getUniqueId);
|
|
||||||
if (activeVigilante.isEmpty() && randomCandidate.isPresent()
|
|
||||||
&& onlinePlayers.findById(randomCandidate.orElseThrow()) != null) {
|
|
||||||
activeVigilante = randomCandidate;
|
|
||||||
}
|
|
||||||
LifecycleState started = lifecycle.start(
|
LifecycleState started = lifecycle.start(
|
||||||
stateManager.snapshot(), tyrant.getUniqueId(), activeVigilante, now.value()
|
stateManager.snapshot(), tyrant.getUniqueId(),
|
||||||
|
vigilante.map(Player::getUniqueId), now.value()
|
||||||
);
|
);
|
||||||
if (activeVigilante.isEmpty() && randomCandidate.isPresent()) {
|
|
||||||
GameState game = started.game();
|
|
||||||
started = new LifecycleState(new GameState(
|
|
||||||
game.lifecycle(), game.tyrantId(), game.vigilanteId(), game.pendingTyrant(),
|
|
||||||
Optional.of(new PendingSelection(
|
|
||||||
randomCandidate.orElseThrow(), now.value().plus(pendingTimeout)
|
|
||||||
)),
|
|
||||||
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
|
|
||||||
game.unspentChoices(), game.purchases()
|
|
||||||
), started.players());
|
|
||||||
}
|
|
||||||
stateManager.replaceState(started);
|
stateManager.replaceState(started);
|
||||||
stateManager.saveIfDirty();
|
stateManager.saveIfDirty();
|
||||||
effects.restoreAll();
|
effects.restoreAll();
|
||||||
sender.sendMessage(ChatColor.GREEN + "Tyrant game started.");
|
sender.sendMessage(ChatColor.GREEN + "Tyrant game started.");
|
||||||
if (vigilante.isEmpty()) {
|
if (vigilante.isEmpty()) {
|
||||||
sender.sendMessage(ChatColor.YELLOW
|
sender.sendMessage(ChatColor.YELLOW
|
||||||
+ "An eligible recently active Vigilante will be selected automatically.");
|
+ "The Vigilante arena challenge is open.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +143,44 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
sender.sendMessage(ChatColor.GREEN + "Tyrant game reset.");
|
sender.sendMessage(ChatColor.GREEN + "Tyrant game reset.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void grantPoint(CommandSender sender, String[] arguments) {
|
||||||
|
if (arguments.length != 1) {
|
||||||
|
sender.sendMessage(ChatColor.YELLOW + "Usage: /tyrantadmin grantpoint");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PersistentState snapshot = stateManager.snapshot();
|
||||||
|
GameState updated = progression.grantPoint(snapshot.game());
|
||||||
|
stateManager.replaceState(new LifecycleState(updated, snapshot.players()));
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
sender.sendMessage(ChatColor.GREEN + "Granted the Tyrant one point; now level "
|
||||||
|
+ updated.tyrantLevel() + " and " + updated.unspentChoices()
|
||||||
|
+ " unspent choices.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void arena(CommandSender sender, String[] arguments) {
|
||||||
|
if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) {
|
||||||
|
sender.sendMessage("Shared role 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 + "Shared role arena set to " + arena.display() + ".");
|
||||||
|
}
|
||||||
|
|
||||||
private void showStatus(CommandSender sender) {
|
private void showStatus(CommandSender sender) {
|
||||||
GameState game = stateManager.game();
|
GameState game = stateManager.game();
|
||||||
sender.sendMessage(ChatColor.GOLD + "Lifecycle: " + game.lifecycle());
|
sender.sendMessage(ChatColor.GOLD + "Lifecycle: " + game.lifecycle());
|
||||||
@@ -183,9 +189,12 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
+ game.vigilanteId().map(Object::toString).orElse("none"));
|
+ game.vigilanteId().map(Object::toString).orElse("none"));
|
||||||
sender.sendMessage("Level: " + game.tyrantLevel()
|
sender.sendMessage("Level: " + game.tyrantLevel()
|
||||||
+ ", choices: " + game.unspentChoices()
|
+ ", choices: " + game.unspentChoices()
|
||||||
+ ", purchases: " + game.purchases());
|
+ ", purchases: " + game.purchases()
|
||||||
|
+ ", claimed legacy rewards: " + game.claimedArmor());
|
||||||
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
|
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
|
||||||
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
|
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
|
||||||
|
sender.sendMessage("Shared role arena: " + arenaLocations.location()
|
||||||
|
.map(ArenaLocation::display).orElse("not configured"));
|
||||||
for (PlayerState player : stateManager.players().values()) {
|
for (PlayerState player : stateManager.players().values()) {
|
||||||
boolean optedOut = player.optedOutUntil().isPresent();
|
boolean optedOut = player.optedOutUntil().isPresent();
|
||||||
sender.sendMessage(
|
sender.sendMessage(
|
||||||
@@ -202,6 +211,6 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
|||||||
|
|
||||||
private static void usage(CommandSender sender) {
|
private static void usage(CommandSender sender) {
|
||||||
sender.sendMessage(ChatColor.YELLOW
|
sender.sendMessage(ChatColor.YELLOW
|
||||||
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm>");
|
+ "Usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena set|grantpoint>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public final class TyrantAdminTabCompleter implements TabCompleter {
|
||||||
|
private static final String PERMISSION = "spigottyrant.admin";
|
||||||
|
private static final List<String> SUBCOMMANDS = List.of(
|
||||||
|
"status", "start", "pause", "resume", "reset", "arena", "grantpoint"
|
||||||
|
);
|
||||||
|
private final OnlinePlayerDirectory onlinePlayers;
|
||||||
|
|
||||||
|
public TyrantAdminTabCompleter(OnlinePlayerDirectory onlinePlayers) {
|
||||||
|
this.onlinePlayers = onlinePlayers;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender,
|
||||||
|
Command command,
|
||||||
|
String alias,
|
||||||
|
String[] arguments
|
||||||
|
) {
|
||||||
|
if (!sender.hasPermission(PERMISSION) || arguments.length == 0) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (arguments.length == 1) {
|
||||||
|
return TabSuggestions.matching(arguments[0], SUBCOMMANDS);
|
||||||
|
}
|
||||||
|
String subcommand = arguments[0].toLowerCase(java.util.Locale.ROOT);
|
||||||
|
if (arguments.length == 2) {
|
||||||
|
return switch (subcommand) {
|
||||||
|
case "start" -> TabSuggestions.matching(arguments[1], onlineNames(null));
|
||||||
|
case "reset" -> TabSuggestions.matching(arguments[1], List.of("confirm"));
|
||||||
|
case "arena" -> TabSuggestions.matching(
|
||||||
|
arguments[1], List.of("set", "status")
|
||||||
|
);
|
||||||
|
default -> List.of();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (arguments.length == 3 && subcommand.equals("start")) {
|
||||||
|
return TabSuggestions.matching(arguments[2], onlineNames(arguments[1]));
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> onlineNames(String excludedName) {
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
for (UUID playerId : onlinePlayers.onlinePlayerIds()) {
|
||||||
|
Player player = onlinePlayers.findById(playerId);
|
||||||
|
if (player != null && (excludedName == null
|
||||||
|
|| !player.getName().equalsIgnoreCase(excludedName))) {
|
||||||
|
names.add(player.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(names);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class TyrantArenaSuccessionService {
|
||||||
|
public LifecycleState claim(PersistentState state, UUID winnerId) {
|
||||||
|
PlayerState winner = state.players().get(winnerId);
|
||||||
|
if (!RoleArenaPolicy.isEligible(state.game(), ArenaRole.TYRANT, winner)) {
|
||||||
|
throw new IllegalStateException("player is not eligible to claim the Tyrant role");
|
||||||
|
}
|
||||||
|
GameState game = state.game();
|
||||||
|
GameState assigned = new GameState(
|
||||||
|
game.lifecycle(), Optional.of(winnerId), Optional.empty(), Optional.empty(),
|
||||||
|
Optional.empty(), game.pausedAt(), game.accumulatedPausedTime(),
|
||||||
|
0, 1, Set.of()
|
||||||
|
);
|
||||||
|
return new LifecycleState(assigned, state.players());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface TyrantArmorItemFactory {
|
||||||
|
ItemStack create(TyrantArmorPiece piece, String tyrantName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
/** Legacy rewards; the historical type and stored armor key also cover tools and weapons. */
|
||||||
|
public enum TyrantArmorPiece {
|
||||||
|
HELMET,
|
||||||
|
CHESTPLATE,
|
||||||
|
LEGGINGS,
|
||||||
|
BOOTS,
|
||||||
|
AXE,
|
||||||
|
PICKAXE,
|
||||||
|
SWORD,
|
||||||
|
HOE,
|
||||||
|
SHOVEL,
|
||||||
|
ELYTRA;
|
||||||
|
|
||||||
|
/** Explicitly excludes the final reward and any future reward additions. */
|
||||||
|
public static java.util.Set<TyrantArmorPiece> regularRewards() {
|
||||||
|
return java.util.Set.of(HELMET, CHESTPLATE, LEGGINGS, BOOTS,
|
||||||
|
AXE, PICKAXE, SWORD, HOE, SHOVEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isArmor() {
|
||||||
|
return switch (this) {
|
||||||
|
case HELMET, CHESTPLATE, LEGGINGS, BOOTS -> true;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TyrantArmorPiece fromGearCommand(String value) {
|
||||||
|
return switch (value.toLowerCase(java.util.Locale.ROOT)) {
|
||||||
|
case "axe" -> AXE;
|
||||||
|
case "pickaxe" -> PICKAXE;
|
||||||
|
case "sword" -> SWORD;
|
||||||
|
case "hoe" -> HOE;
|
||||||
|
case "shovel" -> SHOVEL;
|
||||||
|
case "elytra" -> ELYTRA;
|
||||||
|
default -> throw new IllegalArgumentException("Unknown gear piece");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TyrantArmorPiece fromCommand(String value) {
|
||||||
|
return switch (value.toLowerCase(java.util.Locale.ROOT)) {
|
||||||
|
case "helmet" -> HELMET;
|
||||||
|
case "chestplate", "chest" -> CHESTPLATE;
|
||||||
|
case "leggings", "legs" -> LEGGINGS;
|
||||||
|
case "boots", "feet" -> BOOTS;
|
||||||
|
default -> throw new IllegalArgumentException("Unknown armor piece");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public String commandName() {
|
||||||
|
return name().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public final class TyrantArmorService {
|
||||||
|
public ArmorClaimResult claim(GameState state, UUID claimantId, TyrantArmorPiece piece) {
|
||||||
|
if (state.lifecycle() != GameLifecycle.RUNNING) {
|
||||||
|
return result(state, ArmorClaimStatus.GAME_NOT_RUNNING);
|
||||||
|
}
|
||||||
|
if (state.tyrantId().filter(claimantId::equals).isEmpty()) {
|
||||||
|
return result(state, ArmorClaimStatus.NOT_TYRANT);
|
||||||
|
}
|
||||||
|
if (!state.purchases().containsAll(EnumSet.allOf(TyrantUnlock.class))) {
|
||||||
|
return result(state, ArmorClaimStatus.UNLOCKS_INCOMPLETE);
|
||||||
|
}
|
||||||
|
if (state.unspentChoices() == 0) {
|
||||||
|
return result(state, ArmorClaimStatus.NO_CHOICES);
|
||||||
|
}
|
||||||
|
if (state.claimedArmor().contains(piece)) {
|
||||||
|
return result(state, ArmorClaimStatus.ALREADY_CLAIMED);
|
||||||
|
}
|
||||||
|
if (piece == TyrantArmorPiece.ELYTRA
|
||||||
|
&& !state.claimedArmor().containsAll(TyrantArmorPiece.regularRewards())) {
|
||||||
|
return result(state, ArmorClaimStatus.COLLECTION_INCOMPLETE);
|
||||||
|
}
|
||||||
|
Set<TyrantArmorPiece> claimed = EnumSet.noneOf(TyrantArmorPiece.class);
|
||||||
|
claimed.addAll(state.claimedArmor());
|
||||||
|
claimed.add(piece);
|
||||||
|
GameState updated = new GameState(
|
||||||
|
state.lifecycle(), state.tyrantId(), state.vigilanteId(), state.pendingTyrant(),
|
||||||
|
state.pendingVigilante(), state.pausedAt(), state.accumulatedPausedTime(),
|
||||||
|
state.tyrantLevel(), state.unspentChoices() - 1, state.purchases(), claimed
|
||||||
|
);
|
||||||
|
return result(updated, ArmorClaimStatus.CLAIMED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ArmorClaimResult result(GameState state, ArmorClaimStatus status) {
|
||||||
|
return new ArmorClaimResult(state, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
|
||||||
|
public record TyrantArmorSpec(
|
||||||
|
Material material,
|
||||||
|
String displayName,
|
||||||
|
List<String> lore,
|
||||||
|
int protectionLevel,
|
||||||
|
int mendingLevel,
|
||||||
|
int sharpnessLevel,
|
||||||
|
int efficiencyLevel,
|
||||||
|
boolean unbreakable
|
||||||
|
) {
|
||||||
|
public TyrantArmorSpec {
|
||||||
|
lore = List.copyOf(lore);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TyrantArmorSpec forPiece(TyrantArmorPiece piece, String tyrantName) {
|
||||||
|
Material material = switch (piece) {
|
||||||
|
case HELMET -> Material.NETHERITE_HELMET;
|
||||||
|
case CHESTPLATE -> Material.NETHERITE_CHESTPLATE;
|
||||||
|
case LEGGINGS -> Material.NETHERITE_LEGGINGS;
|
||||||
|
case BOOTS -> Material.NETHERITE_BOOTS;
|
||||||
|
case AXE -> Material.NETHERITE_AXE;
|
||||||
|
case PICKAXE -> Material.NETHERITE_PICKAXE;
|
||||||
|
case SWORD -> Material.NETHERITE_SWORD;
|
||||||
|
case HOE -> Material.NETHERITE_HOE;
|
||||||
|
case SHOVEL -> Material.NETHERITE_SHOVEL;
|
||||||
|
case ELYTRA -> Material.ELYTRA;
|
||||||
|
};
|
||||||
|
String pieceName = readable(piece);
|
||||||
|
boolean elytra = piece == TyrantArmorPiece.ELYTRA;
|
||||||
|
java.util.ArrayList<String> lore = new java.util.ArrayList<>(List.of(
|
||||||
|
"Forged for Tyrant " + tyrantName + ".",
|
||||||
|
"A lasting reward from " + tyrantName + "'s reign."
|
||||||
|
));
|
||||||
|
if (elytra) {
|
||||||
|
lore.add("Earned by completing the Tyrant's legacy collection.");
|
||||||
|
}
|
||||||
|
return new TyrantArmorSpec(
|
||||||
|
material,
|
||||||
|
"Tyrant's " + tyrantName + " " + pieceName,
|
||||||
|
lore,
|
||||||
|
piece.isArmor() ? 5 : 0,
|
||||||
|
elytra ? 0 : 1,
|
||||||
|
piece == TyrantArmorPiece.AXE || piece == TyrantArmorPiece.SWORD ? 6 : 0,
|
||||||
|
!piece.isArmor() && piece != TyrantArmorPiece.SWORD && !elytra ? 6 : 0,
|
||||||
|
elytra
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> confirmationLore() {
|
||||||
|
java.util.ArrayList<String> lines = new java.util.ArrayList<>();
|
||||||
|
if (protectionLevel > 0) {
|
||||||
|
lines.add("Protection V");
|
||||||
|
}
|
||||||
|
if (sharpnessLevel > 0) {
|
||||||
|
lines.add("Sharpness VI");
|
||||||
|
}
|
||||||
|
if (efficiencyLevel > 0) {
|
||||||
|
lines.add("Efficiency VI");
|
||||||
|
}
|
||||||
|
if (mendingLevel > 0) {
|
||||||
|
lines.add("Mending I");
|
||||||
|
}
|
||||||
|
if (unbreakable) {
|
||||||
|
lines.add("Unbreakable");
|
||||||
|
}
|
||||||
|
lines.add("Cost: 1 choice");
|
||||||
|
return List.copyOf(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readable(TyrantArmorPiece piece) {
|
||||||
|
String lower = piece.name().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
return Character.toUpperCase(lower.charAt(0)) + lower.substring(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ public final class TyrantCommand implements CommandExecutor {
|
|||||||
private final TyrantAbilityService tyrantAbilities;
|
private final TyrantAbilityService tyrantAbilities;
|
||||||
private final TyrantControlPanel controlPanel;
|
private final TyrantControlPanel controlPanel;
|
||||||
private final RoleControlItemService roleControlItems;
|
private final RoleControlItemService roleControlItems;
|
||||||
|
private final TyrantArmorService armor = new TyrantArmorService();
|
||||||
|
private final TyrantArmorItemFactory armorItems;
|
||||||
|
|
||||||
public TyrantCommand(
|
public TyrantCommand(
|
||||||
TyrantStateManager stateManager,
|
TyrantStateManager stateManager,
|
||||||
@@ -63,6 +65,20 @@ public final class TyrantCommand implements CommandExecutor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TyrantCommand(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
TyrantProgressionService progression,
|
||||||
|
TyrantArmorItemFactory armorItems
|
||||||
|
) {
|
||||||
|
this(
|
||||||
|
stateManager, progression, new ClassAssignmentService(),
|
||||||
|
new BukkitOnlinePlayerDirectory(), Clock.systemUTC(),
|
||||||
|
new ParticipationService(Duration.ofDays(7)), null, null, null,
|
||||||
|
new TyrantAbilityService(Duration.ofHours(24)), TyrantCommand::unavailablePanel,
|
||||||
|
null, armorItems
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
TyrantCommand(
|
TyrantCommand(
|
||||||
TyrantStateManager stateManager,
|
TyrantStateManager stateManager,
|
||||||
TyrantProgressionService progression,
|
TyrantProgressionService progression,
|
||||||
@@ -129,6 +145,28 @@ public final class TyrantCommand implements CommandExecutor {
|
|||||||
TyrantAbilityService tyrantAbilities,
|
TyrantAbilityService tyrantAbilities,
|
||||||
TyrantControlPanel controlPanel,
|
TyrantControlPanel controlPanel,
|
||||||
RoleControlItemService roleControlItems
|
RoleControlItemService roleControlItems
|
||||||
|
) {
|
||||||
|
this(
|
||||||
|
stateManager, progression, assignments, onlinePlayers, clock, participation,
|
||||||
|
relinquishment, presentation, abilityItems, tyrantAbilities, controlPanel,
|
||||||
|
roleControlItems, new BukkitTyrantArmorItemFactory()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TyrantCommand(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
TyrantProgressionService progression,
|
||||||
|
ClassAssignmentService assignments,
|
||||||
|
OnlinePlayerDirectory onlinePlayers,
|
||||||
|
Clock clock,
|
||||||
|
ParticipationService participation,
|
||||||
|
RoleRelinquishmentService relinquishment,
|
||||||
|
TyrantPresentation presentation,
|
||||||
|
AbilityItemService abilityItems,
|
||||||
|
TyrantAbilityService tyrantAbilities,
|
||||||
|
TyrantControlPanel controlPanel,
|
||||||
|
RoleControlItemService roleControlItems,
|
||||||
|
TyrantArmorItemFactory armorItems
|
||||||
) {
|
) {
|
||||||
this.stateManager = stateManager;
|
this.stateManager = stateManager;
|
||||||
this.progression = progression;
|
this.progression = progression;
|
||||||
@@ -142,6 +180,7 @@ public final class TyrantCommand implements CommandExecutor {
|
|||||||
this.tyrantAbilities = tyrantAbilities;
|
this.tyrantAbilities = tyrantAbilities;
|
||||||
this.controlPanel = controlPanel;
|
this.controlPanel = controlPanel;
|
||||||
this.roleControlItems = roleControlItems;
|
this.roleControlItems = roleControlItems;
|
||||||
|
this.armorItems = armorItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -168,6 +207,14 @@ public final class TyrantCommand implements CommandExecutor {
|
|||||||
buy(player, arguments[1]);
|
buy(player, arguments[1]);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("gear")) {
|
||||||
|
claimLegacyReward(player, arguments[1], false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("armor")) {
|
||||||
|
claimLegacyReward(player, arguments[1], true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("assign")) {
|
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("assign")) {
|
||||||
assign(player, arguments[1], arguments[2]);
|
assign(player, arguments[1], arguments[2]);
|
||||||
return true;
|
return true;
|
||||||
@@ -198,7 +245,7 @@ public final class TyrantCommand implements CommandExecutor {
|
|||||||
}
|
}
|
||||||
player.sendMessage(
|
player.sendMessage(
|
||||||
ChatColor.YELLOW
|
ChatColor.YELLOW
|
||||||
+ "Usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|"
|
+ "Usage: /tyrant <menu|status|choices|buy|armor|gear|assign|item|intelligence|optout|optin|"
|
||||||
+ "relinquish confirm>"
|
+ "relinquish confirm>"
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -220,6 +267,13 @@ public final class TyrantCommand implements CommandExecutor {
|
|||||||
}
|
}
|
||||||
player.sendMessage(ChatColor.YELLOW + "[" + status + "] " + unlock.name());
|
player.sendMessage(ChatColor.YELLOW + "[" + status + "] " + unlock.name());
|
||||||
}
|
}
|
||||||
|
if (state.purchases().size() == TyrantUnlock.values().length) {
|
||||||
|
player.sendMessage(ChatColor.GOLD + "Legacy rewards claimed: "
|
||||||
|
+ (state.claimedArmor().isEmpty() ? "none" : state.claimedArmor()));
|
||||||
|
player.sendMessage(ChatColor.YELLOW
|
||||||
|
+ "Use /tyrant armor <helmet|chestplate|leggings|boots> or "
|
||||||
|
+ "/tyrant gear <axe|pickaxe|sword|hoe|shovel|elytra>.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void buy(Player player, String requestedUnlock) {
|
private void buy(Player player, String requestedUnlock) {
|
||||||
@@ -246,6 +300,40 @@ public final class TyrantCommand implements CommandExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void claimLegacyReward(Player player, String requestedPiece, boolean armorCommand) {
|
||||||
|
TyrantArmorPiece piece;
|
||||||
|
try {
|
||||||
|
piece = armorCommand ? TyrantArmorPiece.fromCommand(requestedPiece)
|
||||||
|
: TyrantArmorPiece.fromGearCommand(requestedPiece);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
player.sendMessage(ChatColor.RED + (armorCommand
|
||||||
|
? "Unknown armor piece. Use helmet, chestplate, leggings, or boots."
|
||||||
|
: "Unknown gear piece. Use axe, pickaxe, sword, hoe, shovel, or elytra."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PersistentState snapshot = stateManager.snapshot();
|
||||||
|
ArmorClaimResult result = armor.claim(snapshot.game(), player.getUniqueId(), piece);
|
||||||
|
if (result.status() != ArmorClaimStatus.CLAIMED) {
|
||||||
|
player.sendMessage(ChatColor.RED + "Could not claim " + piece.commandName()
|
||||||
|
+ ": " + (result.status() == ArmorClaimStatus.COLLECTION_INCOMPLETE
|
||||||
|
? "claim all nine regular legacy rewards this reign first"
|
||||||
|
: readable(result.status())));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int inventorySlot = player.getInventory().firstEmpty();
|
||||||
|
if (inventorySlot < 0) {
|
||||||
|
player.sendMessage(ChatColor.RED
|
||||||
|
+ "Your inventory is full; no choice was consumed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
org.bukkit.inventory.ItemStack item = armorItems.create(piece, player.getName());
|
||||||
|
stateManager.replaceState(new LifecycleState(result.state(), snapshot.players()));
|
||||||
|
player.getInventory().setItem(inventorySlot, item);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
player.sendMessage(ChatColor.GREEN + "Claimed " + piece.commandName()
|
||||||
|
+ " for one choice.");
|
||||||
|
}
|
||||||
|
|
||||||
private void assign(Player tyrant, String requestedClass, String targetName) {
|
private void assign(Player tyrant, String requestedClass, String targetName) {
|
||||||
TyrantClass tyrantClass;
|
TyrantClass tyrantClass;
|
||||||
try {
|
try {
|
||||||
@@ -365,7 +453,7 @@ public final class TyrantCommand implements CommandExecutor {
|
|||||||
private void relinquish(Player player, String[] arguments) {
|
private void relinquish(Player player, String[] arguments) {
|
||||||
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) {
|
if (arguments.length != 2 || !arguments[1].equalsIgnoreCase("confirm")) {
|
||||||
player.sendMessage(ChatColor.RED
|
player.sendMessage(ChatColor.RED
|
||||||
+ "Relinquishing rerolls your role. Use /tyrant relinquish confirm.");
|
+ "Relinquishing ends your role. Use /tyrant relinquish confirm.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (relinquishment == null) {
|
if (relinquishment == null) {
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ public record TyrantControlPanelModel(
|
|||||||
int level,
|
int level,
|
||||||
int unspentChoices,
|
int unspentChoices,
|
||||||
Map<TyrantUnlock, UnlockAvailability> unlocks,
|
Map<TyrantUnlock, UnlockAvailability> unlocks,
|
||||||
|
Set<TyrantArmorPiece> claimedArmor,
|
||||||
Map<TyrantClass, String> classHolders,
|
Map<TyrantClass, String> classHolders,
|
||||||
Duration intelligenceCooldown
|
Duration intelligenceCooldown
|
||||||
) {
|
) {
|
||||||
public TyrantControlPanelModel {
|
public TyrantControlPanelModel {
|
||||||
unlocks = Map.copyOf(unlocks);
|
unlocks = Map.copyOf(unlocks);
|
||||||
|
claimedArmor = Set.copyOf(claimedArmor);
|
||||||
classHolders = Map.copyOf(classHolders);
|
classHolders = Map.copyOf(classHolders);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +27,39 @@ public record TyrantControlPanelModel(
|
|||||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean allStandardUnlocksPurchased() {
|
||||||
|
return purchasedUnlocks().size() == TyrantUnlock.values().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean armorAvailable(TyrantArmorPiece piece) {
|
||||||
|
return allStandardUnlocksPurchased() && unspentChoices > 0
|
||||||
|
&& !claimedArmor.contains(piece)
|
||||||
|
&& (piece != TyrantArmorPiece.ELYTRA
|
||||||
|
|| claimedArmor.containsAll(TyrantArmorPiece.regularRewards()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public java.util.List<String> armorLore(TyrantArmorPiece piece) {
|
||||||
|
java.util.List<String> lines = new java.util.ArrayList<>();
|
||||||
|
lines.add("Status: " + (claimedArmor.contains(piece) ? "CLAIMED"
|
||||||
|
: armorAvailable(piece) ? "AVAILABLE" : "LOCKED"));
|
||||||
|
lines.add("Cost: 1 choice");
|
||||||
|
if (piece == TyrantArmorPiece.ELYTRA) {
|
||||||
|
lines.add("Unbreakable");
|
||||||
|
long regularClaims = TyrantArmorPiece.regularRewards().stream()
|
||||||
|
.filter(claimedArmor::contains).count();
|
||||||
|
if (regularClaims < TyrantArmorPiece.regularRewards().size()) {
|
||||||
|
lines.add("Claim all nine regular legacy rewards first (" + regularClaims + "/9).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!allStandardUnlocksPurchased()) {
|
||||||
|
lines.add("Purchase all six standard unlocks first.");
|
||||||
|
}
|
||||||
|
if (unspentChoices == 0) {
|
||||||
|
lines.add("Earn another choice first.");
|
||||||
|
}
|
||||||
|
return java.util.List.copyOf(lines);
|
||||||
|
}
|
||||||
|
|
||||||
public static TyrantControlPanelModel create(
|
public static TyrantControlPanelModel create(
|
||||||
GameState game,
|
GameState game,
|
||||||
PlayerState tyrant,
|
PlayerState tyrant,
|
||||||
@@ -61,7 +96,8 @@ public record TyrantControlPanelModel(
|
|||||||
? Duration.between(now, tyrant.cooldownEnds().get(Ability.ROSTER_INTELLIGENCE))
|
? Duration.between(now, tyrant.cooldownEnds().get(Ability.ROSTER_INTELLIGENCE))
|
||||||
: Duration.ZERO;
|
: Duration.ZERO;
|
||||||
return new TyrantControlPanelModel(
|
return new TyrantControlPanelModel(
|
||||||
game.tyrantLevel(), game.unspentChoices(), unlocks, holders, cooldown
|
game.tyrantLevel(), game.unspentChoices(), unlocks, game.claimedArmor(),
|
||||||
|
holders, cooldown
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
package games.dmg.spigottyrant;
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
import java.time.Clock;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import org.bukkit.ChatColor;
|
import org.bukkit.ChatColor;
|
||||||
import org.bukkit.Server;
|
import org.bukkit.Server;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
@@ -18,20 +15,17 @@ public final class TyrantDeathListener implements Listener {
|
|||||||
private final TyrantSuccessionService succession;
|
private final TyrantSuccessionService succession;
|
||||||
private final TyrantPresentation presentation;
|
private final TyrantPresentation presentation;
|
||||||
private final Server server;
|
private final Server server;
|
||||||
private final Clock clock;
|
|
||||||
|
|
||||||
public TyrantDeathListener(
|
public TyrantDeathListener(
|
||||||
TyrantStateManager stateManager,
|
TyrantStateManager stateManager,
|
||||||
TyrantSuccessionService succession,
|
TyrantSuccessionService succession,
|
||||||
TyrantPresentation presentation,
|
TyrantPresentation presentation,
|
||||||
Server server,
|
Server server
|
||||||
Clock clock
|
|
||||||
) {
|
) {
|
||||||
this.stateManager = stateManager;
|
this.stateManager = stateManager;
|
||||||
this.succession = succession;
|
this.succession = succession;
|
||||||
this.presentation = presentation;
|
this.presentation = presentation;
|
||||||
this.server = server;
|
this.server = server;
|
||||||
this.clock = clock;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler(priority = EventPriority.MONITOR)
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
@@ -44,22 +38,18 @@ public final class TyrantDeathListener implements Listener {
|
|||||||
}
|
}
|
||||||
Optional<UUID> killerId = Optional.ofNullable(event.getEntity().getKiller())
|
Optional<UUID> killerId = Optional.ofNullable(event.getEntity().getKiller())
|
||||||
.map(Player::getUniqueId);
|
.map(Player::getUniqueId);
|
||||||
Set<UUID> online = server.getOnlinePlayers().stream()
|
|
||||||
.map(Player::getUniqueId)
|
|
||||||
.collect(Collectors.toUnmodifiableSet());
|
|
||||||
LifecycleState succeeded = succession.succeed(
|
LifecycleState succeeded = succession.succeed(
|
||||||
stateManager.snapshot(), killerId, online, clock.instant()
|
stateManager.snapshot(), killerId
|
||||||
);
|
);
|
||||||
stateManager.replaceState(succeeded);
|
stateManager.replaceState(succeeded);
|
||||||
stateManager.saveIfDirty();
|
stateManager.saveIfDirty();
|
||||||
presentation.reconcile(succeeded.game().tyrantId());
|
presentation.reconcile(succeeded.game().tyrantId());
|
||||||
String successor = succeeded.game().tyrantId()
|
String message = succeeded.game().tyrantId().map(id -> {
|
||||||
.map(id -> {
|
|
||||||
Player player = server.getPlayer(id);
|
Player player = server.getPlayer(id);
|
||||||
return player == null ? id.toString() : player.getName();
|
String successor = player == null ? id.toString() : player.getName();
|
||||||
})
|
return ChatColor.RED + successor + " is the new Tyrant!";
|
||||||
.orElse("pending selection");
|
}).orElse(ChatColor.RED
|
||||||
String message = ChatColor.RED + successor + " is the new Tyrant!";
|
+ "The Tyrant role is vacant; the red arena challenge is now open.");
|
||||||
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
|
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,25 @@ public final class TyrantProgressionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public GameState grantPoint(GameState state) {
|
||||||
|
if (state.lifecycle() != GameLifecycle.RUNNING) {
|
||||||
|
throw new IllegalStateException("Tyrant game must be running.");
|
||||||
|
}
|
||||||
|
if (state.tyrantId().isEmpty()) {
|
||||||
|
throw new IllegalStateException("There is no current Tyrant.");
|
||||||
|
}
|
||||||
|
if (state.tyrantLevel() == Integer.MAX_VALUE
|
||||||
|
|| state.unspentChoices() == Integer.MAX_VALUE) {
|
||||||
|
throw new IllegalStateException("Tyrant progression cannot be increased further.");
|
||||||
|
}
|
||||||
|
return copyProgress(
|
||||||
|
state,
|
||||||
|
state.tyrantLevel() + 1,
|
||||||
|
state.unspentChoices() + 1,
|
||||||
|
state.purchases()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public PurchaseResult purchase(
|
public PurchaseResult purchase(
|
||||||
GameState state,
|
GameState state,
|
||||||
UUID buyerId,
|
UUID buyerId,
|
||||||
@@ -50,7 +69,7 @@ public final class TyrantProgressionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public GameState resetReignProgression(GameState state) {
|
public GameState resetReignProgression(GameState state) {
|
||||||
return copyProgress(state, 0, 0, java.util.Set.of());
|
return copyProgress(state, 0, 0, java.util.Set.of(), java.util.Set.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GameState copyProgress(
|
private static GameState copyProgress(
|
||||||
@@ -58,6 +77,16 @@ public final class TyrantProgressionService {
|
|||||||
int level,
|
int level,
|
||||||
int choices,
|
int choices,
|
||||||
java.util.Set<TyrantUnlock> purchases
|
java.util.Set<TyrantUnlock> purchases
|
||||||
|
) {
|
||||||
|
return copyProgress(state, level, choices, purchases, state.claimedArmor());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GameState copyProgress(
|
||||||
|
GameState state,
|
||||||
|
int level,
|
||||||
|
int choices,
|
||||||
|
java.util.Set<TyrantUnlock> purchases,
|
||||||
|
java.util.Set<TyrantArmorPiece> claimedArmor
|
||||||
) {
|
) {
|
||||||
return new GameState(
|
return new GameState(
|
||||||
state.lifecycle(),
|
state.lifecycle(),
|
||||||
@@ -69,7 +98,8 @@ public final class TyrantProgressionService {
|
|||||||
state.accumulatedPausedTime(),
|
state.accumulatedPausedTime(),
|
||||||
level,
|
level,
|
||||||
choices,
|
choices,
|
||||||
purchases
|
purchases,
|
||||||
|
claimedArmor
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +1,27 @@
|
|||||||
package games.dmg.spigottyrant;
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
|
|
||||||
public final class TyrantSuccessionService {
|
public final class TyrantSuccessionService {
|
||||||
private final RoleCandidateSelector candidates;
|
|
||||||
private final Duration activityWindow;
|
|
||||||
private final Duration pendingTimeout;
|
|
||||||
private final RandomGenerator random;
|
|
||||||
|
|
||||||
public TyrantSuccessionService(
|
|
||||||
RoleCandidateSelector candidates,
|
|
||||||
Duration activityWindow,
|
|
||||||
Duration pendingTimeout,
|
|
||||||
RandomGenerator random
|
|
||||||
) {
|
|
||||||
this.candidates = candidates;
|
|
||||||
this.activityWindow = activityWindow;
|
|
||||||
this.pendingTimeout = pendingTimeout;
|
|
||||||
this.random = random;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LifecycleState succeed(
|
public LifecycleState succeed(
|
||||||
PersistentState state,
|
PersistentState state,
|
||||||
Optional<UUID> killerId,
|
Optional<UUID> killerId
|
||||||
Set<UUID> onlinePlayerIds,
|
|
||||||
Instant now
|
|
||||||
) {
|
) {
|
||||||
if (state.game().lifecycle() != GameLifecycle.RUNNING
|
if (state.game().lifecycle() != GameLifecycle.RUNNING
|
||||||
|| state.game().tyrantId().isEmpty()) {
|
|| state.game().tyrantId().isEmpty()) {
|
||||||
throw new IllegalStateException("a running Tyrant reign is required");
|
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());
|
Map<UUID, PlayerState> clearedPlayers = clearAssignments(state.players());
|
||||||
Optional<UUID> selectedTyrant = killerId.isPresent()
|
|
||||||
? killerId
|
|
||||||
: candidates.select(
|
|
||||||
clearedPlayers,
|
|
||||||
Set.of(),
|
|
||||||
Optional.of(formerTyrant),
|
|
||||||
now,
|
|
||||||
activityWindow,
|
|
||||||
random
|
|
||||||
);
|
|
||||||
Optional<UUID> activeTyrant = selectedTyrant
|
|
||||||
.filter(playerId -> killerId.isPresent() || onlinePlayerIds.contains(playerId));
|
|
||||||
Optional<PendingSelection> pendingTyrant = selectedTyrant
|
|
||||||
.filter(playerId -> activeTyrant.isEmpty())
|
|
||||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
|
||||||
|
|
||||||
Optional<UUID> selectedVigilante = activeTyrant.flatMap(tyrantId ->
|
|
||||||
candidates.select(
|
|
||||||
clearedPlayers,
|
|
||||||
Set.of(tyrantId),
|
|
||||||
formerVigilante,
|
|
||||||
now,
|
|
||||||
activityWindow,
|
|
||||||
random
|
|
||||||
)
|
|
||||||
);
|
|
||||||
Optional<UUID> activeVigilante = selectedVigilante
|
|
||||||
.filter(onlinePlayerIds::contains);
|
|
||||||
Optional<PendingSelection> pendingVigilante = selectedVigilante
|
|
||||||
.filter(playerId -> activeVigilante.isEmpty())
|
|
||||||
.map(playerId -> new PendingSelection(playerId, now.plus(pendingTimeout)));
|
|
||||||
|
|
||||||
GameState game = new GameState(
|
GameState game = new GameState(
|
||||||
GameLifecycle.RUNNING,
|
GameLifecycle.RUNNING,
|
||||||
activeTyrant,
|
killerId,
|
||||||
activeVigilante,
|
Optional.empty(),
|
||||||
pendingTyrant,
|
Optional.empty(),
|
||||||
pendingVigilante,
|
Optional.empty(),
|
||||||
Optional.empty(),
|
Optional.empty(),
|
||||||
state.game().accumulatedPausedTime(),
|
state.game().accumulatedPausedTime(),
|
||||||
0,
|
0,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public final class TyrantTabCompleter implements TabCompleter {
|
||||||
|
private static final List<String> SUBCOMMANDS = List.of(
|
||||||
|
"menu", "status", "choices", "buy", "armor", "gear", "assign", "item", "intelligence",
|
||||||
|
"optout", "optin", "relinquish"
|
||||||
|
);
|
||||||
|
private static final List<String> UNLOCKS = java.util.Arrays.stream(TyrantUnlock.values())
|
||||||
|
.map(value -> value.name().toLowerCase(java.util.Locale.ROOT))
|
||||||
|
.toList();
|
||||||
|
private static final List<String> CLASSES = List.of("assassin", "fixer", "tamer");
|
||||||
|
private static final List<String> ARMOR = java.util.Arrays.stream(TyrantArmorPiece.values())
|
||||||
|
.filter(TyrantArmorPiece::isArmor)
|
||||||
|
.map(TyrantArmorPiece::commandName)
|
||||||
|
.toList();
|
||||||
|
private static final List<String> GEAR = java.util.Arrays.stream(TyrantArmorPiece.values())
|
||||||
|
.filter(piece -> !piece.isArmor())
|
||||||
|
.map(TyrantArmorPiece::commandName)
|
||||||
|
.toList();
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final OnlinePlayerDirectory onlinePlayers;
|
||||||
|
|
||||||
|
public TyrantTabCompleter(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
OnlinePlayerDirectory onlinePlayers
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.onlinePlayers = onlinePlayers;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender,
|
||||||
|
Command command,
|
||||||
|
String alias,
|
||||||
|
String[] arguments
|
||||||
|
) {
|
||||||
|
if (!(sender instanceof Player player) || arguments.length == 0) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (arguments.length == 1) {
|
||||||
|
return TabSuggestions.matching(arguments[0], SUBCOMMANDS);
|
||||||
|
}
|
||||||
|
String subcommand = arguments[0].toLowerCase(java.util.Locale.ROOT);
|
||||||
|
if (arguments.length == 2) {
|
||||||
|
return switch (subcommand) {
|
||||||
|
case "buy" -> TabSuggestions.matching(arguments[1], UNLOCKS);
|
||||||
|
case "armor" -> TabSuggestions.matching(arguments[1], ARMOR);
|
||||||
|
case "gear" -> TabSuggestions.matching(arguments[1], GEAR);
|
||||||
|
case "assign" -> TabSuggestions.matching(arguments[1], CLASSES);
|
||||||
|
case "relinquish" -> TabSuggestions.matching(
|
||||||
|
arguments[1], List.of("confirm")
|
||||||
|
);
|
||||||
|
default -> List.of();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (arguments.length == 3 && subcommand.equals("assign")) {
|
||||||
|
return TabSuggestions.matching(arguments[2], eligibleAssignees(player));
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> eligibleAssignees(Player tyrant) {
|
||||||
|
Map<UUID, PlayerState> states = stateManager.players();
|
||||||
|
GameState game = stateManager.game();
|
||||||
|
Set<UUID> online = onlinePlayers.onlinePlayerIds();
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
for (UUID playerId : online) {
|
||||||
|
if (playerId.equals(tyrant.getUniqueId())
|
||||||
|
|| game != null && game.tyrantId().filter(playerId::equals).isPresent()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PlayerState state = states.get(playerId);
|
||||||
|
if (state != null && state.optedOutUntil().isPresent()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Player candidate = onlinePlayers.findById(playerId);
|
||||||
|
if (candidate != null) {
|
||||||
|
names.add(candidate.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(names);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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 (!RoleArenaPolicy.isEligible(
|
||||||
|
state.game(), ArenaRole.VIGILANTE, 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(), game.claimedArmor()
|
||||||
|
);
|
||||||
|
return new LifecycleState(assigned, state.players());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package games.dmg.spigottyrant;
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
import java.time.Clock;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.bukkit.ChatColor;
|
import org.bukkit.ChatColor;
|
||||||
import org.bukkit.Server;
|
import org.bukkit.Server;
|
||||||
@@ -17,20 +15,17 @@ public final class VigilanteDeathListener implements Listener {
|
|||||||
private final TyrantProgressionService progression;
|
private final TyrantProgressionService progression;
|
||||||
private final RoleMaintenanceService maintenance;
|
private final RoleMaintenanceService maintenance;
|
||||||
private final Server server;
|
private final Server server;
|
||||||
private final Clock clock;
|
|
||||||
|
|
||||||
public VigilanteDeathListener(
|
public VigilanteDeathListener(
|
||||||
TyrantStateManager stateManager,
|
TyrantStateManager stateManager,
|
||||||
TyrantProgressionService progression,
|
TyrantProgressionService progression,
|
||||||
RoleMaintenanceService maintenance,
|
RoleMaintenanceService maintenance,
|
||||||
Server server,
|
Server server
|
||||||
Clock clock
|
|
||||||
) {
|
) {
|
||||||
this.stateManager = stateManager;
|
this.stateManager = stateManager;
|
||||||
this.progression = progression;
|
this.progression = progression;
|
||||||
this.maintenance = maintenance;
|
this.maintenance = maintenance;
|
||||||
this.server = server;
|
this.server = server;
|
||||||
this.clock = clock;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler(priority = EventPriority.MONITOR)
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
@@ -47,16 +42,13 @@ public final class VigilanteDeathListener implements Listener {
|
|||||||
PersistentState withProgress = new PersistentState(
|
PersistentState withProgress = new PersistentState(
|
||||||
progressed, stateManager.players()
|
progressed, stateManager.players()
|
||||||
);
|
);
|
||||||
Set<UUID> online = server.getOnlinePlayers().stream()
|
|
||||||
.map(Player::getUniqueId)
|
|
||||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
|
||||||
LifecycleState rerolled = maintenance.rerollVigilante(
|
LifecycleState rerolled = maintenance.rerollVigilante(
|
||||||
withProgress, Optional.of(victimId), online, clock.instant()
|
withProgress, Optional.of(victimId)
|
||||||
);
|
);
|
||||||
stateManager.replaceState(rerolled);
|
stateManager.replaceState(rerolled);
|
||||||
stateManager.saveIfDirty();
|
stateManager.saveIfDirty();
|
||||||
String message = ChatColor.YELLOW + "The Vigilante died; a replacement "
|
String message = ChatColor.YELLOW
|
||||||
+ (rerolled.game().vigilanteId().isPresent() ? "has been selected." : "is pending.");
|
+ "The Vigilante died; the arena challenge is now open.";
|
||||||
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
|
server.getOnlinePlayers().forEach(player -> player.sendMessage(message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
public final class VigilanteTabCompleter implements TabCompleter {
|
||||||
|
private static final List<String> SUBCOMMANDS = List.of(
|
||||||
|
"menu", "item", "invite", "accept", "dismiss", "leave"
|
||||||
|
);
|
||||||
|
private final TyrantStateManager stateManager;
|
||||||
|
private final FollowerService followers;
|
||||||
|
private final OnlinePlayerDirectory onlinePlayers;
|
||||||
|
|
||||||
|
public VigilanteTabCompleter(
|
||||||
|
TyrantStateManager stateManager,
|
||||||
|
FollowerService followers,
|
||||||
|
OnlinePlayerDirectory onlinePlayers
|
||||||
|
) {
|
||||||
|
this.stateManager = stateManager;
|
||||||
|
this.followers = followers;
|
||||||
|
this.onlinePlayers = onlinePlayers;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender,
|
||||||
|
Command command,
|
||||||
|
String alias,
|
||||||
|
String[] arguments
|
||||||
|
) {
|
||||||
|
if (!(sender instanceof Player player) || arguments.length == 0) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (arguments.length == 1) {
|
||||||
|
return TabSuggestions.matching(arguments[0], SUBCOMMANDS);
|
||||||
|
}
|
||||||
|
if (arguments.length != 2) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return switch (arguments[0].toLowerCase(java.util.Locale.ROOT)) {
|
||||||
|
case "invite" -> TabSuggestions.matching(
|
||||||
|
arguments[1], eligibleInvitees(player)
|
||||||
|
);
|
||||||
|
case "dismiss" -> TabSuggestions.matching(
|
||||||
|
arguments[1], currentFollowers(player)
|
||||||
|
);
|
||||||
|
default -> List.of();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> eligibleInvitees(Player vigilante) {
|
||||||
|
GameState game = stateManager.game();
|
||||||
|
Map<UUID, PlayerState> states = stateManager.players();
|
||||||
|
Set<UUID> pending = followers.invitedPlayerIds(vigilante.getUniqueId());
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
for (UUID playerId : onlinePlayers.onlinePlayerIds()) {
|
||||||
|
PlayerState state = states.get(playerId);
|
||||||
|
boolean assignedRole = game.tyrantId().filter(playerId::equals).isPresent()
|
||||||
|
|| game.vigilanteId().filter(playerId::equals).isPresent();
|
||||||
|
boolean ineligibleState = state != null
|
||||||
|
&& (state.optedOutUntil().isPresent() || state.followerOf().isPresent());
|
||||||
|
if (assignedRole || ineligibleState || pending.contains(playerId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Player candidate = onlinePlayers.findById(playerId);
|
||||||
|
if (candidate != null) {
|
||||||
|
names.add(candidate.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(names);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> currentFollowers(Player vigilante) {
|
||||||
|
Map<UUID, PlayerState> states = stateManager.players();
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
for (UUID playerId : onlinePlayers.onlinePlayerIds()) {
|
||||||
|
PlayerState state = states.get(playerId);
|
||||||
|
if (state == null
|
||||||
|
|| state.followerOf().filter(vigilante.getUniqueId()::equals).isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Player candidate = onlinePlayers.findById(playerId);
|
||||||
|
if (candidate != null) {
|
||||||
|
names.add(candidate.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(names);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,7 +95,8 @@ public final class YamlTyrantStateRepository {
|
|||||||
Duration.ofSeconds(nonNegativeLong(yaml, "game.accumulated-paused-seconds")),
|
Duration.ofSeconds(nonNegativeLong(yaml, "game.accumulated-paused-seconds")),
|
||||||
nonNegativeInt(yaml, "game.tyrant-level"),
|
nonNegativeInt(yaml, "game.tyrant-level"),
|
||||||
nonNegativeInt(yaml, "game.unspent-choices"),
|
nonNegativeInt(yaml, "game.unspent-choices"),
|
||||||
enumSet(TyrantUnlock.class, yaml.getStringList("game.purchases"))
|
enumSet(TyrantUnlock.class, yaml.getStringList("game.purchases")),
|
||||||
|
enumSet(TyrantArmorPiece.class, yaml.getStringList("game.claimed-armor"))
|
||||||
);
|
);
|
||||||
} catch (IllegalArgumentException exception) {
|
} catch (IllegalArgumentException exception) {
|
||||||
return GameState.empty();
|
return GameState.empty();
|
||||||
@@ -192,6 +193,11 @@ public final class YamlTyrantStateRepository {
|
|||||||
yaml.set("game.tyrant-level", game.tyrantLevel());
|
yaml.set("game.tyrant-level", game.tyrantLevel());
|
||||||
yaml.set("game.unspent-choices", game.unspentChoices());
|
yaml.set("game.unspent-choices", game.unspentChoices());
|
||||||
yaml.set("game.purchases", game.purchases().stream().map(Enum::name).sorted().toList());
|
yaml.set("game.purchases", game.purchases().stream().map(Enum::name).sorted().toList());
|
||||||
|
// Historical key deliberately stores all legacy rewards, including tools and weapons.
|
||||||
|
yaml.set(
|
||||||
|
"game.claimed-armor",
|
||||||
|
game.claimedArmor().stream().map(Enum::name).sorted().toList()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void savePlayer(YamlConfiguration yaml, PlayerState player) {
|
private static void savePlayer(YamlConfiguration yaml, PlayerState player) {
|
||||||
|
|||||||
@@ -2,12 +2,18 @@
|
|||||||
tyrant-range-blocks: 50
|
tyrant-range-blocks: 50
|
||||||
follower-range-blocks: 50
|
follower-range-blocks: 50
|
||||||
|
|
||||||
# Participation, inactivity, and selection
|
# Shared Tyrant and Vigilante arena and boss
|
||||||
|
vigilante-arena-radius-blocks: 10
|
||||||
|
arena-barrier-height-blocks: 12
|
||||||
|
vigilante-boss-health: 80
|
||||||
|
vigilante-boss-damage: 8
|
||||||
|
vigilante-boss-armor: 10
|
||||||
|
# Set with /tyrantadmin arena set
|
||||||
|
|
||||||
|
# Participation and role inactivity
|
||||||
opt-out-seconds: 604800
|
opt-out-seconds: 604800
|
||||||
role-inactivity-seconds: 172800
|
role-inactivity-seconds: 172800
|
||||||
candidate-activity-window-seconds: 86400
|
role-maintenance-seconds: 60
|
||||||
pending-selection-timeout-seconds: 86400
|
|
||||||
selection-retry-seconds: 60
|
|
||||||
freeze-timers-while-paused: true
|
freeze-timers-while-paused: true
|
||||||
|
|
||||||
# Assassin
|
# Assassin
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ author: dmg.games
|
|||||||
commands:
|
commands:
|
||||||
tyrant:
|
tyrant:
|
||||||
description: View and use Spigot Tyrant game features.
|
description: View and use Spigot Tyrant game features.
|
||||||
usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|relinquish confirm>
|
usage: /tyrant <menu|status|choices|buy|armor <helmet|chestplate|leggings|boots>|gear <axe|pickaxe|sword|hoe|shovel>|assign|item|intelligence|optout|optin|relinquish confirm>
|
||||||
vigilante:
|
vigilante:
|
||||||
description: Manage Vigilante Followers.
|
description: Manage Vigilante Followers.
|
||||||
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
|
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
|
||||||
tyrantadmin:
|
tyrantadmin:
|
||||||
description: Administer the Spigot Tyrant game.
|
description: Administer the Spigot Tyrant game.
|
||||||
usage: /tyrantadmin <status|start|pause|resume|reset confirm>
|
usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena <set|status>|grantpoint>
|
||||||
permission: spigottyrant.admin
|
permission: spigottyrant.admin
|
||||||
permissions:
|
permissions:
|
||||||
spigottyrant.admin:
|
spigottyrant.admin:
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.entity.Zombie;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class ArenaBossStateTest {
|
||||||
|
@Test
|
||||||
|
void idleBossIsCenteredHealedUntargetedAndFrozen() {
|
||||||
|
Zombie boss = mock(Zombie.class);
|
||||||
|
Location center = mock(Location.class);
|
||||||
|
|
||||||
|
ArenaBossState.idle(boss, center, 80.0);
|
||||||
|
|
||||||
|
verify(boss).setAI(false);
|
||||||
|
verify(boss).setTarget(null);
|
||||||
|
verify(boss).setHealth(80.0);
|
||||||
|
verify(boss).teleport(center);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void challengerEntryEnablesBossAndSetsTarget() {
|
||||||
|
Zombie boss = mock(Zombie.class);
|
||||||
|
Player challenger = mock(Player.class);
|
||||||
|
|
||||||
|
ArenaBossState.engage(boss, challenger);
|
||||||
|
|
||||||
|
verify(boss).setAI(true);
|
||||||
|
verify(boss).setTarget(challenger);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class ArenaBuildProtectionTest {
|
||||||
|
private static final ArenaLocation CENTER = new ArenaLocation(
|
||||||
|
"world", 0.0, 64.0, 0.0, 0.0F, 0.0F
|
||||||
|
);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void protectsOnlyInsideConfiguredArenaDuringFight() {
|
||||||
|
assertTrue(ArenaBuildProtection.isProtected(
|
||||||
|
true, CENTER, 10.0, "world", 5.0, 70.0, 5.0
|
||||||
|
));
|
||||||
|
assertFalse(ArenaBuildProtection.isProtected(
|
||||||
|
false, CENTER, 10.0, "world", 5.0, 70.0, 5.0
|
||||||
|
));
|
||||||
|
assertFalse(ArenaBuildProtection.isProtected(
|
||||||
|
true, CENTER, 10.0, "world", 11.0, 70.0, 0.0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockConstruction;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.bukkit.ChatColor;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.MockedConstruction;
|
||||||
|
|
||||||
|
final class BukkitTyrantArmorItemFactoryTest {
|
||||||
|
@Test
|
||||||
|
void elytraReceivesGoldNameGrayLoreAndUnbreakabilityWithoutAnyEnchantments() {
|
||||||
|
ItemMeta meta = mock(ItemMeta.class);
|
||||||
|
try (MockedConstruction<ItemStack> items = mockConstruction(ItemStack.class, (item, context) -> {
|
||||||
|
assertEquals(List.of(Material.ELYTRA), context.arguments());
|
||||||
|
when(item.getItemMeta()).thenReturn(meta);
|
||||||
|
})) {
|
||||||
|
ItemStack item = new BukkitTyrantArmorItemFactory().create(
|
||||||
|
TyrantArmorPiece.valueOf("ELYTRA"), "Alex"
|
||||||
|
);
|
||||||
|
assertSame(items.constructed().get(0), item);
|
||||||
|
verify(meta).setDisplayName(ChatColor.GOLD + "Tyrant's Alex Elytra");
|
||||||
|
verify(meta).setLore(List.of(
|
||||||
|
ChatColor.GRAY + "Forged for Tyrant Alex.",
|
||||||
|
ChatColor.GRAY + "A lasting reward from Alex's reign.",
|
||||||
|
ChatColor.GRAY + "Earned by completing the Tyrant's legacy collection."
|
||||||
|
));
|
||||||
|
verify(meta).setUnbreakable(true);
|
||||||
|
verify(meta, never()).addEnchant(any(), anyInt(), anyBoolean());
|
||||||
|
verify(item).setItemMeta(meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ final class DefaultConfigurationTest {
|
|||||||
PluginSettings settings = PluginSettings.from(values);
|
PluginSettings settings = PluginSettings.from(values);
|
||||||
assertEquals(50.0, settings.tyrantRangeBlocks());
|
assertEquals(50.0, settings.tyrantRangeBlocks());
|
||||||
assertEquals(Duration.ofDays(7), settings.optOutDuration());
|
assertEquals(Duration.ofDays(7), settings.optOutDuration());
|
||||||
|
assertEquals(Duration.ofMinutes(1), settings.roleMaintenanceInterval());
|
||||||
|
assertEquals(12, settings.arenaBarrierHeightBlocks());
|
||||||
assertEquals("FISHING_ROD", settings.tamerItem().material());
|
assertEquals("FISHING_ROD", settings.tamerItem().material());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,20 +17,30 @@ final class PluginSettingsTest {
|
|||||||
assertEquals(50.0, settings.followerRangeBlocks());
|
assertEquals(50.0, settings.followerRangeBlocks());
|
||||||
assertEquals(Duration.ofDays(7), settings.optOutDuration());
|
assertEquals(Duration.ofDays(7), settings.optOutDuration());
|
||||||
assertEquals(Duration.ofHours(48), settings.roleInactivity());
|
assertEquals(Duration.ofHours(48), settings.roleInactivity());
|
||||||
assertEquals(Duration.ofHours(24), settings.candidateActivityWindow());
|
assertEquals(Duration.ofMinutes(1), settings.roleMaintenanceInterval());
|
||||||
assertEquals(Duration.ofHours(24), settings.pendingSelectionTimeout());
|
|
||||||
assertEquals(Duration.ofHours(1), settings.assassinCooldown());
|
assertEquals(Duration.ofHours(1), settings.assassinCooldown());
|
||||||
assertEquals(Duration.ofMinutes(10), settings.assassinInvisibilityDuration());
|
assertEquals(Duration.ofMinutes(10), settings.assassinInvisibilityDuration());
|
||||||
assertEquals(Duration.ofHours(1), settings.fixerCooldown());
|
assertEquals(Duration.ofHours(1), settings.fixerCooldown());
|
||||||
assertEquals(Duration.ofMinutes(10), settings.fixerEffectDuration());
|
assertEquals(Duration.ofMinutes(10), settings.fixerEffectDuration());
|
||||||
assertEquals(Duration.ofHours(24), settings.rosterIntelligenceCooldown());
|
assertEquals(Duration.ofHours(24), settings.rosterIntelligenceCooldown());
|
||||||
|
assertEquals(10.0, settings.vigilanteArenaRadiusBlocks());
|
||||||
|
assertEquals(12, settings.arenaBarrierHeightBlocks());
|
||||||
|
assertEquals(80.0, settings.vigilanteBossHealth());
|
||||||
|
assertEquals(8.0, settings.vigilanteBossDamage());
|
||||||
|
assertEquals(10.0, settings.vigilanteBossArmor());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsLegacySelectionRetryAsMaintenanceInterval() {
|
||||||
|
PluginSettings settings = PluginSettings.from(Map.of("selection-retry-seconds", 30));
|
||||||
|
|
||||||
|
assertEquals(Duration.ofSeconds(30), settings.roleMaintenanceInterval());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void approvedDefaultsCoverAbilitiesItemsMobsAndMessages() {
|
void approvedDefaultsCoverAbilitiesItemsMobsAndMessages() {
|
||||||
PluginSettings settings = PluginSettings.from(Map.of());
|
PluginSettings settings = PluginSettings.from(Map.of());
|
||||||
|
|
||||||
assertEquals(Duration.ofMinutes(1), settings.selectionRetryInterval());
|
|
||||||
assertEquals(Duration.ofSeconds(60), settings.assassinDoubleJumpCooldown());
|
assertEquals(Duration.ofSeconds(60), settings.assassinDoubleJumpCooldown());
|
||||||
assertEquals(Duration.ofSeconds(15), settings.assassinSpeedDuration());
|
assertEquals(Duration.ofSeconds(15), settings.assassinSpeedDuration());
|
||||||
assertEquals(Duration.ofSeconds(20), settings.assassinWeaknessDuration());
|
assertEquals(Duration.ofSeconds(20), settings.assassinWeaknessDuration());
|
||||||
@@ -67,5 +77,25 @@ final class PluginSettingsTest {
|
|||||||
IllegalArgumentException.class,
|
IllegalArgumentException.class,
|
||||||
() -> PluginSettings.from(Map.of("role-inactivity-seconds", -1))
|
() -> PluginSettings.from(Map.of("role-inactivity-seconds", -1))
|
||||||
);
|
);
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> PluginSettings.from(Map.of("role-maintenance-seconds", 0))
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> PluginSettings.from(Map.of("vigilante-arena-radius-blocks", 0))
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> PluginSettings.from(Map.of("arena-barrier-height-blocks", 2))
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> PluginSettings.from(Map.of("vigilante-boss-health", Double.NaN))
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> PluginSettings.from(Map.of("vigilante-boss-armor", 31))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
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 java.util.List;
|
||||||
|
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;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
final class RoleArenaBarrierTest {
|
||||||
|
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 RoleArenaBarrier().build(world, CENTER, 10.0, 12);
|
||||||
|
|
||||||
|
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);
|
||||||
|
RoleArenaBarrier barrier = new RoleArenaBarrier();
|
||||||
|
|
||||||
|
barrier.build(world, CENTER, 10.0, 12);
|
||||||
|
barrier.clear();
|
||||||
|
|
||||||
|
verify(air, atLeastOnce()).setType(Material.BARRIER, false);
|
||||||
|
verify(air).setBlockData(snapshot, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wallExtendsOneBelowFloorForTwelveBlocks() {
|
||||||
|
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);
|
||||||
|
RoleArenaBarrier barrier = new RoleArenaBarrier();
|
||||||
|
|
||||||
|
barrier.build(world, CENTER, 10.0, 12);
|
||||||
|
|
||||||
|
ArgumentCaptor<Integer> y = ArgumentCaptor.forClass(Integer.class);
|
||||||
|
verify(world, atLeastOnce()).getBlockAt(anyInt(), y.capture(), anyInt());
|
||||||
|
List<Integer> values = y.getAllValues();
|
||||||
|
assertEquals(63, values.stream().mapToInt(Integer::intValue).min().orElseThrow());
|
||||||
|
assertEquals(74, values.stream().mapToInt(Integer::intValue).max().orElseThrow());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.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.Color;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.World;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class RoleArenaControllerTest {
|
||||||
|
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
private static final UUID PLAYER = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||||
|
private static final UUID OTHER = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void boundaryColorsIdentifyTheOpenRole() {
|
||||||
|
assertEquals(Color.RED, RoleArenaController.boundaryColor(ArenaRole.TYRANT));
|
||||||
|
assertEquals(Color.BLUE, RoleArenaController.boundaryColor(ArenaRole.VIGILANTE));
|
||||||
|
assertNull(RoleArenaController.boundaryColor(null));
|
||||||
|
assertArrayEquals(
|
||||||
|
new int[] {1}, RoleArenaController.boundaryHeightOffsets(false, 12)
|
||||||
|
);
|
||||||
|
assertArrayEquals(
|
||||||
|
new int[] {1, 4, 7, 10}, RoleArenaController.boundaryHeightOffsets(true, 12)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tyrantArenaWinnerIsMovedOutsideAfterAssignment() {
|
||||||
|
assertWinnerMovedOutside(ArenaRole.TYRANT, tyrantVacancy());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void vigilanteArenaWinnerIsMovedOutsideAfterAssignment() {
|
||||||
|
assertWinnerMovedOutside(ArenaRole.VIGILANTE, openGame());
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
));
|
||||||
|
RoleArenaController controller = new RoleArenaController(
|
||||||
|
manager, mock(Server.class), locations, PluginSettings.from(Map.of())
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.onJoin(mockJoin(player));
|
||||||
|
|
||||||
|
verify(player).sendMessage(contains("world at 10, 64, -5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optedInPlayerIsNotifiedOfTyrantVacancy() {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(PLAYER);
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.game()).thenReturn(tyrantVacancy());
|
||||||
|
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)
|
||||||
|
));
|
||||||
|
RoleArenaController controller = new RoleArenaController(
|
||||||
|
manager, mock(Server.class), locations, PluginSettings.from(Map.of())
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.onJoin(mockJoin(player));
|
||||||
|
|
||||||
|
verify(player).sendMessage(contains("Tyrant position is open"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
));
|
||||||
|
RoleArenaController controller = new RoleArenaController(
|
||||||
|
manager, mock(Server.class), locations, PluginSettings.from(Map.of())
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.onJoin(mockJoin(player));
|
||||||
|
|
||||||
|
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertWinnerMovedOutside(ArenaRole role, GameState game) {
|
||||||
|
ArenaLocation arena = new ArenaLocation("world", 10.0, 64.0, -5.0, 0.0F, 0.0F);
|
||||||
|
World world = mock(World.class);
|
||||||
|
when(world.getName()).thenReturn("world");
|
||||||
|
when(world.getMinHeight()).thenReturn(-64);
|
||||||
|
when(world.getMaxHeight()).thenReturn(320);
|
||||||
|
when(world.getHighestBlockYAt(org.mockito.ArgumentMatchers.anyInt(),
|
||||||
|
org.mockito.ArgumentMatchers.anyInt())).thenReturn(63);
|
||||||
|
when(world.getBlockAt(org.mockito.ArgumentMatchers.anyInt(),
|
||||||
|
org.mockito.ArgumentMatchers.anyInt(),
|
||||||
|
org.mockito.ArgumentMatchers.anyInt())).thenReturn(mock(Block.class));
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(PLAYER);
|
||||||
|
when(player.getName()).thenReturn("Player");
|
||||||
|
when(player.getLocation()).thenReturn(new Location(world, arena.x(), arena.y(), arena.z()));
|
||||||
|
Player other = mock(Player.class);
|
||||||
|
when(other.getUniqueId()).thenReturn(OTHER);
|
||||||
|
PlayerState playerState = PlayerState.newPlayer(PLAYER, "Player");
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.snapshot()).thenReturn(new PersistentState(game, Map.of(PLAYER, playerState)));
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
when(server.getPlayer(PLAYER)).thenReturn(player);
|
||||||
|
when(server.getWorld("world")).thenReturn(world);
|
||||||
|
doReturn(Set.of(player, other)).when(server).getOnlinePlayers();
|
||||||
|
ArenaLocationStore locations = mock(ArenaLocationStore.class);
|
||||||
|
when(locations.location()).thenReturn(Optional.of(arena));
|
||||||
|
RoleArenaController controller = new RoleArenaController(
|
||||||
|
manager, server, locations, PluginSettings.from(Map.of())
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.assignRole(PLAYER, role);
|
||||||
|
|
||||||
|
verify(manager).replaceState(org.mockito.ArgumentMatchers.any(LifecycleState.class));
|
||||||
|
verify(player).teleport(argThat((Location destination) ->
|
||||||
|
!ArenaGeometry.contains(arena, 10.0, "world",
|
||||||
|
destination.getX(), destination.getY(), destination.getZ())
|
||||||
|
), eq(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.PLUGIN));
|
||||||
|
if (role == ArenaRole.VIGILANTE) {
|
||||||
|
verify(player).sendMessage(contains(
|
||||||
|
"You defeated the arena boss and became the Vigilante"
|
||||||
|
));
|
||||||
|
verify(other, never()).sendMessage(contains("Vigilante"));
|
||||||
|
} else {
|
||||||
|
verify(other).sendMessage(contains("Player defeated the arena boss"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerJoinEvent mockJoin(Player player) {
|
||||||
|
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
|
||||||
|
when(event.getPlayer()).thenReturn(player);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GameState tyrantVacancy() {
|
||||||
|
return new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 1, Set.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,78 @@
|
|||||||
|
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 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 RoleArenaPolicyTest {
|
||||||
|
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 tyrantVacancyHasPriorityOverVigilanteVacancy() {
|
||||||
|
assertEquals(Optional.of(ArenaRole.TYRANT), RoleArenaPolicy.openRole(game(
|
||||||
|
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), Optional.empty()
|
||||||
|
)));
|
||||||
|
assertEquals(Optional.of(ArenaRole.VIGILANTE), RoleArenaPolicy.openRole(game(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(), Optional.empty()
|
||||||
|
)));
|
||||||
|
assertEquals(Optional.empty(), RoleArenaPolicy.openRole(game(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.of(PLAYER), Optional.empty()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pausedGameAndLegacyPendingTyrantDoNotOpenFight() {
|
||||||
|
assertEquals(Optional.empty(), RoleArenaPolicy.openRole(game(
|
||||||
|
GameLifecycle.PAUSED, Optional.empty(), Optional.empty(), Optional.empty()
|
||||||
|
)));
|
||||||
|
assertEquals(Optional.empty(), RoleArenaPolicy.openRole(game(
|
||||||
|
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(),
|
||||||
|
Optional.of(new PendingSelection(PLAYER, Instant.parse("2026-08-24T00:00:00Z")))
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activeTyrantCannotChallengeAndOptedOutPlayersRemainIneligible() {
|
||||||
|
GameState vigilanteFight = game(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(), Optional.empty()
|
||||||
|
);
|
||||||
|
GameState tyrantFight = game(
|
||||||
|
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), 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(RoleArenaPolicy.isEligible(vigilanteFight, ArenaRole.VIGILANTE, eligible));
|
||||||
|
assertTrue(RoleArenaPolicy.isEligible(tyrantFight, ArenaRole.TYRANT, eligible));
|
||||||
|
assertFalse(RoleArenaPolicy.isEligible(vigilanteFight, ArenaRole.VIGILANTE, optedOut));
|
||||||
|
assertFalse(RoleArenaPolicy.isEligible(
|
||||||
|
vigilanteFight, ArenaRole.VIGILANTE, PlayerState.newPlayer(TYRANT, "Tyrant")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GameState game(
|
||||||
|
GameLifecycle lifecycle,
|
||||||
|
Optional<UUID> tyrant,
|
||||||
|
Optional<UUID> vigilante,
|
||||||
|
Optional<PendingSelection> pendingTyrant
|
||||||
|
) {
|
||||||
|
return new GameState(
|
||||||
|
lifecycle, tyrant, vigilante, pendingTyrant, Optional.empty(),
|
||||||
|
lifecycle == GameLifecycle.PAUSED
|
||||||
|
? Optional.of(Instant.parse("2026-08-23T12:00:00Z")) : Optional.empty(),
|
||||||
|
Duration.ZERO, 0, 0, Set.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package games.dmg.spigottyrant;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
final class RoleCandidateSelectorTest {
|
|
||||||
@Test
|
|
||||||
void selectsOnlyRecentlyActiveEligibleNonExcludedPlayer() {
|
|
||||||
Instant now = Instant.parse("2026-08-14T12:00:00Z");
|
|
||||||
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
|
||||||
UUID candidate = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
|
||||||
UUID stale = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
|
||||||
Map<UUID, PlayerState> players = Map.of(
|
|
||||||
tyrant, withLogin(PlayerState.newPlayer(tyrant, "Tyrant"), now),
|
|
||||||
candidate, withLogin(PlayerState.newPlayer(candidate, "Candidate"), now.minusSeconds(60)),
|
|
||||||
stale, withLogin(PlayerState.newPlayer(stale, "Stale"), now.minus(Duration.ofHours(25)))
|
|
||||||
);
|
|
||||||
|
|
||||||
Optional<UUID> selected = new RoleCandidateSelector().select(
|
|
||||||
players,
|
|
||||||
Set.of(tyrant),
|
|
||||||
Optional.empty(),
|
|
||||||
now,
|
|
||||||
Duration.ofHours(24),
|
|
||||||
RandomGenerator.of("L64X128MixRandom")
|
|
||||||
);
|
|
||||||
|
|
||||||
assertEquals(Optional.of(candidate), selected);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PlayerState withLogin(PlayerState player, Instant login) {
|
|
||||||
return new PlayerState(
|
|
||||||
player.playerId(), player.latestName(), Optional.of(login), player.optedOutUntil(),
|
|
||||||
player.tyrantClass(), player.followerOf(), player.cooldownEnds(),
|
|
||||||
player.readyAbilityItems(), player.capturedMobs()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
final class RoleMaintenanceServiceTest {
|
final class RoleMaintenanceServiceTest {
|
||||||
@@ -18,21 +17,27 @@ final class RoleMaintenanceServiceTest {
|
|||||||
private static final UUID OTHER = UUID.fromString("44444444-4444-4444-4444-444444444444");
|
private static final UUID OTHER = UUID.fromString("44444444-4444-4444-4444-444444444444");
|
||||||
private static final UUID FOLLOWER = UUID.fromString("55555555-5555-5555-5555-555555555555");
|
private static final UUID FOLLOWER = UUID.fromString("55555555-5555-5555-5555-555555555555");
|
||||||
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
|
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
|
||||||
private final RandomGenerator random = RandomGenerator.of("L64X128MixRandom");
|
|
||||||
private final RoleCandidateSelector selector = new RoleCandidateSelector();
|
|
||||||
private final RoleMaintenanceService service = new RoleMaintenanceService(
|
private final RoleMaintenanceService service = new RoleMaintenanceService(
|
||||||
selector,
|
new TyrantSuccessionService(), Duration.ofHours(48)
|
||||||
new TyrantSuccessionService(
|
|
||||||
selector, Duration.ofHours(24), Duration.ofHours(24), random
|
|
||||||
),
|
|
||||||
Duration.ofHours(48),
|
|
||||||
Duration.ofHours(24),
|
|
||||||
Duration.ofHours(24),
|
|
||||||
random
|
|
||||||
);
|
);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void onlinePendingVigilanteConfirmsWithoutConflictingWithTyrant() {
|
void legacyPendingTyrantIsClearedIntoArenaVacancy() {
|
||||||
|
GameState game = game(
|
||||||
|
Optional.empty(), Optional.empty(),
|
||||||
|
Optional.of(new PendingSelection(CANDIDATE, NOW.plusSeconds(60))),
|
||||||
|
Optional.empty()
|
||||||
|
);
|
||||||
|
PersistentState state = new PersistentState(game, players(active(CANDIDATE)));
|
||||||
|
|
||||||
|
LifecycleState maintained = service.maintain(state, NOW);
|
||||||
|
|
||||||
|
assertEquals(Optional.empty(), maintained.game().tyrantId());
|
||||||
|
assertEquals(Optional.empty(), maintained.game().pendingTyrant());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacyPendingVigilanteIsClearedForArenaSuccession() {
|
||||||
GameState game = game(
|
GameState game = game(
|
||||||
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
|
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
|
||||||
Optional.of(new PendingSelection(CANDIDATE, NOW.plusSeconds(60)))
|
Optional.of(new PendingSelection(CANDIDATE, NOW.plusSeconds(60)))
|
||||||
@@ -41,14 +46,14 @@ final class RoleMaintenanceServiceTest {
|
|||||||
active(TYRANT), active(CANDIDATE)
|
active(TYRANT), active(CANDIDATE)
|
||||||
));
|
));
|
||||||
|
|
||||||
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, CANDIDATE), NOW);
|
LifecycleState maintained = service.maintain(state, NOW);
|
||||||
|
|
||||||
assertEquals(Optional.of(CANDIDATE), maintained.game().vigilanteId());
|
assertEquals(Optional.empty(), maintained.game().vigilanteId());
|
||||||
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
|
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void expiredPendingSelectionRerollsAwayFromPreviousCandidate() {
|
void expiredLegacyPendingVigilanteOpensArenaInsteadOfRerolling() {
|
||||||
GameState game = game(
|
GameState game = game(
|
||||||
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
|
Optional.of(TYRANT), Optional.empty(), Optional.empty(),
|
||||||
Optional.of(new PendingSelection(CANDIDATE, NOW.minusSeconds(1)))
|
Optional.of(new PendingSelection(CANDIDATE, NOW.minusSeconds(1)))
|
||||||
@@ -57,14 +62,14 @@ final class RoleMaintenanceServiceTest {
|
|||||||
active(TYRANT), active(CANDIDATE), active(OTHER)
|
active(TYRANT), active(CANDIDATE), active(OTHER)
|
||||||
));
|
));
|
||||||
|
|
||||||
LifecycleState maintained = service.maintain(state, Set.of(TYRANT), NOW);
|
LifecycleState maintained = service.maintain(state, NOW);
|
||||||
|
|
||||||
assertEquals(OTHER,
|
assertEquals(Optional.empty(), maintained.game().vigilanteId());
|
||||||
maintained.game().pendingVigilante().orElseThrow().candidateId());
|
assertEquals(Optional.empty(), maintained.game().pendingVigilante());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void inactiveVigilanteIsRerolledAndFollowersAreCleared() {
|
void inactiveVigilanteOpensArenaAndFollowersAreCleared() {
|
||||||
PlayerState follower = withFollower(
|
PlayerState follower = withFollower(
|
||||||
withLogin(active(FOLLOWER), NOW.minus(Duration.ofHours(25))),
|
withLogin(active(FOLLOWER), NOW.minus(Duration.ofHours(25))),
|
||||||
VIGILANTE
|
VIGILANTE
|
||||||
@@ -78,15 +83,15 @@ final class RoleMaintenanceServiceTest {
|
|||||||
FOLLOWER, follower, OTHER, active(OTHER)
|
FOLLOWER, follower, OTHER, active(OTHER)
|
||||||
));
|
));
|
||||||
|
|
||||||
LifecycleState maintained = service.maintain(state, Set.of(TYRANT, OTHER), NOW);
|
LifecycleState maintained = service.maintain(state, NOW);
|
||||||
|
|
||||||
assertEquals(Optional.of(OTHER), maintained.game().vigilanteId());
|
assertEquals(Optional.empty(), maintained.game().vigilanteId());
|
||||||
assertEquals(Optional.empty(), maintained.players().get(FOLLOWER).followerOf());
|
assertEquals(Optional.empty(), maintained.players().get(FOLLOWER).followerOf());
|
||||||
assertEquals(Optional.of(TYRANT), maintained.game().tyrantId());
|
assertEquals(Optional.of(TYRANT), maintained.game().tyrantId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void inactiveTyrantEndsReignAndUsesNormalSuccession() {
|
void inactiveTyrantEndsReignAndOpensArena() {
|
||||||
PlayerState staleTyrant = withLogin(
|
PlayerState staleTyrant = withLogin(
|
||||||
active(TYRANT), NOW.minus(Duration.ofHours(48))
|
active(TYRANT), NOW.minus(Duration.ofHours(48))
|
||||||
);
|
);
|
||||||
@@ -104,8 +109,10 @@ final class RoleMaintenanceServiceTest {
|
|||||||
TYRANT, staleTyrant, VIGILANTE, active(VIGILANTE), OTHER, successor
|
TYRANT, staleTyrant, VIGILANTE, active(VIGILANTE), OTHER, successor
|
||||||
));
|
));
|
||||||
|
|
||||||
LifecycleState maintained = service.maintain(state, Set.of(OTHER), NOW);
|
LifecycleState maintained = service.maintain(state, NOW);
|
||||||
|
|
||||||
|
assertEquals(Optional.empty(), maintained.game().tyrantId());
|
||||||
|
assertEquals(Optional.empty(), maintained.game().pendingTyrant());
|
||||||
assertEquals(0, maintained.game().tyrantLevel());
|
assertEquals(0, maintained.game().tyrantLevel());
|
||||||
assertEquals(1, maintained.game().unspentChoices());
|
assertEquals(1, maintained.game().unspentChoices());
|
||||||
assertEquals(Set.of(), maintained.game().purchases());
|
assertEquals(Set.of(), maintained.game().purchases());
|
||||||
|
|||||||
@@ -8,24 +8,18 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
final class RoleRelinquishmentServiceTest {
|
final class RoleRelinquishmentServiceTest {
|
||||||
@Test
|
@Test
|
||||||
void vigilanteCanRelinquishAndTriggerNormalReroll() {
|
void vigilanteCanRelinquishAndOpenArenaChallenge() {
|
||||||
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||||
UUID candidate = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
UUID candidate = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||||
Instant now = Instant.parse("2026-08-14T12:00:00Z");
|
Instant now = Instant.parse("2026-08-14T12:00:00Z");
|
||||||
RoleCandidateSelector selector = new RoleCandidateSelector();
|
TyrantSuccessionService succession = new TyrantSuccessionService();
|
||||||
RandomGenerator random = RandomGenerator.of("L64X128MixRandom");
|
|
||||||
TyrantSuccessionService succession = new TyrantSuccessionService(
|
|
||||||
selector, Duration.ofHours(24), Duration.ofHours(24), random
|
|
||||||
);
|
|
||||||
RoleMaintenanceService maintenance = new RoleMaintenanceService(
|
RoleMaintenanceService maintenance = new RoleMaintenanceService(
|
||||||
selector, succession, Duration.ofHours(48), Duration.ofHours(24),
|
succession, Duration.ofHours(48)
|
||||||
Duration.ofHours(24), random
|
|
||||||
);
|
);
|
||||||
RoleRelinquishmentService service = new RoleRelinquishmentService(
|
RoleRelinquishmentService service = new RoleRelinquishmentService(
|
||||||
succession, maintenance
|
succession, maintenance
|
||||||
@@ -49,7 +43,40 @@ final class RoleRelinquishmentServiceTest {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(RelinquishmentStatus.RELINQUISHED, result.status());
|
assertEquals(RelinquishmentStatus.RELINQUISHED, result.status());
|
||||||
assertEquals(Optional.of(candidate), result.state().game().vigilanteId());
|
assertEquals(Optional.empty(), result.state().game().vigilanteId());
|
||||||
|
assertEquals(Optional.empty(), result.state().game().pendingVigilante());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tyrantRelinquishmentOpensTyrantArenaAndClearsReign() {
|
||||||
|
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
UUID vigilante = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||||
|
Instant now = Instant.parse("2026-08-14T12:00:00Z");
|
||||||
|
TyrantSuccessionService succession = new TyrantSuccessionService();
|
||||||
|
RoleRelinquishmentService service = new RoleRelinquishmentService(
|
||||||
|
succession, new RoleMaintenanceService(succession, Duration.ofHours(48))
|
||||||
|
);
|
||||||
|
GameState game = new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.of(vigilante),
|
||||||
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||||
|
4, 0, Set.of(TyrantUnlock.STRENGTH)
|
||||||
|
);
|
||||||
|
|
||||||
|
RelinquishmentResult result = service.relinquish(
|
||||||
|
new PersistentState(game, Map.of(
|
||||||
|
tyrant, active(tyrant, now), vigilante, active(vigilante, now)
|
||||||
|
)),
|
||||||
|
tyrant,
|
||||||
|
Set.of(tyrant, vigilante),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(RelinquishmentStatus.RELINQUISHED, result.status());
|
||||||
|
assertEquals(Optional.empty(), result.state().game().tyrantId());
|
||||||
|
assertEquals(Optional.empty(), result.state().game().pendingTyrant());
|
||||||
|
assertEquals(Optional.empty(), result.state().game().vigilanteId());
|
||||||
|
assertEquals(0, result.state().game().tyrantLevel());
|
||||||
|
assertEquals(1, result.state().game().unspentChoices());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PlayerState active(UUID id, Instant now) {
|
private static PlayerState active(UUID id, Instant now) {
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.World;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
final class TyrantAdminCommandTest {
|
final class TyrantAdminCommandTest {
|
||||||
@@ -59,6 +62,117 @@ final class TyrantAdminCommandTest {
|
|||||||
verify(effects).suppressAll();
|
verify(effects).suppressAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grantPointAddsAndPersistsOneLevelAndChoice() {
|
||||||
|
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
GameState game = new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||||
|
2, 1, Set.of(TyrantUnlock.ASSASSIN)
|
||||||
|
);
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.snapshot()).thenReturn(new PersistentState(
|
||||||
|
game, Map.of(tyrant, PlayerState.newPlayer(tyrant, "Tyrant"))
|
||||||
|
));
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
|
||||||
|
|
||||||
|
command(manager, mock(ManagedRoleEffects.class)).onCommand(
|
||||||
|
sender, mock(Command.class), "tyrantadmin", new String[] {"grantpoint"}
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state ->
|
||||||
|
state.game().tyrantLevel() == 3
|
||||||
|
&& state.game().unspentChoices() == 2
|
||||||
|
&& state.game().purchases().equals(Set.of(TyrantUnlock.ASSASSIN))
|
||||||
|
));
|
||||||
|
verify(manager).saveIfDirty();
|
||||||
|
verify(sender).sendMessage(contains("level 3 and 2 unspent choices"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grantPointRejectsPausedGameWithoutChangingState() {
|
||||||
|
UUID tyrant = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
GameState paused = new GameState(
|
||||||
|
GameLifecycle.PAUSED, Optional.of(tyrant), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(),
|
||||||
|
Optional.of(Instant.parse("2026-08-14T12:00:00Z")), Duration.ZERO,
|
||||||
|
2, 1, Set.of()
|
||||||
|
);
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.snapshot()).thenReturn(new PersistentState(
|
||||||
|
paused, Map.of(tyrant, PlayerState.newPlayer(tyrant, "Tyrant"))
|
||||||
|
));
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
|
||||||
|
|
||||||
|
command(manager, mock(ManagedRoleEffects.class)).onCommand(
|
||||||
|
sender, mock(Command.class), "tyrantadmin", new String[] {"grantpoint"}
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any());
|
||||||
|
verify(sender).sendMessage(contains("must be running"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grantPointRejectsAdditionalArgumentsWithoutChangingState() {
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
|
||||||
|
|
||||||
|
command(manager, mock(ManagedRoleEffects.class)).onCommand(
|
||||||
|
sender, mock(Command.class), "tyrantadmin",
|
||||||
|
new String[] {"grantpoint", "unexpected"}
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any());
|
||||||
|
verify(sender).sendMessage(contains("Usage: /tyrantadmin grantpoint"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grantPointRejectsTyrantVacancyWithoutChangingState() {
|
||||||
|
GameState vacant = new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||||
|
0, 0, Set.of()
|
||||||
|
);
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.snapshot()).thenReturn(new PersistentState(vacant, Map.of()));
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigottyrant.admin")).thenReturn(true);
|
||||||
|
|
||||||
|
command(manager, mock(ManagedRoleEffects.class)).onCommand(
|
||||||
|
sender, mock(Command.class), "tyrantadmin", new String[] {"grantpoint"}
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(manager, never()).replaceState(org.mockito.ArgumentMatchers.any());
|
||||||
|
verify(sender).sendMessage(contains("no current Tyrant"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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("Shared role arena set"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void resetRequiresExplicitConfirmation() {
|
void resetRequiresExplicitConfirmation() {
|
||||||
TyrantStateManager manager = mock(TyrantStateManager.class);
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class TyrantAdminTabCompleterTest {
|
||||||
|
@Test
|
||||||
|
void permissionGatesAdministrativeSyntaxAndOnlineCandidates() {
|
||||||
|
UUID alphaId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
UUID betaId = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||||
|
Player alpha = player(alphaId, "Alpha");
|
||||||
|
Player beta = player(betaId, "Beta");
|
||||||
|
OnlinePlayerDirectory directory = mock(OnlinePlayerDirectory.class);
|
||||||
|
when(directory.onlinePlayerIds()).thenReturn(Set.of(alphaId, betaId));
|
||||||
|
when(directory.findById(alphaId)).thenReturn(alpha);
|
||||||
|
when(directory.findById(betaId)).thenReturn(beta);
|
||||||
|
TyrantAdminTabCompleter completer = new TyrantAdminTabCompleter(directory);
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
CommandSender denied = mock(CommandSender.class);
|
||||||
|
CommandSender admin = mock(CommandSender.class);
|
||||||
|
when(admin.hasPermission("spigottyrant.admin")).thenReturn(true);
|
||||||
|
|
||||||
|
assertEquals(java.util.List.of(), completer.onTabComplete(
|
||||||
|
denied, command, "tyrantadmin", new String[] {""}
|
||||||
|
));
|
||||||
|
assertTrue(completer.onTabComplete(
|
||||||
|
admin, command, "tyrantadmin", new String[] {""}
|
||||||
|
).containsAll(java.util.List.of(
|
||||||
|
"status", "start", "pause", "resume", "reset", "arena", "grantpoint"
|
||||||
|
)));
|
||||||
|
assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
|
||||||
|
admin, command, "tyrantadmin", new String[] {"start", ""}
|
||||||
|
));
|
||||||
|
assertEquals(java.util.List.of("Beta"), completer.onTabComplete(
|
||||||
|
admin, command, "tyrantadmin", new String[] {"start", "Alpha", ""}
|
||||||
|
));
|
||||||
|
assertEquals(java.util.List.of("confirm"), completer.onTabComplete(
|
||||||
|
admin, command, "tyrantadmin", new String[] {"reset", "c"}
|
||||||
|
));
|
||||||
|
assertEquals(java.util.List.of("set", "status"), completer.onTabComplete(
|
||||||
|
admin, command, "tyrantadmin", new String[] {"arena", "s"}
|
||||||
|
));
|
||||||
|
assertEquals(java.util.List.of(), completer.onTabComplete(
|
||||||
|
admin, command, "tyrantadmin", new String[] {"grantpoint", ""}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Player player(UUID id, String name) {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(id);
|
||||||
|
when(player.getName()).thenReturn(name);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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 TyrantArenaSuccessionServiceTest {
|
||||||
|
@Test
|
||||||
|
void eligibleBossWinnerClaimsVacantTyrantRoleWithFreshReign() {
|
||||||
|
UUID winner = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||||
|
GameState game = new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 1, Set.of()
|
||||||
|
);
|
||||||
|
PersistentState before = new PersistentState(
|
||||||
|
game, Map.of(winner, PlayerState.newPlayer(winner, "Winner"))
|
||||||
|
);
|
||||||
|
|
||||||
|
LifecycleState claimed = new TyrantArenaSuccessionService().claim(before, winner);
|
||||||
|
|
||||||
|
assertEquals(Optional.of(winner), claimed.game().tyrantId());
|
||||||
|
assertEquals(Optional.empty(), claimed.game().vigilanteId());
|
||||||
|
assertEquals(0, claimed.game().tyrantLevel());
|
||||||
|
assertEquals(1, claimed.game().unspentChoices());
|
||||||
|
assertEquals(Set.of(), claimed.game().purchases());
|
||||||
|
assertEquals(
|
||||||
|
Optional.of(ArenaRole.VIGILANTE),
|
||||||
|
RoleArenaPolicy.openRole(claimed.game())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class TyrantArmorServiceTest {
|
||||||
|
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
private static final UUID OTHER = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||||
|
private final TyrantArmorService service = new TyrantArmorService();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fullyUnlockedTyrantSpendsOneChoiceOnAnUnclaimedPiece() {
|
||||||
|
GameState before = game(2, allUnlocks(), Set.of());
|
||||||
|
|
||||||
|
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.HELMET);
|
||||||
|
|
||||||
|
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
|
||||||
|
assertEquals(1, result.state().unspentChoices());
|
||||||
|
assertEquals(Set.of(TyrantArmorPiece.HELMET), result.state().claimedArmor());
|
||||||
|
assertEquals(before.purchases(), result.state().purchases());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eachPieceCanBeClaimedOnlyOncePerReign() {
|
||||||
|
GameState before = game(2, allUnlocks(), Set.of(TyrantArmorPiece.BOOTS));
|
||||||
|
|
||||||
|
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.BOOTS);
|
||||||
|
|
||||||
|
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED, result.status());
|
||||||
|
assertEquals(before, result.state());
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.params.ParameterizedTest
|
||||||
|
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
|
||||||
|
void rejectsIncompleteUnlocksNoChoicesNonTyrantsAndPausedGames(TyrantArmorPiece piece) {
|
||||||
|
GameState complete = game(0, allUnlocks(), Set.of());
|
||||||
|
GameState incomplete = game(1, Set.of(TyrantUnlock.ASSASSIN), Set.of());
|
||||||
|
GameState paused = new GameState(
|
||||||
|
GameLifecycle.PAUSED, complete.tyrantId(), complete.vigilanteId(),
|
||||||
|
complete.pendingTyrant(), complete.pendingVigilante(),
|
||||||
|
Optional.of(java.time.Instant.parse("2026-09-04T00:00:00Z")), Duration.ZERO,
|
||||||
|
1, 1, allUnlocks(), Set.of()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(ArmorClaimStatus.NO_CHOICES,
|
||||||
|
service.claim(complete, TYRANT, piece).status());
|
||||||
|
assertEquals(ArmorClaimStatus.UNLOCKS_INCOMPLETE,
|
||||||
|
service.claim(incomplete, TYRANT, piece).status());
|
||||||
|
assertEquals(ArmorClaimStatus.NOT_TYRANT,
|
||||||
|
service.claim(game(1, allUnlocks(), Set.of()), OTHER, piece).status());
|
||||||
|
assertEquals(ArmorClaimStatus.GAME_NOT_RUNNING,
|
||||||
|
service.claim(paused, TYRANT, piece).status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nineIndependentRewardsCanBePurchasedToolsFirstAndNeverRepeated() {
|
||||||
|
GameState state = game(12, allUnlocks(), Set.of());
|
||||||
|
String[] order = {"AXE", "PICKAXE", "SWORD", "HOE", "SHOVEL",
|
||||||
|
"BOOTS", "HELMET", "LEGGINGS", "CHESTPLATE"};
|
||||||
|
for (String name : order) {
|
||||||
|
TyrantArmorPiece piece = TyrantArmorPiece.valueOf(name);
|
||||||
|
ArmorClaimResult claim = service.claim(state, TYRANT, piece);
|
||||||
|
assertEquals(ArmorClaimStatus.CLAIMED, claim.status());
|
||||||
|
assertEquals(state.unspentChoices() - 1, claim.state().unspentChoices());
|
||||||
|
state = claim.state();
|
||||||
|
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED,
|
||||||
|
service.claim(state, TYRANT, piece).status());
|
||||||
|
assertEquals(state, service.claim(state, TYRANT, piece).state());
|
||||||
|
}
|
||||||
|
assertEquals(9, state.claimedArmor().size());
|
||||||
|
assertEquals(3, state.unspentChoices());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finalElytraCostsOneChoiceAfterExactlyNineRegularClaims() {
|
||||||
|
assertEquals(9, TyrantArmorPiece.regularRewards().size());
|
||||||
|
org.junit.jupiter.api.Assertions.assertFalse(
|
||||||
|
TyrantArmorPiece.regularRewards().contains(TyrantArmorPiece.ELYTRA));
|
||||||
|
GameState before = game(2, allUnlocks(), TyrantArmorPiece.regularRewards());
|
||||||
|
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.ELYTRA);
|
||||||
|
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
|
||||||
|
assertEquals(1, result.state().unspentChoices());
|
||||||
|
assertEquals(10, result.state().claimedArmor().size());
|
||||||
|
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED,
|
||||||
|
service.claim(result.state(), TYRANT, TyrantArmorPiece.ELYTRA).status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.params.ParameterizedTest
|
||||||
|
@org.junit.jupiter.params.provider.EnumSource(value = TyrantArmorPiece.class,
|
||||||
|
names = "ELYTRA", mode = org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE)
|
||||||
|
void everyRegularRewardIsRequiredButCanBeBoughtWithoutElytra(TyrantArmorPiece missing) {
|
||||||
|
Set<TyrantArmorPiece> claims = EnumSet.copyOf(TyrantArmorPiece.regularRewards());
|
||||||
|
claims.remove(missing);
|
||||||
|
GameState before = game(2, allUnlocks(), claims);
|
||||||
|
ArmorClaimResult rejected = service.claim(before, TYRANT, TyrantArmorPiece.ELYTRA);
|
||||||
|
assertEquals(ArmorClaimStatus.COLLECTION_INCOMPLETE, rejected.status());
|
||||||
|
assertEquals(before, rejected.state());
|
||||||
|
assertEquals(ArmorClaimStatus.CLAIMED, service.claim(before, TYRANT, missing).status());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<TyrantUnlock> allUnlocks() {
|
||||||
|
return EnumSet.allOf(TyrantUnlock.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GameState game(
|
||||||
|
int choices,
|
||||||
|
Set<TyrantUnlock> unlocks,
|
||||||
|
Set<TyrantArmorPiece> claimed
|
||||||
|
) {
|
||||||
|
return new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||||
|
6, choices, unlocks, claimed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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 java.util.List;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource;
|
||||||
|
|
||||||
|
final class TyrantArmorSpecTest {
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource({
|
||||||
|
"HELMET,Helmet,5,0,0", "CHESTPLATE,Chestplate,5,0,0",
|
||||||
|
"LEGGINGS,Leggings,5,0,0", "BOOTS,Boots,5,0,0",
|
||||||
|
"AXE,Axe,0,6,6", "PICKAXE,Pickaxe,0,0,6", "SWORD,Sword,0,6,0",
|
||||||
|
"HOE,Hoe,0,0,6", "SHOVEL,Shovel,0,0,6"
|
||||||
|
})
|
||||||
|
void nineRegularRewardsKeepTheirExactSpecifications(
|
||||||
|
String name, String displayPiece, int protection, int sharpness, int efficiency
|
||||||
|
) {
|
||||||
|
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.valueOf(name), "Alex");
|
||||||
|
assertEquals(Material.valueOf("NETHERITE_" + name), spec.material());
|
||||||
|
assertEquals("Tyrant's Alex " + displayPiece, spec.displayName());
|
||||||
|
assertEquals(List.of("Forged for Tyrant Alex.", "A lasting reward from Alex's reign."), spec.lore());
|
||||||
|
assertEquals(protection, spec.protectionLevel());
|
||||||
|
assertEquals(sharpness, spec.sharpnessLevel());
|
||||||
|
assertEquals(efficiency, spec.efficiencyLevel());
|
||||||
|
assertEquals(1, spec.mendingLevel());
|
||||||
|
assertFalse(spec.unbreakable());
|
||||||
|
List<String> expectedConfirmation = protection > 0
|
||||||
|
? List.of("Protection V", "Mending I", "Cost: 1 choice")
|
||||||
|
: sharpness > 0
|
||||||
|
? efficiency > 0
|
||||||
|
? List.of("Sharpness VI", "Efficiency VI", "Mending I", "Cost: 1 choice")
|
||||||
|
: List.of("Sharpness VI", "Mending I", "Cost: 1 choice")
|
||||||
|
: List.of("Efficiency VI", "Mending I", "Cost: 1 choice");
|
||||||
|
assertEquals(expectedConfirmation, spec.confirmationLore());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void menuHasTenDistinctRewardSlotsWithoutOverlappingBack() {
|
||||||
|
int[] slots = BukkitTyrantControlPanelRenderer.legacyRewardSlots();
|
||||||
|
assertEquals(TyrantArmorPiece.values().length, slots.length);
|
||||||
|
assertEquals(10, java.util.Arrays.stream(slots).distinct().count());
|
||||||
|
assertTrue(java.util.Arrays.stream(slots).allMatch(slot -> slot >= 0 && slot < 27 && slot != 22));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void elytraIsUnbreakableWithoutEnchantmentsAndHasCollectionLore() {
|
||||||
|
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.valueOf("ELYTRA"), "Alex");
|
||||||
|
assertEquals(Material.ELYTRA, spec.material());
|
||||||
|
assertEquals("Tyrant's Alex Elytra", spec.displayName());
|
||||||
|
assertEquals(List.of(
|
||||||
|
"Forged for Tyrant Alex.",
|
||||||
|
"A lasting reward from Alex's reign.",
|
||||||
|
"Earned by completing the Tyrant's legacy collection."
|
||||||
|
), spec.lore());
|
||||||
|
assertTrue(spec.unbreakable());
|
||||||
|
assertEquals(0, spec.protectionLevel());
|
||||||
|
assertEquals(0, spec.mendingLevel());
|
||||||
|
assertEquals(0, spec.sharpnessLevel());
|
||||||
|
assertEquals(0, spec.efficiencyLevel());
|
||||||
|
assertEquals(List.of("Unbreakable", "Cost: 1 choice"), spec.confirmationLore());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,16 @@ import java.time.Clock;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.EnumSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.PlayerInventory;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
@@ -97,6 +101,89 @@ final class TyrantCommandTest {
|
|||||||
&& message.contains("ASSASSIN")));
|
&& message.contains("ASSASSIN")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.params.ParameterizedTest
|
||||||
|
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
|
||||||
|
void eligibleTyrantCanClaimNamedRewardIntoInventoryWithoutPossessingEarlierRewards(TyrantArmorPiece piece) {
|
||||||
|
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
GameState game = new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||||
|
6, 2, EnumSet.allOf(TyrantUnlock.class), prerequisiteClaims(piece)
|
||||||
|
);
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.snapshot()).thenReturn(new PersistentState(
|
||||||
|
game, Map.of(tyrantId, PlayerState.newPlayer(tyrantId, "Alex"))
|
||||||
|
));
|
||||||
|
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||||
|
when(inventory.firstEmpty()).thenReturn(4);
|
||||||
|
Player tyrant = mock(Player.class);
|
||||||
|
when(tyrant.getUniqueId()).thenReturn(tyrantId);
|
||||||
|
when(tyrant.getName()).thenReturn("Alex");
|
||||||
|
when(tyrant.getInventory()).thenReturn(inventory);
|
||||||
|
ItemStack reward = mock(ItemStack.class);
|
||||||
|
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
|
||||||
|
when(factory.create(piece, "Alex")).thenReturn(reward);
|
||||||
|
TyrantCommand command = new TyrantCommand(
|
||||||
|
manager, new TyrantProgressionService(), factory
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(
|
||||||
|
tyrant, mock(Command.class), "tyrant", new String[] {piece.isArmor() ? "armor" : "gear", piece.commandName()}
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state ->
|
||||||
|
state.game().unspentChoices() == 1
|
||||||
|
&& state.game().claimedArmor().equals(expectedClaims(piece))
|
||||||
|
));
|
||||||
|
verify(inventory).setItem(4, reward);
|
||||||
|
verify(manager).saveIfDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.params.ParameterizedTest
|
||||||
|
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
|
||||||
|
void fullInventoryDoesNotConsumeChoiceOrCreateReward(TyrantArmorPiece piece) {
|
||||||
|
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
GameState game = new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||||
|
6, 1, EnumSet.allOf(TyrantUnlock.class), prerequisiteClaims(piece)
|
||||||
|
);
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.snapshot()).thenReturn(new PersistentState(
|
||||||
|
game, Map.of(tyrantId, PlayerState.newPlayer(tyrantId, "Alex"))
|
||||||
|
));
|
||||||
|
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||||
|
when(inventory.firstEmpty()).thenReturn(-1);
|
||||||
|
Player tyrant = mock(Player.class);
|
||||||
|
when(tyrant.getUniqueId()).thenReturn(tyrantId);
|
||||||
|
when(tyrant.getInventory()).thenReturn(inventory);
|
||||||
|
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
|
||||||
|
TyrantCommand command = new TyrantCommand(
|
||||||
|
manager, new TyrantProgressionService(), factory
|
||||||
|
);
|
||||||
|
|
||||||
|
command.onCommand(
|
||||||
|
tyrant, mock(Command.class), "tyrant", new String[] {piece.isArmor() ? "armor" : "gear", piece.commandName()}
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(manager, org.mockito.Mockito.never()).replaceState(any());
|
||||||
|
verify(factory, org.mockito.Mockito.never()).create(any(), any());
|
||||||
|
verify(manager, org.mockito.Mockito.never()).saveIfDirty();
|
||||||
|
verify(inventory, org.mockito.Mockito.never()).setItem(org.mockito.ArgumentMatchers.anyInt(), any());
|
||||||
|
verify(tyrant).sendMessage(org.mockito.ArgumentMatchers.contains("inventory is full"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<TyrantArmorPiece> prerequisiteClaims(TyrantArmorPiece piece) {
|
||||||
|
return piece == TyrantArmorPiece.ELYTRA ? TyrantArmorPiece.regularRewards() : Set.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<TyrantArmorPiece> expectedClaims(TyrantArmorPiece piece) {
|
||||||
|
Set<TyrantArmorPiece> claims = EnumSet.noneOf(TyrantArmorPiece.class);
|
||||||
|
claims.addAll(prerequisiteClaims(piece));
|
||||||
|
claims.add(piece);
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void assigningClassNotifiesTyrantAndNewHolder() {
|
void assigningClassNotifiesTyrantAndNewHolder() {
|
||||||
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package games.dmg.spigottyrant;
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.EnumSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -46,4 +49,51 @@ final class TyrantControlPanelModelTest {
|
|||||||
assertEquals("Blade", model.classHolders().get(TyrantClass.ASSASSIN));
|
assertEquals("Blade", model.classHolders().get(TyrantClass.ASSASSIN));
|
||||||
assertEquals(Duration.ofSeconds(90), model.intelligenceCooldown());
|
assertEquals(Duration.ofSeconds(90), model.intelligenceCooldown());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void elytraMenuExplainsMissingPrerequisitesAndRefreshesAfterClaims() {
|
||||||
|
Map<TyrantUnlock, UnlockAvailability> unlocks = new java.util.EnumMap<>(TyrantUnlock.class);
|
||||||
|
for (TyrantUnlock unlock : TyrantUnlock.values()) {
|
||||||
|
unlocks.put(unlock, UnlockAvailability.PURCHASED);
|
||||||
|
}
|
||||||
|
TyrantControlPanelModel incomplete = new TyrantControlPanelModel(
|
||||||
|
16, 1, unlocks, Set.of(), Map.of(), Duration.ZERO);
|
||||||
|
assertFalse(incomplete.armorAvailable(TyrantArmorPiece.ELYTRA));
|
||||||
|
assertTrue(incomplete.armorLore(TyrantArmorPiece.ELYTRA).contains(
|
||||||
|
"Claim all nine regular legacy rewards first (0/9)."));
|
||||||
|
TyrantControlPanelModel ready = new TyrantControlPanelModel(
|
||||||
|
16, 1, unlocks, TyrantArmorPiece.regularRewards(), Map.of(), Duration.ZERO);
|
||||||
|
assertTrue(ready.armorAvailable(TyrantArmorPiece.ELYTRA));
|
||||||
|
assertTrue(ready.armorLore(TyrantArmorPiece.ELYTRA).contains("Status: AVAILABLE"));
|
||||||
|
assertTrue(ready.armorLore(TyrantArmorPiece.ELYTRA).contains("Unbreakable"));
|
||||||
|
TyrantControlPanelModel claimed = new TyrantControlPanelModel(
|
||||||
|
17, 1, unlocks, EnumSet.allOf(TyrantArmorPiece.class), Map.of(), Duration.ZERO);
|
||||||
|
assertFalse(claimed.armorAvailable(TyrantArmorPiece.ELYTRA));
|
||||||
|
assertTrue(claimed.armorLore(TyrantArmorPiece.ELYTRA).contains("Status: CLAIMED"));
|
||||||
|
TyrantControlPanelModel locked = new TyrantControlPanelModel(
|
||||||
|
0, 0, Map.of(), Set.of(), Map.of(), Duration.ZERO);
|
||||||
|
assertTrue(locked.armorLore(TyrantArmorPiece.ELYTRA).contains(
|
||||||
|
"Purchase all six standard unlocks first."));
|
||||||
|
assertTrue(locked.armorLore(TyrantArmorPiece.ELYTRA).contains("Earn another choice first."));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void armorAvailabilityRequiresAllUnlocksAChoiceAndAnUnclaimedSlot() {
|
||||||
|
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
Instant now = Instant.parse("2026-09-04T00:00:00Z");
|
||||||
|
GameState game = new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||||
|
6, 1, EnumSet.allOf(TyrantUnlock.class), Set.of(TyrantArmorPiece.HELMET)
|
||||||
|
);
|
||||||
|
PlayerState tyrant = PlayerState.newPlayer(tyrantId, "Tyrant");
|
||||||
|
|
||||||
|
TyrantControlPanelModel model = TyrantControlPanelModel.create(
|
||||||
|
game, tyrant, Map.of(tyrantId, tyrant), now
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(model.allStandardUnlocksPurchased());
|
||||||
|
assertFalse(model.armorAvailable(TyrantArmorPiece.HELMET));
|
||||||
|
assertTrue(model.armorAvailable(TyrantArmorPiece.CHESTPLATE));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
package games.dmg.spigottyrant;
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.doReturn;
|
import static org.mockito.Mockito.doReturn;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import java.time.Clock;
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -42,7 +38,7 @@ final class TyrantDeathListenerTest {
|
|||||||
when(manager.game()).thenReturn(game);
|
when(manager.game()).thenReturn(game);
|
||||||
when(manager.snapshot()).thenReturn(before);
|
when(manager.snapshot()).thenReturn(before);
|
||||||
TyrantSuccessionService succession = mock(TyrantSuccessionService.class);
|
TyrantSuccessionService succession = mock(TyrantSuccessionService.class);
|
||||||
when(succession.succeed(eq(before), eq(Optional.of(killerId)), any(), any()))
|
when(succession.succeed(eq(before), eq(Optional.of(killerId))))
|
||||||
.thenReturn(succeeded);
|
.thenReturn(succeeded);
|
||||||
TyrantPresentation presentation = mock(TyrantPresentation.class);
|
TyrantPresentation presentation = mock(TyrantPresentation.class);
|
||||||
Server server = mock(Server.class);
|
Server server = mock(Server.class);
|
||||||
@@ -58,8 +54,7 @@ final class TyrantDeathListenerTest {
|
|||||||
manager,
|
manager,
|
||||||
succession,
|
succession,
|
||||||
presentation,
|
presentation,
|
||||||
server,
|
server
|
||||||
Clock.fixed(Instant.parse("2026-08-14T12:00:00Z"), ZoneOffset.UTC)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
listener.onDeath(event);
|
listener.onDeath(event);
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.PlayerInventory;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource;
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource;
|
||||||
|
|
||||||
|
final class TyrantGearCommandSafetyTest {
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource({"gear,helmet", "armor,axe", "armor,elytra", "gear,unknown", "gear,chest"})
|
||||||
|
void malformedAndCrossCategoryRequestsDoNotMutateState(String category, String name) {
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
|
||||||
|
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService(), factory);
|
||||||
|
command.onCommand(mock(Player.class), mock(Command.class), "tyrant", new String[] {category, name});
|
||||||
|
verify(manager, never()).replaceState(any());
|
||||||
|
verify(factory, never()).create(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = TyrantArmorPiece.class, names = {"AXE", "PICKAXE", "SWORD", "HOE", "SHOVEL", "ELYTRA"})
|
||||||
|
void repeatedCommandsReadAuthoritativeClaimsAndDeliverOnlyOnce(TyrantArmorPiece piece) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
GameState game = new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(id), Optional.empty(), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||||
|
6, 2, EnumSet.allOf(TyrantUnlock.class),
|
||||||
|
piece == TyrantArmorPiece.ELYTRA ? TyrantArmorPiece.regularRewards() : Set.of()
|
||||||
|
);
|
||||||
|
AtomicReference<PersistentState> state = new AtomicReference<>(new PersistentState(game, Map.of()));
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.snapshot()).thenAnswer(invocation -> state.get());
|
||||||
|
org.mockito.Mockito.doAnswer(invocation -> {
|
||||||
|
LifecycleState replacement = invocation.getArgument(0);
|
||||||
|
state.set(new PersistentState(replacement.game(), replacement.players()));
|
||||||
|
return null;
|
||||||
|
}).when(manager).replaceState(any());
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(id);
|
||||||
|
when(player.getName()).thenReturn("Alex");
|
||||||
|
when(player.getInventory()).thenReturn(inventory);
|
||||||
|
when(inventory.firstEmpty()).thenReturn(0);
|
||||||
|
ItemStack item = mock(ItemStack.class);
|
||||||
|
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
|
||||||
|
when(factory.create(piece, "Alex")).thenReturn(item);
|
||||||
|
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService(), factory);
|
||||||
|
String[] args = {"GEAR", piece.name()};
|
||||||
|
command.onCommand(player, mock(Command.class), "tyrant", args);
|
||||||
|
command.onCommand(player, mock(Command.class), "tyrant", args);
|
||||||
|
verify(inventory).setItem(0, item);
|
||||||
|
verify(factory).create(piece, "Alex");
|
||||||
|
verify(manager).saveIfDirty();
|
||||||
|
assertEquals(1, state.get().game().unspentChoices());
|
||||||
|
Set<TyrantArmorPiece> expected = EnumSet.noneOf(TyrantArmorPiece.class);
|
||||||
|
expected.addAll(game.claimedArmor());
|
||||||
|
expected.add(piece);
|
||||||
|
assertEquals(expected, state.get().game().claimedArmor());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = TyrantArmorPiece.class, names = "ELYTRA", mode = EnumSource.Mode.EXCLUDE)
|
||||||
|
void elytraRequiresEveryRegularClaimInTheCurrentReign(TyrantArmorPiece missing) {
|
||||||
|
Set<TyrantArmorPiece> claims = EnumSet.copyOf(TyrantArmorPiece.regularRewards());
|
||||||
|
claims.remove(missing);
|
||||||
|
assertElytraRejected(GameLifecycle.RUNNING, true, 1,
|
||||||
|
EnumSet.allOf(TyrantUnlock.class), claims);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@EnumSource(TyrantUnlock.class)
|
||||||
|
void elytraRequiresEveryStandardUnlockEvenWithAllNineClaims(TyrantUnlock missing) {
|
||||||
|
Set<TyrantUnlock> unlocks = EnumSet.allOf(TyrantUnlock.class);
|
||||||
|
unlocks.remove(missing);
|
||||||
|
assertElytraRejected(GameLifecycle.RUNNING, true, 1,
|
||||||
|
unlocks, TyrantArmorPiece.regularRewards());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource({"PAUSED,true,1", "UNSTARTED,true,1", "RUNNING,false,1", "RUNNING,true,0"})
|
||||||
|
void elytraCannotBypassLifecycleAuthorizationOrChoiceCost(
|
||||||
|
GameLifecycle lifecycle, boolean activeTyrant, int choices
|
||||||
|
) {
|
||||||
|
assertElytraRejected(lifecycle, activeTyrant, choices,
|
||||||
|
EnumSet.allOf(TyrantUnlock.class), TyrantArmorPiece.regularRewards());
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.Test
|
||||||
|
void noCurrentReignClaimsCannotQualifyForElytra() {
|
||||||
|
assertElytraRejected(GameLifecycle.RUNNING, true, 1,
|
||||||
|
EnumSet.allOf(TyrantUnlock.class), Set.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.Test
|
||||||
|
void extraElytraArgumentsDoNotCreateOrSaveRewards() {
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
|
||||||
|
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService(), factory);
|
||||||
|
command.onCommand(mock(Player.class), mock(Command.class), "tyrant",
|
||||||
|
new String[] {"gear", "elytra", "confirm"});
|
||||||
|
verify(manager, never()).replaceState(any());
|
||||||
|
verify(manager, never()).saveIfDirty();
|
||||||
|
verify(factory, never()).create(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertElytraRejected(
|
||||||
|
GameLifecycle lifecycle, boolean activeTyrant, int choices,
|
||||||
|
Set<TyrantUnlock> unlocks, Set<TyrantArmorPiece> claims
|
||||||
|
) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
GameState game = new GameState(
|
||||||
|
lifecycle, Optional.of(id), Optional.empty(), Optional.empty(),
|
||||||
|
Optional.empty(), lifecycle == GameLifecycle.PAUSED
|
||||||
|
? Optional.of(java.time.Instant.EPOCH) : Optional.empty(),
|
||||||
|
Duration.ZERO, 16, choices, unlocks, claims
|
||||||
|
);
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.snapshot()).thenReturn(new PersistentState(game, Map.of()));
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(activeTyrant ? id : UUID.randomUUID());
|
||||||
|
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||||
|
when(player.getInventory()).thenReturn(inventory);
|
||||||
|
when(inventory.firstEmpty()).thenReturn(0);
|
||||||
|
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
|
||||||
|
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService(), factory);
|
||||||
|
|
||||||
|
command.onCommand(player, mock(Command.class), "tyrant", new String[] {"gear", "elytra"});
|
||||||
|
|
||||||
|
verify(manager, never()).replaceState(any());
|
||||||
|
verify(manager, never()).saveIfDirty();
|
||||||
|
verify(factory, never()).create(any(), any());
|
||||||
|
verify(inventory, never()).setItem(org.mockito.ArgumentMatchers.anyInt(), any());
|
||||||
|
verify(player).sendMessage(org.mockito.ArgumentMatchers.contains("Could not claim elytra"));
|
||||||
|
assertEquals(game, manager.snapshot().game());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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 java.time.Duration;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class TyrantLegacyRewardPanelTest {
|
||||||
|
@Test
|
||||||
|
void allTenRewardsFitInDistinctSlotsWithoutOverwritingNavigation() {
|
||||||
|
int[] slots = BukkitTyrantControlPanelRenderer.legacyRewardSlots();
|
||||||
|
assertEquals(10, slots.length);
|
||||||
|
assertEquals(TyrantArmorPiece.values().length, slots.length);
|
||||||
|
assertEquals(10, Arrays.stream(slots).distinct().count());
|
||||||
|
assertTrue(Arrays.stream(slots).allMatch(slot -> slot >= 0 && slot < 27 && slot != 22));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void confirmationShowsExactEnchantmentsAndCostForEveryReward() {
|
||||||
|
for (TyrantArmorPiece piece : TyrantArmorPiece.values()) {
|
||||||
|
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(piece, "Alex");
|
||||||
|
assertEquals(spec.protectionLevel() > 0, spec.confirmationLore().contains("Protection V"));
|
||||||
|
assertEquals(spec.sharpnessLevel() > 0, spec.confirmationLore().contains("Sharpness VI"));
|
||||||
|
assertEquals(spec.efficiencyLevel() > 0, spec.confirmationLore().contains("Efficiency VI"));
|
||||||
|
assertEquals(spec.mendingLevel() > 0, spec.confirmationLore().contains("Mending I"));
|
||||||
|
assertEquals(piece == TyrantArmorPiece.ELYTRA, spec.confirmationLore().contains("Unbreakable"));
|
||||||
|
assertTrue(spec.confirmationLore().contains("Cost: 1 choice"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void toolAvailabilityDoesNotRequireArmorAndClaimedToolsBecomeUnavailable() {
|
||||||
|
UUID tyrant = UUID.randomUUID();
|
||||||
|
GameState game = new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||||
|
6, 9, EnumSet.allOf(TyrantUnlock.class), Set.of()
|
||||||
|
);
|
||||||
|
TyrantArmorService service = new TyrantArmorService();
|
||||||
|
for (TyrantArmorPiece piece : TyrantArmorPiece.regularRewards()) {
|
||||||
|
TyrantControlPanelModel before = model(game);
|
||||||
|
assertTrue(before.armorAvailable(piece));
|
||||||
|
game = service.claim(game, tyrant, piece).state();
|
||||||
|
assertFalse(model(game).armorAvailable(piece));
|
||||||
|
}
|
||||||
|
assertFalse(model(game).armorAvailable(TyrantArmorPiece.ELYTRA));
|
||||||
|
assertTrue(model(game).armorLore(TyrantArmorPiece.ELYTRA).contains("Earn another choice first."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TyrantControlPanelModel model(GameState game) {
|
||||||
|
return TyrantControlPanelModel.create(game,
|
||||||
|
PlayerState.newPlayer(game.tyrantId().orElseThrow(), "Alex"), Map.of(),
|
||||||
|
java.time.Instant.EPOCH);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package games.dmg.spigottyrant;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -47,6 +48,45 @@ final class TyrantProgressionServiceTest {
|
|||||||
assertEquals(paused, service.recordVigilanteDeath(paused, Optional.of(TYRANT)));
|
assertEquals(paused, service.recordVigilanteDeath(paused, Optional.of(TYRANT)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void administrativePointGrantAddsOneLevelAndChoicePerInvocation() {
|
||||||
|
GameState base = runningState(2, 1, Set.of(TyrantUnlock.ASSASSIN));
|
||||||
|
GameState before = new GameState(
|
||||||
|
base.lifecycle(), base.tyrantId(), base.vigilanteId(), base.pendingTyrant(),
|
||||||
|
base.pendingVigilante(), base.pausedAt(), base.accumulatedPausedTime(),
|
||||||
|
base.tyrantLevel(), base.unspentChoices(), base.purchases(),
|
||||||
|
Set.of(TyrantArmorPiece.HELMET)
|
||||||
|
);
|
||||||
|
|
||||||
|
GameState first = service.grantPoint(before);
|
||||||
|
GameState second = service.grantPoint(first);
|
||||||
|
|
||||||
|
assertEquals(4, second.tyrantLevel());
|
||||||
|
assertEquals(3, second.unspentChoices());
|
||||||
|
assertEquals(before.purchases(), second.purchases());
|
||||||
|
assertEquals(before.tyrantId(), second.tyrantId());
|
||||||
|
assertEquals(before.vigilanteId(), second.vigilanteId());
|
||||||
|
assertEquals(before.claimedArmor(), second.claimedArmor());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void administrativePointGrantRequiresRunningGameAndCurrentTyrant() {
|
||||||
|
GameState running = runningState(0, 1, Set.of());
|
||||||
|
GameState paused = new GameState(
|
||||||
|
GameLifecycle.PAUSED, running.tyrantId(), running.vigilanteId(),
|
||||||
|
running.pendingTyrant(), running.pendingVigilante(),
|
||||||
|
Optional.of(java.time.Instant.parse("2026-08-14T12:00:00Z")),
|
||||||
|
Duration.ZERO, 0, 1, Set.of()
|
||||||
|
);
|
||||||
|
GameState vacant = new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.empty(), Optional.empty(), Optional.empty(),
|
||||||
|
Optional.empty(), Optional.empty(), Duration.ZERO, 0, 0, Set.of()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> service.grantPoint(paused));
|
||||||
|
assertThrows(IllegalStateException.class, () -> service.grantPoint(vacant));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void purchaseConsumesOneChoiceAndCannotBeBoughtTwice() {
|
void purchaseConsumesOneChoiceAndCannotBeBoughtTwice() {
|
||||||
GameState before = runningState(1, 2, Set.of());
|
GameState before = runningState(1, 2, Set.of());
|
||||||
@@ -90,17 +130,24 @@ final class TyrantProgressionServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void endingReignClearsLevelChoicesAndPurchases() {
|
void endingReignClearsLevelChoicesAndPurchases() {
|
||||||
GameState progressed = runningState(
|
GameState base = runningState(
|
||||||
4,
|
4,
|
||||||
2,
|
2,
|
||||||
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
|
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
|
||||||
);
|
);
|
||||||
|
GameState progressed = new GameState(
|
||||||
|
base.lifecycle(), base.tyrantId(), base.vigilanteId(), base.pendingTyrant(),
|
||||||
|
base.pendingVigilante(), base.pausedAt(), base.accumulatedPausedTime(),
|
||||||
|
base.tyrantLevel(), base.unspentChoices(), base.purchases(),
|
||||||
|
Set.of(TyrantArmorPiece.BOOTS)
|
||||||
|
);
|
||||||
|
|
||||||
GameState reset = service.resetReignProgression(progressed);
|
GameState reset = service.resetReignProgression(progressed);
|
||||||
|
|
||||||
assertEquals(0, reset.tyrantLevel());
|
assertEquals(0, reset.tyrantLevel());
|
||||||
assertEquals(0, reset.unspentChoices());
|
assertEquals(0, reset.unspentChoices());
|
||||||
assertEquals(Set.of(), reset.purchases());
|
assertEquals(Set.of(), reset.purchases());
|
||||||
|
assertEquals(Set.of(), reset.claimedArmor());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GameState runningState(
|
private static GameState runningState(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package games.dmg.spigottyrant;
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -9,7 +8,6 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.random.RandomGenerator;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
final class TyrantSuccessionServiceTest {
|
final class TyrantSuccessionServiceTest {
|
||||||
@@ -18,26 +16,26 @@ final class TyrantSuccessionServiceTest {
|
|||||||
private static final UUID KILLER = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
private static final UUID KILLER = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||||
private static final UUID OTHER = UUID.fromString("44444444-4444-4444-4444-444444444444");
|
private static final UUID OTHER = UUID.fromString("44444444-4444-4444-4444-444444444444");
|
||||||
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
|
private static final Instant NOW = Instant.parse("2026-08-14T12:00:00Z");
|
||||||
private final TyrantSuccessionService service = new TyrantSuccessionService(
|
private final TyrantSuccessionService service = new TyrantSuccessionService();
|
||||||
new RoleCandidateSelector(),
|
|
||||||
Duration.ofHours(24),
|
|
||||||
Duration.ofHours(24),
|
|
||||||
RandomGenerator.of("L64X128MixRandom")
|
|
||||||
);
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void playerKillerBecomesTyrantAndEntireReignIsReset() {
|
void playerKillerBecomesTyrantAndEntireReignIsReset() {
|
||||||
PersistentState before = progressedState();
|
PersistentState before = progressedState();
|
||||||
|
|
||||||
LifecycleState after = service.succeed(
|
LifecycleState after = service.succeed(
|
||||||
before, Optional.of(KILLER), Set.of(KILLER, OTHER), NOW
|
before, Optional.of(KILLER)
|
||||||
);
|
);
|
||||||
|
|
||||||
assertEquals(Optional.of(KILLER), after.game().tyrantId());
|
assertEquals(Optional.of(KILLER), after.game().tyrantId());
|
||||||
assertEquals(0, after.game().tyrantLevel());
|
assertEquals(0, after.game().tyrantLevel());
|
||||||
assertEquals(1, after.game().unspentChoices());
|
assertEquals(1, after.game().unspentChoices());
|
||||||
assertEquals(Set.of(), after.game().purchases());
|
assertEquals(Set.of(), after.game().purchases());
|
||||||
assertNotEquals(after.game().tyrantId(), after.game().vigilanteId());
|
assertEquals(Set.of(), after.game().claimedArmor());
|
||||||
|
assertEquals(Optional.empty(), after.game().vigilanteId());
|
||||||
|
assertEquals(Optional.empty(), after.game().pendingVigilante());
|
||||||
|
assertEquals(
|
||||||
|
Optional.of(ArenaRole.VIGILANTE), RoleArenaPolicy.openRole(after.game())
|
||||||
|
);
|
||||||
for (PlayerState player : after.players().values()) {
|
for (PlayerState player : after.players().values()) {
|
||||||
assertEquals(TyrantClass.NONE, player.tyrantClass());
|
assertEquals(TyrantClass.NONE, player.tyrantClass());
|
||||||
assertEquals(Optional.empty(), player.followerOf());
|
assertEquals(Optional.empty(), player.followerOf());
|
||||||
@@ -46,22 +44,22 @@ final class TyrantSuccessionServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void environmentalDeathRandomlySelectsRecentPlayerInsteadOfFormerTyrant() {
|
void environmentalDeathOpensTyrantArenaWithoutPendingSelection() {
|
||||||
PersistentState before = progressedState();
|
PersistentState before = progressedState();
|
||||||
|
|
||||||
LifecycleState after = service.succeed(before, Optional.empty(), Set.of(OTHER), NOW);
|
LifecycleState after = service.succeed(before, Optional.empty());
|
||||||
|
|
||||||
UUID selected = after.game().tyrantId().orElseGet(() ->
|
assertEquals(Optional.empty(), after.game().tyrantId());
|
||||||
after.game().pendingTyrant().orElseThrow().candidateId()
|
assertEquals(Optional.empty(), after.game().pendingTyrant());
|
||||||
);
|
assertEquals(Optional.empty(), after.game().vigilanteId());
|
||||||
assertNotEquals(OLD_TYRANT, selected);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PersistentState progressedState() {
|
private static PersistentState progressedState() {
|
||||||
GameState game = new GameState(
|
GameState game = new GameState(
|
||||||
GameLifecycle.RUNNING, Optional.of(OLD_TYRANT), Optional.of(OLD_VIGILANTE),
|
GameLifecycle.RUNNING, Optional.of(OLD_TYRANT), Optional.of(OLD_VIGILANTE),
|
||||||
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||||
5, 2, Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
|
5, 2, Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH),
|
||||||
|
java.util.EnumSet.allOf(TyrantArmorPiece.class)
|
||||||
);
|
);
|
||||||
Map<UUID, PlayerState> players = Map.of(
|
Map<UUID, PlayerState> players = Map.of(
|
||||||
OLD_TYRANT, active(new PlayerState(
|
OLD_TYRANT, active(new PlayerState(
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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 static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class TyrantTabCompleterTest {
|
||||||
|
@Test
|
||||||
|
void rootBuyAssignAndConfirmationSuggestionsFollowSyntaxAndPrefix() {
|
||||||
|
TyrantTabCompleter completer = new TyrantTabCompleter(
|
||||||
|
mock(TyrantStateManager.class), mock(OnlinePlayerDirectory.class)
|
||||||
|
);
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
|
||||||
|
List<String> root = completer.onTabComplete(
|
||||||
|
player, command, "tyrant", new String[] {""}
|
||||||
|
);
|
||||||
|
List<String> buy = completer.onTabComplete(
|
||||||
|
player, command, "tyrant", new String[] {"buy", "ro"}
|
||||||
|
);
|
||||||
|
List<String> armor = completer.onTabComplete(
|
||||||
|
player, command, "tyrant", new String[] {"armor", "ch"}
|
||||||
|
);
|
||||||
|
List<String> armorExtra = completer.onTabComplete(
|
||||||
|
player, command, "tyrant", new String[] {"armor", "helmet", ""}
|
||||||
|
);
|
||||||
|
List<String> assign = completer.onTabComplete(
|
||||||
|
player, command, "tyrant", new String[] {"assign", "f"}
|
||||||
|
);
|
||||||
|
List<String> confirm = completer.onTabComplete(
|
||||||
|
player, command, "tyrant", new String[] {"relinquish", "c"}
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(root.containsAll(List.of(
|
||||||
|
"menu", "status", "buy", "armor", "assign", "item"
|
||||||
|
)));
|
||||||
|
assertTrue(root.contains("gear"));
|
||||||
|
assertEquals(List.of("axe", "elytra", "hoe", "pickaxe", "shovel", "sword"),
|
||||||
|
completer.onTabComplete(player, command, "tyrant", new String[] {"gear", ""}));
|
||||||
|
assertEquals(List.of("elytra"), completer.onTabComplete(
|
||||||
|
player, command, "tyrant", new String[] {"GEAR", "EL"}));
|
||||||
|
assertEquals(List.of(), completer.onTabComplete(
|
||||||
|
player, command, "tyrant", new String[] {"gear", "elytra", ""}));
|
||||||
|
assertEquals(List.of(), completer.onTabComplete(
|
||||||
|
player, command, "tyrant", new String[] {"armor", "el"}));
|
||||||
|
assertEquals(List.of("shovel", "sword"),
|
||||||
|
completer.onTabComplete(player, command, "tyrant", new String[] {"GEAR", "S"}));
|
||||||
|
assertEquals(List.of(), completer.onTabComplete(
|
||||||
|
player, command, "tyrant", new String[] {"gear", "axe", ""}));
|
||||||
|
assertEquals(List.of("boots", "chestplate", "helmet", "leggings"),
|
||||||
|
completer.onTabComplete(player, command, "tyrant", new String[] {"armor", ""}));
|
||||||
|
assertFalse(root.contains("SomePlayer"));
|
||||||
|
assertEquals(List.of("roster_intelligence"), buy);
|
||||||
|
assertEquals(List.of("chestplate"), armor);
|
||||||
|
assertEquals(List.of(), armorExtra);
|
||||||
|
assertEquals(List.of("fixer"), assign);
|
||||||
|
assertEquals(List.of("confirm"), confirm);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package games.dmg.spigottyrant;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class VigilanteTabCompleterTest {
|
||||||
|
@Test
|
||||||
|
void rootAndPlayerArgumentsUseMembershipEligibility() {
|
||||||
|
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||||
|
UUID eligibleId = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||||
|
UUID followerId = UUID.fromString("44444444-4444-4444-4444-444444444444");
|
||||||
|
PlayerState eligible = PlayerState.newPlayer(eligibleId, "Eligible");
|
||||||
|
PlayerState follower = follower(followerId, "Follower", vigilanteId);
|
||||||
|
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||||
|
when(manager.game()).thenReturn(new GameState(
|
||||||
|
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.of(vigilanteId),
|
||||||
|
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||||
|
0, 0, Set.of()
|
||||||
|
));
|
||||||
|
when(manager.players()).thenReturn(Map.of(eligibleId, eligible, followerId, follower));
|
||||||
|
OnlinePlayerDirectory directory = mock(OnlinePlayerDirectory.class);
|
||||||
|
when(directory.onlinePlayerIds()).thenReturn(Set.of(eligibleId, followerId, tyrantId));
|
||||||
|
Player eligiblePlayer = player(eligibleId, "Eligible");
|
||||||
|
Player followerPlayer = player(followerId, "Follower");
|
||||||
|
Player tyrantPlayer = player(tyrantId, "Tyrant");
|
||||||
|
when(directory.findById(eligibleId)).thenReturn(eligiblePlayer);
|
||||||
|
when(directory.findById(followerId)).thenReturn(followerPlayer);
|
||||||
|
when(directory.findById(tyrantId)).thenReturn(tyrantPlayer);
|
||||||
|
VigilanteTabCompleter completer = new VigilanteTabCompleter(
|
||||||
|
manager, new FollowerService(), directory
|
||||||
|
);
|
||||||
|
Player vigilante = player(vigilanteId, "Vigilante");
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
|
||||||
|
assertTrue(completer.onTabComplete(
|
||||||
|
vigilante, command, "vigilante", new String[] {""}
|
||||||
|
).containsAll(java.util.List.of("menu", "item", "invite", "dismiss")));
|
||||||
|
assertEquals(java.util.List.of("Eligible"), completer.onTabComplete(
|
||||||
|
vigilante, command, "vigilante", new String[] {"invite", ""}
|
||||||
|
));
|
||||||
|
assertEquals(java.util.List.of("Follower"), completer.onTabComplete(
|
||||||
|
vigilante, command, "vigilante", new String[] {"dismiss", ""}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Player player(UUID id, String name) {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(id);
|
||||||
|
when(player.getName()).thenReturn(name);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerState follower(UUID id, String name, UUID vigilanteId) {
|
||||||
|
PlayerState state = PlayerState.newPlayer(id, name);
|
||||||
|
return new PlayerState(
|
||||||
|
id, name, state.lastLogin(), state.optedOutUntil(), state.tyrantClass(),
|
||||||
|
Optional.of(vigilanteId), state.cooldownEnds(), state.readyAbilityItems(),
|
||||||
|
state.capturedMobs()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ final class YamlTyrantStateRepositoryTest {
|
|||||||
Duration.ofMinutes(15),
|
Duration.ofMinutes(15),
|
||||||
3,
|
3,
|
||||||
2,
|
2,
|
||||||
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
|
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH),
|
||||||
|
Set.of(TyrantArmorPiece.HELMET, TyrantArmorPiece.BOOTS)
|
||||||
);
|
);
|
||||||
PlayerState player = new PlayerState(
|
PlayerState player = new PlayerState(
|
||||||
tyrant,
|
tyrant,
|
||||||
@@ -56,6 +57,84 @@ final class YamlTyrantStateRepositoryTest {
|
|||||||
assertEquals(expected, repository.load());
|
assertEquals(expected, repository.load());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacyArmorLoadsWithoutGrantingToolsAndMixedClaimsSurviveRestart() throws Exception {
|
||||||
|
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||||
|
Files.writeString(stateFile, """
|
||||||
|
game:
|
||||||
|
lifecycle: RUNNING
|
||||||
|
tyrant-id: 11111111-1111-1111-1111-111111111111
|
||||||
|
tyrant-level: 6
|
||||||
|
unspent-choices: 9
|
||||||
|
purchases: [ASSASSIN, FIXER, TAMER, ROSTER_INTELLIGENCE, RESISTANCE, STRENGTH]
|
||||||
|
claimed-armor: [HELMET, BOOTS]
|
||||||
|
""");
|
||||||
|
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(stateFile);
|
||||||
|
GameState loaded = repository.load().game();
|
||||||
|
assertEquals(Set.of(TyrantArmorPiece.HELMET, TyrantArmorPiece.BOOTS), loaded.claimedArmor());
|
||||||
|
TyrantArmorService service = new TyrantArmorService();
|
||||||
|
for (TyrantArmorPiece piece : TyrantArmorPiece.regularRewards()) {
|
||||||
|
if (!piece.isArmor()) {
|
||||||
|
ArmorClaimResult result = service.claim(loaded, loaded.tyrantId().orElseThrow(), piece);
|
||||||
|
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
|
||||||
|
loaded = result.state();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repository.save(new PersistentState(loaded, Map.of()));
|
||||||
|
assertEquals(loaded, new YamlTyrantStateRepository(stateFile).load().game());
|
||||||
|
Files.writeString(stateFile, "game: {}\n");
|
||||||
|
assertEquals(Set.of(), new YamlTyrantStateRepository(stateFile).load().game().claimedArmor());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oldNineRewardSaveEnablesFinalClaimAndElytraRoundTripsUnderHistoricalKey() throws Exception {
|
||||||
|
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||||
|
Files.writeString(stateFile, """
|
||||||
|
game:
|
||||||
|
lifecycle: RUNNING
|
||||||
|
tyrant-id: 11111111-1111-1111-1111-111111111111
|
||||||
|
tyrant-level: 16
|
||||||
|
unspent-choices: 2
|
||||||
|
purchases: [ASSASSIN, FIXER, TAMER, ROSTER_INTELLIGENCE, RESISTANCE, STRENGTH]
|
||||||
|
claimed-armor: [HELMET, CHESTPLATE, LEGGINGS, BOOTS, AXE, PICKAXE, SWORD, HOE, SHOVEL]
|
||||||
|
""");
|
||||||
|
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(stateFile);
|
||||||
|
GameState loaded = repository.load().game();
|
||||||
|
assertEquals(TyrantArmorPiece.regularRewards(), loaded.claimedArmor());
|
||||||
|
assertEquals(9, loaded.claimedArmor().size());
|
||||||
|
TyrantArmorService service = new TyrantArmorService();
|
||||||
|
ArmorClaimResult claim = service.claim(
|
||||||
|
loaded, loaded.tyrantId().orElseThrow(), TyrantArmorPiece.ELYTRA
|
||||||
|
);
|
||||||
|
assertEquals(ArmorClaimStatus.CLAIMED, claim.status());
|
||||||
|
assertEquals(1, claim.state().unspentChoices());
|
||||||
|
assertEquals(java.util.EnumSet.allOf(TyrantArmorPiece.class), claim.state().claimedArmor());
|
||||||
|
PersistentState expected = new PersistentState(claim.state(), Map.of());
|
||||||
|
repository.save(expected);
|
||||||
|
|
||||||
|
org.bukkit.configuration.file.YamlConfiguration yaml =
|
||||||
|
org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(stateFile.toFile());
|
||||||
|
assertEquals(claim.state().claimedArmor().stream().map(Enum::name).sorted().toList(),
|
||||||
|
yaml.getStringList("game.claimed-armor"));
|
||||||
|
PersistentState restarted = new YamlTyrantStateRepository(stateFile).load();
|
||||||
|
assertEquals(expected, restarted);
|
||||||
|
ArmorClaimResult duplicate = service.claim(
|
||||||
|
restarted.game(), loaded.tyrantId().orElseThrow(), TyrantArmorPiece.ELYTRA
|
||||||
|
);
|
||||||
|
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED, duplicate.status());
|
||||||
|
assertEquals(restarted.game(), duplicate.state());
|
||||||
|
|
||||||
|
LifecycleState nextReign = new TyrantSuccessionService().succeed(
|
||||||
|
restarted, Optional.of(UUID.fromString("22222222-2222-2222-2222-222222222222"))
|
||||||
|
);
|
||||||
|
repository.save(new PersistentState(nextReign.game(), nextReign.players()));
|
||||||
|
GameState reset = new YamlTyrantStateRepository(stateFile).load().game();
|
||||||
|
assertEquals(nextReign.game(), reset);
|
||||||
|
assertEquals(Set.of(), reset.claimedArmor());
|
||||||
|
assertEquals(Set.of(), reset.purchases());
|
||||||
|
assertEquals(1, reset.unspentChoices());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void preservesUnknownFieldsForRetainedState() throws Exception {
|
void preservesUnknownFieldsForRetainedState() throws Exception {
|
||||||
UUID playerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
UUID playerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
|||||||
Reference in New Issue
Block a user