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

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