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:
loki5512344 2026-08-06 13:13:59 +02:00
parent 4c255111f0
commit 8c8046a4f5
Signed by: boba
GPG key ID: 253067914055423B
30 changed files with 1027 additions and 227 deletions

View file

@ -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 {
@ -101,6 +174,13 @@ dependencies {
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")
compileOnly("net.kyori:adventure-text-minimessage:4.14.0") compileOnly("net.kyori:adventure-text-minimessage: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 {

View file

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

View file

@ -8,6 +8,7 @@ import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales; import dev.loki.loparkour.config.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);

View file

@ -4,6 +4,7 @@ import dev.loki.loparkour.command.admin.AdminCommandHandler;
import dev.loki.loparkour.command.player.JoinCommandExecutor; import dev.loki.loparkour.command.player.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"));

View file

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

View file

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

View file

@ -0,0 +1,65 @@
package dev.loki.loparkour.command.player;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.command.util.CommandUtil;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.config.locale.Locales;
import dev.loki.loparkour.ghost.model.GhostData;
import dev.loki.loparkour.ghost.service.GhostService;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
/**
* Handles {@code /lp replay [index]}: plays back a player's recorded ghost runs.
*/
public class ReplayCommandExecutor {
private static final int COOLDOWN_MS = 2500;
private static final int DEFAULT_RUN_INDEX = 1;
private static final int INVALID_RUN_INDEX = -1;
public void replay(@Nullable String indexArg, @NotNull CommandSender sender, @NotNull Player p,
@NotNull PlayerCommandHandler player) {
if (!player.cooldown(sender, "replay", COOLDOWN_MS)) {
return;
}
final GhostService ghostService = LoParkour.getGhostService();
if (ghostService == null || !Config.CONFIG.getBoolean("ghost-mode.enabled")) {
CommandUtil.send(sender, Locales.getString(sender, "commands.replay.disabled"));
return;
}
final GhostData run = resolveRun(indexArg, sender, p, ghostService);
if (run == null) {
return;
}
ghostService.startReplay(p, run);
CommandUtil.send(sender, Locales.getString(sender, "commands.replay.starting"));
}
private static GhostData resolveRun(@Nullable String indexArg, @NotNull CommandSender sender,
@NotNull Player p, @NotNull GhostService ghostService) {
final List<GhostData> runs = ghostService.getManager().getRuns(p.getUniqueId());
if (runs.isEmpty()) {
CommandUtil.send(sender, Locales.getString(sender, "commands.replay.no_runs"));
return null;
}
final int index = indexArg == null ? DEFAULT_RUN_INDEX : parseIndex(indexArg);
if (index < DEFAULT_RUN_INDEX || index > runs.size()) {
CommandUtil.send(sender, Locales.getString(sender, "commands.replay.invalid"));
return null;
}
return runs.get(index - DEFAULT_RUN_INDEX);
}
private static int parseIndex(String indexArg) {
try {
return Integer.parseInt(indexArg);
} catch (NumberFormatException e) {
return INVALID_RUN_INDEX;
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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()); /** Max runs kept per player; read from config each call so reloads apply. */
for (int i = 0; i < showTop; i++) { public int maxRuns() {
final GhostPlayer ghost = new GhostPlayer(ghosts.get(i), world); return Config.CONFIG.getInt("ghost-mode.max-runs", DEFAULT_MAX_RUNS);
ghost.spawn(startLocation);
activeGhosts.add(ghost);
}
}
public void stopAllGhosts() {
activeGhosts.forEach(GhostPlayer::stop);
activeGhosts.clear();
}
// ── Queries ───────────────────────────────────────────────────────────────
@Nullable
public GhostData getTopGhost(@NotNull String mode) {
final List<GhostData> ghosts = ghostsByMode.get(mode);
return (ghosts == null || ghosts.isEmpty()) ? null : ghosts.get(0);
}
public List<GhostData> getTopGhosts(@NotNull String mode, int count) {
final List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.isEmpty()) {
return Collections.emptyList();
}
return Collections.unmodifiableList(ghosts.subList(0, Math.min(count, ghosts.size())));
}
public boolean shouldRecordGhost(@NotNull String mode, int score) {
final List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.size() < MAX_GHOSTS_PER_MODE) {
return true;
}
return score > ghosts.get(ghosts.size() - 1).getScore();
} }
// ── 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;
}
} }
} }

View file

@ -50,17 +50,27 @@ public class GhostRecorder {
} }
/** /**
* Stops recording and returns the completed ghost data. * Stops recording and returns the completed ghost data for the given world.
* *
* @param playerUuid UUID of the player — used as the filename in {@link GhostManager}. * @param 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() {

View file

@ -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;
} }
@ -72,6 +93,8 @@ public class GhostData {
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);
} }
} }
} }

View file

@ -0,0 +1,200 @@
package dev.loki.loparkour.ghost.replay;
import com.mojang.authlib.GameProfile;
import com.mojang.authlib.properties.Property;
import dev.loki.loparkour.LoParkour;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoRemovePacket;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket;
import net.minecraft.network.protocol.game.ClientboundRotateHeadPacket;
import net.minecraft.network.protocol.game.ClientboundTeleportEntityPacket;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.level.GameType;
import net.minecraft.world.phys.Vec3;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.EnumSet;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Packet-based fake player shown to a single viewer, replaying a recorded run.
*
* <p>Compiled against the Mojang-mapped 1.20.4 NMS; packets that 1.20.4 only builds
* from a {@code ServerPlayer} are constructed reflectively via {@code sun.misc.Unsafe}
* like {@code Compat}. Version mismatches fail {@link #spawn} and are logged once.
*/
public final class PlayerGhost {
private static final String TEXTURES_PROPERTY = "textures";
private static final int LATENCY = 0;
private static final int NO_DATA = 0;
private static final int BASE_ENTITY_ID = Integer.MAX_VALUE - 10_000;
private static final double FIXED_POINT_FACTOR = 256.0 / 360.0;
private static final double ZERO_SPEED = 0.0;
private static final AtomicInteger NEXT_ENTITY_ID = new AtomicInteger(BASE_ENTITY_ID);
private static volatile Object unsafe;
private static volatile Method allocateMethod;
private static Method sendMethod;
private static boolean warned;
private final Player viewer;
private final GameProfile profile;
private final int entityId;
private boolean active;
public PlayerGhost(@NotNull Player viewer, @NotNull String displayName) {
this.viewer = viewer;
this.entityId = NEXT_ENTITY_ID.incrementAndGet();
this.profile = new GameProfile(UUID.randomUUID(), displayName);
copySkin(viewer, profile);
}
public boolean spawn(@NotNull Location location) {
if (active) {
teleport(location);
return true;
}
try {
final ClientboundPlayerInfoUpdatePacket.Entry entry = new ClientboundPlayerInfoUpdatePacket.Entry(
profile.getId(), profile, true, LATENCY, GameType.SURVIVAL, null, null);
send(connection(viewer), playerInfoUpdate(entry));
send(connection(viewer), new ClientboundAddEntityPacket(entityId, profile.getId(),
location.getX(), location.getY(), location.getZ(),
location.getPitch(), location.getYaw(), EntityType.PLAYER, NO_DATA, Vec3.ZERO, ZERO_SPEED));
send(connection(viewer), headRotation(location.getYaw()));
active = true;
return true;
} catch (ReflectiveOperationException | LinkageError error) {
warnOnce(error);
return false;
}
}
public void teleport(@NotNull Location location) {
if (!active) {
return;
}
try {
send(connection(viewer), teleportPacket(location));
send(connection(viewer), headRotation(location.getYaw()));
} catch (ReflectiveOperationException | LinkageError error) {
warnOnce(error);
}
}
public void remove() {
if (!active) {
return;
}
try {
send(connection(viewer), new ClientboundRemoveEntitiesPacket(entityId));
send(connection(viewer), new ClientboundPlayerInfoRemovePacket(List.of(profile.getId())));
active = false;
} catch (ReflectiveOperationException | LinkageError error) {
warnOnce(error);
}
}
public boolean isActive() {
return active;
}
/** Converts a degree angle to the fixed-point byte used on the wire. */
static byte yawToFixed(float degrees) {
return (byte) Math.round(degrees * FIXED_POINT_FACTOR);
}
private Object playerInfoUpdate(ClientboundPlayerInfoUpdatePacket.Entry entry) throws ReflectiveOperationException {
final ClientboundPlayerInfoUpdatePacket packet = allocate(ClientboundPlayerInfoUpdatePacket.class);
setField(packet, "actions", EnumSet.of(ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER));
setField(packet, "entries", List.of(entry));
return packet;
}
private Object teleportPacket(@NotNull Location location) throws ReflectiveOperationException {
final ClientboundTeleportEntityPacket packet = allocate(ClientboundTeleportEntityPacket.class);
setField(packet, "id", entityId);
setField(packet, "x", location.getX());
setField(packet, "y", location.getY());
setField(packet, "z", location.getZ());
setField(packet, "yRot", yawToFixed(location.getYaw()));
setField(packet, "xRot", yawToFixed(location.getPitch()));
setField(packet, "onGround", true);
return packet;
}
private Object headRotation(float yaw) throws ReflectiveOperationException {
final ClientboundRotateHeadPacket packet = allocate(ClientboundRotateHeadPacket.class);
setField(packet, "entityId", entityId);
setField(packet, "yHeadRot", yawToFixed(yaw));
return packet;
}
private static void copySkin(@NotNull Player player, @NotNull GameProfile profile) {
try {
final Object playerProfile = player.getPlayerProfile();
final Object textures = playerProfile == null
? null : playerProfile.getClass().getMethod("getTextures").invoke(playerProfile);
final Object skin = textures == null
? null : textures.getClass().getMethod("getSkin").invoke(textures);
final String value = skin == null
? null : (String) skin.getClass().getMethod("getValue").invoke(skin);
final String signature = skin == null
? null : (String) skin.getClass().getMethod("getSignature").invoke(skin);
if (value != null && !value.isEmpty()) {
profile.getProperties().put(TEXTURES_PROPERTY,
new Property(TEXTURES_PROPERTY, value, signature));
}
} catch (ReflectiveOperationException | LinkageError error) {
warnOnce(error);
}
}
private static Object connection(@NotNull Player player) throws ReflectiveOperationException {
final Object handle = player.getClass().getMethod("getHandle").invoke(player);
return handle.getClass().getField("connection").get(handle);
}
private static void send(@NotNull Object connection, @NotNull Object packet) throws ReflectiveOperationException {
if (sendMethod == null) {
sendMethod = connection.getClass().getMethod("send", Packet.class);
}
sendMethod.invoke(connection, packet);
}
private static void setField(@NotNull Object instance, @NotNull String name, @NotNull Object value)
throws ReflectiveOperationException {
final Field field = instance.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(instance, value);
}
private static <T> T allocate(Class<T> type) throws ReflectiveOperationException {
if (unsafe == null) {
final Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
final Field field = unsafeClass.getDeclaredField("theUnsafe");
field.setAccessible(true);
unsafe = field.get(null);
allocateMethod = unsafeClass.getMethod("allocateInstance", Class.class);
}
return type.cast(allocateMethod.invoke(unsafe, type));
}
private static void warnOnce(@NotNull Throwable error) {
if (warned) {
return;
}
warned = true;
if (LoParkour.getPlugin() != null) {
LoParkour.getPlugin().getLogger().warning("Ghost replay NPC unsupported on this server: " + error);
}
}
}

View file

@ -0,0 +1,113 @@
package dev.loki.loparkour.ghost.service;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.ghost.model.GhostData;
import dev.loki.loparkour.ghost.model.GhostFrame;
import dev.loki.loparkour.ghost.replay.PlayerGhost;
import dev.loki.loparkour.player.core.ParkourUser;
import dev.lolib.scheduler.ScheduledTask;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.List;
/**
* Plays one recorded {@link GhostData} run back to a single viewer as a
* packet-based fake player ({@link PlayerGhost}), then returns the viewer
* to where they were and fires {@code onFinish}.
*/
public class GhostReplay {
private static final long TICK_DELAY = 0;
private static final long TICK_PERIOD = 1;
private static final int FIRST_FRAME = 0;
private final Player viewer;
private final GhostData run;
private final Runnable onFinish;
private final World world;
private final Location returnTo;
private final List<GhostFrame> frames;
private PlayerGhost ghost;
private ScheduledTask task;
private long startTime;
private int frameIndex;
private Location current;
private boolean moved;
public GhostReplay(@NotNull Player viewer, @NotNull GhostData run, @NotNull Runnable onFinish) {
this.viewer = viewer;
this.run = run;
this.onFinish = onFinish;
this.returnTo = viewer.getLocation().clone();
this.frames = run.getFrames();
final String worldName = run.getWorldName();
World resolved = worldName.isEmpty() ? null : Bukkit.getWorld(worldName);
if (resolved == null) {
resolved = viewer.getWorld();
}
this.world = resolved;
}
public void start() {
if (run.getFrames().isEmpty()) {
onFinish.run();
return;
}
final ParkourUser user = ParkourUser.getUser(viewer);
if (user != null) {
ParkourUser.leave(viewer);
}
final Location start = run.getFrames().get(FIRST_FRAME).toLocation(world);
viewer.teleport(start);
moved = true;
ghost = new PlayerGhost(viewer, run.getPlayerName());
if (!ghost.spawn(start)) {
finish();
return;
}
startTime = System.currentTimeMillis();
frameIndex = FIRST_FRAME;
current = start;
task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::tick, TICK_DELAY, TICK_PERIOD);
}
private void tick() {
if (!viewer.isOnline()) {
finish();
return;
}
final long elapsed = System.currentTimeMillis() - startTime;
while (frameIndex < frames.size()
&& frames.get(frameIndex).getTimestamp() <= elapsed) {
current = frames.get(frameIndex).toLocation(world);
frameIndex++;
}
if (current != null && ghost != null) {
ghost.teleport(current);
}
if (frameIndex >= frames.size()) {
finish();
}
}
private void finish() {
if (task != null) {
task.cancel();
task = null;
}
if (ghost != null) {
ghost.remove();
ghost = null;
}
if (moved && viewer.isOnline()) {
viewer.teleport(returnTo);
}
onFinish.run();
}
}

View file

@ -0,0 +1,99 @@
package dev.loki.loparkour.ghost.service;
import dev.loki.loparkour.config.core.Config;
import dev.loki.loparkour.ghost.core.GhostManager;
import dev.loki.loparkour.ghost.core.GhostRecorder;
import dev.loki.loparkour.ghost.model.GhostData;
import dev.loki.loparkour.generator.core.coordinator.ParkourGenerator;
import dev.loki.loparkour.player.core.ParkourPlayer;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Plugin-owned singleton that records each player's runs as {@link GhostData}
* while the server's {@code ghost-mode} is enabled and the player's per-player
* {@code ghostRecording} toggle is on.
*/
public class GhostService {
private final GhostManager manager = new GhostManager();
private final Map<UUID, GhostRecorder> recorders = new ConcurrentHashMap<>();
private final Set<UUID> replaying = ConcurrentHashMap.newKeySet();
public GhostManager getManager() {
return manager;
}
private boolean serverEnabled() {
return Config.CONFIG.getBoolean("ghost-mode.enabled");
}
private boolean recordingEnabled(ParkourPlayer pp) {
return serverEnabled()
&& !replaying.contains(pp.getUUID())
&& Boolean.TRUE.equals(pp.ghostRecording);
}
public void startRun(ParkourGenerator generator) {
for (final ParkourPlayer pp : generator.getPlayers()) {
if (recordingEnabled(pp)) {
recorders.computeIfAbsent(pp.getUUID(), k -> new GhostRecorder())
.startRecording(pp.getLocation());
} else {
recorders.remove(pp.getUUID());
}
}
}
public void recordPlayers(ParkourGenerator generator) {
for (final ParkourPlayer pp : generator.getPlayers()) {
final GhostRecorder recorder = recorders.get(pp.getUUID());
if (recorder != null) {
recorder.recordFrame(pp.getLocation());
}
}
}
public void endRun(ParkourGenerator generator, int score) {
for (final ParkourPlayer pp : generator.getPlayers()) {
final GhostRecorder recorder = recorders.remove(pp.getUUID());
if (recorder != null && recorder.isRecording()) {
final String worldName = pp.getLocation().getWorld() == null
? "" : pp.getLocation().getWorld().getName();
final GhostData data = recorder.stopRecording(
pp.getUUID(), pp.player.getName(), score, worldName);
manager.saveRun(pp.getUUID(), data);
}
}
}
public void playerLeft(UUID uuid) {
recorders.remove(uuid);
}
public boolean isReplaying(UUID uuid) {
return replaying.contains(uuid);
}
public void setReplaying(UUID uuid, boolean replaying) {
if (replaying) {
this.replaying.add(uuid);
} else {
this.replaying.remove(uuid);
}
}
public void startReplay(@NotNull org.bukkit.entity.Player viewer, @NotNull GhostData run) {
playerLeft(viewer.getUniqueId());
setReplaying(viewer.getUniqueId(), true);
new GhostReplay(viewer, run, () -> setReplaying(viewer.getUniqueId(), false)).start();
}
public void shutdown() {
recorders.clear();
}
}

View file

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

View file

@ -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,35 +56,39 @@ 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))
.setItem(SLOT_SOUND, toggleItem(locale, "sound", pp.sound),
e -> toggleSetting(pp, () -> pp.sound = !pp.sound))
.setItem(SLOT_SPECIAL_BLOCKS, toggleItem(locale, "special_blocks", pp.useSpecialBlocks),
e -> toggleSpecialBlocks(pp))
.setItem(SLOT_FALL_MESSAGE, toggleItem(locale, "fall_message", pp.showFallMessage),
e -> toggleSetting(pp, () -> pp.showFallMessage = !pp.showFallMessage))
.setItem(SLOT_SCOREBOARD, toggleItem(locale, "scoreboard", pp.showScoreboard),
e -> toggleSetting(pp, () -> pp.showScoreboard = !pp.showScoreboard))
.setItem(SLOT_GHOST_RECORDING, toggleItem(locale, "ghost_recording",
Boolean.TRUE.equals(pp.ghostRecording)), e -> toggleGhostRecording(pp))
.setItem(SLOT_STYLE, styleItem(pp), e -> openStyleMenu(pp))
.setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> cycleSchematicDifficulty(pp))
.setItem(SLOT_CLOSE, closeButton(player), e -> Menus.SETTINGS.open(player))
.open(player);
}
private void toggleSetting(ParkourPlayer pp, Runnable update) {
update.run();
pp.updateGeneratorSettings(pp.session.generator); pp.updateGeneratorSettings(pp.session.generator);
open(pp); open(pp);
}) }
.setItem(SLOT_SOUND, toggleItem(locale, "sound", pp.sound), e -> {
pp.sound = !pp.sound; private void toggleSpecialBlocks(ParkourPlayer pp) {
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) { if (pp.session.generator.state.score == 0) {
pp.useSpecialBlocks = !pp.useSpecialBlocks; pp.useSpecialBlocks = !pp.useSpecialBlocks;
pp.updateGeneratorSettings(pp.session.generator); pp.updateGeneratorSettings(pp.session.generator);
} }
open(pp); open(pp);
}) }
.setItem(SLOT_FALL_MESSAGE, toggleItem(locale, "fall_message", pp.showFallMessage), e -> {
pp.showFallMessage = !pp.showFallMessage; private void cycleSchematicDifficulty(ParkourPlayer pp) {
pp.updateGeneratorSettings(pp.session.generator);
open(pp);
})
.setItem(SLOT_SCOREBOARD, toggleItem(locale, "scoreboard", pp.showScoreboard), e -> {
pp.showScoreboard = !pp.showScoreboard;
pp.updateGeneratorSettings(pp.session.generator);
open(pp);
})
.setItem(SLOT_STYLE, styleItem(pp), e -> openStyleMenu(pp))
.setItem(SLOT_SCHEM_DIFF, schemDiffItem(pp), e -> {
if (pp.session.generator.state.score == 0) { 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 List<Double> diffs = List.of(0.0, DIFF_QUARTER, DIFF_HALF, DIFF_THREE_QUARTER, 1.0);
final int idx = diffs.indexOf(pp.schematicDifficulty); final int idx = diffs.indexOf(pp.schematicDifficulty);
@ -91,9 +96,12 @@ public class ParkourSettingsMenu extends LPMenu {
pp.updateGeneratorSettings(pp.session.generator); pp.updateGeneratorSettings(pp.session.generator);
} }
open(pp); open(pp);
}) }
.setItem(SLOT_CLOSE, closeButton(player), e -> Menus.SETTINGS.open(player))
.open(player); private void toggleGhostRecording(ParkourPlayer pp) {
pp.ghostRecording = !Boolean.TRUE.equals(pp.ghostRecording);
pp.save(false);
open(pp);
} }
private ItemStack toggleItem(String locale, String key, boolean value) { private ItemStack toggleItem(String locale, String key, boolean value) {
@ -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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,114 @@
package dev.loki.loparkour.ghost;
import dev.loki.loparkour.ghost.core.GhostManager;
import dev.loki.loparkour.ghost.model.GhostData;
import dev.loki.loparkour.ghost.model.GhostFrame;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for the per-player personal replay storage of {@link GhostManager}.
*/
class GhostManagerTest {
private static final int EVICTION_CAP = 2;
/**
* A manager whose run cap is fixed, overriding the config-driven {@link #maxRuns()}.
* Chosen over mutating {@code Config} statics, which the plugin initializes at
* enable time and are not settable from unit tests.
*/
private static final class FixedCapManager extends GhostManager {
private FixedCapManager(File ghostFolder) {
super(ghostFolder);
}
@Override
public int maxRuns() {
return EVICTION_CAP;
}
}
private static GhostData run(UUID uuid, String name, int score, long timestamp) {
final List<GhostFrame> frames = new ArrayList<>();
frames.add(new GhostFrame(0, 0, 100, 0, 0, 0));
return new GhostData(uuid, name, score, timestamp, "world", frames);
}
@Test
void saveRunAndGetRunsReturnNewestFirst(@TempDir Path tempDir) throws IOException {
final UUID uuid = UUID.randomUUID();
final GhostManager manager = new GhostManager(tempDir.toFile());
final long base = 1_700_000_000_000L;
manager.saveRun(uuid, run(uuid, "A", 10, base));
manager.saveRun(uuid, run(uuid, "B", 20, base + 1_000));
manager.saveRun(uuid, run(uuid, "C", 30, base + 2_000));
final List<GhostData> runs = manager.getRuns(uuid);
assertEquals(3, runs.size());
assertEquals(base + 2_000, runs.get(0).getTimestamp());
assertEquals(30, runs.get(0).getScore());
assertEquals(base, runs.get(2).getTimestamp());
assertEquals(10, runs.get(2).getScore());
}
@Test
void saveRunEvictsOldestBeyondCap(@TempDir Path tempDir) throws IOException {
final UUID uuid = UUID.randomUUID();
final GhostManager manager = new FixedCapManager(tempDir.toFile());
final long base = 1_700_000_000_000L;
for (int i = 0; i < 5; i++) {
manager.saveRun(uuid, run(uuid, "P" + i, i * 10, base + i * 1_000L));
}
final List<GhostData> runs = manager.getRuns(uuid);
assertEquals(EVICTION_CAP, runs.size(), "Old runs beyond the cap must be evicted");
assertEquals(base + 4_000, runs.get(0).getTimestamp());
assertEquals(base + 3_000, runs.get(1).getTimestamp());
}
@Test
void getRunsWithEmptyFolderReturnsEmptyList(@TempDir Path tempDir) {
final GhostManager manager = new GhostManager(tempDir.toFile());
final List<GhostData> runs = manager.getRuns(UUID.randomUUID());
assertTrue(runs.isEmpty(), "Player with no saved runs should return an empty list");
}
@Test
void getRunsIgnoresStaleFormatFiles(@TempDir Path tempDir) throws IOException {
final UUID uuid = UUID.randomUUID();
final GhostManager manager = new GhostManager(tempDir.toFile());
manager.saveRun(uuid, run(uuid, "New", 10, 1_700_000_000_000L));
// Corrupt one file so it reads as an unsupported version
final File playerFolder = new File(tempDir.toFile(), uuid.toString());
final File[] files = playerFolder.listFiles((dir, name) -> name.endsWith(".ghost"));
assertNotNull(files);
try (DataOutputStream out = new DataOutputStream(
new FileOutputStream(files[0], false))) {
out.writeInt(2);
}
final List<GhostData> runs = manager.getRuns(uuid);
assertTrue(runs.isEmpty(), "Stale-format files must be skipped and deleted");
}
}

View file

@ -0,0 +1,42 @@
package dev.loki.loparkour.ghost.replay;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Pure-logic tests for the fixed-point yaw conversion used by the ghost packets.
*/
class PlayerGhostTest {
private static final float[] EDGE_ANGLES = {0f, 90f, 180f, -90f, 359f, 270f};
private static final double MAX_ERROR = 1.0;
@Test
void yawToFixedKnownValues() {
assertEquals(0, PlayerGhost.yawToFixed(0));
assertEquals(64, PlayerGhost.yawToFixed(90));
assertEquals(-128, PlayerGhost.yawToFixed(180));
assertEquals(-64, PlayerGhost.yawToFixed(-90));
assertEquals(-1, PlayerGhost.yawToFixed(359));
}
@Test
void yawRoundTripWithinOneDegree() {
for (final float angle : EDGE_ANGLES) {
final byte fixed = PlayerGhost.yawToFixed(angle);
final double decoded = (fixed & 0xFF) * 360.0 / 256.0;
assertTrue(Math.abs(decoded - normalized(angle)) < MAX_ERROR,
"yaw " + angle + " decoded to " + decoded);
}
}
private static double normalized(float angle) {
double result = angle % 360.0;
if (result < 0) {
result += 360.0;
}
return result;
}
}