feat(progression): unlock spawn through boss kills
Release / release (push) Failing after 13s
CI / build (push) Successful in 52s

This commit is contained in:
dmg
2026-08-08 13:25:42 -04:00
parent edab7f03db
commit 12d9e7af42
12 changed files with 254 additions and 16 deletions
+2
View File
@@ -2,6 +2,8 @@
A Spigot 26.2 plugin providing progression-gated, safe teleportation to each world's spawn area. 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). The behavior under development is specified in the [OKF design bundle](design/index.md).
## Requirements ## Requirements
+6
View File
@@ -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. - 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. - Banning immediately clears grants, boss progress, and cooldown state; unbanning restores none of them.
- Banned player state cannot retain natural or granted access. - 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.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-001: Earn progressive spawn access" title: "US-001: Earn progressive spawn access"
description: Let players unlock spawn teleportation and improve its cooldown by defeating dangerous enemies. description: Let players unlock spawn teleportation and improve its cooldown by defeating dangerous enemies.
status: backlog status: done
--- ---
# US-001: Earn progressive spawn access # 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 ## Acceptance criteria
- [ ] The plugin observes player-attributed kills of the Warden, Ender Dragon, and Wither. - [x] 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. - [x] 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. - [x] 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. - [x] 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. - [x] 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. - [x] 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. - [x] 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. - [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.
- [ ] Progress is presented as a checklist in which credited enemies have a green check and uncredited enemies have a red cross. - [x] 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. - [x] 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] Progress is associated with the player's UUID and survives server and plugin restarts.
## Related ## Related
@@ -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.");
}
}
}
@@ -1,7 +1,29 @@
package games.dmg.triggerspawn; package games.dmg.triggerspawn;
import java.util.Optional;
import org.bukkit.entity.EntityType;
enum BossType { enum BossType {
WARDEN, WARDEN("Warden"),
ENDER_DRAGON, ENDER_DRAGON("Dragon"),
WITHER WITHER("Wither");
private final String displayName;
BossType(String displayName) {
this.displayName = displayName;
}
String displayName() {
return displayName;
}
static Optional<BossType> 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();
};
}
} }
@@ -1,6 +1,9 @@
package games.dmg.triggerspawn; package games.dmg.triggerspawn;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
final class Messages { final class Messages {
@@ -12,4 +15,14 @@ final class Messages {
+ ChatColor.YELLOW + "Cooldown remaining: " + ChatColor.YELLOW + "Cooldown remaining: "
+ ChatColor.WHITE + DurationFormatter.format(remaining) + "."; + ChatColor.WHITE + DurationFormatter.format(remaining) + ".";
} }
static List<String> progressionChecklist(Set<BossType> defeated) {
List<String> 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);
}
} }
@@ -0,0 +1,7 @@
package games.dmg.triggerspawn;
enum ProgressionOutcome {
CREDITED,
ALREADY_CREDITED,
BANNED
}
@@ -0,0 +1,13 @@
package games.dmg.triggerspawn;
import java.time.Duration;
import java.util.Set;
record ProgressionResult(
ProgressionOutcome outcome,
Set<BossType> defeatedBosses,
Duration applicableCooldown) {
ProgressionResult {
defeatedBosses = Set.copyOf(defeatedBosses);
}
}
@@ -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<BossType> 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());
}
}
@@ -19,9 +19,10 @@ public final class TriggerSpawnPlugin extends JavaPlugin {
YamlSpawnStateRepository repository = YamlSpawnStateRepository repository =
new YamlSpawnStateRepository(getDataFolder().toPath().resolve("state.yml")); new YamlSpawnStateRepository(getDataFolder().toPath().resolve("state.yml"));
stateManager = SpawnStateManager.load(repository); stateManager = SpawnStateManager.load(repository);
AccessService access = new AccessService(stateManager, settings);
SpawnAdminCommand executor = new SpawnAdminCommand( SpawnAdminCommand executor = new SpawnAdminCommand(
new SpawnAreaService(stateManager), new SpawnAreaService(stateManager),
new AccessService(stateManager, settings), access,
new SpawnBanService(stateManager), new SpawnBanService(stateManager),
new PlayerResolver(getServer(), stateManager), new PlayerResolver(getServer(), stateManager),
Clock.systemUTC()); Clock.systemUTC());
@@ -31,6 +32,8 @@ public final class TriggerSpawnPlugin extends JavaPlugin {
} }
adminCommand.setExecutor(executor); adminCommand.setExecutor(executor);
adminCommand.setTabCompleter(executor); adminCommand.setTabCompleter(executor);
getServer().getPluginManager().registerEvents(
new BossKillListener(this, new ProgressionService(stateManager, access)), this);
} catch (RuntimeException | IOException exception) { } catch (RuntimeException | IOException exception) {
getLogger().log(Level.SEVERE, "Trigger Spawn could not initialize safely", exception); getLogger().log(Level.SEVERE, "Trigger Spawn could not initialize safely", exception);
getServer().getPluginManager().disablePlugin(this); getServer().getPluginManager().disablePlugin(this);
@@ -3,6 +3,8 @@ package games.dmg.triggerspawn;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration; import java.time.Duration;
import java.util.List;
import java.util.Set;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -15,4 +17,13 @@ final class MessagesTest {
assertTrue(message.contains("cannot use /spawn yet")); assertTrue(message.contains("cannot use /spawn yet"));
assertTrue(message.contains("1h 30m")); assertTrue(message.contains("1h 30m"));
} }
@Test
void checklistColorsCreditedAndRemainingBosses() {
List<String> 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 + ""));
}
} }
@@ -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());
}
}