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
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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue