From 4c4f5a1215eaa29cf8f26893b4b250dabd06a793 Mon Sep 17 00:00:00 2001 From: Michael Burgess Date: Sat, 8 Aug 2026 11:31:30 -0400 Subject: [PATCH] Scale round difficulty over time and add claimable powerups Reshuffles the floor so the target color's share shrinks each round (TargetDensityCalculator) instead of always averaging ~1/paletteSize, making later rounds progressively harder to read. Adds Super Speed and Air Blast powerups that spawn on the floor each round, claimable by walking over or left-clicking their marker, expiring after 2-3 unclaimed rounds. Usage is tracked in player stats. Co-Authored-By: Claude Sonnet 5 --- .../us/tss3/blockparty/BlockPartyPlugin.java | 2 + .../java/us/tss3/blockparty/arena/Arena.java | 249 +++++++++++++++--- .../blockparty/command/BlockPartyCommand.java | 35 ++- .../tss3/blockparty/config/ConfigManager.java | 65 +++++ .../tss3/blockparty/floor/FloorManager.java | 68 ++++- .../blockparty/listener/PowerupListener.java | 36 +++ .../logic/TargetDensityCalculator.java | 35 +++ .../blockparty/persistence/PlayerStats.java | 11 +- .../blockparty/persistence/StatsDatabase.java | 26 +- .../blockparty/persistence/StatsManager.java | 4 + .../blockparty/powerup/ActivePowerup.java | 31 +++ .../blockparty/powerup/PowerupManager.java | 141 ++++++++++ .../tss3/blockparty/powerup/PowerupType.java | 17 ++ src/main/resources/config.yml | 35 +++ src/main/resources/messages.yml | 6 + .../logic/TargetDensityCalculatorTest.java | 29 ++ 16 files changed, 739 insertions(+), 51 deletions(-) create mode 100644 src/main/java/us/tss3/blockparty/listener/PowerupListener.java create mode 100644 src/main/java/us/tss3/blockparty/logic/TargetDensityCalculator.java create mode 100644 src/main/java/us/tss3/blockparty/powerup/ActivePowerup.java create mode 100644 src/main/java/us/tss3/blockparty/powerup/PowerupManager.java create mode 100644 src/main/java/us/tss3/blockparty/powerup/PowerupType.java create mode 100644 src/test/java/us/tss3/blockparty/logic/TargetDensityCalculatorTest.java diff --git a/src/main/java/us/tss3/blockparty/BlockPartyPlugin.java b/src/main/java/us/tss3/blockparty/BlockPartyPlugin.java index d88fa1d..c026494 100644 --- a/src/main/java/us/tss3/blockparty/BlockPartyPlugin.java +++ b/src/main/java/us/tss3/blockparty/BlockPartyPlugin.java @@ -15,6 +15,7 @@ import us.tss3.blockparty.jukebox.VoteCodeManager; import us.tss3.blockparty.jukebox.VoteManager; import us.tss3.blockparty.listener.GameplayRestrictionListener; import us.tss3.blockparty.listener.PlayerConnectionListener; +import us.tss3.blockparty.listener.PowerupListener; import us.tss3.blockparty.persistence.StatsManager; import us.tss3.blockparty.placeholder.BlockPartyExpansion; import us.tss3.blockparty.reward.RewardManager; @@ -65,6 +66,7 @@ public class BlockPartyPlugin extends JavaPlugin implements Listener { getServer().getPluginManager().registerEvents(new PlayerConnectionListener(this), this); getServer().getPluginManager().registerEvents(new GameplayRestrictionListener(this), this); + getServer().getPluginManager().registerEvents(new PowerupListener(this), this); getServer().getPluginManager().registerEvents(this, this); BlockPartyCommand commandHandler = new BlockPartyCommand(this); diff --git a/src/main/java/us/tss3/blockparty/arena/Arena.java b/src/main/java/us/tss3/blockparty/arena/Arena.java index 672aa0e..dc8fe4b 100644 --- a/src/main/java/us/tss3/blockparty/arena/Arena.java +++ b/src/main/java/us/tss3/blockparty/arena/Arena.java @@ -6,9 +6,13 @@ import net.kyori.adventure.title.Title; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; import org.bukkit.scheduler.BukkitTask; +import org.bukkit.util.Vector; import us.tss3.blockparty.BlockPartyPlugin; import us.tss3.blockparty.billboard.BillboardManager; import us.tss3.blockparty.config.ArenaConfig; @@ -19,7 +23,11 @@ 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.logic.TargetDensityCalculator; import us.tss3.blockparty.model.PlayerState; +import us.tss3.blockparty.powerup.ActivePowerup; +import us.tss3.blockparty.powerup.PowerupManager; +import us.tss3.blockparty.powerup.PowerupType; import us.tss3.blockparty.session.PlayerSession; import java.time.Duration; @@ -37,6 +45,7 @@ public class Arena { private final ArenaConfig config; private final FloorManager floorManager; private final BillboardManager billboardManager; + private final PowerupManager powerupManager; private final ArenaStateMachine stateMachine = new ArenaStateMachine(ArenaPhase.DISABLED); private final Random random = new Random(); private final TargetColorSelector selector = new TargetColorSelector(random::nextInt); @@ -46,6 +55,12 @@ public class Arena { private final List tasks = new ArrayList<>(); private BossBar bossBar; + /** Runs for the whole match (not just the brief post-floor-removal window) and is never + * cleared by {@link #cancelAllTasks()}, which fires every round on the timer/round-end + * transitions — a player who falls through the floor or lands on bedrock must be caught + * no matter what round phase is active at that instant, otherwise they linger until the + * next round's own check happens to catch them. */ + private BukkitTask fallWatcherTask; private int countdownRemaining; private int round = 0; @@ -64,6 +79,7 @@ public class Arena { this.config = config; this.floorManager = new FloorManager(plugin, config); this.billboardManager = new BillboardManager(plugin, config); + this.powerupManager = new PowerupManager(plugin.getConfigManager(), floorManager); if (config.isEnabled()) { stateMachine.transition(ArenaPhase.WAITING); } @@ -117,8 +133,13 @@ public class Arena { public void disable() { cancelAllTasks(); + stopFallWatcher(); billboardManager.despawn(); pauseMusic(); + World disableWorld = config.getWorld(); + if (disableWorld != null) { + powerupManager.clearAll(disableWorld); + } plugin.getVoteManager().clear(config.getName()); // Force reset players out regardless of state List all = new ArrayList<>(); @@ -152,6 +173,28 @@ public class Arena { maybeStartCountdown(); } + /** Joins the arena purely as a spectator: not counted against max players, never enters + * the round as a participant, and teleported straight to the spectator spawn. */ + public void addSpectator(Player player) { + PlayerState state = PlayerState.capture(player); + PlayerSession session = new PlayerSession(player.getUniqueId(), config.getName(), state); + session.setRole(PlayerSession.Role.SPECTATOR); + plugin.getSessionManager().add(session); + spectators.add(player.getUniqueId()); + player.getInventory().clear(); + player.setGameMode(GameMode.ADVENTURE); + player.setHealth(20.0); + player.setFoodLevel(20); + player.setFireTicks(0); + Location target = config.getSpectator() != null ? config.getSpectator() : config.getLobby(); + if (target != null) { + player.teleport(target); + } + if (bossBar != null) { + player.showBossBar(bossBar); + } + } + private void preparePlayerForLobby(Player player) { player.getInventory().clear(); player.setGameMode(GameMode.ADVENTURE); @@ -254,16 +297,14 @@ public class Arena { if (!stateMachine.transition(ArenaPhase.RUNNING)) { return; } + startFallWatcher(); round = 0; soloMode = players.size() == 1; previousTarget = null; if (!floorManager.hasLayout()) { floorManager.generate(random); } - if (plugin.getConfigManager().isShuffleFloorEachRound()) { - floorManager.shuffle(random); - } - floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { + prepareRoundFloor(() -> { Location safeSpawn = safeSpawnLocation(); for (UUID uuid : players) { Player p = plugin.getServer().getPlayer(uuid); @@ -280,13 +321,43 @@ public class Arena { }); } + /** Picks the next target color and reshuffles/restores the floor around it, so the shuffle + * can be biased toward making that specific color rare (see {@link TargetDensityCalculator}) + * instead of picking the target only after the floor is already laid out. Shared by + * {@link #beginMatch()} (round 1) and {@link #finishRoundEvaluation()} (every round after). */ + private void prepareRoundFloor(Runnable onComplete) { + List available = floorManager.materialsInLayout().stream().distinct().toList(); + if (available.isEmpty()) { + available = config.getFloorMaterials(); + } + currentTarget = selector.selectExcluding(available, previousTarget); + previousTarget = currentTarget; + if (plugin.getConfigManager().isShuffleFloorEachRound()) { + double fraction = TargetDensityCalculator.fractionForRound( + plugin.getConfigManager().getStartingTargetBlockFraction(), + plugin.getConfigManager().getMinimumTargetBlockFraction(), + plugin.getConfigManager().getTargetBlockFractionReductionPerRound(), + round + 1); + floorManager.shuffle(random, currentTarget, fraction); + } + floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { + World world = config.getWorld(); + if (world != null) { + powerupManager.expireOld(world, round + 1); + powerupManager.maybeSpawn(world, random, round + 1); + powerupManager.applyMarkers(world); + } + onComplete.run(); + }); + } + private void startNextRound() { if (players.isEmpty()) { endMatch(null); return; } // In solo mode the match only ends when the lone player is actually eliminated (see - // evaluateEliminations) or leaves (players.isEmpty() above) — "one player remaining" + // finishRoundEvaluation) 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. @@ -295,12 +366,6 @@ public class Arena { return; } round++; - List 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; @@ -368,12 +433,129 @@ public class Arena { playToParticipants("floor-disappear"); pauseMusic(); floorManager.removeNonTarget(currentTarget, plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { - BukkitTask delay = plugin.getServer().getScheduler().runTaskLater(plugin, this::evaluateEliminations, + BukkitTask finish = plugin.getServer().getScheduler().runTaskLater(plugin, this::finishRoundEvaluation, config.getFloorRemoveDelaySeconds() * 20L); - tasks.add(delay); + tasks.add(finish); }); } + /** Starts the match-long watcher that eliminates a player the instant they actually fall + * through the floor (drop below its surface, or land on bedrock), independent of round + * phase. This is deliberately not judged from a single snapshot after a fixed delay — a + * player who merely jumped while standing on the correct color would otherwise be caught + * mid-air by a one-shot check and wrongly eliminated — and deliberately not confined to + * the brief post-floor-removal window, since a slow fall (e.g. onto distant bedrock) can + * easily outlast that window and would otherwise go undetected until the next round's + * window happened to catch it. */ + private void startFallWatcher() { + if (fallWatcherTask != null) { + fallWatcherTask.cancel(); + } + fallWatcherTask = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { + List fallen = new ArrayList<>(); + for (UUID uuid : players) { + Player p = plugin.getServer().getPlayer(uuid); + if (p == null || p.getWorld() == null) { + continue; + } + Location loc = p.getLocation(); + if (floorManager.hasFallenThrough(p.getWorld(), loc.getX(), loc.getY(), loc.getZ())) { + fallen.add(uuid); + continue; + } + checkPowerupClaim(p, loc); + } + for (UUID uuid : fallen) { + eliminatePlayer(uuid); + } + }, 1L, 2L); + } + + /** Checks whether a participant is standing on an unclaimed powerup marker and, if so, + * claims it for them. Used both by the walk-over poll above and by left-click claiming + * (see PowerupListener), so a player standing still and clicking still works even between + * poll ticks. */ + private void checkPowerupClaim(Player player, Location loc) { + if (getPhase() != ArenaPhase.RUNNING) { + return; + } + // the powerup marker floats in the player's own foot space, one block above the floor + String key = (int) Math.floor(loc.getX()) + "," + (int) Math.floor(loc.getY()) + "," + (int) Math.floor(loc.getZ()); + if (!powerupManager.hasActiveAt(key)) { + return; + } + claimPowerup(player, key); + } + + /** Attempts to claim a powerup at a world position for a participant; safe to call from a + * listener even if nothing is actually there. */ + public void claimPowerupAt(Player player, Location blockLocation) { + if (!players.contains(player.getUniqueId())) { + return; + } + String key = blockLocation.getBlockX() + "," + blockLocation.getBlockY() + "," + blockLocation.getBlockZ(); + claimPowerup(player, key); + } + + private void claimPowerup(Player player, String key) { + ActivePowerup powerup = powerupManager.claim(key); + if (powerup == null) { + return; + } + powerupManager.clearMarkerBlock(config.getWorld(), key); + applyPowerupEffect(player, powerup.getType()); + plugin.getStatsManager().recordPowerupUse(player.getUniqueId()); + Map ph = Map.of("type", powerup.getType().displayName(), "player", player.getName()); + player.sendMessage(plugin.getMessages().get("powerup.claimed-self", ph)); + for (UUID uuid : players) { + if (uuid.equals(player.getUniqueId())) { + continue; + } + Player other = plugin.getServer().getPlayer(uuid); + if (other != null) { + other.sendMessage(plugin.getMessages().get("powerup.claimed-other", ph)); + } + } + } + + private void applyPowerupEffect(Player player, PowerupType type) { + switch (type) { + case SUPER_SPEED -> { + int durationTicks = plugin.getConfigManager().getSuperSpeedDurationSeconds() * 20; + int amplifier = plugin.getConfigManager().getSuperSpeedAmplifier(); + player.addPotionEffect(new PotionEffect(PotionEffectType.SPEED, durationTicks, amplifier, false, true)); + } + case AIR_BLAST -> { + double radius = plugin.getConfigManager().getAirBlastRadius(); + double strength = plugin.getConfigManager().getAirBlastStrength(); + for (UUID uuid : players) { + if (uuid.equals(player.getUniqueId())) { + continue; + } + Player other = plugin.getServer().getPlayer(uuid); + if (other == null || !other.getWorld().equals(player.getWorld())) { + continue; + } + double distance = other.getLocation().distance(player.getLocation()); + if (distance > radius || distance < 0.001) { + continue; + } + Vector push = other.getLocation().toVector().subtract(player.getLocation().toVector()) + .normalize().multiply(strength * (1 - distance / radius)); + push.setY(Math.max(push.getY(), 0.35)); + other.setVelocity(other.getVelocity().add(push)); + } + } + } + } + + private void stopFallWatcher() { + if (fallWatcherTask != null) { + fallWatcherTask.cancel(); + fallWatcherTask = null; + } + } + /** Plays a configured sound directly to every current player/spectator (each at their own * location), instead of once at a single world coordinate — Bukkit's location-based * playSound falls off with distance, so anyone far from that one spot would hear nothing. */ @@ -426,23 +608,14 @@ public class Arena { } } - private void evaluateEliminations() { - List> 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 eliminated = EliminationLogic.determineEliminated(states, currentTarget); - for (UUID uuid : eliminated) { - eliminatePlayer(uuid); - } + /** Called once the post-floor-removal fall window has elapsed. By this point, every + * player who fell through the floor (or hit bedrock) has already been eliminated in + * real time by {@link #monitorFalls}, so whoever remains in {@code players} survived + * the round outright. */ + private void finishRoundEvaluation() { for (UUID uuid : players) { PlayerSession session = plugin.getSessionManager().get(uuid); - if (session != null && !eliminated.contains(uuid)) { + if (session != null) { session.incrementRoundsSurvived(); } } @@ -466,10 +639,7 @@ public class Arena { } BukkitTask restoreDelay = plugin.getServer().getScheduler().runTaskLater(plugin, () -> { - if (plugin.getConfigManager().isShuffleFloorEachRound()) { - floorManager.shuffle(random); - } - floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> { + prepareRoundFloor(() -> { playToParticipants("round-complete"); startMusic(); startNextRound(); @@ -509,6 +679,7 @@ public class Arena { private void endMatch(UUID winner) { stateMachine.transition(ArenaPhase.ENDING); cancelAllTasks(); + stopFallWatcher(); pauseMusic(); if (winner != null) { Player winnerPlayer = plugin.getServer().getPlayer(winner); @@ -582,6 +753,10 @@ public class Arena { } floorManager.cancelActiveTask(); cancelAllTasks(); + World resetWorld = config.getWorld(); + if (resetWorld != null) { + powerupManager.clearAll(resetWorld); + } round = 0; currentMusicTrack = null; plugin.getVoteManager().clear(config.getName()); @@ -592,7 +767,7 @@ public class Arena { private void checkForWinner() { if (getPhase() == ArenaPhase.RUNNING && EliminationLogic.isMatchOver(players.size())) { cancelAllTasks(); - evaluateEliminations(); + finishRoundEvaluation(); } } @@ -611,6 +786,10 @@ public class Arena { cancelAllTasks(); billboardManager.despawn(); pauseMusic(); + World shutdownWorld = config.getWorld(); + if (shutdownWorld != null) { + powerupManager.clearAll(shutdownWorld); + } List all = allParticipants(); for (UUID uuid : all) { Player p = plugin.getServer().getPlayer(uuid); @@ -645,6 +824,10 @@ public class Arena { return billboardManager; } + public PowerupManager getPowerupManager() { + return powerupManager; + } + private String formatMaterial(Material material) { String name = material.name().replace("_CONCRETE", "").replace("_", " ").toLowerCase(); return name.substring(0, 1).toUpperCase() + name.substring(1); diff --git a/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java b/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java index 962be05..ee18cac 100644 --- a/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java +++ b/src/main/java/us/tss3/blockparty/command/BlockPartyCommand.java @@ -57,6 +57,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter { case "list" -> list(sender); case "reload" -> reload(sender); case "join" -> join(sender, args); + case "spectate" -> spectate(sender, args); case "leave" -> leave(sender); case "arenas" -> list(sender); case "stats" -> stats(sender, args); @@ -70,6 +71,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter { List lines = List.of( "/blockparty help", "/blockparty join ", + "/blockparty spectate ", "/blockparty leave", "/blockparty arenas", "/blockparty stats [player]", @@ -432,6 +434,36 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter { sender.sendMessage(plugin.getMessages().get("player.joined", Map.of("arena", name))); } + private void spectate(CommandSender sender, String[] args) { + if (!requirePlayer(sender)) return; + if (!sender.hasPermission("blockparty.spectate") && !sender.hasPermission("blockparty.play")) { + sender.sendMessage(plugin.getMessages().get("errors.no-permission")); + return; + } + Player player = (Player) sender; + String name = argOrCurrentArenaless(args); + if (name == null) { + sender.sendMessage("Usage: /blockparty spectate "); + return; + } + var opt = plugin.getArenaManager().get(name); + if (opt.isEmpty()) { + sender.sendMessage(plugin.getMessages().get("errors.unknown-arena", Map.of("arena", name))); + return; + } + Arena arena = opt.get(); + if (plugin.getSessionManager().isInSession(player.getUniqueId())) { + sender.sendMessage(plugin.getMessages().get("errors.already-in-arena")); + return; + } + if (!arena.isEnabled()) { + sender.sendMessage(plugin.getMessages().get("errors.arena-disabled", Map.of("arena", name))); + return; + } + arena.addSpectator(player); + sender.sendMessage(plugin.getMessages().get("player.spectating", Map.of("arena", name))); + } + private void leave(CommandSender sender) { if (!requirePlayer(sender)) return; Player player = (Player) sender; @@ -481,6 +513,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter { sender.sendMessage("§7Eliminations: §f" + stats.getEliminations()); sender.sendMessage("§7Best round: §f" + stats.getBestRound()); sender.sendMessage("§7Total rounds survived: §f" + stats.getTotalRoundsSurvived()); + sender.sendMessage("§7Powerups used: §f" + stats.getPowerupsUsed()); } private void withArena(CommandSender sender, String[] args, java.util.function.Consumer consumer) { @@ -500,7 +533,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter { @Override public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { if (args.length == 1) { - return List.of("help", "join", "leave", "arenas", "stats", "musicvote", "create", "delete", "enable", "disable", + return List.of("help", "join", "spectate", "leave", "arenas", "stats", "musicvote", "create", "delete", "enable", "disable", "setlobby", "setspawn", "setspectator", "setbillboard", "delbillboard", "billboards", "pos1", "pos2", "setfloor", "generate", "info", "list", "reload") .stream().filter(s -> s.startsWith(args[0].toLowerCase())).collect(Collectors.toList()); } diff --git a/src/main/java/us/tss3/blockparty/config/ConfigManager.java b/src/main/java/us/tss3/blockparty/config/ConfigManager.java index 3d4ebb3..32de3e6 100644 --- a/src/main/java/us/tss3/blockparty/config/ConfigManager.java +++ b/src/main/java/us/tss3/blockparty/config/ConfigManager.java @@ -97,6 +97,71 @@ public class ConfigManager { return config.getBoolean("floor.shuffle-each-round", true); } + /** Fraction of the floor made of the target color on round 1 (0-1). Below the natural + * 1/paletteSize average so even the first round is harder than a plain uniform shuffle. */ + public double getStartingTargetBlockFraction() { + return config.getDouble("floor.starting-target-fraction", 0.5); + } + + /** Fraction of the floor made of the target color on the hardest (late-game) rounds. */ + public double getMinimumTargetBlockFraction() { + return config.getDouble("floor.minimum-target-fraction", 0.06); + } + + /** How much the target color's share of the floor shrinks per round after the first. */ + public double getTargetBlockFractionReductionPerRound() { + return config.getDouble("floor.target-fraction-reduction-per-round", 0.04); + } + + public boolean isPowerupsEnabled() { + return config.getBoolean("powerups.enabled", true); + } + + /** Chance (0-1), rolled once per round, that a new powerup spawns on the floor. */ + public double getPowerupSpawnChance() { + return config.getDouble("powerups.spawn-chance-per-round", 0.35); + } + + /** Cap on how many unclaimed powerups can sit on the floor at once. */ + public int getPowerupMaxActive() { + return config.getInt("powerups.max-active", 1); + } + + /** Minimum number of rounds an unclaimed powerup lingers before disappearing. */ + public int getPowerupMinLifetimeRounds() { + return config.getInt("powerups.min-lifetime-rounds", 2); + } + + /** Maximum number of rounds an unclaimed powerup lingers before disappearing. */ + public int getPowerupMaxLifetimeRounds() { + return config.getInt("powerups.max-lifetime-rounds", 3); + } + + public org.bukkit.Material getPowerupMarkerMaterial(String typeKey) { + String name = config.getString("powerups.types." + typeKey + ".marker-block", "TORCH"); + org.bukkit.Material material = org.bukkit.Material.matchMaterial(name); + return material != null ? material : org.bukkit.Material.TORCH; + } + + public int getSuperSpeedDurationSeconds() { + return config.getInt("powerups.types.super-speed.duration-seconds", 8); + } + + /** Potion effect amplifier, i.e. Speed (amplifier + 1) - amplifier 1 = Speed II. */ + public int getSuperSpeedAmplifier() { + return config.getInt("powerups.types.super-speed.amplifier", 1); + } + + /** Radius, in blocks, that Air Blast pushes other nearby players within. */ + public double getAirBlastRadius() { + return config.getDouble("powerups.types.air-blast.radius", 4.0); + } + + /** Horizontal+vertical knockback strength applied to players caught in an Air Blast. */ + public double getAirBlastStrength() { + return config.getDouble("powerups.types.air-blast.strength", 1.4); + } + public boolean isMusicEnabled() { return config.getBoolean("music.enabled", false); } diff --git a/src/main/java/us/tss3/blockparty/floor/FloorManager.java b/src/main/java/us/tss3/blockparty/floor/FloorManager.java index fac5f97..e786246 100644 --- a/src/main/java/us/tss3/blockparty/floor/FloorManager.java +++ b/src/main/java/us/tss3/blockparty/floor/FloorManager.java @@ -90,23 +90,40 @@ public class FloorManager { return new ArrayList<>(layout.values()); } + /** All position keys ("x,y,z") currently in the layout, e.g. so a powerup spawner can pick + * a random floor cell to hover a marker above. */ + public List layoutKeys() { + return new ArrayList<>(layout.keySet()); + } + /** - * Randomly permutes which material sits at which position, without changing the set of - * positions or how many blocks of each material exist (so target-color counts/fairness - * from {@link #generate} are preserved) — used so the board looks different each round - * instead of always restoring to the exact same static pattern. Purely in-memory: the - * arena's persisted {@code generated-layout} is left untouched, so a restart or - * `/bp generate` still starts from the original saved layout. + * Reassigns every position's material at random, biased so that only {@code targetFraction} + * of the floor ends up as {@code target} — the rest is spread evenly across the other colors + * already present in the layout. Used so the board looks different each round instead of + * always restoring to the exact same static pattern, and so later rounds (a smaller + * {@code targetFraction}) make the target color progressively rarer and harder to spot + * rather than always averaging ~1/(palette size) of the floor. Purely in-memory: the arena's + * persisted {@code generated-layout} is left untouched, so a restart or `/bp generate` still + * starts from the original saved layout. + * + * @param targetFraction desired share (0-1] of the floor that should be {@code target}; + * clamped so at least one block of the target and one non-target + * block remain when both are possible */ - public void shuffle(Random random) { + public void shuffle(Random random, Material target, double targetFraction) { if (layout.size() < 2) { return; } - List materials = new ArrayList<>(layout.values()); - Collections.shuffle(materials, random); + List otherPalette = layout.values().stream().distinct() + .filter(m -> m != target).toList(); List keys = new ArrayList<>(layout.keySet()); - for (int i = 0; i < keys.size(); i++) { - layout.put(keys.get(i), materials.get(i)); + Collections.shuffle(keys, random); + int total = keys.size(); + int targetCount = otherPalette.isEmpty() ? total + : Math.max(1, Math.min(total - 1, (int) Math.round(total * targetFraction))); + for (int i = 0; i < total; i++) { + Material mat = i < targetCount ? target : otherPalette.get(random.nextInt(otherPalette.size())); + layout.put(keys.get(i), mat); } } @@ -195,6 +212,35 @@ public class FloorManager { return layout.get(x + "," + y + "," + z); } + /** Lowest Y coordinate spanned by the floor layout, i.e. the surface players stand on. + * Used as the threshold below which a player is considered to have fallen through. */ + public int minLayoutY() { + int min = Integer.MAX_VALUE; + for (String key : layout.keySet()) { + int y = Integer.parseInt(key.split(",")[1]); + if (y < min) { + min = y; + } + } + return min == Integer.MAX_VALUE ? 0 : min; + } + + /** True once the player has fallen below the floor surface: either they've dropped past the + * layout's Y level entirely, or the block they're standing in/on is bedrock (the arena's + * hard floor beneath any void/gap). Unlike {@link #materialAtLocation}, this only reports + * a problem once a player has actually fallen, so a jump timed at the moment of the check + * never gets mistaken for falling through. */ + public boolean hasFallenThrough(World world, double x, double y, double z) { + if (y < minLayoutY() - 0.5) { + return true; + } + int bx = (int) Math.floor(x); + int bz = (int) Math.floor(z); + int feetY = (int) Math.floor(y); + return world.getBlockAt(bx, feetY, bz).getType() == Material.BEDROCK + || world.getBlockAt(bx, feetY - 1, bz).getType() == Material.BEDROCK; + } + public Material materialAtLocation(World world, double x, double y, double z) { // check the block directly below the player's feet int bx = (int) Math.floor(x); diff --git a/src/main/java/us/tss3/blockparty/listener/PowerupListener.java b/src/main/java/us/tss3/blockparty/listener/PowerupListener.java new file mode 100644 index 0000000..daae02d --- /dev/null +++ b/src/main/java/us/tss3/blockparty/listener/PowerupListener.java @@ -0,0 +1,36 @@ +package us.tss3.blockparty.listener; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import us.tss3.blockparty.BlockPartyPlugin; +import us.tss3.blockparty.arena.Arena; +import us.tss3.blockparty.session.PlayerSession; + +/** Lets participants claim a powerup marker by left-clicking it, in addition to just walking + * over it (see Arena's fall-watcher loop). */ +public class PowerupListener implements Listener { + + private final BlockPartyPlugin plugin; + + public PowerupListener(BlockPartyPlugin plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onLeftClickBlock(PlayerInteractEvent event) { + if (event.getAction() != Action.LEFT_CLICK_BLOCK || event.getClickedBlock() == null) { + return; + } + Player player = event.getPlayer(); + PlayerSession session = plugin.getSessionManager().get(player.getUniqueId()); + if (session == null || session.getRole() != PlayerSession.Role.PLAYER) { + return; + } + plugin.getArenaManager().get(session.getArenaName()).ifPresent(arena -> + arena.claimPowerupAt(player, event.getClickedBlock().getLocation())); + } +} diff --git a/src/main/java/us/tss3/blockparty/logic/TargetDensityCalculator.java b/src/main/java/us/tss3/blockparty/logic/TargetDensityCalculator.java new file mode 100644 index 0000000..81b459e --- /dev/null +++ b/src/main/java/us/tss3/blockparty/logic/TargetDensityCalculator.java @@ -0,0 +1,35 @@ +package us.tss3.blockparty.logic; + +/** Pure math for computing how rare the target color's blocks should be on a given round, + * so the floor gets progressively harder to read as a match goes on. */ +public final class TargetDensityCalculator { + + private TargetDensityCalculator() { + } + + /** + * @param startingFraction fraction of the floor made of the target color on round 1 (0-1) + * @param minimumFraction floor value, never go below this (must stay > 0 so a solution exists) + * @param reductionPerRound fraction shaved off per round after the first + * @param roundNumber 1-indexed round number + * @return fraction of the floor's blocks that should be the target color, clamped to (0, 1] + */ + public static double fractionForRound(double startingFraction, double minimumFraction, + double reductionPerRound, int roundNumber) { + if (roundNumber < 1) { + roundNumber = 1; + } + double reduction = reductionPerRound * (roundNumber - 1); + double result = startingFraction - reduction; + if (result < minimumFraction) { + result = minimumFraction; + } + if (result <= 0) { + result = Double.MIN_VALUE; + } + if (result > 1) { + result = 1; + } + return result; + } +} diff --git a/src/main/java/us/tss3/blockparty/persistence/PlayerStats.java b/src/main/java/us/tss3/blockparty/persistence/PlayerStats.java index 3b06ad0..9c79fda 100644 --- a/src/main/java/us/tss3/blockparty/persistence/PlayerStats.java +++ b/src/main/java/us/tss3/blockparty/persistence/PlayerStats.java @@ -10,18 +10,21 @@ public class PlayerStats { private int eliminations; private int bestRound; private int totalRoundsSurvived; + private int powerupsUsed; - public PlayerStats(UUID uuid, int gamesPlayed, int wins, int eliminations, int bestRound, int totalRoundsSurvived) { + public PlayerStats(UUID uuid, int gamesPlayed, int wins, int eliminations, int bestRound, int totalRoundsSurvived, + int powerupsUsed) { this.uuid = uuid; this.gamesPlayed = gamesPlayed; this.wins = wins; this.eliminations = eliminations; this.bestRound = bestRound; this.totalRoundsSurvived = totalRoundsSurvived; + this.powerupsUsed = powerupsUsed; } public static PlayerStats empty(UUID uuid) { - return new PlayerStats(uuid, 0, 0, 0, 0, 0); + return new PlayerStats(uuid, 0, 0, 0, 0, 0, 0); } public UUID getUuid() { @@ -47,4 +50,8 @@ public class PlayerStats { public int getTotalRoundsSurvived() { return totalRoundsSurvived; } + + public int getPowerupsUsed() { + return powerupsUsed; + } } diff --git a/src/main/java/us/tss3/blockparty/persistence/StatsDatabase.java b/src/main/java/us/tss3/blockparty/persistence/StatsDatabase.java index c0b9960..746aade 100644 --- a/src/main/java/us/tss3/blockparty/persistence/StatsDatabase.java +++ b/src/main/java/us/tss3/blockparty/persistence/StatsDatabase.java @@ -64,6 +64,7 @@ public class StatsDatabase { try (Statement st = connection.createStatement()) { st.execute(createTableStatement()); } + addPowerupsUsedColumnIfMissing(); } catch (Exception ex) { plugin.getLogger().log(Level.SEVERE, "Failed to initialize " + (settings.isMysql() ? "MySQL" : "SQLite") + " stats database", ex); @@ -96,7 +97,8 @@ public class StatsDatabase { "wins INT NOT NULL DEFAULT 0," + "eliminations INT NOT NULL DEFAULT 0," + "best_round INT NOT NULL DEFAULT 0," + - "total_rounds_survived INT NOT NULL DEFAULT 0" + + "total_rounds_survived INT NOT NULL DEFAULT 0," + + "powerups_used INT NOT NULL DEFAULT 0" + ")"; } return "CREATE TABLE IF NOT EXISTS " + table + " (" + @@ -105,10 +107,22 @@ public class StatsDatabase { "wins INTEGER NOT NULL DEFAULT 0," + "eliminations INTEGER NOT NULL DEFAULT 0," + "best_round INTEGER NOT NULL DEFAULT 0," + - "total_rounds_survived INTEGER NOT NULL DEFAULT 0" + + "total_rounds_survived INTEGER NOT NULL DEFAULT 0," + + "powerups_used INTEGER NOT NULL DEFAULT 0" + ")"; } + /** Adds the powerups_used column to a pre-existing table from before this stat was tracked. + * Both SQLite and MySQL support plain ADD COLUMN; a failure here just means the column + * already exists (no IF NOT EXISTS support on either dialect for this statement), which is + * safe to ignore. */ + private void addPowerupsUsedColumnIfMissing() { + try (Statement st = connection.createStatement()) { + st.execute("ALTER TABLE " + table + " ADD COLUMN powerups_used INT NOT NULL DEFAULT 0"); + } catch (SQLException ignored) { + } + } + public synchronized void close() { try { if (connection != null && !connection.isClosed()) { @@ -135,11 +149,11 @@ public class StatsDatabase { try { ensureRow(uuid.toString()); try (PreparedStatement ps = connection.prepareStatement( - "SELECT games_played, wins, eliminations, best_round, total_rounds_survived FROM " + table + " WHERE uuid=?")) { + "SELECT games_played, wins, eliminations, best_round, total_rounds_survived, powerups_used FROM " + table + " WHERE uuid=?")) { ps.setString(1, uuid.toString()); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { - return new PlayerStats(uuid, rs.getInt(1), rs.getInt(2), rs.getInt(3), rs.getInt(4), rs.getInt(5)); + return new PlayerStats(uuid, rs.getInt(1), rs.getInt(2), rs.getInt(3), rs.getInt(4), rs.getInt(5), rs.getInt(6)); } } } @@ -161,6 +175,10 @@ public class StatsDatabase { update(uuid, "eliminations = eliminations + 1"); } + public synchronized void incrementPowerupsUsed(UUID uuid) { + update(uuid, "powerups_used = powerups_used + 1"); + } + public synchronized void recordRoundReached(UUID uuid, int round) { if (connection == null) { return; diff --git a/src/main/java/us/tss3/blockparty/persistence/StatsManager.java b/src/main/java/us/tss3/blockparty/persistence/StatsManager.java index cb873f8..d84d132 100644 --- a/src/main/java/us/tss3/blockparty/persistence/StatsManager.java +++ b/src/main/java/us/tss3/blockparty/persistence/StatsManager.java @@ -40,6 +40,10 @@ public class StatsManager { }); } + public void recordPowerupUse(UUID uuid) { + runAsync(() -> database.incrementPowerupsUsed(uuid)); + } + public CompletableFuture getStats(UUID uuid) { CompletableFuture future = new CompletableFuture<>(); plugin.getServer().getAsyncScheduler().runNow(plugin, task -> future.complete(database.getStats(uuid))); diff --git a/src/main/java/us/tss3/blockparty/powerup/ActivePowerup.java b/src/main/java/us/tss3/blockparty/powerup/ActivePowerup.java new file mode 100644 index 0000000..f5f7c7d --- /dev/null +++ b/src/main/java/us/tss3/blockparty/powerup/ActivePowerup.java @@ -0,0 +1,31 @@ +package us.tss3.blockparty.powerup; + +/** A powerup marker currently sitting on the floor, waiting to be claimed. */ +public class ActivePowerup { + + private final PowerupType type; + private final String positionKey; + private final int spawnedRound; + private final int lifetimeRounds; + + public ActivePowerup(PowerupType type, String positionKey, int spawnedRound, int lifetimeRounds) { + this.type = type; + this.positionKey = positionKey; + this.spawnedRound = spawnedRound; + this.lifetimeRounds = lifetimeRounds; + } + + public PowerupType getType() { + return type; + } + + public String getPositionKey() { + return positionKey; + } + + /** True once this powerup has sat unclaimed since {@code spawnedRound} through and including + * {@code currentRound} for at least {@link #lifetimeRounds} rounds. */ + public boolean isExpired(int currentRound) { + return currentRound - spawnedRound >= lifetimeRounds; + } +} diff --git a/src/main/java/us/tss3/blockparty/powerup/PowerupManager.java b/src/main/java/us/tss3/blockparty/powerup/PowerupManager.java new file mode 100644 index 0000000..51284fd --- /dev/null +++ b/src/main/java/us/tss3/blockparty/powerup/PowerupManager.java @@ -0,0 +1,141 @@ +package us.tss3.blockparty.powerup; + +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import us.tss3.blockparty.config.ConfigManager; +import us.tss3.blockparty.floor.FloorManager; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +/** Owns the powerups currently sitting on a single arena's floor: spawning new ones each round, + * ageing out unclaimed ones, and rendering/clearing their markers in the world. A powerup's + * marker is a non-solid block floating directly above its floor cell (the player's foot space + * when standing there) rather than replacing the floor block itself, so it never removes a + * player's footing and never has to be reconciled with the floor's own color each round. + * Claiming (i.e. what happens when a player actually gets one) is handled by the caller (see + * Arena) since it needs to apply effects and record stats. */ +public class PowerupManager { + + private final ConfigManager configManager; + private final FloorManager floorManager; + /** marker position key ("x,y,z", one block above the floor cell) -> the powerup there */ + private final Map active = new LinkedHashMap<>(); + + public PowerupManager(ConfigManager configManager, FloorManager floorManager) { + this.configManager = configManager; + this.floorManager = floorManager; + } + + public boolean hasActiveAt(String markerKey) { + return active.containsKey(markerKey); + } + + /** Removes and returns the powerup at a marker position (e.g. a player walked over or + * clicked it), or null if nothing is there. Does not touch the world block - the caller is + * expected to clear it via {@link #clearMarkerBlock}. */ + public ActivePowerup claim(String markerKey) { + return active.remove(markerKey); + } + + /** Ages out any powerup that's sat unclaimed too long, clearing its marker. */ + public void expireOld(World world, int currentRound) { + List expired = new ArrayList<>(); + for (Map.Entry entry : active.entrySet()) { + if (entry.getValue().isExpired(currentRound)) { + expired.add(entry.getKey()); + } + } + for (String key : expired) { + active.remove(key); + clearMarkerBlock(world, key); + } + } + + /** Rolls whether a new powerup should appear this round and, if so, places one above a + * random free floor cell. No-op while powerups are disabled or the floor has no free cells. */ + public void maybeSpawn(World world, Random random, int currentRound) { + if (!configManager.isPowerupsEnabled() || world == null) { + return; + } + if (active.size() >= configManager.getPowerupMaxActive()) { + return; + } + if (random.nextDouble() >= configManager.getPowerupSpawnChance()) { + return; + } + List floorKeys = floorManager.layoutKeys(); + if (floorKeys.isEmpty()) { + return; + } + String floorKey = floorKeys.get(random.nextInt(floorKeys.size())); + String markerKey = aboveKey(floorKey); + if (markerKey == null || active.containsKey(markerKey)) { + return; + } + PowerupType[] types = PowerupType.values(); + PowerupType type = types[random.nextInt(types.length)]; + int min = Math.min(configManager.getPowerupMinLifetimeRounds(), configManager.getPowerupMaxLifetimeRounds()); + int max = Math.max(configManager.getPowerupMinLifetimeRounds(), configManager.getPowerupMaxLifetimeRounds()); + int lifetime = min + (max > min ? random.nextInt(max - min + 1) : 0); + active.put(markerKey, new ActivePowerup(type, markerKey, currentRound, lifetime)); + placeMarkerBlock(world, markerKey, markerMaterial(type)); + } + + /** (Re-)places every currently active powerup's marker block in the world. Only strictly + * needed for freshly-spawned ones (the floor restore each round never touches the marker's + * above-floor position), but cheap and safe to call for all of them defensively. */ + public void applyMarkers(World world) { + for (ActivePowerup powerup : active.values()) { + placeMarkerBlock(world, powerup.getPositionKey(), markerMaterial(powerup.getType())); + } + } + + /** Clears every active powerup's marker - used when a match ends/resets so no stray blocks + * are left floating over the arena. */ + public void clearAll(World world) { + for (String key : new ArrayList<>(active.keySet())) { + clearMarkerBlock(world, key); + } + active.clear(); + } + + public Material markerMaterial(PowerupType type) { + return configManager.getPowerupMarkerMaterial(type == PowerupType.SUPER_SPEED ? "super-speed" : "air-blast"); + } + + private void placeMarkerBlock(World world, String key, Material material) { + Block block = blockAt(world, key); + if (block != null && block.getType() != material) { + block.setType(material, false); + } + } + + public void clearMarkerBlock(World world, String key) { + Block block = blockAt(world, key); + if (block != null) { + block.setType(Material.AIR, false); + } + } + + private Block blockAt(World world, String key) { + if (world == null || key == null) { + return null; + } + String[] parts = key.split(","); + return world.getBlockAt(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2])); + } + + private String aboveKey(String floorKey) { + String[] parts = floorKey.split(","); + if (parts.length != 3) { + return null; + } + int y = Integer.parseInt(parts[1]); + return parts[0] + "," + (y + 1) + "," + parts[2]; + } +} diff --git a/src/main/java/us/tss3/blockparty/powerup/PowerupType.java b/src/main/java/us/tss3/blockparty/powerup/PowerupType.java new file mode 100644 index 0000000..17cb384 --- /dev/null +++ b/src/main/java/us/tss3/blockparty/powerup/PowerupType.java @@ -0,0 +1,17 @@ +package us.tss3.blockparty.powerup; + +/** The kinds of temporary abilities that can spawn on the floor for players to claim. */ +public enum PowerupType { + SUPER_SPEED("Super Speed"), + AIR_BLAST("Air Blast"); + + private final String displayName; + + PowerupType(String displayName) { + this.displayName = displayName; + } + + public String displayName() { + return displayName; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index c8d4547..b7f05d0 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -71,6 +71,41 @@ floor: # (from /bp generate) is never modified by shuffling. shuffle-each-round: true + # Controls how rare the target color's blocks are on the floor, so the board gets harder + # to read as a match goes on instead of always averaging ~1/(number of colors) of the floor. + # Only takes effect while shuffle-each-round is true. + # Fraction (0-1) of the floor that is the target color on round 1. + starting-target-fraction: 0.5 + # Fraction (0-1) of the floor that is the target color on the hardest rounds - never goes + # below this, so there's always at least a sliver of the floor players can actually stand on. + minimum-target-fraction: 0.06 + # How much the target color's share of the floor shrinks per round after the first. + target-fraction-reduction-per-round: 0.04 + +powerups: + # Temporary abilities that spawn on the floor for players to claim by walking over them or + # left-clicking them. Unclaimed powerups disappear after min/max-lifetime-rounds. + enabled: true + # Chance, rolled once per round, that a new powerup spawns somewhere on the floor. + spawn-chance-per-round: 0.35 + # Cap on how many unclaimed powerups can be sitting on the floor at once. + max-active: 1 + min-lifetime-rounds: 2 + max-lifetime-rounds: 3 + types: + super-speed: + # Block used to mark a Super Speed powerup's spot on the floor. + marker-block: TORCH + duration-seconds: 8 + # Potion amplifier: 0 = Speed I, 1 = Speed II, etc. + amplifier: 1 + air-blast: + # Block used to mark an Air Blast powerup's spot on the floor. + marker-block: END_ROD + # Radius, in blocks, that nearby players get pushed within when this is claimed. + radius: 4.0 + strength: 1.4 + # example/default timings used when creating a brand-new arena via /blockparty create defaults: countdown-duration: 15 diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index f46b089..09638ef 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -34,6 +34,7 @@ admin: player: joined: "You joined arena '%arena%'." + spectating: "You are now spectating arena '%arena%'." left: "You left the arena." musicvote-code: "Your music vote code: %code% (valid %minutes% minutes) - enter it on the jukebox voting page to pick the next track." @@ -52,6 +53,11 @@ game: win-title: "%player% wins!" no-winner: "The match ended with no winner." +powerup: + spawned: "A %type% powerup appeared on the floor!" + claimed-self: "You claimed %type%!" + claimed-other: "%player% claimed a %type% powerup!" + scoreboard: title: "BlockParty" lines: diff --git a/src/test/java/us/tss3/blockparty/logic/TargetDensityCalculatorTest.java b/src/test/java/us/tss3/blockparty/logic/TargetDensityCalculatorTest.java new file mode 100644 index 0000000..302dd48 --- /dev/null +++ b/src/test/java/us/tss3/blockparty/logic/TargetDensityCalculatorTest.java @@ -0,0 +1,29 @@ +package us.tss3.blockparty.logic; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class TargetDensityCalculatorTest { + + @Test + void firstRoundUsesStartingFraction() { + assertEquals(0.5, TargetDensityCalculator.fractionForRound(0.5, 0.05, 0.1, 1)); + } + + @Test + void reducesEachRound() { + assertEquals(0.4, TargetDensityCalculator.fractionForRound(0.5, 0.05, 0.1, 2), 1e-9); + assertEquals(0.3, TargetDensityCalculator.fractionForRound(0.5, 0.05, 0.1, 3), 1e-9); + } + + @Test + void clampsAtMinimum() { + assertEquals(0.05, TargetDensityCalculator.fractionForRound(0.5, 0.05, 0.1, 20), 1e-9); + } + + @Test + void roundNumberBelowOneTreatedAsOne() { + assertEquals(0.5, TargetDensityCalculator.fractionForRound(0.5, 0.05, 0.1, 0)); + } +}