Compare commits

...
5 Commits
Author SHA1 Message Date
dmg 553db666e1 feat(armor): add Tyrant legacy rewards
Release / release (push) Successful in 2m39s
CI / build (push) Successful in 1m10s
2026-09-04 22:54:11 -04:00
dmg e5de6b76e7 feat(admin): add Tyrant point grant command
Release / release (push) Successful in 2m27s
CI / build (push) Successful in 1m4s
2026-09-04 22:12:47 -04:00
dmg 4b6c709157 fix(arena): keep vigilante victory private
Release / release (push) Successful in 4m34s
CI / build (push) Successful in 1m16s
2026-09-04 22:05:28 -04:00
dmg 17a5c66575 fix(arena): move winners outside after victory 2026-09-04 22:01:52 -04:00
dmg f0dbde17b8 fix(arena): improve containment and boss idle
Release / release (push) Successful in 2m31s
CI / build (push) Successful in 1m8s
2026-08-23 18:13:24 -04:00
55 changed files with 1206 additions and 61 deletions
+2 -1
View File
@@ -6,7 +6,7 @@ description: Defines the durable state and timekeeping needed to restore a Tyran
# Persistence Model # Persistence Model
State is keyed by player UUID while retaining the latest known player name for display. Durable global state includes lifecycle status, current and pending role holders, selection deadlines, paused durations, the current Tyrant reign, and the administrator-configured Vigilante arena center. Active Tyrant or Vigilante challenge bosses and challenger entities are transient and are reconstructed or reset from authoritative role and shared-arena state after restart. State is keyed by player UUID while retaining the latest known player name for display. Durable global state includes lifecycle status, current and pending role holders, selection deadlines, paused durations, the current Tyrant reign and its claimed legacy armor slots, and the administrator-configured Vigilante arena center. Active Tyrant or Vigilante challenge bosses and challenger entities are transient and are reconstructed or reset from authoritative role and shared-arena state after restart.
Durable player state includes participation status, opt-out deadline, login history, role or class, Tyrant level, purchased unlocks, unspent choices, Follower relationship, ability cooldowns, daily intelligence use, bound-item delivery state, and captured mobs. Durable player state includes participation status, opt-out deadline, login history, role or class, Tyrant level, purchased unlocks, unspent choices, Follower relationship, ability cooldowns, daily intelligence use, bound-item delivery state, and captured mobs.
@@ -19,3 +19,4 @@ Elapsed-time rules use UTC instants. Paused time is excluded from timers. Saves
- [Use class ability items](../user-stories/us-014-use-class-ability-items.md) - [Use class ability items](../user-stories/us-014-use-class-ability-items.md)
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md) - [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md)
- [Claim the Tyrant role in the arena](../user-stories/us-020-claim-tyrant-in-arena.md) - [Claim the Tyrant role in the arena](../user-stories/us-020-claim-tyrant-in-arena.md)
- [Claim Tyrant legacy armor](../user-stories/us-021-claim-tyrant-legacy-armor.md)
+5
View File
@@ -23,6 +23,10 @@ The Tyrant starts each reign at level zero with one starting unlock choice. Pers
- Permanent Resistance - Permanent Resistance
- Permanent Strength - Permanent Strength
## Legacy armor
After purchasing all six standard reign unlocks, the Tyrant may spend later choices on one each of four legacy armor slots. These named Netherite rewards are tracked only to prevent duplicate claims during the current reign; the resulting armor remains ordinary transferable equipment and survives role succession in player inventories.
## Proximity ## Proximity
Unless a more specific rule applies, "near the Tyrant" means in the same world and within a configurable 50-block three-dimensional distance. Unless a more specific rule applies, "near the Tyrant" means in the same world and within a configurable 50-block three-dimensional distance.
@@ -38,3 +42,4 @@ Vigilante Follower scaling is capped by default at Strength V and Resistance IV,
- [Support the Vigilante and Followers](../user-stories/us-008-support-vigilante-and-followers.md) - [Support the Vigilante and Followers](../user-stories/us-008-support-vigilante-and-followers.md)
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md) - [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md)
- [Claim the Tyrant role in the arena](../user-stories/us-020-claim-tyrant-in-arena.md) - [Claim the Tyrant role in the arena](../user-stories/us-020-claim-tyrant-in-arena.md)
- [Claim Tyrant legacy armor](../user-stories/us-021-claim-tyrant-legacy-armor.md)
+1
View File
@@ -14,6 +14,7 @@ This bundle documents the asymmetric Tyrant game, its role succession, class abi
- [User stories](user-stories/index.md) - [User stories](user-stories/index.md)
- [Vigilante arena challenge](user-stories/us-019-claim-vigilante-in-arena.md) - [Vigilante arena challenge](user-stories/us-019-claim-vigilante-in-arena.md)
- [Tyrant arena challenge](user-stories/us-020-claim-tyrant-in-arena.md) - [Tyrant arena challenge](user-stories/us-020-claim-tyrant-in-arena.md)
- [Tyrant legacy armor](user-stories/us-021-claim-tyrant-legacy-armor.md)
- [Game state machine](concepts/game-state-machine.md) - [Game state machine](concepts/game-state-machine.md)
- [Role and class model](concepts/role-and-class-model.md) - [Role and class model](concepts/role-and-class-model.md)
- [Persistence model](concepts/persistence-model.md) - [Persistence model](concepts/persistence-model.md)
+32
View File
@@ -6,6 +6,38 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log # Spigot Tyrant Design Log
## 2026-09-04 — Tyrant legacy armor completed
- Added four once-per-reign Netherite legacy armor rewards after all six standard unlocks are purchased, with each successful claim consuming one choice.
- Each named reward identifies its Tyrant in custom lore and carries Protection V and Mending I while remaining ordinary tradable, retainable, and non-recoverable equipment.
- Added command and control-panel selection with confirmation, full-inventory safety, durable claimed-slot state, succession reset, and contextual completion.
- Verified progression, duplicate prevention, item specifications, inventory delivery, persistence, reign reset, UI policy, completion, 134 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`.
## 2026-09-04 — Administrative Tyrant point grant completed
- Added `/tyrantadmin grantpoint` so a permitted administrator can grant the current Tyrant exactly one level and one unspent unlock choice during a running game.
- Grants preserve purchases and unrelated state, persist immediately, report updated progression, reject vacancies and non-running games safely, and appear in contextual command completion.
- Verified progression and command behavior, permission enforcement, completion, 127 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`.
## 2026-09-04 — Vigilante arena victory kept private
- Removed the server-wide Vigilante arena victory announcement that exposed the new Vigilante's identity.
- The winning Vigilante now receives a private confirmation, while the publicly identifiable Tyrant victory announcement remains unchanged.
- Verified private Vigilante and public Tyrant messaging, 121 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`.
## 2026-09-04 — Arena winners safely extracted
- Corrected shared-role arena completion so a successful Tyrant or Vigilante challenger is moved safely outside immediately after assignment and barrier removal.
- Prevented the next challenge's containment enforcement from treating the previous winner as an unauthorized entrant inside the arena.
- Verified both role-victory paths, 121 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`.
## 2026-08-23 — Arena containment and idle boss corrected
- Corrected shared-arena containment with a validated 12-block barrier wall extending below the arena floor and active-fight block placement and breaking protection.
- Kept red or blue role particles visible at multiple wall heights during fights so challengers can see the otherwise invisible barrier from inside.
- Arena bosses now remain AI-disabled at the center until an eligible challenger enters, move normally within the arena during combat, and return healed and idle only on reset or boundary escape.
- Verified wall geometry, safe restoration, boundary visibility policy, build protection, idle/combat boss transitions, configuration validation, compiler warnings, 119 automated tests, and packaging with `./gradlew clean check jar`.
## 2026-08-23 — Shared Tyrant and Vigilante arena succession completed ## 2026-08-23 — Shared Tyrant and Vigilante arena succession completed
- Completed US-020 and reopened related succession, inactivity, administration, configuration, and Vigilante-arena stories so non-player Tyrant replacement is earned rather than random. - Completed US-020 and reopened related succession, inactivity, administration, configuration, and Vigilante-arena stories so non-player Tyrant replacement is earned rather than random.
+1
View File
@@ -20,3 +20,4 @@
18. [US-018: Complete Tyrant commands contextually](us-018-complete-commands-contextually.md) 18. [US-018: Complete Tyrant commands contextually](us-018-complete-commands-contextually.md)
19. [US-019: Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md) 19. [US-019: Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
20. [US-020: Claim the Tyrant role in the arena](us-020-claim-tyrant-in-arena.md) 20. [US-020: Claim the Tyrant role in the arena](us-020-claim-tyrant-in-arena.md)
21. [US-021: Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
@@ -18,6 +18,9 @@ As a **server operator**, I want to start, pause, resume, and inspect the game s
- [x] Role and class effects are suppressed while paused and restored when play resumes. - [x] Role and class effects are suppressed while paused and restored when play resumes.
- [x] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default. - [x] Administrative commands require the `spigottyrant.admin` permission, granted to server operators by default.
- [x] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, legacy pending selections, and the configured shared role arena location. - [x] Administrators can inspect lifecycle state, roles, classes, Followers, purchases, levels, cooldowns, participation, legacy pending selections, and the configured shared role arena location.
- [x] `/tyrantadmin grantpoint` requires administrative permission and grants the current Tyrant exactly one level and one unspent unlock choice.
- [x] A successful administrative point grant persists immediately and confirms the updated level and choice count to the administrator.
- [x] If the game is not running or has no current Tyrant, an administrative point grant changes nothing and reports a clear error.
- [x] Destructive administrative operations require explicit confirmation. - [x] Destructive administrative operations require explicit confirmation.
## Related ## Related
@@ -12,6 +12,9 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
## Acceptance criteria ## Acceptance criteria
- [x] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante. - [x] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante.
- [x] An administrator can grant the current Tyrant one level and one unlock choice without changing purchases or unrelated game state.
- [x] Each successful administrative grant adds exactly one level and one choice, including across repeated invocations.
- [x] After purchasing all six standard unlocks, the Tyrant may spend later choices on one of each legacy armor slot during the reign.
- [x] A Vigilante death caused by another player, the environment, or the Vigilante does not level the Tyrant. - [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] 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] Each class or ability can be purchased at most once during a reign.
@@ -29,3 +32,4 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
- [Assign unlocked classes](us-004-assign-unlocked-classes.md) - [Assign unlocked classes](us-004-assign-unlocked-classes.md)
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md) - [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md) - [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
- [Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
@@ -11,10 +11,10 @@ As a **server operator**, I want configurable and durable game behavior so that
## Acceptance criteria ## Acceptance criteria
- [x] Configuration covers ranges, durations, cooldowns, inactivity and maintenance intervals, effect levels and caps, mob restrictions, item materials and names, messages, timer behavior, and shared-role-arena boss balance. - [x] Configuration covers ranges, durations, cooldowns, inactivity and maintenance intervals, effect levels and caps, mob restrictions, item materials and names, messages, timer behavior, shared-role-arena boss balance, and a validated barrier wall height defaulting to 12 blocks.
- [x] Defaults match the approved user stories, including a 50-block Tyrant range and Follower range, seven-day opt-out, 48-hour inactivity, and one-minute role maintenance. - [x] Defaults match the approved user stories, including a 50-block Tyrant range and Follower range, seven-day opt-out, 48-hour inactivity, and one-minute role maintenance.
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message. - [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
- [x] UUID-keyed state stores lifecycle, current and pending roles, assignments, login history, participation, reign progression, purchases, choices, cooldowns, paused time, item delivery, and captured mobs; the administrator-set arena center is durable across restarts. - [x] UUID-keyed state stores lifecycle, current and pending roles, assignments, login history, participation, reign progression, purchases, choices, claimed legacy armor slots, cooldowns, paused time, item delivery, and captured mobs; the administrator-set arena center is durable across restarts.
- [x] Cooldowns and deadlines use UTC instants and exclude administratively paused time. - [x] Cooldowns and deadlines use UTC instants and exclude administratively paused time.
- [x] State is saved using atomic replacement where supported so failed writes do not replace valid state with partial data. - [x] State is saved using atomic replacement where supported so failed writes do not replace valid state with partial data.
- [x] Corrupt, unknown, or invalid records cannot silently grant progression, roles, powers, items, or duplicated mobs. - [x] Corrupt, unknown, or invalid records cannot silently grant progression, roles, powers, items, or duplicated mobs.
@@ -27,3 +27,4 @@ As a **server operator**, I want configurable and durable game behavior so that
- [Build, test, and release the plugin](us-013-build-test-and-release.md) - [Build, test, and release the plugin](us-013-build-test-and-release.md)
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md) - [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
- [Claim the Tyrant role in the arena](us-020-claim-tyrant-in-arena.md) - [Claim the Tyrant role in the arena](us-020-claim-tyrant-in-arena.md)
- [Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
@@ -22,6 +22,7 @@ As the **Tyrant**, I want a control panel for my reign so that I can inspect pro
- [x] Purchased, available, unavailable, and cooldown-gated actions are visually distinguishable and explain why they cannot currently be used. - [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 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 review and purchase an available unlock after confirming the selected unlock and its choice cost.
- [x] After all standard unlocks are purchased, the Tyrant can select and confirm an unclaimed legacy armor slot through the control panel.
- [x] The Tyrant can select a purchased class and assign or reassign it to an eligible online player. - [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] 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] Class reassignment identifies the current holder and requires confirmation before replacing that holder.
@@ -44,3 +45,4 @@ Automated tests verify command entry points, active-Tyrant authorization, author
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md) - [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
- [Inform players and manage participation](us-011-inform-and-manage-participation.md) - [Inform players and manage participation](us-011-inform-and-manage-participation.md)
- [Use bound role control items](us-017-use-bound-role-control-items.md) - [Use bound role control items](us-017-use-bound-role-control-items.md)
- [Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
@@ -13,6 +13,7 @@ As a **player or administrator**, I want contextual command suggestions so that
- [x] `/tyrant` suggests its available subcommands instead of arbitrary player names. - [x] `/tyrant` suggests its available subcommands instead of arbitrary player names.
- [x] `/tyrant buy` suggests Tyrant unlock names. - [x] `/tyrant buy` suggests Tyrant unlock names.
- [x] `/tyrant armor` suggests `helmet`, `chestplate`, `leggings`, and `boots` and accepts no further arguments.
- [x] `/tyrant assign` suggests `assassin`, `fixer`, and `tamer`, followed by eligible online players. - [x] `/tyrant assign` suggests `assassin`, `fixer`, and `tamer`, followed by eligible online players.
- [x] `/tyrant relinquish` suggests `confirm`. - [x] `/tyrant relinquish` suggests `confirm`.
- [x] `/vigilante` suggests its available subcommands. - [x] `/vigilante` suggests its available subcommands.
@@ -22,6 +23,7 @@ As a **player or administrator**, I want contextual command suggestions so that
- [x] `/tyrantadmin start` suggests online Tyrant and Vigilante candidates. - [x] `/tyrantadmin start` suggests online Tyrant and Vigilante candidates.
- [x] `/tyrantadmin reset` suggests `confirm`. - [x] `/tyrantadmin reset` suggests `confirm`.
- [x] `/tyrantadmin arena` suggests `set` and `status`. - [x] `/tyrantadmin arena` suggests `set` and `status`.
- [x] `/tyrantadmin` suggests `grantpoint`, which accepts no additional arguments.
- [x] Suggestions are filtered case-insensitively by the partially typed argument. - [x] Suggestions are filtered case-insensitively by the partially typed argument.
- [x] Suggestions never include syntactically invalid options for the current argument position. - [x] Suggestions never include syntactically invalid options for the current argument position.
- [x] Console completion works for administrative commands without exposing player-only commands as executable console actions. - [x] Console completion works for administrative commands without exposing player-only commands as executable console actions.
@@ -29,7 +31,7 @@ As a **player or administrator**, I want contextual command suggestions so that
## Validation ## Validation
Automated tests verify root syntax, unlocks, classes, confirmations, eligible recruits, current Followers, permission gating, online administrative candidates, argument positions, and case-insensitive prefix filtering. The complete `./gradlew clean check jar` lifecycle passes. Automated tests verify root syntax, unlocks, legacy armor pieces, classes, confirmations, eligible recruits, current Followers, permission gating, online administrative candidates, the argument-free `grantpoint` command, argument positions, and case-insensitive prefix filtering. The complete `./gradlew clean check jar` lifecycle passes.
## Related ## Related
@@ -39,3 +41,4 @@ Automated tests verify root syntax, unlocks, classes, confirmations, eligible re
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md) - [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md) - [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md) - [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
- [Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
@@ -17,12 +17,17 @@ As an **opted-in participant**, I want a visible one-player boss challenge for a
- [x] The challenge boss defaults to an armored, 80-health enhanced zombie, does not burn in sunlight, and remains within a default 10-block-radius arena. - [x] The challenge boss defaults to an armored, 80-health enhanced zombie, does not burn in sunlight, and remains within a default 10-block-radius arena.
- [x] All online opted-in players are told when the role opens and receive the arena world and coordinates; opted-in players receive the same notice when they log in while it remains open, and opted-out players do not. - [x] All online opted-in players are told when the role opens and receive the arena world and coordinates; opted-in players receive the same notice when they log in while it remains open, and opted-out players do not.
- [x] Exactly one eligible opted-in non-Tyrant player may enter as challenger, including when entry is attempted by walking or teleportation. - [x] Exactly one eligible opted-in non-Tyrant player may enter as challenger, including when entry is attempted by walking or teleportation.
- [x] Starting a fight replaces the passive particle boundary with temporary actual `BARRIER` blocks that are removed safely without permanently changing existing blocks. - [x] Starting a fight builds temporary actual `BARRIER` blocks from one block below the arena floor through a default 12-block wall height, without permanently changing existing blocks.
- [x] Role-colored boundary particles remain visible from inside and outside an active fight at multiple wall heights.
- [x] Any non-challenger who enters or respawns inside the arena is moved safely outside it. - [x] Any non-challenger who enters or respawns inside the arena is moved safely outside it.
- [x] Existing mobs in the arena and mobs that spawn or enter it are removed while preserving the arena boss. - [x] Existing mobs in the arena and mobs that spawn or enter it are removed while preserving the arena boss.
- [x] With no challenger, the boss remains AI-disabled at the arena center; entry enables its AI and target, while reset restores full health, center position, no target, and disabled AI.
- [x] The boss attacks only the active challenger, and only that challenger can damage the boss. - [x] The boss attacks only the active challenger, and only that challenger can damage the boss.
- [x] Challenger death, disconnect, or leaving by any mechanism ends the fight and resets the boss to full health at its spawn; death counts as leaving and the same player may retry immediately. - [x] Challenger death, disconnect, or leaving by any mechanism ends the fight and resets the boss to full health at its spawn; death counts as leaving and the same player may retry immediately.
- [x] Block placement and breaking are denied inside the arena during an active fight so players cannot build over or modify the boundary.
- [x] Killing the boss assigns the challenger as Vigilante, removes the boss and barriers, and restores the passive particle boundary. - [x] Killing the boss assigns the challenger as Vigilante, removes the boss and barriers, and restores the passive particle boundary.
- [x] After winning, the new Vigilante is moved safely outside the arena before normal arena enforcement resumes.
- [x] The Vigilante winner receives a private confirmation, and no server-wide victory message names or reveals the new Vigilante.
- [x] Pausing, resetting, restarting, reloading, and repeated or concurrent events cannot duplicate the boss, barriers, challenger, or Vigilante assignment. - [x] Pausing, resetting, restarting, reloading, and repeated or concurrent events cannot duplicate the boss, barriers, challenger, or Vigilante assignment.
- [x] If the arena is not configured when a challenge should open, the Vigilante remains vacant and administrators receive a clear warning. - [x] If the arena is not configured when a challenge should open, the Vigilante remains vacant and administrators receive a clear warning.
@@ -15,9 +15,10 @@ As an **opted-in participant**, I want to earn a vacant Tyrant role through the
- [x] Tyrant death without a player killer, Tyrant inactivity, and Tyrant relinquishment end the reign, clear all role-side assignments, and open the shared arena with no active or pending Tyrant. - [x] Tyrant death without a player killer, Tyrant inactivity, and Tyrant relinquishment end the reign, clear all role-side assignments, and open the shared arena with no active or pending Tyrant.
- [x] The shared arena opens only one role challenge at a time: a vacant Tyrant takes priority, and the Vigilante challenge can open only after a Tyrant becomes active. - [x] The shared arena opens only one role challenge at a time: a vacant Tyrant takes priority, and the Vigilante challenge can open only after a Tyrant becomes active.
- [x] The passive arena boundary uses red particles for an open Tyrant challenge, blue particles for an open Vigilante challenge, and a neutral marker while neither role challenge is open. - [x] The passive arena boundary uses red particles for an open Tyrant challenge, blue particles for an open Vigilante challenge, and a neutral marker while neither role challenge is open.
- [x] The Tyrant challenge uses the existing configured arena, boss balance, mob exclusion, one-challenger barrier, containment, reset, notification, and missing-location rules. - [x] The Tyrant challenge uses the existing configured arena, boss balance, tall visible one-challenger barrier, block-change protection, idle-center boss behavior, containment, reset, notification, and missing-location rules.
- [x] An eligible opted-in participant, including the former Tyrant, may challenge for the vacant Tyrant role; an active Tyrant cannot enter any arena fight. - [x] An eligible opted-in participant, including the former Tyrant, may challenge for the vacant Tyrant role; an active Tyrant cannot enter any arena fight.
- [x] Defeating the Tyrant challenge boss assigns the challenger as Tyrant with level zero, no purchases, and exactly one starting unlock choice. - [x] Defeating the Tyrant challenge boss assigns the challenger as Tyrant with level zero, no purchases, and exactly one starting unlock choice.
- [x] After winning, the new Tyrant is moved safely outside before any subsequent Vigilante challenge opens.
- [x] Completing the Tyrant challenge immediately permits the blue Vigilante challenge to open at the same location when the Vigilante role is vacant. - [x] Completing the Tyrant challenge immediately permits the blue Vigilante challenge to open at the same location when the Vigilante role is vacant.
- [x] Legacy pending Tyrant replacement state is cleared into an arena vacancy without granting a role. - [x] Legacy pending Tyrant replacement state is cleared into an arena vacancy without granting a role.
- [x] Pausing, resetting, restarting, reloading, and repeated or concurrent events cannot duplicate either role boss, challenger, barrier, or assignment. - [x] Pausing, resetting, restarting, reloading, and repeated or concurrent events cannot duplicate either role boss, challenger, barrier, or assignment.
@@ -0,0 +1,33 @@
---
type: User Story
title: "US-021: Claim Tyrant legacy armor"
description: Let a fully unlocked Tyrant spend later choices on unique tradable armor rewards.
status: done
---
# US-021: Claim Tyrant legacy armor
As a **fully unlocked Tyrant**, I want to turn later progression choices into named armor so that continued victories create lasting trophies with ordinary player value.
## Acceptance criteria
- [x] Once all six standard reign unlocks are purchased, the active Tyrant may spend each additional unspent choice on one legacy armor piece.
- [x] Available rewards are a Netherite Helmet, Netherite Chestplate, Netherite Leggings, and Netherite Boots.
- [x] Each piece has Protection V and Mending I, a display name in the form `Tyrant's <player name> <piece>`, and lore identifying the named Tyrant and reign reward.
- [x] Each armor slot may be claimed only once during a reign, limiting a Tyrant to four legacy armor rewards per reign.
- [x] A successful claim consumes exactly one unspent choice and records the claimed slot durably.
- [x] Armor is delivered to normal inventory rather than auto-equipped; insufficient inventory space does not consume a choice or record a claim.
- [x] The armor is ordinary transferable equipment: it may be worn, repaired, enchanted, dropped, traded, stored, or retained after the reign ends.
- [x] Lost or transferred armor is not recoverable and does not permit that slot to be claimed again during the same reign.
- [x] Claimed-slot state survives logout and restart, resets when the reign ends, and loads missing legacy state as no claimed pieces.
- [x] Paused games, non-Tyrants, incomplete standard unlocks, exhausted choices, duplicate slots, malformed requests, and stale or repeated interactions cannot create or duplicate armor.
- [x] The Tyrant can select and confirm an available armor piece through the existing control panel, using the same domain and persistence rules as the command path.
- [x] `/tyrant armor <helmet|chestplate|leggings|boots>` remains available as an alternative and supports contextual completion.
## 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)
- [Complete Tyrant commands contextually](us-018-complete-commands-contextually.md)
- [Role and class model](../concepts/role-and-class-model.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,4 @@
package games.dmg.spigottyrant;
public record ArmorClaimResult(GameState state, ArmorClaimStatus status) {
}
@@ -0,0 +1,10 @@
package games.dmg.spigottyrant;
public enum ArmorClaimStatus {
CLAIMED,
GAME_NOT_RUNNING,
NOT_TYRANT,
UNLOCKS_INCOMPLETE,
NO_CHOICES,
ALREADY_CLAIMED
}
@@ -0,0 +1,22 @@
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(), "Armor metadata is unavailable");
meta.setDisplayName(ChatColor.GOLD + spec.displayName());
meta.setLore(spec.lore().stream().map(line -> ChatColor.GRAY + line).toList());
meta.addEnchant(Enchantment.PROTECTION, spec.protectionLevel(), true);
meta.addEnchant(Enchantment.MENDING, spec.mendingLevel(), true);
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 Armor", List.of(
model.allStandardUnlocksPurchased()
? "Choose a once-per-reign armor reward."
: "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,67 @@ 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 Armor");
TyrantArmorPiece[] pieces = TyrantArmorPiece.values();
int[] slots = {10, 12, 14, 16};
for (int index = 0; index < pieces.length; index++) {
TyrantArmorPiece piece = pieces[index];
String status = model.claimedArmor().contains(piece) ? "CLAIMED"
: model.armorAvailable(piece) ? "AVAILABLE" : "LOCKED";
inventory.setItem(slots[index], item(
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
ChatColor.GOLD + readable(piece.name()),
List.of("Status: " + status, "Cost: 1 choice")
));
}
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 = {10, 12, 14, 16};
for (int index = 0; index < slots.length; index++) {
if (slot == slots[index] && model(player).armorAvailable(pieces[index])) {
openArmorConfirmation(player, pieces[index]);
return;
}
}
}
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 Armor");
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()),
List.of("Protection V", "Mending I", "Cost: 1 choice")
));
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 armor " + piece.commandName());
openArmor(player);
} }
} }
@@ -386,7 +459,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 +469,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 +481,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 +515,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;
@@ -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()
); );
} }
@@ -13,6 +13,7 @@ public record PluginSettings(
double tyrantRangeBlocks, double tyrantRangeBlocks,
double followerRangeBlocks, double followerRangeBlocks,
double vigilanteArenaRadiusBlocks, double vigilanteArenaRadiusBlocks,
int arenaBarrierHeightBlocks,
double vigilanteBossHealth, double vigilanteBossHealth,
double vigilanteBossDamage, double vigilanteBossDamage,
double vigilanteBossArmor, double vigilanteBossArmor,
@@ -53,6 +54,11 @@ public record PluginSettings(
requireFiniteRange( requireFiniteRange(
vigilanteArenaRadiusBlocks, 1.0, 512.0, "vigilante-arena-radius-blocks" 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(vigilanteBossHealth, 1.0, 1024.0, "vigilante-boss-health");
requireFiniteRange(vigilanteBossDamage, 0.1, 2048.0, "vigilante-boss-damage"); requireFiniteRange(vigilanteBossDamage, 0.1, 2048.0, "vigilante-boss-damage");
requireFiniteRange(vigilanteBossArmor, 0.0, 30.0, "vigilante-boss-armor"); requireFiniteRange(vigilanteBossArmor, 0.0, 30.0, "vigilante-boss-armor");
@@ -100,6 +106,7 @@ public record PluginSettings(
decimal(values, "tyrant-range-blocks", 50.0), decimal(values, "tyrant-range-blocks", 50.0),
decimal(values, "follower-range-blocks", 50.0), decimal(values, "follower-range-blocks", 50.0),
decimal(values, "vigilante-arena-radius-blocks", 10.0), decimal(values, "vigilante-arena-radius-blocks", 10.0),
integer(values, "arena-barrier-height-blocks", 12),
decimal(values, "vigilante-boss-health", 80.0), decimal(values, "vigilante-boss-health", 80.0),
decimal(values, "vigilante-boss-damage", 8.0), decimal(values, "vigilante-boss-damage", 8.0),
decimal(values, "vigilante-boss-armor", 10.0), decimal(values, "vigilante-boss-armor", 10.0),
@@ -8,22 +8,27 @@ import org.bukkit.World;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
final class VigilanteArenaBarrier { final class RoleArenaBarrier {
private final Map<Location, BlockData> replaced = new LinkedHashMap<>(); private final Map<Location, BlockData> replaced = new LinkedHashMap<>();
private boolean built; private boolean built;
void build(World world, ArenaLocation center, double radius) { void build(
World world,
ArenaLocation center,
double radius,
int wallHeight
) {
if (built) { if (built) {
return; return;
} }
built = true; built = true;
int samples = Math.max(64, (int) Math.ceil(2.0 * Math.PI * radius * 2.0)); int samples = Math.max(64, (int) Math.ceil(2.0 * Math.PI * radius * 2.0));
int baseY = (int) Math.floor(center.y()); int baseY = (int) Math.floor(center.y()) - 1;
for (int index = 0; index < samples; index++) { for (int index = 0; index < samples; index++) {
double angle = 2.0 * Math.PI * index / samples; double angle = 2.0 * Math.PI * index / samples;
int x = (int) Math.floor(center.x() + Math.cos(angle) * radius); int x = (int) Math.floor(center.x() + Math.cos(angle) * radius);
int z = (int) Math.floor(center.z() + Math.sin(angle) * radius); int z = (int) Math.floor(center.z() + Math.sin(angle) * radius);
for (int y = baseY; y < baseY + 3; y++) { for (int y = baseY; y < baseY + wallHeight; y++) {
Block block = world.getBlockAt(x, y, z); Block block = world.getBlockAt(x, y, z);
Location location = block.getLocation(); Location location = block.getLocation();
if (replaced.containsKey(location) || !isAir(block.getType())) { if (replaced.containsKey(location) || !isAir(block.getType())) {
@@ -26,6 +26,8 @@ import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDeathEvent; import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.EntityTargetLivingEntityEvent; import org.bukkit.event.entity.EntityTargetLivingEntityEvent;
import org.bukkit.event.entity.PlayerDeathEvent; 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.PlayerJoinEvent;
import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerQuitEvent;
@@ -42,7 +44,7 @@ public final class RoleArenaController implements Listener, Runnable {
new VigilanteArenaSuccessionService(); new VigilanteArenaSuccessionService();
private final TyrantArenaSuccessionService tyrantSuccession = private final TyrantArenaSuccessionService tyrantSuccession =
new TyrantArenaSuccessionService(); new TyrantArenaSuccessionService();
private final VigilanteArenaBarrier barrier = new VigilanteArenaBarrier(); private final RoleArenaBarrier barrier = new RoleArenaBarrier();
private final Set<UUID> relocating = new HashSet<>(); private final Set<UUID> relocating = new HashSet<>();
private UUID bossId; private UUID bossId;
private UUID challengerId; private UUID challengerId;
@@ -121,10 +123,13 @@ public final class RoleArenaController implements Listener, Runnable {
)) { )) {
if (challengerId != null) { if (challengerId != null) {
resetFight(); resetFight();
} else {
ArenaBossState.idle(
boss, toBukkit(world, arena), settings.vigilanteBossHealth()
);
} }
boss.setTarget(null);
} else { } else {
boss.setTarget(challenger); ArenaBossState.engage(boss, challenger);
} }
for (Player player : world.getPlayers()) { for (Player player : world.getPlayers()) {
enforcePosition(player, null, player.getLocation()); enforcePosition(player, null, player.getLocation());
@@ -193,6 +198,24 @@ public final class RoleArenaController implements Listener, Runnable {
} }
} }
@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) @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCombust(EntityCombustEvent event) { public void onCombust(EntityCombustEvent event) {
if (event.getEntity().getUniqueId().equals(bossId)) { if (event.getEntity().getUniqueId().equals(bossId)) {
@@ -274,12 +297,15 @@ public final class RoleArenaController implements Listener, Runnable {
challengerId = player.getUniqueId(); challengerId = player.getUniqueId();
World world = server.getWorld(arena.worldName()); World world = server.getWorld(arena.worldName());
if (world != null) { if (world != null) {
barrier.build(world, arena, settings.vigilanteArenaRadiusBlocks()); barrier.build(
world, arena, settings.vigilanteArenaRadiusBlocks(),
settings.arenaBarrierHeightBlocks()
);
Zombie boss = boss(world); Zombie boss = boss(world);
if (boss != null) { if (boss != null) {
boss.setHealth(settings.vigilanteBossHealth()); boss.setHealth(settings.vigilanteBossHealth());
boss.teleport(toBukkit(world, arena)); boss.teleport(toBukkit(world, arena));
boss.setTarget(player); ArenaBossState.engage(boss, player);
} }
} }
player.sendMessage(ChatColor.GOLD + "Defeat the arena boss to become " player.sendMessage(ChatColor.GOLD + "Defeat the arena boss to become "
@@ -312,9 +338,10 @@ public final class RoleArenaController implements Listener, Runnable {
World world = server.getWorld(configured.orElseThrow().worldName()); World world = server.getWorld(configured.orElseThrow().worldName());
Zombie boss = world == null ? null : boss(world); Zombie boss = world == null ? null : boss(world);
if (boss != null && !boss.isDead()) { if (boss != null && !boss.isDead()) {
boss.setHealth(settings.vigilanteBossHealth()); ArenaBossState.idle(
boss.teleport(toBukkit(world, configured.orElseThrow())); boss, toBukkit(world, configured.orElseThrow()),
boss.setTarget(null); settings.vigilanteBossHealth()
);
} else { } else {
bossId = null; bossId = null;
} }
@@ -349,6 +376,7 @@ public final class RoleArenaController implements Listener, Runnable {
zombie.setCustomNameVisible(true); zombie.setCustomNameVisible(true);
zombie.setRemoveWhenFarAway(false); zombie.setRemoveWhenFarAway(false);
zombie.setCanPickupItems(false); zombie.setCanPickupItems(false);
zombie.setAI(false);
zombie.addScoreboardTag(BOSS_TAG); zombie.addScoreboardTag(BOSS_TAG);
setAttribute(zombie, Attribute.MAX_HEALTH, settings.vigilanteBossHealth()); setAttribute(zombie, Attribute.MAX_HEALTH, settings.vigilanteBossHealth());
setAttribute(zombie, Attribute.ATTACK_DAMAGE, settings.vigilanteBossDamage()); setAttribute(zombie, Attribute.ATTACK_DAMAGE, settings.vigilanteBossDamage());
@@ -372,7 +400,7 @@ public final class RoleArenaController implements Listener, Runnable {
&& !zombie.isDead() ? zombie : null; && !zombie.isDead() ? zombie : null;
} }
private void assignRole(UUID playerId, ArenaRole role) { void assignRole(UUID playerId, ArenaRole role) {
LifecycleState assigned; LifecycleState assigned;
try { try {
assigned = role == ArenaRole.TYRANT assigned = role == ArenaRole.TYRANT
@@ -386,13 +414,42 @@ public final class RoleArenaController implements Listener, Runnable {
stateManager.saveIfDirty(); stateManager.saveIfDirty();
challengerId = null; challengerId = null;
barrier.clear(); barrier.clear();
String name = Optional.ofNullable(server.getPlayer(playerId)) 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()); .map(Player::getName).orElse(playerId.toString());
String message = ChatColor.YELLOW + name + " defeated the arena boss and is the " String message = ChatColor.YELLOW + name + " defeated the arena boss and is the "
+ readable(role) + "!"; + readable(role) + "!";
server.getOnlinePlayers().forEach(player -> player.sendMessage(message)); 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) { private void announceVacancy(ArenaLocation arena, ArenaRole role) {
String message = vacancyMessage(arena, role); String message = vacancyMessage(arena, role);
for (Player player : server.getOnlinePlayers()) { for (Player player : server.getOnlinePlayers()) {
@@ -442,29 +499,46 @@ public final class RoleArenaController implements Listener, Runnable {
} }
private void drawBoundary(World world, ArenaLocation arena, ArenaRole role) { private void drawBoundary(World world, ArenaLocation arena, ArenaRole role) {
if (barrier.active()) {
return;
}
double radius = settings.vigilanteArenaRadiusBlocks(); double radius = settings.vigilanteArenaRadiusBlocks();
Color color = boundaryColor(role); Color color = boundaryColor(role);
Particle.DustOptions dust = color == null Particle.DustOptions dust = color == null
? null : new Particle.DustOptions(color, 1.25F); ? null : new Particle.DustOptions(color, 1.25F);
for (int index = 0; index < 48; index++) { int[] heightOffsets = boundaryHeightOffsets(
double angle = 2.0 * Math.PI * index / 48.0; barrier.active(), settings.arenaBarrierHeightBlocks()
Location point = new Location( );
world, for (int heightOffset : heightOffsets) {
arena.x() + Math.cos(angle) * radius, for (int index = 0; index < 48; index++) {
arena.y() + 1.0, double angle = 2.0 * Math.PI * index / 48.0;
arena.z() + Math.sin(angle) * radius Location point = new Location(
); world,
if (dust == null) { arena.x() + Math.cos(angle) * radius,
world.spawnParticle(Particle.END_ROD, point, 1, 0.0, 0.0, 0.0, 0.0); arena.y() + heightOffset,
} else { arena.z() + Math.sin(angle) * radius
world.spawnParticle(Particle.DUST, point, 1, 0.0, 0.0, 0.0, 0.0, dust); );
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) { private boolean contains(ArenaLocation arena, Location location) {
World world = location.getWorld(); World world = location.getWorld();
return world != null && ArenaGeometry.contains( return world != null && ArenaGeometry.contains(
@@ -535,6 +609,18 @@ public final class RoleArenaController implements Listener, Runnable {
} }
} }
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) { static Color boundaryColor(ArenaRole role) {
if (role == null) { if (role == null) {
return null; return null;
@@ -99,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()
); );
} }
} }
@@ -17,6 +17,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
private final ManagedRoleEffects effects; private final ManagedRoleEffects effects;
private final Clock clock; private final Clock clock;
private final ArenaLocationStore arenaLocations; private final ArenaLocationStore arenaLocations;
private final TyrantProgressionService progression = new TyrantProgressionService();
public TyrantAdminCommand( public TyrantAdminCommand(
TyrantStateManager stateManager, TyrantStateManager stateManager,
@@ -69,6 +70,7 @@ public final class TyrantAdminCommand implements CommandExecutor {
case "resume" -> resume(sender); case "resume" -> resume(sender);
case "reset" -> reset(sender, arguments); case "reset" -> reset(sender, arguments);
case "arena" -> arena(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) {
@@ -141,6 +143,20 @@ 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) { private void arena(CommandSender sender, String[] arguments) {
if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) { if (arguments.length == 1 || arguments[1].equalsIgnoreCase("status")) {
sender.sendMessage("Shared role arena: " + arenaLocations.location() sender.sendMessage("Shared role arena: " + arenaLocations.location()
@@ -173,7 +189,8 @@ 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 armor: " + 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() sender.sendMessage("Shared role arena: " + arenaLocations.location()
@@ -194,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|arena set>"); + "Usage: /tyrantadmin <status|start|pause|resume|reset confirm|arena set|grantpoint>");
} }
} }
@@ -11,7 +11,7 @@ import org.bukkit.entity.Player;
public final class TyrantAdminTabCompleter implements TabCompleter { public final class TyrantAdminTabCompleter implements TabCompleter {
private static final String PERMISSION = "spigottyrant.admin"; private static final String PERMISSION = "spigottyrant.admin";
private static final List<String> SUBCOMMANDS = List.of( private static final List<String> SUBCOMMANDS = List.of(
"status", "start", "pause", "resume", "reset", "arena" "status", "start", "pause", "resume", "reset", "arena", "grantpoint"
); );
private final OnlinePlayerDirectory onlinePlayers; private final OnlinePlayerDirectory onlinePlayers;
@@ -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,22 @@
package games.dmg.spigottyrant;
public enum TyrantArmorPiece {
HELMET,
CHESTPLATE,
LEGGINGS,
BOOTS;
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,38 @@
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);
}
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,41 @@
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
) {
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;
};
String pieceName = readable(piece);
return new TyrantArmorSpec(
material,
"Tyrant's " + tyrantName + " " + pieceName,
List.of(
"Forged for Tyrant " + tyrantName + ".",
"A lasting reward from " + tyrantName + "'s reign."
),
5,
1
);
}
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,10 @@ 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("armor")) {
claimArmor(player, arguments[1]);
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 +241,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|assign|item|intelligence|optout|optin|"
+ "relinquish confirm>" + "relinquish confirm>"
); );
return true; return true;
@@ -220,6 +263,12 @@ 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 armor claimed: "
+ (state.claimedArmor().isEmpty() ? "none" : state.claimedArmor()));
player.sendMessage(ChatColor.YELLOW
+ "Use /tyrant armor <helmet|chestplate|leggings|boots>.");
}
} }
private void buy(Player player, String requestedUnlock) { private void buy(Player player, String requestedUnlock) {
@@ -246,6 +295,36 @@ public final class TyrantCommand implements CommandExecutor {
} }
} }
private void claimArmor(Player player, String requestedPiece) {
TyrantArmorPiece piece;
try {
piece = TyrantArmorPiece.fromCommand(requestedPiece);
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED
+ "Unknown armor piece. Use helmet, chestplate, leggings, or boots.");
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()
+ ": " + 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 {
@@ -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,15 @@ 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);
}
public static TyrantControlPanelModel create( public static TyrantControlPanelModel create(
GameState game, GameState game,
PlayerState tyrant, PlayerState tyrant,
@@ -61,7 +72,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
); );
} }
} }
@@ -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
); );
} }
} }
@@ -12,13 +12,16 @@ import org.bukkit.entity.Player;
public final class TyrantTabCompleter implements TabCompleter { public final class TyrantTabCompleter implements TabCompleter {
private static final List<String> SUBCOMMANDS = List.of( private static final List<String> SUBCOMMANDS = List.of(
"menu", "status", "choices", "buy", "assign", "item", "intelligence", "menu", "status", "choices", "buy", "armor", "assign", "item", "intelligence",
"optout", "optin", "relinquish" "optout", "optin", "relinquish"
); );
private static final List<String> UNLOCKS = java.util.Arrays.stream(TyrantUnlock.values()) private static final List<String> UNLOCKS = java.util.Arrays.stream(TyrantUnlock.values())
.map(value -> value.name().toLowerCase(java.util.Locale.ROOT)) .map(value -> value.name().toLowerCase(java.util.Locale.ROOT))
.toList(); .toList();
private static final List<String> CLASSES = List.of("assassin", "fixer", "tamer"); private static final List<String> CLASSES = List.of("assassin", "fixer", "tamer");
private static final List<String> ARMOR = java.util.Arrays.stream(TyrantArmorPiece.values())
.map(TyrantArmorPiece::commandName)
.toList();
private final TyrantStateManager stateManager; private final TyrantStateManager stateManager;
private final OnlinePlayerDirectory onlinePlayers; private final OnlinePlayerDirectory onlinePlayers;
@@ -47,6 +50,7 @@ public final class TyrantTabCompleter implements TabCompleter {
if (arguments.length == 2) { if (arguments.length == 2) {
return switch (subcommand) { return switch (subcommand) {
case "buy" -> TabSuggestions.matching(arguments[1], UNLOCKS); case "buy" -> TabSuggestions.matching(arguments[1], UNLOCKS);
case "armor" -> TabSuggestions.matching(arguments[1], ARMOR);
case "assign" -> TabSuggestions.matching(arguments[1], CLASSES); case "assign" -> TabSuggestions.matching(arguments[1], CLASSES);
case "relinquish" -> TabSuggestions.matching( case "relinquish" -> TabSuggestions.matching(
arguments[1], List.of("confirm") arguments[1], List.of("confirm")
@@ -15,7 +15,7 @@ public final class VigilanteArenaSuccessionService {
GameState assigned = new GameState( GameState assigned = new GameState(
game.lifecycle(), game.tyrantId(), Optional.of(winnerId), game.pendingTyrant(), game.lifecycle(), game.tyrantId(), Optional.of(winnerId), game.pendingTyrant(),
Optional.empty(), game.pausedAt(), game.accumulatedPausedTime(), Optional.empty(), game.pausedAt(), game.accumulatedPausedTime(),
game.tyrantLevel(), game.unspentChoices(), game.purchases() game.tyrantLevel(), game.unspentChoices(), game.purchases(), game.claimedArmor()
); );
return new LifecycleState(assigned, state.players()); return new LifecycleState(assigned, state.players());
} }
@@ -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,10 @@ 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());
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) {
+1
View File
@@ -4,6 +4,7 @@ follower-range-blocks: 50
# Shared Tyrant and Vigilante arena and boss # Shared Tyrant and Vigilante arena and boss
vigilante-arena-radius-blocks: 10 vigilante-arena-radius-blocks: 10
arena-barrier-height-blocks: 12
vigilante-boss-health: 80 vigilante-boss-health: 80
vigilante-boss-damage: 8 vigilante-boss-damage: 8
vigilante-boss-armor: 10 vigilante-boss-armor: 10
+2 -2
View File
@@ -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>|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|arena <set|status>> 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
));
}
}
@@ -21,6 +21,7 @@ final class DefaultConfigurationTest {
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(Duration.ofMinutes(1), settings.roleMaintenanceInterval());
assertEquals(12, settings.arenaBarrierHeightBlocks());
assertEquals("FISHING_ROD", settings.tamerItem().material()); assertEquals("FISHING_ROD", settings.tamerItem().material());
} }
@@ -24,6 +24,7 @@ final class PluginSettingsTest {
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(10.0, settings.vigilanteArenaRadiusBlocks());
assertEquals(12, settings.arenaBarrierHeightBlocks());
assertEquals(80.0, settings.vigilanteBossHealth()); assertEquals(80.0, settings.vigilanteBossHealth());
assertEquals(8.0, settings.vigilanteBossDamage()); assertEquals(8.0, settings.vigilanteBossDamage());
assertEquals(10.0, settings.vigilanteBossArmor()); assertEquals(10.0, settings.vigilanteBossArmor());
@@ -84,6 +85,10 @@ final class PluginSettingsTest {
IllegalArgumentException.class, IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-arena-radius-blocks", 0)) () -> PluginSettings.from(Map.of("vigilante-arena-radius-blocks", 0))
); );
assertThrows(
IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("arena-barrier-height-blocks", 2))
);
assertThrows( assertThrows(
IllegalArgumentException.class, IllegalArgumentException.class,
() -> PluginSettings.from(Map.of("vigilante-boss-health", Double.NaN)) () -> PluginSettings.from(Map.of("vigilante-boss-health", Double.NaN))
@@ -1,5 +1,6 @@
package games.dmg.spigottyrant; package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.atLeastOnce;
@@ -8,14 +9,16 @@ import static org.mockito.Mockito.never;
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.util.List;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class VigilanteArenaBarrierTest { final class RoleArenaBarrierTest {
private static final ArenaLocation CENTER = new ArenaLocation( private static final ArenaLocation CENTER = new ArenaLocation(
"world", 0.0, 64.0, 0.0, 0.0F, 0.0F "world", 0.0, 64.0, 0.0, 0.0F, 0.0F
); );
@@ -28,7 +31,7 @@ final class VigilanteArenaBarrierTest {
when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0)); when(solid.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
when(solid.getType()).thenReturn(Material.CHEST); when(solid.getType()).thenReturn(Material.CHEST);
new VigilanteArenaBarrier().build(world, CENTER, 10.0); new RoleArenaBarrier().build(world, CENTER, 10.0, 12);
verify(solid, never()).setType(Material.BARRIER, false); verify(solid, never()).setType(Material.BARRIER, false);
} }
@@ -45,12 +48,30 @@ final class VigilanteArenaBarrierTest {
when(air.getType()).thenReturn(Material.AIR); when(air.getType()).thenReturn(Material.AIR);
when(air.getBlockData()).thenReturn(original); when(air.getBlockData()).thenReturn(original);
when(original.clone()).thenReturn(snapshot); when(original.clone()).thenReturn(snapshot);
VigilanteArenaBarrier barrier = new VigilanteArenaBarrier(); RoleArenaBarrier barrier = new RoleArenaBarrier();
barrier.build(world, CENTER, 10.0); barrier.build(world, CENTER, 10.0, 12);
barrier.clear(); barrier.clear();
verify(air, atLeastOnce()).setType(Material.BARRIER, false); verify(air, atLeastOnce()).setType(Material.BARRIER, false);
verify(air).setBlockData(snapshot, 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());
}
} }
@@ -1,8 +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.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.contains; 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.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -15,7 +19,10 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import org.bukkit.Color; import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Server; import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerJoinEvent;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -23,12 +30,29 @@ import org.junit.jupiter.api.Test;
final class RoleArenaControllerTest { final class RoleArenaControllerTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111"); 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 PLAYER = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID OTHER = UUID.fromString("33333333-3333-3333-3333-333333333333");
@Test @Test
void boundaryColorsIdentifyTheOpenRole() { void boundaryColorsIdentifyTheOpenRole() {
assertEquals(Color.RED, RoleArenaController.boundaryColor(ArenaRole.TYRANT)); assertEquals(Color.RED, RoleArenaController.boundaryColor(ArenaRole.TYRANT));
assertEquals(Color.BLUE, RoleArenaController.boundaryColor(ArenaRole.VIGILANTE)); assertEquals(Color.BLUE, RoleArenaController.boundaryColor(ArenaRole.VIGILANTE));
assertNull(RoleArenaController.boundaryColor(null)); 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 @Test
@@ -95,6 +119,53 @@ final class RoleArenaControllerTest {
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.anyString()); 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) { private static PlayerJoinEvent mockJoin(Player player) {
PlayerJoinEvent event = mock(PlayerJoinEvent.class); PlayerJoinEvent event = mock(PlayerJoinEvent.class);
when(event.getPlayer()).thenReturn(player); when(event.getPlayer()).thenReturn(player);
@@ -62,6 +62,93 @@ 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 @Test
void playerCanPersistArenaAtCurrentLocation() { void playerCanPersistArenaAtCurrentLocation() {
TyrantStateManager manager = mock(TyrantStateManager.class); TyrantStateManager manager = mock(TyrantStateManager.class);
@@ -35,7 +35,7 @@ final class TyrantAdminTabCompleterTest {
assertTrue(completer.onTabComplete( assertTrue(completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {""} admin, command, "tyrantadmin", new String[] {""}
).containsAll(java.util.List.of( ).containsAll(java.util.List.of(
"status", "start", "pause", "resume", "reset", "arena" "status", "start", "pause", "resume", "reset", "arena", "grantpoint"
))); )));
assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete( assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"start", ""} admin, command, "tyrantadmin", new String[] {"start", ""}
@@ -49,6 +49,9 @@ final class TyrantAdminTabCompleterTest {
assertEquals(java.util.List.of("set", "status"), completer.onTabComplete( assertEquals(java.util.List.of("set", "status"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"arena", "s"} 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) { private static Player player(UUID id, String name) {
@@ -0,0 +1,76 @@
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());
}
@Test
void rejectsIncompleteUnlocksNoChoicesNonTyrantsAndPausedGames() {
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, TyrantArmorPiece.HELMET).status());
assertEquals(ArmorClaimStatus.UNLOCKS_INCOMPLETE,
service.claim(incomplete, TYRANT, TyrantArmorPiece.HELMET).status());
assertEquals(ArmorClaimStatus.NOT_TYRANT,
service.claim(game(1, allUnlocks(), Set.of()), OTHER,
TyrantArmorPiece.HELMET).status());
assertEquals(ArmorClaimStatus.GAME_NOT_RUNNING,
service.claim(paused, TYRANT, TyrantArmorPiece.HELMET).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,27 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
final class TyrantArmorSpecTest {
@Test
void everyPieceIsNetheriteWithNamedLoreAndApprovedEnchantments() {
assertEquals(Material.NETHERITE_HELMET,
TyrantArmorSpec.forPiece(TyrantArmorPiece.HELMET, "Alex").material());
assertEquals(Material.NETHERITE_CHESTPLATE,
TyrantArmorSpec.forPiece(TyrantArmorPiece.CHESTPLATE, "Alex").material());
assertEquals(Material.NETHERITE_LEGGINGS,
TyrantArmorSpec.forPiece(TyrantArmorPiece.LEGGINGS, "Alex").material());
assertEquals(Material.NETHERITE_BOOTS,
TyrantArmorSpec.forPiece(TyrantArmorPiece.BOOTS, "Alex").material());
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.HELMET, "Alex");
assertEquals("Tyrant's Alex Helmet", spec.displayName());
assertTrue(spec.lore().stream().anyMatch(line -> line.contains("Alex")));
assertEquals(5, spec.protectionLevel());
assertEquals(1, spec.mendingLevel());
}
}
@@ -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,74 @@ final class TyrantCommandTest {
&& message.contains("ASSASSIN"))); && message.contains("ASSASSIN")));
} }
@Test
void fullyUnlockedTyrantCanClaimNamedArmorIntoInventory() {
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), Set.of()
);
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(TyrantArmorPiece.HELMET, "Alex")).thenReturn(reward);
TyrantCommand command = new TyrantCommand(
manager, new TyrantProgressionService(), factory
);
command.onCommand(
tyrant, mock(Command.class), "tyrant", new String[] {"armor", "helmet"}
);
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state ->
state.game().unspentChoices() == 1
&& state.game().claimedArmor().equals(Set.of(TyrantArmorPiece.HELMET))
));
verify(inventory).setItem(4, reward);
verify(manager).saveIfDirty();
}
@Test
void fullInventoryDoesNotConsumeChoiceOrCreateArmor() {
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), Set.of()
);
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[] {"armor", "boots"}
);
verify(manager, org.mockito.Mockito.never()).replaceState(any());
verify(factory, org.mockito.Mockito.never()).create(any(), any());
verify(tyrant).sendMessage(org.mockito.ArgumentMatchers.contains("inventory is full"));
}
@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,24 @@ 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 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));
}
} }
@@ -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(
@@ -30,6 +30,7 @@ final class TyrantSuccessionServiceTest {
assertEquals(0, after.game().tyrantLevel()); assertEquals(0, after.game().tyrantLevel());
assertEquals(1, after.game().unspentChoices()); assertEquals(1, after.game().unspentChoices());
assertEquals(Set.of(), after.game().purchases()); assertEquals(Set.of(), after.game().purchases());
assertEquals(Set.of(), after.game().claimedArmor());
assertEquals(Optional.empty(), after.game().vigilanteId()); assertEquals(Optional.empty(), after.game().vigilanteId());
assertEquals(Optional.empty(), after.game().pendingVigilante()); assertEquals(Optional.empty(), after.game().pendingVigilante());
assertEquals( assertEquals(
@@ -57,7 +58,8 @@ final class TyrantSuccessionServiceTest {
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),
Set.of(TyrantArmorPiece.CHESTPLATE)
); );
Map<UUID, PlayerState> players = Map.of( Map<UUID, PlayerState> players = Map.of(
OLD_TYRANT, active(new PlayerState( OLD_TYRANT, active(new PlayerState(
@@ -25,6 +25,12 @@ final class TyrantTabCompleterTest {
List<String> buy = completer.onTabComplete( List<String> buy = completer.onTabComplete(
player, command, "tyrant", new String[] {"buy", "ro"} 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( List<String> assign = completer.onTabComplete(
player, command, "tyrant", new String[] {"assign", "f"} player, command, "tyrant", new String[] {"assign", "f"}
); );
@@ -32,9 +38,13 @@ final class TyrantTabCompleterTest {
player, command, "tyrant", new String[] {"relinquish", "c"} player, command, "tyrant", new String[] {"relinquish", "c"}
); );
assertTrue(root.containsAll(List.of("menu", "status", "buy", "assign", "item"))); assertTrue(root.containsAll(List.of(
"menu", "status", "buy", "armor", "assign", "item"
)));
assertFalse(root.contains("SomePlayer")); assertFalse(root.contains("SomePlayer"));
assertEquals(List.of("roster_intelligence"), buy); assertEquals(List.of("roster_intelligence"), buy);
assertEquals(List.of("chestplate"), armor);
assertEquals(List.of(), armorExtra);
assertEquals(List.of("fixer"), assign); assertEquals(List.of("fixer"), assign);
assertEquals(List.of("confirm"), confirm); assertEquals(List.of("confirm"), confirm);
} }
@@ -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,