Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afd8a03f1b |
@@ -12,10 +12,12 @@ Durable player state includes participation status, opt-out deadline, login hist
|
||||
|
||||
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 all nine legacy reward enum values. 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.
|
||||
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
|
||||
|
||||
- [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)
|
||||
|
||||
@@ -23,9 +23,11 @@ The Tyrant starts each reign at level zero with one starting unlock choice. Pers
|
||||
- Permanent Resistance
|
||||
- Permanent Strength
|
||||
|
||||
## Legacy armor
|
||||
## Legacy rewards
|
||||
|
||||
After purchasing all six standard reign unlocks, the Tyrant may spend later choices on one each of four legacy armor slots. These named Netherite rewards are tracked only to prevent duplicate claims during the current reign; the resulting armor remains ordinary transferable equipment and survives role succession in player inventories.
|
||||
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
|
||||
|
||||
@@ -37,6 +39,8 @@ Vigilante Follower scaling is capped by default at Strength V and Resistance IV,
|
||||
|
||||
## 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)
|
||||
- [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)
|
||||
|
||||
@@ -14,6 +14,7 @@ This bundle documents the asymmetric Tyrant game, its role succession, class abi
|
||||
- [User stories](user-stories/index.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)
|
||||
- [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)
|
||||
|
||||
@@ -6,6 +6,15 @@ description: Chronological record of material decisions affecting the Spigot Tyr
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -22,3 +22,4 @@
|
||||
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)
|
||||
|
||||
@@ -26,6 +26,12 @@ 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] 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).
|
||||
|
||||
@@ -23,6 +23,12 @@ As a **server operator**, I want configurable and durable game behavior so that
|
||||
|
||||
- [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).
|
||||
|
||||
@@ -34,6 +34,12 @@ 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] 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -13,7 +13,7 @@ As a **player or administrator**, I want contextual command suggestions so that
|
||||
|
||||
- [x] `/tyrant` suggests its available subcommands instead of arbitrary player names.
|
||||
- [x] `/tyrant buy` suggests Tyrant unlock names.
|
||||
- [x] `/tyrant gear` suggests `axe`, `pickaxe`, `sword`, `hoe`, and `shovel` and accepts no further arguments; see [US-022](us-022-purchase-legacy-tools-and-weapons.md).
|
||||
- [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 relinquish` suggests `confirm`.
|
||||
@@ -34,6 +34,10 @@ As a **player or administrator**, I want contextual command suggestions so that
|
||||
|
||||
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).
|
||||
|
||||
@@ -24,7 +24,13 @@ As a **fully unlocked Tyrant**, I want to turn later progression choices into na
|
||||
- [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 total rewards without any armor-first requirement; armor commands, enchantments, and saved claims remain compatible.
|
||||
- [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
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ As the **fully unlocked Tyrant**, I want to purchase named tools and weapons so
|
||||
- [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.
|
||||
|
||||
@@ -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)
|
||||
@@ -5,6 +5,7 @@ public enum ArmorClaimStatus {
|
||||
GAME_NOT_RUNNING,
|
||||
NOT_TYRANT,
|
||||
UNLOCKS_INCOMPLETE,
|
||||
COLLECTION_INCOMPLETE,
|
||||
NO_CHOICES,
|
||||
ALREADY_CLAIMED
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ public final class BukkitTyrantArmorItemFactory implements TyrantArmorItemFactor
|
||||
if (spec.efficiencyLevel() > 0) {
|
||||
meta.addEnchant(Enchantment.EFFICIENCY, spec.efficiencyLevel(), true);
|
||||
}
|
||||
meta.addEnchant(Enchantment.MENDING, spec.mendingLevel(), true);
|
||||
if (spec.mendingLevel() > 0) {
|
||||
meta.addEnchant(Enchantment.MENDING, spec.mendingLevel(), true);
|
||||
}
|
||||
meta.setUnbreakable(spec.unbreakable());
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ public final class BukkitTyrantControlPanelRenderer
|
||||
inventory.setItem(22, item(Material.NETHERITE_CHESTPLATE,
|
||||
ChatColor.GOLD + "Legacy Rewards", List.of(
|
||||
model.allStandardUnlocksPurchased()
|
||||
? "Choose a once-per-reign armor, tool, or weapon."
|
||||
? "Choose legacy gear; complete the collection for Elytra."
|
||||
: "Purchase all six standard unlocks first.",
|
||||
"Claimed: " + (model.claimedArmor().isEmpty()
|
||||
? "none" : model.claimedArmor())
|
||||
@@ -118,12 +118,10 @@ public final class BukkitTyrantControlPanelRenderer
|
||||
int[] slots = legacyRewardSlots();
|
||||
for (int index = 0; index < pieces.length; index++) {
|
||||
TyrantArmorPiece piece = pieces[index];
|
||||
String status = model.claimedArmor().contains(piece) ? "CLAIMED"
|
||||
: model.armorAvailable(piece) ? "AVAILABLE" : "LOCKED";
|
||||
inventory.setItem(slots[index], item(
|
||||
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
|
||||
ChatColor.GOLD + readable(piece.name()),
|
||||
List.of("Status: " + status, "Cost: 1 choice")
|
||||
model.armorLore(piece)
|
||||
));
|
||||
}
|
||||
inventory.setItem(22, backItem());
|
||||
@@ -146,7 +144,7 @@ public final class BukkitTyrantControlPanelRenderer
|
||||
}
|
||||
|
||||
static int[] legacyRewardSlots() {
|
||||
return new int[] {9, 10, 11, 12, 13, 14, 15, 16, 17};
|
||||
return new int[] {9, 10, 11, 12, 13, 14, 15, 16, 17, 4};
|
||||
}
|
||||
|
||||
private void openArmorConfirmation(Player player, TyrantArmorPiece piece) {
|
||||
|
||||
@@ -10,7 +10,14 @@ public enum TyrantArmorPiece {
|
||||
PICKAXE,
|
||||
SWORD,
|
||||
HOE,
|
||||
SHOVEL;
|
||||
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) {
|
||||
@@ -26,6 +33,7 @@ public enum TyrantArmorPiece {
|
||||
case "sword" -> SWORD;
|
||||
case "hoe" -> HOE;
|
||||
case "shovel" -> SHOVEL;
|
||||
case "elytra" -> ELYTRA;
|
||||
default -> throw new IllegalArgumentException("Unknown gear piece");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ public final class TyrantArmorService {
|
||||
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);
|
||||
|
||||
@@ -10,7 +10,8 @@ public record TyrantArmorSpec(
|
||||
int protectionLevel,
|
||||
int mendingLevel,
|
||||
int sharpnessLevel,
|
||||
int efficiencyLevel
|
||||
int efficiencyLevel,
|
||||
boolean unbreakable
|
||||
) {
|
||||
public TyrantArmorSpec {
|
||||
lore = List.copyOf(lore);
|
||||
@@ -27,19 +28,26 @@ public record TyrantArmorSpec(
|
||||
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,
|
||||
List.of(
|
||||
"Forged for Tyrant " + tyrantName + ".",
|
||||
"A lasting reward from " + tyrantName + "'s reign."
|
||||
),
|
||||
lore,
|
||||
piece.isArmor() ? 5 : 0,
|
||||
1,
|
||||
elytra ? 0 : 1,
|
||||
piece == TyrantArmorPiece.AXE || piece == TyrantArmorPiece.SWORD ? 6 : 0,
|
||||
!piece.isArmor() && piece != TyrantArmorPiece.SWORD ? 6 : 0
|
||||
!piece.isArmor() && piece != TyrantArmorPiece.SWORD && !elytra ? 6 : 0,
|
||||
elytra
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +62,12 @@ public record TyrantArmorSpec(
|
||||
if (efficiencyLevel > 0) {
|
||||
lines.add("Efficiency VI");
|
||||
}
|
||||
lines.add("Mending I");
|
||||
if (mendingLevel > 0) {
|
||||
lines.add("Mending I");
|
||||
}
|
||||
if (unbreakable) {
|
||||
lines.add("Unbreakable");
|
||||
}
|
||||
lines.add("Cost: 1 choice");
|
||||
return List.copyOf(lines);
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ public final class TyrantCommand implements CommandExecutor {
|
||||
+ (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>.");
|
||||
+ "/tyrant gear <axe|pickaxe|sword|hoe|shovel|elytra>.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,14 +308,16 @@ public final class TyrantCommand implements CommandExecutor {
|
||||
} 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, or shovel."));
|
||||
: "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()
|
||||
+ ": " + readable(result.status()));
|
||||
+ ": " + (result.status() == ArmorClaimStatus.COLLECTION_INCOMPLETE
|
||||
? "claim all nine regular legacy rewards this reign first"
|
||||
: readable(result.status())));
|
||||
return;
|
||||
}
|
||||
int inventorySlot = player.getInventory().firstEmpty();
|
||||
|
||||
@@ -33,7 +33,31 @@ public record TyrantControlPanelModel(
|
||||
|
||||
public boolean armorAvailable(TyrantArmorPiece piece) {
|
||||
return allStandardUnlocksPurchased() && unspentChoices > 0
|
||||
&& !claimedArmor.contains(piece);
|
||||
&& !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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,33 @@ final class TyrantArmorServiceTest {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,42 +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 {
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({
|
||||
"AXE,6,6", "PICKAXE,0,6", "SWORD,6,0", "HOE,0,6", "SHOVEL,0,6"
|
||||
@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 toolsHaveExactApprovedEnchantments(String name, int sharpness, int efficiency) {
|
||||
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(0, spec.protectionLevel());
|
||||
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());
|
||||
assertTrue(spec.displayName().startsWith("Tyrant's Alex "));
|
||||
assertEquals(TyrantArmorSpec.forPiece(TyrantArmorPiece.HELMET, "Alex").lore(), spec.lore());
|
||||
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 everyPieceIsNetheriteWithNamedLoreAndApprovedEnchantments() {
|
||||
assertEquals(Material.NETHERITE_HELMET,
|
||||
TyrantArmorSpec.forPiece(TyrantArmorPiece.HELMET, "Alex").material());
|
||||
assertEquals(Material.NETHERITE_CHESTPLATE,
|
||||
TyrantArmorSpec.forPiece(TyrantArmorPiece.CHESTPLATE, "Alex").material());
|
||||
assertEquals(Material.NETHERITE_LEGGINGS,
|
||||
TyrantArmorSpec.forPiece(TyrantArmorPiece.LEGGINGS, "Alex").material());
|
||||
assertEquals(Material.NETHERITE_BOOTS,
|
||||
TyrantArmorSpec.forPiece(TyrantArmorPiece.BOOTS, "Alex").material());
|
||||
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));
|
||||
}
|
||||
|
||||
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.HELMET, "Alex");
|
||||
assertEquals("Tyrant's Alex Helmet", spec.displayName());
|
||||
assertTrue(spec.lore().stream().anyMatch(line -> line.contains("Alex")));
|
||||
assertEquals(5, spec.protectionLevel());
|
||||
assertEquals(1, spec.mendingLevel());
|
||||
@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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,12 +103,12 @@ final class TyrantCommandTest {
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
|
||||
void fullyUnlockedTyrantCanClaimNamedArmorIntoInventory(TyrantArmorPiece piece) {
|
||||
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), Set.of()
|
||||
6, 2, EnumSet.allOf(TyrantUnlock.class), prerequisiteClaims(piece)
|
||||
);
|
||||
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||
when(manager.snapshot()).thenReturn(new PersistentState(
|
||||
@@ -133,7 +133,7 @@ final class TyrantCommandTest {
|
||||
|
||||
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state ->
|
||||
state.game().unspentChoices() == 1
|
||||
&& state.game().claimedArmor().equals(Set.of(piece))
|
||||
&& state.game().claimedArmor().equals(expectedClaims(piece))
|
||||
));
|
||||
verify(inventory).setItem(4, reward);
|
||||
verify(manager).saveIfDirty();
|
||||
@@ -141,12 +141,12 @@ final class TyrantCommandTest {
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class)
|
||||
void fullInventoryDoesNotConsumeChoiceOrCreateArmor(TyrantArmorPiece piece) {
|
||||
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), Set.of()
|
||||
6, 1, EnumSet.allOf(TyrantUnlock.class), prerequisiteClaims(piece)
|
||||
);
|
||||
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||
when(manager.snapshot()).thenReturn(new PersistentState(
|
||||
@@ -168,9 +168,22 @@ final class TyrantCommandTest {
|
||||
|
||||
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
|
||||
void assigningClassNotifiesTyrantAndNewHolder() {
|
||||
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
@@ -50,6 +50,33 @@ final class TyrantControlPanelModelTest {
|
||||
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");
|
||||
|
||||
@@ -24,7 +24,7 @@ import org.junit.jupiter.params.provider.CsvSource;
|
||||
|
||||
final class TyrantGearCommandSafetyTest {
|
||||
@ParameterizedTest
|
||||
@CsvSource({"gear,helmet", "armor,axe", "gear,unknown", "gear,chest"})
|
||||
@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);
|
||||
@@ -35,13 +35,14 @@ final class TyrantGearCommandSafetyTest {
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = TyrantArmorPiece.class, names = {"AXE", "PICKAXE", "SWORD", "HOE", "SHOVEL"})
|
||||
@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), Set.of()
|
||||
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);
|
||||
@@ -68,6 +69,85 @@ final class TyrantGearCommandSafetyTest {
|
||||
verify(factory).create(piece, "Alex");
|
||||
verify(manager).saveIfDirty();
|
||||
assertEquals(1, state.get().game().unspentChoices());
|
||||
assertEquals(Set.of(piece), state.get().game().claimedArmor());
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
final class TyrantLegacyRewardPanelTest {
|
||||
@Test
|
||||
void allNineRewardsFitInDistinctSlotsWithoutOverwritingNavigation() {
|
||||
void allTenRewardsFitInDistinctSlotsWithoutOverwritingNavigation() {
|
||||
int[] slots = BukkitTyrantControlPanelRenderer.legacyRewardSlots();
|
||||
assertEquals(9, slots.length);
|
||||
assertEquals(10, slots.length);
|
||||
assertEquals(TyrantArmorPiece.values().length, slots.length);
|
||||
assertEquals(9, Arrays.stream(slots).distinct().count());
|
||||
assertEquals(10, Arrays.stream(slots).distinct().count());
|
||||
assertTrue(Arrays.stream(slots).allMatch(slot -> slot >= 0 && slot < 27 && slot != 22));
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ final class TyrantLegacyRewardPanelTest {
|
||||
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"));
|
||||
assertTrue(spec.confirmationLore().containsAll(java.util.List.of("Mending I", "Cost: 1 choice")));
|
||||
assertEquals(spec.mendingLevel() > 0, spec.confirmationLore().contains("Mending I"));
|
||||
assertEquals(piece == TyrantArmorPiece.ELYTRA, spec.confirmationLore().contains("Unbreakable"));
|
||||
assertTrue(spec.confirmationLore().contains("Cost: 1 choice"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +45,14 @@ final class TyrantLegacyRewardPanelTest {
|
||||
6, 9, EnumSet.allOf(TyrantUnlock.class), Set.of()
|
||||
);
|
||||
TyrantArmorService service = new TyrantArmorService();
|
||||
for (TyrantArmorPiece piece : TyrantArmorPiece.values()) {
|
||||
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) {
|
||||
|
||||
@@ -42,8 +42,14 @@ final class TyrantTabCompleterTest {
|
||||
"menu", "status", "buy", "armor", "assign", "item"
|
||||
)));
|
||||
assertTrue(root.contains("gear"));
|
||||
assertEquals(List.of("axe", "hoe", "pickaxe", "shovel", "sword"),
|
||||
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(
|
||||
|
||||
@@ -73,7 +73,7 @@ final class YamlTyrantStateRepositoryTest {
|
||||
GameState loaded = repository.load().game();
|
||||
assertEquals(Set.of(TyrantArmorPiece.HELMET, TyrantArmorPiece.BOOTS), loaded.claimedArmor());
|
||||
TyrantArmorService service = new TyrantArmorService();
|
||||
for (TyrantArmorPiece piece : TyrantArmorPiece.values()) {
|
||||
for (TyrantArmorPiece piece : TyrantArmorPiece.regularRewards()) {
|
||||
if (!piece.isArmor()) {
|
||||
ArmorClaimResult result = service.claim(loaded, loaded.tyrantId().orElseThrow(), piece);
|
||||
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
|
||||
@@ -86,6 +86,55 @@ final class YamlTyrantStateRepositoryTest {
|
||||
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
|
||||
void preservesUnknownFieldsForRetainedState() throws Exception {
|
||||
UUID playerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
Reference in New Issue
Block a user