Scale round difficulty over time and add claimable powerups
Build / build (push) Successful in 1m17s
Build / build (push) Successful in 1m17s
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 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8049aa04af
commit
4c4f5a1215
@@ -15,6 +15,7 @@ import us.tss3.blockparty.jukebox.VoteCodeManager;
|
|||||||
import us.tss3.blockparty.jukebox.VoteManager;
|
import us.tss3.blockparty.jukebox.VoteManager;
|
||||||
import us.tss3.blockparty.listener.GameplayRestrictionListener;
|
import us.tss3.blockparty.listener.GameplayRestrictionListener;
|
||||||
import us.tss3.blockparty.listener.PlayerConnectionListener;
|
import us.tss3.blockparty.listener.PlayerConnectionListener;
|
||||||
|
import us.tss3.blockparty.listener.PowerupListener;
|
||||||
import us.tss3.blockparty.persistence.StatsManager;
|
import us.tss3.blockparty.persistence.StatsManager;
|
||||||
import us.tss3.blockparty.placeholder.BlockPartyExpansion;
|
import us.tss3.blockparty.placeholder.BlockPartyExpansion;
|
||||||
import us.tss3.blockparty.reward.RewardManager;
|
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 PlayerConnectionListener(this), this);
|
||||||
getServer().getPluginManager().registerEvents(new GameplayRestrictionListener(this), this);
|
getServer().getPluginManager().registerEvents(new GameplayRestrictionListener(this), this);
|
||||||
|
getServer().getPluginManager().registerEvents(new PowerupListener(this), this);
|
||||||
getServer().getPluginManager().registerEvents(this, this);
|
getServer().getPluginManager().registerEvents(this, this);
|
||||||
|
|
||||||
BlockPartyCommand commandHandler = new BlockPartyCommand(this);
|
BlockPartyCommand commandHandler = new BlockPartyCommand(this);
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import net.kyori.adventure.title.Title;
|
|||||||
import org.bukkit.GameMode;
|
import org.bukkit.GameMode;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
import org.bukkit.Material;
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.World;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.inventory.ItemStack;
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.potion.PotionEffect;
|
||||||
|
import org.bukkit.potion.PotionEffectType;
|
||||||
import org.bukkit.scheduler.BukkitTask;
|
import org.bukkit.scheduler.BukkitTask;
|
||||||
|
import org.bukkit.util.Vector;
|
||||||
import us.tss3.blockparty.BlockPartyPlugin;
|
import us.tss3.blockparty.BlockPartyPlugin;
|
||||||
import us.tss3.blockparty.billboard.BillboardManager;
|
import us.tss3.blockparty.billboard.BillboardManager;
|
||||||
import us.tss3.blockparty.config.ArenaConfig;
|
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.EliminationLogic;
|
||||||
import us.tss3.blockparty.logic.RoundTimeCalculator;
|
import us.tss3.blockparty.logic.RoundTimeCalculator;
|
||||||
import us.tss3.blockparty.logic.TargetColorSelector;
|
import us.tss3.blockparty.logic.TargetColorSelector;
|
||||||
|
import us.tss3.blockparty.logic.TargetDensityCalculator;
|
||||||
import us.tss3.blockparty.model.PlayerState;
|
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 us.tss3.blockparty.session.PlayerSession;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -37,6 +45,7 @@ public class Arena {
|
|||||||
private final ArenaConfig config;
|
private final ArenaConfig config;
|
||||||
private final FloorManager floorManager;
|
private final FloorManager floorManager;
|
||||||
private final BillboardManager billboardManager;
|
private final BillboardManager billboardManager;
|
||||||
|
private final PowerupManager powerupManager;
|
||||||
private final ArenaStateMachine stateMachine = new ArenaStateMachine(ArenaPhase.DISABLED);
|
private final ArenaStateMachine stateMachine = new ArenaStateMachine(ArenaPhase.DISABLED);
|
||||||
private final Random random = new Random();
|
private final Random random = new Random();
|
||||||
private final TargetColorSelector selector = new TargetColorSelector(random::nextInt);
|
private final TargetColorSelector selector = new TargetColorSelector(random::nextInt);
|
||||||
@@ -46,6 +55,12 @@ public class Arena {
|
|||||||
|
|
||||||
private final List<BukkitTask> tasks = new ArrayList<>();
|
private final List<BukkitTask> tasks = new ArrayList<>();
|
||||||
private BossBar bossBar;
|
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 countdownRemaining;
|
||||||
private int round = 0;
|
private int round = 0;
|
||||||
@@ -64,6 +79,7 @@ public class Arena {
|
|||||||
this.config = config;
|
this.config = config;
|
||||||
this.floorManager = new FloorManager(plugin, config);
|
this.floorManager = new FloorManager(plugin, config);
|
||||||
this.billboardManager = new BillboardManager(plugin, config);
|
this.billboardManager = new BillboardManager(plugin, config);
|
||||||
|
this.powerupManager = new PowerupManager(plugin.getConfigManager(), floorManager);
|
||||||
if (config.isEnabled()) {
|
if (config.isEnabled()) {
|
||||||
stateMachine.transition(ArenaPhase.WAITING);
|
stateMachine.transition(ArenaPhase.WAITING);
|
||||||
}
|
}
|
||||||
@@ -117,8 +133,13 @@ public class Arena {
|
|||||||
|
|
||||||
public void disable() {
|
public void disable() {
|
||||||
cancelAllTasks();
|
cancelAllTasks();
|
||||||
|
stopFallWatcher();
|
||||||
billboardManager.despawn();
|
billboardManager.despawn();
|
||||||
pauseMusic();
|
pauseMusic();
|
||||||
|
World disableWorld = config.getWorld();
|
||||||
|
if (disableWorld != null) {
|
||||||
|
powerupManager.clearAll(disableWorld);
|
||||||
|
}
|
||||||
plugin.getVoteManager().clear(config.getName());
|
plugin.getVoteManager().clear(config.getName());
|
||||||
// Force reset players out regardless of state
|
// Force reset players out regardless of state
|
||||||
List<UUID> all = new ArrayList<>();
|
List<UUID> all = new ArrayList<>();
|
||||||
@@ -152,6 +173,28 @@ public class Arena {
|
|||||||
maybeStartCountdown();
|
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) {
|
private void preparePlayerForLobby(Player player) {
|
||||||
player.getInventory().clear();
|
player.getInventory().clear();
|
||||||
player.setGameMode(GameMode.ADVENTURE);
|
player.setGameMode(GameMode.ADVENTURE);
|
||||||
@@ -254,16 +297,14 @@ public class Arena {
|
|||||||
if (!stateMachine.transition(ArenaPhase.RUNNING)) {
|
if (!stateMachine.transition(ArenaPhase.RUNNING)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
startFallWatcher();
|
||||||
round = 0;
|
round = 0;
|
||||||
soloMode = players.size() == 1;
|
soloMode = players.size() == 1;
|
||||||
previousTarget = null;
|
previousTarget = null;
|
||||||
if (!floorManager.hasLayout()) {
|
if (!floorManager.hasLayout()) {
|
||||||
floorManager.generate(random);
|
floorManager.generate(random);
|
||||||
}
|
}
|
||||||
if (plugin.getConfigManager().isShuffleFloorEachRound()) {
|
prepareRoundFloor(() -> {
|
||||||
floorManager.shuffle(random);
|
|
||||||
}
|
|
||||||
floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
|
|
||||||
Location safeSpawn = safeSpawnLocation();
|
Location safeSpawn = safeSpawnLocation();
|
||||||
for (UUID uuid : players) {
|
for (UUID uuid : players) {
|
||||||
Player p = plugin.getServer().getPlayer(uuid);
|
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<Material> 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() {
|
private void startNextRound() {
|
||||||
if (players.isEmpty()) {
|
if (players.isEmpty()) {
|
||||||
endMatch(null);
|
endMatch(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// In solo mode the match only ends when the lone player is actually eliminated (see
|
// 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.
|
// 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
|
// 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.
|
// round has actually been played (round > 0); at round 0 that's just the initial join.
|
||||||
@@ -295,12 +366,6 @@ public class Arena {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
round++;
|
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(
|
currentRoundTime = RoundTimeCalculator.timeForRound(
|
||||||
config.getStartingRoundTime(), config.getMinimumRoundTime(), config.getRoundTimeReduction(), round);
|
config.getStartingRoundTime(), config.getMinimumRoundTime(), config.getRoundTimeReduction(), round);
|
||||||
roundTimeRemaining = currentRoundTime;
|
roundTimeRemaining = currentRoundTime;
|
||||||
@@ -368,12 +433,129 @@ public class Arena {
|
|||||||
playToParticipants("floor-disappear");
|
playToParticipants("floor-disappear");
|
||||||
pauseMusic();
|
pauseMusic();
|
||||||
floorManager.removeNonTarget(currentTarget, plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
|
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);
|
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<UUID> 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<String, String> 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
|
/** 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
|
* 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. */
|
* 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() {
|
/** Called once the post-floor-removal fall window has elapsed. By this point, every
|
||||||
List<EliminationLogic.PlayerFloorState<UUID, Material>> states = new ArrayList<>();
|
* player who fell through the floor (or hit bedrock) has already been eliminated in
|
||||||
for (UUID uuid : players) {
|
* real time by {@link #monitorFalls}, so whoever remains in {@code players} survived
|
||||||
Player p = plugin.getServer().getPlayer(uuid);
|
* the round outright. */
|
||||||
Material standing = null;
|
private void finishRoundEvaluation() {
|
||||||
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) {
|
for (UUID uuid : players) {
|
||||||
PlayerSession session = plugin.getSessionManager().get(uuid);
|
PlayerSession session = plugin.getSessionManager().get(uuid);
|
||||||
if (session != null && !eliminated.contains(uuid)) {
|
if (session != null) {
|
||||||
session.incrementRoundsSurvived();
|
session.incrementRoundsSurvived();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -466,10 +639,7 @@ public class Arena {
|
|||||||
}
|
}
|
||||||
|
|
||||||
BukkitTask restoreDelay = plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
|
BukkitTask restoreDelay = plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
|
||||||
if (plugin.getConfigManager().isShuffleFloorEachRound()) {
|
prepareRoundFloor(() -> {
|
||||||
floorManager.shuffle(random);
|
|
||||||
}
|
|
||||||
floorManager.restoreFull(plugin.getConfigManager().getFloorBatchBlocksPerTick(), () -> {
|
|
||||||
playToParticipants("round-complete");
|
playToParticipants("round-complete");
|
||||||
startMusic();
|
startMusic();
|
||||||
startNextRound();
|
startNextRound();
|
||||||
@@ -509,6 +679,7 @@ public class Arena {
|
|||||||
private void endMatch(UUID winner) {
|
private void endMatch(UUID winner) {
|
||||||
stateMachine.transition(ArenaPhase.ENDING);
|
stateMachine.transition(ArenaPhase.ENDING);
|
||||||
cancelAllTasks();
|
cancelAllTasks();
|
||||||
|
stopFallWatcher();
|
||||||
pauseMusic();
|
pauseMusic();
|
||||||
if (winner != null) {
|
if (winner != null) {
|
||||||
Player winnerPlayer = plugin.getServer().getPlayer(winner);
|
Player winnerPlayer = plugin.getServer().getPlayer(winner);
|
||||||
@@ -582,6 +753,10 @@ public class Arena {
|
|||||||
}
|
}
|
||||||
floorManager.cancelActiveTask();
|
floorManager.cancelActiveTask();
|
||||||
cancelAllTasks();
|
cancelAllTasks();
|
||||||
|
World resetWorld = config.getWorld();
|
||||||
|
if (resetWorld != null) {
|
||||||
|
powerupManager.clearAll(resetWorld);
|
||||||
|
}
|
||||||
round = 0;
|
round = 0;
|
||||||
currentMusicTrack = null;
|
currentMusicTrack = null;
|
||||||
plugin.getVoteManager().clear(config.getName());
|
plugin.getVoteManager().clear(config.getName());
|
||||||
@@ -592,7 +767,7 @@ public class Arena {
|
|||||||
private void checkForWinner() {
|
private void checkForWinner() {
|
||||||
if (getPhase() == ArenaPhase.RUNNING && EliminationLogic.isMatchOver(players.size())) {
|
if (getPhase() == ArenaPhase.RUNNING && EliminationLogic.isMatchOver(players.size())) {
|
||||||
cancelAllTasks();
|
cancelAllTasks();
|
||||||
evaluateEliminations();
|
finishRoundEvaluation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +786,10 @@ public class Arena {
|
|||||||
cancelAllTasks();
|
cancelAllTasks();
|
||||||
billboardManager.despawn();
|
billboardManager.despawn();
|
||||||
pauseMusic();
|
pauseMusic();
|
||||||
|
World shutdownWorld = config.getWorld();
|
||||||
|
if (shutdownWorld != null) {
|
||||||
|
powerupManager.clearAll(shutdownWorld);
|
||||||
|
}
|
||||||
List<UUID> all = allParticipants();
|
List<UUID> all = allParticipants();
|
||||||
for (UUID uuid : all) {
|
for (UUID uuid : all) {
|
||||||
Player p = plugin.getServer().getPlayer(uuid);
|
Player p = plugin.getServer().getPlayer(uuid);
|
||||||
@@ -645,6 +824,10 @@ public class Arena {
|
|||||||
return billboardManager;
|
return billboardManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PowerupManager getPowerupManager() {
|
||||||
|
return powerupManager;
|
||||||
|
}
|
||||||
|
|
||||||
private String formatMaterial(Material material) {
|
private String formatMaterial(Material material) {
|
||||||
String name = material.name().replace("_CONCRETE", "").replace("_", " ").toLowerCase();
|
String name = material.name().replace("_CONCRETE", "").replace("_", " ").toLowerCase();
|
||||||
return name.substring(0, 1).toUpperCase() + name.substring(1);
|
return name.substring(0, 1).toUpperCase() + name.substring(1);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
|
|||||||
case "list" -> list(sender);
|
case "list" -> list(sender);
|
||||||
case "reload" -> reload(sender);
|
case "reload" -> reload(sender);
|
||||||
case "join" -> join(sender, args);
|
case "join" -> join(sender, args);
|
||||||
|
case "spectate" -> spectate(sender, args);
|
||||||
case "leave" -> leave(sender);
|
case "leave" -> leave(sender);
|
||||||
case "arenas" -> list(sender);
|
case "arenas" -> list(sender);
|
||||||
case "stats" -> stats(sender, args);
|
case "stats" -> stats(sender, args);
|
||||||
@@ -70,6 +71,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
|
|||||||
List<String> lines = List.of(
|
List<String> lines = List.of(
|
||||||
"/blockparty help",
|
"/blockparty help",
|
||||||
"/blockparty join <arena>",
|
"/blockparty join <arena>",
|
||||||
|
"/blockparty spectate <arena>",
|
||||||
"/blockparty leave",
|
"/blockparty leave",
|
||||||
"/blockparty arenas",
|
"/blockparty arenas",
|
||||||
"/blockparty stats [player]",
|
"/blockparty stats [player]",
|
||||||
@@ -432,6 +434,36 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
|
|||||||
sender.sendMessage(plugin.getMessages().get("player.joined", Map.of("arena", name)));
|
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 <arena>");
|
||||||
|
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) {
|
private void leave(CommandSender sender) {
|
||||||
if (!requirePlayer(sender)) return;
|
if (!requirePlayer(sender)) return;
|
||||||
Player player = (Player) sender;
|
Player player = (Player) sender;
|
||||||
@@ -481,6 +513,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
|
|||||||
sender.sendMessage("§7Eliminations: §f" + stats.getEliminations());
|
sender.sendMessage("§7Eliminations: §f" + stats.getEliminations());
|
||||||
sender.sendMessage("§7Best round: §f" + stats.getBestRound());
|
sender.sendMessage("§7Best round: §f" + stats.getBestRound());
|
||||||
sender.sendMessage("§7Total rounds survived: §f" + stats.getTotalRoundsSurvived());
|
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<Arena> consumer) {
|
private void withArena(CommandSender sender, String[] args, java.util.function.Consumer<Arena> consumer) {
|
||||||
@@ -500,7 +533,7 @@ public class BlockPartyCommand implements CommandExecutor, TabCompleter {
|
|||||||
@Override
|
@Override
|
||||||
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
|
||||||
if (args.length == 1) {
|
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")
|
"setlobby", "setspawn", "setspectator", "setbillboard", "delbillboard", "billboards", "pos1", "pos2", "setfloor", "generate", "info", "list", "reload")
|
||||||
.stream().filter(s -> s.startsWith(args[0].toLowerCase())).collect(Collectors.toList());
|
.stream().filter(s -> s.startsWith(args[0].toLowerCase())).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,71 @@ public class ConfigManager {
|
|||||||
return config.getBoolean("floor.shuffle-each-round", true);
|
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() {
|
public boolean isMusicEnabled() {
|
||||||
return config.getBoolean("music.enabled", false);
|
return config.getBoolean("music.enabled", false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,23 +90,40 @@ public class FloorManager {
|
|||||||
return new ArrayList<>(layout.values());
|
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<String> layoutKeys() {
|
||||||
|
return new ArrayList<>(layout.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Randomly permutes which material sits at which position, without changing the set of
|
* Reassigns every position's material at random, biased so that only {@code targetFraction}
|
||||||
* positions or how many blocks of each material exist (so target-color counts/fairness
|
* of the floor ends up as {@code target} — the rest is spread evenly across the other colors
|
||||||
* from {@link #generate} are preserved) — used so the board looks different each round
|
* already present in the layout. Used so the board looks different each round instead of
|
||||||
* instead of always restoring to the exact same static pattern. Purely in-memory: the
|
* always restoring to the exact same static pattern, and so later rounds (a smaller
|
||||||
* arena's persisted {@code generated-layout} is left untouched, so a restart or
|
* {@code targetFraction}) make the target color progressively rarer and harder to spot
|
||||||
* `/bp generate` still starts from the original saved layout.
|
* 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) {
|
if (layout.size() < 2) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<Material> materials = new ArrayList<>(layout.values());
|
List<Material> otherPalette = layout.values().stream().distinct()
|
||||||
Collections.shuffle(materials, random);
|
.filter(m -> m != target).toList();
|
||||||
List<String> keys = new ArrayList<>(layout.keySet());
|
List<String> keys = new ArrayList<>(layout.keySet());
|
||||||
for (int i = 0; i < keys.size(); i++) {
|
Collections.shuffle(keys, random);
|
||||||
layout.put(keys.get(i), materials.get(i));
|
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);
|
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) {
|
public Material materialAtLocation(World world, double x, double y, double z) {
|
||||||
// check the block directly below the player's feet
|
// check the block directly below the player's feet
|
||||||
int bx = (int) Math.floor(x);
|
int bx = (int) Math.floor(x);
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,18 +10,21 @@ public class PlayerStats {
|
|||||||
private int eliminations;
|
private int eliminations;
|
||||||
private int bestRound;
|
private int bestRound;
|
||||||
private int totalRoundsSurvived;
|
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.uuid = uuid;
|
||||||
this.gamesPlayed = gamesPlayed;
|
this.gamesPlayed = gamesPlayed;
|
||||||
this.wins = wins;
|
this.wins = wins;
|
||||||
this.eliminations = eliminations;
|
this.eliminations = eliminations;
|
||||||
this.bestRound = bestRound;
|
this.bestRound = bestRound;
|
||||||
this.totalRoundsSurvived = totalRoundsSurvived;
|
this.totalRoundsSurvived = totalRoundsSurvived;
|
||||||
|
this.powerupsUsed = powerupsUsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static PlayerStats empty(UUID uuid) {
|
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() {
|
public UUID getUuid() {
|
||||||
@@ -47,4 +50,8 @@ public class PlayerStats {
|
|||||||
public int getTotalRoundsSurvived() {
|
public int getTotalRoundsSurvived() {
|
||||||
return totalRoundsSurvived;
|
return totalRoundsSurvived;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getPowerupsUsed() {
|
||||||
|
return powerupsUsed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public class StatsDatabase {
|
|||||||
try (Statement st = connection.createStatement()) {
|
try (Statement st = connection.createStatement()) {
|
||||||
st.execute(createTableStatement());
|
st.execute(createTableStatement());
|
||||||
}
|
}
|
||||||
|
addPowerupsUsedColumnIfMissing();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
plugin.getLogger().log(Level.SEVERE, "Failed to initialize " +
|
plugin.getLogger().log(Level.SEVERE, "Failed to initialize " +
|
||||||
(settings.isMysql() ? "MySQL" : "SQLite") + " stats database", ex);
|
(settings.isMysql() ? "MySQL" : "SQLite") + " stats database", ex);
|
||||||
@@ -96,7 +97,8 @@ public class StatsDatabase {
|
|||||||
"wins INT NOT NULL DEFAULT 0," +
|
"wins INT NOT NULL DEFAULT 0," +
|
||||||
"eliminations INT NOT NULL DEFAULT 0," +
|
"eliminations INT NOT NULL DEFAULT 0," +
|
||||||
"best_round 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 + " (" +
|
return "CREATE TABLE IF NOT EXISTS " + table + " (" +
|
||||||
@@ -105,10 +107,22 @@ public class StatsDatabase {
|
|||||||
"wins INTEGER NOT NULL DEFAULT 0," +
|
"wins INTEGER NOT NULL DEFAULT 0," +
|
||||||
"eliminations INTEGER NOT NULL DEFAULT 0," +
|
"eliminations INTEGER NOT NULL DEFAULT 0," +
|
||||||
"best_round 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() {
|
public synchronized void close() {
|
||||||
try {
|
try {
|
||||||
if (connection != null && !connection.isClosed()) {
|
if (connection != null && !connection.isClosed()) {
|
||||||
@@ -135,11 +149,11 @@ public class StatsDatabase {
|
|||||||
try {
|
try {
|
||||||
ensureRow(uuid.toString());
|
ensureRow(uuid.toString());
|
||||||
try (PreparedStatement ps = connection.prepareStatement(
|
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());
|
ps.setString(1, uuid.toString());
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (rs.next()) {
|
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");
|
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) {
|
public synchronized void recordRoundReached(UUID uuid, int round) {
|
||||||
if (connection == null) {
|
if (connection == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ public class StatsManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void recordPowerupUse(UUID uuid) {
|
||||||
|
runAsync(() -> database.incrementPowerupsUsed(uuid));
|
||||||
|
}
|
||||||
|
|
||||||
public CompletableFuture<PlayerStats> getStats(UUID uuid) {
|
public CompletableFuture<PlayerStats> getStats(UUID uuid) {
|
||||||
CompletableFuture<PlayerStats> future = new CompletableFuture<>();
|
CompletableFuture<PlayerStats> future = new CompletableFuture<>();
|
||||||
plugin.getServer().getAsyncScheduler().runNow(plugin, task -> future.complete(database.getStats(uuid)));
|
plugin.getServer().getAsyncScheduler().runNow(plugin, task -> future.complete(database.getStats(uuid)));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, ActivePowerup> 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<String> expired = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, ActivePowerup> 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<String> 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,41 @@ floor:
|
|||||||
# (from /bp generate) is never modified by shuffling.
|
# (from /bp generate) is never modified by shuffling.
|
||||||
shuffle-each-round: true
|
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
|
# example/default timings used when creating a brand-new arena via /blockparty create
|
||||||
defaults:
|
defaults:
|
||||||
countdown-duration: 15
|
countdown-duration: 15
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ admin:
|
|||||||
|
|
||||||
player:
|
player:
|
||||||
joined: "<green>You joined arena '%arena%'.</green>"
|
joined: "<green>You joined arena '%arena%'.</green>"
|
||||||
|
spectating: "<green>You are now spectating arena '%arena%'.</green>"
|
||||||
left: "<yellow>You left the arena.</yellow>"
|
left: "<yellow>You left the arena.</yellow>"
|
||||||
musicvote-code: "<green>Your music vote code: <bold>%code%</bold></green> <gray>(valid %minutes% minutes) - enter it on the jukebox voting page to pick the next track.</gray>"
|
musicvote-code: "<green>Your music vote code: <bold>%code%</bold></green> <gray>(valid %minutes% minutes) - enter it on the jukebox voting page to pick the next track.</gray>"
|
||||||
|
|
||||||
@@ -52,6 +53,11 @@ game:
|
|||||||
win-title: "<gold><bold>%player% wins!</bold></gold>"
|
win-title: "<gold><bold>%player% wins!</bold></gold>"
|
||||||
no-winner: "<gray>The match ended with no winner.</gray>"
|
no-winner: "<gray>The match ended with no winner.</gray>"
|
||||||
|
|
||||||
|
powerup:
|
||||||
|
spawned: "<aqua>A %type% powerup appeared on the floor!</aqua>"
|
||||||
|
claimed-self: "<green>You claimed <bold>%type%</bold>!</green>"
|
||||||
|
claimed-other: "<yellow>%player% claimed a %type% powerup!</yellow>"
|
||||||
|
|
||||||
scoreboard:
|
scoreboard:
|
||||||
title: "<gold><bold>BlockParty</bold></gold>"
|
title: "<gold><bold>BlockParty</bold></gold>"
|
||||||
lines:
|
lines:
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user