From 9259ee5c42bc297693020ee860e36a8e945e7b5b Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 00:16:57 +0800 Subject: [PATCH] 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))