diff --git a/sanctuary/build.gradle b/sanctuary/build.gradle index e363262..e0741b0 100644 --- a/sanctuary/build.gradle +++ b/sanctuary/build.gradle @@ -78,6 +78,14 @@ tasks.register("zombieNamesSmoke", JavaExec) { mainClass = "fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryZombieNamesSmoke" } +tasks.register("capeJoinSyncSmoke", JavaExec) { + group = "verification" + description = "Checks delayed, bounded cape snapshot retries after player reconnects." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.sanctuary.gameplay.CapeJoinSyncQueueSmoke" +} + tasks.register("verifyBlackMarket") { group = "verification" description = "Checks the physical sign market, flash Shop, escrow ledger and mailbox delivery." @@ -229,6 +237,7 @@ tasks.register("verifySanctuary") { dependsOn tasks.named("progressionModelSmoke"), tasks.named("serverIdentityModelSmoke"), tasks.named("shopDeliveryTimingSmoke"), tasks.named("blackMarketModelSmoke"), tasks.named("questBoardModelSmoke"), tasks.named("shopHeaderLayoutSmoke"), tasks.named("zombieNamesSmoke"), + tasks.named("capeJoinSyncSmoke"), tasks.named("verifyBlackMarket") doLast { diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/CapeJoinSyncQueue.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/CapeJoinSyncQueue.java new file mode 100644 index 0000000..281ab50 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/CapeJoinSyncQueue.java @@ -0,0 +1,46 @@ +package fr.koka99cab.sanctuary26.sanctuary.gameplay; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** Bounded reconnect retry state for a player's complete cape snapshot. */ +final class CapeJoinSyncQueue { + enum Decision { WAIT, SYNC, DROP } + + private final int retryTicks; + private final Map remainingTicks = new LinkedHashMap<>(); + + CapeJoinSyncQueue(int retryTicks) { + if (retryTicks < 1) throw new IllegalArgumentException("Cape retry window must be positive"); + this.retryTicks = retryTicks; + } + + void schedule(UUID playerId) { + remainingTicks.put(playerId, retryTicks); + } + + List pendingPlayerIds() { + return List.copyOf(remainingTicks.keySet()); + } + + Decision poll(UUID playerId, boolean connected, boolean channelReady) { + Integer remaining = remainingTicks.get(playerId); + if (remaining == null) return Decision.DROP; + if (!connected) { + remainingTicks.remove(playerId); + return Decision.DROP; + } + if (channelReady) { + remainingTicks.remove(playerId); + return Decision.SYNC; + } + if (remaining <= 1) { + remainingTicks.remove(playerId); + return Decision.DROP; + } + remainingTicks.put(playerId, remaining - 1); + return Decision.WAIT; + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryCapeService.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryCapeService.java index daab98f..186f524 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryCapeService.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryCapeService.java @@ -1,13 +1,18 @@ package fr.koka99cab.sanctuary26.sanctuary.gameplay; import fr.koka99cab.sanctuary26.sanctuary.network.CapeSyncPayload; +import java.util.UUID; import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.fabricmc.fabric.api.networking.v1.PlayerLookup; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; /** Server authority and multiplayer distribution for equipped cape visuals. */ public final class SanctuaryCapeService { + private static final int JOIN_SYNC_RETRY_TICKS = 100; + private static final CapeJoinSyncQueue JOIN_SYNCS = new CapeJoinSyncQueue(JOIN_SYNC_RETRY_TICKS); private static boolean initialized; private SanctuaryCapeService() { @@ -17,6 +22,7 @@ public final class SanctuaryCapeService { if (initialized) return; initialized = true; ServerPlayerEvents.AFTER_RESPAWN.register((oldPlayer, newPlayer, alive) -> sync(newPlayer)); + ServerTickEvents.END_SERVER_TICK.register(SanctuaryCapeService::tickJoinSyncs); } public static void join(ServerPlayer joined) { @@ -24,6 +30,10 @@ public final class SanctuaryCapeService { send(joined, online); } sync(joined); + // JOIN can run while the reconnecting client's play channel or player list is + // still settling. Replay the complete snapshot on a later server tick so a + // missed initial packet never requires removing and re-equipping the cape. + JOIN_SYNCS.schedule(joined.getUUID()); } public static void sync(ServerPlayer owner) { @@ -32,6 +42,24 @@ public final class SanctuaryCapeService { } } + private static void tickJoinSyncs(MinecraftServer server) { + for (UUID playerId : JOIN_SYNCS.pendingPlayerIds()) { + ServerPlayer receiver = server.getPlayerList().getPlayer(playerId); + CapeJoinSyncQueue.Decision decision = JOIN_SYNCS.poll(playerId, receiver != null, + receiver != null && ServerPlayNetworking.canSend(receiver, CapeSyncPayload.ID)); + if (decision == CapeJoinSyncQueue.Decision.SYNC) sendSnapshot(receiver); + } + } + + private static void sendSnapshot(ServerPlayer receiver) { + boolean includedReceiver = false; + for (ServerPlayer owner : PlayerLookup.all(receiver.level().getServer())) { + send(receiver, owner); + includedReceiver |= owner.getUUID().equals(receiver.getUUID()); + } + if (!includedReceiver) send(receiver, receiver); + } + private static void send(ServerPlayer receiver, ServerPlayer owner) { if (!ServerPlayNetworking.canSend(receiver, CapeSyncPayload.ID)) return; String capeId = SanctuaryCapes.itemId(SanctuaryInventoryCapacity.equippedCape(owner)); diff --git a/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/CapeJoinSyncQueueSmoke.java b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/CapeJoinSyncQueueSmoke.java new file mode 100644 index 0000000..44ce6d6 --- /dev/null +++ b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/CapeJoinSyncQueueSmoke.java @@ -0,0 +1,41 @@ +package fr.koka99cab.sanctuary26.sanctuary.gameplay; + +import java.util.UUID; + +/** Executable coverage for delayed and bounded cape synchronization after reconnect. */ +public final class CapeJoinSyncQueueSmoke { + private CapeJoinSyncQueueSmoke() { + } + + public static void main(String[] args) { + CapeJoinSyncQueue queue = new CapeJoinSyncQueue(3); + UUID playerId = UUID.fromString("12345678-1234-5678-9abc-def012345678"); + + queue.schedule(playerId); + require(queue.pendingPlayerIds().size() == 1, "Reconnect did not schedule one cape snapshot"); + require(queue.poll(playerId, true, false) == CapeJoinSyncQueue.Decision.WAIT, + "Unavailable play channel was not retried"); + queue.schedule(playerId); + require(queue.pendingPlayerIds().size() == 1, "Repeated join duplicated the cape retry"); + require(queue.poll(playerId, true, true) == CapeJoinSyncQueue.Decision.SYNC, + "Ready play channel did not release the cape snapshot"); + require(queue.pendingPlayerIds().isEmpty(), "Successful cape sync stayed pending"); + + queue.schedule(playerId); + require(queue.poll(playerId, false, false) == CapeJoinSyncQueue.Decision.DROP, + "Disconnected player retained a cape retry"); + + queue.schedule(playerId); + require(queue.poll(playerId, true, false) == CapeJoinSyncQueue.Decision.WAIT, + "Cape retry window ended one tick too early"); + require(queue.poll(playerId, true, false) == CapeJoinSyncQueue.Decision.WAIT, + "Cape retry window ended two ticks too early"); + require(queue.poll(playerId, true, false) == CapeJoinSyncQueue.Decision.DROP, + "Cape retry escaped its bounded window"); + require(queue.pendingPlayerIds().isEmpty(), "Expired cape retry stayed pending"); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalStateException(message); + } +}