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

@ -5,7 +5,10 @@ import dev.loki.loparkour.ghost.model.GhostFrame;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.BufferedOutputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
@ -34,18 +37,20 @@ class GhostDataTest {
assertEquals(uuid, data.getPlayerUuid());
assertEquals(42, data.getScore());
assertEquals(2, data.getFrames().size());
assertTrue(data.getTimestamp() > 0, "Run timestamp should be set");
}
@Test
void testSaveAndLoad(@TempDir Path tempDir) throws IOException {
// Create test data
// Create test data with an explicit completion timestamp (v4 format)
List<GhostFrame> frames = new ArrayList<>();
frames.add(new GhostFrame(0, 0, 100, 0, 0, 0));
frames.add(new GhostFrame(500, 1, 100, 0, 45, 0));
frames.add(new GhostFrame(1000, 2, 100, 0, 90, 0));
UUID uuid = UUID.randomUUID();
GhostData original = new GhostData(uuid, "Player1", 100, frames);
long timestamp = 1_700_000_000_000L;
GhostData original = new GhostData(uuid, "Player1", 100, timestamp, "world", frames);
// Save to file
File file = tempDir.resolve("test.ghost").toFile();
@ -61,6 +66,9 @@ class GhostDataTest {
assertEquals(original.getPlayerName(), loaded.getPlayerName());
assertEquals(original.getPlayerUuid(), loaded.getPlayerUuid());
assertEquals(original.getScore(), loaded.getScore());
assertEquals(original.getTimestamp(), loaded.getTimestamp());
assertEquals(original.getWorldName(), loaded.getWorldName());
assertEquals("world", loaded.getWorldName());
assertEquals(original.getFrames().size(), loaded.getFrames().size());
// Verify frames
@ -187,4 +195,29 @@ class GhostDataTest {
assertEquals(1, data.getFrames().size(),
"GhostData should create defensive copy of frames list");
}
@Test
void testOldVersionFormatRejected(@TempDir Path tempDir) throws IOException {
// Simulate an old v3 file: version header 3 followed by the v3 field layout
File file = tempDir.resolve("old-v3.ghost").toFile();
UUID uuid = UUID.randomUUID();
try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new FileOutputStream(file)))) {
out.writeInt(3); // v3 version header
out.writeUTF(uuid.toString());
out.writeUTF("OldPlayer");
out.writeInt(50); // score
out.writeLong(1_700_000_000_000L); // timestamp
out.writeInt(1); // frame count
out.writeLong(0); // frame timestamp
out.writeDouble(0);
out.writeDouble(100);
out.writeDouble(0);
out.writeFloat(0);
out.writeFloat(0);
}
assertNull(GhostData.loadFromFile(file),
"pre-v4 files must be rejected as stale (return null)");
}
}

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