fix jump slab
This commit is contained in:
parent
d9eb747c1b
commit
978f739045
43 changed files with 5685 additions and 832 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("[/\\\\.]", "_");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue