From c7c6f1cf293cf1fccfb0c0cf1053f828a5961687 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 14:21:48 +0200 Subject: [PATCH 01/10] Fix persisted offline player identity resolution --- .../persistence/PlayerReferenceResolver.java | 110 ++++++++++++++---- 1 file changed, 89 insertions(+), 21 deletions(-) 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..053fb68 100644 --- a/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java +++ b/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java @@ -11,6 +11,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; /** Resolves DataRegistryApi snapshots without importing or managing DataRegistryApi entities. */ public final class PlayerReferenceResolver { @@ -25,37 +26,104 @@ 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 Optional findIdentityByUuid(UUID uuid) { + if (uuid == null) { + return Optional.empty(); + } + return playerDirectory.findActiveIdentityCached(uuid) + .or(() -> await(playerDirectory.findByUuid(uuid))); + } + + public Optional findIdentityByUuid(String uuid) { + if (uuid == null || uuid.isBlank()) { + return Optional.empty(); + } + String normalized = uuid.trim(); + return playerDirectory.findActiveIdentityCached(normalized) + .or(() -> await(playerDirectory.findByUuid(normalized))); + } + public Optional findIdentityByUsername(String username) { - if (username == null || username.isBlank()) return Optional.empty(); + 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(); + Optional active = playerDirectory.snapshotActiveIdentities().values().stream() + .filter(identity -> identity.username().equalsIgnoreCase(normalized)) + .findFirst(); + return active.or(() -> await(playerDirectory.findByUsernameIgnoreCase(username.trim()))); } + 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(); + } + try { + return findIdentityByUuid(UUID.fromString(identifier.trim())); + } catch (IllegalArgumentException ignored) { + return findIdentityByUsername(identifier); + } } + 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 active = playerDirectory.snapshotActiveIdentities().values().stream() + .filter(identity -> playerId.equals(identity.playerId())) + .findFirst(); + return active.or(() -> await(playerDirectory.findByPlayerId(playerId))); } + public CompletionStage> findPersistedIdentityByIdentifier(String identifier) { return playerDirectory.findByIdentifier(identifier); } - 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; - return playerDirectory.snapshotActiveIdentities().values().stream() - .filter(identity -> playerId.equals(identity.playerId())) - .findFirst().map(PlayerReference::from).orElseGet(() -> PlayerReference.byId(playerId)); + if (playerId == null || playerId <= 0L) { + return null; + } + return findActiveIdentityById(playerId) + .map(PlayerReference::from) + .orElseGet(() -> PlayerReference.byId(playerId)); + } + + private static Optional await(CompletionStage> stage) { + if (stage == null) { + return Optional.empty(); + } + try { + Optional result = stage.toCompletableFuture().get(); + return result == null ? Optional.empty() : result; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return Optional.empty(); + } catch (ExecutionException | RuntimeException exception) { + return Optional.empty(); + } } } From 3e87a6bf9449dc8e759caaad1a8328573a07cda9 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 14:23:18 +0200 Subject: [PATCH 02/10] Bound compatibility identity lookups --- .../persistence/PlayerReferenceResolver.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 053fb68..34add0a 100644 --- a/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java +++ b/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java @@ -12,10 +12,14 @@ 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. */ public final class PlayerReferenceResolver { + private static final long PERSISTED_LOOKUP_TIMEOUT_SECONDS = 3L; + private final PlayerDirectory playerDirectory; public PlayerReferenceResolver(DataRegistryApi dataRegistry) { @@ -88,7 +92,10 @@ public Optional findActiveIdentityById(Long playerId) { } public CompletionStage> findPersistedIdentityByIdentifier(String identifier) { - return playerDirectory.findByIdentifier(identifier); + if (identifier == null || identifier.isBlank()) { + return CompletableFuture.completedFuture(Optional.empty()); + } + return playerDirectory.findByIdentifier(identifier.trim()); } public CompletableFuture> whenReady(UUID uuid) { @@ -117,12 +124,15 @@ private static Optional await(CompletionStage> stage) { return Optional.empty(); } try { - Optional result = stage.toCompletableFuture().get(); + Optional result = stage.toCompletableFuture().get( + PERSISTED_LOOKUP_TIMEOUT_SECONDS, + TimeUnit.SECONDS + ); return result == null ? Optional.empty() : result; } catch (InterruptedException exception) { Thread.currentThread().interrupt(); return Optional.empty(); - } catch (ExecutionException | RuntimeException exception) { + } catch (ExecutionException | TimeoutException | RuntimeException exception) { return Optional.empty(); } } From cfa2fcabb576d5f4a33a2d2b9f2657aa2e13c1f2 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 14:23:44 +0200 Subject: [PATCH 03/10] Test offline identity fallback --- .../PlayerReferenceResolverTest.java | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) 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..154d44f 100644 --- a/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java +++ b/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java @@ -8,6 +8,7 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -20,7 +21,7 @@ class PlayerReferenceResolverTest { @Test - void resolveManagedUsesActiveIdentityWithoutTouchingFeatureOrm() { + void resolveManagedUsesActiveIdentityWithoutQueryingPersistence() { PlayerDirectory directory = mock(PlayerDirectory.class); Session session = mock(Session.class); UUID uuid = UUID.randomUUID(); @@ -36,32 +37,75 @@ void resolveManagedUsesActiveIdentityWithoutTouchingFeatureOrm() { } @Test - void resolveManagedReturnsNullWhenNoActiveSnapshotExists() { + void resolveManagedUsesPersistedIdentityWhenPlayerIsOffline() { 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); - assertNull(result); - verify(directory, never()).findByUuid(uuid); + assertEquals(PlayerReference.from(identity), result); + verify(directory).findByUuid(uuid); verifyNoInteractions(session); } @Test - void resolveManagedByIdUsesSnapshotAndFallsBackToScalarId() { + void resolveManagedReturnsNullWhenIdentityDoesNotExist() { 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())); + + PlayerReference result = new PlayerReferenceResolver(directory).resolveManaged(session, uuid); + + assertNull(result); + verify(directory).findByUuid(uuid); + verifyNoInteractions(session); + } + + @Test + void findByIdentifierUsesPersistedCaseInsensitiveUsernameWhenOffline() { + 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).findByIdentifier("aLiCe"); + + assertEquals(Optional.of(PlayerReference.from(identity)), result); + verify(directory).findByUsernameIgnoreCase("aLiCe"); + } + + @Test + void resolveManagedByIdUsesActiveThenPersistedIdentityAndFinallyScalarId() { + 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), resolver.resolveManagedById(session, 23L)); + assertEquals(PlayerReference.byId(24L), resolver.resolveManagedById(session, 24L)); verifyNoInteractions(session); } } From 3cd446934355f00e6ba6bdbab90850c8657fc7fa Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 14:25:41 +0200 Subject: [PATCH 04/10] Restore offline language identity lookup --- .../service/LanguageService.java | 66 ++++++++++++++----- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageService.java b/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageService.java index d2df1b5..4bccf29 100644 --- a/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageService.java +++ b/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageService.java @@ -19,12 +19,17 @@ 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; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.stream.Collectors; public final class LanguageService implements LanguageAPI { + private static final long PERSISTED_LOOKUP_TIMEOUT_SECONDS = 3L; + public record LanguageState(Language preference, Language effective) { } @@ -80,20 +85,18 @@ public Language getEffectiveLanguage(UUID playerUuid) { public void set(UUID playerUuid, Language language) { Objects.requireNonNull(language, "language"); - resolvePlayerId(playerUuid).thenCompose(playerId -> { - if (playerId.isEmpty()) { - return CompletableFuture.completedFuture(null); - } - return players.findLanguage(playerId.get()).thenApply(current -> { - LanguageState next = new LanguageState( - language, - resolveEffectiveLanguageForWrite(playerUuid, language, current.orElse(null)) - ); - players.saveLanguage(playerId.get(), next.preference().name(), next.effective().name()); - stateCache.put(playerUuid, next); - return null; - }); - }); + Optional playerId = await(resolvePlayerId(playerUuid)); + if (playerId.isEmpty()) { + return; + } + + Optional current = await(players.findLanguage(playerId.get())); + LanguageState next = new LanguageState( + language, + resolveEffectiveLanguageForWrite(playerUuid, language, current.orElse(null)) + ); + awaitVoid(players.saveLanguage(playerId.get(), next.preference().name(), next.effective().name())); + stateCache.put(playerUuid, next); } public Optional resolveUuidByName(String username) { @@ -113,7 +116,10 @@ public Optional resolveUuidByName(String username) { } public Optional findUuidByName(String username) { - return Optional.empty(); + if (username == null || username.isBlank()) { + return Optional.empty(); + } + return await(players.findIdentityByUsername(username.trim())).map(PlayerIdentity::uuid); } private LanguageState getState(UUID playerUuid) { @@ -309,6 +315,36 @@ private static String nullToEmpty(String value) { return value == null ? "" : value; } + private static Optional await(CompletionStage> stage) { + if (stage == null) { + return Optional.empty(); + } + try { + Optional result = stage.toCompletableFuture().get( + PERSISTED_LOOKUP_TIMEOUT_SECONDS, + TimeUnit.SECONDS + ); + return result == null ? Optional.empty() : result; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return Optional.empty(); + } catch (ExecutionException | TimeoutException | RuntimeException exception) { + return Optional.empty(); + } + } + + private static void awaitVoid(CompletionStage stage) { + if (stage == null) { + return; + } + try { + stage.toCompletableFuture().get(PERSISTED_LOOKUP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } catch (ExecutionException | TimeoutException | RuntimeException ignored) { + } + } + private void scheduleAutoDetectedMessage(UUID playerUuid, Language effectiveLanguage) { feature.getLifecycleManager().getTaskManager().scheduleDelayedTask(() -> feature.getPlugin().getProxy().getPlayer(playerUuid).ifPresent(player -> { From ad8b43e08ea4483043523a1fe9cd29ec00415d1d Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 14:26:09 +0200 Subject: [PATCH 05/10] Test offline language lookup and synchronous update --- .../service/LanguageServiceTest.java | 69 ++++++++++++++++--- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java b/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java index 03e2650..3e677d9 100644 --- a/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java +++ b/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java @@ -3,6 +3,8 @@ import com.velocitypowered.api.proxy.ProxyServer; import nl.hauntedmc.dataregistry.api.DataRegistryApi; import nl.hauntedmc.dataregistry.api.player.PlayerData; +import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; +import nl.hauntedmc.dataregistry.api.player.PlayerLanguageSettings; import nl.hauntedmc.proxyfeatures.ProxyFeatures; import nl.hauntedmc.proxyfeatures.api.io.localization.Language; import nl.hauntedmc.proxyfeatures.features.playerlanguage.PlayerLanguage; @@ -14,32 +16,76 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class LanguageServiceTest { @Test void setDoesNotCacheWhenPlayerRowIsMissing() throws Exception { + TestContext context = context(); + UUID uuid = UUID.randomUUID(); + + when(context.players().findActiveIdentityCached(uuid)).thenReturn(Optional.empty()); + when(context.players().findIdentity(uuid)) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + context.service().set(uuid, Language.NL); + + assertTrue(cache(context.service()).isEmpty()); + verify(context.players(), never()).saveLanguage(anyLong(), anyString(), anyString()); + } + + @Test + void findUuidByNameUsesPersistedIdentityForOfflinePlayer() { + TestContext context = context(); + UUID uuid = UUID.randomUUID(); + PlayerIdentity identity = new PlayerIdentity(12L, uuid, "OfflineAlice"); + + when(context.players().findIdentityByUsername("OfflineAlice")) + .thenReturn(CompletableFuture.completedFuture(Optional.of(identity))); + + assertEquals(Optional.of(uuid), context.service().findUuidByName(" OfflineAlice ")); + } + + @Test + void setWaitsForPersistenceAndUpdatesCacheBeforeReturning() throws Exception { + TestContext context = context(); + UUID uuid = UUID.randomUUID(); + PlayerIdentity identity = new PlayerIdentity(13L, uuid, "Alice"); + PlayerLanguageSettings settings = new PlayerLanguageSettings("EN", "EN"); + + when(context.players().findActiveIdentityCached(uuid)).thenReturn(Optional.of(identity)); + when(context.players().findLanguage(13L)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(settings))); + when(context.players().saveLanguage(13L, "NL", "NL")) + .thenReturn(CompletableFuture.completedFuture(null)); + + context.service().set(uuid, Language.NL); + + assertEquals(Language.NL, context.service().getPreference(uuid)); + assertEquals(Language.NL, context.service().getEffectiveLanguage(uuid)); + verify(context.players()).saveLanguage(13L, "NL", "NL"); + } + + private static TestContext context() { PlayerLanguage feature = mock(PlayerLanguage.class); ProxyFeatures plugin = mock(ProxyFeatures.class); ProxyServer proxy = mock(ProxyServer.class); DataRegistryApi dataRegistry = mock(DataRegistryApi.class); PlayerData players = mock(PlayerData.class); - UUID uuid = UUID.randomUUID(); when(feature.getPlugin()).thenReturn(plugin); when(plugin.getProxy()).thenReturn(proxy); - when(proxy.getPlayer(uuid)).thenReturn(Optional.empty()); when(dataRegistry.players()).thenReturn(players); - when(players.findActiveIdentityCached(uuid)).thenReturn(Optional.empty()); - when(players.findIdentity(uuid)).thenReturn(CompletableFuture.completedFuture(Optional.empty())); - - LanguageService service = new LanguageService(feature, dataRegistry); - service.set(uuid, Language.NL); - assertTrue(cache(service).isEmpty()); - verify(players, never()).saveLanguage(anyLong(), anyString(), anyString()); + return new TestContext(players, new LanguageService(feature, dataRegistry)); } @SuppressWarnings("unchecked") @@ -48,4 +94,7 @@ private static Map cache(LanguageService se field.setAccessible(true); return (Map) field.get(service); } + + private record TestContext(PlayerData players, LanguageService service) { + } } From b198debc66391a574dff39618b7984859537e8bf Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 14:26:44 +0200 Subject: [PATCH 06/10] Fix language settings test fixture --- .../features/playerlanguage/service/LanguageServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java b/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java index 3e677d9..9aaf1b1 100644 --- a/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java +++ b/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java @@ -59,7 +59,7 @@ void setWaitsForPersistenceAndUpdatesCacheBeforeReturning() throws Exception { TestContext context = context(); UUID uuid = UUID.randomUUID(); PlayerIdentity identity = new PlayerIdentity(13L, uuid, "Alice"); - PlayerLanguageSettings settings = new PlayerLanguageSettings("EN", "EN"); + PlayerLanguageSettings settings = new PlayerLanguageSettings(13L, "EN", "EN"); when(context.players().findActiveIdentityCached(uuid)).thenReturn(Optional.of(identity)); when(context.players().findLanguage(13L)) From 78425e7e5fa8c5eee3aa198fe8ecbd8e0fd27603 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 14:32:18 +0200 Subject: [PATCH 07/10] Keep vote fix scoped to shared identity resolver --- .../service/LanguageService.java | 66 +++++-------------- 1 file changed, 15 insertions(+), 51 deletions(-) diff --git a/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageService.java b/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageService.java index 4bccf29..d2df1b5 100644 --- a/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageService.java +++ b/src/main/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageService.java @@ -19,17 +19,12 @@ 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; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.stream.Collectors; public final class LanguageService implements LanguageAPI { - private static final long PERSISTED_LOOKUP_TIMEOUT_SECONDS = 3L; - public record LanguageState(Language preference, Language effective) { } @@ -85,18 +80,20 @@ public Language getEffectiveLanguage(UUID playerUuid) { public void set(UUID playerUuid, Language language) { Objects.requireNonNull(language, "language"); - Optional playerId = await(resolvePlayerId(playerUuid)); - if (playerId.isEmpty()) { - return; - } - - Optional current = await(players.findLanguage(playerId.get())); - LanguageState next = new LanguageState( - language, - resolveEffectiveLanguageForWrite(playerUuid, language, current.orElse(null)) - ); - awaitVoid(players.saveLanguage(playerId.get(), next.preference().name(), next.effective().name())); - stateCache.put(playerUuid, next); + resolvePlayerId(playerUuid).thenCompose(playerId -> { + if (playerId.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + return players.findLanguage(playerId.get()).thenApply(current -> { + LanguageState next = new LanguageState( + language, + resolveEffectiveLanguageForWrite(playerUuid, language, current.orElse(null)) + ); + players.saveLanguage(playerId.get(), next.preference().name(), next.effective().name()); + stateCache.put(playerUuid, next); + return null; + }); + }); } public Optional resolveUuidByName(String username) { @@ -116,10 +113,7 @@ public Optional resolveUuidByName(String username) { } public Optional findUuidByName(String username) { - if (username == null || username.isBlank()) { - return Optional.empty(); - } - return await(players.findIdentityByUsername(username.trim())).map(PlayerIdentity::uuid); + return Optional.empty(); } private LanguageState getState(UUID playerUuid) { @@ -315,36 +309,6 @@ private static String nullToEmpty(String value) { return value == null ? "" : value; } - private static Optional await(CompletionStage> stage) { - if (stage == null) { - return Optional.empty(); - } - try { - Optional result = stage.toCompletableFuture().get( - PERSISTED_LOOKUP_TIMEOUT_SECONDS, - TimeUnit.SECONDS - ); - return result == null ? Optional.empty() : result; - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - return Optional.empty(); - } catch (ExecutionException | TimeoutException | RuntimeException exception) { - return Optional.empty(); - } - } - - private static void awaitVoid(CompletionStage stage) { - if (stage == null) { - return; - } - try { - stage.toCompletableFuture().get(PERSISTED_LOOKUP_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - } catch (ExecutionException | TimeoutException | RuntimeException ignored) { - } - } - private void scheduleAutoDetectedMessage(UUID playerUuid, Language effectiveLanguage) { feature.getLifecycleManager().getTaskManager().scheduleDelayedTask(() -> feature.getPlugin().getProxy().getPlayer(playerUuid).ifPresent(player -> { From 548568574799d96a181425e1ad6e2be4c6f38a43 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 14:32:35 +0200 Subject: [PATCH 08/10] Remove unrelated language regression tests --- .../service/LanguageServiceTest.java | 69 +++---------------- 1 file changed, 10 insertions(+), 59 deletions(-) diff --git a/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java b/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java index 9aaf1b1..03e2650 100644 --- a/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java +++ b/src/test/java/nl/hauntedmc/proxyfeatures/features/playerlanguage/service/LanguageServiceTest.java @@ -3,8 +3,6 @@ import com.velocitypowered.api.proxy.ProxyServer; import nl.hauntedmc.dataregistry.api.DataRegistryApi; import nl.hauntedmc.dataregistry.api.player.PlayerData; -import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; -import nl.hauntedmc.dataregistry.api.player.PlayerLanguageSettings; import nl.hauntedmc.proxyfeatures.ProxyFeatures; import nl.hauntedmc.proxyfeatures.api.io.localization.Language; import nl.hauntedmc.proxyfeatures.features.playerlanguage.PlayerLanguage; @@ -16,76 +14,32 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.anyLong; -import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; class LanguageServiceTest { @Test void setDoesNotCacheWhenPlayerRowIsMissing() throws Exception { - TestContext context = context(); - UUID uuid = UUID.randomUUID(); - - when(context.players().findActiveIdentityCached(uuid)).thenReturn(Optional.empty()); - when(context.players().findIdentity(uuid)) - .thenReturn(CompletableFuture.completedFuture(Optional.empty())); - - context.service().set(uuid, Language.NL); - - assertTrue(cache(context.service()).isEmpty()); - verify(context.players(), never()).saveLanguage(anyLong(), anyString(), anyString()); - } - - @Test - void findUuidByNameUsesPersistedIdentityForOfflinePlayer() { - TestContext context = context(); - UUID uuid = UUID.randomUUID(); - PlayerIdentity identity = new PlayerIdentity(12L, uuid, "OfflineAlice"); - - when(context.players().findIdentityByUsername("OfflineAlice")) - .thenReturn(CompletableFuture.completedFuture(Optional.of(identity))); - - assertEquals(Optional.of(uuid), context.service().findUuidByName(" OfflineAlice ")); - } - - @Test - void setWaitsForPersistenceAndUpdatesCacheBeforeReturning() throws Exception { - TestContext context = context(); - UUID uuid = UUID.randomUUID(); - PlayerIdentity identity = new PlayerIdentity(13L, uuid, "Alice"); - PlayerLanguageSettings settings = new PlayerLanguageSettings(13L, "EN", "EN"); - - when(context.players().findActiveIdentityCached(uuid)).thenReturn(Optional.of(identity)); - when(context.players().findLanguage(13L)) - .thenReturn(CompletableFuture.completedFuture(Optional.of(settings))); - when(context.players().saveLanguage(13L, "NL", "NL")) - .thenReturn(CompletableFuture.completedFuture(null)); - - context.service().set(uuid, Language.NL); - - assertEquals(Language.NL, context.service().getPreference(uuid)); - assertEquals(Language.NL, context.service().getEffectiveLanguage(uuid)); - verify(context.players()).saveLanguage(13L, "NL", "NL"); - } - - private static TestContext context() { PlayerLanguage feature = mock(PlayerLanguage.class); ProxyFeatures plugin = mock(ProxyFeatures.class); ProxyServer proxy = mock(ProxyServer.class); DataRegistryApi dataRegistry = mock(DataRegistryApi.class); PlayerData players = mock(PlayerData.class); + UUID uuid = UUID.randomUUID(); when(feature.getPlugin()).thenReturn(plugin); when(plugin.getProxy()).thenReturn(proxy); + when(proxy.getPlayer(uuid)).thenReturn(Optional.empty()); when(dataRegistry.players()).thenReturn(players); + when(players.findActiveIdentityCached(uuid)).thenReturn(Optional.empty()); + when(players.findIdentity(uuid)).thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + LanguageService service = new LanguageService(feature, dataRegistry); + service.set(uuid, Language.NL); - return new TestContext(players, new LanguageService(feature, dataRegistry)); + assertTrue(cache(service).isEmpty()); + verify(players, never()).saveLanguage(anyLong(), anyString(), anyString()); } @SuppressWarnings("unchecked") @@ -94,7 +48,4 @@ private static Map cache(LanguageService se field.setAccessible(true); return (Map) field.get(service); } - - private record TestContext(PlayerData players, LanguageService service) { - } } From b4095b96122840f167848f011f202bfc14498d02 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 14:54:20 +0200 Subject: [PATCH 09/10] Harden persisted identity fallback threading --- .../persistence/PlayerReferenceResolver.java | 163 +++++++++++++++--- 1 file changed, 141 insertions(+), 22 deletions(-) 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 34add0a..94bbdf0 100644 --- a/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java +++ b/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java @@ -15,7 +15,14 @@ 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; @@ -42,12 +49,27 @@ 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(); } - return playerDirectory.findActiveIdentityCached(uuid) - .or(() -> await(playerDirectory.findByUuid(uuid))); + Optional cached = playerDirectory.findActiveIdentityCached(uuid); + if (cached.isPresent() || !mayWaitForPersistence()) { + return cached; + } + return await(playerDirectory.findByUuid(uuid)); } public Optional findIdentityByUuid(String uuid) { @@ -55,45 +77,109 @@ public Optional findIdentityByUuid(String uuid) { return Optional.empty(); } String normalized = uuid.trim(); - return playerDirectory.findActiveIdentityCached(normalized) - .or(() -> await(playerDirectory.findByUuid(normalized))); + 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); - Optional active = playerDirectory.snapshotActiveIdentities().values().stream() - .filter(identity -> identity.username().equalsIgnoreCase(normalized)) - .findFirst(); - return active.or(() -> await(playerDirectory.findByUsernameIgnoreCase(username.trim()))); + 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(); } + 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 findIdentityByUuid(UUID.fromString(identifier.trim())); + return findIdentityByUuidAsync(UUID.fromString(normalized)); } catch (IllegalArgumentException ignored) { - return findIdentityByUsername(identifier); + 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(); } - Optional active = playerDirectory.snapshotActiveIdentities().values().stream() - .filter(identity -> playerId.equals(identity.playerId())) - .findFirst(); - return active.or(() -> await(playerDirectory.findByPlayerId(playerId))); + 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) { if (identifier == null || identifier.isBlank()) { - return CompletableFuture.completedFuture(Optional.empty()); + return completedEmpty(); } return playerDirectory.findByIdentifier(identifier.trim()); } @@ -119,21 +205,54 @@ public PlayerReference resolveManagedById(Session ignored, Long playerId) { .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(); + } + + 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 = stage.toCompletableFuture().get( - PERSISTED_LOOKUP_TIMEOUT_SECONDS, - TimeUnit.SECONDS - ); + 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 (ExecutionException | TimeoutException | RuntimeException exception) { + } 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()); + } } From 691c5243258c8f1782c617eda00a39b7ff94afc4 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 22 Jul 2026 19:43:16 +0200 Subject: [PATCH 10/10] Test async-safe offline identity resolution --- .../PlayerReferenceResolverTest.java | 93 +++++++++++++++++-- 1 file changed, 84 insertions(+), 9 deletions(-) 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 154d44f..2b4b51d 100644 --- a/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java +++ b/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java @@ -9,9 +9,13 @@ 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; @@ -37,7 +41,7 @@ void resolveManagedUsesActiveIdentityWithoutQueryingPersistence() { } @Test - void resolveManagedUsesPersistedIdentityWhenPlayerIsOffline() { + void resolveManagedUsesPersistedIdentityFromBackgroundWorkerWhenPlayerIsOffline() throws Exception { PlayerDirectory directory = mock(PlayerDirectory.class); Session session = mock(Session.class); UUID uuid = UUID.randomUUID(); @@ -46,7 +50,8 @@ void resolveManagedUsesPersistedIdentityWhenPlayerIsOffline() { 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)); assertEquals(PlayerReference.from(identity), result); verify(directory).findByUuid(uuid); @@ -54,7 +59,45 @@ void resolveManagedUsesPersistedIdentityWhenPlayerIsOffline() { } @Test - void resolveManagedReturnsNullWhenIdentityDoesNotExist() { + 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(); @@ -62,7 +105,8 @@ void resolveManagedReturnsNullWhenIdentityDoesNotExist() { when(directory.findActiveIdentityCached(uuid)).thenReturn(Optional.empty()); when(directory.findByUuid(uuid)).thenReturn(CompletableFuture.completedFuture(Optional.empty())); - PlayerReference result = new PlayerReferenceResolver(directory).resolveManaged(session, uuid); + PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory); + PlayerReference result = runOnThread("ProxyFeatures-worker", () -> resolver.resolveManaged(session, uuid)); assertNull(result); verify(directory).findByUuid(uuid); @@ -70,7 +114,7 @@ void resolveManagedReturnsNullWhenIdentityDoesNotExist() { } @Test - void findByIdentifierUsesPersistedCaseInsensitiveUsernameWhenOffline() { + void findByIdentifierAsyncUsesPersistedCaseInsensitiveUsernameWhenOffline() { PlayerDirectory directory = mock(PlayerDirectory.class); UUID uuid = UUID.randomUUID(); PlayerIdentity identity = new PlayerIdentity(21L, uuid, "Alice"); @@ -79,14 +123,17 @@ void findByIdentifierUsesPersistedCaseInsensitiveUsernameWhenOffline() { when(directory.findByUsernameIgnoreCase("aLiCe")) .thenReturn(CompletableFuture.completedFuture(Optional.of(identity))); - Optional result = new PlayerReferenceResolver(directory).findByIdentifier("aLiCe"); + Optional result = new PlayerReferenceResolver(directory) + .findByIdentifierAsync("aLiCe") + .toCompletableFuture() + .join(); assertEquals(Optional.of(PlayerReference.from(identity)), result); verify(directory).findByUsernameIgnoreCase("aLiCe"); } @Test - void resolveManagedByIdUsesActiveThenPersistedIdentityAndFinallyScalarId() { + void resolveManagedByIdUsesActiveThenPersistedIdentityAndFinallyScalarId() throws Exception { PlayerDirectory directory = mock(PlayerDirectory.class); Session session = mock(Session.class); UUID activeUuid = UUID.randomUUID(); @@ -104,8 +151,36 @@ void resolveManagedByIdUsesActiveThenPersistedIdentityAndFinallyScalarId() { PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory); assertEquals(PlayerReference.from(activeIdentity), resolver.resolveManagedById(session, 22L)); - assertEquals(PlayerReference.from(offlineIdentity), resolver.resolveManagedById(session, 23L)); - assertEquals(PlayerReference.byId(24L), resolver.resolveManagedById(session, 24L)); + 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); + } }