Compare commits
10 commits
3b5d3d97d5
...
4e3ccfd736
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e3ccfd736 | |||
| 9783418734 | |||
| dee403b5f4 | |||
| 2f020d6b34 | |||
| cd330608b6 | |||
| 5de4d026ef | |||
| 8c8046a4f5 | |||
| 4c255111f0 | |||
| fea15e61c8 | |||
| c0ca488b9d |
74 changed files with 2503 additions and 742 deletions
64
.github/workflows/build.yml
vendored
Normal file
64
.github/workflows/build.yml
vendored
Normal 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
43
CHANGELOG.md
Normal 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.
|
||||
|
|
@ -8,7 +8,7 @@ Advanced infinitely generating parkour plugin with ghost system, leaderboards an
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[English](#english) | [Русский](#russian)
|
||||
|
||||
|
|
|
|||
146
build.gradle.kts
146
build.gradle.kts
|
|
@ -1,24 +1,41 @@
|
|||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
// Remap tooling: rebuilds the Mojang-mapped 1.20.4 NMS classes at build time.
|
||||
buildscript {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://maven.fabricmc.net/")
|
||||
}
|
||||
dependencies {
|
||||
classpath("net.fabricmc:mapping-io:0.6.1")
|
||||
classpath("net.fabricmc:tiny-remapper:0.9.0")
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("java")
|
||||
id("io.github.goooler.shadow") version("8.1.8")
|
||||
id("com.gradleup.shadow") version("9.6.1")
|
||||
id("xyz.jpenilla.run-paper") version("3.0.2")
|
||||
checkstyle
|
||||
pmd
|
||||
}
|
||||
|
||||
group = "dev.loki"
|
||||
version = "1.3.3"
|
||||
version = "1.4.0"
|
||||
description = "LoParkour - Advanced parkour plugin for Minecraft"
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
// Multi-version build: pass -PmcVersion / -PapiCoordinate / -PjavaRelease / -PapiVersion
|
||||
// to build the same source against a different Paper API (e.g. GitHub Actions matrix).
|
||||
val mcVersion: String = providers.gradleProperty("mcVersion").getOrElse("1.20.4")
|
||||
val apiCoordinate: String = providers.gradleProperty("apiCoordinate")
|
||||
.getOrElse("org.spigotmc:spigot-api:$mcVersion-R0.1-SNAPSHOT")
|
||||
val javaRelease: Int = providers.gradleProperty("javaRelease").map { it.toInt() }.getOrElse(21)
|
||||
val apiVersion: String = providers.gradleProperty("apiVersion").getOrElse("1.20")
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
|
||||
maven {
|
||||
name = "papermc"
|
||||
url = uri("https://repo.papermc.io/repository/maven-public/")
|
||||
|
|
@ -44,11 +61,6 @@ repositories {
|
|||
url = uri("https://repo.extendedclip.com/content/repositories/placeholderapi/")
|
||||
}
|
||||
|
||||
maven {
|
||||
name = "mv-repo"
|
||||
url = uri("https://repo.onarandombox.com/content/groups/public/")
|
||||
}
|
||||
|
||||
maven {
|
||||
name = "dmulloy2-repo"
|
||||
url = uri("https://repo.dmulloy2.net/repository/public/")
|
||||
|
|
@ -76,9 +88,95 @@ repositories {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Mojang-mapped NMS (net.minecraft.*) for the packet NPC renderer ---------
|
||||
// repo.papermc.io purged the old io.papermc.paper:paper:1.20.4-R0.1-SNAPSHOT
|
||||
// dev-bundle, so the 1.20.4 vanilla server jar (bundler/obfuscated) is remapped
|
||||
// with Mojang's official mappings via tiny-remapper into the same Mojang-mapped
|
||||
// classes the dev-bundle shipped. Result is cached under the Gradle user home.
|
||||
val nmsVersion = "1.20.4"
|
||||
val nmsCacheDir = gradle.gradleUserHomeDir.resolve("caches/loparkour-nms/$nmsVersion")
|
||||
val nmsServerJar = nmsCacheDir.resolve("server.jar")
|
||||
val nmsNestedJar = nmsCacheDir.resolve("server-nested.jar")
|
||||
val nmsMappings = nmsCacheDir.resolve("server-mappings.txt")
|
||||
val nmsTiny = nmsCacheDir.resolve("server-mappings.tiny")
|
||||
val nmsMappedJar = nmsCacheDir.resolve("server-mojang-mapped.jar")
|
||||
val nmsAuthlibJar = nmsCacheDir.resolve("authlib-6.0.52.jar")
|
||||
|
||||
val remapNms by tasks.registering {
|
||||
outputs.files(nmsServerJar, nmsMappings, nmsMappedJar, nmsAuthlibJar)
|
||||
doLast {
|
||||
nmsCacheDir.mkdirs()
|
||||
fun download(url: String, target: File) {
|
||||
if (target.isFile) {
|
||||
return
|
||||
}
|
||||
URI(url).toURL().openStream().use { input ->
|
||||
target.outputStream().use { input.copyTo(it) }
|
||||
}
|
||||
}
|
||||
download("https://piston-data.mojang.com/v1/objects/8dd1a28015f51b1803213892b50b7b4fc76e594d/server.jar", nmsServerJar)
|
||||
download("https://piston-data.mojang.com/v1/objects/c1cafe916dd8b58ed1fe0564fc8f786885224e62/server.txt", nmsMappings)
|
||||
download("https://libraries.minecraft.net/com/mojang/authlib/6.0.52/authlib-6.0.52.jar", nmsAuthlibJar)
|
||||
if (!nmsNestedJar.isFile) {
|
||||
ZipFile(nmsServerJar).use { zip ->
|
||||
zip.getInputStream(zip.getEntry("META-INF/versions/$nmsVersion/server-$nmsVersion.jar")).use { input ->
|
||||
nmsNestedJar.outputStream().use { input.copyTo(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val tree = net.fabricmc.mappingio.tree.MemoryMappingTree(false)
|
||||
net.fabricmc.mappingio.MappingReader.read(nmsMappings.toPath(), net.fabricmc.mappingio.format.MappingFormat.PROGUARD_FILE, tree)
|
||||
net.fabricmc.mappingio.MappingWriter.create(nmsTiny.toPath(), net.fabricmc.mappingio.format.MappingFormat.TINY_2_FILE).use { writer ->
|
||||
tree.accept(net.fabricmc.mappingio.adapter.MappingSourceNsSwitch(writer, "target"))
|
||||
}
|
||||
val remapper = net.fabricmc.tinyremapper.TinyRemapper.newRemapper()
|
||||
.withMappings(net.fabricmc.tinyremapper.TinyUtils.createTinyMappingProvider(nmsTiny.toPath(), "target", "source"))
|
||||
.ignoreConflicts(true)
|
||||
.rebuildSourceFilenames(true)
|
||||
.build()
|
||||
net.fabricmc.tinyremapper.OutputConsumerPath.Builder(nmsMappedJar.toPath()).build().use { output ->
|
||||
output.addNonClassFiles(nmsNestedJar.toPath())
|
||||
remapper.readInputs(nmsNestedJar.toPath())
|
||||
remapper.apply(output)
|
||||
}
|
||||
remapper.finish()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<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> {
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) {
|
||||
stack.pop();
|
||||
}
|
||||
current -= 2;
|
||||
private static void popToLevel(@NotNull Deque<Section> stack, int indent) {
|
||||
while (!stack.isEmpty() && stack.peek().indent() >= indent) {
|
||||
stack.pop();
|
||||
}
|
||||
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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package dev.loki.loparkour.generator.jump.placement;
|
|||
|
||||
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
|
||||
import dev.loki.loparkour.player.core.ParkourPlayer;
|
||||
import dev.loki.loparkour.util.misc.Compat;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
|
|
@ -31,11 +32,14 @@ class BlockWorldPlacer {
|
|||
block.setBlockData(data, false);
|
||||
|
||||
final Location loc = block.getLocation().add(BLOCK_CENTER_OFFSET, BLOCK_CENTER_OFFSET, BLOCK_CENTER_OFFSET);
|
||||
final Particle particle = Compat.particle("BLOCK_CRACK", "BLOCK");
|
||||
for (final ParkourPlayer player : generator.getPlayers()) {
|
||||
player.player.spawnParticle(
|
||||
Particle.BLOCK_CRACK, loc, PARTICLE_COUNT,
|
||||
PARTICLE_OFFSET, PARTICLE_OFFSET, PARTICLE_OFFSET, PARTICLE_EXTRA, data
|
||||
);
|
||||
if (particle != null) {
|
||||
player.player.spawnParticle(
|
||||
particle, loc, PARTICLE_COUNT,
|
||||
PARTICLE_OFFSET, PARTICLE_OFFSET, PARTICLE_OFFSET, PARTICLE_EXTRA, data
|
||||
);
|
||||
}
|
||||
player.player.playSound(loc, Sound.BLOCK_STONE_PLACE, PLACE_SOUND_VOLUME, 1.0f);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
block.setType(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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
final int showTop = Math.min(Config.CONFIG.getInt("ghost-mode.show-top"), ghosts.size());
|
||||
for (int i = 0; i < showTop; i++) {
|
||||
final GhostPlayer ghost = new GhostPlayer(ghosts.get(i), world);
|
||||
ghost.spawn(startLocation);
|
||||
activeGhosts.add(ghost);
|
||||
}
|
||||
evictOldRuns(playerFolder);
|
||||
}
|
||||
|
||||
public void stopAllGhosts() {
|
||||
activeGhosts.forEach(GhostPlayer::stop);
|
||||
activeGhosts.clear();
|
||||
}
|
||||
|
||||
// ── Queries ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Nullable
|
||||
public GhostData getTopGhost(@NotNull String mode) {
|
||||
final List<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -69,9 +90,11 @@ public class GhostData {
|
|||
try (DataOutputStream out = new DataOutputStream(
|
||||
new BufferedOutputStream(new FileOutputStream(file)))) {
|
||||
out.writeInt(FORMAT_VERSION); // version header
|
||||
out.writeUTF(playerUuid.toString()); // UUID (filename-safe, collision-free)
|
||||
out.writeUTF(playerUuid.toString()); // UUID (filename-safe, collision-free)
|
||||
out.writeUTF(playerName); // display name (for the ghost label)
|
||||
out.writeInt(score);
|
||||
out.writeLong(timestamp); // run completion time (ordering)
|
||||
out.writeUTF(worldName); // world the run happened in
|
||||
out.writeInt(frames.size());
|
||||
for (final GhostFrame frame : frames) {
|
||||
out.writeLong(frame.getTimestamp());
|
||||
|
|
@ -87,8 +110,8 @@ public class GhostData {
|
|||
/**
|
||||
* Loads a ghost from file.
|
||||
*
|
||||
* @return The loaded data, or {@code null} if the file is an old v1 format
|
||||
* (no version header / UUID) — caller should delete and ignore it.
|
||||
* @return The loaded data, or {@code null} if the file uses an older format
|
||||
* (anything other than {@link #FORMAT_VERSION}) — caller deletes it.
|
||||
*/
|
||||
public static GhostData loadFromFile(@NotNull File file) throws IOException {
|
||||
try (DataInputStream in = new DataInputStream(
|
||||
|
|
@ -96,14 +119,15 @@ public class GhostData {
|
|||
|
||||
final int version = in.readInt();
|
||||
if (version != FORMAT_VERSION) {
|
||||
// Old format (v1) started with readUTF for playerName, not an int version.
|
||||
// Those files are stale — return null so the manager can discard them.
|
||||
// Files not matching the current format are stale — return null so the manager discards them.
|
||||
return null;
|
||||
}
|
||||
|
||||
final UUID uuid = UUID.fromString(in.readUTF());
|
||||
final String name = in.readUTF();
|
||||
final int score = in.readInt();
|
||||
final long timestamp = in.readLong();
|
||||
final String world = in.readUTF();
|
||||
final int frameCount = in.readInt();
|
||||
|
||||
final List<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
200
src/main/java/dev/loki/loparkour/ghost/replay/PlayerGhost.java
Normal file
200
src/main/java/dev/loki/loparkour/ghost/replay/PlayerGhost.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/main/java/dev/loki/loparkour/ghost/service/GhostReplay.java
Normal file
113
src/main/java/dev/loki/loparkour/ghost/service/GhostReplay.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ public class ParkourSettingsMenu extends LPMenu {
|
|||
private static final int SLOT_SPECIAL_BLOCKS = 12;
|
||||
private static final int SLOT_FALL_MESSAGE = 13;
|
||||
private static final int SLOT_SCOREBOARD = 14;
|
||||
private static final int SLOT_GHOST_RECORDING = 15;
|
||||
private static final int SLOT_STYLE = 19;
|
||||
private static final int SLOT_SCHEM_DIFF = 20;
|
||||
private static final int SLOT_CLOSE = 31;
|
||||
|
|
@ -55,47 +56,54 @@ public class ParkourSettingsMenu extends LPMenu {
|
|||
final String title = Locales.getString(locale, "settings.name");
|
||||
|
||||
baseGui(title, ROWS_MAIN)
|
||||
.setItem(SLOT_PARTICLES, toggleItem(locale, "particles", pp.particles), e -> {
|
||||
pp.particles = !pp.particles;
|
||||
pp.updateGeneratorSettings(pp.session.generator);
|
||||
open(pp);
|
||||
})
|
||||
.setItem(SLOT_SOUND, toggleItem(locale, "sound", pp.sound), e -> {
|
||||
pp.sound = !pp.sound;
|
||||
pp.updateGeneratorSettings(pp.session.generator);
|
||||
open(pp);
|
||||
})
|
||||
.setItem(SLOT_SPECIAL_BLOCKS, toggleItem(locale, "special_blocks", pp.useSpecialBlocks), e -> {
|
||||
if (pp.session.generator.state.score == 0) {
|
||||
pp.useSpecialBlocks = !pp.useSpecialBlocks;
|
||||
pp.updateGeneratorSettings(pp.session.generator);
|
||||
}
|
||||
open(pp);
|
||||
})
|
||||
.setItem(SLOT_FALL_MESSAGE, toggleItem(locale, "fall_message", pp.showFallMessage), e -> {
|
||||
pp.showFallMessage = !pp.showFallMessage;
|
||||
pp.updateGeneratorSettings(pp.session.generator);
|
||||
open(pp);
|
||||
})
|
||||
.setItem(SLOT_SCOREBOARD, toggleItem(locale, "scoreboard", pp.showScoreboard), e -> {
|
||||
pp.showScoreboard = !pp.showScoreboard;
|
||||
pp.updateGeneratorSettings(pp.session.generator);
|
||||
open(pp);
|
||||
})
|
||||
.setItem(SLOT_PARTICLES, toggleItem(locale, "particles", pp.particles),
|
||||
e -> toggleSetting(pp, () -> pp.particles = !pp.particles))
|
||||
.setItem(SLOT_SOUND, toggleItem(locale, "sound", pp.sound),
|
||||
e -> toggleSetting(pp, () -> pp.sound = !pp.sound))
|
||||
.setItem(SLOT_SPECIAL_BLOCKS, toggleItem(locale, "special_blocks", pp.useSpecialBlocks),
|
||||
e -> toggleSpecialBlocks(pp))
|
||||
.setItem(SLOT_FALL_MESSAGE, toggleItem(locale, "fall_message", pp.showFallMessage),
|
||||
e -> toggleSetting(pp, () -> pp.showFallMessage = !pp.showFallMessage))
|
||||
.setItem(SLOT_SCOREBOARD, toggleItem(locale, "scoreboard", pp.showScoreboard),
|
||||
e -> toggleSetting(pp, () -> pp.showScoreboard = !pp.showScoreboard))
|
||||
.setItem(SLOT_GHOST_RECORDING, toggleItem(locale, "ghost_recording",
|
||||
Boolean.TRUE.equals(pp.ghostRecording)), e -> toggleGhostRecording(pp))
|
||||
.setItem(SLOT_STYLE, styleItem(pp), e -> openStyleMenu(pp))
|
||||
.setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> {
|
||||
if (pp.session.generator.state.score == 0) {
|
||||
final List<Double> diffs = List.of(0.0, DIFF_QUARTER, DIFF_HALF, DIFF_THREE_QUARTER, 1.0);
|
||||
final int idx = diffs.indexOf(pp.schematicDifficulty);
|
||||
pp.schematicDifficulty = diffs.get((idx + 1) % diffs.size());
|
||||
pp.updateGeneratorSettings(pp.session.generator);
|
||||
}
|
||||
open(pp);
|
||||
})
|
||||
.setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> cycleSchematicDifficulty(pp))
|
||||
.setItem(SLOT_CLOSE, closeButton(player), e -> Menus.SETTINGS.open(player))
|
||||
.open(player);
|
||||
}
|
||||
|
||||
private void toggleSetting(ParkourPlayer pp, Runnable update) {
|
||||
update.run();
|
||||
pp.updateGeneratorSettings(pp.session.generator);
|
||||
open(pp);
|
||||
}
|
||||
|
||||
private void toggleSpecialBlocks(ParkourPlayer pp) {
|
||||
if (pp.session.generator.state.score == 0) {
|
||||
pp.useSpecialBlocks = !pp.useSpecialBlocks;
|
||||
pp.updateGeneratorSettings(pp.session.generator);
|
||||
}
|
||||
open(pp);
|
||||
}
|
||||
|
||||
private void cycleSchematicDifficulty(ParkourPlayer pp) {
|
||||
if (pp.session.generator.state.score == 0) {
|
||||
final List<Double> diffs = List.of(0.0, DIFF_QUARTER, DIFF_HALF, DIFF_THREE_QUARTER, 1.0);
|
||||
final int idx = diffs.indexOf(pp.schematicDifficulty);
|
||||
pp.schematicDifficulty = diffs.get((idx + 1) % diffs.size());
|
||||
pp.updateGeneratorSettings(pp.session.generator);
|
||||
}
|
||||
open(pp);
|
||||
}
|
||||
|
||||
private void toggleGhostRecording(ParkourPlayer pp) {
|
||||
pp.ghostRecording = !Boolean.TRUE.equals(pp.ghostRecording);
|
||||
pp.save(false);
|
||||
open(pp);
|
||||
}
|
||||
|
||||
private ItemStack toggleItem(String locale, String key, boolean value) {
|
||||
final Material mat = value ? Material.LIME_STAINED_GLASS_PANE : Material.RED_STAINED_GLASS_PANE;
|
||||
final String symbol = Locales.getString(locale, "settings.parkour_settings." + (value ? "enabled" : "disabled"));
|
||||
|
|
@ -106,13 +114,7 @@ public class ParkourSettingsMenu extends LPMenu {
|
|||
final ItemMeta meta = item.getItemMeta();
|
||||
if (meta != null) {
|
||||
meta.setDisplayName(ColorUtil.color(name + " §7[" + symbol + "§7]"));
|
||||
if (lore != null && !lore.isEmpty()) {
|
||||
final List<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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -61,23 +61,32 @@ public final class UserRegistry {
|
|||
}
|
||||
|
||||
public static void unregister(@NotNull ParkourUser user, boolean restorePreviousData, boolean kickIfBungee, boolean urgent) {
|
||||
dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices.unloadPlayer(user.getUUID());
|
||||
new ParkourLeaveEvent(user).call();
|
||||
|
||||
try {
|
||||
user.unregister();
|
||||
resetScoreboard(user);
|
||||
} catch (Exception ex) {
|
||||
handleUnregisterError(user, ex);
|
||||
}
|
||||
|
||||
if (restorePreviousData && shouldKickToBungee(kickIfBungee)) {
|
||||
BungeeUtil.sendPlayerToServer(user.player, Config.CONFIG.getString("bungeecord.return_server"));
|
||||
if (user.unregistering) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (restorePreviousData) {
|
||||
restorePreviousState(user, urgent);
|
||||
user.unregistering = true;
|
||||
try {
|
||||
dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices.unloadPlayer(user.getUUID());
|
||||
new ParkourLeaveEvent(user).call();
|
||||
|
||||
try {
|
||||
user.unregister();
|
||||
resetScoreboard(user);
|
||||
} catch (Exception ex) {
|
||||
handleUnregisterError(user, ex);
|
||||
}
|
||||
|
||||
if (restorePreviousData && shouldKickToBungee(kickIfBungee)) {
|
||||
BungeeUtil.sendPlayerToServer(user.player, Config.CONFIG.getString("bungeecord.return_server"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (restorePreviousData) {
|
||||
restorePreviousState(user, urgent);
|
||||
}
|
||||
} finally {
|
||||
user.unregistering = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<>());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
123
src/main/java/dev/loki/loparkour/util/misc/Compat.java
Normal file
123
src/main/java/dev/loki/loparkour/util/misc/Compat.java
Normal 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 < 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -247,6 +247,11 @@ settings:
|
|||
name: "Play sounds"
|
||||
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, every generated block||&#A0A0A0will play a sound."
|
||||
|
||||
ghost_recording:
|
||||
material: ""
|
||||
name: "Record ghost runs"
|
||||
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, your runs are recorded||&#A0A0A0so you can replay them later."
|
||||
|
||||
special_blocks:
|
||||
material: ""
|
||||
name: "Use special blocks"
|
||||
|
|
@ -381,9 +386,15 @@ commands:
|
|||
unknown_player: "&#FF4444Unknown player."
|
||||
not_playing: "&#FF4444That player isn't playing."
|
||||
reload: "&#A0A0A0Reloading config files... (locales load asynchronously)"
|
||||
replay:
|
||||
disabled: "&#FF4444Ghost replays are disabled on this server."
|
||||
no_runs: "&#A0A0A0You have no recorded runs yet."
|
||||
invalid: "&#FF4444Invalid run number."
|
||||
starting: "7FF55Playing back your run..."
|
||||
help:
|
||||
header: "񢩈&m--------------- &#FF6464&lLoParkour 񢩈&m---------------"
|
||||
line_parkour: "&#A0A0A0/parkour 񢩈- Main command"
|
||||
line_replay: "&#A0A0A0/parkour replay [run] 񢩈- Watch your recorded runs"
|
||||
line_join: "&#A0A0A0/parkour join [mode/player] 񢩈- Join a mode"
|
||||
line_leave: "&#A0A0A0/parkour leave 񢩈- Leave parkour"
|
||||
line_menu: "&#A0A0A0/parkour menu 񢩈- Open the menu"
|
||||
|
|
|
|||
|
|
@ -246,6 +246,11 @@ settings:
|
|||
name: "Воспроизводить звуки"
|
||||
lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, каждый сгенерированный блок||&#A0A0A0будет воспроизводить звук."
|
||||
|
||||
ghost_recording:
|
||||
material: ""
|
||||
name: "Записывать призрачные забеги"
|
||||
lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, ваши забеги записываются,||&#A0A0A0чтобы вы могли их повторить позже."
|
||||
|
||||
special_blocks:
|
||||
material: ""
|
||||
name: "Использовать специальные блоки"
|
||||
|
|
@ -380,9 +385,15 @@ commands:
|
|||
unknown_player: "&#FF4444Неизвестный игрок."
|
||||
not_playing: "&#FF4444Этот игрок не в паркуре."
|
||||
reload: "&#A0A0A0Перезагрузка конфигов... (локали грузятся асинхронно)"
|
||||
replay:
|
||||
disabled: "&#FF4444Призрачные повторы отключены на этом сервере."
|
||||
no_runs: "&#A0A0A0У вас пока нет записанных забегов."
|
||||
invalid: "&#FF4444Неверный номер забега."
|
||||
starting: "7FF55Воспроизведение вашего забега..."
|
||||
help:
|
||||
header: "񢩈&m--------------- &#FF6464&lLoParkour 񢩈&m---------------"
|
||||
line_parkour: "&#A0A0A0/parkour 񢩈- Главная команда"
|
||||
line_replay: "&#A0A0A0/parkour replay [забег] 񢩈- Смотреть записанные забеги"
|
||||
line_join: "&#A0A0A0/parkour join [режим/игрок] 񢩈- Войти в режим"
|
||||
line_leave: "&#A0A0A0/parkour leave 񢩈- Выйти"
|
||||
line_menu: "&#A0A0A0/parkour menu 񢩈- Открыть меню"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,249 @@
|
|||
package dev.loki.loparkour.config.core.merge;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Unit tests for the template/user config merge. Uses 2-space indentation,
|
||||
* matching the shipped config templates.
|
||||
*/
|
||||
class ConfigMergeProcessorTest {
|
||||
|
||||
private static final String TEMPLATE = """
|
||||
debug: false
|
||||
options:
|
||||
leads:
|
||||
amount:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
- 4
|
||||
- 5
|
||||
- 6
|
||||
- 7
|
||||
- 8
|
||||
- 9
|
||||
- 10
|
||||
|
||||
time:
|
||||
format: 24
|
||||
|
||||
focus-mode:
|
||||
whitelist:
|
||||
- 'ip'
|
||||
- 'msg'
|
||||
|
||||
styles:
|
||||
list:
|
||||
red: [red_wool, red_stained_glass]
|
||||
""";
|
||||
|
||||
private final ConfigMergeProcessor processor = new ConfigMergeProcessor();
|
||||
|
||||
@Test
|
||||
void customizedBlockListSurvivesMerge() {
|
||||
final List<String> user = List.of(
|
||||
"debug: true",
|
||||
"options:",
|
||||
" leads:",
|
||||
" amount:",
|
||||
" - 1",
|
||||
" - 2",
|
||||
" - 3",
|
||||
"",
|
||||
" time:",
|
||||
" format: 12");
|
||||
|
||||
final List<String> merged = processor.merge(lines(TEMPLATE), user, null);
|
||||
|
||||
assertTrue(merged.contains(" - 1"));
|
||||
assertTrue(merged.contains(" - 2"));
|
||||
assertTrue(merged.contains(" - 3"));
|
||||
assertFalse(merged.contains(" - 4"));
|
||||
assertTrue(merged.contains("debug: true"));
|
||||
assertTrue(merged.contains(" format: 12"));
|
||||
assertTrue(merged.contains(" - 'ip'"));
|
||||
assertTrue(merged.contains(" red: [red_wool, red_stained_glass]"));
|
||||
assertValidYaml(merged);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unchangedBlockListStaysIdentical() {
|
||||
final List<String> template = lines(TEMPLATE);
|
||||
assertEquals(template, processor.merge(template, template, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void newTemplateKeysAreAdded() {
|
||||
final List<String> template = List.of(
|
||||
"old-key: 'old'",
|
||||
"new-key: 'new'",
|
||||
"new-section:",
|
||||
" enabled: true");
|
||||
final List<String> user = List.of("old-key: 'custom'");
|
||||
|
||||
final List<String> merged = processor.merge(template, user, null);
|
||||
|
||||
assertTrue(merged.contains("old-key: 'custom'"));
|
||||
assertTrue(merged.contains("new-key: 'new'"));
|
||||
assertTrue(merged.contains("new-section:"));
|
||||
assertTrue(merged.contains(" enabled: true"));
|
||||
assertValidYaml(merged);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoredSectionUserCustomizedIsPreserved() {
|
||||
final List<String> user = List.of(
|
||||
"styles:",
|
||||
" list:",
|
||||
" red: [custom_red_wool]",
|
||||
" extra: [extra_wool]");
|
||||
|
||||
final List<String> merged = processor.merge(lines(TEMPLATE), user, List.of("styles"));
|
||||
|
||||
assertTrue(merged.contains(" red: [custom_red_wool]"));
|
||||
assertTrue(merged.contains(" extra: [extra_wool]"));
|
||||
assertFalse(merged.contains(" red: [red_wool, red_stained_glass]"));
|
||||
assertValidYaml(merged);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoredSectionMissingUsesTemplate() {
|
||||
final List<String> user = List.of("debug: false");
|
||||
|
||||
final List<String> merged = processor.merge(lines(TEMPLATE), user, List.of("styles"));
|
||||
|
||||
assertTrue(merged.contains("styles:"));
|
||||
assertTrue(merged.contains(" list:"));
|
||||
assertTrue(merged.contains(" red: [red_wool, red_stained_glass]"));
|
||||
assertValidYaml(merged);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoredSectionBlockListIsPreserved() {
|
||||
final List<String> template = List.of(
|
||||
"focus-mode:",
|
||||
" enabled: false",
|
||||
" whitelist:",
|
||||
" - 'ip'",
|
||||
" - 'msg'");
|
||||
final List<String> user = List.of(
|
||||
"focus-mode:",
|
||||
" enabled: true",
|
||||
" whitelist:",
|
||||
" - 'custom'");
|
||||
|
||||
final List<String> merged = processor.merge(template, user, List.of("focus-mode"));
|
||||
|
||||
assertTrue(merged.contains(" enabled: true"));
|
||||
assertTrue(merged.contains(" - 'custom'"));
|
||||
assertFalse(merged.contains("- 'ip'"));
|
||||
assertValidYaml(merged);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileBasedMergeAppliesBlockList(@TempDir Path tempDir) throws IOException {
|
||||
final Path templateFile = tempDir.resolve("template.yml");
|
||||
final Path existingFile = tempDir.resolve("existing.yml");
|
||||
Files.write(templateFile, lines(TEMPLATE));
|
||||
Files.write(existingFile, List.of(
|
||||
"debug: true",
|
||||
"options:",
|
||||
" leads:",
|
||||
" amount:",
|
||||
" - 1",
|
||||
" - 2",
|
||||
" - 3"));
|
||||
|
||||
try (InputStream is = Files.newInputStream(templateFile)) {
|
||||
final List<String> merged = processor.merge(is, existingFile.toFile(), null);
|
||||
assertTrue(merged.contains(" - 1"));
|
||||
assertTrue(merged.contains(" - 3"));
|
||||
assertFalse(merged.contains(" - 4"));
|
||||
assertTrue(merged.contains("debug: true"));
|
||||
assertValidYaml(merged);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoredSectionWithSiblingScalarsIsPreserved() {
|
||||
final List<String> template = List.of(
|
||||
"difficulty:",
|
||||
" d0e227d2: 0.25",
|
||||
" dbef835e: 0.25",
|
||||
" 27f41406: 1.0");
|
||||
final List<String> user = List.of(
|
||||
"difficulty:",
|
||||
" d0e227d2: 0.5",
|
||||
" dbef835e: 0.25",
|
||||
" abc12345: 0.75");
|
||||
|
||||
final List<String> merged = processor.merge(template, user, List.of("difficulty"));
|
||||
|
||||
assertTrue(merged.contains(" d0e227d2: 0.5"));
|
||||
assertTrue(merged.contains(" dbef835e: 0.25"));
|
||||
assertTrue(merged.contains(" abc12345: 0.75"));
|
||||
assertFalse(merged.contains("27f41406: 1.0"));
|
||||
assertValidYaml(merged);
|
||||
}
|
||||
|
||||
@Test
|
||||
void siblingScalarsAllSubstitute() {
|
||||
final List<String> template = List.of(
|
||||
"world:",
|
||||
" name: 'loparkour'",
|
||||
" fall-back: 'world'",
|
||||
" delete-on-reload: true");
|
||||
final List<String> user = List.of(
|
||||
"world:",
|
||||
" name: 'custom'",
|
||||
" fall-back: 'custom-world'");
|
||||
|
||||
final List<String> merged = processor.merge(template, user, null);
|
||||
|
||||
assertTrue(merged.contains(" name: 'custom'"));
|
||||
assertTrue(merged.contains(" fall-back: 'custom-world'"));
|
||||
assertTrue(merged.contains(" delete-on-reload: true"));
|
||||
assertValidYaml(merged);
|
||||
}
|
||||
|
||||
@Test
|
||||
void freshInstallMergesIdentically() throws IOException {
|
||||
assertFreshInstallIdentity("config.yml", List.of("styles"));
|
||||
assertFreshInstallIdentity("generation.yml", null);
|
||||
assertFreshInstallIdentity("rewards-v2.yml", List.of("score-rewards", "interval-rewards", "one-time-rewards"));
|
||||
assertFreshInstallIdentity("schematics/schematics.yml", List.of("difficulty"));
|
||||
}
|
||||
|
||||
private static void assertFreshInstallIdentity(String resource, List<String> ignored) throws IOException {
|
||||
try (InputStream is = ConfigMergeProcessorTest.class.getResourceAsStream("/" + resource)) {
|
||||
assertNotNull(is, "missing resource " + resource);
|
||||
final List<String> template = new BufferedReader(new InputStreamReader(is)).lines().toList();
|
||||
assertEquals(template, new ConfigMergeProcessor().merge(template, template, ignored),
|
||||
"fresh-install merge must be identity for " + resource);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> lines(String content) {
|
||||
return content.lines().toList();
|
||||
}
|
||||
|
||||
private static void assertValidYaml(List<String> merged) {
|
||||
final Object parsed = new Yaml().load(String.join("\n", merged));
|
||||
assertNotNull(parsed);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package dev.loki.loparkour.generator.jump.calculation;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Unit tests for the offset math and the safety-fallback logic of JumpCalculator.
|
||||
*/
|
||||
class JumpCalculatorTest {
|
||||
|
||||
private static final double DELTA = 1.0E-6;
|
||||
private static final double DIAGONAL_UNIT = 0.7071067811865476;
|
||||
private static final double DEFAULT_SD = 1;
|
||||
|
||||
@Mock
|
||||
private World world;
|
||||
|
||||
private Location current;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
current = new Location(world, 0, 100, 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void diagonalHeadingOffsetStaysOnPerpendicularBand() {
|
||||
final Vector heading = new Vector(DIAGONAL_UNIT, 0, DIAGONAL_UNIT);
|
||||
final Vector perpendicular = new Vector(-heading.getZ(), 0, heading.getX());
|
||||
final Vector offset = JumpCalculator.offsetFor(heading, 3, 1, 2);
|
||||
|
||||
assertEquals(2, dot(offset, perpendicular), DELTA);
|
||||
assertEquals(3, dot(offset, heading), DELTA);
|
||||
assertEquals(1, offset.getY(), DELTA);
|
||||
}
|
||||
|
||||
@Test
|
||||
void xAxisHeadingAppliesJitterOnZAxis() {
|
||||
final Vector offset = JumpCalculator.offsetFor(new Vector(1, 0, 0), 4, 0, -2);
|
||||
|
||||
assertEquals(4, offset.getX(), DELTA);
|
||||
assertEquals(0, offset.getY(), DELTA);
|
||||
assertEquals(-2, offset.getZ(), DELTA);
|
||||
}
|
||||
|
||||
@Test
|
||||
void zAxisHeadingAppliesJitterOnXAxis() {
|
||||
final Vector offset = JumpCalculator.offsetFor(new Vector(0, 0, 1), 2, -1, 3);
|
||||
|
||||
assertEquals(-3, offset.getX(), DELTA);
|
||||
assertEquals(-1, offset.getY(), DELTA);
|
||||
assertEquals(2, offset.getZ(), DELTA);
|
||||
}
|
||||
|
||||
@Test
|
||||
void safetyFallbackReturnsValidStepForUnitHeading() {
|
||||
final JumpValidator validator = new JumpValidator();
|
||||
final Location fallback = JumpCalculator.safetyFallback(current, new Vector(1, 0, 0), DEFAULT_SD, new Random(42), validator);
|
||||
|
||||
assertNotEquals(current, fallback);
|
||||
assertTrue(validator.canJump(current, fallback));
|
||||
}
|
||||
|
||||
@Test
|
||||
void safetyFallbackReturnsCurrentWhenStraightStepImpossible() {
|
||||
final JumpValidator validator = new JumpValidator();
|
||||
final Location fallback = JumpCalculator.safetyFallback(current, new Vector(100, 0, 0), DEFAULT_SD, new Random(1), validator);
|
||||
|
||||
assertEquals(current, fallback);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateAndRetryJumpNeverReturnsUnreachableBlock() {
|
||||
final JumpValidator validator = new JumpValidator();
|
||||
final Vector heading = new Vector(0, 0, 1);
|
||||
|
||||
for (int distance = 1; distance <= 4; distance++) {
|
||||
for (int height = -2; height <= 1; height++) {
|
||||
final Location result = JumpCalculator.validateAndRetryJump(
|
||||
current, heading, distance, height, new Random(distance * 10 + height), DEFAULT_SD);
|
||||
|
||||
assertTrue(validator.canJump(current, result),
|
||||
"unreachable block for distance=" + distance + " height=" + height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateAndRetryJumpReturnsCurrentAsLastResort() {
|
||||
final Location result = JumpCalculator.validateAndRetryJump(
|
||||
current, new Vector(100, 0, 0), 4, 1, new Random(7), DEFAULT_SD);
|
||||
|
||||
assertEquals(current, result);
|
||||
}
|
||||
|
||||
private static double dot(Vector a, Vector b) {
|
||||
return a.getX() * b.getX() + a.getY() * b.getY() + a.getZ() * b.getZ();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
114
src/test/java/dev/loki/loparkour/ghost/GhostManagerTest.java
Normal file
114
src/test/java/dev/loki/loparkour/ghost/GhostManagerTest.java
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package dev.loki.loparkour.leaderboard.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Unit tests for Score time parsing and serialization.
|
||||
*/
|
||||
class ScoreTest {
|
||||
|
||||
private static final String UNKNOWN_TIME = "?";
|
||||
|
||||
@Test
|
||||
void testGetTimeMillis_MinutesSeconds() {
|
||||
final Score score = new Score("player", "3:05.123", "1", 10);
|
||||
assertEquals(185123L, score.getTimeMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetTimeMillis_HoursMinutesSeconds() {
|
||||
final Score score = new Score("player", "1:03:05.123", "1", 10);
|
||||
assertEquals(3785123L, score.getTimeMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetTimeMillis_MinutesOver59() {
|
||||
final Score score = new Score("player", "75:05.123", "1", 10);
|
||||
assertEquals(4505123L, score.getTimeMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetTimeMillis_ZeroTime() {
|
||||
final Score score = new Score("player", "0:10.000", "1", 10);
|
||||
assertEquals(10000L, score.getTimeMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetTimeMillis_UnknownTime() {
|
||||
final Score score = new Score("player", UNKNOWN_TIME, "1", 10);
|
||||
assertEquals(Long.MAX_VALUE, score.getTimeMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetTimeMillis_MillisLessMinutesSeconds() {
|
||||
final Score score = new Score("player", "3:05", "1", 10);
|
||||
assertEquals(185000L, score.getTimeMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetTimeMillis_MillisLessHoursMinutesSeconds() {
|
||||
final Score score = new Score("player", "1:2:3", "1", 10);
|
||||
assertEquals(3723000L, score.getTimeMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetTimeMillis_Malformed() {
|
||||
assertEquals(Long.MAX_VALUE, new Score("player", "abc", "1", 10).getTimeMillis());
|
||||
assertEquals(Long.MAX_VALUE, new Score("player", "", "1", 10).getTimeMillis());
|
||||
assertEquals(Long.MAX_VALUE, new Score("player", "3:05:10.123.456", "1", 10).getTimeMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFromString_ToString_RoundTrip() {
|
||||
final Score score = new Score("player", "3:05.123", "Hard", 1500);
|
||||
assertEquals(score, Score.fromString(score.toString()));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package dev.loki.loparkour.schematic.core;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ParkourSchematic#isSaneSize(double, double, double)}.
|
||||
*/
|
||||
class ParkourSchematicSizeTest {
|
||||
|
||||
@Test
|
||||
void acceptsSaneDimensions() {
|
||||
assertTrue(ParkourSchematic.isSaneSize(1, 1, 1));
|
||||
assertTrue(ParkourSchematic.isSaneSize(10, 5, 4));
|
||||
assertTrue(ParkourSchematic.isSaneSize(ParkourSchematic.MAX_SCHEMATIC_EDGE, 1, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsZeroDimensions() {
|
||||
assertFalse(ParkourSchematic.isSaneSize(0, 5, 4));
|
||||
assertFalse(ParkourSchematic.isSaneSize(5, 0, 4));
|
||||
assertFalse(ParkourSchematic.isSaneSize(5, 4, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNegativeDimensions() {
|
||||
assertFalse(ParkourSchematic.isSaneSize(-1, 5, 4));
|
||||
assertFalse(ParkourSchematic.isSaneSize(5, -2, 4));
|
||||
assertFalse(ParkourSchematic.isSaneSize(5, 4, -3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedEdge() {
|
||||
assertFalse(ParkourSchematic.isSaneSize(ParkourSchematic.MAX_SCHEMATIC_EDGE + 1, 1, 1));
|
||||
assertFalse(ParkourSchematic.isSaneSize(1, ParkourSchematic.MAX_SCHEMATIC_EDGE + 1, 1));
|
||||
assertFalse(ParkourSchematic.isSaneSize(1, 1, ParkourSchematic.MAX_SCHEMATIC_EDGE + 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTooManyBlocks() {
|
||||
assertFalse(ParkourSchematic.isSaneSize(100, 100, 100));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue