Compare commits

...

10 commits

Author SHA1 Message Date
4e3ccfd736
docs: add 1.4.0 changelog (en+ru)
Some checks are pending
Build / Build mc-1.19.2 (push) Waiting to run
Build / Build mc-1.20.1 (push) Waiting to run
Build / Build mc-1.21 (push) Waiting to run
Build / Build mc-1.21.11 (push) Waiting to run
Build / Build mc-26.1.2 (push) Waiting to run
Build / Build mc-26.2 (push) Waiting to run
Build / Quality gate (default 1.20.4) (push) Waiting to run
2026-08-06 14:43:35 +02:00
9783418734
chore: bump version to 1.4.0 2026-08-06 14:37:35 +02:00
dee403b5f4
ci: align deps with new-style paper-api platform (fix 1.21.11/26.x)
- gson 2.10.1 -> 2.11.0 (matches the paper-api platform constraint)
- fastutil pinned to 8.5.15 (paper platform version; was 8.5.9 conflicting)
- worldedit-core/bukkit isTransitive=false (drops its fastutil strictly 8.5.12
  constraint that clashed with the paper-api platform)

Verified locally: default 1.20.4 quality gate (79 tests), 1.19.2, 1.20.1,
1.21, 1.21.11 all green.
2026-08-06 14:21:09 +02:00
2f020d6b34
ci: drop flaky onarandombox maven repo (fix 503 worldedit resolution)
The mv-repo (repo.onarandombox.com) intermittently returns 503 in CI and
is not needed: Multiverse-Core is only a plugin.yml softdepend (no maven
dep), and worldedit-core 7.3.6 resolves from maven.enginehub.org.
2026-08-06 14:09:43 +02:00
cd330608b6
ci: migrate actions to v5 (checkout, setup-java, upload-artifact)
Fixes Node.js 20 deprecation warnings on GitHub Actions runners.
2026-08-06 14:03:21 +02:00
5de4d026ef
ci: fix multi-version build matrix (Gradle 9 + Java 21 for 1.19.x)
- gradle wrapper 8.14.3 -> 9.6.1 (supports Java 25 -> unblocks mc 26.x matrix)
- shadow plugin io.github.goooler.shadow 8.1.8 -> com.gradleup.shadow 9.6.1 (Gradle 9 compatible)
- fix ProcessResources expand null description under Gradle 9 strict typing
- matrix: 1.19.2/1.20.1 java 17 -> 21 (plugin requires Java 21; lolib jar is 21 bytecode)

Verified locally: default 1.20.4 quality gate, 1.19.2, 1.20.1, 1.21, 1.21.11 all green.
26.x builds now run on Gradle 9 in CI (needs JDK 25).
2026-08-06 13:51:51 +02:00
8c8046a4f5
feat: personal run replays (ghost system)
- GhostData: worldName field, format v4; GhostManager stores last N runs per player (ghosts/<uuid>/<ts>.ghost)
- GhostService: per-player recording (start on run start, every tick, finalize on fall), gated by ghost-mode.enabled + per-player toggle
- PlayerGhost: packet-based fake-player NPC with viewer skin (NMS via remapNms gradle task, reflection send, graceful degradation)
- GhostReplay: teleport viewer to run start, replay frames via PlayerGhost, restore on finish
- /lp replay [index] command + tab completion; settings menu ghost_recording toggle (persisted disk/SQL)
- config: ghost-mode.{enabled,max-runs}, remove dead show-top/transparency + GHOST_* statics
- tests: GhostData v4 round-trip, GhostManager eviction/ordering, yawToFixed (79 total)
2026-08-06 13:13:59 +02:00
4c255111f0
fix: coop session creation, schematic cleanup, wire dead generator config
- coop: create() builds a real CoopGenerator session (host + lobby), join() tracks contributions (п.23)
- schematics: removeSchematicBlocks() airs and prunes schematic blocks instead of removeTrailBlocks(0) (п.34)
- config: wire jump-validation into JumpValidator, useSpecialBlocks gates special chances,
  blockLead drives generation lead; remove dead jump-types keys and EffectManager (п.38)
2026-08-06 10:59:30 +02:00
fea15e61c8
fix: resolve 11 P1 bugs from audit (leaderboard, SQL, coop, jump, lifecycle, schematics, config)
- leaderboard: parse M:SS.mmm/H:MM:SS(.mmm), switch TIME sort to long (п.25)
- sql: single Hikari pool, non-blocking connect, adaptive init order, settings JSON round-trip (п.27-30)
- coop: remove unregister recursion via session-only leave + reentrancy guard (п.24)
- jump: perpendicular jitter for diagonal heading, valid safety-fallback on retry exhaustion (п.32-33)
- lifecycle: fall threshold relative to course, lead via generated-blocks counter, one-time rewards once (п.35-37)
- schematics: catch RuntimeExceptions, validate size, 10MB file limit (п.31)
- config merge: preserve block-sequence lists and ignored sections, fix path reversal (п.26)
- tests: +31 (score, jump, schematics, config merge) = 72 total
2026-08-06 10:49:05 +02:00
c0ca488b9d
ci: multi-version build matrix + multi-MC compat layer
- Parametrize build: -PmcVersion / -PapiCoordinate / -PjavaRelease / -PapiVersion;
  jar name includes mc version, plugin.yml api-version parametrized
- Compat (reflection): sendBlockChanges / getClientViewDistance /
  getStructureManager (Paper 1.19.3+); particle()/potionEffect() name lookup
  for 1.21 renames (BLOCK_CRACK->BLOCK, REDSTONE->DUST, FIREWORKS_SPARK->
  FIREWORK, JUMP->JUMP_BOOST, SLOW->SLOWNESS); drop CHERRY materials (1.20+)
- Fix Java 17 'unconditional instanceof' in JoinCommandExecutor
- capabilitiesResolution for org.bukkit:bukkit capability conflict on newer
  paper-api artifacts
- .github/workflows/build.yml: matrix for 1.19.2/1.20.1 (Java 17),
  1.21/1.21.11 (Java 21), 26.1.2/26.2 (Java 25) + quality gate
- Verified locally: default + 1.19.2/1.20.1/1.21/1.21.11 build; 26.x deps resolve
2026-08-05 22:48:26 +02:00
74 changed files with 2503 additions and 742 deletions

64
.github/workflows/build.yml vendored Normal file
View file

@ -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

43
CHANGELOG.md Normal file
View file

@ -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 <name>`, 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.

View file

@ -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)

View file

@ -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<JavaCompile>().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<ProcessResources> {
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<Test> {
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.release = 21
options.release = javaRelease
}
tasks.withType<Javadoc> {

View file

@ -35,6 +35,13 @@
<suppress checks="IllegalCatch" files=".*[\\/]command[\\/]core[\\/]LoParkourCommand\.java"/>
<suppress checks="IllegalCatch" files=".*[\\/]config[\\/]core[\\/]merge[\\/]ConfigUpdater\.java"/>
<!--
SchematicManager: catches RuntimeException so a corrupted/truncated schematic
(WorldEdit/Bukkit throw unchecked exceptions) is skipped instead of breaking
plugin enable. Not swallowing — the failure is logged at SEVERE with the cause.
-->
<suppress checks="IllegalCatch" files=".*[\\/]schematic[\\/]core[\\/]SchematicManager\.java"/>
<!-- ===== IllegalCatch / NPathComplexity / WhitespaceAround (legacy debt) ===== -->
<suppress checks="IllegalCatch" files=".*[\\/]LoParkour.java"/>
<suppress checks="NPathComplexity" files=".*[\\/]adaptive[\\/]bootstrap[\\/]AdaptiveServices.java"/>

View file

@ -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

View file

@ -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) {

View file

@ -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);

View file

@ -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"));

View file

@ -23,6 +23,7 @@ class CommandTabCompleter {
private static final List<String> SCHEMATIC_SUBCOMMANDS = List.of(
"wand", "pos1", "pos2", "create", "paste", "list", "reload"
);
private static final List<String> 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<String> completeReplay(CommandSender sender, String input) {
final List<String> completions = new ArrayList<>(REPLAY_RUN_INDEXES);
return filter(input, completions);
}
private static List<String> completeThird(String a1Raw, String a2Raw, String input) {
if (!"schematic".equalsIgnoreCase(a1Raw)) {
return Collections.emptyList();
}
return completeSchematicThird(a2Raw, input);
}
private static List<String> completeSchematicThird(String a2Raw, String input) {
final List<String> completions = new ArrayList<>();
if ("paste".equalsIgnoreCase(a2Raw)) {
addLoadedSchematicIds(completions);

View file

@ -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;
}

View file

@ -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"));

View file

@ -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<GhostData> 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;
}
}
}

View file

@ -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<String, String> extractFullPathValues(@NotNull List<String> lines) {
final Map<String, String> values = new LinkedHashMap<>();
final Deque<String> stack = new ArrayDeque<>();
int prevIndent = -1;
final Deque<Section> 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<String, List<String>> extractBlockSequences(@NotNull List<String> lines) {
final Map<String, List<String>> lists = new LinkedHashMap<>();
final Deque<Section> stack = new ArrayDeque<>();
for (int i = 0; i < lines.size(); i++) {
final LineTokens tokens = parseLine(lines.get(i));
if (tokens == null) {
continue;
}
popToLevel(stack, tokens.indent());
final String fullPath = resolvePath(stack, tokens.leaf());
if (tokens.isSection()) {
final List<String> items = tokens.after().isEmpty() ? listItems(lines, i + 1, tokens.indent()) : null;
if (items != null) {
lists.put(fullPath, items);
}
stack.push(new Section(tokens.leaf(), tokens.indent()));
}
}
return lists;
}
@Nullable
private static List<String> listItems(@NotNull List<String> lines, int from, int sectionIndent) {
final int start = firstListItemIndex(lines, from, sectionIndent);
if (start < 0) {
return null;
}
final List<String> items = new ArrayList<>();
for (int j = start; j < listEnd(lines, start); j++) {
items.add(lines.get(j).trim());
}
return items;
}
@NotNull
public static List<String> pathsFor(@NotNull List<String> lines) {
final List<String> paths = new ArrayList<>(lines.size());
final Deque<Section> stack = new ArrayDeque<>();
for (final String line : lines) {
final LineTokens tokens = parseLine(line);
if (tokens == null) {
paths.add(null);
continue;
}
popToLevel(stack, tokens.indent());
final String fullPath = resolvePath(stack, tokens.leaf());
if (tokens.isSection()) {
stack.push(new Section(tokens.leaf(), tokens.indent()));
}
paths.add(fullPath);
}
return paths;
}
static boolean isListItem(@NotNull String line) {
final String trimmed = line.trim();
return "-".equals(trimmed) || trimmed.startsWith("- ");
}
static int firstListItemIndex(@NotNull List<String> lines, int from, int sectionIndent) {
for (int i = from; i < lines.size(); i++) {
final String trimmed = lines.get(i).trim();
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
continue;
}
return isListItem(lines.get(i)) && indentOf(lines.get(i)) > sectionIndent ? i : -1;
}
return -1;
}
static int listEnd(@NotNull List<String> lines, int start) {
int j = start;
while (j < lines.size() && isListItem(lines.get(j))) {
j++;
}
return j;
}
@Nullable
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<String> stack, int indent, int prevIndent) {
int current = prevIndent;
while (current >= 0 && indent <= current) {
if (!stack.isEmpty()) {
private static void popToLevel(@NotNull Deque<Section> stack, int indent) {
while (!stack.isEmpty() && stack.peek().indent() >= indent) {
stack.pop();
}
current -= 2;
}
return current;
}
@NotNull
public static String resolvePath(@NotNull Deque<String> stack, @NotNull String leaf) {
return stack.isEmpty() ? leaf : String.join(".", stack) + "." + leaf;
private static String resolvePath(@NotNull Deque<Section> 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) {
}
}

View file

@ -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<String> ignoredKeys) throws IOException {
final List<String> templateLines = parser.readLines(templateStream);
final List<String> existingLines = existingFile.exists() ? parser.readLines(existingFile) : List.of();
return merge(templateLines, existingLines, ignoredKeys);
}
@NotNull
List<String> merge(
@NotNull List<String> templateLines,
@NotNull List<String> existingLines,
@Nullable List<String> ignoredKeys) {
final Set<String> ignored = normalizeIgnored(ignoredKeys);
final Map<String, String> existingValues = parser.extractFullPathValues(existingLines);
final Map<String, List<String>> existingLists = parser.extractBlockSequences(existingLines);
final Map<String, List<String>> existingSections = extractSections(existingLines);
final List<String> templatePaths = ConfigMergeParser.pathsFor(templateLines);
final List<String> result = new ArrayList<>();
final Deque<String> 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<String> result,
@NotNull List<String> templateLines,
int i,
@NotNull String fullPath,
@NotNull Set<String> ignored,
@NotNull Map<String, List<String>> existingSections,
@NotNull Map<String, List<String>> existingLists) {
if (isIgnored(fullPath, ignored)) {
final int next = appendIgnoredSection(result, templateLines, i, fullPath, existingSections);
if (next > i) {
return next;
}
}
result.add(templateLines.get(i));
return appendBlockList(result, templateLines, i, fullPath, ConfigMergeParser.indentOf(templateLines.get(i)), existingLists);
}
private static int appendIgnoredSection(
@NotNull List<String> result,
@NotNull List<String> templateLines,
int i,
@NotNull String fullPath,
@NotNull Map<String, List<String>> existingSections) {
final List<String> userSection = existingSections.get(fullPath);
if (userSection == null) {
return i;
}
final int delta = ConfigMergeParser.indentOf(templateLines.get(i)) - ConfigMergeParser.indentOf(userSection.get(0));
for (final String sectionLine : userSection) {
result.add(reindent(sectionLine, delta));
}
return i + sectionSpan(templateLines, ConfigMergeParser.pathsFor(templateLines), i).size();
}
private static int appendBlockList(
@NotNull List<String> result,
@NotNull List<String> templateLines,
int i,
@NotNull String fullPath,
int sectionIndent,
@NotNull Map<String, List<String>> existingLists) {
final int listStart = ConfigMergeParser.firstListItemIndex(templateLines, i + 1, sectionIndent);
if (listStart < 0) {
return i + 1;
}
final List<String> userItems = existingLists.get(fullPath);
if (userItems == null) {
return i + 1;
}
final int listIndent = ConfigMergeParser.indentOf(templateLines.get(listStart));
for (int k = i + 1; k < listStart; k++) {
result.add(templateLines.get(k));
}
for (final String item : userItems) {
result.add(" ".repeat(listIndent) + item);
}
return ConfigMergeParser.listEnd(templateLines, listStart);
}
@NotNull
private static String applyExistingValue(
@NotNull String line,
@NotNull String fullPath,
@NotNull Map<String, String> 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<String, List<String>> extractSections(@NotNull List<String> lines) {
final Map<String, List<String>> sections = new LinkedHashMap<>();
final List<String> paths = ConfigMergeParser.pathsFor(lines);
for (int i = 0; i < lines.size(); i++) {
final ConfigMergeParser.LineTokens tokens = ConfigMergeParser.parseLine(lines.get(i));
if (tokens != null && tokens.isSection()) {
sections.put(paths.get(i), sectionSpan(lines, paths, i));
}
}
return sections;
}
private static List<String> sectionSpan(
@NotNull List<String> lines,
@NotNull List<String> paths,
int start) {
final String path = paths.get(start);
final int indent = ConfigMergeParser.indentOf(lines.get(start));
final List<String> span = new ArrayList<>();
int j = start;
while (j < lines.size()) {
final String candidate = paths.get(j);
if (j > start && candidate != null && !candidate.startsWith(path + ".")) {
break;
}
span.add(lines.get(j));
j++;
}
while (span.size() > 1
&& ConfigMergeParser.parseLine(span.get(span.size() - 1)) == null
&& ConfigMergeParser.indentOf(span.get(span.size() - 1)) <= indent) {
span.remove(span.size() - 1);
}
return span;
}
@NotNull
private static Set<String> normalizeIgnored(@Nullable List<String> keys) {
if (keys == null) {

View file

@ -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<String, Boolean> JUMP_TYPE_ENABLED;
public static Map<String, Double> 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;

View file

@ -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<String, Boolean> JUMP_TYPE_ENABLED;
public static Map<String, Double> 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;

View file

@ -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);
}
}

View file

@ -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);
}
}
}

View file

@ -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.
*
* <p>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)}.</p>
* 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();
}

View file

@ -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);
}

View file

@ -38,6 +38,7 @@ public class GeneratorState {
// Iteration is safe without external synchronization as all mutations happen on the same thread.
public final List<Block> history = new ArrayList<>();
public int lastPositionIndexPlayer = -1;
public int generatedBlocks = 0;
// Schematic state
public boolean deleteSchematic = false;

View file

@ -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;
}

View file

@ -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<Block> blocks) {
if (!generator.profile.get("particles").asBoolean()) {
return;
}
final List<Location> 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<ParkourPlayer> 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<Block> 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));
}
}

View file

@ -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;
@ -40,7 +51,6 @@ public class JumpCalculator {
*/
@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");
}
@ -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 {
@ -111,68 +114,80 @@ public class JumpCalculator {
}
@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);
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);
}
final Vector perpendicular = new Vector(-horizontal.getZ() / length, 0, horizontal.getX() / length);
return horizontal.multiply(distance).add(perpendicular.multiply(randomOffset)).setY(height);
}
return offset;
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;

View file

@ -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) {

View file

@ -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++;
}
}

View file

@ -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;

View file

@ -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()) {
if (particle != null) {
player.player.spawnParticle(
Particle.BLOCK_CRACK, loc, PARTICLE_COUNT,
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);
}
}

View file

@ -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()) {

View file

@ -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);
}
}
}

View file

@ -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,8 +92,8 @@ 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;
@ -98,4 +101,9 @@ public class LifecycleTickManager {
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;
}
}

View file

@ -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<Block> 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) {
final Set<Block> 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;

View file

@ -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,12 +131,8 @@ 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);

View file

@ -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.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>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.</li>
* <li>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.</li>
* <li>Old v1 files (no UUID) are detected via {@link GhostData#loadFromFile}
* returning null — they are deleted automatically on first load.</li>
* </ul>
* <p>Layout: {@code ghosts/<playerUuid>/<timestamp>.ghost}. Each player keeps
* their last {@link #maxRuns()} runs; older files are evicted on save.
*
* <p>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<String, List<GhostData>> ghostsByMode = new HashMap<>();
private final List<GhostPlayer> 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<GhostData> 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<GhostData> ghosts = new ArrayList<>();
final List<GhostData> 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<GhostData> 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<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.isEmpty()) {
return;
evictOldRuns(playerFolder);
}
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);
}
}
public void stopAllGhosts() {
activeGhosts.forEach(GhostPlayer::stop);
activeGhosts.clear();
}
// ── Queries ───────────────────────────────────────────────────────────────
@Nullable
public GhostData getTopGhost(@NotNull String mode) {
final List<GhostData> ghosts = ghostsByMode.get(mode);
return (ghosts == null || ghosts.isEmpty()) ? null : ghosts.get(0);
}
public List<GhostData> getTopGhosts(@NotNull String mode, int count) {
final List<GhostData> 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<GhostData> 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;
}
}
}

View file

@ -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() {

View file

@ -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.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>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.</li>
* <li>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.</li>
* </ul>
* <p>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<GhostFrame> frames;
public GhostData(@NotNull UUID playerUuid, @NotNull String playerName,
int score, @NotNull List<GhostFrame> frames) {
int score, long timestamp, @NotNull String worldName,
@NotNull List<GhostFrame> 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<GhostFrame> 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<GhostFrame> 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<GhostFrame> getFrames() {
return frames;
}
@ -72,6 +93,8 @@ public class GhostData {
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<GhostFrame> 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);
}
}
}

View file

@ -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.
*
* <p>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> T allocate(Class<T> 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);
}
}
}

View file

@ -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<GhostFrame> 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();
}
}

View file

@ -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<UUID, GhostRecorder> recorders = new ConcurrentHashMap<>();
private final Set<UUID> 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();
}
}

View file

@ -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) {

View file

@ -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);

View file

@ -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"),

View file

@ -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,35 +56,39 @@ 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;
.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 -> 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);
})
.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 -> {
}
private void toggleSpecialBlocks(ParkourPlayer pp) {
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_STYLE, styleItem(pp), e -> openStyleMenu(pp))
.setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> {
}
private void cycleSchematicDifficulty(ParkourPlayer pp) {
if (pp.session.generator.state.score == 0) {
final List<Double> diffs = List.of(0.0, DIFF_QUARTER, DIFF_HALF, DIFF_THREE_QUARTER, 1.0);
final int idx = diffs.indexOf(pp.schematicDifficulty);
@ -91,9 +96,12 @@ public class ParkourSettingsMenu extends LPMenu {
pp.updateGeneratorSettings(pp.session.generator);
}
open(pp);
})
.setItem(SLOT_CLOSE, closeButton(player), e -> Menus.SETTINGS.open(player))
.open(player);
}
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) {
@ -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<String> 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<String> 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<String> 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<String> 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) {

View file

@ -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);
}
}
}
}

View file

@ -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<UUID, Integer> 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;
}
}

View file

@ -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.
*
* <p>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.</p>
*
* <p>Problems fixed vs previous version:
* <ul>
* <li>Did NOT implement MultiMode → showed up in single-player menu</li>
* <li>CoopGenerator was a private inner class — inaccessible from join/leave</li>
* <li>sharedScore was a separate field that duplicated state.score</li>
* <li>fall() called super.fall() THEN tried to clear state that was already reset</li>
* <li>playerContributions never cleared between falls correctly</li>
* <li>create(Player) silently created a solo session instead of rejecting</li>
* </ul>
*/
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<UUID, Integer> 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;
}
}
}

View file

@ -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

View file

@ -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());
}

View file

@ -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) {

View file

@ -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<String, OptionContainer> getColumnMappings() {

View file

@ -61,6 +61,12 @@ public final class UserRegistry {
}
public static void unregister(@NotNull ParkourUser user, boolean restorePreviousData, boolean kickIfBungee, boolean urgent) {
if (user.unregistering) {
return;
}
user.unregistering = true;
try {
dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices.unloadPlayer(user.getUUID());
new ParkourLeaveEvent(user).call();
@ -79,6 +85,9 @@ public final class UserRegistry {
if (restorePreviousData) {
restorePreviousState(user, urgent);
}
} finally {
user.unregistering = false;
}
}
public static boolean isUser(@Nullable Player player) {

View file

@ -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;

View file

@ -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();

View file

@ -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;
}
}
}

View file

@ -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()));

View file

@ -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

View file

@ -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<Runnable> 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);

View file

@ -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<String, Object> 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<Map<String, Object>>() {}.getType();
final Map<String, Object> parsed = LoParkour.getGson().fromJson(settings, type);
if (parsed == null) {
player.setSettings(new HashMap<>());
return;
}
normalizeNumericSettings(parsed);
player.setSettings(parsed);
}
private static void normalizeNumericSettings(@NotNull Map<String, Object> settings) {
for (final Map.Entry<String, Object> entry : settings.entrySet()) {
final Object value = entry.getValue();
if (value instanceof Double) {
final double number = (Double) value;
if (number % 1 == 0) {
entry.setValue((int) number);
}
}
}
}
/**

View file

@ -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<String> 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());
}
}

View file

@ -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<>());
}
}

View file

@ -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 &lt; 1.19.3).
*/
public static void sendBlockChanges(@Nullable Player player, @Nullable Collection<BlockState> 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;
}
}

View file

@ -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

View file

@ -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:

View file

@ -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: "&#55FF55Playing back your run..."
help:
header: "&#404040&m--------------- &#FF6464&lLoParkour &#404040&m---------------"
line_parkour: "&#A0A0A0/parkour &#404040- Main command"
line_replay: "&#A0A0A0/parkour replay [run] &#404040- Watch your recorded runs"
line_join: "&#A0A0A0/parkour join [mode/player] &#404040- Join a mode"
line_leave: "&#A0A0A0/parkour leave &#404040- Leave parkour"
line_menu: "&#A0A0A0/parkour menu &#404040- Open the menu"

View file

@ -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: "&#55FF55Воспроизведение вашего забега..."
help:
header: "&#404040&m--------------- &#FF6464&lLoParkour &#404040&m---------------"
line_parkour: "&#A0A0A0/parkour &#404040- Главная команда"
line_replay: "&#A0A0A0/parkour replay [забег] &#404040- Смотреть записанные забеги"
line_join: "&#A0A0A0/parkour join [режим/игрок] &#404040- Войти в режим"
line_leave: "&#A0A0A0/parkour leave &#404040- Выйти"
line_menu: "&#A0A0A0/parkour menu &#404040- Открыть меню"

View file

@ -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:

View file

@ -0,0 +1,249 @@
package dev.loki.loparkour.config.core.merge;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.yaml.snakeyaml.Yaml;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit tests for the template/user config merge. Uses 2-space indentation,
* matching the shipped config templates.
*/
class ConfigMergeProcessorTest {
private static final String TEMPLATE = """
debug: false
options:
leads:
amount:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
time:
format: 24
focus-mode:
whitelist:
- 'ip'
- 'msg'
styles:
list:
red: [red_wool, red_stained_glass]
""";
private final ConfigMergeProcessor processor = new ConfigMergeProcessor();
@Test
void customizedBlockListSurvivesMerge() {
final List<String> user = List.of(
"debug: true",
"options:",
" leads:",
" amount:",
" - 1",
" - 2",
" - 3",
"",
" time:",
" format: 12");
final List<String> merged = processor.merge(lines(TEMPLATE), user, null);
assertTrue(merged.contains(" - 1"));
assertTrue(merged.contains(" - 2"));
assertTrue(merged.contains(" - 3"));
assertFalse(merged.contains(" - 4"));
assertTrue(merged.contains("debug: true"));
assertTrue(merged.contains(" format: 12"));
assertTrue(merged.contains(" - 'ip'"));
assertTrue(merged.contains(" red: [red_wool, red_stained_glass]"));
assertValidYaml(merged);
}
@Test
void unchangedBlockListStaysIdentical() {
final List<String> template = lines(TEMPLATE);
assertEquals(template, processor.merge(template, template, null));
}
@Test
void newTemplateKeysAreAdded() {
final List<String> template = List.of(
"old-key: 'old'",
"new-key: 'new'",
"new-section:",
" enabled: true");
final List<String> user = List.of("old-key: 'custom'");
final List<String> merged = processor.merge(template, user, null);
assertTrue(merged.contains("old-key: 'custom'"));
assertTrue(merged.contains("new-key: 'new'"));
assertTrue(merged.contains("new-section:"));
assertTrue(merged.contains(" enabled: true"));
assertValidYaml(merged);
}
@Test
void ignoredSectionUserCustomizedIsPreserved() {
final List<String> user = List.of(
"styles:",
" list:",
" red: [custom_red_wool]",
" extra: [extra_wool]");
final List<String> merged = processor.merge(lines(TEMPLATE), user, List.of("styles"));
assertTrue(merged.contains(" red: [custom_red_wool]"));
assertTrue(merged.contains(" extra: [extra_wool]"));
assertFalse(merged.contains(" red: [red_wool, red_stained_glass]"));
assertValidYaml(merged);
}
@Test
void ignoredSectionMissingUsesTemplate() {
final List<String> user = List.of("debug: false");
final List<String> merged = processor.merge(lines(TEMPLATE), user, List.of("styles"));
assertTrue(merged.contains("styles:"));
assertTrue(merged.contains(" list:"));
assertTrue(merged.contains(" red: [red_wool, red_stained_glass]"));
assertValidYaml(merged);
}
@Test
void ignoredSectionBlockListIsPreserved() {
final List<String> template = List.of(
"focus-mode:",
" enabled: false",
" whitelist:",
" - 'ip'",
" - 'msg'");
final List<String> user = List.of(
"focus-mode:",
" enabled: true",
" whitelist:",
" - 'custom'");
final List<String> merged = processor.merge(template, user, List.of("focus-mode"));
assertTrue(merged.contains(" enabled: true"));
assertTrue(merged.contains(" - 'custom'"));
assertFalse(merged.contains("- 'ip'"));
assertValidYaml(merged);
}
@Test
void fileBasedMergeAppliesBlockList(@TempDir Path tempDir) throws IOException {
final Path templateFile = tempDir.resolve("template.yml");
final Path existingFile = tempDir.resolve("existing.yml");
Files.write(templateFile, lines(TEMPLATE));
Files.write(existingFile, List.of(
"debug: true",
"options:",
" leads:",
" amount:",
" - 1",
" - 2",
" - 3"));
try (InputStream is = Files.newInputStream(templateFile)) {
final List<String> merged = processor.merge(is, existingFile.toFile(), null);
assertTrue(merged.contains(" - 1"));
assertTrue(merged.contains(" - 3"));
assertFalse(merged.contains(" - 4"));
assertTrue(merged.contains("debug: true"));
assertValidYaml(merged);
}
}
@Test
void ignoredSectionWithSiblingScalarsIsPreserved() {
final List<String> template = List.of(
"difficulty:",
" d0e227d2: 0.25",
" dbef835e: 0.25",
" 27f41406: 1.0");
final List<String> user = List.of(
"difficulty:",
" d0e227d2: 0.5",
" dbef835e: 0.25",
" abc12345: 0.75");
final List<String> merged = processor.merge(template, user, List.of("difficulty"));
assertTrue(merged.contains(" d0e227d2: 0.5"));
assertTrue(merged.contains(" dbef835e: 0.25"));
assertTrue(merged.contains(" abc12345: 0.75"));
assertFalse(merged.contains("27f41406: 1.0"));
assertValidYaml(merged);
}
@Test
void siblingScalarsAllSubstitute() {
final List<String> template = List.of(
"world:",
" name: 'loparkour'",
" fall-back: 'world'",
" delete-on-reload: true");
final List<String> user = List.of(
"world:",
" name: 'custom'",
" fall-back: 'custom-world'");
final List<String> merged = processor.merge(template, user, null);
assertTrue(merged.contains(" name: 'custom'"));
assertTrue(merged.contains(" fall-back: 'custom-world'"));
assertTrue(merged.contains(" delete-on-reload: true"));
assertValidYaml(merged);
}
@Test
void freshInstallMergesIdentically() throws IOException {
assertFreshInstallIdentity("config.yml", List.of("styles"));
assertFreshInstallIdentity("generation.yml", null);
assertFreshInstallIdentity("rewards-v2.yml", List.of("score-rewards", "interval-rewards", "one-time-rewards"));
assertFreshInstallIdentity("schematics/schematics.yml", List.of("difficulty"));
}
private static void assertFreshInstallIdentity(String resource, List<String> ignored) throws IOException {
try (InputStream is = ConfigMergeProcessorTest.class.getResourceAsStream("/" + resource)) {
assertNotNull(is, "missing resource " + resource);
final List<String> template = new BufferedReader(new InputStreamReader(is)).lines().toList();
assertEquals(template, new ConfigMergeProcessor().merge(template, template, ignored),
"fresh-install merge must be identity for " + resource);
}
}
private static List<String> lines(String content) {
return content.lines().toList();
}
private static void assertValidYaml(List<String> merged) {
final Object parsed = new Yaml().load(String.join("\n", merged));
assertNotNull(parsed);
}
}

View file

@ -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();
}
}

View file

@ -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<GhostFrame> 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)");
}
}

View file

@ -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<GhostFrame> 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<GhostData> 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<GhostData> 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<GhostData> 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<GhostData> runs = manager.getRuns(uuid);
assertTrue(runs.isEmpty(), "Stale-format files must be skipped and deleted");
}
}

View file

@ -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;
}
}

View file

@ -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()));
}
}

View file

@ -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));
}
}