feat(progression): unlock spawn through boss kills
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
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<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;
|
||||
|
||||
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<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 =
|
||||
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);
|
||||
|
||||
@@ -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<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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user