Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
553db666e1 |
@@ -6,7 +6,7 @@ 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 the administrator-configured Vigilante arena center. Active Tyrant or Vigilante challenge bosses and challenger entities are transient and are reconstructed or reset from authoritative role and shared-arena state after restart.
|
||||
State is keyed by player UUID while retaining the latest known player name for display. Durable global state includes lifecycle status, current and pending role holders, selection deadlines, paused durations, the current Tyrant reign and its claimed legacy armor slots, and the administrator-configured Vigilante arena center. Active Tyrant or Vigilante challenge bosses and challenger entities are transient and are reconstructed or reset from authoritative role and shared-arena state after restart.
|
||||
|
||||
Durable player state includes participation status, opt-out deadline, login history, role or class, Tyrant level, purchased unlocks, unspent choices, Follower relationship, ability cooldowns, daily intelligence use, bound-item delivery state, and captured mobs.
|
||||
|
||||
@@ -19,3 +19,4 @@ Elapsed-time rules use UTC instants. Paused time is excluded from timers. Saves
|
||||
- [Use class ability items](../user-stories/us-014-use-class-ability-items.md)
|
||||
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md)
|
||||
- [Claim the Tyrant role in the arena](../user-stories/us-020-claim-tyrant-in-arena.md)
|
||||
- [Claim Tyrant legacy armor](../user-stories/us-021-claim-tyrant-legacy-armor.md)
|
||||
|
||||
@@ -23,6 +23,10 @@ The Tyrant starts each reign at level zero with one starting unlock choice. Pers
|
||||
- Permanent Resistance
|
||||
- Permanent Strength
|
||||
|
||||
## Legacy armor
|
||||
|
||||
After purchasing all six standard reign unlocks, the Tyrant may spend later choices on one each of four legacy armor slots. These named Netherite rewards are tracked only to prevent duplicate claims during the current reign; the resulting armor remains ordinary transferable equipment and survives role succession in player inventories.
|
||||
|
||||
## Proximity
|
||||
|
||||
Unless a more specific rule applies, "near the Tyrant" means in the same world and within a configurable 50-block three-dimensional distance.
|
||||
@@ -38,3 +42,4 @@ Vigilante Follower scaling is capped by default at Strength V and Resistance IV,
|
||||
- [Support the Vigilante and Followers](../user-stories/us-008-support-vigilante-and-followers.md)
|
||||
- [Claim the Vigilante role in the arena](../user-stories/us-019-claim-vigilante-in-arena.md)
|
||||
- [Claim the Tyrant role in the arena](../user-stories/us-020-claim-tyrant-in-arena.md)
|
||||
- [Claim Tyrant legacy armor](../user-stories/us-021-claim-tyrant-legacy-armor.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 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)
|
||||
- [Persistence model](concepts/persistence-model.md)
|
||||
|
||||
@@ -6,6 +6,13 @@ description: Chronological record of material decisions affecting the Spigot Tyr
|
||||
|
||||
# Spigot Tyrant Design Log
|
||||
|
||||
## 2026-09-04 — Tyrant legacy armor completed
|
||||
|
||||
- Added four once-per-reign Netherite legacy armor rewards after all six standard unlocks are purchased, with each successful claim consuming one choice.
|
||||
- Each named reward identifies its Tyrant in custom lore and carries Protection V and Mending I while remaining ordinary tradable, retainable, and non-recoverable equipment.
|
||||
- Added command and control-panel selection with confirmation, full-inventory safety, durable claimed-slot state, succession reset, and contextual completion.
|
||||
- Verified progression, duplicate prevention, item specifications, inventory delivery, persistence, reign reset, UI policy, completion, 134 automated tests, compiler warnings, and packaging with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-09-04 — Administrative Tyrant point grant completed
|
||||
|
||||
- Added `/tyrantadmin grantpoint` so a permitted administrator can grant the current Tyrant exactly one level and one unspent unlock choice during a running game.
|
||||
|
||||
@@ -20,3 +20,4 @@
|
||||
18. [US-018: Complete Tyrant commands contextually](us-018-complete-commands-contextually.md)
|
||||
19. [US-019: Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||
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)
|
||||
|
||||
@@ -14,6 +14,7 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
|
||||
- [x] The Tyrant gains one level and one unlock choice only when personally credited with killing the current Vigilante.
|
||||
- [x] An administrator can grant the current Tyrant one level and one unlock choice without changing purchases or unrelated game state.
|
||||
- [x] Each successful administrative grant adds exactly one level and one choice, including across repeated invocations.
|
||||
- [x] After purchasing all six standard unlocks, the Tyrant may spend later choices on one of each legacy armor slot during the reign.
|
||||
- [x] A Vigilante death caused by another player, the environment, or the Vigilante does not level the Tyrant.
|
||||
- [x] 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.
|
||||
@@ -31,3 +32,4 @@ As the **Tyrant**, I want to earn one meaningful choice for defeating the Vigila
|
||||
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
|
||||
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
|
||||
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
||||
- [Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
|
||||
|
||||
@@ -14,7 +14,7 @@ As a **server operator**, I want configurable and durable game behavior so that
|
||||
- [x] Configuration covers ranges, durations, cooldowns, inactivity and maintenance intervals, effect levels and caps, mob restrictions, item materials and names, messages, timer behavior, shared-role-arena boss balance, and a validated barrier wall height defaulting to 12 blocks.
|
||||
- [x] Defaults match the approved user stories, including a 50-block Tyrant range and Follower range, seven-day opt-out, 48-hour inactivity, and one-minute role maintenance.
|
||||
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
|
||||
- [x] UUID-keyed state stores lifecycle, current and pending roles, assignments, login history, participation, reign progression, purchases, choices, cooldowns, paused time, item delivery, and captured mobs; the administrator-set arena center is durable across restarts.
|
||||
- [x] UUID-keyed state stores lifecycle, current and pending roles, assignments, login history, participation, reign progression, purchases, choices, claimed legacy armor slots, cooldowns, paused time, item delivery, and captured mobs; the administrator-set arena center is durable across restarts.
|
||||
- [x] Cooldowns and deadlines use UTC instants and exclude administratively paused time.
|
||||
- [x] State is saved using atomic replacement where supported so failed writes do not replace valid state with partial data.
|
||||
- [x] Corrupt, unknown, or invalid records cannot silently grant progression, roles, powers, items, or duplicated mobs.
|
||||
@@ -27,3 +27,4 @@ As a **server operator**, I want configurable and durable game behavior so that
|
||||
- [Build, test, and release the plugin](us-013-build-test-and-release.md)
|
||||
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||
- [Claim the Tyrant role in the arena](us-020-claim-tyrant-in-arena.md)
|
||||
- [Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
|
||||
|
||||
@@ -22,6 +22,7 @@ As the **Tyrant**, I want a control panel for my reign so that I can inspect pro
|
||||
- [x] Purchased, available, unavailable, and cooldown-gated actions are visually distinguishable and explain why they cannot currently be used.
|
||||
- [x] 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] 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.
|
||||
@@ -44,3 +45,4 @@ Automated tests verify command entry points, active-Tyrant authorization, author
|
||||
- [Purchase and use Tyrant abilities](us-009-use-tyrant-abilities.md)
|
||||
- [Inform players and manage participation](us-011-inform-and-manage-participation.md)
|
||||
- [Use bound role control items](us-017-use-bound-role-control-items.md)
|
||||
- [Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
|
||||
|
||||
@@ -13,6 +13,7 @@ As a **player or administrator**, I want contextual command suggestions so that
|
||||
|
||||
- [x] `/tyrant` suggests its available subcommands instead of arbitrary player names.
|
||||
- [x] `/tyrant buy` suggests Tyrant unlock names.
|
||||
- [x] `/tyrant armor` suggests `helmet`, `chestplate`, `leggings`, and `boots` and accepts no further arguments.
|
||||
- [x] `/tyrant assign` suggests `assassin`, `fixer`, and `tamer`, followed by eligible online players.
|
||||
- [x] `/tyrant relinquish` suggests `confirm`.
|
||||
- [x] `/vigilante` suggests its available subcommands.
|
||||
@@ -30,7 +31,7 @@ As a **player or administrator**, I want contextual command suggestions so that
|
||||
|
||||
## Validation
|
||||
|
||||
Automated tests verify root syntax, unlocks, classes, confirmations, eligible recruits, current Followers, permission gating, online administrative candidates, the argument-free `grantpoint` command, argument positions, and case-insensitive prefix filtering. The complete `./gradlew clean check jar` lifecycle passes.
|
||||
Automated tests verify root syntax, unlocks, legacy armor pieces, classes, confirmations, eligible recruits, current Followers, permission gating, online administrative candidates, the argument-free `grantpoint` command, argument positions, and case-insensitive prefix filtering. The complete `./gradlew clean check jar` lifecycle passes.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -40,3 +41,4 @@ Automated tests verify root syntax, unlocks, classes, confirmations, eligible re
|
||||
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
||||
- [Claim the Vigilante role in the arena](us-019-claim-vigilante-in-arena.md)
|
||||
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
|
||||
- [Claim Tyrant legacy armor](us-021-claim-tyrant-legacy-armor.md)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-021: Claim Tyrant legacy armor"
|
||||
description: Let a fully unlocked Tyrant spend later choices on unique tradable armor rewards.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-021: Claim Tyrant legacy armor
|
||||
|
||||
As a **fully unlocked Tyrant**, I want to turn later progression choices into named armor so that continued victories create lasting trophies with ordinary player value.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Once all six standard reign unlocks are purchased, the active Tyrant may spend each additional unspent choice on one legacy armor piece.
|
||||
- [x] Available rewards are a Netherite Helmet, Netherite Chestplate, Netherite Leggings, and Netherite Boots.
|
||||
- [x] Each piece has Protection V and Mending I, a display name in the form `Tyrant's <player name> <piece>`, and lore identifying the named Tyrant and reign reward.
|
||||
- [x] Each armor slot may be claimed only once during a reign, limiting a Tyrant to four legacy armor rewards per reign.
|
||||
- [x] A successful claim consumes exactly one unspent choice and records the claimed slot durably.
|
||||
- [x] Armor is delivered to normal inventory rather than auto-equipped; insufficient inventory space does not consume a choice or record a claim.
|
||||
- [x] The armor is ordinary transferable equipment: it may be worn, repaired, enchanted, dropped, traded, stored, or retained after the reign ends.
|
||||
- [x] Lost or transferred armor is not recoverable and does not permit that slot to be claimed again during the same reign.
|
||||
- [x] Claimed-slot state survives logout and restart, resets when the reign ends, and loads missing legacy state as no claimed pieces.
|
||||
- [x] Paused games, non-Tyrants, incomplete standard unlocks, exhausted choices, duplicate slots, malformed requests, and stale or repeated interactions cannot create or duplicate armor.
|
||||
- [x] The Tyrant can select and confirm an available armor piece through the existing control panel, using the same domain and persistence rules as the command path.
|
||||
- [x] `/tyrant armor <helmet|chestplate|leggings|boots>` remains available as an alternative and supports contextual completion.
|
||||
|
||||
## Related
|
||||
|
||||
- [Progress and purchase Tyrant unlocks](us-003-progress-and-purchase-unlocks.md)
|
||||
- [Configure and persist game state](us-012-configure-and-persist-state.md)
|
||||
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
|
||||
- [Complete Tyrant commands contextually](us-018-complete-commands-contextually.md)
|
||||
- [Role and class model](../concepts/role-and-class-model.md)
|
||||
@@ -0,0 +1,4 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public record ArmorClaimResult(GameState state, ArmorClaimStatus status) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public enum ArmorClaimStatus {
|
||||
CLAIMED,
|
||||
GAME_NOT_RUNNING,
|
||||
NOT_TYRANT,
|
||||
UNLOCKS_INCOMPLETE,
|
||||
NO_CHOICES,
|
||||
ALREADY_CLAIMED
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.enchantments.Enchantment;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
|
||||
public final class BukkitTyrantArmorItemFactory implements TyrantArmorItemFactory {
|
||||
@Override
|
||||
public ItemStack create(TyrantArmorPiece piece, String tyrantName) {
|
||||
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(piece, tyrantName);
|
||||
ItemStack item = new ItemStack(spec.material());
|
||||
ItemMeta meta = Objects.requireNonNull(item.getItemMeta(), "Armor metadata is unavailable");
|
||||
meta.setDisplayName(ChatColor.GOLD + spec.displayName());
|
||||
meta.setLore(spec.lore().stream().map(line -> ChatColor.GRAY + line).toList());
|
||||
meta.addEnchant(Enchantment.PROTECTION, spec.protectionLevel(), true);
|
||||
meta.addEnchant(Enchantment.MENDING, spec.mendingLevel(), true);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,14 @@ public final class BukkitTyrantControlPanelRenderer
|
||||
String intelligence = intelligenceText(model);
|
||||
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(
|
||||
model.allStandardUnlocksPurchased()
|
||||
? "Choose a once-per-reign armor reward."
|
||||
: "Purchase all six standard unlocks first.",
|
||||
"Claimed: " + (model.claimedArmor().isEmpty()
|
||||
? "none" : model.claimedArmor())
|
||||
)));
|
||||
player.openInventory(inventory);
|
||||
}
|
||||
|
||||
@@ -83,6 +91,10 @@ public final class BukkitTyrantControlPanelRenderer
|
||||
case ASSIGN_CONFIRMATION -> clickAssignmentConfirmation(
|
||||
player, event.getRawSlot(), holder.tyrantClass(), holder.targetId()
|
||||
);
|
||||
case ARMOR -> clickArmor(player, event.getRawSlot());
|
||||
case ARMOR_CONFIRMATION -> clickArmorConfirmation(
|
||||
player, event.getRawSlot(), holder.armorPiece()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +105,67 @@ public final class BukkitTyrantControlPanelRenderer
|
||||
openClasses(player);
|
||||
} else if (slot == 16) {
|
||||
openAbilities(player);
|
||||
} else if (slot == 22) {
|
||||
openArmor(player);
|
||||
}
|
||||
}
|
||||
|
||||
private void openArmor(Player player) {
|
||||
TyrantControlPanelModel model = model(player);
|
||||
MenuHolder holder = new MenuHolder(MenuView.ARMOR, null, null);
|
||||
Inventory inventory = create(holder, 27, TITLE + " — Legacy Armor");
|
||||
TyrantArmorPiece[] pieces = TyrantArmorPiece.values();
|
||||
int[] slots = {10, 12, 14, 16};
|
||||
for (int index = 0; index < pieces.length; index++) {
|
||||
TyrantArmorPiece piece = pieces[index];
|
||||
String status = model.claimedArmor().contains(piece) ? "CLAIMED"
|
||||
: model.armorAvailable(piece) ? "AVAILABLE" : "LOCKED";
|
||||
inventory.setItem(slots[index], item(
|
||||
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
|
||||
ChatColor.GOLD + readable(piece.name()),
|
||||
List.of("Status: " + status, "Cost: 1 choice")
|
||||
));
|
||||
}
|
||||
inventory.setItem(22, backItem());
|
||||
player.openInventory(inventory);
|
||||
}
|
||||
|
||||
private void clickArmor(Player player, int slot) {
|
||||
if (slot == 22) {
|
||||
refresh(player);
|
||||
return;
|
||||
}
|
||||
TyrantArmorPiece[] pieces = TyrantArmorPiece.values();
|
||||
int[] slots = {10, 12, 14, 16};
|
||||
for (int index = 0; index < slots.length; index++) {
|
||||
if (slot == slots[index] && model(player).armorAvailable(pieces[index])) {
|
||||
openArmorConfirmation(player, pieces[index]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void openArmorConfirmation(Player player, TyrantArmorPiece piece) {
|
||||
MenuHolder holder = new MenuHolder(
|
||||
MenuView.ARMOR_CONFIRMATION, null, null, null, piece
|
||||
);
|
||||
Inventory inventory = create(holder, 27, TITLE + " — Confirm Armor");
|
||||
inventory.setItem(11, item(Material.RED_WOOL, ChatColor.RED + "Cancel", List.of()));
|
||||
inventory.setItem(13, item(
|
||||
TyrantArmorSpec.forPiece(piece, player.getName()).material(),
|
||||
ChatColor.GOLD + "Tyrant's " + player.getName() + " " + readable(piece.name()),
|
||||
List.of("Protection V", "Mending I", "Cost: 1 choice")
|
||||
));
|
||||
inventory.setItem(15, item(Material.LIME_WOOL, ChatColor.GREEN + "Confirm", List.of()));
|
||||
player.openInventory(inventory);
|
||||
}
|
||||
|
||||
private void clickArmorConfirmation(Player player, int slot, TyrantArmorPiece piece) {
|
||||
if (slot == 11) {
|
||||
openArmor(player);
|
||||
} else if (slot == 15 && piece != null) {
|
||||
player.performCommand("tyrant armor " + piece.commandName());
|
||||
openArmor(player);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +459,9 @@ public final class BukkitTyrantControlPanelRenderer
|
||||
CLASSES,
|
||||
ABILITIES,
|
||||
PLAYERS,
|
||||
ASSIGN_CONFIRMATION
|
||||
ASSIGN_CONFIRMATION,
|
||||
ARMOR,
|
||||
ARMOR_CONFIRMATION
|
||||
}
|
||||
|
||||
private static final class MenuHolder implements InventoryHolder {
|
||||
@@ -394,6 +469,7 @@ public final class BukkitTyrantControlPanelRenderer
|
||||
private final TyrantUnlock unlock;
|
||||
private final TyrantClass tyrantClass;
|
||||
private final UUID targetId;
|
||||
private final TyrantArmorPiece armorPiece;
|
||||
private Inventory inventory;
|
||||
|
||||
private MenuHolder(MenuView view, TyrantUnlock unlock, TyrantClass tyrantClass) {
|
||||
@@ -405,11 +481,22 @@ public final class BukkitTyrantControlPanelRenderer
|
||||
TyrantUnlock unlock,
|
||||
TyrantClass tyrantClass,
|
||||
UUID targetId
|
||||
) {
|
||||
this(view, unlock, tyrantClass, targetId, null);
|
||||
}
|
||||
|
||||
private MenuHolder(
|
||||
MenuView view,
|
||||
TyrantUnlock unlock,
|
||||
TyrantClass tyrantClass,
|
||||
UUID targetId,
|
||||
TyrantArmorPiece armorPiece
|
||||
) {
|
||||
this.view = view;
|
||||
this.unlock = unlock;
|
||||
this.tyrantClass = tyrantClass;
|
||||
this.targetId = targetId;
|
||||
this.armorPiece = armorPiece;
|
||||
}
|
||||
|
||||
private MenuView view() {
|
||||
@@ -428,6 +515,10 @@ public final class BukkitTyrantControlPanelRenderer
|
||||
return targetId;
|
||||
}
|
||||
|
||||
private TyrantArmorPiece armorPiece() {
|
||||
return armorPiece;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Inventory getInventory() {
|
||||
return inventory;
|
||||
|
||||
@@ -52,7 +52,8 @@ public final class GameLifecycleService {
|
||||
current.accumulatedPausedTime(),
|
||||
current.tyrantLevel(),
|
||||
current.unspentChoices(),
|
||||
current.purchases()
|
||||
current.purchases(),
|
||||
current.claimedArmor()
|
||||
);
|
||||
return new LifecycleState(paused, state.players());
|
||||
}
|
||||
@@ -77,7 +78,8 @@ public final class GameLifecycleService {
|
||||
current.accumulatedPausedTime().plus(pausedFor),
|
||||
current.tyrantLevel(),
|
||||
current.unspentChoices(),
|
||||
current.purchases()
|
||||
current.purchases(),
|
||||
current.claimedArmor()
|
||||
);
|
||||
Map<UUID, PlayerState> players = new HashMap<>();
|
||||
for (PlayerState player : state.players().values()) {
|
||||
|
||||
@@ -16,7 +16,8 @@ public record GameState(
|
||||
Duration accumulatedPausedTime,
|
||||
int tyrantLevel,
|
||||
int unspentChoices,
|
||||
Set<TyrantUnlock> purchases
|
||||
Set<TyrantUnlock> purchases,
|
||||
Set<TyrantArmorPiece> claimedArmor
|
||||
) {
|
||||
public GameState {
|
||||
lifecycle = lifecycle == null ? GameLifecycle.UNSTARTED : lifecycle;
|
||||
@@ -28,6 +29,7 @@ public record GameState(
|
||||
accumulatedPausedTime = accumulatedPausedTime == null
|
||||
? Duration.ZERO : accumulatedPausedTime;
|
||||
purchases = purchases == null ? Set.of() : Set.copyOf(purchases);
|
||||
claimedArmor = claimedArmor == null ? Set.of() : Set.copyOf(claimedArmor);
|
||||
if (tyrantId.isPresent() && tyrantId.equals(vigilanteId)) {
|
||||
throw new IllegalArgumentException("Tyrant and Vigilante must be different players");
|
||||
}
|
||||
@@ -42,6 +44,24 @@ public record GameState(
|
||||
}
|
||||
}
|
||||
|
||||
public GameState(
|
||||
GameLifecycle lifecycle,
|
||||
Optional<UUID> tyrantId,
|
||||
Optional<UUID> vigilanteId,
|
||||
Optional<PendingSelection> pendingTyrant,
|
||||
Optional<PendingSelection> pendingVigilante,
|
||||
Optional<Instant> pausedAt,
|
||||
Duration accumulatedPausedTime,
|
||||
int tyrantLevel,
|
||||
int unspentChoices,
|
||||
Set<TyrantUnlock> purchases
|
||||
) {
|
||||
this(
|
||||
lifecycle, tyrantId, vigilanteId, pendingTyrant, pendingVigilante, pausedAt,
|
||||
accumulatedPausedTime, tyrantLevel, unspentChoices, purchases, Set.of()
|
||||
);
|
||||
}
|
||||
|
||||
public static GameState empty() {
|
||||
return new GameState(
|
||||
GameLifecycle.UNSTARTED,
|
||||
@@ -53,6 +73,7 @@ public record GameState(
|
||||
Duration.ZERO,
|
||||
0,
|
||||
0,
|
||||
Set.of(),
|
||||
Set.of()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ public final class RoleMaintenanceService {
|
||||
return new GameState(
|
||||
game.lifecycle(), tyrant, vigilante, pendingTyrant, pendingVigilante,
|
||||
game.pausedAt(), game.accumulatedPausedTime(), game.tyrantLevel(),
|
||||
game.unspentChoices(), game.purchases()
|
||||
game.unspentChoices(), game.purchases(), game.claimedArmor()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,8 @@ public final class TyrantAdminCommand implements CommandExecutor {
|
||||
+ game.vigilanteId().map(Object::toString).orElse("none"));
|
||||
sender.sendMessage("Level: " + game.tyrantLevel()
|
||||
+ ", choices: " + game.unspentChoices()
|
||||
+ ", purchases: " + game.purchases());
|
||||
+ ", purchases: " + game.purchases()
|
||||
+ ", claimed armor: " + game.claimedArmor());
|
||||
sender.sendMessage("Pending Tyrant: " + game.pendingTyrant());
|
||||
sender.sendMessage("Pending Vigilante: " + game.pendingVigilante());
|
||||
sender.sendMessage("Shared role arena: " + arenaLocations.location()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface TyrantArmorItemFactory {
|
||||
ItemStack create(TyrantArmorPiece piece, String tyrantName);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
public enum TyrantArmorPiece {
|
||||
HELMET,
|
||||
CHESTPLATE,
|
||||
LEGGINGS,
|
||||
BOOTS;
|
||||
|
||||
public static TyrantArmorPiece fromCommand(String value) {
|
||||
return switch (value.toLowerCase(java.util.Locale.ROOT)) {
|
||||
case "helmet" -> HELMET;
|
||||
case "chestplate", "chest" -> CHESTPLATE;
|
||||
case "leggings", "legs" -> LEGGINGS;
|
||||
case "boots", "feet" -> BOOTS;
|
||||
default -> throw new IllegalArgumentException("Unknown armor piece");
|
||||
};
|
||||
}
|
||||
|
||||
public String commandName() {
|
||||
return name().toLowerCase(java.util.Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class TyrantArmorService {
|
||||
public ArmorClaimResult claim(GameState state, UUID claimantId, TyrantArmorPiece piece) {
|
||||
if (state.lifecycle() != GameLifecycle.RUNNING) {
|
||||
return result(state, ArmorClaimStatus.GAME_NOT_RUNNING);
|
||||
}
|
||||
if (state.tyrantId().filter(claimantId::equals).isEmpty()) {
|
||||
return result(state, ArmorClaimStatus.NOT_TYRANT);
|
||||
}
|
||||
if (!state.purchases().containsAll(EnumSet.allOf(TyrantUnlock.class))) {
|
||||
return result(state, ArmorClaimStatus.UNLOCKS_INCOMPLETE);
|
||||
}
|
||||
if (state.unspentChoices() == 0) {
|
||||
return result(state, ArmorClaimStatus.NO_CHOICES);
|
||||
}
|
||||
if (state.claimedArmor().contains(piece)) {
|
||||
return result(state, ArmorClaimStatus.ALREADY_CLAIMED);
|
||||
}
|
||||
Set<TyrantArmorPiece> claimed = EnumSet.noneOf(TyrantArmorPiece.class);
|
||||
claimed.addAll(state.claimedArmor());
|
||||
claimed.add(piece);
|
||||
GameState updated = new GameState(
|
||||
state.lifecycle(), state.tyrantId(), state.vigilanteId(), state.pendingTyrant(),
|
||||
state.pendingVigilante(), state.pausedAt(), state.accumulatedPausedTime(),
|
||||
state.tyrantLevel(), state.unspentChoices() - 1, state.purchases(), claimed
|
||||
);
|
||||
return result(updated, ArmorClaimStatus.CLAIMED);
|
||||
}
|
||||
|
||||
private static ArmorClaimResult result(GameState state, ArmorClaimStatus status) {
|
||||
return new ArmorClaimResult(state, status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import java.util.List;
|
||||
import org.bukkit.Material;
|
||||
|
||||
public record TyrantArmorSpec(
|
||||
Material material,
|
||||
String displayName,
|
||||
List<String> lore,
|
||||
int protectionLevel,
|
||||
int mendingLevel
|
||||
) {
|
||||
public TyrantArmorSpec {
|
||||
lore = List.copyOf(lore);
|
||||
}
|
||||
|
||||
public static TyrantArmorSpec forPiece(TyrantArmorPiece piece, String tyrantName) {
|
||||
Material material = switch (piece) {
|
||||
case HELMET -> Material.NETHERITE_HELMET;
|
||||
case CHESTPLATE -> Material.NETHERITE_CHESTPLATE;
|
||||
case LEGGINGS -> Material.NETHERITE_LEGGINGS;
|
||||
case BOOTS -> Material.NETHERITE_BOOTS;
|
||||
};
|
||||
String pieceName = readable(piece);
|
||||
return new TyrantArmorSpec(
|
||||
material,
|
||||
"Tyrant's " + tyrantName + " " + pieceName,
|
||||
List.of(
|
||||
"Forged for Tyrant " + tyrantName + ".",
|
||||
"A lasting reward from " + tyrantName + "'s reign."
|
||||
),
|
||||
5,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
private static String readable(TyrantArmorPiece piece) {
|
||||
String lower = piece.name().toLowerCase(java.util.Locale.ROOT);
|
||||
return Character.toUpperCase(lower.charAt(0)) + lower.substring(1);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ public final class TyrantCommand implements CommandExecutor {
|
||||
private final TyrantAbilityService tyrantAbilities;
|
||||
private final TyrantControlPanel controlPanel;
|
||||
private final RoleControlItemService roleControlItems;
|
||||
private final TyrantArmorService armor = new TyrantArmorService();
|
||||
private final TyrantArmorItemFactory armorItems;
|
||||
|
||||
public TyrantCommand(
|
||||
TyrantStateManager stateManager,
|
||||
@@ -63,6 +65,20 @@ public final class TyrantCommand implements CommandExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
TyrantCommand(
|
||||
TyrantStateManager stateManager,
|
||||
TyrantProgressionService progression,
|
||||
TyrantArmorItemFactory armorItems
|
||||
) {
|
||||
this(
|
||||
stateManager, progression, new ClassAssignmentService(),
|
||||
new BukkitOnlinePlayerDirectory(), Clock.systemUTC(),
|
||||
new ParticipationService(Duration.ofDays(7)), null, null, null,
|
||||
new TyrantAbilityService(Duration.ofHours(24)), TyrantCommand::unavailablePanel,
|
||||
null, armorItems
|
||||
);
|
||||
}
|
||||
|
||||
TyrantCommand(
|
||||
TyrantStateManager stateManager,
|
||||
TyrantProgressionService progression,
|
||||
@@ -129,6 +145,28 @@ public final class TyrantCommand implements CommandExecutor {
|
||||
TyrantAbilityService tyrantAbilities,
|
||||
TyrantControlPanel controlPanel,
|
||||
RoleControlItemService roleControlItems
|
||||
) {
|
||||
this(
|
||||
stateManager, progression, assignments, onlinePlayers, clock, participation,
|
||||
relinquishment, presentation, abilityItems, tyrantAbilities, controlPanel,
|
||||
roleControlItems, new BukkitTyrantArmorItemFactory()
|
||||
);
|
||||
}
|
||||
|
||||
public TyrantCommand(
|
||||
TyrantStateManager stateManager,
|
||||
TyrantProgressionService progression,
|
||||
ClassAssignmentService assignments,
|
||||
OnlinePlayerDirectory onlinePlayers,
|
||||
Clock clock,
|
||||
ParticipationService participation,
|
||||
RoleRelinquishmentService relinquishment,
|
||||
TyrantPresentation presentation,
|
||||
AbilityItemService abilityItems,
|
||||
TyrantAbilityService tyrantAbilities,
|
||||
TyrantControlPanel controlPanel,
|
||||
RoleControlItemService roleControlItems,
|
||||
TyrantArmorItemFactory armorItems
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.progression = progression;
|
||||
@@ -142,6 +180,7 @@ public final class TyrantCommand implements CommandExecutor {
|
||||
this.tyrantAbilities = tyrantAbilities;
|
||||
this.controlPanel = controlPanel;
|
||||
this.roleControlItems = roleControlItems;
|
||||
this.armorItems = armorItems;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -168,6 +207,10 @@ public final class TyrantCommand implements CommandExecutor {
|
||||
buy(player, arguments[1]);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("armor")) {
|
||||
claimArmor(player, arguments[1]);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("assign")) {
|
||||
assign(player, arguments[1], arguments[2]);
|
||||
return true;
|
||||
@@ -198,7 +241,7 @@ public final class TyrantCommand implements CommandExecutor {
|
||||
}
|
||||
player.sendMessage(
|
||||
ChatColor.YELLOW
|
||||
+ "Usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|"
|
||||
+ "Usage: /tyrant <menu|status|choices|buy|armor|assign|item|intelligence|optout|optin|"
|
||||
+ "relinquish confirm>"
|
||||
);
|
||||
return true;
|
||||
@@ -220,6 +263,12 @@ 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: "
|
||||
+ (state.claimedArmor().isEmpty() ? "none" : state.claimedArmor()));
|
||||
player.sendMessage(ChatColor.YELLOW
|
||||
+ "Use /tyrant armor <helmet|chestplate|leggings|boots>.");
|
||||
}
|
||||
}
|
||||
|
||||
private void buy(Player player, String requestedUnlock) {
|
||||
@@ -246,6 +295,36 @@ public final class TyrantCommand implements CommandExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
private void claimArmor(Player player, String requestedPiece) {
|
||||
TyrantArmorPiece piece;
|
||||
try {
|
||||
piece = TyrantArmorPiece.fromCommand(requestedPiece);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Unknown armor piece. Use helmet, chestplate, leggings, or boots.");
|
||||
return;
|
||||
}
|
||||
PersistentState snapshot = stateManager.snapshot();
|
||||
ArmorClaimResult result = armor.claim(snapshot.game(), player.getUniqueId(), piece);
|
||||
if (result.status() != ArmorClaimStatus.CLAIMED) {
|
||||
player.sendMessage(ChatColor.RED + "Could not claim " + piece.commandName()
|
||||
+ ": " + readable(result.status()));
|
||||
return;
|
||||
}
|
||||
int inventorySlot = player.getInventory().firstEmpty();
|
||||
if (inventorySlot < 0) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Your inventory is full; no choice was consumed.");
|
||||
return;
|
||||
}
|
||||
org.bukkit.inventory.ItemStack item = armorItems.create(piece, player.getName());
|
||||
stateManager.replaceState(new LifecycleState(result.state(), snapshot.players()));
|
||||
player.getInventory().setItem(inventorySlot, item);
|
||||
stateManager.saveIfDirty();
|
||||
player.sendMessage(ChatColor.GREEN + "Claimed " + piece.commandName()
|
||||
+ " for one choice.");
|
||||
}
|
||||
|
||||
private void assign(Player tyrant, String requestedClass, String targetName) {
|
||||
TyrantClass tyrantClass;
|
||||
try {
|
||||
|
||||
@@ -10,11 +10,13 @@ public record TyrantControlPanelModel(
|
||||
int level,
|
||||
int unspentChoices,
|
||||
Map<TyrantUnlock, UnlockAvailability> unlocks,
|
||||
Set<TyrantArmorPiece> claimedArmor,
|
||||
Map<TyrantClass, String> classHolders,
|
||||
Duration intelligenceCooldown
|
||||
) {
|
||||
public TyrantControlPanelModel {
|
||||
unlocks = Map.copyOf(unlocks);
|
||||
claimedArmor = Set.copyOf(claimedArmor);
|
||||
classHolders = Map.copyOf(classHolders);
|
||||
}
|
||||
|
||||
@@ -25,6 +27,15 @@ public record TyrantControlPanelModel(
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
public boolean allStandardUnlocksPurchased() {
|
||||
return purchasedUnlocks().size() == TyrantUnlock.values().length;
|
||||
}
|
||||
|
||||
public boolean armorAvailable(TyrantArmorPiece piece) {
|
||||
return allStandardUnlocksPurchased() && unspentChoices > 0
|
||||
&& !claimedArmor.contains(piece);
|
||||
}
|
||||
|
||||
public static TyrantControlPanelModel create(
|
||||
GameState game,
|
||||
PlayerState tyrant,
|
||||
@@ -61,7 +72,8 @@ public record TyrantControlPanelModel(
|
||||
? Duration.between(now, tyrant.cooldownEnds().get(Ability.ROSTER_INTELLIGENCE))
|
||||
: Duration.ZERO;
|
||||
return new TyrantControlPanelModel(
|
||||
game.tyrantLevel(), game.unspentChoices(), unlocks, holders, cooldown
|
||||
game.tyrantLevel(), game.unspentChoices(), unlocks, game.claimedArmor(),
|
||||
holders, cooldown
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public final class TyrantProgressionService {
|
||||
}
|
||||
|
||||
public GameState resetReignProgression(GameState state) {
|
||||
return copyProgress(state, 0, 0, java.util.Set.of());
|
||||
return copyProgress(state, 0, 0, java.util.Set.of(), java.util.Set.of());
|
||||
}
|
||||
|
||||
private static GameState copyProgress(
|
||||
@@ -77,6 +77,16 @@ public final class TyrantProgressionService {
|
||||
int level,
|
||||
int choices,
|
||||
java.util.Set<TyrantUnlock> purchases
|
||||
) {
|
||||
return copyProgress(state, level, choices, purchases, state.claimedArmor());
|
||||
}
|
||||
|
||||
private static GameState copyProgress(
|
||||
GameState state,
|
||||
int level,
|
||||
int choices,
|
||||
java.util.Set<TyrantUnlock> purchases,
|
||||
java.util.Set<TyrantArmorPiece> claimedArmor
|
||||
) {
|
||||
return new GameState(
|
||||
state.lifecycle(),
|
||||
@@ -88,7 +98,8 @@ public final class TyrantProgressionService {
|
||||
state.accumulatedPausedTime(),
|
||||
level,
|
||||
choices,
|
||||
purchases
|
||||
purchases,
|
||||
claimedArmor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,16 @@ import org.bukkit.entity.Player;
|
||||
|
||||
public final class TyrantTabCompleter implements TabCompleter {
|
||||
private static final List<String> SUBCOMMANDS = List.of(
|
||||
"menu", "status", "choices", "buy", "assign", "item", "intelligence",
|
||||
"menu", "status", "choices", "buy", "armor", "assign", "item", "intelligence",
|
||||
"optout", "optin", "relinquish"
|
||||
);
|
||||
private static final List<String> UNLOCKS = java.util.Arrays.stream(TyrantUnlock.values())
|
||||
.map(value -> value.name().toLowerCase(java.util.Locale.ROOT))
|
||||
.toList();
|
||||
private static final List<String> CLASSES = List.of("assassin", "fixer", "tamer");
|
||||
private static final List<String> ARMOR = java.util.Arrays.stream(TyrantArmorPiece.values())
|
||||
.map(TyrantArmorPiece::commandName)
|
||||
.toList();
|
||||
private final TyrantStateManager stateManager;
|
||||
private final OnlinePlayerDirectory onlinePlayers;
|
||||
|
||||
@@ -47,6 +50,7 @@ public final class TyrantTabCompleter implements TabCompleter {
|
||||
if (arguments.length == 2) {
|
||||
return switch (subcommand) {
|
||||
case "buy" -> TabSuggestions.matching(arguments[1], UNLOCKS);
|
||||
case "armor" -> TabSuggestions.matching(arguments[1], ARMOR);
|
||||
case "assign" -> TabSuggestions.matching(arguments[1], CLASSES);
|
||||
case "relinquish" -> TabSuggestions.matching(
|
||||
arguments[1], List.of("confirm")
|
||||
|
||||
@@ -15,7 +15,7 @@ public final class VigilanteArenaSuccessionService {
|
||||
GameState assigned = new GameState(
|
||||
game.lifecycle(), game.tyrantId(), Optional.of(winnerId), game.pendingTyrant(),
|
||||
Optional.empty(), game.pausedAt(), game.accumulatedPausedTime(),
|
||||
game.tyrantLevel(), game.unspentChoices(), game.purchases()
|
||||
game.tyrantLevel(), game.unspentChoices(), game.purchases(), game.claimedArmor()
|
||||
);
|
||||
return new LifecycleState(assigned, state.players());
|
||||
}
|
||||
|
||||
@@ -95,7 +95,8 @@ public final class YamlTyrantStateRepository {
|
||||
Duration.ofSeconds(nonNegativeLong(yaml, "game.accumulated-paused-seconds")),
|
||||
nonNegativeInt(yaml, "game.tyrant-level"),
|
||||
nonNegativeInt(yaml, "game.unspent-choices"),
|
||||
enumSet(TyrantUnlock.class, yaml.getStringList("game.purchases"))
|
||||
enumSet(TyrantUnlock.class, yaml.getStringList("game.purchases")),
|
||||
enumSet(TyrantArmorPiece.class, yaml.getStringList("game.claimed-armor"))
|
||||
);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return GameState.empty();
|
||||
@@ -192,6 +193,10 @@ 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());
|
||||
yaml.set(
|
||||
"game.claimed-armor",
|
||||
game.claimedArmor().stream().map(Enum::name).sorted().toList()
|
||||
);
|
||||
}
|
||||
|
||||
private static void savePlayer(YamlConfiguration yaml, PlayerState player) {
|
||||
|
||||
@@ -7,7 +7,7 @@ author: dmg.games
|
||||
commands:
|
||||
tyrant:
|
||||
description: View and use Spigot Tyrant game features.
|
||||
usage: /tyrant <menu|status|choices|buy|assign|item|intelligence|optout|optin|relinquish confirm>
|
||||
usage: /tyrant <menu|status|choices|buy|armor <helmet|chestplate|leggings|boots>|assign|item|intelligence|optout|optin|relinquish confirm>
|
||||
vigilante:
|
||||
description: Manage Vigilante Followers.
|
||||
usage: /vigilante <menu|item|invite <player>|accept|dismiss <player>|leave>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class TyrantArmorServiceTest {
|
||||
private static final UUID TYRANT = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
private static final UUID OTHER = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||
private final TyrantArmorService service = new TyrantArmorService();
|
||||
|
||||
@Test
|
||||
void fullyUnlockedTyrantSpendsOneChoiceOnAnUnclaimedPiece() {
|
||||
GameState before = game(2, allUnlocks(), Set.of());
|
||||
|
||||
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.HELMET);
|
||||
|
||||
assertEquals(ArmorClaimStatus.CLAIMED, result.status());
|
||||
assertEquals(1, result.state().unspentChoices());
|
||||
assertEquals(Set.of(TyrantArmorPiece.HELMET), result.state().claimedArmor());
|
||||
assertEquals(before.purchases(), result.state().purchases());
|
||||
}
|
||||
|
||||
@Test
|
||||
void eachPieceCanBeClaimedOnlyOncePerReign() {
|
||||
GameState before = game(2, allUnlocks(), Set.of(TyrantArmorPiece.BOOTS));
|
||||
|
||||
ArmorClaimResult result = service.claim(before, TYRANT, TyrantArmorPiece.BOOTS);
|
||||
|
||||
assertEquals(ArmorClaimStatus.ALREADY_CLAIMED, result.status());
|
||||
assertEquals(before, result.state());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsIncompleteUnlocksNoChoicesNonTyrantsAndPausedGames() {
|
||||
GameState complete = game(0, allUnlocks(), Set.of());
|
||||
GameState incomplete = game(1, Set.of(TyrantUnlock.ASSASSIN), Set.of());
|
||||
GameState paused = new GameState(
|
||||
GameLifecycle.PAUSED, complete.tyrantId(), complete.vigilanteId(),
|
||||
complete.pendingTyrant(), complete.pendingVigilante(),
|
||||
Optional.of(java.time.Instant.parse("2026-09-04T00:00:00Z")), Duration.ZERO,
|
||||
1, 1, allUnlocks(), Set.of()
|
||||
);
|
||||
|
||||
assertEquals(ArmorClaimStatus.NO_CHOICES,
|
||||
service.claim(complete, TYRANT, TyrantArmorPiece.HELMET).status());
|
||||
assertEquals(ArmorClaimStatus.UNLOCKS_INCOMPLETE,
|
||||
service.claim(incomplete, TYRANT, TyrantArmorPiece.HELMET).status());
|
||||
assertEquals(ArmorClaimStatus.NOT_TYRANT,
|
||||
service.claim(game(1, allUnlocks(), Set.of()), OTHER,
|
||||
TyrantArmorPiece.HELMET).status());
|
||||
assertEquals(ArmorClaimStatus.GAME_NOT_RUNNING,
|
||||
service.claim(paused, TYRANT, TyrantArmorPiece.HELMET).status());
|
||||
}
|
||||
|
||||
private static Set<TyrantUnlock> allUnlocks() {
|
||||
return EnumSet.allOf(TyrantUnlock.class);
|
||||
}
|
||||
|
||||
private static GameState game(
|
||||
int choices,
|
||||
Set<TyrantUnlock> unlocks,
|
||||
Set<TyrantArmorPiece> claimed
|
||||
) {
|
||||
return new GameState(
|
||||
GameLifecycle.RUNNING, Optional.of(TYRANT), Optional.empty(),
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||
6, choices, unlocks, claimed
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class TyrantArmorSpecTest {
|
||||
@Test
|
||||
void everyPieceIsNetheriteWithNamedLoreAndApprovedEnchantments() {
|
||||
assertEquals(Material.NETHERITE_HELMET,
|
||||
TyrantArmorSpec.forPiece(TyrantArmorPiece.HELMET, "Alex").material());
|
||||
assertEquals(Material.NETHERITE_CHESTPLATE,
|
||||
TyrantArmorSpec.forPiece(TyrantArmorPiece.CHESTPLATE, "Alex").material());
|
||||
assertEquals(Material.NETHERITE_LEGGINGS,
|
||||
TyrantArmorSpec.forPiece(TyrantArmorPiece.LEGGINGS, "Alex").material());
|
||||
assertEquals(Material.NETHERITE_BOOTS,
|
||||
TyrantArmorSpec.forPiece(TyrantArmorPiece.BOOTS, "Alex").material());
|
||||
|
||||
TyrantArmorSpec spec = TyrantArmorSpec.forPiece(TyrantArmorPiece.HELMET, "Alex");
|
||||
assertEquals("Tyrant's Alex Helmet", spec.displayName());
|
||||
assertTrue(spec.lore().stream().anyMatch(line -> line.contains("Alex")));
|
||||
assertEquals(5, spec.protectionLevel());
|
||||
assertEquals(1, spec.mendingLevel());
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,16 @@ import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
@@ -97,6 +101,74 @@ final class TyrantCommandTest {
|
||||
&& message.contains("ASSASSIN")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullyUnlockedTyrantCanClaimNamedArmorIntoInventory() {
|
||||
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
GameState game = new GameState(
|
||||
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||
6, 2, EnumSet.allOf(TyrantUnlock.class), Set.of()
|
||||
);
|
||||
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||
when(manager.snapshot()).thenReturn(new PersistentState(
|
||||
game, Map.of(tyrantId, PlayerState.newPlayer(tyrantId, "Alex"))
|
||||
));
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
when(inventory.firstEmpty()).thenReturn(4);
|
||||
Player tyrant = mock(Player.class);
|
||||
when(tyrant.getUniqueId()).thenReturn(tyrantId);
|
||||
when(tyrant.getName()).thenReturn("Alex");
|
||||
when(tyrant.getInventory()).thenReturn(inventory);
|
||||
ItemStack reward = mock(ItemStack.class);
|
||||
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
|
||||
when(factory.create(TyrantArmorPiece.HELMET, "Alex")).thenReturn(reward);
|
||||
TyrantCommand command = new TyrantCommand(
|
||||
manager, new TyrantProgressionService(), factory
|
||||
);
|
||||
|
||||
command.onCommand(
|
||||
tyrant, mock(Command.class), "tyrant", new String[] {"armor", "helmet"}
|
||||
);
|
||||
|
||||
verify(manager).replaceState(org.mockito.ArgumentMatchers.argThat(state ->
|
||||
state.game().unspentChoices() == 1
|
||||
&& state.game().claimedArmor().equals(Set.of(TyrantArmorPiece.HELMET))
|
||||
));
|
||||
verify(inventory).setItem(4, reward);
|
||||
verify(manager).saveIfDirty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullInventoryDoesNotConsumeChoiceOrCreateArmor() {
|
||||
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
GameState game = new GameState(
|
||||
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||
6, 1, EnumSet.allOf(TyrantUnlock.class), Set.of()
|
||||
);
|
||||
TyrantStateManager manager = mock(TyrantStateManager.class);
|
||||
when(manager.snapshot()).thenReturn(new PersistentState(
|
||||
game, Map.of(tyrantId, PlayerState.newPlayer(tyrantId, "Alex"))
|
||||
));
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
when(inventory.firstEmpty()).thenReturn(-1);
|
||||
Player tyrant = mock(Player.class);
|
||||
when(tyrant.getUniqueId()).thenReturn(tyrantId);
|
||||
when(tyrant.getInventory()).thenReturn(inventory);
|
||||
TyrantArmorItemFactory factory = mock(TyrantArmorItemFactory.class);
|
||||
TyrantCommand command = new TyrantCommand(
|
||||
manager, new TyrantProgressionService(), factory
|
||||
);
|
||||
|
||||
command.onCommand(
|
||||
tyrant, mock(Command.class), "tyrant", new String[] {"armor", "boots"}
|
||||
);
|
||||
|
||||
verify(manager, org.mockito.Mockito.never()).replaceState(any());
|
||||
verify(factory, org.mockito.Mockito.never()).create(any(), any());
|
||||
verify(tyrant).sendMessage(org.mockito.ArgumentMatchers.contains("inventory is full"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void assigningClassNotifiesTyrantAndNewHolder() {
|
||||
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package games.dmg.spigottyrant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -46,4 +49,24 @@ final class TyrantControlPanelModelTest {
|
||||
assertEquals("Blade", model.classHolders().get(TyrantClass.ASSASSIN));
|
||||
assertEquals(Duration.ofSeconds(90), model.intelligenceCooldown());
|
||||
}
|
||||
|
||||
@Test
|
||||
void armorAvailabilityRequiresAllUnlocksAChoiceAndAnUnclaimedSlot() {
|
||||
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||
Instant now = Instant.parse("2026-09-04T00:00:00Z");
|
||||
GameState game = new GameState(
|
||||
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.empty(),
|
||||
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
|
||||
6, 1, EnumSet.allOf(TyrantUnlock.class), Set.of(TyrantArmorPiece.HELMET)
|
||||
);
|
||||
PlayerState tyrant = PlayerState.newPlayer(tyrantId, "Tyrant");
|
||||
|
||||
TyrantControlPanelModel model = TyrantControlPanelModel.create(
|
||||
game, tyrant, Map.of(tyrantId, tyrant), now
|
||||
);
|
||||
|
||||
assertTrue(model.allStandardUnlocksPurchased());
|
||||
assertFalse(model.armorAvailable(TyrantArmorPiece.HELMET));
|
||||
assertTrue(model.armorAvailable(TyrantArmorPiece.CHESTPLATE));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,13 @@ final class TyrantProgressionServiceTest {
|
||||
|
||||
@Test
|
||||
void administrativePointGrantAddsOneLevelAndChoicePerInvocation() {
|
||||
GameState before = runningState(2, 1, Set.of(TyrantUnlock.ASSASSIN));
|
||||
GameState base = runningState(2, 1, Set.of(TyrantUnlock.ASSASSIN));
|
||||
GameState before = new GameState(
|
||||
base.lifecycle(), base.tyrantId(), base.vigilanteId(), base.pendingTyrant(),
|
||||
base.pendingVigilante(), base.pausedAt(), base.accumulatedPausedTime(),
|
||||
base.tyrantLevel(), base.unspentChoices(), base.purchases(),
|
||||
Set.of(TyrantArmorPiece.HELMET)
|
||||
);
|
||||
|
||||
GameState first = service.grantPoint(before);
|
||||
GameState second = service.grantPoint(first);
|
||||
@@ -60,6 +66,7 @@ final class TyrantProgressionServiceTest {
|
||||
assertEquals(before.purchases(), second.purchases());
|
||||
assertEquals(before.tyrantId(), second.tyrantId());
|
||||
assertEquals(before.vigilanteId(), second.vigilanteId());
|
||||
assertEquals(before.claimedArmor(), second.claimedArmor());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -123,17 +130,24 @@ final class TyrantProgressionServiceTest {
|
||||
|
||||
@Test
|
||||
void endingReignClearsLevelChoicesAndPurchases() {
|
||||
GameState progressed = runningState(
|
||||
GameState base = runningState(
|
||||
4,
|
||||
2,
|
||||
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
|
||||
);
|
||||
GameState progressed = new GameState(
|
||||
base.lifecycle(), base.tyrantId(), base.vigilanteId(), base.pendingTyrant(),
|
||||
base.pendingVigilante(), base.pausedAt(), base.accumulatedPausedTime(),
|
||||
base.tyrantLevel(), base.unspentChoices(), base.purchases(),
|
||||
Set.of(TyrantArmorPiece.BOOTS)
|
||||
);
|
||||
|
||||
GameState reset = service.resetReignProgression(progressed);
|
||||
|
||||
assertEquals(0, reset.tyrantLevel());
|
||||
assertEquals(0, reset.unspentChoices());
|
||||
assertEquals(Set.of(), reset.purchases());
|
||||
assertEquals(Set.of(), reset.claimedArmor());
|
||||
}
|
||||
|
||||
private static GameState runningState(
|
||||
|
||||
@@ -30,6 +30,7 @@ final class TyrantSuccessionServiceTest {
|
||||
assertEquals(0, after.game().tyrantLevel());
|
||||
assertEquals(1, after.game().unspentChoices());
|
||||
assertEquals(Set.of(), after.game().purchases());
|
||||
assertEquals(Set.of(), after.game().claimedArmor());
|
||||
assertEquals(Optional.empty(), after.game().vigilanteId());
|
||||
assertEquals(Optional.empty(), after.game().pendingVigilante());
|
||||
assertEquals(
|
||||
@@ -57,7 +58,8 @@ final class TyrantSuccessionServiceTest {
|
||||
GameState game = new GameState(
|
||||
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)
|
||||
5, 2, Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH),
|
||||
Set.of(TyrantArmorPiece.CHESTPLATE)
|
||||
);
|
||||
Map<UUID, PlayerState> players = Map.of(
|
||||
OLD_TYRANT, active(new PlayerState(
|
||||
|
||||
@@ -25,6 +25,12 @@ final class TyrantTabCompleterTest {
|
||||
List<String> buy = completer.onTabComplete(
|
||||
player, command, "tyrant", new String[] {"buy", "ro"}
|
||||
);
|
||||
List<String> armor = completer.onTabComplete(
|
||||
player, command, "tyrant", new String[] {"armor", "ch"}
|
||||
);
|
||||
List<String> armorExtra = completer.onTabComplete(
|
||||
player, command, "tyrant", new String[] {"armor", "helmet", ""}
|
||||
);
|
||||
List<String> assign = completer.onTabComplete(
|
||||
player, command, "tyrant", new String[] {"assign", "f"}
|
||||
);
|
||||
@@ -32,9 +38,13 @@ final class TyrantTabCompleterTest {
|
||||
player, command, "tyrant", new String[] {"relinquish", "c"}
|
||||
);
|
||||
|
||||
assertTrue(root.containsAll(List.of("menu", "status", "buy", "assign", "item")));
|
||||
assertTrue(root.containsAll(List.of(
|
||||
"menu", "status", "buy", "armor", "assign", "item"
|
||||
)));
|
||||
assertFalse(root.contains("SomePlayer"));
|
||||
assertEquals(List.of("roster_intelligence"), buy);
|
||||
assertEquals(List.of("chestplate"), armor);
|
||||
assertEquals(List.of(), armorExtra);
|
||||
assertEquals(List.of("fixer"), assign);
|
||||
assertEquals(List.of("confirm"), confirm);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ final class YamlTyrantStateRepositoryTest {
|
||||
Duration.ofMinutes(15),
|
||||
3,
|
||||
2,
|
||||
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH)
|
||||
Set.of(TyrantUnlock.ASSASSIN, TyrantUnlock.STRENGTH),
|
||||
Set.of(TyrantArmorPiece.HELMET, TyrantArmorPiece.BOOTS)
|
||||
);
|
||||
PlayerState player = new PlayerState(
|
||||
tyrant,
|
||||
|
||||
Reference in New Issue
Block a user