From 774d0a9310d8163dd5845ec1c7e4c3040ce0a0e2 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sun, 19 Jul 2026 21:57:12 +0800 Subject: [PATCH 1/3] perf(render): cache stable ray proxy identities --- .../ClientInteractionProxyRegistry.java | 11 + ...SparrowRayInteractionProxyCoordinator.java | 81 +++++--- ...rowRayInteractionProxyCoordinatorTest.java | 188 +++++++++++++++++- 3 files changed, 244 insertions(+), 36 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/render/display/ClientInteractionProxyRegistry.java b/src/main/java/top/ellan/mahjong/render/display/ClientInteractionProxyRegistry.java index 94ce305..b22e282 100644 --- a/src/main/java/top/ellan/mahjong/render/display/ClientInteractionProxyRegistry.java +++ b/src/main/java/top/ellan/mahjong/render/display/ClientInteractionProxyRegistry.java @@ -26,6 +26,14 @@ public static String tableIdFor(int entityId, UUID viewerId) { return owner != null && viewerId.equals(owner.viewerId()) ? owner.tableId() : null; } + public static boolean isOwnedBy(int entityId, UUID viewerId, String tableId) { + if (viewerId == null || tableId == null) { + return false; + } + ProxyOwner owner = OWNERS.get(entityId); + return owner != null && owner.matches(viewerId, tableId); + } + public static void unregister(int entityId, UUID viewerId, String tableId) { if (viewerId == null || tableId == null) { return; @@ -65,5 +73,8 @@ public static void clear() { } private record ProxyOwner(UUID viewerId, String tableId) { + private boolean matches(UUID expectedViewerId, String expectedTableId) { + return expectedViewerId.equals(this.viewerId) && expectedTableId.equals(this.tableId); + } } } diff --git a/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java b/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java index c2c7271..de08d00 100644 --- a/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java +++ b/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java @@ -36,9 +36,11 @@ final class SparrowRayInteractionProxyCoordinator { private static final Backend DEFAULT_BACKEND = new SparrowBackend(); private final TableSessionContext session; + private final String tableId; private final Backend backend; private final Map> regions = new LinkedHashMap<>(); private final AtomicBoolean warningLogged = new AtomicBoolean(); + private int entityCount; SparrowRayInteractionProxyCoordinator(TableSessionContext session) { this(session, DEFAULT_BACKEND); @@ -46,6 +48,7 @@ final class SparrowRayInteractionProxyCoordinator { SparrowRayInteractionProxyCoordinator(TableSessionContext session, Backend backend) { this.session = session; + this.tableId = session == null ? null : session.id(); this.backend = backend; } @@ -84,13 +87,16 @@ synchronized void replace( next.put(viewerId, current); continue; } - List geometry = interactionGeometry(interactions); - List proxies = this.backend.create(viewer, interactions); + List stableInteractions = List.copyOf(interactions); + List geometry = interactionGeometry(stableInteractions); + List proxies = List.copyOf(this.backend.create(viewer, stableInteractions)); if (!proxies.isEmpty()) { ActiveProxies created = new ActiveProxies( viewer, - List.copyOf(proxies), - geometry + proxies, + stableInteractions, + geometry, + proxies.get(0).entityId() ); next.put(viewerId, created); pendingSpawns.add(new PendingSpawn(viewerId, created)); @@ -108,18 +114,21 @@ synchronized void replace( this.removeActive(viewerId, active); } }); + int previousCount = proxyCount(previous); if (next.isEmpty()) { this.regions.remove(regionKey); + this.entityCount -= previousCount; return; } Map immutableNext = Map.copyOf(next); this.regions.put(regionKey, immutableNext); + this.entityCount += proxyCount(immutableNext) - previousCount; for (PendingSpawn pending : pendingSpawns) { UUID viewerId = pending.viewerId(); ActiveProxies active = pending.active(); - for (ClientProxy proxy : active.proxies()) { - ClientInteractionProxyRegistry.register(proxy.entityId(), viewerId, this.session.id()); + for (int index = 0; index < active.proxies().size(); index++) { + ClientInteractionProxyRegistry.register(active.entityId(index), viewerId, this.tableId); } try { this.session.runForViewer( @@ -166,16 +175,18 @@ private boolean canReuse( ActiveProxies active, List interactions ) { - if (active == null - || active.viewer() != viewer - || !sameGeometry(active.geometry(), interactions) - || active.proxies().isEmpty()) { + if (active == null || active.viewer() != viewer || active.proxies().isEmpty()) { return false; } - for (ClientProxy proxy : active.proxies()) { - if (!this.session.id().equals( - ClientInteractionProxyRegistry.tableIdFor(proxy.entityId(), viewerId) - )) { + if (active.interactions() != interactions && !sameGeometry(active.geometry(), interactions)) { + return false; + } + return this.hasOwnership(viewerId, active); + } + + private boolean hasOwnership(UUID viewerId, ActiveProxies active) { + for (int index = 0; index < active.proxies().size(); index++) { + if (!ClientInteractionProxyRegistry.isOwnedBy(active.entityId(index), viewerId, this.tableId)) { return false; } } @@ -228,16 +239,9 @@ synchronized boolean isCurrent(String regionKey, Set expectedOwners) { continue; } ActiveProxies active = activeByViewer == null ? null : activeByViewer.get(ownerId); - if (active == null || active.proxies().isEmpty()) { + if (active == null || active.proxies().isEmpty() || !this.hasOwnership(ownerId, active)) { return false; } - for (ClientProxy proxy : active.proxies()) { - if (!this.session.id().equals( - ClientInteractionProxyRegistry.tableIdFor(proxy.entityId(), ownerId) - )) { - return false; - } - } } return true; } @@ -247,6 +251,7 @@ synchronized void remove(String regionKey) { if (removed == null) { return; } + this.entityCount -= proxyCount(removed); removed.forEach(this::removeActive); } @@ -266,6 +271,7 @@ synchronized void removeViewer(UUID viewerId) { } else { this.regions.put(regionKey, Map.copyOf(remaining)); } + this.entityCount -= removed.proxies().size(); this.removeActive(viewerId, removed); } } @@ -274,13 +280,15 @@ synchronized void clear() { for (String regionKey : List.copyOf(this.regions.keySet())) { this.remove(regionKey); } - ClientInteractionProxyRegistry.clearTable(this.session.id()); + this.entityCount = 0; + ClientInteractionProxyRegistry.clearTable(this.tableId); } synchronized void shutdown() { Map> activeRegions = Map.copyOf(this.regions); this.regions.clear(); - ClientInteractionProxyRegistry.clearTable(this.session.id()); + this.entityCount = 0; + ClientInteractionProxyRegistry.clearTable(this.tableId); activeRegions.values().forEach(activeByViewer -> activeByViewer.forEach((viewerId, active) -> { if (active.viewer().isOnline()) { this.destroyQuietly(active.viewer(), active.proxies()); @@ -289,11 +297,13 @@ synchronized void shutdown() { } synchronized int entityCount() { + return this.entityCount; + } + + private static int proxyCount(Map activeByViewer) { int count = 0; - for (Map activeByViewer : this.regions.values()) { - for (ActiveProxies active : activeByViewer.values()) { - count += active.proxies().size(); - } + for (ActiveProxies active : activeByViewer.values()) { + count += active.proxies().size(); } return count; } @@ -318,14 +328,14 @@ private void spawnIfCurrent(String regionKey, UUID viewerId, ActiveProxies expec } } - private void unregister(UUID viewerId, List proxies) { - for (ClientProxy proxy : proxies) { - ClientInteractionProxyRegistry.unregister(proxy.entityId(), viewerId, this.session.id()); + private void unregister(UUID viewerId, ActiveProxies active) { + for (int index = 0; index < active.proxies().size(); index++) { + ClientInteractionProxyRegistry.unregister(active.entityId(index), viewerId, this.tableId); } } private void removeActive(UUID viewerId, ActiveProxies active) { - this.unregister(viewerId, active.proxies()); + this.unregister(viewerId, active); if (!active.viewer().isOnline()) { return; } @@ -381,8 +391,13 @@ interface ClientProxy { private record ActiveProxies( Player viewer, List proxies, - List geometry + List interactions, + List geometry, + int firstEntityId ) { + private int entityId(int index) { + return index == 0 ? this.firstEntityId : this.proxies.get(index).entityId(); + } } private record PendingSpawn(UUID viewerId, ActiveProxies active) { diff --git a/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java b/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java index 484e03b..68b215d 100644 --- a/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java +++ b/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java @@ -13,6 +13,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; @@ -75,6 +76,8 @@ void replaceAndRemoveKeepUnknownEntityOwnershipInLockstep() { assertNull(ClientInteractionProxyRegistry.tableIdFor(1201, VIEWER_ID)); assertEquals(0, coordinator.entityCount()); verify(backend).destroy(viewer, List.of(proxy)); + verify(session, times(1)).id(); + verify(proxy, times(1)).entityId(); } @Test @@ -123,7 +126,7 @@ void clearedViewerOwnershipMakesAnUnchangedRegionStaleForReconnect() { } @Test - void unchangedGeometryReusesClientProxyWithoutMorePackets() { + void sameImmutableInteractionSnapshotReusesWithoutRepeatedIdentityReads() { TableSessionContext session = mock(TableSessionContext.class); SparrowRayInteractionProxyCoordinator.Backend backend = mock( SparrowRayInteractionProxyCoordinator.Backend.class @@ -146,9 +149,184 @@ void unchangedGeometryReusesClientProxyWithoutMorePackets() { session, backend ); + List interactions = List.of(interaction()); + Map> interactionsByViewer = Map.of( + VIEWER_ID, + interactions + ); + + coordinator.replace("actions", interactionsByViewer); + coordinator.replace("actions", interactionsByViewer); + + verify(backend, times(1)).create(eq(viewer), any()); + verify(backend, times(1)).spawn(viewer, List.of(proxy)); + verify(proxy, times(1)).entityId(); + verify(session, times(1)).id(); + assertEquals(1, coordinator.entityCount()); + } + + @Test + void mutableInteractionListGeometryChangeCannotHitIdentityFastPath() { + TableSessionContext session = mock(TableSessionContext.class); + SparrowRayInteractionProxyCoordinator.Backend backend = mock( + SparrowRayInteractionProxyCoordinator.Backend.class + ); + SparrowRayInteractionProxyCoordinator.ClientProxy firstProxy = mock( + SparrowRayInteractionProxyCoordinator.ClientProxy.class + ); + SparrowRayInteractionProxyCoordinator.ClientProxy changedProxy = mock( + SparrowRayInteractionProxyCoordinator.ClientProxy.class + ); + Player viewer = mock(Player.class); + when(session.id()).thenReturn("table-a"); + when(session.onlinePlayer(VIEWER_ID)).thenReturn(viewer); + when(viewer.isOnline()).thenReturn(true); + when(backend.available()).thenReturn(true); + when(backend.create(eq(viewer), any())) + .thenReturn(List.of(firstProxy)) + .thenReturn(List.of(changedProxy)); + when(firstProxy.entityId()).thenReturn(1205); + when(changedProxy.entityId()).thenReturn(1206); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(session).runForViewer(eq(viewer), any(Runnable.class)); + SparrowRayInteractionProxyCoordinator coordinator = new SparrowRayInteractionProxyCoordinator( + session, + backend + ); + List interactions = new ArrayList<>(); + interactions.add(interaction()); + Map> interactionsByViewer = Map.of( + VIEWER_ID, + interactions + ); + + coordinator.replace("actions", interactionsByViewer); + interactions.set(0, interaction("view:river", 0.75F)); + coordinator.replace("actions", interactionsByViewer); + + verify(backend, times(2)).create(eq(viewer), any()); + verify(backend).destroy(viewer, List.of(firstProxy)); + verify(backend).spawn(viewer, List.of(firstProxy)); + verify(backend).spawn(viewer, List.of(changedProxy)); + assertNull(ClientInteractionProxyRegistry.tableIdFor(1205, VIEWER_ID)); + assertEquals("table-a", ClientInteractionProxyRegistry.tableIdFor(1206, VIEWER_ID)); + assertEquals(1, coordinator.entityCount()); + } + + @Test + void multipleProxyOwnershipRemainsCompleteWithCachedFirstEntityId() { + TableSessionContext session = mock(TableSessionContext.class); + SparrowRayInteractionProxyCoordinator.Backend backend = mock( + SparrowRayInteractionProxyCoordinator.Backend.class + ); + SparrowRayInteractionProxyCoordinator.ClientProxy firstProxy = mock( + SparrowRayInteractionProxyCoordinator.ClientProxy.class + ); + SparrowRayInteractionProxyCoordinator.ClientProxy secondProxy = mock( + SparrowRayInteractionProxyCoordinator.ClientProxy.class + ); + Player viewer = mock(Player.class); + when(session.id()).thenReturn("table-a"); + when(session.onlinePlayer(VIEWER_ID)).thenReturn(viewer); + when(viewer.isOnline()).thenReturn(true); + when(backend.available()).thenReturn(true); + when(backend.create(eq(viewer), any())).thenReturn(List.of(firstProxy, secondProxy)); + when(firstProxy.entityId()).thenReturn(1207); + when(secondProxy.entityId()).thenReturn(1208); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(session).runForViewer(eq(viewer), any(Runnable.class)); + SparrowRayInteractionProxyCoordinator coordinator = new SparrowRayInteractionProxyCoordinator( + session, + backend + ); coordinator.replace("actions", Map.of(VIEWER_ID, List.of(interaction()))); + + assertTrue(coordinator.isCurrent("actions", Set.of(VIEWER_ID))); + assertEquals("table-a", ClientInteractionProxyRegistry.tableIdFor(1207, VIEWER_ID)); + assertEquals("table-a", ClientInteractionProxyRegistry.tableIdFor(1208, VIEWER_ID)); + coordinator.remove("actions"); + assertNull(ClientInteractionProxyRegistry.tableIdFor(1207, VIEWER_ID)); + assertNull(ClientInteractionProxyRegistry.tableIdFor(1208, VIEWER_ID)); + verify(firstProxy, times(1)).entityId(); + verify(secondProxy, times(3)).entityId(); + } + + @Test + void cachedEntityCountTracksTheSameViewerAcrossMultipleRegions() { + TableSessionContext session = mock(TableSessionContext.class); + SparrowRayInteractionProxyCoordinator.Backend backend = mock( + SparrowRayInteractionProxyCoordinator.Backend.class + ); + SparrowRayInteractionProxyCoordinator.ClientProxy firstProxy = mock( + SparrowRayInteractionProxyCoordinator.ClientProxy.class + ); + SparrowRayInteractionProxyCoordinator.ClientProxy secondProxy = mock( + SparrowRayInteractionProxyCoordinator.ClientProxy.class + ); + Player viewer = mock(Player.class); + when(session.id()).thenReturn("table-a"); + when(session.onlinePlayer(VIEWER_ID)).thenReturn(viewer); + when(viewer.isOnline()).thenReturn(true); + when(backend.available()).thenReturn(true); + when(backend.create(eq(viewer), any())) + .thenReturn(List.of(firstProxy)) + .thenReturn(List.of(secondProxy)); + when(firstProxy.entityId()).thenReturn(1209); + when(secondProxy.entityId()).thenReturn(1210); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(session).runForViewer(eq(viewer), any(Runnable.class)); + SparrowRayInteractionProxyCoordinator coordinator = new SparrowRayInteractionProxyCoordinator( + session, + backend + ); + + coordinator.replace("actions", Map.of(VIEWER_ID, List.of(interaction()))); + coordinator.replace("hand", Map.of(VIEWER_ID, List.of(interaction()))); + + assertEquals(2, coordinator.entityCount()); + coordinator.removeViewer(VIEWER_ID); + assertEquals(0, coordinator.entityCount()); + assertNull(ClientInteractionProxyRegistry.tableIdFor(1209, VIEWER_ID)); + assertNull(ClientInteractionProxyRegistry.tableIdFor(1210, VIEWER_ID)); + } + + @Test + void unchangedGeometryWithDifferentActionReusesClientProxyWithoutMorePackets() { + TableSessionContext session = mock(TableSessionContext.class); + SparrowRayInteractionProxyCoordinator.Backend backend = mock( + SparrowRayInteractionProxyCoordinator.Backend.class + ); + SparrowRayInteractionProxyCoordinator.ClientProxy proxy = mock( + SparrowRayInteractionProxyCoordinator.ClientProxy.class + ); + Player viewer = mock(Player.class); + when(session.id()).thenReturn("table-a"); + when(session.onlinePlayer(VIEWER_ID)).thenReturn(viewer); + when(viewer.isOnline()).thenReturn(true); + when(backend.available()).thenReturn(true); + when(backend.create(eq(viewer), any())).thenReturn(List.of(proxy)); + when(proxy.entityId()).thenReturn(1204); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(session).runForViewer(eq(viewer), any(Runnable.class)); + SparrowRayInteractionProxyCoordinator coordinator = new SparrowRayInteractionProxyCoordinator( + session, + backend + ); + coordinator.replace("actions", Map.of(VIEWER_ID, List.of(interaction()))); + coordinator.replace( + "actions", + Map.of(VIEWER_ID, List.of(interaction("view:river:updated", 0.5F))) + ); verify(backend, times(1)).create(eq(viewer), any()); verify(backend, times(1)).spawn(viewer, List.of(proxy)); @@ -157,6 +335,10 @@ void unchangedGeometryReusesClientProxyWithoutMorePackets() { } private static DisplayInteractionRayRegistry.RayInteraction interaction() { + return interaction("view:river", 0.5F); + } + + private static DisplayInteractionRayRegistry.RayInteraction interaction(String command, float width) { return new DisplayInteractionRayRegistry.RayInteraction( WORLD_ID, 0.0D, @@ -164,10 +346,10 @@ private static DisplayInteractionRayRegistry.RayInteraction interaction() { 3.0D, 1.0D, 0.0D, - 0.5F, + width, 0.3F, 0.0F, - DisplayClickAction.playerCommand("table-a", VIEWER_ID, "view:river") + DisplayClickAction.playerCommand("table-a", VIEWER_ID, command) ); } } From 14c879ccf46fb172c6b52228d853ad528145c556 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sun, 19 Jul 2026 22:19:26 +0800 Subject: [PATCH 2/3] perf(render): bypass redundant viewer lookups --- ...SparrowRayInteractionProxyCoordinator.java | 33 +++++------- ...rowRayInteractionProxyCoordinatorTest.java | 54 ++++++++++++++++++- 2 files changed, 65 insertions(+), 22 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java b/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java index de08d00..13d6ba6 100644 --- a/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java +++ b/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java @@ -78,23 +78,23 @@ synchronized void replace( if (viewerId == null || interactions == null || interactions.isEmpty()) { continue; } - Player viewer = this.session.onlinePlayer(viewerId); - if (viewer == null || !viewer.isOnline()) { - continue; - } ActiveProxies current = previous.get(viewerId); - if (this.canReuse(viewerId, viewer, current, interactions)) { + if (current != null + && current.viewer().isOnline() + && this.canReuse(viewerId, current, interactions)) { next.put(viewerId, current); continue; } - List stableInteractions = List.copyOf(interactions); - List geometry = interactionGeometry(stableInteractions); - List proxies = List.copyOf(this.backend.create(viewer, stableInteractions)); + Player viewer = this.session.onlinePlayer(viewerId); + if (viewer == null || !viewer.isOnline()) { + continue; + } + List geometry = interactionGeometry(interactions); + List proxies = List.copyOf(this.backend.create(viewer, interactions)); if (!proxies.isEmpty()) { ActiveProxies created = new ActiveProxies( viewer, proxies, - stableInteractions, geometry, proxies.get(0).entityId() ); @@ -156,12 +156,10 @@ private boolean canReuseRegion( if (viewerId == null || interactions == null || interactions.isEmpty()) { continue; } - Player viewer = this.session.onlinePlayer(viewerId); - if (viewer == null || !viewer.isOnline()) { - continue; - } ActiveProxies active = previous.get(viewerId); - if (!this.canReuse(viewerId, viewer, active, interactions)) { + if (active == null + || !active.viewer().isOnline() + || !this.canReuse(viewerId, active, interactions)) { return false; } reusableViewers++; @@ -171,14 +169,10 @@ private boolean canReuseRegion( private boolean canReuse( UUID viewerId, - Player viewer, ActiveProxies active, List interactions ) { - if (active == null || active.viewer() != viewer || active.proxies().isEmpty()) { - return false; - } - if (active.interactions() != interactions && !sameGeometry(active.geometry(), interactions)) { + if (active == null || active.proxies().isEmpty() || !sameGeometry(active.geometry(), interactions)) { return false; } return this.hasOwnership(viewerId, active); @@ -391,7 +385,6 @@ interface ClientProxy { private record ActiveProxies( Player viewer, List proxies, - List interactions, List geometry, int firstEntityId ) { diff --git a/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java b/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java index 68b215d..5d60e6b 100644 --- a/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java +++ b/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java @@ -126,7 +126,7 @@ void clearedViewerOwnershipMakesAnUnchangedRegionStaleForReconnect() { } @Test - void sameImmutableInteractionSnapshotReusesWithoutRepeatedIdentityReads() { + void unchangedRegionReusesWithoutRepeatedSessionOrIdentityReads() { TableSessionContext session = mock(TableSessionContext.class); SparrowRayInteractionProxyCoordinator.Backend backend = mock( SparrowRayInteractionProxyCoordinator.Backend.class @@ -162,11 +162,12 @@ void sameImmutableInteractionSnapshotReusesWithoutRepeatedIdentityReads() { verify(backend, times(1)).spawn(viewer, List.of(proxy)); verify(proxy, times(1)).entityId(); verify(session, times(1)).id(); + verify(session, times(1)).onlinePlayer(VIEWER_ID); assertEquals(1, coordinator.entityCount()); } @Test - void mutableInteractionListGeometryChangeCannotHitIdentityFastPath() { + void mutableInteractionListGeometryChangeForcesRebuild() { TableSessionContext session = mock(TableSessionContext.class); SparrowRayInteractionProxyCoordinator.Backend backend = mock( SparrowRayInteractionProxyCoordinator.Backend.class @@ -256,6 +257,55 @@ void multipleProxyOwnershipRemainsCompleteWithCachedFirstEntityId() { verify(secondProxy, times(3)).entityId(); } + @Test + void offlineCachedViewerUsesFreshSessionPlayer() { + TableSessionContext session = mock(TableSessionContext.class); + SparrowRayInteractionProxyCoordinator.Backend backend = mock( + SparrowRayInteractionProxyCoordinator.Backend.class + ); + SparrowRayInteractionProxyCoordinator.ClientProxy firstProxy = mock( + SparrowRayInteractionProxyCoordinator.ClientProxy.class + ); + SparrowRayInteractionProxyCoordinator.ClientProxy reconnectedProxy = mock( + SparrowRayInteractionProxyCoordinator.ClientProxy.class + ); + Player firstViewer = mock(Player.class); + Player reconnectedViewer = mock(Player.class); + when(session.id()).thenReturn("table-a"); + when(session.onlinePlayer(VIEWER_ID)).thenReturn(firstViewer).thenReturn(reconnectedViewer); + when(firstViewer.isOnline()).thenReturn(true); + when(reconnectedViewer.isOnline()).thenReturn(true); + when(backend.available()).thenReturn(true); + when(backend.create(eq(firstViewer), any())).thenReturn(List.of(firstProxy)); + when(backend.create(eq(reconnectedViewer), any())).thenReturn(List.of(reconnectedProxy)); + when(firstProxy.entityId()).thenReturn(1211); + when(reconnectedProxy.entityId()).thenReturn(1212); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(session).runForViewer(any(Player.class), any(Runnable.class)); + SparrowRayInteractionProxyCoordinator coordinator = new SparrowRayInteractionProxyCoordinator( + session, + backend + ); + Map> interactions = Map.of( + VIEWER_ID, + List.of(interaction()) + ); + + coordinator.replace("actions", interactions); + when(firstViewer.isOnline()).thenReturn(false); + coordinator.replace("actions", interactions); + + verify(backend).spawn(firstViewer, List.of(firstProxy)); + verify(backend).spawn(reconnectedViewer, List.of(reconnectedProxy)); + verify(backend, never()).destroy(firstViewer, List.of(firstProxy)); + verify(session, times(2)).onlinePlayer(VIEWER_ID); + assertNull(ClientInteractionProxyRegistry.tableIdFor(1211, VIEWER_ID)); + assertEquals("table-a", ClientInteractionProxyRegistry.tableIdFor(1212, VIEWER_ID)); + assertEquals(1, coordinator.entityCount()); + } + @Test void cachedEntityCountTracksTheSameViewerAcrossMultipleRegions() { TableSessionContext session = mock(TableSessionContext.class); From ef9ec5e01b91525c31f6051bc306f822bdc00a35 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sun, 19 Jul 2026 22:46:40 +0800 Subject: [PATCH 3/3] perf(render): isolate the viewer lookup fast path --- .../ClientInteractionProxyRegistry.java | 11 - ...SparrowRayInteractionProxyCoordinator.java | 74 +++---- ...rowRayInteractionProxyCoordinatorTest.java | 202 +----------------- 3 files changed, 40 insertions(+), 247 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/render/display/ClientInteractionProxyRegistry.java b/src/main/java/top/ellan/mahjong/render/display/ClientInteractionProxyRegistry.java index b22e282..94ce305 100644 --- a/src/main/java/top/ellan/mahjong/render/display/ClientInteractionProxyRegistry.java +++ b/src/main/java/top/ellan/mahjong/render/display/ClientInteractionProxyRegistry.java @@ -26,14 +26,6 @@ public static String tableIdFor(int entityId, UUID viewerId) { return owner != null && viewerId.equals(owner.viewerId()) ? owner.tableId() : null; } - public static boolean isOwnedBy(int entityId, UUID viewerId, String tableId) { - if (viewerId == null || tableId == null) { - return false; - } - ProxyOwner owner = OWNERS.get(entityId); - return owner != null && owner.matches(viewerId, tableId); - } - public static void unregister(int entityId, UUID viewerId, String tableId) { if (viewerId == null || tableId == null) { return; @@ -73,8 +65,5 @@ public static void clear() { } private record ProxyOwner(UUID viewerId, String tableId) { - private boolean matches(UUID expectedViewerId, String expectedTableId) { - return expectedViewerId.equals(this.viewerId) && expectedTableId.equals(this.tableId); - } } } diff --git a/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java b/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java index 13d6ba6..10a91d3 100644 --- a/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java +++ b/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java @@ -36,11 +36,9 @@ final class SparrowRayInteractionProxyCoordinator { private static final Backend DEFAULT_BACKEND = new SparrowBackend(); private final TableSessionContext session; - private final String tableId; private final Backend backend; private final Map> regions = new LinkedHashMap<>(); private final AtomicBoolean warningLogged = new AtomicBoolean(); - private int entityCount; SparrowRayInteractionProxyCoordinator(TableSessionContext session) { this(session, DEFAULT_BACKEND); @@ -48,7 +46,6 @@ final class SparrowRayInteractionProxyCoordinator { SparrowRayInteractionProxyCoordinator(TableSessionContext session, Backend backend) { this.session = session; - this.tableId = session == null ? null : session.id(); this.backend = backend; } @@ -90,14 +87,9 @@ synchronized void replace( continue; } List geometry = interactionGeometry(interactions); - List proxies = List.copyOf(this.backend.create(viewer, interactions)); + List proxies = this.backend.create(viewer, interactions); if (!proxies.isEmpty()) { - ActiveProxies created = new ActiveProxies( - viewer, - proxies, - geometry, - proxies.get(0).entityId() - ); + ActiveProxies created = new ActiveProxies(viewer, List.copyOf(proxies), geometry); next.put(viewerId, created); pendingSpawns.add(new PendingSpawn(viewerId, created)); } @@ -114,21 +106,18 @@ synchronized void replace( this.removeActive(viewerId, active); } }); - int previousCount = proxyCount(previous); if (next.isEmpty()) { this.regions.remove(regionKey); - this.entityCount -= previousCount; return; } Map immutableNext = Map.copyOf(next); this.regions.put(regionKey, immutableNext); - this.entityCount += proxyCount(immutableNext) - previousCount; for (PendingSpawn pending : pendingSpawns) { UUID viewerId = pending.viewerId(); ActiveProxies active = pending.active(); - for (int index = 0; index < active.proxies().size(); index++) { - ClientInteractionProxyRegistry.register(active.entityId(index), viewerId, this.tableId); + for (ClientProxy proxy : active.proxies()) { + ClientInteractionProxyRegistry.register(proxy.entityId(), viewerId, this.session.id()); } try { this.session.runForViewer( @@ -172,15 +161,15 @@ private boolean canReuse( ActiveProxies active, List interactions ) { - if (active == null || active.proxies().isEmpty() || !sameGeometry(active.geometry(), interactions)) { + if (active == null + || !sameGeometry(active.geometry(), interactions) + || active.proxies().isEmpty()) { return false; } - return this.hasOwnership(viewerId, active); - } - - private boolean hasOwnership(UUID viewerId, ActiveProxies active) { - for (int index = 0; index < active.proxies().size(); index++) { - if (!ClientInteractionProxyRegistry.isOwnedBy(active.entityId(index), viewerId, this.tableId)) { + for (ClientProxy proxy : active.proxies()) { + if (!this.session.id().equals( + ClientInteractionProxyRegistry.tableIdFor(proxy.entityId(), viewerId) + )) { return false; } } @@ -233,9 +222,16 @@ synchronized boolean isCurrent(String regionKey, Set expectedOwners) { continue; } ActiveProxies active = activeByViewer == null ? null : activeByViewer.get(ownerId); - if (active == null || active.proxies().isEmpty() || !this.hasOwnership(ownerId, active)) { + if (active == null || active.proxies().isEmpty()) { return false; } + for (ClientProxy proxy : active.proxies()) { + if (!this.session.id().equals( + ClientInteractionProxyRegistry.tableIdFor(proxy.entityId(), ownerId) + )) { + return false; + } + } } return true; } @@ -245,7 +241,6 @@ synchronized void remove(String regionKey) { if (removed == null) { return; } - this.entityCount -= proxyCount(removed); removed.forEach(this::removeActive); } @@ -265,7 +260,6 @@ synchronized void removeViewer(UUID viewerId) { } else { this.regions.put(regionKey, Map.copyOf(remaining)); } - this.entityCount -= removed.proxies().size(); this.removeActive(viewerId, removed); } } @@ -274,15 +268,13 @@ synchronized void clear() { for (String regionKey : List.copyOf(this.regions.keySet())) { this.remove(regionKey); } - this.entityCount = 0; - ClientInteractionProxyRegistry.clearTable(this.tableId); + ClientInteractionProxyRegistry.clearTable(this.session.id()); } synchronized void shutdown() { Map> activeRegions = Map.copyOf(this.regions); this.regions.clear(); - this.entityCount = 0; - ClientInteractionProxyRegistry.clearTable(this.tableId); + ClientInteractionProxyRegistry.clearTable(this.session.id()); activeRegions.values().forEach(activeByViewer -> activeByViewer.forEach((viewerId, active) -> { if (active.viewer().isOnline()) { this.destroyQuietly(active.viewer(), active.proxies()); @@ -291,13 +283,11 @@ synchronized void shutdown() { } synchronized int entityCount() { - return this.entityCount; - } - - private static int proxyCount(Map activeByViewer) { int count = 0; - for (ActiveProxies active : activeByViewer.values()) { - count += active.proxies().size(); + for (Map activeByViewer : this.regions.values()) { + for (ActiveProxies active : activeByViewer.values()) { + count += active.proxies().size(); + } } return count; } @@ -322,14 +312,14 @@ private void spawnIfCurrent(String regionKey, UUID viewerId, ActiveProxies expec } } - private void unregister(UUID viewerId, ActiveProxies active) { - for (int index = 0; index < active.proxies().size(); index++) { - ClientInteractionProxyRegistry.unregister(active.entityId(index), viewerId, this.tableId); + private void unregister(UUID viewerId, List proxies) { + for (ClientProxy proxy : proxies) { + ClientInteractionProxyRegistry.unregister(proxy.entityId(), viewerId, this.session.id()); } } private void removeActive(UUID viewerId, ActiveProxies active) { - this.unregister(viewerId, active); + this.unregister(viewerId, active.proxies()); if (!active.viewer().isOnline()) { return; } @@ -385,12 +375,8 @@ interface ClientProxy { private record ActiveProxies( Player viewer, List proxies, - List geometry, - int firstEntityId + List geometry ) { - private int entityId(int index) { - return index == 0 ? this.firstEntityId : this.proxies.get(index).entityId(); - } } private record PendingSpawn(UUID viewerId, ActiveProxies active) { diff --git a/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java b/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java index 5d60e6b..b0ecf06 100644 --- a/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java +++ b/src/test/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinatorTest.java @@ -13,7 +13,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; @@ -76,8 +75,6 @@ void replaceAndRemoveKeepUnknownEntityOwnershipInLockstep() { assertNull(ClientInteractionProxyRegistry.tableIdFor(1201, VIEWER_ID)); assertEquals(0, coordinator.entityCount()); verify(backend).destroy(viewer, List.of(proxy)); - verify(session, times(1)).id(); - verify(proxy, times(1)).entityId(); } @Test @@ -126,7 +123,7 @@ void clearedViewerOwnershipMakesAnUnchangedRegionStaleForReconnect() { } @Test - void unchangedRegionReusesWithoutRepeatedSessionOrIdentityReads() { + void unchangedGeometryReusesClientProxyWithoutMorePacketsOrViewerLookups() { TableSessionContext session = mock(TableSessionContext.class); SparrowRayInteractionProxyCoordinator.Backend backend = mock( SparrowRayInteractionProxyCoordinator.Backend.class @@ -149,114 +146,17 @@ void unchangedRegionReusesWithoutRepeatedSessionOrIdentityReads() { session, backend ); - List interactions = List.of(interaction()); - Map> interactionsByViewer = Map.of( - VIEWER_ID, - interactions - ); - coordinator.replace("actions", interactionsByViewer); - coordinator.replace("actions", interactionsByViewer); + coordinator.replace("actions", Map.of(VIEWER_ID, List.of(interaction()))); + coordinator.replace("actions", Map.of(VIEWER_ID, List.of(interaction()))); verify(backend, times(1)).create(eq(viewer), any()); verify(backend, times(1)).spawn(viewer, List.of(proxy)); - verify(proxy, times(1)).entityId(); - verify(session, times(1)).id(); + verify(backend, never()).destroy(viewer, List.of(proxy)); verify(session, times(1)).onlinePlayer(VIEWER_ID); assertEquals(1, coordinator.entityCount()); } - @Test - void mutableInteractionListGeometryChangeForcesRebuild() { - TableSessionContext session = mock(TableSessionContext.class); - SparrowRayInteractionProxyCoordinator.Backend backend = mock( - SparrowRayInteractionProxyCoordinator.Backend.class - ); - SparrowRayInteractionProxyCoordinator.ClientProxy firstProxy = mock( - SparrowRayInteractionProxyCoordinator.ClientProxy.class - ); - SparrowRayInteractionProxyCoordinator.ClientProxy changedProxy = mock( - SparrowRayInteractionProxyCoordinator.ClientProxy.class - ); - Player viewer = mock(Player.class); - when(session.id()).thenReturn("table-a"); - when(session.onlinePlayer(VIEWER_ID)).thenReturn(viewer); - when(viewer.isOnline()).thenReturn(true); - when(backend.available()).thenReturn(true); - when(backend.create(eq(viewer), any())) - .thenReturn(List.of(firstProxy)) - .thenReturn(List.of(changedProxy)); - when(firstProxy.entityId()).thenReturn(1205); - when(changedProxy.entityId()).thenReturn(1206); - doAnswer(invocation -> { - invocation.getArgument(1).run(); - return null; - }).when(session).runForViewer(eq(viewer), any(Runnable.class)); - SparrowRayInteractionProxyCoordinator coordinator = new SparrowRayInteractionProxyCoordinator( - session, - backend - ); - List interactions = new ArrayList<>(); - interactions.add(interaction()); - Map> interactionsByViewer = Map.of( - VIEWER_ID, - interactions - ); - - coordinator.replace("actions", interactionsByViewer); - interactions.set(0, interaction("view:river", 0.75F)); - coordinator.replace("actions", interactionsByViewer); - - verify(backend, times(2)).create(eq(viewer), any()); - verify(backend).destroy(viewer, List.of(firstProxy)); - verify(backend).spawn(viewer, List.of(firstProxy)); - verify(backend).spawn(viewer, List.of(changedProxy)); - assertNull(ClientInteractionProxyRegistry.tableIdFor(1205, VIEWER_ID)); - assertEquals("table-a", ClientInteractionProxyRegistry.tableIdFor(1206, VIEWER_ID)); - assertEquals(1, coordinator.entityCount()); - } - - @Test - void multipleProxyOwnershipRemainsCompleteWithCachedFirstEntityId() { - TableSessionContext session = mock(TableSessionContext.class); - SparrowRayInteractionProxyCoordinator.Backend backend = mock( - SparrowRayInteractionProxyCoordinator.Backend.class - ); - SparrowRayInteractionProxyCoordinator.ClientProxy firstProxy = mock( - SparrowRayInteractionProxyCoordinator.ClientProxy.class - ); - SparrowRayInteractionProxyCoordinator.ClientProxy secondProxy = mock( - SparrowRayInteractionProxyCoordinator.ClientProxy.class - ); - Player viewer = mock(Player.class); - when(session.id()).thenReturn("table-a"); - when(session.onlinePlayer(VIEWER_ID)).thenReturn(viewer); - when(viewer.isOnline()).thenReturn(true); - when(backend.available()).thenReturn(true); - when(backend.create(eq(viewer), any())).thenReturn(List.of(firstProxy, secondProxy)); - when(firstProxy.entityId()).thenReturn(1207); - when(secondProxy.entityId()).thenReturn(1208); - doAnswer(invocation -> { - invocation.getArgument(1).run(); - return null; - }).when(session).runForViewer(eq(viewer), any(Runnable.class)); - SparrowRayInteractionProxyCoordinator coordinator = new SparrowRayInteractionProxyCoordinator( - session, - backend - ); - - coordinator.replace("actions", Map.of(VIEWER_ID, List.of(interaction()))); - - assertTrue(coordinator.isCurrent("actions", Set.of(VIEWER_ID))); - assertEquals("table-a", ClientInteractionProxyRegistry.tableIdFor(1207, VIEWER_ID)); - assertEquals("table-a", ClientInteractionProxyRegistry.tableIdFor(1208, VIEWER_ID)); - coordinator.remove("actions"); - assertNull(ClientInteractionProxyRegistry.tableIdFor(1207, VIEWER_ID)); - assertNull(ClientInteractionProxyRegistry.tableIdFor(1208, VIEWER_ID)); - verify(firstProxy, times(1)).entityId(); - verify(secondProxy, times(3)).entityId(); - } - @Test void offlineCachedViewerUsesFreshSessionPlayer() { TableSessionContext session = mock(TableSessionContext.class); @@ -278,8 +178,8 @@ void offlineCachedViewerUsesFreshSessionPlayer() { when(backend.available()).thenReturn(true); when(backend.create(eq(firstViewer), any())).thenReturn(List.of(firstProxy)); when(backend.create(eq(reconnectedViewer), any())).thenReturn(List.of(reconnectedProxy)); - when(firstProxy.entityId()).thenReturn(1211); - when(reconnectedProxy.entityId()).thenReturn(1212); + when(firstProxy.entityId()).thenReturn(1205); + when(reconnectedProxy.entityId()).thenReturn(1206); doAnswer(invocation -> { invocation.getArgument(1).run(); return null; @@ -301,94 +201,12 @@ void offlineCachedViewerUsesFreshSessionPlayer() { verify(backend).spawn(reconnectedViewer, List.of(reconnectedProxy)); verify(backend, never()).destroy(firstViewer, List.of(firstProxy)); verify(session, times(2)).onlinePlayer(VIEWER_ID); - assertNull(ClientInteractionProxyRegistry.tableIdFor(1211, VIEWER_ID)); - assertEquals("table-a", ClientInteractionProxyRegistry.tableIdFor(1212, VIEWER_ID)); - assertEquals(1, coordinator.entityCount()); - } - - @Test - void cachedEntityCountTracksTheSameViewerAcrossMultipleRegions() { - TableSessionContext session = mock(TableSessionContext.class); - SparrowRayInteractionProxyCoordinator.Backend backend = mock( - SparrowRayInteractionProxyCoordinator.Backend.class - ); - SparrowRayInteractionProxyCoordinator.ClientProxy firstProxy = mock( - SparrowRayInteractionProxyCoordinator.ClientProxy.class - ); - SparrowRayInteractionProxyCoordinator.ClientProxy secondProxy = mock( - SparrowRayInteractionProxyCoordinator.ClientProxy.class - ); - Player viewer = mock(Player.class); - when(session.id()).thenReturn("table-a"); - when(session.onlinePlayer(VIEWER_ID)).thenReturn(viewer); - when(viewer.isOnline()).thenReturn(true); - when(backend.available()).thenReturn(true); - when(backend.create(eq(viewer), any())) - .thenReturn(List.of(firstProxy)) - .thenReturn(List.of(secondProxy)); - when(firstProxy.entityId()).thenReturn(1209); - when(secondProxy.entityId()).thenReturn(1210); - doAnswer(invocation -> { - invocation.getArgument(1).run(); - return null; - }).when(session).runForViewer(eq(viewer), any(Runnable.class)); - SparrowRayInteractionProxyCoordinator coordinator = new SparrowRayInteractionProxyCoordinator( - session, - backend - ); - - coordinator.replace("actions", Map.of(VIEWER_ID, List.of(interaction()))); - coordinator.replace("hand", Map.of(VIEWER_ID, List.of(interaction()))); - - assertEquals(2, coordinator.entityCount()); - coordinator.removeViewer(VIEWER_ID); - assertEquals(0, coordinator.entityCount()); - assertNull(ClientInteractionProxyRegistry.tableIdFor(1209, VIEWER_ID)); - assertNull(ClientInteractionProxyRegistry.tableIdFor(1210, VIEWER_ID)); - } - - @Test - void unchangedGeometryWithDifferentActionReusesClientProxyWithoutMorePackets() { - TableSessionContext session = mock(TableSessionContext.class); - SparrowRayInteractionProxyCoordinator.Backend backend = mock( - SparrowRayInteractionProxyCoordinator.Backend.class - ); - SparrowRayInteractionProxyCoordinator.ClientProxy proxy = mock( - SparrowRayInteractionProxyCoordinator.ClientProxy.class - ); - Player viewer = mock(Player.class); - when(session.id()).thenReturn("table-a"); - when(session.onlinePlayer(VIEWER_ID)).thenReturn(viewer); - when(viewer.isOnline()).thenReturn(true); - when(backend.available()).thenReturn(true); - when(backend.create(eq(viewer), any())).thenReturn(List.of(proxy)); - when(proxy.entityId()).thenReturn(1204); - doAnswer(invocation -> { - invocation.getArgument(1).run(); - return null; - }).when(session).runForViewer(eq(viewer), any(Runnable.class)); - SparrowRayInteractionProxyCoordinator coordinator = new SparrowRayInteractionProxyCoordinator( - session, - backend - ); - - coordinator.replace("actions", Map.of(VIEWER_ID, List.of(interaction()))); - coordinator.replace( - "actions", - Map.of(VIEWER_ID, List.of(interaction("view:river:updated", 0.5F))) - ); - - verify(backend, times(1)).create(eq(viewer), any()); - verify(backend, times(1)).spawn(viewer, List.of(proxy)); - verify(backend, never()).destroy(viewer, List.of(proxy)); + assertNull(ClientInteractionProxyRegistry.tableIdFor(1205, VIEWER_ID)); + assertEquals("table-a", ClientInteractionProxyRegistry.tableIdFor(1206, VIEWER_ID)); assertEquals(1, coordinator.entityCount()); } private static DisplayInteractionRayRegistry.RayInteraction interaction() { - return interaction("view:river", 0.5F); - } - - private static DisplayInteractionRayRegistry.RayInteraction interaction(String command, float width) { return new DisplayInteractionRayRegistry.RayInteraction( WORLD_ID, 0.0D, @@ -396,10 +214,10 @@ private static DisplayInteractionRayRegistry.RayInteraction interaction(String c 3.0D, 1.0D, 0.0D, - width, + 0.5F, 0.3F, 0.0F, - DisplayClickAction.playerCommand("table-a", VIEWER_ID, command) + DisplayClickAction.playerCommand("table-a", VIEWER_ID, "view:river") ); } }