From 5823b65a9e288dfa932ce2d85bf24fb367b0b8f8 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Fri, 17 Jul 2026 23:35:19 +0800 Subject: [PATCH 01/12] perf(render): skip unchanged ray lifecycle work Avoid rebuilding equivalent interaction snapshots and proxy-region maps while preserving action and ownership changes. --- .../DisplayInteractionRayRegistry.java | 68 +++++++++--- ...SparrowRayInteractionProxyCoordinator.java | 28 +++++ .../FlatDisplayInteractionRegistryTest.java | 104 ++++++++++++++++++ 3 files changed, 187 insertions(+), 13 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/render/display/DisplayInteractionRayRegistry.java b/src/main/java/top/ellan/mahjong/render/display/DisplayInteractionRayRegistry.java index 2516236..15929a5 100644 --- a/src/main/java/top/ellan/mahjong/render/display/DisplayInteractionRayRegistry.java +++ b/src/main/java/top/ellan/mahjong/render/display/DisplayInteractionRayRegistry.java @@ -46,15 +46,19 @@ public static void replaceRegion( return; } VIEWER_INTERACTIONS.compute(viewerId, (ignored, current) -> { - if (current != null && !tableId.equals(current.tableId())) { - return interactions == null || interactions.isEmpty() - ? current - : ViewerInteractions.single(tableId, regionKey, interactions); + boolean empty = interactions == null || interactions.isEmpty(); + if (current == null) { + return empty ? null : ViewerInteractions.single(tableId, regionKey, interactions); } - Map> regions = current == null - ? new LinkedHashMap<>() - : new LinkedHashMap<>(current.regions()); - if (interactions == null || interactions.isEmpty()) { + if (!tableId.equals(current.tableId())) { + return empty ? current : ViewerInteractions.single(tableId, regionKey, interactions); + } + List previous = current.regions().get(regionKey); + if ((empty && previous == null) || (!empty && Objects.equals(previous, interactions))) { + return current; + } + Map> regions = new LinkedHashMap<>(current.regions()); + if (empty) { regions.remove(regionKey); } else { regions.put(regionKey, List.copyOf(interactions)); @@ -129,11 +133,11 @@ public static synchronized void replacePublicJoinRegion( return; } PublicRegionKey key = new PublicRegionKey(tableId, regionKey); - List publicJoins = interactions == null - ? List.of() - : interactions.stream() - .filter(interaction -> isPublicJoinForTable(interaction, tableId)) - .toList(); + List previous = PUBLIC_JOIN_REGIONS.get(key); + if (samePublicJoinInteractions(previous, tableId, interactions)) { + return; + } + List publicJoins = filterPublicJoinInteractions(tableId, interactions); if (publicJoins.isEmpty()) { PUBLIC_JOIN_REGIONS.remove(key); } else { @@ -211,6 +215,44 @@ private static void rebuildPublicJoinSnapshot() { publicJoinInteractions = List.copyOf(flattened); } + private static boolean samePublicJoinInteractions( + List previous, + String tableId, + List interactions + ) { + int previousIndex = 0; + if (interactions != null) { + for (RayInteraction interaction : interactions) { + if (!isPublicJoinForTable(interaction, tableId)) { + continue; + } + if (previous == null + || previousIndex >= previous.size() + || !Objects.equals(previous.get(previousIndex), interaction)) { + return false; + } + previousIndex++; + } + } + return previous == null ? previousIndex == 0 : previousIndex == previous.size(); + } + + private static List filterPublicJoinInteractions( + String tableId, + List interactions + ) { + if (interactions == null || interactions.isEmpty()) { + return List.of(); + } + List publicJoins = new ArrayList<>(interactions.size()); + for (RayInteraction interaction : interactions) { + if (isPublicJoinForTable(interaction, tableId)) { + publicJoins.add(interaction); + } + } + return publicJoins.isEmpty() ? List.of() : List.copyOf(publicJoins); + } + private static boolean isPublicJoinForTable(RayInteraction interaction, String tableId) { return interaction != null && interaction.action().actionType() == DisplayClickAction.ActionType.JOIN_SEAT 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 b92da00..c2c7271 100644 --- a/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java +++ b/src/main/java/top/ellan/mahjong/table/render/SparrowRayInteractionProxyCoordinator.java @@ -62,6 +62,9 @@ synchronized void replace( } Map previous = this.regions.getOrDefault(regionKey, Map.of()); + if (this.canReuseRegion(previous, interactionsByViewer)) { + return; + } Map next = new LinkedHashMap<>(); List pendingSpawns = new ArrayList<>(); try { @@ -132,6 +135,31 @@ synchronized void replace( } } + private boolean canReuseRegion( + Map previous, + Map> interactionsByViewer + ) { + int reusableViewers = 0; + for (Map.Entry> entry + : interactionsByViewer.entrySet()) { + UUID viewerId = entry.getKey(); + List interactions = entry.getValue(); + 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)) { + return false; + } + reusableViewers++; + } + return reusableViewers == previous.size(); + } + private boolean canReuse( UUID viewerId, Player viewer, diff --git a/src/test/java/top/ellan/mahjong/render/display/FlatDisplayInteractionRegistryTest.java b/src/test/java/top/ellan/mahjong/render/display/FlatDisplayInteractionRegistryTest.java index 7fba400..b3c8292 100644 --- a/src/test/java/top/ellan/mahjong/render/display/FlatDisplayInteractionRegistryTest.java +++ b/src/test/java/top/ellan/mahjong/render/display/FlatDisplayInteractionRegistryTest.java @@ -3,6 +3,7 @@ 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.assertSame; import java.util.List; import java.util.UUID; @@ -132,6 +133,109 @@ void independentActionAndHandRegionsCoexistAndClearSeparately() { assertEquals(List.of(tile), DisplayInteractionRayRegistry.snapshot(VIEWER_ID)); } + @Test + void identicalRegionReplacementKeepsTheExistingSnapshotAndActionChangesStillApply() { + DisplayInteractionRayRegistry.RayInteraction first = interaction( + 0.0D, + 3.0D, + 1.0D, + 0.0D, + action("first") + ); + DisplayInteractionRayRegistry.replaceRegion( + VIEWER_ID, + "table-a", + "viewer-actions", + List.of(first) + ); + List firstSnapshot = + DisplayInteractionRayRegistry.snapshot(VIEWER_ID); + + DisplayInteractionRayRegistry.replaceRegion( + VIEWER_ID, + "table-a", + "viewer-actions", + List.of(first) + ); + + assertSame(firstSnapshot, DisplayInteractionRayRegistry.snapshot(VIEWER_ID)); + + DisplayInteractionRayRegistry.RayInteraction changed = interaction( + 0.0D, + 3.0D, + 1.0D, + 0.0D, + action("second") + ); + DisplayInteractionRayRegistry.replaceRegion( + VIEWER_ID, + "table-a", + "viewer-actions", + List.of(changed) + ); + + assertEquals(List.of(changed), DisplayInteractionRayRegistry.snapshot(VIEWER_ID)); + } + + @Test + void identicalPublicJoinReplacementKeepsTheExistingFlattenedSnapshot() { + DisplayInteractionRayRegistry.RayInteraction join = interaction( + 0.0D, + 3.0D, + 1.0D, + 0.0D, + DisplayClickAction.joinSeat("table-a", SeatWind.EAST) + ); + DisplayInteractionRayRegistry.RayInteraction privateDecision = interaction( + 0.0D, + 4.0D, + 1.0D, + 0.0D, + action("private") + ); + List input = List.of(join, privateDecision); + + DisplayInteractionRayRegistry.replacePublicJoinRegion("table-a", "seat-label:EAST", input); + List firstSnapshot = + DisplayInteractionRayRegistry.publicJoinSnapshot(); + + DisplayInteractionRayRegistry.replacePublicJoinRegion("table-a", "seat-label:EAST", input); + + assertSame(firstSnapshot, DisplayInteractionRayRegistry.publicJoinSnapshot()); + assertEquals(List.of(join), firstSnapshot); + } + + @Test + void replacingPublicJoinActionStillUpdatesTheFlattenedSnapshot() { + DisplayInteractionRayRegistry.RayInteraction east = interaction( + 0.0D, + 3.0D, + 1.0D, + 0.0D, + DisplayClickAction.joinSeat("table-a", SeatWind.EAST) + ); + DisplayInteractionRayRegistry.RayInteraction south = interaction( + 0.0D, + 3.0D, + 1.0D, + 0.0D, + DisplayClickAction.joinSeat("table-a", SeatWind.SOUTH) + ); + + DisplayInteractionRayRegistry.replacePublicJoinRegion( + "table-a", + "seat-label:EAST", + List.of(east) + ); + DisplayInteractionRayRegistry.replacePublicJoinRegion( + "table-a", + "seat-label:EAST", + List.of(south) + ); + + assertEquals(List.of(south), DisplayInteractionRayRegistry.publicJoinSnapshot()); + } + @Test void tableAndViewerCleanupCannotLeaveStaleControls() { DisplayInteractionRayRegistry.RayInteraction interaction = interaction( From 9259ee5c42bc297693020ee860e36a8e945e7b5b Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 00:16:57 +0800 Subject: [PATCH 02/12] perf(render): reuse stable viewer membership plans Avoid rebuilding sorted viewer signatures and exclusion lists when a table's subject, viewer sequence, and seats are unchanged. --- .../render/TableRenderSnapshotFactory.java | 153 ++++++++++++------ .../render/TableRenderSnapshotFactoryTest.kt | 70 +++++++- 2 files changed, 175 insertions(+), 48 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/table/render/TableRenderSnapshotFactory.java b/src/main/java/top/ellan/mahjong/table/render/TableRenderSnapshotFactory.java index f12695d..41cac27 100644 --- a/src/main/java/top/ellan/mahjong/table/render/TableRenderSnapshotFactory.java +++ b/src/main/java/top/ellan/mahjong/table/render/TableRenderSnapshotFactory.java @@ -17,52 +17,19 @@ import org.bukkit.entity.Player; public final class TableRenderSnapshotFactory { + private static final SeatWind[] SEAT_WINDS = SeatWind.values(); + + private ViewerMembershipPlan cachedViewerMembershipPlan; + public TableRenderSnapshot create(TableRenderSubject session, long version, long cancellationNonce) { Location tableCenter = session.center(); boolean started = session.isStarted(); - List serializedOnlineViewerIds = session.viewers().stream() - .map(Player::getUniqueId) - .distinct() - .map(viewerId -> new SerializedViewerId(viewerId, viewerId.toString())) - .sorted(Comparator.comparing(SerializedViewerId::serializedId)) - .toList(); - List onlineViewerIds = serializedOnlineViewerIds.stream() - .map(SerializedViewerId::id) - .toList(); - Set onlineViewerIdSet = new HashSet<>(onlineViewerIds); - SeatWind[] seatWinds = SeatWind.values(); - EnumMap seatPlayerIds = new EnumMap<>(SeatWind.class); - for (SeatWind wind : seatWinds) { - seatPlayerIds.put(wind, session.playerAt(wind)); - } - Map viewerMembershipSignatures = new HashMap<>(); - Map> viewerIdsExcluding = new HashMap<>(); - for (UUID playerId : seatPlayerIds.values()) { - if (viewerMembershipSignatures.containsKey(playerId) - || playerId != null && !onlineViewerIdSet.contains(playerId)) { - continue; - } - viewerMembershipSignatures.put( - playerId, - this.viewerMembershipSignature(serializedOnlineViewerIds, playerId) - ); - viewerIdsExcluding.put( - playerId, - playerId == null ? List.copyOf(onlineViewerIds) : this.viewerIdsExcluding(onlineViewerIds, playerId) - ); - } + ViewerMembershipPlan viewerMembershipPlan = this.resolveViewerMembershipPlan(session); EnumMap seats = new EnumMap<>(SeatWind.class); - for (SeatWind wind : seatWinds) { + for (SeatWind wind : SEAT_WINDS) { seats.put( wind, - this.captureSeatSnapshot( - session, - wind, - seatPlayerIds.get(wind), - onlineViewerIdSet, - viewerMembershipSignatures, - viewerIdsExcluding - ) + this.captureSeatSnapshot(session, wind, viewerMembershipPlan.seat(wind)) ); } return new TableRenderSnapshot( @@ -127,14 +94,72 @@ public TableSeatRenderSnapshot createPrivateHandSeat(TableRenderSubject session, ); } + private ViewerMembershipPlan resolveViewerMembershipPlan(TableRenderSubject session) { + List viewers = session.viewers(); + ViewerMembershipPlan cached = this.cachedViewerMembershipPlan; + if (cached != null && cached.matches(session, viewers)) { + return cached; + } + ViewerMembershipPlan captured = this.captureViewerMembershipPlan(session, viewers); + this.cachedViewerMembershipPlan = captured; + return captured; + } + + private ViewerMembershipPlan captureViewerMembershipPlan(TableRenderSubject session, List viewers) { + List sourceViewerIds = viewers.stream() + .map(Player::getUniqueId) + .toList(); + List serializedOnlineViewerIds = sourceViewerIds.stream() + .distinct() + .map(viewerId -> new SerializedViewerId(viewerId, viewerId.toString())) + .sorted(Comparator.comparing(SerializedViewerId::serializedId)) + .toList(); + List onlineViewerIds = serializedOnlineViewerIds.stream() + .map(SerializedViewerId::id) + .toList(); + Set onlineViewerIdSet = new HashSet<>(onlineViewerIds); + Map membershipsByPlayerId = new HashMap<>(); + EnumMap seatMemberships = new EnumMap<>(SeatWind.class); + for (SeatWind wind : SEAT_WINDS) { + UUID playerId = session.playerAt(wind); + SeatViewerMembership membership = membershipsByPlayerId.get(playerId); + if (membership == null && !membershipsByPlayerId.containsKey(playerId)) { + membership = this.captureSeatViewerMembership( + playerId, + serializedOnlineViewerIds, + onlineViewerIds, + onlineViewerIdSet + ); + membershipsByPlayerId.put(playerId, membership); + } + seatMemberships.put(wind, membership); + } + return new ViewerMembershipPlan(session, sourceViewerIds, seatMemberships); + } + + private SeatViewerMembership captureSeatViewerMembership( + UUID playerId, + List serializedOnlineViewerIds, + List onlineViewerIds, + Set onlineViewerIdSet + ) { + if (playerId != null && !onlineViewerIdSet.contains(playerId)) { + return new SeatViewerMembership(playerId, false, "", List.of()); + } + return new SeatViewerMembership( + playerId, + playerId != null, + this.viewerMembershipSignature(serializedOnlineViewerIds, playerId), + playerId == null ? List.copyOf(onlineViewerIds) : this.viewerIdsExcluding(onlineViewerIds, playerId) + ); + } + private TableSeatRenderSnapshot captureSeatSnapshot( TableRenderSubject session, SeatWind wind, - UUID playerId, - Set onlineViewerIdSet, - Map viewerMembershipSignatures, - Map> viewerIdsExcluding + SeatViewerMembership viewerMembership ) { + UUID playerId = viewerMembership.playerId(); boolean occupied = playerId != null; return new TableSeatRenderSnapshot( wind, @@ -145,13 +170,13 @@ private TableSeatRenderSnapshot captureSeatSnapshot( occupied && session.isRiichi(playerId), occupied && session.isReady(playerId), occupied && session.isQueuedToLeave(playerId), - occupied && onlineViewerIdSet.contains(playerId), - viewerMembershipSignatures.getOrDefault(playerId, ""), + viewerMembership.online(), + viewerMembership.signature(), occupied ? session.selectedHandTileIndex(playerId) : -1, occupied ? session.selectedHandTileIndices(playerId) : List.of(), occupied ? session.riichiDiscardIndex(playerId) : -1, session.stickLayoutCount(wind), - viewerIdsExcluding.getOrDefault(playerId, List.of()), + viewerMembership.viewerIdsExcluding(), occupied ? session.hand(playerId) : List.of(), occupied ? session.discards(playerId) : List.of(), occupied ? session.fuuro(playerId) : List.of(), @@ -176,5 +201,39 @@ private List viewerIdsExcluding(List onlineViewerIds, UUID excludedP .toList(); } + private record ViewerMembershipPlan( + TableRenderSubject subject, + List sourceViewerIds, + EnumMap seatMemberships + ) { + private boolean matches(TableRenderSubject session, List viewers) { + if (this.subject != session || this.sourceViewerIds.size() != viewers.size()) { + return false; + } + for (SeatWind wind : SEAT_WINDS) { + if (!Objects.equals(this.seat(wind).playerId(), session.playerAt(wind))) { + return false; + } + } + for (int index = 0; index < this.sourceViewerIds.size(); index++) { + if (!this.sourceViewerIds.get(index).equals(viewers.get(index).getUniqueId())) { + return false; + } + } + return true; + } + + private SeatViewerMembership seat(SeatWind wind) { + return this.seatMemberships.get(wind); + } + } + + private record SeatViewerMembership( + UUID playerId, + boolean online, + String signature, + List viewerIdsExcluding + ) {} + private record SerializedViewerId(UUID id, String serializedId) {} } diff --git a/src/test/kotlin/top/ellan/mahjong/table/render/TableRenderSnapshotFactoryTest.kt b/src/test/kotlin/top/ellan/mahjong/table/render/TableRenderSnapshotFactoryTest.kt index 26aeeb8..5545532 100644 --- a/src/test/kotlin/top/ellan/mahjong/table/render/TableRenderSnapshotFactoryTest.kt +++ b/src/test/kotlin/top/ellan/mahjong/table/render/TableRenderSnapshotFactoryTest.kt @@ -2,6 +2,7 @@ package top.ellan.mahjong.table.render import org.bukkit.Location import org.bukkit.entity.Player +import org.mockito.AdditionalAnswers import org.mockito.ArgumentMatchers import org.mockito.Mockito.doAnswer import org.mockito.Mockito.mock @@ -15,6 +16,8 @@ import top.ellan.mahjong.table.core.MahjongTableSession import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame import kotlin.test.assertTrue class TableRenderSnapshotFactoryTest { @@ -117,8 +120,55 @@ class TableRenderSnapshotFactoryTest { assertEquals(144, snapshot.wallCapacity()) assertTrue(!snapshot.usesDeadWall()) + val reusedSnapshot = factory.create(session, 2L, 0L) + for (wind in SeatWind.values()) { + assertSame(snapshot.seat(wind).viewerIdsExcluding(), reusedSnapshot.seat(wind).viewerIdsExcluding()) + assertSame( + snapshot.seat(wind).viewerMembershipSignature(), + reusedSnapshot.seat(wind).viewerMembershipSignature(), + ) + } + + `when`(session.viewers()).thenReturn( + listOf(eastViewer, duplicateEastViewer, westViewer, southViewer), + ) + val reorderedSnapshot = factory.create(session, 3L, 0L) + assertEquals(eastSeat.viewerIdsExcluding(), reorderedSnapshot.seat(SeatWind.EAST).viewerIdsExcluding()) + assertEquals( + eastSeat.viewerMembershipSignature(), + reorderedSnapshot.seat(SeatWind.EAST).viewerMembershipSignature(), + ) + assertNotSame( + reusedSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + reorderedSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + ) + + `when`(session.viewers()).thenReturn( + listOf(southViewer, westViewer, duplicateEastViewer, eastViewer), + ) + val restoredSnapshot = factory.create(session, 4L, 0L) + val secondSession = + mock( + MahjongTableSession::class.java, + AdditionalAnswers.delegatesTo(session), + ) + val secondSessionSnapshot = factory.create(secondSession, 5L, 0L) + assertEquals( + restoredSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + secondSessionSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + ) + assertNotSame( + restoredSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + secondSessionSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + ) + assertNotSame( + restoredSnapshot.seat(SeatWind.EAST).viewerMembershipSignature(), + secondSessionSnapshot.seat(SeatWind.EAST).viewerMembershipSignature(), + ) + + factory.create(session, 6L, 0L) `when`(session.playerAt(SeatWind.NORTH)).thenReturn(null) - val emptyNorthSeat = factory.create(session, 2L, 0L).seat(SeatWind.NORTH) + val emptyNorthSeat = factory.create(session, 7L, 0L).seat(SeatWind.NORTH) assertEquals(listOf(eastId, westId, southId), emptyNorthSeat.viewerIdsExcluding()) assertEquals( "00000000-0000-0000-0000-000000000011" + @@ -127,6 +177,24 @@ class TableRenderSnapshotFactoryTest { emptyNorthSeat.viewerMembershipSignature(), ) + `when`(session.viewers()).thenReturn(listOf(westViewer, eastViewer)) + val removedViewerSnapshot = factory.create(session, 8L, 0L) + assertTrue(!removedViewerSnapshot.seat(SeatWind.SOUTH).online()) + assertTrue(removedViewerSnapshot.seat(SeatWind.SOUTH).viewerIdsExcluding().isEmpty()) + assertTrue(removedViewerSnapshot.seat(SeatWind.SOUTH).viewerMembershipSignature().isEmpty()) + assertEquals( + listOf(westId), + removedViewerSnapshot.seat(SeatWind.EAST).viewerIdsExcluding(), + ) + assertEquals( + westId.toString(), + removedViewerSnapshot.seat(SeatWind.EAST).viewerMembershipSignature(), + ) + assertEquals( + listOf(eastId, westId), + removedViewerSnapshot.seat(SeatWind.NORTH).viewerIdsExcluding(), + ) + verify(session, never()).onlinePlayer(ArgumentMatchers.any(UUID::class.java)) verify(session, never()).viewerIdsExcluding(ArgumentMatchers.any(UUID::class.java)) verify(session, never()).viewerMembershipSignatureFor(ArgumentMatchers.any(UUID::class.java)) From 778dca14ef91a43e8fb2159da1271c6e9bdfab4b Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 01:25:02 +0800 Subject: [PATCH 03/12] perf(render): reduce layout precompute churn Reuse stable hand coordinates, compute dead-wall geometry once, and retain private immutable list backing storage to lower per-refresh layout cost. --- .../render/layout/TableRenderLayout.java | 105 ++++++++++++------ .../render/VariantWallRenderLayoutTest.kt | 34 +++++- 2 files changed, 101 insertions(+), 38 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java b/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java index eb0498c..dba6e1e 100644 --- a/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java +++ b/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java @@ -42,6 +42,7 @@ public final class TableRenderLayout { private static final int DEAD_WALL_SIZE = 14; private static final int LIVE_WALL_SIZE = TOTAL_WALL_TILES - DEAD_WALL_SIZE; private static final int DISCARDS_PER_ROW = 6; + private static final SeatWind[] SEAT_WINDS = SeatWind.values(); private TableRenderLayout() { } @@ -53,9 +54,12 @@ public static LayoutPlan precompute(TableRenderSnapshot snapshot) { Point tableVisualAnchor = new Point(tableCenter.x(), tableCenter.y() + TABLE_VISUAL_Y_OFFSET, tableCenter.z()); double borderSpanX = bounds.width() + TABLE_TOP_SIZE_EXPANSION + TABLE_BORDER_THICKNESS; double borderSpanZ = bounds.depth() + TABLE_TOP_SIZE_EXPANSION + TABLE_BORDER_THICKNESS; + List deadWallPlacements = snapshot.started() && snapshot.usesDeadWall() + ? deadWallPlacements(displayCenter, snapshot) + : List.of(); EnumMap seats = new EnumMap<>(SeatWind.class); - for (SeatWind wind : SeatWind.values()) { + for (SeatWind wind : SEAT_WINDS) { TableSeatRenderSnapshot seat = snapshot.seat(wind); seats.put(wind, precomputeSeat(displayCenter, snapshot, seat)); } @@ -67,8 +71,8 @@ public static LayoutPlan precompute(TableRenderSnapshot snapshot) { borderSpanX, borderSpanZ, seats, - precomputeWall(displayCenter, snapshot), - precomputeDora(displayCenter, snapshot) + precomputeWall(displayCenter, snapshot, deadWallPlacements), + precomputeDora(snapshot, deadWallPlacements) ); } @@ -115,12 +119,18 @@ private static SeatLayoutPlan precomputeSeat( ); } - List publicHandPoints = new ArrayList<>(seat.hand().size()); - List privateHandPoints = new ArrayList<>(seat.hand().size()); + int handSize = seat.hand().size(); + List publicHandPoints = new ArrayList<>(handSize); + List privateHandPoints = new ArrayList<>(handSize); List selectedHandTileIndices = seat.selectedHandTileIndices(); - for (int tileIndex = 0; tileIndex < seat.hand().size(); tileIndex++) { - publicHandPoints.add(handTilePoint(displayCenter, seat, wind, tileIndex, false)); - privateHandPoints.add(handTilePoint(displayCenter, seat, wind, tileIndex, selectedHandTileIndices.contains(tileIndex))); + double startingPos = handStartingPosition(seat, handSize); + SeatWind direction = displayDirection(wind); + for (int tileIndex = 0; tileIndex < handSize; tileIndex++) { + Point publicPoint = handTilePoint(handBase, direction, handSize, startingPos, tileIndex, false); + publicHandPoints.add(publicPoint); + privateHandPoints.add(selectedHandTileIndices.contains(tileIndex) + ? handTilePoint(handBase, direction, handSize, startingPos, tileIndex, true) + : publicPoint); } return new SeatLayoutPlan( @@ -130,8 +140,8 @@ private static SeatLayoutPlan precomputeSeat( playerNameLocation, interactionLocation, yaw, - List.copyOf(publicHandPoints), - List.copyOf(privateHandPoints), + immutableList(publicHandPoints), + immutableList(privateHandPoints), precomputeDiscards(displayCenter, seat, snapshot.openDoorSeat()), precomputeMelds(displayCenter, seat), precomputeSticks(displayCenter, seat) @@ -157,10 +167,20 @@ private static SeatLayoutPlan precomputePrivateHandSeat(Point displayCenter, Tab List.of() ); } - List privateHandPoints = new ArrayList<>(seat.hand().size()); + int handSize = seat.hand().size(); + List privateHandPoints = new ArrayList<>(handSize); List selectedHandTileIndices = seat.selectedHandTileIndices(); - for (int tileIndex = 0; tileIndex < seat.hand().size(); tileIndex++) { - privateHandPoints.add(handTilePoint(displayCenter, seat, wind, tileIndex, selectedHandTileIndices.contains(tileIndex))); + double startingPos = handStartingPosition(seat, handSize); + SeatWind direction = displayDirection(wind); + for (int tileIndex = 0; tileIndex < handSize; tileIndex++) { + privateHandPoints.add(handTilePoint( + handBase, + direction, + handSize, + startingPos, + tileIndex, + selectedHandTileIndices.contains(tileIndex) + )); } return new SeatLayoutPlan( wind, @@ -170,14 +190,18 @@ private static SeatLayoutPlan precomputePrivateHandSeat(Point displayCenter, Tab handBase, yaw, List.of(), - List.copyOf(privateHandPoints), + immutableList(privateHandPoints), List.of(), List.of(), List.of() ); } - private static List precomputeWall(Point displayCenter, TableRenderSnapshot snapshot) { + private static List precomputeWall( + Point displayCenter, + TableRenderSnapshot snapshot, + List deadWallPlacements + ) { if (!snapshot.started()) { return List.of(); } @@ -195,7 +219,6 @@ private static List precomputeWall(Point displayCenter, TableRend } } - List deadWallPlacements = deadWallPlacements(displayCenter, snapshot); List placements = Arrays.asList(new TilePlacement[TOTAL_WALL_TILES]); boolean[] occupiedSlots = new boolean[TOTAL_WALL_TILES]; int breakTileIndex = wallBreakTileIndex(snapshot); @@ -261,7 +284,7 @@ private static List precomputeLiveWallWithoutDeadWall(Point displ private static int exposedFlowerCount(TableRenderSnapshot snapshot) { int count = 0; - for (SeatWind wind : SeatWind.values()) { + for (SeatWind wind : SEAT_WINDS) { TableSeatRenderSnapshot seat = snapshot.seat(wind); if (seat == null) { continue; @@ -277,17 +300,19 @@ private static int exposedFlowerCount(TableRenderSnapshot snapshot) { return count; } - private static List precomputeDora(Point displayCenter, TableRenderSnapshot snapshot) { + private static List precomputeDora( + TableRenderSnapshot snapshot, + List deadWallPlacements + ) { if (!snapshot.started() || !snapshot.usesDeadWall()) { return List.of(); } - List deadWallPlacements = deadWallPlacements(displayCenter, snapshot); List placements = new ArrayList<>(snapshot.doraIndicators().size()); for (int i = 0; i < snapshot.doraIndicators().size(); i++) { DeadWallPlacement placement = deadWallPlacements.get(doraIndicatorDeadWallIndex(snapshot.kanCount(), i)); placements.add(new TilePlacement(placement.point(), placement.yaw(), snapshot.doraIndicators().get(i), DisplayEntities.TileRenderPose.FLAT_FACE_UP)); } - return List.copyOf(placements); + return immutableList(placements); } private static List precomputeSticks(Point displayCenter, TableSeatRenderSnapshot seat) { @@ -301,7 +326,7 @@ private static List precomputeSticks(Point displayCenter, TableS if (seat.riichi()) { placements.add(new StickPlacement(riichiStickCenter(displayCenter, seat.wind()), riichiStickLongOnX(seat.wind()), ScoringStick.P1000)); } - return List.copyOf(placements); + return immutableList(placements); } private static List precomputeDiscards( @@ -350,7 +375,7 @@ private static List precomputeDiscards( DisplayEntities.TileRenderPose.FLAT_FACE_UP )); } - return List.copyOf(placements); + return immutableList(placements); } private static List precomputeMelds(Point displayCenter, TableSeatRenderSnapshot seat) { @@ -440,27 +465,31 @@ private static List precomputeMelds(Point displayCenter, TableSea lastTileWasHorizontal = false; } } - return List.copyOf(placements); + return immutableList(placements); } - private static Point handTilePoint( - Point displayCenter, - TableSeatRenderSnapshot seat, - SeatWind wind, - int tileIndex, - boolean selected - ) { - Point handBase = handDirectionBase(displayCenter, wind); - int handSize = seat.hand().size(); + private static double handStartingPosition(TableSeatRenderSnapshot seat, int handSize) { int meldCount = seat.melds().size(); double fuuroOffset = meldCount < 3 ? 0.0D : (meldCount - 2.0D) * TILE_WIDTH; int stickCount = seat.stickLayoutCount(); double sticksOffset = stickCount < 3 ? 0.0D : (stickCount - 2.0D) * STICK_DEPTH; - double startingPos = (handSize * TILE_WIDTH + Math.max(0, handSize - 1) * TILE_PADDING) / 2.0D + fuuroOffset + sticksOffset; + return (handSize * TILE_WIDTH + Math.max(0, handSize - 1) * TILE_PADDING) / 2.0D + + fuuroOffset + + sticksOffset; + } + + private static Point handTilePoint( + Point handBase, + SeatWind direction, + int handSize, + double startingPos, + int tileIndex, + boolean selected + ) { double drawGap = tileIndex == handSize - 1 && handSize % 3 == 2 ? TILE_PADDING * 15.0D : 0.0D; double stackOffset = tileIndex * (TILE_WIDTH + TILE_PADDING) + drawGap; double tileYOffset = selected ? SELECTED_HAND_TILE_Y_OFFSET : 0.0D; - return switch (displayDirection(wind)) { + return switch (direction) { case EAST -> handBase.add(0.0D, UPRIGHT_TILE_Y + tileYOffset, startingPos - stackOffset); case SOUTH -> handBase.add(-startingPos + stackOffset, UPRIGHT_TILE_Y + tileYOffset, 0.0D); case WEST -> handBase.add(0.0D, UPRIGHT_TILE_Y + tileYOffset, -startingPos + stackOffset); @@ -468,8 +497,12 @@ private static Point handTilePoint( }; } + private static List immutableList(List values) { + return values.isEmpty() ? List.of() : Collections.unmodifiableList(values); + } + private static int wallBreakTileIndex(TableRenderSnapshot snapshot) { - int seatCount = SeatWind.values().length; + int seatCount = SEAT_WINDS.length; int dicePoints = snapshot.dicePoints(); int breakDice = snapshot.breakDicePoints(); int openDoorIndex = Math.floorMod(snapshot.dealerSeat().index() + dicePoints - 1, seatCount); @@ -500,7 +533,7 @@ private static TableBounds tableBoundsFromTiles(Point center) { } private static Point meldStartByDisplayDirection(Point center, SeatWind direction) { - for (SeatWind wind : SeatWind.values()) { + for (SeatWind wind : SEAT_WINDS) { if (displayDirection(wind) == direction) { return meldStart(center, wind); } diff --git a/src/test/kotlin/top/ellan/mahjong/render/VariantWallRenderLayoutTest.kt b/src/test/kotlin/top/ellan/mahjong/render/VariantWallRenderLayoutTest.kt index 751b5bc..ed782a2 100644 --- a/src/test/kotlin/top/ellan/mahjong/render/VariantWallRenderLayoutTest.kt +++ b/src/test/kotlin/top/ellan/mahjong/render/VariantWallRenderLayoutTest.kt @@ -17,8 +17,11 @@ import java.util.EnumMap import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull +import kotlin.test.assertNotSame import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue class VariantWallRenderLayoutTest { @@ -66,6 +69,31 @@ class VariantWallRenderLayoutTest { assertNull(plan.wallTiles()[87]) } + @Test + fun `unselected private hand points reuse public coordinates and layout lists remain immutable`() { + val plan = + TableRenderLayout.precompute( + snapshot( + MahjongVariant.GB, + remainingWallCount = 91, + eastHand = listOf(MahjongTile.M1, MahjongTile.M2, MahjongTile.M3), + eastSelectedHandTileIndices = listOf(1), + ), + ) + val east = plan.seat(SeatWind.EAST) + + assertSame(east.publicHandPoints()[0], east.privateHandPoints()[0]) + assertNotSame(east.publicHandPoints()[1], east.privateHandPoints()[1]) + assertEquals( + east.publicHandPoints()[1].y() + 0.06, + east.privateHandPoints()[1].y(), + 1.0e-9, + ) + assertFailsWith { + east.publicHandPoints().add(east.publicHandPoints()[0]) + } + } + @Test fun `wall slots use variant specific tiles per side`() { assertEquals(SeatWind.EAST, WallLayout.wallSeat(35, 36)) @@ -157,6 +185,8 @@ class VariantWallRenderLayoutTest { kanCount: Int = 0, dora: List = emptyList(), eastFlowers: List = emptyList(), + eastHand: List = emptyList(), + eastSelectedHandTileIndices: List = emptyList(), dealerSeat: SeatWind = SeatWind.EAST, roundIndex: Int = 0, dicePoints: Int = 3, @@ -183,11 +213,11 @@ class VariantWallRenderLayoutTest { true, "", -1, - emptyList(), + if (wind == SeatWind.EAST) eastSelectedHandTileIndices else emptyList(), -1, 0, emptyList(), - emptyList(), + if (wind == SeatWind.EAST) eastHand else emptyList(), emptyList(), melds, emptyList(), From 8e14bf6f38062c87c87e977417859268d9d8d5ee Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 17:38:54 +0800 Subject: [PATCH 04/12] perf(live): make plugin scenario reproducible --- docs/performance-testing.md | 26 ++++--- perf/live/artifacts.lock.json | 20 ++--- .../fixtures/mahjongpaper-smoke-config.yml | 19 +++++ perf/live/run_server.py | 18 ++++- perf/live/tests/test_download_locked.py | 22 +++--- perf/live/tests/test_run_server.py | 2 +- .../mahjong/bootstrap/MahjongPaperPlugin.java | 78 ++++++++++++++++++- .../TableViewerSnapshotFactory.java | 2 +- ...ableViewerSnapshotFactoryOverheadTest.java | 11 +++ 9 files changed, 162 insertions(+), 36 deletions(-) create mode 100644 perf/live/fixtures/mahjongpaper-smoke-config.yml diff --git a/docs/performance-testing.md b/docs/performance-testing.md index 41dc009..f1efb3e 100644 --- a/docs/performance-testing.md +++ b/docs/performance-testing.md @@ -362,16 +362,16 @@ The dependency surface is deliberately fixed: | External client runtime | Node `22.23.1` | | Protocol implementation | `minecraft-protocol` `1.66.2`, locked by npm integrity | | Paper | `1.20.1` build `196`, SHA-256 `234a9b32098100c6fc116664d64e36ccdb58b5b649af0f80bcccb08b0255eaea` | -| CraftEngine for plugin runs | Paper `26.7.3` (`Len451or`), SHA-512 from the committed artifact lock | +| CraftEngine for plugin runs | Paper `26.7.4` (`aINSQrXC`), SHA-512 from the committed artifact lock | `perf/live/artifacts.lock.json` contains immutable URLs, byte sizes and hashes. Downloads are written to a temporary file, size/hash verified, fsynced, then atomically moved into place. The Paper URL is the official Paper downloads-service object. The optional CraftEngine entry -is artifact `craftengine-paper-26.7.3`, the latest Paper/Folia/Purpur file published for that +is artifact `craftengine-paper-26.7.4`, the latest Paper/Folia/Purpur file published for that version. Do not substitute the same-version Bukkit/Spigot file: Modrinth marks that separate artifact for Minecraft 26.x only, while the locked Paper file explicitly includes Paper 1.20.1 through 26.2. Its committed metadata source is -`https://api.modrinth.com/v2/version/Len451or`. +`https://api.modrinth.com/v2/version/aINSQrXC`. ### What is actually counted @@ -424,13 +424,16 @@ or substitutes a stub named `CraftEngine`. ```powershell python perf/live/download_locked.py ` - --artifact craftengine-paper-26.7.3 ` - --destination build/live-cache/craft-engine-paper-plugin-26.7.3.jar + --artifact craftengine-paper-26.7.4 ` + --destination build/live-cache/craft-engine-paper-plugin-26.7.4.jar python perf/live/run_server.py ` --paper-jar build/live-cache/server.jar ` --plugin-jar build/libs/mahjong-paper-1.5.0.jar ` - --craftengine-jar build/live-cache/craft-engine-paper-plugin-26.7.3.jar ` + --plugin-config perf/live/fixtures/mahjongpaper-smoke-config.yml ` + --craftengine-jar build/live-cache/craft-engine-paper-plugin-26.7.4.jar ` --required-plugin MahjongPaper ` + --setup-command 'execute at MahjongPerfBot run fill ~-16 ~-1 ~-16 ~16 ~-1 ~16 minecraft:stone' ` + --setup-command 'execute at MahjongPerfBot run fill ~-16 ~ ~-16 ~16 ~10 ~16 minecraft:air' ` --scenario-name majsoul-hanchan-four-bot-spectator ` --scenario-command "/mahjong botmatch MAJSOUL_HANCHAN" ` --scenario-ready-pattern 'Round .+ \| Turn .+ \| Wall [1-9][0-9]* \| Spectators 1' ` @@ -449,11 +452,11 @@ and must observe the committed ready pattern before warmup and measurement begin traffic is a hard failure, not a fallback to an empty-server keepalive run. The standalone measurement-infrastructure PR deliberately does not execute this plugin profile -as its required pull-request check. The current foundation baseline does not expose Caffeine to -the plugin classloader on a fresh Paper instance, so the real plugin profile correctly stops at -the required-plugin enablement check. That dependency blocker must be fixed in the foundation -change and then exercised by a stacked run; this harness does not patch the plugin descriptor, -inject a fake dependency, or turn the known failure green. +as its required pull-request check. Plugin runs use the committed fixture config to disable game +room placement restrictions, persistence and ranking equally for base and candidate, then create +a flat, clear table footprint relative to the connected protocol client before starting the +botmatch. The harness still loads the real locked CraftEngine plugin and does not patch plugin +metadata or inject a fake dependency. ### Measurement rules @@ -480,6 +483,7 @@ run-manifest.json hashes, versions, ports, timings, shutdown res paper-summary.json derived TPS/MSPT sample distributions raw/paper-samples.jsonl exact timestamped Paper command responses plus parsed values raw/server-stdout.log complete server console output +raw/inputs/plugin-config.yml exact staged plugin fixture configuration, when supplied raw/instance/logs/latest.log Paper's own latest.log raw/runtime-jars.json SHA-256 of server/plugin/runtime jars actually loaded raw/protocol-client/packets.jsonl one record per externally observed Minecraft frame diff --git a/perf/live/artifacts.lock.json b/perf/live/artifacts.lock.json index 0372637..3aee941 100644 --- a/perf/live/artifacts.lock.json +++ b/perf/live/artifacts.lock.json @@ -16,13 +16,13 @@ }, "source_metadata": "https://fill.papermc.io/v3/projects/paper/versions/1.20.1/builds" }, - "craftengine-paper-26.7.3": { + "craftengine-paper-26.7.4": { "kind": "server-plugin", "project": "craftengine", "distribution": "paper", - "version": "26.7.3", + "version": "26.7.4", "release_type": "beta", - "modrinth_version_id": "Len451or", + "modrinth_version_id": "aINSQrXC", "loaders": [ "folia", "paper", @@ -53,18 +53,18 @@ "26.1.2", "26.2" ], - "filename": "craft-engine-paper-plugin-26.7.3.jar", - "url": "https://cdn.modrinth.com/data/tRX6FMfQ/versions/Len451or/craft-engine-paper-plugin-26.7.3.jar", - "size": 8907932, + "filename": "craft-engine-paper-plugin-26.7.4.jar", + "url": "https://cdn.modrinth.com/data/tRX6FMfQ/versions/aINSQrXC/craft-engine-paper-plugin-26.7.4.jar", + "size": 8970694, "hash": { "algorithm": "sha512", - "digest": "e87351417e4f99504cf0a8868bad03a88b12bc9bda5c36f0ee4c7451e3525f08146b2130b61cd34b222ee41c38ca75aa9ced9ada65bc3540c80aa66c3d39d689" + "digest": "1d6982e325552fd51a9a481252e0c055c32a5b6e3c2d816cf69ca4b2f355a5f7af1abed00ff9d29c08fb32ff293d9d11837ad4372c34675b73682a98b1ec86b0" }, "published_hashes": { - "sha1": "467fa7317635cf9f76812bd73a47d984c57cf53f", - "sha512": "e87351417e4f99504cf0a8868bad03a88b12bc9bda5c36f0ee4c7451e3525f08146b2130b61cd34b222ee41c38ca75aa9ced9ada65bc3540c80aa66c3d39d689" + "sha1": "179e7b8b191093e41beca56e3d52ae608f46e161", + "sha512": "1d6982e325552fd51a9a481252e0c055c32a5b6e3c2d816cf69ca4b2f355a5f7af1abed00ff9d29c08fb32ff293d9d11837ad4372c34675b73682a98b1ec86b0" }, - "source_metadata": "https://api.modrinth.com/v2/version/Len451or" + "source_metadata": "https://api.modrinth.com/v2/version/aINSQrXC" } } } diff --git a/perf/live/fixtures/mahjongpaper-smoke-config.yml b/perf/live/fixtures/mahjongpaper-smoke-config.yml new file mode 100644 index 0000000..79ef9d4 --- /dev/null +++ b/perf/live/fixtures/mahjongpaper-smoke-config.yml @@ -0,0 +1,19 @@ +# Deterministic live-performance fixture. Base and candidate runs must use this exact file. +database: + enabled: false + +tables: + persistence: + enabled: false + +gameRooms: + enabled: false + restrictNewTables: false + enterExitMessages: false + +ranking: + enabled: false + +integrations: + craftengine: + exportBundleOnEnable: true diff --git a/perf/live/run_server.py b/perf/live/run_server.py index bf3baae..0b6352f 100644 --- a/perf/live/run_server.py +++ b/perf/live/run_server.py @@ -33,7 +33,7 @@ TPS_VALUES = re.compile(r"\*?(\d+(?:\.\d+)?)") MSPT_TRIPLE = re.compile(r"(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)") READY_LINE = re.compile(r"Done \([^)]+\)! For help, type") -DEFAULT_CRAFTENGINE_ARTIFACT = "craftengine-paper-26.7.3" +DEFAULT_CRAFTENGINE_ARTIFACT = "craftengine-paper-26.7.4" MAHJONG_TRAFFIC_COMMAND = re.compile( r"^/mahjong botmatch (MAJSOUL_HANCHAN|MAJSOUL_TONPUU|GB|SICHUAN)$" ) @@ -474,6 +474,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--paper-lock", type=Path, default=script_dir / "artifacts.lock.json") parser.add_argument("--paper-artifact", default="paper-1.20.1-196") parser.add_argument("--plugin-jar", type=Path) + parser.add_argument("--plugin-config", type=Path) parser.add_argument("--craftengine-jar", type=Path) parser.add_argument("--craftengine-artifact", default=DEFAULT_CRAFTENGINE_ARTIFACT) parser.add_argument("--required-plugin", action="append", default=[]) @@ -505,6 +506,10 @@ def validate_args(args: argparse.Namespace) -> None: raise ValueError(f"Paper jar does not exist: {args.paper_jar}") if args.plugin_jar is not None and not args.plugin_jar.is_file(): raise ValueError(f"Plugin jar does not exist: {args.plugin_jar}") + if args.plugin_config is not None and not args.plugin_config.is_file(): + raise ValueError(f"Plugin config does not exist: {args.plugin_config}") + if args.plugin_config is not None and args.plugin_jar is None: + raise ValueError("--plugin-config requires --plugin-jar") if args.plugin_jar is not None and args.craftengine_jar is None: raise ValueError("--plugin-jar requires the real locked --craftengine-jar; a stub dependency is not accepted") if args.craftengine_jar is not None and not args.craftengine_jar.is_file(): @@ -558,6 +563,7 @@ def main() -> int: paper_verification = verify_file(args.paper_jar, paper_spec) input_artifacts: dict[str, Any] = {"paper": paper_verification} plugin_name = None + plugin_configuration = None if args.plugin_jar is not None: plugin_name = jar_plugin_name(args.plugin_jar) if not plugin_name: @@ -565,6 +571,11 @@ def main() -> int: input_artifacts["plugin"] = hash_description(args.plugin_jar) craftengine_spec = artifact_from_lock(lock, args.craftengine_artifact) input_artifacts["craftengine"] = verify_file(args.craftengine_jar, craftengine_spec) + if args.plugin_config is not None: + plugin_configuration = hash_description(args.plugin_config) + input_copy = raw_dir / "inputs" / "plugin-config.yml" + input_copy.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(args.plugin_config, input_copy) ports = reserve_ports(2) server_port, rcon_port = ports @@ -604,6 +615,10 @@ def main() -> int: plugins_dir.mkdir() shutil.copy2(args.craftengine_jar, plugins_dir / str(artifact_from_lock(lock, args.craftengine_artifact)["filename"])) shutil.copy2(args.plugin_jar, plugins_dir / args.plugin_jar.name) + if args.plugin_config is not None: + plugin_data_dir = plugins_dir / plugin_name + plugin_data_dir.mkdir() + shutil.copy2(args.plugin_config, plugin_data_dir / "config.yml") java_command = [ args.java, @@ -785,6 +800,7 @@ def main() -> int: "artifact_lock": hash_description(args.paper_lock.resolve()), "world_template": world_description, "plugin_name": plugin_name, + "plugin_configuration": plugin_configuration, "setup_commands": args.setup_command, "scenario": { "name": args.scenario_name, diff --git a/perf/live/tests/test_download_locked.py b/perf/live/tests/test_download_locked.py index 41cea48..9e9dbcd 100644 --- a/perf/live/tests/test_download_locked.py +++ b/perf/live/tests/test_download_locked.py @@ -28,34 +28,34 @@ def test_committed_paper_lock_has_expected_immutable_object(self) -> None: def test_committed_craftengine_lock_matches_latest_paper_release(self) -> None: lock = load_lock(LIVE_DIR / "artifacts.lock.json") - craftengine = artifact_from_lock(lock, "craftengine-paper-26.7.3") + craftengine = artifact_from_lock(lock, "craftengine-paper-26.7.4") self.assertEqual("craftengine", craftengine["project"]) self.assertEqual("paper", craftengine["distribution"]) - self.assertEqual("26.7.3", craftengine["version"]) - self.assertEqual("Len451or", craftengine["modrinth_version_id"]) + self.assertEqual("26.7.4", craftengine["version"]) + self.assertEqual("aINSQrXC", craftengine["modrinth_version_id"]) self.assertIn("paper", craftengine["loaders"]) self.assertIn("1.20.1", craftengine["minecraft_versions"]) self.assertIn("26.1.2", craftengine["minecraft_versions"]) - self.assertEqual("craft-engine-paper-plugin-26.7.3.jar", craftengine["filename"]) + self.assertEqual("craft-engine-paper-plugin-26.7.4.jar", craftengine["filename"]) self.assertEqual( - "https://cdn.modrinth.com/data/tRX6FMfQ/versions/Len451or/" - "craft-engine-paper-plugin-26.7.3.jar", + "https://cdn.modrinth.com/data/tRX6FMfQ/versions/aINSQrXC/" + "craft-engine-paper-plugin-26.7.4.jar", craftengine["url"], ) - self.assertEqual(8_907_932, craftengine["size"]) + self.assertEqual(8_970_694, craftengine["size"]) self.assertEqual("sha512", craftengine["hash"]["algorithm"]) self.assertEqual( - "e87351417e4f99504cf0a8868bad03a88b12bc9bda5c36f0ee4c7451e3525f0" - "8146b2130b61cd34b222ee41c38ca75aa9ced9ada65bc3540c80aa66c3d39d689", + "1d6982e325552fd51a9a481252e0c055c32a5b6e3c2d816cf69ca4b2f355a5f7" + "af1abed00ff9d29c08fb32ff293d9d11837ad4372c34675b73682a98b1ec86b0", craftengine["hash"]["digest"], ) self.assertEqual(craftengine["hash"]["digest"], craftengine["published_hashes"]["sha512"]) self.assertEqual( - "467fa7317635cf9f76812bd73a47d984c57cf53f", + "179e7b8b191093e41beca56e3d52ae608f46e161", craftengine["published_hashes"]["sha1"], ) self.assertEqual( - "https://api.modrinth.com/v2/version/Len451or", + "https://api.modrinth.com/v2/version/aINSQrXC", craftengine["source_metadata"], ) diff --git a/perf/live/tests/test_run_server.py b/perf/live/tests/test_run_server.py index 6336e8c..844e3e6 100644 --- a/perf/live/tests/test_run_server.py +++ b/perf/live/tests/test_run_server.py @@ -47,7 +47,7 @@ def test_percentile_uses_linear_interpolation(self) -> None: class LiveInputTest(unittest.TestCase): def test_uses_latest_paper_compatible_craftengine_by_default(self) -> None: - self.assertEqual("craftengine-paper-26.7.3", DEFAULT_CRAFTENGINE_ARTIFACT) + self.assertEqual("craftengine-paper-26.7.4", DEFAULT_CRAFTENGINE_ARTIFACT) def test_reserved_ports_are_distinct(self) -> None: ports = reserve_ports(3) diff --git a/src/main/java/top/ellan/mahjong/bootstrap/MahjongPaperPlugin.java b/src/main/java/top/ellan/mahjong/bootstrap/MahjongPaperPlugin.java index a1bd3b1..8af3d7d 100644 --- a/src/main/java/top/ellan/mahjong/bootstrap/MahjongPaperPlugin.java +++ b/src/main/java/top/ellan/mahjong/bootstrap/MahjongPaperPlugin.java @@ -33,12 +33,18 @@ import java.lang.reflect.Proxy; import java.io.IOException; import java.util.Collection; +import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; +import org.bukkit.command.Command; +import org.bukkit.command.CommandMap; +import org.bukkit.command.CommandSender; import org.bukkit.command.PluginCommand; +import org.bukkit.command.PluginIdentifiableCommand; +import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; public final class MahjongPaperPlugin extends JavaPlugin { @@ -58,6 +64,8 @@ public final class MahjongPaperPlugin extends JavaPlugin { private GameRoomSelectionService gameRoomSelectionService; private GameRoomSelectionPreviewService gameRoomSelectionPreviewService; private PluginTask gameRoomTickTask; + private CommandMap legacyPaperCommandMap; + private Command legacyPaperCommand; @Override public void onEnable() { @@ -153,6 +161,7 @@ public void onEnable() { @Override public void onDisable() { + this.unregisterLegacyPaperCommand(); if (this.craftEngine != null) { this.craftEngine.disableFurnitureInteractionBridge(); this.craftEngine.clearTrackedCullables(); @@ -226,7 +235,12 @@ private boolean registerMahjongCommand(MahjongCommand mahjongCommand) { PluginCommand command = this.getCommand("mahjong"); if (command == null) { - this.getLogger().severe("MahjongPaper command is missing from plugin.yml; disabling plugin."); + if (this.registerLegacyPaperCommand(mahjongCommand)) { + return true; + } + this.getLogger().severe( + "MahjongPaper could not register its command through Paper lifecycle events, plugin.yml, or the legacy Paper command map; disabling plugin." + ); this.getServer().getPluginManager().disablePlugin(this); return false; } @@ -235,6 +249,34 @@ private boolean registerMahjongCommand(MahjongCommand mahjongCommand) { return true; } + private boolean registerLegacyPaperCommand(MahjongCommand delegate) { + CommandMap commandMap = this.getServer().getCommandMap(); + LegacyPaperMahjongCommand command = new LegacyPaperMahjongCommand(this, delegate); + boolean registeredPrimaryLabel = commandMap.register("mahjong", "mahjongpaper", command); + if (!registeredPrimaryLabel) { + removeLegacyPaperCommand(commandMap, command); + return false; + } + this.legacyPaperCommandMap = commandMap; + this.legacyPaperCommand = command; + return true; + } + + private void unregisterLegacyPaperCommand() { + CommandMap commandMap = this.legacyPaperCommandMap; + Command command = this.legacyPaperCommand; + this.legacyPaperCommandMap = null; + this.legacyPaperCommand = null; + if (commandMap != null && command != null) { + removeLegacyPaperCommand(commandMap, command); + } + } + + private static void removeLegacyPaperCommand(CommandMap commandMap, Command command) { + commandMap.getKnownCommands().entrySet().removeIf(entry -> entry.getValue() == command); + command.unregister(commandMap); + } + private PaperCommandRegistrationResult registerPaperCommand(MahjongCommand mahjongCommand) { try { Class lifecycleEventsClass = Class.forName("io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents"); @@ -321,6 +363,40 @@ private enum PaperCommandRegistrationResult { FAILED } + /** Command-map adapter for Paper versions that support paper-plugin.yml but predate lifecycle commands. */ + private static final class LegacyPaperMahjongCommand extends Command implements PluginIdentifiableCommand { + private final MahjongPaperPlugin plugin; + private final MahjongCommand delegate; + + private LegacyPaperMahjongCommand(MahjongPaperPlugin plugin, MahjongCommand delegate) { + super( + "mahjong", + "Manage MahjongPaper tables and rounds. Use /mahjong help for command explanations.", + "/mahjong ", + java.util.List.of() + ); + this.plugin = plugin; + this.delegate = delegate; + this.setPermission(delegate.permission()); + } + + @Override + public boolean execute(CommandSender sender, String commandLabel, String[] args) { + return this.delegate.onCommand(sender, this, commandLabel, args); + } + + @Override + public List tabComplete(CommandSender sender, String alias, String[] args) { + List suggestions = this.delegate.onTabComplete(sender, this, alias, args); + return suggestions == null ? List.of() : suggestions; + } + + @Override + public Plugin getPlugin() { + return this.plugin; + } + } + public String reloadMahjongConfiguration() { PluginSettings previousSettings = this.settings; PluginSettings reloadedSettings; diff --git a/src/main/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactory.java b/src/main/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactory.java index 1229838..fc282cb 100644 --- a/src/main/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactory.java +++ b/src/main/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactory.java @@ -45,7 +45,7 @@ public Component createStateSummary(Player player) { Locale locale = this.session.plugin().messages().resolveLocale(player); UUID viewerId = player.getUniqueId(); PlayerActionSnapshot actionSnapshot = this.actionSnapshotFactory.capture(viewerId); - ViewerSummarySnapshot summary = this.captureViewerSummarySnapshot(locale, viewerId, actionSnapshot, false); + ViewerSummarySnapshot summary = this.captureViewerSummarySnapshot(locale, viewerId, actionSnapshot, true); return this.session.plugin().messages().render( player, "command.rule_summary", diff --git a/src/test/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactoryOverheadTest.java b/src/test/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactoryOverheadTest.java index 3f8dc20..abcaa4e 100644 --- a/src/test/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactoryOverheadTest.java +++ b/src/test/java/top/ellan/mahjong/table/presentation/TableViewerSnapshotFactoryOverheadTest.java @@ -53,6 +53,17 @@ void activeRoundAddsViewRiverButtonWhenNoTurnOrReactionActionsExist() { } } + @Test + void commandStateSummaryIncludesTheLiveRoundTurnWallAndSpectatorCount() { + Fixture fixture = fixture(false, true, SeatWind.SOUTH, false, MahjongVariant.RIICHI); + + String summary = PlainTextComponentSerializer.plainText().serialize( + new TableViewerSnapshotFactory(fixture.session()).createStateSummary(fixture.viewer()) + ); + + assertEquals("Round East 1 | Turn Other player | Wall 42 | Spectators 0", summary); + } + @Test void activeOverheadViewIsReadOnlyAndUsesShiftToReturn() { Fixture fixture = fixture(true); From 074e2fdd283c6043394f9f6aa12558a409cce454 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 14:31:08 +0800 Subject: [PATCH 05/12] perf: add hotspot lifecycle benchmarks --- .github/workflows/performance-ab.yml | 8 + perf/ab/gate-config.json | 226 ++++++++++++++ .../riichi/RiichiReactionBenchmark.java | 94 ++++++ .../SessionRenderLayoutCacheBenchmark.java | 208 +++++++++++++ .../round/SichuanHuEvaluatorBenchmark.java | 56 ++++ .../render/RayProxyLifecycleBenchmark.java | 210 +++++++++++++ .../render/RegionRenderApplyBenchmark.java | 292 ++++++++++++++++++ 7 files changed, 1094 insertions(+) create mode 100644 src/perfTest/java/top/ellan/mahjong/riichi/RiichiReactionBenchmark.java create mode 100644 src/perfTest/java/top/ellan/mahjong/table/core/SessionRenderLayoutCacheBenchmark.java create mode 100644 src/perfTest/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluatorBenchmark.java create mode 100644 src/perfTest/java/top/ellan/mahjong/table/render/RayProxyLifecycleBenchmark.java create mode 100644 src/perfTest/java/top/ellan/mahjong/table/render/RegionRenderApplyBenchmark.java diff --git a/.github/workflows/performance-ab.yml b/.github/workflows/performance-ab.yml index 7876ad5..184644a 100644 --- a/.github/workflows/performance-ab.yml +++ b/.github/workflows/performance-ab.yml @@ -40,6 +40,10 @@ on: - layout - scheduler-reflection - ray-proxy + - riichi-reaction + - sichuan-hu + - region-apply + - layout-cache - infra forks: description: Trusted CI override for JMH forks (blank uses base config) @@ -157,6 +161,10 @@ jobs: "performance-layout": "layout", "performance-scheduler-reflection": "scheduler-reflection", "performance-ray-proxy": "ray-proxy", + "performance-riichi-reaction": "riichi-reaction", + "performance-sichuan-hu": "sichuan-hu", + "performance-region-apply": "region-apply", + "performance-layout-cache": "layout-cache", } selected = [profile for label, profile in choices.items() if label in labels] if len(selected) != 1: diff --git a/perf/ab/gate-config.json b/perf/ab/gate-config.json index f438505..11457ec 100644 --- a/perf/ab/gate-config.json +++ b/perf/ab/gate-config.json @@ -189,6 +189,88 @@ "top/ellan/mahjong/runtime/ServerScheduler.class" ] }, + "riichi-reaction": { + "include": "^top\\.ellan\\.mahjong\\.riichi\\.RiichiReactionBenchmark\\.(?:structurallyImpossibleCall|structurallyImpossibleRonOnly|firstPossiblePonAnalysis)$", + "benchmark_ids": [ + "riichi.impossible.time", + "riichi.impossible.alloc", + "riichi.ron-only.time", + "riichi.ron-only.alloc", + "riichi.pon.time", + "riichi.pon.alloc" + ], + "minimum_passes": 2, + "must_pass": [ + "riichi.impossible.time", + "riichi.ron-only.time" + ], + "required_classes": [ + "top/ellan/mahjong/riichi/RiichiPlayerAnalysisState.class", + "top/ellan/mahjong/riichi/RiichiPlayerState.class" + ] + }, + "sichuan-hu": { + "include": "^top\\.ellan\\.mahjong\\.table\\.core\\.round\\.SichuanHuEvaluatorBenchmark\\.(?:waitingTilesStandardHand|evaluateStandardWin|canWinWithFixedMeld)$", + "benchmark_ids": [ + "sichuan.waiting.time", + "sichuan.waiting.alloc", + "sichuan.evaluate.time", + "sichuan.evaluate.alloc", + "sichuan.can-win.time", + "sichuan.can-win.alloc" + ], + "minimum_passes": 3, + "must_pass": [ + "sichuan.waiting.time", + "sichuan.evaluate.time", + "sichuan.can-win.time" + ], + "required_classes": [ + "top/ellan/mahjong/table/core/round/SichuanHuEvaluator.class" + ] + }, + "region-apply": { + "include": "^top\\.ellan\\.mahjong\\.table\\.render\\.RegionRenderApplyBenchmark\\.(?:planAndDeferRepresentativeTableRegions|applyStableStartedTable|precomputeCompleteStartedTableRegions)$", + "benchmark_ids": [ + "region.apply-deferred.time", + "region.apply-deferred.alloc", + "region.apply-stable.time", + "region.apply-stable.alloc", + "region.complete-fingerprints.time", + "region.complete-fingerprints.alloc" + ], + "minimum_passes": 3, + "must_pass": [ + "region.apply-deferred.time", + "region.apply-stable.time", + "region.complete-fingerprints.time" + ], + "required_classes": [ + "top/ellan/mahjong/table/render/TableRegionDisplayCoordinator.class", + "top/ellan/mahjong/table/render/TableRegionFingerprintService.class" + ] + }, + "layout-cache": { + "include": "^top\\.ellan\\.mahjong\\.table\\.core\\.SessionRenderLayoutCacheBenchmark\\.(?:stableSessionCacheHit|representativeSeatComponentMiss|representativeWallComponentMiss)$", + "benchmark_ids": [ + "layout-cache.hit.time", + "layout-cache.hit.alloc", + "layout-cache.seat-miss.time", + "layout-cache.seat-miss.alloc", + "layout-cache.wall-miss.time", + "layout-cache.wall-miss.alloc" + ], + "minimum_passes": 3, + "must_pass": [ + "layout-cache.hit.time", + "layout-cache.seat-miss.time", + "layout-cache.wall-miss.time" + ], + "required_classes": [ + "top/ellan/mahjong/render/layout/TableRenderLayout.class", + "top/ellan/mahjong/table/core/MahjongTableSession.class" + ] + }, "ray-proxy": { "include": "^top\\.ellan\\.mahjong\\.perf\\.RayProxyCoordinatorBenchmark\\.proxyPlan(?:1Viewer|4Viewers|32Viewers)$", "benchmark_ids": [ @@ -414,6 +496,150 @@ "metric": "gc.alloc.rate.norm", "direction": "lower" }, + { + "id": "riichi.impossible.time", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.structurallyImpossibleCall", + "metric": "primary", + "direction": "lower" + }, + { + "id": "riichi.impossible.alloc", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.structurallyImpossibleCall", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "riichi.ron-only.time", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.structurallyImpossibleRonOnly", + "metric": "primary", + "direction": "lower" + }, + { + "id": "riichi.ron-only.alloc", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.structurallyImpossibleRonOnly", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "riichi.pon.time", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.firstPossiblePonAnalysis", + "metric": "primary", + "direction": "lower" + }, + { + "id": "riichi.pon.alloc", + "benchmark": "top.ellan.mahjong.riichi.RiichiReactionBenchmark.firstPossiblePonAnalysis", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "sichuan.waiting.time", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.waitingTilesStandardHand", + "metric": "primary", + "direction": "lower" + }, + { + "id": "sichuan.waiting.alloc", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.waitingTilesStandardHand", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "sichuan.evaluate.time", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.evaluateStandardWin", + "metric": "primary", + "direction": "lower" + }, + { + "id": "sichuan.evaluate.alloc", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.evaluateStandardWin", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "sichuan.can-win.time", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.canWinWithFixedMeld", + "metric": "primary", + "direction": "lower" + }, + { + "id": "sichuan.can-win.alloc", + "benchmark": "top.ellan.mahjong.table.core.round.SichuanHuEvaluatorBenchmark.canWinWithFixedMeld", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "region.apply-deferred.time", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.planAndDeferRepresentativeTableRegions", + "metric": "primary", + "direction": "lower" + }, + { + "id": "region.apply-deferred.alloc", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.planAndDeferRepresentativeTableRegions", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "region.apply-stable.time", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.applyStableStartedTable", + "metric": "primary", + "direction": "lower" + }, + { + "id": "region.apply-stable.alloc", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.applyStableStartedTable", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "region.complete-fingerprints.time", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.precomputeCompleteStartedTableRegions", + "metric": "primary", + "direction": "lower" + }, + { + "id": "region.complete-fingerprints.alloc", + "benchmark": "top.ellan.mahjong.table.render.RegionRenderApplyBenchmark.precomputeCompleteStartedTableRegions", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "layout-cache.hit.time", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.stableSessionCacheHit", + "metric": "primary", + "direction": "lower" + }, + { + "id": "layout-cache.hit.alloc", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.stableSessionCacheHit", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "layout-cache.seat-miss.time", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.representativeSeatComponentMiss", + "metric": "primary", + "direction": "lower" + }, + { + "id": "layout-cache.seat-miss.alloc", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.representativeSeatComponentMiss", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, + { + "id": "layout-cache.wall-miss.time", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.representativeWallComponentMiss", + "metric": "primary", + "direction": "lower" + }, + { + "id": "layout-cache.wall-miss.alloc", + "benchmark": "top.ellan.mahjong.table.core.SessionRenderLayoutCacheBenchmark.representativeWallComponentMiss", + "metric": "gc.alloc.rate.norm", + "direction": "lower" + }, { "id": "ray.viewers1.time", "benchmark": "top.ellan.mahjong.perf.RayProxyCoordinatorBenchmark.proxyPlan1Viewer", diff --git a/src/perfTest/java/top/ellan/mahjong/riichi/RiichiReactionBenchmark.java b/src/perfTest/java/top/ellan/mahjong/riichi/RiichiReactionBenchmark.java new file mode 100644 index 0000000..83a2180 --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/riichi/RiichiReactionBenchmark.java @@ -0,0 +1,94 @@ +package top.ellan.mahjong.riichi; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import top.ellan.mahjong.riichi.model.MahjongTile; +import top.ellan.mahjong.riichi.model.TileInstance; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class RiichiReactionBenchmark { + @Benchmark + public ReactionOptions structurallyImpossibleCall(NoCallState state) { + return state.player.reactionOptionsFor(state.discard, false, false); + } + + @Benchmark + public ReactionOptions structurallyImpossibleRonOnly(NoCallState state) { + return state.player.reactionOptionsFor(state.discard, false, true); + } + + @Benchmark + public ReactionOptions firstPossiblePonAnalysis(PossibleCallState state) { + return state.player.reactionOptionsFor(state.discard, false, false); + } + + @State(Scope.Thread) + public static class NoCallState { + private RiichiPlayerState player; + private TileInstance discard; + private Map furoReactions; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + this.player = player(List.of( + MahjongTile.M1, MahjongTile.M4, MahjongTile.M7, + MahjongTile.P1, MahjongTile.P4, MahjongTile.P7, + MahjongTile.S1, MahjongTile.S4, MahjongTile.S7, + MahjongTile.SOUTH, MahjongTile.WEST, MahjongTile.WHITE_DRAGON, MahjongTile.GREEN_DRAGON + )); + this.discard = tile(100, MahjongTile.EAST); + if (this.player.reactionOptionsFor(this.discard, false, false) != null) { + throw new IllegalStateException("No-call reaction fixture changed"); + } + Field cacheField = RiichiPlayerAnalysisState.class.getDeclaredField("cachedFuroReactions"); + cacheField.setAccessible(true); + this.furoReactions = (Map) cacheField.get(this.player); + } + + @Setup(Level.Invocation) + public void clearFuroReaction() { + this.furoReactions.clear(); + } + } + + @State(Scope.Thread) + public static class PossibleCallState { + private RiichiPlayerState player; + private TileInstance discard; + + @Setup(Level.Invocation) + public void setUp() { + this.player = player(List.of( + MahjongTile.M5_RED, MahjongTile.M5, MahjongTile.M1, MahjongTile.M9, + MahjongTile.P1, MahjongTile.P9, MahjongTile.S1, MahjongTile.S9, + MahjongTile.EAST, MahjongTile.SOUTH, MahjongTile.WEST, + MahjongTile.WHITE_DRAGON, MahjongTile.GREEN_DRAGON + )); + this.discard = tile(200, MahjongTile.M5); + } + } + + private static RiichiPlayerState player(List hand) { + RiichiPlayerState player = new RiichiPlayerState("benchmark", "benchmark", false); + for (int index = 0; index < hand.size(); index++) { + player.getHands().add(tile(index, hand.get(index))); + } + return player; + } + + private static TileInstance tile(int id, MahjongTile tile) { + return new TileInstance(new UUID(0L, id + 1L), tile); + } +} diff --git a/src/perfTest/java/top/ellan/mahjong/table/core/SessionRenderLayoutCacheBenchmark.java b/src/perfTest/java/top/ellan/mahjong/table/core/SessionRenderLayoutCacheBenchmark.java new file mode 100644 index 0000000..8a5c26f --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/table/core/SessionRenderLayoutCacheBenchmark.java @@ -0,0 +1,208 @@ +package top.ellan.mahjong.table.core; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.EnumMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import top.ellan.mahjong.model.MahjongTile; +import top.ellan.mahjong.model.MahjongVariant; +import top.ellan.mahjong.model.SeatWind; +import top.ellan.mahjong.render.layout.TableRenderLayout; +import top.ellan.mahjong.render.scene.MeldView; +import top.ellan.mahjong.render.snapshot.TableRenderSnapshot; +import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot; +import top.ellan.mahjong.riichi.model.ScoringStick; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class SessionRenderLayoutCacheBenchmark { + @Benchmark + public TableRenderLayout.LayoutPlan stableSessionCacheHit(HitState state) { + state.alternate = !state.alternate; + return state.precompute(state.alternate ? state.first : state.second); + } + + @Benchmark + public TableRenderLayout.LayoutPlan representativeSeatComponentMiss(SeatMissState state) { + state.alternate = !state.alternate; + return state.precompute(state.alternate ? state.first : state.second); + } + + @Benchmark + public TableRenderLayout.LayoutPlan representativeWallComponentMiss(WallMissState state) { + state.alternate = !state.alternate; + return state.precompute(state.alternate ? state.first : state.second); + } + + private abstract static class CacheState { + private MethodHandle precompute; + + final void initializePrecompute() throws ReflectiveOperationException { + try { + Class cacheType = Class.forName("top.ellan.mahjong.table.core.SessionRenderLayoutCache"); + Constructor constructor = cacheType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object cache = constructor.newInstance(); + Method method = cacheType.getDeclaredMethod("precompute", TableRenderSnapshot.class); + method.setAccessible(true); + this.precompute = MethodHandles.lookup() + .unreflect(method) + .bindTo(cache) + .asType(MethodType.methodType(TableRenderLayout.LayoutPlan.class, TableRenderSnapshot.class)); + } catch (ClassNotFoundException ignored) { + this.precompute = MethodHandles.lookup().findStatic( + TableRenderLayout.class, + "precompute", + MethodType.methodType(TableRenderLayout.LayoutPlan.class, TableRenderSnapshot.class) + ); + } catch (NoSuchMethodException | IllegalAccessException exception) { + throw new ReflectiveOperationException(exception); + } + } + + final TableRenderLayout.LayoutPlan precompute(TableRenderSnapshot snapshot) { + try { + return (TableRenderLayout.LayoutPlan) this.precompute.invokeExact(snapshot); + } catch (Throwable throwable) { + throw new IllegalStateException("Unable to invoke the layout precompute benchmark target", throwable); + } + } + } + + @State(Scope.Thread) + public static class HitState extends CacheState { + private TableRenderSnapshot first; + private TableRenderSnapshot second; + private boolean alternate; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + this.initializePrecompute(); + this.first = snapshot(1L); + this.second = snapshot(2L); + this.precompute(this.first); + } + } + + @State(Scope.Thread) + public static class SeatMissState extends CacheState { + private TableRenderSnapshot first; + private TableRenderSnapshot second; + private boolean alternate; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + this.initializePrecompute(); + this.first = snapshot(1L, 70, List.of(4)); + this.second = snapshot(2L, 70, List.of(3)); + this.precompute(this.first); + } + } + + @State(Scope.Thread) + public static class WallMissState extends CacheState { + private TableRenderSnapshot first; + private TableRenderSnapshot second; + private boolean alternate; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + this.initializePrecompute(); + this.first = snapshot(1L, 70, List.of(4)); + this.second = snapshot(2L, 69, List.of(4)); + this.precompute(this.first); + } + } + + private static TableRenderSnapshot snapshot(long version) { + return snapshot(version, 70, List.of(4)); + } + + private static TableRenderSnapshot snapshot(long version, int remainingWallCount, List selectedIndices) { + List hand = List.of( + MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, MahjongTile.M4, MahjongTile.M5_RED, + MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, MahjongTile.S6, MahjongTile.S7, + MahjongTile.S8, MahjongTile.RED_DRAGON, MahjongTile.RED_DRAGON + ); + List discards = List.of( + MahjongTile.EAST, MahjongTile.SOUTH, MahjongTile.WEST, MahjongTile.NORTH, + MahjongTile.WHITE_DRAGON, MahjongTile.GREEN_DRAGON, MahjongTile.RED_DRAGON, + MahjongTile.M1, MahjongTile.M9, MahjongTile.P1, MahjongTile.P9, + MahjongTile.S1, MahjongTile.S9, MahjongTile.M5_RED, MahjongTile.P5_RED, + MahjongTile.S5_RED, MahjongTile.M4, MahjongTile.P6 + ); + MeldView meld = new MeldView( + List.of(MahjongTile.P2, MahjongTile.P2, MahjongTile.P2), + List.of(false, false, false), + 1, + 90, + MahjongTile.P2 + ); + EnumMap seats = new EnumMap<>(SeatWind.class); + for (SeatWind wind : SeatWind.values()) { + seats.put(wind, new TableSeatRenderSnapshot( + wind, + new UUID(0L, wind.index() + 1L), + wind.name(), + wind.name(), + 25_000, + wind == SeatWind.EAST, + true, + false, + true, + "viewer-" + wind.name(), + selectedIndices.get(0), + selectedIndices, + wind == SeatWind.EAST ? 5 : -1, + wind.index() + 1, + List.of(), + hand, + discards, + List.of(meld), + List.of(ScoringStick.P1000), + List.of(ScoringStick.P100) + )); + } + return new TableRenderSnapshot( + version, + 0L, + "benchmark_world", + 12.75D, + 64.0D, + -7.25D, + true, + false, + false, + remainingWallCount, + 1, + 7, + 5, + 1, + 0, + SeatWind.EAST, + SeatWind.SOUTH, + SeatWind.WEST, + "waiting", + "rules", + "center-" + version, + null, + null, + List.of(MahjongTile.M1), + MahjongVariant.RIICHI, + seats + ); + } +} diff --git a/src/perfTest/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluatorBenchmark.java b/src/perfTest/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluatorBenchmark.java new file mode 100644 index 0000000..29448b1 --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/table/core/round/SichuanHuEvaluatorBenchmark.java @@ -0,0 +1,56 @@ +package top.ellan.mahjong.table.core.round; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import top.ellan.mahjong.model.MahjongTile; + +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class SichuanHuEvaluatorBenchmark { + private List waitingHand; + private List fixedMeldHand; + + @Setup(Level.Trial) + public void setUp() { + this.waitingHand = List.of( + MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, + MahjongTile.M4, MahjongTile.M5_RED, MahjongTile.M6, + MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, + MahjongTile.S7, MahjongTile.S8, MahjongTile.S9, MahjongTile.P5 + ); + this.fixedMeldHand = List.of( + MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, + MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, + MahjongTile.S9, MahjongTile.S9, MahjongTile.S5_RED, MahjongTile.S5 + ); + if (!SichuanHuEvaluator.waitingTiles(this.waitingHand, 0) + .equals(List.of(MahjongTile.P2, MahjongTile.P5)) + || !SichuanHuEvaluator.canWin(this.fixedMeldHand, MahjongTile.S5, 1)) { + throw new IllegalStateException("Sichuan benchmark fixture changed"); + } + } + + @Benchmark + public List waitingTilesStandardHand() { + return SichuanHuEvaluator.waitingTiles(this.waitingHand, 0); + } + + @Benchmark + public SichuanHuEvaluator.Result evaluateStandardWin() { + return SichuanHuEvaluator.evaluate(this.waitingHand, MahjongTile.P5_RED, 0); + } + + @Benchmark + public boolean canWinWithFixedMeld() { + return SichuanHuEvaluator.canWin(this.fixedMeldHand, MahjongTile.S5, 1); + } +} diff --git a/src/perfTest/java/top/ellan/mahjong/table/render/RayProxyLifecycleBenchmark.java b/src/perfTest/java/top/ellan/mahjong/table/render/RayProxyLifecycleBenchmark.java new file mode 100644 index 0000000..88a9ad2 --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/table/render/RayProxyLifecycleBenchmark.java @@ -0,0 +1,210 @@ +package top.ellan.mahjong.table.render; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.bukkit.entity.Player; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import top.ellan.mahjong.render.display.DisplayClickAction; +import top.ellan.mahjong.render.display.DisplayInteractionRayRegistry; +import top.ellan.mahjong.table.core.TableSessionContext; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class RayProxyLifecycleBenchmark { + @Benchmark + public int initialCreate32Viewers(InitialCreateState state) { + state.coordinator.replace("viewer-actions", state.interactions); + return state.coordinator.entityCount(); + } + + @Benchmark + public int unchanged32Viewers(LifecycleState state) { + state.coordinator.replace("viewer-actions", state.geometryA); + return state.coordinator.entityCount(); + } + + @Benchmark + public int changedGeometry32Viewers(LifecycleState state) { + state.geometryBActive = !state.geometryBActive; + state.coordinator.replace("viewer-actions", state.geometryBActive ? state.geometryB : state.geometryA); + return state.coordinator.entityCount(); + } + + @Benchmark + public int oneViewerChurn(LifecycleState state) { + state.lastViewerActive = !state.lastViewerActive; + state.coordinator.replace("viewer-actions", state.lastViewerActive ? state.geometryA : state.geometryA31); + return state.coordinator.entityCount(); + } + + @State(Scope.Thread) + public static class InitialCreateState { + private SparrowRayInteractionProxyCoordinator coordinator; + private Map> interactions; + + @Setup(Level.Invocation) + public void setUp() { + Fixture fixture = fixture(); + this.coordinator = fixture.coordinator; + this.interactions = fixture.geometryA; + } + + @TearDown(Level.Invocation) + public void tearDown() { + this.coordinator.shutdown(); + } + } + + @State(Scope.Thread) + public static class LifecycleState { + private SparrowRayInteractionProxyCoordinator coordinator; + private Map> geometryA; + private Map> geometryB; + private Map> geometryA31; + private boolean geometryBActive; + private boolean lastViewerActive = true; + + @Setup(Level.Trial) + public void setUp() { + Fixture fixture = fixture(); + this.coordinator = fixture.coordinator; + this.geometryA = fixture.geometryA; + this.geometryB = fixture.geometryB; + this.geometryA31 = withoutLast(this.geometryA); + this.coordinator.replace("viewer-actions", this.geometryA); + if (this.coordinator.entityCount() != 32) { + throw new IllegalStateException("Ray lifecycle fixture changed"); + } + } + + @TearDown(Level.Trial) + public void tearDown() { + this.coordinator.shutdown(); + } + } + + private static Fixture fixture() { + Map players = players(); + TableSessionContext session = session(players); + return new Fixture( + new SparrowRayInteractionProxyCoordinator(session, new CountingBackend()), + interactions(players.keySet(), 0.0D), + interactions(players.keySet(), 0.5D) + ); + } + + private static Map> interactions( + Iterable viewers, + double centerX + ) { + Map> result = new LinkedHashMap<>(); + UUID worldId = new UUID(0x52415950524F5859L, 1L); + for (UUID viewerId : viewers) { + result.put(viewerId, List.of(new DisplayInteractionRayRegistry.RayInteraction( + worldId, centerX, 65.0D, 0.0D, 1.0D, 0.0D, + 0.24F, 0.90F, 0.075F, + DisplayClickAction.playerCommand("perf-ray", viewerId, "noop") + ))); + } + return Map.copyOf(result); + } + + private static Map> withoutLast( + Map> source + ) { + Map> result = new LinkedHashMap<>(source); + UUID last = null; + for (UUID viewerId : result.keySet()) last = viewerId; + result.remove(last); + return Map.copyOf(result); + } + + private static Map players() { + Map players = new LinkedHashMap<>(); + for (int index = 0; index < 32; index++) { + UUID id = new UUID(0x5241595649455700L, index + 1L); + InvocationHandler handler = (proxy, method, args) -> switch (method.getName()) { + case "getUniqueId" -> id; + case "isOnline" -> true; + case "hashCode" -> id.hashCode(); + case "equals" -> proxy == args[0]; + case "toString" -> "RayViewer[" + id + "]"; + default -> defaultValue(method); + }; + players.put(id, (Player) Proxy.newProxyInstance(Player.class.getClassLoader(), new Class[] {Player.class}, handler)); + } + return players; + } + + private static TableSessionContext session(Map players) { + InvocationHandler handler = (proxy, method, args) -> switch (method.getName()) { + case "id" -> "perf-ray-lifecycle"; + case "onlinePlayer" -> players.get(args[0]); + case "runForViewer" -> { ((Runnable) args[1]).run(); yield null; } + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + case "toString" -> "RayLifecycleSession"; + default -> defaultValue(method); + }; + return (TableSessionContext) Proxy.newProxyInstance( + TableSessionContext.class.getClassLoader(), new Class[] {TableSessionContext.class}, handler + ); + } + + private static Object defaultValue(Method method) { + Class type = method.getReturnType(); + if (!type.isPrimitive()) return null; + if (type == boolean.class) return false; + if (type == char.class) return '\0'; + if (type == byte.class) return (byte) 0; + if (type == short.class) return (short) 0; + if (type == int.class) return 0; + if (type == long.class) return 0L; + if (type == float.class) return 0.0F; + if (type == double.class) return 0.0D; + return null; + } + + private record Fixture( + SparrowRayInteractionProxyCoordinator coordinator, + Map> geometryA, + Map> geometryB + ) {} + + private static final class CountingBackend implements SparrowRayInteractionProxyCoordinator.Backend { + private int nextEntityId = 1_000_000; + + @Override public boolean available() { return true; } + + @Override + public List create( + Player viewer, + List interactions + ) { + List proxies = new ArrayList<>(interactions.size()); + for (int index = 0; index < interactions.size(); index++) { + int entityId = ++this.nextEntityId; + proxies.add(() -> entityId); + } + return List.copyOf(proxies); + } + + @Override public void spawn(Player viewer, List proxies) {} + @Override public void destroy(Player viewer, List proxies) {} + } +} diff --git a/src/perfTest/java/top/ellan/mahjong/table/render/RegionRenderApplyBenchmark.java b/src/perfTest/java/top/ellan/mahjong/table/render/RegionRenderApplyBenchmark.java new file mode 100644 index 0000000..1467324 --- /dev/null +++ b/src/perfTest/java/top/ellan/mahjong/table/render/RegionRenderApplyBenchmark.java @@ -0,0 +1,292 @@ +package top.ellan.mahjong.table.render; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.bukkit.Location; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import top.ellan.mahjong.config.PluginSettings; +import top.ellan.mahjong.model.MahjongTile; +import top.ellan.mahjong.model.MahjongVariant; +import top.ellan.mahjong.model.SeatWind; +import top.ellan.mahjong.render.TableRenderSubject; +import top.ellan.mahjong.render.layout.TableRenderLayout; +import top.ellan.mahjong.render.scene.MeldView; +import top.ellan.mahjong.render.scene.TableRenderer; +import top.ellan.mahjong.render.snapshot.TableRenderPrecomputeResult; +import top.ellan.mahjong.render.snapshot.TableRenderSnapshot; +import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot; +import top.ellan.mahjong.riichi.model.ScoringStick; +import top.ellan.mahjong.table.core.TableSessionContext; + +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class RegionRenderApplyBenchmark { + private TableRegionDisplayCoordinator planningCoordinator; + private TableRegionDisplayCoordinator stableCoordinator; + private TableRenderPrecomputeResult planningResult; + private TableRenderPrecomputeResult stableResult; + private MethodHandle completeFingerprintPrecompute; + + @Setup(Level.Trial) + public void setUp() throws ReflectiveOperationException { + TableSessionContext session = session(); + TableRenderSnapshot representativeSnapshot = snapshot(true); + TableRenderLayout.LayoutPlan representativeLayout = TableRenderLayout.precompute(representativeSnapshot); + this.completeFingerprintPrecompute = completeFingerprintHandle( + session, representativeSnapshot, representativeLayout + ); + this.planningResult = new TableRenderPrecomputeResult( + representativeSnapshot, + fingerprints(session, representativeSnapshot, representativeLayout), + representativeLayout + ); + this.planningCoordinator = new TableRegionDisplayCoordinator(session, new TableRegionFingerprintService(), 0, 0); + + TableRenderSnapshot stableSnapshot = snapshot(false); + TableRenderLayout.LayoutPlan stableLayout = TableRenderLayout.precompute(stableSnapshot); + Map fingerprints = fingerprints(session, stableSnapshot, stableLayout); + this.stableResult = new TableRenderPrecomputeResult(stableSnapshot, fingerprints, stableLayout); + this.stableCoordinator = new TableRegionDisplayCoordinator(session, new TableRegionFingerprintService()); + seedStableState(this.stableCoordinator, fingerprints); + int planned = plannedRegionCount(representativeSnapshot, representativeLayout); + if (planned != 327 + || !this.planningCoordinator.applyRenderPrecompute(this.planningResult) + || this.stableCoordinator.applyRenderPrecompute(this.stableResult)) { + throw new IllegalStateException("Region benchmark contract changed: planned=" + planned); + } + } + + @Benchmark + public boolean planAndDeferRepresentativeTableRegions() { + return this.planningCoordinator.applyRenderPrecompute(this.planningResult); + } + + @Benchmark + public boolean applyStableStartedTable() { + return this.stableCoordinator.applyRenderPrecompute(this.stableResult); + } + + @Benchmark + @SuppressWarnings("unchecked") + public Map precomputeCompleteStartedTableRegions() throws Throwable { + return (Map) this.completeFingerprintPrecompute.invokeExact(); + } + + private static MethodHandle completeFingerprintHandle( + TableSessionContext session, + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan layout + ) throws ReflectiveOperationException { + TableRegionFingerprintService service = new TableRegionFingerprintService(); + try { + Method method = TableRegionFingerprintService.class.getDeclaredMethod( + "precomputeRegionFingerprints", + TableRenderSubject.class, + TableRenderSnapshot.class, + TableRenderLayout.LayoutPlan.class + ); + return MethodHandles.lookup() + .unreflect(method) + .bindTo(service) + .bindTo(session) + .bindTo(snapshot) + .bindTo(layout) + .asType(MethodType.methodType(Map.class)); + } catch (NoSuchMethodException ignored) { + try { + return MethodHandles.lookup() + .findStatic( + RegionRenderApplyBenchmark.class, + "fingerprints", + MethodType.methodType( + Map.class, + TableSessionContext.class, + TableRenderSnapshot.class, + TableRenderLayout.LayoutPlan.class + ) + ) + .bindTo(session) + .bindTo(snapshot) + .bindTo(layout); + } catch (NoSuchMethodException | IllegalAccessException exception) { + throw new ReflectiveOperationException(exception); + } + } catch (IllegalAccessException exception) { + throw new ReflectiveOperationException(exception); + } + } + + private static Map fingerprints( + TableSessionContext session, + TableRenderSnapshot snapshot, + TableRenderLayout.LayoutPlan layout + ) { + TableRegionFingerprintService service = new TableRegionFingerprintService(); + Map values = new HashMap<>(service.precomputeRegionFingerprints(session, snapshot)); + for (SeatWind wind : SeatWind.values()) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + TableRenderLayout.SeatLayoutPlan seatPlan = layout.seat(wind); + for (int index = 0; index < seat.hand().size(); index++) { + values.put( + handPublicRegionKey(wind, index), + service.handPublicTileFingerprint(snapshot, seat, seatPlan, index) + ); + values.put( + handPrivateRegionKey(wind, index), + service.handPrivateTileFingerprint(seat, seatPlan, index) + ); + } + for (int index = 0; index < seatPlan.discardPlacements().size(); index++) { + values.put(discardRegionKey(wind, index), service.discardTileFingerprint(seat, seatPlan, index)); + } + for (int index = 0; index < seatPlan.meldPlacements().size(); index++) { + values.put(meldRegionKey(wind, index), service.meldTileFingerprint(seat, seatPlan, index)); + } + } + for (int index = 0; index < layout.wallTiles().size(); index++) { + values.put(wallRegionKey(index), service.wallTileFingerprint(layout, index)); + } + return Map.copyOf(values); + } + + private static int plannedRegionCount(TableRenderSnapshot snapshot, TableRenderLayout.LayoutPlan layout) { + int count = 3 + layout.wallTiles().size(); + for (SeatWind wind : SeatWind.values()) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + TableRenderLayout.SeatLayoutPlan seatPlan = layout.seat(wind); + if (seat.playerId() == null) { + count += 3; + continue; + } + count += 3 + seat.hand().size() * 2 + seatPlan.discardPlacements().size() + seatPlan.meldPlacements().size(); + } + return count; + } + + @SuppressWarnings("unchecked") + private static void seedStableState(TableRegionDisplayCoordinator coordinator, Map fingerprints) + throws ReflectiveOperationException { + Field fingerprintField = TableRegionDisplayCoordinator.class.getDeclaredField("regionFingerprints"); + fingerprintField.setAccessible(true); + ((Map) fingerprintField.get(coordinator)).putAll(fingerprints); + Field displayField = TableRegionDisplayCoordinator.class.getDeclaredField("regionDisplays"); + displayField.setAccessible(true); + Map> displays = + (Map>) displayField.get(coordinator); + displays.put("table", List.of()); + for (SeatWind wind : SeatWind.values()) { + displays.put(seatRegionKey("visual", wind), List.of()); + } + } + + private static String seatRegionKey(String region, SeatWind wind) { + return region + ":" + wind.name(); + } + + private static String discardRegionKey(SeatWind wind, int index) { + return seatRegionKey("discards-" + index, wind); + } + + private static String handPublicRegionKey(SeatWind wind, int index) { + return seatRegionKey("hand-public-" + index, wind); + } + + private static String handPrivateRegionKey(SeatWind wind, int index) { + return seatRegionKey("hand-private-" + index, wind); + } + + private static String meldRegionKey(SeatWind wind, int index) { + return seatRegionKey("melds-" + index, wind); + } + + private static String wallRegionKey(int index) { + return "wall-" + index; + } + + private static TableSessionContext session() { + TableRenderer renderer = new TableRenderer(); + InvocationHandler handler = (proxy, method, arguments) -> switch (method.getName()) { + case "id" -> "perf-region-apply"; + case "plugin" -> null; + case "renderer" -> renderer; + case "center" -> new Location(null, 12.75D, 64.0D, -7.25D); + case "currentVariant" -> MahjongVariant.RIICHI; + case "settings" -> PluginSettings.defaults(); + case "toString" -> "RegionApplySession"; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + default -> defaultValue(method); + }; + return (TableSessionContext) Proxy.newProxyInstance( + TableSessionContext.class.getClassLoader(), new Class[] {TableSessionContext.class}, handler + ); + } + + private static TableRenderSnapshot snapshot(boolean withHands) { + List hand = withHands + ? List.of( + MahjongTile.M1, MahjongTile.M2, MahjongTile.M3, MahjongTile.M4, MahjongTile.M5_RED, + MahjongTile.P2, MahjongTile.P3, MahjongTile.P4, MahjongTile.S6, MahjongTile.S7, MahjongTile.RED_DRAGON + ) + : List.of(); + List discards = List.of( + MahjongTile.EAST, MahjongTile.SOUTH, MahjongTile.WEST, MahjongTile.NORTH, + MahjongTile.WHITE_DRAGON, MahjongTile.GREEN_DRAGON, MahjongTile.RED_DRAGON, + MahjongTile.M1, MahjongTile.M9, MahjongTile.P1, MahjongTile.P9, + MahjongTile.S1, MahjongTile.S9, MahjongTile.M5_RED, MahjongTile.P5_RED, + MahjongTile.S5_RED, MahjongTile.M4, MahjongTile.P6 + ); + MeldView meld = new MeldView( + List.of(MahjongTile.P2, MahjongTile.P2, MahjongTile.P2), + List.of(false, false, false), 1, 90, MahjongTile.P2 + ); + EnumMap seats = new EnumMap<>(SeatWind.class); + for (SeatWind wind : SeatWind.values()) { + seats.put(wind, new TableSeatRenderSnapshot( + wind, new UUID(0L, wind.index() + 1L), wind.name(), wind.name(), 25_000, + false, true, false, true, "viewer-" + wind.name(), -1, List.of(), -1, 0, + List.of(), hand, discards, List.of(meld), List.of(), List.of(ScoringStick.P100) + )); + } + return new TableRenderSnapshot( + 1L, 0L, "benchmark_world", 12.75D, 64.0D, -7.25D, + true, false, false, 70, 1, 7, 5, 1, 0, + SeatWind.EAST, SeatWind.SOUTH, SeatWind.WEST, + "waiting", "rules", "center", null, MahjongTile.RED_DRAGON, + List.of(MahjongTile.M1), MahjongVariant.RIICHI, seats + ); + } + + private static Object defaultValue(Method method) { + Class type = method.getReturnType(); + if (!type.isPrimitive()) return null; + if (type == boolean.class) return false; + if (type == char.class) return '\0'; + if (type == byte.class) return (byte) 0; + if (type == short.class) return (short) 0; + if (type == int.class) return 0; + if (type == long.class) return 0L; + if (type == float.class) return 0.0F; + if (type == double.class) return 0.0D; + return null; + } +} From c9a82f69ed02cbc4f31dec1835a18bc84a673c5b Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 17:42:35 +0800 Subject: [PATCH 06/12] perf(render): reuse stable session layouts --- .../render/layout/TableRenderLayout.java | 117 ++++++++++ .../table/core/MahjongTableSession.java | 4 +- .../table/core/SessionRenderLayoutCache.java | 109 ++++++++++ .../core/SessionRenderLayoutCacheTest.kt | 200 ++++++++++++++++++ 4 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 src/main/java/top/ellan/mahjong/table/core/SessionRenderLayoutCache.java create mode 100644 src/test/kotlin/top/ellan/mahjong/table/core/SessionRenderLayoutCacheTest.kt diff --git a/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java b/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java index dba6e1e..5ebc8df 100644 --- a/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java +++ b/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java @@ -12,6 +12,7 @@ import java.util.Collections; import java.util.EnumMap; import java.util.List; +import java.util.Objects; public final class TableRenderLayout { private static final double ONE_SIXTEENTH = 1.0D / 16.0D; @@ -76,6 +77,122 @@ public static LayoutPlan precompute(TableRenderSnapshot snapshot) { ); } + /** Reuses immutable center, seat and wall geometry from the immediately preceding layout. */ + public static LayoutPlan precompute( + TableRenderSnapshot snapshot, + TableRenderSnapshot previousSnapshot, + LayoutPlan previousLayout + ) { + if (previousSnapshot == null || previousLayout == null) { + return precompute(snapshot); + } + boolean reusableCenter = sameCenter(snapshot, previousSnapshot); + Point displayCenter; + Point tableCenter; + Point tableVisualAnchor; + double borderSpanX; + double borderSpanZ; + if (reusableCenter) { + displayCenter = previousLayout.displayCenter(); + tableCenter = previousLayout.tableCenter(); + tableVisualAnchor = previousLayout.tableVisualAnchor(); + borderSpanX = previousLayout.borderSpanX(); + borderSpanZ = previousLayout.borderSpanZ(); + } else { + displayCenter = new Point(snapshot.centerX(), snapshot.centerY() + DISPLAY_CENTER_Y_OFFSET, snapshot.centerZ()); + TableBounds bounds = tableBoundsFromTiles(displayCenter); + tableCenter = new Point(bounds.centerX(), displayCenter.y(), bounds.centerZ()); + tableVisualAnchor = new Point(tableCenter.x(), tableCenter.y() + TABLE_VISUAL_Y_OFFSET, tableCenter.z()); + borderSpanX = bounds.width() + TABLE_TOP_SIZE_EXPANSION + TABLE_BORDER_THICKNESS; + borderSpanZ = bounds.depth() + TABLE_TOP_SIZE_EXPANSION + TABLE_BORDER_THICKNESS; + } + + EnumMap seats = new EnumMap<>(SeatWind.class); + for (SeatWind wind : SEAT_WINDS) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + if (reusableCenter && sameSeatLayoutInputs(snapshot, previousSnapshot, wind)) { + seats.put(wind, previousLayout.seat(wind)); + } else { + seats.put(wind, precomputeSeat(displayCenter, snapshot, seat)); + } + } + + List wallTiles; + List doraTiles; + if (reusableCenter && sameWallLayoutInputs(snapshot, previousSnapshot)) { + wallTiles = previousLayout.wallTiles(); + doraTiles = previousLayout.doraTiles(); + } else { + List deadWallPlacements = snapshot.started() && snapshot.usesDeadWall() + ? deadWallPlacements(displayCenter, snapshot) + : List.of(); + wallTiles = precomputeWall(displayCenter, snapshot, deadWallPlacements); + doraTiles = precomputeDora(snapshot, deadWallPlacements); + } + + return new LayoutPlan( + displayCenter, + tableCenter, + tableVisualAnchor, + borderSpanX, + borderSpanZ, + seats, + wallTiles, + doraTiles + ); + } + + private static boolean sameCenter(TableRenderSnapshot left, TableRenderSnapshot right) { + return Double.doubleToLongBits(left.centerX()) == Double.doubleToLongBits(right.centerX()) + && Double.doubleToLongBits(left.centerY()) == Double.doubleToLongBits(right.centerY()) + && Double.doubleToLongBits(left.centerZ()) == Double.doubleToLongBits(right.centerZ()); + } + + private static boolean sameSeatLayoutInputs( + TableRenderSnapshot leftSnapshot, + TableRenderSnapshot rightSnapshot, + SeatWind wind + ) { + TableSeatRenderSnapshot left = leftSnapshot.seat(wind); + TableSeatRenderSnapshot right = rightSnapshot.seat(wind); + boolean leftOccupied = left.playerId() != null; + boolean rightOccupied = right.playerId() != null; + if (leftOccupied != rightOccupied) { + return false; + } + if (!leftOccupied) { + return true; + } + return left.hand().size() == right.hand().size() + && left.selectedHandTileIndices().equals(right.selectedHandTileIndices()) + && left.riichiDiscardIndex() == right.riichiDiscardIndex() + && left.stickLayoutCount() == right.stickLayoutCount() + && left.discards().equals(right.discards()) + && left.melds().equals(right.melds()) + && left.cornerSticks().equals(right.cornerSticks()) + && left.riichi() == right.riichi() + && leftSnapshot.openDoorSeat() == rightSnapshot.openDoorSeat(); + } + + private static boolean sameWallLayoutInputs(TableRenderSnapshot left, TableRenderSnapshot right) { + if (left.started() != right.started() + || left.remainingWallCount() != right.remainingWallCount() + || left.kanCount() != right.kanCount() + || left.dicePoints() != right.dicePoints() + || left.breakDicePoints() != right.breakDicePoints() + || left.dealerSeat() != right.dealerSeat() + || left.variant() != right.variant() + || !left.doraIndicators().equals(right.doraIndicators())) { + return false; + } + for (SeatWind wind : SEAT_WINDS) { + if (!Objects.equals(left.seat(wind).melds(), right.seat(wind).melds())) { + return false; + } + } + return true; + } + public static SeatLayoutPlan precomputeSeatOnly(TableRenderSnapshot snapshot, SeatWind wind) { if (snapshot == null || wind == null) { throw new IllegalArgumentException("snapshot and wind are required"); diff --git a/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java b/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java index 210bd7d..9a48202 100644 --- a/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java +++ b/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java @@ -79,6 +79,7 @@ public final class MahjongTableSession implements TableSessionMutator, TableMemb private final TableRenderer renderer = new TableRenderer(); private final TableRenderSnapshotFactory renderSnapshotFactory = new TableRenderSnapshotFactory(); private final TableRegionFingerprintService regionFingerprintService = new TableRegionFingerprintService(); + private final SessionRenderLayoutCache renderLayoutCache = new SessionRenderLayoutCache(); private MahjongRule configuredRule; // These three fields are written on the table's region thread (during // startRound / completeRoundStartInternal / setRoundControllerInternal) @@ -1635,10 +1636,11 @@ public TableRenderSnapshot captureRenderSnapshot(long version, long cancellation } public TableRenderPrecomputeResult precomputeRender(TableRenderSnapshot snapshot) { + TableRenderLayout.LayoutPlan layout = this.renderLayoutCache.precompute(snapshot); return new TableRenderPrecomputeResult( snapshot, this.regionFingerprintService.precomputeRegionFingerprints(this, snapshot), - TableRenderLayout.precompute(snapshot) + layout ); } diff --git a/src/main/java/top/ellan/mahjong/table/core/SessionRenderLayoutCache.java b/src/main/java/top/ellan/mahjong/table/core/SessionRenderLayoutCache.java new file mode 100644 index 0000000..881156c --- /dev/null +++ b/src/main/java/top/ellan/mahjong/table/core/SessionRenderLayoutCache.java @@ -0,0 +1,109 @@ +package top.ellan.mahjong.table.core; + +import top.ellan.mahjong.model.MahjongTile; +import top.ellan.mahjong.model.MahjongVariant; +import top.ellan.mahjong.model.SeatWind; +import top.ellan.mahjong.render.layout.TableRenderLayout; +import top.ellan.mahjong.render.scene.MeldView; +import top.ellan.mahjong.render.snapshot.TableRenderSnapshot; +import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot; +import top.ellan.mahjong.riichi.model.ScoringStick; +import java.util.List; + +/** Single-entry, immutable layout cache owned by one table session. */ +final class SessionRenderLayoutCache { + private volatile Entry cached; + + TableRenderLayout.LayoutPlan precompute(TableRenderSnapshot snapshot) { + LayoutKey key = LayoutKey.from(snapshot); + Entry current = this.cached; + if (current != null && current.key().equals(key)) { + return current.layout(); + } + TableRenderLayout.LayoutPlan layout = current == null + ? TableRenderLayout.precompute(snapshot) + : TableRenderLayout.precompute(snapshot, current.snapshot(), current.layout()); + this.cached = new Entry(key, snapshot, layout); + return layout; + } + + void clear() { + this.cached = null; + } + + private record Entry(LayoutKey key, TableRenderSnapshot snapshot, TableRenderLayout.LayoutPlan layout) { + } + + private record LayoutKey( + double centerX, + double centerY, + double centerZ, + boolean started, + int remainingWallCount, + int kanCount, + int dicePoints, + int breakDicePoints, + SeatWind dealerSeat, + SeatWind openDoorSeat, + List doraIndicators, + MahjongVariant variant, + SeatLayoutKey east, + SeatLayoutKey south, + SeatLayoutKey west, + SeatLayoutKey north + ) { + private static LayoutKey from(TableRenderSnapshot snapshot) { + return new LayoutKey( + snapshot.centerX(), + snapshot.centerY(), + snapshot.centerZ(), + snapshot.started(), + snapshot.remainingWallCount(), + snapshot.kanCount(), + snapshot.dicePoints(), + snapshot.breakDicePoints(), + snapshot.dealerSeat(), + snapshot.openDoorSeat(), + snapshot.doraIndicators(), + snapshot.variant(), + SeatLayoutKey.from(snapshot.seat(SeatWind.EAST)), + SeatLayoutKey.from(snapshot.seat(SeatWind.SOUTH)), + SeatLayoutKey.from(snapshot.seat(SeatWind.WEST)), + SeatLayoutKey.from(snapshot.seat(SeatWind.NORTH)) + ); + } + } + + private record SeatLayoutKey( + SeatWind wind, + boolean occupied, + int handSize, + List selectedHandTileIndices, + int riichiDiscardIndex, + int stickLayoutCount, + List discards, + List melds, + List cornerSticks, + boolean riichi + ) { + private static SeatLayoutKey from(TableSeatRenderSnapshot seat) { + if (seat.playerId() == null) { + return new SeatLayoutKey( + seat.wind(), false, 0, List.of(), -1, 0, List.of(), List.of(), List.of(), false + ); + } + return new SeatLayoutKey( + seat.wind(), + true, + seat.hand().size(), + seat.selectedHandTileIndices(), + seat.riichiDiscardIndex(), + seat.stickLayoutCount(), + seat.discards(), + seat.melds(), + seat.cornerSticks(), + seat.riichi() + ); + } + } +} diff --git a/src/test/kotlin/top/ellan/mahjong/table/core/SessionRenderLayoutCacheTest.kt b/src/test/kotlin/top/ellan/mahjong/table/core/SessionRenderLayoutCacheTest.kt new file mode 100644 index 0000000..da90092 --- /dev/null +++ b/src/test/kotlin/top/ellan/mahjong/table/core/SessionRenderLayoutCacheTest.kt @@ -0,0 +1,200 @@ +package top.ellan.mahjong.table.core + +import top.ellan.mahjong.model.MahjongTile +import top.ellan.mahjong.model.MahjongVariant +import top.ellan.mahjong.model.SeatWind +import top.ellan.mahjong.render.scene.MeldView +import top.ellan.mahjong.render.snapshot.TableRenderSnapshot +import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot +import top.ellan.mahjong.riichi.model.ScoringStick +import java.util.EnumMap +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +class SessionRenderLayoutCacheTest { + @Test + fun `equivalent layout inputs reuse the immutable plan`() { + val cache = SessionRenderLayoutCache() + assertSame(cache.precompute(snapshot(version = 1)), cache.precompute(snapshot(version = 2))) + } + + @Test + fun `every geometry dependency invalidates the cached plan`() { + val baseline = snapshot() + val changed = + listOf( + snapshot(centerX = 4.0), + snapshot(centerY = 65.0), + snapshot(centerZ = -3.0), + snapshot(started = false), + snapshot(remainingWallCount = 69), + snapshot(kanCount = 2), + snapshot(dicePoints = 8), + snapshot(breakDicePoints = 6), + snapshot(dealerSeat = SeatWind.SOUTH), + snapshot(openDoorSeat = SeatWind.NORTH), + snapshot(doraIndicators = listOf(MahjongTile.P1)), + snapshot(variant = MahjongVariant.GB), + snapshot(hand = defaultHand() + MahjongTile.NORTH), + snapshot(selectedIndices = listOf(3)), + snapshot(riichiDiscardIndex = 2), + snapshot(stickLayoutCount = 4), + snapshot(discards = listOf(MahjongTile.EAST, MahjongTile.WEST)), + snapshot(melds = listOf(defaultMeld(), defaultMeld())), + snapshot(cornerSticks = listOf(ScoringStick.P1000)), + snapshot(riichi = false), + snapshot(occupied = false), + ) + changed.forEachIndexed { index, candidate -> + val cache = SessionRenderLayoutCache() + val first = cache.precompute(baseline) + val incremental = cache.precompute(candidate) + assertNotSame(first, incremental, "dependency index=$index") + assertEquals( + top.ellan.mahjong.render.layout.TableRenderLayout + .precompute(candidate), + incremental, + "incremental layout dependency index=$index", + ) + } + } + + @Test + fun `seat-only changes reuse immutable center and wall geometry`() { + val cache = SessionRenderLayoutCache() + val first = cache.precompute(snapshot(selectedIndices = listOf(4))) + val second = cache.precompute(snapshot(version = 2, selectedIndices = listOf(3))) + + assertNotSame(first, second) + assertSame(first.displayCenter(), second.displayCenter()) + assertSame(first.tableCenter(), second.tableCenter()) + assertSame(first.wallTiles(), second.wallTiles()) + SeatWind.values().forEach { wind -> assertNotSame(first.seat(wind), second.seat(wind)) } + } + + @Test + fun `wall-only changes reuse every immutable seat plan`() { + val cache = SessionRenderLayoutCache() + val first = cache.precompute(snapshot(remainingWallCount = 70)) + val second = cache.precompute(snapshot(version = 2, remainingWallCount = 69)) + + assertNotSame(first.wallTiles(), second.wallTiles()) + SeatWind.values().forEach { wind -> assertSame(first.seat(wind), second.seat(wind)) } + } + + @Test + fun `clear forces recomputation`() { + val cache = SessionRenderLayoutCache() + val input = snapshot() + val first = cache.precompute(input) + cache.clear() + assertNotSame(first, cache.precompute(input)) + } +} + +private fun snapshot( + version: Long = 1, + centerX: Double = 3.0, + centerY: Double = 64.0, + centerZ: Double = -2.0, + started: Boolean = true, + remainingWallCount: Int = 70, + kanCount: Int = 1, + dicePoints: Int = 7, + breakDicePoints: Int = 5, + dealerSeat: SeatWind = SeatWind.EAST, + openDoorSeat: SeatWind = SeatWind.WEST, + doraIndicators: List = listOf(MahjongTile.M1), + variant: MahjongVariant = MahjongVariant.RIICHI, + hand: List = defaultHand(), + selectedIndices: List = listOf(4), + riichiDiscardIndex: Int = 1, + stickLayoutCount: Int = 3, + discards: List = listOf(MahjongTile.EAST, MahjongTile.SOUTH), + melds: List = listOf(defaultMeld()), + cornerSticks: List = listOf(ScoringStick.P100), + riichi: Boolean = true, + occupied: Boolean = true, +): TableRenderSnapshot { + val seats = EnumMap(SeatWind::class.java) + SeatWind.values().forEach { wind -> + seats[wind] = + TableSeatRenderSnapshot( + wind, + if (occupied) UUID(0, wind.index().toLong() + 1) else null, + wind.name, + wind.name, + 25_000, + riichi, + true, + false, + true, + "viewer-${wind.name}", + selectedIndices.lastOrNull() ?: -1, + selectedIndices, + riichiDiscardIndex, + stickLayoutCount, + emptyList(), + hand, + discards, + melds, + emptyList(), + cornerSticks, + ) + } + return TableRenderSnapshot( + version, + 0, + "world", + centerX, + centerY, + centerZ, + started, + false, + false, + remainingWallCount, + kanCount, + dicePoints, + breakDicePoints, + 1, + 0, + dealerSeat, + SeatWind.SOUTH, + openDoorSeat, + "waiting", + "rules", + "center-$version", + null, + null, + doraIndicators, + variant, + seats, + ) +} + +private fun defaultHand(): List = + listOf( + MahjongTile.M1, + MahjongTile.M2, + MahjongTile.M3, + MahjongTile.P1, + MahjongTile.P2, + MahjongTile.P3, + MahjongTile.S1, + MahjongTile.S2, + MahjongTile.S3, + MahjongTile.EAST, + MahjongTile.EAST, + ) + +private fun defaultMeld(): MeldView = + MeldView( + listOf(MahjongTile.P7, MahjongTile.P7, MahjongTile.P7), + listOf(false, false, false), + 1, + 90, + null, + ) From 520f80c01151e1a057b267f09d236bf6d87c14ad Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sun, 19 Jul 2026 20:03:05 +0800 Subject: [PATCH 07/12] perf(scheduler): publish capability caches atomically Reuse scheduler capability lookups without allowing concurrent readers to pair one target with another target's scheduler. --- .../mahjong/runtime/ServerScheduler.java | 39 +++- .../ServerSchedulerReflectionCacheTest.java | 218 +++++++++++++++++- 2 files changed, 245 insertions(+), 12 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/runtime/ServerScheduler.java b/src/main/java/top/ellan/mahjong/runtime/ServerScheduler.java index 7a984b0..f73747b 100644 --- a/src/main/java/top/ellan/mahjong/runtime/ServerScheduler.java +++ b/src/main/java/top/ellan/mahjong/runtime/ServerScheduler.java @@ -91,6 +91,16 @@ public boolean isCancelled() { private final Plugin plugin; + /** + * Each capability is published as one immutable entry so readers can never + * observe a target from one resolution paired with another target's scheduler. + * The null scheduler value is intentional: it negatively caches unavailable + * Folia capabilities on standard Paper runtimes. + */ + private volatile SchedulerCapability globalSchedulerCapability; + private volatile SchedulerCapability regionSchedulerCapability; + private volatile SchedulerCapability entitySchedulerCapability; + public ServerScheduler(Plugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); } @@ -313,15 +323,35 @@ public PluginTask removeEntity(Entity entity, long delayTicks) { } private Object globalRegionScheduler() { - return this.invokeNoArgs(this.plugin.getServer(), GET_GLOBAL_REGION_SCHEDULER); + Object server = this.plugin.getServer(); + SchedulerCapability capability = this.globalSchedulerCapability; + if (capability != null && capability.target() == server) { + return capability.scheduler(); + } + Object scheduler = this.invokeNoArgs(server, GET_GLOBAL_REGION_SCHEDULER); + this.globalSchedulerCapability = new SchedulerCapability(server, scheduler); + return scheduler; } private Object regionScheduler() { - return this.invokeNoArgs(this.plugin.getServer(), GET_REGION_SCHEDULER); + Object server = this.plugin.getServer(); + SchedulerCapability capability = this.regionSchedulerCapability; + if (capability != null && capability.target() == server) { + return capability.scheduler(); + } + Object scheduler = this.invokeNoArgs(server, GET_REGION_SCHEDULER); + this.regionSchedulerCapability = new SchedulerCapability(server, scheduler); + return scheduler; } private Object entityScheduler(Entity entity) { - return this.invokeNoArgs(entity, GET_ENTITY_SCHEDULER); + SchedulerCapability capability = this.entitySchedulerCapability; + if (capability != null && capability.target() == entity) { + return capability.scheduler(); + } + Object scheduler = this.invokeNoArgs(entity, GET_ENTITY_SCHEDULER); + this.entitySchedulerCapability = new SchedulerCapability(entity, scheduler); + return scheduler; } private boolean isPluginEnabled() { @@ -367,6 +397,9 @@ private static PluginTask wrap(Object task) { return task == null ? NO_OP_TASK : new ScheduledTaskHandle(task); } + private record SchedulerCapability(Object target, Object scheduler) { + } + private record BukkitTaskHandle(BukkitTask task) implements PluginTask { @Override public void cancel() { diff --git a/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerReflectionCacheTest.java b/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerReflectionCacheTest.java index 6464bcc..2776e4b 100644 --- a/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerReflectionCacheTest.java +++ b/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerReflectionCacheTest.java @@ -7,21 +7,36 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import io.papermc.paper.threadedregions.scheduler.EntityScheduler; import io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler; +import io.papermc.paper.threadedregions.scheduler.RegionScheduler; import io.papermc.paper.threadedregions.scheduler.ScheduledTask; import java.lang.reflect.Method; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -import org.junit.jupiter.api.Test; +import org.bukkit.Location; import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.entity.Entity; import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.Test; final class ServerSchedulerReflectionCacheTest { + private static final Runnable NO_OP = () -> { + }; + @Test void cachesAvailableAndUnavailableMethodResolutionsPerRuntimeClass() { assertEquals(0, ServerScheduler.cachedMethodResolutionCount(ReflectionProbe.class)); @@ -38,7 +53,7 @@ void cachesAvailableAndUnavailableMethodResolutionsPerRuntimeClass() { } @Test - void invokesPaperSchedulerAndWrapsItsTaskThroughCachedMethods() { + void invokesPaperSchedulerAndReusesItsCapabilityByServerIdentity() { Plugin plugin = mock(Plugin.class); Server server = mock(Server.class); GlobalRegionScheduler globalScheduler = mock(GlobalRegionScheduler.class); @@ -55,16 +70,179 @@ void invokesPaperSchedulerAndWrapsItsTaskThroughCachedMethods() { return scheduledTask; }); - PluginTask task = new ServerScheduler(plugin).runGlobal(executions::incrementAndGet); + ServerScheduler scheduler = new ServerScheduler(plugin); + PluginTask first = scheduler.runGlobal(executions::incrementAndGet); + PluginTask second = scheduler.runGlobal(executions::incrementAndGet); - assertEquals(1, executions.get()); - assertFalse(task.isCancelled()); + assertEquals(2, executions.get()); + verify(server, times(1)).getGlobalRegionScheduler(); + assertFalse(first.isCancelled()); + assertFalse(second.isCancelled()); when(scheduledTask.isCancelled()).thenReturn(true); - assertTrue(task.isCancelled()); - task.cancel(); + assertTrue(first.isCancelled()); + first.cancel(); verify(scheduledTask).cancel(); } + @Test + void negativelyCachesUnavailableCapabilitiesOnPaper() { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); + World world = mock(World.class); + Entity entity = mock(Entity.class); + BukkitScheduler bukkitScheduler = mock(BukkitScheduler.class); + BukkitTask bukkitTask = mock(BukkitTask.class); + Location location = new Location(world, 0.0, 64.0, 0.0); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(bukkitScheduler); + when(bukkitScheduler.runTask(eq(plugin), any(Runnable.class))).thenReturn(bukkitTask); + + ServerScheduler scheduler = new ServerScheduler(plugin); + scheduler.runGlobal(NO_OP); + scheduler.runGlobal(NO_OP); + scheduler.runRegion(location, NO_OP); + scheduler.runRegion(location, NO_OP); + scheduler.runEntity(entity, NO_OP); + scheduler.runEntity(entity, NO_OP); + + verify(server, times(1)).getGlobalRegionScheduler(); + verify(server, times(1)).getRegionScheduler(); + verify(entity, times(1)).getScheduler(); + verify(bukkitScheduler, times(6)).runTask(eq(plugin), any(Runnable.class)); + } + + @Test + void invalidatesCapabilitiesWhenTargetIdentityChanges() { + Plugin globalPlugin = mock(Plugin.class); + Server firstServer = mock(Server.class); + Server secondServer = mock(Server.class); + GlobalRegionScheduler firstGlobal = mock(GlobalRegionScheduler.class); + GlobalRegionScheduler secondGlobal = mock(GlobalRegionScheduler.class); + ScheduledTask task = mock(ScheduledTask.class); + + when(globalPlugin.isEnabled()).thenReturn(true); + when(globalPlugin.getServer()).thenReturn(firstServer, firstServer, secondServer, secondServer); + when(firstServer.getGlobalRegionScheduler()).thenReturn(firstGlobal); + when(secondServer.getGlobalRegionScheduler()).thenReturn(secondGlobal); + when(firstGlobal.run(any(Plugin.class), any())).thenReturn(task); + when(secondGlobal.run(any(Plugin.class), any())).thenReturn(task); + + ServerScheduler globalScheduler = new ServerScheduler(globalPlugin); + globalScheduler.runGlobal(NO_OP); + globalScheduler.runGlobal(NO_OP); + globalScheduler.runGlobal(NO_OP); + globalScheduler.runGlobal(NO_OP); + + verify(firstServer, times(1)).getGlobalRegionScheduler(); + verify(secondServer, times(1)).getGlobalRegionScheduler(); + + Plugin regionPlugin = mock(Plugin.class); + RegionScheduler firstRegion = mock(RegionScheduler.class); + RegionScheduler secondRegion = mock(RegionScheduler.class); + World world = mock(World.class); + Location location = new Location(world, 0.0, 64.0, 0.0); + + when(regionPlugin.isEnabled()).thenReturn(true); + when(regionPlugin.getServer()).thenReturn(firstServer, firstServer, secondServer, secondServer); + when(firstServer.getRegionScheduler()).thenReturn(firstRegion); + when(secondServer.getRegionScheduler()).thenReturn(secondRegion); + when(firstRegion.run(any(Plugin.class), any(Location.class), any())).thenReturn(task); + when(secondRegion.run(any(Plugin.class), any(Location.class), any())).thenReturn(task); + + ServerScheduler regionScheduler = new ServerScheduler(regionPlugin); + regionScheduler.runRegion(location, NO_OP); + regionScheduler.runRegion(location, NO_OP); + regionScheduler.runRegion(location, NO_OP); + regionScheduler.runRegion(location, NO_OP); + + verify(firstServer, times(1)).getRegionScheduler(); + verify(secondServer, times(1)).getRegionScheduler(); + + Plugin entityPlugin = mock(Plugin.class); + Entity firstEntity = mock(Entity.class); + Entity secondEntity = mock(Entity.class); + EntityScheduler firstEntityScheduler = mock(EntityScheduler.class); + EntityScheduler secondEntityScheduler = mock(EntityScheduler.class); + + when(entityPlugin.isEnabled()).thenReturn(true); + when(firstEntity.getScheduler()).thenReturn(firstEntityScheduler); + when(secondEntity.getScheduler()).thenReturn(secondEntityScheduler); + when(firstEntityScheduler.run(any(Plugin.class), any(), any(Runnable.class))).thenReturn(task); + when(secondEntityScheduler.run(any(Plugin.class), any(), any(Runnable.class))).thenReturn(task); + + ServerScheduler entityScheduler = new ServerScheduler(entityPlugin); + entityScheduler.runEntity(firstEntity, NO_OP); + entityScheduler.runEntity(firstEntity, NO_OP); + entityScheduler.runEntity(secondEntity, NO_OP); + entityScheduler.runEntity(secondEntity, NO_OP); + entityScheduler.runEntity(firstEntity, NO_OP); + + verify(firstEntity, times(2)).getScheduler(); + verify(secondEntity, times(1)).getScheduler(); + } + + @Test + void neverRoutesConcurrentEntityCallsThroughAnotherEntityScheduler() throws InterruptedException { + Plugin plugin = mock(Plugin.class); + Entity firstEntity = mock(Entity.class); + Entity secondEntity = mock(Entity.class); + EntityScheduler firstEntityScheduler = mock(EntityScheduler.class); + EntityScheduler secondEntityScheduler = mock(EntityScheduler.class); + ScheduledTask task = mock(ScheduledTask.class); + AtomicInteger wrongRoutes = new AtomicInteger(); + + when(plugin.isEnabled()).thenReturn(true); + when(firstEntity.getScheduler()).thenReturn(firstEntityScheduler); + when(secondEntity.getScheduler()).thenReturn(secondEntityScheduler); + when(firstEntityScheduler.run(any(Plugin.class), any(), any(Runnable.class))).thenAnswer(invocation -> { + if (!Thread.currentThread().getName().equals("scheduler-entity-first")) { + wrongRoutes.incrementAndGet(); + } + return task; + }); + when(secondEntityScheduler.run(any(Plugin.class), any(), any(Runnable.class))).thenAnswer(invocation -> { + if (!Thread.currentThread().getName().equals("scheduler-entity-second")) { + wrongRoutes.incrementAndGet(); + } + return task; + }); + + ServerScheduler scheduler = new ServerScheduler(plugin); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(2); + AtomicReference failure = new AtomicReference<>(); + Thread firstThread = new Thread( + () -> runEntityLoop(scheduler, firstEntity, ready, start, done, failure), + "scheduler-entity-first" + ); + Thread secondThread = new Thread( + () -> runEntityLoop(scheduler, secondEntity, ready, start, done, failure), + "scheduler-entity-second" + ); + + firstThread.start(); + secondThread.start(); + try { + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS)); + } finally { + start.countDown(); + firstThread.interrupt(); + secondThread.interrupt(); + firstThread.join(); + secondThread.join(); + } + + if (failure.get() != null) { + throw new AssertionError("Concurrent scheduler invocation failed", failure.get()); + } + assertEquals(0, wrongRoutes.get()); + } + @Test void doesNotFallbackAndScheduleTwiceWhenPaperSchedulerThrows() { Plugin plugin = mock(Plugin.class); @@ -80,13 +258,35 @@ void doesNotFallbackAndScheduleTwiceWhenPaperSchedulerThrows() { throw new IllegalStateException("scheduler failed after entry"); }); - assertThrows(IllegalStateException.class, () -> new ServerScheduler(plugin).runGlobal(() -> { - })); + assertThrows(IllegalStateException.class, () -> new ServerScheduler(plugin).runGlobal(NO_OP)); assertEquals(1, schedulerCalls.get()); verify(server, never()).getScheduler(); } + private static void runEntityLoop( + ServerScheduler scheduler, + Entity entity, + CountDownLatch ready, + CountDownLatch start, + CountDownLatch done, + AtomicReference failure + ) { + ready.countDown(); + try { + if (!start.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Concurrent scheduler start timed out"); + } + for (int iteration = 0; iteration < 20_000; iteration++) { + scheduler.runEntity(entity, NO_OP); + } + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + done.countDown(); + } + } + private static final class ReflectionProbe { public void accept(String value) { } From c437d6b34558cb5aa18b4ed50d74da703232846c Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sun, 19 Jul 2026 21:08:23 +0800 Subject: [PATCH 08/12] perf(scheduler): cache immutable dispatch plans Avoid repeated reflection lookups and flatten scheduler fallbacks while preserving identity-safe concurrent routing. --- .../mahjong/runtime/ServerScheduler.java | 580 ++++++++++++++---- .../ServerSchedulerDispatchPlanTest.java | 300 +++++++++ .../ServerSchedulerReflectionCacheTest.java | 16 + 3 files changed, 781 insertions(+), 115 deletions(-) create mode 100644 src/test/java/top/ellan/mahjong/runtime/ServerSchedulerDispatchPlanTest.java diff --git a/src/main/java/top/ellan/mahjong/runtime/ServerScheduler.java b/src/main/java/top/ellan/mahjong/runtime/ServerScheduler.java index f73747b..6046992 100644 --- a/src/main/java/top/ellan/mahjong/runtime/ServerScheduler.java +++ b/src/main/java/top/ellan/mahjong/runtime/ServerScheduler.java @@ -8,11 +8,18 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; -import net.momirealms.sparrow.reflection.method.SMethod; +import net.momirealms.sparrow.reflection.method.SMethod0; +import net.momirealms.sparrow.reflection.method.SMethod1; +import net.momirealms.sparrow.reflection.method.SMethod2; +import net.momirealms.sparrow.reflection.method.SMethod3; +import net.momirealms.sparrow.reflection.method.SMethod4; +import net.momirealms.sparrow.reflection.method.SMethod5; import net.momirealms.sparrow.reflection.method.SparrowMethod; import org.bukkit.Location; +import org.bukkit.Server; import org.bukkit.entity.Entity; import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; import org.bukkit.scheduler.BukkitTask; public final class ServerScheduler { @@ -77,6 +84,38 @@ protected Cache computeValue(Class type) { return Caffeine.newBuilder().build(); } }; + private static final ClassValue GLOBAL_SCHEDULER_DISPATCH_PLAN_CACHE = + new ClassValue<>() { + @Override + protected GlobalSchedulerDispatchPlan computeValue(Class type) { + return new GlobalSchedulerDispatchPlan( + resolveMethod(type, RUN_GLOBAL), + resolveMethod(type, RUN_GLOBAL_DELAYED), + resolveMethod(type, RUN_GLOBAL_TIMER) + ); + } + }; + private static final ClassValue REGION_SCHEDULER_DISPATCH_PLAN_CACHE = + new ClassValue<>() { + @Override + protected RegionSchedulerDispatchPlan computeValue(Class type) { + return new RegionSchedulerDispatchPlan( + resolveMethod(type, RUN_REGION), + resolveMethod(type, RUN_REGION_DELAYED), + resolveMethod(type, RUN_REGION_TIMER) + ); + } + }; + private static final ClassValue ENTITY_SCHEDULER_DISPATCH_PLAN_CACHE = + new ClassValue<>() { + @Override + protected EntitySchedulerDispatchPlan computeValue(Class type) { + return new EntitySchedulerDispatchPlan( + resolveMethod(type, RUN_ENTITY), + resolveMethod(type, RUN_ENTITY_DELAYED) + ); + } + }; private static final PluginTask NO_OP_TASK = new PluginTask() { @Override @@ -93,13 +132,14 @@ public boolean isCancelled() { /** * Each capability is published as one immutable entry so readers can never - * observe a target from one resolution paired with another target's scheduler. - * The null scheduler value is intentional: it negatively caches unavailable - * Folia capabilities on standard Paper runtimes. + * observe a target from one resolution paired with another target's scheduler + * or invocation plan. Null scheduler values intentionally negative-cache + * unavailable Folia capabilities on standard Paper runtimes. */ - private volatile SchedulerCapability globalSchedulerCapability; - private volatile SchedulerCapability regionSchedulerCapability; - private volatile SchedulerCapability entitySchedulerCapability; + private volatile GlobalSchedulerCapability globalSchedulerCapability; + private volatile RegionSchedulerCapability regionSchedulerCapability; + private volatile EntitySchedulerCapability entitySchedulerCapability; + private volatile BukkitSchedulerCapability bukkitSchedulerCapability; public ServerScheduler(Plugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); @@ -109,71 +149,32 @@ public PluginTask runGlobal(Runnable runnable) { if (runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.globalRegionScheduler(); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_GLOBAL, - this.plugin, - taskConsumer(runnable) - ); - if (task != null) { - return task; - } - } - return wrap(this.plugin.getServer().getScheduler().runTask(this.plugin, runnable)); + return this.runGlobalValidated(this.plugin.getServer(), runnable); } public PluginTask runGlobalDelayed(Runnable runnable, long delayTicks) { if (runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.globalRegionScheduler(); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_GLOBAL_DELAYED, - this.plugin, - taskConsumer(runnable), - delayTicks - ); - if (task != null) { - return task; - } - } - return wrap(this.plugin.getServer().getScheduler().runTaskLater(this.plugin, runnable, delayTicks)); + return this.runGlobalDelayedValidated(this.plugin.getServer(), runnable, delayTicks); } public PluginTask runGlobalTimer(Runnable runnable, long delayTicks, long periodTicks) { if (runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.globalRegionScheduler(); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_GLOBAL_TIMER, - this.plugin, - taskConsumer(runnable), - delayTicks, - periodTicks - ); - if (task != null) { - return task; - } - } - return wrap(this.plugin.getServer().getScheduler().runTaskTimer(this.plugin, runnable, delayTicks, periodTicks)); + return this.runGlobalTimerValidated(this.plugin.getServer(), runnable, delayTicks, periodTicks); } public PluginTask runRegion(Location location, Runnable runnable) { if (location == null || location.getWorld() == null || runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.regionScheduler(); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_REGION, + Server server = this.plugin.getServer(); + RegionSchedulerCapability capability = this.regionSchedulerCapability(server); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeRun( + capability.scheduler(), this.plugin, location, taskConsumer(runnable) @@ -182,18 +183,18 @@ public PluginTask runRegion(Location location, Runnable runnable) { return task; } } - return this.runGlobal(runnable); + return this.runGlobalValidated(server, runnable); } public PluginTask runRegionDelayed(Location location, Runnable runnable, long delayTicks) { if (location == null || location.getWorld() == null || runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.regionScheduler(); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_REGION_DELAYED, + Server server = this.plugin.getServer(); + RegionSchedulerCapability capability = this.regionSchedulerCapability(server); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeDelayed( + capability.scheduler(), this.plugin, location, taskConsumer(runnable), @@ -203,18 +204,18 @@ public PluginTask runRegionDelayed(Location location, Runnable runnable, long de return task; } } - return this.runGlobalDelayed(runnable, delayTicks); + return this.runGlobalDelayedValidated(server, runnable, delayTicks); } public PluginTask runRegionTimer(Location location, Runnable runnable, long delayTicks, long periodTicks) { if (location == null || location.getWorld() == null || runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.regionScheduler(); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_REGION_TIMER, + Server server = this.plugin.getServer(); + RegionSchedulerCapability capability = this.regionSchedulerCapability(server); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeTimer( + capability.scheduler(), this.plugin, location, taskConsumer(runnable), @@ -225,18 +226,17 @@ public PluginTask runRegionTimer(Location location, Runnable runnable, long dela return task; } } - return this.runGlobalTimer(runnable, delayTicks, periodTicks); + return this.runGlobalTimerValidated(server, runnable, delayTicks, periodTicks); } public PluginTask runEntity(Entity entity, Runnable runnable) { if (entity == null || runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.entityScheduler(entity); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_ENTITY, + EntitySchedulerCapability capability = this.entitySchedulerCapability(entity); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeRun( + capability.scheduler(), this.plugin, taskConsumer(runnable), NO_OP_RUNNABLE @@ -245,18 +245,17 @@ public PluginTask runEntity(Entity entity, Runnable runnable) { return task; } } - return this.runGlobal(runnable); + return this.runGlobalValidated(this.plugin.getServer(), runnable); } public PluginTask runEntityDelayed(Entity entity, Runnable runnable, long delayTicks) { if (entity == null || runnable == null || !this.isPluginEnabled()) { return NO_OP_TASK; } - Object scheduler = this.entityScheduler(entity); - if (scheduler != null) { - PluginTask task = this.invokeSchedulerTask( - scheduler, - RUN_ENTITY_DELAYED, + EntitySchedulerCapability capability = this.entitySchedulerCapability(entity); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeDelayed( + capability.scheduler(), this.plugin, taskConsumer(runnable), NO_OP_RUNNABLE, @@ -266,7 +265,7 @@ public PluginTask runEntityDelayed(Entity entity, Runnable runnable, long delayT return task; } } - return this.runGlobalDelayed(runnable, delayTicks); + return this.runGlobalDelayedValidated(this.plugin.getServer(), runnable, delayTicks); } @SuppressWarnings("unchecked") @@ -277,7 +276,7 @@ public CompletableFuture teleport(Entity entity, Location location) { MethodResolution teleportAsync = resolveMethod(entity.getClass(), TELEPORT_ASYNC); if (teleportAsync.isAvailable()) { try { - Object result = teleportAsync.invoke(entity, location); + Object result = teleportAsync.invoke1(entity, location); if (result instanceof CompletableFuture future) { return (CompletableFuture) future; } @@ -322,35 +321,103 @@ public PluginTask removeEntity(Entity entity, long delayTicks) { return this.runEntityDelayed(entity, removeTask, delayTicks); } - private Object globalRegionScheduler() { - Object server = this.plugin.getServer(); - SchedulerCapability capability = this.globalSchedulerCapability; + private PluginTask runGlobalValidated(Server server, Runnable runnable) { + GlobalSchedulerCapability capability = this.globalSchedulerCapability(server); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeRun( + capability.scheduler(), + this.plugin, + taskConsumer(runnable) + ); + if (task != null) { + return task; + } + } + return wrap(this.bukkitScheduler(server).runTask(this.plugin, runnable)); + } + + private PluginTask runGlobalDelayedValidated(Server server, Runnable runnable, long delayTicks) { + GlobalSchedulerCapability capability = this.globalSchedulerCapability(server); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeDelayed( + capability.scheduler(), + this.plugin, + taskConsumer(runnable), + delayTicks + ); + if (task != null) { + return task; + } + } + return wrap(this.bukkitScheduler(server).runTaskLater(this.plugin, runnable, delayTicks)); + } + + private PluginTask runGlobalTimerValidated(Server server, Runnable runnable, long delayTicks, long periodTicks) { + GlobalSchedulerCapability capability = this.globalSchedulerCapability(server); + if (capability.scheduler() != null) { + PluginTask task = capability.dispatchPlan().invokeTimer( + capability.scheduler(), + this.plugin, + taskConsumer(runnable), + delayTicks, + periodTicks + ); + if (task != null) { + return task; + } + } + return wrap(this.bukkitScheduler(server).runTaskTimer(this.plugin, runnable, delayTicks, periodTicks)); + } + + private GlobalSchedulerCapability globalSchedulerCapability(Server server) { + GlobalSchedulerCapability capability = this.globalSchedulerCapability; if (capability != null && capability.target() == server) { - return capability.scheduler(); + return capability; } - Object scheduler = this.invokeNoArgs(server, GET_GLOBAL_REGION_SCHEDULER); - this.globalSchedulerCapability = new SchedulerCapability(server, scheduler); - return scheduler; + Object scheduler = invokeNoArgs(server, GET_GLOBAL_REGION_SCHEDULER); + GlobalSchedulerDispatchPlan dispatchPlan = scheduler == null + ? null + : GLOBAL_SCHEDULER_DISPATCH_PLAN_CACHE.get(scheduler.getClass()); + capability = new GlobalSchedulerCapability(server, scheduler, dispatchPlan); + this.globalSchedulerCapability = capability; + return capability; } - private Object regionScheduler() { - Object server = this.plugin.getServer(); - SchedulerCapability capability = this.regionSchedulerCapability; + private RegionSchedulerCapability regionSchedulerCapability(Server server) { + RegionSchedulerCapability capability = this.regionSchedulerCapability; if (capability != null && capability.target() == server) { - return capability.scheduler(); + return capability; } - Object scheduler = this.invokeNoArgs(server, GET_REGION_SCHEDULER); - this.regionSchedulerCapability = new SchedulerCapability(server, scheduler); - return scheduler; + Object scheduler = invokeNoArgs(server, GET_REGION_SCHEDULER); + RegionSchedulerDispatchPlan dispatchPlan = scheduler == null + ? null + : REGION_SCHEDULER_DISPATCH_PLAN_CACHE.get(scheduler.getClass()); + capability = new RegionSchedulerCapability(server, scheduler, dispatchPlan); + this.regionSchedulerCapability = capability; + return capability; } - private Object entityScheduler(Entity entity) { - SchedulerCapability capability = this.entitySchedulerCapability; + private EntitySchedulerCapability entitySchedulerCapability(Entity entity) { + EntitySchedulerCapability capability = this.entitySchedulerCapability; if (capability != null && capability.target() == entity) { + return capability; + } + Object scheduler = invokeNoArgs(entity, GET_ENTITY_SCHEDULER); + EntitySchedulerDispatchPlan dispatchPlan = scheduler == null + ? null + : ENTITY_SCHEDULER_DISPATCH_PLAN_CACHE.get(scheduler.getClass()); + capability = new EntitySchedulerCapability(entity, scheduler, dispatchPlan); + this.entitySchedulerCapability = capability; + return capability; + } + + private BukkitScheduler bukkitScheduler(Server server) { + BukkitSchedulerCapability capability = this.bukkitSchedulerCapability; + if (capability != null && capability.target() == server) { return capability.scheduler(); } - Object scheduler = this.invokeNoArgs(entity, GET_ENTITY_SCHEDULER); - this.entitySchedulerCapability = new SchedulerCapability(entity, scheduler); + BukkitScheduler scheduler = server.getScheduler(); + this.bukkitSchedulerCapability = new BukkitSchedulerCapability(server, scheduler); return scheduler; } @@ -358,7 +425,7 @@ private boolean isPluginEnabled() { return this.plugin.isEnabled(); } - private Object invokeNoArgs(Object target, MethodSignature signature) { + private static Object invokeNoArgs(Object target, MethodSignature signature) { if (target == null) { return null; } @@ -367,19 +434,77 @@ private Object invokeNoArgs(Object target, MethodSignature signature) { return null; } try { - return resolution.invoke(target); + return resolution.invoke0(target); } catch (IllegalAccessException exception) { return null; } } - private PluginTask invokeSchedulerTask(Object scheduler, MethodSignature signature, Object... args) { - MethodResolution resolution = resolveMethod(scheduler.getClass(), signature); + private static PluginTask invokeSchedulerTask( + Object scheduler, + MethodResolution resolution, + Object argument0, + Object argument1 + ) { if (!resolution.isAvailable()) { return null; } try { - return wrap(resolution.invoke(scheduler, args)); + return wrap(resolution.invoke2(scheduler, argument0, argument1)); + } catch (IllegalAccessException exception) { + return null; + } + } + + private static PluginTask invokeSchedulerTask( + Object scheduler, + MethodResolution resolution, + Object argument0, + Object argument1, + Object argument2 + ) { + if (!resolution.isAvailable()) { + return null; + } + try { + return wrap(resolution.invoke3(scheduler, argument0, argument1, argument2)); + } catch (IllegalAccessException exception) { + return null; + } + } + + private static PluginTask invokeSchedulerTask( + Object scheduler, + MethodResolution resolution, + Object argument0, + Object argument1, + Object argument2, + Object argument3 + ) { + if (!resolution.isAvailable()) { + return null; + } + try { + return wrap(resolution.invoke4(scheduler, argument0, argument1, argument2, argument3)); + } catch (IllegalAccessException exception) { + return null; + } + } + + private static PluginTask invokeSchedulerTask( + Object scheduler, + MethodResolution resolution, + Object argument0, + Object argument1, + Object argument2, + Object argument3, + Object argument4 + ) { + if (!resolution.isAvailable()) { + return null; + } + try { + return wrap(resolution.invoke5(scheduler, argument0, argument1, argument2, argument3, argument4)); } catch (IllegalAccessException exception) { return null; } @@ -397,7 +522,117 @@ private static PluginTask wrap(Object task) { return task == null ? NO_OP_TASK : new ScheduledTaskHandle(task); } - private record SchedulerCapability(Object target, Object scheduler) { + private record GlobalSchedulerDispatchPlan( + MethodResolution runMethod, + MethodResolution delayedMethod, + MethodResolution timerMethod + ) { + private PluginTask invokeRun(Object scheduler, Plugin plugin, Consumer consumer) { + return invokeSchedulerTask(scheduler, this.runMethod, plugin, consumer); + } + + private PluginTask invokeDelayed(Object scheduler, Plugin plugin, Consumer consumer, long delayTicks) { + return invokeSchedulerTask(scheduler, this.delayedMethod, plugin, consumer, delayTicks); + } + + private PluginTask invokeTimer( + Object scheduler, + Plugin plugin, + Consumer consumer, + long delayTicks, + long periodTicks + ) { + return invokeSchedulerTask(scheduler, this.timerMethod, plugin, consumer, delayTicks, periodTicks); + } + } + + private record RegionSchedulerDispatchPlan( + MethodResolution runMethod, + MethodResolution delayedMethod, + MethodResolution timerMethod + ) { + private PluginTask invokeRun( + Object scheduler, + Plugin plugin, + Location location, + Consumer consumer + ) { + return invokeSchedulerTask(scheduler, this.runMethod, plugin, location, consumer); + } + + private PluginTask invokeDelayed( + Object scheduler, + Plugin plugin, + Location location, + Consumer consumer, + long delayTicks + ) { + return invokeSchedulerTask(scheduler, this.delayedMethod, plugin, location, consumer, delayTicks); + } + + private PluginTask invokeTimer( + Object scheduler, + Plugin plugin, + Location location, + Consumer consumer, + long delayTicks, + long periodTicks + ) { + return invokeSchedulerTask( + scheduler, + this.timerMethod, + plugin, + location, + consumer, + delayTicks, + periodTicks + ); + } + } + + private record EntitySchedulerDispatchPlan(MethodResolution runMethod, MethodResolution delayedMethod) { + private PluginTask invokeRun( + Object scheduler, + Plugin plugin, + Consumer consumer, + Runnable retired + ) { + return invokeSchedulerTask(scheduler, this.runMethod, plugin, consumer, retired); + } + + private PluginTask invokeDelayed( + Object scheduler, + Plugin plugin, + Consumer consumer, + Runnable retired, + long delayTicks + ) { + return invokeSchedulerTask(scheduler, this.delayedMethod, plugin, consumer, retired, delayTicks); + } + } + + private record GlobalSchedulerCapability( + Server target, + Object scheduler, + GlobalSchedulerDispatchPlan dispatchPlan + ) { + } + + private record RegionSchedulerCapability( + Server target, + Object scheduler, + RegionSchedulerDispatchPlan dispatchPlan + ) { + } + + private record EntitySchedulerCapability( + Entity target, + Object scheduler, + EntitySchedulerDispatchPlan dispatchPlan + ) { + } + + private record BukkitSchedulerCapability(Server target, BukkitScheduler scheduler) { } private record BukkitTaskHandle(BukkitTask task) implements PluginTask { @@ -434,7 +669,7 @@ private static Object invokeTaskMethod(Object task, MethodSignature signature) { return null; } try { - return resolution.invoke(task); + return resolution.invoke0(task); } catch (IllegalAccessException exception) { return null; } @@ -481,32 +716,147 @@ private Class[] parameterArray() { } } - private record MethodResolution(Method method, SMethod invoker) { - private static final MethodResolution UNAVAILABLE = new MethodResolution(null, null); + private static final class MethodResolution { + private static final MethodResolution UNAVAILABLE = new MethodResolution( + null, + null, + null, + null, + null, + null, + null + ); + + private final Method method; + private final SMethod0 invoker0; + private final SMethod1 invoker1; + private final SMethod2 invoker2; + private final SMethod3 invoker3; + private final SMethod4 invoker4; + private final SMethod5 invoker5; + + private MethodResolution( + Method method, + SMethod0 invoker0, + SMethod1 invoker1, + SMethod2 invoker2, + SMethod3 invoker3, + SMethod4 invoker4, + SMethod5 invoker5 + ) { + this.method = method; + this.invoker0 = invoker0; + this.invoker1 = invoker1; + this.invoker2 = invoker2; + this.invoker3 = invoker3; + this.invoker4 = invoker4; + this.invoker5 = invoker5; + } private static MethodResolution available(Method method) { - SMethod invoker = null; + SMethod0 invoker0 = null; + SMethod1 invoker1 = null; + SMethod2 invoker2 = null; + SMethod3 invoker3 = null; + SMethod4 invoker4 = null; + SMethod5 invoker5 = null; try { - invoker = SparrowMethod.of(method).asm(); + SparrowMethod sparrowMethod = SparrowMethod.of(method); + switch (method.getParameterCount()) { + case 0 -> invoker0 = sparrowMethod.asm$0(); + case 1 -> invoker1 = sparrowMethod.asm$1(); + case 2 -> invoker2 = sparrowMethod.asm$2(); + case 3 -> invoker3 = sparrowMethod.asm$3(); + case 4 -> invoker4 = sparrowMethod.asm$4(); + case 5 -> invoker5 = sparrowMethod.asm$5(); + default -> { + } + } } catch (RuntimeException | LinkageError exception) { // Some generated or strongly encapsulated runtime classes reject hidden invoker generation. } - return new MethodResolution(method, invoker); + return new MethodResolution(method, invoker0, invoker1, invoker2, invoker3, invoker4, invoker5); + } + + private Method method() { + return this.method; } private boolean isAvailable() { return this.method != null; } - private Object invoke(Object target, Object... args) throws IllegalAccessException { + private Object invoke0(Object target) throws IllegalAccessException { + this.requireAvailable(); + if (this.invoker0 != null) { + return this.invoker0.invoke(target); + } + return this.invokeReflectively(target); + } + + private Object invoke1(Object target, Object argument0) throws IllegalAccessException { + this.requireAvailable(); + if (this.invoker1 != null) { + return this.invoker1.invoke(target, argument0); + } + return this.invokeReflectively(target, argument0); + } + + private Object invoke2(Object target, Object argument0, Object argument1) throws IllegalAccessException { + this.requireAvailable(); + if (this.invoker2 != null) { + return this.invoker2.invoke(target, argument0, argument1); + } + return this.invokeReflectively(target, argument0, argument1); + } + + private Object invoke3(Object target, Object argument0, Object argument1, Object argument2) + throws IllegalAccessException { + this.requireAvailable(); + if (this.invoker3 != null) { + return this.invoker3.invoke(target, argument0, argument1, argument2); + } + return this.invokeReflectively(target, argument0, argument1, argument2); + } + + private Object invoke4( + Object target, + Object argument0, + Object argument1, + Object argument2, + Object argument3 + ) throws IllegalAccessException { + this.requireAvailable(); + if (this.invoker4 != null) { + return this.invoker4.invoke(target, argument0, argument1, argument2, argument3); + } + return this.invokeReflectively(target, argument0, argument1, argument2, argument3); + } + + private Object invoke5( + Object target, + Object argument0, + Object argument1, + Object argument2, + Object argument3, + Object argument4 + ) throws IllegalAccessException { + this.requireAvailable(); + if (this.invoker5 != null) { + return this.invoker5.invoke(target, argument0, argument1, argument2, argument3, argument4); + } + return this.invokeReflectively(target, argument0, argument1, argument2, argument3, argument4); + } + + private void requireAvailable() { if (this.method == null) { throw new IllegalStateException("Cannot invoke an unavailable method"); } - if (this.invoker != null) { - return this.invoker.invoke(target, args); - } + } + + private Object invokeReflectively(Object target, Object... arguments) throws IllegalAccessException { try { - return this.method.invoke(target, args); + return this.method.invoke(target, arguments); } catch (InvocationTargetException exception) { Throwable cause = exception.getCause(); if (cause instanceof RuntimeException runtimeException) { diff --git a/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerDispatchPlanTest.java b/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerDispatchPlanTest.java new file mode 100644 index 0000000..32e06dd --- /dev/null +++ b/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerDispatchPlanTest.java @@ -0,0 +1,300 @@ +package top.ellan.mahjong.runtime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.papermc.paper.threadedregions.scheduler.EntityScheduler; +import io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler; +import io.papermc.paper.threadedregions.scheduler.RegionScheduler; +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.Test; + +final class ServerSchedulerDispatchPlanTest { + private static final Runnable NO_OP = () -> { + }; + + @Test + void dispatchesEveryFoliaMethodShapeThroughCachedPlans() { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); + World world = mock(World.class); + Location location = new Location(world, 8.0, 64.0, -8.0); + Entity entity = mock(Entity.class); + GlobalRegionScheduler global = mock(GlobalRegionScheduler.class); + RegionScheduler region = mock(RegionScheduler.class); + EntityScheduler entityScheduler = mock(EntityScheduler.class); + ScheduledTask task = mock(ScheduledTask.class); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenReturn(server); + when(server.getGlobalRegionScheduler()).thenReturn(global); + when(server.getRegionScheduler()).thenReturn(region); + when(entity.getScheduler()).thenReturn(entityScheduler); + when(global.run(eq(plugin), any())).thenReturn(task); + when(global.runDelayed(eq(plugin), any(), anyLong())).thenReturn(task); + when(global.runAtFixedRate(eq(plugin), any(), anyLong(), anyLong())).thenReturn(task); + when(region.run(eq(plugin), eq(location), any())).thenReturn(task); + when(region.runDelayed(eq(plugin), eq(location), any(), anyLong())).thenReturn(task); + when(region.runAtFixedRate(eq(plugin), eq(location), any(), anyLong(), anyLong())).thenReturn(task); + when(entityScheduler.run(eq(plugin), any(), any(Runnable.class))).thenReturn(task); + when(entityScheduler.runDelayed(eq(plugin), any(), any(Runnable.class), anyLong())).thenReturn(task); + + ServerScheduler scheduler = new ServerScheduler(plugin); + + assertFalse(scheduler.runGlobal(NO_OP).isCancelled()); + assertFalse(scheduler.runGlobalDelayed(NO_OP, 11L).isCancelled()); + assertFalse(scheduler.runGlobalTimer(NO_OP, 13L, 17L).isCancelled()); + assertFalse(scheduler.runRegion(location, NO_OP).isCancelled()); + assertFalse(scheduler.runRegionDelayed(location, NO_OP, 19L).isCancelled()); + assertFalse(scheduler.runRegionTimer(location, NO_OP, 23L, 29L).isCancelled()); + assertFalse(scheduler.runEntity(entity, NO_OP).isCancelled()); + assertFalse(scheduler.runEntityDelayed(entity, NO_OP, 31L).isCancelled()); + + verify(server, times(1)).getGlobalRegionScheduler(); + verify(server, times(1)).getRegionScheduler(); + verify(entity, times(1)).getScheduler(); + verify(global).run(eq(plugin), any()); + verify(global).runDelayed(eq(plugin), any(), eq(11L)); + verify(global).runAtFixedRate(eq(plugin), any(), eq(13L), eq(17L)); + verify(region).run(eq(plugin), eq(location), any()); + verify(region).runDelayed(eq(plugin), eq(location), any(), eq(19L)); + verify(region).runAtFixedRate(eq(plugin), eq(location), any(), eq(23L), eq(29L)); + verify(entityScheduler).run(eq(plugin), any(), any(Runnable.class)); + verify(entityScheduler).runDelayed(eq(plugin), any(), any(Runnable.class), eq(31L)); + } + + @Test + void missingRegionAndEntityCapabilitiesFallBackToGlobalFoliaScheduler() { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); + World world = mock(World.class); + Location location = new Location(world, 0.0, 64.0, 0.0); + Entity entity = mock(Entity.class); + GlobalRegionScheduler global = mock(GlobalRegionScheduler.class); + ScheduledTask task = mock(ScheduledTask.class); + AtomicInteger executions = new AtomicInteger(); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenReturn(server); + when(server.getGlobalRegionScheduler()).thenReturn(global); + when(global.run(eq(plugin), any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + Consumer consumer = invocation.getArgument(1, Consumer.class); + consumer.accept(task); + return task; + }); + + ServerScheduler scheduler = new ServerScheduler(plugin); + scheduler.runRegion(location, executions::incrementAndGet); + scheduler.runEntity(entity, executions::incrementAndGet); + + assertEquals(2, executions.get()); + verify(server, times(1)).getRegionScheduler(); + verify(entity, times(1)).getScheduler(); + verify(server, times(1)).getGlobalRegionScheduler(); + verify(global, times(2)).run(eq(plugin), any()); + verify(server, never()).getScheduler(); + } + + @Test + void cachesBukkitFallbackByServerIdentityAndInvalidatesOnServerChange() { + Plugin plugin = mock(Plugin.class); + Server firstServer = mock(Server.class); + Server secondServer = mock(Server.class); + BukkitScheduler firstScheduler = mock(BukkitScheduler.class); + BukkitScheduler secondScheduler = mock(BukkitScheduler.class); + BukkitTask firstTask = mock(BukkitTask.class); + BukkitTask secondTask = mock(BukkitTask.class); + World world = mock(World.class); + Location location = new Location(world, 0.0, 64.0, 0.0); + Entity entity = mock(Entity.class); + AtomicReference currentServer = new AtomicReference<>(firstServer); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenAnswer(invocation -> currentServer.get()); + when(firstServer.getScheduler()).thenReturn(firstScheduler); + when(secondServer.getScheduler()).thenReturn(secondScheduler); + when(firstScheduler.runTask(eq(plugin), any(Runnable.class))).thenReturn(firstTask); + when(secondScheduler.runTask(eq(plugin), any(Runnable.class))).thenReturn(secondTask); + + ServerScheduler scheduler = new ServerScheduler(plugin); + scheduler.runGlobal(NO_OP); + scheduler.runRegion(location, NO_OP); + scheduler.runEntity(entity, NO_OP); + currentServer.set(secondServer); + scheduler.runGlobal(NO_OP); + scheduler.runRegion(location, NO_OP); + scheduler.runEntity(entity, NO_OP); + + verify(plugin, times(6)).getServer(); + verify(firstServer, times(1)).getScheduler(); + verify(secondServer, times(1)).getScheduler(); + verify(firstScheduler, times(3)).runTask(eq(plugin), any(Runnable.class)); + verify(secondScheduler, times(3)).runTask(eq(plugin), any(Runnable.class)); + } + + @Test + void nullFoliaTaskDoesNotScheduleAgainThroughBukkit() { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); + GlobalRegionScheduler global = mock(GlobalRegionScheduler.class); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenReturn(server); + when(server.getGlobalRegionScheduler()).thenReturn(global); + when(global.run(eq(plugin), any())).thenReturn(null); + + PluginTask task = new ServerScheduler(plugin).runGlobal(NO_OP); + + assertTrue(task.isCancelled()); + verify(global, times(1)).run(eq(plugin), any()); + verify(server, never()).getScheduler(); + } + + @Test + void regionSchedulerFailureNeverFallsBackOrSchedulesTwice() { + Plugin plugin = mock(Plugin.class); + Server server = mock(Server.class); + World world = mock(World.class); + Location location = new Location(world, 0.0, 64.0, 0.0); + RegionScheduler region = mock(RegionScheduler.class); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenReturn(server); + when(server.getRegionScheduler()).thenReturn(region); + when(region.run(eq(plugin), eq(location), any())).thenThrow(new IllegalStateException("region failed")); + + assertThrows(IllegalStateException.class, () -> new ServerScheduler(plugin).runRegion(location, NO_OP)); + + verify(region, times(1)).run(eq(plugin), eq(location), any()); + verify(server, never()).getGlobalRegionScheduler(); + verify(server, never()).getScheduler(); + } + + @Test + void entitySchedulerFailureNeverFallsBackOrSchedulesTwice() { + Plugin plugin = mock(Plugin.class); + Entity entity = mock(Entity.class); + EntityScheduler entityScheduler = mock(EntityScheduler.class); + + when(plugin.isEnabled()).thenReturn(true); + when(entity.getScheduler()).thenReturn(entityScheduler); + when(entityScheduler.run(eq(plugin), any(), any(Runnable.class))) + .thenThrow(new IllegalStateException("entity failed")); + + assertThrows(IllegalStateException.class, () -> new ServerScheduler(plugin).runEntity(entity, NO_OP)); + + verify(entityScheduler, times(1)).run(eq(plugin), any(), any(Runnable.class)); + verify(plugin, never()).getServer(); + } + + @Test + void neverRoutesConcurrentGlobalCallsThroughAnotherServersScheduler() throws InterruptedException { + Plugin plugin = mock(Plugin.class); + Server firstServer = mock(Server.class); + Server secondServer = mock(Server.class); + GlobalRegionScheduler firstScheduler = mock(GlobalRegionScheduler.class); + GlobalRegionScheduler secondScheduler = mock(GlobalRegionScheduler.class); + ScheduledTask task = mock(ScheduledTask.class); + ThreadLocal threadServer = new ThreadLocal<>(); + AtomicInteger wrongRoutes = new AtomicInteger(); + + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getServer()).thenAnswer(invocation -> threadServer.get()); + when(firstServer.getGlobalRegionScheduler()).thenReturn(firstScheduler); + when(secondServer.getGlobalRegionScheduler()).thenReturn(secondScheduler); + when(firstScheduler.run(eq(plugin), any())).thenAnswer(invocation -> { + if (!Thread.currentThread().getName().equals("scheduler-server-first")) { + wrongRoutes.incrementAndGet(); + } + return task; + }); + when(secondScheduler.run(eq(plugin), any())).thenAnswer(invocation -> { + if (!Thread.currentThread().getName().equals("scheduler-server-second")) { + wrongRoutes.incrementAndGet(); + } + return task; + }); + + ServerScheduler scheduler = new ServerScheduler(plugin); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(2); + AtomicReference failure = new AtomicReference<>(); + Thread firstThread = new Thread( + () -> runGlobalLoop(scheduler, threadServer, firstServer, ready, start, done, failure), + "scheduler-server-first" + ); + Thread secondThread = new Thread( + () -> runGlobalLoop(scheduler, threadServer, secondServer, ready, start, done, failure), + "scheduler-server-second" + ); + + firstThread.start(); + secondThread.start(); + try { + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS)); + } finally { + start.countDown(); + firstThread.interrupt(); + secondThread.interrupt(); + firstThread.join(); + secondThread.join(); + } + + if (failure.get() != null) { + throw new AssertionError("Concurrent scheduler invocation failed", failure.get()); + } + assertEquals(0, wrongRoutes.get()); + } + + private static void runGlobalLoop( + ServerScheduler scheduler, + ThreadLocal threadServer, + Server server, + CountDownLatch ready, + CountDownLatch start, + CountDownLatch done, + AtomicReference failure + ) { + threadServer.set(server); + ready.countDown(); + try { + if (!start.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Concurrent scheduler start timed out"); + } + for (int iteration = 0; iteration < 20_000; iteration++) { + scheduler.runGlobal(NO_OP); + } + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + threadServer.remove(); + done.countDown(); + } + } +} diff --git a/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerReflectionCacheTest.java b/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerReflectionCacheTest.java index 2776e4b..34a7031 100644 --- a/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerReflectionCacheTest.java +++ b/src/test/java/top/ellan/mahjong/runtime/ServerSchedulerReflectionCacheTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -202,12 +203,26 @@ void neverRoutesConcurrentEntityCallsThroughAnotherEntityScheduler() throws Inte } return task; }); + when(firstEntityScheduler.runDelayed(any(Plugin.class), any(), any(Runnable.class), anyLong())) + .thenAnswer(invocation -> { + if (!Thread.currentThread().getName().equals("scheduler-entity-first")) { + wrongRoutes.incrementAndGet(); + } + return task; + }); when(secondEntityScheduler.run(any(Plugin.class), any(), any(Runnable.class))).thenAnswer(invocation -> { if (!Thread.currentThread().getName().equals("scheduler-entity-second")) { wrongRoutes.incrementAndGet(); } return task; }); + when(secondEntityScheduler.runDelayed(any(Plugin.class), any(), any(Runnable.class), anyLong())) + .thenAnswer(invocation -> { + if (!Thread.currentThread().getName().equals("scheduler-entity-second")) { + wrongRoutes.incrementAndGet(); + } + return task; + }); ServerScheduler scheduler = new ServerScheduler(plugin); CountDownLatch ready = new CountDownLatch(2); @@ -279,6 +294,7 @@ private static void runEntityLoop( } for (int iteration = 0; iteration < 20_000; iteration++) { scheduler.runEntity(entity, NO_OP); + scheduler.runEntityDelayed(entity, NO_OP, 1L); } } catch (Throwable throwable) { failure.compareAndSet(null, throwable); From 774d0a9310d8163dd5845ec1c7e4c3040ce0a0e2 Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sun, 19 Jul 2026 21:57:12 +0800 Subject: [PATCH 09/12] 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 10/12] 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 11/12] 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") ); } } From 082f1adcb6b11ade4cb99646b50d926c7408d71f Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Tue, 21 Jul 2026 23:30:29 +0800 Subject: [PATCH 12/12] fix(interact): widen click dedup window to stop cross-tick double-firing PlayerUseUnknownEntityEvent is not Cancellable, so Paper keeps firing a follow-up PlayerInteractEvent for the same physical click. The previous 40ms dedup window is shorter than one server tick (50ms), so when the two events land on different ticks the duplicate slips through and the hand-tile toggle (select then discard) triggers on a single click. Raise the dedup windows in TableEventCoordinator and MahjongTableManager to 150ms (3 ticks) so the follow-up event is always caught. Sichuan exchange phase already bypasses hand-tile dedup, so rapid exchange clicks are unaffected. --- .../top/ellan/mahjong/table/core/MahjongTableManager.java | 4 ++-- .../top/ellan/mahjong/table/core/TableEventCoordinator.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/top/ellan/mahjong/table/core/MahjongTableManager.java b/src/main/java/top/ellan/mahjong/table/core/MahjongTableManager.java index eebe0ee..14e78b7 100644 --- a/src/main/java/top/ellan/mahjong/table/core/MahjongTableManager.java +++ b/src/main/java/top/ellan/mahjong/table/core/MahjongTableManager.java @@ -46,8 +46,8 @@ public final class MahjongTableManager implements Listener { private static final String ADMIN_PERMISSION = "mahjongpaper.admin"; - private static final long DUPLICATE_DISPLAY_ACTION_WINDOW_NANOS = 40_000_000L; - private static final long DUPLICATE_HAND_TILE_CLICK_WINDOW_NANOS = 40_000_000L; + private static final long DUPLICATE_DISPLAY_ACTION_WINDOW_NANOS = 150_000_000L; + private static final long DUPLICATE_HAND_TILE_CLICK_WINDOW_NANOS = 150_000_000L; private static final long RECENT_DISPLAY_ACTION_TTL_SECONDS = 60L; private static final long RECENT_HAND_TILE_CLICK_TTL_SECONDS = 60L; private static final double PERSISTED_TABLE_CLEANUP_RADIUS_XZ = 4.5D; diff --git a/src/main/java/top/ellan/mahjong/table/core/TableEventCoordinator.java b/src/main/java/top/ellan/mahjong/table/core/TableEventCoordinator.java index f1e6304..b5a86ca 100644 --- a/src/main/java/top/ellan/mahjong/table/core/TableEventCoordinator.java +++ b/src/main/java/top/ellan/mahjong/table/core/TableEventCoordinator.java @@ -41,13 +41,13 @@ import org.bukkit.util.RayTraceResult; final class TableEventCoordinator implements Listener { - private static final long DUPLICATE_DISPLAY_ACTION_WINDOW_NANOS = 40_000_000L; + private static final long DUPLICATE_DISPLAY_ACTION_WINDOW_NANOS = 150_000_000L; private static final long OVERHEAD_EXIT_GUARD_SECONDS = 2L; private static final double FLAT_INTERACTION_MAX_DISTANCE = 6.0D; private static final double FLAT_INTERACTION_BLOCK_EPSILON = 0.05D; /** * Bounded TTL on the per-player recent-action cache. The dedup window is - * 40ms (DUPLICATE_DISPLAY_ACTION_WINDOW_NANOS), so anything older than a + * 150ms (DUPLICATE_DISPLAY_ACTION_WINDOW_NANOS), so anything older than a * few seconds is by definition not a duplicate of a fresh click — but the * previous bare ConcurrentHashMap only evicted on PlayerQuitEvent, which * on Folia can occasionally be missed when the player's entity region