feat: personal run replays (ghost system)
- GhostData: worldName field, format v4; GhostManager stores last N runs per player (ghosts/<uuid>/<ts>.ghost)
- GhostService: per-player recording (start on run start, every tick, finalize on fall), gated by ghost-mode.enabled + per-player toggle
- PlayerGhost: packet-based fake-player NPC with viewer skin (NMS via remapNms gradle task, reflection send, graceful degradation)
- GhostReplay: teleport viewer to run start, replay frames via PlayerGhost, restore on finish
- /lp replay [index] command + tab completion; settings menu ghost_recording toggle (persisted disk/SQL)
- config: ghost-mode.{enabled,max-runs}, remove dead show-top/transparency + GHOST_* statics
- tests: GhostData v4 round-trip, GhostManager eviction/ordering, yawToFixed (79 total)
This commit is contained in:
parent
4c255111f0
commit
8c8046a4f5
30 changed files with 1027 additions and 227 deletions
|
|
@ -1,3 +1,19 @@
|
||||||
|
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 {
|
plugins {
|
||||||
id("java")
|
id("java")
|
||||||
id("io.github.goooler.shadow") version("8.1.8")
|
id("io.github.goooler.shadow") version("8.1.8")
|
||||||
|
|
@ -77,6 +93,63 @@ 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
|
// Resolve the org.bukkit:bukkit capability conflict that newer paper-api artifacts
|
||||||
// introduce (they both bundle the bukkit classes and declare the capability).
|
// introduce (they both bundle the bukkit classes and declare the capability).
|
||||||
configurations.configureEach {
|
configurations.configureEach {
|
||||||
|
|
@ -100,6 +173,13 @@ dependencies {
|
||||||
} else {
|
} else {
|
||||||
compileOnly(apiCoordinate)
|
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 are referenced by the NMS packet signatures (IntList).
|
||||||
|
compileOnly("it.unimi.dsi:fastutil:8.5.9")
|
||||||
|
|
||||||
// Adventure API (for Component)
|
// Adventure API (for Component)
|
||||||
compileOnly("net.kyori:adventure-api:4.14.0")
|
compileOnly("net.kyori:adventure-api:4.14.0")
|
||||||
|
|
@ -133,6 +213,8 @@ dependencies {
|
||||||
testImplementation("org.mockito:mockito-junit-jupiter:5.10.0")
|
testImplementation("org.mockito:mockito-junit-jupiter:5.10.0")
|
||||||
testImplementation("org.spigotmc:spigot-api:1.20.4-R0.1-SNAPSHOT")
|
testImplementation("org.spigotmc:spigot-api:1.20.4-R0.1-SNAPSHOT")
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
// Test runtime classpath for loading PlayerGhost (references NMS + authlib types).
|
||||||
|
testImplementation(files(nmsMappedJar, nmsAuthlibJar))
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.shadowJar {
|
tasks.shadowJar {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package dev.loki.loparkour;
|
||||||
|
|
||||||
import dev.loki.loparkour.bootstrap.PluginBootstrap;
|
import dev.loki.loparkour.bootstrap.PluginBootstrap;
|
||||||
import dev.loki.loparkour.config.core.Config;
|
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.hook.papi.PAPIHook;
|
||||||
import dev.loki.loparkour.mode.base.Modes;
|
import dev.loki.loparkour.mode.base.Modes;
|
||||||
import dev.loki.loparkour.player.core.ParkourUser;
|
import dev.loki.loparkour.player.core.ParkourUser;
|
||||||
|
|
@ -37,6 +38,7 @@ public final class LoParkour extends LoPlugin {
|
||||||
|
|
||||||
private static LoParkour instance;
|
private static LoParkour instance;
|
||||||
private static SchematicManager schematicManager;
|
private static SchematicManager schematicManager;
|
||||||
|
private static GhostService ghostService;
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
private static PAPIHook placeholderHook;
|
private static PAPIHook placeholderHook;
|
||||||
|
|
@ -68,10 +70,18 @@ public final class LoParkour extends LoPlugin {
|
||||||
return schematicManager;
|
return schematicManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static GhostService getGhostService() {
|
||||||
|
return ghostService;
|
||||||
|
}
|
||||||
|
|
||||||
public void setSchematicManager(@NotNull SchematicManager manager) {
|
public void setSchematicManager(@NotNull SchematicManager manager) {
|
||||||
schematicManager = manager;
|
schematicManager = manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setGhostService(@NotNull GhostService service) {
|
||||||
|
ghostService = service;
|
||||||
|
}
|
||||||
|
|
||||||
public void setPlaceholderHook(@Nullable PAPIHook hook) {
|
public void setPlaceholderHook(@Nullable PAPIHook hook) {
|
||||||
placeholderHook = hook;
|
placeholderHook = hook;
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +120,9 @@ public final class LoParkour extends LoPlugin {
|
||||||
|
|
||||||
Rewards.clear();
|
Rewards.clear();
|
||||||
dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices.shutdown();
|
dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices.shutdown();
|
||||||
|
if (ghostService != null) {
|
||||||
|
ghostService.shutdown();
|
||||||
|
}
|
||||||
Storage.close();
|
Storage.close();
|
||||||
World.delete();
|
World.delete();
|
||||||
} catch (Throwable t) {
|
} 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.locale.Locales;
|
||||||
import dev.loki.loparkour.config.options.Option;
|
import dev.loki.loparkour.config.options.Option;
|
||||||
import dev.loki.loparkour.duels.DuelsMode;
|
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.holo.HoloHook;
|
||||||
import dev.loki.loparkour.hook.papi.PAPIHook;
|
import dev.loki.loparkour.hook.papi.PAPIHook;
|
||||||
import dev.loki.loparkour.listener.gameplay.ParkourRestrictionListener;
|
import dev.loki.loparkour.listener.gameplay.ParkourRestrictionListener;
|
||||||
|
|
@ -49,6 +50,7 @@ public final class PluginBootstrap {
|
||||||
Config.reload(true);
|
Config.reload(true);
|
||||||
loadSchematics(plugin);
|
loadSchematics(plugin);
|
||||||
registerModes();
|
registerModes();
|
||||||
|
plugin.setGhostService(new GhostService());
|
||||||
AdaptiveServices.init(plugin);
|
AdaptiveServices.init(plugin);
|
||||||
registerHooks(plugin);
|
registerHooks(plugin);
|
||||||
setupWorld(plugin);
|
setupWorld(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.JoinCommandExecutor;
|
||||||
import dev.loki.loparkour.command.player.LeaderboardCommandExecutor;
|
import dev.loki.loparkour.command.player.LeaderboardCommandExecutor;
|
||||||
import dev.loki.loparkour.command.player.PlayerCommandHandler;
|
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.schematic.SchematicCommandHandler;
|
||||||
import dev.loki.loparkour.command.util.CommandUtil;
|
import dev.loki.loparkour.command.util.CommandUtil;
|
||||||
import dev.loki.loparkour.config.locale.Locales;
|
import dev.loki.loparkour.config.locale.Locales;
|
||||||
|
|
@ -80,6 +81,7 @@ class CommandRouter {
|
||||||
switch (a1.toLowerCase()) {
|
switch (a1.toLowerCase()) {
|
||||||
case "join" -> join.join(a2, sender, p, player);
|
case "join" -> join.join(a2, sender, p, player);
|
||||||
case "leaderboard" -> leaderboard.open(a2, sender, p);
|
case "leaderboard" -> leaderboard.open(a2, sender, p);
|
||||||
|
case "replay" -> new ReplayCommandExecutor().replay(a2, sender, p, player);
|
||||||
case "schematic" -> {
|
case "schematic" -> {
|
||||||
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
|
if (!p.hasPermission(ParkourOption.ADMIN.permission)) {
|
||||||
CommandUtil.send(sender, Locales.getString(p, "other.no_do"));
|
CommandUtil.send(sender, Locales.getString(p, "other.no_do"));
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class CommandTabCompleter {
|
||||||
private static final List<String> SCHEMATIC_SUBCOMMANDS = List.of(
|
private static final List<String> SCHEMATIC_SUBCOMMANDS = List.of(
|
||||||
"wand", "pos1", "pos2", "create", "paste", "list", "reload"
|
"wand", "pos1", "pos2", "create", "paste", "list", "reload"
|
||||||
);
|
);
|
||||||
|
private static final List<String> REPLAY_RUN_INDEXES = List.of("1", "2", "3", "4", "5");
|
||||||
|
|
||||||
private CommandTabCompleter() {
|
private CommandTabCompleter() {
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +50,7 @@ class CommandTabCompleter {
|
||||||
completions.add("join");
|
completions.add("join");
|
||||||
completions.add("leave");
|
completions.add("leave");
|
||||||
}
|
}
|
||||||
|
completions.add("replay");
|
||||||
if (ParkourOption.MAIN.mayPerform(sender)) {
|
if (ParkourOption.MAIN.mayPerform(sender)) {
|
||||||
completions.add("menu");
|
completions.add("menu");
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +72,7 @@ class CommandTabCompleter {
|
||||||
return switch (a1) {
|
return switch (a1) {
|
||||||
case "reset" -> completeReset(sender, input);
|
case "reset" -> completeReset(sender, input);
|
||||||
case "join" -> completeJoin(sender, input);
|
case "join" -> completeJoin(sender, input);
|
||||||
|
case "replay" -> completeReplay(sender, input);
|
||||||
case "leaderboard" -> completeLeaderboard(sender, input);
|
case "leaderboard" -> completeLeaderboard(sender, input);
|
||||||
case "schematic" -> completeSchematic(sender, input);
|
case "schematic" -> completeSchematic(sender, input);
|
||||||
case "create" -> completeCreate(sender, input);
|
case "create" -> completeCreate(sender, input);
|
||||||
|
|
@ -141,14 +144,15 @@ class CommandTabCompleter {
|
||||||
return filter(input, completions);
|
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) {
|
private static List<String> completeThird(String a1Raw, String a2Raw, String input) {
|
||||||
if (!"schematic".equalsIgnoreCase(a1Raw)) {
|
if (!"schematic".equalsIgnoreCase(a1Raw)) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
return completeSchematicThird(a2Raw, input);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<String> completeSchematicThird(String a2Raw, String input) {
|
|
||||||
final List<String> completions = new ArrayList<>();
|
final List<String> completions = new ArrayList<>();
|
||||||
if ("paste".equalsIgnoreCase(a2Raw)) {
|
if ("paste".equalsIgnoreCase(a2Raw)) {
|
||||||
addLoadedSchematicIds(completions);
|
addLoadedSchematicIds(completions);
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ public class PlayerCommandHandler {
|
||||||
}
|
}
|
||||||
Menus.LEADERBOARDS.open(player);
|
Menus.LEADERBOARDS.open(player);
|
||||||
}
|
}
|
||||||
|
case "replay" -> new ReplayCommandExecutor().replay(null, sender, player, this);
|
||||||
case "schematic" -> {
|
case "schematic" -> {
|
||||||
if (!player.hasPermission(ParkourOption.ADMIN.permission)) {
|
if (!player.hasPermission(ParkourOption.ADMIN.permission)) {
|
||||||
send(sender, Locales.getString(player, "other.no_do"));
|
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, Locales.getString(sender, "commands.help.header"));
|
||||||
send(sender, "");
|
send(sender, "");
|
||||||
send(sender, Locales.getString(sender, "commands.help.line_parkour"));
|
send(sender, Locales.getString(sender, "commands.help.line_parkour"));
|
||||||
|
send(sender, Locales.getString(sender, "commands.help.line_replay"));
|
||||||
if (sender.hasPermission(ParkourOption.JOIN.permission)) {
|
if (sender.hasPermission(ParkourOption.JOIN.permission)) {
|
||||||
send(sender, Locales.getString(sender, "commands.help.line_join"));
|
send(sender, Locales.getString(sender, "commands.help.line_join"));
|
||||||
send(sender, Locales.getString(sender, "commands.help.line_leave"));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,9 +87,6 @@ public class Option {
|
||||||
public static int BLOCK_CLEANUP_DISTANCE;
|
public static int BLOCK_CLEANUP_DISTANCE;
|
||||||
public static int CLEANUP_INTERVAL;
|
public static int CLEANUP_INTERVAL;
|
||||||
public static int TRAIL_KEEP_BEHIND;
|
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 boolean JUMP_VALIDATION_ENABLED;
|
||||||
public static double MAX_JUMP_DISTANCE;
|
public static double MAX_JUMP_DISTANCE;
|
||||||
public static double MAX_HORIZONTAL_DISTANCE;
|
public static double MAX_HORIZONTAL_DISTANCE;
|
||||||
|
|
@ -143,9 +140,6 @@ public class Option {
|
||||||
BLOCK_CLEANUP_DISTANCE = OptionGeneration.BLOCK_CLEANUP_DISTANCE;
|
BLOCK_CLEANUP_DISTANCE = OptionGeneration.BLOCK_CLEANUP_DISTANCE;
|
||||||
CLEANUP_INTERVAL = OptionGeneration.CLEANUP_INTERVAL;
|
CLEANUP_INTERVAL = OptionGeneration.CLEANUP_INTERVAL;
|
||||||
TRAIL_KEEP_BEHIND = OptionGeneration.TRAIL_KEEP_BEHIND;
|
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;
|
JUMP_VALIDATION_ENABLED = OptionGeneration.JUMP_VALIDATION_ENABLED;
|
||||||
MAX_JUMP_DISTANCE = OptionGeneration.MAX_JUMP_DISTANCE;
|
MAX_JUMP_DISTANCE = OptionGeneration.MAX_JUMP_DISTANCE;
|
||||||
MAX_HORIZONTAL_DISTANCE = OptionGeneration.MAX_HORIZONTAL_DISTANCE;
|
MAX_HORIZONTAL_DISTANCE = OptionGeneration.MAX_HORIZONTAL_DISTANCE;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.config.core.Config;
|
import dev.loki.loparkour.config.core.Config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generation-related configuration options (validation, memory, ghost mode).
|
* Generation-related configuration options (validation, memory).
|
||||||
*/
|
*/
|
||||||
public class OptionGeneration {
|
public class OptionGeneration {
|
||||||
|
|
||||||
|
|
@ -14,8 +14,6 @@ public class OptionGeneration {
|
||||||
private static final int DEFAULT_BLOCK_CLEANUP_DISTANCE = 100;
|
private static final int DEFAULT_BLOCK_CLEANUP_DISTANCE = 100;
|
||||||
private static final int DEFAULT_CLEANUP_INTERVAL = 100;
|
private static final int DEFAULT_CLEANUP_INTERVAL = 100;
|
||||||
private static final int DEFAULT_TRAIL_KEEP_BEHIND = 10;
|
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 double PERCENT_DIVISOR = 100.0;
|
||||||
private static final int DEFAULT_MIN_Y = 100;
|
private static final int DEFAULT_MIN_Y = 100;
|
||||||
private static final int DEFAULT_MAX_Y = 200;
|
private static final int DEFAULT_MAX_Y = 200;
|
||||||
|
|
@ -47,11 +45,6 @@ public class OptionGeneration {
|
||||||
public static int CLEANUP_INTERVAL;
|
public static int CLEANUP_INTERVAL;
|
||||||
public static int TRAIL_KEEP_BEHIND;
|
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 ───────────────────────────────────────────────────────
|
// ── Jump validation ───────────────────────────────────────────────────────
|
||||||
public static boolean JUMP_VALIDATION_ENABLED;
|
public static boolean JUMP_VALIDATION_ENABLED;
|
||||||
public static double MAX_JUMP_DISTANCE;
|
public static double MAX_JUMP_DISTANCE;
|
||||||
|
|
@ -62,7 +55,6 @@ public class OptionGeneration {
|
||||||
public static void init() {
|
public static void init() {
|
||||||
initJumpValidation();
|
initJumpValidation();
|
||||||
initMemory();
|
initMemory();
|
||||||
initGhostMode();
|
|
||||||
initGenerationProbabilities();
|
initGenerationProbabilities();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,15 +80,6 @@ public class OptionGeneration {
|
||||||
? Config.CONFIG.getInt("memory.trail-keep-behind") : DEFAULT_TRAIL_KEEP_BEHIND;
|
? 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() {
|
private static void initGenerationProbabilities() {
|
||||||
TYPE_NORMAL = Config.GENERATION.getInt("generation.type.normal") / PERCENT_DIVISOR;
|
TYPE_NORMAL = Config.GENERATION.getInt("generation.type.normal") / PERCENT_DIVISOR;
|
||||||
TYPE_SPECIAL = Config.GENERATION.getInt("generation.type.special") / PERCENT_DIVISOR;
|
TYPE_SPECIAL = Config.GENERATION.getInt("generation.type.special") / PERCENT_DIVISOR;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package dev.loki.loparkour.generator.core.model;
|
package dev.loki.loparkour.generator.core.model;
|
||||||
|
|
||||||
|
import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.config.core.Config;
|
import dev.loki.loparkour.config.core.Config;
|
||||||
import dev.loki.loparkour.config.options.Option;
|
import dev.loki.loparkour.config.options.Option;
|
||||||
import dev.loki.loparkour.session.core.Session;
|
import dev.loki.loparkour.session.core.Session;
|
||||||
|
|
@ -88,6 +89,10 @@ public final class Island {
|
||||||
session.generator.startTick();
|
session.generator.startTick();
|
||||||
session.getPlayers().forEach(pp -> pp.setup(ps));
|
session.getPlayers().forEach(pp -> pp.setup(ps));
|
||||||
|
|
||||||
|
if (LoParkour.getGhostService() != null) {
|
||||||
|
LoParkour.getGhostService().startRun(session.generator);
|
||||||
|
}
|
||||||
|
|
||||||
this.blocks = placed;
|
this.blocks = placed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package dev.loki.loparkour.generator.lifecycle.loop;
|
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.ParkourFallEvent;
|
||||||
import dev.loki.loparkour.api.event.score.ParkourScoreEvent;
|
import dev.loki.loparkour.api.event.score.ParkourScoreEvent;
|
||||||
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
|
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
|
||||||
|
|
@ -107,6 +108,7 @@ public class LifecycleEventManager {
|
||||||
|
|
||||||
private void processFall() {
|
private void processFall() {
|
||||||
// Record statistics
|
// Record statistics
|
||||||
|
final int score = generator.state.score;
|
||||||
final String time = generator.getFormattedTime();
|
final String time = generator.getFormattedTime();
|
||||||
final String difficulty = String.valueOf(generator.getDifficultyScore());
|
final String difficulty = String.valueOf(generator.getDifficultyScore());
|
||||||
|
|
||||||
|
|
@ -121,7 +123,15 @@ public class LifecycleEventManager {
|
||||||
player.clearScoredBlocks();
|
player.clearScoredBlocks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (LoParkour.getGhostService() != null) {
|
||||||
|
LoParkour.getGhostService().endRun(generator, score);
|
||||||
|
}
|
||||||
|
|
||||||
// Reset generator state
|
// Reset generator state
|
||||||
generator.reset(true);
|
generator.reset(true);
|
||||||
|
|
||||||
|
if (LoParkour.getGhostService() != null) {
|
||||||
|
LoParkour.getGhostService().startRun(generator);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,185 +1,151 @@
|
||||||
package dev.loki.loparkour.ghost.core;
|
package dev.loki.loparkour.ghost.core;
|
||||||
|
|
||||||
import dev.loki.loparkour.ghost.model.GhostData;
|
|
||||||
|
|
||||||
import dev.loki.loparkour.LoParkour;
|
import dev.loki.loparkour.LoParkour;
|
||||||
import dev.loki.loparkour.config.core.Config;
|
import dev.loki.loparkour.config.core.Config;
|
||||||
import org.bukkit.Location;
|
import dev.loki.loparkour.ghost.model.GhostData;
|
||||||
import org.bukkit.World;
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
import org.jetbrains.annotations.Nullable;
|
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
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:
|
* <p>Layout: {@code ghosts/<playerUuid>/<timestamp>.ghost}. Each player keeps
|
||||||
* <ul>
|
* their last {@link #maxRuns()} runs; older files are evicted on save.
|
||||||
* <li>Files were named {@code playerName.ghost} — collisions between players sharing
|
*
|
||||||
* a name, and path traversal via names like {@code ../../config}.
|
* <p>The {@code File}-based constructor and {@link #maxRuns()} are public rather
|
||||||
* Now named {@code uuid.ghost} — guaranteed unique and filesystem-safe.</li>
|
* than package-private so {@code GhostManagerTest} (in the sibling
|
||||||
* <li>When evicting the lowest ghost, the old file was looked up by player name
|
* {@code dev.loki.loparkour.ghost} package) can inject a temp folder and
|
||||||
* which failed if the name had changed since the run was saved.</li>
|
* override the run cap without loading config state.
|
||||||
* <li>Old v1 files (no UUID) are detected via {@link GhostData#loadFromFile}
|
|
||||||
* returning null — they are deleted automatically on first load.</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
*/
|
||||||
public class GhostManager {
|
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 File ghostFolder;
|
||||||
private final Map<String, List<GhostData>> ghostsByMode = new HashMap<>();
|
|
||||||
private final List<GhostPlayer> activeGhosts = new ArrayList<>();
|
|
||||||
|
|
||||||
public GhostManager() {
|
public GhostManager() {
|
||||||
this.ghostFolder = LoParkour.getInFolder("ghosts");
|
this(LoParkour.getInFolder("ghosts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public GhostManager(@NotNull File ghostFolder) {
|
||||||
|
this.ghostFolder = ghostFolder;
|
||||||
if (!ghostFolder.exists()) {
|
if (!ghostFolder.exists()) {
|
||||||
ghostFolder.mkdirs();
|
ghostFolder.mkdirs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Loading ───────────────────────────────────────────────────────────────
|
// ── Queries ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public void loadGhosts(@NotNull String mode) {
|
/**
|
||||||
final File modeFolder = new File(ghostFolder, sanitizeMode(mode));
|
* Loads a player's saved runs, newest first.
|
||||||
if (!modeFolder.exists()) {
|
*
|
||||||
return;
|
* @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) {
|
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) {
|
for (final File file : files) {
|
||||||
try {
|
try {
|
||||||
final GhostData data = GhostData.loadFromFile(file);
|
final GhostData data = GhostData.loadFromFile(file);
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
// Old v1 format — delete and skip
|
|
||||||
LoParkour.getPlugin().getLogger().info(
|
|
||||||
"Deleting outdated ghost file (old format): " + file.getName());
|
|
||||||
file.delete();
|
file.delete();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ghosts.add(data);
|
runs.add(data);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LoParkour.getPlugin().getLogger().severe(
|
LoParkour.getPlugin().getLogger().severe(
|
||||||
"Failed to load ghost file " + file.getName() + ": " + e.getMessage());
|
"Failed to load ghost file " + file.getName() + ": " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed());
|
runs.sort(Comparator.comparingLong(GhostData::getTimestamp).reversed()
|
||||||
ghostsByMode.put(mode, ghosts);
|
.thenComparing(Comparator.comparingInt(GhostData::getScore).reversed())
|
||||||
|
.thenComparing(GhostData::getPlayerName));
|
||||||
|
return Collections.unmodifiableList(runs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Saving ────────────────────────────────────────────────────────────────
|
// ── Saving ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public void saveGhost(@NotNull String mode, @NotNull GhostData data) {
|
public void saveRun(@NotNull UUID playerUuid, @NotNull GhostData data) {
|
||||||
final File modeFolder = new File(ghostFolder, sanitizeMode(mode));
|
if (playerUuid == null || data == null) {
|
||||||
if (!modeFolder.exists()) {
|
LoParkour.getPlugin().getLogger().warning("saveRun called with null argument");
|
||||||
modeFolder.mkdirs();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<GhostData> ghosts = new ArrayList<>(ghostsByMode.getOrDefault(mode, new ArrayList<>()));
|
final File playerFolder = playerFolder(playerUuid);
|
||||||
ghosts.add(data);
|
if (!playerFolder.exists()) {
|
||||||
ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed());
|
playerFolder.mkdirs();
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ghostsByMode.put(mode, ghosts);
|
final File file = new File(playerFolder, data.getTimestamp() + FILE_EXTENSION);
|
||||||
|
|
||||||
// Save new ghost — named by UUID, not by player name
|
|
||||||
final File file = ghostFile(modeFolder, data);
|
|
||||||
try {
|
try {
|
||||||
data.saveToFile(file);
|
data.saveToFile(file);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LoParkour.getPlugin().getLogger().severe(
|
LoParkour.getPlugin().getLogger().severe(
|
||||||
"Failed to save ghost for " + data.getPlayerName() + ": " + e.getMessage());
|
"Failed to save ghost run 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")) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
evictOldRuns(playerFolder);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void stopAllGhosts() {
|
/** Max runs kept per player; read from config each call so reloads apply. */
|
||||||
activeGhosts.forEach(GhostPlayer::stop);
|
public int maxRuns() {
|
||||||
activeGhosts.clear();
|
return Config.CONFIG.getInt("ghost-mode.max-runs", DEFAULT_MAX_RUNS);
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internals ─────────────────────────────────────────────────────────────
|
// ── Internals ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Returns the ghost file for a run — always UUID-based, never name-based. */
|
private File playerFolder(@NotNull UUID playerUuid) {
|
||||||
private File ghostFile(File modeFolder, GhostData data) {
|
return new File(ghostFolder, playerUuid.toString());
|
||||||
return new File(modeFolder, data.getPlayerUuid().toString() + ".ghost");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private void evictOldRuns(@NotNull File playerFolder) {
|
||||||
* Strips any path separators from mode names so they can't be used to
|
final File[] files = playerFolder.listFiles((dir, name) -> name.endsWith(FILE_EXTENSION));
|
||||||
* escape the ghosts directory (e.g. mode name "../../etc").
|
final int cap = maxRuns();
|
||||||
*/
|
if (files == null || files.length <= cap) {
|
||||||
private static String sanitizeMode(String mode) {
|
return;
|
||||||
return mode.replaceAll("[/\\\\.]", "_");
|
}
|
||||||
|
|
||||||
|
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 playerUuid UUID of the player — used as the filename in {@link GhostManager}.
|
||||||
* @param playerName Display name shown on the ghost label in-game.
|
* @param playerName Display name shown on the ghost label in-game.
|
||||||
* @param score The score achieved this run.
|
* @param score The score achieved this run.
|
||||||
|
* @param worldName The world the run happened in (may be empty).
|
||||||
*/
|
*/
|
||||||
public GhostData stopRecording(@NotNull UUID playerUuid,
|
public GhostData stopRecording(@NotNull UUID playerUuid,
|
||||||
@NotNull String playerName,
|
@NotNull String playerName,
|
||||||
int score) {
|
int score,
|
||||||
|
@NotNull String worldName) {
|
||||||
recording = false;
|
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() {
|
public boolean isRecording() {
|
||||||
|
|
|
||||||
|
|
@ -15,36 +15,49 @@ import java.util.List;
|
||||||
import java.util.UUID;
|
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:
|
* <p>Format version 4 adds the {@link #worldName} after the timestamp; older
|
||||||
* <ul>
|
* versions are rejected by {@link #loadFromFile} (returns {@code null}) so the
|
||||||
* <li>Files were named {@code playerName.ghost} — two players with the same name
|
* caller can delete the stale file.
|
||||||
* 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>
|
|
||||||
*/
|
*/
|
||||||
public class GhostData {
|
public class GhostData {
|
||||||
|
|
||||||
/** Binary format version — bump when changing the serialised layout. */
|
/** 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 UUID playerUuid;
|
||||||
private final String playerName;
|
private final String playerName;
|
||||||
private final int score;
|
private final int score;
|
||||||
|
private final long timestamp;
|
||||||
|
private final String worldName;
|
||||||
private final List<GhostFrame> frames;
|
private final List<GhostFrame> frames;
|
||||||
|
|
||||||
public GhostData(@NotNull UUID playerUuid, @NotNull String playerName,
|
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.playerUuid = playerUuid;
|
||||||
this.playerName = playerName;
|
this.playerName = playerName;
|
||||||
this.score = score;
|
this.score = score;
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.worldName = worldName;
|
||||||
this.frames = new ArrayList<>(frames);
|
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 ───────────────────────────────────────────────────────────────
|
// ── Getters ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public UUID getPlayerUuid() {
|
public UUID getPlayerUuid() {
|
||||||
|
|
@ -59,6 +72,14 @@ public class GhostData {
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWorldName() {
|
||||||
|
return worldName;
|
||||||
|
}
|
||||||
|
|
||||||
public List<GhostFrame> getFrames() {
|
public List<GhostFrame> getFrames() {
|
||||||
return frames;
|
return frames;
|
||||||
}
|
}
|
||||||
|
|
@ -69,9 +90,11 @@ public class GhostData {
|
||||||
try (DataOutputStream out = new DataOutputStream(
|
try (DataOutputStream out = new DataOutputStream(
|
||||||
new BufferedOutputStream(new FileOutputStream(file)))) {
|
new BufferedOutputStream(new FileOutputStream(file)))) {
|
||||||
out.writeInt(FORMAT_VERSION); // version header
|
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.writeUTF(playerName); // display name (for the ghost label)
|
||||||
out.writeInt(score);
|
out.writeInt(score);
|
||||||
|
out.writeLong(timestamp); // run completion time (ordering)
|
||||||
|
out.writeUTF(worldName); // world the run happened in
|
||||||
out.writeInt(frames.size());
|
out.writeInt(frames.size());
|
||||||
for (final GhostFrame frame : frames) {
|
for (final GhostFrame frame : frames) {
|
||||||
out.writeLong(frame.getTimestamp());
|
out.writeLong(frame.getTimestamp());
|
||||||
|
|
@ -87,8 +110,8 @@ public class GhostData {
|
||||||
/**
|
/**
|
||||||
* Loads a ghost from file.
|
* Loads a ghost from file.
|
||||||
*
|
*
|
||||||
* @return The loaded data, or {@code null} if the file is an old v1 format
|
* @return The loaded data, or {@code null} if the file uses an older format
|
||||||
* (no version header / UUID) — caller should delete and ignore it.
|
* (anything other than {@link #FORMAT_VERSION}) — caller deletes it.
|
||||||
*/
|
*/
|
||||||
public static GhostData loadFromFile(@NotNull File file) throws IOException {
|
public static GhostData loadFromFile(@NotNull File file) throws IOException {
|
||||||
try (DataInputStream in = new DataInputStream(
|
try (DataInputStream in = new DataInputStream(
|
||||||
|
|
@ -96,14 +119,15 @@ public class GhostData {
|
||||||
|
|
||||||
final int version = in.readInt();
|
final int version = in.readInt();
|
||||||
if (version != FORMAT_VERSION) {
|
if (version != FORMAT_VERSION) {
|
||||||
// Old format (v1) started with readUTF for playerName, not an int version.
|
// Files not matching the current format are stale — return null so the manager discards them.
|
||||||
// Those files are stale — return null so the manager can discard them.
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final UUID uuid = UUID.fromString(in.readUTF());
|
final UUID uuid = UUID.fromString(in.readUTF());
|
||||||
final String name = in.readUTF();
|
final String name = in.readUTF();
|
||||||
final int score = in.readInt();
|
final int score = in.readInt();
|
||||||
|
final long timestamp = in.readLong();
|
||||||
|
final String world = in.readUTF();
|
||||||
final int frameCount = in.readInt();
|
final int frameCount = in.readInt();
|
||||||
|
|
||||||
final List<GhostFrame> frames = new ArrayList<>(frameCount);
|
final List<GhostFrame> frames = new ArrayList<>(frameCount);
|
||||||
|
|
@ -114,7 +138,7 @@ public class GhostData {
|
||||||
in.readFloat(), in.readFloat()));
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ public enum ParkourOption {
|
||||||
FALL_MESSAGE("settings.parkour_settings.items.fall_message", "LoParkour.settings.fall_message"),
|
FALL_MESSAGE("settings.parkour_settings.items.fall_message", "LoParkour.settings.fall_message"),
|
||||||
PARTICLES("settings.parkour_settings.items.particles", "LoParkour.settings.particles"),
|
PARTICLES("settings.parkour_settings.items.particles", "LoParkour.settings.particles"),
|
||||||
SOUND("settings.parkour_settings.items.sound", "LoParkour.settings.sound"),
|
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"),
|
SPECIAL_BLOCKS("settings.parkour_settings.items.special_blocks", "LoParkour.settings.special_blocks"),
|
||||||
|
|
||||||
LANG("settings.lang", "LoParkour.settings.lang"),
|
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_SPECIAL_BLOCKS = 12;
|
||||||
private static final int SLOT_FALL_MESSAGE = 13;
|
private static final int SLOT_FALL_MESSAGE = 13;
|
||||||
private static final int SLOT_SCOREBOARD = 14;
|
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_STYLE = 19;
|
||||||
private static final int SLOT_SCHEM_DIFF = 20;
|
private static final int SLOT_SCHEM_DIFF = 20;
|
||||||
private static final int SLOT_CLOSE = 31;
|
private static final int SLOT_CLOSE = 31;
|
||||||
|
|
@ -55,47 +56,54 @@ public class ParkourSettingsMenu extends LPMenu {
|
||||||
final String title = Locales.getString(locale, "settings.name");
|
final String title = Locales.getString(locale, "settings.name");
|
||||||
|
|
||||||
baseGui(title, ROWS_MAIN)
|
baseGui(title, ROWS_MAIN)
|
||||||
.setItem(SLOT_PARTICLES, toggleItem(locale, "particles", pp.particles), e -> {
|
.setItem(SLOT_PARTICLES, toggleItem(locale, "particles", pp.particles),
|
||||||
pp.particles = !pp.particles;
|
e -> toggleSetting(pp, () -> pp.particles = !pp.particles))
|
||||||
pp.updateGeneratorSettings(pp.session.generator);
|
.setItem(SLOT_SOUND, toggleItem(locale, "sound", pp.sound),
|
||||||
open(pp);
|
e -> toggleSetting(pp, () -> pp.sound = !pp.sound))
|
||||||
})
|
.setItem(SLOT_SPECIAL_BLOCKS, toggleItem(locale, "special_blocks", pp.useSpecialBlocks),
|
||||||
.setItem(SLOT_SOUND, toggleItem(locale, "sound", pp.sound), e -> {
|
e -> toggleSpecialBlocks(pp))
|
||||||
pp.sound = !pp.sound;
|
.setItem(SLOT_FALL_MESSAGE, toggleItem(locale, "fall_message", pp.showFallMessage),
|
||||||
pp.updateGeneratorSettings(pp.session.generator);
|
e -> toggleSetting(pp, () -> pp.showFallMessage = !pp.showFallMessage))
|
||||||
open(pp);
|
.setItem(SLOT_SCOREBOARD, toggleItem(locale, "scoreboard", pp.showScoreboard),
|
||||||
})
|
e -> toggleSetting(pp, () -> pp.showScoreboard = !pp.showScoreboard))
|
||||||
.setItem(SLOT_SPECIAL_BLOCKS, toggleItem(locale, "special_blocks", pp.useSpecialBlocks), e -> {
|
.setItem(SLOT_GHOST_RECORDING, toggleItem(locale, "ghost_recording",
|
||||||
if (pp.session.generator.state.score == 0) {
|
Boolean.TRUE.equals(pp.ghostRecording)), e -> toggleGhostRecording(pp))
|
||||||
pp.useSpecialBlocks = !pp.useSpecialBlocks;
|
|
||||||
pp.updateGeneratorSettings(pp.session.generator);
|
|
||||||
}
|
|
||||||
open(pp);
|
|
||||||
})
|
|
||||||
.setItem(SLOT_FALL_MESSAGE, toggleItem(locale, "fall_message", pp.showFallMessage), e -> {
|
|
||||||
pp.showFallMessage = !pp.showFallMessage;
|
|
||||||
pp.updateGeneratorSettings(pp.session.generator);
|
|
||||||
open(pp);
|
|
||||||
})
|
|
||||||
.setItem(SLOT_SCOREBOARD, toggleItem(locale, "scoreboard", pp.showScoreboard), e -> {
|
|
||||||
pp.showScoreboard = !pp.showScoreboard;
|
|
||||||
pp.updateGeneratorSettings(pp.session.generator);
|
|
||||||
open(pp);
|
|
||||||
})
|
|
||||||
.setItem(SLOT_STYLE, styleItem(pp), e -> openStyleMenu(pp))
|
.setItem(SLOT_STYLE, styleItem(pp), e -> openStyleMenu(pp))
|
||||||
.setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> {
|
.setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> cycleSchematicDifficulty(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);
|
|
||||||
})
|
|
||||||
.setItem(SLOT_CLOSE, closeButton(player), e -> Menus.SETTINGS.open(player))
|
.setItem(SLOT_CLOSE, closeButton(player), e -> Menus.SETTINGS.open(player))
|
||||||
.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) {
|
private ItemStack toggleItem(String locale, String key, boolean value) {
|
||||||
final Material mat = value ? Material.LIME_STAINED_GLASS_PANE : Material.RED_STAINED_GLASS_PANE;
|
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"));
|
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();
|
final ItemMeta meta = item.getItemMeta();
|
||||||
if (meta != null) {
|
if (meta != null) {
|
||||||
meta.setDisplayName(ColorUtil.color(name + " §7[" + symbol + "§7]"));
|
meta.setDisplayName(ColorUtil.color(name + " §7[" + symbol + "§7]"));
|
||||||
if (lore != null && !lore.isEmpty()) {
|
applyLore(meta, lore, symbol);
|
||||||
final List<String> loreLines = new ArrayList<>();
|
|
||||||
for (final String line : lore.split("\\|\\|")) {
|
|
||||||
loreLines.add(ColorUtil.color(line.replace("%s", symbol)));
|
|
||||||
}
|
|
||||||
meta.setLore(loreLines);
|
|
||||||
}
|
|
||||||
item.setItemMeta(meta);
|
item.setItemMeta(meta);
|
||||||
}
|
}
|
||||||
return item;
|
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 name = Locales.getString(pp.locale, "settings.parkour_settings.items.schematics.name");
|
||||||
final String lore = Locales.getString(pp.locale, "settings.parkour_settings.items.schematics.lore");
|
final String lore = Locales.getString(pp.locale, "settings.parkour_settings.items.schematics.lore");
|
||||||
meta.setDisplayName(ColorUtil.color(name.replace("%s", label)));
|
meta.setDisplayName(ColorUtil.color(name.replace("%s", label)));
|
||||||
if (lore != null && !lore.isEmpty()) {
|
applyLore(meta, lore, label);
|
||||||
final List<String> loreLines = new ArrayList<>();
|
|
||||||
for (final String line : lore.split("\\|\\|")) {
|
|
||||||
loreLines.add(ColorUtil.color(line.replace("%s", label)));
|
|
||||||
}
|
|
||||||
meta.setLore(loreLines);
|
|
||||||
}
|
|
||||||
item.setItemMeta(meta);
|
item.setItemMeta(meta);
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
|
|
@ -150,18 +146,23 @@ public class ParkourSettingsMenu extends LPMenu {
|
||||||
final ItemMeta meta = item.getItemMeta();
|
final ItemMeta meta = item.getItemMeta();
|
||||||
if (meta != null) {
|
if (meta != null) {
|
||||||
meta.setDisplayName(ColorUtil.color(name.replace("%s", pp.style)));
|
meta.setDisplayName(ColorUtil.color(name.replace("%s", pp.style)));
|
||||||
if (lore != null && !lore.isEmpty()) {
|
applyLore(meta, lore, pp.style);
|
||||||
final List<String> loreLines = new ArrayList<>();
|
|
||||||
for (final String line : lore.split("\\|\\|")) {
|
|
||||||
loreLines.add(ColorUtil.color(line.replace("%s", pp.style)));
|
|
||||||
}
|
|
||||||
meta.setLore(loreLines);
|
|
||||||
}
|
|
||||||
item.setItemMeta(meta);
|
item.setItemMeta(meta);
|
||||||
}
|
}
|
||||||
return item;
|
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) {
|
private void openStyleMenu(ParkourPlayer pp) {
|
||||||
final Player player = pp.player;
|
final Player player = pp.player;
|
||||||
final String locale = pp.locale;
|
final String locale = pp.locale;
|
||||||
|
|
@ -179,7 +180,6 @@ public class ParkourSettingsMenu extends LPMenu {
|
||||||
if (Config.CONFIG.getBoolean("permissions.per-style") && !player.hasPermission(perm)) {
|
if (Config.CONFIG.getBoolean("permissions.per-style") && !player.hasPermission(perm)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
final ItemStack item = new ItemStack(style.getNext());
|
final ItemStack item = new ItemStack(style.getNext());
|
||||||
final ItemMeta meta = item.getItemMeta();
|
final ItemMeta meta = item.getItemMeta();
|
||||||
if (meta != null) {
|
if (meta != null) {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ public class ParkourPlayer extends ParkourUser {
|
||||||
public @Expose Boolean useSpecialBlocks;
|
public @Expose Boolean useSpecialBlocks;
|
||||||
public @Expose Boolean showFallMessage;
|
public @Expose Boolean showFallMessage;
|
||||||
public @Expose Boolean showScoreboard;
|
public @Expose Boolean showScoreboard;
|
||||||
|
public @Expose Boolean ghostRecording;
|
||||||
public @Expose Integer selectedTime;
|
public @Expose Integer selectedTime;
|
||||||
public @Expose String style;
|
public @Expose String style;
|
||||||
public @Expose String locale;
|
public @Expose String locale;
|
||||||
|
|
@ -106,6 +107,10 @@ public class ParkourPlayer extends ParkourUser {
|
||||||
// Clear scored blocks to prevent memory leak
|
// Clear scored blocks to prevent memory leak
|
||||||
scoring.clear();
|
scoring.clear();
|
||||||
|
|
||||||
|
if (LoParkour.getGhostService() != null) {
|
||||||
|
LoParkour.getGhostService().playerLeft(player.getUniqueId());
|
||||||
|
}
|
||||||
|
|
||||||
persistence.save(this, LoParkour.getPlugin().isEnabled());
|
persistence.save(this, LoParkour.getPlugin().isEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ public final class PlayerSettingsManager {
|
||||||
COLUMN_MAPPINGS.put("schematicDifficulty", new OptionContainer(ParkourOption.SCHEMATICS, (p, v) ->
|
COLUMN_MAPPINGS.put("schematicDifficulty", new OptionContainer(ParkourOption.SCHEMATICS, (p, v) ->
|
||||||
p.schematicDifficulty = parseDoubleSafe(v, defaultSchematicDifficulty())));
|
p.schematicDifficulty = parseDoubleSafe(v, defaultSchematicDifficulty())));
|
||||||
COLUMN_MAPPINGS.put("sound", new OptionContainer(ParkourOption.SOUND, (p, v) -> p.sound = parseBoolean(v)));
|
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() {
|
public static Map<String, OptionContainer> getColumnMappings() {
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ public class SessionStateManager {
|
||||||
if (session.generator != null) {
|
if (session.generator != null) {
|
||||||
try {
|
try {
|
||||||
session.generator.tick();
|
session.generator.tick();
|
||||||
|
if (LoParkour.getGhostService() != null) {
|
||||||
|
LoParkour.getGhostService().recordPlayers(session.generator);
|
||||||
|
}
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
session.generator.session.getPlayers().forEach(p ->
|
session.generator.session.getPlayers().forEach(p ->
|
||||||
p.player.sendMessage("§c[LoParkour] Tick error: " + t.getMessage()));
|
p.player.sendMessage("§c[LoParkour] Tick error: " + t.getMessage()));
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ public class StorageDisk {
|
||||||
settings.put("showFallMsg", from.showFallMessage);
|
settings.put("showFallMsg", from.showFallMessage);
|
||||||
settings.put("showScoreboard", from.showScoreboard);
|
settings.put("showScoreboard", from.showScoreboard);
|
||||||
settings.put("selectedTime", from.selectedTime);
|
settings.put("selectedTime", from.selectedTime);
|
||||||
|
settings.put("ghostRecording", from.ghostRecording);
|
||||||
settings.put("collectedRewards", from.collectedRewards);
|
settings.put("collectedRewards", from.collectedRewards);
|
||||||
|
|
||||||
// Sanitize legacy Boolean locale values
|
// Sanitize legacy Boolean locale values
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,7 @@ public class SQLDataMapper {
|
||||||
settings.put("showFallMsg", player.showFallMessage);
|
settings.put("showFallMsg", player.showFallMessage);
|
||||||
settings.put("showScoreboard", player.showScoreboard);
|
settings.put("showScoreboard", player.showScoreboard);
|
||||||
settings.put("selectedTime", player.selectedTime);
|
settings.put("selectedTime", player.selectedTime);
|
||||||
|
settings.put("ghostRecording", player.ghostRecording);
|
||||||
settings.put("collectedRewards", player.collectedRewards);
|
settings.put("collectedRewards", player.collectedRewards);
|
||||||
settings.put("locale", player.locale);
|
settings.put("locale", player.locale);
|
||||||
settings.put("schematicDifficulty", player.schematicDifficulty);
|
settings.put("schematicDifficulty", player.schematicDifficulty);
|
||||||
|
|
|
||||||
|
|
@ -158,9 +158,8 @@ memory:
|
||||||
|
|
||||||
# -= Ghost mode options =-
|
# -= Ghost mode options =-
|
||||||
ghost-mode:
|
ghost-mode:
|
||||||
enabled: false
|
enabled: true
|
||||||
show-top: 3
|
max-runs: 5
|
||||||
transparency: 0.5
|
|
||||||
|
|
||||||
# -= Game modes options =-
|
# -= Game modes options =-
|
||||||
modes:
|
modes:
|
||||||
|
|
@ -291,6 +290,10 @@ default-values:
|
||||||
enabled: true
|
enabled: true
|
||||||
default: true
|
default: true
|
||||||
|
|
||||||
|
ghost_recording:
|
||||||
|
enabled: true
|
||||||
|
default: false
|
||||||
|
|
||||||
special_blocks:
|
special_blocks:
|
||||||
enabled: true
|
enabled: true
|
||||||
default: true
|
default: true
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,11 @@ settings:
|
||||||
name: "Play sounds"
|
name: "Play sounds"
|
||||||
lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, every generated block||&#A0A0A0will play a sound."
|
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:
|
special_blocks:
|
||||||
material: ""
|
material: ""
|
||||||
name: "Use special blocks"
|
name: "Use special blocks"
|
||||||
|
|
@ -381,9 +386,15 @@ commands:
|
||||||
unknown_player: "&#FF4444Unknown player."
|
unknown_player: "&#FF4444Unknown player."
|
||||||
not_playing: "&#FF4444That player isn't playing."
|
not_playing: "&#FF4444That player isn't playing."
|
||||||
reload: "&#A0A0A0Reloading config files... (locales load asynchronously)"
|
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:
|
help:
|
||||||
header: "񢩈&m--------------- &#FF6464&lLoParkour 񢩈&m---------------"
|
header: "񢩈&m--------------- &#FF6464&lLoParkour 񢩈&m---------------"
|
||||||
line_parkour: "&#A0A0A0/parkour 񢩈- Main command"
|
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_join: "&#A0A0A0/parkour join [mode/player] 񢩈- Join a mode"
|
||||||
line_leave: "&#A0A0A0/parkour leave 񢩈- Leave parkour"
|
line_leave: "&#A0A0A0/parkour leave 񢩈- Leave parkour"
|
||||||
line_menu: "&#A0A0A0/parkour menu 񢩈- Open the menu"
|
line_menu: "&#A0A0A0/parkour menu 񢩈- Open the menu"
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,11 @@ settings:
|
||||||
name: "Воспроизводить звуки"
|
name: "Воспроизводить звуки"
|
||||||
lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, каждый сгенерированный блок||&#A0A0A0будет воспроизводить звук."
|
lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, каждый сгенерированный блок||&#A0A0A0будет воспроизводить звук."
|
||||||
|
|
||||||
|
ghost_recording:
|
||||||
|
material: ""
|
||||||
|
name: "Записывать призрачные забеги"
|
||||||
|
lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, ваши забеги записываются,||&#A0A0A0чтобы вы могли их повторить позже."
|
||||||
|
|
||||||
special_blocks:
|
special_blocks:
|
||||||
material: ""
|
material: ""
|
||||||
name: "Использовать специальные блоки"
|
name: "Использовать специальные блоки"
|
||||||
|
|
@ -380,9 +385,15 @@ commands:
|
||||||
unknown_player: "&#FF4444Неизвестный игрок."
|
unknown_player: "&#FF4444Неизвестный игрок."
|
||||||
not_playing: "&#FF4444Этот игрок не в паркуре."
|
not_playing: "&#FF4444Этот игрок не в паркуре."
|
||||||
reload: "&#A0A0A0Перезагрузка конфигов... (локали грузятся асинхронно)"
|
reload: "&#A0A0A0Перезагрузка конфигов... (локали грузятся асинхронно)"
|
||||||
|
replay:
|
||||||
|
disabled: "&#FF4444Призрачные повторы отключены на этом сервере."
|
||||||
|
no_runs: "&#A0A0A0У вас пока нет записанных забегов."
|
||||||
|
invalid: "&#FF4444Неверный номер забега."
|
||||||
|
starting: "7FF55Воспроизведение вашего забега..."
|
||||||
help:
|
help:
|
||||||
header: "񢩈&m--------------- &#FF6464&lLoParkour 񢩈&m---------------"
|
header: "񢩈&m--------------- &#FF6464&lLoParkour 񢩈&m---------------"
|
||||||
line_parkour: "&#A0A0A0/parkour 񢩈- Главная команда"
|
line_parkour: "&#A0A0A0/parkour 񢩈- Главная команда"
|
||||||
|
line_replay: "&#A0A0A0/parkour replay [забег] 񢩈- Смотреть записанные забеги"
|
||||||
line_join: "&#A0A0A0/parkour join [режим/игрок] 񢩈- Войти в режим"
|
line_join: "&#A0A0A0/parkour join [режим/игрок] 񢩈- Войти в режим"
|
||||||
line_leave: "&#A0A0A0/parkour leave 񢩈- Выйти"
|
line_leave: "&#A0A0A0/parkour leave 񢩈- Выйти"
|
||||||
line_menu: "&#A0A0A0/parkour menu 񢩈- Открыть меню"
|
line_menu: "&#A0A0A0/parkour menu 񢩈- Открыть меню"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ import dev.loki.loparkour.ghost.model.GhostFrame;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.BufferedOutputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
@ -34,18 +37,20 @@ class GhostDataTest {
|
||||||
assertEquals(uuid, data.getPlayerUuid());
|
assertEquals(uuid, data.getPlayerUuid());
|
||||||
assertEquals(42, data.getScore());
|
assertEquals(42, data.getScore());
|
||||||
assertEquals(2, data.getFrames().size());
|
assertEquals(2, data.getFrames().size());
|
||||||
|
assertTrue(data.getTimestamp() > 0, "Run timestamp should be set");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testSaveAndLoad(@TempDir Path tempDir) throws IOException {
|
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<>();
|
List<GhostFrame> frames = new ArrayList<>();
|
||||||
frames.add(new GhostFrame(0, 0, 100, 0, 0, 0));
|
frames.add(new GhostFrame(0, 0, 100, 0, 0, 0));
|
||||||
frames.add(new GhostFrame(500, 1, 100, 0, 45, 0));
|
frames.add(new GhostFrame(500, 1, 100, 0, 45, 0));
|
||||||
frames.add(new GhostFrame(1000, 2, 100, 0, 90, 0));
|
frames.add(new GhostFrame(1000, 2, 100, 0, 90, 0));
|
||||||
|
|
||||||
UUID uuid = UUID.randomUUID();
|
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
|
// Save to file
|
||||||
File file = tempDir.resolve("test.ghost").toFile();
|
File file = tempDir.resolve("test.ghost").toFile();
|
||||||
|
|
@ -61,6 +66,9 @@ class GhostDataTest {
|
||||||
assertEquals(original.getPlayerName(), loaded.getPlayerName());
|
assertEquals(original.getPlayerName(), loaded.getPlayerName());
|
||||||
assertEquals(original.getPlayerUuid(), loaded.getPlayerUuid());
|
assertEquals(original.getPlayerUuid(), loaded.getPlayerUuid());
|
||||||
assertEquals(original.getScore(), loaded.getScore());
|
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());
|
assertEquals(original.getFrames().size(), loaded.getFrames().size());
|
||||||
|
|
||||||
// Verify frames
|
// Verify frames
|
||||||
|
|
@ -187,4 +195,29 @@ class GhostDataTest {
|
||||||
assertEquals(1, data.getFrames().size(),
|
assertEquals(1, data.getFrames().size(),
|
||||||
"GhostData should create defensive copy of frames list");
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue