diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index 64a1fbe47..4fda22913 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java @@ -1342,8 +1342,54 @@ private void loadVersionFile() { advancedCoreBuildNumber = conf.getString("buildnumber", "NOTSET"); } + private volatile com.bencodez.advancedcore.api.user.presence.PlayerPresenceService playerPresenceService; + private com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup playerPresenceLookup; + + /** + * Read checked history for an already resolved server UUID, without name/profile + * resolution or storage on the caller. UNKNOWN is retained by display filters. + * Completion may run on a worker; schedule platform effects explicitly. + */ + public java.util.concurrent.CompletionStage + checkPlayerPresenceAsync(java.util.UUID identity) { + var service = playerPresenceService; + return service == null + ? java.util.concurrent.CompletableFuture.completedStage( + com.bencodez.advancedcore.api.user.presence.PlayerPresence.UNKNOWN) + : service.checkAsync(identity); + } + + /** Check verified current/historical UUIDs; unresolved aliases must not be omitted. */ + public java.util.concurrent.CompletionStage + checkPlayerPresenceIdentitiesAsync(java.util.Collection identities) { + var service = playerPresenceService; + return service == null + ? java.util.concurrent.CompletableFuture.completedStage( + com.bencodez.advancedcore.api.user.presence.PlayerPresence.UNKNOWN) + : service.checkIdentitiesAsync(identities); + } + + /** Also require proof that no unmapped offline identity can retain earlier history. */ + public java.util.concurrent.CompletionStage + checkPlayerPresenceIdentitiesAsync(java.util.Collection identities, boolean completeOnlineHistory) { + var service = playerPresenceService; + return service == null + ? java.util.concurrent.CompletableFuture.completedStage( + com.bencodez.advancedcore.api.user.presence.PlayerPresence.UNKNOWN) + : service.checkIdentitiesAsync(identities, completeOnlineHistory); + } + @Override public void onDisable() { + var presence = playerPresenceService; + playerPresenceService = null; + if (presence != null) presence.close(); + var lookup = playerPresenceLookup; + playerPresenceLookup = null; + if (lookup != null) { + lookup.close(); + org.bukkit.event.HandlerList.unregisterAll(lookup); + } new com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle(this).shutdown(); javaPlugin = null; } @@ -1358,6 +1404,23 @@ public void onEnable() { loginTimer = runtimeExecutors.getLoginTimer(); advancedCoreCommandLoader = CommandLoader.getInstance(); inventoryTimer = runtimeExecutors.getInventoryTimer(); + var previousPresence = playerPresenceService; + if (previousPresence != null) previousPresence.close(); + playerPresenceService = null; + var previousLookup = playerPresenceLookup; + if (previousLookup != null) { + previousLookup.close(); + org.bukkit.event.HandlerList.unregisterAll(previousLookup); + } + var presenceServer = getServer(); + var presenceDirectory = com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup + .capturePrimaryHistoryDirectory(presenceServer); + var presenceLookup = new com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup(presenceDirectory); + presenceLookup.capturePlayerSaving(presenceServer); + playerPresenceLookup = presenceLookup; + registerEvents(presenceLookup); + presenceLookup.captureJoinedPlayers(presenceServer); + playerPresenceService = new com.bencodez.advancedcore.api.user.presence.PlayerPresenceService(timer, presenceLookup); onPreLoad(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BatchPlayerPresenceLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BatchPlayerPresenceLookup.java new file mode 100644 index 000000000..bd4b72c56 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BatchPlayerPresenceLookup.java @@ -0,0 +1,10 @@ +package com.bencodez.advancedcore.api.user.presence; + +import java.util.List; +import java.util.UUID; + +/** Internal cohort optimization; older/custom public lookup implementations remain compatible. */ +interface BatchPlayerPresenceLookup extends PlayerPresenceLookup { + record Query(UUID identity, boolean completeOnlineHistory) { } + List checkBatch(List queries) throws Exception; +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java new file mode 100644 index 000000000..8b7b34b49 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/BukkitPlayerPresenceLookup.java @@ -0,0 +1,354 @@ +package com.bencodez.advancedcore.api.user.presence; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.HashSet; +import java.util.UUID; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.nio.file.attribute.FileTime; +import org.bukkit.Server; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +/** Read-only retained server history, with owner-observed joins in this generation. */ +public final class BukkitPlayerPresenceLookup implements BatchPlayerPresenceLookup, Listener, AutoCloseable { + private static final int MAX_DIRECTORY_ENTRIES = 1_000_000; + private static final int MAX_RECOVERY_IDENTITIES = 65536; + private static final Set CANONICAL_SUFFIXES = Set.of(".dat", ".dat_old", ".dat_new", ".dat.tmp", + ".dat.offline-read", ".dat_old.offline-read"); + private record ScopeIdentity(Object fileKey, FileTime created) { } + private record RecoveryIndex(ScopeIdentity scope, FileTime modified, Set identities, boolean offlineHistory) { } + private record RecoverySnapshot(RecoveryIndex index, Set retainedCandidates) { } + private static final RecoveryIndex RETIRED = new RecoveryIndex(null, null, Set.of(), true); + private final AtomicReference recoveryIndex = new AtomicReference<>(); + private final Object recoveryScan = new Object(); + @FunctionalInterface interface AttributeReader { + BasicFileAttributes read(Path path) throws IOException; + } + @FunctionalInterface interface DirectoryReader { + java.nio.file.DirectoryStream open(Path path) throws IOException; + } + private final DirectoryReader directoryReader; + private final AttributeReader attributeReader; + private final Path historyDirectory; + private final HashSet joined = new HashSet<>(); + private boolean negativeUnavailable; + private boolean observedOfflineHistory; + private boolean closed; + + /** The caller must supply the actual server-owned playerdata directory. */ + public BukkitPlayerPresenceLookup(Path historyDirectory) { + this(historyDirectory, path -> Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS)); + } + + BukkitPlayerPresenceLookup(Path historyDirectory, AttributeReader attributeReader) { + this(historyDirectory, attributeReader, Files::newDirectoryStream); + } + + BukkitPlayerPresenceLookup(Path historyDirectory, AttributeReader attributeReader, DirectoryReader directoryReader) { + this.attributeReader = java.util.Objects.requireNonNull(attributeReader); + this.directoryReader = java.util.Objects.requireNonNull(directoryReader); + Path candidate = historyDirectory == null ? null : historyDirectory.toAbsolutePath(); + if (candidate != null) for (Path part : candidate) { + if (part.toString().equals("..")) { candidate = null; break; } + } + this.historyDirectory = candidate == null ? null : candidate.normalize(); + } + + /** Metadata only, on the platform owner. Never infer storage from a dimension path. */ + public static Path capturePrimaryHistoryDirectory(Server server) { + if (server == null || !server.getClass().getName().matches( + "org\\.bukkit\\.craftbukkit(?:\\.v[0-9]+_[0-9]+_R[0-9]+)?\\.CraftServer")) return null; + try { + try { + Object directory = server.getClass().getMethod("getLevelDirectory").invoke(server); + return directory instanceof Path path ? path.resolve("playerdata") : null; + } catch (NoSuchMethodException olderApi) { + Object players = server.getClass().getMethod("getHandle").invoke(server); + Object storage = players.getClass().getField("playerIo").get(players); + Object directory = storage.getClass().getMethod("getPlayerDir").invoke(storage); + return directory instanceof File file ? file.toPath() : null; + } + } catch (ReflectiveOperationException | RuntimeException | LinkageError unavailable) { + return null; + } + } + + /** Capture in-memory platform saving policy on the owner before publishing the service. */ + public void capturePlayerSaving(Server server) { + boolean unavailable = true; + try { + var config = server == null || server.spigot() == null ? null : server.spigot().getConfig(); + if (config != null) { + Object disabled = config.get("players.disable-saving"); + // An absent key uses the platform's false default; malformed metadata is uncertain. + unavailable = disabled != null && !Boolean.FALSE.equals(disabled); + } + } catch (RuntimeException | LinkageError unsupported) { + // Unsupported/unreadable policy cannot establish that absent files imply absent history. + } + if (unavailable) synchronized (joined) { negativeUnavailable = true; } + } + + /** Capture UUID-only online metadata on the platform owner after registering this listener. */ + public void captureJoinedPlayers(Server server) { + try { + if (server == null) throw new IllegalStateException("Unavailable server"); + int examined = 0; + for (var player : server.getOnlinePlayers()) { + if (++examined > 65536) throw new IllegalStateException("Join evidence limit"); + observeJoined(player.getUniqueId()); + } + } catch (RuntimeException | LinkageError unavailable) { + synchronized (joined) { negativeUnavailable = true; } + } + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onJoin(PlayerJoinEvent event) { + try { observeJoined(event.getPlayer().getUniqueId()); } + catch (RuntimeException | LinkageError unavailable) { + synchronized (joined) { negativeUnavailable = true; } + } + } + + private void observeJoined(UUID identity) { + synchronized (joined) { + if (closed) return; + if (identity != null && !isOnlineIdentity(identity)) observedOfflineHistory = true; + if (identity == null || (!joined.contains(identity) && joined.size() >= 65536)) + negativeUnavailable = true; + else joined.add(identity); + } + } + + private PlayerPresence liveEvidence(UUID identity) { + synchronized (joined) { + if (closed) return PlayerPresence.UNKNOWN; + return joined.contains(identity) ? PlayerPresence.JOINED : null; + } + } + + @Override + public PlayerPresence check(UUID identity) { + return check(identity, false); + } + + @Override + public PlayerPresence checkOnlineHistory(UUID identity) { + return check(identity, true); + } + + private PlayerPresence check(UUID identity, boolean completeOnlineHistory) { + return check(identity, completeOnlineHistory, true); + } + + private PlayerPresence check(UUID identity, boolean completeOnlineHistory, boolean scanRecovery) { + if (identity == null) return PlayerPresence.UNKNOWN; + PlayerPresence live = liveEvidence(identity); + if (live != null) return live; + if (historyDirectory == null) return PlayerPresence.UNKNOWN; + try { + if (!unlinkedScope()) return PlayerPresence.UNKNOWN; + BasicFileAttributes before = attributes(historyDirectory); + ScopeIdentity scope = identity(before); + if (!before.isDirectory() || before.isOther() || scope == null) return PlayerPresence.UNKNOWN; + boolean present = false, valid = false, failed = false; + for (String suffix : new String[] { ".dat", ".dat_old", ".dat_new", ".dat.tmp", + ".dat.offline-read", ".dat_old.offline-read" }) { + Path file = historyDirectory.resolve(identity + suffix); + try { + BasicFileAttributes record = attributes(file); + present = true; + ScopeIdentity recordIdentity = identity(record); + if (!record.isRegularFile() || record.isOther() || recordIdentity == null + || record.size() > PlayerHistoryFormat.MAX_COMPRESSED) continue; + byte[] bytes; + try (var input = FileChannel.open(file, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + ByteBuffer buffer = ByteBuffer.allocate((int) record.size() + 1); + while (buffer.hasRemaining() && input.read(buffer) != -1) { } + if (buffer.position() != record.size()) continue; + bytes = java.util.Arrays.copyOf(buffer.array(), buffer.position()); + } + BasicFileAttributes after = attributes(file); + if (!recordIdentity.equals(identity(after)) || !after.isRegularFile() || after.isOther() + || record.size() != after.size() + || !record.lastModifiedTime().equals(after.lastModifiedTime())) continue; + try { + if (PlayerHistoryFormat.valid(bytes)) { valid = true; break; } + } catch (IOException invalid) { /* Malformed history stays unknown, never absent. */ } + } catch (NoSuchFileException absent) { + // Only a confirmed missing-file result can support a negative. + } catch (IOException unavailable) { + failed = true; + // A sibling may still establish positive retained history. + } + } + // Native saves use UUID-random.dat; their existence prevents absence + // proof even if a crash left no canonical record. Never load or alter them. + boolean recovery = scanRecovery && !valid && !present && !failed && hasRecovery(identity, before, completeOnlineHistory); + BasicFileAttributes after = attributes(historyDirectory); + if (!unlinkedScope() || !after.isDirectory() || after.isOther() || !scope.equals(identity(after))) + return PlayerPresence.UNKNOWN; + live = liveEvidence(identity); + if (live != null) return live; + if (valid) return PlayerPresence.JOINED; + synchronized (joined) { + if (closed || negativeUnavailable || present || failed || recovery + || (completeOnlineHistory && (observedOfflineHistory || !isSupportedOnlineAlias(identity))) + || !before.lastModifiedTime().equals(after.lastModifiedTime())) return PlayerPresence.UNKNOWN; + return joined.contains(identity) ? PlayerPresence.JOINED : PlayerPresence.NEVER_JOINED; + } + } catch (IOException | RuntimeException unavailable) { + return PlayerPresence.UNKNOWN; + } + } + + private boolean hasRecovery(UUID identity, BasicFileAttributes scope, boolean completeOnlineHistory) throws IOException { + // Even a cached absence index needs currently readable directory scope. + try (var ignored = directoryReader.open(historyDirectory)) { } + synchronized (recoveryScan) { + ScopeIdentity scopeIdentity = identity(scope); + if (scopeIdentity == null) return true; + RecoveryIndex cached = recoveryIndex.get(); + if (cached == RETIRED) return true; + // Cached retained evidence can preserve uncertainty. Cached absence + // cannot certify current entries even with a key: times may collide. + if (scopeIdentity.fileKey() != null && cached != null && cached.scope().equals(scopeIdentity) + && cached.modified().equals(scope.lastModifiedTime()) + && (cached.identities().contains(identity) || (completeOnlineHistory && cached.offlineHistory()))) + return true; + RecoverySnapshot scanned = scanRecovery(scope, Set.of(identity)); + return scanned == null || scanned.retainedCandidates().contains(identity) + || scanned.index().identities().contains(identity) + || (completeOnlineHistory && scanned.index().offlineHistory()); + } + } + + /** Called under recoveryScan; no negative result is retained for a later cohort. */ + private RecoverySnapshot scanRecovery(BasicFileAttributes scope, Set candidates) throws IOException { + RecoveryIndex cached = recoveryIndex.get(); + if (cached == RETIRED) return null; + ScopeIdentity scopeIdentity = identity(scope); + if (scopeIdentity == null) return null; + HashSet identities = new HashSet<>(); + HashSet retainedCandidates = new HashSet<>(); + boolean offlineHistory = false; + int examined = 0; + try (var entries = directoryReader.open(historyDirectory)) { + for (Path entry : entries) { + if (++examined > MAX_DIRECTORY_ENTRIES) return null; + String name = entry.getFileName().toString(); + if (name.length() < 40) continue; + String suffix = name.substring(36).toLowerCase(java.util.Locale.ROOT); + boolean temporary = suffix.startsWith("-") && suffix.endsWith(".dat") + && suffix.length() > 5; + boolean corrupted = suffix.startsWith("_corrupted_") + && (suffix.endsWith(".dat") || suffix.endsWith(".dat_old")); + UUID record; + try { record = UUID.fromString(name.substring(0, 36)); } + catch (IllegalArgumentException unrelated) { continue; } + if (!record.toString().equalsIgnoreCase(name.substring(0, 36))) continue; + // Native offline UUIDs can retain history under an earlier name. + // Their ownership cannot be inferred from the current name cache. + if (!isOnlineIdentity(record) && (suffix.startsWith(".dat") || temporary || corrupted)) + offlineHistory = true; + boolean canonical = CANONICAL_SUFFIXES.contains(suffix); + if (candidates.contains(record) && (canonical || temporary || corrupted)) retainedCandidates.add(record); + if (!temporary && !corrupted) continue; + identities.add(record); + if (identities.size() > MAX_RECOVERY_IDENTITIES) return null; + } + } + BasicFileAttributes after = attributes(historyDirectory); + if (!unlinkedScope() || !after.isDirectory() || after.isOther() || !scopeIdentity.equals(identity(after)) + || !scope.lastModifiedTime().equals(after.lastModifiedTime())) return null; + RecoveryIndex scanned = new RecoveryIndex(scopeIdentity, scope.lastModifiedTime(), Set.copyOf(identities), offlineHistory); + // close never waits for the scan and a retired scan cannot repopulate it. + if (!recoveryIndex.compareAndSet(cached, scanned)) return null; + return new RecoverySnapshot(scanned, Set.copyOf(retainedCandidates)); + } + + @Override + public java.util.List checkBatch(java.util.List queries) { + synchronized (joined) { + if (closed) return java.util.Collections.nCopies(queries.size(), PlayerPresence.UNKNOWN); + } + var results = new java.util.ArrayList(queries.size()); + var candidates = new HashSet(); + BasicFileAttributes before = null; + try { + if (historyDirectory != null && unlinkedScope()) before = attributes(historyDirectory); + } catch (IOException | RuntimeException unavailable) { /* Positives may still be available. */ } + for (var query : queries) { + PlayerPresence evidence = check(query.identity(), query.completeOnlineHistory(), false); + results.add(evidence); + if (evidence == PlayerPresence.NEVER_JOINED) candidates.add(query.identity()); + } + if (candidates.isEmpty()) return results; + RecoverySnapshot snapshot = null; + try { + if (before != null && before.isDirectory() && !before.isOther()) { + synchronized (recoveryScan) { snapshot = scanRecovery(before, candidates); } + } + } catch (IOException | RuntimeException unavailable) { /* All provisional negatives stay unknown. */ } + for (int index = 0; index < queries.size(); index++) { + if (results.get(index) != PlayerPresence.NEVER_JOINED) continue; + var query = queries.get(index); + PlayerPresence live = liveEvidence(query.identity()); + if (live != null) { results.set(index, live); continue; } + synchronized (joined) { + if (!closed && joined.contains(query.identity())) results.set(index, PlayerPresence.JOINED); + else if (closed || negativeUnavailable || snapshot == null + || snapshot.retainedCandidates().contains(query.identity()) + || snapshot.index().identities().contains(query.identity()) + || (query.completeOnlineHistory() && (observedOfflineHistory || snapshot.index().offlineHistory()))) + results.set(index, PlayerPresence.UNKNOWN); + } + } + return results; + } + + private static ScopeIdentity identity(BasicFileAttributes attributes) { + Object key = attributes.fileKey(); + FileTime created = attributes.creationTime(); + // Some providers (including Windows) omit keys. An unavailable birth time + // cannot support the fallback scope check; retained results stay unknown. + if (key == null && (created == null || created.equals(FileTime.fromMillis(0)))) return null; + return new ScopeIdentity(key, created); + } + + private static boolean isOnlineIdentity(UUID identity) { + return identity.version() == 4 && identity.variant() == 2; + } + + private static boolean isSupportedOnlineAlias(UUID identity) { + return identity.variant() == 2 && (identity.version() == 3 || identity.version() == 4); + } + + private boolean unlinkedScope() { + for (Path part = historyDirectory; part != null; part = part.getParent()) + if (Files.isSymbolicLink(part)) return false; + return true; + } + + private BasicFileAttributes attributes(Path path) throws IOException { + return attributeReader.read(path); + } + + @Override public void close() { + synchronized (joined) { closed = true; joined.clear(); } + recoveryIndex.set(RETIRED); + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryFormat.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryFormat.java new file mode 100644 index 000000000..f212591a4 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryFormat.java @@ -0,0 +1,122 @@ +package com.bencodez.advancedcore.api.user.presence; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.util.HashSet; +import java.util.zip.CRC32; +import java.util.zip.DataFormatException; +import java.util.zip.Inflater; + +/** Bounded structural inspection only: no server loader, data fixing or writes. */ +final class PlayerHistoryFormat { + static final int MAX_COMPRESSED = 1024 * 1024; + private static final int MAX_EXPANDED = 8 * 1024 * 1024; + private int nodes; + + static boolean valid(byte[] compressed) throws IOException { + byte[] payload = expand(compressed); + try (var input = new DataInputStream(new ByteArrayInputStream(payload))) { + if (input.readUnsignedByte() != 10) return false; + input.readUTF(); + new PlayerHistoryFormat().tag(input, 10, 0); + return input.read() == -1; + } + } + + private static byte[] expand(byte[] bytes) throws IOException { + if (bytes.length < 18 || bytes.length > MAX_COMPRESSED + || (bytes[0] & 255) != 31 || (bytes[1] & 255) != 139 || bytes[2] != 8) + throw new IOException("Invalid history compression"); + int flags = bytes[3] & 255; + if ((flags & 224) != 0) throw new IOException("Invalid history flags"); + int offset = 10; + if ((flags & 4) != 0) { + if (offset + 2 > bytes.length - 8) throw new IOException("Truncated history header"); + int length = (bytes[offset] & 255) | ((bytes[offset + 1] & 255) << 8); + offset += 2 + length; + } + for (int flag : new int[] {8, 16}) if ((flags & flag) != 0) { + boolean terminated = false; + while (offset < bytes.length - 8) if (bytes[offset++] == 0) { terminated = true; break; } + if (!terminated) + throw new IOException("Truncated history header"); + } + if ((flags & 2) != 0) { + if (offset + 2 > bytes.length - 8) throw new IOException("Truncated history header"); + var headerCrc = new CRC32(); headerCrc.update(bytes, 0, offset); + int expected = (bytes[offset] & 255) | ((bytes[offset + 1] & 255) << 8); + if ((headerCrc.getValue() & 65535) != expected) throw new IOException("Invalid history header checksum"); + offset += 2; + } + if (offset > bytes.length - 8) throw new IOException("Truncated history frame"); + var inflater = new Inflater(true); + try { + inflater.setInput(bytes, offset, bytes.length - offset); + var output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + while (!inflater.finished()) { + int count = inflater.inflate(buffer); + if (count == 0 && !inflater.finished()) throw new IOException("Incomplete history frame"); + if (output.size() > MAX_EXPANDED - count) throw new IOException("History expansion limit"); + output.write(buffer, 0, count); + } + int trailer = bytes.length - inflater.getRemaining(); + if (trailer != bytes.length - 8) throw new IOException("Adjacent history frame data"); + byte[] payload = output.toByteArray(); + var crc = new CRC32(); crc.update(payload); + if (littleInt(bytes, trailer) != crc.getValue() || littleInt(bytes, trailer + 4) != payload.length) + throw new IOException("Invalid history checksum"); + return payload; + } catch (DataFormatException invalid) { + throw new IOException("Invalid history deflate", invalid); + } finally { + inflater.end(); + } + } + + private static long littleInt(byte[] data, int offset) { + long result = 0; + for (int index = 0; index < 4; index++) result |= (long) (data[offset + index] & 255) << (8 * index); + return result; + } + + private void tag(DataInputStream input, int type, int depth) throws IOException { + if (depth > 64 || ++nodes > 100000) throw new IOException("History structure limit"); + switch (type) { + case 1 -> input.readByte(); + case 2 -> input.readShort(); + case 3, 5 -> input.readInt(); + case 4, 6 -> input.readLong(); + case 7 -> array(input, 1); + case 8 -> input.readUTF(); + case 9 -> { + int element = input.readUnsignedByte(); + int count = input.readInt(); + if (count < 0 || count > 100000 || element > 12 || (element == 0 && count != 0)) + throw new IOException("Invalid history list"); + for (int index = 0; index < count; index++) tag(input, element, depth + 1); + } + case 10 -> { + var names = new HashSet(); + int child; + while ((child = input.readUnsignedByte()) != 0) { + String name = input.readUTF(); + if (!names.add(name)) throw new IOException("Duplicate history name"); + tag(input, child, depth + 1); + } + } + case 11 -> array(input, 4); + case 12 -> array(input, 8); + default -> throw new IOException("Invalid history tag"); + } + } + + private void array(DataInputStream input, int width) throws IOException { + int count = input.readInt(); + if (count < 0 || count > MAX_EXPANDED / width || (long) count * width > input.available()) + throw new IOException("Invalid history array"); + input.skipNBytes((long) count * width); + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresence.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresence.java new file mode 100644 index 000000000..250fbaec9 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresence.java @@ -0,0 +1,8 @@ +package com.bencodez.advancedcore.api.user.presence; + +/** Checked server history; plugin user-row existence is not server-history evidence. */ +public enum PlayerPresence { + JOINED, + NEVER_JOINED, + UNKNOWN +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceLookup.java new file mode 100644 index 000000000..5aced2d9e --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceLookup.java @@ -0,0 +1,22 @@ +package com.bencodez.advancedcore.api.user.presence; + +import java.util.UUID; + +/** + * A read-only lookup of an already resolved identity on a persistence worker. + * Implementations must return UNKNOWN for incomplete identity/history evidence. + * Absence from a plugin's storage is insufficient evidence for NEVER_JOINED. + */ +@FunctionalInterface +public interface PlayerPresenceLookup { + PlayerPresence check(UUID identity) throws Exception; + + /** + * Additionally certify absence of unmapped offline history for an online identity. + * Older providers retain positive evidence but cannot certify the wider scope. + */ + default PlayerPresence checkOnlineHistory(UUID identity) throws Exception { + PlayerPresence evidence = check(identity); + return evidence == PlayerPresence.NEVER_JOINED ? PlayerPresence.UNKNOWN : evidence; + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java new file mode 100644 index 000000000..38e74c730 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceService.java @@ -0,0 +1,208 @@ +package com.bencodez.advancedcore.api.user.presence; + +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.IdentityHashMap; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; + +/** + * Bounded asynchronous checked history. The supplied executor belongs to the + * existing persistence runtime; this service creates no worker or storage. + * Completions can run on the worker or the requesting/retiring thread. Callers + * must schedule platform effects explicitly, never attach blocking callbacks. + */ +public final class PlayerPresenceService implements AutoCloseable { + private static final int MAX_PENDING = 128; + private final Executor worker; + private final PlayerPresenceLookup lookup; + private final Object admission = new Object(); + private final IdentityHashMap, Boolean> pending = new IdentityHashMap<>(); + private record BatchRequest(BatchPlayerPresenceLookup.Query query, CompletableFuture result) { } + private final ArrayDeque queuedBatch = new ArrayDeque<>(); + private boolean batchScheduled; + private boolean closed; + + public PlayerPresenceService(Executor worker, PlayerPresenceLookup lookup) { + this.worker = Objects.requireNonNull(worker, "worker"); + this.lookup = Objects.requireNonNull(lookup, "lookup"); + } + + /** No name/UUID resolution, storage or Bukkit call is performed by this method. */ + public CompletionStage checkAsync(UUID identity) { + CompletableFuture result = new CompletableFuture<>(); + if (identity == null) return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + synchronized (admission) { + if (closed || pending.size() >= MAX_PENDING) + return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + pending.put(result, Boolean.TRUE); + } + submit(identity, result); + return result.minimalCompletionStage(); + } + + /** Check up to eight verified current/historical UUIDs for the same identity. */ + public CompletionStage checkIdentitiesAsync(Collection identities) { + return checkIdentitiesAsync(identities, false); + } + + /** Require retained offline-history scope proof when current names cannot enumerate old migrations. */ + public CompletionStage checkIdentitiesAsync(Collection identities, + boolean requireCompleteOnlineHistory) { + LinkedHashSet distinct = new LinkedHashSet<>(); + if (identities == null) return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + try { + int examined = 0; + for (UUID identity : identities) { + // Bound iteration too: a very large duplicate collection isn't free. + if (++examined > 8 || identity == null) + return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + distinct.add(identity); + } + } catch (RuntimeException unavailable) { + return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + } + if (distinct.isEmpty()) return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + CompletableFuture aggregate = new CompletableFuture<>(); + var results = new ArrayList>(distinct.size()); + synchronized (admission) { + if (closed || MAX_PENDING - pending.size() < 1 + distinct.size()) + return CompletableFuture.completedStage(PlayerPresence.UNKNOWN); + // Reserve the aggregate and every child before scheduling any lookup. + // Concurrent requests cannot turn an admitted family into partial work. + pending.put(aggregate, Boolean.TRUE); + for (UUID ignored : distinct) { + var child = new CompletableFuture(); + pending.put(child, Boolean.TRUE); + results.add(child); + } + } + CompletableFuture.allOf(results.toArray(CompletableFuture[]::new)).thenRun(() -> { + boolean unknown = false; + for (var result : results) { + PlayerPresence evidence = result.join(); // All reads have already completed. + if (evidence == PlayerPresence.JOINED) { + finish(aggregate, PlayerPresence.JOINED); + return; + } + unknown |= evidence == PlayerPresence.UNKNOWN; + } + finish(aggregate, unknown ? PlayerPresence.UNKNOWN : PlayerPresence.NEVER_JOINED); + }); + int index = 0; + for (UUID identity : distinct) submit(identity, results.get(index++), requireCompleteOnlineHistory); + return aggregate.minimalCompletionStage(); + } + + private void submit(UUID identity, CompletableFuture result) { + submit(identity, result, false); + } + + private void submit(UUID identity, CompletableFuture result, boolean completeOnlineHistory) { + if (lookup instanceof BatchPlayerPresenceLookup batch) { + var request = new BatchRequest(new BatchPlayerPresenceLookup.Query(identity, completeOnlineHistory), result); + try { + // Registration must reach its own place on the shared worker. + // Joining a previously queued read must never overtake persistence. + worker.execute(() -> registerBatch(request, batch)); + } catch (RuntimeException rejected) { + finish(result, PlayerPresence.UNKNOWN); + } + return; + } + try { + worker.execute(() -> completeLookup(identity, result, completeOnlineHistory)); + } catch (RuntimeException rejected) { + finish(result, PlayerPresence.UNKNOWN); + } + } + + private void registerBatch(BatchRequest request, BatchPlayerPresenceLookup batch) { + synchronized (admission) { + if (closed || !pending.containsKey(request.result())) return; + queuedBatch.add(request); + if (batchScheduled) return; + batchScheduled = true; + } + try { + // Yield behind already queued storage and registrations before reading. + worker.execute(() -> completeBatch(batch)); + } catch (RuntimeException rejected) { + ArrayList retiring; + synchronized (admission) { + retiring = new ArrayList<>(queuedBatch); + queuedBatch.clear(); + batchScheduled = false; + } + for (var entry : retiring) finish(entry.result(), PlayerPresence.UNKNOWN); + } + } + + private void completeBatch(BatchPlayerPresenceLookup batch) { + ArrayList cohort; + synchronized (admission) { + if (closed) return; + cohort = new ArrayList<>(queuedBatch); + queuedBatch.clear(); + batchScheduled = false; + } + List evidence = null; + try { + evidence = batch.checkBatch(cohort.stream().map(BatchRequest::query).toList()); + if (evidence == null || evidence.size() != cohort.size()) evidence = null; + } catch (Exception failed) { + if (failed instanceof InterruptedException) Thread.currentThread().interrupt(); + } + for (int index = 0; index < cohort.size(); index++) { + PlayerPresence result = evidence == null ? null : evidence.get(index); + finish(cohort.get(index).result(), result == null ? PlayerPresence.UNKNOWN : result); + } + } + + private void completeLookup(UUID identity, CompletableFuture result, boolean completeOnlineHistory) { + synchronized (admission) { + // Queued retired work never touches the provider or replacement state. + if (closed || !pending.containsKey(result)) return; + } + PlayerPresence evidence; + try { + evidence = completeOnlineHistory ? lookup.checkOnlineHistory(identity) : lookup.check(identity); + if (evidence == null) evidence = PlayerPresence.UNKNOWN; + } catch (Exception failed) { + if (failed instanceof InterruptedException) Thread.currentThread().interrupt(); + evidence = PlayerPresence.UNKNOWN; + } + finish(result, evidence); + } + + private void finish(CompletableFuture result, PlayerPresence evidence) { + synchronized (admission) { + if (pending.remove(result) == null) return; + // Publish the terminal result before retirement can acquire admission. + // Callbacks are reentrant and, by contract, must never block. + if (closed) evidence = PlayerPresence.UNKNOWN; + result.complete(evidence); + } + } + + /** Retire this generation without waiting for or shutting down its shared worker. */ + @Override + public void close() { + ArrayList> retiring; + synchronized (admission) { + if (closed) return; + closed = true; + retiring = new ArrayList<>(pending.keySet()); + pending.clear(); + queuedBatch.clear(); + } + for (CompletableFuture result : retiring) + result.complete(PlayerPresence.UNKNOWN); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/CompleteOnlinePresenceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/CompleteOnlinePresenceTest.java new file mode 100644 index 000000000..46a27eda2 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/CompleteOnlinePresenceTest.java @@ -0,0 +1,67 @@ +package com.bencodez.advancedcore.api.user.presence; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayDeque; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class CompleteOnlinePresenceTest { + private static final UUID USER = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + + @Test void legacyProviderKeepsPositiveAndLegacyNegativeButCannotCertifyOnlineScope() { + try (var legacy = new PlayerPresenceService(Runnable::run, id -> PlayerPresence.NEVER_JOINED)) { + assertEquals(PlayerPresence.NEVER_JOINED, legacy.checkAsync(USER).toCompletableFuture().join()); + assertEquals(PlayerPresence.UNKNOWN, legacy.checkIdentitiesAsync(List.of(USER), true).toCompletableFuture().join()); + } + try (var positive = new PlayerPresenceService(Runnable::run, id -> PlayerPresence.JOINED)) { + assertEquals(PlayerPresence.JOINED, positive.checkIdentitiesAsync(List.of(USER), true).toCompletableFuture().join()); + } + } + + @Test void completeScopeProviderRunsOnlyAfterWorkerAdmission() { + var tasks = new ArrayDeque(); var calls = new AtomicInteger(); + PlayerPresenceLookup provider = new PlayerPresenceLookup() { + public PlayerPresence check(UUID id) { fail("This operation requires the wider scope"); return null; } + public PlayerPresence checkOnlineHistory(UUID id) { calls.incrementAndGet(); return PlayerPresence.NEVER_JOINED; } + }; + try (var service = new PlayerPresenceService(tasks::add, provider)) { + var result = service.checkIdentitiesAsync(List.of(USER), true).toCompletableFuture(); + assertFalse(result.isDone()); assertEquals(0, calls.get()); + tasks.remove().run(); assertEquals(PlayerPresence.NEVER_JOINED, result.join()); assertEquals(1, calls.get()); + } + } + + @Test void verifiedHistoricalPositiveWinsOverUnmappedOnlineUncertainty() { + UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + PlayerPresenceLookup provider = new PlayerPresenceLookup() { + public PlayerPresence check(UUID id) { return id.equals(former) ? PlayerPresence.JOINED : PlayerPresence.NEVER_JOINED; } + public PlayerPresence checkOnlineHistory(UUID id) { return id.equals(former) ? PlayerPresence.JOINED : PlayerPresence.UNKNOWN; } + }; + try (var service = new PlayerPresenceService(Runnable::run, provider)) { + assertEquals(PlayerPresence.JOINED, service.checkIdentitiesAsync(List.of(USER, former), true).toCompletableFuture().join()); + } + } + + @Test void completeFamilyReservesAllChildrenAndRetiredQueueNeverTouchesProvider() { + var tasks = new ArrayDeque(); var calls = new AtomicInteger(); + PlayerPresenceLookup provider = new PlayerPresenceLookup() { + public PlayerPresence check(UUID id) { calls.incrementAndGet(); return PlayerPresence.NEVER_JOINED; } + public PlayerPresence checkOnlineHistory(UUID id) { calls.incrementAndGet(); return PlayerPresence.NEVER_JOINED; } + }; + try (var service = new PlayerPresenceService(tasks::add, provider)) { + for (int count = 0; count < 126; count++) service.checkAsync(USER); + var rejected = service.checkIdentitiesAsync(List.of(USER, UUID.randomUUID()), true).toCompletableFuture(); + assertEquals(PlayerPresence.UNKNOWN, rejected.join()); assertEquals(126, tasks.size()); + service.close(); while (!tasks.isEmpty()) tasks.remove().run(); assertEquals(0, calls.get()); + } + } + + @Test void rejectedCompleteScopeWorkerSettlesAggregateAsUnknown() { + try (var service = new PlayerPresenceService(task -> { throw new java.util.concurrent.RejectedExecutionException("synthetic"); }, + id -> PlayerPresence.NEVER_JOINED)) { + assertEquals(PlayerPresence.UNKNOWN, service.checkIdentitiesAsync(List.of(USER), true).toCompletableFuture().join()); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryStabilityTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryStabilityTest.java new file mode 100644 index 000000000..132cb9df7 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerHistoryStabilityTest.java @@ -0,0 +1,325 @@ +package com.bencodez.advancedcore.api.user.presence; + +import static org.junit.jupiter.api.Assertions.*; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.nio.file.AccessDeniedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.UUID; +import java.util.zip.GZIPOutputStream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PlayerHistoryStabilityTest { + private static final UUID USER = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + @TempDir Path directory; + + private byte[] history() throws Exception { + return history(USER); + } + + private byte[] history(UUID recordIdentity) throws Exception { + var nbt = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(nbt)) { + out.writeByte(10); out.writeUTF(""); + out.writeByte(3); out.writeUTF("DataVersion"); out.writeInt(1); + out.writeByte(11); out.writeUTF("UUID"); out.writeInt(4); + out.writeInt((int) (recordIdentity.getMostSignificantBits() >>> 32)); out.writeInt((int) recordIdentity.getMostSignificantBits()); + out.writeInt((int) (recordIdentity.getLeastSignificantBits() >>> 32)); out.writeInt((int) recordIdentity.getLeastSignificantBits()); + out.writeByte(10); out.writeUTF("bukkit"); + out.writeByte(8); out.writeUTF("lastKnownName"); out.writeUTF("Synthetic\r\n歴史"); out.writeByte(0); + out.writeByte(0); + } + var bytes = new ByteArrayOutputStream(); + try (var gzip = new GZIPOutputStream(bytes)) { gzip.write(nbt.toByteArray()); } + return bytes.toByteArray(); + } + + private static BasicFileAttributes actual(Path path) throws java.io.IOException { + return Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } + private static BasicFileAttributes withoutKey(BasicFileAttributes source) { + return metadata(source, null, source.creationTime()); + } + private static BasicFileAttributes metadata(BasicFileAttributes source, Object key, FileTime created) { + return new BasicFileAttributes() { + public FileTime lastModifiedTime() { return source.lastModifiedTime(); } + public FileTime lastAccessTime() { return source.lastAccessTime(); } + public FileTime creationTime() { return created; } + public boolean isRegularFile() { return source.isRegularFile(); } + public boolean isDirectory() { return source.isDirectory(); } + public boolean isSymbolicLink() { return source.isSymbolicLink(); } + public boolean isOther() { return source.isOther(); } + public long size() { return source.size(); } + public Object fileKey() { return key; } + }; + } + + @Test void legacyUuidAbsenceAlsoRescansKeyedScopesWithCollidingTimes() throws Exception { + BasicFileAttributes attrs = actual(directory); + BasicFileAttributes fixed = metadata(attrs, new Object(), attrs.creationTime()); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : actual(path))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + Files.createTempFile(directory, USER + "-", ".dat"); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void cachedRecoveryForOneUuidCannotCertifyAnotherUuidAbsentWithCollidingTimes() throws Exception { + BasicFileAttributes attrs = actual(directory); + BasicFileAttributes fixed = metadata(attrs, new Object(), attrs.creationTime()); + UUID other = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + Files.createTempFile(directory, USER + "-", ".dat"); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : actual(path))) { + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + Files.createTempFile(directory, other + "-", ".dat"); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(other)); + } + } + + @Test void keyedScopeWithCollidingDirectoryTimesMustDiscoverNewOfflineHistory() throws Exception { + BasicFileAttributes attrs = actual(directory); + BasicFileAttributes fixed = metadata(attrs, new Object(), attrs.creationTime()); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : actual(path))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.checkOnlineHistory(USER)); + UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + Files.write(directory.resolve(former + ".dat"), history(former)); + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + } + } + + @Test void versionFourWithNonOnlineVariantRecordCannotCertifyOnlineAbsence() throws Exception { + UUID legacy = UUID.fromString("00000000-0000-4000-0000-000000000001"); + assertEquals(4, legacy.version()); assertNotEquals(2, legacy.variant()); + Files.write(directory.resolve(legacy + ".dat"), history(legacy)); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + } + } + + @Test void versionFourNonOnlineVariantJoinCannotCertifyOnlineAbsenceBeforeSave() { + var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class); + org.mockito.Mockito.when(player.getUniqueId()).thenReturn(UUID.fromString("00000000-0000-4000-0000-000000000001")); + var event = org.mockito.Mockito.mock(org.bukkit.event.player.PlayerJoinEvent.class); + org.mockito.Mockito.when(event.getPlayer()).thenReturn(player); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + lookup.onJoin(event); + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + } + } + + @Test void unsupportedOnlineAliasRetainsPositiveButCannotCertifyAbsentHistory() throws Exception { + UUID legacy = UUID.fromString("00000000-0000-4000-0000-000000000001"); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(legacy)); + Files.write(directory.resolve(legacy + ".dat"), history(legacy)); + assertEquals(PlayerPresence.JOINED, lookup.checkOnlineHistory(legacy)); + } + } + + @Test void completeOnlineOnlyScopeCanProveAbsenceWithoutChangingLegacyApi() throws Exception { + UUID anotherOnline = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + Files.write(directory.resolve(anotherOnline + ".dat"), history(anotherOnline)); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.checkOnlineHistory(USER)); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + } + } + + @Test void formerOfflineNameBlocksOnlineNegativeWithoutGuessingOwnership() throws Exception { + UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + byte[] retained = history(former); Path record = Files.write(directory.resolve(former + ".dat"), retained); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + assertArrayEquals(retained, Files.readAllBytes(record)); + Files.write(directory.resolve(USER + ".dat"), history()); + assertEquals(PlayerPresence.JOINED, lookup.checkOnlineHistory(USER)); + } + } + + @Test void unmappedCorruptBackupAndInterruptedOfflineSavesBlockOnlineNegatives() throws Exception { + UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + for (String suffix : new String[] { ".dat_old", ".dat.offline-read", "-temporary.dat", "_corrupted_1.dat_old" }) { + Path retained = Files.write(directory.resolve(former + suffix), new byte[] {0}); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER), suffix); + assertArrayEquals(new byte[] {0}, Files.readAllBytes(retained)); + } finally { Files.delete(retained); } + } + } + + @Test void nonOnlineUuidAndCaseInsensitiveRecoveryScopeCannotBecomeOnlineAbsence() throws Exception { + UUID legacy = new UUID(0, 1); + Path retained = Files.write(directory.resolve(legacy.toString().toUpperCase(java.util.Locale.ROOT) + "-SAVE.DAT"), new byte[] {0}); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(legacy)); + assertArrayEquals(new byte[] {0}, Files.readAllBytes(retained)); + } + } + + @Test void keylessScopeWithUnchangedTimesRescansNewOfflineRecords() throws Exception { + BasicFileAttributes fixed = withoutKey(actual(directory)); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : withoutKey(actual(path)))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.checkOnlineHistory(USER)); + UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSynthetic".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + Files.write(directory.resolve(former + ".dat"), history(former)); + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + lookup.close(); assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + } + } + + @Test void observedNonOnlineJoinPreventsUnmappedAbsenceBeforeFirstSave() { + var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class); + org.mockito.Mockito.when(player.getUniqueId()).thenReturn(new UUID(0, 1)); + var event = org.mockito.Mockito.mock(org.bukkit.event.player.PlayerJoinEvent.class); + org.mockito.Mockito.when(event.getPlayer()).thenReturn(player); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + lookup.onJoin(event); + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + assertEquals(PlayerPresence.JOINED, lookup.checkOnlineHistory(player.getUniqueId())); + } + } + + @Test void completeKeylessRecordCanSupplyPositiveHistoryWithoutMutation() throws Exception { + byte[] record = history(); Path file = Files.write(directory.resolve(USER + ".dat"), record); + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> withoutKey(actual(path)))) { + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + assertArrayEquals(record, Files.readAllBytes(file)); + } + } + + @Test void keylessReadableUnchangedEmptyDirectoryCanSupplyNegativeHistory() { + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> withoutKey(actual(path)))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + } + } + + @Test void readableValidCurrentRecordSurvivesUnreadableSibling() throws Exception { + byte[] record = history(); Path file = Files.write(directory.resolve(USER + ".dat"), record); + Path sibling = Files.write(directory.resolve(USER + ".dat_old"), new byte[] {0}); + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> { + if (path.equals(sibling)) throw new AccessDeniedException("synthetic"); + return actual(path); + })) { + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + assertArrayEquals(record, Files.readAllBytes(file)); + } + } + + @Test void unreadableCurrentRecordDoesNotHideReadableBackupEvidence() throws Exception { + Path current = Files.write(directory.resolve(USER + ".dat"), new byte[] {0}); + byte[] record = history(); Path backup = Files.write(directory.resolve(USER + ".dat_old"), record); + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> { + if (path.equals(current)) throw new AccessDeniedException("synthetic"); + return actual(path); + })) { + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + assertArrayEquals(record, Files.readAllBytes(backup)); + } + } + @Test void absentKeysAndUnavailableCreationMetadataStayUnknown() throws Exception { + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> metadata(actual(path), null, FileTime.fromMillis(0)))) { + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + Files.write(directory.resolve(USER + ".dat"), history()); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void changedKeylessDirectoryCreationCannotSupplyNegativeProof() { + var reads = new java.util.concurrent.atomic.AtomicInteger(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> { + var attrs = actual(path); + FileTime created = attrs.creationTime(); + if (path.equals(directory) && reads.incrementAndGet() > 1) + created = FileTime.fromMillis(created.toMillis() + 1000); + return metadata(attrs, null, created); + })) { + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void changedKeylessRecordCreationCannotSupplyPositiveProof() throws Exception { + Path file = Files.write(directory.resolve(USER + ".dat"), history()); + var reads = new java.util.concurrent.atomic.AtomicInteger(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> { + var attrs = actual(path); FileTime created = attrs.creationTime(); + if (path.equals(file) && reads.incrementAndGet() > 1) + created = FileTime.fromMillis(created.toMillis() + 1000); + return metadata(attrs, null, created); + })) { + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void disappearingAvailableRecordKeyCannotSupplyPositiveProof() throws Exception { + Path file = Files.write(directory.resolve(USER + ".dat"), history()); + var reads = new java.util.concurrent.atomic.AtomicInteger(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> { + var attrs = actual(path); + if (path.equals(file) && reads.incrementAndGet() > 1) return withoutKey(attrs); + return attrs; + })) { + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void unreadableAbsentCandidateCannotSupplyNegativeProof() { + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> { + if (path.equals(directory.resolve(USER + ".dat"))) throw new AccessDeniedException("synthetic"); + return actual(path); + })) { + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void keylessRecoveryRescansEvenWhenDirectoryTimestampsAreUnchanged() throws Exception { + BasicFileAttributes unchangedScope = withoutKey(actual(directory)); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? unchangedScope : withoutKey(actual(path)))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + Files.createTempFile(directory, USER + "-", ".dat"); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + + @Test void keylessReplacementWithMatchingTimestampsCannotReuseAbsentRecovery() throws Exception { + BasicFileAttributes unchangedScope = withoutKey(actual(directory)); + Path displaced = directory.resolveSibling(directory.getFileName() + "-displaced"); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? unchangedScope : withoutKey(actual(path)))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + Files.move(directory, displaced); + Files.createDirectory(directory); + Files.createTempFile(directory, USER + "-", ".dat"); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } finally { + Files.deleteIfExists(displaced); + } + } + + @Test void keylessCachedRecoveryIndexRefreshesAfterDirectoryChange() throws Exception { + try (var lookup = new BukkitPlayerPresenceLookup(directory, path -> withoutKey(actual(path)))) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + Path retained = Files.createTempFile(directory, USER + "-", ".dat"); + Files.setLastModifiedTime(directory, FileTime.fromMillis(System.currentTimeMillis() + 2000)); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + Files.delete(retained); + Files.setLastModifiedTime(directory, FileTime.fromMillis(System.currentTimeMillis() + 4000)); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + lookup.close(); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + } + +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceBatchScanTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceBatchScanTest.java new file mode 100644 index 000000000..861f7b6dc --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/user/presence/PlayerPresenceBatchScanTest.java @@ -0,0 +1,204 @@ +package com.bencodez.advancedcore.api.user.presence; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PlayerPresenceBatchScanTest { + @TempDir Path directory; + private static BasicFileAttributes attributes(Path path) throws java.io.IOException { + return Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + } + @Test void admittedMissingIdentityBurstSharesOneFreshDirectoryEnumeration() throws Exception { + var scans = new AtomicInteger(); + var queued = new ArrayDeque(); + var results = new ArrayList>(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, PlayerPresenceBatchScanTest::attributes, + path -> { scans.incrementAndGet(); return Files.newDirectoryStream(path); }); + var service = new PlayerPresenceService(queued::add, lookup)) { + for (int i = 0; i < 128; i++) + results.add(service.checkAsync(new UUID(0x1234400080000000L, i))); + assertEquals(0, scans.get(), "Caller never reads history"); + while (!queued.isEmpty()) queued.remove().run(); + for (var result : results) assertEquals(PlayerPresence.NEVER_JOINED, result.toCompletableFuture().join()); + assertEquals(1, scans.get(), "One pending cohort must not enumerate once per missing player"); + } + } + @Test void aLaterRequestCannotOvertakeEarlierSharedWorkerMutation() throws Exception { + UUID first = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + UUID second = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + var queued = new ArrayDeque(); + try (var lookup = new BukkitPlayerPresenceLookup(directory); + var service = new PlayerPresenceService(queued::add, lookup)) { + var earlier = service.checkAsync(first); + queued.add(() -> { + try { Files.createTempFile(directory, second + "-", ".dat"); } + catch (java.io.IOException failure) { throw new AssertionError(failure); } + }); + var later = service.checkAsync(second); + while (!queued.isEmpty()) queued.remove().run(); + assertNotNull(earlier.toCompletableFuture().join()); + assertEquals(PlayerPresence.UNKNOWN, later.toCompletableFuture().join(), + "A read submitted after queued persistence must not use an earlier cohort's absence"); + } + } + + private static final UUID FIRST = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + private static final UUID SECOND = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + private void drain(ArrayDeque worker) { + int turns = 0; + while (!worker.isEmpty()) { assertTrue(++turns < 1024); worker.remove().run(); } + } + private BasicFileAttributes fixedDirectory() throws Exception { + BasicFileAttributes snapshot = attributes(directory); + var fixed = org.mockito.Mockito.mock(BasicFileAttributes.class, + org.mockito.AdditionalAnswers.delegatesTo(snapshot)); + org.mockito.Mockito.when(fixed.fileKey()).thenReturn(null); + return fixed; + } + @Test void laterCohortRescansKeylessDirectoryEvenWhenTimesCollide() throws Exception { + BasicFileAttributes fixed = fixedDirectory(); + var worker = new ArrayDeque(); var opens = new AtomicInteger(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : attributes(path), + path -> { opens.incrementAndGet(); return Files.newDirectoryStream(path); }); + var service = new PlayerPresenceService(worker::add, lookup)) { + var first = service.checkAsync(FIRST); drain(worker); + assertEquals(PlayerPresence.NEVER_JOINED, first.toCompletableFuture().join()); + Path recovery = Files.createTempFile(directory, FIRST + "-", ".dat"); + var later = service.checkAsync(FIRST); drain(worker); + assertEquals(PlayerPresence.UNKNOWN, later.toCompletableFuture().join()); + assertEquals(2, opens.get()); assertTrue(Files.exists(recovery)); + } + } + @Test void directQuerySeesCanonicalRecordAppearingDuringRecoveryEnumeration() throws Exception { + assertDirectRecoveryCandidates(false); + } + @Test void directOnlineHistorySeesCanonicalRecordAppearingDuringRecoveryEnumeration() throws Exception { + assertDirectRecoveryCandidates(true); + } + private void assertDirectRecoveryCandidates(boolean complete) throws Exception { + for (String suffix : java.util.List.of(".dat", ".dat_old", ".dat_new", ".dat.tmp", + ".dat.offline-read", ".dat_old.offline-read", ".DAT")) { + BasicFileAttributes fixed = fixedDirectory(); + Path retained = directory.resolve(FIRST + suffix); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : attributes(path), path -> { + Files.write(retained, new byte[] {0}); return Files.newDirectoryStream(path); + })) { + assertEquals(PlayerPresence.UNKNOWN, + complete ? lookup.checkOnlineHistory(FIRST) : lookup.check(FIRST), suffix); + assertArrayEquals(new byte[] {0}, Files.readAllBytes(retained)); + } finally { Files.deleteIfExists(retained); } + } + } + @Test void unrelatedCanonicalRecordDoesNotInvalidateDirectAbsence() throws Exception { + BasicFileAttributes fixed = fixedDirectory(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : attributes(path), path -> { + Files.write(directory.resolve(SECOND + ".dat"), new byte[] {0}); + return Files.newDirectoryStream(path); + })) { + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(FIRST)); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.checkOnlineHistory(FIRST)); + } + } + + @Test void canonicalRecordAppearingAfterPointChecksCannotBecomeNegative() throws Exception { + BasicFileAttributes fixed = fixedDirectory(); var worker = new ArrayDeque(); + Path retained = directory.resolve(FIRST + ".dat"); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> path.equals(directory) ? fixed : attributes(path), path -> { + Files.write(retained, new byte[] {0}); return Files.newDirectoryStream(path); + }); var service = new PlayerPresenceService(worker::add, lookup)) { + var result = service.checkAsync(FIRST); drain(worker); + assertEquals(PlayerPresence.UNKNOWN, result.toCompletableFuture().join()); + assertArrayEquals(new byte[] {0}, Files.readAllBytes(retained)); + } + } + @Test void sharedScanPreservesIndividualAndCompleteOnlineScope() throws Exception { + UUID former = UUID.nameUUIDFromBytes("OfflinePlayer:FormerSyntheticBatch".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + Files.write(directory.resolve(former + ".dat"), new byte[] {0}); + var worker = new ArrayDeque(); var opens = new AtomicInteger(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, PlayerPresenceBatchScanTest::attributes, + path -> { opens.incrementAndGet(); return Files.newDirectoryStream(path); }); + var service = new PlayerPresenceService(worker::add, lookup)) { + var individual = service.checkAsync(FIRST); + var complete = service.checkIdentitiesAsync(java.util.List.of(SECOND), true); + drain(worker); + assertEquals(PlayerPresence.NEVER_JOINED, individual.toCompletableFuture().join()); + assertEquals(PlayerPresence.UNKNOWN, complete.toCompletableFuture().join()); + assertEquals(1, opens.get()); + } + } + @Test void eightAliasFamilySharesOneScanAndStillReservesAggregate() throws Exception { + var worker = new ArrayDeque(); var opens = new AtomicInteger(); + var aliases = new ArrayList(); + for (int i = 0; i < 8; i++) aliases.add(new UUID(0x1234400080000000L, i)); + try (var lookup = new BukkitPlayerPresenceLookup(directory, PlayerPresenceBatchScanTest::attributes, + path -> { opens.incrementAndGet(); return Files.newDirectoryStream(path); }); + var service = new PlayerPresenceService(worker::add, lookup)) { + var result = service.checkIdentitiesAsync(aliases); + assertFalse(result.toCompletableFuture().isDone()); drain(worker); + assertEquals(PlayerPresence.NEVER_JOINED, result.toCompletableFuture().join()); + assertEquals(1, opens.get()); + } + } + @Test void failedSharedScanCannotEraseObservedPositiveEvidence() throws Exception { + var worker = new ArrayDeque(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, PlayerPresenceBatchScanTest::attributes, + path -> { throw new java.nio.file.AccessDeniedException("synthetic-history"); }); + var service = new PlayerPresenceService(worker::add, lookup)) { + var player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class); + org.mockito.Mockito.when(player.getUniqueId()).thenReturn(FIRST); + lookup.onJoin(new org.bukkit.event.player.PlayerJoinEvent(player, null)); + var positive = service.checkAsync(FIRST); var absent = service.checkAsync(SECOND); drain(worker); + assertEquals(PlayerPresence.JOINED, positive.toCompletableFuture().join()); + assertEquals(PlayerPresence.UNKNOWN, absent.toCompletableFuture().join()); + } + } + @Test void retiredProviderDoesNotReadDirectoryForQueuedCohort() throws Exception { + var worker = new ArrayDeque(); var reads = new AtomicInteger(); + try (var lookup = new BukkitPlayerPresenceLookup(directory, + path -> { reads.incrementAndGet(); return attributes(path); }); + var service = new PlayerPresenceService(worker::add, lookup)) { + var result = service.checkAsync(FIRST); lookup.close(); drain(worker); + assertEquals(PlayerPresence.UNKNOWN, result.toCompletableFuture().join()); + assertEquals(0, reads.get()); + } + } + @Test void closeDuringScanCannotPublishNegativeOrRepopulateRetiredLookup() throws Exception { + var worker = new ArrayDeque(); var holder = new BukkitPlayerPresenceLookup[1]; + holder[0] = new BukkitPlayerPresenceLookup(directory, PlayerPresenceBatchScanTest::attributes, + path -> { holder[0].close(); return Files.newDirectoryStream(path); }); + try (var lookup = holder[0]; var service = new PlayerPresenceService(worker::add, lookup)) { + var result = service.checkAsync(FIRST); drain(worker); + assertEquals(PlayerPresence.UNKNOWN, result.toCompletableFuture().join()); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(FIRST)); + } + } + @Test void rejectedFlushSettlesRequestsAndNextCohortCanProceed() throws Exception { + var worker = new ArrayDeque(); var submissions = new AtomicInteger(); + java.util.concurrent.Executor executor = task -> { + if (submissions.incrementAndGet() == 2) throw new java.util.concurrent.RejectedExecutionException(); + worker.add(task); + }; + try (var lookup = new BukkitPlayerPresenceLookup(directory); + var service = new PlayerPresenceService(executor, lookup)) { + var rejected = service.checkAsync(FIRST); drain(worker); + assertEquals(PlayerPresence.UNKNOWN, rejected.toCompletableFuture().join()); + var later = service.checkAsync(SECOND); drain(worker); + assertEquals(PlayerPresence.NEVER_JOINED, later.toCompletableFuture().join()); + } + } + +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java new file mode 100644 index 000000000..4e6bebbf5 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java @@ -0,0 +1,234 @@ +package com.bencodez.advancedcore.tests.user.presence; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.ArrayDeque; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.OfflinePlayer; +import org.junit.jupiter.api.io.TempDir; +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.dynamic.loading.ClassLoadingStrategy; +import net.bytebuddy.implementation.FixedValue; +import net.bytebuddy.implementation.InvocationHandlerAdapter; +import net.bytebuddy.implementation.StubMethod; +import static net.bytebuddy.matcher.ElementMatchers.named; +import static net.bytebuddy.matcher.ElementMatchers.takesArguments; +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.presence.PlayerPresence; +import com.bencodez.advancedcore.api.user.presence.PlayerPresenceService; +import com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup; +import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle; + +class AdvancedCorePlayerPresenceTest { + private static final UUID USER = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + @TempDir Path temporary; + + public static class StorageFixture { + private final java.io.File directory; + public StorageFixture(Path directory) { this.directory = directory.toFile(); } + public java.io.File getPlayerDir() { return directory; } + } + + public static class PlayerListFixture { + public final StorageFixture playerIo; + public PlayerListFixture(Path directory) { playerIo = new StorageFixture(directory); } + } + + private Server levelServer(Path level) throws Exception { + return levelServer(level, false); + } + + private Server levelServer(Path level, boolean savingDisabled) throws Exception { + var config = new org.bukkit.configuration.file.YamlConfiguration(); + config.set("players.disable-saving", savingDisabled); + var spigot = mock(Server.Spigot.class); when(spigot.getConfig()).thenReturn(config); + return (Server) new ByteBuddy().subclass(Object.class).implement(Server.class) + .name("org.bukkit.craftbukkit.CraftServer") + .method(net.bytebuddy.matcher.ElementMatchers.any()).intercept(StubMethod.INSTANCE) + .defineMethod("getLevelDirectory", Path.class, java.lang.reflect.Modifier.PUBLIC) + .intercept(FixedValue.value(level)) + .method(named("getOnlinePlayers")).intercept(FixedValue.value(java.util.List.of())) + .method(named("spigot")).intercept(FixedValue.value(spigot)) + .make().load(getClass().getClassLoader(), ClassLoadingStrategy.Default.WRAPPER) + .getLoaded().getDeclaredConstructor().newInstance(); + } + + @Test void olderApiCapturesActualPlayerStorageWithoutWorldDirectoryAssumptions() throws Exception { + Path history = Files.createDirectory(temporary.resolve("legacy-playerdata")); + Server server = (Server) new ByteBuddy().subclass(Object.class).implement(Server.class) + .name("org.bukkit.craftbukkit.v1_21_R1.CraftServer") + .method(net.bytebuddy.matcher.ElementMatchers.any()).intercept(StubMethod.INSTANCE) + .defineMethod("getHandle", PlayerListFixture.class, java.lang.reflect.Modifier.PUBLIC) + .intercept(FixedValue.value(new PlayerListFixture(history))) + .make().load(getClass().getClassLoader(), ClassLoadingStrategy.Default.WRAPPER) + .getLoaded().getDeclaredConstructor().newInstance(); + assertEquals(history, BukkitPlayerPresenceLookup.capturePrimaryHistoryDirectory(server)); + assertEquals(PlayerPresence.NEVER_JOINED, new BukkitPlayerPresenceLookup(history).check(USER)); + } + + @Test void actualEnableRegistersJoinEvidenceBeforePublishingAndDisableRetiresIt() throws Exception { + Path level = Files.createDirectory(temporary.resolve("runtime-level")); + Path history = Files.createDirectory(level.resolve("playerdata")); + Server server = levelServer(level); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + doReturn(server).when(plugin).getServer(); + var worker = mock(java.util.concurrent.ScheduledExecutorService.class); + var tasks = new ArrayDeque(); + doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(worker).execute(any(Runnable.class)); + var executors = mock(AdvancedCoreLifecycle.RuntimeExecutors.class); + when(executors.getTimer()).thenReturn(worker); + var registered = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { + registered.set(call.getArgument(0)); + assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(USER))); + return null; + }).when(plugin).registerEvents(any(BukkitPlayerPresenceLookup.class)); + doThrow(new IllegalStateException("stop after presence initialization")).when(plugin).onPreLoad(); + try (var runtime = mockStatic(AdvancedCoreLifecycle.class)) { + runtime.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(plugin)).thenReturn(executors); + assertThrows(IllegalStateException.class, plugin::onEnable); + } + assertNotNull(registered.get()); + var result = plugin.checkPlayerPresenceAsync(USER); + assertFalse(result.toCompletableFuture().isDone()); while (!tasks.isEmpty()) tasks.remove().run(); + assertEquals(PlayerPresence.NEVER_JOINED, value(result)); + try (var lifecycle = mockConstruction(AdvancedCoreLifecycle.class)) { plugin.onDisable(); } + assertEquals(PlayerPresence.UNKNOWN, registered.get().check(USER)); + verify(worker, times(2)).execute(any(Runnable.class)); verifyNoMoreInteractions(worker); + try (var files = Files.list(history)) { assertEquals(0, files.count()); } + } + + @Test void actualEnableWithPlayerSavingDisabledKeepsAbsentHistoryUnknownAndLiveJoinsPositive() throws Exception { + Path level = Files.createDirectory(temporary.resolve("saving-disabled")); + Path history = Files.createDirectory(level.resolve("playerdata")); + Server server = levelServer(level, true); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + doReturn(server).when(plugin).getServer(); + var worker = mock(java.util.concurrent.ScheduledExecutorService.class); var tasks = new ArrayDeque(); + doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(worker).execute(any(Runnable.class)); + var executors = mock(AdvancedCoreLifecycle.RuntimeExecutors.class); when(executors.getTimer()).thenReturn(worker); + var registered = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { registered.set(call.getArgument(0)); return null; }) + .when(plugin).registerEvents(any(BukkitPlayerPresenceLookup.class)); + doThrow(new IllegalStateException("stop after presence initialization")).when(plugin).onPreLoad(); + try (var runtime = mockStatic(AdvancedCoreLifecycle.class)) { + runtime.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(plugin)).thenReturn(executors); + assertThrows(IllegalStateException.class, plugin::onEnable); + } + var result = plugin.checkPlayerPresenceAsync(USER); while (!tasks.isEmpty()) tasks.remove().run(); + assertEquals(PlayerPresence.UNKNOWN, value(result), "No saved record cannot prove absence when saving is disabled"); + var player = mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(USER); + registered.get().onJoin(new org.bukkit.event.player.PlayerJoinEvent(player, null)); + result = plugin.checkPlayerPresenceAsync(USER); while (!tasks.isEmpty()) tasks.remove().run(); + assertEquals(PlayerPresence.JOINED, value(result)); + try (var files = Files.list(history)) { assertEquals(0, files.count()); } + try (var lifecycle = mockConstruction(AdvancedCoreLifecycle.class)) { plugin.onDisable(); } + } + + private static void attach(AdvancedCorePlugin plugin, PlayerPresenceService service) throws Exception { + Field field = AdvancedCorePlugin.class.getDeclaredField("playerPresenceService"); + field.setAccessible(true); field.set(plugin, service); + } + + private static PlayerPresence value(CompletionStage result) { + return result.toCompletableFuture().join(); + } + + @Test void partialStartupHasUnknownHistoryInsteadOfCreatingAnIdentity() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(USER))); + assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(null))); + verify(plugin, never()).getServer(); + verify(plugin, never()).getUserManager(); + } + + @Test void publicApiKeepsUuidLookupAndHistoryOffRequestingThread() throws Exception { + var worker = new ArrayDeque(); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + try (var service = new PlayerPresenceService(worker::add, id -> { + assertEquals(USER, id); return PlayerPresence.JOINED; + })) { + attach(plugin, service); + var result = plugin.checkPlayerPresenceAsync(USER); + assertFalse(result.toCompletableFuture().isDone()); + worker.remove().run(); + assertEquals(PlayerPresence.JOINED, value(result)); + verify(plugin, never()).getServer(); + verify(plugin, never()).getUserManager(); + } + } + + @Test void actualDisableRetiresRequestsBeforeExistingLifecycleRuns() throws Exception { + var worker = new ArrayDeque(); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + var reads = new java.util.concurrent.atomic.AtomicInteger(); + var service = new PlayerPresenceService(worker::add, id -> { + reads.incrementAndGet(); return PlayerPresence.JOINED; + }); + attach(plugin, service); + var pending = plugin.checkPlayerPresenceAsync(USER); + try (var lifecycle = mockConstruction(AdvancedCoreLifecycle.class, (instance, context) -> + doAnswer(call -> { + assertEquals(PlayerPresence.UNKNOWN, value(pending)); + assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(USER))); + return null; + }).when(instance).shutdown())) { + plugin.onDisable(); + assertEquals(1, lifecycle.constructed().size()); + verify(lifecycle.constructed().getFirst()).shutdown(); + } + worker.remove().run(); + assertEquals(0, reads.get()); + assertEquals(PlayerPresence.UNKNOWN, value(pending)); + } + + @Test void completeCapturedScopeAndPublicApiReadHistoryOnActualWorker() throws Exception { + Path levelDirectory = Files.createDirectory(temporary.resolve("server-level")); + Path worldDirectory = Files.createDirectory(temporary.resolve("dimension")); + Path historyDirectory = Files.createDirectory(levelDirectory.resolve("playerdata")); + World world = mock(World.class); + when(world.getEnvironment()).thenReturn(World.Environment.NORMAL); + when(world.getWorldFolder()).thenReturn(worldDirectory.toFile()); + // A complete synthetic CraftServer-shaped scope, isolated in a child loader. + Server server = (Server) new ByteBuddy().subclass(Object.class).implement(Server.class) + .name("org.bukkit.craftbukkit.CraftServer").method(net.bytebuddy.matcher.ElementMatchers.any()) + .intercept(StubMethod.INSTANCE) + .defineMethod("getLevelDirectory", Path.class, java.lang.reflect.Modifier.PUBLIC) + .intercept(FixedValue.value(levelDirectory)) + .method(named("getWorlds")).intercept(FixedValue.value(java.util.List.of(world))) + .method(named("getOfflinePlayer").and(takesArguments(UUID.class))) + .intercept(InvocationHandlerAdapter.of((proxy, method, arguments) -> { + fail("Checked reads must never invoke the server history loader"); return null; + })).make().load(getClass().getClassLoader(), ClassLoadingStrategy.Default.WRAPPER) + .getLoaded().getDeclaredConstructor().newInstance(); + Path captured = BukkitPlayerPresenceLookup.capturePrimaryHistoryDirectory(server); + assertEquals(historyDirectory, captured); + long requester = Thread.currentThread().threadId(); + AtomicLong historyThread = new AtomicLong(-1); + var actualLookup = new BukkitPlayerPresenceLookup(captured); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + try (var worker = Executors.newSingleThreadExecutor(); + var service = new PlayerPresenceService(worker, id -> { + historyThread.set(Thread.currentThread().threadId()); return actualLookup.check(id); + })) { + attach(plugin, service); + assertEquals(PlayerPresence.NEVER_JOINED, + plugin.checkPlayerPresenceAsync(USER).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertNotEquals(requester, historyThread.get()); + verifyNoInteractions(world); + verify(plugin, never()).getUserManager(); + try (var contents = Files.list(historyDirectory)) { assertEquals(0, contents.count()); } + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/BukkitPlayerPresenceLookupTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/BukkitPlayerPresenceLookupTest.java new file mode 100644 index 000000000..52e908eb4 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/BukkitPlayerPresenceLookupTest.java @@ -0,0 +1,354 @@ +package com.bencodez.advancedcore.tests.user.presence; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.zip.GZIPOutputStream; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerJoinEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.advancedcore.api.user.presence.BukkitPlayerPresenceLookup; +import com.bencodez.advancedcore.api.user.presence.PlayerPresence; + +class BukkitPlayerPresenceLookupTest { + private static final UUID USER = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + @TempDir Path directory; + + static byte[] gzip(byte[] payload) throws Exception { + var bytes = new ByteArrayOutputStream(); + try (var gzip = new GZIPOutputStream(bytes)) { gzip.write(payload); } + return bytes.toByteArray(); + } + + static byte[] completeHistory() throws Exception { + var bytes = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(bytes)) { + out.writeByte(10); out.writeUTF(""); + for (int type = 1; type <= 12; type++) { + out.writeByte(type); out.writeUTF("entry-" + type); + switch (type) { + case 1 -> out.writeByte(1); + case 2 -> out.writeShort(2); + case 3 -> out.writeInt(3); + case 4 -> out.writeLong(4); + case 5 -> out.writeFloat(5); + case 6 -> out.writeDouble(6); + case 7 -> { out.writeInt(2); out.write(new byte[] {1, 2}); } + case 8 -> out.writeUTF("déjà-joined-歴史 🎉\r\n adjacent text"); + case 9 -> { out.writeByte(3); out.writeInt(2); out.writeInt(1); out.writeInt(2); } + case 10 -> { out.writeByte(8); out.writeUTF("name"); out.writeUTF("nested"); out.writeByte(0); } + case 11 -> { out.writeInt(1); out.writeInt(1); } + case 12 -> { out.writeInt(1); out.writeLong(1); } + } + } + out.writeByte(0); + } + return bytes.toByteArray(); + } + + @Test void completeAllTagHistoryIsReadOnlyAndDoesNotNeedServerLoader() throws Exception { + byte[] record = gzip(completeHistory()); + Path file = Files.write(directory.resolve(USER + ".dat"), record); + assertEquals(PlayerPresence.JOINED, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(record, Files.readAllBytes(file)); + try (var files = Files.list(directory)) { assertEquals(1, files.count()); } + } + + @Test void missingRecordNeedsReadableScopeAndNeverCreatesState() { + assertEquals(PlayerPresence.NEVER_JOINED, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory.resolve("missing")).check(USER)); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(null).check(USER)); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(null)); + } + + @Test void everyRecoveryOrMigrationSuffixIsRetainedAndMalformedIsUnknown() throws Exception { + for (String suffix : new String[] {".dat", ".dat_old", ".dat_new", ".dat.tmp", + ".dat.offline-read", ".dat_old.offline-read"}) { + Path file = directory.resolve(USER + suffix); + Files.write(file, new byte[] {0}); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(USER)); + byte[] valid = gzip(completeHistory()); Files.write(file, valid); + assertEquals(PlayerPresence.JOINED, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(valid, Files.readAllBytes(file)); Files.delete(file); + } + } + + @Test void unrelatedIdentityAndMigrationFilesAreNotClaimedOrRenamed() throws Exception { + UUID historical = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + byte[] record = gzip(completeHistory()); Path old = Files.write(directory.resolve(historical + ".dat"), record); + var lookup = new BukkitPlayerPresenceLookup(directory); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + assertEquals(PlayerPresence.JOINED, lookup.check(historical)); + assertArrayEquals(record, Files.readAllBytes(old)); + assertFalse(Files.exists(directory.resolve(historical + ".dat.offline-read"))); + } + + @Test void migratedBackupCannotBeMistakenForMissingHistory() throws Exception { + Path retained = directory.resolve(USER + ".dat_old.offline-read"); + byte[] valid = gzip(completeHistory()); Files.write(retained, valid); + assertEquals(PlayerPresence.JOINED, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(valid, Files.readAllBytes(retained)); + byte[] malformed = {0}; Files.write(retained, malformed); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(malformed, Files.readAllBytes(retained)); + try (var files = Files.list(directory)) { assertEquals(1, files.count()); } + } + + @Test void interruptedNativeFirstSaveIsUnknownAndRetainedVerbatim() throws Exception { + Path file = Files.createTempFile(directory, USER + "-", ".dat"); + byte[] record = gzip(completeHistory()); Files.write(file, record); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(record, Files.readAllBytes(file)); + Files.write(file, new byte[] {0}); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(new byte[] {0}, Files.readAllBytes(file)); + } + + @Test void recoveryEvidenceIsScopedToItsIdentityAndSurvivesMalformedContent() throws Exception { + UUID other = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + Path temp = Files.createTempFile(directory, other + "-", ".dat"); + Files.write(temp, new byte[] {0}); + var lookup = new BukkitPlayerPresenceLookup(directory); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(other)); + Files.write(directory.resolve(USER + "_corrupted_2026-10-14_01-00-00.dat_old"), new byte[] {0}); + Files.setLastModifiedTime(directory, java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis() + 2000)); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + assertArrayEquals(new byte[] {0}, Files.readAllBytes(temp)); + Files.write(directory.resolve(USER + ".dat"), gzip(completeHistory())); + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + lookup.close(); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + + @Test void recoveryIndexRefreshesAfterCreationDeletionAndDirectoryReplacement() throws Exception { + Path scope = Files.createDirectory(directory.resolve("playerdata")); + var lookup = new BukkitPlayerPresenceLookup(scope); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + Path pending = Files.createTempFile(scope, USER + "-", ".dat"); + Files.setLastModifiedTime(scope, java.nio.file.attribute.FileTime.fromMillis(1000)); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + Files.delete(pending); + Files.setLastModifiedTime(scope, java.nio.file.attribute.FileTime.fromMillis(2000)); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + Files.move(scope, directory.resolve("previous")); + Files.createDirectory(scope); + Path replacementPending = Files.createTempFile(scope, USER + "-", ".dat"); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + Files.delete(replacementPending); + Files.delete(scope); + Files.createSymbolicLink(scope, directory.resolve("previous")); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + + @Test void normalLargeHistoryDirectoryStillSupportsNegativeProof() throws Exception { + for (int i = 0; i < 1200; i++) + Files.write(directory.resolve(new UUID(0, i) + ".dat"), new byte[] {0}); + Files.write(directory.resolve("unrelated-random.dat"), new byte[] {0}); + var lookup = new BukkitPlayerPresenceLookup(directory); + assertEquals(PlayerPresence.NEVER_JOINED, lookup.check(USER)); + assertEquals(PlayerPresence.NEVER_JOINED, + lookup.check(UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"))); + try (var files = Files.list(directory)) { assertEquals(1201, files.count()); } + } + + @Test void symbolicFileScopeAndDotDotCannotSupplyNegativeProof() throws Exception { + Path real = Files.createDirectory(directory.resolve("déjà-joined-歴史")); + Path alias = Files.createSymbolicLink(directory.resolve("alias"), real); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(alias).check(USER)); + assertEquals(PlayerPresence.UNKNOWN, + new BukkitPlayerPresenceLookup(alias.resolve("..").resolve(real.getFileName())).check(USER)); + Path file = Files.write(directory.resolve("record"), gzip(completeHistory())); + Files.createSymbolicLink(real.resolve(USER + ".dat"), file); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(real).check(USER)); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(file).check(USER)); + } + + @Test void joinedSnapshotAndEventsCoverUnsavedPlayersAndRetirement() { + Player live = mock(Player.class); when(live.getUniqueId()).thenReturn(USER); + Server server = mock(Server.class); doReturn(java.util.List.of(live)).when(server).getOnlinePlayers(); + var lookup = new BukkitPlayerPresenceLookup(directory); lookup.captureJoinedPlayers(server); + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + verify(server).getOnlinePlayers(); verifyNoMoreInteractions(server); + lookup.close(); lookup.onJoin(new PlayerJoinEvent(live, "synthetic")); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + var replacement = new BukkitPlayerPresenceLookup(null); + replacement.onJoin(new PlayerJoinEvent(live, "synthetic")); + assertEquals(PlayerPresence.JOINED, replacement.check(USER)); + } + + @Test void failedOnlineSnapshotDisablesNegativeEvidenceButKeepsValidHistoryPositive() throws Exception { + Server server = mock(Server.class); when(server.getOnlinePlayers()).thenThrow(new IllegalStateException("synthetic")); + var lookup = new BukkitPlayerPresenceLookup(directory); lookup.captureJoinedPlayers(server); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + Files.write(directory.resolve(USER + ".dat"), gzip(completeHistory())); + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + } + + @Test void unsupportedServerCannotSupplyNegativeScope() { + Server unsupported = mock(Server.class); + assertNull(BukkitPlayerPresenceLookup.capturePrimaryHistoryDirectory(unsupported)); + verifyNoInteractions(unsupported); + assertNull(BukkitPlayerPresenceLookup.capturePrimaryHistoryDirectory(null)); + } + + @TestFactory java.util.stream.Stream everyTruncatedCompleteFrameStaysUnknownAndUnchanged() throws Exception { + byte[] record = gzip(completeHistory()); + return java.util.stream.IntStream.range(0, record.length).mapToObj(length -> + DynamicTest.dynamicTest("truncated gzip bytes=" + length, () -> { + byte[] truncated = java.util.Arrays.copyOf(record, length); + Path file = Files.write(directory.resolve(USER + ".dat"), truncated); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(USER)); + assertArrayEquals(truncated, Files.readAllBytes(file)); + })); + } + + @Test void malformedTypedAndAdjacentPayloadsCannotBecomePositiveOrNegativeHistory() throws Exception { + var bad = new java.util.ArrayList(); + byte[] payload = completeHistory(); byte[] valid = gzip(payload); + bad.add(gzip(java.util.Arrays.copyOf(payload, payload.length - 1))); + bad.add(gzip(java.util.Arrays.copyOf(payload, payload.length + 1))); + bad.add(gzip(new byte[] {10, 0, 0, 13, 0, 0, 0})); // Unknown named tag. + bad.add(gzip(new byte[] {10, 0, 0, 7, 0, 0, -1, -1, -1, -1, 0})); // Negative array length. + bad.add(gzip(new byte[] {10, 0, 0, 9, 0, 0, 0, 0, 0, 0, 1, 0})); // Nonempty END list. + bad.add(gzip(new byte[] {10, 0, 0, 8, 0, 0, 0, 1, (byte) 0xc2, 0})); // Truncated modified UTF. + bad.add(gzip(new byte[] {10, 0, 0, 1, 0, 0, 1, 1, 0, 0, 2, 0})); // Duplicate name. + byte[] checksum = valid.clone(); checksum[checksum.length - 8] ^= 1; bad.add(checksum); + byte[] reserved = valid.clone(); reserved[3] = (byte) 128; bad.add(reserved); + bad.add(java.util.Arrays.copyOf(valid, valid.length + 1)); + byte[] doubleFrame = new byte[valid.length * 2]; + System.arraycopy(valid, 0, doubleFrame, 0, valid.length); + System.arraycopy(valid, 0, doubleFrame, valid.length, valid.length); bad.add(doubleFrame); + var depthBytes = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(depthBytes)) { + out.writeByte(10); out.writeUTF(""); + for (int depth = 0; depth < 70; depth++) { out.writeByte(10); out.writeUTF("nested"); } + for (int depth = 0; depth <= 70; depth++) out.writeByte(0); + } + bad.add(gzip(depthBytes.toByteArray())); + var nodes = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(nodes)) { + out.writeByte(10); out.writeUTF(""); out.writeByte(9); out.writeUTF("list"); + out.writeByte(1); out.writeInt(100001); out.write(new byte[100001]); out.writeByte(0); + } + bad.add(gzip(nodes.toByteArray())); + byte[] expanded = new byte[8 * 1024 * 1024 + 1]; bad.add(gzip(expanded)); + bad.add(new byte[1024 * 1024 + 1]); + for (int index = 0; index < bad.size(); index++) { + Path file = Files.write(directory.resolve(USER + ".dat"), bad.get(index)); + assertEquals(PlayerPresence.UNKNOWN, new BukkitPlayerPresenceLookup(directory).check(USER), "case=" + index); + assertArrayEquals(bad.get(index), Files.readAllBytes(file)); + } + } + + @Test void optionalGzipFilenameAndHeaderChecksumRemainValidData() throws Exception { + byte[] original = gzip(completeHistory()); + var header = new ByteArrayOutputStream(); + header.write(original, 0, 10); + byte[] first = header.toByteArray(); first[3] = 10; header.reset(); header.write(first); + header.write("synthetic.dat".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); header.write(0); + var crc = new java.util.zip.CRC32(); crc.update(header.toByteArray()); + header.write((int) crc.getValue() & 255); header.write((int) (crc.getValue() >> 8) & 255); + header.write(original, 10, original.length - 10); + byte[] framed = header.toByteArray(); Files.write(directory.resolve(USER + ".dat"), framed); + assertEquals(PlayerPresence.JOINED, new BukkitPlayerPresenceLookup(directory).check(USER)); + } + + @Test void validExactByteDepthAndNodeLimitsRemainAccepted() throws Exception { + var records = new java.util.ArrayList(); + byte[] original = gzip(completeHistory()); + byte[] compressedLimit = new byte[1024 * 1024]; + System.arraycopy(original, 0, compressedLimit, 0, 10); compressedLimit[3] = 16; + int padding = compressedLimit.length - original.length; + java.util.Arrays.fill(compressedLimit, 10, 10 + padding - 1, (byte) 'a'); + System.arraycopy(original, 10, compressedLimit, 10 + padding, original.length - 10); + records.add(compressedLimit); + var expanded = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(expanded)) { + out.writeByte(10); out.writeUTF(""); out.writeByte(7); out.writeUTF(""); + int count = 8 * 1024 * 1024 - 11; + out.writeInt(count); out.write(new byte[count]); out.writeByte(0); + } + assertEquals(8 * 1024 * 1024, expanded.size()); records.add(gzip(expanded.toByteArray())); + var depth = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(depth)) { + out.writeByte(10); out.writeUTF(""); + for (int index = 0; index < 64; index++) { out.writeByte(10); out.writeUTF("nested"); } + for (int index = 0; index <= 64; index++) out.writeByte(0); + } + records.add(gzip(depth.toByteArray())); + var nodes = new ByteArrayOutputStream(); + try (var out = new DataOutputStream(nodes)) { + out.writeByte(10); out.writeUTF(""); out.writeByte(9); out.writeUTF("list"); + out.writeByte(1); out.writeInt(99998); out.write(new byte[99998]); out.writeByte(0); + } + records.add(gzip(nodes.toByteArray())); + for (int index = 0; index < records.size(); index++) { + Files.write(directory.resolve(USER + ".dat"), records.get(index)); + assertEquals(PlayerPresence.JOINED, new BukkitPlayerPresenceLookup(directory).check(USER), "limit=" + index); + } + } + + @Test void joinEvidenceSaturationKeepsUnknownVisibleAndPreservesKnownPositive() { + var lookup = new BukkitPlayerPresenceLookup(directory); + Player live = mock(Player.class); + var index = new java.util.concurrent.atomic.AtomicLong(); + when(live.getUniqueId()).thenAnswer(call -> new UUID(0, index.incrementAndGet())); + PlayerJoinEvent event = new PlayerJoinEvent(live, "synthetic"); + for (int count = 0; count < 65537; count++) lookup.onJoin(event); + assertEquals(PlayerPresence.JOINED, lookup.check(new UUID(0, 1))); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(new UUID(0, 65537))); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + } + @Test void savingPolicyDefaultsTypesAndFailuresOnlyRestrictNegativeEvidence() throws Exception { + for (Object disabled : new Object[] {null, false, true, "false", 0}) { + var config = new org.bukkit.configuration.file.YamlConfiguration(); config.set("players.disable-saving", disabled); + var server = mock(Server.class); var spigot = mock(Server.Spigot.class); + when(server.spigot()).thenReturn(spigot); when(spigot.getConfig()).thenReturn(config); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + lookup.capturePlayerSaving(server); + PlayerPresence expected = disabled == null || Boolean.FALSE.equals(disabled) + ? PlayerPresence.NEVER_JOINED : PlayerPresence.UNKNOWN; + assertEquals(expected, lookup.check(USER)); assertEquals(expected, lookup.checkOnlineHistory(USER)); + } + } + for (Server server : new Server[] {null, mock(Server.class), failingSavingServer()}) { + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + lookup.capturePlayerSaving(server); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(USER)); + assertEquals(PlayerPresence.UNKNOWN, lookup.checkOnlineHistory(USER)); + } + } + } + + private Server failingSavingServer() { + var server = mock(Server.class); var spigot = mock(Server.Spigot.class); + when(server.spigot()).thenReturn(spigot); + when(spigot.getConfig()).thenThrow(new UnsupportedOperationException("synthetic unavailable")); + return server; + } + + @Test void disabledOrUnknownSavingPreservesRetainedPositivesAndCannotReenableNegatives() throws Exception { + byte[] record = gzip(completeHistory()); Path file = Files.write(directory.resolve(USER + ".dat"), record); + var server = mock(Server.class); var spigot = mock(Server.Spigot.class); + var config = new org.bukkit.configuration.file.YamlConfiguration(); config.set("players.disable-saving", true); + when(server.spigot()).thenReturn(spigot); when(spigot.getConfig()).thenReturn(config); + try (var lookup = new BukkitPlayerPresenceLookup(directory)) { + lookup.capturePlayerSaving(server); + assertEquals(PlayerPresence.JOINED, lookup.check(USER)); + assertEquals(PlayerPresence.JOINED, lookup.checkOnlineHistory(USER)); + config.set("players.disable-saving", false); lookup.capturePlayerSaving(server); + assertEquals(PlayerPresence.UNKNOWN, lookup.check(new UUID(0, 2))); + assertArrayEquals(record, Files.readAllBytes(file)); + } + } + +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/PlayerPresenceServiceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/PlayerPresenceServiceTest.java new file mode 100644 index 000000000..7c9fc930c --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/PlayerPresenceServiceTest.java @@ -0,0 +1,283 @@ +package com.bencodez.advancedcore.tests.user.presence; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicInteger; +import com.bencodez.advancedcore.api.user.presence.PlayerPresence; +import com.bencodez.advancedcore.api.user.presence.PlayerPresenceService; +import org.junit.jupiter.api.Test; + +class PlayerPresenceServiceTest { + private static final UUID USER = UUID.fromString("d4519fc4-a782-4d11-8235-617209238441"); + + private static PlayerPresence value(CompletionStage result) { + return result.toCompletableFuture().join(); + } + + @Test void callerDoesNotReadHistoryAndPreservesAllThreeEvidenceOutcomes() { + for (PlayerPresence evidence : PlayerPresence.values()) { + var queued = new ArrayDeque(); + var reads = new AtomicInteger(); + try (var service = new PlayerPresenceService(queued::add, identity -> { + assertEquals(USER, identity); reads.incrementAndGet(); return evidence; + })) { + var result = service.checkAsync(USER); + assertFalse(result.toCompletableFuture().isDone()); + assertEquals(0, reads.get()); + queued.remove().run(); + assertEquals(evidence, value(result)); + assertEquals(1, reads.get()); + } + } + } + + @Test void absentIdentityAndRejectedWorkerDoNotBecomeNegativeHistory() { + var reads = new AtomicInteger(); + try (var service = new PlayerPresenceService(task -> { throw new RejectedExecutionException(); }, id -> { + reads.incrementAndGet(); return PlayerPresence.NEVER_JOINED; + })) { + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(null))); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + assertEquals(0, reads.get()); + } + } + + @Test void failedAndMissingProviderEvidenceRemainUnknown() { + try (var service = new PlayerPresenceService(Runnable::run, id -> { throw new IOException("synthetic"); })) { + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + } + try (var service = new PlayerPresenceService(Runnable::run, id -> null)) { + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + } + } + + @Test void saturationIsBoundedAndCompletedReadFreesAdmission() { + var queued = new ArrayDeque(); + try (var service = new PlayerPresenceService(queued::add, id -> PlayerPresence.JOINED)) { + for (int i = 0; i < 128; i++) assertFalse(service.checkAsync(USER).toCompletableFuture().isDone()); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + assertEquals(128, queued.size()); + queued.remove().run(); + assertFalse(service.checkAsync(USER).toCompletableFuture().isDone()); + assertEquals(128, queued.size()); + } + } + + @Test void retirementCompletesQueuedRequestsWithoutReadingOrStoppingSharedWorker() { + var queued = new ArrayDeque(); + var reads = new AtomicInteger(); + var service = new PlayerPresenceService(queued::add, id -> { reads.incrementAndGet(); return PlayerPresence.JOINED; }); + var result = service.checkAsync(USER); + service.close(); service.close(); + assertEquals(PlayerPresence.UNKNOWN, value(result)); + queued.remove().run(); + assertEquals(0, reads.get()); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + queued.add(reads::incrementAndGet); queued.remove().run(); + assertEquals(1, reads.get()); + } + + @Test void retiredStartedReadCannotPublishPositiveIntoItsSuccessor() { + var queued = new ArrayDeque(); + var holder = new PlayerPresenceService[1]; + holder[0] = new PlayerPresenceService(queued::add, id -> { + holder[0].close(); return PlayerPresence.JOINED; + }); + var predecessor = holder[0].checkAsync(USER); + queued.remove().run(); + assertEquals(PlayerPresence.UNKNOWN, value(predecessor)); + try (var successor = new PlayerPresenceService(queued::add, id -> PlayerPresence.NEVER_JOINED)) { + var result = successor.checkAsync(USER); queued.remove().run(); + assertEquals(PlayerPresence.NEVER_JOINED, value(result)); + assertEquals(PlayerPresence.UNKNOWN, value(predecessor)); + } + } + + @Test void verifiedHistoricalUuidCanSupplyPositiveMigrationEvidence() { + var worker = new ArrayDeque(); + UUID historical = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + try (var service = new PlayerPresenceService(worker::add, + id -> id.equals(historical) ? PlayerPresence.JOINED : PlayerPresence.NEVER_JOINED)) { + var result = service.checkIdentitiesAsync(java.util.List.of(USER, historical)); + worker.remove().run(); + assertFalse(result.toCompletableFuture().isDone()); + worker.remove().run(); + assertEquals(PlayerPresence.JOINED, value(result)); + } + } + + @Test void incompleteMigrationProofAndUnboundedAliasInputsStayUnknown() { + var worker = new ArrayDeque(); + UUID historical = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + try (var service = new PlayerPresenceService(worker::add, + id -> id.equals(historical) ? PlayerPresence.UNKNOWN : PlayerPresence.NEVER_JOINED)) { + var result = service.checkIdentitiesAsync(java.util.List.of(USER, historical)); + worker.remove().run(); worker.remove().run(); + assertEquals(PlayerPresence.UNKNOWN, value(result)); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkIdentitiesAsync(java.util.Arrays.asList(USER, null)))); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkIdentitiesAsync(java.util.List.of()))); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkIdentitiesAsync(java.util.Collections.nCopies(9, USER)))); + assertTrue(worker.isEmpty()); + } + } + + @Test void completeNegativeAliasesAreDeduplicatedAndRetirementStillWins() { + var worker = new ArrayDeque(); + var service = new PlayerPresenceService(worker::add, id -> PlayerPresence.NEVER_JOINED); + var negative = service.checkIdentitiesAsync(java.util.List.of(USER, USER)); + assertEquals(1, worker.size()); worker.remove().run(); + assertEquals(PlayerPresence.NEVER_JOINED, value(negative)); + var retiring = service.checkIdentitiesAsync(java.util.List.of(USER)); + service.close(); + assertEquals(PlayerPresence.UNKNOWN, value(retiring)); + worker.remove().run(); + } + + @Test void retirementInvalidatesUnfinishedAggregateEvenAfterOneAliasJoined() { + var worker = new ArrayDeque(); + UUID historical = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + var reads = new AtomicInteger(); + var service = new PlayerPresenceService(worker::add, id -> { + reads.incrementAndGet(); + return id.equals(USER) ? PlayerPresence.JOINED : PlayerPresence.NEVER_JOINED; + }); + var aggregate = service.checkIdentitiesAsync(java.util.List.of(USER, historical)); + worker.remove().run(); + assertFalse(aggregate.toCompletableFuture().isDone()); + service.close(); + assertEquals(PlayerPresence.UNKNOWN, value(aggregate)); + worker.remove().run(); + assertEquals(1, reads.get()); + assertEquals(PlayerPresence.UNKNOWN, value(aggregate)); + } + + @Test void completedAggregateKeepsPositiveEvidenceAfterRetirement() { + var worker = new ArrayDeque(); + UUID historical = UUID.fromString("b3f2bd99-5325-4a51-a642-12674e147381"); + var service = new PlayerPresenceService(worker::add, + id -> id.equals(USER) ? PlayerPresence.JOINED : PlayerPresence.UNKNOWN); + var aggregate = service.checkIdentitiesAsync(java.util.List.of(USER, historical)); + worker.remove().run(); worker.remove().run(); + assertEquals(PlayerPresence.JOINED, value(aggregate)); + service.close(); + assertEquals(PlayerPresence.JOINED, value(aggregate)); + } + + @Test void aggregateAdmissionCannotGrowPastTheSharedPendingBound() { + var worker = new ArrayDeque(); + var service = new PlayerPresenceService(worker::add, id -> PlayerPresence.JOINED); + for (int index = 0; index < 64; index++) + assertFalse(service.checkIdentitiesAsync(java.util.List.of(USER)).toCompletableFuture().isDone()); + assertEquals(64, worker.size()); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkIdentitiesAsync(java.util.List.of(USER)))); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + worker.remove().run(); + assertFalse(service.checkIdentitiesAsync(java.util.List.of(USER)).toCompletableFuture().isDone()); + service.close(); + while (!worker.isEmpty()) worker.remove().run(); + } + @Test void completionCanReentrantlyRetireQueuedIndividualAndAggregateRequests() { + var worker = new ArrayDeque(); + var reads = new AtomicInteger(); + var service = new PlayerPresenceService(worker::add, id -> { + reads.incrementAndGet(); return PlayerPresence.JOINED; + }); + var first = service.checkAsync(USER); + var queued = service.checkAsync(USER); + var aggregate = service.checkIdentitiesAsync(java.util.List.of(USER)); + var callback = first.thenAccept(evidence -> { + assertEquals(PlayerPresence.JOINED, evidence); + service.close(); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + }); + worker.remove().run(); + callback.toCompletableFuture().join(); + assertEquals(PlayerPresence.JOINED, value(first)); + assertEquals(PlayerPresence.UNKNOWN, value(queued)); + assertEquals(PlayerPresence.UNKNOWN, value(aggregate)); + while (!worker.isEmpty()) worker.remove().run(); + assertEquals(1, reads.get()); + } + + @Test void aggregateCompletionCanReentrantlyRetireAnotherAggregate() { + var worker = new ArrayDeque(); + var service = new PlayerPresenceService(worker::add, id -> PlayerPresence.NEVER_JOINED); + var first = service.checkIdentitiesAsync(java.util.List.of(USER)); + var queued = service.checkIdentitiesAsync(java.util.List.of(USER)); + var callback = first.thenRun(service::close); + worker.remove().run(); + callback.toCompletableFuture().join(); + assertEquals(PlayerPresence.NEVER_JOINED, value(first)); + assertEquals(PlayerPresence.UNKNOWN, value(queued)); + while (!worker.isEmpty()) worker.remove().run(); + } + + @Test void aliasFamilyRejectsWithoutQueueingPartialReadsWhenCapacityIsInsufficient() { + var worker = new ArrayDeque(); + try (var service = new PlayerPresenceService(worker::add, id -> PlayerPresence.NEVER_JOINED)) { + for (int index = 0; index < 126; index++) service.checkAsync(USER); + var result = service.checkIdentitiesAsync(java.util.List.of(USER, new UUID(0, 2))); + assertTrue(result.toCompletableFuture().isDone(), "Insufficient capacity rejects the complete request immediately"); + assertEquals(PlayerPresence.UNKNOWN, value(result)); + assertEquals(126, worker.size(), "No partial alias work is admitted"); + } + } + + @Test void aliasFamilyReservesEveryChildBeforeReentrantWorkerAdmission() { + var worker = new ArrayDeque(); + var service = new PlayerPresenceService[1]; + var arm = new java.util.concurrent.atomic.AtomicBoolean(); + var competing = new java.util.concurrent.atomic.AtomicReference>(); + service[0] = new PlayerPresenceService(task -> { + if (arm.compareAndSet(true, false)) competing.set(service[0].checkAsync(new UUID(0, 3))); + worker.add(task); + }, id -> PlayerPresence.NEVER_JOINED); + try (var current = service[0]) { + for (int index = 0; index < 125; index++) current.checkAsync(USER); + arm.set(true); + var result = current.checkIdentitiesAsync(java.util.List.of(USER, new UUID(0, 2))); + assertNotNull(competing.get()); + assertTrue(competing.get().toCompletableFuture().isDone()); + assertEquals(PlayerPresence.UNKNOWN, value(competing.get())); + assertEquals(127, worker.size()); + while (!worker.isEmpty()) worker.remove().run(); + assertEquals(PlayerPresence.NEVER_JOINED, value(result)); + } + } + + @Test void rejectedAliasSubmissionSettlesFamilyAndReleasesEveryReservation() { + var worker = new ArrayDeque(); var submissions = new AtomicInteger(); + try (var service = new PlayerPresenceService(task -> { + if (submissions.incrementAndGet() == 1) throw new RejectedExecutionException(); + worker.add(task); + }, id -> PlayerPresence.NEVER_JOINED)) { + var family = service.checkIdentitiesAsync(java.util.List.of(USER, new UUID(0, 2))); + assertFalse(family.toCompletableFuture().isDone()); + assertEquals(1, worker.size()); worker.remove().run(); + assertEquals(PlayerPresence.UNKNOWN, value(family)); + for (int index = 0; index < 128; index++) + assertFalse(service.checkAsync(USER).toCompletableFuture().isDone()); + assertEquals(128, worker.size()); + assertEquals(PlayerPresence.UNKNOWN, value(service.checkAsync(USER))); + } + } + + @Test void retirementDuringFirstAliasSubmissionCancelsEveryReservedChild() { + var worker = new ArrayDeque(); var reads = new AtomicInteger(); + var holder = new PlayerPresenceService[1]; + holder[0] = new PlayerPresenceService(task -> { + holder[0].close(); worker.add(task); + }, id -> { reads.incrementAndGet(); return PlayerPresence.JOINED; }); + var family = holder[0].checkIdentitiesAsync(java.util.List.of(USER, new UUID(0, 2))); + assertEquals(PlayerPresence.UNKNOWN, value(family)); + while (!worker.isEmpty()) worker.remove().run(); + assertEquals(0, reads.get()); + assertEquals(PlayerPresence.UNKNOWN, value(holder[0].checkAsync(USER))); + } + +} diff --git a/docs/checked-player-presence.md b/docs/checked-player-presence.md new file mode 100644 index 000000000..983203817 --- /dev/null +++ b/docs/checked-player-presence.md @@ -0,0 +1,84 @@ +# Checked server player presence + +`AdvancedCorePlugin.checkPlayerPresenceAsync(UUID)` reads server join-history evidence for an already resolved UUID. +It returns `CompletionStage` with `JOINED`, `NEVER_JOINED` or `UNKNOWN`. It does not resolve a name, +create a user, write history or use plugin-row existence as join proof. Existing boolean identity-validation APIs +remain unchanged. Resolve the correct current server UUID on an appropriate worker before calling this API; +an unresolved or ambiguous migrated identity must remain unknown rather than being guessed from a name. + +`checkPlayerPresenceIdentitiesAsync(Collection)` accepts up to eight verified current/historical UUIDs +belonging to the same person. After input validation, positive evidence from any supplied identity is sufficient. Negative evidence +requires every supplied identity to be confirmed negative; uncertain, empty, missing or oversized input stays +unknown. The API does not discover aliases or validate their ownership. Supply a null member to represent an +unresolved part of a migration, rather than silently omitting it: this makes the complete request unknown before +any read is queued. Duplicate UUIDs are read only once. + +The requesting thread performs no history, filesystem or UUID lookup. The existing AdvancedCore storage executor +owns the read, with at most 128 admitted entries per service. An alias request atomically reserves one entry +for its public aggregate plus one for each distinct child. Insufficient capacity rejects the complete request +as unknown without scheduling partial alias reads. Missing identity, saturation, executor rejection, +provider failure and retired requests return `UNKNOWN`. Completions may run on a worker, requesting or retiring +thread: use an asynchronous continuation or the correct platform scheduler for client work. Never attach +blocking storage or player effects directly. A result is an inspection snapshot, not permission to mutate a +player or proof that a later menu/configuration generation is still current. + +The runtime captures server-wide player-storage directory metadata and in-memory player-saving policy during +enable on the platform owner. Disabled, malformed or unavailable saving policy disables negative answers +for that generation while retained records and observed joins can still establish positives. The absent +`players.disable-saving` key follows the platform false default. Workers never load platform configuration. +Recognized CraftBukkit implementations provide either the level-directory API or the player's storage-directory +metadata; a world/dimension folder is never assumed to own player data. Unsupported layouts remain `UNKNOWN`. +The worker reads UUID-named history directly without calling the server's offline-player loader, profile lookup, +data fixer or migration logic. Complete GZIP/NBT records supply `JOINED`; malformed or incomplete records remain +`UNKNOWN`. Current, recovery and retained migration files are read without creating, moving or rewriting them. +Input is bounded to 1 MiB compressed, 8 MiB expanded, depth 64 and 100,000 structural nodes; oversized history is +unknown. Negative evidence requires a readable unchanged directory with no record or recovery file for that UUID. +Native interrupted-save files (`UUID-random.dat`) and corruption backups prevent negative answers even when no +canonical record survives; their contents are never read as positive proof or changed. A worker-owned directory +index preserves retained recovery evidence across checks. Every absent result requires a fresh bounded +enumeration: directory identity and timestamps alone cannot certify unchanged entries. +For the built-in provider, requests register at their own position on the existing storage worker, +then one bounded cohort shares a fresh scan. Registration turns cannot move a later request ahead +of previously queued persistence. Each cohort includes at most the existing 128 admitted entries; +custom providers retain their original individual scheduling. Point-record checks run before the +common enumeration, and a canonical/recovery record discovered there prevents a false negative. +Subsequent cohorts always scan afresh, including on keyless filesystems with colliding timestamps; +no cached negative result certifies a later request. +Scanning is bounded to 1,000,000 entries and 65,536 recovery identities; incomplete, failed or unstable scans +remain unknown. Closing the lookup clears the index without waiting for an in-flight scan. +File keys strengthen stability checks when the provider supplies them. Providers without file keys use +unchanged creation timestamps, modification times and record sizes; unavailable creation metadata remains +unknown. A stable valid record provides positive evidence without inspecting later siblings. A failed +candidate read can prevent negative evidence but cannot erase a readable valid sibling. Directory readability +is rechecked before using an absence index. A missing directory, symbolic link, failed scope read or changed +scope remains unknown. No raw history or paths are +returned or logged. + +The additive `checkPlayerPresenceIdentitiesAsync(identities, true)` operation also checks whether the +actual history scope contains unmapped offline/non-online UUID records or observed joins. Current-name +cache mappings cannot enumerate all former offline names. Such retained evidence prevents a negative +online-family result unless a checked alias already supplies positive history. A stable readable scope +containing only online UUID records can still supply `NEVER_JOINED`; malformed offline records and +interrupted/corruption backups keep the result unknown. This uses the same bounded worker-owned index +and admission limits, with fresh bounded enumeration before each wider negative result because directory +timestamps cannot certify unchanged contents. Online UUIDs require version 4 and variant 2; unsupported +aliases retain positives but cannot certify wider absence. No profile lookup or history mutation is used. Existing UUID-only methods retain +their behavior. Providers that implement only the original lookup method preserve positives but +cannot certify the wider negative scope. + +A generation-owned join listener and initial UUID-only online snapshot supply positive evidence for players whose +first history file has not yet been saved. The requesting/worker threads never inspect player entity state. +This evidence retains at most 65,536 joined UUIDs; failed snapshot capture or capacity exhaustion disables new +negative answers while retained positive evidence remains usable. Retired listeners cannot repopulate the old +generation. Alias requests share the same admission/retirement boundary as individual reads: an unfinished +aggregate becomes `UNKNOWN` at retirement even if one alias already completed positive. + +These are checks against the server's retained history for the resolved identity. They cannot reconstruct +deleted history or unrelated UUIDs after an identity-mode change. Keep uncertain migrated identities visible. +Shutdown retires requests before existing cleanup shuts down the shared worker. Re-enable retires the previous +service; delayed reads do not publish into its replacement. The service creates no executor or alternate user +storage and does not replace existing lifecycle cleanup. + +An optional display filter must keep both `JOINED` and `UNKNOWN`, and exclude only confirmed `NEVER_JOINED`. +Filtering must not change award eligibility. Exact consumer integration and live platform validation are +separate acceptance checks; passing the library's focused tests does not prove either.