Compare commits

..
3 Commits
Author SHA1 Message Date
dmg afd8a03f1b feat(gear): add final unbreakable Elytra reward
Release / release (push) Successful in 2m51s
CI / build (push) Successful in 1m10s
2026-09-09 22:28:40 -04:00
dmg 0c4a95801a feat(gear): add Tyrant legacy tools and weapons
Release / release (push) Successful in 2m56s
CI / build (push) Successful in 1m12s
2026-09-09 22:02:59 -04:00
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
42 changed files with 1473 additions and 22 deletions
+8 -1
View File
@@ -6,16 +6,23 @@ 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, tool, and weapon rewards, 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.
Elapsed-time rules use UTC instants. Paused time is excluded from timers. Saves use atomic replacement where supported, preserve unknown fields where practical, and reject corrupt records rather than granting powers, assignments, or duplicated entities. Elapsed-time rules use UTC instants. Paused time is excluded from timers. Saves use atomic replacement where supported, preserve unknown fields where practical, and reject corrupt records rather than granting powers, assignments, or duplicated entities.
The historical `game.claimed-armor` list stores the nine regular legacy reward enum values plus `ELYTRA` for the final collection reward. Elytra eligibility uses exactly the nine regular current-reign claims, never item possession or Elytra itself. Original armor values remain unchanged; absent state means no claims. Claim state resets with the reign, not when an item is transferred or lost. Delivered equipment has no role binding and remains in ordinary Minecraft item storage.
## Related stories ## Related stories
- [Complete the legacy collection with Elytra](../user-stories/us-023-complete-legacy-collection-with-elytra.md)
- [Purchase legacy tools and weapons](../user-stories/us-022-purchase-legacy-tools-and-weapons.md)
- [Inform players and manage participation](../user-stories/us-011-inform-and-manage-participation.md) - [Inform players and manage participation](../user-stories/us-011-inform-and-manage-participation.md)
- [Configure and persist game state](../user-stories/us-012-configure-and-persist-state.md) - [Configure and persist game state](../user-stories/us-012-configure-and-persist-state.md)
- [Use class ability items](../user-stories/us-014-use-class-ability-items.md) - [Use class ability items](../user-stories/us-014-use-class-ability-items.md)
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md) - [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)
+9
View File
@@ -23,6 +23,12 @@ The Tyrant starts each reign at level zero with one starting unlock choice. Pers
- Permanent Resistance - Permanent Resistance
- Permanent Strength - Permanent Strength
## Legacy rewards
After purchasing all six standard reign unlocks, the Tyrant may spend one choice each on four armor pieces and five tools/weapons independently, in any order. Once all nine regular rewards have been claimed in the current reign, one additional choice buys a final unbreakable Elytra once per reign. Earlier items need not still be possessed. Elytra is excluded from the explicit prerequisite set.
All ten rewards remain ordinary transferable equipment after succession; only claim eligibility resets. The Elytra carries Minecraft's Unbreakable flag without redundant enchantments, a gold Tyrant/player name, and gray collection lore. Loss or destruction does not permit replacement.
## 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.
@@ -33,8 +39,11 @@ Vigilante Follower scaling is capped by default at Strength V and Resistance IV,
## Related stories ## Related stories
- [Complete the legacy collection with Elytra](../user-stories/us-023-complete-legacy-collection-with-elytra.md)
- [Progress and purchase Tyrant unlocks](../user-stories/us-003-progress-and-purchase-unlocks.md) - [Progress and purchase Tyrant unlocks](../user-stories/us-003-progress-and-purchase-unlocks.md)
- [Assign unlocked classes](../user-stories/us-004-assign-unlocked-classes.md) - [Assign unlocked classes](../user-stories/us-004-assign-unlocked-classes.md)
- [Support the Vigilante and Followers](../user-stories/us-008-support-vigilante-and-followers.md) - [Support the Vigilante and Followers](../user-stories/us-008-support-vigilante-and-followers.md)
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md) - [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)
+3
View File
@@ -14,6 +14,9 @@ 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)
- [Final unbreakable legacy Elytra](user-stories/us-023-complete-legacy-collection-with-elytra.md)
- [Tyrant legacy tools and weapons](user-stories/us-022-purchase-legacy-tools-and-weapons.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)
+24
View File
@@ -6,6 +6,30 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log # Spigot Tyrant Design Log
## 2026-09-09 — Final unbreakable legacy Elytra completed
- Completed approved [US-023](user-stories/us-023-complete-legacy-collection-with-elytra.md) and related progression, persistence, panel, completion, armor, and tool stories; updated catalogs and concepts.
- Added one final Elytra for one additional choice after all six standard unlocks and exactly the nine regular current-reign claims, with no possession requirement and no Elytra self-prerequisite.
- Applied Minecraft Unbreakable metadata without enchantments, exact gold name and gray collection lore, ordinary transferable item behavior, `/tyrant gear elytra`, contextual completion, prerequisite explanations, and unbreakability/cost confirmation.
- Preserved historical `game.claimed-armor` storage, backward-compatible loading, reign reset, regular reward independence, and authoritative claim/delivery safety.
- Verified `./gradlew clean check jar`: 221 passing tests, zero failures/errors/skips, warnings-as-errors compilation, and JAR packaging. OKF structural validation covers all 30 design Markdown documents and local links; `git diff --check` passed.
- Documented no live-server smoke test, separate mocked UI/item/command verification, and the preexisting synchronous persistence and non-atomic YAML/inventory crash risks. No commit, push, deployment, or local-testing configuration change was performed.
## 2026-09-09 — Tyrant legacy tools and weapons completed
- Completed approved US-022 and related progression, persistence, panel, completion, and armor story updates; cataloged five Netherite tool/weapon rewards alongside four armor rewards.
- All nine rewards cost one choice after the six standard unlocks and may be purchased in any order, once each per reign; tools carry the approved Sharpness VI and/or Efficiency VI plus Mending I and normal transferable named-item lore.
- Added `/tyrant gear`, category-specific completion, nine-item panel selection and exact enchantment confirmation while retaining `/tyrant armor` and backward-compatible armor claims under the historical storage key.
- Confirmed initial failing feature tests, then verified 177 passing automated tests, warnings-as-errors compilation, and JAR packaging with `./gradlew clean check jar`; validated 29 OKF Markdown documents and local links.
- Documented live-server smoke testing and the preexisting lack of cross-store crash atomicity between YAML claims and Minecraft inventory as verification limitations.
## 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 ## 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. - Added `/tyrantadmin grantpoint` so a permitted administrator can grant the current Tyrant exactly one level and one unspent unlock choice during a running game.
+3
View File
@@ -20,3 +20,6 @@
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)
22. [US-022: Purchase legacy tools and weapons](us-022-purchase-legacy-tools-and-weapons.md)
23. [US-023: Complete the legacy collection with an unbreakable Elytra](us-023-complete-legacy-collection-with-elytra.md)
@@ -14,6 +14,7 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
- [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] 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] 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 each of nine legacy armor, tool, and weapon rewards in any order during the reign, without armor prerequisites; see [US-022](us-022-purchase-legacy-tools-and-weapons.md).
- [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.
@@ -25,9 +26,20 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
- [x] A confirmation displays the selected unlock and choice cost before purchase. - [x] A confirmation displays the selected unlock and choice cost before purchase.
- [x] The control panel refreshes the available choices immediately after a successful purchase. - [x] The control panel refreshes the available choices immediately after a successful purchase.
## US-023 extension
Verified with 221 passing tests via `./gradlew clean check jar`; see [US-023 verification and limitations](us-023-complete-legacy-collection-with-elytra.md#verification-scope-and-limitations).
- [x] After those nine independent claims, one further choice buys the final once-per-reign [unbreakable Elytra](us-023-complete-legacy-collection-with-elytra.md); prior item possession is irrelevant.
## US-022 validation
Verified all nine independent one-choice legacy purchases and duplicate prevention with `./gradlew clean check jar` (177 passing tests); see [US-022 verification](us-022-purchase-legacy-tools-and-weapons.md#validation-scope).
## Related ## Related
- [Role and class model](../concepts/role-and-class-model.md) - [Role and class model](../concepts/role-and-class-model.md)
- [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)
@@ -14,16 +14,29 @@ As a **server operator**, I want configurable and durable game behavior so that
- [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] 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.
- [x] Unknown forward-compatible configuration and state fields are preserved where practical. - [x] Unknown forward-compatible configuration and state fields are preserved where practical.
- [x] Plugin disable removes transient effects and presentation safely while preserving durable state. - [x] Plugin disable removes transient effects and presentation safely while preserving durable state.
- [x] Tool and weapon claims persist alongside existing armor claims with backward-compatible loading and per-reign reset; see [US-022](us-022-purchase-legacy-tools-and-weapons.md).
## US-023 extension
Verified with 221 passing tests via `./gradlew clean check jar`; see [US-023 verification and limitations](us-023-complete-legacy-collection-with-elytra.md#verification-scope-and-limitations).
- [x] Final [Elytra claims](us-023-complete-legacy-collection-with-elytra.md) use `game.claimed-armor`, load older missing/armor/tool claim state, survive restart, and reset with the reign without changing delivered items.
## US-022 validation
Verified older armor-only YAML, absent claim state, mixed armor/tool restart round trips, and nine-item reign reset with `./gradlew clean check jar` (177 passing tests); see [US-022 verification and limitations](us-022-purchase-legacy-tools-and-weapons.md#validation-scope).
## Related ## Related
- [Persistence model](../concepts/persistence-model.md) - [Persistence model](../concepts/persistence-model.md)
- [Build, test, and release the plugin](us-013-build-test-and-release.md) - [Build, test, and release the plugin](us-013-build-test-and-release.md)
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md) - [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 any of nine unclaimed legacy rewards through the control panel, including [US-022 tools and weapons](us-022-purchase-legacy-tools-and-weapons.md).
- [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.
@@ -33,10 +34,20 @@ As the **Tyrant**, I want a control panel for my reign so that I can inspect pro
- [x] Existing `/tyrant choices`, `/tyrant buy`, `/tyrant assign`, and `/tyrant intelligence` commands remain available as alternatives. - [x] Existing `/tyrant choices`, `/tyrant buy`, `/tyrant assign`, and `/tyrant intelligence` commands remain available as alternatives.
- [x] The active Tyrant can open the control panel by using the bound Tyrant control item. - [x] The active Tyrant can open the control panel by using the bound Tyrant control item.
## US-023 extension
Verified with 221 passing tests via `./gradlew clean check jar`; see [US-023 verification and limitations](us-023-complete-legacy-collection-with-elytra.md#verification-scope-and-limitations).
- [x] The tenth reward is the final [Elytra](us-023-complete-legacy-collection-with-elytra.md); its menu explains missing standard unlocks, nine current-reign regular claims, and choices, and confirms unbreakability and one-choice cost through the authoritative command path.
## Validation ## Validation
Automated tests verify command entry points, active-Tyrant authorization, authoritative overview state, the exact 100-tick login delay, lifecycle cancellation, and duplicate-opening prevention. The complete `./gradlew clean check jar` lifecycle passes. Automated tests verify command entry points, active-Tyrant authorization, authoritative overview state, the exact 100-tick login delay, lifecycle cancellation, and duplicate-opening prevention. The complete `./gradlew clean check jar` lifecycle passes.
## US-022 validation
Verified nine distinct reward slots, authoritative availability after claims, and exact confirmation enchantments/cost with `./gradlew clean check jar` (177 passing tests). The renderer dispatches confirmation through the tested armor/gear command paths; see [US-022 verification](us-022-purchase-legacy-tools-and-weapons.md#validation-scope). Live-server rendering remains a smoke-test follow-up.
## Related ## Related
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md) - [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
@@ -44,3 +55,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,8 @@ 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 gear` suggests `axe`, `pickaxe`, `sword`, `hoe`, `shovel`, and `elytra` and accepts no further arguments; see [US-022](us-022-purchase-legacy-tools-and-weapons.md) and [US-023](us-023-complete-legacy-collection-with-elytra.md).
- [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.
@@ -30,7 +32,15 @@ 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, the argument-free `grantpoint` command, 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.
## US-023 validation
Verified Elytra completion, case-insensitive prefix matching, argument positions, and unchanged armor completion with 221 passing tests via `./gradlew clean check jar`; see [US-023 verification and limitations](us-023-complete-legacy-collection-with-elytra.md#verification-scope-and-limitations).
## US-022 validation
Verified gear discovery, all five alphabetically sorted tool names, case-insensitive prefix matching, no extra arguments, and unchanged armor-only suggestions with `./gradlew clean check jar` (177 passing tests); see [US-022 verification](us-022-purchase-legacy-tools-and-weapons.md#validation-scope).
## Related ## Related
@@ -40,3 +50,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)
@@ -0,0 +1,45 @@
---
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.
- [x] The four armor claims coexist with five independent [tool and weapon claims](us-022-purchase-legacy-tools-and-weapons.md), for nine regular rewards without any armor-first requirement; armor commands, enchantments, and saved claims remain compatible.
## US-023 extension
Verified with 221 passing tests via `./gradlew clean check jar`; see [US-023 verification and limitations](us-023-complete-legacy-collection-with-elytra.md#verification-scope-and-limitations).
- [x] Armor remains independently purchasable and unchanged; its four current-reign claims count toward the final [Elytra](us-023-complete-legacy-collection-with-elytra.md), which is not an armor prerequisite or armor-command option.
## US-022 validation
Verified armor delivery/full-inventory safety across all four pieces, armor-only command parsing/completion, existing item specification, older saved claims, and tool-first purchasing with `./gradlew clean check jar` (177 passing tests); see [US-022 verification](us-022-purchase-legacy-tools-and-weapons.md#validation-scope).
## 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,52 @@
---
type: User Story
title: "US-022: Purchase legacy tools and weapons"
description: Let a fully unlocked Tyrant spend choices on lasting Netherite tools and weapons alongside legacy armor.
status: done
---
# US-022: Purchase legacy tools and weapons
As the **fully unlocked Tyrant**, I want to purchase named tools and weapons so that continued victories create useful, transferable reign trophies.
## Acceptance criteria
- [x] After all six standard unlocks, the active Tyrant may spend one choice each on Axe, Pickaxe, Sword, Hoe, and Shovel in any order alongside armor, with no armor prerequisites.
- [x] All five items are Netherite: Axe has Sharpness VI, Efficiency VI, Mending I; Pickaxe, Hoe, and Shovel have Efficiency VI, Mending I; Sword has Sharpness VI, Mending I.
- [x] Each item uses the armor naming/lore convention and remains ordinary transferable, persistent, repairable equipment without role binding or recovery.
- [x] Each of nine distinct legacy rewards can be purchased once per reign; losing or transferring an item does not restore eligibility.
- [x] Claims survive restart, preserve existing saved armor claims, and reset with the reign while delivered items remain untouched.
- [x] Full inventory, paused games, unauthorized players, incomplete unlocks, no choices, malformed requests, and repeated interactions cannot consume choices or issue rewards.
- [x] The control panel lists all nine rewards and confirms the selected item, enchantments, and one-choice cost before executing the authoritative claim path and refreshing.
- [x] `/tyrant gear <axe|pickaxe|sword|hoe|shovel>` offers contextual, case-insensitive completion with no extra arguments; `/tyrant armor` remains unchanged.
## US-023 extension
Verified with 221 passing tests via `./gradlew clean check jar`; see [US-023 verification and limitations](us-023-complete-legacy-collection-with-elytra.md#verification-scope-and-limitations).
- [x] The nine regular rewards remain independent and unchanged; all nine current-reign claims unlock eligibility for the tenth [unbreakable Elytra](us-023-complete-legacy-collection-with-elytra.md), purchased separately for one choice through the expanded gear command/menu.
## Implementation
Extend the existing legacy reward service and historical armor-named state types to share one durable claim set. Preserve the `game.claimed-armor` storage key and original enum values for compatibility; new tool values are additional claims, not armor prerequisites.
- [Reward types and command parsing](../../src/main/java/games/dmg/spigottyrant/TyrantArmorPiece.java)
- [Item specification](../../src/main/java/games/dmg/spigottyrant/TyrantArmorSpec.java) and [Bukkit item creation](../../src/main/java/games/dmg/spigottyrant/BukkitTyrantArmorItemFactory.java)
- [Shared claim rules](../../src/main/java/games/dmg/spigottyrant/TyrantArmorService.java), [command delivery](../../src/main/java/games/dmg/spigottyrant/TyrantCommand.java), and [panel confirmation](../../src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java)
- [State persistence](../../src/main/java/games/dmg/spigottyrant/YamlTyrantStateRepository.java)
## Validation scope
Verified `./gradlew clean check jar`: 177 tests passed, zero failures/errors/skips, Java compilation with `-Xlint:all -Werror`, and JAR packaging. OKF v0.1 validation checks all 29 Markdown documents for YAML frontmatter, story statuses and acceptance criteria, reserved index/log structure, and repository-local links. Initial red tests failed on missing tool rewards and gear completion before implementation.
Automated coverage includes all nine inventory deliveries and full-inventory rejections, tool-first claims and duplicate prevention, lifecycle/authorization/progression guards, repeated gear command execution against updated state, malformed and cross-category requests, sorted case-insensitive completion, exact item specifications and confirmation lore, unique panel slots, backward-compatible YAML loading and mixed-claim restart round trips, and succession reset.
No live-server smoke test has been performed. Persistence uses the existing state manager: saving YAML and saving Minecraft inventory are not a cross-store atomic transaction, so abrupt crashes or disk failures during delivery retain the preexisting armor-path risk. Older plugin binaries do not understand new tool enum values; backward compatibility means loading older armor saves in this version, not safe downgrade after new claims.
## Related
- [Progress and purchase unlocks](us-003-progress-and-purchase-unlocks.md)
- [Configure and persist state](us-012-configure-and-persist-state.md)
- [Tyrant control panel](us-015-manage-tyrant-control-panel.md)
- [Contextual completion](us-018-complete-commands-contextually.md)
- [Legacy armor](us-021-claim-tyrant-legacy-armor.md)
@@ -0,0 +1,52 @@
---
type: User Story
title: "US-023: Complete the legacy collection with an unbreakable Elytra"
description: Reward a completed current-reign legacy collection with one final transferable Elytra.
status: done
---
# US-023: Complete the legacy collection with an unbreakable Elytra
As the **active Tyrant**, I want a final unbreakable Elytra after completing my reign's collection so that continued victories earn a lasting trophy.
## Acceptance criteria
- [x] After all six standard unlocks and all nine regular armor, tool, and weapon claims in the current reign, the active Tyrant can spend one additional choice on one Elytra; possession of earlier rewards is not required.
- [x] The nine regular rewards remain independent of one another and of Elytra; Elytra is not its own prerequisite.
- [x] The reward is Minecraft Elytra with the Unbreakable flag and no enchantments, including no Mending or Unbreaking.
- [x] The gold display name is `Tyrant's <player name> Elytra`; gray lore is `Forged for Tyrant <name>.`, `A lasting reward from <name>'s reign.`, and `Earned by completing the Tyrant's legacy collection.`.
- [x] The ordinary item can be traded, stored, repaired, retained after the reign, lost, or destroyed; no binding or replacement is provided.
- [x] The menu explains unmet prerequisites and confirms Elytra, unbreakability, and the one-choice cost before using the authoritative command path.
- [x] `/tyrant gear elytra` purchases the reward and contextual completion includes Elytra, case-insensitive prefixes, and no extra arguments; armor commands remain unchanged.
- [x] Claims persist under backward-compatible storage and reset each reign without touching delivered items.
- [x] Full inventory, paused/inactive games, unauthorized claimants, incomplete prerequisites, no choices, and duplicate or stale requests cannot issue rewards or consume choices.
## Implementation
Reuse historical armor-named reward types and the `game.claimed-armor` key. Use an explicit nine-item prerequisite set, not all reward enum values. The approved Unbreakable design replaces the initial enchantment proposal.
### Implementation references
- [Explicit reward set and parsing](../../src/main/java/games/dmg/spigottyrant/TyrantArmorPiece.java) and [claim rules](../../src/main/java/games/dmg/spigottyrant/TyrantArmorService.java)
- [Item specification](../../src/main/java/games/dmg/spigottyrant/TyrantArmorSpec.java) and [Bukkit metadata creation](../../src/main/java/games/dmg/spigottyrant/BukkitTyrantArmorItemFactory.java)
- [Command delivery](../../src/main/java/games/dmg/spigottyrant/TyrantCommand.java), [contextual completion](../../src/main/java/games/dmg/spigottyrant/TyrantTabCompleter.java), [menu policy](../../src/main/java/games/dmg/spigottyrant/TyrantControlPanelModel.java), and [renderer/confirmation dispatch](../../src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java)
- [Historical-key persistence](../../src/main/java/games/dmg/spigottyrant/YamlTyrantStateRepository.java) and [restart/reset tests](../../src/test/java/games/dmg/spigottyrant/YamlTyrantStateRepositoryTest.java)
## Verification scope and limitations
Verified on 2026-09-09: `./gradlew clean check jar` passed with 221 tests, zero failures/errors/skips, Java 17 warnings-as-errors compilation, and JAR packaging. OKF v0.1 structural validation passed for all 30 design Markdown documents (YAML frontmatter, required story fields/statuses/criteria, reserved index/log structure, and local links); `git diff --check` passed.
Automated coverage includes each missing regular claim and standard unlock, no prior item possession, one-choice purchase, repeated command input, full inventory, lifecycle/authorization guards, malformed and cross-category requests, contextual completion, exact item metadata and confirmation text, ten distinct menu slots, missing-prerequisite menu explanations, older saves, Elytra restart round trips, and succession reset. The initial test run failed to compile because the partial implementation lacked the Elytra enum value; the first complete build also caught three obsolete nine-reward regression assumptions.
No live Spigot smoke test has been performed. Item metadata calls are verified with Mockito; actual flight, durability, trading, storage, loss/destruction, and post-reign inventory behavior rely on ordinary Minecraft item semantics and are not live-server tested. Renderer layout, menu policy, confirmation specification, and command delivery are tested separately rather than through a real client click sequence. Bukkit serializes these command and inventory handlers on the server thread; this does not provide arbitrary asynchronous callers with thread safety.
The existing persistence/delivery path is retained: YAML claims and Minecraft inventory saves are not a cross-store atomic transaction. Abrupt crashes or disk failures during delivery retain the preexisting risk of loss or duplication, and the existing state manager performs synchronous saves. No persistence architecture change or crash-atomicity guarantee is introduced. Backward compatibility means loading old saves in the new plugin, not downgrading to older binaries that do not recognize `ELYTRA`.
## Related
- [Progression](us-003-progress-and-purchase-unlocks.md)
- [Persistence](us-012-configure-and-persist-state.md)
- [Control panel](us-015-manage-tyrant-control-panel.md)
- [Completion](us-018-complete-commands-contextually.md)
- [Armor](us-021-claim-tyrant-legacy-armor.md)
- [Tools and weapons](us-022-purchase-legacy-tools-and-weapons.md)
@@ -0,0 +1,4 @@
package games.dmg.spigottyrant;
public record ArmorClaimResult(GameState state, ArmorClaimStatus status) {
}
@@ -0,0 +1,11 @@
package games.dmg.spigottyrant;
public enum ArmorClaimStatus {
CLAIMED,
GAME_NOT_RUNNING,
NOT_TYRANT,
UNLOCKS_INCOMPLETE,
COLLECTION_INCOMPLETE,
NO_CHOICES,
ALREADY_CLAIMED
}
@@ -0,0 +1,33 @@
package games.dmg.spigottyrant;
import java.util.Objects;
import org.bukkit.ChatColor;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
public final class BukkitTyrantArmorItemFactory implements TyrantArmorItemFactory {
@Override
public ItemStack create(TyrantArmorPiece piece, String tyrantName) {
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(piece, tyrantName);
ItemStack item = new ItemStack(spec.material());
ItemMeta meta = Objects.requireNonNull(item.getItemMeta(), "Legacy reward metadata is unavailable");
meta.setDisplayName(ChatColor.GOLD + spec.displayName());
meta.setLore(spec.lore().stream().map(line -> ChatColor.GRAY + line).toList());
if (spec.protectionLevel() > 0) {
meta.addEnchant(Enchantment.PROTECTION, spec.protectionLevel(), true);
}
if (spec.sharpnessLevel() > 0) {
meta.addEnchant(Enchantment.SHARPNESS, spec.sharpnessLevel(), true);
}
if (spec.efficiencyLevel() > 0) {
meta.addEnchant(Enchantment.EFFICIENCY, spec.efficiencyLevel(), true);
}
if (spec.mendingLevel() > 0) {
meta.addEnchant(Enchantment.MENDING, spec.mendingLevel(), true);
}
meta.setUnbreakable(spec.unbreakable());
item.setItemMeta(meta);
return item;
}
}
@@ -52,6 +52,14 @@ public final class BukkitTyrantControlPanelRenderer
String intelligence = intelligenceText(model); String intelligence = intelligenceText(model);
inventory.setItem(16, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE + "Roster Intelligence", inventory.setItem(16, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE + "Roster Intelligence",
List.of(intelligence, "Click to activate when ready."))); List.of(intelligence, "Click to activate when ready.")));
inventory.setItem(22, item(Material.NETHERITE_CHESTPLATE,
ChatColor.GOLD + "Legacy Rewards", List.of(
model.allStandardUnlocksPurchased()
? "Choose legacy gear; complete the collection for Elytra."
: "Purchase all six standard unlocks first.",
"Claimed: " + (model.claimedArmor().isEmpty()
? "none" : model.claimedArmor())
)));
player.openInventory(inventory); player.openInventory(inventory);
} }
@@ -83,6 +91,10 @@ public final class BukkitTyrantControlPanelRenderer
case ASSIGN_CONFIRMATION -> clickAssignmentConfirmation( case ASSIGN_CONFIRMATION -> clickAssignmentConfirmation(
player, event.getRawSlot(), holder.tyrantClass(), holder.targetId() player, event.getRawSlot(), holder.tyrantClass(), holder.targetId()
); );
case ARMOR -> clickArmor(player, event.getRawSlot());
case ARMOR_CONFIRMATION -> clickArmorConfirmation(
player, event.getRawSlot(), holder.armorPiece()
);
} }
} }
@@ -93,6 +105,70 @@ public final class BukkitTyrantControlPanelRenderer
openClasses(player); openClasses(player);
} else if (slot == 16) { } else if (slot == 16) {
openAbilities(player); openAbilities(player);
} else if (slot == 22) {
openArmor(player);
}
}
private void openArmor(Player player) {
TyrantControlPanelModel model = model(player);
MenuHolder holder = new MenuHolder(MenuView.ARMOR, null, null);
Inventory inventory = create(holder, 27, TITLE + " — Legacy Rewards");
TyrantArmorPiece[] pieces = TyrantArmorPiece.values();
int[] slots = legacyRewardSlots();
for (int index = 0; index < pieces.length; index++) {
TyrantArmorPiece piece = pieces[index];
inventory.setItem(slots[index], item(
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
ChatColor.GOLD + readable(piece.name()),
model.armorLore(piece)
));
}
inventory.setItem(22, backItem());
player.openInventory(inventory);
}
private void clickArmor(Player player, int slot) {
if (slot == 22) {
refresh(player);
return;
}
TyrantArmorPiece[] pieces = TyrantArmorPiece.values();
int[] slots = legacyRewardSlots();
for (int index = 0; index < slots.length; index++) {
if (slot == slots[index] && model(player).armorAvailable(pieces[index])) {
openArmorConfirmation(player, pieces[index]);
return;
}
}
}
static int[] legacyRewardSlots() {
return new int[] {9, 10, 11, 12, 13, 14, 15, 16, 17, 4};
}
private void openArmorConfirmation(Player player, TyrantArmorPiece piece) {
MenuHolder holder = new MenuHolder(
MenuView.ARMOR_CONFIRMATION, null, null, null, piece
);
Inventory inventory = create(holder, 27, TITLE + " — Confirm Reward");
inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of()));
inventory.setItem(13, item(
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
ChatColor.GOLD + "Tyrant's " + player.getName() + " " + readable(piece.name()),
TyrantArmorSpec.forPiece(piece, player.getName()).confirmationLore()
));
inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of()));
player.openInventory(inventory);
}
private void clickArmorConfirmation(Player player, int slot, TyrantArmorPiece piece) {
if (slot == 11) {
openArmor(player);
} else if (slot == 15 && piece != null) {
player.performCommand("tyrant " + (piece.isArmor() ? "armor " : "gear ")
+ piece.commandName());
openArmor(player);
} }
} }
@@ -386,7 +462,9 @@ public final class BukkitTyrantControlPanelRenderer
CLASSES, CLASSES,
ABILITIES, ABILITIES,
PLAYERS, PLAYERS,
ASSIGN_CONFIRMATION ASSIGN_CONFIRMATION,
ARMOR,
ARMOR_CONFIRMATION
} }
private static final class MenuHolder implements InventoryHolder { private static final class MenuHolder implements InventoryHolder {
@@ -394,6 +472,7 @@ public final class BukkitTyrantControlPanelRenderer
private final TyrantUnlock unlock; private final TyrantUnlock unlock;
private final TyrantClass tyrantClass; private final TyrantClass tyrantClass;
private final UUID targetId; private final UUID targetId;
private final TyrantArmorPiece armorPiece;
private Inventory inventory; private Inventory inventory;
private MenuHolder(MenuView view, TyrantUnlock unlock, TyrantClass tyrantClass) { private MenuHolder(MenuView view, TyrantUnlock unlock, TyrantClass tyrantClass) {
@@ -405,11 +484,22 @@ public final class BukkitTyrantControlPanelRenderer
TyrantUnlock unlock, TyrantUnlock unlock,
TyrantClass tyrantClass, TyrantClass tyrantClass,
UUID targetId UUID targetId
) {
this(view, unlock, tyrantClass, targetId, null);
}
private MenuHolder(
MenuView view,
TyrantUnlock unlock,
TyrantClass tyrantClass,
UUID targetId,
TyrantArmorPiece armorPiece
) { ) {
this.view = view; this.view = view;
this.unlock = unlock; this.unlock = unlock;
this.tyrantClass = tyrantClass; this.tyrantClass = tyrantClass;
this.targetId = targetId; this.targetId = targetId;
this.armorPiece = armorPiece;
} }
private MenuView view() { private MenuView view() {
@@ -428,6 +518,10 @@ public final class BukkitTyrantControlPanelRenderer
return targetId; return targetId;
} }
private TyrantArmorPiece armorPiece() {
return armorPiece;
}
@Override @Override
public Inventory getInventory() { public Inventory getInventory() {
return inventory; return inventory;
@@ -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()
); );
} }
@@ -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()
); );
} }
} }
@@ -189,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 legacy rewards: " + game.claimedArmor());
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant()); sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante()); sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
sender.sendMessage("Shared role arena: " + arenaLocations.location() sender.sendMessage("Shared role arena: " + arenaLocations.location()
@@ -0,0 +1,8 @@
package games.dmg.spigottyrant;
import org.bukkit.inventory.ItemStack;
@FunctionalInterface
public interface TyrantArmorItemFactory {
ItemStack create(TyrantArmorPiece piece, String tyrantName);
}
@@ -0,0 +1,54 @@
package games.dmg.spigottyrant;
/** Legacy rewards; the historical type and stored armor key also cover tools and weapons. */
public enum TyrantArmorPiece {
HELMET,
CHESTPLATE,
LEGGINGS,
BOOTS,
AXE,
PICKAXE,
SWORD,
HOE,
SHOVEL,
ELYTRA;
/** Explicitly excludes the final reward and any future reward additions. */
public static java.util.Set<TyrantArmorPiece> regularRewards() {
return java.util.Set.of(HELMET, CHESTPLATE, LEGGINGS, BOOTS,
AXE, PICKAXE, SWORD, HOE, SHOVEL);
}
public boolean isArmor() {
return switch (this) {
case HELMET, CHESTPLATE, LEGGINGS, BOOTS -> true;
default -> false;
};
}
public static TyrantArmorPiece fromGearCommand(String value) {
return switch (value.toLowerCase(java.util.Locale.ROOT)) {
case "axe" -> AXE;
case "pickaxe" -> PICKAXE;
case "sword" -> SWORD;
case "hoe" -> HOE;
case "shovel" -> SHOVEL;
case "elytra" -> ELYTRA;
default -> throw new IllegalArgumentException("Unknown gear piece");
};
}
public static TyrantArmorPiece fromCommand(String value) {
return switch (value.toLowerCase(java.util.Locale.ROOT)) {
case "helmet" -> HELMET;
case "chestplate", "chest" -> CHESTPLATE;
case "leggings", "legs" -> LEGGINGS;
case "boots", "feet" -> BOOTS;
default -> throw new IllegalArgumentException("Unknown armor piece");
};
}
public String commandName() {
return name().toLowerCase(java.util.Locale.ROOT);
}
}
@@ -0,0 +1,42 @@
package games.dmg.spigottyrant;
import java.util.EnumSet;
import java.util.Set;
import java.util.UUID;
public final class TyrantArmorService {
public ArmorClaimResult claim(GameState state, UUID claimantId, TyrantArmorPiece piece) {
if (state.lifecycle() != GameLifecycle.RUNNING) {
return result(state, ArmorClaimStatus.GAME_NOT_RUNNING);
}
if (state.tyrantId().filter(claimantId::equals).isEmpty()) {
return result(state, ArmorClaimStatus.NOT_TYRANT);
}
if (!state.purchases().containsAll(EnumSet.allOf(TyrantUnlock.class))) {
return result(state, ArmorClaimStatus.UNLOCKS_INCOMPLETE);
}
if (state.unspentChoices() == 0) {
return result(state, ArmorClaimStatus.NO_CHOICES);
}
if (state.claimedArmor().contains(piece)) {
return result(state, ArmorClaimStatus.ALREADY_CLAIMED);
}
if (piece == TyrantArmorPiece.ELYTRA
&& !state.claimedArmor().containsAll(TyrantArmorPiece.regularRewards())) {
return result(state, ArmorClaimStatus.COLLECTION_INCOMPLETE);
}
Set<TyrantArmorPiece> claimed = EnumSet.noneOf(TyrantArmorPiece.class);
claimed.addAll(state.claimedArmor());
claimed.add(piece);
GameState updated = new GameState(
state.lifecycle(), state.tyrantId(), state.vigilanteId(), state.pendingTyrant(),
state.pendingVigilante(), state.pausedAt(), state.accumulatedPausedTime(),
state.tyrantLevel(), state.unspentChoices() - 1, state.purchases(), claimed
);
return result(updated, ArmorClaimStatus.CLAIMED);
}
private static ArmorClaimResult result(GameState state, ArmorClaimStatus status) {
return new ArmorClaimResult(state, status);
}
}
@@ -0,0 +1,79 @@
package games.dmg.spigottyrant;
import java.util.List;
import org.bukkit.Material;
public record TyrantArmorSpec(
Material material,
String displayName,
List<String> lore,
int protectionLevel,
int mendingLevel,
int sharpnessLevel,
int efficiencyLevel,
boolean unbreakable
) {
public TyrantArmorSpec {
lore = List.copyOf(lore);
}
public static TyrantArmorSpec forPiece(TyrantArmorPiece piece, String tyrantName) {
Material material = switch (piece) {
case HELMET -> Material.NETHERITE_HELMET;
case CHESTPLATE -> Material.NETHERITE_CHESTPLATE;
case LEGGINGS -> Material.NETHERITE_LEGGINGS;
case BOOTS -> Material.NETHERITE_BOOTS;
case AXE -> Material.NETHERITE_AXE;
case PICKAXE -> Material.NETHERITE_PICKAXE;
case SWORD -> Material.NETHERITE_SWORD;
case HOE -> Material.NETHERITE_HOE;
case SHOVEL -> Material.NETHERITE_SHOVEL;
case ELYTRA -> Material.ELYTRA;
};
String pieceName = readable(piece);
boolean elytra = piece == TyrantArmorPiece.ELYTRA;
java.util.ArrayList<String> lore = new java.util.ArrayList<>(List.of(
"Forged for Tyrant " + tyrantName + ".",
"A lasting reward from " + tyrantName + "'s reign."
));
if (elytra) {
lore.add("Earned by completing the Tyrant's legacy collection.");
}
return new TyrantArmorSpec(
material,
"Tyrant's " + tyrantName + " " + pieceName,
lore,
piece.isArmor() ? 5 : 0,
elytra ? 0 : 1,
piece == TyrantArmorPiece.AXE || piece == TyrantArmorPiece.SWORD ? 6 : 0,
!piece.isArmor() && piece != TyrantArmorPiece.SWORD && !elytra ? 6 : 0,
elytra
);
}
public List<String> confirmationLore() {
java.util.ArrayList<String> lines = new java.util.ArrayList<>();
if (protectionLevel > 0) {
lines.add("Protection V");
}
if (sharpnessLevel > 0) {
lines.add("Sharpness VI");
}
if (efficiencyLevel > 0) {
lines.add("Efficiency VI");
}
if (mendingLevel > 0) {
lines.add("Mending I");
}
if (unbreakable) {
lines.add("Unbreakable");
}
lines.add("Cost: 1 choice");
return List.copyOf(lines);
}
private static String readable(TyrantArmorPiece piece) {
String lower = piece.name().toLowerCase(java.util.Locale.ROOT);
return Character.toUpperCase(lower.charAt(0)) + lower.substring(1);
}
}
@@ -22,6 +22,8 @@ public final class TyrantCommand implements CommandExecutor {
private final TyrantAbilityService tyrantAbilities; private final TyrantAbilityService tyrantAbilities;
private final TyrantControlPanel controlPanel; private final TyrantControlPanel controlPanel;
private final RoleControlItemService roleControlItems; private final RoleControlItemService roleControlItems;
private final TyrantArmorService armor = new TyrantArmorService();
private final TyrantArmorItemFactory armorItems;
public TyrantCommand( public TyrantCommand(
TyrantStateManager stateManager, TyrantStateManager stateManager,
@@ -63,6 +65,20 @@ public final class TyrantCommand implements CommandExecutor {
); );
} }
TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
TyrantArmorItemFactory armorItems
) {
this(
stateManager, progression, new ClassAssignmentService(),
new BukkitOnlinePlayerDirectory(), Clock.systemUTC(),
new ParticipationService(Duration.ofDays(7)), null, null, null,
new TyrantAbilityService(Duration.ofHours(24)), TyrantCommand::unavailablePanel,
null, armorItems
);
}
TyrantCommand( TyrantCommand(
TyrantStateManager stateManager, TyrantStateManager stateManager,
TyrantProgressionService progression, TyrantProgressionService progression,
@@ -129,6 +145,28 @@ public final class TyrantCommand implements CommandExecutor {
TyrantAbilityService tyrantAbilities, TyrantAbilityService tyrantAbilities,
TyrantControlPanel controlPanel, TyrantControlPanel controlPanel,
RoleControlItemService roleControlItems RoleControlItemService roleControlItems
) {
this(
stateManager, progression, assignments, onlinePlayers, clock, participation,
relinquishment, presentation, abilityItems, tyrantAbilities, controlPanel,
roleControlItems, new BukkitTyrantArmorItemFactory()
);
}
public TyrantCommand(
TyrantStateManager stateManager,
TyrantProgressionService progression,
ClassAssignmentService assignments,
OnlinePlayerDirectory onlinePlayers,
Clock clock,
ParticipationService participation,
RoleRelinquishmentService relinquishment,
TyrantPresentation presentation,
AbilityItemService abilityItems,
TyrantAbilityService tyrantAbilities,
TyrantControlPanel controlPanel,
RoleControlItemService roleControlItems,
TyrantArmorItemFactory armorItems
) { ) {
this.stateManager = stateManager; this.stateManager = stateManager;
this.progression = progression; this.progression = progression;
@@ -142,6 +180,7 @@ public final class TyrantCommand implements CommandExecutor {
this.tyrantAbilities = tyrantAbilities; this.tyrantAbilities = tyrantAbilities;
this.controlPanel = controlPanel; this.controlPanel = controlPanel;
this.roleControlItems = roleControlItems; this.roleControlItems = roleControlItems;
this.armorItems = armorItems;
} }
@Override @Override
@@ -168,6 +207,14 @@ public final class TyrantCommand implements CommandExecutor {
buy(player, arguments[1]); buy(player, arguments[1]);
return true; return true;
} }
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("gear")) {
claimLegacyReward(player, arguments[1], false);
return true;
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("armor")) {
claimLegacyReward(player, arguments[1], true);
return true;
}
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("assign")) { if (arguments.length == 3 && arguments[0].equalsIgnoreCase("assign")) {
assign(player, arguments[1], arguments[2]); assign(player, arguments[1], arguments[2]);
return true; return true;
@@ -198,7 +245,7 @@ public final class TyrantCommand implements CommandExecutor {
} }
player.sendMessage( player.sendMessage(
ChatColor.YELLOW ChatColor.YELLOW
+ "Usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|" + "Usage: /tyrant <menu|status|choices|buy|armor|gear|assign|item|intelligence|optout|optin|"
+ "relinquish confirm>" + "relinquish confirm>"
); );
return true; return true;
@@ -220,6 +267,13 @@ public final class TyrantCommand implements CommandExecutor {
} }
player.sendMessage(ChatColor.YELLOW + "[" + status + "] " + unlock.name()); player.sendMessage(ChatColor.YELLOW + "[" + status + "] " + unlock.name());
} }
if (state.purchases().size() == TyrantUnlock.values().length) {
player.sendMessage(ChatColor.GOLD + "Legacy rewards claimed: "
+ (state.claimedArmor().isEmpty() ? "none" : state.claimedArmor()));
player.sendMessage(ChatColor.YELLOW
+ "Use /tyrant armor <helmet|chestplate|leggings|boots> or "
+ "/tyrant gear <axe|pickaxe|sword|hoe|shovel|elytra>.");
}
} }
private void buy(Player player, String requestedUnlock) { private void buy(Player player, String requestedUnlock) {
@@ -246,6 +300,40 @@ public final class TyrantCommand implements CommandExecutor {
} }
} }
private void claimLegacyReward(Player player, String requestedPiece, boolean armorCommand) {
TyrantArmorPiece piece;
try {
piece = armorCommand ? TyrantArmorPiece.fromCommand(requestedPiece)
: TyrantArmorPiece.fromGearCommand(requestedPiece);
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED + (armorCommand
? "Unknown armor piece. Use helmet, chestplate, leggings, or boots."
: "Unknown gear piece. Use axe, pickaxe, sword, hoe, shovel, or elytra."));
return;
}
PersistentState snapshot = stateManager.snapshot();
ArmorClaimResult result = armor.claim(snapshot.game(), player.getUniqueId(), piece);
if (result.status() != ArmorClaimStatus.CLAIMED) {
player.sendMessage(ChatColor.RED + "Could not claim " + piece.commandName()
+ ": " + (result.status() == ArmorClaimStatus.COLLECTION_INCOMPLETE
? "claim all nine regular legacy rewards this reign first"
: readable(result.status())));
return;
}
int inventorySlot = player.getInventory().firstEmpty();
if (inventorySlot < 0) {
player.sendMessage(ChatColor.RED
+ "Your inventory is full; no choice was consumed.");
return;
}
org.bukkit.inventory.ItemStack item = armorItems.create(piece, player.getName());
stateManager.replaceState(new LifecycleState(result.state(), snapshot.players()));
player.getInventory().setItem(inventorySlot, item);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.GREEN + "Claimed " + piece.commandName()
+ " for one choice.");
}
private void assign(Player tyrant, String requestedClass, String targetName) { private void assign(Player tyrant, String requestedClass, String targetName) {
TyrantClass tyrantClass; TyrantClass tyrantClass;
try { try {
@@ -10,11 +10,13 @@ public record TyrantControlPanelModel(
int level, int level,
int unspentChoices, int unspentChoices,
Map<TyrantUnlock, UnlockAvailability> unlocks, Map<TyrantUnlock, UnlockAvailability> unlocks,
Set<TyrantArmorPiece> claimedArmor,
Map<TyrantClass, String> classHolders, Map<TyrantClass, String> classHolders,
Duration intelligenceCooldown Duration intelligenceCooldown
) { ) {
public TyrantControlPanelModel { public TyrantControlPanelModel {
unlocks = Map.copyOf(unlocks); unlocks = Map.copyOf(unlocks);
claimedArmor = Set.copyOf(claimedArmor);
classHolders = Map.copyOf(classHolders); classHolders = Map.copyOf(classHolders);
} }
@@ -25,6 +27,39 @@ public record TyrantControlPanelModel(
.collect(java.util.stream.Collectors.toUnmodifiableSet()); .collect(java.util.stream.Collectors.toUnmodifiableSet());
} }
public boolean allStandardUnlocksPurchased() {
return purchasedUnlocks().size() == TyrantUnlock.values().length;
}
public boolean armorAvailable(TyrantArmorPiece piece) {
return allStandardUnlocksPurchased() && unspentChoices > 0
&& !claimedArmor.contains(piece)
&& (piece != TyrantArmorPiece.ELYTRA
|| claimedArmor.containsAll(TyrantArmorPiece.regularRewards()));
}
public java.util.List<String> armorLore(TyrantArmorPiece piece) {
java.util.List<String> lines = new java.util.ArrayList<>();
lines.add("Status: " + (claimedArmor.contains(piece) ? "CLAIMED"
: armorAvailable(piece) ? "AVAILABLE" : "LOCKED"));
lines.add("Cost: 1 choice");
if (piece == TyrantArmorPiece.ELYTRA) {
lines.add("Unbreakable");
long regularClaims = TyrantArmorPiece.regularRewards().stream()
.filter(claimedArmor::contains).count();
if (regularClaims < TyrantArmorPiece.regularRewards().size()) {
lines.add("Claim all nine regular legacy rewards first (" + regularClaims + "/9).");
}
}
if (!allStandardUnlocksPurchased()) {
lines.add("Purchase all six standard unlocks first.");
}
if (unspentChoices == 0) {
lines.add("Earn another choice first.");
}
return java.util.List.copyOf(lines);
}
public static TyrantControlPanelModel create( public static TyrantControlPanelModel create(
GameState game, GameState game,
PlayerState tyrant, PlayerState tyrant,
@@ -61,7 +96,8 @@ public record TyrantControlPanelModel(
? Duration.between(now, tyrant.cooldownEnds().get(Ability.ROSTER_INTELLIGENCE)) ? Duration.between(now, tyrant.cooldownEnds().get(Ability.ROSTER_INTELLIGENCE))
: Duration.ZERO; : Duration.ZERO;
return new TyrantControlPanelModel( return new TyrantControlPanelModel(
game.tyrantLevel(), game.unspentChoices(), unlocks, holders, cooldown game.tyrantLevel(), game.unspentChoices(), unlocks, game.claimedArmor(),
holders, cooldown
); );
} }
} }
@@ -69,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(
@@ -77,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(),
@@ -88,7 +98,8 @@ public final class TyrantProgressionService {
state.accumulatedPausedTime(), state.accumulatedPausedTime(),
level, level,
choices, choices,
purchases purchases,
claimedArmor
); );
} }
} }
@@ -12,13 +12,21 @@ 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", "gear", "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())
.filter(TyrantArmorPiece::isArmor)
.map(TyrantArmorPiece::commandName)
.toList();
private static final List<String> GEAR = java.util.Arrays.stream(TyrantArmorPiece.values())
.filter(piece -> !piece.isArmor())
.map(TyrantArmorPiece::commandName)
.toList();
private final TyrantStateManager stateManager; private final TyrantStateManager stateManager;
private final OnlinePlayerDirectory onlinePlayers; private final OnlinePlayerDirectory onlinePlayers;
@@ -47,6 +55,8 @@ 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 "gear" -> TabSuggestions.matching(arguments[1], GEAR);
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,11 @@ public final class YamlTyrantStateRepository {
yaml.set("game.tyrant-level", game.tyrantLevel()); yaml.set("game.tyrant-level", game.tyrantLevel());
yaml.set("game.unspent-choices", game.unspentChoices()); yaml.set("game.unspent-choices", game.unspentChoices());
yaml.set("game.purchases", game.purchases().stream().map(Enum::name).sorted().toList()); yaml.set("game.purchases", game.purchases().stream().map(Enum::name).sorted().toList());
// Historical key deliberately stores all legacy rewards, including tools and weapons.
yaml.set(
"game.claimed-armor",
game.claimedArmor().stream().map(Enum::name).sorted().toList()
);
} }
private static void savePlayer(YamlConfiguration yaml, PlayerState player) { private static void savePlayer(YamlConfiguration yaml, PlayerState player) {
+1 -1
View File
@@ -7,7 +7,7 @@ author: dmg.games
commands: commands:
tyrant: tyrant:
description: View and use Spigot Tyrant game features. description: View and use Spigot Tyrant game features.
usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|relinquish confirm> usage: /tyrant <menu|status|choices|buy|armor <helmet|chestplate|leggings|boots>|gear <axe|pickaxe|sword|hoe|shovel>|assign|item|intelligence|optout|optin|relinquish confirm>
vigilante: vigilante:
description: Manage Vigilante Followers. description: Manage Vigilante Followers.
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave> usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
@@ -0,0 +1,45 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.junit.jupiter.api.Test;
import org.mockito.MockedConstruction;
final class BukkitTyrantArmorItemFactoryTest {
@Test
void elytraReceivesGoldNameGrayLoreAndUnbreakabilityWithoutAnyEnchantments() {
ItemMeta meta = mock(ItemMeta.class);
try (MockedConstruction<ItemStack> items = mockConstruction(ItemStack.class, (item, context) -> {
assertEquals(List.of(Material.ELYTRA), context.arguments());
when(item.getItemMeta()).thenReturn(meta);
})) {
ItemStack item = new BukkitTyrantArmorItemFactory().create(
TyrantArmorPiece.valueOf("ELYTRA"), "Alex"
);
assertSame(items.constructed().get(0), item);
verify(meta).setDisplayName(ChatColor.GOLD + "Tyrant's Alex Elytra");
verify(meta).setLore(List.of(
ChatColor.GRAY + "Forged for Tyrant Alex.",
ChatColor.GRAY + "A lasting reward from Alex's reign.",
ChatColor.GRAY + "Earned by completing the Tyrant's legacy collection."
));
verify(meta).setUnbreakable(true);
verify(meta, never()).addEnchant(any(), anyInt(), anyBoolean());
verify(item).setItemMeta(meta);
}
}
}
@@ -0,0 +1,122 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.util.EnumSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class TyrantArmorServiceTest {
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID OTHER = UUID.fromString("22222222-2222-2222-2222-222222222222");
private final TyrantArmorService service = new TyrantArmorService();
@Test
void fullyUnlockedTyrantSpendsOneChoiceOnAnUnclaimedPiece() {
GameState before = game(2, allUnlocks(), Set.of());
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.HELMET);
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
assertEquals(1, result.state().unspentChoices());
assertEquals(Set.of(TyrantArmorPiece.HELMET), result.state().claimedArmor());
assertEquals(before.purchases(), result.state().purchases());
}
@Test
void eachPieceCanBeClaimedOnlyOncePerReign() {
GameState before = game(2, allUnlocks(), Set.of(TyrantArmorPiece.BOOTS));
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.BOOTS);
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED, result.status());
assertEquals(before, result.state());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
void rejectsIncompleteUnlocksNoChoicesNonTyrantsAndPausedGames(TyrantArmorPiece piece) {
GameState complete = game(0, allUnlocks(), Set.of());
GameState incomplete = game(1, Set.of(TyrantUnlock.ASSASSIN), Set.of());
GameState paused = new GameState(
GameLifecycle.PAUSED, complete.tyrantId(), complete.vigilanteId(),
complete.pendingTyrant(), complete.pendingVigilante(),
Optional.of(java.time.Instant.parse("2026-09-04T00:00:00Z")), Duration.ZERO,
1, 1, allUnlocks(), Set.of()
);
assertEquals(ArmorClaimStatus.NO_CHOICES,
service.claim(complete, TYRANT, piece).status());
assertEquals(ArmorClaimStatus.UNLOCKS_INCOMPLETE,
service.claim(incomplete, TYRANT, piece).status());
assertEquals(ArmorClaimStatus.NOT_TYRANT,
service.claim(game(1, allUnlocks(), Set.of()), OTHER, piece).status());
assertEquals(ArmorClaimStatus.GAME_NOT_RUNNING,
service.claim(paused, TYRANT, piece).status());
}
@Test
void nineIndependentRewardsCanBePurchasedToolsFirstAndNeverRepeated() {
GameState state = game(12, allUnlocks(), Set.of());
String[] order = {"AXE", "PICKAXE", "SWORD", "HOE", "SHOVEL",
"BOOTS", "HELMET", "LEGGINGS", "CHESTPLATE"};
for (String name : order) {
TyrantArmorPiece piece = TyrantArmorPiece.valueOf(name);
ArmorClaimResult claim = service.claim(state, TYRANT, piece);
assertEquals(ArmorClaimStatus.CLAIMED, claim.status());
assertEquals(state.unspentChoices() - 1, claim.state().unspentChoices());
state = claim.state();
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED,
service.claim(state, TYRANT, piece).status());
assertEquals(state, service.claim(state, TYRANT, piece).state());
}
assertEquals(9, state.claimedArmor().size());
assertEquals(3, state.unspentChoices());
}
@Test
void finalElytraCostsOneChoiceAfterExactlyNineRegularClaims() {
assertEquals(9, TyrantArmorPiece.regularRewards().size());
org.junit.jupiter.api.Assertions.assertFalse(
TyrantArmorPiece.regularRewards().contains(TyrantArmorPiece.ELYTRA));
GameState before = game(2, allUnlocks(), TyrantArmorPiece.regularRewards());
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.ELYTRA);
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
assertEquals(1, result.state().unspentChoices());
assertEquals(10, result.state().claimedArmor().size());
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED,
service.claim(result.state(), TYRANT, TyrantArmorPiece.ELYTRA).status());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(value = TyrantArmorPiece.class,
names = "ELYTRA", mode = org.junit.jupiter.params.provider.EnumSource.Mode.EXCLUDE)
void everyRegularRewardIsRequiredButCanBeBoughtWithoutElytra(TyrantArmorPiece missing) {
Set<TyrantArmorPiece> claims = EnumSet.copyOf(TyrantArmorPiece.regularRewards());
claims.remove(missing);
GameState before = game(2, allUnlocks(), claims);
ArmorClaimResult rejected = service.claim(before, TYRANT, TyrantArmorPiece.ELYTRA);
assertEquals(ArmorClaimStatus.COLLECTION_INCOMPLETE, rejected.status());
assertEquals(before, rejected.state());
assertEquals(ArmorClaimStatus.CLAIMED, service.claim(before, TYRANT, missing).status());
}
private static Set<TyrantUnlock> allUnlocks() {
return EnumSet.allOf(TyrantUnlock.class);
}
private static GameState game(
int choices,
Set<TyrantUnlock> unlocks,
Set<TyrantArmorPiece> claimed
) {
return new GameState(
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, choices, unlocks, claimed
);
}
}
@@ -0,0 +1,68 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
final class TyrantArmorSpecTest {
@ParameterizedTest
@CsvSource({
"HELMET,Helmet,5,0,0", "CHESTPLATE,Chestplate,5,0,0",
"LEGGINGS,Leggings,5,0,0", "BOOTS,Boots,5,0,0",
"AXE,Axe,0,6,6", "PICKAXE,Pickaxe,0,0,6", "SWORD,Sword,0,6,0",
"HOE,Hoe,0,0,6", "SHOVEL,Shovel,0,0,6"
})
void nineRegularRewardsKeepTheirExactSpecifications(
String name, String displayPiece, int protection, int sharpness, int efficiency
) {
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.valueOf(name), "Alex");
assertEquals(Material.valueOf("NETHERITE_" + name), spec.material());
assertEquals("Tyrant's Alex " + displayPiece, spec.displayName());
assertEquals(List.of("Forged for Tyrant Alex.", "A lasting reward from Alex's reign."), spec.lore());
assertEquals(protection, spec.protectionLevel());
assertEquals(sharpness, spec.sharpnessLevel());
assertEquals(efficiency, spec.efficiencyLevel());
assertEquals(1, spec.mendingLevel());
assertFalse(spec.unbreakable());
List<String> expectedConfirmation = protection > 0
? List.of("Protection V", "Mending I", "Cost: 1 choice")
: sharpness > 0
? efficiency > 0
? List.of("Sharpness VI", "Efficiency VI", "Mending I", "Cost: 1 choice")
: List.of("Sharpness VI", "Mending I", "Cost: 1 choice")
: List.of("Efficiency VI", "Mending I", "Cost: 1 choice");
assertEquals(expectedConfirmation, spec.confirmationLore());
}
@Test
void menuHasTenDistinctRewardSlotsWithoutOverlappingBack() {
int[] slots = BukkitTyrantControlPanelRenderer.legacyRewardSlots();
assertEquals(TyrantArmorPiece.values().length, slots.length);
assertEquals(10, java.util.Arrays.stream(slots).distinct().count());
assertTrue(java.util.Arrays.stream(slots).allMatch(slot -> slot >= 0 && slot < 27 && slot != 22));
}
@Test
void elytraIsUnbreakableWithoutEnchantmentsAndHasCollectionLore() {
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.valueOf("ELYTRA"), "Alex");
assertEquals(Material.ELYTRA, spec.material());
assertEquals("Tyrant's Alex Elytra", spec.displayName());
assertEquals(List.of(
"Forged for Tyrant Alex.",
"A lasting reward from Alex's reign.",
"Earned by completing the Tyrant's legacy collection."
), spec.lore());
assertTrue(spec.unbreakable());
assertEquals(0, spec.protectionLevel());
assertEquals(0, spec.mendingLevel());
assertEquals(0, spec.sharpnessLevel());
assertEquals(0, spec.efficiencyLevel());
assertEquals(List.of("Unbreakable", "Cost: 1 choice"), spec.confirmationLore());
}
}
@@ -10,12 +10,16 @@ import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@@ -97,6 +101,89 @@ final class TyrantCommandTest {
&& message.contains("ASSASSIN"))); && message.contains("ASSASSIN")));
} }
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
void eligibleTyrantCanClaimNamedRewardIntoInventoryWithoutPossessingEarlierRewards(TyrantArmorPiece piece) {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, 2, EnumSet.allOf(TyrantUnlock.class), prerequisiteClaims(piece)
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(
game, Map.of(tyrantId, PlayerState.newPlayer(tyrantId, "Alex"))
));
PlayerInventory inventory = mock(PlayerInventory.class);
when(inventory.firstEmpty()).thenReturn(4);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
when(tyrant.getName()).thenReturn("Alex");
when(tyrant.getInventory()).thenReturn(inventory);
ItemStack reward = mock(ItemStack.class);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
when(factory.create(piece, "Alex")).thenReturn(reward);
TyrantCommand command = new TyrantCommand(
manager, new TyrantProgressionService(), factory
);
command.onCommand(
tyrant, mock(Command.class), "tyrant", new String[] {piece.isArmor() ? "armor" : "gear", piece.commandName()}
);
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state ->
state.game().unspentChoices() == 1
&& state.game().claimedArmor().equals(expectedClaims(piece))
));
verify(inventory).setItem(4, reward);
verify(manager).saveIfDirty();
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
void fullInventoryDoesNotConsumeChoiceOrCreateReward(TyrantArmorPiece piece) {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, 1, EnumSet.allOf(TyrantUnlock.class), prerequisiteClaims(piece)
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(
game, Map.of(tyrantId, PlayerState.newPlayer(tyrantId, "Alex"))
));
PlayerInventory inventory = mock(PlayerInventory.class);
when(inventory.firstEmpty()).thenReturn(-1);
Player tyrant = mock(Player.class);
when(tyrant.getUniqueId()).thenReturn(tyrantId);
when(tyrant.getInventory()).thenReturn(inventory);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
TyrantCommand command = new TyrantCommand(
manager, new TyrantProgressionService(), factory
);
command.onCommand(
tyrant, mock(Command.class), "tyrant", new String[] {piece.isArmor() ? "armor" : "gear", piece.commandName()}
);
verify(manager, org.mockito.Mockito.never()).replaceState(any());
verify(factory, org.mockito.Mockito.never()).create(any(), any());
verify(manager, org.mockito.Mockito.never()).saveIfDirty();
verify(inventory, org.mockito.Mockito.never()).setItem(org.mockito.ArgumentMatchers.anyInt(), any());
verify(tyrant).sendMessage(org.mockito.ArgumentMatchers.contains("inventory is full"));
}
private static Set<TyrantArmorPiece> prerequisiteClaims(TyrantArmorPiece piece) {
return piece == TyrantArmorPiece.ELYTRA ? TyrantArmorPiece.regularRewards() : Set.of();
}
private static Set<TyrantArmorPiece> expectedClaims(TyrantArmorPiece piece) {
Set<TyrantArmorPiece> claims = EnumSet.noneOf(TyrantArmorPiece.class);
claims.addAll(prerequisiteClaims(piece));
claims.add(piece);
return claims;
}
@Test @Test
void assigningClassNotifiesTyrantAndNewHolder() { void assigningClassNotifiesTyrantAndNewHolder() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
@@ -1,9 +1,12 @@
package games.dmg.spigottyrant; package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.EnumSet;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@@ -46,4 +49,51 @@ final class TyrantControlPanelModelTest {
assertEquals("Blade", model.classHolders().get(TyrantClass.ASSASSIN)); assertEquals("Blade", model.classHolders().get(TyrantClass.ASSASSIN));
assertEquals(Duration.ofSeconds(90), model.intelligenceCooldown()); assertEquals(Duration.ofSeconds(90), model.intelligenceCooldown());
} }
@Test
void elytraMenuExplainsMissingPrerequisitesAndRefreshesAfterClaims() {
Map<TyrantUnlock, UnlockAvailability> unlocks = new java.util.EnumMap<>(TyrantUnlock.class);
for (TyrantUnlock unlock : TyrantUnlock.values()) {
unlocks.put(unlock, UnlockAvailability.PURCHASED);
}
TyrantControlPanelModel incomplete = new TyrantControlPanelModel(
16, 1, unlocks, Set.of(), Map.of(), Duration.ZERO);
assertFalse(incomplete.armorAvailable(TyrantArmorPiece.ELYTRA));
assertTrue(incomplete.armorLore(TyrantArmorPiece.ELYTRA).contains(
"Claim all nine regular legacy rewards first (0/9)."));
TyrantControlPanelModel ready = new TyrantControlPanelModel(
16, 1, unlocks, TyrantArmorPiece.regularRewards(), Map.of(), Duration.ZERO);
assertTrue(ready.armorAvailable(TyrantArmorPiece.ELYTRA));
assertTrue(ready.armorLore(TyrantArmorPiece.ELYTRA).contains("Status: AVAILABLE"));
assertTrue(ready.armorLore(TyrantArmorPiece.ELYTRA).contains("Unbreakable"));
TyrantControlPanelModel claimed = new TyrantControlPanelModel(
17, 1, unlocks, EnumSet.allOf(TyrantArmorPiece.class), Map.of(), Duration.ZERO);
assertFalse(claimed.armorAvailable(TyrantArmorPiece.ELYTRA));
assertTrue(claimed.armorLore(TyrantArmorPiece.ELYTRA).contains("Status: CLAIMED"));
TyrantControlPanelModel locked = new TyrantControlPanelModel(
0, 0, Map.of(), Set.of(), Map.of(), Duration.ZERO);
assertTrue(locked.armorLore(TyrantArmorPiece.ELYTRA).contains(
"Purchase all six standard unlocks first."));
assertTrue(locked.armorLore(TyrantArmorPiece.ELYTRA).contains("Earn another choice first."));
}
@Test
void armorAvailabilityRequiresAllUnlocksAChoiceAndAnUnclaimedSlot() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
Instant now = Instant.parse("2026-09-04T00:00:00Z");
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, 1, EnumSet.allOf(TyrantUnlock.class), Set.of(TyrantArmorPiece.HELMET)
);
PlayerState tyrant = PlayerState.newPlayer(tyrantId, "Tyrant");
TyrantControlPanelModel model = TyrantControlPanelModel.create(
game, tyrant, Map.of(tyrantId, tyrant), now
);
assertTrue(model.allStandardUnlocksPurchased());
assertFalse(model.armorAvailable(TyrantArmorPiece.HELMET));
assertTrue(model.armorAvailable(TyrantArmorPiece.CHESTPLATE));
}
} }
@@ -0,0 +1,153 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.EnumSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.CsvSource;
final class TyrantGearCommandSafetyTest {
@ParameterizedTest
@CsvSource({"gear,helmet", "armor,axe", "armor,elytra", "gear,unknown", "gear,chest"})
void malformedAndCrossCategoryRequestsDoNotMutateState(String category, String name) {
TyrantStateManager manager = mock(TyrantStateManager.class);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService(), factory);
command.onCommand(mock(Player.class), mock(Command.class), "tyrant", new String[] {category, name});
verify(manager, never()).replaceState(any());
verify(factory, never()).create(any(), any());
}
@ParameterizedTest
@EnumSource(value = TyrantArmorPiece.class, names = {"AXE", "PICKAXE", "SWORD", "HOE", "SHOVEL", "ELYTRA"})
void repeatedCommandsReadAuthoritativeClaimsAndDeliverOnlyOnce(TyrantArmorPiece piece) {
UUID id = UUID.randomUUID();
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(id), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.empty(), Duration.ZERO,
6, 2, EnumSet.allOf(TyrantUnlock.class),
piece == TyrantArmorPiece.ELYTRA ? TyrantArmorPiece.regularRewards() : Set.of()
);
AtomicReference<PersistentState> state = new AtomicReference<>(new PersistentState(game, Map.of()));
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenAnswer(invocation -> state.get());
org.mockito.Mockito.doAnswer(invocation -> {
LifecycleState replacement = invocation.getArgument(0);
state.set(new PersistentState(replacement.game(), replacement.players()));
return null;
}).when(manager).replaceState(any());
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn("Alex");
when(player.getInventory()).thenReturn(inventory);
when(inventory.firstEmpty()).thenReturn(0);
ItemStack item = mock(ItemStack.class);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
when(factory.create(piece, "Alex")).thenReturn(item);
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService(), factory);
String[] args = {"GEAR", piece.name()};
command.onCommand(player, mock(Command.class), "tyrant", args);
command.onCommand(player, mock(Command.class), "tyrant", args);
verify(inventory).setItem(0, item);
verify(factory).create(piece, "Alex");
verify(manager).saveIfDirty();
assertEquals(1, state.get().game().unspentChoices());
Set<TyrantArmorPiece> expected = EnumSet.noneOf(TyrantArmorPiece.class);
expected.addAll(game.claimedArmor());
expected.add(piece);
assertEquals(expected, state.get().game().claimedArmor());
}
@ParameterizedTest
@EnumSource(value = TyrantArmorPiece.class, names = "ELYTRA", mode = EnumSource.Mode.EXCLUDE)
void elytraRequiresEveryRegularClaimInTheCurrentReign(TyrantArmorPiece missing) {
Set<TyrantArmorPiece> claims = EnumSet.copyOf(TyrantArmorPiece.regularRewards());
claims.remove(missing);
assertElytraRejected(GameLifecycle.RUNNING, true, 1,
EnumSet.allOf(TyrantUnlock.class), claims);
}
@ParameterizedTest
@EnumSource(TyrantUnlock.class)
void elytraRequiresEveryStandardUnlockEvenWithAllNineClaims(TyrantUnlock missing) {
Set<TyrantUnlock> unlocks = EnumSet.allOf(TyrantUnlock.class);
unlocks.remove(missing);
assertElytraRejected(GameLifecycle.RUNNING, true, 1,
unlocks, TyrantArmorPiece.regularRewards());
}
@ParameterizedTest
@CsvSource({"PAUSED,true,1", "UNSTARTED,true,1", "RUNNING,false,1", "RUNNING,true,0"})
void elytraCannotBypassLifecycleAuthorizationOrChoiceCost(
GameLifecycle lifecycle, boolean activeTyrant, int choices
) {
assertElytraRejected(lifecycle, activeTyrant, choices,
EnumSet.allOf(TyrantUnlock.class), TyrantArmorPiece.regularRewards());
}
@org.junit.jupiter.api.Test
void noCurrentReignClaimsCannotQualifyForElytra() {
assertElytraRejected(GameLifecycle.RUNNING, true, 1,
EnumSet.allOf(TyrantUnlock.class), Set.of());
}
@org.junit.jupiter.api.Test
void extraElytraArgumentsDoNotCreateOrSaveRewards() {
TyrantStateManager manager = mock(TyrantStateManager.class);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService(), factory);
command.onCommand(mock(Player.class), mock(Command.class), "tyrant",
new String[] {"gear", "elytra", "confirm"});
verify(manager, never()).replaceState(any());
verify(manager, never()).saveIfDirty();
verify(factory, never()).create(any(), any());
}
private static void assertElytraRejected(
GameLifecycle lifecycle, boolean activeTyrant, int choices,
Set<TyrantUnlock> unlocks, Set<TyrantArmorPiece> claims
) {
UUID id = UUID.randomUUID();
GameState game = new GameState(
lifecycle, Optional.of(id), Optional.empty(), Optional.empty(),
Optional.empty(), lifecycle == GameLifecycle.PAUSED
? Optional.of(java.time.Instant.EPOCH) : Optional.empty(),
Duration.ZERO, 16, choices, unlocks, claims
);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.snapshot()).thenReturn(new PersistentState(game, Map.of()));
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(activeTyrant ? id : UUID.randomUUID());
PlayerInventory inventory = mock(PlayerInventory.class);
when(player.getInventory()).thenReturn(inventory);
when(inventory.firstEmpty()).thenReturn(0);
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
TyrantCommand command = new TyrantCommand(manager, new TyrantProgressionService(), factory);
command.onCommand(player, mock(Command.class), "tyrant", new String[] {"gear", "elytra"});
verify(manager, never()).replaceState(any());
verify(manager, never()).saveIfDirty();
verify(factory, never()).create(any(), any());
verify(inventory, never()).setItem(org.mockito.ArgumentMatchers.anyInt(), any());
verify(player).sendMessage(org.mockito.ArgumentMatchers.contains("Could not claim elytra"));
assertEquals(game, manager.snapshot().game());
}
}
@@ -0,0 +1,63 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class TyrantLegacyRewardPanelTest {
@Test
void allTenRewardsFitInDistinctSlotsWithoutOverwritingNavigation() {
int[] slots = BukkitTyrantControlPanelRenderer.legacyRewardSlots();
assertEquals(10, slots.length);
assertEquals(TyrantArmorPiece.values().length, slots.length);
assertEquals(10, Arrays.stream(slots).distinct().count());
assertTrue(Arrays.stream(slots).allMatch(slot -> slot >= 0 && slot < 27 && slot != 22));
}
@Test
void confirmationShowsExactEnchantmentsAndCostForEveryReward() {
for (TyrantArmorPiece piece : TyrantArmorPiece.values()) {
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(piece, "Alex");
assertEquals(spec.protectionLevel() > 0, spec.confirmationLore().contains("Protection V"));
assertEquals(spec.sharpnessLevel() > 0, spec.confirmationLore().contains("Sharpness VI"));
assertEquals(spec.efficiencyLevel() > 0, spec.confirmationLore().contains("Efficiency VI"));
assertEquals(spec.mendingLevel() > 0, spec.confirmationLore().contains("Mending I"));
assertEquals(piece == TyrantArmorPiece.ELYTRA, spec.confirmationLore().contains("Unbreakable"));
assertTrue(spec.confirmationLore().contains("Cost: 1 choice"));
}
}
@Test
void toolAvailabilityDoesNotRequireArmorAndClaimedToolsBecomeUnavailable() {
UUID tyrant = UUID.randomUUID();
GameState game = new GameState(
GameLifecycle.RUNNING, Optional.of(tyrant), Optional.empty(),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
6, 9, EnumSet.allOf(TyrantUnlock.class), Set.of()
);
TyrantArmorService service = new TyrantArmorService();
for (TyrantArmorPiece piece : TyrantArmorPiece.regularRewards()) {
TyrantControlPanelModel before = model(game);
assertTrue(before.armorAvailable(piece));
game = service.claim(game, tyrant, piece).state();
assertFalse(model(game).armorAvailable(piece));
}
assertFalse(model(game).armorAvailable(TyrantArmorPiece.ELYTRA));
assertTrue(model(game).armorLore(TyrantArmorPiece.ELYTRA).contains("Earn another choice first."));
}
private static TyrantControlPanelModel model(GameState game) {
return TyrantControlPanelModel.create(game,
PlayerState.newPlayer(game.tyrantId().orElseThrow(), "Alex"), Map.of(),
java.time.Instant.EPOCH);
}
}
@@ -50,7 +50,13 @@ final class TyrantProgressionServiceTest {
@Test @Test
void administrativePointGrantAddsOneLevelAndChoicePerInvocation() { void administrativePointGrantAddsOneLevelAndChoicePerInvocation() {
GameState before = runningState(2, 1, Set.of(TyrantUnlock.ASSASSIN)); 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 first = service.grantPoint(before);
GameState second = service.grantPoint(first); GameState second = service.grantPoint(first);
@@ -60,6 +66,7 @@ final class TyrantProgressionServiceTest {
assertEquals(before.purchases(), second.purchases()); assertEquals(before.purchases(), second.purchases());
assertEquals(before.tyrantId(), second.tyrantId()); assertEquals(before.tyrantId(), second.tyrantId());
assertEquals(before.vigilanteId(), second.vigilanteId()); assertEquals(before.vigilanteId(), second.vigilanteId());
assertEquals(before.claimedArmor(), second.claimedArmor());
} }
@Test @Test
@@ -123,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),
java.util.EnumSet.allOf(TyrantArmorPiece.class)
); );
Map<UUID, PlayerState> players = Map.of( Map<UUID, PlayerState> players = Map.of(
OLD_TYRANT, active(new PlayerState( OLD_TYRANT, active(new PlayerState(
@@ -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,28 @@ 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"
)));
assertTrue(root.contains("gear"));
assertEquals(List.of("axe", "elytra", "hoe", "pickaxe", "shovel", "sword"),
completer.onTabComplete(player, command, "tyrant", new String[] {"gear", ""}));
assertEquals(List.of("elytra"), completer.onTabComplete(
player, command, "tyrant", new String[] {"GEAR", "EL"}));
assertEquals(List.of(), completer.onTabComplete(
player, command, "tyrant", new String[] {"gear", "elytra", ""}));
assertEquals(List.of(), completer.onTabComplete(
player, command, "tyrant", new String[] {"armor", "el"}));
assertEquals(List.of("shovel", "sword"),
completer.onTabComplete(player, command, "tyrant", new String[] {"GEAR", "S"}));
assertEquals(List.of(), completer.onTabComplete(
player, command, "tyrant", new String[] {"gear", "axe", ""}));
assertEquals(List.of("boots", "chestplate", "helmet", "leggings"),
completer.onTabComplete(player, command, "tyrant", new String[] {"armor", ""}));
assertFalse(root.contains("SomePlayer")); 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,
@@ -56,6 +57,84 @@ final class YamlTyrantStateRepositoryTest {
assertEquals(expected, repository.load()); assertEquals(expected, repository.load());
} }
@Test
void legacyArmorLoadsWithoutGrantingToolsAndMixedClaimsSurviveRestart() throws Exception {
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
game:
lifecycle: RUNNING
tyrant-id: 11111111-1111-1111-1111-111111111111
tyrant-level: 6
unspent-choices: 9
purchases: [ASSASSIN, FIXER, TAMER, ROSTER_INTELLIGENCE, RESISTANCE, STRENGTH]
claimed-armor: [HELMET, BOOTS]
""");
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(stateFile);
GameState loaded = repository.load().game();
assertEquals(Set.of(TyrantArmorPiece.HELMET, TyrantArmorPiece.BOOTS), loaded.claimedArmor());
TyrantArmorService service = new TyrantArmorService();
for (TyrantArmorPiece piece : TyrantArmorPiece.regularRewards()) {
if (!piece.isArmor()) {
ArmorClaimResult result = service.claim(loaded, loaded.tyrantId().orElseThrow(), piece);
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
loaded = result.state();
}
}
repository.save(new PersistentState(loaded, Map.of()));
assertEquals(loaded, new YamlTyrantStateRepository(stateFile).load().game());
Files.writeString(stateFile, "game: {}\n");
assertEquals(Set.of(), new YamlTyrantStateRepository(stateFile).load().game().claimedArmor());
}
@Test
void oldNineRewardSaveEnablesFinalClaimAndElytraRoundTripsUnderHistoricalKey() throws Exception {
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
game:
lifecycle: RUNNING
tyrant-id: 11111111-1111-1111-1111-111111111111
tyrant-level: 16
unspent-choices: 2
purchases: [ASSASSIN, FIXER, TAMER, ROSTER_INTELLIGENCE, RESISTANCE, STRENGTH]
claimed-armor: [HELMET, CHESTPLATE, LEGGINGS, BOOTS, AXE, PICKAXE, SWORD, HOE, SHOVEL]
""");
YamlTyrantStateRepository repository = new YamlTyrantStateRepository(stateFile);
GameState loaded = repository.load().game();
assertEquals(TyrantArmorPiece.regularRewards(), loaded.claimedArmor());
assertEquals(9, loaded.claimedArmor().size());
TyrantArmorService service = new TyrantArmorService();
ArmorClaimResult claim = service.claim(
loaded, loaded.tyrantId().orElseThrow(), TyrantArmorPiece.ELYTRA
);
assertEquals(ArmorClaimStatus.CLAIMED, claim.status());
assertEquals(1, claim.state().unspentChoices());
assertEquals(java.util.EnumSet.allOf(TyrantArmorPiece.class), claim.state().claimedArmor());
PersistentState expected = new PersistentState(claim.state(), Map.of());
repository.save(expected);
org.bukkit.configuration.file.YamlConfiguration yaml =
org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(stateFile.toFile());
assertEquals(claim.state().claimedArmor().stream().map(Enum::name).sorted().toList(),
yaml.getStringList("game.claimed-armor"));
PersistentState restarted = new YamlTyrantStateRepository(stateFile).load();
assertEquals(expected, restarted);
ArmorClaimResult duplicate = service.claim(
restarted.game(), loaded.tyrantId().orElseThrow(), TyrantArmorPiece.ELYTRA
);
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED, duplicate.status());
assertEquals(restarted.game(), duplicate.state());
LifecycleState nextReign = new TyrantSuccessionService().succeed(
restarted, Optional.of(UUID.fromString("22222222-2222-2222-2222-222222222222"))
);
repository.save(new PersistentState(nextReign.game(), nextReign.players()));
GameState reset = new YamlTyrantStateRepository(stateFile).load().game();
assertEquals(nextReign.game(), reset);
assertEquals(Set.of(), reset.claimedArmor());
assertEquals(Set.of(), reset.purchases());
assertEquals(1, reset.unspentChoices());
}
@Test @Test
void preservesUnknownFieldsForRetainedState() throws Exception { void preservesUnknownFieldsForRetainedState() throws Exception {
UUID playerId = UUID.fromString("11111111-1111-1111-1111-111111111111"); UUID playerId = UUID.fromString("11111111-1111-1111-1111-111111111111");