From fea15e61c89ce16971608ee6dfe3ea0088520d41 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Thu, 6 Aug 2026 10:49:05 +0200 Subject: [PATCH] fix: resolve 11 P1 bugs from audit (leaderboard, SQL, coop, jump, lifecycle, schematics, config) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - leaderboard: parse M:SS.mmm/H:MM:SS(.mmm), switch TIME sort to long (п.25) - sql: single Hikari pool, non-blocking connect, adaptive init order, settings JSON round-trip (п.27-30) - coop: remove unregister recursion via session-only leave + reentrancy guard (п.24) - jump: perpendicular jitter for diagonal heading, valid safety-fallback on retry exhaustion (п.32-33) - lifecycle: fall threshold relative to course, lead via generated-blocks counter, one-time rewards once (п.35-37) - schematics: catch RuntimeExceptions, validate size, 10MB file limit (п.31) - config merge: preserve block-sequence lists and ignored sections, fix path reversal (п.26) - tests: +31 (score, jump, schematics, config merge) = 72 total --- config/checkstyle/suppressions.xml | 7 + .../loparkour/bootstrap/PluginBootstrap.java | 2 +- .../config/core/merge/ConfigMergeParser.java | 113 ++++++-- .../core/merge/ConfigMergeProcessor.java | 147 +++++++++-- .../generator/core/model/GeneratorState.java | 1 + .../jump/calculation/JumpCalculator.java | 147 ++++++----- .../generator/jump/placement/BlockPlacer.java | 3 + .../jump/placement/SchematicBlockPlacer.java | 1 + .../lifecycle/loop/LifecycleEventManager.java | 10 + .../lifecycle/loop/LifecycleTickManager.java | 7 +- .../lifecycle/player/GeneratorCleanup.java | 1 + .../leaderboard/core/LeaderboardSorter.java | 6 +- .../loparkour/leaderboard/model/Score.java | 61 +++-- .../loparkour/mode/impl/multi/CoopMode.java | 2 +- .../loparkour/player/core/ParkourUser.java | 2 + .../player/service/UserRegistry.java | 37 ++- .../schematic/core/ParkourSchematic.java | 34 +++ .../schematic/core/SchematicManager.java | 31 ++- .../schematic/schem/SchemLoader.java | 11 +- .../storage/sql/SQLConnectionManager.java | 2 + .../loparkour/storage/sql/SQLDataMapper.java | 43 ++- .../loparkour/storage/sql/StorageSQL.java | 37 ++- .../storage/sql/repo/SQLPlayerRepository.java | 2 + .../core/merge/ConfigMergeProcessorTest.java | 249 ++++++++++++++++++ .../jump/calculation/JumpCalculatorTest.java | 110 ++++++++ .../leaderboard/model/ScoreTest.java | 68 +++++ .../core/ParkourSchematicSizeTest.java | 45 ++++ 27 files changed, 1012 insertions(+), 167 deletions(-) create mode 100644 src/test/java/dev/loki/loparkour/config/core/merge/ConfigMergeProcessorTest.java create mode 100644 src/test/java/dev/loki/loparkour/generator/jump/calculation/JumpCalculatorTest.java create mode 100644 src/test/java/dev/loki/loparkour/leaderboard/model/ScoreTest.java create mode 100644 src/test/java/dev/loki/loparkour/schematic/core/ParkourSchematicSizeTest.java diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml index 8ff3e68..c3cabca 100644 --- a/config/checkstyle/suppressions.xml +++ b/config/checkstyle/suppressions.xml @@ -35,6 +35,13 @@ + + + diff --git a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java index b040b20..a82ad3b 100644 --- a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java +++ b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java @@ -47,9 +47,9 @@ public final class PluginBootstrap { public static void enable(@NotNull LoParkour plugin) { Config.reload(true); - AdaptiveServices.init(plugin); loadSchematics(plugin); registerModes(); + AdaptiveServices.init(plugin); registerHooks(plugin); setupWorld(plugin); registerEventsAndCommands(plugin); diff --git a/src/main/java/dev/loki/loparkour/config/core/merge/ConfigMergeParser.java b/src/main/java/dev/loki/loparkour/config/core/merge/ConfigMergeParser.java index 06e05ab..21cd2a9 100644 --- a/src/main/java/dev/loki/loparkour/config/core/merge/ConfigMergeParser.java +++ b/src/main/java/dev/loki/loparkour/config/core/merge/ConfigMergeParser.java @@ -10,6 +10,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.nio.file.Files; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; import java.util.LinkedHashMap; import java.util.List; @@ -36,26 +37,105 @@ public final class ConfigMergeParser { @NotNull public Map extractFullPathValues(@NotNull List lines) { final Map values = new LinkedHashMap<>(); - final Deque stack = new ArrayDeque<>(); - int prevIndent = -1; + final Deque
stack = new ArrayDeque<>(); for (final String line : lines) { final LineTokens tokens = parseLine(line); if (tokens == null) { continue; } - prevIndent = popToLevel(stack, tokens.indent(), prevIndent); + popToLevel(stack, tokens.indent()); final String fullPath = resolvePath(stack, tokens.leaf()); if (tokens.isSection()) { - stack.push(tokens.leaf()); + stack.push(new Section(tokens.leaf(), tokens.indent())); } else { values.put(fullPath, tokens.after()); } - prevIndent = tokens.indent(); } return values; } + @NotNull + public Map> extractBlockSequences(@NotNull List lines) { + final Map> lists = new LinkedHashMap<>(); + final Deque
stack = new ArrayDeque<>(); + + for (int i = 0; i < lines.size(); i++) { + final LineTokens tokens = parseLine(lines.get(i)); + if (tokens == null) { + continue; + } + popToLevel(stack, tokens.indent()); + final String fullPath = resolvePath(stack, tokens.leaf()); + if (tokens.isSection()) { + final List items = tokens.after().isEmpty() ? listItems(lines, i + 1, tokens.indent()) : null; + if (items != null) { + lists.put(fullPath, items); + } + stack.push(new Section(tokens.leaf(), tokens.indent())); + } + } + return lists; + } + + @Nullable + private static List listItems(@NotNull List lines, int from, int sectionIndent) { + final int start = firstListItemIndex(lines, from, sectionIndent); + if (start < 0) { + return null; + } + final List items = new ArrayList<>(); + for (int j = start; j < listEnd(lines, start); j++) { + items.add(lines.get(j).trim()); + } + return items; + } + + @NotNull + public static List pathsFor(@NotNull List lines) { + final List paths = new ArrayList<>(lines.size()); + final Deque
stack = new ArrayDeque<>(); + + for (final String line : lines) { + final LineTokens tokens = parseLine(line); + if (tokens == null) { + paths.add(null); + continue; + } + popToLevel(stack, tokens.indent()); + final String fullPath = resolvePath(stack, tokens.leaf()); + if (tokens.isSection()) { + stack.push(new Section(tokens.leaf(), tokens.indent())); + } + paths.add(fullPath); + } + return paths; + } + + static boolean isListItem(@NotNull String line) { + final String trimmed = line.trim(); + return "-".equals(trimmed) || trimmed.startsWith("- "); + } + + static int firstListItemIndex(@NotNull List lines, int from, int sectionIndent) { + for (int i = from; i < lines.size(); i++) { + final String trimmed = lines.get(i).trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + return isListItem(lines.get(i)) && indentOf(lines.get(i)) > sectionIndent ? i : -1; + } + return -1; + } + + static int listEnd(@NotNull List lines, int start) { + int j = start; + while (j < lines.size() && isListItem(lines.get(j))) { + j++; + } + return j; + } + @Nullable public static LineTokens parseLine(@NotNull String line) { if (isSkippable(line)) { @@ -80,20 +160,18 @@ public final class ConfigMergeParser { return after.isEmpty() || after.startsWith("#"); } - public static int popToLevel(@NotNull Deque stack, int indent, int prevIndent) { - int current = prevIndent; - while (current >= 0 && indent <= current) { - if (!stack.isEmpty()) { - stack.pop(); - } - current -= 2; + private static void popToLevel(@NotNull Deque
stack, int indent) { + while (!stack.isEmpty() && stack.peek().indent() >= indent) { + stack.pop(); } - return current; } @NotNull - public static String resolvePath(@NotNull Deque stack, @NotNull String leaf) { - return stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf; + private static String resolvePath(@NotNull Deque
stack, @NotNull String leaf) { + if (stack.isEmpty()) { + return leaf; + } + return String.join(".", stack.stream().map(Section::name).toList().reversed()) + "." + leaf; } public static int indentOf(@NotNull String line) { @@ -104,6 +182,9 @@ public final class ConfigMergeParser { return i; } + private record Section(String name, int indent) { + } + public record LineTokens(String leaf, String after, int indent, boolean isSection) { } -} \ No newline at end of file +} diff --git a/src/main/java/dev/loki/loparkour/config/core/merge/ConfigMergeProcessor.java b/src/main/java/dev/loki/loparkour/config/core/merge/ConfigMergeProcessor.java index 4494df8..385fc65 100644 --- a/src/main/java/dev/loki/loparkour/config/core/merge/ConfigMergeProcessor.java +++ b/src/main/java/dev/loki/loparkour/config/core/merge/ConfigMergeProcessor.java @@ -6,18 +6,13 @@ import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.util.ArrayDeque; import java.util.ArrayList; -import java.util.Deque; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; -/** - * Builds the merged file by walking the template and substituting any scalar - * value that the user has already customized, preserving comments and sections. - */ public final class ConfigMergeProcessor { private final ConfigMergeParser parser = new ConfigMergeParser(); @@ -27,44 +22,106 @@ public final class ConfigMergeProcessor { @NotNull InputStream templateStream, @NotNull File existingFile, @Nullable List ignoredKeys) throws IOException { - final List templateLines = parser.readLines(templateStream); final List existingLines = existingFile.exists() ? parser.readLines(existingFile) : List.of(); + return merge(templateLines, existingLines, ignoredKeys); + } + @NotNull + List merge( + @NotNull List templateLines, + @NotNull List existingLines, + @Nullable List ignoredKeys) { final Set ignored = normalizeIgnored(ignoredKeys); final Map existingValues = parser.extractFullPathValues(existingLines); - + final Map> existingLists = parser.extractBlockSequences(existingLines); + final Map> existingSections = extractSections(existingLines); + final List templatePaths = ConfigMergeParser.pathsFor(templateLines); final List result = new ArrayList<>(); - final Deque stack = new ArrayDeque<>(); - int prevIndent = -1; - - for (final String line : templateLines) { + int i = 0; + while (i < templateLines.size()) { + final String line = templateLines.get(i); final ConfigMergeParser.LineTokens tokens = ConfigMergeParser.parseLine(line); if (tokens == null) { result.add(line); + i++; continue; } - prevIndent = ConfigMergeParser.popToLevel(stack, tokens.indent(), prevIndent); - final String fullPath = ConfigMergeParser.resolvePath(stack, tokens.leaf()); if (tokens.isSection()) { - stack.push(tokens.leaf()); - result.add(line); - } else if (isIgnored(fullPath, ignored)) { - result.add(line); + i = appendSection(result, templateLines, i, templatePaths.get(i), ignored, existingSections, existingLists); } else { - result.add(applyExistingValue(line, fullPath, existingValues)); + result.add(applyExistingValue(line, templatePaths.get(i), existingValues)); + i++; } - prevIndent = tokens.indent(); } return result; } + private static int appendSection( + @NotNull List result, + @NotNull List templateLines, + int i, + @NotNull String fullPath, + @NotNull Set ignored, + @NotNull Map> existingSections, + @NotNull Map> existingLists) { + if (isIgnored(fullPath, ignored)) { + final int next = appendIgnoredSection(result, templateLines, i, fullPath, existingSections); + if (next > i) { + return next; + } + } + result.add(templateLines.get(i)); + return appendBlockList(result, templateLines, i, fullPath, ConfigMergeParser.indentOf(templateLines.get(i)), existingLists); + } + + private static int appendIgnoredSection( + @NotNull List result, + @NotNull List templateLines, + int i, + @NotNull String fullPath, + @NotNull Map> existingSections) { + final List userSection = existingSections.get(fullPath); + if (userSection == null) { + return i; + } + final int delta = ConfigMergeParser.indentOf(templateLines.get(i)) - ConfigMergeParser.indentOf(userSection.get(0)); + for (final String sectionLine : userSection) { + result.add(reindent(sectionLine, delta)); + } + return i + sectionSpan(templateLines, ConfigMergeParser.pathsFor(templateLines), i).size(); + } + + private static int appendBlockList( + @NotNull List result, + @NotNull List templateLines, + int i, + @NotNull String fullPath, + int sectionIndent, + @NotNull Map> existingLists) { + final int listStart = ConfigMergeParser.firstListItemIndex(templateLines, i + 1, sectionIndent); + if (listStart < 0) { + return i + 1; + } + final List userItems = existingLists.get(fullPath); + if (userItems == null) { + return i + 1; + } + final int listIndent = ConfigMergeParser.indentOf(templateLines.get(listStart)); + for (int k = i + 1; k < listStart; k++) { + result.add(templateLines.get(k)); + } + for (final String item : userItems) { + result.add(" ".repeat(listIndent) + item); + } + return ConfigMergeParser.listEnd(templateLines, listStart); + } + @NotNull private static String applyExistingValue( @NotNull String line, @NotNull String fullPath, @NotNull Map existingValues) { - final String existing = existingValues.get(fullPath); if (existing == null) { return line; @@ -72,6 +129,52 @@ public final class ConfigMergeProcessor { return line.substring(0, line.indexOf(':') + 1) + " " + existing; } + @NotNull + private static String reindent(@NotNull String line, int delta) { + if (line.isEmpty()) { + return line; + } + final int currentIndent = ConfigMergeParser.indentOf(line); + return " ".repeat(Math.max(0, currentIndent + delta)) + line.substring(currentIndent); + } + + @NotNull + private static Map> extractSections(@NotNull List lines) { + final Map> sections = new LinkedHashMap<>(); + final List paths = ConfigMergeParser.pathsFor(lines); + for (int i = 0; i < lines.size(); i++) { + final ConfigMergeParser.LineTokens tokens = ConfigMergeParser.parseLine(lines.get(i)); + if (tokens != null && tokens.isSection()) { + sections.put(paths.get(i), sectionSpan(lines, paths, i)); + } + } + return sections; + } + + private static List sectionSpan( + @NotNull List lines, + @NotNull List paths, + int start) { + final String path = paths.get(start); + final int indent = ConfigMergeParser.indentOf(lines.get(start)); + final List span = new ArrayList<>(); + int j = start; + while (j < lines.size()) { + final String candidate = paths.get(j); + if (j > start && candidate != null && !candidate.startsWith(path + ".")) { + break; + } + span.add(lines.get(j)); + j++; + } + while (span.size() > 1 + && ConfigMergeParser.parseLine(span.get(span.size() - 1)) == null + && ConfigMergeParser.indentOf(span.get(span.size() - 1)) <= indent) { + span.remove(span.size() - 1); + } + return span; + } + @NotNull private static Set normalizeIgnored(@Nullable List keys) { if (keys == null) { @@ -93,4 +196,4 @@ public final class ConfigMergeProcessor { } return false; } -} \ No newline at end of file +} diff --git a/src/main/java/dev/loki/loparkour/generator/core/model/GeneratorState.java b/src/main/java/dev/loki/loparkour/generator/core/model/GeneratorState.java index a9def71..6917ff6 100644 --- a/src/main/java/dev/loki/loparkour/generator/core/model/GeneratorState.java +++ b/src/main/java/dev/loki/loparkour/generator/core/model/GeneratorState.java @@ -38,6 +38,7 @@ public class GeneratorState { // Iteration is safe without external synchronization as all mutations happen on the same thread. public final List history = new ArrayList<>(); public int lastPositionIndexPlayer = -1; + public int generatedBlocks = 0; // Schematic state public boolean deleteSchematic = false; diff --git a/src/main/java/dev/loki/loparkour/generator/jump/calculation/JumpCalculator.java b/src/main/java/dev/loki/loparkour/generator/jump/calculation/JumpCalculator.java index cd152ea..a0807a8 100644 --- a/src/main/java/dev/loki/loparkour/generator/jump/calculation/JumpCalculator.java +++ b/src/main/java/dev/loki/loparkour/generator/jump/calculation/JumpCalculator.java @@ -1,9 +1,10 @@ package dev.loki.loparkour.generator.jump.calculation; -import dev.loki.loparkour.generator.core.model.GeneratorOption; import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator; +import dev.loki.loparkour.generator.core.model.GeneratorOption; import dev.loki.loparkour.generator.jump.placement.BlockSelector; import dev.loki.loparkour.util.misc.MaterialUtil; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; @@ -11,6 +12,10 @@ import org.bukkit.util.BoundingBox; import org.bukkit.util.Vector; import org.jetbrains.annotations.NotNull; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + /** * Calculates jump positions and validates jump constraints. */ @@ -21,7 +26,13 @@ public class JumpCalculator { private static final int DEFAULT_MAX_DISTANCE = 4; private static final int RESTRICTED_MAX_DISTANCE = 3; private static final double RANDOM_OFFSET_SD = 0.5; + private static final double DEFAULT_RANDOM_OFFSET_SD = 1.0; private static final int MAX_RETRY_ATTEMPTS = 10; + private static final int MAX_REROLL_ATTEMPTS = 3; + private static final int SAFETY_STEP_DISTANCE = 1; + private static final int SAFETY_STEP_HEIGHT = 0; + private static final double NO_LATERAL_JITTER = 0.0; + private static final double ZERO_HEADING_EPSILON = 1.0E-6; private static final int PACKED_ICE_MAX_DISTANCE = 4; private static final int BLUE_ICE_MAX_DISTANCE = 4; private static final int ICE_MAX_DISTANCE = 3; @@ -29,22 +40,21 @@ public class JumpCalculator { private final ParkourGenerator generator; private final BlockSelector blockSelector; - + public JumpCalculator(@NotNull ParkourGenerator generator, @NotNull BlockSelector blockSelector) { this.generator = generator; this.blockSelector = blockSelector; } - + /** * Calculate the next block position based on current block and jump parameters. */ @NotNull public Block calculateNextBlock(@NotNull Block current, int distance, int height) { - // Ensure zone is initialized if (generator.state.zone == null || generator.state.zone.length < 2) { throw new IllegalStateException("Generator zone not initialized"); } - + final JumpDirector director = new JumpDirector( BoundingBox.of(generator.state.zone[0], generator.state.zone[1]), current.getLocation().toVector() @@ -53,12 +63,10 @@ public class JumpCalculator { generator.state.heading = director.getRecommendedHeading(generator.state.heading); int resolvedHeight = director.getRecommendedHeight(height); - // Apply material-specific restrictions final JumpConstraints constraints = calculateJumpConstraints(current); resolvedHeight = Math.min(resolvedHeight, constraints.maxHeight); int resolvedDistance = Math.min(distance, constraints.maxDistance); - - // Clamp values to valid ranges + resolvedHeight = Math.max(MIN_HEIGHT, Math.min(2, resolvedHeight)); resolvedDistance = Math.max(1, Math.min(MAX_VALID_DISTANCE, resolvedDistance)); @@ -66,33 +74,28 @@ public class JumpCalculator { resolvedDistance = Math.max(resolvedDistance - resolvedHeight, 1); } - // Calculate offset - final Vector offset = calculateJumpOffset(resolvedDistance, resolvedHeight); - final Block candidate = current.getLocation().add(offset).getBlock(); - - // Validate jump with retry mechanism - return validateAndRetryJump(current, candidate, resolvedDistance, resolvedHeight); + final double randomOffsetSd = Arrays.asList(generator.generatorOptions) + .contains(GeneratorOption.REDUCE_RANDOM_BLOCK_SELECTION_ANGLE) ? RANDOM_OFFSET_SD : DEFAULT_RANDOM_OFFSET_SD; + final Location candidate = validateAndRetryJump(current.getLocation(), generator.state.heading, + resolvedDistance, resolvedHeight, ThreadLocalRandom.current(), randomOffsetSd); + return candidate.getBlock(); } - - /** - * Calculate jump constraints based on the material of the current block. - */ + @NotNull public JumpConstraints calculateJumpConstraints(@NotNull Block currentBlock) { final Material material = currentBlock.getType(); final BlockData blockData = currentBlock.getBlockData(); - - // Default constraints + int maxHeight = 1; int maxDistance = DEFAULT_MAX_DISTANCE; - + if (MaterialUtil.isSlabMaterial(material)) { if (blockData instanceof org.bukkit.block.data.type.Slab slab) { if (slab.getType() == org.bukkit.block.data.type.Slab.Type.BOTTOM) { - maxHeight = -1; // Bottom slab: reduced height capability + maxHeight = -1; maxDistance = RESTRICTED_MAX_DISTANCE; } else { - maxHeight = 0; // Top slab: normal restrictions + maxHeight = 0; maxDistance = RESTRICTED_MAX_DISTANCE; } } else { @@ -106,77 +109,89 @@ public class JumpCalculator { maxHeight = getSpecialMaterialMaxHeight(material); maxDistance = getSpecialMaterialMaxDistance(material); } - + return new JumpConstraints(maxHeight, maxDistance); } - - @NotNull - private Vector calculateJumpOffset(int distance, int height) { - final double sd = java.util.Arrays.asList(generator.generatorOptions) - .contains(GeneratorOption.REDUCE_RANDOM_BLOCK_SELECTION_ANGLE) ? RANDOM_OFFSET_SD : 1; - final int randomOffset = Math.min( - new JumpOffsetGenerator(height, distance).getRandomOffset(0, sd), - (int) Math.floor(Math.sqrt(JumpValidator.MAX_HORIZONTAL_DISTANCE * JumpValidator.MAX_HORIZONTAL_DISTANCE - distance * distance)) - ); - final Vector offset = generator.state.heading.clone().multiply(distance).setY(height); - if (offset.getX() == 0) { - offset.setX(randomOffset); - } else { - offset.setZ(randomOffset); + @NotNull + static Vector offsetFor(@NotNull Vector heading, int distance, int height, double randomOffset) { + final Vector horizontal = new Vector(heading.getX(), 0, heading.getZ()); + final double length = horizontal.length(); + if (length < ZERO_HEADING_EPSILON) { + return new Vector(0, height, 0); } - - return offset; + final Vector perpendicular = new Vector(-horizontal.getZ() / length, 0, horizontal.getX() / length); + return horizontal.multiply(distance).add(perpendicular.multiply(randomOffset)).setY(height); } - + + static int randomOffset(int height, int distance, double standardDeviation, @NotNull Random random) { + final int maxJitter = (int) Math.floor( + Math.sqrt(JumpValidator.MAX_HORIZONTAL_DISTANCE * JumpValidator.MAX_HORIZONTAL_DISTANCE - distance * distance)); + return Math.min(new JumpOffsetGenerator(height, distance).getRandomOffset(0, standardDeviation, random), maxJitter); + } + @NotNull - private Block validateAndRetryJump(@NotNull Block current, @NotNull Block candidate, int distance, int height) { + static Location validateAndRetryJump(@NotNull Location current, @NotNull Vector heading, int distance, int height, + @NotNull Random random, double randomOffsetSd) { final JumpValidator validator = new JumpValidator(); int resolvedDistance = distance; int resolvedHeight = height; - Block resolvedCandidate = candidate; int attempts = 0; - - while (!validator.canJump(current.getLocation(), resolvedCandidate.getLocation()) && attempts < MAX_RETRY_ATTEMPTS) { - // Reduce distance and height to make jump easier + Location candidate = current.clone().add(offsetFor(heading, resolvedDistance, resolvedHeight, + randomOffset(resolvedHeight, resolvedDistance, randomOffsetSd, random))); + while (!validator.canJump(current, candidate) && attempts < MAX_RETRY_ATTEMPTS) { resolvedDistance = Math.max(1, resolvedDistance - 1); resolvedHeight = Math.max(MIN_HEIGHT, resolvedHeight - 1); - - final Vector offset = calculateJumpOffset(resolvedDistance, resolvedHeight); - resolvedCandidate = current.getLocation().add(offset).getBlock(); + candidate = current.clone().add(offsetFor(heading, resolvedDistance, resolvedHeight, + randomOffset(resolvedHeight, resolvedDistance, randomOffsetSd, random))); attempts++; } - - return resolvedCandidate; + + if (validator.canJump(current, candidate)) { + return candidate; + } + return safetyFallback(current, heading, randomOffsetSd, random, validator); } - + + @NotNull + static Location safetyFallback(@NotNull Location current, @NotNull Vector heading, double randomOffsetSd, + @NotNull Random random, @NotNull JumpValidator validator) { + for (int i = 0; i < MAX_REROLL_ATTEMPTS; i++) { + final Location reRolled = current.clone().add(offsetFor(heading, SAFETY_STEP_DISTANCE, SAFETY_STEP_HEIGHT, + randomOffset(SAFETY_STEP_HEIGHT, SAFETY_STEP_DISTANCE, randomOffsetSd, random))); + if (validator.canJump(current, reRolled)) { + return reRolled; + } + } + final Location straight = current.clone().add( + offsetFor(heading, SAFETY_STEP_DISTANCE, SAFETY_STEP_HEIGHT, NO_LATERAL_JITTER)); + return validator.canJump(current, straight) ? straight : current; + } + private int getSpecialMaterialMaxHeight(@NotNull Material material) { return switch (material) { - case PACKED_ICE -> 1; // Ice allows normal height - case BLUE_ICE -> 1; // Blue ice same - case ICE -> 0; // Regular ice melts, slippery - case LADDER -> 1; // Ladder allows some height - default -> 0; // Most special materials restrict height + case PACKED_ICE -> 1; + case BLUE_ICE -> 1; + case ICE -> 0; + case LADDER -> 1; + default -> 0; }; } private int getSpecialMaterialMaxDistance(@NotNull Material material) { return switch (material) { - case PACKED_ICE -> PACKED_ICE_MAX_DISTANCE; // Ice — full distance, slippery - case BLUE_ICE -> BLUE_ICE_MAX_DISTANCE; // Blue ice — full distance - case ICE -> ICE_MAX_DISTANCE; // Regular ice — reduced - case LADDER -> 2; // Ladder is very restrictive - default -> DEFAULT_SPECIAL_MAX_DISTANCE; // Default restriction + case PACKED_ICE -> PACKED_ICE_MAX_DISTANCE; + case BLUE_ICE -> BLUE_ICE_MAX_DISTANCE; + case ICE -> ICE_MAX_DISTANCE; + case LADDER -> 2; + default -> DEFAULT_SPECIAL_MAX_DISTANCE; }; } - /** - * Represents jump constraints for a specific block type. - */ public static class JumpConstraints { public final int maxHeight; public final int maxDistance; - + public JumpConstraints(int maxHeight, int maxDistance) { this.maxHeight = maxHeight; this.maxDistance = maxDistance; diff --git a/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockPlacer.java b/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockPlacer.java index 1faf4f2..08e96d9 100644 --- a/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockPlacer.java +++ b/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockPlacer.java @@ -54,6 +54,7 @@ public class BlockPlacer { worldPlacer.place(block, blockData); generator.state.history.add(block); + generator.state.generatedBlocks++; generator.state.playerSpawn = spawn; } @@ -100,6 +101,7 @@ public class BlockPlacer { final Block selectedBlock = blocks.get(0); worldPlacer.place(selectedBlock, blockData); generator.state.history.add(selectedBlock); + generator.state.generatedBlocks++; } private void placeSpecialBlock() { @@ -118,5 +120,6 @@ public class BlockPlacer { final Block selectedBlock = blocks.get(0); worldPlacer.place(selectedBlock, specialBlockData); generator.state.history.add(selectedBlock); + generator.state.generatedBlocks++; } } \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/generator/jump/placement/SchematicBlockPlacer.java b/src/main/java/dev/loki/loparkour/generator/jump/placement/SchematicBlockPlacer.java index d5befc0..00638f2 100644 --- a/src/main/java/dev/loki/loparkour/generator/jump/placement/SchematicBlockPlacer.java +++ b/src/main/java/dev/loki/loparkour/generator/jump/placement/SchematicBlockPlacer.java @@ -53,6 +53,7 @@ class SchematicBlockPlacer { } generator.state.history.addAll(schematicBlocks); + generator.state.generatedBlocks += schematicBlocks.size(); generator.state.schematicBlocks = schematicBlocks; for (final ParkourPlayer player : generator.getPlayers()) { diff --git a/src/main/java/dev/loki/loparkour/generator/lifecycle/loop/LifecycleEventManager.java b/src/main/java/dev/loki/loparkour/generator/lifecycle/loop/LifecycleEventManager.java index c61f928..1b22035 100644 --- a/src/main/java/dev/loki/loparkour/generator/lifecycle/loop/LifecycleEventManager.java +++ b/src/main/java/dev/loki/loparkour/generator/lifecycle/loop/LifecycleEventManager.java @@ -10,6 +10,7 @@ import dev.loki.loparkour.reward.core.Rewards; import org.bukkit.Bukkit; import org.jetbrains.annotations.NotNull; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -90,9 +91,18 @@ public class LifecycleEventManager { if (!Rewards.ONE_TIME_REWARDS.containsKey(score)) { return; } + if (player.collectedRewards == null) { + player.collectedRewards = new ArrayList<>(); + } + final String rewardKey = String.valueOf(score); + if (player.collectedRewards.contains(rewardKey)) { + return; + } for (final Reward reward : Rewards.ONE_TIME_REWARDS.get(score)) { reward.execute(player, mode); } + player.collectedRewards.add(rewardKey); + player.save(false); } private void processFall() { diff --git a/src/main/java/dev/loki/loparkour/generator/lifecycle/loop/LifecycleTickManager.java b/src/main/java/dev/loki/loparkour/generator/lifecycle/loop/LifecycleTickManager.java index 39ddaf9..7e01e4f 100644 --- a/src/main/java/dev/loki/loparkour/generator/lifecycle/loop/LifecycleTickManager.java +++ b/src/main/java/dev/loki/loparkour/generator/lifecycle/loop/LifecycleTickManager.java @@ -3,6 +3,7 @@ package dev.loki.loparkour.generator.lifecycle.loop; import dev.loki.loparkour.config.options.Option; import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator; import dev.loki.loparkour.generator.lifecycle.player.GeneratorCleanup; +import org.bukkit.block.Block; import org.jetbrains.annotations.NotNull; /** @@ -71,7 +72,9 @@ public class LifecycleTickManager { return; } - final double fallThreshold = generator.state.playerSpawn.getY() - FALL_THRESHOLD_OFFSET; + final Block latest = generator.getLatest(); + final double referenceY = latest != null ? latest.getY() : generator.state.playerSpawn.getY(); + final double fallThreshold = referenceY - FALL_THRESHOLD_OFFSET; for (final var player : generator.getPlayers()) { if (player.player.getLocation().getY() < fallThreshold) { @@ -89,7 +92,7 @@ public class LifecycleTickManager { } private void maintainBlockLead() { - final int currentBlocks = generator.state.history.size(); + final int currentBlocks = generator.state.generatedBlocks; final int targetBlocks = generator.state.score + GENERATION_LEAD; if (currentBlocks < targetBlocks) { diff --git a/src/main/java/dev/loki/loparkour/generator/lifecycle/player/GeneratorCleanup.java b/src/main/java/dev/loki/loparkour/generator/lifecycle/player/GeneratorCleanup.java index cc1964a..96afe5f 100644 --- a/src/main/java/dev/loki/loparkour/generator/lifecycle/player/GeneratorCleanup.java +++ b/src/main/java/dev/loki/loparkour/generator/lifecycle/player/GeneratorCleanup.java @@ -122,6 +122,7 @@ public class GeneratorCleanup { // Reset state generator.state.history.clear(); + generator.state.generatedBlocks = 0; generator.state.score = 0; generator.state.start = null; diff --git a/src/main/java/dev/loki/loparkour/leaderboard/core/LeaderboardSorter.java b/src/main/java/dev/loki/loparkour/leaderboard/core/LeaderboardSorter.java index 7f119fd..bb08599 100644 --- a/src/main/java/dev/loki/loparkour/leaderboard/core/LeaderboardSorter.java +++ b/src/main/java/dev/loki/loparkour/leaderboard/core/LeaderboardSorter.java @@ -58,7 +58,7 @@ public class LeaderboardSorter { return compareScore(one, two); } case TIME -> { - return Integer.compare(one.getValue().getTimeMillis(), two.getValue().getTimeMillis()); + return Long.compare(one.getValue().getTimeMillis(), two.getValue().getTimeMillis()); } case DIFFICULTY -> { return compareDifficulty(one.getValue().difficulty(), two.getValue().difficulty()); @@ -74,8 +74,8 @@ public class LeaderboardSorter { if (scoreComparison != 0) { return scoreComparison; } - // Use Integer.compare to avoid overflow - return Integer.compare(one.getValue().getTimeMillis(), two.getValue().getTimeMillis()); + // Use Long.compare to avoid overflow + return Long.compare(one.getValue().getTimeMillis(), two.getValue().getTimeMillis()); } private static int compareDifficulty(String diff1, String diff2) { diff --git a/src/main/java/dev/loki/loparkour/leaderboard/model/Score.java b/src/main/java/dev/loki/loparkour/leaderboard/model/Score.java index 55df2b7..65470a8 100644 --- a/src/main/java/dev/loki/loparkour/leaderboard/model/Score.java +++ b/src/main/java/dev/loki/loparkour/leaderboard/model/Score.java @@ -12,9 +12,14 @@ public record Score(String name, String time, String difficulty, int score) { private static final int SCORE_PARTS_COUNT = 4; private static final int SCORE_INDEX = 3; - private static final int TIME_PARTS_COUNT = 3; - private static final int SECONDS_PER_MINUTE = 60; - private static final int MILLIS_PER_SECOND = 1000; + private static final int TIME_SEGMENTS_MIN = 2; + private static final int TIME_SEGMENTS_MAX = 3; + private static final int MILLIS_SEGMENT_COUNT = 2; + private static final long SECONDS_PER_MINUTE = 60L; + private static final long SECONDS_PER_HOUR = 3600L; + private static final long MILLIS_PER_SECOND = 1000L; + private static final String UNKNOWN_TIME = "?"; + private static final long UNKNOWN_TIME_MILLIS = Long.MAX_VALUE; /** * Gets a {@link Score} instance from a string @@ -39,27 +44,49 @@ public record Score(String name, String time, String difficulty, int score) { /** * @return This score's time in millis. */ - public int getTimeMillis() { - if ("?".equals(time)) { - return Integer.MAX_VALUE; // Unknown time sorts last + public long getTimeMillis() { + if (UNKNOWN_TIME.equals(time)) { + return UNKNOWN_TIME_MILLIS; // Unknown time sorts last } try { - final String[] split = time.split(":"); - if (split.length != TIME_PARTS_COUNT) { - return Integer.MAX_VALUE; - } - - final int m = Integer.parseInt(split[0]); - final int s = Integer.parseInt(split[1]); - final int ms = Integer.parseInt(split[2]); - - return m * SECONDS_PER_MINUTE * MILLIS_PER_SECOND + s * MILLIS_PER_SECOND + ms; + return parseTime(time); } catch (NumberFormatException e) { - return Integer.MAX_VALUE; + return UNKNOWN_TIME_MILLIS; } } + private static long parseTime(String value) { + final String[] segments = value.split(":"); + if (segments.length < TIME_SEGMENTS_MIN || segments.length > TIME_SEGMENTS_MAX) { + return UNKNOWN_TIME_MILLIS; + } + + final String[] secondsAndMillis = segments[segments.length - 1].split("\\."); + if (secondsAndMillis.length > MILLIS_SEGMENT_COUNT) { + return UNKNOWN_TIME_MILLIS; + } + + return fullSeconds(segments, secondsAndMillis) * MILLIS_PER_SECOND + millis(secondsAndMillis); + } + + private static long fullSeconds(String[] segments, String[] secondsAndMillis) { + final long seconds = Long.parseLong(secondsAndMillis[0]); + if (segments.length == TIME_SEGMENTS_MAX) { + return Long.parseLong(segments[0]) * SECONDS_PER_HOUR + + Long.parseLong(segments[1]) * SECONDS_PER_MINUTE + + seconds; + } + return Long.parseLong(segments[0]) * SECONDS_PER_MINUTE + seconds; + } + + private static long millis(String[] secondsAndMillis) { + if (secondsAndMillis.length == MILLIS_SEGMENT_COUNT) { + return Long.parseLong(secondsAndMillis[1]); + } + return 0L; + } + @Override public String toString() { return String.format("%s,%s,%s,%s", name, time, difficulty, score); diff --git a/src/main/java/dev/loki/loparkour/mode/impl/multi/CoopMode.java b/src/main/java/dev/loki/loparkour/mode/impl/multi/CoopMode.java index 4622cdb..4779ced 100644 --- a/src/main/java/dev/loki/loparkour/mode/impl/multi/CoopMode.java +++ b/src/main/java/dev/loki/loparkour/mode/impl/multi/CoopMode.java @@ -114,7 +114,7 @@ public class CoopMode implements MultiMode { if (pp == null) { return; } - ParkourUser.leave(pp); + session.removePlayers(pp); } @Override diff --git a/src/main/java/dev/loki/loparkour/player/core/ParkourUser.java b/src/main/java/dev/loki/loparkour/player/core/ParkourUser.java index 85a73c0..bea588a 100644 --- a/src/main/java/dev/loki/loparkour/player/core/ParkourUser.java +++ b/src/main/java/dev/loki/loparkour/player/core/ParkourUser.java @@ -70,6 +70,8 @@ public abstract class ParkourUser { public final Player player; public final Instant joined; private ScoreboardManager scoreboardManager; + /** True while unregister is in progress; re-entrant unregister calls become no-ops. */ + public boolean unregistering; @SuppressWarnings("deprecation") // Scoreboard: legacy registerNewObjective(String,String,String) public ParkourUser(@NotNull Player player, @NotNull Session session, @Nullable PreviousData previousData) { diff --git a/src/main/java/dev/loki/loparkour/player/service/UserRegistry.java b/src/main/java/dev/loki/loparkour/player/service/UserRegistry.java index 4fcaa09..311b872 100644 --- a/src/main/java/dev/loki/loparkour/player/service/UserRegistry.java +++ b/src/main/java/dev/loki/loparkour/player/service/UserRegistry.java @@ -61,23 +61,32 @@ public final class UserRegistry { } public static void unregister(@NotNull ParkourUser user, boolean restorePreviousData, boolean kickIfBungee, boolean urgent) { - dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices.unloadPlayer(user.getUUID()); - 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")); + if (user.unregistering) { return; } - if (restorePreviousData) { - restorePreviousState(user, urgent); + user.unregistering = true; + try { + dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices.unloadPlayer(user.getUUID()); + 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); + } + } finally { + user.unregistering = false; } } diff --git a/src/main/java/dev/loki/loparkour/schematic/core/ParkourSchematic.java b/src/main/java/dev/loki/loparkour/schematic/core/ParkourSchematic.java index 2bbbc65..8102835 100644 --- a/src/main/java/dev/loki/loparkour/schematic/core/ParkourSchematic.java +++ b/src/main/java/dev/loki/loparkour/schematic/core/ParkourSchematic.java @@ -1,12 +1,14 @@ package dev.loki.loparkour.schematic.core; import com.sk89q.worldedit.extent.clipboard.Clipboard; +import com.sk89q.worldedit.math.BlockVector3; import dev.loki.loparkour.schematic.nbt.StructurePaster; import dev.loki.loparkour.schematic.schem.SchemPaster; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.structure.Structure; +import org.bukkit.util.BlockVector; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -17,6 +19,9 @@ import java.util.List; */ public final class ParkourSchematic { + static final int MAX_SCHEMATIC_EDGE = 128; + static final int MAX_SCHEMATIC_BLOCKS = 100_000; + private final String id; private final double difficulty; private final SchematicFormat format; @@ -49,6 +54,35 @@ public final class ParkourSchematic { return new ParkourSchematic(id, difficulty, SchematicFormat.SCHEM, null, clipboard); } + /** + * Rejects sizes that are non-positive, exceed the max edge length, or exceed the max block count. + */ + static boolean isSaneSize(double width, double height, double length) { + if (width <= 0 || height <= 0 || length <= 0) { + return false; + } + if (width > MAX_SCHEMATIC_EDGE || height > MAX_SCHEMATIC_EDGE || length > MAX_SCHEMATIC_EDGE) { + return false; + } + return width * height * length <= MAX_SCHEMATIC_BLOCKS; + } + + static boolean isSaneSize(@Nullable Structure structure) { + if (structure == null) { + return false; + } + final BlockVector size = structure.getSize(); + return isSaneSize(size.getBlockX(), size.getBlockY(), size.getBlockZ()); + } + + static boolean isSaneSize(@Nullable Clipboard clipboard) { + if (clipboard == null || clipboard.getRegion() == null) { + return false; + } + final BlockVector3 size = clipboard.getDimensions(); + return isSaneSize(size.x(), size.y(), size.z()); + } + @NotNull public String getId() { return id; diff --git a/src/main/java/dev/loki/loparkour/schematic/core/SchematicManager.java b/src/main/java/dev/loki/loparkour/schematic/core/SchematicManager.java index 2e591b4..703d5e8 100644 --- a/src/main/java/dev/loki/loparkour/schematic/core/SchematicManager.java +++ b/src/main/java/dev/loki/loparkour/schematic/core/SchematicManager.java @@ -22,8 +22,8 @@ import java.util.concurrent.ThreadLocalRandom; import java.util.logging.Level; /** - * Loads {@code .nbt}, {@code .schem}, and {@code .schematic} files from {@code plugins/LoParkour/schematics/}. - * Difficulty per structure is configured in {@code schematics/schematics.yml}. + * Loads {@code .nbt}, {@code .schem}, and {@code .schematic} files from the schematics folder; + * difficulty per structure is configured in {@code schematics/schematics.yml}. */ public final class SchematicManager { @@ -92,7 +92,6 @@ public final class SchematicManager { if (files == null || files.length == 0) { return 0; } - final StructureManager structureManager = Compat.getStructureManager(); if (structureManager == null) { return 0; @@ -102,11 +101,17 @@ public final class SchematicManager { final String stem = stem(file, ".nbt"); try { final Structure structure = structureManager.loadStructure(file); + if (!ParkourSchematic.isSaneSize(structure)) { + LoParkour.getPlugin().getLogger().warning("Skipping " + file.getName() + ": invalid structure size"); + continue; + } register(stem, ParkourSchematic.fromNbt(stem, resolveDifficulty(stem), structure)); ok++; } catch (IOException e) { - LoParkour.getPlugin().getLogger().severe("Failed to load structure: " + file.getName()); - LoParkour.getPlugin().getLogger().log(Level.SEVERE, "Detailed cause:", e); + logLoadFailure("structure", file, e); + } catch (RuntimeException e) { + // broad catch: corrupted schematics throw unchecked exceptions; never break plugin enable + logLoadFailure("structure", file, e); } } return ok; @@ -120,7 +125,6 @@ public final class SchematicManager { if (files == null || files.length == 0) { return 0; } - int ok = 0; for (final File file : files) { final String stem = stem(file, file.getName().toLowerCase(Locale.ROOT).endsWith(".schematic") @@ -128,11 +132,17 @@ public final class SchematicManager { : ".schem"); try { final Clipboard clipboard = SchemLoader.load(file); + if (!ParkourSchematic.isSaneSize(clipboard)) { + LoParkour.getPlugin().getLogger().warning("Skipping " + file.getName() + ": invalid clipboard size"); + continue; + } register(stem, ParkourSchematic.fromSchem(stem, resolveDifficulty(stem), clipboard)); ok++; } catch (IOException e) { - LoParkour.getPlugin().getLogger().severe("Failed to load schematic: " + file.getName()); - LoParkour.getPlugin().getLogger().log(Level.SEVERE, "Detailed cause:", e); + logLoadFailure("schematic", file, e); + } catch (RuntimeException e) { + // broad catch: corrupted schematics throw unchecked exceptions; never break plugin enable + logLoadFailure("schematic", file, e); } } return ok; @@ -149,6 +159,11 @@ public final class SchematicManager { loaded.put(key, schematic); } + private static void logLoadFailure(@NotNull String kind, @NotNull File file, @NotNull Throwable cause) { + LoParkour.getPlugin().getLogger().severe("Failed to load " + kind + ": " + file.getName()); + LoParkour.getPlugin().getLogger().log(Level.SEVERE, "Detailed cause:", cause); + } + @NotNull private static String stem(@NotNull File file, @NotNull String suffix) { final String name = file.getName(); diff --git a/src/main/java/dev/loki/loparkour/schematic/schem/SchemLoader.java b/src/main/java/dev/loki/loparkour/schematic/schem/SchemLoader.java index d899d7b..f1b183d 100644 --- a/src/main/java/dev/loki/loparkour/schematic/schem/SchemLoader.java +++ b/src/main/java/dev/loki/loparkour/schematic/schem/SchemLoader.java @@ -12,18 +12,27 @@ import java.io.IOException; public final class SchemLoader { + private static final int MAX_FILE_SIZE = 10 * 1024 * 1024; + private SchemLoader() { } @NotNull public static Clipboard load(@NotNull File file) throws IOException { + if (file.length() > MAX_FILE_SIZE) { + throw new IOException("Schematic file too large: " + file.getName()); + } final ClipboardFormat format = ClipboardFormats.findByFile(file); if (format == null) { throw new IOException("Unsupported schematic file: " + file.getName()); } try (FileInputStream in = new FileInputStream(file); ClipboardReader reader = format.getReader(in)) { - return reader.read(); + final Clipboard clipboard = reader.read(); + if (clipboard == null) { + throw new IOException("Schematic file returned no clipboard: " + file.getName()); + } + return clipboard; } } } diff --git a/src/main/java/dev/loki/loparkour/storage/sql/SQLConnectionManager.java b/src/main/java/dev/loki/loparkour/storage/sql/SQLConnectionManager.java index cdad28d..6cdd92b 100644 --- a/src/main/java/dev/loki/loparkour/storage/sql/SQLConnectionManager.java +++ b/src/main/java/dev/loki/loparkour/storage/sql/SQLConnectionManager.java @@ -19,6 +19,7 @@ public class SQLConnectionManager { private static final int MAX_POOL_SIZE = 10; private static final int CONNECTION_TIMEOUT_MILLIS = 5000; private static final int MAX_CONNECTION_LIFETIME_MILLIS = 1_800_000; + private static final int INITIALIZATION_FAIL_TIMEOUT = -1; private volatile boolean connected = false; private final List onConnectCallbacks = new CopyOnWriteArrayList<>(); @@ -47,6 +48,7 @@ public class SQLConnectionManager { config.setMinimumIdle(2); config.setConnectionTimeout(CONNECTION_TIMEOUT_MILLIS); config.setMaxLifetime(MAX_CONNECTION_LIFETIME_MILLIS); + config.setInitializationFailTimeout(INITIALIZATION_FAIL_TIMEOUT); config.setPoolName("LoParkour"); dataSource = new HikariDataSource(config); diff --git a/src/main/java/dev/loki/loparkour/storage/sql/SQLDataMapper.java b/src/main/java/dev/loki/loparkour/storage/sql/SQLDataMapper.java index 3553685..fe5c5a5 100644 --- a/src/main/java/dev/loki/loparkour/storage/sql/SQLDataMapper.java +++ b/src/main/java/dev/loki/loparkour/storage/sql/SQLDataMapper.java @@ -1,10 +1,13 @@ package dev.loki.loparkour.storage.sql; +import com.google.gson.reflect.TypeToken; +import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.leaderboard.model.Score; import dev.loki.loparkour.player.core.ParkourPlayer; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.lang.reflect.Type; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -107,19 +110,49 @@ public class SQLDataMapper { // Apply settings if available if (data.settings() != null && !data.settings().isEmpty()) { deserializeSettings(player, data.settings()); + } else { + player.setSettings(new HashMap<>()); } } @NotNull private static String serializeSettings(@NotNull ParkourPlayer player) { - // Simplified settings serialization - // In real implementation, would serialize player settings to JSON - return "{}"; + final Map settings = new HashMap<>(); + settings.put("style", player.style); + settings.put("blockLead", player.blockLead); + settings.put("useParticles", player.particles); + settings.put("useSpecial", player.useSpecialBlocks); + settings.put("showFallMsg", player.showFallMessage); + settings.put("showScoreboard", player.showScoreboard); + settings.put("selectedTime", player.selectedTime); + settings.put("collectedRewards", player.collectedRewards); + settings.put("locale", player.locale); + settings.put("schematicDifficulty", player.schematicDifficulty); + settings.put("sound", player.sound); + return LoParkour.getGson().toJson(settings); } private static void deserializeSettings(@NotNull ParkourPlayer player, @NotNull String settings) { - // Simplified settings deserialization - // In real implementation, would deserialize JSON to player settings + final Type type = new TypeToken>() {}.getType(); + final Map parsed = LoParkour.getGson().fromJson(settings, type); + if (parsed == null) { + player.setSettings(new HashMap<>()); + return; + } + normalizeNumericSettings(parsed); + player.setSettings(parsed); + } + + private static void normalizeNumericSettings(@NotNull Map settings) { + for (final Map.Entry entry : settings.entrySet()) { + final Object value = entry.getValue(); + if (value instanceof Double) { + final double number = (Double) value; + if (number % 1 == 0) { + entry.setValue((int) number); + } + } + } } /** diff --git a/src/main/java/dev/loki/loparkour/storage/sql/StorageSQL.java b/src/main/java/dev/loki/loparkour/storage/sql/StorageSQL.java index 4df71f0..979e257 100644 --- a/src/main/java/dev/loki/loparkour/storage/sql/StorageSQL.java +++ b/src/main/java/dev/loki/loparkour/storage/sql/StorageSQL.java @@ -1,15 +1,19 @@ package dev.loki.loparkour.storage.sql; +import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.leaderboard.model.Score; import dev.loki.loparkour.player.core.ParkourPlayer; +import dev.loki.loparkour.storage.sql.repo.SQLPlayerRepository; +import dev.loki.loparkour.storage.sql.repo.SQLScoreRepository; +import dev.loki.loparkour.storage.sql.schema.SQLSchemaManager; +import dev.lolib.scheduler.Scheduler; import org.jetbrains.annotations.NotNull; import java.sql.SQLException; import java.util.Map; +import java.util.Set; import java.util.UUID; -import dev.loki.loparkour.storage.sql.repo.SQLPlayerRepository; -import dev.loki.loparkour.storage.sql.repo.SQLScoreRepository; -import dev.loki.loparkour.storage.sql.schema.SQLSchemaManager; +import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; /** @@ -22,6 +26,7 @@ public class StorageSQL { } private static final Logger LOGGER = Logger.getLogger(StorageSQL.class.getName()); + private static final Set INITIALIZED_MODES = ConcurrentHashMap.newKeySet(); private static SQLConnectionManager connectionManager; /** @@ -43,11 +48,11 @@ public class StorageSQL { * Run callback when database is connected. */ public static void runWhenConnected(@NotNull Runnable callback) { - if (isConnected()) { - callback.run(); - } else { - LOGGER.warning("Database not connected, skipping operation"); + if (connectionManager == null) { + LOGGER.warning("SQL storage not initialized yet, operation skipped"); + return; } + connectionManager.runWhenConnected(callback); } /** @@ -58,21 +63,31 @@ public class StorageSQL { connectionManager.close(); connectionManager = null; } + INITIALIZED_MODES.clear(); } /** * Initialize database connection and tables. */ public static void init(@NotNull String mode) { - try { + if (connectionManager == null) { connectionManager = new SQLConnectionManager(); connectionManager.connect(); + } + final SQLConnectionManager manager = connectionManager; + manager.runWhenConnected(() -> + Scheduler.get(LoParkour.getPlugin()).runAsync(() -> ensureTables(manager, mode))); + } - SQLSchemaManager.createTables(connectionManager, mode); - + private static void ensureTables(@NotNull SQLConnectionManager manager, @NotNull String mode) { + if (!INITIALIZED_MODES.add(mode)) { + return; + } + try { + SQLSchemaManager.createTables(manager, mode); LOGGER.info("Database initialized successfully for mode: " + mode); } catch (SQLException e) { - LOGGER.severe("Failed to initialize database: " + e.getMessage()); + LOGGER.severe("Failed to initialize database for mode " + mode + ": " + e.getMessage()); } } diff --git a/src/main/java/dev/loki/loparkour/storage/sql/repo/SQLPlayerRepository.java b/src/main/java/dev/loki/loparkour/storage/sql/repo/SQLPlayerRepository.java index da97853..eafa7c7 100644 --- a/src/main/java/dev/loki/loparkour/storage/sql/repo/SQLPlayerRepository.java +++ b/src/main/java/dev/loki/loparkour/storage/sql/repo/SQLPlayerRepository.java @@ -38,6 +38,8 @@ public class SQLPlayerRepository { final SQLDataMapper.PlayerData data = SQLDataMapper.mapPlayerData(rs); if (data != null) { SQLDataMapper.applyPlayerData(player, data); + } else { + player.setSettings(new java.util.HashMap<>()); } } diff --git a/src/test/java/dev/loki/loparkour/config/core/merge/ConfigMergeProcessorTest.java b/src/test/java/dev/loki/loparkour/config/core/merge/ConfigMergeProcessorTest.java new file mode 100644 index 0000000..89cc34d --- /dev/null +++ b/src/test/java/dev/loki/loparkour/config/core/merge/ConfigMergeProcessorTest.java @@ -0,0 +1,249 @@ +package dev.loki.loparkour.config.core.merge; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.yaml.snakeyaml.Yaml; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the template/user config merge. Uses 2-space indentation, + * matching the shipped config templates. + */ +class ConfigMergeProcessorTest { + + private static final String TEMPLATE = """ + debug: false + options: + leads: + amount: + - 1 + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + + time: + format: 24 + + focus-mode: + whitelist: + - 'ip' + - 'msg' + + styles: + list: + red: [red_wool, red_stained_glass] + """; + + private final ConfigMergeProcessor processor = new ConfigMergeProcessor(); + + @Test + void customizedBlockListSurvivesMerge() { + final List user = List.of( + "debug: true", + "options:", + " leads:", + " amount:", + " - 1", + " - 2", + " - 3", + "", + " time:", + " format: 12"); + + final List merged = processor.merge(lines(TEMPLATE), user, null); + + assertTrue(merged.contains(" - 1")); + assertTrue(merged.contains(" - 2")); + assertTrue(merged.contains(" - 3")); + assertFalse(merged.contains(" - 4")); + assertTrue(merged.contains("debug: true")); + assertTrue(merged.contains(" format: 12")); + assertTrue(merged.contains(" - 'ip'")); + assertTrue(merged.contains(" red: [red_wool, red_stained_glass]")); + assertValidYaml(merged); + } + + @Test + void unchangedBlockListStaysIdentical() { + final List template = lines(TEMPLATE); + assertEquals(template, processor.merge(template, template, null)); + } + + @Test + void newTemplateKeysAreAdded() { + final List template = List.of( + "old-key: 'old'", + "new-key: 'new'", + "new-section:", + " enabled: true"); + final List user = List.of("old-key: 'custom'"); + + final List merged = processor.merge(template, user, null); + + assertTrue(merged.contains("old-key: 'custom'")); + assertTrue(merged.contains("new-key: 'new'")); + assertTrue(merged.contains("new-section:")); + assertTrue(merged.contains(" enabled: true")); + assertValidYaml(merged); + } + + @Test + void ignoredSectionUserCustomizedIsPreserved() { + final List user = List.of( + "styles:", + " list:", + " red: [custom_red_wool]", + " extra: [extra_wool]"); + + final List merged = processor.merge(lines(TEMPLATE), user, List.of("styles")); + + assertTrue(merged.contains(" red: [custom_red_wool]")); + assertTrue(merged.contains(" extra: [extra_wool]")); + assertFalse(merged.contains(" red: [red_wool, red_stained_glass]")); + assertValidYaml(merged); + } + + @Test + void ignoredSectionMissingUsesTemplate() { + final List user = List.of("debug: false"); + + final List merged = processor.merge(lines(TEMPLATE), user, List.of("styles")); + + assertTrue(merged.contains("styles:")); + assertTrue(merged.contains(" list:")); + assertTrue(merged.contains(" red: [red_wool, red_stained_glass]")); + assertValidYaml(merged); + } + + @Test + void ignoredSectionBlockListIsPreserved() { + final List template = List.of( + "focus-mode:", + " enabled: false", + " whitelist:", + " - 'ip'", + " - 'msg'"); + final List user = List.of( + "focus-mode:", + " enabled: true", + " whitelist:", + " - 'custom'"); + + final List merged = processor.merge(template, user, List.of("focus-mode")); + + assertTrue(merged.contains(" enabled: true")); + assertTrue(merged.contains(" - 'custom'")); + assertFalse(merged.contains("- 'ip'")); + assertValidYaml(merged); + } + + @Test + void fileBasedMergeAppliesBlockList(@TempDir Path tempDir) throws IOException { + final Path templateFile = tempDir.resolve("template.yml"); + final Path existingFile = tempDir.resolve("existing.yml"); + Files.write(templateFile, lines(TEMPLATE)); + Files.write(existingFile, List.of( + "debug: true", + "options:", + " leads:", + " amount:", + " - 1", + " - 2", + " - 3")); + + try (InputStream is = Files.newInputStream(templateFile)) { + final List merged = processor.merge(is, existingFile.toFile(), null); + assertTrue(merged.contains(" - 1")); + assertTrue(merged.contains(" - 3")); + assertFalse(merged.contains(" - 4")); + assertTrue(merged.contains("debug: true")); + assertValidYaml(merged); + } + } + + @Test + void ignoredSectionWithSiblingScalarsIsPreserved() { + final List template = List.of( + "difficulty:", + " d0e227d2: 0.25", + " dbef835e: 0.25", + " 27f41406: 1.0"); + final List user = List.of( + "difficulty:", + " d0e227d2: 0.5", + " dbef835e: 0.25", + " abc12345: 0.75"); + + final List merged = processor.merge(template, user, List.of("difficulty")); + + assertTrue(merged.contains(" d0e227d2: 0.5")); + assertTrue(merged.contains(" dbef835e: 0.25")); + assertTrue(merged.contains(" abc12345: 0.75")); + assertFalse(merged.contains("27f41406: 1.0")); + assertValidYaml(merged); + } + + @Test + void siblingScalarsAllSubstitute() { + final List template = List.of( + "world:", + " name: 'loparkour'", + " fall-back: 'world'", + " delete-on-reload: true"); + final List user = List.of( + "world:", + " name: 'custom'", + " fall-back: 'custom-world'"); + + final List merged = processor.merge(template, user, null); + + assertTrue(merged.contains(" name: 'custom'")); + assertTrue(merged.contains(" fall-back: 'custom-world'")); + assertTrue(merged.contains(" delete-on-reload: true")); + assertValidYaml(merged); + } + + @Test + void freshInstallMergesIdentically() throws IOException { + assertFreshInstallIdentity("config.yml", List.of("styles")); + assertFreshInstallIdentity("generation.yml", null); + assertFreshInstallIdentity("rewards-v2.yml", List.of("score-rewards", "interval-rewards", "one-time-rewards")); + assertFreshInstallIdentity("schematics/schematics.yml", List.of("difficulty")); + } + + private static void assertFreshInstallIdentity(String resource, List ignored) throws IOException { + try (InputStream is = ConfigMergeProcessorTest.class.getResourceAsStream("/" + resource)) { + assertNotNull(is, "missing resource " + resource); + final List template = new BufferedReader(new InputStreamReader(is)).lines().toList(); + assertEquals(template, new ConfigMergeProcessor().merge(template, template, ignored), + "fresh-install merge must be identity for " + resource); + } + } + + private static List lines(String content) { + return content.lines().toList(); + } + + private static void assertValidYaml(List merged) { + final Object parsed = new Yaml().load(String.join("\n", merged)); + assertNotNull(parsed); + } +} diff --git a/src/test/java/dev/loki/loparkour/generator/jump/calculation/JumpCalculatorTest.java b/src/test/java/dev/loki/loparkour/generator/jump/calculation/JumpCalculatorTest.java new file mode 100644 index 0000000..20893c7 --- /dev/null +++ b/src/test/java/dev/loki/loparkour/generator/jump/calculation/JumpCalculatorTest.java @@ -0,0 +1,110 @@ +package dev.loki.loparkour.generator.jump.calculation; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the offset math and the safety-fallback logic of JumpCalculator. + */ +class JumpCalculatorTest { + + private static final double DELTA = 1.0E-6; + private static final double DIAGONAL_UNIT = 0.7071067811865476; + private static final double DEFAULT_SD = 1; + + @Mock + private World world; + + private Location current; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + current = new Location(world, 0, 100, 0); + } + + @Test + void diagonalHeadingOffsetStaysOnPerpendicularBand() { + final Vector heading = new Vector(DIAGONAL_UNIT, 0, DIAGONAL_UNIT); + final Vector perpendicular = new Vector(-heading.getZ(), 0, heading.getX()); + final Vector offset = JumpCalculator.offsetFor(heading, 3, 1, 2); + + assertEquals(2, dot(offset, perpendicular), DELTA); + assertEquals(3, dot(offset, heading), DELTA); + assertEquals(1, offset.getY(), DELTA); + } + + @Test + void xAxisHeadingAppliesJitterOnZAxis() { + final Vector offset = JumpCalculator.offsetFor(new Vector(1, 0, 0), 4, 0, -2); + + assertEquals(4, offset.getX(), DELTA); + assertEquals(0, offset.getY(), DELTA); + assertEquals(-2, offset.getZ(), DELTA); + } + + @Test + void zAxisHeadingAppliesJitterOnXAxis() { + final Vector offset = JumpCalculator.offsetFor(new Vector(0, 0, 1), 2, -1, 3); + + assertEquals(-3, offset.getX(), DELTA); + assertEquals(-1, offset.getY(), DELTA); + assertEquals(2, offset.getZ(), DELTA); + } + + @Test + void safetyFallbackReturnsValidStepForUnitHeading() { + final JumpValidator validator = new JumpValidator(); + final Location fallback = JumpCalculator.safetyFallback(current, new Vector(1, 0, 0), DEFAULT_SD, new Random(42), validator); + + assertNotEquals(current, fallback); + assertTrue(validator.canJump(current, fallback)); + } + + @Test + void safetyFallbackReturnsCurrentWhenStraightStepImpossible() { + final JumpValidator validator = new JumpValidator(); + final Location fallback = JumpCalculator.safetyFallback(current, new Vector(100, 0, 0), DEFAULT_SD, new Random(1), validator); + + assertEquals(current, fallback); + } + + @Test + void validateAndRetryJumpNeverReturnsUnreachableBlock() { + final JumpValidator validator = new JumpValidator(); + final Vector heading = new Vector(0, 0, 1); + + for (int distance = 1; distance <= 4; distance++) { + for (int height = -2; height <= 1; height++) { + final Location result = JumpCalculator.validateAndRetryJump( + current, heading, distance, height, new Random(distance * 10 + height), DEFAULT_SD); + + assertTrue(validator.canJump(current, result), + "unreachable block for distance=" + distance + " height=" + height); + } + } + } + + @Test + void validateAndRetryJumpReturnsCurrentAsLastResort() { + final Location result = JumpCalculator.validateAndRetryJump( + current, new Vector(100, 0, 0), 4, 1, new Random(7), DEFAULT_SD); + + assertEquals(current, result); + } + + private static double dot(Vector a, Vector b) { + return a.getX() * b.getX() + a.getY() * b.getY() + a.getZ() * b.getZ(); + } +} diff --git a/src/test/java/dev/loki/loparkour/leaderboard/model/ScoreTest.java b/src/test/java/dev/loki/loparkour/leaderboard/model/ScoreTest.java new file mode 100644 index 0000000..511b173 --- /dev/null +++ b/src/test/java/dev/loki/loparkour/leaderboard/model/ScoreTest.java @@ -0,0 +1,68 @@ +package dev.loki.loparkour.leaderboard.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit tests for Score time parsing and serialization. + */ +class ScoreTest { + + private static final String UNKNOWN_TIME = "?"; + + @Test + void testGetTimeMillis_MinutesSeconds() { + final Score score = new Score("player", "3:05.123", "1", 10); + assertEquals(185123L, score.getTimeMillis()); + } + + @Test + void testGetTimeMillis_HoursMinutesSeconds() { + final Score score = new Score("player", "1:03:05.123", "1", 10); + assertEquals(3785123L, score.getTimeMillis()); + } + + @Test + void testGetTimeMillis_MinutesOver59() { + final Score score = new Score("player", "75:05.123", "1", 10); + assertEquals(4505123L, score.getTimeMillis()); + } + + @Test + void testGetTimeMillis_ZeroTime() { + final Score score = new Score("player", "0:10.000", "1", 10); + assertEquals(10000L, score.getTimeMillis()); + } + + @Test + void testGetTimeMillis_UnknownTime() { + final Score score = new Score("player", UNKNOWN_TIME, "1", 10); + assertEquals(Long.MAX_VALUE, score.getTimeMillis()); + } + + @Test + void testGetTimeMillis_MillisLessMinutesSeconds() { + final Score score = new Score("player", "3:05", "1", 10); + assertEquals(185000L, score.getTimeMillis()); + } + + @Test + void testGetTimeMillis_MillisLessHoursMinutesSeconds() { + final Score score = new Score("player", "1:2:3", "1", 10); + assertEquals(3723000L, score.getTimeMillis()); + } + + @Test + void testGetTimeMillis_Malformed() { + assertEquals(Long.MAX_VALUE, new Score("player", "abc", "1", 10).getTimeMillis()); + assertEquals(Long.MAX_VALUE, new Score("player", "", "1", 10).getTimeMillis()); + assertEquals(Long.MAX_VALUE, new Score("player", "3:05:10.123.456", "1", 10).getTimeMillis()); + } + + @Test + void testFromString_ToString_RoundTrip() { + final Score score = new Score("player", "3:05.123", "Hard", 1500); + assertEquals(score, Score.fromString(score.toString())); + } +} diff --git a/src/test/java/dev/loki/loparkour/schematic/core/ParkourSchematicSizeTest.java b/src/test/java/dev/loki/loparkour/schematic/core/ParkourSchematicSizeTest.java new file mode 100644 index 0000000..9ff6dc8 --- /dev/null +++ b/src/test/java/dev/loki/loparkour/schematic/core/ParkourSchematicSizeTest.java @@ -0,0 +1,45 @@ +package dev.loki.loparkour.schematic.core; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link ParkourSchematic#isSaneSize(double, double, double)}. + */ +class ParkourSchematicSizeTest { + + @Test + void acceptsSaneDimensions() { + assertTrue(ParkourSchematic.isSaneSize(1, 1, 1)); + assertTrue(ParkourSchematic.isSaneSize(10, 5, 4)); + assertTrue(ParkourSchematic.isSaneSize(ParkourSchematic.MAX_SCHEMATIC_EDGE, 1, 1)); + } + + @Test + void rejectsZeroDimensions() { + assertFalse(ParkourSchematic.isSaneSize(0, 5, 4)); + assertFalse(ParkourSchematic.isSaneSize(5, 0, 4)); + assertFalse(ParkourSchematic.isSaneSize(5, 4, 0)); + } + + @Test + void rejectsNegativeDimensions() { + assertFalse(ParkourSchematic.isSaneSize(-1, 5, 4)); + assertFalse(ParkourSchematic.isSaneSize(5, -2, 4)); + assertFalse(ParkourSchematic.isSaneSize(5, 4, -3)); + } + + @Test + void rejectsOversizedEdge() { + assertFalse(ParkourSchematic.isSaneSize(ParkourSchematic.MAX_SCHEMATIC_EDGE + 1, 1, 1)); + assertFalse(ParkourSchematic.isSaneSize(1, ParkourSchematic.MAX_SCHEMATIC_EDGE + 1, 1)); + assertFalse(ParkourSchematic.isSaneSize(1, 1, ParkourSchematic.MAX_SCHEMATIC_EDGE + 1)); + } + + @Test + void rejectsTooManyBlocks() { + assertFalse(ParkourSchematic.isSaneSize(100, 100, 100)); + } +}