fix all migrate 80%

This commit is contained in:
loki 2026-03-07 12:34:59 +01:00
parent d1dcf6ab57
commit 574f9fc4b5
21 changed files with 1054 additions and 1154 deletions

View file

@ -57,30 +57,32 @@ public final class GenerationOptions {
static void init() {
// Jump validation
JUMP_VALIDATION_ENABLED = Config.CONFIG.getBoolean("jump-validation.enabled");
MAX_JUMP_DISTANCE = Config.CONFIG.getDouble("jump-validation.max-distance");
MAX_HORIZONTAL_DISTANCE = Config.CONFIG.getDouble("jump-validation.max-horizontal");
MAX_VERTICAL_UP = Config.CONFIG.getDouble("jump-validation.max-vertical-up");
MAX_VERTICAL_DOWN = Config.CONFIG.getDouble("jump-validation.max-vertical-down");
JUMP_VALIDATION_ENABLED = Config.CONFIG.isPath("jump-validation.enabled") && Config.CONFIG.getBoolean("jump-validation.enabled");
MAX_JUMP_DISTANCE = Config.CONFIG.isPath("jump-validation.max-distance") ? Config.CONFIG.getDouble("jump-validation.max-distance") : 5.0;
MAX_HORIZONTAL_DISTANCE = Config.CONFIG.isPath("jump-validation.max-horizontal") ? Config.CONFIG.getDouble("jump-validation.max-horizontal") : 4.0;
MAX_VERTICAL_UP = Config.CONFIG.isPath("jump-validation.max-vertical-up") ? Config.CONFIG.getDouble("jump-validation.max-vertical-up") : 1.0;
MAX_VERTICAL_DOWN = Config.CONFIG.isPath("jump-validation.max-vertical-down") ? Config.CONFIG.getDouble("jump-validation.max-vertical-down") : 3.0;
// Jump types
JUMP_TYPES_ENABLED = Config.CONFIG.getBoolean("jump-types.enabled");
JUMP_TYPES_ENABLED = Config.CONFIG.isPath("jump-types.enabled") && Config.CONFIG.getBoolean("jump-types.enabled");
JUMP_TYPE_ENABLED = new java.util.HashMap<>();
JUMP_TYPE_CHANCE = new java.util.HashMap<>();
for (String type : Config.CONFIG.getChildren("jump-types.types")) {
String path = "jump-types.types." + type;
JUMP_TYPE_ENABLED.put(type, Config.CONFIG.getBoolean(path + ".enabled"));
JUMP_TYPE_CHANCE.put(type, Config.CONFIG.getDouble(path + ".chance"));
if (Config.CONFIG.isPath("jump-types.types")) {
for (String type : Config.CONFIG.getChildren("jump-types.types")) {
String path = "jump-types.types." + type;
JUMP_TYPE_ENABLED.put(type, Config.CONFIG.isPath(path + ".enabled") && Config.CONFIG.getBoolean(path + ".enabled"));
JUMP_TYPE_CHANCE.put(type, Config.CONFIG.isPath(path + ".chance") ? Config.CONFIG.getDouble(path + ".chance") : 0.0);
}
}
// Memory
BLOCK_CLEANUP_DISTANCE = Config.CONFIG.getInt("memory.block-cleanup-distance");
CLEANUP_INTERVAL = Config.CONFIG.getInt("memory.cleanup-interval");
BLOCK_CLEANUP_DISTANCE = Config.CONFIG.isPath("memory.block-cleanup-distance") ? Config.CONFIG.getInt("memory.block-cleanup-distance") : 100;
CLEANUP_INTERVAL = Config.CONFIG.isPath("memory.cleanup-interval") ? Config.CONFIG.getInt("memory.cleanup-interval") : 100;
// Ghost
GHOST_MODE_ENABLED = Config.CONFIG.getBoolean("ghost-mode.enabled");
GHOST_SHOW_TOP = Config.CONFIG.getInt("ghost-mode.show-top");
GHOST_TRANSPARENCY = Config.CONFIG.getDouble("ghost-mode.transparency");
GHOST_MODE_ENABLED = Config.CONFIG.isPath("ghost-mode.enabled") && Config.CONFIG.getBoolean("ghost-mode.enabled");
GHOST_SHOW_TOP = Config.CONFIG.isPath("ghost-mode.show-top") ? Config.CONFIG.getInt("ghost-mode.show-top") : 3;
GHOST_TRANSPARENCY = Config.CONFIG.isPath("ghost-mode.transparency") ? Config.CONFIG.getDouble("ghost-mode.transparency") : 0.5;
// Generation chances
TYPE_NORMAL = Config.GENERATION.getInt("generation.type.normal") / 100.0;

View file

@ -45,6 +45,12 @@ public class Option {
/** @see SqlOptions#SQL */
public static boolean SQL;
public static String SQL_URL;
public static int SQL_PORT;
public static String SQL_DB;
public static String SQL_USERNAME;
public static String SQL_PASSWORD;
public static String SQL_PREFIX;
/** @see ParticleOptions#SHAPE */
public static ParticleShape PARTICLE_SHAPE;
@ -87,7 +93,13 @@ public class Option {
/** Copy sub-class fields to this class so existing code still compiles. */
private static void syncForwardedFields() {
SQL = SqlOptions.SQL;
SQL = SqlOptions.SQL;
SQL_URL = SqlOptions.URL;
SQL_PORT = SqlOptions.PORT;
SQL_DB = SqlOptions.DB;
SQL_USERNAME = SqlOptions.USERNAME;
SQL_PASSWORD = SqlOptions.PASSWORD;
SQL_PREFIX = SqlOptions.PREFIX;
PARTICLE_SHAPE = ParticleOptions.SHAPE;
SOUND_TYPE = ParticleOptions.SOUND_TYPE;

View file

@ -40,13 +40,13 @@ public class BlockPlacer {
/** Generates one block or schematic ahead. */
public void generate() {
if (g.waitForSchematicCompletion) return;
if (g.state.waitForSchematicCompletion) return;
Map<ParkourGenerator.BlockGenerationType, Double> chances = new HashMap<>(g.defaultChances);
if (g.schematicCooldown > 0
Map<ParkourGenerator.BlockGenerationType, Double> chances = new HashMap<>(g.state.defaultChances);
if (g.state.schematicCooldown > 0
|| g.generatorOptions.contains(GeneratorOption.DISABLE_SCHEMATICS)
|| g.profile.get("schematicDifficulty").asDouble() == 0.0
|| !g.schematicBlocks.isEmpty()) {
|| !g.state.schematicBlocks.isEmpty()) {
chances.remove(ParkourGenerator.BlockGenerationType.SCHEMATIC);
}
if (!g.profile.get("useSpecialBlocks").asBoolean()) {
@ -67,27 +67,27 @@ public class BlockPlacer {
/** Seeds the history and generates the initial lead. */
public void generateFirst(Location spawn, Location blockSpawn) {
g.playerSpawn = spawn;
g.lastStandingPlayerLocation = spawn;
g.blockSpawn = blockSpawn;
g.history.add(blockSpawn.getBlock());
g.state.playerSpawn = spawn;
g.state.lastStandingPlayerLocation = spawn;
g.state.blockSpawn = blockSpawn;
g.state.history.add(blockSpawn.getBlock());
generate(g.profile.get("blockLead").asInt());
}
/** Clears schematic blocks and resets cooldown. */
public void deleteSchematic() {
if (!g.deleteSchematic) return;
g.schematicBlocks.forEach(b -> b.setType(Material.AIR));
g.schematicBlocks.clear();
g.deleteSchematic = false;
g.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
if (!g.state.deleteSchematic) return;
g.state.schematicBlocks.forEach(b -> b.setType(Material.AIR));
g.state.schematicBlocks.clear();
g.state.deleteSchematic = false;
g.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
}
// ── Block selection ────────────────────────────────────────────────────────
protected List<Block> selectBlocks() {
int height = Probs.random(g.heightChances);
int distance = Probs.random(g.distanceChances);
int height = Probs.random(g.state.heightChances);
int distance = Probs.random(g.state.distanceChances);
return List.of(selectNext(g.getLatest(), distance, height));
}
@ -95,7 +95,7 @@ public class BlockPlacer {
JumpDirector director = new JumpDirector(
BoundingBox.of(g.zone[0], g.zone[1]), g.getLatest().getLocation().toVector());
g.heading = director.getRecommendedHeading(g.heading);
g.state.heading = director.getRecommendedHeading(g.state.heading);
height = director.getRecommendedHeight(height);
switch (g.getLatest().getType()) {
@ -107,11 +107,11 @@ public class BlockPlacer {
double sd = g.generatorOptions.contains(GeneratorOption.REDUCE_RANDOM_BLOCK_SELECTION_ANGLE) ? 0.5 : 1;
int randomOffset = new JumpOffsetGenerator(height, distance).getRandomOffset(0, sd);
Vector offset = g.heading.clone().multiply(distance + 1).setY(height);
Vector offset = g.state.heading.clone().multiply(distance + 1).setY(height);
if (offset.getX() == 0) offset.setX(randomOffset);
else offset.setZ(randomOffset);
offset.rotateAroundY(angleInY(g.heading, Option.HEADING.getDirection()));
offset.rotateAroundY(angleInY(g.state.heading, Option.HEADING.getDirection()));
return current.getLocation().add(offset).getBlock();
}
@ -137,7 +137,7 @@ public class BlockPlacer {
for (Block block : blocks) {
BlockData data = (jump == ParkourGenerator.BlockGenerationType.SPECIAL
&& !g.generatorOptions.contains(GeneratorOption.DISABLE_SPECIAL))
? Probs.random(g.specialChances)
? Probs.random(g.state.specialChances)
: selectBlockData();
if (data instanceof Fence) block = block.getLocation().subtract(0, 1, 0).getBlock();
@ -148,8 +148,8 @@ public class BlockPlacer {
new ParkourBlockGenerateEvent(placed, g, g.player).call();
g.effects.particles(placed);
g.effects.sound(placed);
g.history.addAll(placed);
g.schematicCooldown--;
g.state.history.addAll(placed);
g.state.schematicCooldown--;
}
private boolean tryGenerateSchematic() {
@ -166,14 +166,14 @@ public class BlockPlacer {
var pool = candidates.isEmpty() ? new ArrayList<>(all.values()) : candidates;
var schematic = pool.get(new java.util.Random().nextInt(pool.size()));
Location origin = g.getLatest().getLocation().add(g.heading.clone().multiply(2));
Location origin = g.getLatest().getLocation().add(g.state.heading.clone().multiply(2));
List<Block> placed = rotatedPaste(schematic, origin);
if (placed.isEmpty()) return false;
g.schematicBlocks.addAll(placed);
g.history.addAll(placed);
g.waitForSchematicCompletion = true;
g.deleteSchematic = false;
g.state.schematicBlocks.addAll(placed);
g.state.history.addAll(placed);
g.state.waitForSchematicCompletion = true;
g.state.deleteSchematic = false;
new ParkourSchematicGenerateEvent(schematic, g, g.player).call();
return true;

View file

@ -1,120 +1,7 @@
package dev.loki.loparkour.generator;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.style.Style;
import dev.loki.loparkour.util.Probs;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.Vector;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Selects next parkour blocks (SRP - Single Responsibility)
* @deprecated Logic merged into {@link BlockPlacer}. Delete this file.
*/
public class BlockSelector {
private final Profile profile;
private final Location[] zone;
private final Vector initialHeading;
private final Map<Integer, Double> heightChances = new HashMap<>();
private final Map<Integer, Double> distanceChances = new HashMap<>();
private Vector heading;
public BlockSelector(Profile profile, Location[] zone, Vector initialHeading) {
this.profile = profile;
this.zone = zone;
this.heading = initialHeading.clone();
this.initialHeading = initialHeading.clone();
calculateChances();
}
private void calculateChances() {
heightChances.put(1, Option.NORMAL_HEIGHT_1);
heightChances.put(0, Option.NORMAL_HEIGHT_0);
heightChances.put(-1, Option.NORMAL_HEIGHT_NEG1);
heightChances.put(-2, Option.NORMAL_HEIGHT_NEG2);
distanceChances.put(1, Option.NORMAL_DISTANCE_1);
distanceChances.put(2, Option.NORMAL_DISTANCE_2);
distanceChances.put(3, Option.NORMAL_DISTANCE_3);
distanceChances.put(4, Option.NORMAL_DISTANCE_4);
}
public List<Block> selectBlocks(Block latest) {
int height = Probs.random(heightChances);
int distance = Probs.random(distanceChances);
return List.of(selectNext(latest, distance, height));
}
private Block selectNext(Block current, int distance, int height) {
JumpDirector director = new JumpDirector(
BoundingBox.of(zone[0], zone[1]),
current.getLocation().toVector()
);
heading = director.getRecommendedHeading(heading);
height = director.getRecommendedHeight(height);
// Ensure special blocks are possible
switch (current.getType()) {
case SMOOTH_QUARTZ_SLAB -> height = Math.min(height, 0);
case GLASS_PANE -> distance = Math.min(distance, 3);
}
if (height > 0) {
distance = Math.max(distance - height, 1);
}
int randomOffset = new JumpOffsetGenerator(height, distance).getRandomOffset(0, 1);
Vector offset = heading.clone()
.multiply(distance + 1)
.setY(height);
if (offset.getX() == 0) {
offset.setX(randomOffset);
} else {
offset.setZ(randomOffset);
}
offset.rotateAroundY(angleInY(heading, initialHeading));
return current.getLocation().add(offset).getBlock();
}
public BlockData selectBlockData() {
Style style = Registry.getStyle(profile.get("style").value());
if (style == null) {
profile.set("style", Registry.getStyles().stream()
.findFirst()
.orElseThrow()
.getName());
return selectBlockData();
}
return style.getNext().createBlockData();
}
private double angleInY(Vector a, Vector b) {
double det = a.getX() * b.getZ() - a.getZ() * b.getX();
return Math.atan2(det, a.dot(b));
}
public Vector getHeading() {
return heading;
}
public void resetHeading() {
heading = initialHeading.clone();
}
}
@Deprecated
class BlockSelector {}

View file

@ -69,7 +69,7 @@ public final class Island {
// First parkour block should be generated FROM the parkour start block
// in the direction of heading, not AT the parkour start block
Location parkourStart = parkour.getLocation().add(session.generator.heading);
Location parkourStart = parkour.getLocation().add(session.generator.state.heading);
session.generator.generateFirst(ps, parkourStart);
session.generator.startTick();

View file

@ -1,10 +1,6 @@
package dev.loki.loparkour.generator;
package dev.loki.loparkour.generator;
import dev.lolib.scheduler.Scheduler;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.Registry;
import dev.loki.loparkour.api.event.ParkourFallEvent;
import dev.loki.loparkour.api.event.ParkourScoreEvent;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.leaderboard.Leaderboard;
@ -14,15 +10,12 @@ import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.reward.Rewards;
import dev.loki.loparkour.schematic.lpschem.LPSchematic;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.world.Divider;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@ -31,61 +24,23 @@ import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
* Coordinates parkour generation, ticking, scoring, and reset.
*
* Heavy lifting is delegated to:
* <ul>
* <li>{@link BlockPlacer} – block selection &amp; placement</li>
* <li>{@link EffectManager} – particles &amp; sounds</li>
* </ul>
*/
public class ParkourGenerator {
public static final int BLOCK_TRAIL = 2;
// ── State ──────────────────────────────────────────────────────────────────
public int score = 0;
public int totalScore = 0;
public int schematicCooldown;
public boolean stopped = false;
public Location[] zone;
public ParkourPlayer player;
public dev.lolib.scheduler.ScheduledTask task;
private dev.lolib.scheduler.ScheduledTask cleanupTask;
public Location blockSpawn;
public Location playerSpawn;
public Instant start;
public Vector heading = Option.HEADING.getDirection();
public final List<GeneratorOption> generatorOptions;
public final Session session;
public final Profile profile = new Profile();
public final Island island;
// Chance maps – populated by calculateChances()
public final Map<Integer, Double> distanceChances = new HashMap<>();
public final Map<Integer, Double> heightChances = new HashMap<>();
public final Map<BlockData, Double> specialChances = new HashMap<>();
public final Map<BlockGenerationType, Double> defaultChances = new HashMap<>();
// Schematic state
public boolean deleteSchematic = false;
public boolean waitForSchematicCompletion = false;
public List<Block> schematicBlocks = new ArrayList<>();
// History
public List<Block> history = new LinkedList<>();
public int lastPositionIndexPlayer = -1;
public Location lastStandingPlayerLocation;
// Delegates
public final GeneratorState state = new GeneratorState();
public final GeneratorLifecycle lifecycle;
public final EffectManager effects;
public final BlockPlacer placer;
// ── Constructors ───────────────────────────────────────────────────────────
public final Session session;
public final ParkourPlayer player;
public final Profile profile = new Profile();
public final Island island;
public final List<GeneratorOption> generatorOptions;
public Location[] zone;
public dev.lolib.scheduler.ScheduledTask task;
public ParkourGenerator(@NotNull Session session, @Nullable LPSchematic schematic,
GeneratorOption... generatorOptions) {
@ -94,9 +49,13 @@ public class ParkourGenerator {
this.player = session.getPlayers().get(0);
this.island = new Island(session, schematic);
this.zone = Divider.toSelection(session);
this.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
this.state.schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
this.state.heading = Option.HEADING.getDirection();
this.effects = new EffectManager(this);
this.placer = new BlockPlacer(this);
this.placer = new BlockPlacer(this);
this.lifecycle = new GeneratorLifecycle(this);
calculateChances();
}
@ -110,211 +69,100 @@ public class ParkourGenerator {
var manager = LoParkour.getSchematicManager();
if (manager == null) return null;
LPSchematic s = manager.getSchematic(name);
if (s == null) LoParkour.getPlugin().getLogger()
.warning("Island schematic '%s' not found!".formatted(name));
if (s == null) {
LoParkour.getPlugin().getLogger().warning("Island schematic '%s' not found!".formatted(name));
}
return s;
}
public void overrideProfile() {}
// ── Chances ────────────────────────────────────────────────────────────────
protected void calculateChances() {
defaultChances.clear();
defaultChances.put(BlockGenerationType.DEFAULT, Option.TYPE_NORMAL);
defaultChances.put(BlockGenerationType.SCHEMATIC, Option.TYPE_SCHEMATICS);
defaultChances.put(BlockGenerationType.SPECIAL, Option.TYPE_SPECIAL);
state.defaultChances.clear();
state.defaultChances.put(BlockGenerationType.DEFAULT, Option.TYPE_NORMAL);
state.defaultChances.put(BlockGenerationType.SCHEMATIC, Option.TYPE_SCHEMATICS);
state.defaultChances.put(BlockGenerationType.SPECIAL, Option.TYPE_SPECIAL);
heightChances.clear();
heightChances.put( 1, Option.NORMAL_HEIGHT_1);
heightChances.put( 0, Option.NORMAL_HEIGHT_0);
heightChances.put(-1, Option.NORMAL_HEIGHT_NEG1);
heightChances.put(-2, Option.NORMAL_HEIGHT_NEG2);
state.heightChances.clear();
state.heightChances.put(1, Option.NORMAL_HEIGHT_1);
state.heightChances.put(0, Option.NORMAL_HEIGHT_0);
state.heightChances.put(-1, Option.NORMAL_HEIGHT_NEG1);
state.heightChances.put(-2, Option.NORMAL_HEIGHT_NEG2);
distanceChances.clear();
distanceChances.put(1, Option.NORMAL_DISTANCE_1);
distanceChances.put(2, Option.NORMAL_DISTANCE_2);
distanceChances.put(3, Option.NORMAL_DISTANCE_3);
distanceChances.put(4, Option.NORMAL_DISTANCE_4);
state.distanceChances.clear();
state.distanceChances.put(1, Option.NORMAL_DISTANCE_1);
state.distanceChances.put(2, Option.NORMAL_DISTANCE_2);
state.distanceChances.put(3, Option.NORMAL_DISTANCE_3);
state.distanceChances.put(4, Option.NORMAL_DISTANCE_4);
specialChances.clear();
specialChances.put(Material.PACKED_ICE.createBlockData(), Option.SPECIAL_ICE);
specialChances.put(Material.SMOOTH_QUARTZ_SLAB.createBlockData("[type=bottom]"), Option.SPECIAL_SLAB);
specialChances.put(Material.GLASS_PANE.createBlockData(), Option.SPECIAL_PANE);
specialChances.put(Material.OAK_FENCE.createBlockData(), Option.SPECIAL_FENCE);
state.specialChances.clear();
state.specialChances.put(Material.PACKED_ICE.createBlockData(), Option.SPECIAL_ICE);
state.specialChances.put(Material.SMOOTH_QUARTZ_SLAB.createBlockData("[type=bottom]"), Option.SPECIAL_SLAB);
state.specialChances.put(Material.GLASS_PANE.createBlockData(), Option.SPECIAL_PANE);
state.specialChances.put(Material.OAK_FENCE.createBlockData(), Option.SPECIAL_FENCE);
}
// ── Generation (delegates) ─────────────────────────────────────────────────
public void generate() { placer.generate(); }
public void generate(int amount) { placer.generate(amount); }
public void generateFirst(Location spawn, Location block) { placer.generateFirst(spawn, block); }
public void generate() { placer.generate(); }
public void generate(int amount) { placer.generate(amount); }
public void generateFirst(Location spawn, Location block) {
placer.generateFirst(spawn, block);
}
// ── Tick ───────────────────────────────────────────────────────────────────
public void startTick() {
task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, 0, 1);
cleanupTask = Scheduler.get(LoParkour.getPlugin()).runTimer(this::cleanupDistantBlocks, 0, Option.CLEANUP_INTERVAL);
}
public void tick() {
if (stopped) { task.cancel(); if (cleanupTask != null) cleanupTask.cancel(); return; }
getPlayers().forEach(p -> {
updateVisualTime(p, p.selectedTime);
p.updateScoreboard(this);
p.player.setSaturation(20);
});
getSpectators().forEach(ParkourSpectator::update);
if (player.getLocation().getWorld() != lastStandingPlayerLocation.getWorld()) return;
if (player.getLocation().subtract(lastStandingPlayerLocation).getY() < -10) { fall(); return; }
Block below = blockBelow();
if (below == null) return;
// Schematic end block
if (schematicBlocks.contains(below) && below.getType() == Material.RED_WOOL && !deleteSchematic) {
for (int i = 0; i < profile.get("schematicDifficulty").asDouble() * 15; i++) score();
waitForSchematicCompletion = false;
schematicCooldown = Config.GENERATION.getInt("advanced.schematic-cooldown");
generate(profile.get("blockLead").asInt());
deleteSchematic = true;
return;
}
if (!history.contains(below)) return;
int idx = history.indexOf(below);
int delta = idx - lastPositionIndexPlayer;
if (delta <= 0) return;
lastStandingPlayerLocation = player.getLocation();
int lead = profile.get("blockLead").asInt();
if (history.size() - idx <= lead) generate(lead - (history.size() - idx));
lastPositionIndexPlayer = idx;
// Remove trail blocks behind player
for (int i = idx - BLOCK_TRAIL - 1; i >= idx - 4 * BLOCK_TRAIL; i--) {
if (i > 0) history.get(i).setType(Material.AIR);
}
cleanupDistantBlocks();
placer.deleteSchematic();
int pts = Config.CONFIG.getBoolean("scoring.all-points") ? delta : 1;
for (int i = 0; i < pts; i++) score();
if (start == null) start = Instant.now();
}
private @Nullable Block blockBelow() {
Location loc = player.getLocation().subtract(0, 1, 0);
Block b = loc.getBlock();
if (b.getType() == Material.AIR) {
if (loc.subtract(0, 0.5, 0).getBlock().getType() == Material.AIR) return null;
b = loc.getBlock();
}
return b;
}
private void updateVisualTime(ParkourPlayer p, int selectedTime) {
int t = 18000 + selectedTime;
if (t >= 24000) t -= 24000;
p.player.setPlayerTime(t, false);
}
// ── Score ──────────────────────────────────────────────────────────────────
protected void score() {
score++;
totalScore++;
checkRewards();
new ParkourScoreEvent(player).call();
}
private void checkRewards() {
if (!Rewards.REWARDS_ENABLED || score == 0) return;
if (Rewards.SCORE_REWARDS.containsKey(score))
Rewards.SCORE_REWARDS.get(score).forEach(r -> r.execute(player, getMode()));
int intervalScore = Config.CONFIG.getBoolean("scoring.rewards-use-total-score") ? totalScore : score;
for (int interval : Rewards.INTERVAL_REWARDS.keySet()) {
if (intervalScore % interval == 0)
Rewards.INTERVAL_REWARDS.get(interval).forEach(r -> r.execute(player, getMode()));
}
String key = Integer.toString(score);
if (Rewards.ONE_TIME_REWARDS.containsKey(score) && !player.collectedRewards.contains(key)) {
Rewards.ONE_TIME_REWARDS.get(score).forEach(r -> r.execute(player, getMode()));
player.collectedRewards.add(key);
}
}
// ── Fall / Reset ───────────────────────────────────────────────────────────
protected void fall() {
new ParkourFallEvent(player).call();
reset(true);
}
public void startTick() { lifecycle.startTick(); }
public void tick() { lifecycle.tick(); }
protected void fall() { lifecycle.fall(); }
protected void score() { lifecycle.score(); }
public void reset(boolean regenerate) {
stopped = !regenerate;
if (!regenerate && task == null)
state.stopped = !regenerate;
if (!regenerate && task == null) {
LoParkour.getPlugin().getLogger().warning("## Incomplete joining setup — report this!");
lastPositionIndexPlayer = 0;
if (!history.isEmpty()) {
history.remove(0);
history.forEach(b -> b.setType(Material.AIR, false));
history.clear();
}
waitForSchematicCompletion = false;
deleteSchematic = true;
state.lastPositionIndexPlayer = 0;
if (!state.history.isEmpty()) {
state.history.remove(0);
state.history.forEach(b -> b.setType(Material.AIR, false));
state.history.clear();
}
state.resetSchematicState();
placer.deleteSchematic();
Leaderboard lb = getMode().getLeaderboard();
int record = lb != null ? lb.get(player.getUUID()).score() : 0;
if (profile.get("showFallMessage").asBoolean()) sendFallMessage(record);
if (lb != null && score > record)
registerScore(getDetailedTime(), Double.toString(getDifficultyScore()).substring(0, 3), score);
score = 0;
start = null;
heading = Option.HEADING.getDirection();
if (profile.get("showFallMessage").asBoolean()) {
sendFallMessage(record);
}
if (lb != null && state.score > record) {
registerScore(getDetailedTime(), Double.toString(getDifficultyScore()).substring(0, 3), state.score);
}
state.resetScore();
state.heading = Option.HEADING.getDirection();
if (regenerate) {
player.teleport(playerSpawn);
generateFirst(playerSpawn, blockSpawn);
player.teleport(state.playerSpawn);
generateFirst(state.playerSpawn, state.blockSpawn);
return;
}
island.destroy();
if (getPlayers().isEmpty())
if (getPlayers().isEmpty()) {
getSpectators().forEach(s -> Modes.DEFAULT.create(s.player));
}
}
private void sendFallMessage(int record) {
String key;
int number = 0;
if (score == record) key = "settings.parkour_settings.items.fall_message.formats.tied";
else if (score > record) { key = "settings.parkour_settings.items.fall_message.formats.beat"; number = score - record; }
else { key = "settings.parkour_settings.items.fall_message.formats.miss"; number = record - score; }
if (state.score == record) {
key = "settings.parkour_settings.items.fall_message.formats.tied";
} else if (state.score > record) {
key = "settings.parkour_settings.items.fall_message.formats.beat";
number = state.score - record;
} else {
key = "settings.parkour_settings.items.fall_message.formats.miss";
number = record - state.score;
}
for (ParkourPlayer p : getPlayers()) {
p.sendTranslated("settings.parkour_settings.items.fall_message.divider");
p.sendTranslated("settings.parkour_settings.items.fall_message.score", Integer.toString(score));
p.sendTranslated("settings.parkour_settings.items.fall_message.time", getFormattedTime());
p.sendTranslated("settings.parkour_settings.items.fall_message.score", Integer.toString(state.score));
p.sendTranslated("settings.parkour_settings.items.fall_message.time", getFormattedTime());
p.sendTranslated("settings.parkour_settings.items.fall_message.high_score", Integer.toString(record));
p.sendTranslated(key, Integer.toString(number));
p.sendTranslated(key, Integer.toString(number));
p.sendTranslated("settings.parkour_settings.items.fall_message.divider");
}
}
@ -325,29 +173,8 @@ public class ParkourGenerator {
getPlayers().forEach(p -> lb.put(p.getUUID(), new Score(p.getName(), time, difficulty, score)));
}
// ── Cleanup ────────────────────────────────────────────────────────────────
protected void cleanupDistantBlocks() {
if (history.size() < Option.BLOCK_CLEANUP_DISTANCE * 2) return;
Location loc = player.getLocation();
int removed = 0;
Iterator<Block> it = history.iterator();
while (it.hasNext()) {
Block b = it.next();
if (b.getLocation().distance(loc) > Option.BLOCK_CLEANUP_DISTANCE) {
b.setType(Material.AIR, false);
it.remove();
removed++;
} else break;
}
if (removed > 0) lastPositionIndexPlayer = Math.max(0, lastPositionIndexPlayer - removed);
}
// ── Misc ───────────────────────────────────────────────────────────────────
public void menu(ParkourPlayer player) { Menus.PARKOUR_SETTINGS.open(player); }
public Block getLatest() { return history.get(history.size() - 1); }
public Block getLatest() { return state.history.get(state.history.size() - 1); }
public double getDifficultyScore() {
double s = 0;
@ -357,11 +184,16 @@ public class ParkourGenerator {
return s;
}
public String getFormattedTime() { return getTime(Config.CONFIG.getString("options.time.score-format")); }
public String getDetailedTime() { return getTime("mm:ss:SSS"); }
public String getFormattedTime() {
return getTime(Config.CONFIG.getString("options.time.score-format"));
}
public String getDetailedTime() {
return getTime("mm:ss:SSS");
}
private String getTime(String format) {
var ms = Instant.now().minusMillis(start != null ? start.toEpochMilli() : Instant.now().toEpochMilli());
var ms = Instant.now().minusMillis(state.start != null ? state.start.toEpochMilli() : Instant.now().toEpochMilli());
try {
return DateTimeFormatter.ofPattern(format).withZone(ZoneOffset.UTC).format(ms);
} catch (IllegalArgumentException ex) {
@ -371,8 +203,7 @@ public class ParkourGenerator {
}
public Mode getMode() { return Modes.DEFAULT; }
@NotNull public List<ParkourPlayer> getPlayers() { return session.getPlayers(); }
@NotNull public List<ParkourPlayer> getPlayers() { return session.getPlayers(); }
@NotNull public List<ParkourSpectator> getSpectators() { return session.getSpectators(); }
public enum BlockGenerationType { DEFAULT, SCHEMATIC, SPECIAL }

View file

@ -1,126 +1,8 @@
package dev.loki.loparkour.generator;
import dev.loki.loparkour.api.event.ParkourScoreEvent;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.reward.Rewards;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* Manages scoring and rewards (SRP - Single Responsibility)
* @deprecated Score logic lives in {@link GeneratorLifecycle}.
* This class was created but never wired in. Delete this file.
*/
public class ScoreManager {
private final ParkourPlayer player;
private final Mode mode;
private final Profile profile;
private int score = 0;
private int totalScore = 0;
private Instant start;
public ScoreManager(ParkourPlayer player, Mode mode, Profile profile) {
this.player = player;
this.mode = mode;
this.profile = profile;
}
public void addScore(int points) {
for (int i = 0; i < points; i++) {
score++;
totalScore++;
checkRewards();
new ParkourScoreEvent(player).call();
}
if (start == null) {
start = Instant.now();
}
}
private void checkRewards() {
if (!Rewards.REWARDS_ENABLED || score == 0) {
return;
}
if (Rewards.SCORE_REWARDS.containsKey(score)) {
Rewards.SCORE_REWARDS.get(score).forEach(s -> s.execute(player, mode));
}
int intervalScore = Config.CONFIG.getBoolean("scoring.rewards-use-total-score") ? totalScore : score;
for (int interval : Rewards.INTERVAL_REWARDS.keySet()) {
if (intervalScore % interval != 0) continue;
Rewards.INTERVAL_REWARDS.get(interval).forEach(s -> s.execute(player, mode));
}
if (Rewards.ONE_TIME_REWARDS.containsKey(score) &&
!player.collectedRewards.contains(Integer.toString(score))) {
Rewards.ONE_TIME_REWARDS.get(score).forEach(s -> s.execute(player, mode));
player.collectedRewards.add(Integer.toString(score));
}
}
public void reset() {
score = 0;
start = null;
}
public void registerScore(List<ParkourPlayer> players) {
Leaderboard leaderboard = mode.getLeaderboard();
if (leaderboard == null) return;
String time = getDetailedTime();
String difficulty = Double.toString(getDifficultyScore()).substring(0, Math.min(3, Double.toString(getDifficultyScore()).length()));
players.forEach(p -> leaderboard.put(p.getUUID(), new Score(p.getName(), time, difficulty, score)));
}
public String getFormattedTime() {
return getTime(Config.CONFIG.getString("options.time.score-format"));
}
public String getDetailedTime() {
return getTime("mm:ss:SSS");
}
private String getTime(String format) {
var timeMs = Instant.now().minusMillis(start != null ? start.toEpochMilli() : Instant.now().toEpochMilli());
try {
return DateTimeFormatter.ofPattern(format)
.withZone(ZoneOffset.UTC)
.format(timeMs);
} catch (IllegalArgumentException ex) {
return "";
}
}
private double getDifficultyScore() {
double score = 0;
if (profile.get("useSpecialBlocks").asBoolean()) score += 0.5;
double schematicDiff = profile.get("schematicDifficulty").asDouble();
if (schematicDiff > 0) {
if (schematicDiff <= 0.25) score += 0.2;
else if (schematicDiff <= 0.5) score += 0.3;
else if (schematicDiff <= 0.75) score += 0.4;
else score += 0.5;
}
return score;
}
public int getScore() {
return score;
}
public int getTotalScore() {
return totalScore;
}
}
@Deprecated
class ScoreManager {}

View file

@ -0,0 +1,29 @@
package dev.loki.loparkour.player;
import com.google.common.io.ByteArrayDataOutput;
import com.google.common.io.ByteStreams;
import dev.loki.loparkour.LoParkour;
import org.bukkit.entity.Player;
import org.bukkit.plugin.messaging.ChannelNotRegisteredException;
/**
* Utility for BungeeCord operations.
*
* @since 5.0.0
*/
class BungeeUtil {
public static void sendPlayerToServer(Player player, String server) {
ByteArrayDataOutput out = ByteStreams.newDataOutput();
out.writeUTF("Connect");
out.writeUTF(server);
try {
player.sendPluginMessage(LoParkour.getPlugin(), "BungeeCord", out.toByteArray());
} catch (ChannelNotRegisteredException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error while trying to send %s to server %s. - %s".formatted(player.getName(), server, ex.getMessage()));
player.kickPlayer("Couldn't move you to %s. Please rejoin.".formatted(server));
}
}
}

View file

@ -36,33 +36,7 @@ import java.util.function.BiConsumer;
*/
public class ParkourPlayer extends ParkourUser {
public static final Map<String, OptionContainer> PLAYER_COLUMNS = new HashMap<>();
static {
PLAYER_COLUMNS.put("uuid", new OptionContainer(null, null));
PLAYER_COLUMNS.put("style", new OptionContainer(ParkourOption.STYLES, (player, v) -> player.style = v));
PLAYER_COLUMNS.put("blockLead", new OptionContainer(ParkourOption.LEADS, (player, v) -> player.blockLead = Integer.parseInt(v)));
PLAYER_COLUMNS.put("useParticles", new OptionContainer(ParkourOption.PARTICLES, (player, v) -> player.particles = parseBoolean(v)));
PLAYER_COLUMNS.put("useSpecial", new OptionContainer(ParkourOption.SPECIAL_BLOCKS, (player, v) -> player.useSpecialBlocks = parseBoolean(v)));
PLAYER_COLUMNS.put("showFallMsg", new OptionContainer(ParkourOption.FALL_MESSAGE, (player, v) -> player.showFallMessage = parseBoolean(v)));
PLAYER_COLUMNS.put("showScoreboard", new OptionContainer(ParkourOption.SCOREBOARD, (player, v) -> player.showScoreboard = parseBoolean(v)));
PLAYER_COLUMNS.put("selectedTime", new OptionContainer(ParkourOption.TIME, (player, v) -> player.selectedTime = Integer.parseInt(v)));
PLAYER_COLUMNS.put("collectedRewards", new OptionContainer(null, (player, v) -> {
player.collectedRewards = new ArrayList<>();
if (!v.isEmpty()) {
player.collectedRewards.addAll(Arrays.stream(v.replaceAll("[ \\[\\]]", "").split(","))
.distinct()
.toList());
}
}));
PLAYER_COLUMNS.put("locale", new OptionContainer(ParkourOption.LANG, (player, v) -> {
player._locale = v;
player.locale = v;
}));
PLAYER_COLUMNS.put("schematicDifficulty", new OptionContainer(ParkourOption.SCHEMATICS, (player, v) -> player.schematicDifficulty = Double.parseDouble(v)));
PLAYER_COLUMNS.put("sound", new OptionContainer(ParkourOption.SOUND, (player, v) -> player.sound = parseBoolean(v)));
}
public static final Map<String, OptionContainer> PLAYER_COLUMNS = PlayerSettingsManager.getColumnMappings();
public @Expose Double schematicDifficulty;
public @Expose Integer blockLead;
@ -95,9 +69,7 @@ public class ParkourPlayer extends ParkourUser {
}
private static boolean parseBoolean(String string) {
return string == null
|| string.equals("1") // for MySQL
|| string.equals("true"); // for disk
return string == null || string.equals("1") || string.equals("true");
}
/**
@ -147,21 +119,7 @@ public class ParkourPlayer extends ParkourUser {
* @param settings The settings map.
*/
public void setSettings(@NotNull Map<String, Object> settings) {
for (String key : PLAYER_COLUMNS.keySet()) {
Object value = settings.get(key);
OptionContainer container = PLAYER_COLUMNS.get(key);
if (container.consumer == null) {
continue;
}
if (value == null || !Option.OPTIONS_ENABLED.getOrDefault(container.option, true)) {
container.consumer.accept(this, Option.OPTIONS_DEFAULTS.getOrDefault(container.option, ""));
continue;
}
container.consumer.accept(this, String.valueOf(value));
}
PlayerSettingsManager.applySettings(this, settings);
}
/**
@ -227,7 +185,6 @@ public class ParkourPlayer extends ParkourUser {
}
public record OptionContainer(ParkourOption option, BiConsumer<ParkourPlayer, String> consumer) {
}
private List<Integer> getEvenlyDistributedSlots(int count) {

View file

@ -1,43 +1,25 @@
package dev.loki.loparkour.player;
import com.google.common.io.ByteArrayDataOutput;
import com.google.common.io.ByteStreams;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.api.event.ParkourJoinEvent;
import dev.loki.loparkour.api.event.ParkourLeaveEvent;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.hook.FloodgateHook;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.menu.ParkourOption;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.player.data.PreviousData;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.storage.Storage;
import dev.loki.loparkour.world.Divider;
import dev.loki.loparkour.util.ColorUtil;
import io.papermc.lib.PaperLib;
import me.clip.placeholderapi.PlaceholderAPI;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.plugin.messaging.ChannelNotRegisteredException;
import org.bukkit.scoreboard.DisplaySlot;
import org.bukkit.scoreboard.Objective;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.Team;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Superclass of every type of player. This encompasses every player currently in the Parkour world.
@ -48,100 +30,31 @@ import java.util.stream.Collectors;
public abstract class ParkourUser {
public static @NotNull ParkourPlayer register(@NotNull Player player, @NotNull Session session) {
PreviousData data = null;
ParkourUser existing = getUser(player);
if (existing != null) {
data = existing.previousData;
unregister(existing, false, false, false);
} else {
}
ParkourPlayer pp = new ParkourPlayer(player, session, data);
joinCount++;
new ParkourJoinEvent(pp).call();
Storage.readPlayer(pp);
return pp;
return UserRegistry.register(player, session);
}
public static void leave(@NotNull Player player) {
ParkourUser user = getUser(player);
if (user == null) return;
leave(user);
UserRegistry.leave(player);
}
public static void leave(@NotNull ParkourUser user) {
unregister(user, true, true, false);
UserRegistry.leave(user);
}
public static void unregister(@NotNull ParkourUser user, boolean restorePreviousData, boolean kickIfBungee, boolean urgent) {
new ParkourLeaveEvent(user).call();
try {
user.unregister();
// Reset scoreboard to main
if (user.board != null) {
user.player.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard());
user.board = null;
}
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().log(java.util.logging.Level.SEVERE,
"Error while trying to make player " + user.getName() + " leave", ex);
user.send("<red><bold>There was an error while trying to handle leaving.");
}
if (restorePreviousData && Config.CONFIG.getBoolean("bungeecord.enabled") && kickIfBungee) {
sendPlayerToServer(user.player, Config.CONFIG.getString("bungeecord.return_server"));
return;
}
if (!restorePreviousData) return;
user.previousData.apply(user.player, urgent);
Mode mode = user.session.generator.getMode();
if (mode == null) {
LoParkour.getPlugin().getLogger().severe("Mode is null for %s".formatted(user.getName()));
mode = Modes.DEFAULT;
}
if (user instanceof ParkourPlayer player) {
Mode finalMode = mode;
user.previousData.onLeave.forEach(r -> r.execute(player, finalMode));
}
}
private static void sendPlayerToServer(Player player, String server) {
ByteArrayDataOutput out = ByteStreams.newDataOutput();
out.writeUTF("Connect");
out.writeUTF(server);
try {
player.sendPluginMessage(LoParkour.getPlugin(), "BungeeCord", out.toByteArray());
} catch (ChannelNotRegisteredException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error while trying to send %s to server %s. - %s".formatted(player.getName(), server, ex.getMessage()));
player.kickPlayer("Couldn't move you to %s. Please rejoin.".formatted(server));
}
UserRegistry.unregister(user, restorePreviousData, kickIfBungee, urgent);
}
public static boolean isUser(@Nullable Player player) {
return player != null && getUsers().stream().anyMatch(other -> other.player == player);
return UserRegistry.isUser(player);
}
public static @Nullable ParkourUser getUser(@NotNull Player player) {
return getUsers().stream()
.filter(other -> other.getUUID().equals(player.getUniqueId()))
.findAny()
.orElse(null);
return UserRegistry.getUser(player);
}
public static Set<ParkourUser> getUsers() {
return Divider.sections.keySet().stream()
.flatMap(session -> session.getUsers().stream())
.collect(Collectors.toSet());
return UserRegistry.getUsers();
}
// ─── Fields ───────────────────────────────────────────────────────────────
@ -153,7 +66,7 @@ public abstract class ParkourUser {
public final Session session;
public final Player player;
public final Instant joined;
public static int joinCount;
private ScoreboardManager scoreboardManager;
public ParkourUser(@NotNull Player player, @NotNull Session session, @Nullable PreviousData previousData) {
this.player = player;
@ -163,7 +76,8 @@ public abstract class ParkourUser {
if (Boolean.parseBoolean(Option.OPTIONS_DEFAULTS.get(ParkourOption.SCOREBOARD))) {
this.board = Bukkit.getScoreboardManager().getNewScoreboard();
// Create the sidebar objective immediately and assign to player
this.scoreboardManager = new ScoreboardManager(player, player.getUniqueId(), board, locale);
Objective obj = this.board.registerNewObjective("lp_sidebar", "dummy",
ColorUtil.color(Locales.getString(locale, "scoreboard.title")));
obj.setDisplaySlot(DisplaySlot.SIDEBAR);
@ -187,68 +101,11 @@ public abstract class ParkourUser {
/**
* Updates the sidebar scoreboard for the given generator state.
* Uses vanilla Bukkit scoreboard API — Team prefix trick for coloured lines.
*/
public void updateScoreboard(ParkourGenerator generator) {
if (board == null || !generator.profile.get("showScoreboard").asBoolean()) {
return;
if (scoreboardManager != null) {
scoreboardManager.update(generator);
}
Leaderboard leaderboard = generator.getMode().getLeaderboard();
Score top = leaderboard == null ? new Score("?", "?", "?", 0) : leaderboard.getScoreAtRank(1);
Score high = leaderboard == null ? new Score("?", "?", "?", 0) : leaderboard.get(getUUID());
if (top == null) top = new Score("?", "?", "?", 0);
// Update title
Objective obj = board.getObjective("lp_sidebar");
if (obj == null) {
obj = board.registerNewObjective("lp_sidebar", "dummy",
ColorUtil.color(replace(Locales.getString(locale, "scoreboard.title"), top, high, generator)));
obj.setDisplaySlot(DisplaySlot.SIDEBAR);
} else {
obj.setDisplayName(ColorUtil.color(
replace(Locales.getString(locale, "scoreboard.title"), top, high, generator)));
}
// Update lines using Team prefix trick (each line = unique entry).
// We need globally-unique entries: use a combination of colour codes that gives
// up to 32 unique strings (§0§0, §0§1, … §1§0 …). This safely covers any
// reasonable scoreboard length.
List<String> lines = replace(Locales.getStringList(locale, "scoreboard.lines"), top, high, generator);
String[] codes = {"0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f"};
for (int i = 0; i < lines.size(); i++) {
String entry = "§" + codes[i / codes.length % codes.length] + "§" + codes[i % codes.length];
String teamName = "lp_line_" + i;
Team team = board.getTeam(teamName);
if (team == null) {
team = board.registerNewTeam(teamName);
team.addEntry(entry);
}
team.setPrefix(ColorUtil.color(lines.get(i)));
obj.getScore(entry).setScore(lines.size() - i);
}
}
private List<String> replace(List<String> s, Score top, Score high, ParkourGenerator generator) {
return s.stream().map(line -> replace(line, top, high, generator)).toList();
}
private String replace(String s, Score top, Score high, ParkourGenerator generator) {
return ColorUtil.color(translate(player, s)
.replace("%score%", Integer.toString(generator.score))
.replace("%time%", generator.getFormattedTime())
.replace("%difficulty%", Double.toString(generator.getDifficultyScore()))
.replace("%top_score%", Integer.toString(top.score()))
.replace("%top_player%", top.name())
.replace("%top_time%", top.time())
.replace("%high_score%", Integer.toString(high.score()))
.replace("%high_score_time%", high.time()));
}
private String translate(Player player, String string) {
return LoParkour.getPlaceholderHook() == null ? string : PlaceholderAPI.setPlaceholders(player, string);
}
public UUID getUUID() { return player.getUniqueId(); }

View file

@ -0,0 +1,82 @@
package dev.loki.loparkour.player;
import dev.loki.loparkour.config.Option;
import dev.loki.loparkour.menu.ParkourOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiConsumer;
/**
* Manages player settings and their mapping to database columns.
*
* @since 5.0.0
*/
class PlayerSettingsManager {
private static final Map<String, OptionContainer> COLUMN_MAPPINGS = new HashMap<>();
static {
initializeColumnMappings();
}
private static void initializeColumnMappings() {
COLUMN_MAPPINGS.put("uuid", new OptionContainer(null, null));
COLUMN_MAPPINGS.put("style", new OptionContainer(ParkourOption.STYLES, (p, v) -> p.style = v));
COLUMN_MAPPINGS.put("blockLead", new OptionContainer(ParkourOption.LEADS, (p, v) -> p.blockLead = Integer.parseInt(v)));
COLUMN_MAPPINGS.put("useParticles", new OptionContainer(ParkourOption.PARTICLES, (p, v) -> p.particles = parseBoolean(v)));
COLUMN_MAPPINGS.put("useSpecial", new OptionContainer(ParkourOption.SPECIAL_BLOCKS, (p, v) -> p.useSpecialBlocks = parseBoolean(v)));
COLUMN_MAPPINGS.put("showFallMsg", new OptionContainer(ParkourOption.FALL_MESSAGE, (p, v) -> p.showFallMessage = parseBoolean(v)));
COLUMN_MAPPINGS.put("showScoreboard", new OptionContainer(ParkourOption.SCOREBOARD, (p, v) -> p.showScoreboard = parseBoolean(v)));
COLUMN_MAPPINGS.put("selectedTime", new OptionContainer(ParkourOption.TIME, (p, v) -> p.selectedTime = Integer.parseInt(v)));
COLUMN_MAPPINGS.put("collectedRewards", new OptionContainer(null, PlayerSettingsManager::applyCollectedRewards));
COLUMN_MAPPINGS.put("locale", new OptionContainer(ParkourOption.LANG, (p, v) -> {
p._locale = v;
p.locale = v;
}));
COLUMN_MAPPINGS.put("schematicDifficulty", new OptionContainer(ParkourOption.SCHEMATICS, (p, v) -> p.schematicDifficulty = Double.parseDouble(v)));
COLUMN_MAPPINGS.put("sound", new OptionContainer(ParkourOption.SOUND, (p, v) -> p.sound = parseBoolean(v)));
}
public static Map<String, OptionContainer> getColumnMappings() {
return COLUMN_MAPPINGS;
}
public static void applySettings(ParkourPlayer player, Map<String, Object> settings) {
for (String key : COLUMN_MAPPINGS.keySet()) {
Object value = settings.get(key);
OptionContainer container = COLUMN_MAPPINGS.get(key);
if (container.consumer == null) {
continue;
}
if (value == null || !Option.OPTIONS_ENABLED.getOrDefault(container.option, true)) {
String defaultValue = Option.OPTIONS_DEFAULTS.getOrDefault(container.option, "");
container.consumer.accept(player, defaultValue);
continue;
}
container.consumer.accept(player, String.valueOf(value));
}
}
private static boolean parseBoolean(String string) {
return string == null || string.equals("1") || string.equals("true");
}
private static void applyCollectedRewards(ParkourPlayer player, String value) {
player.collectedRewards = new ArrayList<>();
if (!value.isEmpty()) {
player.collectedRewards.addAll(Arrays.stream(value.replaceAll("[ \\[\\]]", "").split(","))
.distinct()
.toList());
}
}
public record OptionContainer(ParkourOption option, BiConsumer<ParkourPlayer, String> consumer) {
}
}

View file

@ -0,0 +1,132 @@
package dev.loki.loparkour.player;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.generator.ParkourGenerator;
import dev.loki.loparkour.leaderboard.Leaderboard;
import dev.loki.loparkour.leaderboard.Score;
import dev.loki.loparkour.util.ColorUtil;
import me.clip.placeholderapi.PlaceholderAPI;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.DisplaySlot;
import org.bukkit.scoreboard.Objective;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.Team;
import java.util.List;
import java.util.UUID;
/**
* Manages scoreboard display for parkour users.
*
* @since 5.0.0
*/
class ScoreboardManager {
private static final String[] COLOR_CODES = {
"0", "1", "2", "3", "4", "5", "6", "7",
"8", "9", "a", "b", "c", "d", "e", "f"
};
private final Player player;
private final UUID playerUUID;
private final Scoreboard board;
private final String locale;
public ScoreboardManager(Player player, UUID playerUUID, Scoreboard board, String locale) {
this.player = player;
this.playerUUID = playerUUID;
this.board = board;
this.locale = locale;
}
public void update(ParkourGenerator generator) {
if (board == null || !generator.profile.get("showScoreboard").asBoolean()) {
return;
}
Leaderboard leaderboard = generator.getMode().getLeaderboard();
Score top = getTopScore(leaderboard);
Score high = getHighScore(leaderboard);
updateTitle(top, high, generator);
updateLines(top, high, generator);
}
private Score getTopScore(Leaderboard leaderboard) {
if (leaderboard == null) return createDefaultScore();
Score score = leaderboard.getScoreAtRank(1);
return score != null ? score : createDefaultScore();
}
private Score getHighScore(Leaderboard leaderboard) {
if (leaderboard == null) return createDefaultScore();
Score score = leaderboard.get(playerUUID);
return score != null ? score : createDefaultScore();
}
private Score createDefaultScore() {
return new Score("?", "?", "?", 0);
}
private void updateTitle(Score top, Score high, ParkourGenerator generator) {
Objective obj = board.getObjective("lp_sidebar");
if (obj == null) {
obj = board.registerNewObjective("lp_sidebar", "dummy",
ColorUtil.color(replacePlaceholders(Locales.getString(locale, "scoreboard.title"), top, high, generator)));
obj.setDisplaySlot(DisplaySlot.SIDEBAR);
} else {
obj.setDisplayName(ColorUtil.color(
replacePlaceholders(Locales.getString(locale, "scoreboard.title"), top, high, generator)));
}
}
private void updateLines(Score top, Score high, ParkourGenerator generator) {
Objective obj = board.getObjective("lp_sidebar");
if (obj == null) return;
List<String> lines = Locales.getStringList(locale, "scoreboard.lines").stream()
.map(line -> replacePlaceholders(line, top, high, generator))
.toList();
for (int i = 0; i < lines.size(); i++) {
String entry = createUniqueEntry(i);
String teamName = "lp_line_" + i;
Team team = board.getTeam(teamName);
if (team == null) {
team = board.registerNewTeam(teamName);
team.addEntry(entry);
}
team.setPrefix(ColorUtil.color(lines.get(i)));
obj.getScore(entry).setScore(lines.size() - i);
}
}
private String createUniqueEntry(int index) {
return "§" + COLOR_CODES[index / COLOR_CODES.length % COLOR_CODES.length] +
"§" + COLOR_CODES[index % COLOR_CODES.length];
}
private String replacePlaceholders(String text, Score top, Score high, ParkourGenerator generator) {
String replaced = text
.replace("%score%", Integer.toString(generator.state.score))
.replace("%time%", generator.getFormattedTime())
.replace("%difficulty%", Double.toString(generator.getDifficultyScore()))
.replace("%top_score%", Integer.toString(top.score()))
.replace("%top_player%", top.name())
.replace("%top_time%", top.time())
.replace("%high_score%", Integer.toString(high.score()))
.replace("%high_score_time%", high.time());
return applyPlaceholderAPI(replaced);
}
private String applyPlaceholderAPI(String text) {
try {
return PlaceholderAPI.setPlaceholders(player, text);
} catch (Exception e) {
return text;
}
}
}

View file

@ -0,0 +1,128 @@
package dev.loki.loparkour.player;
import dev.loki.loparkour.api.event.ParkourJoinEvent;
import dev.loki.loparkour.api.event.ParkourLeaveEvent;
import dev.loki.loparkour.config.Config;
import dev.loki.loparkour.mode.Mode;
import dev.loki.loparkour.mode.Modes;
import dev.loki.loparkour.player.data.PreviousData;
import dev.loki.loparkour.session.Session;
import dev.loki.loparkour.storage.Storage;
import dev.loki.loparkour.world.Divider;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Manages registration and unregistration of parkour users.
*
* @since 5.0.0
*/
public class UserRegistry {
private static int joinCount = 0;
public static @NotNull ParkourPlayer register(@NotNull Player player, @NotNull Session session) {
PreviousData data = null;
ParkourUser existing = getUser(player);
if (existing != null) {
data = existing.previousData;
unregister(existing, false, false, false);
}
ParkourPlayer pp = new ParkourPlayer(player, session, data);
joinCount++;
new ParkourJoinEvent(pp).call();
Storage.readPlayer(pp);
return pp;
}
public static void leave(@NotNull Player player) {
ParkourUser user = getUser(player);
if (user != null) {
leave(user);
}
}
public static void leave(@NotNull ParkourUser user) {
unregister(user, true, true, false);
}
public static void unregister(@NotNull ParkourUser user, boolean restorePreviousData, boolean kickIfBungee, boolean urgent) {
new ParkourLeaveEvent(user).call();
try {
user.unregister();
resetScoreboard(user);
} catch (Exception ex) {
handleUnregisterError(user, ex);
}
if (restorePreviousData && shouldKickToBungee(kickIfBungee)) {
BungeeUtil.sendPlayerToServer(user.player, Config.CONFIG.getString("bungeecord.return_server"));
return;
}
if (restorePreviousData) {
restorePreviousState(user, urgent);
}
}
public static boolean isUser(@Nullable Player player) {
return player != null && getUsers().stream().anyMatch(other -> other.player == player);
}
public static @Nullable ParkourUser getUser(@NotNull Player player) {
return getUsers().stream()
.filter(other -> other.getUUID().equals(player.getUniqueId()))
.findAny()
.orElse(null);
}
public static Set<ParkourUser> getUsers() {
return Divider.sections.keySet().stream()
.flatMap(session -> session.getUsers().stream())
.collect(Collectors.toSet());
}
public static int getJoinCount() {
return joinCount;
}
private static void resetScoreboard(ParkourUser user) {
if (user.board != null) {
user.player.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard());
user.board = null;
}
}
private static void handleUnregisterError(ParkourUser user, Exception ex) {
user.player.getServer().getLogger().log(java.util.logging.Level.SEVERE,
"Error while trying to make player " + user.getName() + " leave", ex);
user.send("<red><bold>There was an error while trying to handle leaving.");
}
private static boolean shouldKickToBungee(boolean kickIfBungee) {
return Config.CONFIG.getBoolean("bungeecord.enabled") && kickIfBungee;
}
private static void restorePreviousState(ParkourUser user, boolean urgent) {
user.previousData.apply(user.player, urgent);
Mode mode = user.session.generator.getMode();
if (mode == null) {
mode = Modes.DEFAULT;
}
if (user instanceof ParkourPlayer player) {
Mode finalMode = mode;
user.previousData.onLeave.forEach(r -> r.execute(player, finalMode));
}
}
}

View file

@ -42,14 +42,9 @@ public class Session {
private Visibility visibility = Visibility.PUBLIC;
/**
* List of muted users.
* Manages users (players and spectators) in this session.
*/
private final List<ParkourUser> muted = new ArrayList<>();
/**
* List of users.
*/
private final Map<UUID, ParkourUser> users = new HashMap<>();
private final SessionUserManager userManager = new SessionUserManager(this);
/**
* Function that takes the current session and returns whether new players should be accepted.
@ -121,13 +116,7 @@ public class Session {
* @param toAdd The players to add.
*/
public void addPlayers(ParkourPlayer... toAdd) {
for (ParkourPlayer player : toAdd) {
for (ParkourPlayer to : getPlayers()) {
to.send(Locales.getString(player.locale, "lobby.other_join").formatted(player.getName()));
}
users.put(player.getUUID(), player);
}
userManager.addPlayers(toAdd);
}
/**
@ -136,31 +125,14 @@ public class Session {
* @param toRemove The players to remove.
*/
public void removePlayers(ParkourPlayer... toRemove) {
for (ParkourPlayer player : toRemove) {
users.remove(player.getUUID());
}
List<ParkourPlayer> players = getPlayers();
for (ParkourPlayer player : toRemove) {
for (ParkourPlayer to : players) {
to.send(Locales.getString(player.locale, "lobby.other_leave").formatted(player.getName()));
}
}
if (toRemove.length > 0 && players.isEmpty()) {
generator.reset(false);
Divider.remove(this);
}
userManager.removePlayers(toRemove);
}
/**
* @return The players.
*/
public List<ParkourPlayer> getPlayers() {
return users.values().stream()
.filter(user -> user instanceof ParkourPlayer)
.map(user -> (ParkourPlayer) user)
.toList();
return userManager.getPlayers();
}
/**
@ -169,13 +141,7 @@ public class Session {
* @param spectators The spectators to add.
*/
public void addSpectators(ParkourSpectator... spectators) {
for (ParkourSpectator spectator : spectators) {
for (ParkourPlayer player : getPlayers()) {
player.sendTranslated("play.spectator.other_join", spectator.getName());
}
users.put(spectator.getUUID(), spectator);
}
userManager.addSpectators(spectators);
}
/**
@ -184,30 +150,21 @@ public class Session {
* @param spectators The spectators to remove.
*/
public void removeSpectators(ParkourSpectator... spectators) {
for (ParkourSpectator spectator : spectators) {
for (ParkourPlayer player : getPlayers()) {
player.sendTranslated("play.spectator.other_leave", spectator.getName());
}
users.remove(spectator.getUUID());
}
userManager.removeSpectators(spectators);
}
/**
* @return The spectators.
*/
public List<ParkourSpectator> getSpectators() {
return users.values().stream()
.filter(user -> user instanceof ParkourSpectator)
.map(user -> (ParkourSpectator) user)
.toList();
return userManager.getSpectators();
}
/**
* @return The users.
*/
public List<ParkourUser> getUsers() {
return new ArrayList<>(users.values());
return userManager.getUsers();
}
/**
@ -224,9 +181,7 @@ public class Session {
* @param user The user to (un)mute.
*/
public void toggleMute(@NotNull ParkourUser user) {
if (!muted.remove(user)) {
muted.add(user);
}
userManager.toggleMute(user);
}
/**
@ -234,7 +189,15 @@ public class Session {
* @return True when the user is muted, false if not.
*/
public boolean isMuted(@NotNull ParkourUser user) {
return muted.contains(user);
return userManager.isMuted(user);
}
/**
* Called when all players have left the session.
*/
void onAllPlayersLeft() {
generator.reset(false);
Divider.remove(this);
}
/**

View file

@ -0,0 +1,112 @@
package dev.loki.loparkour.session;
import dev.loki.loparkour.config.Locales;
import dev.loki.loparkour.player.ParkourPlayer;
import dev.loki.loparkour.player.ParkourSpectator;
import dev.loki.loparkour.player.ParkourUser;
import java.util.*;
/**
* Manages users (players and spectators) within a session.
*
* @since 5.0.0
*/
class SessionUserManager {
private final Map<UUID, ParkourUser> users = new HashMap<>();
private final List<ParkourUser> muted = new ArrayList<>();
private final Session session;
public SessionUserManager(Session session) {
this.session = session;
}
public void addPlayers(ParkourPlayer... toAdd) {
for (ParkourPlayer player : toAdd) {
notifyJoin(player);
users.put(player.getUUID(), player);
}
}
public void removePlayers(ParkourPlayer... toRemove) {
for (ParkourPlayer player : toRemove) {
users.remove(player.getUUID());
}
List<ParkourPlayer> remainingPlayers = getPlayers();
notifyLeave(toRemove, remainingPlayers);
if (toRemove.length > 0 && remainingPlayers.isEmpty()) {
session.onAllPlayersLeft();
}
}
public void addSpectators(ParkourSpectator... spectators) {
for (ParkourSpectator spectator : spectators) {
notifySpectatorJoin(spectator);
users.put(spectator.getUUID(), spectator);
}
}
public void removeSpectators(ParkourSpectator... spectators) {
for (ParkourSpectator spectator : spectators) {
notifySpectatorLeave(spectator);
users.remove(spectator.getUUID());
}
}
public List<ParkourPlayer> getPlayers() {
return users.values().stream()
.filter(user -> user instanceof ParkourPlayer)
.map(user -> (ParkourPlayer) user)
.toList();
}
public List<ParkourSpectator> getSpectators() {
return users.values().stream()
.filter(user -> user instanceof ParkourSpectator)
.map(user -> (ParkourSpectator) user)
.toList();
}
public List<ParkourUser> getUsers() {
return new ArrayList<>(users.values());
}
public void toggleMute(ParkourUser user) {
if (!muted.remove(user)) {
muted.add(user);
}
}
public boolean isMuted(ParkourUser user) {
return muted.contains(user);
}
private void notifyJoin(ParkourPlayer player) {
for (ParkourPlayer to : getPlayers()) {
to.send(Locales.getString(player.locale, "lobby.other_join").formatted(player.getName()));
}
}
private void notifyLeave(ParkourPlayer[] toRemove, List<ParkourPlayer> remainingPlayers) {
for (ParkourPlayer player : toRemove) {
for (ParkourPlayer to : remainingPlayers) {
to.send(Locales.getString(player.locale, "lobby.other_leave").formatted(player.getName()));
}
}
}
private void notifySpectatorJoin(ParkourSpectator spectator) {
for (ParkourPlayer player : getPlayers()) {
player.sendTranslated("play.spectator.other_join", spectator.getName());
}
}
private void notifySpectatorLeave(ParkourSpectator spectator) {
for (ParkourPlayer player : getPlayers()) {
player.sendTranslated("play.spectator.other_leave", spectator.getName());
}
}
}

View file

@ -0,0 +1,124 @@
package dev.loki.loparkour.storage;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Option;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Manages SQL database connection lifecycle.
*
* @since 5.0.0
*/
class SQLConnectionManager {
private Connection connection;
private volatile boolean connected = false;
private final List<Runnable> onConnectCallbacks = new CopyOnWriteArrayList<>();
public boolean isConnected() {
return connected;
}
public void runWhenConnected(Runnable callback) {
if (connected) {
callback.run();
} else {
onConnectCallbacks.add(callback);
}
}
public void connect() {
try {
LoParkour.log("Connecting to MySQL...");
loadDriver();
connection = DriverManager.getConnection(
buildConnectionUrl(),
Option.SQL_USERNAME,
Option.SQL_PASSWORD);
connected = true;
LoParkour.log("Connected to MySQL");
executeCallbacks();
} catch (Exception ex) {
handleConnectionError(ex);
}
}
public void close() {
try {
if (connection != null && !connection.isClosed()) {
connection.close();
LoParkour.log("Closed connection to MySQL");
}
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error while trying to close connection to SQL database - " + ex.getMessage());
}
}
public PreparedStatement prepareStatement(String sql) {
validateConnection();
if (connection == null) return null;
try {
return connection.prepareStatement(sql);
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe(
"Error preparing statement: %s - %s".formatted(sql, ex.getMessage()));
return null;
}
}
public void validateConnection() {
try {
if (connection == null || !connection.isValid(2)) {
LoParkour.getPlugin().getLogger().warning("MySQL connection lost, attempting reconnect...");
connect();
}
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().severe("Error reconnecting to MySQL - " + ex.getMessage());
Option.SQL = false;
}
}
private void loadDriver() throws ClassNotFoundException {
try {
Class.forName("com.mysql.cj.jdbc.Driver");
} catch (ClassNotFoundException e) {
Class.forName("com.mysql.jdbc.Driver");
}
}
private String buildConnectionUrl() {
return ("jdbc:mysql://%s:%d/%s?allowPublicKeyRetrieval=true" +
"&useSSL=false&useUnicode=true&characterEncoding=utf-8" +
"&autoReconnect=true&maxReconnects=2&connectTimeout=5000&socketTimeout=5000")
.formatted(Option.SQL_URL, Option.SQL_PORT, Option.SQL_DB);
}
private void executeCallbacks() {
for (Runnable cb : onConnectCallbacks) {
try {
cb.run();
} catch (Exception e) {
LoParkour.getPlugin().getLogger().severe("Error in SQL onConnect callback: " + e.getMessage());
}
}
onConnectCallbacks.clear();
}
private void handleConnectionError(Exception ex) {
LoParkour.getPlugin().getLogger().severe(
"Could not connect to MySQL - check your SQL settings - " + ex.getMessage());
LoParkour.getPlugin().getLogger().severe("Disabling SQL storage, using local storage instead");
Option.SQL = false;
}
}

View file

@ -0,0 +1,50 @@
package dev.loki.loparkour.storage;
import dev.loki.loparkour.config.Option;
/**
* Manages database schema migrations.
*
* @since 5.0.0
*/
class SQLMigrationManager {
private final SQLQueryExecutor queryExecutor;
public SQLMigrationManager(SQLQueryExecutor queryExecutor) {
this.queryExecutor = queryExecutor;
}
public void initializeDatabase() {
createDatabase();
createOptionsTable();
runMigrations();
}
private void createDatabase() {
queryExecutor.executeUpdate("CREATE DATABASE IF NOT EXISTS `%s`;".formatted(Option.SQL_DB));
queryExecutor.executeUpdate("USE `%s`;".formatted(Option.SQL_DB));
}
private void createOptionsTable() {
queryExecutor.executeUpdate(
("CREATE TABLE IF NOT EXISTS `%soptions` " +
"(`uuid` CHAR(36) NOT NULL, `time` VARCHAR(8), `style` VARCHAR(32), `blockLead` INT, " +
"`useParticles` BOOLEAN, `useDifficulty` BOOLEAN, `useSpecial` BOOLEAN, " +
"`showFallMsg` BOOLEAN, `showScoreboard` BOOLEAN, PRIMARY KEY (`uuid`)) " +
"ENGINE = InnoDB CHARSET = utf8;").formatted(Option.SQL_PREFIX));
}
private void runMigrations() {
String prefix = Option.SQL_PREFIX;
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `time`;".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` ADD `selectedTime` INT NOT NULL;".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` ADD `collectedRewards` MEDIUMTEXT;".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` ADD `locale` VARCHAR(8);".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` ADD `schematicDifficulty` DOUBLE;".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` ADD `sound` BOOLEAN;".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useDifficulty`;".formatted(prefix));
queryExecutor.executeUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useStructure`;".formatted(prefix));
}
}

View file

@ -0,0 +1,47 @@
package dev.loki.loparkour.storage;
import dev.loki.loparkour.LoParkour;
import java.sql.PreparedStatement;
import java.sql.SQLException;
/**
* Executes SQL queries with error handling.
*
* @since 5.0.0
*/
class SQLQueryExecutor {
private final SQLConnectionManager connectionManager;
public SQLQueryExecutor(SQLConnectionManager connectionManager) {
this.connectionManager = connectionManager;
}
public void executeUpdate(String sql) {
executeUpdate(sql, false);
}
public void executeUpdateSuppressed(String sql) {
executeUpdate(sql, true);
}
private void executeUpdate(String sql, boolean suppressErrors) {
connectionManager.validateConnection();
try (PreparedStatement stmt = connectionManager.prepareStatement(sql)) {
if (stmt != null) {
stmt.executeUpdate();
}
} catch (SQLException ex) {
if (!suppressErrors) {
LoParkour.getPlugin().getLogger().severe(
"Error while sending update: %s - %s".formatted(sql, ex.getMessage()));
}
}
}
public PreparedStatement prepareStatement(String sql) {
return connectionManager.prepareStatement(sql);
}
}

View file

@ -21,32 +21,19 @@ import java.util.concurrent.CopyOnWriteArrayList;
*/
class StorageSQL {
private static Connection connection;
private static boolean initialized = false;
private static volatile boolean connected = false;
private static final SQLConnectionManager connectionManager = new SQLConnectionManager();
private static final SQLQueryExecutor queryExecutor = new SQLQueryExecutor(connectionManager);
private static final SQLMigrationManager migrationManager = new SQLMigrationManager(queryExecutor);
// Tables queued to create after connection is established
private static boolean initialized = false;
private static final List<String> pendingTableCreations = new CopyOnWriteArrayList<>();
// Callbacks to run after connection is ready (e.g. initial leaderboard reads)
private static final List<Runnable> onConnectCallbacks = new CopyOnWriteArrayList<>();
/**
* Returns true if the SQL connection is ready.
*/
public static boolean isConnected() {
return connected;
return connectionManager.isConnected();
}
/**
* Runs the given callback immediately if already connected, or defers it until connect() succeeds.
*/
public static void runWhenConnected(Runnable callback) {
if (connected) {
callback.run();
} else {
onConnectCallbacks.add(callback);
}
connectionManager.runWhenConnected(callback);
}
public static void init(String mode) {
@ -64,30 +51,20 @@ class StorageSQL {
if (!initialized) {
initialized = true;
// Queue this table for creation after connect() finishes
pendingTableCreations.add(createTableSql);
// Connect async — table creation happens inside connect() after connection is ready
Bukkit.getScheduler().runTaskAsynchronously(LoParkour.getPlugin(), StorageSQL::connect);
} else {
// Already connected (or connecting) — send directly
pendingTableCreations.add(createTableSql);
}
}
public static void close() {
try {
if (connection != null && !connection.isClosed()) {
connection.close();
LoParkour.log("Closed connection to MySQL");
}
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe("Error while trying to close connection to SQL database - " + ex.getMessage());
}
connectionManager.close();
}
public static @NotNull Map<UUID, Score> readScores(@NotNull String mode) {
String sql = "SELECT * FROM `%s`;".formatted(getTableName(mode));
try (PreparedStatement stmt = prepareStatement(sql)) {
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) {
if (stmt == null) return new HashMap<>();
try (ResultSet results = stmt.executeQuery()) {
Map<UUID, Score> scores = new HashMap<>();
@ -101,13 +78,14 @@ class StorageSQL {
return scores;
}
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe("Error while trying to read SQL data of %s - %s".formatted(mode, ex.getMessage()));
LoParkour.getPlugin().getLogger().severe(
"Error while trying to read SQL data of %s - %s".formatted(mode, ex.getMessage()));
return new HashMap<>();
}
}
public static void writeScores(@NotNull String mode, @NotNull Map<UUID, Score> scores) {
new HashMap<>(scores).forEach((uuid, score) -> sendUpdate("""
new HashMap<>(scores).forEach((uuid, score) -> queryExecutor.executeUpdate("""
INSERT INTO `%s`
(uuid, name, time, difficulty, score)
VALUES ('%s', '%s', '%s', '%s', %d)
@ -125,10 +103,16 @@ class StorageSQL {
public static void readPlayer(@NotNull ParkourPlayer player) {
String sql = "SELECT * FROM `%soptions` WHERE uuid = '%s';".formatted(Option.SQL_PREFIX, player.getUUID());
try (PreparedStatement stmt = prepareStatement(sql)) {
if (stmt == null) { player.setSettings(new HashMap<>()); return; }
try (PreparedStatement stmt = queryExecutor.prepareStatement(sql)) {
if (stmt == null) {
player.setSettings(new HashMap<>());
return;
}
try (ResultSet results = stmt.executeQuery()) {
if (!results.next()) { player.setSettings(new HashMap<>()); return; }
if (!results.next()) {
player.setSettings(new HashMap<>());
return;
}
Map<String, Object> settings = new HashMap<>();
for (String key : ParkourPlayer.PLAYER_COLUMNS.keySet()) {
@ -142,7 +126,8 @@ class StorageSQL {
player.setSettings(settings);
}
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe("Error reading SQL data of %s - %s".formatted(player.getName(), ex.getMessage()));
LoParkour.getPlugin().getLogger().severe(
"Error reading SQL data of %s - %s".formatted(player.getName(), ex.getMessage()));
}
}
@ -150,7 +135,7 @@ class StorageSQL {
DecimalFormat df = new DecimalFormat("#.######", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
String schematicDifficulty = df.format(player.schematicDifficulty);
sendUpdate("""
queryExecutor.executeUpdate("""
INSERT INTO `%soptions`
(uuid, style, blockLead, useParticles, useSpecial, showFallMsg, showScoreboard,
selectedTime, collectedRewards, locale, schematicDifficulty, sound)
@ -178,105 +163,12 @@ class StorageSQL {
}
private static void connect() {
try {
LoParkour.log("Connecting to MySQL...");
connectionManager.connect();
migrationManager.initializeDatabase();
try {
Class.forName("com.mysql.cj.jdbc.Driver");
} catch (ClassNotFoundException e) {
Class.forName("com.mysql.jdbc.Driver");
}
connection = DriverManager.getConnection(
("jdbc:mysql://%s:%d/%s?allowPublicKeyRetrieval=true" +
"&useSSL=false&useUnicode=true&characterEncoding=utf-8" +
"&autoReconnect=true&maxReconnects=2&connectTimeout=5000&socketTimeout=5000")
.formatted(Option.SQL_URL, Option.SQL_PORT, Option.SQL_DB),
Option.SQL_USERNAME, Option.SQL_PASSWORD);
sendUpdate("CREATE DATABASE IF NOT EXISTS `%s`;".formatted(Option.SQL_DB));
sendUpdate("USE `%s`;".formatted(Option.SQL_DB));
// Base options table
sendUpdate(("CREATE TABLE IF NOT EXISTS `%soptions` " +
"(`uuid` CHAR(36) NOT NULL, `time` VARCHAR(8), `style` VARCHAR(32), `blockLead` INT, " +
"`useParticles` BOOLEAN, `useDifficulty` BOOLEAN, `useSpecial` BOOLEAN, " +
"`showFallMsg` BOOLEAN, `showScoreboard` BOOLEAN, PRIMARY KEY (`uuid`)) " +
"ENGINE = InnoDB CHARSET = utf8;").formatted(Option.SQL_PREFIX));
// Migrations
sendUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `time`;".formatted(Option.SQL_PREFIX));
sendUpdateSuppressed("ALTER TABLE `%soptions` ADD `selectedTime` INT NOT NULL;".formatted(Option.SQL_PREFIX));
sendUpdateSuppressed("ALTER TABLE `%soptions` ADD `collectedRewards` MEDIUMTEXT;".formatted(Option.SQL_PREFIX));
sendUpdateSuppressed("ALTER TABLE `%soptions` ADD `locale` VARCHAR(8);".formatted(Option.SQL_PREFIX));
sendUpdateSuppressed("ALTER TABLE `%soptions` ADD `schematicDifficulty` DOUBLE;".formatted(Option.SQL_PREFIX));
sendUpdateSuppressed("ALTER TABLE `%soptions` ADD `sound` BOOLEAN;".formatted(Option.SQL_PREFIX));
sendUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useDifficulty`;".formatted(Option.SQL_PREFIX));
sendUpdateSuppressed("ALTER TABLE `%soptions` DROP COLUMN `useStructure`;".formatted(Option.SQL_PREFIX));
// Now flush all pending table creations (leaderboard tables queued before connection was ready)
for (String sql : pendingTableCreations) {
sendUpdate(sql);
}
pendingTableCreations.clear();
LoParkour.log("Connected to MySQL");
connected = true;
// Run deferred callbacks (e.g. initial leaderboard reads)
for (Runnable cb : onConnectCallbacks) {
try { cb.run(); } catch (Exception e) {
LoParkour.getPlugin().getLogger().severe("Error in SQL onConnect callback: " + e.getMessage());
}
}
onConnectCallbacks.clear();
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().severe("Could not connect to MySQL - check your SQL settings - " + ex.getMessage());
LoParkour.getPlugin().getLogger().severe("Disabling SQL storage, using local storage instead");
Option.SQL = false;
}
}
private static void validateConnection() {
try {
if (connection == null || !connection.isValid(2)) {
LoParkour.getPlugin().getLogger().warning("MySQL connection lost, attempting reconnect...");
connect();
}
} catch (Exception ex) {
LoParkour.getPlugin().getLogger().severe("Error reconnecting to MySQL - " + ex.getMessage());
Option.SQL = false;
}
}
/** Returns a PreparedStatement with proper connection validation. Caller must close it. */
private static PreparedStatement prepareStatement(String sql) {
validateConnection();
if (connection == null) return null;
try {
return connection.prepareStatement(sql);
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe("Error preparing statement: %s - %s".formatted(sql, ex.getMessage()));
return null;
}
}
private static void sendUpdate(String sql) {
validateConnection();
if (connection == null) return;
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.executeUpdate();
} catch (SQLException ex) {
LoParkour.getPlugin().getLogger().severe("Error while sending update: %s - %s".formatted(sql, ex.getMessage()));
}
}
private static void sendUpdateSuppressed(String sql) {
validateConnection();
if (connection == null) return;
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.executeUpdate();
} catch (SQLException ignored) {
for (String sql : pendingTableCreations) {
queryExecutor.executeUpdate(sql);
}
pendingTableCreations.clear();
}
}