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.
|
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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user