fix: resolve 11 P1 bugs from audit (leaderboard, SQL, coop, jump, lifecycle, schematics, config)
- 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
This commit is contained in:
parent
c0ca488b9d
commit
fea15e61c8
27 changed files with 1012 additions and 167 deletions
|
|
@ -35,6 +35,13 @@
|
||||||
<suppress checks="IllegalCatch" files=".*[\\/]command[\\/]core[\\/]LoParkourCommand\.java"/>
|
<suppress checks="IllegalCatch" files=".*[\\/]command[\\/]core[\\/]LoParkourCommand\.java"/>
|
||||||
<suppress checks="IllegalCatch" files=".*[\\/]config[\\/]core[\\/]merge[\\/]ConfigUpdater\.java"/>
|
<suppress checks="IllegalCatch" files=".*[\\/]config[\\/]core[\\/]merge[\\/]ConfigUpdater\.java"/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
SchematicManager: catches RuntimeException so a corrupted/truncated schematic
|
||||||
|
(WorldEdit/Bukkit throw unchecked exceptions) is skipped instead of breaking
|
||||||
|
plugin enable. Not swallowing — the failure is logged at SEVERE with the cause.
|
||||||
|
-->
|
||||||
|
<suppress checks="IllegalCatch" files=".*[\\/]schematic[\\/]core[\\/]SchematicManager\.java"/>
|
||||||
|
|
||||||
<!-- ===== IllegalCatch / NPathComplexity / WhitespaceAround (legacy debt) ===== -->
|
<!-- ===== IllegalCatch / NPathComplexity / WhitespaceAround (legacy debt) ===== -->
|
||||||
<suppress checks="IllegalCatch" files=".*[\\/]LoParkour.java"/>
|
<suppress checks="IllegalCatch" files=".*[\\/]LoParkour.java"/>
|
||||||
<suppress checks="NPathComplexity" files=".*[\\/]adaptive[\\/]bootstrap[\\/]AdaptiveServices.java"/>
|
<suppress checks="NPathComplexity" files=".*[\\/]adaptive[\\/]bootstrap[\\/]AdaptiveServices.java"/>
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,9 @@ public final class PluginBootstrap {
|
||||||
|
|
||||||
public static void enable(@NotNull LoParkour plugin) {
|
public static void enable(@NotNull LoParkour plugin) {
|
||||||
Config.reload(true);
|
Config.reload(true);
|
||||||
AdaptiveServices.init(plugin);
|
|
||||||
loadSchematics(plugin);
|
loadSchematics(plugin);
|
||||||
registerModes();
|
registerModes();
|
||||||
|
AdaptiveServices.init(plugin);
|
||||||
registerHooks(plugin);
|
registerHooks(plugin);
|
||||||
setupWorld(plugin);
|
setupWorld(plugin);
|
||||||
registerEventsAndCommands(plugin);
|
registerEventsAndCommands(plugin);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import java.io.InputStream;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Deque;
|
import java.util.Deque;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -36,26 +37,105 @@ public final class ConfigMergeParser {
|
||||||
@NotNull
|
@NotNull
|
||||||
public Map<String, String> extractFullPathValues(@NotNull List<String> lines) {
|
public Map<String, String> extractFullPathValues(@NotNull List<String> lines) {
|
||||||
final Map<String, String> values = new LinkedHashMap<>();
|
final Map<String, String> values = new LinkedHashMap<>();
|
||||||
final Deque<String> stack = new ArrayDeque<>();
|
final Deque<Section> stack = new ArrayDeque<>();
|
||||||
int prevIndent = -1;
|
|
||||||
|
|
||||||
for (final String line : lines) {
|
for (final String line : lines) {
|
||||||
final LineTokens tokens = parseLine(line);
|
final LineTokens tokens = parseLine(line);
|
||||||
if (tokens == null) {
|
if (tokens == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
prevIndent = popToLevel(stack, tokens.indent(), prevIndent);
|
popToLevel(stack, tokens.indent());
|
||||||
final String fullPath = resolvePath(stack, tokens.leaf());
|
final String fullPath = resolvePath(stack, tokens.leaf());
|
||||||
if (tokens.isSection()) {
|
if (tokens.isSection()) {
|
||||||
stack.push(tokens.leaf());
|
stack.push(new Section(tokens.leaf(), tokens.indent()));
|
||||||
} else {
|
} else {
|
||||||
values.put(fullPath, tokens.after());
|
values.put(fullPath, tokens.after());
|
||||||
}
|
}
|
||||||
prevIndent = tokens.indent();
|
|
||||||
}
|
}
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
public Map<String, List<String>> extractBlockSequences(@NotNull List<String> lines) {
|
||||||
|
final Map<String, List<String>> lists = new LinkedHashMap<>();
|
||||||
|
final Deque<Section> 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<String> 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<String> listItems(@NotNull List<String> lines, int from, int sectionIndent) {
|
||||||
|
final int start = firstListItemIndex(lines, from, sectionIndent);
|
||||||
|
if (start < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final List<String> items = new ArrayList<>();
|
||||||
|
for (int j = start; j < listEnd(lines, start); j++) {
|
||||||
|
items.add(lines.get(j).trim());
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
public static List<String> pathsFor(@NotNull List<String> lines) {
|
||||||
|
final List<String> paths = new ArrayList<>(lines.size());
|
||||||
|
final Deque<Section> 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<String> 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<String> lines, int start) {
|
||||||
|
int j = start;
|
||||||
|
while (j < lines.size() && isListItem(lines.get(j))) {
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
public static LineTokens parseLine(@NotNull String line) {
|
public static LineTokens parseLine(@NotNull String line) {
|
||||||
if (isSkippable(line)) {
|
if (isSkippable(line)) {
|
||||||
|
|
@ -80,20 +160,18 @@ public final class ConfigMergeParser {
|
||||||
return after.isEmpty() || after.startsWith("#");
|
return after.isEmpty() || after.startsWith("#");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int popToLevel(@NotNull Deque<String> stack, int indent, int prevIndent) {
|
private static void popToLevel(@NotNull Deque<Section> stack, int indent) {
|
||||||
int current = prevIndent;
|
while (!stack.isEmpty() && stack.peek().indent() >= indent) {
|
||||||
while (current >= 0 && indent <= current) {
|
stack.pop();
|
||||||
if (!stack.isEmpty()) {
|
|
||||||
stack.pop();
|
|
||||||
}
|
|
||||||
current -= 2;
|
|
||||||
}
|
}
|
||||||
return current;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
public static String resolvePath(@NotNull Deque<String> stack, @NotNull String leaf) {
|
private static String resolvePath(@NotNull Deque<Section> stack, @NotNull String leaf) {
|
||||||
return stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf;
|
if (stack.isEmpty()) {
|
||||||
|
return leaf;
|
||||||
|
}
|
||||||
|
return String.join(".", stack.stream().map(Section::name).toList().reversed()) + "." + leaf;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int indentOf(@NotNull String line) {
|
public static int indentOf(@NotNull String line) {
|
||||||
|
|
@ -104,6 +182,9 @@ public final class ConfigMergeParser {
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record Section(String name, int indent) {
|
||||||
|
}
|
||||||
|
|
||||||
public record LineTokens(String leaf, String after, int indent, boolean isSection) {
|
public record LineTokens(String leaf, String after, int indent, boolean isSection) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,13 @@ import org.jetbrains.annotations.Nullable;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.util.ArrayDeque;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Deque;
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
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 {
|
public final class ConfigMergeProcessor {
|
||||||
|
|
||||||
private final ConfigMergeParser parser = new ConfigMergeParser();
|
private final ConfigMergeParser parser = new ConfigMergeParser();
|
||||||
|
|
@ -27,44 +22,106 @@ public final class ConfigMergeProcessor {
|
||||||
@NotNull InputStream templateStream,
|
@NotNull InputStream templateStream,
|
||||||
@NotNull File existingFile,
|
@NotNull File existingFile,
|
||||||
@Nullable List<String> ignoredKeys) throws IOException {
|
@Nullable List<String> ignoredKeys) throws IOException {
|
||||||
|
|
||||||
final List<String> templateLines = parser.readLines(templateStream);
|
final List<String> templateLines = parser.readLines(templateStream);
|
||||||
final List<String> existingLines = existingFile.exists() ? parser.readLines(existingFile) : List.of();
|
final List<String> existingLines = existingFile.exists() ? parser.readLines(existingFile) : List.of();
|
||||||
|
return merge(templateLines, existingLines, ignoredKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
List<String> merge(
|
||||||
|
@NotNull List<String> templateLines,
|
||||||
|
@NotNull List<String> existingLines,
|
||||||
|
@Nullable List<String> ignoredKeys) {
|
||||||
final Set<String> ignored = normalizeIgnored(ignoredKeys);
|
final Set<String> ignored = normalizeIgnored(ignoredKeys);
|
||||||
final Map<String, String> existingValues = parser.extractFullPathValues(existingLines);
|
final Map<String, String> existingValues = parser.extractFullPathValues(existingLines);
|
||||||
|
final Map<String, List<String>> existingLists = parser.extractBlockSequences(existingLines);
|
||||||
|
final Map<String, List<String>> existingSections = extractSections(existingLines);
|
||||||
|
final List<String> templatePaths = ConfigMergeParser.pathsFor(templateLines);
|
||||||
final List<String> result = new ArrayList<>();
|
final List<String> result = new ArrayList<>();
|
||||||
final Deque<String> stack = new ArrayDeque<>();
|
int i = 0;
|
||||||
int prevIndent = -1;
|
while (i < templateLines.size()) {
|
||||||
|
final String line = templateLines.get(i);
|
||||||
for (final String line : templateLines) {
|
|
||||||
final ConfigMergeParser.LineTokens tokens = ConfigMergeParser.parseLine(line);
|
final ConfigMergeParser.LineTokens tokens = ConfigMergeParser.parseLine(line);
|
||||||
if (tokens == null) {
|
if (tokens == null) {
|
||||||
result.add(line);
|
result.add(line);
|
||||||
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
prevIndent = ConfigMergeParser.popToLevel(stack, tokens.indent(), prevIndent);
|
|
||||||
final String fullPath = ConfigMergeParser.resolvePath(stack, tokens.leaf());
|
|
||||||
if (tokens.isSection()) {
|
if (tokens.isSection()) {
|
||||||
stack.push(tokens.leaf());
|
i = appendSection(result, templateLines, i, templatePaths.get(i), ignored, existingSections, existingLists);
|
||||||
result.add(line);
|
|
||||||
} else if (isIgnored(fullPath, ignored)) {
|
|
||||||
result.add(line);
|
|
||||||
} else {
|
} else {
|
||||||
result.add(applyExistingValue(line, fullPath, existingValues));
|
result.add(applyExistingValue(line, templatePaths.get(i), existingValues));
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
prevIndent = tokens.indent();
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int appendSection(
|
||||||
|
@NotNull List<String> result,
|
||||||
|
@NotNull List<String> templateLines,
|
||||||
|
int i,
|
||||||
|
@NotNull String fullPath,
|
||||||
|
@NotNull Set<String> ignored,
|
||||||
|
@NotNull Map<String, List<String>> existingSections,
|
||||||
|
@NotNull Map<String, List<String>> 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<String> result,
|
||||||
|
@NotNull List<String> templateLines,
|
||||||
|
int i,
|
||||||
|
@NotNull String fullPath,
|
||||||
|
@NotNull Map<String, List<String>> existingSections) {
|
||||||
|
final List<String> 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<String> result,
|
||||||
|
@NotNull List<String> templateLines,
|
||||||
|
int i,
|
||||||
|
@NotNull String fullPath,
|
||||||
|
int sectionIndent,
|
||||||
|
@NotNull Map<String, List<String>> existingLists) {
|
||||||
|
final int listStart = ConfigMergeParser.firstListItemIndex(templateLines, i + 1, sectionIndent);
|
||||||
|
if (listStart < 0) {
|
||||||
|
return i + 1;
|
||||||
|
}
|
||||||
|
final List<String> 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
|
@NotNull
|
||||||
private static String applyExistingValue(
|
private static String applyExistingValue(
|
||||||
@NotNull String line,
|
@NotNull String line,
|
||||||
@NotNull String fullPath,
|
@NotNull String fullPath,
|
||||||
@NotNull Map<String, String> existingValues) {
|
@NotNull Map<String, String> existingValues) {
|
||||||
|
|
||||||
final String existing = existingValues.get(fullPath);
|
final String existing = existingValues.get(fullPath);
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
return line;
|
return line;
|
||||||
|
|
@ -72,6 +129,52 @@ public final class ConfigMergeProcessor {
|
||||||
return line.substring(0, line.indexOf(':') + 1) + " " + existing;
|
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<String, List<String>> extractSections(@NotNull List<String> lines) {
|
||||||
|
final Map<String, List<String>> sections = new LinkedHashMap<>();
|
||||||
|
final List<String> 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<String> sectionSpan(
|
||||||
|
@NotNull List<String> lines,
|
||||||
|
@NotNull List<String> paths,
|
||||||
|
int start) {
|
||||||
|
final String path = paths.get(start);
|
||||||
|
final int indent = ConfigMergeParser.indentOf(lines.get(start));
|
||||||
|
final List<String> 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
|
@NotNull
|
||||||
private static Set<String> normalizeIgnored(@Nullable List<String> keys) {
|
private static Set<String> normalizeIgnored(@Nullable List<String> keys) {
|
||||||
if (keys == null) {
|
if (keys == null) {
|
||||||
|
|
@ -93,4 +196,4 @@ public final class ConfigMergeProcessor {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ public class GeneratorState {
|
||||||
// Iteration is safe without external synchronization as all mutations happen on the same thread.
|
// Iteration is safe without external synchronization as all mutations happen on the same thread.
|
||||||
public final List<Block> history = new ArrayList<>();
|
public final List<Block> history = new ArrayList<>();
|
||||||
public int lastPositionIndexPlayer = -1;
|
public int lastPositionIndexPlayer = -1;
|
||||||
|
public int generatedBlocks = 0;
|
||||||
|
|
||||||
// Schematic state
|
// Schematic state
|
||||||
public boolean deleteSchematic = false;
|
public boolean deleteSchematic = false;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
package dev.loki.loparkour.generator.jump.calculation;
|
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.coordinator.ParkourGenerator;
|
||||||
|
import dev.loki.loparkour.generator.core.model.GeneratorOption;
|
||||||
import dev.loki.loparkour.generator.jump.placement.BlockSelector;
|
import dev.loki.loparkour.generator.jump.placement.BlockSelector;
|
||||||
import dev.loki.loparkour.util.misc.MaterialUtil;
|
import dev.loki.loparkour.util.misc.MaterialUtil;
|
||||||
|
import org.bukkit.Location;
|
||||||
import org.bukkit.Material;
|
import org.bukkit.Material;
|
||||||
import org.bukkit.block.Block;
|
import org.bukkit.block.Block;
|
||||||
import org.bukkit.block.data.BlockData;
|
import org.bukkit.block.data.BlockData;
|
||||||
|
|
@ -11,6 +12,10 @@ import org.bukkit.util.BoundingBox;
|
||||||
import org.bukkit.util.Vector;
|
import org.bukkit.util.Vector;
|
||||||
import org.jetbrains.annotations.NotNull;
|
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.
|
* 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 DEFAULT_MAX_DISTANCE = 4;
|
||||||
private static final int RESTRICTED_MAX_DISTANCE = 3;
|
private static final int RESTRICTED_MAX_DISTANCE = 3;
|
||||||
private static final double RANDOM_OFFSET_SD = 0.5;
|
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_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 PACKED_ICE_MAX_DISTANCE = 4;
|
||||||
private static final int BLUE_ICE_MAX_DISTANCE = 4;
|
private static final int BLUE_ICE_MAX_DISTANCE = 4;
|
||||||
private static final int ICE_MAX_DISTANCE = 3;
|
private static final int ICE_MAX_DISTANCE = 3;
|
||||||
|
|
@ -29,22 +40,21 @@ public class JumpCalculator {
|
||||||
|
|
||||||
private final ParkourGenerator generator;
|
private final ParkourGenerator generator;
|
||||||
private final BlockSelector blockSelector;
|
private final BlockSelector blockSelector;
|
||||||
|
|
||||||
public JumpCalculator(@NotNull ParkourGenerator generator, @NotNull BlockSelector blockSelector) {
|
public JumpCalculator(@NotNull ParkourGenerator generator, @NotNull BlockSelector blockSelector) {
|
||||||
this.generator = generator;
|
this.generator = generator;
|
||||||
this.blockSelector = blockSelector;
|
this.blockSelector = blockSelector;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate the next block position based on current block and jump parameters.
|
* Calculate the next block position based on current block and jump parameters.
|
||||||
*/
|
*/
|
||||||
@NotNull
|
@NotNull
|
||||||
public Block calculateNextBlock(@NotNull Block current, int distance, int height) {
|
public Block calculateNextBlock(@NotNull Block current, int distance, int height) {
|
||||||
// Ensure zone is initialized
|
|
||||||
if (generator.state.zone == null || generator.state.zone.length < 2) {
|
if (generator.state.zone == null || generator.state.zone.length < 2) {
|
||||||
throw new IllegalStateException("Generator zone not initialized");
|
throw new IllegalStateException("Generator zone not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
final JumpDirector director = new JumpDirector(
|
final JumpDirector director = new JumpDirector(
|
||||||
BoundingBox.of(generator.state.zone[0], generator.state.zone[1]),
|
BoundingBox.of(generator.state.zone[0], generator.state.zone[1]),
|
||||||
current.getLocation().toVector()
|
current.getLocation().toVector()
|
||||||
|
|
@ -53,12 +63,10 @@ public class JumpCalculator {
|
||||||
generator.state.heading = director.getRecommendedHeading(generator.state.heading);
|
generator.state.heading = director.getRecommendedHeading(generator.state.heading);
|
||||||
int resolvedHeight = director.getRecommendedHeight(height);
|
int resolvedHeight = director.getRecommendedHeight(height);
|
||||||
|
|
||||||
// Apply material-specific restrictions
|
|
||||||
final JumpConstraints constraints = calculateJumpConstraints(current);
|
final JumpConstraints constraints = calculateJumpConstraints(current);
|
||||||
resolvedHeight = Math.min(resolvedHeight, constraints.maxHeight);
|
resolvedHeight = Math.min(resolvedHeight, constraints.maxHeight);
|
||||||
int resolvedDistance = Math.min(distance, constraints.maxDistance);
|
int resolvedDistance = Math.min(distance, constraints.maxDistance);
|
||||||
|
|
||||||
// Clamp values to valid ranges
|
|
||||||
resolvedHeight = Math.max(MIN_HEIGHT, Math.min(2, resolvedHeight));
|
resolvedHeight = Math.max(MIN_HEIGHT, Math.min(2, resolvedHeight));
|
||||||
resolvedDistance = Math.max(1, Math.min(MAX_VALID_DISTANCE, resolvedDistance));
|
resolvedDistance = Math.max(1, Math.min(MAX_VALID_DISTANCE, resolvedDistance));
|
||||||
|
|
||||||
|
|
@ -66,33 +74,28 @@ public class JumpCalculator {
|
||||||
resolvedDistance = Math.max(resolvedDistance - resolvedHeight, 1);
|
resolvedDistance = Math.max(resolvedDistance - resolvedHeight, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate offset
|
final double randomOffsetSd = Arrays.asList(generator.generatorOptions)
|
||||||
final Vector offset = calculateJumpOffset(resolvedDistance, resolvedHeight);
|
.contains(GeneratorOption.REDUCE_RANDOM_BLOCK_SELECTION_ANGLE) ? RANDOM_OFFSET_SD : DEFAULT_RANDOM_OFFSET_SD;
|
||||||
final Block candidate = current.getLocation().add(offset).getBlock();
|
final Location candidate = validateAndRetryJump(current.getLocation(), generator.state.heading,
|
||||||
|
resolvedDistance, resolvedHeight, ThreadLocalRandom.current(), randomOffsetSd);
|
||||||
// Validate jump with retry mechanism
|
return candidate.getBlock();
|
||||||
return validateAndRetryJump(current, candidate, resolvedDistance, resolvedHeight);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate jump constraints based on the material of the current block.
|
|
||||||
*/
|
|
||||||
@NotNull
|
@NotNull
|
||||||
public JumpConstraints calculateJumpConstraints(@NotNull Block currentBlock) {
|
public JumpConstraints calculateJumpConstraints(@NotNull Block currentBlock) {
|
||||||
final Material material = currentBlock.getType();
|
final Material material = currentBlock.getType();
|
||||||
final BlockData blockData = currentBlock.getBlockData();
|
final BlockData blockData = currentBlock.getBlockData();
|
||||||
|
|
||||||
// Default constraints
|
|
||||||
int maxHeight = 1;
|
int maxHeight = 1;
|
||||||
int maxDistance = DEFAULT_MAX_DISTANCE;
|
int maxDistance = DEFAULT_MAX_DISTANCE;
|
||||||
|
|
||||||
if (MaterialUtil.isSlabMaterial(material)) {
|
if (MaterialUtil.isSlabMaterial(material)) {
|
||||||
if (blockData instanceof org.bukkit.block.data.type.Slab slab) {
|
if (blockData instanceof org.bukkit.block.data.type.Slab slab) {
|
||||||
if (slab.getType() == org.bukkit.block.data.type.Slab.Type.BOTTOM) {
|
if (slab.getType() == org.bukkit.block.data.type.Slab.Type.BOTTOM) {
|
||||||
maxHeight = -1; // Bottom slab: reduced height capability
|
maxHeight = -1;
|
||||||
maxDistance = RESTRICTED_MAX_DISTANCE;
|
maxDistance = RESTRICTED_MAX_DISTANCE;
|
||||||
} else {
|
} else {
|
||||||
maxHeight = 0; // Top slab: normal restrictions
|
maxHeight = 0;
|
||||||
maxDistance = RESTRICTED_MAX_DISTANCE;
|
maxDistance = RESTRICTED_MAX_DISTANCE;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -106,77 +109,89 @@ public class JumpCalculator {
|
||||||
maxHeight = getSpecialMaterialMaxHeight(material);
|
maxHeight = getSpecialMaterialMaxHeight(material);
|
||||||
maxDistance = getSpecialMaterialMaxDistance(material);
|
maxDistance = getSpecialMaterialMaxDistance(material);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new JumpConstraints(maxHeight, maxDistance);
|
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);
|
@NotNull
|
||||||
if (offset.getX() == 0) {
|
static Vector offsetFor(@NotNull Vector heading, int distance, int height, double randomOffset) {
|
||||||
offset.setX(randomOffset);
|
final Vector horizontal = new Vector(heading.getX(), 0, heading.getZ());
|
||||||
} else {
|
final double length = horizontal.length();
|
||||||
offset.setZ(randomOffset);
|
if (length < ZERO_HEADING_EPSILON) {
|
||||||
|
return new Vector(0, height, 0);
|
||||||
}
|
}
|
||||||
|
final Vector perpendicular = new Vector(-horizontal.getZ() / length, 0, horizontal.getX() / length);
|
||||||
return offset;
|
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
|
@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();
|
final JumpValidator validator = new JumpValidator();
|
||||||
int resolvedDistance = distance;
|
int resolvedDistance = distance;
|
||||||
int resolvedHeight = height;
|
int resolvedHeight = height;
|
||||||
Block resolvedCandidate = candidate;
|
|
||||||
int attempts = 0;
|
int attempts = 0;
|
||||||
|
Location candidate = current.clone().add(offsetFor(heading, resolvedDistance, resolvedHeight,
|
||||||
while (!validator.canJump(current.getLocation(), resolvedCandidate.getLocation()) && attempts < MAX_RETRY_ATTEMPTS) {
|
randomOffset(resolvedHeight, resolvedDistance, randomOffsetSd, random)));
|
||||||
// Reduce distance and height to make jump easier
|
while (!validator.canJump(current, candidate) && attempts < MAX_RETRY_ATTEMPTS) {
|
||||||
resolvedDistance = Math.max(1, resolvedDistance - 1);
|
resolvedDistance = Math.max(1, resolvedDistance - 1);
|
||||||
resolvedHeight = Math.max(MIN_HEIGHT, resolvedHeight - 1);
|
resolvedHeight = Math.max(MIN_HEIGHT, resolvedHeight - 1);
|
||||||
|
candidate = current.clone().add(offsetFor(heading, resolvedDistance, resolvedHeight,
|
||||||
final Vector offset = calculateJumpOffset(resolvedDistance, resolvedHeight);
|
randomOffset(resolvedHeight, resolvedDistance, randomOffsetSd, random)));
|
||||||
resolvedCandidate = current.getLocation().add(offset).getBlock();
|
|
||||||
attempts++;
|
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) {
|
private int getSpecialMaterialMaxHeight(@NotNull Material material) {
|
||||||
return switch (material) {
|
return switch (material) {
|
||||||
case PACKED_ICE -> 1; // Ice allows normal height
|
case PACKED_ICE -> 1;
|
||||||
case BLUE_ICE -> 1; // Blue ice same
|
case BLUE_ICE -> 1;
|
||||||
case ICE -> 0; // Regular ice melts, slippery
|
case ICE -> 0;
|
||||||
case LADDER -> 1; // Ladder allows some height
|
case LADDER -> 1;
|
||||||
default -> 0; // Most special materials restrict height
|
default -> 0;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private int getSpecialMaterialMaxDistance(@NotNull Material material) {
|
private int getSpecialMaterialMaxDistance(@NotNull Material material) {
|
||||||
return switch (material) {
|
return switch (material) {
|
||||||
case PACKED_ICE -> PACKED_ICE_MAX_DISTANCE; // Ice — full distance, slippery
|
case PACKED_ICE -> PACKED_ICE_MAX_DISTANCE;
|
||||||
case BLUE_ICE -> BLUE_ICE_MAX_DISTANCE; // Blue ice — full distance
|
case BLUE_ICE -> BLUE_ICE_MAX_DISTANCE;
|
||||||
case ICE -> ICE_MAX_DISTANCE; // Regular ice — reduced
|
case ICE -> ICE_MAX_DISTANCE;
|
||||||
case LADDER -> 2; // Ladder is very restrictive
|
case LADDER -> 2;
|
||||||
default -> DEFAULT_SPECIAL_MAX_DISTANCE; // Default restriction
|
default -> DEFAULT_SPECIAL_MAX_DISTANCE;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents jump constraints for a specific block type.
|
|
||||||
*/
|
|
||||||
public static class JumpConstraints {
|
public static class JumpConstraints {
|
||||||
public final int maxHeight;
|
public final int maxHeight;
|
||||||
public final int maxDistance;
|
public final int maxDistance;
|
||||||
|
|
||||||
public JumpConstraints(int maxHeight, int maxDistance) {
|
public JumpConstraints(int maxHeight, int maxDistance) {
|
||||||
this.maxHeight = maxHeight;
|
this.maxHeight = maxHeight;
|
||||||
this.maxDistance = maxDistance;
|
this.maxDistance = maxDistance;
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ public class BlockPlacer {
|
||||||
|
|
||||||
worldPlacer.place(block, blockData);
|
worldPlacer.place(block, blockData);
|
||||||
generator.state.history.add(block);
|
generator.state.history.add(block);
|
||||||
|
generator.state.generatedBlocks++;
|
||||||
generator.state.playerSpawn = spawn;
|
generator.state.playerSpawn = spawn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,6 +101,7 @@ public class BlockPlacer {
|
||||||
final Block selectedBlock = blocks.get(0);
|
final Block selectedBlock = blocks.get(0);
|
||||||
worldPlacer.place(selectedBlock, blockData);
|
worldPlacer.place(selectedBlock, blockData);
|
||||||
generator.state.history.add(selectedBlock);
|
generator.state.history.add(selectedBlock);
|
||||||
|
generator.state.generatedBlocks++;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void placeSpecialBlock() {
|
private void placeSpecialBlock() {
|
||||||
|
|
@ -118,5 +120,6 @@ public class BlockPlacer {
|
||||||
final Block selectedBlock = blocks.get(0);
|
final Block selectedBlock = blocks.get(0);
|
||||||
worldPlacer.place(selectedBlock, specialBlockData);
|
worldPlacer.place(selectedBlock, specialBlockData);
|
||||||
generator.state.history.add(selectedBlock);
|
generator.state.history.add(selectedBlock);
|
||||||
|
generator.state.generatedBlocks++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,6 +53,7 @@ class SchematicBlockPlacer {
|
||||||
}
|
}
|
||||||
|
|
||||||
generator.state.history.addAll(schematicBlocks);
|
generator.state.history.addAll(schematicBlocks);
|
||||||
|
generator.state.generatedBlocks += schematicBlocks.size();
|
||||||
generator.state.schematicBlocks = schematicBlocks;
|
generator.state.schematicBlocks = schematicBlocks;
|
||||||
|
|
||||||
for (final ParkourPlayer player : generator.getPlayers()) {
|
for (final ParkourPlayer player : generator.getPlayers()) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import dev.loki.loparkour.reward.core.Rewards;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
|
@ -90,9 +91,18 @@ public class LifecycleEventManager {
|
||||||
if (!Rewards.ONE_TIME_REWARDS.containsKey(score)) {
|
if (!Rewards.ONE_TIME_REWARDS.containsKey(score)) {
|
||||||
return;
|
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)) {
|
for (final Reward reward : Rewards.ONE_TIME_REWARDS.get(score)) {
|
||||||
reward.execute(player, mode);
|
reward.execute(player, mode);
|
||||||
}
|
}
|
||||||
|
player.collectedRewards.add(rewardKey);
|
||||||
|
player.save(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void processFall() {
|
private void processFall() {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package dev.loki.loparkour.generator.lifecycle.loop;
|
||||||
import dev.loki.loparkour.config.options.Option;
|
import dev.loki.loparkour.config.options.Option;
|
||||||
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
|
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
|
||||||
import dev.loki.loparkour.generator.lifecycle.player.GeneratorCleanup;
|
import dev.loki.loparkour.generator.lifecycle.player.GeneratorCleanup;
|
||||||
|
import org.bukkit.block.Block;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -71,7 +72,9 @@ public class LifecycleTickManager {
|
||||||
return;
|
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()) {
|
for (final var player : generator.getPlayers()) {
|
||||||
if (player.player.getLocation().getY() < fallThreshold) {
|
if (player.player.getLocation().getY() < fallThreshold) {
|
||||||
|
|
@ -89,7 +92,7 @@ public class LifecycleTickManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void maintainBlockLead() {
|
private void maintainBlockLead() {
|
||||||
final int currentBlocks = generator.state.history.size();
|
final int currentBlocks = generator.state.generatedBlocks;
|
||||||
final int targetBlocks = generator.state.score + GENERATION_LEAD;
|
final int targetBlocks = generator.state.score + GENERATION_LEAD;
|
||||||
|
|
||||||
if (currentBlocks < targetBlocks) {
|
if (currentBlocks < targetBlocks) {
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ public class GeneratorCleanup {
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
generator.state.history.clear();
|
generator.state.history.clear();
|
||||||
|
generator.state.generatedBlocks = 0;
|
||||||
generator.state.score = 0;
|
generator.state.score = 0;
|
||||||
generator.state.start = null;
|
generator.state.start = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ public class LeaderboardSorter {
|
||||||
return compareScore(one, two);
|
return compareScore(one, two);
|
||||||
}
|
}
|
||||||
case TIME -> {
|
case TIME -> {
|
||||||
return Integer.compare(one.getValue().getTimeMillis(), two.getValue().getTimeMillis());
|
return Long.compare(one.getValue().getTimeMillis(), two.getValue().getTimeMillis());
|
||||||
}
|
}
|
||||||
case DIFFICULTY -> {
|
case DIFFICULTY -> {
|
||||||
return compareDifficulty(one.getValue().difficulty(), two.getValue().difficulty());
|
return compareDifficulty(one.getValue().difficulty(), two.getValue().difficulty());
|
||||||
|
|
@ -74,8 +74,8 @@ public class LeaderboardSorter {
|
||||||
if (scoreComparison != 0) {
|
if (scoreComparison != 0) {
|
||||||
return scoreComparison;
|
return scoreComparison;
|
||||||
}
|
}
|
||||||
// Use Integer.compare to avoid overflow
|
// Use Long.compare to avoid overflow
|
||||||
return Integer.compare(one.getValue().getTimeMillis(), two.getValue().getTimeMillis());
|
return Long.compare(one.getValue().getTimeMillis(), two.getValue().getTimeMillis());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int compareDifficulty(String diff1, String diff2) {
|
private static int compareDifficulty(String diff1, String diff2) {
|
||||||
|
|
|
||||||
|
|
@ -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_PARTS_COUNT = 4;
|
||||||
private static final int SCORE_INDEX = 3;
|
private static final int SCORE_INDEX = 3;
|
||||||
private static final int TIME_PARTS_COUNT = 3;
|
private static final int TIME_SEGMENTS_MIN = 2;
|
||||||
private static final int SECONDS_PER_MINUTE = 60;
|
private static final int TIME_SEGMENTS_MAX = 3;
|
||||||
private static final int MILLIS_PER_SECOND = 1000;
|
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
|
* 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.
|
* @return This score's time in millis.
|
||||||
*/
|
*/
|
||||||
public int getTimeMillis() {
|
public long getTimeMillis() {
|
||||||
if ("?".equals(time)) {
|
if (UNKNOWN_TIME.equals(time)) {
|
||||||
return Integer.MAX_VALUE; // Unknown time sorts last
|
return UNKNOWN_TIME_MILLIS; // Unknown time sorts last
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final String[] split = time.split(":");
|
return parseTime(time);
|
||||||
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;
|
|
||||||
} catch (NumberFormatException e) {
|
} 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
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return String.format("%s,%s,%s,%s", name, time, difficulty, score);
|
return String.format("%s,%s,%s,%s", name, time, difficulty, score);
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ public class CoopMode implements MultiMode {
|
||||||
if (pp == null) {
|
if (pp == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ParkourUser.leave(pp);
|
session.removePlayers(pp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ public abstract class ParkourUser {
|
||||||
public final Player player;
|
public final Player player;
|
||||||
public final Instant joined;
|
public final Instant joined;
|
||||||
private ScoreboardManager scoreboardManager;
|
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)
|
@SuppressWarnings("deprecation") // Scoreboard: legacy registerNewObjective(String,String,String)
|
||||||
public ParkourUser(@NotNull Player player, @NotNull Session session, @Nullable PreviousData previousData) {
|
public ParkourUser(@NotNull Player player, @NotNull Session session, @Nullable PreviousData previousData) {
|
||||||
|
|
|
||||||
|
|
@ -61,23 +61,32 @@ public final class UserRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void unregister(@NotNull ParkourUser user, boolean restorePreviousData, boolean kickIfBungee, boolean urgent) {
|
public static void unregister(@NotNull ParkourUser user, boolean restorePreviousData, boolean kickIfBungee, boolean urgent) {
|
||||||
dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices.unloadPlayer(user.getUUID());
|
if (user.unregistering) {
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (restorePreviousData) {
|
user.unregistering = true;
|
||||||
restorePreviousState(user, urgent);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
package dev.loki.loparkour.schematic.core;
|
package dev.loki.loparkour.schematic.core;
|
||||||
|
|
||||||
import com.sk89q.worldedit.extent.clipboard.Clipboard;
|
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.nbt.StructurePaster;
|
||||||
import dev.loki.loparkour.schematic.schem.SchemPaster;
|
import dev.loki.loparkour.schematic.schem.SchemPaster;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
import org.bukkit.World;
|
import org.bukkit.World;
|
||||||
import org.bukkit.block.Block;
|
import org.bukkit.block.Block;
|
||||||
import org.bukkit.structure.Structure;
|
import org.bukkit.structure.Structure;
|
||||||
|
import org.bukkit.util.BlockVector;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
|
@ -17,6 +19,9 @@ import java.util.List;
|
||||||
*/
|
*/
|
||||||
public final class ParkourSchematic {
|
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 String id;
|
||||||
private final double difficulty;
|
private final double difficulty;
|
||||||
private final SchematicFormat format;
|
private final SchematicFormat format;
|
||||||
|
|
@ -49,6 +54,35 @@ public final class ParkourSchematic {
|
||||||
return new ParkourSchematic(id, difficulty, SchematicFormat.SCHEM, null, clipboard);
|
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
|
@NotNull
|
||||||
public String getId() {
|
public String getId() {
|
||||||
return id;
|
return id;
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads {@code .nbt}, {@code .schem}, and {@code .schematic} files from {@code plugins/LoParkour/schematics/}.
|
* Loads {@code .nbt}, {@code .schem}, and {@code .schematic} files from the schematics folder;
|
||||||
* Difficulty per structure is configured in {@code schematics/schematics.yml}.
|
* difficulty per structure is configured in {@code schematics/schematics.yml}.
|
||||||
*/
|
*/
|
||||||
public final class SchematicManager {
|
public final class SchematicManager {
|
||||||
|
|
||||||
|
|
@ -92,7 +92,6 @@ public final class SchematicManager {
|
||||||
if (files == null || files.length == 0) {
|
if (files == null || files.length == 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
final StructureManager structureManager = Compat.getStructureManager();
|
final StructureManager structureManager = Compat.getStructureManager();
|
||||||
if (structureManager == null) {
|
if (structureManager == null) {
|
||||||
return 0;
|
return 0;
|
||||||
|
|
@ -102,11 +101,17 @@ public final class SchematicManager {
|
||||||
final String stem = stem(file, ".nbt");
|
final String stem = stem(file, ".nbt");
|
||||||
try {
|
try {
|
||||||
final Structure structure = structureManager.loadStructure(file);
|
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));
|
register(stem, ParkourSchematic.fromNbt(stem, resolveDifficulty(stem), structure));
|
||||||
ok++;
|
ok++;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LoParkour.getPlugin().getLogger().severe("Failed to load structure: " + file.getName());
|
logLoadFailure("structure", file, e);
|
||||||
LoParkour.getPlugin().getLogger().log(Level.SEVERE, "Detailed cause:", e);
|
} catch (RuntimeException e) {
|
||||||
|
// broad catch: corrupted schematics throw unchecked exceptions; never break plugin enable
|
||||||
|
logLoadFailure("structure", file, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
|
|
@ -120,7 +125,6 @@ public final class SchematicManager {
|
||||||
if (files == null || files.length == 0) {
|
if (files == null || files.length == 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
int ok = 0;
|
int ok = 0;
|
||||||
for (final File file : files) {
|
for (final File file : files) {
|
||||||
final String stem = stem(file, file.getName().toLowerCase(Locale.ROOT).endsWith(".schematic")
|
final String stem = stem(file, file.getName().toLowerCase(Locale.ROOT).endsWith(".schematic")
|
||||||
|
|
@ -128,11 +132,17 @@ public final class SchematicManager {
|
||||||
: ".schem");
|
: ".schem");
|
||||||
try {
|
try {
|
||||||
final Clipboard clipboard = SchemLoader.load(file);
|
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));
|
register(stem, ParkourSchematic.fromSchem(stem, resolveDifficulty(stem), clipboard));
|
||||||
ok++;
|
ok++;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LoParkour.getPlugin().getLogger().severe("Failed to load schematic: " + file.getName());
|
logLoadFailure("schematic", file, e);
|
||||||
LoParkour.getPlugin().getLogger().log(Level.SEVERE, "Detailed cause:", e);
|
} catch (RuntimeException e) {
|
||||||
|
// broad catch: corrupted schematics throw unchecked exceptions; never break plugin enable
|
||||||
|
logLoadFailure("schematic", file, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
|
|
@ -149,6 +159,11 @@ public final class SchematicManager {
|
||||||
loaded.put(key, schematic);
|
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
|
@NotNull
|
||||||
private static String stem(@NotNull File file, @NotNull String suffix) {
|
private static String stem(@NotNull File file, @NotNull String suffix) {
|
||||||
final String name = file.getName();
|
final String name = file.getName();
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,27 @@ import java.io.IOException;
|
||||||
|
|
||||||
public final class SchemLoader {
|
public final class SchemLoader {
|
||||||
|
|
||||||
|
private static final int MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
private SchemLoader() {
|
private SchemLoader() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
public static Clipboard load(@NotNull File file) throws IOException {
|
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);
|
final ClipboardFormat format = ClipboardFormats.findByFile(file);
|
||||||
if (format == null) {
|
if (format == null) {
|
||||||
throw new IOException("Unsupported schematic file: " + file.getName());
|
throw new IOException("Unsupported schematic file: " + file.getName());
|
||||||
}
|
}
|
||||||
try (FileInputStream in = new FileInputStream(file);
|
try (FileInputStream in = new FileInputStream(file);
|
||||||
ClipboardReader reader = format.getReader(in)) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ public class SQLConnectionManager {
|
||||||
private static final int MAX_POOL_SIZE = 10;
|
private static final int MAX_POOL_SIZE = 10;
|
||||||
private static final int CONNECTION_TIMEOUT_MILLIS = 5000;
|
private static final int CONNECTION_TIMEOUT_MILLIS = 5000;
|
||||||
private static final int MAX_CONNECTION_LIFETIME_MILLIS = 1_800_000;
|
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 volatile boolean connected = false;
|
||||||
private final List<Runnable> onConnectCallbacks = new CopyOnWriteArrayList<>();
|
private final List<Runnable> onConnectCallbacks = new CopyOnWriteArrayList<>();
|
||||||
|
|
@ -47,6 +48,7 @@ public class SQLConnectionManager {
|
||||||
config.setMinimumIdle(2);
|
config.setMinimumIdle(2);
|
||||||
config.setConnectionTimeout(CONNECTION_TIMEOUT_MILLIS);
|
config.setConnectionTimeout(CONNECTION_TIMEOUT_MILLIS);
|
||||||
config.setMaxLifetime(MAX_CONNECTION_LIFETIME_MILLIS);
|
config.setMaxLifetime(MAX_CONNECTION_LIFETIME_MILLIS);
|
||||||
|
config.setInitializationFailTimeout(INITIALIZATION_FAIL_TIMEOUT);
|
||||||
config.setPoolName("LoParkour");
|
config.setPoolName("LoParkour");
|
||||||
|
|
||||||
dataSource = new HikariDataSource(config);
|
dataSource = new HikariDataSource(config);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package dev.loki.loparkour.storage.sql;
|
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.leaderboard.model.Score;
|
||||||
import dev.loki.loparkour.player.core.ParkourPlayer;
|
import dev.loki.loparkour.player.core.ParkourPlayer;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.jetbrains.annotations.Nullable;
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.lang.reflect.Type;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
|
@ -107,19 +110,49 @@ public class SQLDataMapper {
|
||||||
// Apply settings if available
|
// Apply settings if available
|
||||||
if (data.settings() != null && !data.settings().isEmpty()) {
|
if (data.settings() != null && !data.settings().isEmpty()) {
|
||||||
deserializeSettings(player, data.settings());
|
deserializeSettings(player, data.settings());
|
||||||
|
} else {
|
||||||
|
player.setSettings(new HashMap<>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
private static String serializeSettings(@NotNull ParkourPlayer player) {
|
private static String serializeSettings(@NotNull ParkourPlayer player) {
|
||||||
// Simplified settings serialization
|
final Map<String, Object> settings = new HashMap<>();
|
||||||
// In real implementation, would serialize player settings to JSON
|
settings.put("style", player.style);
|
||||||
return "{}";
|
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) {
|
private static void deserializeSettings(@NotNull ParkourPlayer player, @NotNull String settings) {
|
||||||
// Simplified settings deserialization
|
final Type type = new TypeToken<Map<String, Object>>() {}.getType();
|
||||||
// In real implementation, would deserialize JSON to player settings
|
final Map<String, Object> 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<String, Object> settings) {
|
||||||
|
for (final Map.Entry<String, Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
package dev.loki.loparkour.storage.sql;
|
package dev.loki.loparkour.storage.sql;
|
||||||
|
|
||||||
|
import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.leaderboard.model.Score;
|
import dev.loki.loparkour.leaderboard.model.Score;
|
||||||
import dev.loki.loparkour.player.core.ParkourPlayer;
|
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 org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import dev.loki.loparkour.storage.sql.repo.SQLPlayerRepository;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import dev.loki.loparkour.storage.sql.repo.SQLScoreRepository;
|
|
||||||
import dev.loki.loparkour.storage.sql.schema.SQLSchemaManager;
|
|
||||||
import java.util.logging.Logger;
|
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 Logger LOGGER = Logger.getLogger(StorageSQL.class.getName());
|
||||||
|
private static final Set<String> INITIALIZED_MODES = ConcurrentHashMap.newKeySet();
|
||||||
private static SQLConnectionManager connectionManager;
|
private static SQLConnectionManager connectionManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -43,11 +48,11 @@ public class StorageSQL {
|
||||||
* Run callback when database is connected.
|
* Run callback when database is connected.
|
||||||
*/
|
*/
|
||||||
public static void runWhenConnected(@NotNull Runnable callback) {
|
public static void runWhenConnected(@NotNull Runnable callback) {
|
||||||
if (isConnected()) {
|
if (connectionManager == null) {
|
||||||
callback.run();
|
LOGGER.warning("SQL storage not initialized yet, operation skipped");
|
||||||
} else {
|
return;
|
||||||
LOGGER.warning("Database not connected, skipping operation");
|
|
||||||
}
|
}
|
||||||
|
connectionManager.runWhenConnected(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,21 +63,31 @@ public class StorageSQL {
|
||||||
connectionManager.close();
|
connectionManager.close();
|
||||||
connectionManager = null;
|
connectionManager = null;
|
||||||
}
|
}
|
||||||
|
INITIALIZED_MODES.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize database connection and tables.
|
* Initialize database connection and tables.
|
||||||
*/
|
*/
|
||||||
public static void init(@NotNull String mode) {
|
public static void init(@NotNull String mode) {
|
||||||
try {
|
if (connectionManager == null) {
|
||||||
connectionManager = new SQLConnectionManager();
|
connectionManager = new SQLConnectionManager();
|
||||||
connectionManager.connect();
|
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);
|
LOGGER.info("Database initialized successfully for mode: " + mode);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
LOGGER.severe("Failed to initialize database: " + e.getMessage());
|
LOGGER.severe("Failed to initialize database for mode " + mode + ": " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ public class SQLPlayerRepository {
|
||||||
final SQLDataMapper.PlayerData data = SQLDataMapper.mapPlayerData(rs);
|
final SQLDataMapper.PlayerData data = SQLDataMapper.mapPlayerData(rs);
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
SQLDataMapper.applyPlayerData(player, data);
|
SQLDataMapper.applyPlayerData(player, data);
|
||||||
|
} else {
|
||||||
|
player.setSettings(new java.util.HashMap<>());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<String> user = List.of(
|
||||||
|
"debug: true",
|
||||||
|
"options:",
|
||||||
|
" leads:",
|
||||||
|
" amount:",
|
||||||
|
" - 1",
|
||||||
|
" - 2",
|
||||||
|
" - 3",
|
||||||
|
"",
|
||||||
|
" time:",
|
||||||
|
" format: 12");
|
||||||
|
|
||||||
|
final List<String> 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<String> template = lines(TEMPLATE);
|
||||||
|
assertEquals(template, processor.merge(template, template, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newTemplateKeysAreAdded() {
|
||||||
|
final List<String> template = List.of(
|
||||||
|
"old-key: 'old'",
|
||||||
|
"new-key: 'new'",
|
||||||
|
"new-section:",
|
||||||
|
" enabled: true");
|
||||||
|
final List<String> user = List.of("old-key: 'custom'");
|
||||||
|
|
||||||
|
final List<String> 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<String> user = List.of(
|
||||||
|
"styles:",
|
||||||
|
" list:",
|
||||||
|
" red: [custom_red_wool]",
|
||||||
|
" extra: [extra_wool]");
|
||||||
|
|
||||||
|
final List<String> 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<String> user = List.of("debug: false");
|
||||||
|
|
||||||
|
final List<String> 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<String> template = List.of(
|
||||||
|
"focus-mode:",
|
||||||
|
" enabled: false",
|
||||||
|
" whitelist:",
|
||||||
|
" - 'ip'",
|
||||||
|
" - 'msg'");
|
||||||
|
final List<String> user = List.of(
|
||||||
|
"focus-mode:",
|
||||||
|
" enabled: true",
|
||||||
|
" whitelist:",
|
||||||
|
" - 'custom'");
|
||||||
|
|
||||||
|
final List<String> 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<String> 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<String> template = List.of(
|
||||||
|
"difficulty:",
|
||||||
|
" d0e227d2: 0.25",
|
||||||
|
" dbef835e: 0.25",
|
||||||
|
" 27f41406: 1.0");
|
||||||
|
final List<String> user = List.of(
|
||||||
|
"difficulty:",
|
||||||
|
" d0e227d2: 0.5",
|
||||||
|
" dbef835e: 0.25",
|
||||||
|
" abc12345: 0.75");
|
||||||
|
|
||||||
|
final List<String> 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<String> template = List.of(
|
||||||
|
"world:",
|
||||||
|
" name: 'loparkour'",
|
||||||
|
" fall-back: 'world'",
|
||||||
|
" delete-on-reload: true");
|
||||||
|
final List<String> user = List.of(
|
||||||
|
"world:",
|
||||||
|
" name: 'custom'",
|
||||||
|
" fall-back: 'custom-world'");
|
||||||
|
|
||||||
|
final List<String> 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<String> ignored) throws IOException {
|
||||||
|
try (InputStream is = ConfigMergeProcessorTest.class.getResourceAsStream("/" + resource)) {
|
||||||
|
assertNotNull(is, "missing resource " + resource);
|
||||||
|
final List<String> 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<String> lines(String content) {
|
||||||
|
return content.lines().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertValidYaml(List<String> merged) {
|
||||||
|
final Object parsed = new Yaml().load(String.join("\n", merged));
|
||||||
|
assertNotNull(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue