diff --git a/build.gradle.kts b/build.gradle.kts index f864b11..c4cee70 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { id("java") 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().configureEach { dependsOn(remapNms) } + // Resolve the org.bukkit:bukkit capability conflict that newer paper-api artifacts // introduce (they both bundle the bukkit classes and declare the capability). configurations.configureEach { @@ -100,6 +173,13 @@ dependencies { } else { compileOnly(apiCoordinate) } + + // NMS (net.minecraft.*) for packet NPC rendering — compileOnly, Mojang-mapped. + // Built once by the remapNms task above (vanilla 1.20.4 server + official + // mappings) because the io.papermc.paper:paper:1.20.4 dev-bundle was purged. + compileOnly(files(nmsMappedJar, nmsAuthlibJar)) + // fastutil types are referenced by the NMS packet signatures (IntList). + compileOnly("it.unimi.dsi:fastutil:8.5.9") // Adventure API (for Component) compileOnly("net.kyori:adventure-api:4.14.0") @@ -133,6 +213,8 @@ dependencies { testImplementation("org.mockito:mockito-junit-jupiter:5.10.0") testImplementation("org.spigotmc:spigot-api:1.20.4-R0.1-SNAPSHOT") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + // Test runtime classpath for loading PlayerGhost (references NMS + authlib types). + testImplementation(files(nmsMappedJar, nmsAuthlibJar)) } tasks.shadowJar { diff --git a/src/main/java/dev/loki/loparkour/LoParkour.java b/src/main/java/dev/loki/loparkour/LoParkour.java index e5a30fd..53f3fd9 100644 --- a/src/main/java/dev/loki/loparkour/LoParkour.java +++ b/src/main/java/dev/loki/loparkour/LoParkour.java @@ -2,6 +2,7 @@ package dev.loki.loparkour; import dev.loki.loparkour.bootstrap.PluginBootstrap; import dev.loki.loparkour.config.core.Config; +import dev.loki.loparkour.ghost.service.GhostService; import dev.loki.loparkour.hook.papi.PAPIHook; import dev.loki.loparkour.mode.base.Modes; import dev.loki.loparkour.player.core.ParkourUser; @@ -37,6 +38,7 @@ public final class LoParkour extends LoPlugin { private static LoParkour instance; private static SchematicManager schematicManager; + private static GhostService ghostService; @Nullable private static PAPIHook placeholderHook; @@ -68,10 +70,18 @@ public final class LoParkour extends LoPlugin { return schematicManager; } + public static GhostService getGhostService() { + return ghostService; + } + public void setSchematicManager(@NotNull SchematicManager manager) { schematicManager = manager; } + public void setGhostService(@NotNull GhostService service) { + ghostService = service; + } + public void setPlaceholderHook(@Nullable PAPIHook hook) { placeholderHook = hook; } @@ -110,6 +120,9 @@ public final class LoParkour extends LoPlugin { Rewards.clear(); dev.loki.loparkour.adaptive.bootstrap.AdaptiveServices.shutdown(); + if (ghostService != null) { + ghostService.shutdown(); + } Storage.close(); World.delete(); } catch (Throwable t) { diff --git a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java index a82ad3b..7eebebd 100644 --- a/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java +++ b/src/main/java/dev/loki/loparkour/bootstrap/PluginBootstrap.java @@ -8,6 +8,7 @@ import dev.loki.loparkour.config.core.Config; import dev.loki.loparkour.config.locale.Locales; import dev.loki.loparkour.config.options.Option; import dev.loki.loparkour.duels.DuelsMode; +import dev.loki.loparkour.ghost.service.GhostService; import dev.loki.loparkour.hook.holo.HoloHook; import dev.loki.loparkour.hook.papi.PAPIHook; import dev.loki.loparkour.listener.gameplay.ParkourRestrictionListener; @@ -49,6 +50,7 @@ public final class PluginBootstrap { Config.reload(true); loadSchematics(plugin); registerModes(); + plugin.setGhostService(new GhostService()); AdaptiveServices.init(plugin); registerHooks(plugin); setupWorld(plugin); diff --git a/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java b/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java index 89c828d..22428cf 100644 --- a/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java +++ b/src/main/java/dev/loki/loparkour/command/core/CommandRouter.java @@ -4,6 +4,7 @@ import dev.loki.loparkour.command.admin.AdminCommandHandler; import dev.loki.loparkour.command.player.JoinCommandExecutor; import dev.loki.loparkour.command.player.LeaderboardCommandExecutor; import dev.loki.loparkour.command.player.PlayerCommandHandler; +import dev.loki.loparkour.command.player.ReplayCommandExecutor; import dev.loki.loparkour.command.schematic.SchematicCommandHandler; import dev.loki.loparkour.command.util.CommandUtil; import dev.loki.loparkour.config.locale.Locales; @@ -80,6 +81,7 @@ class CommandRouter { switch (a1.toLowerCase()) { case "join" -> join.join(a2, sender, p, player); case "leaderboard" -> leaderboard.open(a2, sender, p); + case "replay" -> new ReplayCommandExecutor().replay(a2, sender, p, player); case "schematic" -> { if (!p.hasPermission(ParkourOption.ADMIN.permission)) { CommandUtil.send(sender, Locales.getString(p, "other.no_do")); diff --git a/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java b/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java index 157b87a..d07996f 100644 --- a/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java +++ b/src/main/java/dev/loki/loparkour/command/core/CommandTabCompleter.java @@ -23,6 +23,7 @@ class CommandTabCompleter { private static final List SCHEMATIC_SUBCOMMANDS = List.of( "wand", "pos1", "pos2", "create", "paste", "list", "reload" ); + private static final List REPLAY_RUN_INDEXES = List.of("1", "2", "3", "4", "5"); private CommandTabCompleter() { } @@ -49,6 +50,7 @@ class CommandTabCompleter { completions.add("join"); completions.add("leave"); } + completions.add("replay"); if (ParkourOption.MAIN.mayPerform(sender)) { completions.add("menu"); } @@ -70,6 +72,7 @@ class CommandTabCompleter { return switch (a1) { case "reset" -> completeReset(sender, input); case "join" -> completeJoin(sender, input); + case "replay" -> completeReplay(sender, input); case "leaderboard" -> completeLeaderboard(sender, input); case "schematic" -> completeSchematic(sender, input); case "create" -> completeCreate(sender, input); @@ -141,14 +144,15 @@ class CommandTabCompleter { return filter(input, completions); } + private static List completeReplay(CommandSender sender, String input) { + final List completions = new ArrayList<>(REPLAY_RUN_INDEXES); + return filter(input, completions); + } + private static List completeThird(String a1Raw, String a2Raw, String input) { if (!"schematic".equalsIgnoreCase(a1Raw)) { return Collections.emptyList(); } - return completeSchematicThird(a2Raw, input); - } - - private static List completeSchematicThird(String a2Raw, String input) { final List completions = new ArrayList<>(); if ("paste".equalsIgnoreCase(a2Raw)) { addLoadedSchematicIds(completions); diff --git a/src/main/java/dev/loki/loparkour/command/player/PlayerCommandHandler.java b/src/main/java/dev/loki/loparkour/command/player/PlayerCommandHandler.java index 26d628f..daf75cd 100644 --- a/src/main/java/dev/loki/loparkour/command/player/PlayerCommandHandler.java +++ b/src/main/java/dev/loki/loparkour/command/player/PlayerCommandHandler.java @@ -77,6 +77,7 @@ public class PlayerCommandHandler { } Menus.LEADERBOARDS.open(player); } + case "replay" -> new ReplayCommandExecutor().replay(null, sender, player, this); case "schematic" -> { if (!player.hasPermission(ParkourOption.ADMIN.permission)) { send(sender, Locales.getString(player, "other.no_do")); @@ -121,6 +122,7 @@ public class PlayerCommandHandler { send(sender, Locales.getString(sender, "commands.help.header")); send(sender, ""); send(sender, Locales.getString(sender, "commands.help.line_parkour")); + send(sender, Locales.getString(sender, "commands.help.line_replay")); if (sender.hasPermission(ParkourOption.JOIN.permission)) { send(sender, Locales.getString(sender, "commands.help.line_join")); send(sender, Locales.getString(sender, "commands.help.line_leave")); diff --git a/src/main/java/dev/loki/loparkour/command/player/ReplayCommandExecutor.java b/src/main/java/dev/loki/loparkour/command/player/ReplayCommandExecutor.java new file mode 100644 index 0000000..e2b7af9 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/command/player/ReplayCommandExecutor.java @@ -0,0 +1,65 @@ +package dev.loki.loparkour.command.player; + +import dev.loki.loparkour.LoParkour; +import dev.loki.loparkour.command.util.CommandUtil; +import dev.loki.loparkour.config.core.Config; +import dev.loki.loparkour.config.locale.Locales; +import dev.loki.loparkour.ghost.model.GhostData; +import dev.loki.loparkour.ghost.service.GhostService; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * Handles {@code /lp replay [index]}: plays back a player's recorded ghost runs. + */ +public class ReplayCommandExecutor { + + private static final int COOLDOWN_MS = 2500; + private static final int DEFAULT_RUN_INDEX = 1; + private static final int INVALID_RUN_INDEX = -1; + + public void replay(@Nullable String indexArg, @NotNull CommandSender sender, @NotNull Player p, + @NotNull PlayerCommandHandler player) { + if (!player.cooldown(sender, "replay", COOLDOWN_MS)) { + return; + } + final GhostService ghostService = LoParkour.getGhostService(); + if (ghostService == null || !Config.CONFIG.getBoolean("ghost-mode.enabled")) { + CommandUtil.send(sender, Locales.getString(sender, "commands.replay.disabled")); + return; + } + final GhostData run = resolveRun(indexArg, sender, p, ghostService); + if (run == null) { + return; + } + ghostService.startReplay(p, run); + CommandUtil.send(sender, Locales.getString(sender, "commands.replay.starting")); + } + + private static GhostData resolveRun(@Nullable String indexArg, @NotNull CommandSender sender, + @NotNull Player p, @NotNull GhostService ghostService) { + final List runs = ghostService.getManager().getRuns(p.getUniqueId()); + if (runs.isEmpty()) { + CommandUtil.send(sender, Locales.getString(sender, "commands.replay.no_runs")); + return null; + } + final int index = indexArg == null ? DEFAULT_RUN_INDEX : parseIndex(indexArg); + if (index < DEFAULT_RUN_INDEX || index > runs.size()) { + CommandUtil.send(sender, Locales.getString(sender, "commands.replay.invalid")); + return null; + } + return runs.get(index - DEFAULT_RUN_INDEX); + } + + private static int parseIndex(String indexArg) { + try { + return Integer.parseInt(indexArg); + } catch (NumberFormatException e) { + return INVALID_RUN_INDEX; + } + } +} diff --git a/src/main/java/dev/loki/loparkour/config/options/Option.java b/src/main/java/dev/loki/loparkour/config/options/Option.java index 8032fa9..f58262a 100644 --- a/src/main/java/dev/loki/loparkour/config/options/Option.java +++ b/src/main/java/dev/loki/loparkour/config/options/Option.java @@ -87,9 +87,6 @@ public class Option { public static int BLOCK_CLEANUP_DISTANCE; public static int CLEANUP_INTERVAL; public static int TRAIL_KEEP_BEHIND; - public static boolean GHOST_MODE_ENABLED; - public static int GHOST_SHOW_TOP; - public static double GHOST_TRANSPARENCY; public static boolean JUMP_VALIDATION_ENABLED; public static double MAX_JUMP_DISTANCE; public static double MAX_HORIZONTAL_DISTANCE; @@ -143,9 +140,6 @@ public class Option { BLOCK_CLEANUP_DISTANCE = OptionGeneration.BLOCK_CLEANUP_DISTANCE; CLEANUP_INTERVAL = OptionGeneration.CLEANUP_INTERVAL; TRAIL_KEEP_BEHIND = OptionGeneration.TRAIL_KEEP_BEHIND; - GHOST_MODE_ENABLED = OptionGeneration.GHOST_MODE_ENABLED; - GHOST_SHOW_TOP = OptionGeneration.GHOST_SHOW_TOP; - GHOST_TRANSPARENCY = OptionGeneration.GHOST_TRANSPARENCY; JUMP_VALIDATION_ENABLED = OptionGeneration.JUMP_VALIDATION_ENABLED; MAX_JUMP_DISTANCE = OptionGeneration.MAX_JUMP_DISTANCE; MAX_HORIZONTAL_DISTANCE = OptionGeneration.MAX_HORIZONTAL_DISTANCE; diff --git a/src/main/java/dev/loki/loparkour/config/options/section/OptionGeneration.java b/src/main/java/dev/loki/loparkour/config/options/section/OptionGeneration.java index 7df449e..3300cf8 100644 --- a/src/main/java/dev/loki/loparkour/config/options/section/OptionGeneration.java +++ b/src/main/java/dev/loki/loparkour/config/options/section/OptionGeneration.java @@ -4,7 +4,7 @@ import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.config.core.Config; /** - * Generation-related configuration options (validation, memory, ghost mode). + * Generation-related configuration options (validation, memory). */ public class OptionGeneration { @@ -14,8 +14,6 @@ public class OptionGeneration { private static final int DEFAULT_BLOCK_CLEANUP_DISTANCE = 100; private static final int DEFAULT_CLEANUP_INTERVAL = 100; private static final int DEFAULT_TRAIL_KEEP_BEHIND = 10; - private static final int DEFAULT_GHOST_SHOW_TOP = 3; - private static final double DEFAULT_GHOST_TRANSPARENCY = 0.5; private static final double PERCENT_DIVISOR = 100.0; private static final int DEFAULT_MIN_Y = 100; private static final int DEFAULT_MAX_Y = 200; @@ -47,11 +45,6 @@ public class OptionGeneration { public static int CLEANUP_INTERVAL; public static int TRAIL_KEEP_BEHIND; - // ── Ghost mode ──────────────────────────────────────────────────────────── - public static boolean GHOST_MODE_ENABLED; - public static int GHOST_SHOW_TOP; - public static double GHOST_TRANSPARENCY; - // ── Jump validation ─────────────────────────────────────────────────────── public static boolean JUMP_VALIDATION_ENABLED; public static double MAX_JUMP_DISTANCE; @@ -62,7 +55,6 @@ public class OptionGeneration { public static void init() { initJumpValidation(); initMemory(); - initGhostMode(); initGenerationProbabilities(); } @@ -88,15 +80,6 @@ public class OptionGeneration { ? Config.CONFIG.getInt("memory.trail-keep-behind") : DEFAULT_TRAIL_KEEP_BEHIND; } - private static void initGhostMode() { - GHOST_MODE_ENABLED = Config.CONFIG.isPath("ghost-mode.enabled") - && Config.CONFIG.getBoolean("ghost-mode.enabled"); - GHOST_SHOW_TOP = Config.CONFIG.isPath("ghost-mode.show-top") - ? Config.CONFIG.getInt("ghost-mode.show-top") : DEFAULT_GHOST_SHOW_TOP; - GHOST_TRANSPARENCY = Config.CONFIG.isPath("ghost-mode.transparency") - ? Config.CONFIG.getDouble("ghost-mode.transparency") : DEFAULT_GHOST_TRANSPARENCY; - } - private static void initGenerationProbabilities() { TYPE_NORMAL = Config.GENERATION.getInt("generation.type.normal") / PERCENT_DIVISOR; TYPE_SPECIAL = Config.GENERATION.getInt("generation.type.special") / PERCENT_DIVISOR; diff --git a/src/main/java/dev/loki/loparkour/generator/core/model/Island.java b/src/main/java/dev/loki/loparkour/generator/core/model/Island.java index ff3047b..74544f0 100644 --- a/src/main/java/dev/loki/loparkour/generator/core/model/Island.java +++ b/src/main/java/dev/loki/loparkour/generator/core/model/Island.java @@ -1,5 +1,6 @@ package dev.loki.loparkour.generator.core.model; +import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.config.core.Config; import dev.loki.loparkour.config.options.Option; import dev.loki.loparkour.session.core.Session; @@ -88,6 +89,10 @@ public final class Island { session.generator.startTick(); session.getPlayers().forEach(pp -> pp.setup(ps)); + if (LoParkour.getGhostService() != null) { + LoParkour.getGhostService().startRun(session.generator); + } + this.blocks = placed; } diff --git a/src/main/java/dev/loki/loparkour/generator/lifecycle/loop/LifecycleEventManager.java b/src/main/java/dev/loki/loparkour/generator/lifecycle/loop/LifecycleEventManager.java index 1b22035..4a4fbb5 100644 --- a/src/main/java/dev/loki/loparkour/generator/lifecycle/loop/LifecycleEventManager.java +++ b/src/main/java/dev/loki/loparkour/generator/lifecycle/loop/LifecycleEventManager.java @@ -1,5 +1,6 @@ package dev.loki.loparkour.generator.lifecycle.loop; +import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.api.event.score.ParkourFallEvent; import dev.loki.loparkour.api.event.score.ParkourScoreEvent; import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator; @@ -107,6 +108,7 @@ public class LifecycleEventManager { private void processFall() { // Record statistics + final int score = generator.state.score; final String time = generator.getFormattedTime(); final String difficulty = String.valueOf(generator.getDifficultyScore()); @@ -121,7 +123,15 @@ public class LifecycleEventManager { player.clearScoredBlocks(); } + if (LoParkour.getGhostService() != null) { + LoParkour.getGhostService().endRun(generator, score); + } + // Reset generator state generator.reset(true); + + if (LoParkour.getGhostService() != null) { + LoParkour.getGhostService().startRun(generator); + } } } diff --git a/src/main/java/dev/loki/loparkour/ghost/core/GhostManager.java b/src/main/java/dev/loki/loparkour/ghost/core/GhostManager.java index 7b02aee..58e6d29 100644 --- a/src/main/java/dev/loki/loparkour/ghost/core/GhostManager.java +++ b/src/main/java/dev/loki/loparkour/ghost/core/GhostManager.java @@ -1,185 +1,151 @@ package dev.loki.loparkour.ghost.core; -import dev.loki.loparkour.ghost.model.GhostData; - import dev.loki.loparkour.LoParkour; import dev.loki.loparkour.config.core.Config; -import org.bukkit.Location; -import org.bukkit.World; +import dev.loki.loparkour.ghost.model.GhostData; + import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; -import java.util.HashMap; import java.util.List; -import java.util.Map; +import java.util.UUID; /** - * Loads, saves, and spawns ghost runs per mode. + * Stores each player's recent parkour runs on disk as individual ghost files. * - *

Bugs fixed vs original: - *

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

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

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

Bugs fixed vs original: - *

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

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

Compiled against the Mojang-mapped 1.20.4 NMS; packets that 1.20.4 only builds + * from a {@code ServerPlayer} are constructed reflectively via {@code sun.misc.Unsafe} + * like {@code Compat}. Version mismatches fail {@link #spawn} and are logged once. + */ +public final class PlayerGhost { + + private static final String TEXTURES_PROPERTY = "textures"; + private static final int LATENCY = 0; + private static final int NO_DATA = 0; + private static final int BASE_ENTITY_ID = Integer.MAX_VALUE - 10_000; + private static final double FIXED_POINT_FACTOR = 256.0 / 360.0; + private static final double ZERO_SPEED = 0.0; + private static final AtomicInteger NEXT_ENTITY_ID = new AtomicInteger(BASE_ENTITY_ID); + private static volatile Object unsafe; + private static volatile Method allocateMethod; + private static Method sendMethod; + private static boolean warned; + + private final Player viewer; + private final GameProfile profile; + private final int entityId; + private boolean active; + + public PlayerGhost(@NotNull Player viewer, @NotNull String displayName) { + this.viewer = viewer; + this.entityId = NEXT_ENTITY_ID.incrementAndGet(); + this.profile = new GameProfile(UUID.randomUUID(), displayName); + copySkin(viewer, profile); + } + + public boolean spawn(@NotNull Location location) { + if (active) { + teleport(location); + return true; + } + try { + final ClientboundPlayerInfoUpdatePacket.Entry entry = new ClientboundPlayerInfoUpdatePacket.Entry( + profile.getId(), profile, true, LATENCY, GameType.SURVIVAL, null, null); + send(connection(viewer), playerInfoUpdate(entry)); + send(connection(viewer), new ClientboundAddEntityPacket(entityId, profile.getId(), + location.getX(), location.getY(), location.getZ(), + location.getPitch(), location.getYaw(), EntityType.PLAYER, NO_DATA, Vec3.ZERO, ZERO_SPEED)); + send(connection(viewer), headRotation(location.getYaw())); + active = true; + return true; + } catch (ReflectiveOperationException | LinkageError error) { + warnOnce(error); + return false; + } + } + + public void teleport(@NotNull Location location) { + if (!active) { + return; + } + try { + send(connection(viewer), teleportPacket(location)); + send(connection(viewer), headRotation(location.getYaw())); + } catch (ReflectiveOperationException | LinkageError error) { + warnOnce(error); + } + } + + public void remove() { + if (!active) { + return; + } + try { + send(connection(viewer), new ClientboundRemoveEntitiesPacket(entityId)); + send(connection(viewer), new ClientboundPlayerInfoRemovePacket(List.of(profile.getId()))); + active = false; + } catch (ReflectiveOperationException | LinkageError error) { + warnOnce(error); + } + } + + public boolean isActive() { + return active; + } + + /** Converts a degree angle to the fixed-point byte used on the wire. */ + static byte yawToFixed(float degrees) { + return (byte) Math.round(degrees * FIXED_POINT_FACTOR); + } + + private Object playerInfoUpdate(ClientboundPlayerInfoUpdatePacket.Entry entry) throws ReflectiveOperationException { + final ClientboundPlayerInfoUpdatePacket packet = allocate(ClientboundPlayerInfoUpdatePacket.class); + setField(packet, "actions", EnumSet.of(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER)); + setField(packet, "entries", List.of(entry)); + return packet; + } + + private Object teleportPacket(@NotNull Location location) throws ReflectiveOperationException { + final ClientboundTeleportEntityPacket packet = allocate(ClientboundTeleportEntityPacket.class); + setField(packet, "id", entityId); + setField(packet, "x", location.getX()); + setField(packet, "y", location.getY()); + setField(packet, "z", location.getZ()); + setField(packet, "yRot", yawToFixed(location.getYaw())); + setField(packet, "xRot", yawToFixed(location.getPitch())); + setField(packet, "onGround", true); + return packet; + } + + private Object headRotation(float yaw) throws ReflectiveOperationException { + final ClientboundRotateHeadPacket packet = allocate(ClientboundRotateHeadPacket.class); + setField(packet, "entityId", entityId); + setField(packet, "yHeadRot", yawToFixed(yaw)); + return packet; + } + + private static void copySkin(@NotNull Player player, @NotNull GameProfile profile) { + try { + final Object playerProfile = player.getPlayerProfile(); + final Object textures = playerProfile == null + ? null : playerProfile.getClass().getMethod("getTextures").invoke(playerProfile); + final Object skin = textures == null + ? null : textures.getClass().getMethod("getSkin").invoke(textures); + final String value = skin == null + ? null : (String) skin.getClass().getMethod("getValue").invoke(skin); + final String signature = skin == null + ? null : (String) skin.getClass().getMethod("getSignature").invoke(skin); + if (value != null && !value.isEmpty()) { + profile.getProperties().put(TEXTURES_PROPERTY, + new Property(TEXTURES_PROPERTY, value, signature)); + } + } catch (ReflectiveOperationException | LinkageError error) { + warnOnce(error); + } + } + + private static Object connection(@NotNull Player player) throws ReflectiveOperationException { + final Object handle = player.getClass().getMethod("getHandle").invoke(player); + return handle.getClass().getField("connection").get(handle); + } + + private static void send(@NotNull Object connection, @NotNull Object packet) throws ReflectiveOperationException { + if (sendMethod == null) { + sendMethod = connection.getClass().getMethod("send", Packet.class); + } + sendMethod.invoke(connection, packet); + } + + private static void setField(@NotNull Object instance, @NotNull String name, @NotNull Object value) + throws ReflectiveOperationException { + final Field field = instance.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(instance, value); + } + + private static T allocate(Class type) throws ReflectiveOperationException { + if (unsafe == null) { + final Class unsafeClass = Class.forName("sun.misc.Unsafe"); + final Field field = unsafeClass.getDeclaredField("theUnsafe"); + field.setAccessible(true); + unsafe = field.get(null); + allocateMethod = unsafeClass.getMethod("allocateInstance", Class.class); + } + return type.cast(allocateMethod.invoke(unsafe, type)); + } + + private static void warnOnce(@NotNull Throwable error) { + if (warned) { + return; + } + warned = true; + if (LoParkour.getPlugin() != null) { + LoParkour.getPlugin().getLogger().warning("Ghost replay NPC unsupported on this server: " + error); + } + } +} diff --git a/src/main/java/dev/loki/loparkour/ghost/service/GhostReplay.java b/src/main/java/dev/loki/loparkour/ghost/service/GhostReplay.java new file mode 100644 index 0000000..5fdbc6f --- /dev/null +++ b/src/main/java/dev/loki/loparkour/ghost/service/GhostReplay.java @@ -0,0 +1,113 @@ +package dev.loki.loparkour.ghost.service; + +import dev.loki.loparkour.LoParkour; +import dev.loki.loparkour.ghost.model.GhostData; +import dev.loki.loparkour.ghost.model.GhostFrame; +import dev.loki.loparkour.ghost.replay.PlayerGhost; +import dev.loki.loparkour.player.core.ParkourUser; +import dev.lolib.scheduler.ScheduledTask; +import dev.lolib.scheduler.Scheduler; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Plays one recorded {@link GhostData} run back to a single viewer as a + * packet-based fake player ({@link PlayerGhost}), then returns the viewer + * to where they were and fires {@code onFinish}. + */ +public class GhostReplay { + + private static final long TICK_DELAY = 0; + private static final long TICK_PERIOD = 1; + private static final int FIRST_FRAME = 0; + + private final Player viewer; + private final GhostData run; + private final Runnable onFinish; + private final World world; + private final Location returnTo; + private final List frames; + + private PlayerGhost ghost; + private ScheduledTask task; + private long startTime; + private int frameIndex; + private Location current; + private boolean moved; + + public GhostReplay(@NotNull Player viewer, @NotNull GhostData run, @NotNull Runnable onFinish) { + this.viewer = viewer; + this.run = run; + this.onFinish = onFinish; + this.returnTo = viewer.getLocation().clone(); + this.frames = run.getFrames(); + final String worldName = run.getWorldName(); + World resolved = worldName.isEmpty() ? null : Bukkit.getWorld(worldName); + if (resolved == null) { + resolved = viewer.getWorld(); + } + this.world = resolved; + } + + public void start() { + if (run.getFrames().isEmpty()) { + onFinish.run(); + return; + } + final ParkourUser user = ParkourUser.getUser(viewer); + if (user != null) { + ParkourUser.leave(viewer); + } + final Location start = run.getFrames().get(FIRST_FRAME).toLocation(world); + viewer.teleport(start); + moved = true; + ghost = new PlayerGhost(viewer, run.getPlayerName()); + if (!ghost.spawn(start)) { + finish(); + return; + } + startTime = System.currentTimeMillis(); + frameIndex = FIRST_FRAME; + current = start; + task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, TICK_DELAY, TICK_PERIOD); + } + + private void tick() { + if (!viewer.isOnline()) { + finish(); + return; + } + final long elapsed = System.currentTimeMillis() - startTime; + while (frameIndex < frames.size() + && frames.get(frameIndex).getTimestamp() <= elapsed) { + current = frames.get(frameIndex).toLocation(world); + frameIndex++; + } + if (current != null && ghost != null) { + ghost.teleport(current); + } + if (frameIndex >= frames.size()) { + finish(); + } + } + + private void finish() { + if (task != null) { + task.cancel(); + task = null; + } + if (ghost != null) { + ghost.remove(); + ghost = null; + } + if (moved && viewer.isOnline()) { + viewer.teleport(returnTo); + } + onFinish.run(); + } +} diff --git a/src/main/java/dev/loki/loparkour/ghost/service/GhostService.java b/src/main/java/dev/loki/loparkour/ghost/service/GhostService.java new file mode 100644 index 0000000..ff7c165 --- /dev/null +++ b/src/main/java/dev/loki/loparkour/ghost/service/GhostService.java @@ -0,0 +1,99 @@ +package dev.loki.loparkour.ghost.service; + +import dev.loki.loparkour.config.core.Config; +import dev.loki.loparkour.ghost.core.GhostManager; +import dev.loki.loparkour.ghost.core.GhostRecorder; +import dev.loki.loparkour.ghost.model.GhostData; +import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator; +import dev.loki.loparkour.player.core.ParkourPlayer; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Plugin-owned singleton that records each player's runs as {@link GhostData} + * while the server's {@code ghost-mode} is enabled and the player's per-player + * {@code ghostRecording} toggle is on. + */ +public class GhostService { + + private final GhostManager manager = new GhostManager(); + private final Map recorders = new ConcurrentHashMap<>(); + private final Set replaying = ConcurrentHashMap.newKeySet(); + + public GhostManager getManager() { + return manager; + } + + private boolean serverEnabled() { + return Config.CONFIG.getBoolean("ghost-mode.enabled"); + } + + private boolean recordingEnabled(ParkourPlayer pp) { + return serverEnabled() + && !replaying.contains(pp.getUUID()) + && Boolean.TRUE.equals(pp.ghostRecording); + } + + public void startRun(ParkourGenerator generator) { + for (final ParkourPlayer pp : generator.getPlayers()) { + if (recordingEnabled(pp)) { + recorders.computeIfAbsent(pp.getUUID(), k -> new GhostRecorder()) + .startRecording(pp.getLocation()); + } else { + recorders.remove(pp.getUUID()); + } + } + } + + public void recordPlayers(ParkourGenerator generator) { + for (final ParkourPlayer pp : generator.getPlayers()) { + final GhostRecorder recorder = recorders.get(pp.getUUID()); + if (recorder != null) { + recorder.recordFrame(pp.getLocation()); + } + } + } + + public void endRun(ParkourGenerator generator, int score) { + for (final ParkourPlayer pp : generator.getPlayers()) { + final GhostRecorder recorder = recorders.remove(pp.getUUID()); + if (recorder != null && recorder.isRecording()) { + final String worldName = pp.getLocation().getWorld() == null + ? "" : pp.getLocation().getWorld().getName(); + final GhostData data = recorder.stopRecording( + pp.getUUID(), pp.player.getName(), score, worldName); + manager.saveRun(pp.getUUID(), data); + } + } + } + + public void playerLeft(UUID uuid) { + recorders.remove(uuid); + } + + public boolean isReplaying(UUID uuid) { + return replaying.contains(uuid); + } + + public void setReplaying(UUID uuid, boolean replaying) { + if (replaying) { + this.replaying.add(uuid); + } else { + this.replaying.remove(uuid); + } + } + + public void startReplay(@NotNull org.bukkit.entity.Player viewer, @NotNull GhostData run) { + playerLeft(viewer.getUniqueId()); + setReplaying(viewer.getUniqueId(), true); + new GhostReplay(viewer, run, () -> setReplaying(viewer.getUniqueId(), false)).start(); + } + + public void shutdown() { + recorders.clear(); + } +} diff --git a/src/main/java/dev/loki/loparkour/menu/core/screen/ParkourOption.java b/src/main/java/dev/loki/loparkour/menu/core/screen/ParkourOption.java index a3aee36..0fcacb0 100644 --- a/src/main/java/dev/loki/loparkour/menu/core/screen/ParkourOption.java +++ b/src/main/java/dev/loki/loparkour/menu/core/screen/ParkourOption.java @@ -33,6 +33,7 @@ public enum ParkourOption { FALL_MESSAGE("settings.parkour_settings.items.fall_message", "LoParkour.settings.fall_message"), PARTICLES("settings.parkour_settings.items.particles", "LoParkour.settings.particles"), SOUND("settings.parkour_settings.items.sound", "LoParkour.settings.sound"), + GHOST_RECORDING("settings.parkour_settings.items.ghost_recording", "LoParkour.settings.ghost_recording"), SPECIAL_BLOCKS("settings.parkour_settings.items.special_blocks", "LoParkour.settings.special_blocks"), LANG("settings.lang", "LoParkour.settings.lang"), diff --git a/src/main/java/dev/loki/loparkour/menu/settings/ParkourSettingsMenu.java b/src/main/java/dev/loki/loparkour/menu/settings/ParkourSettingsMenu.java index 3d3cece..23591e2 100644 --- a/src/main/java/dev/loki/loparkour/menu/settings/ParkourSettingsMenu.java +++ b/src/main/java/dev/loki/loparkour/menu/settings/ParkourSettingsMenu.java @@ -27,6 +27,7 @@ public class ParkourSettingsMenu extends LPMenu { private static final int SLOT_SPECIAL_BLOCKS = 12; private static final int SLOT_FALL_MESSAGE = 13; private static final int SLOT_SCOREBOARD = 14; + private static final int SLOT_GHOST_RECORDING = 15; private static final int SLOT_STYLE = 19; private static final int SLOT_SCHEM_DIFF = 20; private static final int SLOT_CLOSE = 31; @@ -55,47 +56,54 @@ public class ParkourSettingsMenu extends LPMenu { final String title = Locales.getString(locale, "settings.name"); baseGui(title, ROWS_MAIN) - .setItem(SLOT_PARTICLES, toggleItem(locale, "particles", pp.particles), e -> { - pp.particles = !pp.particles; - pp.updateGeneratorSettings(pp.session.generator); - open(pp); - }) - .setItem(SLOT_SOUND, toggleItem(locale, "sound", pp.sound), e -> { - pp.sound = !pp.sound; - pp.updateGeneratorSettings(pp.session.generator); - open(pp); - }) - .setItem(SLOT_SPECIAL_BLOCKS, toggleItem(locale, "special_blocks", pp.useSpecialBlocks), e -> { - if (pp.session.generator.state.score == 0) { - pp.useSpecialBlocks = !pp.useSpecialBlocks; - pp.updateGeneratorSettings(pp.session.generator); - } - open(pp); - }) - .setItem(SLOT_FALL_MESSAGE, toggleItem(locale, "fall_message", pp.showFallMessage), e -> { - pp.showFallMessage = !pp.showFallMessage; - pp.updateGeneratorSettings(pp.session.generator); - open(pp); - }) - .setItem(SLOT_SCOREBOARD, toggleItem(locale, "scoreboard", pp.showScoreboard), e -> { - pp.showScoreboard = !pp.showScoreboard; - pp.updateGeneratorSettings(pp.session.generator); - open(pp); - }) + .setItem(SLOT_PARTICLES, toggleItem(locale, "particles", pp.particles), + e -> toggleSetting(pp, () -> pp.particles = !pp.particles)) + .setItem(SLOT_SOUND, toggleItem(locale, "sound", pp.sound), + e -> toggleSetting(pp, () -> pp.sound = !pp.sound)) + .setItem(SLOT_SPECIAL_BLOCKS, toggleItem(locale, "special_blocks", pp.useSpecialBlocks), + e -> toggleSpecialBlocks(pp)) + .setItem(SLOT_FALL_MESSAGE, toggleItem(locale, "fall_message", pp.showFallMessage), + e -> toggleSetting(pp, () -> pp.showFallMessage = !pp.showFallMessage)) + .setItem(SLOT_SCOREBOARD, toggleItem(locale, "scoreboard", pp.showScoreboard), + e -> toggleSetting(pp, () -> pp.showScoreboard = !pp.showScoreboard)) + .setItem(SLOT_GHOST_RECORDING, toggleItem(locale, "ghost_recording", + Boolean.TRUE.equals(pp.ghostRecording)), e -> toggleGhostRecording(pp)) .setItem(SLOT_STYLE, styleItem(pp), e -> openStyleMenu(pp)) - .setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> { - if (pp.session.generator.state.score == 0) { - final List diffs = List.of(0.0, DIFF_QUARTER, DIFF_HALF, DIFF_THREE_QUARTER, 1.0); - final int idx = diffs.indexOf(pp.schematicDifficulty); - pp.schematicDifficulty = diffs.get((idx + 1) % diffs.size()); - pp.updateGeneratorSettings(pp.session.generator); - } - open(pp); - }) + .setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> cycleSchematicDifficulty(pp)) .setItem(SLOT_CLOSE, closeButton(player), e -> Menus.SETTINGS.open(player)) .open(player); } + private void toggleSetting(ParkourPlayer pp, Runnable update) { + update.run(); + pp.updateGeneratorSettings(pp.session.generator); + open(pp); + } + + private void toggleSpecialBlocks(ParkourPlayer pp) { + if (pp.session.generator.state.score == 0) { + pp.useSpecialBlocks = !pp.useSpecialBlocks; + pp.updateGeneratorSettings(pp.session.generator); + } + open(pp); + } + + private void cycleSchematicDifficulty(ParkourPlayer pp) { + if (pp.session.generator.state.score == 0) { + final List diffs = List.of(0.0, DIFF_QUARTER, DIFF_HALF, DIFF_THREE_QUARTER, 1.0); + final int idx = diffs.indexOf(pp.schematicDifficulty); + pp.schematicDifficulty = diffs.get((idx + 1) % diffs.size()); + pp.updateGeneratorSettings(pp.session.generator); + } + open(pp); + } + + private void toggleGhostRecording(ParkourPlayer pp) { + pp.ghostRecording = !Boolean.TRUE.equals(pp.ghostRecording); + pp.save(false); + open(pp); + } + private ItemStack toggleItem(String locale, String key, boolean value) { final Material mat = value ? Material.LIME_STAINED_GLASS_PANE : Material.RED_STAINED_GLASS_PANE; final String symbol = Locales.getString(locale, "settings.parkour_settings." + (value ? "enabled" : "disabled")); @@ -106,13 +114,7 @@ public class ParkourSettingsMenu extends LPMenu { final ItemMeta meta = item.getItemMeta(); if (meta != null) { meta.setDisplayName(ColorUtil.color(name + " §7[" + symbol + "§7]")); - if (lore != null && !lore.isEmpty()) { - final List loreLines = new ArrayList<>(); - for (final String line : lore.split("\\|\\|")) { - loreLines.add(ColorUtil.color(line.replace("%s", symbol))); - } - meta.setLore(loreLines); - } + applyLore(meta, lore, symbol); item.setItemMeta(meta); } return item; @@ -131,13 +133,7 @@ public class ParkourSettingsMenu extends LPMenu { final String name = Locales.getString(pp.locale, "settings.parkour_settings.items.schematics.name"); final String lore = Locales.getString(pp.locale, "settings.parkour_settings.items.schematics.lore"); meta.setDisplayName(ColorUtil.color(name.replace("%s", label))); - if (lore != null && !lore.isEmpty()) { - final List loreLines = new ArrayList<>(); - for (final String line : lore.split("\\|\\|")) { - loreLines.add(ColorUtil.color(line.replace("%s", label))); - } - meta.setLore(loreLines); - } + applyLore(meta, lore, label); item.setItemMeta(meta); } return item; @@ -150,18 +146,23 @@ public class ParkourSettingsMenu extends LPMenu { final ItemMeta meta = item.getItemMeta(); if (meta != null) { meta.setDisplayName(ColorUtil.color(name.replace("%s", pp.style))); - if (lore != null && !lore.isEmpty()) { - final List loreLines = new ArrayList<>(); - for (final String line : lore.split("\\|\\|")) { - loreLines.add(ColorUtil.color(line.replace("%s", pp.style))); - } - meta.setLore(loreLines); - } + applyLore(meta, lore, pp.style); item.setItemMeta(meta); } return item; } + private void applyLore(ItemMeta meta, String lore, String replacement) { + if (lore == null || lore.isEmpty()) { + return; + } + final List loreLines = new ArrayList<>(); + for (final String line : lore.split("\\|\\|")) { + loreLines.add(ColorUtil.color(line.replace("%s", replacement))); + } + meta.setLore(loreLines); + } + private void openStyleMenu(ParkourPlayer pp) { final Player player = pp.player; final String locale = pp.locale; @@ -179,7 +180,6 @@ public class ParkourSettingsMenu extends LPMenu { if (Config.CONFIG.getBoolean("permissions.per-style") && !player.hasPermission(perm)) { continue; } - final ItemStack item = new ItemStack(style.getNext()); final ItemMeta meta = item.getItemMeta(); if (meta != null) { diff --git a/src/main/java/dev/loki/loparkour/player/core/ParkourPlayer.java b/src/main/java/dev/loki/loparkour/player/core/ParkourPlayer.java index e195d67..ea235d8 100644 --- a/src/main/java/dev/loki/loparkour/player/core/ParkourPlayer.java +++ b/src/main/java/dev/loki/loparkour/player/core/ParkourPlayer.java @@ -37,6 +37,7 @@ public class ParkourPlayer extends ParkourUser { public @Expose Boolean useSpecialBlocks; public @Expose Boolean showFallMessage; public @Expose Boolean showScoreboard; + public @Expose Boolean ghostRecording; public @Expose Integer selectedTime; public @Expose String style; public @Expose String locale; @@ -106,6 +107,10 @@ public class ParkourPlayer extends ParkourUser { // Clear scored blocks to prevent memory leak scoring.clear(); + if (LoParkour.getGhostService() != null) { + LoParkour.getGhostService().playerLeft(player.getUniqueId()); + } + persistence.save(this, LoParkour.getPlugin().isEnabled()); } diff --git a/src/main/java/dev/loki/loparkour/player/service/PlayerSettingsManager.java b/src/main/java/dev/loki/loparkour/player/service/PlayerSettingsManager.java index ba69e2f..c647b13 100644 --- a/src/main/java/dev/loki/loparkour/player/service/PlayerSettingsManager.java +++ b/src/main/java/dev/loki/loparkour/player/service/PlayerSettingsManager.java @@ -52,6 +52,7 @@ public final class PlayerSettingsManager { COLUMN_MAPPINGS.put("schematicDifficulty", new OptionContainer(ParkourOption.SCHEMATICS, (p, v) -> p.schematicDifficulty = parseDoubleSafe(v, defaultSchematicDifficulty()))); COLUMN_MAPPINGS.put("sound", new OptionContainer(ParkourOption.SOUND, (p, v) -> p.sound = parseBoolean(v))); + COLUMN_MAPPINGS.put("ghostRecording", new OptionContainer(ParkourOption.GHOST_RECORDING, (p, v) -> p.ghostRecording = parseBoolean(v))); } public static Map getColumnMappings() { diff --git a/src/main/java/dev/loki/loparkour/session/manager/SessionStateManager.java b/src/main/java/dev/loki/loparkour/session/manager/SessionStateManager.java index 4d85daa..4208827 100644 --- a/src/main/java/dev/loki/loparkour/session/manager/SessionStateManager.java +++ b/src/main/java/dev/loki/loparkour/session/manager/SessionStateManager.java @@ -78,6 +78,9 @@ public class SessionStateManager { if (session.generator != null) { try { session.generator.tick(); + if (LoParkour.getGhostService() != null) { + LoParkour.getGhostService().recordPlayers(session.generator); + } } catch (Throwable t) { session.generator.session.getPlayers().forEach(p -> p.player.sendMessage("§c[LoParkour] Tick error: " + t.getMessage())); diff --git a/src/main/java/dev/loki/loparkour/storage/disk/StorageDisk.java b/src/main/java/dev/loki/loparkour/storage/disk/StorageDisk.java index dfc75f0..64ba772 100644 --- a/src/main/java/dev/loki/loparkour/storage/disk/StorageDisk.java +++ b/src/main/java/dev/loki/loparkour/storage/disk/StorageDisk.java @@ -101,6 +101,7 @@ public class StorageDisk { settings.put("showFallMsg", from.showFallMessage); settings.put("showScoreboard", from.showScoreboard); settings.put("selectedTime", from.selectedTime); + settings.put("ghostRecording", from.ghostRecording); settings.put("collectedRewards", from.collectedRewards); // Sanitize legacy Boolean locale values diff --git a/src/main/java/dev/loki/loparkour/storage/sql/SQLDataMapper.java b/src/main/java/dev/loki/loparkour/storage/sql/SQLDataMapper.java index fe5c5a5..78b7a80 100644 --- a/src/main/java/dev/loki/loparkour/storage/sql/SQLDataMapper.java +++ b/src/main/java/dev/loki/loparkour/storage/sql/SQLDataMapper.java @@ -125,6 +125,7 @@ public class SQLDataMapper { settings.put("showFallMsg", player.showFallMessage); settings.put("showScoreboard", player.showScoreboard); settings.put("selectedTime", player.selectedTime); + settings.put("ghostRecording", player.ghostRecording); settings.put("collectedRewards", player.collectedRewards); settings.put("locale", player.locale); settings.put("schematicDifficulty", player.schematicDifficulty); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index a0a8240..14acb7f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -158,9 +158,8 @@ memory: # -= Ghost mode options =- ghost-mode: - enabled: false - show-top: 3 - transparency: 0.5 + enabled: true + max-runs: 5 # -= Game modes options =- modes: @@ -291,6 +290,10 @@ default-values: enabled: true default: true + ghost_recording: + enabled: true + default: false + special_blocks: enabled: true default: true diff --git a/src/main/resources/locales/en.yml b/src/main/resources/locales/en.yml index cf9b484..fa8bce1 100644 --- a/src/main/resources/locales/en.yml +++ b/src/main/resources/locales/en.yml @@ -247,6 +247,11 @@ settings: name: "Play sounds" lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, every generated block||&#A0A0A0will play a sound." + ghost_recording: + material: "" + name: "Record ghost runs" + lore: "&#A0A0A0Currently: %s||&#A0A0A0If enabled, your runs are recorded||&#A0A0A0so you can replay them later." + special_blocks: material: "" name: "Use special blocks" @@ -381,9 +386,15 @@ commands: unknown_player: "&#FF4444Unknown player." not_playing: "&#FF4444That player isn't playing." reload: "&#A0A0A0Reloading config files... (locales load asynchronously)" + replay: + disabled: "&#FF4444Ghost replays are disabled on this server." + no_runs: "&#A0A0A0You have no recorded runs yet." + invalid: "&#FF4444Invalid run number." + starting: "7FF55Playing back your run..." help: header: "񢩈&m--------------- &#FF6464&lLoParkour 񢩈&m---------------" line_parkour: "&#A0A0A0/parkour 񢩈- Main command" + line_replay: "&#A0A0A0/parkour replay [run] 񢩈- Watch your recorded runs" line_join: "&#A0A0A0/parkour join [mode/player] 񢩈- Join a mode" line_leave: "&#A0A0A0/parkour leave 񢩈- Leave parkour" line_menu: "&#A0A0A0/parkour menu 񢩈- Open the menu" diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 1438637..06ebb92 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -246,6 +246,11 @@ settings: name: "Воспроизводить звуки" lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, каждый сгенерированный блок||&#A0A0A0будет воспроизводить звук." + ghost_recording: + material: "" + name: "Записывать призрачные забеги" + lore: "&#A0A0A0Текущее: %s||&#A0A0A0Если включено, ваши забеги записываются,||&#A0A0A0чтобы вы могли их повторить позже." + special_blocks: material: "" name: "Использовать специальные блоки" @@ -380,9 +385,15 @@ commands: unknown_player: "&#FF4444Неизвестный игрок." not_playing: "&#FF4444Этот игрок не в паркуре." reload: "&#A0A0A0Перезагрузка конфигов... (локали грузятся асинхронно)" + replay: + disabled: "&#FF4444Призрачные повторы отключены на этом сервере." + no_runs: "&#A0A0A0У вас пока нет записанных забегов." + invalid: "&#FF4444Неверный номер забега." + starting: "7FF55Воспроизведение вашего забега..." help: header: "񢩈&m--------------- &#FF6464&lLoParkour 񢩈&m---------------" line_parkour: "&#A0A0A0/parkour 񢩈- Главная команда" + line_replay: "&#A0A0A0/parkour replay [забег] 񢩈- Смотреть записанные забеги" line_join: "&#A0A0A0/parkour join [режим/игрок] 񢩈- Войти в режим" line_leave: "&#A0A0A0/parkour leave 񢩈- Выйти" line_menu: "&#A0A0A0/parkour menu 񢩈- Открыть меню" diff --git a/src/test/java/dev/loki/loparkour/ghost/GhostDataTest.java b/src/test/java/dev/loki/loparkour/ghost/GhostDataTest.java index d93ffda..3dbccea 100644 --- a/src/test/java/dev/loki/loparkour/ghost/GhostDataTest.java +++ b/src/test/java/dev/loki/loparkour/ghost/GhostDataTest.java @@ -5,7 +5,10 @@ import dev.loki.loparkour.ghost.model.GhostFrame; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.io.BufferedOutputStream; +import java.io.DataOutputStream; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; @@ -34,18 +37,20 @@ class GhostDataTest { assertEquals(uuid, data.getPlayerUuid()); assertEquals(42, data.getScore()); assertEquals(2, data.getFrames().size()); + assertTrue(data.getTimestamp() > 0, "Run timestamp should be set"); } @Test void testSaveAndLoad(@TempDir Path tempDir) throws IOException { - // Create test data + // Create test data with an explicit completion timestamp (v4 format) List frames = new ArrayList<>(); frames.add(new GhostFrame(0, 0, 100, 0, 0, 0)); frames.add(new GhostFrame(500, 1, 100, 0, 45, 0)); frames.add(new GhostFrame(1000, 2, 100, 0, 90, 0)); UUID uuid = UUID.randomUUID(); - GhostData original = new GhostData(uuid, "Player1", 100, frames); + long timestamp = 1_700_000_000_000L; + GhostData original = new GhostData(uuid, "Player1", 100, timestamp, "world", frames); // Save to file File file = tempDir.resolve("test.ghost").toFile(); @@ -61,6 +66,9 @@ class GhostDataTest { assertEquals(original.getPlayerName(), loaded.getPlayerName()); assertEquals(original.getPlayerUuid(), loaded.getPlayerUuid()); assertEquals(original.getScore(), loaded.getScore()); + assertEquals(original.getTimestamp(), loaded.getTimestamp()); + assertEquals(original.getWorldName(), loaded.getWorldName()); + assertEquals("world", loaded.getWorldName()); assertEquals(original.getFrames().size(), loaded.getFrames().size()); // Verify frames @@ -187,4 +195,29 @@ class GhostDataTest { assertEquals(1, data.getFrames().size(), "GhostData should create defensive copy of frames list"); } + + @Test + void testOldVersionFormatRejected(@TempDir Path tempDir) throws IOException { + // Simulate an old v3 file: version header 3 followed by the v3 field layout + File file = tempDir.resolve("old-v3.ghost").toFile(); + UUID uuid = UUID.randomUUID(); + try (DataOutputStream out = new DataOutputStream( + new BufferedOutputStream(new FileOutputStream(file)))) { + out.writeInt(3); // v3 version header + out.writeUTF(uuid.toString()); + out.writeUTF("OldPlayer"); + out.writeInt(50); // score + out.writeLong(1_700_000_000_000L); // timestamp + out.writeInt(1); // frame count + out.writeLong(0); // frame timestamp + out.writeDouble(0); + out.writeDouble(100); + out.writeDouble(0); + out.writeFloat(0); + out.writeFloat(0); + } + + assertNull(GhostData.loadFromFile(file), + "pre-v4 files must be rejected as stale (return null)"); + } } diff --git a/src/test/java/dev/loki/loparkour/ghost/GhostManagerTest.java b/src/test/java/dev/loki/loparkour/ghost/GhostManagerTest.java new file mode 100644 index 0000000..c2d6587 --- /dev/null +++ b/src/test/java/dev/loki/loparkour/ghost/GhostManagerTest.java @@ -0,0 +1,114 @@ +package dev.loki.loparkour.ghost; + +import dev.loki.loparkour.ghost.core.GhostManager; +import dev.loki.loparkour.ghost.model.GhostData; +import dev.loki.loparkour.ghost.model.GhostFrame; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the per-player personal replay storage of {@link GhostManager}. + */ +class GhostManagerTest { + + private static final int EVICTION_CAP = 2; + + /** + * A manager whose run cap is fixed, overriding the config-driven {@link #maxRuns()}. + * Chosen over mutating {@code Config} statics, which the plugin initializes at + * enable time and are not settable from unit tests. + */ + private static final class FixedCapManager extends GhostManager { + + private FixedCapManager(File ghostFolder) { + super(ghostFolder); + } + + @Override + public int maxRuns() { + return EVICTION_CAP; + } + } + + private static GhostData run(UUID uuid, String name, int score, long timestamp) { + final List frames = new ArrayList<>(); + frames.add(new GhostFrame(0, 0, 100, 0, 0, 0)); + return new GhostData(uuid, name, score, timestamp, "world", frames); + } + + @Test + void saveRunAndGetRunsReturnNewestFirst(@TempDir Path tempDir) throws IOException { + final UUID uuid = UUID.randomUUID(); + final GhostManager manager = new GhostManager(tempDir.toFile()); + final long base = 1_700_000_000_000L; + + manager.saveRun(uuid, run(uuid, "A", 10, base)); + manager.saveRun(uuid, run(uuid, "B", 20, base + 1_000)); + manager.saveRun(uuid, run(uuid, "C", 30, base + 2_000)); + + final List runs = manager.getRuns(uuid); + + assertEquals(3, runs.size()); + assertEquals(base + 2_000, runs.get(0).getTimestamp()); + assertEquals(30, runs.get(0).getScore()); + assertEquals(base, runs.get(2).getTimestamp()); + assertEquals(10, runs.get(2).getScore()); + } + + @Test + void saveRunEvictsOldestBeyondCap(@TempDir Path tempDir) throws IOException { + final UUID uuid = UUID.randomUUID(); + final GhostManager manager = new FixedCapManager(tempDir.toFile()); + final long base = 1_700_000_000_000L; + + for (int i = 0; i < 5; i++) { + manager.saveRun(uuid, run(uuid, "P" + i, i * 10, base + i * 1_000L)); + } + + final List runs = manager.getRuns(uuid); + + assertEquals(EVICTION_CAP, runs.size(), "Old runs beyond the cap must be evicted"); + assertEquals(base + 4_000, runs.get(0).getTimestamp()); + assertEquals(base + 3_000, runs.get(1).getTimestamp()); + } + + @Test + void getRunsWithEmptyFolderReturnsEmptyList(@TempDir Path tempDir) { + final GhostManager manager = new GhostManager(tempDir.toFile()); + + final List runs = manager.getRuns(UUID.randomUUID()); + + assertTrue(runs.isEmpty(), "Player with no saved runs should return an empty list"); + } + + @Test + void getRunsIgnoresStaleFormatFiles(@TempDir Path tempDir) throws IOException { + final UUID uuid = UUID.randomUUID(); + final GhostManager manager = new GhostManager(tempDir.toFile()); + manager.saveRun(uuid, run(uuid, "New", 10, 1_700_000_000_000L)); + + // Corrupt one file so it reads as an unsupported version + final File playerFolder = new File(tempDir.toFile(), uuid.toString()); + final File[] files = playerFolder.listFiles((dir, name) -> name.endsWith(".ghost")); + assertNotNull(files); + try (DataOutputStream out = new DataOutputStream( + new FileOutputStream(files[0], false))) { + out.writeInt(2); + } + + final List runs = manager.getRuns(uuid); + + assertTrue(runs.isEmpty(), "Stale-format files must be skipped and deleted"); + } +} diff --git a/src/test/java/dev/loki/loparkour/ghost/replay/PlayerGhostTest.java b/src/test/java/dev/loki/loparkour/ghost/replay/PlayerGhostTest.java new file mode 100644 index 0000000..e543a08 --- /dev/null +++ b/src/test/java/dev/loki/loparkour/ghost/replay/PlayerGhostTest.java @@ -0,0 +1,42 @@ +package dev.loki.loparkour.ghost.replay; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pure-logic tests for the fixed-point yaw conversion used by the ghost packets. + */ +class PlayerGhostTest { + + private static final float[] EDGE_ANGLES = {0f, 90f, 180f, -90f, 359f, 270f}; + private static final double MAX_ERROR = 1.0; + + @Test + void yawToFixedKnownValues() { + assertEquals(0, PlayerGhost.yawToFixed(0)); + assertEquals(64, PlayerGhost.yawToFixed(90)); + assertEquals(-128, PlayerGhost.yawToFixed(180)); + assertEquals(-64, PlayerGhost.yawToFixed(-90)); + assertEquals(-1, PlayerGhost.yawToFixed(359)); + } + + @Test + void yawRoundTripWithinOneDegree() { + for (final float angle : EDGE_ANGLES) { + final byte fixed = PlayerGhost.yawToFixed(angle); + final double decoded = (fixed & 0xFF) * 360.0 / 256.0; + assertTrue(Math.abs(decoded - normalized(angle)) < MAX_ERROR, + "yaw " + angle + " decoded to " + decoded); + } + } + + private static double normalized(float angle) { + double result = angle % 360.0; + if (result < 0) { + result += 360.0; + } + return result; + } +}