diff --git a/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java b/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java
index 7429aeb..94bbdf0 100644
--- a/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java
+++ b/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java
@@ -11,10 +11,22 @@
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
-/** Resolves DataRegistryApi snapshots without importing or managing DataRegistryApi entities. */
+/**
+ * Resolves immutable DataRegistry player snapshots without importing or managing DataRegistry entities.
+ *
+ *
Synchronous methods are compatibility helpers. They are cache-first and may use a bounded
+ * persisted fallback only from background threads. They never wait for persistence on a likely
+ * server, event-loop, or Netty thread. New code that needs offline-player correctness should use
+ * the explicit asynchronous methods.
+ */
public final class PlayerReferenceResolver {
+ private static final long PERSISTED_LOOKUP_TIMEOUT_SECONDS = 3L;
+
private final PlayerDirectory playerDirectory;
public PlayerReferenceResolver(DataRegistryApi dataRegistry) {
@@ -25,37 +37,222 @@ public PlayerReferenceResolver(PlayerDirectory playerDirectory) {
this.playerDirectory = Objects.requireNonNull(playerDirectory, "playerDirectory");
}
- public Optional findByUuid(UUID uuid) { return findIdentityByUuid(uuid).map(PlayerReference::from); }
- public Optional findByUuid(String uuid) { return findIdentityByUuid(uuid).map(PlayerReference::from); }
- public Optional findByIdentifier(String identifier) { return findIdentityByIdentifier(identifier).map(PlayerReference::from); }
- public Optional findIdentityByUuid(UUID uuid) { return playerDirectory.findActiveIdentityCached(uuid); }
- public Optional findIdentityByUuid(String uuid) { return playerDirectory.findActiveIdentityCached(uuid); }
+ public Optional findByUuid(UUID uuid) {
+ return findIdentityByUuid(uuid).map(PlayerReference::from);
+ }
+
+ public Optional findByUuid(String uuid) {
+ return findIdentityByUuid(uuid).map(PlayerReference::from);
+ }
+
+ public Optional findByIdentifier(String identifier) {
+ return findIdentityByIdentifier(identifier).map(PlayerReference::from);
+ }
+
+ public CompletionStage> findByUuidAsync(UUID uuid) {
+ return findIdentityByUuidAsync(uuid).thenApply(identity -> identity.map(PlayerReference::from));
+ }
+
+ public CompletionStage> findByUuidAsync(String uuid) {
+ return findIdentityByUuidAsync(uuid).thenApply(identity -> identity.map(PlayerReference::from));
+ }
+
+ public CompletionStage> findByIdentifierAsync(String identifier) {
+ return findIdentityByIdentifierAsync(identifier).thenApply(identity -> identity.map(PlayerReference::from));
+ }
+
+ public Optional findIdentityByUuid(UUID uuid) {
+ if (uuid == null) {
+ return Optional.empty();
+ }
+ Optional cached = playerDirectory.findActiveIdentityCached(uuid);
+ if (cached.isPresent() || !mayWaitForPersistence()) {
+ return cached;
+ }
+ return await(playerDirectory.findByUuid(uuid));
+ }
+
+ public Optional findIdentityByUuid(String uuid) {
+ if (uuid == null || uuid.isBlank()) {
+ return Optional.empty();
+ }
+ String normalized = uuid.trim();
+ Optional cached = playerDirectory.findActiveIdentityCached(normalized);
+ if (cached.isPresent() || !mayWaitForPersistence()) {
+ return cached;
+ }
+ return await(playerDirectory.findByUuid(normalized));
+ }
+
+ public CompletionStage> findIdentityByUuidAsync(UUID uuid) {
+ if (uuid == null) {
+ return completedEmpty();
+ }
+ Optional cached = playerDirectory.findActiveIdentityCached(uuid);
+ return cached.isPresent()
+ ? CompletableFuture.completedFuture(cached)
+ : playerDirectory.findByUuid(uuid);
+ }
+
+ public CompletionStage> findIdentityByUuidAsync(String uuid) {
+ if (uuid == null || uuid.isBlank()) {
+ return completedEmpty();
+ }
+ String normalized = uuid.trim();
+ Optional cached = playerDirectory.findActiveIdentityCached(normalized);
+ return cached.isPresent()
+ ? CompletableFuture.completedFuture(cached)
+ : playerDirectory.findByUuid(normalized);
+ }
+
public Optional findIdentityByUsername(String username) {
- if (username == null || username.isBlank()) return Optional.empty();
- String normalized = username.trim().toLowerCase(Locale.ROOT);
- return playerDirectory.snapshotActiveIdentities().values().stream()
- .filter(identity -> identity.username().equalsIgnoreCase(normalized)).findFirst();
+ if (username == null || username.isBlank()) {
+ return Optional.empty();
+ }
+ String normalized = username.trim();
+ Optional cached = findCachedIdentityByUsername(normalized);
+ if (cached.isPresent() || !mayWaitForPersistence()) {
+ return cached;
+ }
+ return await(playerDirectory.findByUsernameIgnoreCase(normalized));
+ }
+
+ public CompletionStage> findIdentityByUsernameAsync(String username) {
+ if (username == null || username.isBlank()) {
+ return completedEmpty();
+ }
+ String normalized = username.trim();
+ Optional cached = findCachedIdentityByUsername(normalized);
+ return cached.isPresent()
+ ? CompletableFuture.completedFuture(cached)
+ : playerDirectory.findByUsernameIgnoreCase(normalized);
}
+
public Optional findIdentityByIdentifier(String identifier) {
- if (identifier == null || identifier.isBlank()) return Optional.empty();
- try { return findIdentityByUuid(UUID.fromString(identifier.trim())); }
- catch (IllegalArgumentException ignored) { return findIdentityByUsername(identifier); }
+ if (identifier == null || identifier.isBlank()) {
+ return Optional.empty();
+ }
+ String normalized = identifier.trim();
+ try {
+ return findIdentityByUuid(UUID.fromString(normalized));
+ } catch (IllegalArgumentException ignored) {
+ return findIdentityByUsername(normalized);
+ }
+ }
+
+ public CompletionStage> findIdentityByIdentifierAsync(String identifier) {
+ if (identifier == null || identifier.isBlank()) {
+ return completedEmpty();
+ }
+ String normalized = identifier.trim();
+ try {
+ return findIdentityByUuidAsync(UUID.fromString(normalized));
+ } catch (IllegalArgumentException ignored) {
+ return findIdentityByUsernameAsync(normalized);
+ }
}
+
+ /**
+ * Compatibility lookup by stable player id. Despite the historical method name, this method is
+ * cache-first and may use persistence from a background thread.
+ */
public Optional findActiveIdentityById(Long playerId) {
- if (playerId == null || playerId <= 0L) return Optional.empty();
- return playerDirectory.snapshotActiveIdentities().values().stream()
- .filter(identity -> playerId.equals(identity.playerId())).findFirst();
+ if (playerId == null || playerId <= 0L) {
+ return Optional.empty();
+ }
+ Optional cached = findCachedIdentityById(playerId);
+ if (cached.isPresent() || !mayWaitForPersistence()) {
+ return cached;
+ }
+ return await(playerDirectory.findByPlayerId(playerId));
}
+
+ public CompletionStage> findIdentityByIdAsync(Long playerId) {
+ if (playerId == null || playerId <= 0L) {
+ return completedEmpty();
+ }
+ Optional cached = findCachedIdentityById(playerId);
+ return cached.isPresent()
+ ? CompletableFuture.completedFuture(cached)
+ : playerDirectory.findByPlayerId(playerId);
+ }
+
public CompletionStage> findPersistedIdentityByIdentifier(String identifier) {
- return playerDirectory.findByIdentifier(identifier);
+ if (identifier == null || identifier.isBlank()) {
+ return completedEmpty();
+ }
+ return playerDirectory.findByIdentifier(identifier.trim());
}
- public CompletableFuture> whenReady(UUID uuid) { return playerDirectory.whenReady(uuid); }
- public PlayerReference resolveManaged(Session ignored, UUID uuid) { return findByUuid(uuid).orElse(null); }
- public PlayerReference resolveManaged(Session ignored, String uuid) { return findByUuid(uuid).orElse(null); }
+
+ public CompletableFuture> whenReady(UUID uuid) {
+ return playerDirectory.whenReady(uuid);
+ }
+
+ public PlayerReference resolveManaged(Session ignored, UUID uuid) {
+ return findByUuid(uuid).orElse(null);
+ }
+
+ public PlayerReference resolveManaged(Session ignored, String uuid) {
+ return findByUuid(uuid).orElse(null);
+ }
+
public PlayerReference resolveManagedById(Session ignored, Long playerId) {
- if (playerId == null || playerId <= 0L) return null;
+ if (playerId == null || playerId <= 0L) {
+ return null;
+ }
+ return findActiveIdentityById(playerId)
+ .map(PlayerReference::from)
+ .orElseGet(() -> PlayerReference.byId(playerId));
+ }
+
+ private Optional findCachedIdentityByUsername(String username) {
+ String normalized = username.toLowerCase(Locale.ROOT);
+ return playerDirectory.snapshotActiveIdentities().values().stream()
+ .filter(identity -> identity.username().toLowerCase(Locale.ROOT).equals(normalized))
+ .findFirst();
+ }
+
+ private Optional findCachedIdentityById(Long playerId) {
return playerDirectory.snapshotActiveIdentities().values().stream()
.filter(identity -> playerId.equals(identity.playerId()))
- .findFirst().map(PlayerReference::from).orElseGet(() -> PlayerReference.byId(playerId));
+ .findFirst();
+ }
+
+ private static boolean mayWaitForPersistence() {
+ return !isLikelyServerEventThread(Thread.currentThread().getName());
+ }
+
+ static boolean isLikelyServerEventThread(String threadName) {
+ if (threadName == null) {
+ return false;
+ }
+ String normalized = threadName.toLowerCase(Locale.ROOT);
+ return normalized.contains("server thread")
+ || normalized.contains("main")
+ || normalized.contains("event")
+ || normalized.contains("netty");
+ }
+
+ private static Optional await(CompletionStage> stage) {
+ if (stage == null) {
+ return Optional.empty();
+ }
+ CompletableFuture> future = stage.toCompletableFuture();
+ try {
+ Optional result = future.get(PERSISTED_LOOKUP_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ return result == null ? Optional.empty() : result;
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ return Optional.empty();
+ } catch (TimeoutException exception) {
+ future.cancel(true);
+ return Optional.empty();
+ } catch (ExecutionException | RuntimeException exception) {
+ return Optional.empty();
+ }
+ }
+
+ private static CompletionStage> completedEmpty() {
+ return CompletableFuture.completedFuture(Optional.empty());
}
}
diff --git a/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java b/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java
index a7aabc4..2b4b51d 100644
--- a/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java
+++ b/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java
@@ -8,9 +8,14 @@
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -20,7 +25,7 @@
class PlayerReferenceResolverTest {
@Test
- void resolveManagedUsesActiveIdentityWithoutTouchingFeatureOrm() {
+ void resolveManagedUsesActiveIdentityWithoutQueryingPersistence() {
PlayerDirectory directory = mock(PlayerDirectory.class);
Session session = mock(Session.class);
UUID uuid = UUID.randomUUID();
@@ -36,32 +41,146 @@ void resolveManagedUsesActiveIdentityWithoutTouchingFeatureOrm() {
}
@Test
- void resolveManagedReturnsNullWhenNoActiveSnapshotExists() {
+ void resolveManagedUsesPersistedIdentityFromBackgroundWorkerWhenPlayerIsOffline() throws Exception {
PlayerDirectory directory = mock(PlayerDirectory.class);
Session session = mock(Session.class);
UUID uuid = UUID.randomUUID();
+ PlayerIdentity identity = new PlayerIdentity(13L, uuid, "OfflineAlice");
when(directory.findActiveIdentityCached(uuid)).thenReturn(Optional.empty());
+ when(directory.findByUuid(uuid)).thenReturn(CompletableFuture.completedFuture(Optional.of(identity)));
- PlayerReference result = new PlayerReferenceResolver(directory).resolveManaged(session, uuid);
+ PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory);
+ PlayerReference result = runOnThread("ProxyFeatures-Votifier-worker", () -> resolver.resolveManaged(session, uuid));
- assertNull(result);
- verify(directory, never()).findByUuid(uuid);
+ assertEquals(PlayerReference.from(identity), result);
+ verify(directory).findByUuid(uuid);
verifyNoInteractions(session);
}
@Test
- void resolveManagedByIdUsesSnapshotAndFallsBackToScalarId() {
+ void synchronousLookupNeverQueriesPersistenceFromEventThread() throws Exception {
+ PlayerDirectory directory = mock(PlayerDirectory.class);
+ UUID uuid = UUID.randomUUID();
+
+ when(directory.snapshotActiveIdentities()).thenReturn(Map.of());
+
+ PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory);
+ Optional result = runOnThread(
+ "Velocity Netty EventLoop",
+ () -> resolver.findByIdentifier("OfflineAlice")
+ );
+
+ assertEquals(Optional.empty(), result);
+ verify(directory, never()).findByUsernameIgnoreCase("OfflineAlice");
+ verify(directory, never()).findByUuid(uuid);
+ }
+
+ @Test
+ void asyncLookupQueriesPersistenceWithoutBlockingEventThread() throws Exception {
+ PlayerDirectory directory = mock(PlayerDirectory.class);
+ UUID uuid = UUID.randomUUID();
+ PlayerIdentity identity = new PlayerIdentity(14L, uuid, "OfflineAlice");
+
+ when(directory.snapshotActiveIdentities()).thenReturn(Map.of());
+ when(directory.findByUsernameIgnoreCase("OfflineAlice"))
+ .thenReturn(CompletableFuture.completedFuture(Optional.of(identity)));
+
+ PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory);
+ Optional result = runOnThread(
+ "Velocity Netty EventLoop",
+ () -> resolver.findByIdentifierAsync("OfflineAlice").toCompletableFuture().join()
+ );
+
+ assertEquals(Optional.of(PlayerReference.from(identity)), result);
+ verify(directory).findByUsernameIgnoreCase("OfflineAlice");
+ }
+
+ @Test
+ void resolveManagedReturnsNullWhenPersistedIdentityDoesNotExist() throws Exception {
PlayerDirectory directory = mock(PlayerDirectory.class);
Session session = mock(Session.class);
UUID uuid = UUID.randomUUID();
- PlayerIdentity identity = new PlayerIdentity(22L, uuid, "Bob");
- when(directory.snapshotActiveIdentities()).thenReturn(Map.of(uuid.toString(), identity));
+
+ when(directory.findActiveIdentityCached(uuid)).thenReturn(Optional.empty());
+ when(directory.findByUuid(uuid)).thenReturn(CompletableFuture.completedFuture(Optional.empty()));
+
+ PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory);
+ PlayerReference result = runOnThread("ProxyFeatures-worker", () -> resolver.resolveManaged(session, uuid));
+
+ assertNull(result);
+ verify(directory).findByUuid(uuid);
+ verifyNoInteractions(session);
+ }
+
+ @Test
+ void findByIdentifierAsyncUsesPersistedCaseInsensitiveUsernameWhenOffline() {
+ PlayerDirectory directory = mock(PlayerDirectory.class);
+ UUID uuid = UUID.randomUUID();
+ PlayerIdentity identity = new PlayerIdentity(21L, uuid, "Alice");
+
+ when(directory.snapshotActiveIdentities()).thenReturn(Map.of());
+ when(directory.findByUsernameIgnoreCase("aLiCe"))
+ .thenReturn(CompletableFuture.completedFuture(Optional.of(identity)));
+
+ Optional result = new PlayerReferenceResolver(directory)
+ .findByIdentifierAsync("aLiCe")
+ .toCompletableFuture()
+ .join();
+
+ assertEquals(Optional.of(PlayerReference.from(identity)), result);
+ verify(directory).findByUsernameIgnoreCase("aLiCe");
+ }
+
+ @Test
+ void resolveManagedByIdUsesActiveThenPersistedIdentityAndFinallyScalarId() throws Exception {
+ PlayerDirectory directory = mock(PlayerDirectory.class);
+ Session session = mock(Session.class);
+ UUID activeUuid = UUID.randomUUID();
+ UUID offlineUuid = UUID.randomUUID();
+ PlayerIdentity activeIdentity = new PlayerIdentity(22L, activeUuid, "Bob");
+ PlayerIdentity offlineIdentity = new PlayerIdentity(23L, offlineUuid, "OfflineBob");
+
+ when(directory.snapshotActiveIdentities())
+ .thenReturn(Map.of(activeUuid.toString(), activeIdentity), Map.of(), Map.of());
+ when(directory.findByPlayerId(23L))
+ .thenReturn(CompletableFuture.completedFuture(Optional.of(offlineIdentity)));
+ when(directory.findByPlayerId(24L))
+ .thenReturn(CompletableFuture.completedFuture(Optional.empty()));
PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory);
- assertEquals(PlayerReference.from(identity), resolver.resolveManagedById(session, 22L));
- assertEquals(PlayerReference.byId(23L), resolver.resolveManagedById(session, 23L));
+ assertEquals(PlayerReference.from(activeIdentity), resolver.resolveManagedById(session, 22L));
+ assertEquals(
+ PlayerReference.from(offlineIdentity),
+ runOnThread("ProxyFeatures-worker", () -> resolver.resolveManagedById(session, 23L))
+ );
+ assertEquals(
+ PlayerReference.byId(24L),
+ runOnThread("ProxyFeatures-worker", () -> resolver.resolveManagedById(session, 24L))
+ );
verifyNoInteractions(session);
}
+
+ @Test
+ void detectsLikelyServerAndEventThreads() {
+ assertTrue(PlayerReferenceResolver.isLikelyServerEventThread("Server thread"));
+ assertTrue(PlayerReferenceResolver.isLikelyServerEventThread("Velocity Netty EventLoop"));
+ assertTrue(PlayerReferenceResolver.isLikelyServerEventThread("main"));
+ assertFalse(PlayerReferenceResolver.isLikelyServerEventThread("ProxyFeatures-Votifier-worker"));
+ assertFalse(PlayerReferenceResolver.isLikelyServerEventThread(null));
+ }
+
+ private static T runOnThread(String name, Supplier supplier) throws Exception {
+ CompletableFuture result = new CompletableFuture<>();
+ Thread thread = new Thread(() -> {
+ try {
+ result.complete(supplier.get());
+ } catch (Throwable throwable) {
+ result.completeExceptionally(throwable);
+ }
+ }, name);
+ thread.start();
+ return result.get(5, TimeUnit.SECONDS);
+ }
}