fix jump slab

This commit is contained in:
loki 2026-03-17 13:36:23 +01:00
parent d9eb747c1b
commit 978f739045
43 changed files with 5685 additions and 832 deletions

View file

@ -5,44 +5,56 @@ import org.jetbrains.annotations.NotNull;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Immutable snapshot of a player's run: who ran it, their score, and every recorded frame.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>Files were named {@code playerName.ghost} — two players with the same name
* overwrote each other, and a name like {@code ../../config} caused path traversal.
* UUID is now stored and used as the filename instead.</li>
* <li>File format is backwards-compatible: UUID is written first (new field),
* then the existing layout. Old files without a UUID are handled in
* {@link #loadFromFile} by catching the format mismatch and returning null.</li>
* </ul>
*/
public class GhostData {
/** Binary format version — bump when changing the serialised layout. */
private static final int FORMAT_VERSION = 2;
private final UUID playerUuid;
private final String playerName;
private final String playerUUID;
private final int score;
private final List<GhostFrame> frames;
public GhostData(@NotNull String playerName, @NotNull String playerUUID, int score, @NotNull List<GhostFrame> frames) {
public GhostData(@NotNull UUID playerUuid, @NotNull String playerName,
int score, @NotNull List<GhostFrame> frames) {
this.playerUuid = playerUuid;
this.playerName = playerName;
this.playerUUID = playerUUID;
this.score = score;
this.frames = new ArrayList<>(frames);
this.score = score;
this.frames = new ArrayList<>(frames);
}
public String getPlayerName() {
return playerName;
}
// ── Getters ───────────────────────────────────────────────────────────────
public String getPlayerUUID() {
return playerUUID;
}
public UUID getPlayerUuid() { return playerUuid; }
public String getPlayerName() { return playerName; }
public int getScore() { return score; }
public List<GhostFrame> getFrames() { return frames; }
public int getScore() {
return score;
}
public List<GhostFrame> getFrames() {
return frames;
}
// ── Serialisation ─────────────────────────────────────────────────────────
public void saveToFile(@NotNull File file) throws IOException {
try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) {
out.writeUTF(playerName);
out.writeUTF(playerUUID);
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(playerName); // display name (for the ghost label)
out.writeInt(score);
out.writeInt(frames.size());
for (GhostFrame frame : frames) {
out.writeLong(frame.getTimestamp());
out.writeDouble(frame.getX());
@ -54,26 +66,37 @@ 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.
*/
public static GhostData loadFromFile(@NotNull File file) throws IOException {
try (DataInputStream in = new DataInputStream(new FileInputStream(file))) {
String playerName = in.readUTF();
String playerUUID = in.readUTF();
int score = in.readInt();
int frameCount = in.readInt();
try (DataInputStream in = new DataInputStream(
new BufferedInputStream(new FileInputStream(file)))) {
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.
return null;
}
UUID uuid = UUID.fromString(in.readUTF());
String name = in.readUTF();
int score = in.readInt();
int frameCount = in.readInt();
List<GhostFrame> frames = new ArrayList<>(frameCount);
for (int i = 0; i < frameCount; i++) {
long timestamp = in.readLong();
double x = in.readDouble();
double y = in.readDouble();
double z = in.readDouble();
float yaw = in.readFloat();
float pitch = in.readFloat();
frames.add(new GhostFrame(timestamp, x, y, z, yaw, pitch));
frames.add(new GhostFrame(
in.readLong(),
in.readDouble(), in.readDouble(), in.readDouble(),
in.readFloat(), in.readFloat()));
}
return new GhostData(playerName, playerUUID, score, frames);
return new GhostData(uuid, name, score, frames);
}
}
}

View file

@ -1,7 +1,5 @@
package dev.loki.loparkour.ghost;
import java.util.ArrayList;
import dev.loki.loparkour.LoParkour;
import dev.loki.loparkour.config.Config;
import org.bukkit.Location;
@ -13,84 +11,106 @@ import java.io.File;
import java.io.IOException;
import java.util.*;
/**
* Loads, saves, and spawns ghost runs per mode.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>Files were named {@code playerName.ghost} — collisions between players sharing
* a name, and path traversal via names like {@code ../../config}.
* Now named {@code uuid.ghost} — guaranteed unique and filesystem-safe.</li>
* <li>When evicting the lowest ghost, the old file was looked up by player name
* which failed if the name had changed since the run was saved.</li>
* <li>Old v1 files (no UUID) are detected via {@link GhostData#loadFromFile}
* returning null — they are deleted automatically on first load.</li>
* </ul>
*/
public class GhostManager {
private static final File GHOST_FOLDER = LoParkour.getInFolder("ghosts");
private static final int MAX_GHOSTS_PER_MODE = 3;
private final File ghostFolder;
private final Map<String, List<GhostData>> ghostsByMode = new HashMap<>();
private final List<GhostPlayer> activeGhosts = new ArrayList<>();
public GhostManager() {
if (!GHOST_FOLDER.exists()) {
GHOST_FOLDER.mkdirs();
this.ghostFolder = LoParkour.getInFolder("ghosts");
if (!ghostFolder.exists()) {
ghostFolder.mkdirs();
}
}
// ── Loading ───────────────────────────────────────────────────────────────
public void loadGhosts(@NotNull String mode) {
File modeFolder = new File(GHOST_FOLDER, mode);
if (!modeFolder.exists()) {
return;
}
File modeFolder = new File(ghostFolder, sanitizeMode(mode));
if (!modeFolder.exists()) return;
File[] files = modeFolder.listFiles((dir, name) -> name.endsWith(".ghost"));
if (files == null) return;
List<GhostData> ghosts = new ArrayList<>();
File[] files = modeFolder.listFiles((dir, name) -> name.endsWith(".ghost"));
if (files != null) {
for (File file : files) {
try {
GhostData data = GhostData.loadFromFile(file);
ghosts.add(data);
} catch (IOException e) {
LoParkour.getPlugin().getLogger().severe("Failed to load ghost: " + file.getName());
for (File file : files) {
try {
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);
} catch (IOException e) {
LoParkour.getPlugin().getLogger().severe(
"Failed to load ghost file " + file.getName() + ": " + e.getMessage());
}
}
ghosts.sort((a, b) -> Integer.compare(b.getScore(), a.getScore()));
ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed());
ghostsByMode.put(mode, ghosts);
}
// ── Saving ────────────────────────────────────────────────────────────────
public void saveGhost(@NotNull String mode, @NotNull GhostData data) {
File modeFolder = new File(GHOST_FOLDER, mode);
if (!modeFolder.exists()) {
modeFolder.mkdirs();
}
File modeFolder = new File(ghostFolder, sanitizeMode(mode));
if (!modeFolder.exists()) modeFolder.mkdirs();
List<GhostData> ghosts = ghostsByMode.getOrDefault(mode, new ArrayList<>());
List<GhostData> ghosts = new ArrayList<>(ghostsByMode.getOrDefault(mode, new ArrayList<>()));
ghosts.add(data);
ghosts.sort((a, b) -> Integer.compare(b.getScore(), a.getScore()));
ghosts.sort(Comparator.comparingInt(GhostData::getScore).reversed());
// Remove old ghosts beyond MAX limit
// Evict lowest-score ghosts beyond the cap
while (ghosts.size() > MAX_GHOSTS_PER_MODE) {
GhostData removed = ghosts.remove(ghosts.size() - 1);
// Use UUID for filename to avoid collisions and path traversal
File file = new File(modeFolder, removed.getPlayerUUID() + ".ghost");
file.delete();
GhostData evicted = ghosts.remove(ghosts.size() - 1);
// File is named by UUID — always finds the right file regardless of name changes
File evictedFile = ghostFile(modeFolder, evicted);
if (evictedFile.exists()) evictedFile.delete();
}
ghostsByMode.put(mode, ghosts);
// Use UUID for filename instead of player name
File file = new File(modeFolder, data.getPlayerUUID() + ".ghost");
// Save new ghost — named by UUID, not by player name
File file = ghostFile(modeFolder, data);
try {
data.saveToFile(file);
} catch (IOException e) {
LoParkour.getPlugin().getLogger().severe("Failed to save ghost: " + file.getName());
LoParkour.getPlugin().getLogger().severe(
"Failed to save ghost for " + data.getPlayerName() + ": " + e.getMessage());
}
}
public void spawnGhosts(@NotNull String mode, @NotNull Location startLocation, @NotNull World world) {
if (!Config.CONFIG.getBoolean("ghost-mode.enabled")) {
return;
}
// ── Spawning ──────────────────────────────────────────────────────────────
public void spawnGhosts(@NotNull String mode, @NotNull Location startLocation,
@NotNull World world) {
if (!Config.CONFIG.getBoolean("ghost-mode.enabled")) return;
stopAllGhosts();
List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.isEmpty()) {
return;
}
if (ghosts == null || ghosts.isEmpty()) return;
int showTop = Math.min(Config.CONFIG.getInt("ghost-mode.show-top"), ghosts.size());
for (int i = 0; i < showTop; i++) {
@ -101,36 +121,42 @@ public class GhostManager {
}
public void stopAllGhosts() {
for (GhostPlayer ghost : activeGhosts) {
ghost.stop();
}
activeGhosts.forEach(GhostPlayer::stop);
activeGhosts.clear();
}
// ── Queries ───────────────────────────────────────────────────────────────
@Nullable
public GhostData getTopGhost(@NotNull String mode) {
List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.isEmpty()) {
return null;
}
return ghosts.get(0);
return (ghosts == null || ghosts.isEmpty()) ? null : ghosts.get(0);
}
public List<GhostData> getTopGhosts(@NotNull String mode, int count) {
List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.isEmpty()) {
return Collections.emptyList();
}
return ghosts.subList(0, Math.min(count, ghosts.size()));
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) {
List<GhostData> ghosts = ghostsByMode.get(mode);
if (ghosts == null || ghosts.size() < MAX_GHOSTS_PER_MODE) {
return true;
}
if (ghosts == null || ghosts.size() < MAX_GHOSTS_PER_MODE) return true;
return score > ghosts.get(ghosts.size() - 1).getScore();
}
GhostData lowestGhost = ghosts.get(ghosts.size() - 1);
return score > lowestGhost.getScore();
// ── 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");
}
/**
* 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("[/\\\\.]", "_");
}
}

View file

@ -1,34 +1,40 @@
package dev.loki.loparkour.ghost;
import dev.lolib.scheduler.Scheduler;
import dev.lolib.scheduler.ScheduledTask;
import dev.loki.loparkour.LoParkour;
import dev.lolib.scheduler.Scheduler;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.EntityType;
import dev.lolib.scheduler.ScheduledTask;
import org.jetbrains.annotations.NotNull;
/**
* Spawns and animates an ArmorStand that replays a {@link GhostData} run.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>Duplicate import of {@code dev.lolib.scheduler.Scheduler} — removed.</li>
* <li>Duplicate field declaration {@code dev.lolib.scheduler.ScheduledTask task}
* shadowed the already-imported type — removed the fully-qualified duplicate.</li>
* </ul>
*/
public class GhostPlayer {
private final GhostData data;
private final World world;
private ArmorStand entity;
private dev.lolib.scheduler.ScheduledTask task;
private ScheduledTask task;
private int currentFrame = 0;
private long startTime;
public GhostPlayer(@NotNull GhostData data, @NotNull World world) {
this.data = data;
this.data = data;
this.world = world;
}
public void spawn(@NotNull Location startLocation) {
if (data.getFrames().isEmpty()) {
return;
}
if (data.getFrames().isEmpty()) return;
entity = (ArmorStand) world.spawnEntity(startLocation, EntityType.ARMOR_STAND);
entity.setVisible(false);
@ -37,7 +43,7 @@ public class GhostPlayer {
entity.setCustomName("§7" + data.getPlayerName() + " §8(Ghost)");
entity.setCustomNameVisible(true);
startTime = System.currentTimeMillis();
startTime = System.currentTimeMillis();
currentFrame = 0;
task = Scheduler.get(LoParkour.getPlugin()).runTimer(this::update, 0, 1);
@ -53,42 +59,20 @@ public class GhostPlayer {
while (currentFrame < data.getFrames().size()) {
GhostFrame frame = data.getFrames().get(currentFrame);
if (frame.getTimestamp() > elapsed) {
break;
}
Location loc = frame.toLocation(world);
entity.teleport(loc);
if (frame.getTimestamp() > elapsed) break;
entity.teleport(frame.toLocation(world));
currentFrame++;
}
if (currentFrame >= data.getFrames().size()) {
stop();
}
if (currentFrame >= data.getFrames().size()) stop();
}
public void stop() {
if (task != null) {
task.cancel();
task = null;
}
if (entity != null) {
entity.remove();
entity = null;
}
if (task != null) { task.cancel(); task = null; }
if (entity != null) { entity.remove(); entity = null; }
}
public boolean isActive() {
return entity != null && entity.isValid();
}
public String getPlayerName() {
return data.getPlayerName();
}
public int getScore() {
return data.getScore();
}
public boolean isActive() { return entity != null && entity.isValid(); }
public String getPlayerName() { return data.getPlayerName(); }
public int getScore() { return data.getScore(); }
}

View file

@ -5,7 +5,17 @@ import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Records a player's movement as a sequence of {@link GhostFrame}s.
*
* <p>Bugs fixed vs original:
* <ul>
* <li>{@code stopRecording} took only {@code playerName} — now also takes {@code playerUuid}
* so {@link GhostData} can store it and {@link GhostManager} can name files by UUID.</li>
* </ul>
*/
public class GhostRecorder {
private static final double MIN_MOVEMENT = 0.1;
@ -18,36 +28,34 @@ public class GhostRecorder {
public void startRecording(@NotNull Location startLocation) {
frames.clear();
lastLocation = startLocation.clone();
startTime = System.currentTimeMillis();
recording = true;
startTime = System.currentTimeMillis();
recording = true;
frames.add(new GhostFrame(0, startLocation.clone()));
}
public void recordFrame(@NotNull Location currentLocation) {
if (!recording) {
return;
}
if (lastLocation.distance(currentLocation) < MIN_MOVEMENT) {
return;
}
if (!recording) return;
if (lastLocation.distance(currentLocation) < MIN_MOVEMENT) return;
long timestamp = System.currentTimeMillis() - startTime;
frames.add(new GhostFrame(timestamp, currentLocation.clone()));
lastLocation = currentLocation.clone();
}
public GhostData stopRecording(String playerName, String playerUUID, int score) {
/**
* Stops recording and returns the completed ghost data.
*
* @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.
*/
public GhostData stopRecording(@NotNull UUID playerUuid,
@NotNull String playerName,
int score) {
recording = false;
return new GhostData(playerName, playerUUID, score, frames);
return new GhostData(playerUuid, playerName, score, frames);
}
public boolean isRecording() {
return recording;
}
public int getFrameCount() {
return frames.size();
}
public boolean isRecording() { return recording; }
public int getFrameCount() { return frames.size(); }
}