diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4faa735 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,64 @@ +name: Build + +on: + push: + branches: [ main ] + pull_request: + workflow_dispatch: + +jobs: + # Build a shadowJar per Minecraft version against the matching Paper API. + build-matrix: + name: Build mc-${{ matrix.mc }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # 1.19.2 / 1.20.1 — Java 21 (plugin requires Java 21+; lolib jar is 21 bytecode) + - { mc: "1.19.2", coordinate: "io.papermc.paper:paper-api:1.19.2-R0.1-SNAPSHOT", java: 21, release: 21, api: "1.19" } + - { mc: "1.20.1", coordinate: "io.papermc.paper:paper-api:1.20.1-R0.1-SNAPSHOT", java: 21, release: 21, api: "1.20" } + # 1.21 — Java 21 + - { mc: "1.21", coordinate: "io.papermc.paper:paper-api:1.21-R0.1-SNAPSHOT", java: 21, release: 21, api: "1.20" } + # 1.21.11 — Java 21 + - { mc: "1.21.11", coordinate: "io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT", java: 21, release: 21, api: "1.21" } + # 26.1.2 / 26.2 — Java 25 (new-style paper-api build artifacts; requires the + # Gradle 9 wrapper + com.gradleup.shadow 9.x, which support running on Java 25). + - { mc: "26.1.2", coordinate: "io.papermc.paper:paper-api:26.1.2.build.74-stable", java: 25, release: 25, api: "1.21" } + - { mc: "26.2", coordinate: "io.papermc.paper:paper-api:26.2.build.97-stable", java: 25, release: 25, api: "1.21" } + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - name: Build jar (mc ${{ matrix.mc }}) + run: > + ./gradlew --no-daemon shadowJar + -PmcVersion=${{ matrix.mc }} + -PapiCoordinate='${{ matrix.coordinate }}' + -PjavaRelease=${{ matrix.release }} + -PapiVersion=${{ matrix.api }} + - name: Upload jar + uses: actions/upload-artifact@v5 + with: + name: LoParkour-${{ matrix.mc }} + path: build/libs/LoParkour-*.jar + + # Full quality gate (checkstyle + PMD + tests) on the default build. + quality: + name: Quality gate (default 1.20.4) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + - name: Full build + run: ./gradlew --no-daemon build + - name: Upload default jar + uses: actions/upload-artifact@v5 + with: + name: LoParkour-default-1.20.4 + path: build/libs/LoParkour-*.jar diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..388d77f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# LoParkour 1.4.0 + +## Русский + +### Новое + +- **Ghost: личные реплеи забегов.** Серверный тумблер записи (`ghost-mode.enabled`) + персональный переключатель в меню настроек. Запоминаются последние 5 забегов игрока (`ghost-mode.max-runs`). Просмотр через `/lp replay [номер]`: вас телепортирует на старт записанного забега, и моделька игрока с вашим скином повторяет движение по блокам. +- **Coop-режим теперь работает.** `/lp join coop` создаёт общую сессию, друзья заходят через `/lp join <ник>`, общий счёт и статистика вклада. +- **Мультиверсия в CI.** Сборка под Minecraft 1.19.2, 1.20.1, 1.20.4, 1.21, 1.21.11, 26.1.2, 26.2 (Java 21+). + +### Исправления + +- **Лидерборды.** Чинится TIME-сортировка: время `mm:ss.SSS` и `HH:mm:ss.SSS` теперь корректно парсятся, тай-брейки работают. +- **SQL-хранилище.** Один пул соединений вместо утечки на каждый режим, неблокирующее подключение, настройки игрока теперь реально сохраняются в базу. +- **ConfigUpdater.** Больше не затирает пользовательские настройки: списки и секции сохраняются, новые ключи добавляются точечно. +- **Генерация.** Диагональный курс больше не ходит зигзагом; генератор никогда не выдаёт недостижимый блок; чинится ложное падение на длинных спусках и генерация вхолостую. +- **Схематики.** Битые файлы больше не роняют плагин при запуске; добавлена проверка размеров и лимит файла. +- **One-time награды** выдаются один раз, а не каждый забег. +- **Coop-рекурсия** при выходе игрока устранена. +- **Мёртвый конфиг генератора** подключён: `jump-validation.*`, `useSpecialBlocks`, `blockLead` работают; неиспользуемые ключи `jump-types.*` и класс EffectManager удалены. +- **Сборка.** Gradle 9, актуальный shadow-плагин, убраны падающие Maven-репозитории. + +--- + +## English + +### New + +- **Ghost: personal run replays.** Server-wide recording toggle (`ghost-mode.enabled`) plus a per-player toggle in the settings menu. The last 5 runs per player are kept (`ghost-mode.max-runs`). Watch via `/lp replay [number]`: you are teleported to the start of the recorded run and a figure with your skin replays the movement block by block. +- **Coop mode now works.** `/lp join coop` creates a shared session, friends join via `/lp join `, shared score and contribution stats. +- **Multi-version CI.** Builds for Minecraft 1.19.2, 1.20.1, 1.20.4, 1.21, 1.21.11, 26.1.2, 26.2 (Java 21+). + +### Fixes + +- **Leaderboards.** TIME sorting fixed: `mm:ss.SSS` and `HH:mm:ss.SSS` are parsed correctly, tie-breakers work. +- **SQL storage.** Single connection pool instead of a leak per mode, non-blocking connect, player settings are now actually persisted to the database. +- **ConfigUpdater.** No longer wipes user settings: lists and sections are preserved, new keys are added surgically. +- **Generation.** Diagonal courses no longer zigzag; the generator never emits an unreachable block; false falls on long descents and idle generation are fixed. +- **Schematics.** Corrupt files no longer crash the plugin on startup; size validation and a file size limit were added. +- **One-time rewards** are granted once per player, not every run. +- **Coop recursion** on player leave is eliminated. +- **Dead generator config** is wired up: `jump-validation.*`, `useSpecialBlocks`, `blockLead` now work; unused `jump-types.*` keys and the EffectManager class were removed. +- **Build.** Gradle 9, up-to-date shadow plugin, flaky Maven repositories removed. diff --git a/README.md b/README.md index be426fb..1536a43 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Advanced infinitely generating parkour plugin with ghost system, leaderboards an ![Paper](https://img.shields.io/badge/Paper-1.19.2+-blue?style=flat-square) ![Folia](https://img.shields.io/badge/Folia-supported-purple?style=flat-square) ![License](https://img.shields.io/badge/license-GPLv3-blue?style=flat-square&logo=gnu&logoColor=white) -![version](https://img.shields.io/badge/version-1.3.3-green?style=flat-square) +![version](https://img.shields.io/badge/version-1.4.0-green?style=flat-square) [English](#english) | [Русский](#russian) diff --git a/build.gradle.kts b/build.gradle.kts index 0885b7f..30b5586 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,24 +1,41 @@ +import java.io.File +import java.net.URI +import java.util.zip.ZipFile + +// Remap tooling: rebuilds the Mojang-mapped 1.20.4 NMS classes at build time. +buildscript { + repositories { + mavenCentral() + maven("https://maven.fabricmc.net/") + } + dependencies { + classpath("net.fabricmc:mapping-io:0.6.1") + classpath("net.fabricmc:tiny-remapper:0.9.0") + } +} + plugins { id("java") - id("io.github.goooler.shadow") version("8.1.8") + id("com.gradleup.shadow") version("9.6.1") id("xyz.jpenilla.run-paper") version("3.0.2") checkstyle pmd } group = "dev.loki" -version = "1.3.3" +version = "1.4.0" description = "LoParkour - Advanced parkour plugin for Minecraft" -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} +// Multi-version build: pass -PmcVersion / -PapiCoordinate / -PjavaRelease / -PapiVersion +// to build the same source against a different Paper API (e.g. GitHub Actions matrix). +val mcVersion: String = providers.gradleProperty("mcVersion").getOrElse("1.20.4") +val apiCoordinate: String = providers.gradleProperty("apiCoordinate") + .getOrElse("org.spigotmc:spigot-api:$mcVersion-R0.1-SNAPSHOT") +val javaRelease: Int = providers.gradleProperty("javaRelease").map { it.toInt() }.getOrElse(21) +val apiVersion: String = providers.gradleProperty("apiVersion").getOrElse("1.20") repositories { mavenCentral() - maven { name = "papermc" url = uri("https://repo.papermc.io/repository/maven-public/") @@ -44,11 +61,6 @@ repositories { url = uri("https://repo.extendedclip.com/content/repositories/placeholderapi/") } - maven { - name = "mv-repo" - url = uri("https://repo.onarandombox.com/content/groups/public/") - } - maven { name = "dmulloy2-repo" url = uri("https://repo.dmulloy2.net/repository/public/") @@ -76,9 +88,95 @@ repositories { } } +// --- Mojang-mapped NMS (net.minecraft.*) for the packet NPC renderer --------- +// repo.papermc.io purged the old io.papermc.paper:paper:1.20.4-R0.1-SNAPSHOT +// dev-bundle, so the 1.20.4 vanilla server jar (bundler/obfuscated) is remapped +// with Mojang's official mappings via tiny-remapper into the same Mojang-mapped +// classes the dev-bundle shipped. Result is cached under the Gradle user home. +val nmsVersion = "1.20.4" +val nmsCacheDir = gradle.gradleUserHomeDir.resolve("caches/loparkour-nms/$nmsVersion") +val nmsServerJar = nmsCacheDir.resolve("server.jar") +val nmsNestedJar = nmsCacheDir.resolve("server-nested.jar") +val nmsMappings = nmsCacheDir.resolve("server-mappings.txt") +val nmsTiny = nmsCacheDir.resolve("server-mappings.tiny") +val nmsMappedJar = nmsCacheDir.resolve("server-mojang-mapped.jar") +val nmsAuthlibJar = nmsCacheDir.resolve("authlib-6.0.52.jar") + +val remapNms by tasks.registering { + outputs.files(nmsServerJar, nmsMappings, nmsMappedJar, nmsAuthlibJar) + doLast { + nmsCacheDir.mkdirs() + fun download(url: String, target: File) { + if (target.isFile) { + return + } + URI(url).toURL().openStream().use { input -> + target.outputStream().use { input.copyTo(it) } + } + } + download("https://piston-data.mojang.com/v1/objects/8dd1a28015f51b1803213892b50b7b4fc76e594d/server.jar", nmsServerJar) + download("https://piston-data.mojang.com/v1/objects/c1cafe916dd8b58ed1fe0564fc8f786885224e62/server.txt", nmsMappings) + download("https://libraries.minecraft.net/com/mojang/authlib/6.0.52/authlib-6.0.52.jar", nmsAuthlibJar) + if (!nmsNestedJar.isFile) { + ZipFile(nmsServerJar).use { zip -> + zip.getInputStream(zip.getEntry("META-INF/versions/$nmsVersion/server-$nmsVersion.jar")).use { input -> + nmsNestedJar.outputStream().use { input.copyTo(it) } + } + } + } + val tree = net.fabricmc.mappingio.tree.MemoryMappingTree(false) + net.fabricmc.mappingio.MappingReader.read(nmsMappings.toPath(), net.fabricmc.mappingio.format.MappingFormat.PROGUARD_FILE, tree) + net.fabricmc.mappingio.MappingWriter.create(nmsTiny.toPath(), net.fabricmc.mappingio.format.MappingFormat.TINY_2_FILE).use { writer -> + tree.accept(net.fabricmc.mappingio.adapter.MappingSourceNsSwitch(writer, "target")) + } + val remapper = net.fabricmc.tinyremapper.TinyRemapper.newRemapper() + .withMappings(net.fabricmc.tinyremapper.TinyUtils.createTinyMappingProvider(nmsTiny.toPath(), "target", "source")) + .ignoreConflicts(true) + .rebuildSourceFilenames(true) + .build() + net.fabricmc.tinyremapper.OutputConsumerPath.Builder(nmsMappedJar.toPath()).build().use { output -> + output.addNonClassFiles(nmsNestedJar.toPath()) + remapper.readInputs(nmsNestedJar.toPath()) + remapper.apply(output) + } + remapper.finish() + } +} + +tasks.withType().configureEach { dependsOn(remapNms) } + +// Resolve the org.bukkit:bukkit capability conflict that newer paper-api artifacts +// introduce (they both bundle the bukkit classes and declare the capability). +configurations.configureEach { + resolutionStrategy.capabilitiesResolution.withCapability("org.bukkit:bukkit") { + selectHighestVersion() + } +} + dependencies { - // Spigot API - compileOnly("org.spigotmc:spigot-api:1.20.4-R0.1-SNAPSHOT") + // Spigot/Paper API (overridable per MC version via -PapiCoordinate). + // Newer paper-api artifacts declare a conflicting org.bukkit:bukkit capability + // (they bundle the classes themselves) — drop the duplicate transitive. + if (apiCoordinate.startsWith("io.papermc.paper:paper-api")) { + val (apiGroup, apiName, apiVersion) = apiCoordinate.split(":") + compileOnly(mapOf( + "group" to apiGroup, + "name" to apiName, + "version" to apiVersion, + "exclude" to mapOf("group" to "org.bukkit", "module" to "bukkit") + )) + } else { + compileOnly(apiCoordinate) + } + + // NMS (net.minecraft.*) for packet NPC rendering — compileOnly, Mojang-mapped. + // Built once by the remapNms task above (vanilla 1.20.4 server + official + // mappings) because the io.papermc.paper:paper:1.20.4 dev-bundle was purged. + compileOnly(files(nmsMappedJar, nmsAuthlibJar)) + // fastutil types (IntList) are referenced by the NMS packet signatures. 8.5.15 + // matches the version pinned by the new-style paper-api platform (1.21.11+/26.x) + // and resolves fine against the older paper-api artifacts too. + compileOnly("it.unimi.dsi:fastutil:8.5.15") // Adventure API (for Component) compileOnly("net.kyori:adventure-api:4.14.0") @@ -89,13 +187,16 @@ dependencies { // Shaded dependencies implementation("io.papermc:paperlib:1.0.7") - implementation("com.google.code.gson:gson:2.10.1") + implementation("com.google.code.gson:gson:2.11.0") implementation("org.bstats:bstats-bukkit:3.2.1") implementation("com.zaxxer:HikariCP:5.1.0") implementation("com.mysql:mysql-connector-j:8.3.0") implementation("org.apache.commons:commons-math3:3.6.1") - compileOnly("com.sk89q.worldedit:worldedit-core:7.3.6") - compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.3.6") + // worldedit-core brings its own platform constraint on fastutil (strictly 8.5.12), + // which conflicts with the new-style paper-api platform (8.5.15+); we only need + // worldedit's own classes (Clipboard, BlockVector3, ...), not its transitive deps. + compileOnly("com.sk89q.worldedit:worldedit-core:7.3.6") { isTransitive = false } + compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.3.6") { isTransitive = false } // Provided dependencies (plugins) compileOnly("me.clip:placeholderapi:2.11.6") @@ -112,11 +213,13 @@ dependencies { testImplementation("org.mockito:mockito-junit-jupiter:5.10.0") testImplementation("org.spigotmc:spigot-api:1.20.4-R0.1-SNAPSHOT") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + // Test runtime classpath for loading PlayerGhost (references NMS + authlib types). + testImplementation(files(nmsMappedJar, nmsAuthlibJar)) } tasks.shadowJar { archiveClassifier.set("") - archiveFileName.set("LoParkour-${project.version}.jar") + archiveFileName.set("LoParkour-${project.version}-mc${mcVersion}.jar") // Relocate shaded dependencies relocate("dev.lolib", "dev.loki.loparkour.lib.lolib") @@ -141,7 +244,8 @@ tasks.withType { filesMatching(listOf("plugin.yml", "config.yml")) { expand( "version" to project.version, - "description" to project.description + "description" to (project.description ?: ""), + "apiVersion" to apiVersion ) } } @@ -152,7 +256,7 @@ tasks.withType { tasks.withType { options.encoding = "UTF-8" - options.release = 21 + options.release = javaRelease } tasks.withType { 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/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d4081da..a351597 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/dev/loki/loparkour/LoParkour.java b/src/main/java/dev/loki/loparkour/LoParkour.java index e5a30fd..53f3fd9 100644 --- a/src/main/java/dev/loki/loparkour/LoParkour.java +++ b/src/main/java/dev/loki/loparkour/LoParkour.java @@ -2,6 +2,7 @@ package dev.loki.loparkour; import dev.loki.loparkour.bootstrap.PluginBootstrap; import dev.loki.loparkour.config.core.Config; +import dev.loki.loparkour.ghost.service.GhostService; import dev.loki.loparkour.hook.papi.PAPIHook; import dev.loki.loparkour.mode.base.Modes; import dev.loki.loparkour.player.core.ParkourUser; @@ -37,6 +38,7 @@ public final class LoParkour extends LoPlugin { private static LoParkour instance; private static SchematicManager schematicManager; + private static GhostService ghostService; @Nullable private static PAPIHook placeholderHook; @@ -68,10 +70,18 @@ public final class LoParkour extends LoPlugin { return schematicManager; } + public static GhostService getGhostService() { + return ghostService; + } + public void setSchematicManager(@NotNull SchematicManager manager) { schematicManager = manager; } + public void setGhostService(@NotNull GhostService service) { + ghostService = service; + } + public void setPlaceholderHook(@Nullable PAPIHook hook) { placeholderHook = hook; } @@ -110,6 +120,9 @@ public final class LoParkour extends LoPlugin { Rewards.clear(); dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices.shutdown(); + if (ghostService != null) { + ghostService.shutdown(); + } Storage.close(); World.delete(); } catch (Throwable t) { diff --git a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java index b040b20..7eebebd 100644 --- a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java +++ b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java @@ -8,6 +8,7 @@ import dev.loki.loparkour.config.core.Config; import dev.loki.loparkour.config.locale.Locales; import dev.loki.loparkour.config.options.Option; import dev.loki.loparkour.duels.DuelsMode; +import dev.loki.loparkour.ghost.service.GhostService; import dev.loki.loparkour.hook.holo.HoloHook; import dev.loki.loparkour.hook.papi.PAPIHook; import dev.loki.loparkour.listener.gameplay.ParkourRestrictionListener; @@ -47,9 +48,10 @@ public final class PluginBootstrap { public static void enable(@NotNull LoParkour plugin) { Config.reload(true); - AdaptiveServices.init(plugin); loadSchematics(plugin); registerModes(); + plugin.setGhostService(new GhostService()); + AdaptiveServices.init(plugin); registerHooks(plugin); setupWorld(plugin); registerEventsAndCommands(plugin); diff --git a/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java b/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java index 89c828d..22428cf 100644 --- a/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java +++ b/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java @@ -4,6 +4,7 @@ import dev.loki.loparkour.command.admin.AdminCommandHandler; import dev.loki.loparkour.command.player.JoinCommandExecutor; import dev.loki.loparkour.command.player.LeaderboardCommandExecutor; import dev.loki.loparkour.command.player.PlayerCommandHandler; +import dev.loki.loparkour.command.player.ReplayCommandExecutor; import dev.loki.loparkour.command.schematic.SchematicCommandHandler; import dev.loki.loparkour.command.util.CommandUtil; import dev.loki.loparkour.config.locale.Locales; @@ -80,6 +81,7 @@ class CommandRouter { switch (a1.toLowerCase()) { case "join" -> join.join(a2, sender, p, player); case "leaderboard" -> leaderboard.open(a2, sender, p); + case "replay" -> new ReplayCommandExecutor().replay(a2, sender, p, player); case "schematic" -> { if (!p.hasPermission(ParkourOption.ADMIN.permission)) { CommandUtil.send(sender, Locales.getString(p, "other.no_do")); diff --git a/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java b/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java index 157b87a..d07996f 100644 --- a/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java +++ b/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java @@ -23,6 +23,7 @@ class CommandTabCompleter { private static final List SCHEMATIC_SUBCOMMANDS = List.of( "wand", "pos1", "pos2", "create", "paste", "list", "reload" ); + private static final List REPLAY_RUN_INDEXES = List.of("1", "2", "3", "4", "5"); private CommandTabCompleter() { } @@ -49,6 +50,7 @@ class CommandTabCompleter { completions.add("join"); completions.add("leave"); } + completions.add("replay"); if (ParkourOption.MAIN.mayPerform(sender)) { completions.add("menu"); } @@ -70,6 +72,7 @@ class CommandTabCompleter { return switch (a1) { case "reset" -> completeReset(sender, input); case "join" -> completeJoin(sender, input); + case "replay" -> completeReplay(sender, input); case "leaderboard" -> completeLeaderboard(sender, input); case "schematic" -> completeSchematic(sender, input); case "create" -> completeCreate(sender, input); @@ -141,14 +144,15 @@ class CommandTabCompleter { return filter(input, completions); } + private static List completeReplay(CommandSender sender, String input) { + final List completions = new ArrayList<>(REPLAY_RUN_INDEXES); + return filter(input, completions); + } + private static List completeThird(String a1Raw, String a2Raw, String input) { if (!"schematic".equalsIgnoreCase(a1Raw)) { return Collections.emptyList(); } - return completeSchematicThird(a2Raw, input); - } - - private static List completeSchematicThird(String a2Raw, String input) { final List completions = new ArrayList<>(); if ("paste".equalsIgnoreCase(a2Raw)) { addLoadedSchematicIds(completions); diff --git a/src/main/java/dev/loki/loparkour/command/player/JoinCommandExecutor.java b/src/main/java/dev/loki/loparkour/command/player/JoinCommandExecutor.java index 4a08852..b852e68 100644 --- a/src/main/java/dev/loki/loparkour/command/player/JoinCommandExecutor.java +++ b/src/main/java/dev/loki/loparkour/command/player/JoinCommandExecutor.java @@ -23,7 +23,8 @@ public class JoinCommandExecutor { if (!player.cooldown(sender, "join", COOLDOWN_MS) || !ParkourOption.JOIN.mayPerform(p)) { return; } - if (Registry.getMode(arg) instanceof Mode mode) { + final Mode mode = Registry.getMode(arg); + if (mode != null) { mode.create(p); return; } diff --git a/src/main/java/dev/loki/loparkour/command/player/PlayerCommandHandler.java b/src/main/java/dev/loki/loparkour/command/player/PlayerCommandHandler.java index 26d628f..daf75cd 100644 --- a/src/main/java/dev/loki/loparkour/command/player/PlayerCommandHandler.java +++ b/src/main/java/dev/loki/loparkour/command/player/PlayerCommandHandler.java @@ -77,6 +77,7 @@ public class PlayerCommandHandler { } Menus.LEADERBOARDS.open(player); } + case "replay" -> new ReplayCommandExecutor().replay(null, sender, player, this); case "schematic" -> { if (!player.hasPermission(ParkourOption.ADMIN.permission)) { send(sender, Locales.getString(player, "other.no_do")); @@ -121,6 +122,7 @@ public class PlayerCommandHandler { send(sender, Locales.getString(sender, "commands.help.header")); send(sender, ""); send(sender, Locales.getString(sender, "commands.help.line_parkour")); + send(sender, Locales.getString(sender, "commands.help.line_replay")); if (sender.hasPermission(ParkourOption.JOIN.permission)) { send(sender, Locales.getString(sender, "commands.help.line_join")); send(sender, Locales.getString(sender, "commands.help.line_leave")); diff --git a/src/main/java/dev/loki/loparkour/command/player/ReplayCommandExecutor.java b/src/main/java/dev/loki/loparkour/command/player/ReplayCommandExecutor.java new file mode 100644 index 0000000..e2b7af9 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/player/ReplayCommandExecutor.java @@ -0,0 +1,65 @@ +package dev.loki.loparkour.command.player; + +import dev.loki.loparkour.LoParkour; +import dev.loki.loparkour.command.util.CommandUtil; +import dev.loki.loparkour.config.core.Config; +import dev.loki.loparkour.config.locale.Locales; +import dev.loki.loparkour.ghost.model.GhostData; +import dev.loki.loparkour.ghost.service.GhostService; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * Handles {@code /lp replay [index]}: plays back a player's recorded ghost runs. + */ +public class ReplayCommandExecutor { + + private static final int COOLDOWN_MS = 2500; + private static final int DEFAULT_RUN_INDEX = 1; + private static final int INVALID_RUN_INDEX = -1; + + public void replay(@Nullable String indexArg, @NotNull CommandSender sender, @NotNull Player p, + @NotNull PlayerCommandHandler player) { + if (!player.cooldown(sender, "replay", COOLDOWN_MS)) { + return; + } + final GhostService ghostService = LoParkour.getGhostService(); + if (ghostService == null || !Config.CONFIG.getBoolean("ghost-mode.enabled")) { + CommandUtil.send(sender, Locales.getString(sender, "commands.replay.disabled")); + return; + } + final GhostData run = resolveRun(indexArg, sender, p, ghostService); + if (run == null) { + return; + } + ghostService.startReplay(p, run); + CommandUtil.send(sender, Locales.getString(sender, "commands.replay.starting")); + } + + private static GhostData resolveRun(@Nullable String indexArg, @NotNull CommandSender sender, + @NotNull Player p, @NotNull GhostService ghostService) { + final List runs = ghostService.getManager().getRuns(p.getUniqueId()); + if (runs.isEmpty()) { + CommandUtil.send(sender, Locales.getString(sender, "commands.replay.no_runs")); + return null; + } + final int index = indexArg == null ? DEFAULT_RUN_INDEX : parseIndex(indexArg); + if (index < DEFAULT_RUN_INDEX || index > runs.size()) { + CommandUtil.send(sender, Locales.getString(sender, "commands.replay.invalid")); + return null; + } + return runs.get(index - DEFAULT_RUN_INDEX); + } + + private static int parseIndex(String indexArg) { + try { + return Integer.parseInt(indexArg); + } catch (NumberFormatException e) { + return INVALID_RUN_INDEX; + } + } +} 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/config/options/Option.java b/src/main/java/dev/loki/loparkour/config/options/Option.java index ce94ad0..f58262a 100644 --- a/src/main/java/dev/loki/loparkour/config/options/Option.java +++ b/src/main/java/dev/loki/loparkour/config/options/Option.java @@ -87,17 +87,11 @@ public class Option { public static int BLOCK_CLEANUP_DISTANCE; public static int CLEANUP_INTERVAL; public static int TRAIL_KEEP_BEHIND; - public static boolean GHOST_MODE_ENABLED; - public static int GHOST_SHOW_TOP; - public static double GHOST_TRANSPARENCY; public static boolean JUMP_VALIDATION_ENABLED; public static double MAX_JUMP_DISTANCE; public static double MAX_HORIZONTAL_DISTANCE; public static double MAX_VERTICAL_UP; public static double MAX_VERTICAL_DOWN; - public static boolean JUMP_TYPES_ENABLED; - public static Map JUMP_TYPE_ENABLED; - public static Map JUMP_TYPE_CHANCE; // General public static double BORDER_SIZE; @@ -146,17 +140,11 @@ public class Option { BLOCK_CLEANUP_DISTANCE = OptionGeneration.BLOCK_CLEANUP_DISTANCE; CLEANUP_INTERVAL = OptionGeneration.CLEANUP_INTERVAL; TRAIL_KEEP_BEHIND = OptionGeneration.TRAIL_KEEP_BEHIND; - GHOST_MODE_ENABLED = OptionGeneration.GHOST_MODE_ENABLED; - GHOST_SHOW_TOP = OptionGeneration.GHOST_SHOW_TOP; - GHOST_TRANSPARENCY = OptionGeneration.GHOST_TRANSPARENCY; JUMP_VALIDATION_ENABLED = OptionGeneration.JUMP_VALIDATION_ENABLED; MAX_JUMP_DISTANCE = OptionGeneration.MAX_JUMP_DISTANCE; MAX_HORIZONTAL_DISTANCE = OptionGeneration.MAX_HORIZONTAL_DISTANCE; MAX_VERTICAL_UP = OptionGeneration.MAX_VERTICAL_UP; MAX_VERTICAL_DOWN = OptionGeneration.MAX_VERTICAL_DOWN; - JUMP_TYPES_ENABLED = OptionGeneration.JUMP_TYPES_ENABLED; - JUMP_TYPE_ENABLED = OptionGeneration.JUMP_TYPE_ENABLED; - JUMP_TYPE_CHANCE = OptionGeneration.JUMP_TYPE_CHANCE; // General BORDER_SIZE = OptionGeneral.BORDER_SIZE; diff --git a/src/main/java/dev/loki/loparkour/config/options/section/OptionGeneration.java b/src/main/java/dev/loki/loparkour/config/options/section/OptionGeneration.java index f18fb09..3300cf8 100644 --- a/src/main/java/dev/loki/loparkour/config/options/section/OptionGeneration.java +++ b/src/main/java/dev/loki/loparkour/config/options/section/OptionGeneration.java @@ -3,11 +3,8 @@ package dev.loki.loparkour.config.options.section; import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.config.core.Config; -import java.util.HashMap; -import java.util.Map; - /** - * Generation-related configuration options (jump types, validation, memory, ghost mode). + * Generation-related configuration options (validation, memory). */ public class OptionGeneration { @@ -17,8 +14,6 @@ public class OptionGeneration { private static final int DEFAULT_BLOCK_CLEANUP_DISTANCE = 100; private static final int DEFAULT_CLEANUP_INTERVAL = 100; private static final int DEFAULT_TRAIL_KEEP_BEHIND = 10; - private static final int DEFAULT_GHOST_SHOW_TOP = 3; - private static final double DEFAULT_GHOST_TRANSPARENCY = 0.5; private static final double PERCENT_DIVISOR = 100.0; private static final int DEFAULT_MIN_Y = 100; private static final int DEFAULT_MAX_Y = 200; @@ -50,11 +45,6 @@ public class OptionGeneration { public static int CLEANUP_INTERVAL; public static int TRAIL_KEEP_BEHIND; - // ── Ghost mode ──────────────────────────────────────────────────────────── - public static boolean GHOST_MODE_ENABLED; - public static int GHOST_SHOW_TOP; - public static double GHOST_TRANSPARENCY; - // ── Jump validation ─────────────────────────────────────────────────────── public static boolean JUMP_VALIDATION_ENABLED; public static double MAX_JUMP_DISTANCE; @@ -62,16 +52,9 @@ public class OptionGeneration { public static double MAX_VERTICAL_UP; public static double MAX_VERTICAL_DOWN; - // ── Jump types ──────────────────────────────────────────────────────────── - public static boolean JUMP_TYPES_ENABLED; - public static Map JUMP_TYPE_ENABLED; - public static Map JUMP_TYPE_CHANCE; - public static void init() { initJumpValidation(); - initJumpTypes(); initMemory(); - initGhostMode(); initGenerationProbabilities(); } @@ -88,23 +71,6 @@ public class OptionGeneration { ? Config.CONFIG.getDouble("jump-validation.max-vertical-down") : DEFAULT_MAX_VERTICAL_DOWN; } - private static void initJumpTypes() { - JUMP_TYPES_ENABLED = Config.CONFIG.isPath("jump-types.enabled") - && Config.CONFIG.getBoolean("jump-types.enabled"); - JUMP_TYPE_ENABLED = new HashMap<>(); - JUMP_TYPE_CHANCE = new HashMap<>(); - - if (Config.CONFIG.isPath("jump-types.types")) { - for (final String type : Config.CONFIG.getChildren("jump-types.types")) { - final String p = "jump-types.types." + type; - JUMP_TYPE_ENABLED.put(type, Config.CONFIG.isPath(p + ".enabled") - && Config.CONFIG.getBoolean(p + ".enabled")); - JUMP_TYPE_CHANCE.put(type, Config.CONFIG.isPath(p + ".chance") - ? Config.CONFIG.getDouble(p + ".chance") : 0.0); - } - } - } - private static void initMemory() { BLOCK_CLEANUP_DISTANCE = Config.CONFIG.isPath("memory.block-cleanup-distance") ? Config.CONFIG.getInt("memory.block-cleanup-distance") : DEFAULT_BLOCK_CLEANUP_DISTANCE; @@ -114,15 +80,6 @@ public class OptionGeneration { ? Config.CONFIG.getInt("memory.trail-keep-behind") : DEFAULT_TRAIL_KEEP_BEHIND; } - private static void initGhostMode() { - GHOST_MODE_ENABLED = Config.CONFIG.isPath("ghost-mode.enabled") - && Config.CONFIG.getBoolean("ghost-mode.enabled"); - GHOST_SHOW_TOP = Config.CONFIG.isPath("ghost-mode.show-top") - ? Config.CONFIG.getInt("ghost-mode.show-top") : DEFAULT_GHOST_SHOW_TOP; - GHOST_TRANSPARENCY = Config.CONFIG.isPath("ghost-mode.transparency") - ? Config.CONFIG.getDouble("ghost-mode.transparency") : DEFAULT_GHOST_TRANSPARENCY; - } - private static void initGenerationProbabilities() { TYPE_NORMAL = Config.GENERATION.getInt("generation.type.normal") / PERCENT_DIVISOR; TYPE_SPECIAL = Config.GENERATION.getInt("generation.type.special") / PERCENT_DIVISOR; diff --git a/src/main/java/dev/loki/loparkour/elytra/generator/ElytraBoost.java b/src/main/java/dev/loki/loparkour/elytra/generator/ElytraBoost.java index 0ad12de..7acbd30 100644 --- a/src/main/java/dev/loki/loparkour/elytra/generator/ElytraBoost.java +++ b/src/main/java/dev/loki/loparkour/elytra/generator/ElytraBoost.java @@ -1,6 +1,7 @@ package dev.loki.loparkour.elytra.generator; import dev.loki.loparkour.elytra.section.ElytraSection; +import dev.loki.loparkour.util.misc.Compat; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; @@ -23,6 +24,7 @@ final class ElytraBoost { private static final float BLAST_PITCH = 1.4f; private static final double TRAIL_OFFSET = 0.2; private static final double TRAIL_SPEED = 0.02; + private static final Particle SPARK = Compat.particle("FIREWORKS_SPARK", "FIREWORK"); private int boostedSectionIdx = Integer.MIN_VALUE; void tick(Player player, ElytraSection section, int idx) { @@ -53,7 +55,9 @@ final class ElytraBoost { .add(direction.clone().multiply(INITIAL_PUSH)); player.setVelocity(velocity); final Location loc = player.getLocation(); - loc.getWorld().spawnParticle(Particle.FIREWORKS_SPARK, loc, FIREWORK_COUNT, BURST_OFFSET, BURST_OFFSET, BURST_OFFSET, BURST_SPEED); + if (SPARK != null) { + loc.getWorld().spawnParticle(SPARK, loc, FIREWORK_COUNT, BURST_OFFSET, BURST_OFFSET, BURST_OFFSET, BURST_SPEED); + } loc.getWorld().playSound(loc, Sound.ENTITY_FIREWORK_ROCKET_LAUNCH, 1.0f, 1.0f); loc.getWorld().playSound(loc, Sound.ENTITY_FIREWORK_ROCKET_BLAST, BLAST_VOLUME, BLAST_PITCH); } @@ -64,9 +68,9 @@ final class ElytraBoost { player.setGliding(true); } player.setVelocity(player.getVelocity().add(direction.clone().multiply(SUSTAINED_BOOST))); - if (player.getTicksLived() % PARTICLE_EVERY_TICKS == 0) { + if (SPARK != null && player.getTicksLived() % PARTICLE_EVERY_TICKS == 0) { final Location loc = player.getLocation(); - loc.getWorld().spawnParticle(Particle.FIREWORKS_SPARK, loc, FIREWORK_PARTICLE_COUNT, + loc.getWorld().spawnParticle(SPARK, loc, FIREWORK_PARTICLE_COUNT, TRAIL_OFFSET, TRAIL_OFFSET, TRAIL_OFFSET, TRAIL_SPEED); } } diff --git a/src/main/java/dev/loki/loparkour/elytra/section/ClientBlockChanger.java b/src/main/java/dev/loki/loparkour/elytra/section/ClientBlockChanger.java index 6093194..f7c2bdb 100644 --- a/src/main/java/dev/loki/loparkour/elytra/section/ClientBlockChanger.java +++ b/src/main/java/dev/loki/loparkour/elytra/section/ClientBlockChanger.java @@ -13,6 +13,7 @@ import org.bukkit.World; import org.bukkit.block.BlockState; import org.bukkit.entity.Player; import org.bukkit.util.Vector; +import dev.loki.loparkour.util.misc.Compat; import org.jetbrains.annotations.NotNull; /** @@ -49,7 +50,7 @@ public class ClientBlockChanger { public void check(Player player, Material material) { final int playerCx = player.getLocation().getChunk().getX(); - final int rawVd = player.getClientViewDistance(); + final int rawVd = Compat.getClientViewDistance(player); final int vd = rawVd > 0 ? rawVd : Math.max(DEFAULT_MIN_VIEW_DISTANCE, player.getWorld().getViewDistance()); final int forwardLimit = playerCx + Math.min(vd, MAX_FORWARD_CHUNKS); @@ -111,7 +112,7 @@ public class ClientBlockChanger { state.setType(material); states.add(state); } - player.sendBlockChanges(states); + Compat.sendBlockChanges(player, states); } /** @@ -134,7 +135,7 @@ public class ClientBlockChanger { state.setType(material); states.add(state); } - player.sendBlockChanges(states); + Compat.sendBlockChanges(player, states); } } } diff --git a/src/main/java/dev/loki/loparkour/elytra/section/ElytraSection.java b/src/main/java/dev/loki/loparkour/elytra/section/ElytraSection.java index 9234d5a..d86cdab 100644 --- a/src/main/java/dev/loki/loparkour/elytra/section/ElytraSection.java +++ b/src/main/java/dev/loki/loparkour/elytra/section/ElytraSection.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import java.util.Random; import java.util.Set; +import dev.loki.loparkour.util.misc.Compat; import org.apache.commons.math3.analysis.interpolation.SplineInterpolator; import org.apache.commons.math3.analysis.polynomials.PolynomialSplineFunction; import org.bukkit.Material; @@ -16,11 +17,8 @@ import org.bukkit.entity.Player; import org.bukkit.util.Vector; /** - * Represents a section of the total parkour of size KNOTS. - * - *

Stores {@link Vector} coordinates instead of Bukkit Block instances to avoid - * pinning chunks in memory. Display blocks are sent client-side via - * {@link Player#sendBlockChanges} and undone in {@link #clear(Player)}.

+ * A parkour section of {@value #KNOTS} knots; stores Vectors (no chunk pinning). + * Display blocks are sent client-side and undone in {@link #clear(Player)}. */ public class ElytraSection { @@ -155,7 +153,7 @@ public class ElytraSection { state.setType(Material.AIR); states.add(state); } - player.sendBlockChanges(states); + Compat.sendBlockChanges(player, states); } localBlocks.clear(); } diff --git a/src/main/java/dev/loki/loparkour/generator/core/coordinator/GeneratorProfileManager.java b/src/main/java/dev/loki/loparkour/generator/core/coordinator/GeneratorProfileManager.java index ccc2c17..ad63d5c 100644 --- a/src/main/java/dev/loki/loparkour/generator/core/coordinator/GeneratorProfileManager.java +++ b/src/main/java/dev/loki/loparkour/generator/core/coordinator/GeneratorProfileManager.java @@ -127,6 +127,12 @@ public class GeneratorProfileManager { generator.state.defaultChances.put(ParkourGenerator.BlockGenerationType.SPECIAL, (double) specialChance); generator.state.defaultChances.put(ParkourGenerator.BlockGenerationType.SCHEMATIC, (double) schematicChance); + final boolean specialsEnabled = !generator.profile.settings.containsKey("useSpecialBlocks") + || generator.profile.get("useSpecialBlocks").asBoolean(); + if (!specialsEnabled) { + generator.state.defaultChances.put(ParkourGenerator.BlockGenerationType.SPECIAL, 0.0); + } + normalizeMap(generator.state.defaultChances); } 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/core/model/Island.java b/src/main/java/dev/loki/loparkour/generator/core/model/Island.java index ff3047b..74544f0 100644 --- a/src/main/java/dev/loki/loparkour/generator/core/model/Island.java +++ b/src/main/java/dev/loki/loparkour/generator/core/model/Island.java @@ -1,5 +1,6 @@ package dev.loki.loparkour.generator.core.model; +import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.config.core.Config; import dev.loki.loparkour.config.options.Option; import dev.loki.loparkour.session.core.Session; @@ -88,6 +89,10 @@ public final class Island { session.generator.startTick(); session.getPlayers().forEach(pp -> pp.setup(ps)); + if (LoParkour.getGhostService() != null) { + LoParkour.getGhostService().startRun(session.generator); + } + this.blocks = placed; } diff --git a/src/main/java/dev/loki/loparkour/generator/effect/EffectManager.java b/src/main/java/dev/loki/loparkour/generator/effect/EffectManager.java deleted file mode 100644 index 6b74207..0000000 --- a/src/main/java/dev/loki/loparkour/generator/effect/EffectManager.java +++ /dev/null @@ -1,95 +0,0 @@ -package dev.loki.loparkour.generator.effect; - -import dev.loki.loparkour.config.options.Option; -import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator; -import dev.loki.loparkour.player.core.ParkourPlayer; -import dev.loki.loparkour.util.particle.ParticleUtil; -import org.bukkit.Location; -import org.bukkit.block.Block; - -import java.util.List; - -/** - * Handles particle and sound effects for the parkour generator. - * Extracted from ParkourGenerator to keep it focused on generation logic. - */ -public class EffectManager { - - private static final int DOT_PARTICLE_COUNT = 5; - private static final double DOT_PARTICLE_OFFSET = 0.1; - private static final double CIRCLE_CENTER_OFFSET = 0.5; - private static final int CIRCLE_RADIUS = 8; - private static final double BOX_PARTICLE_SPACING = 0.3; - - private final ParkourGenerator generator; - - public EffectManager(ParkourGenerator generator) { - this.generator = generator; - } - - /** - * Spawns particles around the given blocks based on the configured shape. - */ - public void particles(List blocks) { - if (!generator.profile.get("particles").asBoolean()) { - return; - } - - final List locations = blocks.stream().map(Block::getLocation).toList(); - final Location max = locations.stream().reduce((a, b) -> { - final double x = Math.max(a.getX(), b.getX()); - final double y = Math.max(a.getY(), b.getY()); - final double z = Math.max(a.getZ(), b.getZ()); - return new Location(a.getWorld(), x, y, z); - }).orElse(null); - final Location min = locations.stream().reduce((a, b) -> { - final double x = Math.min(a.getX(), b.getX()); - final double y = Math.min(a.getY(), b.getY()); - final double z = Math.min(a.getZ(), b.getZ()); - return new Location(a.getWorld(), x, y, z); - }).orElse(null); - - if (max == null || min == null || max.getWorld() == null) { - return; - } - - final List viewers = generator.getPlayers(); - - switch (Option.PARTICLE_SHAPE) { - case DOT -> { - final Location center = min.clone().add( - (max.getX() - min.getX()) / 2, - (max.getY() - min.getY()) / 2, - (max.getZ() - min.getZ()) / 2 - ); - viewers.forEach(p -> p.player.spawnParticle( - Option.PARTICLE_TYPE, center, DOT_PARTICLE_COUNT, DOT_PARTICLE_OFFSET, DOT_PARTICLE_OFFSET, DOT_PARTICLE_OFFSET, 0, - Option.PARTICLE_DATA - )); - } - case CIRCLE -> ParticleUtil.circle( - min.clone().add(CIRCLE_CENTER_OFFSET, CIRCLE_CENTER_OFFSET, CIRCLE_CENTER_OFFSET), Option.PARTICLE_TYPE, CIRCLE_RADIUS, 1 - ); - case BOX -> ParticleUtil.box( - org.bukkit.util.BoundingBox.of(min, max), max.getWorld(), Option.PARTICLE_TYPE, - viewers.isEmpty() ? null : viewers.get(0).player, BOX_PARTICLE_SPACING - ); - default -> {} - } - } - - /** - * Plays the configured sound at the first block's location. - */ - public void sound(List blocks) { - if (!generator.profile.get("sound").asBoolean() || blocks.isEmpty()) { - return; - } - - final Location loc = blocks.get(0).getLocation(); - generator.getPlayers().forEach(p -> - p.player.playSound(loc, Option.SOUND_TYPE, Option.SOUND_VOLUME, Option.SOUND_PITCH)); - generator.getSpectators().forEach(s -> - s.player.playSound(loc, Option.SOUND_TYPE, Option.SOUND_VOLUME, Option.SOUND_PITCH)); - } -} 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/calculation/JumpValidator.java b/src/main/java/dev/loki/loparkour/generator/jump/calculation/JumpValidator.java index c81a240..0fe8035 100644 --- a/src/main/java/dev/loki/loparkour/generator/jump/calculation/JumpValidator.java +++ b/src/main/java/dev/loki/loparkour/generator/jump/calculation/JumpValidator.java @@ -1,5 +1,6 @@ package dev.loki.loparkour.generator.jump.calculation; +import dev.loki.loparkour.config.options.Option; import dev.loki.loparkour.util.misc.MaterialUtil; import org.bukkit.Location; import org.bukkit.util.Vector; @@ -22,7 +23,12 @@ public class JumpValidator { private final double maxVerticalDown; public JumpValidator() { - this(MAX_JUMP_DISTANCE, MAX_HORIZONTAL_DISTANCE, MAX_VERTICAL_UP, MAX_VERTICAL_DOWN); + this( + Option.JUMP_VALIDATION_ENABLED ? Option.MAX_JUMP_DISTANCE : MAX_JUMP_DISTANCE, + Option.JUMP_VALIDATION_ENABLED ? Option.MAX_HORIZONTAL_DISTANCE : MAX_HORIZONTAL_DISTANCE, + Option.JUMP_VALIDATION_ENABLED ? Option.MAX_VERTICAL_UP : MAX_VERTICAL_UP, + Option.JUMP_VALIDATION_ENABLED ? Option.MAX_VERTICAL_DOWN : MAX_VERTICAL_DOWN + ); } public JumpValidator(double maxDistance, double maxHorizontal, double maxVerticalUp, double maxVerticalDown) { 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/BlockSelector.java b/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockSelector.java index d3c06de..d44248b 100644 --- a/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockSelector.java +++ b/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockSelector.java @@ -123,7 +123,6 @@ public class BlockSelector { material == Material.JUNGLE_FENCE || material == Material.ACACIA_FENCE || material == Material.MANGROVE_FENCE || - material == Material.CHERRY_FENCE || material == Material.CRIMSON_FENCE || material == Material.WARPED_FENCE || material == Material.NETHER_BRICK_FENCE; @@ -137,7 +136,6 @@ public class BlockSelector { material == Material.JUNGLE_TRAPDOOR || material == Material.ACACIA_TRAPDOOR || material == Material.MANGROVE_TRAPDOOR || - material == Material.CHERRY_TRAPDOOR || material == Material.CRIMSON_TRAPDOOR || material == Material.WARPED_TRAPDOOR || material == Material.IRON_TRAPDOOR; diff --git a/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockWorldPlacer.java b/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockWorldPlacer.java index af4557c..303777a 100644 --- a/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockWorldPlacer.java +++ b/src/main/java/dev/loki/loparkour/generator/jump/placement/BlockWorldPlacer.java @@ -2,6 +2,7 @@ package dev.loki.loparkour.generator.jump.placement; import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator; import dev.loki.loparkour.player.core.ParkourPlayer; +import dev.loki.loparkour.util.misc.Compat; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; @@ -31,11 +32,14 @@ class BlockWorldPlacer { block.setBlockData(data, false); final Location loc = block.getLocation().add(BLOCK_CENTER_OFFSET, BLOCK_CENTER_OFFSET, BLOCK_CENTER_OFFSET); + final Particle particle = Compat.particle("BLOCK_CRACK", "BLOCK"); for (final ParkourPlayer player : generator.getPlayers()) { - player.player.spawnParticle( - Particle.BLOCK_CRACK, loc, PARTICLE_COUNT, - PARTICLE_OFFSET, PARTICLE_OFFSET, PARTICLE_OFFSET, PARTICLE_EXTRA, data - ); + if (particle != null) { + player.player.spawnParticle( + particle, loc, PARTICLE_COUNT, + PARTICLE_OFFSET, PARTICLE_OFFSET, PARTICLE_OFFSET, PARTICLE_EXTRA, data + ); + } player.player.playSound(loc, Sound.BLOCK_STONE_PLACE, PLACE_SOUND_VOLUME, 1.0f); } } 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..4a4fbb5 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 @@ -1,5 +1,6 @@ package dev.loki.loparkour.generator.lifecycle.loop; +import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.api.event.score.ParkourFallEvent; import dev.loki.loparkour.api.event.score.ParkourScoreEvent; import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator; @@ -10,6 +11,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,13 +92,23 @@ 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() { // Record statistics + final int score = generator.state.score; final String time = generator.getFormattedTime(); final String difficulty = String.valueOf(generator.getDifficultyScore()); @@ -111,7 +123,15 @@ public class LifecycleEventManager { player.clearScoredBlocks(); } + if (LoParkour.getGhostService() != null) { + LoParkour.getGhostService().endRun(generator, score); + } + // Reset generator state generator.reset(true); + + if (LoParkour.getGhostService() != null) { + LoParkour.getGhostService().startRun(generator); + } } } 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..a30eef6 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,13 +92,18 @@ public class LifecycleTickManager { } private void maintainBlockLead() { - final int currentBlocks = generator.state.history.size(); - final int targetBlocks = generator.state.score + GENERATION_LEAD; - + final int currentBlocks = generator.state.generatedBlocks; + final int targetBlocks = generator.state.score + generationLead(); + if (currentBlocks < targetBlocks) { final int needed = targetBlocks - currentBlocks; // Cap generation per tick to prevent runaway generation in multiplayer generator.generate(Math.min(needed, MAX_GENERATE_PER_TICK)); } } + + private int generationLead() { + final int lead = generator.profile.get("blockLead").asInt(); + return lead > 0 ? lead : GENERATION_LEAD; + } } 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..499d123 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 @@ -9,7 +9,9 @@ import org.bukkit.block.Block; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; /** * Handles cleanup operations for parkour generator. @@ -91,26 +93,28 @@ public class GeneratorCleanup { } /** - * Remove trail blocks behind player at specified index. + * Remove schematic blocks once the player completes them. + * The end block is kept in history so the landing player can score it. */ - public void removeTrailBlocks(int startIndex) { - if (startIndex < 0 || startIndex >= generator.state.history.size()) { + public void removeSchematicBlocks() { + final List schematicBlocks = generator.state.schematicBlocks; + if (schematicBlocks == null || schematicBlocks.isEmpty()) { return; } - - final int endIndex = Math.min(startIndex + Option.BLOCK_CLEANUP_DISTANCE, generator.state.history.size()); - - for (int i = startIndex; i < endIndex; i++) { - final Block block = generator.state.history.get(i); - if (block != null && block.getType() != Material.AIR) { - block.setType(Material.AIR); + + final Set remove = new HashSet<>(); + for (int i = 0; i < schematicBlocks.size() - 1; i++) { + final Block block = schematicBlocks.get(i); + if (block != null) { + if (block.getType() != Material.AIR) { + block.setType(Material.AIR); + } + remove.add(block); } } - - // Remove from history (synchronized to prevent concurrent index shift) - synchronized (generator.state.history) { - generator.state.history.subList(startIndex, endIndex).clear(); - } + + generator.state.history.removeIf(remove::contains); + generator.state.schematicBlocks = null; } /** @@ -122,6 +126,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/generator/lifecycle/player/PlayerInteractionHandler.java b/src/main/java/dev/loki/loparkour/generator/lifecycle/player/PlayerInteractionHandler.java index 723d716..6980223 100644 --- a/src/main/java/dev/loki/loparkour/generator/lifecycle/player/PlayerInteractionHandler.java +++ b/src/main/java/dev/loki/loparkour/generator/lifecycle/player/PlayerInteractionHandler.java @@ -112,7 +112,7 @@ public class PlayerInteractionHandler { private void handleBlockInteraction(@NotNull ParkourPlayer player, @NotNull Block block) { // Check if it's a schematic end block if (isSchematicEndBlock(block)) { - handleSchematicEndBlock(block); + handleSchematicEndBlock(); } else if (isOnSchematicBlock(block)) { // Schematic block that is NOT the last — skip scoring return; @@ -131,13 +131,9 @@ public class PlayerInteractionHandler { && historyContains(generator.state.schematicBlocks, block); } - private void handleSchematicEndBlock(@NotNull Block block) { - // Remove schematic blocks and continue with normal generation - if (generator.state.schematicBlocks != null) { - cleanup.removeTrailBlocks(0); // Remove schematic trail - generator.state.schematicBlocks = null; - } - + private void handleSchematicEndBlock() { + cleanup.removeSchematicBlocks(); + // Continue normal generation generator.generate(GENERATION_LEAD); } diff --git a/src/main/java/dev/loki/loparkour/ghost/core/GhostManager.java b/src/main/java/dev/loki/loparkour/ghost/core/GhostManager.java index 7b02aee..58e6d29 100644 --- a/src/main/java/dev/loki/loparkour/ghost/core/GhostManager.java +++ b/src/main/java/dev/loki/loparkour/ghost/core/GhostManager.java @@ -1,185 +1,151 @@ package dev.loki.loparkour.ghost.core; -import dev.loki.loparkour.ghost.model.GhostData; - import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.config.core.Config; -import org.bukkit.Location; -import org.bukkit.World; +import dev.loki.loparkour.ghost.model.GhostData; + import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; -import java.util.HashMap; import java.util.List; -import java.util.Map; +import java.util.UUID; /** - * Loads, saves, and spawns ghost runs per mode. + * Stores each player's recent parkour runs on disk as individual ghost files. * - *

Bugs fixed vs original: - *

    - *
  • Files were named {@code playerName.ghost} — collisions between players sharing - * a name, and path traversal via names like {@code ../../config}. - * Now named {@code uuid.ghost} — guaranteed unique and filesystem-safe.
  • - *
  • When evicting the lowest ghost, the old file was looked up by player name - * which failed if the name had changed since the run was saved.
  • - *
  • Old v1 files (no UUID) are detected via {@link GhostData#loadFromFile} - * returning null — they are deleted automatically on first load.
  • - *
+ *

Layout: {@code ghosts//.ghost}. Each player keeps + * their last {@link #maxRuns()} runs; older files are evicted on save. + * + *

The {@code File}-based constructor and {@link #maxRuns()} are public rather + * than package-private so {@code GhostManagerTest} (in the sibling + * {@code dev.loki.loparkour.ghost} package) can inject a temp folder and + * override the run cap without loading config state. */ public class GhostManager { - private static final int MAX_GHOSTS_PER_MODE = 3; + /** Fallback run cap per player when the config key is missing. */ + static final int DEFAULT_MAX_RUNS = 5; + + /** Timestamp used for files whose name is not a valid run timestamp (oldest). */ + private static final long MALFORMED_NAME_TIMESTAMP = Long.MIN_VALUE; + + private static final String FILE_EXTENSION = ".ghost"; private final File ghostFolder; - private final Map> ghostsByMode = new HashMap<>(); - private final List activeGhosts = new ArrayList<>(); public GhostManager() { - this.ghostFolder = LoParkour.getInFolder("ghosts"); + this(LoParkour.getInFolder("ghosts")); + } + + public GhostManager(@NotNull File ghostFolder) { + this.ghostFolder = ghostFolder; if (!ghostFolder.exists()) { ghostFolder.mkdirs(); } } - // ── Loading ─────────────────────────────────────────────────────────────── + // ── Queries ─────────────────────────────────────────────────────────────── - public void loadGhosts(@NotNull String mode) { - final File modeFolder = new File(ghostFolder, sanitizeMode(mode)); - if (!modeFolder.exists()) { - return; + /** + * Loads a player's saved runs, newest first. + * + * @return unmodifiable, newest-first list; empty if the player has no runs. + */ + public List getRuns(@NotNull UUID playerUuid) { + if (playerUuid == null) { + LoParkour.getPlugin().getLogger().warning("getRuns called with null player UUID"); + return Collections.emptyList(); } - final File[] files = modeFolder.listFiles((dir, name) -> name.endsWith(".ghost")); + final File[] files = playerFolder(playerUuid) + .listFiles((dir, name) -> name.endsWith(FILE_EXTENSION)); if (files == null) { - return; + return Collections.emptyList(); } - final List ghosts = new ArrayList<>(); + final List runs = new ArrayList<>(files.length); for (final File file : files) { try { final GhostData data = GhostData.loadFromFile(file); if (data == null) { - // Old v1 format — delete and skip - LoParkour.getPlugin().getLogger().info( - "Deleting outdated ghost file (old format): " + file.getName()); file.delete(); continue; } - ghosts.add(data); + runs.add(data); } catch (IOException e) { LoParkour.getPlugin().getLogger().severe( "Failed to load ghost file " + file.getName() + ": " + e.getMessage()); } } - ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed()); - ghostsByMode.put(mode, ghosts); + runs.sort(Comparator.comparingLong(GhostData::getTimestamp).reversed() + .thenComparing(Comparator.comparingInt(GhostData::getScore).reversed()) + .thenComparing(GhostData::getPlayerName)); + return Collections.unmodifiableList(runs); } // ── Saving ──────────────────────────────────────────────────────────────── - public void saveGhost(@NotNull String mode, @NotNull GhostData data) { - final File modeFolder = new File(ghostFolder, sanitizeMode(mode)); - if (!modeFolder.exists()) { - modeFolder.mkdirs(); + public void saveRun(@NotNull UUID playerUuid, @NotNull GhostData data) { + if (playerUuid == null || data == null) { + LoParkour.getPlugin().getLogger().warning("saveRun called with null argument"); + return; } - final List ghosts = new ArrayList<>(ghostsByMode.getOrDefault(mode, new ArrayList<>())); - ghosts.add(data); - ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed()); - - // Evict lowest-score ghosts beyond the cap - while (ghosts.size() > MAX_GHOSTS_PER_MODE) { - final GhostData evicted = ghosts.remove(ghosts.size() - 1); - // File is named by UUID — always finds the right file regardless of name changes - final File evictedFile = ghostFile(modeFolder, evicted); - if (evictedFile.exists()) { - evictedFile.delete(); - } + final File playerFolder = playerFolder(playerUuid); + if (!playerFolder.exists()) { + playerFolder.mkdirs(); } - ghostsByMode.put(mode, ghosts); - - // Save new ghost — named by UUID, not by player name - final File file = ghostFile(modeFolder, data); + final File file = new File(playerFolder, data.getTimestamp() + FILE_EXTENSION); try { data.saveToFile(file); } catch (IOException e) { LoParkour.getPlugin().getLogger().severe( - "Failed to save ghost for " + data.getPlayerName() + ": " + e.getMessage()); - } - } - - // ── Spawning ────────────────────────────────────────────────────────────── - - public void spawnGhosts(@NotNull String mode, @NotNull Location startLocation, - @NotNull World world) { - if (!Config.CONFIG.getBoolean("ghost-mode.enabled")) { + "Failed to save ghost run for " + data.getPlayerName() + ": " + e.getMessage()); return; } - - stopAllGhosts(); - - final List ghosts = ghostsByMode.get(mode); - if (ghosts == null || ghosts.isEmpty()) { - return; - } - - final int showTop = Math.min(Config.CONFIG.getInt("ghost-mode.show-top"), ghosts.size()); - for (int i = 0; i < showTop; i++) { - final GhostPlayer ghost = new GhostPlayer(ghosts.get(i), world); - ghost.spawn(startLocation); - activeGhosts.add(ghost); - } + evictOldRuns(playerFolder); } - public void stopAllGhosts() { - activeGhosts.forEach(GhostPlayer::stop); - activeGhosts.clear(); - } - - // ── Queries ─────────────────────────────────────────────────────────────── - - @Nullable - public GhostData getTopGhost(@NotNull String mode) { - final List ghosts = ghostsByMode.get(mode); - return (ghosts == null || ghosts.isEmpty()) ? null : ghosts.get(0); - } - - public List getTopGhosts(@NotNull String mode, int count) { - final List ghosts = ghostsByMode.get(mode); - if (ghosts == null || ghosts.isEmpty()) { - return Collections.emptyList(); - } - return Collections.unmodifiableList(ghosts.subList(0, Math.min(count, ghosts.size()))); - } - - public boolean shouldRecordGhost(@NotNull String mode, int score) { - final List ghosts = ghostsByMode.get(mode); - if (ghosts == null || ghosts.size() < MAX_GHOSTS_PER_MODE) { - return true; - } - return score > ghosts.get(ghosts.size() - 1).getScore(); + /** Max runs kept per player; read from config each call so reloads apply. */ + public int maxRuns() { + return Config.CONFIG.getInt("ghost-mode.max-runs", DEFAULT_MAX_RUNS); } // ── Internals ───────────────────────────────────────────────────────────── - /** Returns the ghost file for a run — always UUID-based, never name-based. */ - private File ghostFile(File modeFolder, GhostData data) { - return new File(modeFolder, data.getPlayerUuid().toString() + ".ghost"); + private File playerFolder(@NotNull UUID playerUuid) { + return new File(ghostFolder, playerUuid.toString()); } - /** - * Strips any path separators from mode names so they can't be used to - * escape the ghosts directory (e.g. mode name "../../etc"). - */ - private static String sanitizeMode(String mode) { - return mode.replaceAll("[/\\\\.]", "_"); + private void evictOldRuns(@NotNull File playerFolder) { + final File[] files = playerFolder.listFiles((dir, name) -> name.endsWith(FILE_EXTENSION)); + final int cap = maxRuns(); + if (files == null || files.length <= cap) { + return; + } + + Arrays.sort(files, Comparator.comparingLong(GhostManager::fileTimestamp) + .thenComparing(File::getName)); + final int toDelete = files.length - cap; + for (int i = 0; i < toDelete; i++) { + files[i].delete(); + } + } + + /** Parses the run timestamp from a file name; malformed names count as oldest. */ + private static long fileTimestamp(@NotNull File file) { + final String name = file.getName().replace(FILE_EXTENSION, ""); + try { + return Long.parseLong(name); + } catch (NumberFormatException e) { + return MALFORMED_NAME_TIMESTAMP; + } } } diff --git a/src/main/java/dev/loki/loparkour/ghost/core/GhostRecorder.java b/src/main/java/dev/loki/loparkour/ghost/core/GhostRecorder.java index f6dbcd6..ad02af8 100644 --- a/src/main/java/dev/loki/loparkour/ghost/core/GhostRecorder.java +++ b/src/main/java/dev/loki/loparkour/ghost/core/GhostRecorder.java @@ -50,17 +50,27 @@ public class GhostRecorder { } /** - * Stops recording and returns the completed ghost data. + * Stops recording and returns the completed ghost data for the given world. * * @param playerUuid UUID of the player — used as the filename in {@link GhostManager}. * @param playerName Display name shown on the ghost label in-game. * @param score The score achieved this run. + * @param worldName The world the run happened in (may be empty). */ public GhostData stopRecording(@NotNull UUID playerUuid, @NotNull String playerName, - int score) { + int score, + @NotNull String worldName) { recording = false; - return new GhostData(playerUuid, playerName, score, frames); + return new GhostData(playerUuid, playerName, score, + System.currentTimeMillis(), worldName, frames); + } + + /** Stops recording without a world name. */ + public GhostData stopRecording(@NotNull UUID playerUuid, + @NotNull String playerName, + int score) { + return stopRecording(playerUuid, playerName, score, ""); } public boolean isRecording() { diff --git a/src/main/java/dev/loki/loparkour/ghost/model/GhostData.java b/src/main/java/dev/loki/loparkour/ghost/model/GhostData.java index 92a889b..2fe3bda 100644 --- a/src/main/java/dev/loki/loparkour/ghost/model/GhostData.java +++ b/src/main/java/dev/loki/loparkour/ghost/model/GhostData.java @@ -15,36 +15,49 @@ import java.util.List; import java.util.UUID; /** - * Immutable snapshot of a player's run: who ran it, their score, and every recorded frame. + * Immutable snapshot of a player's run: who ran it, their score, when it + * finished, which world it happened in, and every recorded frame. Serialized + * to a single binary file. * - *

Bugs fixed vs original: - *

    - *
  • Files were named {@code playerName.ghost} — two players with the same name - * overwrote each other, and a name like {@code ../../config} caused path traversal. - * UUID is now stored and used as the filename instead.
  • - *
  • File format is backwards-compatible: UUID is written first (new field), - * then the existing layout. Old files without a UUID are handled in - * {@link #loadFromFile} by catching the format mismatch and returning null.
  • - *
+ *

Format version 4 adds the {@link #worldName} after the timestamp; older + * versions are rejected by {@link #loadFromFile} (returns {@code null}) so the + * caller can delete the stale file. */ public class GhostData { /** Binary format version — bump when changing the serialised layout. */ - private static final int FORMAT_VERSION = 2; + private static final int FORMAT_VERSION = 4; private final UUID playerUuid; private final String playerName; private final int score; + private final long timestamp; + private final String worldName; private final List frames; public GhostData(@NotNull UUID playerUuid, @NotNull String playerName, - int score, @NotNull List frames) { + int score, long timestamp, @NotNull String worldName, + @NotNull List frames) { this.playerUuid = playerUuid; this.playerName = playerName; this.score = score; + this.timestamp = timestamp; + this.worldName = worldName; this.frames = new ArrayList<>(frames); } + /** Creates a run without a world name (legacy callers / default recorder). */ + public GhostData(@NotNull UUID playerUuid, @NotNull String playerName, + int score, long timestamp, @NotNull List frames) { + this(playerUuid, playerName, score, timestamp, "", frames); + } + + /** Creates a run stamped with the current time (used by {@code GhostRecorder}). */ + public GhostData(@NotNull UUID playerUuid, @NotNull String playerName, + int score, @NotNull List frames) { + this(playerUuid, playerName, score, System.currentTimeMillis(), "", frames); + } + // ── Getters ─────────────────────────────────────────────────────────────── public UUID getPlayerUuid() { @@ -59,6 +72,14 @@ public class GhostData { return score; } + public long getTimestamp() { + return timestamp; + } + + public String getWorldName() { + return worldName; + } + public List getFrames() { return frames; } @@ -69,9 +90,11 @@ public class GhostData { try (DataOutputStream out = new DataOutputStream( new BufferedOutputStream(new FileOutputStream(file)))) { out.writeInt(FORMAT_VERSION); // version header - out.writeUTF(playerUuid.toString()); // UUID (filename-safe, collision-free) + out.writeUTF(playerUuid.toString()); // UUID (filename-safe, collision-free) out.writeUTF(playerName); // display name (for the ghost label) out.writeInt(score); + out.writeLong(timestamp); // run completion time (ordering) + out.writeUTF(worldName); // world the run happened in out.writeInt(frames.size()); for (final GhostFrame frame : frames) { out.writeLong(frame.getTimestamp()); @@ -87,8 +110,8 @@ public class GhostData { /** * Loads a ghost from file. * - * @return The loaded data, or {@code null} if the file is an old v1 format - * (no version header / UUID) — caller should delete and ignore it. + * @return The loaded data, or {@code null} if the file uses an older format + * (anything other than {@link #FORMAT_VERSION}) — caller deletes it. */ public static GhostData loadFromFile(@NotNull File file) throws IOException { try (DataInputStream in = new DataInputStream( @@ -96,14 +119,15 @@ public class GhostData { final int version = in.readInt(); if (version != FORMAT_VERSION) { - // Old format (v1) started with readUTF for playerName, not an int version. - // Those files are stale — return null so the manager can discard them. + // Files not matching the current format are stale — return null so the manager discards them. return null; } final UUID uuid = UUID.fromString(in.readUTF()); final String name = in.readUTF(); final int score = in.readInt(); + final long timestamp = in.readLong(); + final String world = in.readUTF(); final int frameCount = in.readInt(); final List frames = new ArrayList<>(frameCount); @@ -114,7 +138,7 @@ public class GhostData { in.readFloat(), in.readFloat())); } - return new GhostData(uuid, name, score, frames); + return new GhostData(uuid, name, score, timestamp, world, frames); } } } diff --git a/src/main/java/dev/loki/loparkour/ghost/replay/PlayerGhost.java b/src/main/java/dev/loki/loparkour/ghost/replay/PlayerGhost.java new file mode 100644 index 0000000..8e91ff5 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/ghost/replay/PlayerGhost.java @@ -0,0 +1,200 @@ +package dev.loki.loparkour.ghost.replay; + +import com.mojang.authlib.GameProfile; +import com.mojang.authlib.properties.Property; +import dev.loki.loparkour.LoParkour; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientboundAddEntityPacket; +import net.minecraft.network.protocol.game.ClientboundPlayerInfoRemovePacket; +import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket; +import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket; +import net.minecraft.network.protocol.game.ClientboundRotateHeadPacket; +import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.GameType; +import net.minecraft.world.phys.Vec3; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.EnumSet; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Packet-based fake player shown to a single viewer, replaying a recorded run. + * + *

Compiled against the Mojang-mapped 1.20.4 NMS; packets that 1.20.4 only builds + * from a {@code ServerPlayer} are constructed reflectively via {@code sun.misc.Unsafe} + * like {@code Compat}. Version mismatches fail {@link #spawn} and are logged once. + */ +public final class PlayerGhost { + + private static final String TEXTURES_PROPERTY = "textures"; + private static final int LATENCY = 0; + private static final int NO_DATA = 0; + private static final int BASE_ENTITY_ID = Integer.MAX_VALUE - 10_000; + private static final double FIXED_POINT_FACTOR = 256.0 / 360.0; + private static final double ZERO_SPEED = 0.0; + private static final AtomicInteger NEXT_ENTITY_ID = new AtomicInteger(BASE_ENTITY_ID); + private static volatile Object unsafe; + private static volatile Method allocateMethod; + private static Method sendMethod; + private static boolean warned; + + private final Player viewer; + private final GameProfile profile; + private final int entityId; + private boolean active; + + public PlayerGhost(@NotNull Player viewer, @NotNull String displayName) { + this.viewer = viewer; + this.entityId = NEXT_ENTITY_ID.incrementAndGet(); + this.profile = new GameProfile(UUID.randomUUID(), displayName); + copySkin(viewer, profile); + } + + public boolean spawn(@NotNull Location location) { + if (active) { + teleport(location); + return true; + } + try { + final ClientboundPlayerInfoUpdatePacket.Entry entry = new ClientboundPlayerInfoUpdatePacket.Entry( + profile.getId(), profile, true, LATENCY, GameType.SURVIVAL, null, null); + send(connection(viewer), playerInfoUpdate(entry)); + send(connection(viewer), new ClientboundAddEntityPacket(entityId, profile.getId(), + location.getX(), location.getY(), location.getZ(), + location.getPitch(), location.getYaw(), EntityType.PLAYER, NO_DATA, Vec3.ZERO, ZERO_SPEED)); + send(connection(viewer), headRotation(location.getYaw())); + active = true; + return true; + } catch (ReflectiveOperationException | LinkageError error) { + warnOnce(error); + return false; + } + } + + public void teleport(@NotNull Location location) { + if (!active) { + return; + } + try { + send(connection(viewer), teleportPacket(location)); + send(connection(viewer), headRotation(location.getYaw())); + } catch (ReflectiveOperationException | LinkageError error) { + warnOnce(error); + } + } + + public void remove() { + if (!active) { + return; + } + try { + send(connection(viewer), new ClientboundRemoveEntitiesPacket(entityId)); + send(connection(viewer), new ClientboundPlayerInfoRemovePacket(List.of(profile.getId()))); + active = false; + } catch (ReflectiveOperationException | LinkageError error) { + warnOnce(error); + } + } + + public boolean isActive() { + return active; + } + + /** Converts a degree angle to the fixed-point byte used on the wire. */ + static byte yawToFixed(float degrees) { + return (byte) Math.round(degrees * FIXED_POINT_FACTOR); + } + + private Object playerInfoUpdate(ClientboundPlayerInfoUpdatePacket.Entry entry) throws ReflectiveOperationException { + final ClientboundPlayerInfoUpdatePacket packet = allocate(ClientboundPlayerInfoUpdatePacket.class); + setField(packet, "actions", EnumSet.of(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER)); + setField(packet, "entries", List.of(entry)); + return packet; + } + + private Object teleportPacket(@NotNull Location location) throws ReflectiveOperationException { + final ClientboundTeleportEntityPacket packet = allocate(ClientboundTeleportEntityPacket.class); + setField(packet, "id", entityId); + setField(packet, "x", location.getX()); + setField(packet, "y", location.getY()); + setField(packet, "z", location.getZ()); + setField(packet, "yRot", yawToFixed(location.getYaw())); + setField(packet, "xRot", yawToFixed(location.getPitch())); + setField(packet, "onGround", true); + return packet; + } + + private Object headRotation(float yaw) throws ReflectiveOperationException { + final ClientboundRotateHeadPacket packet = allocate(ClientboundRotateHeadPacket.class); + setField(packet, "entityId", entityId); + setField(packet, "yHeadRot", yawToFixed(yaw)); + return packet; + } + + private static void copySkin(@NotNull Player player, @NotNull GameProfile profile) { + try { + final Object playerProfile = player.getPlayerProfile(); + final Object textures = playerProfile == null + ? null : playerProfile.getClass().getMethod("getTextures").invoke(playerProfile); + final Object skin = textures == null + ? null : textures.getClass().getMethod("getSkin").invoke(textures); + final String value = skin == null + ? null : (String) skin.getClass().getMethod("getValue").invoke(skin); + final String signature = skin == null + ? null : (String) skin.getClass().getMethod("getSignature").invoke(skin); + if (value != null && !value.isEmpty()) { + profile.getProperties().put(TEXTURES_PROPERTY, + new Property(TEXTURES_PROPERTY, value, signature)); + } + } catch (ReflectiveOperationException | LinkageError error) { + warnOnce(error); + } + } + + private static Object connection(@NotNull Player player) throws ReflectiveOperationException { + final Object handle = player.getClass().getMethod("getHandle").invoke(player); + return handle.getClass().getField("connection").get(handle); + } + + private static void send(@NotNull Object connection, @NotNull Object packet) throws ReflectiveOperationException { + if (sendMethod == null) { + sendMethod = connection.getClass().getMethod("send", Packet.class); + } + sendMethod.invoke(connection, packet); + } + + private static void setField(@NotNull Object instance, @NotNull String name, @NotNull Object value) + throws ReflectiveOperationException { + final Field field = instance.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(instance, value); + } + + private static T allocate(Class type) throws ReflectiveOperationException { + if (unsafe == null) { + final Class unsafeClass = Class.forName("sun.misc.Unsafe"); + final Field field = unsafeClass.getDeclaredField("theUnsafe"); + field.setAccessible(true); + unsafe = field.get(null); + allocateMethod = unsafeClass.getMethod("allocateInstance", Class.class); + } + return type.cast(allocateMethod.invoke(unsafe, type)); + } + + private static void warnOnce(@NotNull Throwable error) { + if (warned) { + return; + } + warned = true; + if (LoParkour.getPlugin() != null) { + LoParkour.getPlugin().getLogger().warning("Ghost replay NPC unsupported on this server: " + error); + } + } +} diff --git a/src/main/java/dev/loki/loparkour/ghost/service/GhostReplay.java b/src/main/java/dev/loki/loparkour/ghost/service/GhostReplay.java new file mode 100644 index 0000000..5fdbc6f --- /dev/null +++ b/src/main/java/dev/loki/loparkour/ghost/service/GhostReplay.java @@ -0,0 +1,113 @@ +package dev.loki.loparkour.ghost.service; + +import dev.loki.loparkour.LoParkour; +import dev.loki.loparkour.ghost.model.GhostData; +import dev.loki.loparkour.ghost.model.GhostFrame; +import dev.loki.loparkour.ghost.replay.PlayerGhost; +import dev.loki.loparkour.player.core.ParkourUser; +import dev.lolib.scheduler.ScheduledTask; +import dev.lolib.scheduler.Scheduler; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Plays one recorded {@link GhostData} run back to a single viewer as a + * packet-based fake player ({@link PlayerGhost}), then returns the viewer + * to where they were and fires {@code onFinish}. + */ +public class GhostReplay { + + private static final long TICK_DELAY = 0; + private static final long TICK_PERIOD = 1; + private static final int FIRST_FRAME = 0; + + private final Player viewer; + private final GhostData run; + private final Runnable onFinish; + private final World world; + private final Location returnTo; + private final List frames; + + private PlayerGhost ghost; + private ScheduledTask task; + private long startTime; + private int frameIndex; + private Location current; + private boolean moved; + + public GhostReplay(@NotNull Player viewer, @NotNull GhostData run, @NotNull Runnable onFinish) { + this.viewer = viewer; + this.run = run; + this.onFinish = onFinish; + this.returnTo = viewer.getLocation().clone(); + this.frames = run.getFrames(); + final String worldName = run.getWorldName(); + World resolved = worldName.isEmpty() ? null : Bukkit.getWorld(worldName); + if (resolved == null) { + resolved = viewer.getWorld(); + } + this.world = resolved; + } + + public void start() { + if (run.getFrames().isEmpty()) { + onFinish.run(); + return; + } + final ParkourUser user = ParkourUser.getUser(viewer); + if (user != null) { + ParkourUser.leave(viewer); + } + final Location start = run.getFrames().get(FIRST_FRAME).toLocation(world); + viewer.teleport(start); + moved = true; + ghost = new PlayerGhost(viewer, run.getPlayerName()); + if (!ghost.spawn(start)) { + finish(); + return; + } + startTime = System.currentTimeMillis(); + frameIndex = FIRST_FRAME; + current = start; + task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, TICK_DELAY, TICK_PERIOD); + } + + private void tick() { + if (!viewer.isOnline()) { + finish(); + return; + } + final long elapsed = System.currentTimeMillis() - startTime; + while (frameIndex < frames.size() + && frames.get(frameIndex).getTimestamp() <= elapsed) { + current = frames.get(frameIndex).toLocation(world); + frameIndex++; + } + if (current != null && ghost != null) { + ghost.teleport(current); + } + if (frameIndex >= frames.size()) { + finish(); + } + } + + private void finish() { + if (task != null) { + task.cancel(); + task = null; + } + if (ghost != null) { + ghost.remove(); + ghost = null; + } + if (moved && viewer.isOnline()) { + viewer.teleport(returnTo); + } + onFinish.run(); + } +} diff --git a/src/main/java/dev/loki/loparkour/ghost/service/GhostService.java b/src/main/java/dev/loki/loparkour/ghost/service/GhostService.java new file mode 100644 index 0000000..ff7c165 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/ghost/service/GhostService.java @@ -0,0 +1,99 @@ +package dev.loki.loparkour.ghost.service; + +import dev.loki.loparkour.config.core.Config; +import dev.loki.loparkour.ghost.core.GhostManager; +import dev.loki.loparkour.ghost.core.GhostRecorder; +import dev.loki.loparkour.ghost.model.GhostData; +import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator; +import dev.loki.loparkour.player.core.ParkourPlayer; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Plugin-owned singleton that records each player's runs as {@link GhostData} + * while the server's {@code ghost-mode} is enabled and the player's per-player + * {@code ghostRecording} toggle is on. + */ +public class GhostService { + + private final GhostManager manager = new GhostManager(); + private final Map recorders = new ConcurrentHashMap<>(); + private final Set replaying = ConcurrentHashMap.newKeySet(); + + public GhostManager getManager() { + return manager; + } + + private boolean serverEnabled() { + return Config.CONFIG.getBoolean("ghost-mode.enabled"); + } + + private boolean recordingEnabled(ParkourPlayer pp) { + return serverEnabled() + && !replaying.contains(pp.getUUID()) + && Boolean.TRUE.equals(pp.ghostRecording); + } + + public void startRun(ParkourGenerator generator) { + for (final ParkourPlayer pp : generator.getPlayers()) { + if (recordingEnabled(pp)) { + recorders.computeIfAbsent(pp.getUUID(), k -> new GhostRecorder()) + .startRecording(pp.getLocation()); + } else { + recorders.remove(pp.getUUID()); + } + } + } + + public void recordPlayers(ParkourGenerator generator) { + for (final ParkourPlayer pp : generator.getPlayers()) { + final GhostRecorder recorder = recorders.get(pp.getUUID()); + if (recorder != null) { + recorder.recordFrame(pp.getLocation()); + } + } + } + + public void endRun(ParkourGenerator generator, int score) { + for (final ParkourPlayer pp : generator.getPlayers()) { + final GhostRecorder recorder = recorders.remove(pp.getUUID()); + if (recorder != null && recorder.isRecording()) { + final String worldName = pp.getLocation().getWorld() == null + ? "" : pp.getLocation().getWorld().getName(); + final GhostData data = recorder.stopRecording( + pp.getUUID(), pp.player.getName(), score, worldName); + manager.saveRun(pp.getUUID(), data); + } + } + } + + public void playerLeft(UUID uuid) { + recorders.remove(uuid); + } + + public boolean isReplaying(UUID uuid) { + return replaying.contains(uuid); + } + + public void setReplaying(UUID uuid, boolean replaying) { + if (replaying) { + this.replaying.add(uuid); + } else { + this.replaying.remove(uuid); + } + } + + public void startReplay(@NotNull org.bukkit.entity.Player viewer, @NotNull GhostData run) { + playerLeft(viewer.getUniqueId()); + setReplaying(viewer.getUniqueId(), true); + new GhostReplay(viewer, run, () -> setReplaying(viewer.getUniqueId(), false)).start(); + } + + public void shutdown() { + recorders.clear(); + } +} 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/menu/core/screen/ParkourOption.java b/src/main/java/dev/loki/loparkour/menu/core/screen/ParkourOption.java index a3aee36..0fcacb0 100644 --- a/src/main/java/dev/loki/loparkour/menu/core/screen/ParkourOption.java +++ b/src/main/java/dev/loki/loparkour/menu/core/screen/ParkourOption.java @@ -33,6 +33,7 @@ public enum ParkourOption { FALL_MESSAGE("settings.parkour_settings.items.fall_message", "LoParkour.settings.fall_message"), PARTICLES("settings.parkour_settings.items.particles", "LoParkour.settings.particles"), SOUND("settings.parkour_settings.items.sound", "LoParkour.settings.sound"), + GHOST_RECORDING("settings.parkour_settings.items.ghost_recording", "LoParkour.settings.ghost_recording"), SPECIAL_BLOCKS("settings.parkour_settings.items.special_blocks", "LoParkour.settings.special_blocks"), LANG("settings.lang", "LoParkour.settings.lang"), diff --git a/src/main/java/dev/loki/loparkour/menu/settings/ParkourSettingsMenu.java b/src/main/java/dev/loki/loparkour/menu/settings/ParkourSettingsMenu.java index 3d3cece..23591e2 100644 --- a/src/main/java/dev/loki/loparkour/menu/settings/ParkourSettingsMenu.java +++ b/src/main/java/dev/loki/loparkour/menu/settings/ParkourSettingsMenu.java @@ -27,6 +27,7 @@ public class ParkourSettingsMenu extends LPMenu { private static final int SLOT_SPECIAL_BLOCKS = 12; private static final int SLOT_FALL_MESSAGE = 13; private static final int SLOT_SCOREBOARD = 14; + private static final int SLOT_GHOST_RECORDING = 15; private static final int SLOT_STYLE = 19; private static final int SLOT_SCHEM_DIFF = 20; private static final int SLOT_CLOSE = 31; @@ -55,47 +56,54 @@ public class ParkourSettingsMenu extends LPMenu { final String title = Locales.getString(locale, "settings.name"); baseGui(title, ROWS_MAIN) - .setItem(SLOT_PARTICLES, toggleItem(locale, "particles", pp.particles), e -> { - pp.particles = !pp.particles; - pp.updateGeneratorSettings(pp.session.generator); - open(pp); - }) - .setItem(SLOT_SOUND, toggleItem(locale, "sound", pp.sound), e -> { - pp.sound = !pp.sound; - pp.updateGeneratorSettings(pp.session.generator); - open(pp); - }) - .setItem(SLOT_SPECIAL_BLOCKS, toggleItem(locale, "special_blocks", pp.useSpecialBlocks), e -> { - if (pp.session.generator.state.score == 0) { - pp.useSpecialBlocks = !pp.useSpecialBlocks; - pp.updateGeneratorSettings(pp.session.generator); - } - open(pp); - }) - .setItem(SLOT_FALL_MESSAGE, toggleItem(locale, "fall_message", pp.showFallMessage), e -> { - pp.showFallMessage = !pp.showFallMessage; - pp.updateGeneratorSettings(pp.session.generator); - open(pp); - }) - .setItem(SLOT_SCOREBOARD, toggleItem(locale, "scoreboard", pp.showScoreboard), e -> { - pp.showScoreboard = !pp.showScoreboard; - pp.updateGeneratorSettings(pp.session.generator); - open(pp); - }) + .setItem(SLOT_PARTICLES, toggleItem(locale, "particles", pp.particles), + e -> toggleSetting(pp, () -> pp.particles = !pp.particles)) + .setItem(SLOT_SOUND, toggleItem(locale, "sound", pp.sound), + e -> toggleSetting(pp, () -> pp.sound = !pp.sound)) + .setItem(SLOT_SPECIAL_BLOCKS, toggleItem(locale, "special_blocks", pp.useSpecialBlocks), + e -> toggleSpecialBlocks(pp)) + .setItem(SLOT_FALL_MESSAGE, toggleItem(locale, "fall_message", pp.showFallMessage), + e -> toggleSetting(pp, () -> pp.showFallMessage = !pp.showFallMessage)) + .setItem(SLOT_SCOREBOARD, toggleItem(locale, "scoreboard", pp.showScoreboard), + e -> toggleSetting(pp, () -> pp.showScoreboard = !pp.showScoreboard)) + .setItem(SLOT_GHOST_RECORDING, toggleItem(locale, "ghost_recording", + Boolean.TRUE.equals(pp.ghostRecording)), e -> toggleGhostRecording(pp)) .setItem(SLOT_STYLE, styleItem(pp), e -> openStyleMenu(pp)) - .setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> { - if (pp.session.generator.state.score == 0) { - final List diffs = List.of(0.0, DIFF_QUARTER, DIFF_HALF, DIFF_THREE_QUARTER, 1.0); - final int idx = diffs.indexOf(pp.schematicDifficulty); - pp.schematicDifficulty = diffs.get((idx + 1) % diffs.size()); - pp.updateGeneratorSettings(pp.session.generator); - } - open(pp); - }) + .setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> cycleSchematicDifficulty(pp)) .setItem(SLOT_CLOSE, closeButton(player), e -> Menus.SETTINGS.open(player)) .open(player); } + private void toggleSetting(ParkourPlayer pp, Runnable update) { + update.run(); + pp.updateGeneratorSettings(pp.session.generator); + open(pp); + } + + private void toggleSpecialBlocks(ParkourPlayer pp) { + if (pp.session.generator.state.score == 0) { + pp.useSpecialBlocks = !pp.useSpecialBlocks; + pp.updateGeneratorSettings(pp.session.generator); + } + open(pp); + } + + private void cycleSchematicDifficulty(ParkourPlayer pp) { + if (pp.session.generator.state.score == 0) { + final List diffs = List.of(0.0, DIFF_QUARTER, DIFF_HALF, DIFF_THREE_QUARTER, 1.0); + final int idx = diffs.indexOf(pp.schematicDifficulty); + pp.schematicDifficulty = diffs.get((idx + 1) % diffs.size()); + pp.updateGeneratorSettings(pp.session.generator); + } + open(pp); + } + + private void toggleGhostRecording(ParkourPlayer pp) { + pp.ghostRecording = !Boolean.TRUE.equals(pp.ghostRecording); + pp.save(false); + open(pp); + } + private ItemStack toggleItem(String locale, String key, boolean value) { final Material mat = value ? Material.LIME_STAINED_GLASS_PANE : Material.RED_STAINED_GLASS_PANE; final String symbol = Locales.getString(locale, "settings.parkour_settings." + (value ? "enabled" : "disabled")); @@ -106,13 +114,7 @@ public class ParkourSettingsMenu extends LPMenu { final ItemMeta meta = item.getItemMeta(); if (meta != null) { meta.setDisplayName(ColorUtil.color(name + " §7[" + symbol + "§7]")); - if (lore != null && !lore.isEmpty()) { - final List loreLines = new ArrayList<>(); - for (final String line : lore.split("\\|\\|")) { - loreLines.add(ColorUtil.color(line.replace("%s", symbol))); - } - meta.setLore(loreLines); - } + applyLore(meta, lore, symbol); item.setItemMeta(meta); } return item; @@ -131,13 +133,7 @@ public class ParkourSettingsMenu extends LPMenu { final String name = Locales.getString(pp.locale, "settings.parkour_settings.items.schematics.name"); final String lore = Locales.getString(pp.locale, "settings.parkour_settings.items.schematics.lore"); meta.setDisplayName(ColorUtil.color(name.replace("%s", label))); - if (lore != null && !lore.isEmpty()) { - final List loreLines = new ArrayList<>(); - for (final String line : lore.split("\\|\\|")) { - loreLines.add(ColorUtil.color(line.replace("%s", label))); - } - meta.setLore(loreLines); - } + applyLore(meta, lore, label); item.setItemMeta(meta); } return item; @@ -150,18 +146,23 @@ public class ParkourSettingsMenu extends LPMenu { final ItemMeta meta = item.getItemMeta(); if (meta != null) { meta.setDisplayName(ColorUtil.color(name.replace("%s", pp.style))); - if (lore != null && !lore.isEmpty()) { - final List loreLines = new ArrayList<>(); - for (final String line : lore.split("\\|\\|")) { - loreLines.add(ColorUtil.color(line.replace("%s", pp.style))); - } - meta.setLore(loreLines); - } + applyLore(meta, lore, pp.style); item.setItemMeta(meta); } return item; } + private void applyLore(ItemMeta meta, String lore, String replacement) { + if (lore == null || lore.isEmpty()) { + return; + } + final List loreLines = new ArrayList<>(); + for (final String line : lore.split("\\|\\|")) { + loreLines.add(ColorUtil.color(line.replace("%s", replacement))); + } + meta.setLore(loreLines); + } + private void openStyleMenu(ParkourPlayer pp) { final Player player = pp.player; final String locale = pp.locale; @@ -179,7 +180,6 @@ public class ParkourSettingsMenu extends LPMenu { if (Config.CONFIG.getBoolean("permissions.per-style") && !player.hasPermission(perm)) { continue; } - final ItemStack item = new ItemStack(style.getNext()); final ItemMeta meta = item.getItemMeta(); if (meta != null) { diff --git a/src/main/java/dev/loki/loparkour/mode/impl/barrier/BarrierRenderer.java b/src/main/java/dev/loki/loparkour/mode/impl/barrier/BarrierRenderer.java index b619680..0191795 100644 --- a/src/main/java/dev/loki/loparkour/mode/impl/barrier/BarrierRenderer.java +++ b/src/main/java/dev/loki/loparkour/mode/impl/barrier/BarrierRenderer.java @@ -2,6 +2,7 @@ package dev.loki.loparkour.mode.impl.barrier; import dev.loki.loparkour.generator.core.model.GeneratorState; import dev.loki.loparkour.player.core.ParkourPlayer; +import dev.loki.loparkour.util.misc.Compat; import org.bukkit.Color; import org.bukkit.Location; import org.bukkit.Material; @@ -144,12 +145,15 @@ class BarrierRenderer { final double length = Math.sqrt(dx * dx + dy * dy + dz * dz); final int steps = Math.max(1, (int) (length / PARTICLE_STEP)); + final Particle dust = Compat.particle("REDSTONE", "DUST"); for (int i = 0; i <= steps; i++) { final double t = (double) i / steps; final double px = start.x + dx * t; final double py = start.y + dy * t; final double pz = start.z + dz * t; - player.spawnParticle(Particle.REDSTONE, px, py, pz, 1, options); + if (dust != null) { + player.spawnParticle(dust, px, py, pz, 1, options); + } } } } \ No newline at end of file diff --git a/src/main/java/dev/loki/loparkour/mode/impl/multi/CoopGenerator.java b/src/main/java/dev/loki/loparkour/mode/impl/multi/CoopGenerator.java new file mode 100644 index 0000000..99bc091 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/mode/impl/multi/CoopGenerator.java @@ -0,0 +1,89 @@ +package dev.loki.loparkour.mode.impl.multi; + +import dev.loki.loparkour.config.locale.Locales; +import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator; +import dev.loki.loparkour.mode.base.Mode; +import dev.loki.loparkour.mode.base.Modes; +import dev.loki.loparkour.player.core.ParkourPlayer; +import dev.loki.loparkour.session.core.Session; +import dev.loki.loparkour.util.text.ColorUtil; +import org.bukkit.Sound; +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Co-op generator — all players share one score; per-UUID block + * contributions are tracked for the fall() stats message. + */ +class CoopGenerator extends ParkourGenerator { + + private static final int MILESTONE_INTERVAL = 50; + private static final int TITLE_FADE_IN = 5; + private static final int TITLE_STAY = 40; + private static final int TITLE_FADE_OUT = 10; + private static final float MILESTONE_VOLUME = 0.6f; + private static final float MILESTONE_PITCH = 1.5f; + private static final int PERCENT_MULTIPLIER = 100; + private final Map contributions = new HashMap<>(); + + CoopGenerator(@NotNull Session session) { + super(session); + session.getPlayers().forEach(pp -> contributions.put(pp.getUUID(), 0)); + } + + @Override + protected void score() { + super.score(); + + for (final ParkourPlayer pp : getPlayers()) { + contributions.compute(pp.getUUID(), (uuid, count) -> (count == null ? 0 : count) + 1); + } + + if (state.score % MILESTONE_INTERVAL == 0) { + getPlayers().forEach(pp -> { + pp.player.sendTitle( + ColorUtil.color(Locales.getString(pp.locale, "modes.coop.milestone_title").formatted(state.score)), + ColorUtil.color(Locales.getString(pp.locale, "modes.coop.milestone_subtitle")), + TITLE_FADE_IN, TITLE_STAY, TITLE_FADE_OUT); + pp.player.playSound(pp.getLocation(), + Sound.ENTITY_PLAYER_LEVELUP, MILESTONE_VOLUME, MILESTONE_PITCH); + }); + } + } + + /** + * Registers a player joining mid-game so their blocks count in fall() stats. + */ + void onPlayerJoin(ParkourPlayer pp) { + contributions.putIfAbsent(pp.getUUID(), 0); + } + + @Override + public void fall() { + getPlayers().forEach(pp -> { + final int contrib = contributions.getOrDefault(pp.getUUID(), 0); + final int pct = state.score > 0 ? (contrib * PERCENT_MULTIPLIER / state.score) : 0; + pp.sendTranslated("modes.coop.stats", + Integer.toString(state.score), + Integer.toString(contrib), + Integer.toString(pct)); + }); + + super.fall(); + } + + @Override + public void reset(boolean regenerate) { + super.reset(regenerate); + contributions.clear(); + getPlayers().forEach(pp -> contributions.put(pp.getUUID(), 0)); + } + + @Override + public Mode getMode() { + return Modes.COOP; + } +} 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..e72f9b9 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 @@ -1,44 +1,23 @@ package dev.loki.loparkour.mode.impl.multi; -import dev.loki.loparkour.mode.base.MultiMode; -import dev.loki.loparkour.mode.base.ModeMessages; -import dev.loki.loparkour.mode.base.Mode; -import dev.loki.loparkour.mode.base.Modes; - import dev.loki.loparkour.config.core.Config; -import dev.loki.loparkour.config.locale.Locales; -import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator; -import dev.loki.loparkour.util.text.ColorUtil; import dev.loki.loparkour.leaderboard.core.Leaderboard; import dev.loki.loparkour.menu.core.Menus; +import dev.loki.loparkour.mode.base.ModeMessages; +import dev.loki.loparkour.mode.base.MultiMode; import dev.loki.loparkour.player.core.ParkourPlayer; import dev.loki.loparkour.player.core.ParkourUser; import dev.loki.loparkour.session.core.Session; -import org.bukkit.Sound; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - /** * Co-op Mode — multiple players share one session and one score. * *

Implements {@link MultiMode} so it is excluded from the single-player * mode selection menu in {@link dev.loki.loparkour.menu.play.SingleMenu}. * Players join via /parkour coop or through a lobby invite flow.

- * - *

Problems fixed vs previous version: - *

    - *
  • Did NOT implement MultiMode → showed up in single-player menu
  • - *
  • CoopGenerator was a private inner class — inaccessible from join/leave
  • - *
  • sharedScore was a separate field that duplicated state.score
  • - *
  • fall() called super.fall() THEN tried to clear state that was already reset
  • - *
  • playerContributions never cleared between falls correctly
  • - *
  • create(Player) silently created a solo session instead of rejecting
  • - *
*/ public class CoopMode implements MultiMode { @@ -50,10 +29,6 @@ public class CoopMode implements MultiMode { return "coop"; } - /** - * Returns null so this mode never appears in any menu item list. - * Players join via lobby invite or command only. - */ @Override @Nullable public dev.loki.loparkour.util.item.Item getItem(String locale) { @@ -67,23 +42,28 @@ public class CoopMode implements MultiMode { } /** - * Called when a player tries to start coop solo from a command/menu. - * Opens the lobby menu so they can invite others — does NOT create a session here. - * A session is created only once a second player joins via {@link #join}. + * Creates a coop session with the caller as host and opens the lobby so + * the host can invite others. Later joiners are added via {@link #join}. */ @Override public void create(Player player) { if (!ModeMessages.checkJoiningEnabled(player)) { return; } - // Opening lobby menu lets player invite others — actual session created in join() + + final ParkourPlayer pp = ParkourPlayer.getPlayer(player); + if (pp != null && pp.session.generator instanceof CoopGenerator) { + return; + } + + player.closeInventory(); + Session.create(CoopGenerator::new, + session -> session.getPlayers().size() < getMaxPlayers(), + null, + player); Menus.LOBBY.open(player); } - /** - * Adds a player to an existing coop session, or creates a new session if - * the target player is not yet in one. - */ @Override public void join(Player joiningPlayer, Session session) { if (!ModeMessages.checkJoiningEnabled(joiningPlayer)) { @@ -94,106 +74,28 @@ public class CoopMode implements MultiMode { return; } - // Register the joining player into the existing session final ParkourPlayer pp = ParkourUser.register(joiningPlayer, session); session.addPlayers(pp); pp.updateGeneratorSettings(session.generator); + if (session.generator instanceof CoopGenerator coopGenerator) { + coopGenerator.onPlayerJoin(pp); + } - // Inform everyone session.getPlayers().forEach(p -> p.sendTranslated("modes.coop.player_joined", joiningPlayer.getName())); } - /** - * Removes a player from a coop session. - * If the last player leaves, the session ends normally. - */ @Override public void leave(Player player, Session session) { final ParkourPlayer pp = ParkourPlayer.getPlayer(player); if (pp == null) { return; } - ParkourUser.leave(pp); + session.removePlayers(pp); } @Override public int getMaxPlayers() { return Config.CONFIG.getInt("modes.coop.max-players"); } - - /** Package-accessible so Session.create() lambda and join() can use it. */ - static class CoopGenerator extends ParkourGenerator { - - private static final int MILESTONE_INTERVAL = 50; - private static final int TITLE_FADE_IN = 5; - private static final int TITLE_STAY = 40; - private static final int TITLE_FADE_OUT = 10; - private static final float MILESTONE_VOLUME = 0.6f; - private static final float MILESTONE_PITCH = 1.5f; - private static final int PERCENT_MULTIPLIER = 100; - /** Individual block contributions per player UUID. */ - private final Map contributions = new HashMap<>(); - - CoopGenerator(@NotNull Session session) { - super(session); - session.getPlayers().forEach(pp -> contributions.put(pp.getUUID(), 0)); - } - - @Override - protected void score() { - super.score(); // increments state.score and totalScore - - // Track contributions for all players in session - for (final ParkourPlayer pp : getPlayers()) { - contributions.compute(pp.getUUID(), (uuid, count) -> (count == null ? 0 : count) + 1); - } - - // Milestone every 50 points - if (state.score % MILESTONE_INTERVAL == 0) { - getPlayers().forEach(pp -> { - pp.player.sendTitle( - ColorUtil.color(Locales.getString(pp.locale, "modes.coop.milestone_title").formatted(state.score)), - ColorUtil.color(Locales.getString(pp.locale, "modes.coop.milestone_subtitle")), - TITLE_FADE_IN, TITLE_STAY, TITLE_FADE_OUT); - pp.player.playSound(pp.getLocation(), - Sound.ENTITY_PLAYER_LEVELUP, MILESTONE_VOLUME, MILESTONE_PITCH); - }); - } - } - - /** - * When a new player joins mid-game, add them to contributions map. - */ - public void onPlayerJoin(ParkourPlayer pp) { - contributions.putIfAbsent(pp.getUUID(), 0); - } - - @Override - public void fall() { - // Show each player their individual contribution before reset - getPlayers().forEach(pp -> { - final int contrib = contributions.getOrDefault(pp.getUUID(), 0); - final int pct = state.score > 0 ? (contrib * PERCENT_MULTIPLIER / state.score) : 0; - pp.sendTranslated("modes.coop.stats", - Integer.toString(state.score), - Integer.toString(contrib), - Integer.toString(pct)); - }); - - super.fall(); // handles reset, leaderboard, etc. - } - - @Override - public void reset(boolean regenerate) { - super.reset(regenerate); - contributions.clear(); - getPlayers().forEach(pp -> contributions.put(pp.getUUID(), 0)); - } - - @Override - public Mode getMode() { - return Modes.COOP; - } - } } diff --git a/src/main/java/dev/loki/loparkour/mode/impl/solo/GravityShiftMode.java b/src/main/java/dev/loki/loparkour/mode/impl/solo/GravityShiftMode.java index ce9f5ed..f9eb54a 100644 --- a/src/main/java/dev/loki/loparkour/mode/impl/solo/GravityShiftMode.java +++ b/src/main/java/dev/loki/loparkour/mode/impl/solo/GravityShiftMode.java @@ -7,6 +7,7 @@ import dev.loki.loparkour.mode.base.Modes; import dev.loki.loparkour.config.core.Config; import dev.loki.loparkour.config.locale.Locales; import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator; +import dev.loki.loparkour.util.misc.Compat; import dev.loki.loparkour.leaderboard.core.Leaderboard; import dev.loki.loparkour.player.core.ParkourPlayer; import dev.loki.loparkour.session.core.Session; @@ -87,9 +88,9 @@ public class GravityShiftMode implements Mode { ? Config.CONFIG.getInt("modes.gravity-shift.interval") : DEFAULT_INTERVAL; this.effects = new HashMap<>(); - loadEffect("jump-boost", PotionEffectType.JUMP); + loadEffect("jump-boost", Compat.potionEffect("JUMP_BOOST", "JUMP")); loadEffect("speed", PotionEffectType.SPEED); - loadEffect("slowness", PotionEffectType.SLOW); + loadEffect("slowness", Compat.potionEffect("SLOWNESS", "SLOW")); loadEffect("levitation", PotionEffectType.LEVITATION); // Initialize jump counts for all players diff --git a/src/main/java/dev/loki/loparkour/player/core/ParkourPlayer.java b/src/main/java/dev/loki/loparkour/player/core/ParkourPlayer.java index e195d67..ea235d8 100644 --- a/src/main/java/dev/loki/loparkour/player/core/ParkourPlayer.java +++ b/src/main/java/dev/loki/loparkour/player/core/ParkourPlayer.java @@ -37,6 +37,7 @@ public class ParkourPlayer extends ParkourUser { public @Expose Boolean useSpecialBlocks; public @Expose Boolean showFallMessage; public @Expose Boolean showScoreboard; + public @Expose Boolean ghostRecording; public @Expose Integer selectedTime; public @Expose String style; public @Expose String locale; @@ -106,6 +107,10 @@ public class ParkourPlayer extends ParkourUser { // Clear scored blocks to prevent memory leak scoring.clear(); + if (LoParkour.getGhostService() != null) { + LoParkour.getGhostService().playerLeft(player.getUniqueId()); + } + persistence.save(this, LoParkour.getPlugin().isEnabled()); } 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/PlayerSettingsManager.java b/src/main/java/dev/loki/loparkour/player/service/PlayerSettingsManager.java index ba69e2f..c647b13 100644 --- a/src/main/java/dev/loki/loparkour/player/service/PlayerSettingsManager.java +++ b/src/main/java/dev/loki/loparkour/player/service/PlayerSettingsManager.java @@ -52,6 +52,7 @@ public final class PlayerSettingsManager { COLUMN_MAPPINGS.put("schematicDifficulty", new OptionContainer(ParkourOption.SCHEMATICS, (p, v) -> p.schematicDifficulty = parseDoubleSafe(v, defaultSchematicDifficulty()))); COLUMN_MAPPINGS.put("sound", new OptionContainer(ParkourOption.SOUND, (p, v) -> p.sound = parseBoolean(v))); + COLUMN_MAPPINGS.put("ghostRecording", new OptionContainer(ParkourOption.GHOST_RECORDING, (p, v) -> p.ghostRecording = parseBoolean(v))); } public static Map getColumnMappings() { 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 419d81b..703d5e8 100644 --- a/src/main/java/dev/loki/loparkour/schematic/core/SchematicManager.java +++ b/src/main/java/dev/loki/loparkour/schematic/core/SchematicManager.java @@ -4,7 +4,7 @@ import com.sk89q.worldedit.extent.clipboard.Clipboard; import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.schematic.schem.SchemLoader; import dev.loki.loparkour.config.core.Config; -import org.bukkit.Bukkit; +import dev.loki.loparkour.util.misc.Compat; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.structure.Structure; import org.bukkit.structure.StructureManager; @@ -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,18 +92,26 @@ public final class SchematicManager { if (files == null || files.length == 0) { return 0; } - - final StructureManager structureManager = Bukkit.getStructureManager(); + final StructureManager structureManager = Compat.getStructureManager(); + if (structureManager == null) { + return 0; + } int ok = 0; for (final File file : files) { 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; @@ -117,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") @@ -125,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; @@ -146,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/session/manager/SessionStateManager.java b/src/main/java/dev/loki/loparkour/session/manager/SessionStateManager.java index 4d85daa..4208827 100644 --- a/src/main/java/dev/loki/loparkour/session/manager/SessionStateManager.java +++ b/src/main/java/dev/loki/loparkour/session/manager/SessionStateManager.java @@ -78,6 +78,9 @@ public class SessionStateManager { if (session.generator != null) { try { session.generator.tick(); + if (LoParkour.getGhostService() != null) { + LoParkour.getGhostService().recordPlayers(session.generator); + } } catch (Throwable t) { session.generator.session.getPlayers().forEach(p -> p.player.sendMessage("§c[LoParkour] Tick error: " + t.getMessage())); diff --git a/src/main/java/dev/loki/loparkour/storage/disk/StorageDisk.java b/src/main/java/dev/loki/loparkour/storage/disk/StorageDisk.java index dfc75f0..64ba772 100644 --- a/src/main/java/dev/loki/loparkour/storage/disk/StorageDisk.java +++ b/src/main/java/dev/loki/loparkour/storage/disk/StorageDisk.java @@ -101,6 +101,7 @@ public class StorageDisk { settings.put("showFallMsg", from.showFallMessage); settings.put("showScoreboard", from.showScoreboard); settings.put("selectedTime", from.selectedTime); + settings.put("ghostRecording", from.ghostRecording); settings.put("collectedRewards", from.collectedRewards); // Sanitize legacy Boolean locale values 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..78b7a80 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,50 @@ 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("ghostRecording", player.ghostRecording); + 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/main/java/dev/loki/loparkour/util/misc/Compat.java b/src/main/java/dev/loki/loparkour/util/misc/Compat.java new file mode 100644 index 0000000..eb55a3d --- /dev/null +++ b/src/main/java/dev/loki/loparkour/util/misc/Compat.java @@ -0,0 +1,123 @@ +package dev.loki.loparkour.util.misc; + +import org.bukkit.Bukkit; +import org.bukkit.Particle; +import org.bukkit.block.BlockState; +import org.bukkit.entity.Player; +import org.bukkit.potion.PotionEffectType; +import org.bukkit.structure.StructureManager; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Method; +import java.util.Collection; + +/** + * Reflection helpers for APIs that do not exist on every Minecraft version + * (sendBlockChanges / getClientViewDistance are Paper 1.19.3+; StructureManager + * is Bukkit 1.19.3+). Lets the single source tree compile against any Paper API + * and degrade gracefully at runtime on older servers. + */ +public final class Compat { + + private Compat() { + } + + private static final Method SEND_BLOCK_CHANGES = find(Player.class, "sendBlockChanges", Collection.class); + private static final Method CLIENT_VIEW_DISTANCE = find(Player.class, "getClientViewDistance"); + private static final Method STRUCTURE_MANAGER = findServerStructureManager(); + + private static Method findServerStructureManager() { + try { + return find(Bukkit.getServer().getClass(), "getStructureManager"); + } catch (IllegalStateException ignored) { + return null; + } + } + + /** + * Sends a batch of client-side block changes. No-op on servers where the + * method does not exist (Paper < 1.19.3). + */ + public static void sendBlockChanges(@Nullable Player player, @Nullable Collection states) { + if (player == null || states == null || states.isEmpty() || SEND_BLOCK_CHANGES == null) { + return; + } + try { + SEND_BLOCK_CHANGES.invoke(player, states); + } catch (ReflectiveOperationException ignored) { + // API removed at runtime — degrade gracefully + } + } + + /** + * Client view distance (Paper), or -1 when unavailable so the caller can + * fall back to the world view distance. + */ + public static int getClientViewDistance(@Nullable Player player) { + if (player == null || CLIENT_VIEW_DISTANCE == null) { + return -1; + } + try { + return (int) CLIENT_VIEW_DISTANCE.invoke(player); + } catch (ReflectiveOperationException ignored) { + return -1; + } + } + + /** + * {@link Bukkit#getStructureManager()}, or {@code null} on versions without it. + */ + @Nullable + public static StructureManager getStructureManager() { + if (STRUCTURE_MANAGER == null) { + return null; + } + try { + return (StructureManager) STRUCTURE_MANAGER.invoke(Bukkit.getServer()); + } catch (ReflectiveOperationException ignored) { + return null; + } + } + + @Nullable + private static Method find(Class type, String name, Class... params) { + try { + return type.getMethod(name, params); + } catch (NoSuchMethodException ignored) { + return null; + } + } + + /** + * Resolves a {@link Particle} by trying each candidate name in order. Particle + * enum constants were renamed between API versions (e.g. BLOCK_CRACK → BLOCK, + * REDSTONE → DUST, FIREWORKS_SPARK → FIREWORK), so pass the old and the new name. + * Returns {@code null} when none of the names exist. + */ + @Nullable + public static Particle particle(String... candidateNames) { + for (final String name : candidateNames) { + try { + return Particle.valueOf(name); + } catch (IllegalArgumentException ignored) { + // try next candidate + } + } + return null; + } + + /** + * Resolves a {@link PotionEffectType} by name (JUMP → JUMP_BOOST, SLOW → + * SLOWNESS were renamed in 1.21), or {@code null} when none exists. + */ + @Nullable + public static PotionEffectType potionEffect(String... candidateNames) { + for (final String name : candidateNames) { + final PotionEffectType type = PotionEffectType.getByName(name); + if (type != null) { + return type; + } + } + return null; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 1b223be..14acb7f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -150,26 +150,6 @@ jump-validation: max-vertical-up: 1.25 max-vertical-down: 3.0 -# -= Jump types options =- -jump-types: - enabled: false - types: - neo-jump: - enabled: true - chance: 0.05 - head-hitter: - enabled: true - chance: 0.08 - fence-jump: - enabled: true - chance: 0.10 - trapdoor-jump: - enabled: true - chance: 0.12 - ladder-jump: - enabled: true - chance: 0.07 - # -= Memory optimization =- memory: block-cleanup-distance: 20 @@ -178,9 +158,8 @@ memory: # -= Ghost mode options =- ghost-mode: - enabled: false - show-top: 3 - transparency: 0.5 + enabled: true + max-runs: 5 # -= Game modes options =- modes: @@ -311,6 +290,10 @@ default-values: enabled: true default: true + ghost_recording: + enabled: true + default: false + special_blocks: enabled: true default: true diff --git a/src/main/resources/generation.yml b/src/main/resources/generation.yml index 43399cf..dc44ec5 100644 --- a/src/main/resources/generation.yml +++ b/src/main/resources/generation.yml @@ -34,17 +34,6 @@ generation: pane: 10 fence: 10 - # -= Jump type variations =- - # Chances for different jump types within normal blocks - # These add variety to the parkour generation - jump-types: - normal: 70 - neo: 5 - head-hitter: 5 - fence: 10 - trapdoor: 5 - ladder: 5 - # -= Jump settings =- settings: diff --git a/src/main/resources/locales/en.yml b/src/main/resources/locales/en.yml index cf9b484..fa8bce1 100644 --- a/src/main/resources/locales/en.yml +++ b/src/main/resources/locales/en.yml @@ -247,6 +247,11 @@ settings: name: "Play sounds" lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, every generated block||&#A0A0A0will play a sound." + ghost_recording: + material: "" + name: "Record ghost runs" + lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, your runs are recorded||&#A0A0A0so you can replay them later." + special_blocks: material: "" name: "Use special blocks" @@ -381,9 +386,15 @@ commands: unknown_player: "&#FF4444Unknown player." not_playing: "&#FF4444That player isn't playing." reload: "&#A0A0A0Reloading config files... (locales load asynchronously)" + replay: + disabled: "&#FF4444Ghost replays are disabled on this server." + no_runs: "&#A0A0A0You have no recorded runs yet." + invalid: "&#FF4444Invalid run number." + starting: "7FF55Playing back your run..." help: header: "񢩈&m--------------- &#FF6464&lLoParkour 񢩈&m---------------" line_parkour: "&#A0A0A0/parkour 񢩈- Main command" + line_replay: "&#A0A0A0/parkour replay [run] 񢩈- Watch your recorded runs" line_join: "&#A0A0A0/parkour join [mode/player] 񢩈- Join a mode" line_leave: "&#A0A0A0/parkour leave 񢩈- Leave parkour" line_menu: "&#A0A0A0/parkour menu 񢩈- Open the menu" diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 1438637..06ebb92 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -246,6 +246,11 @@ settings: name: "Воспроизводить звуки" lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, каждый сгенерированный блок||&#A0A0A0будет воспроизводить звук." + ghost_recording: + material: "" + name: "Записывать призрачные забеги" + lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, ваши забеги записываются,||&#A0A0A0чтобы вы могли их повторить позже." + special_blocks: material: "" name: "Использовать специальные блоки" @@ -380,9 +385,15 @@ commands: unknown_player: "&#FF4444Неизвестный игрок." not_playing: "&#FF4444Этот игрок не в паркуре." reload: "&#A0A0A0Перезагрузка конфигов... (локали грузятся асинхронно)" + replay: + disabled: "&#FF4444Призрачные повторы отключены на этом сервере." + no_runs: "&#A0A0A0У вас пока нет записанных забегов." + invalid: "&#FF4444Неверный номер забега." + starting: "7FF55Воспроизведение вашего забега..." help: header: "񢩈&m--------------- &#FF6464&lLoParkour 񢩈&m---------------" line_parkour: "&#A0A0A0/parkour 񢩈- Главная команда" + line_replay: "&#A0A0A0/parkour replay [забег] 񢩈- Смотреть записанные забеги" line_join: "&#A0A0A0/parkour join [режим/игрок] 񢩈- Войти в режим" line_leave: "&#A0A0A0/parkour leave 񢩈- Выйти" line_menu: "&#A0A0A0/parkour menu 񢩈- Открыть меню" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 6884eb9..f1bb873 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -2,7 +2,7 @@ name: 'LoParkour' description: 'Advanced infinitely generating parkour plugin.' author: loki version: ${version} -api-version: 1.20 +api-version: ${apiVersion} main: dev.loki.loparkour.LoParkour softdepend: [floodgate, Vault, PlaceholderAPI, Multiverse-Core, VoidGen] commands: 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/ghost/GhostDataTest.java b/src/test/java/dev/loki/loparkour/ghost/GhostDataTest.java index d93ffda..3dbccea 100644 --- a/src/test/java/dev/loki/loparkour/ghost/GhostDataTest.java +++ b/src/test/java/dev/loki/loparkour/ghost/GhostDataTest.java @@ -5,7 +5,10 @@ import dev.loki.loparkour.ghost.model.GhostFrame; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.io.BufferedOutputStream; +import java.io.DataOutputStream; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; @@ -34,18 +37,20 @@ class GhostDataTest { assertEquals(uuid, data.getPlayerUuid()); assertEquals(42, data.getScore()); assertEquals(2, data.getFrames().size()); + assertTrue(data.getTimestamp() > 0, "Run timestamp should be set"); } @Test void testSaveAndLoad(@TempDir Path tempDir) throws IOException { - // Create test data + // Create test data with an explicit completion timestamp (v4 format) List frames = new ArrayList<>(); frames.add(new GhostFrame(0, 0, 100, 0, 0, 0)); frames.add(new GhostFrame(500, 1, 100, 0, 45, 0)); frames.add(new GhostFrame(1000, 2, 100, 0, 90, 0)); UUID uuid = UUID.randomUUID(); - GhostData original = new GhostData(uuid, "Player1", 100, frames); + long timestamp = 1_700_000_000_000L; + GhostData original = new GhostData(uuid, "Player1", 100, timestamp, "world", frames); // Save to file File file = tempDir.resolve("test.ghost").toFile(); @@ -61,6 +66,9 @@ class GhostDataTest { assertEquals(original.getPlayerName(), loaded.getPlayerName()); assertEquals(original.getPlayerUuid(), loaded.getPlayerUuid()); assertEquals(original.getScore(), loaded.getScore()); + assertEquals(original.getTimestamp(), loaded.getTimestamp()); + assertEquals(original.getWorldName(), loaded.getWorldName()); + assertEquals("world", loaded.getWorldName()); assertEquals(original.getFrames().size(), loaded.getFrames().size()); // Verify frames @@ -187,4 +195,29 @@ class GhostDataTest { assertEquals(1, data.getFrames().size(), "GhostData should create defensive copy of frames list"); } + + @Test + void testOldVersionFormatRejected(@TempDir Path tempDir) throws IOException { + // Simulate an old v3 file: version header 3 followed by the v3 field layout + File file = tempDir.resolve("old-v3.ghost").toFile(); + UUID uuid = UUID.randomUUID(); + try (DataOutputStream out = new DataOutputStream( + new BufferedOutputStream(new FileOutputStream(file)))) { + out.writeInt(3); // v3 version header + out.writeUTF(uuid.toString()); + out.writeUTF("OldPlayer"); + out.writeInt(50); // score + out.writeLong(1_700_000_000_000L); // timestamp + out.writeInt(1); // frame count + out.writeLong(0); // frame timestamp + out.writeDouble(0); + out.writeDouble(100); + out.writeDouble(0); + out.writeFloat(0); + out.writeFloat(0); + } + + assertNull(GhostData.loadFromFile(file), + "pre-v4 files must be rejected as stale (return null)"); + } } diff --git a/src/test/java/dev/loki/loparkour/ghost/GhostManagerTest.java b/src/test/java/dev/loki/loparkour/ghost/GhostManagerTest.java new file mode 100644 index 0000000..c2d6587 --- /dev/null +++ b/src/test/java/dev/loki/loparkour/ghost/GhostManagerTest.java @@ -0,0 +1,114 @@ +package dev.loki.loparkour.ghost; + +import dev.loki.loparkour.ghost.core.GhostManager; +import dev.loki.loparkour.ghost.model.GhostData; +import dev.loki.loparkour.ghost.model.GhostFrame; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the per-player personal replay storage of {@link GhostManager}. + */ +class GhostManagerTest { + + private static final int EVICTION_CAP = 2; + + /** + * A manager whose run cap is fixed, overriding the config-driven {@link #maxRuns()}. + * Chosen over mutating {@code Config} statics, which the plugin initializes at + * enable time and are not settable from unit tests. + */ + private static final class FixedCapManager extends GhostManager { + + private FixedCapManager(File ghostFolder) { + super(ghostFolder); + } + + @Override + public int maxRuns() { + return EVICTION_CAP; + } + } + + private static GhostData run(UUID uuid, String name, int score, long timestamp) { + final List frames = new ArrayList<>(); + frames.add(new GhostFrame(0, 0, 100, 0, 0, 0)); + return new GhostData(uuid, name, score, timestamp, "world", frames); + } + + @Test + void saveRunAndGetRunsReturnNewestFirst(@TempDir Path tempDir) throws IOException { + final UUID uuid = UUID.randomUUID(); + final GhostManager manager = new GhostManager(tempDir.toFile()); + final long base = 1_700_000_000_000L; + + manager.saveRun(uuid, run(uuid, "A", 10, base)); + manager.saveRun(uuid, run(uuid, "B", 20, base + 1_000)); + manager.saveRun(uuid, run(uuid, "C", 30, base + 2_000)); + + final List runs = manager.getRuns(uuid); + + assertEquals(3, runs.size()); + assertEquals(base + 2_000, runs.get(0).getTimestamp()); + assertEquals(30, runs.get(0).getScore()); + assertEquals(base, runs.get(2).getTimestamp()); + assertEquals(10, runs.get(2).getScore()); + } + + @Test + void saveRunEvictsOldestBeyondCap(@TempDir Path tempDir) throws IOException { + final UUID uuid = UUID.randomUUID(); + final GhostManager manager = new FixedCapManager(tempDir.toFile()); + final long base = 1_700_000_000_000L; + + for (int i = 0; i < 5; i++) { + manager.saveRun(uuid, run(uuid, "P" + i, i * 10, base + i * 1_000L)); + } + + final List runs = manager.getRuns(uuid); + + assertEquals(EVICTION_CAP, runs.size(), "Old runs beyond the cap must be evicted"); + assertEquals(base + 4_000, runs.get(0).getTimestamp()); + assertEquals(base + 3_000, runs.get(1).getTimestamp()); + } + + @Test + void getRunsWithEmptyFolderReturnsEmptyList(@TempDir Path tempDir) { + final GhostManager manager = new GhostManager(tempDir.toFile()); + + final List runs = manager.getRuns(UUID.randomUUID()); + + assertTrue(runs.isEmpty(), "Player with no saved runs should return an empty list"); + } + + @Test + void getRunsIgnoresStaleFormatFiles(@TempDir Path tempDir) throws IOException { + final UUID uuid = UUID.randomUUID(); + final GhostManager manager = new GhostManager(tempDir.toFile()); + manager.saveRun(uuid, run(uuid, "New", 10, 1_700_000_000_000L)); + + // Corrupt one file so it reads as an unsupported version + final File playerFolder = new File(tempDir.toFile(), uuid.toString()); + final File[] files = playerFolder.listFiles((dir, name) -> name.endsWith(".ghost")); + assertNotNull(files); + try (DataOutputStream out = new DataOutputStream( + new FileOutputStream(files[0], false))) { + out.writeInt(2); + } + + final List runs = manager.getRuns(uuid); + + assertTrue(runs.isEmpty(), "Stale-format files must be skipped and deleted"); + } +} diff --git a/src/test/java/dev/loki/loparkour/ghost/replay/PlayerGhostTest.java b/src/test/java/dev/loki/loparkour/ghost/replay/PlayerGhostTest.java new file mode 100644 index 0000000..e543a08 --- /dev/null +++ b/src/test/java/dev/loki/loparkour/ghost/replay/PlayerGhostTest.java @@ -0,0 +1,42 @@ +package dev.loki.loparkour.ghost.replay; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure-logic tests for the fixed-point yaw conversion used by the ghost packets. + */ +class PlayerGhostTest { + + private static final float[] EDGE_ANGLES = {0f, 90f, 180f, -90f, 359f, 270f}; + private static final double MAX_ERROR = 1.0; + + @Test + void yawToFixedKnownValues() { + assertEquals(0, PlayerGhost.yawToFixed(0)); + assertEquals(64, PlayerGhost.yawToFixed(90)); + assertEquals(-128, PlayerGhost.yawToFixed(180)); + assertEquals(-64, PlayerGhost.yawToFixed(-90)); + assertEquals(-1, PlayerGhost.yawToFixed(359)); + } + + @Test + void yawRoundTripWithinOneDegree() { + for (final float angle : EDGE_ANGLES) { + final byte fixed = PlayerGhost.yawToFixed(angle); + final double decoded = (fixed & 0xFF) * 360.0 / 256.0; + assertTrue(Math.abs(decoded - normalized(angle)) < MAX_ERROR, + "yaw " + angle + " decoded to " + decoded); + } + } + + private static double normalized(float angle) { + double result = angle % 360.0; + if (result < 0) { + result += 360.0; + } + return result; + } +} 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)); + } +}