diff --git a/src/main/java/net/tfminecraft/games/display/DisplayManager.java b/src/main/java/net/tfminecraft/games/display/DisplayManager.java index f87e97f..d7a0f17 100644 --- a/src/main/java/net/tfminecraft/games/display/DisplayManager.java +++ b/src/main/java/net/tfminecraft/games/display/DisplayManager.java @@ -18,11 +18,16 @@ import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerInteractEntityEvent; import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; +import io.papermc.paper.event.packet.PlayerChunkLoadEvent; +import io.papermc.paper.event.packet.PlayerChunkUnloadEvent; + import net.tfminecraft.games.Games; import net.tfminecraft.games.Messages; import net.tfminecraft.games.cache.Cache; @@ -227,10 +232,11 @@ public void onChunkLoad(ChunkLoadEvent event) { continue; } for (Player viewer : nearby(origin)) { - if (!token.viewers.contains(viewer.getUniqueId())) { - show(viewer, token); + // The server just loaded this chunk, so clients no longer have the packet entity. + if (token.viewers.contains(viewer.getUniqueId())) { + reshow(viewer, token); } else { - sendUpdate(viewer, token, 0, 0); + show(viewer, token); } } } @@ -256,18 +262,113 @@ public void onInteract(PlayerInteractEntityEvent event) { event.getPlayer().sendMessage(Messages.get("display.clicked", "token", tokenId)); } + /** + * The client drops packet entities when it unloads their chunk. A metadata update does not + * bring them back, so coming into the chunk has to spawn them again. + */ + @EventHandler + public void onPlayerChunkLoad(PlayerChunkLoadEvent event) { + syncChunk(event.getPlayer(), event.getWorld(), event.getChunk().getX(), event.getChunk().getZ(), true); + } + + @EventHandler + public void onPlayerChunkUnload(PlayerChunkUnloadEvent event) { + syncChunk(event.getPlayer(), event.getWorld(), event.getChunk().getX(), event.getChunk().getZ(), false); + } + + @EventHandler + public void onMove(PlayerMoveEvent event) { + Location to = event.getTo(); + if (to == null) { + return; + } + Location from = event.getFrom(); + if (from.getBlockX() == to.getBlockX() && from.getBlockY() == to.getBlockY() + && from.getBlockZ() == to.getBlockZ()) { + return; + } + refreshViewer(event.getPlayer()); + } + + @EventHandler + public void onRespawn(PlayerRespawnEvent event) { + Bukkit.getScheduler().runTask(Games.plugin, () -> showNearby(event.getPlayer())); + } + private void showNearby(Player viewer) { - if (!viewer.isOnline() || !ProtocolLibBridge.isReady()) { + refreshViewer(viewer); + } + + private void refreshViewer(Player viewer) { + if (viewer == null || !viewer.isOnline() || !ProtocolLibBridge.isReady()) { return; } Location loc = viewer.getLocation(); for (Token token : tokens.values()) { - if (inRange(loc, token.origin) && !token.viewers.contains(viewer.getUniqueId())) { + // Do not destroy on the way out of display range. The client keeps the entity until + // the chunk unloads, and destroying it would blank the table for anyone still in view. + if (respawnFor(inRange(loc, token.origin), token.viewers.contains(viewer.getUniqueId()), false)) { show(viewer, token); } } } + private void syncChunk(Player viewer, World world, int chunkX, int chunkZ, boolean loaded) { + if (viewer == null || !viewer.isOnline() || world == null || !ProtocolLibBridge.isReady()) { + return; + } + for (Token token : tokens.values()) { + if (!inChunk(token, world, chunkX, chunkZ)) { + continue; + } + boolean tracking = token.viewers.contains(viewer.getUniqueId()); + boolean near = inRange(viewer.getLocation(), token.origin); + if (!loaded || hideFor(near, tracking)) { + hide(viewer, token); + continue; + } + if (respawnFor(near, tracking, true)) { + reshow(viewer, token); + } + } + } + + /** True when this viewer needs a fresh spawn packet, not a metadata tweak. */ + static boolean respawnFor(boolean inRange, boolean tracking, boolean chunkReloaded) { + return inRange && (!tracking || chunkReloaded); + } + + /** True when a tracked viewer is too far to be shown the chunk again. */ + static boolean hideFor(boolean inRange, boolean tracking) { + return tracking && !inRange; + } + + private static boolean inChunk(Token token, World world, int chunkX, int chunkZ) { + Location origin = token.origin; + if (origin == null || origin.getWorld() == null || !origin.getWorld().equals(world)) { + return false; + } + return (origin.getBlockX() >> 4) == chunkX && (origin.getBlockZ() >> 4) == chunkZ; + } + + private void reshow(Player viewer, Token token) { + if (!ProtocolLibBridge.isReady()) { + return; + } + hide(viewer, token); + show(viewer, token); + } + + private void hide(Player viewer, Token token) { + if (viewer == null || token == null || !token.viewers.remove(viewer.getUniqueId())) { + return; + } + if (!ProtocolLibBridge.isReady()) { + return; + } + ProtocolLibBridge.getPackets().destroy(viewer, List.of(token.entityId)); + } + private void hideAll(Player viewer) { List ids = new ArrayList<>(); for (Token token : tokens.values()) { diff --git a/src/main/java/net/tfminecraft/games/game/BlackjackGame.java b/src/main/java/net/tfminecraft/games/game/BlackjackGame.java index 6dc9970..bf12d59 100644 --- a/src/main/java/net/tfminecraft/games/game/BlackjackGame.java +++ b/src/main/java/net/tfminecraft/games/game/BlackjackGame.java @@ -289,6 +289,7 @@ private static String formatPileTotal(List cards) { @Override public void onSessionStart(Table table) { + cancelBetTimer(table); if (table.shufflePolicy() == ShufflePolicy.ROUND) { TableManager.get().reshuffleFull(table); } @@ -366,6 +367,23 @@ public void onChipIn(Table table, Player player) { startBetTimer(table); } + /** + * An auto table with a legal bet and no running clock never deals. A dealer claim, a leave, + * or a restart can cancel the countdown and leave the chips sitting there. + */ + static boolean needsBetClock(boolean live, boolean auto, boolean betOpen, boolean frozen, + boolean clockRunning, boolean legalBet) { + return !live && auto && betOpen && !frozen && !clockRunning && legalBet; + } + + @Override + public void onClock(Table table) { + if (needsBetClock(table.live(), auto(table), table.betOpen(), GuildTables.frozen(table), + betTimers.containsKey(table.getId()), TableManager.get().hasLegalBlackjackBox(table))) { + startBetTimer(table); + } + } + @Override public void onLeave(Table table, Player player) { UUID id = player.getUniqueId(); @@ -667,9 +685,15 @@ private void prepareIdle(Table table) { return; } table.setBetOpen(true); - table.setAutoCountdown(0); + if (!betTimers.containsKey(table.getId())) { + table.setAutoCountdown(0); + } TableManager.get().refreshLabel(table); syncTrayHolo(table); + if (needsBetClock(false, true, true, false, betTimers.containsKey(table.getId()), + TableManager.get().hasLegalBlackjackBox(table))) { + startBetTimer(table); + } } /** diff --git a/src/main/java/net/tfminecraft/games/game/Game.java b/src/main/java/net/tfminecraft/games/game/Game.java index 6def226..6c0e81b 100644 --- a/src/main/java/net/tfminecraft/games/game/Game.java +++ b/src/main/java/net/tfminecraft/games/game/Game.java @@ -99,6 +99,11 @@ default void onDealerGone(Table table) { onTableReady(table); } + /** + * Once a second. Blackjack restarts a bet window whose clock was lost. + */ + default void onClock(Table table) {} + /** * Whether this player may put money on the felt right now. Games that deal a hand to a fixed * set of seats say no to anyone not in the live hand, so a late stake cannot buy into a hand diff --git a/src/main/java/net/tfminecraft/games/table/TableManager.java b/src/main/java/net/tfminecraft/games/table/TableManager.java index 981b9ed..1c0b940 100644 --- a/src/main/java/net/tfminecraft/games/table/TableManager.java +++ b/src/main/java/net/tfminecraft/games/table/TableManager.java @@ -193,6 +193,12 @@ private void tickHands() { handTicks++; for (Table table : tables.values()) { tickAway(table); + if (handTicks % 20 == 0) { + Game clock = gameOf(table); + if (clock != null) { + clock.onClock(table); + } + } Map> hands = table.getHands(); if (hands.isEmpty()) { continue; diff --git a/src/test/java/net/tfminecraft/games/display/DisplayManagerTest.java b/src/test/java/net/tfminecraft/games/display/DisplayManagerTest.java index a559191..744bb94 100644 --- a/src/test/java/net/tfminecraft/games/display/DisplayManagerTest.java +++ b/src/test/java/net/tfminecraft/games/display/DisplayManagerTest.java @@ -14,6 +14,7 @@ import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerInteractEntityEvent; import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.inventory.EquipmentSlot; @@ -26,6 +27,8 @@ import org.mockbukkit.mockbukkit.world.WorldMock; import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; +import io.papermc.paper.event.packet.PlayerChunkLoadEvent; +import io.papermc.paper.event.packet.PlayerChunkUnloadEvent; import net.tfminecraft.games.Games; import net.tfminecraft.games.Messages; import net.tfminecraft.games.cache.Cache; @@ -212,10 +215,52 @@ void loadedChunkRefreshesExistingViewersAndShowsNewNearbyViewersOnlyForThatChunk ChunkLoadEvent event = new ChunkLoadEvent(world.getChunkAt(0, 0), false); clearInvocations(packets); manager.onChunkLoad(event); - verify(packets).update(eq(owner), anyInt(), eq(back), any(), eq(0), eq(0)); - verify(packets).update(eq(observer), anyInt(), eq(back), any(), eq(0), eq(0)); + verify(packets).destroy(eq(owner), anyList()); + verify(packets).spawn(eq(owner), anyInt(), any(), eq(origin), eq(back), any()); + verify(packets).destroy(eq(observer), anyList()); + verify(packets).spawn(eq(observer), anyInt(), any(), eq(origin), eq(back), any()); verify(packets).spawn(eq(distant), anyInt(), any(), eq(origin), eq(back), any()); - verify(packets, times(1)).spawn(any(), anyInt(), any(), any(), any(), any()); + verify(packets, never()).update(any(), anyInt(), any(), any(), anyInt(), anyInt()); + } + + @Test + void returningToAChunkRespawnsCardsTheClientDropped() { + manager.spawn(token, origin, back, null); + clearInvocations(packets); + manager.onPlayerChunkLoad(new PlayerChunkLoadEvent(world.getChunkAt(0, 0), owner)); + verify(packets).destroy(eq(owner), anyList()); + verify(packets).spawn(eq(owner), anyInt(), any(), eq(origin), eq(back), any()); + verify(packets, never()).update(eq(owner), anyInt(), any(), any(), anyInt(), anyInt()); + } + + @Test + void leavingAChunkForgetsTheViewerUntilTheyLoadItAgain() { + manager.spawn(token, origin, back, null); + clearInvocations(packets); + manager.onPlayerChunkUnload(new PlayerChunkUnloadEvent(world.getChunkAt(0, 0), owner)); + verify(packets).destroy(eq(owner), anyList()); + clearInvocations(packets); + manager.onPlayerChunkLoad(new PlayerChunkLoadEvent(world.getChunkAt(0, 0), owner)); + verify(packets, never()).destroy(eq(owner), anyList()); + verify(packets).spawn(eq(owner), anyInt(), any(), eq(origin), eq(back), any()); + } + + @Test + void walkingBackIntoRangeSpawnsACardThatWasNeverTracked() { + manager.spawn(token, origin, back, null); + distant.teleport(origin); + clearInvocations(packets); + manager.onMove(new PlayerMoveEvent(distant, origin.clone().add(11, 0, 0), origin)); + verify(packets).spawn(eq(distant), anyInt(), any(), eq(origin), eq(back), any()); + } + + @Test + void walkingOutOfRangeLeavesTheClientEntityAlone() { + manager.spawn(token, origin, back, null); + clearInvocations(packets); + Location far = origin.clone().add(11, 0, 0); + manager.onMove(new PlayerMoveEvent(owner, origin, far)); + verify(packets, never()).destroy(eq(owner), anyList()); } @Test diff --git a/src/test/java/net/tfminecraft/games/game/BlackjackGameTest.java b/src/test/java/net/tfminecraft/games/game/BlackjackGameTest.java index 1a501a3..a5e77b9 100644 --- a/src/test/java/net/tfminecraft/games/game/BlackjackGameTest.java +++ b/src/test/java/net/tfminecraft/games/game/BlackjackGameTest.java @@ -650,6 +650,30 @@ void resultDelayDefersBustSettlementAndIgnoresRemovedTable() { verify(wagers, never()).announceWins(any(), anyString()); } + @Test + void openAutomaticWindowWithALegalBetStartsItsClockWithoutAnotherChip() { + table.setDealerId(null); + table.setAutoDealer(true); + game.onTableReady(table); + assertEquals(2, table.autoCountdown()); + server.getScheduler().performTicks(20); + assertEquals(1, table.autoCountdown()); + } + + @Test + void lostBetClockRestartsWhileALegalBetIsStillOpen() { + table.setDealerId(null); + table.setAutoDealer(true); + table.setBetOpen(true); + assertEquals(0, table.autoCountdown()); + game.onClock(table); + assertEquals(2, table.autoCountdown()); + server.getScheduler().performTicks(20); + assertEquals(1, table.autoCountdown()); + game.onClock(table); + assertEquals(1, table.autoCountdown(), "a running window must not start over"); + } + @Test void automaticBetTimerStartsOnlyOnceAndClosesBeforeAutomaticDeal() { table.setDealerId(null); @@ -886,6 +910,7 @@ void uncoveredChipIsRefundedExactlyAndDoesNotStartCountdown() { table.setDealerId(null); table.setAutoDealer(true); fundingAccepted = false; + bets.clear(); game.onTableReady(table); bets.put(player.getUniqueId(), 27); game.onChipIn(table, player, 7, new ItemStack(Material.GOLD_NUGGET));