From 12d9e7af42fe10384c763a854385775a91d87784 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 8 Aug 2026 13:25:42 -0400 Subject: [PATCH] feat(progression): unlock spawn through boss kills --- README.md | 2 + design/log.md | 6 ++ .../us-001-earn-progressive-spawn-access.md | 24 +++---- .../dmg/triggerspawn/BossKillListener.java | 50 ++++++++++++++ .../java/games/dmg/triggerspawn/BossType.java | 28 +++++++- .../java/games/dmg/triggerspawn/Messages.java | 13 ++++ .../dmg/triggerspawn/ProgressionOutcome.java | 7 ++ .../dmg/triggerspawn/ProgressionResult.java | 13 ++++ .../dmg/triggerspawn/ProgressionService.java | 46 +++++++++++++ .../dmg/triggerspawn/TriggerSpawnPlugin.java | 5 +- .../games/dmg/triggerspawn/MessagesTest.java | 11 ++++ .../triggerspawn/ProgressionServiceTest.java | 65 +++++++++++++++++++ 12 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 src/main/java/games/dmg/triggerspawn/BossKillListener.java create mode 100644 src/main/java/games/dmg/triggerspawn/ProgressionOutcome.java create mode 100644 src/main/java/games/dmg/triggerspawn/ProgressionResult.java create mode 100644 src/main/java/games/dmg/triggerspawn/ProgressionService.java create mode 100644 src/test/java/games/dmg/triggerspawn/ProgressionServiceTest.java diff --git a/README.md b/README.md index d120929..d3c92bf 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A Spigot 26.2 plugin providing progression-gated, safe teleportation to each world's spawn area. +Players unlock `/spawn` by killing a Warden, Ender Dragon, or Wither after the plugin is installed. Their first, second, and third unique kills give default cooldowns of 8 hours, 4 hours, and 1 hour respectively. Each new unique kill clears the current cooldown immediately; repeated kills of the same boss do not count. + The behavior under development is specified in the [OKF design bundle](design/index.md). ## Requirements diff --git a/design/log.md b/design/log.md index cebeaa2..22fbc45 100644 --- a/design/log.md +++ b/design/log.md @@ -51,3 +51,9 @@ description: Chronological record of significant Trigger Spawn design decisions. - Added persistent `/spawn` bans, unbans, and paginated ban listing for online and resolvable offline players. - Banning immediately clears grants, boss progress, and cooldown state; unbanning restores none of them. - Banned player state cannot retain natural or granted access. + +## 2026-08-08 — Boss progression + +- Added player-attributed Warden, Ender Dragon, and Wither kill observation without importing historical accomplishments. +- Unique kills unlock and improve the configured cooldown tier in any order; duplicate kills do not count. +- Each credited kill persists UUID-based progress, clears the current cooldown, and displays an on-screen reward plus colored checklist. diff --git a/design/user-stories/us-001-earn-progressive-spawn-access.md b/design/user-stories/us-001-earn-progressive-spawn-access.md index dadf7f9..fe374e2 100644 --- a/design/user-stories/us-001-earn-progressive-spawn-access.md +++ b/design/user-stories/us-001-earn-progressive-spawn-access.md @@ -2,7 +2,7 @@ type: User Story title: "US-001: Earn progressive spawn access" description: Let players unlock spawn teleportation and improve its cooldown by defeating dangerous enemies. -status: backlog +status: done --- # US-001: Earn progressive spawn access @@ -11,17 +11,17 @@ As a **player**, I want to unlock `/spawn` by defeating major enemies so that my ## Acceptance criteria -- [ ] The plugin observes player-attributed kills of the Warden, Ender Dragon, and Wither. -- [ ] Only kills observed while the plugin is operating count; historical statistics and advancements are not imported. -- [ ] The three enemies may be killed in any order. -- [ ] The first unique qualifying kill unlocks `/spawn` with the configured one-kill cooldown, which defaults to 8 hours. -- [ ] The second unique qualifying kill applies the configured two-kill cooldown, which defaults to 4 hours. -- [ ] The third unique qualifying kill applies the configured three-kill cooldown, which defaults to 1 hour. -- [ ] Repeated kills of an already credited enemy do not improve the player's tier. -- [ ] Each newly credited kill displays an on-screen message stating that `/spawn` has been unlocked or updated, is immediately available, and has the shown applicable cooldown. -- [ ] Progress is presented as a checklist in which credited enemies have a green check and uncredited enemies have a red cross. -- [ ] Each newly credited unique kill immediately clears any active `/spawn` cooldown as a progression reward. -- [ ] Progress is associated with the player's UUID and survives server and plugin restarts. +- [x] The plugin observes player-attributed kills of the Warden, Ender Dragon, and Wither. +- [x] Only kills observed while the plugin is operating count; historical statistics and advancements are not imported. +- [x] The three enemies may be killed in any order. +- [x] The first unique qualifying kill unlocks `/spawn` with the configured one-kill cooldown, which defaults to 8 hours. +- [x] The second unique qualifying kill applies the configured two-kill cooldown, which defaults to 4 hours. +- [x] The third unique qualifying kill applies the configured three-kill cooldown, which defaults to 1 hour. +- [x] Repeated kills of an already credited enemy do not improve the player's tier. +- [x] Each newly credited kill displays an on-screen message stating that `/spawn` has been unlocked or updated, is immediately available, and has the shown applicable cooldown. +- [x] Progress is presented as a checklist in which credited enemies have a green check and uncredited enemies have a red cross. +- [x] Each newly credited unique kill immediately clears any active `/spawn` cooldown as a progression reward. +- [x] Progress is associated with the player's UUID and survives server and plugin restarts. ## Related diff --git a/src/main/java/games/dmg/triggerspawn/BossKillListener.java b/src/main/java/games/dmg/triggerspawn/BossKillListener.java new file mode 100644 index 0000000..30ca348 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/BossKillListener.java @@ -0,0 +1,50 @@ +package games.dmg.triggerspawn; + +import java.io.IOException; +import java.util.logging.Level; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.plugin.Plugin; + +final class BossKillListener implements Listener { + private final Plugin plugin; + private final ProgressionService progression; + + BossKillListener(Plugin plugin, ProgressionService progression) { + this.plugin = plugin; + this.progression = progression; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onEntityDeath(EntityDeathEvent event) { + BossType boss = BossType.from(event.getEntityType()).orElse(null); + Player killer = event.getEntity().getKiller(); + if (boss == null || killer == null) { + return; + } + try { + ProgressionResult result = progression.creditKill( + new PlayerIdentity(killer.getUniqueId(), killer.getName()), boss); + if (result.outcome() != ProgressionOutcome.CREDITED) { + return; + } + String title = result.defeatedBosses().size() == 1 + ? ChatColor.GREEN + "/spawn unlocked!" + : ChatColor.GREEN + "/spawn cooldown improved!"; + String subtitle = ChatColor.YELLOW + "Available now — cooldown " + + DurationFormatter.format(result.applicableCooldown()); + killer.sendTitle(title, subtitle, 10, 70, 20); + killer.sendMessage(ChatColor.GOLD + "Spawn progression:"); + for (String line : Messages.progressionChecklist(result.defeatedBosses())) { + killer.sendMessage(line); + } + } catch (IOException exception) { + plugin.getLogger().log(Level.SEVERE, "Could not persist boss progression", exception); + killer.sendMessage(ChatColor.RED + "Your spawn progress could not be saved; contact an administrator."); + } + } +} diff --git a/src/main/java/games/dmg/triggerspawn/BossType.java b/src/main/java/games/dmg/triggerspawn/BossType.java index 105cf6d..5d82636 100644 --- a/src/main/java/games/dmg/triggerspawn/BossType.java +++ b/src/main/java/games/dmg/triggerspawn/BossType.java @@ -1,7 +1,29 @@ package games.dmg.triggerspawn; +import java.util.Optional; +import org.bukkit.entity.EntityType; + enum BossType { - WARDEN, - ENDER_DRAGON, - WITHER + WARDEN("Warden"), + ENDER_DRAGON("Dragon"), + WITHER("Wither"); + + private final String displayName; + + BossType(String displayName) { + this.displayName = displayName; + } + + String displayName() { + return displayName; + } + + static Optional from(EntityType type) { + return switch (type) { + case WARDEN -> Optional.of(WARDEN); + case ENDER_DRAGON -> Optional.of(ENDER_DRAGON); + case WITHER -> Optional.of(WITHER); + default -> Optional.empty(); + }; + } } diff --git a/src/main/java/games/dmg/triggerspawn/Messages.java b/src/main/java/games/dmg/triggerspawn/Messages.java index c240c60..a19a414 100644 --- a/src/main/java/games/dmg/triggerspawn/Messages.java +++ b/src/main/java/games/dmg/triggerspawn/Messages.java @@ -1,6 +1,9 @@ package games.dmg.triggerspawn; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; import org.bukkit.ChatColor; final class Messages { @@ -12,4 +15,14 @@ final class Messages { + ChatColor.YELLOW + "Cooldown remaining: " + ChatColor.WHITE + DurationFormatter.format(remaining) + "."; } + + static List progressionChecklist(Set defeated) { + List lines = new ArrayList<>(); + for (BossType boss : BossType.values()) { + boolean credited = defeated.contains(boss); + lines.add((credited ? ChatColor.GREEN + "✔" : ChatColor.RED + "✘") + + " killed " + boss.displayName()); + } + return List.copyOf(lines); + } } diff --git a/src/main/java/games/dmg/triggerspawn/ProgressionOutcome.java b/src/main/java/games/dmg/triggerspawn/ProgressionOutcome.java new file mode 100644 index 0000000..912604f --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/ProgressionOutcome.java @@ -0,0 +1,7 @@ +package games.dmg.triggerspawn; + +enum ProgressionOutcome { + CREDITED, + ALREADY_CREDITED, + BANNED +} diff --git a/src/main/java/games/dmg/triggerspawn/ProgressionResult.java b/src/main/java/games/dmg/triggerspawn/ProgressionResult.java new file mode 100644 index 0000000..495f38b --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/ProgressionResult.java @@ -0,0 +1,13 @@ +package games.dmg.triggerspawn; + +import java.time.Duration; +import java.util.Set; + +record ProgressionResult( + ProgressionOutcome outcome, + Set defeatedBosses, + Duration applicableCooldown) { + ProgressionResult { + defeatedBosses = Set.copyOf(defeatedBosses); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/ProgressionService.java b/src/main/java/games/dmg/triggerspawn/ProgressionService.java new file mode 100644 index 0000000..9b25c9f --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/ProgressionService.java @@ -0,0 +1,46 @@ +package games.dmg.triggerspawn; + +import java.io.IOException; +import java.time.Duration; +import java.util.EnumSet; +import java.util.Optional; + +final class ProgressionService { + private final SpawnStateManager stateManager; + private final AccessService access; + + ProgressionService(SpawnStateManager stateManager, AccessService access) { + this.stateManager = stateManager; + this.access = access; + } + + ProgressionResult creditKill(PlayerIdentity identity, BossType boss) throws IOException { + PlayerState current = stateManager.player(identity.playerId(), identity.latestName()); + if (current.banned()) { + return new ProgressionResult( + ProgressionOutcome.BANNED, current.defeatedBosses(), Duration.ZERO); + } + if (current.defeatedBosses().contains(boss)) { + return new ProgressionResult( + ProgressionOutcome.ALREADY_CREDITED, + current.defeatedBosses(), + access.cooldownFor(current).orElse(Duration.ZERO)); + } + EnumSet defeated = current.defeatedBosses().isEmpty() + ? EnumSet.noneOf(BossType.class) + : EnumSet.copyOf(current.defeatedBosses()); + defeated.add(boss); + PlayerState updated = new PlayerState( + identity.playerId(), + identity.latestName(), + defeated, + current.grantedCooldown(), + false, + Optional.empty()); + stateManager.putPlayer(updated); + return new ProgressionResult( + ProgressionOutcome.CREDITED, + defeated, + access.cooldownFor(updated).orElseThrow()); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java index 16fc378..e025699 100644 --- a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java +++ b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java @@ -19,9 +19,10 @@ public final class TriggerSpawnPlugin extends JavaPlugin { YamlSpawnStateRepository repository = new YamlSpawnStateRepository(getDataFolder().toPath().resolve("state.yml")); stateManager = SpawnStateManager.load(repository); + AccessService access = new AccessService(stateManager, settings); SpawnAdminCommand executor = new SpawnAdminCommand( new SpawnAreaService(stateManager), - new AccessService(stateManager, settings), + access, new SpawnBanService(stateManager), new PlayerResolver(getServer(), stateManager), Clock.systemUTC()); @@ -31,6 +32,8 @@ public final class TriggerSpawnPlugin extends JavaPlugin { } adminCommand.setExecutor(executor); adminCommand.setTabCompleter(executor); + getServer().getPluginManager().registerEvents( + new BossKillListener(this, new ProgressionService(stateManager, access)), this); } catch (RuntimeException | IOException exception) { getLogger().log(Level.SEVERE, "Trigger Spawn could not initialize safely", exception); getServer().getPluginManager().disablePlugin(this); diff --git a/src/test/java/games/dmg/triggerspawn/MessagesTest.java b/src/test/java/games/dmg/triggerspawn/MessagesTest.java index a1656e7..41bf15c 100644 --- a/src/test/java/games/dmg/triggerspawn/MessagesTest.java +++ b/src/test/java/games/dmg/triggerspawn/MessagesTest.java @@ -3,6 +3,8 @@ package games.dmg.triggerspawn; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.Duration; +import java.util.List; +import java.util.Set; import org.bukkit.ChatColor; import org.junit.jupiter.api.Test; @@ -15,4 +17,13 @@ final class MessagesTest { assertTrue(message.contains("cannot use /spawn yet")); assertTrue(message.contains("1h 30m")); } + + @Test + void checklistColorsCreditedAndRemainingBosses() { + List checklist = Messages.progressionChecklist(Set.of(BossType.WARDEN)); + + assertTrue(checklist.get(0).startsWith(ChatColor.GREEN + "✔")); + assertTrue(checklist.get(1).startsWith(ChatColor.RED + "✘")); + assertTrue(checklist.get(2).startsWith(ChatColor.RED + "✘")); + } } diff --git a/src/test/java/games/dmg/triggerspawn/ProgressionServiceTest.java b/src/test/java/games/dmg/triggerspawn/ProgressionServiceTest.java new file mode 100644 index 0000000..dc35348 --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/ProgressionServiceTest.java @@ -0,0 +1,65 @@ +package games.dmg.triggerspawn; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.bukkit.configuration.MemoryConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class ProgressionServiceTest { + @TempDir + Path temporaryDirectory; + + @Test + void uniqueKillsUnlockImproveAndImmediatelyClearCooldown() throws Exception { + UUID playerId = UUID.randomUUID(); + SpawnStateManager manager = SpawnStateManager.load(new YamlSpawnStateRepository( + temporaryDirectory.resolve("state.yml"))); + manager.putPlayer(new PlayerState( + playerId, + "Alex", + Set.of(), + Optional.empty(), + false, + Optional.of(Instant.now()))); + PluginSettings settings = PluginSettings.from(new MemoryConfiguration()); + ProgressionService progression = + new ProgressionService(manager, new AccessService(manager, settings)); + PlayerIdentity identity = new PlayerIdentity(playerId, "Alex"); + + ProgressionResult first = progression.creditKill(identity, BossType.WITHER); + ProgressionResult duplicate = progression.creditKill(identity, BossType.WITHER); + ProgressionResult second = progression.creditKill(identity, BossType.WARDEN); + + assertEquals(ProgressionOutcome.CREDITED, first.outcome()); + assertEquals(Duration.ofHours(8), first.applicableCooldown()); + assertEquals(ProgressionOutcome.ALREADY_CREDITED, duplicate.outcome()); + assertEquals(Duration.ofHours(4), second.applicableCooldown()); + assertEquals(2, second.defeatedBosses().size()); + assertTrue(manager.snapshot().players().get(playerId).lastSpawnUse().isEmpty()); + } + + @Test + void bannedPlayersEarnNoProgress() throws Exception { + UUID playerId = UUID.randomUUID(); + SpawnStateManager manager = SpawnStateManager.load(new YamlSpawnStateRepository( + temporaryDirectory.resolve("state.yml"))); + PlayerIdentity identity = new PlayerIdentity(playerId, "Alex"); + new SpawnBanService(manager).ban(identity); + ProgressionService progression = new ProgressionService( + manager, + new AccessService(manager, PluginSettings.from(new MemoryConfiguration()))); + + assertEquals( + ProgressionOutcome.BANNED, + progression.creditKill(identity, BossType.ENDER_DRAGON).outcome()); + assertTrue(manager.snapshot().players().get(playerId).defeatedBosses().isEmpty()); + } +}