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:
parent
4c255111f0
commit
8c8046a4f5
30 changed files with 1027 additions and 227 deletions
|
|
@ -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)");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
114
src/test/java/dev/loki/loparkour/ghost/GhostManagerTest.java
Normal file
114
src/test/java/dev/loki/loparkour/ghost/GhostManagerTest.java
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue