Fix solo-play win condition, floor-spawn clipping, and make round-end block removal instantaneous
Build / build (push) Successful in 1m17s

This commit is contained in:
Michael Burgess
2026-08-07 08:09:45 -04:00
parent c78d35d104
commit 07d210d8db
3 changed files with 57 additions and 31 deletions
+7 -1
View File
@@ -134,12 +134,18 @@ disabled, deleted, or the plugin shuts down, so it never leaks entities.
off entirely. Eliminated players become spectators at the configured spectator location.
- Last player standing wins; rewards, stats and celebration effects (fireworks, titles,
sounds) fire automatically.
- **Solo play**: if a match starts with only one player (e.g. `min-players: 1` for testing),
"one player remaining" is the expected steady state, not a win — the match keeps running
rounds, each shorter than the last, until that player is actually eliminated or leaves.
With two or more players, the match ends the moment only one remains.
## Configuration overview
- **`config.yml`** — global settings: `ui.*` toggles for scoreboard/bossbar/titles/actionbar,
`sounds.*` (Bukkit `Sound` enum names; invalid names are logged and skipped, never crash
the plugin), `performance.floor-blocks-per-tick` (batch size for floor mutations),
the plugin), `performance.floor-blocks-per-tick` (batch size for floor *generation* and
*restoration*, spread across ticks — the round-end removal of non-target blocks is always
instantaneous, all at once, by design),
`defaults.*` (used only when `/bp create` seeds a new arena), `default-floor-materials`
(the full 16-color concrete palette by default), `rewards.winner.commands`, and
`integrations.placeholderapi`.
@@ -49,6 +49,10 @@ public class Arena {
private int countdownRemaining;
private int round = 0;
/** True when the match started with exactly one player (solo/testing play): in that case
* the match doesn't end just because "one player remains" — it keeps running rounds until
* that player is actually eliminated or leaves. */
private boolean soloMode;
private Material currentTarget;
private Material previousTarget;
private int currentRoundTime;
@@ -248,19 +252,21 @@ public class Arena {
return;
}
round = 0;
soloMode = players.size() == 1;
previousTarget = null;
if (!floorManager.hasLayout()) {
floorManager.generate(random);
}
floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
Location safeSpawn = safeSpawnLocation();
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());
if (safeSpawn != null) {
p.teleport(safeSpawn);
}
}
startNextRound();
@@ -272,10 +278,12 @@ public class Arena {
endMatch(null);
return;
}
// Only treat "down to one player" as a win once at least one round has actually been
// played (round > 0). At round 0 (the very first round after the countdown), a single
// player is expected whenever min-players is configured as 1 for solo play/testing.
if (round > 0 && EliminationLogic.isMatchOver(players.size())) {
// In solo mode the match only ends when the lone player is actually eliminated (see
// evaluateEliminations) or leaves (players.isEmpty() above) — "one player remaining"
// is the expected steady state, not a win condition, so round count keeps climbing.
// In normal multiplayer, only treat "down to one player" as a win once at least one
// round has actually been played (round > 0); at round 0 that's just the initial join.
if (!soloMode && round > 0 && EliminationLogic.isMatchOver(players.size())) {
endMatch(players.get(0));
return;
}
@@ -379,8 +387,12 @@ public class Arena {
}
}
if (EliminationLogic.isMatchOver(players.size())) {
UUID winner = players.isEmpty() ? null : players.get(0);
// In solo mode, "1 player left" is the normal steady state — only end when that
// player is actually eliminated (players empty). In multiplayer, end as soon as at
// most one player remains.
boolean over = soloMode ? players.isEmpty() : EliminationLogic.isMatchOver(players.size());
if (over) {
UUID winner = (soloMode || players.isEmpty()) ? null : players.get(0);
if (bossBar != null) {
for (UUID uuid : spectators) {
Player p = plugin.getServer().getPlayer(uuid);
@@ -536,6 +548,23 @@ public class Arena {
spectators.clear();
}
/** The configured spawn, lifted above the floor's top block layer if it would otherwise
* place the player inside/underneath the freshly (re)generated floor. */
private Location safeSpawnLocation() {
Location spawn = config.getSpawn();
if (spawn == null) {
return null;
}
if (config.hasFloorRegion()) {
int floorTopY = Math.max(config.getPos1()[1], config.getPos2()[1]);
if (spawn.getBlockY() <= floorTopY) {
spawn = spawn.clone();
spawn.setY(floorTopY + 1);
}
}
return spawn;
}
public BillboardManager getBillboardManager() {
return billboardManager;
}
@@ -121,7 +121,15 @@ public class FloorManager {
activeTask = runnable.runTaskTimer(plugin, 0L, 1L);
}
/** Removes every block that is not the target material, batched across ticks. */
/**
* Removes every block that is not the target material, all within a single tick so the
* non-target floor visibly disappears all at once rather than block-by-block. Unlike
* {@link #restoreFull} / {@link #generate}, this is intentionally not spread across ticks:
* the "reveal" moment is meant to be instantaneous. Arena floors are expected to stay in
* the tens-to-low-thousands of blocks; for a given target color roughly (palette-size - 1)
* / palette-size of the floor is removed, which stays comfortably within a single-tick
* synchronous block-mutation budget.
*/
public void removeNonTarget(Material target, int blocksPerTick, Runnable onComplete) {
World world = config.getWorld();
if (world == null) {
@@ -131,32 +139,15 @@ public class FloorManager {
return;
}
cancelActiveTask();
Deque<String> queue = new ArrayDeque<>();
for (Map.Entry<String, Material> entry : layout.entrySet()) {
if (entry.getValue() != target) {
queue.add(entry.getKey());
setBlock(world, entry.getKey(), Material.AIR);
}
}
org.bukkit.scheduler.BukkitRunnable runnable = new org.bukkit.scheduler.BukkitRunnable() {
@Override
public void run() {
int processed = 0;
while (processed < blocksPerTick && !queue.isEmpty()) {
String key = queue.poll();
setBlock(world, key, Material.AIR);
processed++;
}
if (queue.isEmpty()) {
cancel();
activeTask = null;
if (onComplete != null) {
onComplete.run();
}
}
}
};
activeTask = runnable.runTaskTimer(plugin, 0L, 1L);
}
/** Clears the entire floor region to air, batched. */
public void clearAll(int blocksPerTick, Runnable onComplete) {