diff --git a/design/concepts/persistence-model.md b/design/concepts/persistence-model.md index c2f74ab..aa5ff49 100644 --- a/design/concepts/persistence-model.md +++ b/design/concepts/persistence-model.md @@ -6,14 +6,18 @@ description: Defines the durable state and timekeeping needed to restore a Tyran # Persistence Model -State is keyed by player UUID while retaining the latest known player name for display. Durable global state includes lifecycle status, current and pending role holders, selection deadlines, paused durations, the current Tyrant reign and its claimed legacy armor slots, and the administrator-configured Vigilante arena center. Active Tyrant or Vigilante challenge bosses and challenger entities are transient and are reconstructed or reset from authoritative role and shared-arena state after restart. +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. 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. + ## Related stories +- [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) - [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) diff --git a/design/index.md b/design/index.md index 43f657f..c1c5f7e 100644 --- a/design/index.md +++ b/design/index.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) +- [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) - [Role and class model](concepts/role-and-class-model.md) diff --git a/design/log.md b/design/log.md index 4d4d4ba..6fcf993 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,14 @@ description: Chronological record of material decisions affecting the Spigot Tyr # Spigot Tyrant Design Log +## 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. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index e5d53bf..cb2cf6e 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -21,3 +21,4 @@ 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) 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) diff --git a/design/user-stories/us-003-progress-and-purchase-unlocks.md b/design/user-stories/us-003-progress-and-purchase-unlocks.md index 5033d97..f694ba4 100644 --- a/design/user-stories/us-003-progress-and-purchase-unlocks.md +++ b/design/user-stories/us-003-progress-and-purchase-unlocks.md @@ -14,7 +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] An administrator can grant the current Tyrant one level and one unlock choice without changing purchases or unrelated game state. - [x] Each successful administrative grant adds exactly one level and one choice, including across repeated invocations. -- [x] After purchasing all six standard unlocks, the Tyrant may spend later choices on one of each legacy armor slot during the reign. +- [x] 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] 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. @@ -26,6 +26,10 @@ 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-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 - [Role and class model](../concepts/role-and-class-model.md) diff --git a/design/user-stories/us-012-configure-and-persist-state.md b/design/user-stories/us-012-configure-and-persist-state.md index 719548a..5b69b4b 100644 --- a/design/user-stories/us-012-configure-and-persist-state.md +++ b/design/user-stories/us-012-configure-and-persist-state.md @@ -21,6 +21,12 @@ As a **server operator**, I want configurable and durable game behavior so that - [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] 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-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 - [Persistence model](../concepts/persistence-model.md) diff --git a/design/user-stories/us-015-manage-tyrant-control-panel.md b/design/user-stories/us-015-manage-tyrant-control-panel.md index acc1251..4131a02 100644 --- a/design/user-stories/us-015-manage-tyrant-control-panel.md +++ b/design/user-stories/us-015-manage-tyrant-control-panel.md @@ -22,7 +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] 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] After all standard unlocks are purchased, the Tyrant can select and confirm an unclaimed legacy armor slot through the control panel. +- [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 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. @@ -38,6 +38,10 @@ As the **Tyrant**, I want a control panel for my reign so that I can inspect pro 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 - [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md) diff --git a/design/user-stories/us-018-complete-commands-contextually.md b/design/user-stories/us-018-complete-commands-contextually.md index 6df55ea..9ac5acc 100644 --- a/design/user-stories/us-018-complete-commands-contextually.md +++ b/design/user-stories/us-018-complete-commands-contextually.md @@ -13,6 +13,7 @@ As a **player or administrator**, I want contextual command suggestions so that - [x] `/tyrant` suggests its available subcommands instead of arbitrary player names. - [x] `/tyrant 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 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`. @@ -33,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-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 - [Start, pause, and administer the game](us-001-start-pause-and-administer.md) diff --git a/design/user-stories/us-021-claim-tyrant-legacy-armor.md b/design/user-stories/us-021-claim-tyrant-legacy-armor.md index f499f01..95ed59e 100644 --- a/design/user-stories/us-021-claim-tyrant-legacy-armor.md +++ b/design/user-stories/us-021-claim-tyrant-legacy-armor.md @@ -24,6 +24,12 @@ 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 ` 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. + +## 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) diff --git a/design/user-stories/us-022-purchase-legacy-tools-and-weapons.md b/design/user-stories/us-022-purchase-legacy-tools-and-weapons.md new file mode 100644 index 0000000..1847765 --- /dev/null +++ b/design/user-stories/us-022-purchase-legacy-tools-and-weapons.md @@ -0,0 +1,46 @@ +--- +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 ` offers contextual, case-insensitive completion with no extra arguments; `/tyrant armor` remains unchanged. + +## 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) diff --git a/src/main/java/games/dmg/spigottyrant/BukkitTyrantArmorItemFactory.java b/src/main/java/games/dmg/spigottyrant/BukkitTyrantArmorItemFactory.java index 8d95d0f..3648b5c 100644 --- a/src/main/java/games/dmg/spigottyrant/BukkitTyrantArmorItemFactory.java +++ b/src/main/java/games/dmg/spigottyrant/BukkitTyrantArmorItemFactory.java @@ -11,10 +11,18 @@ public final class BukkitTyrantArmorItemFactory implements TyrantArmorItemFactor public ItemStack create(TyrantArmorPiece piece, String tyrantName) { TyrantArmorSpec spec = TyrantArmorSpec.forPiece(piece, tyrantName); ItemStack item = new ItemStack(spec.material()); - ItemMeta meta = Objects.requireNonNull(item.getItemMeta(), "Armor metadata is unavailable"); + 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()); - meta.addEnchant(Enchantment.PROTECTION, spec.protectionLevel(), true); + 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); + } meta.addEnchant(Enchantment.MENDING, spec.mendingLevel(), true); item.setItemMeta(meta); return item; diff --git a/src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java b/src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java index 7bd68f7..41e54d6 100644 --- a/src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java +++ b/src/main/java/games/dmg/spigottyrant/BukkitTyrantControlPanelRenderer.java @@ -53,9 +53,9 @@ public final class BukkitTyrantControlPanelRenderer inventory.setItem(16, item(Material.SPYGLASS, ChatColor.LIGHT_PURPLE + "Roster Intelligence", List.of(intelligence, "Click to activate when ready."))); inventory.setItem(22, item(Material.NETHERITE_CHESTPLATE, - ChatColor.GOLD + "Legacy Armor", List.of( + ChatColor.GOLD + "Legacy Rewards", List.of( model.allStandardUnlocksPurchased() - ? "Choose a once-per-reign armor reward." + ? "Choose a once-per-reign armor, tool, or weapon." : "Purchase all six standard unlocks first.", "Claimed: " + (model.claimedArmor().isEmpty() ? "none" : model.claimedArmor()) @@ -113,9 +113,9 @@ public final class BukkitTyrantControlPanelRenderer private void openArmor(Player player) { TyrantControlPanelModel model = model(player); MenuHolder holder = new MenuHolder(MenuView.ARMOR, null, null); - Inventory inventory = create(holder, 27, TITLE + " — Legacy Armor"); + Inventory inventory = create(holder, 27, TITLE + " — Legacy Rewards"); TyrantArmorPiece[] pieces = TyrantArmorPiece.values(); - int[] slots = {10, 12, 14, 16}; + int[] slots = legacyRewardSlots(); for (int index = 0; index < pieces.length; index++) { TyrantArmorPiece piece = pieces[index]; String status = model.claimedArmor().contains(piece) ? "CLAIMED" @@ -136,7 +136,7 @@ public final class BukkitTyrantControlPanelRenderer return; } TyrantArmorPiece[] pieces = TyrantArmorPiece.values(); - int[] slots = {10, 12, 14, 16}; + int[] slots = legacyRewardSlots(); for (int index = 0; index < slots.length; index++) { if (slot == slots[index] && model(player).armorAvailable(pieces[index])) { openArmorConfirmation(player, pieces[index]); @@ -145,16 +145,20 @@ public final class BukkitTyrantControlPanelRenderer } } + static int[] legacyRewardSlots() { + return new int[] {9, 10, 11, 12, 13, 14, 15, 16, 17}; + } + private void openArmorConfirmation(Player player, TyrantArmorPiece piece) { MenuHolder holder = new MenuHolder( MenuView.ARMOR_CONFIRMATION, null, null, null, piece ); - Inventory inventory = create(holder, 27, TITLE + " — Confirm Armor"); + Inventory 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()), - List.of("Protection V", "Mending I", "Cost: 1 choice") + TyrantArmorSpec.forPiece(piece, player.getName()).confirmationLore() )); inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of())); player.openInventory(inventory); @@ -164,7 +168,8 @@ public final class BukkitTyrantControlPanelRenderer if (slot == 11) { openArmor(player); } else if (slot == 15 && piece != null) { - player.performCommand("tyrant armor " + piece.commandName()); + player.performCommand("tyrant " + (piece.isArmor() ? "armor " : "gear ") + + piece.commandName()); openArmor(player); } } diff --git a/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java b/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java index f0903bc..2c780b3 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantAdminCommand.java @@ -190,7 +190,7 @@ public final class TyrantAdminCommand implements CommandExecutor { sender.sendMessage("Level: " + game.tyrantLevel() + ", choices: " + game.unspentChoices() + ", purchases: " + game.purchases() - + ", claimed armor: " + game.claimedArmor()); + + ", claimed legacy rewards: " + game.claimedArmor()); sender.sendMessage("Pending Tyrant: " + game.pendingTyrant()); sender.sendMessage("Pending Vigilante: " + game.pendingVigilante()); sender.sendMessage("Shared role arena: " + arenaLocations.location() diff --git a/src/main/java/games/dmg/spigottyrant/TyrantArmorPiece.java b/src/main/java/games/dmg/spigottyrant/TyrantArmorPiece.java index 3d9e62a..ce4c342 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantArmorPiece.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantArmorPiece.java @@ -1,10 +1,34 @@ 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; + 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; + default -> throw new IllegalArgumentException("Unknown gear piece"); + }; + } public static TyrantArmorPiece fromCommand(String value) { return switch (value.toLowerCase(java.util.Locale.ROOT)) { diff --git a/src/main/java/games/dmg/spigottyrant/TyrantArmorSpec.java b/src/main/java/games/dmg/spigottyrant/TyrantArmorSpec.java index 8b56db7..7c9de52 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantArmorSpec.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantArmorSpec.java @@ -8,7 +8,9 @@ public record TyrantArmorSpec( String displayName, List lore, int protectionLevel, - int mendingLevel + int mendingLevel, + int sharpnessLevel, + int efficiencyLevel ) { public TyrantArmorSpec { lore = List.copyOf(lore); @@ -20,6 +22,11 @@ public record TyrantArmorSpec( 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; }; String pieceName = readable(piece); return new TyrantArmorSpec( @@ -29,11 +36,29 @@ public record TyrantArmorSpec( "Forged for Tyrant " + tyrantName + ".", "A lasting reward from " + tyrantName + "'s reign." ), - 5, - 1 + piece.isArmor() ? 5 : 0, + 1, + piece == TyrantArmorPiece.AXE || piece == TyrantArmorPiece.SWORD ? 6 : 0, + !piece.isArmor() && piece != TyrantArmorPiece.SWORD ? 6 : 0 ); } + public List confirmationLore() { + java.util.ArrayList 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"); + } + lines.add("Mending I"); + 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); diff --git a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java index e8b5f61..b395caf 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantCommand.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantCommand.java @@ -207,8 +207,12 @@ public final class TyrantCommand implements CommandExecutor { buy(player, arguments[1]); 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")) { - claimArmor(player, arguments[1]); + claimLegacyReward(player, arguments[1], true); return true; } if (arguments.length == 3 && arguments[0].equalsIgnoreCase("assign")) { @@ -241,7 +245,7 @@ public final class TyrantCommand implements CommandExecutor { } player.sendMessage( ChatColor.YELLOW - + "Usage: /tyrant " ); return true; @@ -264,10 +268,11 @@ public final class TyrantCommand implements CommandExecutor { player.sendMessage(ChatColor.YELLOW + "[" + status + "] " + unlock.name()); } if (state.purchases().size() == TyrantUnlock.values().length) { - player.sendMessage(ChatColor.GOLD + "Legacy armor claimed: " + player.sendMessage(ChatColor.GOLD + "Legacy rewards claimed: " + (state.claimedArmor().isEmpty() ? "none" : state.claimedArmor())); player.sendMessage(ChatColor.YELLOW - + "Use /tyrant armor ."); + + "Use /tyrant armor or " + + "/tyrant gear ."); } } @@ -295,13 +300,15 @@ public final class TyrantCommand implements CommandExecutor { } } - private void claimArmor(Player player, String requestedPiece) { + private void claimLegacyReward(Player player, String requestedPiece, boolean armorCommand) { TyrantArmorPiece piece; try { - piece = TyrantArmorPiece.fromCommand(requestedPiece); + piece = armorCommand ? TyrantArmorPiece.fromCommand(requestedPiece) + : TyrantArmorPiece.fromGearCommand(requestedPiece); } catch (IllegalArgumentException exception) { - player.sendMessage(ChatColor.RED - + "Unknown armor piece. Use helmet, chestplate, leggings, or boots."); + player.sendMessage(ChatColor.RED + (armorCommand + ? "Unknown armor piece. Use helmet, chestplate, leggings, or boots." + : "Unknown gear piece. Use axe, pickaxe, sword, hoe, or shovel.")); return; } PersistentState snapshot = stateManager.snapshot(); diff --git a/src/main/java/games/dmg/spigottyrant/TyrantTabCompleter.java b/src/main/java/games/dmg/spigottyrant/TyrantTabCompleter.java index 09f28aa..f59f153 100644 --- a/src/main/java/games/dmg/spigottyrant/TyrantTabCompleter.java +++ b/src/main/java/games/dmg/spigottyrant/TyrantTabCompleter.java @@ -12,7 +12,7 @@ import org.bukkit.entity.Player; public final class TyrantTabCompleter implements TabCompleter { private static final List SUBCOMMANDS = List.of( - "menu", "status", "choices", "buy", "armor", "assign", "item", "intelligence", + "menu", "status", "choices", "buy", "armor", "gear", "assign", "item", "intelligence", "optout", "optin", "relinquish" ); private static final List UNLOCKS = java.util.Arrays.stream(TyrantUnlock.values()) @@ -20,6 +20,11 @@ public final class TyrantTabCompleter implements TabCompleter { .toList(); private static final List CLASSES = List.of("assassin", "fixer", "tamer"); private static final List ARMOR = java.util.Arrays.stream(TyrantArmorPiece.values()) + .filter(TyrantArmorPiece::isArmor) + .map(TyrantArmorPiece::commandName) + .toList(); + private static final List GEAR = java.util.Arrays.stream(TyrantArmorPiece.values()) + .filter(piece -> !piece.isArmor()) .map(TyrantArmorPiece::commandName) .toList(); private final TyrantStateManager stateManager; @@ -51,6 +56,7 @@ public final class TyrantTabCompleter implements TabCompleter { return switch (subcommand) { case "buy" -> TabSuggestions.matching(arguments[1], UNLOCKS); case "armor" -> TabSuggestions.matching(arguments[1], ARMOR); + case "gear" -> TabSuggestions.matching(arguments[1], GEAR); case "assign" -> TabSuggestions.matching(arguments[1], CLASSES); case "relinquish" -> TabSuggestions.matching( arguments[1], List.of("confirm") diff --git a/src/main/java/games/dmg/spigottyrant/YamlTyrantStateRepository.java b/src/main/java/games/dmg/spigottyrant/YamlTyrantStateRepository.java index 1f429ce..f53fa86 100644 --- a/src/main/java/games/dmg/spigottyrant/YamlTyrantStateRepository.java +++ b/src/main/java/games/dmg/spigottyrant/YamlTyrantStateRepository.java @@ -193,6 +193,7 @@ public final class YamlTyrantStateRepository { yaml.set("game.tyrant-level", game.tyrantLevel()); yaml.set("game.unspent-choices", game.unspentChoices()); 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() diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 26ad798..a72071c 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,7 +7,7 @@ author: dmg.games commands: tyrant: description: View and use Spigot Tyrant game features. - usage: /tyrant |assign|item|intelligence|optout|optin|relinquish confirm> + usage: /tyrant |gear |assign|item|intelligence|optout|optin|relinquish confirm> vigilante: description: Manage Vigilante Followers. usage: /vigilante |accept|dismiss |leave> diff --git a/src/test/java/games/dmg/spigottyrant/TyrantArmorServiceTest.java b/src/test/java/games/dmg/spigottyrant/TyrantArmorServiceTest.java index 6d9255c..cdcc308 100644 --- a/src/test/java/games/dmg/spigottyrant/TyrantArmorServiceTest.java +++ b/src/test/java/games/dmg/spigottyrant/TyrantArmorServiceTest.java @@ -36,8 +36,9 @@ final class TyrantArmorServiceTest { assertEquals(before, result.state()); } - @Test - void rejectsIncompleteUnlocksNoChoicesNonTyrantsAndPausedGames() { + @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( @@ -48,14 +49,32 @@ final class TyrantArmorServiceTest { ); assertEquals(ArmorClaimStatus.NO_CHOICES, - service.claim(complete, TYRANT, TyrantArmorPiece.HELMET).status()); + service.claim(complete, TYRANT, piece).status()); assertEquals(ArmorClaimStatus.UNLOCKS_INCOMPLETE, - service.claim(incomplete, TYRANT, TyrantArmorPiece.HELMET).status()); + service.claim(incomplete, TYRANT, piece).status()); assertEquals(ArmorClaimStatus.NOT_TYRANT, - service.claim(game(1, allUnlocks(), Set.of()), OTHER, - TyrantArmorPiece.HELMET).status()); + service.claim(game(1, allUnlocks(), Set.of()), OTHER, piece).status()); assertEquals(ArmorClaimStatus.GAME_NOT_RUNNING, - service.claim(paused, TYRANT, TyrantArmorPiece.HELMET).status()); + 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()); } private static Set allUnlocks() { diff --git a/src/test/java/games/dmg/spigottyrant/TyrantArmorSpecTest.java b/src/test/java/games/dmg/spigottyrant/TyrantArmorSpecTest.java index 5514f72..8576af8 100644 --- a/src/test/java/games/dmg/spigottyrant/TyrantArmorSpecTest.java +++ b/src/test/java/games/dmg/spigottyrant/TyrantArmorSpecTest.java @@ -7,6 +7,21 @@ import org.bukkit.Material; import org.junit.jupiter.api.Test; 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" + }) + void toolsHaveExactApprovedEnchantments(String name, int sharpness, int efficiency) { + TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.valueOf(name), "Alex"); + assertEquals(Material.valueOf("NETHERITE_" + name), spec.material()); + assertEquals(0, 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()); + } + @Test void everyPieceIsNetheriteWithNamedLoreAndApprovedEnchantments() { assertEquals(Material.NETHERITE_HELMET, diff --git a/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java b/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java index da5da12..9900315 100644 --- a/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java +++ b/src/test/java/games/dmg/spigottyrant/TyrantCommandTest.java @@ -101,8 +101,9 @@ final class TyrantCommandTest { && message.contains("ASSASSIN"))); } - @Test - void fullyUnlockedTyrantCanClaimNamedArmorIntoInventory() { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class) + void fullyUnlockedTyrantCanClaimNamedArmorIntoInventory(TyrantArmorPiece piece) { UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); GameState game = new GameState( GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(), @@ -121,25 +122,26 @@ final class TyrantCommandTest { when(tyrant.getInventory()).thenReturn(inventory); ItemStack reward = mock(ItemStack.class); TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class); - when(factory.create(TyrantArmorPiece.HELMET, "Alex")).thenReturn(reward); + when(factory.create(piece, "Alex")).thenReturn(reward); TyrantCommand command = new TyrantCommand( manager, new TyrantProgressionService(), factory ); command.onCommand( - tyrant, mock(Command.class), "tyrant", new String[] {"armor", "helmet"} + 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(Set.of(TyrantArmorPiece.HELMET)) + && state.game().claimedArmor().equals(Set.of(piece)) )); verify(inventory).setItem(4, reward); verify(manager).saveIfDirty(); } - @Test - void fullInventoryDoesNotConsumeChoiceOrCreateArmor() { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(TyrantArmorPiece.class) + void fullInventoryDoesNotConsumeChoiceOrCreateArmor(TyrantArmorPiece piece) { UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111"); GameState game = new GameState( GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(), @@ -161,7 +163,7 @@ final class TyrantCommandTest { ); command.onCommand( - tyrant, mock(Command.class), "tyrant", new String[] {"armor", "boots"} + tyrant, mock(Command.class), "tyrant", new String[] {piece.isArmor() ? "armor" : "gear", piece.commandName()} ); verify(manager, org.mockito.Mockito.never()).replaceState(any()); diff --git a/src/test/java/games/dmg/spigottyrant/TyrantGearCommandSafetyTest.java b/src/test/java/games/dmg/spigottyrant/TyrantGearCommandSafetyTest.java new file mode 100644 index 0000000..8e234f7 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantGearCommandSafetyTest.java @@ -0,0 +1,73 @@ +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", "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"}) + 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() + ); + AtomicReference 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()); + assertEquals(Set.of(piece), state.get().game().claimedArmor()); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantLegacyRewardPanelTest.java b/src/test/java/games/dmg/spigottyrant/TyrantLegacyRewardPanelTest.java new file mode 100644 index 0000000..df950c3 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantLegacyRewardPanelTest.java @@ -0,0 +1,59 @@ +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 allNineRewardsFitInDistinctSlotsWithoutOverwritingNavigation() { + int[] slots = BukkitTyrantControlPanelRenderer.legacyRewardSlots(); + assertEquals(9, slots.length); + assertEquals(TyrantArmorPiece.values().length, slots.length); + assertEquals(9, 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")); + assertTrue(spec.confirmationLore().containsAll(java.util.List.of("Mending I", "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.values()) { + TyrantControlPanelModel before = model(game); + assertTrue(before.armorAvailable(piece)); + game = service.claim(game, tyrant, piece).state(); + assertFalse(model(game).armorAvailable(piece)); + } + } + + private static TyrantControlPanelModel model(GameState game) { + return TyrantControlPanelModel.create(game, + PlayerState.newPlayer(game.tyrantId().orElseThrow(), "Alex"), Map.of(), + java.time.Instant.EPOCH); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantSuccessionServiceTest.java b/src/test/java/games/dmg/spigottyrant/TyrantSuccessionServiceTest.java index 157d29f..49f5206 100644 --- a/src/test/java/games/dmg/spigottyrant/TyrantSuccessionServiceTest.java +++ b/src/test/java/games/dmg/spigottyrant/TyrantSuccessionServiceTest.java @@ -59,7 +59,7 @@ final class TyrantSuccessionServiceTest { GameLifecycle.RUNNING, Optional.of(OLD_TYRANT), Optional.of(OLD_VIGILANTE), Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO, 5, 2, Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH), - Set.of(TyrantArmorPiece.CHESTPLATE) + java.util.EnumSet.allOf(TyrantArmorPiece.class) ); Map players = Map.of( OLD_TYRANT, active(new PlayerState( diff --git a/src/test/java/games/dmg/spigottyrant/TyrantTabCompleterTest.java b/src/test/java/games/dmg/spigottyrant/TyrantTabCompleterTest.java index 0031622..87db7e7 100644 --- a/src/test/java/games/dmg/spigottyrant/TyrantTabCompleterTest.java +++ b/src/test/java/games/dmg/spigottyrant/TyrantTabCompleterTest.java @@ -41,6 +41,15 @@ final class TyrantTabCompleterTest { assertTrue(root.containsAll(List.of( "menu", "status", "buy", "armor", "assign", "item" ))); + assertTrue(root.contains("gear")); + assertEquals(List.of("axe", "hoe", "pickaxe", "shovel", "sword"), + completer.onTabComplete(player, command, "tyrant", new String[] {"gear", ""})); + assertEquals(List.of("shovel", "sword"), + completer.onTabComplete(player, command, "tyrant", new String[] {"GEAR", "S"})); + assertEquals(List.of(), completer.onTabComplete( + player, command, "tyrant", new String[] {"gear", "axe", ""})); + assertEquals(List.of("boots", "chestplate", "helmet", "leggings"), + completer.onTabComplete(player, command, "tyrant", new String[] {"armor", ""})); assertFalse(root.contains("SomePlayer")); assertEquals(List.of("roster_intelligence"), buy); assertEquals(List.of("chestplate"), armor); diff --git a/src/test/java/games/dmg/spigottyrant/YamlTyrantStateRepositoryTest.java b/src/test/java/games/dmg/spigottyrant/YamlTyrantStateRepositoryTest.java index 46d15af..75a3343 100644 --- a/src/test/java/games/dmg/spigottyrant/YamlTyrantStateRepositoryTest.java +++ b/src/test/java/games/dmg/spigottyrant/YamlTyrantStateRepositoryTest.java @@ -57,6 +57,35 @@ final class YamlTyrantStateRepositoryTest { 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.values()) { + 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 preservesUnknownFieldsForRetainedState() throws Exception { UUID playerId = UUID.fromString("11111111-1111-1111-1111-111111111111");