Build configurable BlockParty minigame
Build / build (push) Failing after 27s

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Michael Burgess
2026-08-07 05:47:55 -04:00
co-authored by Claude Sonnet 5
commit 7b02b7efb7
49 changed files with 4349 additions and 0 deletions
@@ -0,0 +1,529 @@
package us.tss3.blockparty.arena;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.title.Title;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.scheduler.BukkitTask;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.config.ArenaConfig;
import us.tss3.blockparty.floor.FloorManager;
import us.tss3.blockparty.logic.ArenaPhase;
import us.tss3.blockparty.logic.ArenaStateMachine;
import us.tss3.blockparty.logic.CountdownLogic;
import us.tss3.blockparty.logic.EliminationLogic;
import us.tss3.blockparty.logic.RoundTimeCalculator;
import us.tss3.blockparty.logic.TargetColorSelector;
import us.tss3.blockparty.model.PlayerState;
import us.tss3.blockparty.session.PlayerSession;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
/** A single running arena: owns its state machine, tasks and round lifecycle. */
public class Arena {
private final BlockPartyPlugin plugin;
private final ArenaConfig config;
private final FloorManager floorManager;
private final ArenaStateMachine stateMachine = new ArenaStateMachine(ArenaPhase.DISABLED);
private final Random random = new Random();
private final TargetColorSelector selector = new TargetColorSelector(random::nextInt);
private final List<UUID> players = new ArrayList<>();
private final List<UUID> spectators = new ArrayList<>();
private final List<BukkitTask> tasks = new ArrayList<>();
private BossBar bossBar;
private int countdownRemaining;
private int round = 0;
private Material currentTarget;
private Material previousTarget;
private int currentRoundTime;
private int roundTimeRemaining;
public Arena(BlockPartyPlugin plugin, ArenaConfig config) {
this.plugin = plugin;
this.config = config;
this.floorManager = new FloorManager(plugin, config);
if (config.isEnabled()) {
stateMachine.transition(ArenaPhase.WAITING);
}
}
public ArenaConfig getConfig() {
return config;
}
public FloorManager getFloorManager() {
return floorManager;
}
public ArenaPhase getPhase() {
return stateMachine.getState();
}
public List<UUID> getPlayers() {
return players;
}
public List<UUID> getSpectators() {
return spectators;
}
public int getRound() {
return round;
}
public Material getCurrentTarget() {
return currentTarget;
}
public int getRoundTimeRemaining() {
return roundTimeRemaining;
}
public int getCurrentRoundTime() {
return currentRoundTime;
}
public boolean isEnabled() {
return getPhase() != ArenaPhase.DISABLED;
}
public void enable() {
if (stateMachine.transition(ArenaPhase.WAITING)) {
config.setEnabled(true);
}
}
public void disable() {
cancelAllTasks();
// Force reset players out regardless of state
List<UUID> all = new ArrayList<>();
all.addAll(players);
all.addAll(spectators);
for (UUID uuid : all) {
removePlayerForce(uuid);
}
stateMachine.transition(ArenaPhase.DISABLED);
config.setEnabled(false);
}
// ---------- Join / Leave ----------
public boolean canJoin() {
ArenaPhase phase = getPhase();
if (phase != ArenaPhase.WAITING && !(phase == ArenaPhase.STARTING && config.isAllowJoinWhileStarting())) {
return false;
}
return players.size() < config.getMaxPlayers();
}
public void addPlayer(Player player) {
PlayerState state = PlayerState.capture(player);
PlayerSession session = new PlayerSession(player.getUniqueId(), config.getName(), state);
plugin.getSessionManager().add(session);
players.add(player.getUniqueId());
preparePlayerForLobby(player);
plugin.getScoreboardManager().attach(player, this);
maybeStartCountdown();
}
private void preparePlayerForLobby(Player player) {
player.getInventory().clear();
player.setGameMode(GameMode.ADVENTURE);
player.setHealth(20.0);
player.setFoodLevel(20);
player.setFireTicks(0);
if (config.getLobby() != null) {
player.teleport(config.getLobby());
}
}
public void removePlayer(UUID uuid, boolean restoreState) {
players.remove(uuid);
spectators.remove(uuid);
PlayerSession session = plugin.getSessionManager().get(uuid);
Player player = plugin.getServer().getPlayer(uuid);
if (session != null) {
if (restoreState && player != null) {
session.getSavedState().restore(player);
}
plugin.getSessionManager().remove(uuid);
}
if (player != null) {
plugin.getScoreboardManager().detach(player);
}
checkCountdownCancel();
checkForWinner();
}
private void removePlayerForce(UUID uuid) {
removePlayer(uuid, true);
}
private void checkCountdownCancel() {
if (getPhase() == ArenaPhase.STARTING
&& CountdownLogic.shouldCancel(players.size(), config.getMinPlayers(), true)) {
cancelCountdown();
}
}
// ---------- Countdown / Starting ----------
private void maybeStartCountdown() {
if (getPhase() == ArenaPhase.WAITING
&& CountdownLogic.shouldStart(players.size(), config.getMinPlayers(), false)) {
startCountdown();
}
}
private void startCountdown() {
if (!stateMachine.transition(ArenaPhase.STARTING)) {
return;
}
countdownRemaining = config.getCountdownDuration();
BukkitTask task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
if (CountdownLogic.isFinished(countdownRemaining)) {
return;
}
broadcastCountdownTick();
countdownRemaining = CountdownLogic.tick(countdownRemaining);
if (CountdownLogic.isFinished(countdownRemaining)) {
beginMatch();
}
}, 0L, 20L);
tasks.add(task);
}
private void cancelCountdown() {
cancelAllTasks();
stateMachine.transition(ArenaPhase.WAITING);
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
p.sendMessage(plugin.getMessages().get("countdown.cancelled"));
}
}
}
private void broadcastCountdownTick() {
Map<String, String> ph = Map.of("time", String.valueOf(countdownRemaining), "arena", config.getName());
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
if (p == null) {
continue;
}
if (plugin.getConfigManager().isActionbarEnabled()) {
p.sendActionBar(plugin.getMessages().get("countdown.actionbar", ph));
}
if (plugin.getConfigManager().isTitlesEnabled() && (countdownRemaining <= 5 || countdownRemaining == config.getCountdownDuration())) {
p.showTitle(Title.title(plugin.getMessages().get("countdown.title", ph), Component.empty()));
}
plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("countdown"), 1f, 1f);
}
}
// ---------- Match flow ----------
private void beginMatch() {
cancelAllTasks();
if (!stateMachine.transition(ArenaPhase.RUNNING)) {
return;
}
round = 0;
previousTarget = null;
if (!floorManager.hasLayout()) {
floorManager.generate(random);
}
floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
if (p == null) {
continue;
}
p.setGameMode(GameMode.ADVENTURE);
if (config.getSpawn() != null) {
p.teleport(config.getSpawn());
}
}
startNextRound();
});
}
private void startNextRound() {
if (EliminationLogic.isMatchOver(players.size())) {
endMatch(players.isEmpty() ? null : (players.isEmpty() ? null : players.get(0)));
return;
}
round++;
List<Material> available = floorManager.materialsInLayout().stream().distinct().toList();
if (available.isEmpty()) {
available = config.getFloorMaterials();
}
currentTarget = selector.selectExcluding(available, previousTarget);
previousTarget = currentTarget;
currentRoundTime = RoundTimeCalculator.timeForRound(
config.getStartingRoundTime(), config.getMinimumRoundTime(), config.getRoundTimeReduction(), round);
roundTimeRemaining = currentRoundTime;
announceTarget();
setupBossBar();
BukkitTask task = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> {
roundTimeRemaining--;
updateBossBar();
if (roundTimeRemaining <= 0) {
cancelAllTasks();
processRoundEnd();
}
}, 20L, 20L);
tasks.add(task);
}
private void announceTarget() {
Map<String, String> ph = Map.of("color", formatMaterial(currentTarget), "round", String.valueOf(round),
"time", String.valueOf(currentRoundTime));
ItemStack display = new ItemStack(currentTarget);
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
if (p == null) {
continue;
}
if (plugin.getConfigManager().isTitlesEnabled()) {
p.showTitle(Title.title(plugin.getMessages().get("round.title", ph), plugin.getMessages().get("round.subtitle", ph)));
}
if (plugin.getConfigManager().isActionbarEnabled()) {
p.sendActionBar(plugin.getMessages().get("round.actionbar", ph));
}
p.getInventory().setItem(8, display.clone());
plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("target-select"), 1f, 1f);
}
}
private void setupBossBar() {
if (!plugin.getConfigManager().isBossbarEnabled()) {
return;
}
if (bossBar == null) {
bossBar = BossBar.bossBar(Component.text("BlockParty"), 1f, BossBar.Color.YELLOW, BossBar.Overlay.PROGRESS);
}
bossBar.progress(1f);
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
p.showBossBar(bossBar);
}
}
}
private void updateBossBar() {
if (bossBar == null || currentRoundTime <= 0) {
return;
}
float progress = Math.max(0f, Math.min(1f, (float) roundTimeRemaining / (float) currentRoundTime));
bossBar.progress(progress);
}
private void processRoundEnd() {
plugin.getSoundUtil().play(config.getSpawn(), plugin.getConfigManager().getSound("floor-disappear"), 1f, 1f);
floorManager.removeNonTarget(currentTarget, plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
BukkitTask delay = plugin.getServer().getScheduler().runTaskLater(plugin, this::evaluateEliminations,
config.getFloorRemoveDelaySeconds() * 20L);
tasks.add(delay);
});
}
private void evaluateEliminations() {
List<EliminationLogic.PlayerFloorState<UUID, Material>> states = new ArrayList<>();
for (UUID uuid : players) {
Player p = plugin.getServer().getPlayer(uuid);
Material standing = null;
if (p != null && p.getWorld() != null) {
standing = floorManager.materialAtLocation(p.getWorld(), p.getLocation().getX(), p.getLocation().getY(), p.getLocation().getZ());
}
states.add(new EliminationLogic.PlayerFloorState<>(uuid, standing));
}
List<UUID> eliminated = EliminationLogic.determineEliminated(states, currentTarget);
for (UUID uuid : eliminated) {
eliminatePlayer(uuid);
}
for (UUID uuid : players) {
PlayerSession session = plugin.getSessionManager().get(uuid);
if (session != null && !eliminated.contains(uuid)) {
session.incrementRoundsSurvived();
}
}
if (EliminationLogic.isMatchOver(players.size())) {
UUID winner = players.isEmpty() ? null : players.get(0);
if (bossBar != null) {
for (UUID uuid : spectators) {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
p.hideBossBar(bossBar);
}
}
}
endMatch(winner);
return;
}
BukkitTask restoreDelay = plugin.getServer().getScheduler().runTaskLater(plugin, () ->
floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), this::startNextRound),
config.getFloorRestoreDelaySeconds() * 20L);
tasks.add(restoreDelay);
}
private void eliminatePlayer(UUID uuid) {
players.remove(uuid);
spectators.add(uuid);
Player p = plugin.getServer().getPlayer(uuid);
PlayerSession session = plugin.getSessionManager().get(uuid);
if (session != null) {
session.setRole(PlayerSession.Role.SPECTATOR);
session.setEliminated(true);
}
if (p != null) {
p.setGameMode(GameMode.SPECTATOR);
if (config.getSpectator() != null) {
p.teleport(config.getSpectator());
}
p.sendMessage(plugin.getMessages().get("game.eliminated", Map.of("arena", config.getName(), "round", String.valueOf(round))));
plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("eliminate"), 1f, 1f);
if (bossBar != null) {
p.hideBossBar(bossBar);
}
}
plugin.getStatsManager().recordElimination(uuid, round);
}
private void endMatch(UUID winner) {
stateMachine.transition(ArenaPhase.ENDING);
cancelAllTasks();
if (winner != null) {
Player winnerPlayer = plugin.getServer().getPlayer(winner);
String winnerName = winnerPlayer != null ? winnerPlayer.getName() : "Unknown";
for (UUID uuid : allParticipants()) {
Player p = plugin.getServer().getPlayer(uuid);
if (p == null) {
continue;
}
p.showTitle(Title.title(plugin.getMessages().get("game.win-title", Map.of("player", winnerName)),
Component.empty()));
plugin.getSoundUtil().play(p, plugin.getConfigManager().getSound("victory"), 1f, 1f);
}
if (winnerPlayer != null) {
spawnCelebration(winnerPlayer);
}
plugin.getStatsManager().recordWin(winner);
plugin.getRewardManager().dispatchWinnerRewards(winnerName, config.getName(), round);
} else {
for (UUID uuid : allParticipants()) {
Player p = plugin.getServer().getPlayer(uuid);
if (p != null) {
p.sendMessage(plugin.getMessages().get("game.no-winner"));
}
}
}
for (UUID uuid : players) {
plugin.getStatsManager().recordGamePlayed(uuid);
}
for (UUID uuid : spectators) {
plugin.getStatsManager().recordGamePlayed(uuid);
}
BukkitTask endTask = plugin.getServer().getScheduler().runTaskLater(plugin, this::resetToWaiting,
config.getWinEndingDelaySeconds() * 20L);
tasks.add(endTask);
}
private void spawnCelebration(Player winner) {
try {
for (int i = 0; i < 3; i++) {
org.bukkit.entity.Firework fw = winner.getWorld().spawn(winner.getLocation(), org.bukkit.entity.Firework.class);
org.bukkit.inventory.meta.FireworkMeta meta = fw.getFireworkMeta();
meta.addEffect(org.bukkit.FireworkEffect.builder()
.withColor(org.bukkit.Color.YELLOW, org.bukkit.Color.LIME)
.with(org.bukkit.FireworkEffect.Type.BALL_LARGE)
.trail(true)
.build());
meta.setPower(1);
fw.setFireworkMeta(meta);
}
} catch (Exception ignored) {
}
}
private List<UUID> allParticipants() {
List<UUID> all = new ArrayList<>(players);
all.addAll(spectators);
return all;
}
private void resetToWaiting() {
List<UUID> all = allParticipants();
for (UUID uuid : all) {
removePlayer(uuid, true);
}
players.clear();
spectators.clear();
if (bossBar != null) {
bossBar.removeViewer(plugin.getServer().getConsoleSender());
}
floorManager.cancelActiveTask();
cancelAllTasks();
round = 0;
stateMachine.transition(ArenaPhase.WAITING);
}
private void checkForWinner() {
if (getPhase() == ArenaPhase.RUNNING && EliminationLogic.isMatchOver(players.size())) {
cancelAllTasks();
evaluateEliminations();
}
}
private void cancelAllTasks() {
for (BukkitTask task : tasks) {
if (task != null) {
task.cancel();
}
}
tasks.clear();
floorManager.cancelActiveTask();
}
/** Called on plugin disable / world unload to safely stop everything and restore players. */
public void shutdown() {
cancelAllTasks();
List<UUID> all = allParticipants();
for (UUID uuid : all) {
Player p = plugin.getServer().getPlayer(uuid);
PlayerSession session = plugin.getSessionManager().get(uuid);
if (session != null && p != null) {
session.getSavedState().restore(p);
}
plugin.getSessionManager().remove(uuid);
}
players.clear();
spectators.clear();
}
private String formatMaterial(Material material) {
String name = material.name().replace("_CONCRETE", "").replace("_", " ").toLowerCase();
return name.substring(0, 1).toUpperCase() + name.substring(1);
}
}
@@ -0,0 +1,115 @@
package us.tss3.blockparty.arena;
import us.tss3.blockparty.BlockPartyPlugin;
import us.tss3.blockparty.config.ArenaConfig;
import us.tss3.blockparty.config.ArenaConfigLoader;
import us.tss3.blockparty.logic.ArenaPhase;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** Owns the collection of loaded arenas, keyed by lowercase name. */
public class ArenaManager {
private final BlockPartyPlugin plugin;
private final ArenaConfigLoader loader;
private final Map<String, Arena> arenas = new LinkedHashMap<>();
public ArenaManager(BlockPartyPlugin plugin) {
this.plugin = plugin;
this.loader = new ArenaConfigLoader(plugin);
}
public ArenaConfigLoader getLoader() {
return loader;
}
public void loadAll() {
for (Arena arena : arenas.values()) {
arena.shutdown();
}
arenas.clear();
for (ArenaConfig cfg : loader.loadAll()) {
arenas.put(cfg.getName().toLowerCase(), new Arena(plugin, cfg));
}
plugin.getLogger().info("Loaded " + arenas.size() + " arena(s).");
}
/** Reload config/messages/arenas without disturbing arenas that are RUNNING or ENDING. */
public void reloadSafely() {
for (Map.Entry<String, Arena> entry : arenas.entrySet()) {
Arena arena = entry.getValue();
if (arena.getPhase() == ArenaPhase.RUNNING || arena.getPhase() == ArenaPhase.ENDING) {
plugin.getLogger().warning("Skipping reload of arena '" + arena.getConfig().getName() + "' - match in progress.");
continue;
}
ArenaConfig fresh = loader.load(new java.io.File(loader.getArenasFolder(), arena.getConfig().getName() + ".yml"));
if (fresh != null) {
arenas.put(entry.getKey(), new Arena(plugin, fresh));
}
}
// Load any brand-new arena files not currently tracked
for (ArenaConfig cfg : loader.loadAll()) {
arenas.putIfAbsent(cfg.getName().toLowerCase(), new Arena(plugin, cfg));
}
}
public Optional<Arena> get(String name) {
if (name == null) {
return Optional.empty();
}
return Optional.ofNullable(arenas.get(name.toLowerCase()));
}
public boolean exists(String name) {
return arenas.containsKey(name.toLowerCase());
}
public Arena createArena(String name, org.bukkit.World world) {
ArenaConfig cfg = new ArenaConfig(name);
if (world != null) {
cfg.setWorldName(world.getName());
}
cfg.setFloorMaterials(plugin.getConfigManager().getDefaultFloorMaterials().stream()
.map(org.bukkit.Material::matchMaterial)
.filter(java.util.Objects::nonNull)
.toList());
cfg.setStartingRoundTime(plugin.getConfigManager().getDefaultStartingRoundTime());
cfg.setMinimumRoundTime(plugin.getConfigManager().getDefaultMinimumRoundTime());
cfg.setRoundTimeReduction(plugin.getConfigManager().getDefaultRoundReduction());
cfg.setCountdownDuration(plugin.getConfigManager().getDefaultCountdown());
loader.save(cfg);
Arena arena = new Arena(plugin, cfg);
arenas.put(name.toLowerCase(), arena);
return arena;
}
public boolean deleteArena(String name) {
Arena arena = arenas.remove(name.toLowerCase());
if (arena != null) {
arena.shutdown();
}
return loader.delete(name);
}
public Collection<Arena> all() {
return arenas.values();
}
public List<String> names() {
return arenas.values().stream().map(a -> a.getConfig().getName()).toList();
}
public void save(Arena arena) {
loader.save(arena.getConfig());
}
public void shutdownAll() {
for (Arena arena : arenas.values()) {
arena.shutdown();
}
}
}