From 27d511d99ae50242ae3093d233928967d052f919 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sat, 5 Sep 2026 20:30:30 -0400 Subject: [PATCH] Fixed replacement staging stalls - Concurrent replacement requests report busy before waiting for the manager lock - Replacement staging reports localized phases and elapsed time - Player progress messages use entity scheduling - Pack copies flush completed files after traversal - Replacement admission and progress tests cover scheduling and cleanup - Language editor tests use a local catalog response --- .../core/PendingWorldReplacementManager.java | 243 +++++++++--------- .../iris/core/WorldReplacementProgress.java | 116 +++++++++ .../iris/core/commands/CommandIris.java | 2 + .../PendingWorldReplacementAdmissionTest.java | 45 ++++ .../core/WorldReplacementProgressTest.java | 147 +++++++++++ .../localization/RuntimeProgressMessages.java | 18 ++ .../history/GenerationPackRepository.java | 13 +- core/src/main/resources/languages/de_DE.json | 9 + core/src/main/resources/languages/es_ES.json | 9 + core/src/main/resources/languages/fi_FI.json | 9 + core/src/main/resources/languages/fr_FR.json | 9 + core/src/main/resources/languages/he_IL.json | 9 + core/src/main/resources/languages/it_IT.json | 9 + core/src/main/resources/languages/ja-JP.json | 9 + core/src/main/resources/languages/ko_KR.json | 9 + core/src/main/resources/languages/lt_LT.json | 9 + core/src/main/resources/languages/nl_NL.json | 9 + core/src/main/resources/languages/pl_PL.json | 9 + core/src/main/resources/languages/pt_PT.json | 9 + core/src/main/resources/languages/ru_RU.json | 9 + core/src/main/resources/languages/tr_TR.json | 9 + core/src/main/resources/languages/vi_VI.json | 9 + core/src/main/resources/languages/zh_CN.json | 9 + core/src/main/resources/languages/zh_TW.json | 9 + .../localization/IrisLanguageEditorTest.java | 62 ++++- 25 files changed, 671 insertions(+), 128 deletions(-) create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/WorldReplacementProgress.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementAdmissionTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/WorldReplacementProgressTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java index a4f56a307..d00c993aa 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java @@ -15,6 +15,7 @@ import art.arcane.iris.core.lifecycle.WorldReplacementJournal; import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase; import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction; import art.arcane.iris.core.lifecycle.WorldReplacementSeed; +import art.arcane.iris.core.localization.RuntimeProgressMessages; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.core.service.StudioSVC; @@ -115,7 +116,7 @@ public final class PendingWorldReplacementManager implements Listener { return worldKey; } - public synchronized StagedReplacement stageReplacement( + public StagedReplacement stageReplacement( VolmitSender sender, NamespacedKey worldKey, IrisDimension dimension, @@ -128,132 +129,142 @@ public final class PendingWorldReplacementManager implements Listener { OptionalLong seedSelection = requestedSeed == null ? OptionalLong.empty() : OptionalLong.of(requestedSeed.longValue()); - IrisStartupValidation.requireWorldReplacementStagingReady(); - if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) { - throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap."); - } - PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName()); LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get(); try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire( LifecycleOperationCoordinator.Domain.WORLD_MUTATION, LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE, requiredWorldKey.toString() - )) { - if (findTransaction(requiredWorldSlotKey) != null) { - throw new IOException("A replacement is already pending for " + requiredWorldKey + "."); - } - ExactWorldSlotPathPolicy.Target target = resolveTarget(requiredWorldSlotKey); - requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment()); - requireVanillaSlotEnabled(target.slotKind()); - UUID transactionId = UUID.randomUUID(); - ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId); - WorldReplacementFilesystem.requireExistingTarget(paths); - String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey); - DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true); - if (!datapacks.succeeded()) { - throw new IOException("Iris could not compile the dimension datapacks."); - } + ); WorldReplacementProgress progress = WorldReplacementProgress.start( + requiredSender, requiredWorldKey.toString())) { + synchronized (this) { + IrisStartupValidation.requireWorldReplacementStagingReady(); + if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) { + throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap."); + } + PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName()); + if (findTransaction(requiredWorldSlotKey) != null) { + throw new IOException("A replacement is already pending for " + requiredWorldKey + "."); + } + ExactWorldSlotPathPolicy.Target target = resolveTarget(requiredWorldSlotKey); + requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment()); + requireVanillaSlotEnabled(target.slotKind()); + UUID transactionId = UUID.randomUUID(); + ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId); + WorldReplacementFilesystem.requireExistingTarget(paths); + String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey); + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_DATAPACKS); + DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true); + if (!datapacks.succeeded()) { + throw new IOException("Iris could not compile the dimension datapacks."); + } - boolean targetPresent = true; - WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot( - ServerProperties.BUKKIT_YML, - worldName - ); - Transaction transaction = null; - boolean journalWritten = false; - boolean configurationApplied = false; - try { - Files.createDirectory(paths.stage()); - long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed( - paths.target(), - paths.stage(), - seedSelection - ); - IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld( - requiredSender, - requiredDimension, - paths.stage().toFile(), - effectiveSeed - ); - if (installed == null) { - throw new IOException("Iris could not stage the dimension pack."); - } - requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment()); - if (target.slotKind() == SlotKind.VANILLA_OVERWORLD) { - WorldReplacementEntryGuard.stage(target.levelRoot(), paths.stage(), transactionId); - } - File stagedPack = IrisWorldStorage.requireActiveGenerationPackRoot( - paths.stage().toFile(), - effectiveSeed - ); - IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack); - String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath()); - transaction = new Transaction( - transactionId, - requiredWorldSlotKey, - worldName, - target.levelRoot(), - installed.getLoadKey(), - effectiveSeed, - packFingerprint, - originalConfiguration, - targetPresent, - Phase.PREPARED - ); - writeTransaction(transaction); - journalWritten = true; - GeneratorReplacement replacement = BukkitWorldConfiguration.replaceIfMatching( + boolean targetPresent = true; + WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot( ServerProperties.BUKKIT_YML, - worldName, - originalConfiguration, - installed.getLoadKey(), - effectiveSeed + worldName ); - if (!replacement.applied()) { - throw new IOException("bukkit.yml changed while the replacement was being staged."); - } - configurationApplied = true; - transaction = transaction.withPhase(Phase.ARMED); - writeTransaction(transaction); - return new StagedReplacement( - requiredWorldKey, - worldName, - installed.getLoadKey(), - effectiveSeed, - targetPresent, - datapacks.restartRequired() - ); - } catch (Throwable failure) { - if (configurationApplied && transaction != null) { - try { - WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction); - if (!BukkitWorldConfiguration.restoreIfMatching( - ServerProperties.BUKKIT_YML, - worldName, - replacement, - originalConfiguration - )) { - failure.addSuppressed(new IOException( - "bukkit.yml changed before the failed replacement could be restored.")); - } - } catch (Throwable restoreFailure) { - failure.addSuppressed(restoreFailure); + Transaction transaction = null; + boolean journalWritten = false; + boolean configurationApplied = false; + try { + Files.createDirectory(paths.stage()); + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_SEED); + long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed( + paths.target(), + paths.stage(), + seedSelection + ); + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK); + IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld( + requiredSender, + requiredDimension, + paths.stage().toFile(), + effectiveSeed + ); + if (installed == null) { + throw new IOException("Iris could not stage the dimension pack."); } - } - if (!configurationApplied || configurationMatches(originalConfiguration, worldName)) { - try { - WorldReplacementFilesystem.discardStage(paths); - if (journalWritten) { - deleteJournal(transactionId); - } - } catch (Throwable cleanupFailure) { - failure.addSuppressed(cleanupFailure); + requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment()); + if (target.slotKind() == SlotKind.VANILLA_OVERWORLD) { + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PLAYERS); + WorldReplacementEntryGuard.stage(target.levelRoot(), paths.stage(), transactionId); } + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_VERIFY); + File stagedPack = IrisWorldStorage.requireActiveGenerationPackRoot( + paths.stage().toFile(), + effectiveSeed + ); + IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack); + String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath()); + transaction = new Transaction( + transactionId, + requiredWorldSlotKey, + worldName, + target.levelRoot(), + installed.getLoadKey(), + effectiveSeed, + packFingerprint, + originalConfiguration, + targetPresent, + Phase.PREPARED + ); + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_SAVE); + writeTransaction(transaction); + journalWritten = true; + GeneratorReplacement replacement = BukkitWorldConfiguration.replaceIfMatching( + ServerProperties.BUKKIT_YML, + worldName, + originalConfiguration, + installed.getLoadKey(), + effectiveSeed + ); + if (!replacement.applied()) { + throw new IOException("bukkit.yml changed while the replacement was being staged."); + } + configurationApplied = true; + transaction = transaction.withPhase(Phase.ARMED); + writeTransaction(transaction); + return new StagedReplacement( + requiredWorldKey, + worldName, + installed.getLoadKey(), + effectiveSeed, + targetPresent, + datapacks.restartRequired() + ); + } catch (Throwable failure) { + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_CLEANUP); + if (configurationApplied && transaction != null) { + try { + WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction); + if (!BukkitWorldConfiguration.restoreIfMatching( + ServerProperties.BUKKIT_YML, + worldName, + replacement, + originalConfiguration + )) { + failure.addSuppressed(new IOException( + "bukkit.yml changed before the failed replacement could be restored.")); + } + } catch (Throwable restoreFailure) { + failure.addSuppressed(restoreFailure); + } + } + if (!configurationApplied || configurationMatches(originalConfiguration, worldName)) { + try { + WorldReplacementFilesystem.discardStage(paths); + if (journalWritten) { + deleteJournal(transactionId); + } + } catch (Throwable cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + if (failure instanceof IOException ioFailure) { + throw ioFailure; + } + throw new IOException("Failed to stage replacement for " + requiredWorldKey + ".", failure); } - if (failure instanceof IOException ioFailure) { - throw ioFailure; - } - throw new IOException("Failed to stage replacement for " + requiredWorldKey + ".", failure); } } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/WorldReplacementProgress.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/WorldReplacementProgress.java new file mode 100644 index 000000000..3778ad89a --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/WorldReplacementProgress.java @@ -0,0 +1,116 @@ +package art.arcane.iris.core; + +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.localization.RuntimeProgressMessages; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.localization.LanguageAudience; +import art.arcane.volmlib.util.localization.MessageArgs; +import art.arcane.volmlib.util.localization.MessageKey; +import art.arcane.volmlib.util.localization.TextKey; +import org.bukkit.entity.Player; + +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public final class WorldReplacementProgress implements AutoCloseable { + private static final int HEARTBEAT_INTERVAL_TICKS = 200; + + private final VolmitSender sender; + private final String worldKey; + private final Player player; + private final UUID languageAudience; + private final long startedAtNanos; + private String detail; + private int taskId = -1; + private boolean firstTick = true; + private boolean closed; + + private WorldReplacementProgress(VolmitSender sender, String worldKey) { + this.sender = Objects.requireNonNull(sender, "sender"); + this.worldKey = Objects.requireNonNull(worldKey, "worldKey"); + player = sender.isPlayer() ? sender.player() : null; + languageAudience = player == null ? LanguageAudience.current() : player.getUniqueId(); + startedAtNanos = System.nanoTime(); + detail = IrisLanguage.text( + languageAudience, + RuntimeProgressMessages.WORLD_CREATE_STAGE_INITIALIZING, + MessageArgs.empty() + ); + } + + public static WorldReplacementProgress start(VolmitSender sender, String worldKey) { + WorldReplacementProgress progress = new WorldReplacementProgress(sender, worldKey); + progress.send(RuntimeProgressMessages.WORLD_REPLACE_START); + try { + progress.taskId = J.ar(progress::heartbeat, HEARTBEAT_INTERVAL_TICKS); + } catch (Throwable failure) { + IrisLogging.reportError("Could not start world replacement progress updates for " + worldKey + ".", failure); + } + return progress; + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + if (taskId >= 0) { + try { + J.car(taskId); + } catch (Throwable failure) { + IrisLogging.reportError("Could not cancel world replacement progress updates for " + worldKey + ".", failure); + } + } + } + + public synchronized void stage(TextKey stage) { + if (closed || stage == null) { + return; + } + String detail = IrisLanguage.text(languageAudience, stage, MessageArgs.empty()); + if (detail.equals(this.detail)) { + return; + } + this.detail = detail; + send(RuntimeProgressMessages.WORLD_REPLACE_PROGRESS); + } + + private synchronized void heartbeat() { + if (closed) { + return; + } + if (firstTick) { + firstTick = false; + return; + } + send(RuntimeProgressMessages.WORLD_REPLACE_PROGRESS); + } + + private void send(MessageKey key) { + try { + MessageArgs.Builder arguments = MessageArgs.builder().untrusted("world", worldKey); + if (key == RuntimeProgressMessages.WORLD_REPLACE_PROGRESS) { + arguments.untrusted("stage", detail) + .untrusted("elapsed", TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - startedAtNanos)); + } + String message = IrisLanguage.text(languageAudience, key, arguments.build()); + if (player == null) { + sender.sendMessage(message); + } else { + J.runEntity(player, () -> deliverToPlayer(message)); + } + } catch (Throwable failure) { + IrisLogging.reportError("Could not report world replacement progress for " + worldKey + ".", failure); + } + } + + private synchronized void deliverToPlayer(String message) { + if (!closed) { + sender.sendMessage(message); + } + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java index 4d60a7f55..295bbf3e7 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java @@ -257,6 +257,8 @@ public class CommandIris implements DirectorExecutor { sender().sendMessage(C.GREEN + "Staged Iris replacement for " + staged.worldKey() + seedDetail + ". Restart once to publish it. The current dimension is retained until Iris " + "verifies the replacement."); + } catch (LifecycleOperationCoordinator.BusyException busy) { + sender().sendMessage(C.YELLOW + busy.getMessage()); } catch (Throwable failure) { Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure); String detail = failure.getMessage() == null || failure.getMessage().isBlank() diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementAdmissionTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementAdmissionTest.java new file mode 100644 index 000000000..fc0a662c0 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementAdmissionTest.java @@ -0,0 +1,45 @@ +package art.arcane.iris.core; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator.BusyException; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator.Domain; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator.Lease; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator.OperationKind; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.util.common.plugin.VolmitSender; +import org.bukkit.NamespacedKey; +import org.junit.Test; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; + +public class PendingWorldReplacementAdmissionTest { + @Test + public void busyReplacementRejectsBeforeWaitingForTheManagerMonitor() throws Exception { + PendingWorldReplacementManager manager = new PendingWorldReplacementManager(mock(Iris.class)); + VolmitSender sender = mock(VolmitSender.class); + IrisDimension dimension = mock(IrisDimension.class); + NamespacedKey worldKey = NamespacedKey.minecraft("overworld"); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try (Lease lease = LifecycleOperationCoordinator.get().acquire( + Domain.WORLD_MUTATION, OperationKind.WORLD_REPLACE, worldKey.toString())) { + synchronized (manager) { + Future rejected = executor.submit(() -> assertThrows( + BusyException.class, + () -> manager.stageReplacement(sender, worldKey, dimension, null) + )); + assertEquals(lease.operation(), rejected.get(2L, TimeUnit.SECONDS).currentOperation()); + } + } finally { + executor.shutdownNow(); + executor.awaitTermination(5L, TimeUnit.SECONDS); + } + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/WorldReplacementProgressTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/WorldReplacementProgressTest.java new file mode 100644 index 000000000..10f67f98d --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/WorldReplacementProgressTest.java @@ -0,0 +1,147 @@ +package art.arcane.iris.core; + +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.localization.RuntimeProgressMessages; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.localization.MessageArgs; +import art.arcane.volmlib.util.localization.MessageKey; +import org.bukkit.entity.Player; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +public class WorldReplacementProgressTest { + @Test + public void reportsStartStageAndHeartbeatUntilClosed() { + VolmitSender sender = mock(VolmitSender.class); + List messages = new ArrayList<>(); + doAnswer(invocation -> messages.add(invocation.getArgument(0))).when(sender).sendMessage(anyString()); + AtomicReference heartbeat = new AtomicReference<>(); + try (MockedStatic scheduler = mockStatic(J.class)) { + scheduler.when(() -> J.ar(any(Runnable.class), eq(200))).thenAnswer(invocation -> { + heartbeat.set(invocation.getArgument(0)); + return 42; + }); + + WorldReplacementProgress progress = WorldReplacementProgress.start(sender, "minecraft:overworld"); + assertEquals(1, messages.size()); + assertTrue(messages.getFirst().contains("Preparing Iris replacement for minecraft:overworld")); + + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK); + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK); + assertEquals(2, messages.size()); + assertTrue(messages.getLast().contains("Copying and validating the dimension pack")); + assertTrue(messages.getLast().matches(".*\\([0-9]+s elapsed\\).*")); + + heartbeat.get().run(); + assertEquals(2, messages.size()); + heartbeat.get().run(); + assertEquals(3, messages.size()); + assertTrue(messages.getLast().contains("Copying and validating the dimension pack")); + + progress.close(); + progress.close(); + heartbeat.get().run(); + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_SAVE); + assertEquals(3, messages.size()); + scheduler.verify(() -> J.car(42), times(1)); + } + } + + @Test + public void schedulingFailureDoesNotPreventPhaseReports() { + VolmitSender sender = mock(VolmitSender.class); + List messages = new ArrayList<>(); + doAnswer(invocation -> messages.add(invocation.getArgument(0))).when(sender).sendMessage(anyString()); + IllegalStateException failure = new IllegalStateException("Scheduler stopped"); + try (MockedStatic scheduler = mockStatic(J.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + scheduler.when(() -> J.ar(any(Runnable.class), eq(200))).thenThrow(failure); + try (WorldReplacementProgress progress = WorldReplacementProgress.start(sender, "minecraft:overworld")) { + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK); + } + + assertEquals(2, messages.size()); + logging.verify(() -> IrisLogging.reportError(anyString(), eq(failure))); + scheduler.verify(() -> J.car(anyInt()), times(0)); + } + } + + @Test + public void unavailableTimerLeavesPhaseReportsUsable() { + VolmitSender sender = mock(VolmitSender.class); + List messages = new ArrayList<>(); + doAnswer(invocation -> messages.add(invocation.getArgument(0))).when(sender).sendMessage(anyString()); + try (MockedStatic scheduler = mockStatic(J.class)) { + scheduler.when(() -> J.ar(any(Runnable.class), eq(200))).thenReturn(-1); + try (WorldReplacementProgress progress = WorldReplacementProgress.start(sender, "minecraft:overworld")) { + progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK); + } + + assertEquals(2, messages.size()); + scheduler.verify(() -> J.car(anyInt()), times(0)); + } + } + + @Test + public void playerReportsUseEntitySchedulingAndRetainAudience() { + VolmitSender sender = mock(VolmitSender.class); + Player player = mock(Player.class); + UUID playerId = UUID.randomUUID(); + when(sender.isPlayer()).thenReturn(true); + when(sender.player()).thenReturn(player); + when(player.getUniqueId()).thenReturn(playerId); + List messages = new ArrayList<>(); + List deliveries = new ArrayList<>(); + List audiences = new ArrayList<>(); + AtomicReference heartbeat = new AtomicReference<>(); + doAnswer(invocation -> messages.add(invocation.getArgument(0))).when(sender).sendMessage(anyString()); + try (MockedStatic scheduler = mockStatic(J.class); + MockedStatic language = mockStatic(IrisLanguage.class)) { + language.when(() -> IrisLanguage.text(any(UUID.class), any(MessageKey.class), any(MessageArgs.class))) + .thenAnswer(invocation -> { + audiences.add(invocation.getArgument(0)); + return "Replacement progress"; + }); + scheduler.when(() -> J.ar(any(Runnable.class), eq(200))).thenAnswer(invocation -> { + heartbeat.set(invocation.getArgument(0)); + return 42; + }); + scheduler.when(() -> J.runEntity(eq(player), any(Runnable.class))).thenAnswer(invocation -> { + deliveries.add(invocation.getArgument(1)); + return true; + }); + + WorldReplacementProgress progress = WorldReplacementProgress.start(sender, "minecraft:overworld"); + assertTrue(messages.isEmpty()); + deliveries.getFirst().run(); + assertEquals(1, messages.size()); + heartbeat.get().run(); + heartbeat.get().run(); + assertEquals(2, deliveries.size()); + assertEquals(List.of(playerId, playerId, playerId), audiences); + + progress.close(); + deliveries.getLast().run(); + assertEquals(1, messages.size()); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/localization/RuntimeProgressMessages.java b/core/src/main/java/art/arcane/iris/core/localization/RuntimeProgressMessages.java index 57864139f..029be4f0e 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/RuntimeProgressMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/RuntimeProgressMessages.java @@ -47,6 +47,15 @@ public final class RuntimeProgressMessages { public static final TextKey WORLD_CREATE_STAGE_PREGENERATE = TextKey.of("iris.runtime.world_create.stage.pregenerate", "Pregenerating"); public static final TextKey WORLD_CREATE_STAGE_FINALIZE = TextKey.of("iris.runtime.world_create.stage.finalize", "Finalizing"); public static final TextKey WORLD_CREATE_STAGE_COMPLETE = TextKey.of("iris.runtime.world_create.stage.complete", "World ready"); + public static final TextKey WORLD_REPLACE_START = TextKey.of("iris.runtime.world_replace.start", C.YELLOW + "Preparing Iris replacement for {world}. The current world remains active until restart."); + public static final TextKey WORLD_REPLACE_PROGRESS = TextKey.of("iris.runtime.world_replace.progress", C.YELLOW + "Replacement for {world}: {stage} ({elapsed}s elapsed)"); + public static final TextKey WORLD_REPLACE_STAGE_DATAPACKS = TextKey.of("iris.runtime.world_replace.stage.datapacks", "Checking dimension datapacks"); + public static final TextKey WORLD_REPLACE_STAGE_SEED = TextKey.of("iris.runtime.world_replace.stage.seed", "Preparing the replacement seed"); + public static final TextKey WORLD_REPLACE_STAGE_PACK = TextKey.of("iris.runtime.world_replace.stage.pack", "Copying and validating the dimension pack"); + public static final TextKey WORLD_REPLACE_STAGE_PLAYERS = TextKey.of("iris.runtime.world_replace.stage.players", "Recording player entry protection"); + public static final TextKey WORLD_REPLACE_STAGE_VERIFY = TextKey.of("iris.runtime.world_replace.stage.verify", "Verifying the staged replacement"); + public static final TextKey WORLD_REPLACE_STAGE_SAVE = TextKey.of("iris.runtime.world_replace.stage.save", "Saving the replacement for restart"); + public static final TextKey WORLD_REPLACE_STAGE_CLEANUP = TextKey.of("iris.runtime.world_replace.stage.cleanup", "Cleaning up the failed replacement"); public static final TextKey WORLD_PREGEN_ACTION = TextKey.of("iris.runtime.world_create.pregen.action", "{bar}" + C.GRAY + " " + C.YELLOW + "{percent}%" + C.GRAY + " | " + C.WHITE + "Pregenerating"); public static final TextKey WORLD_PREGEN_CONSOLE = TextKey.of("iris.runtime.world_create.pregen.console", C.GOLD + "Pregenerating " + C.YELLOW + "{percent}%"); public static final TextKey CHUNK_TITLE_REGEN = TextKey.of("iris.runtime.chunk_job.title.regen", "Regen"); @@ -140,6 +149,15 @@ public final class RuntimeProgressMessages { WORLD_CREATE_STAGE_PREGENERATE, WORLD_CREATE_STAGE_FINALIZE, WORLD_CREATE_STAGE_COMPLETE, + WORLD_REPLACE_START, + WORLD_REPLACE_PROGRESS, + WORLD_REPLACE_STAGE_DATAPACKS, + WORLD_REPLACE_STAGE_SEED, + WORLD_REPLACE_STAGE_PACK, + WORLD_REPLACE_STAGE_PLAYERS, + WORLD_REPLACE_STAGE_VERIFY, + WORLD_REPLACE_STAGE_SAVE, + WORLD_REPLACE_STAGE_CLEANUP, WORLD_PREGEN_ACTION, WORLD_PREGEN_CONSOLE, CHUNK_TITLE_REGEN, diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java index e83523761..5e49721c0 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationPackRepository.java @@ -15,6 +15,8 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.regex.Pattern; @@ -220,6 +222,7 @@ public final class GenerationPackRepository { + normalizedSource + " and " + normalizedTarget); } + List copiedFiles = new ArrayList<>(); Files.walkFileTree(normalizedSource, new SimpleFileVisitor<>() { @Override public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) throws IOException { @@ -250,11 +253,8 @@ public final class GenerationPackRepository { return FileVisitResult.CONTINUE; } Path destination = copyDestination(normalizedSource, normalizedTarget, file); - Files.createDirectories(Objects.requireNonNull(destination.getParent(), "Pack entry parent")); Files.copy(file, destination, StandardCopyOption.COPY_ATTRIBUTES); - try (FileChannel channel = FileChannel.open(destination, StandardOpenOption.WRITE)) { - channel.force(true); - } + copiedFiles.add(destination); return FileVisitResult.CONTINUE; } @@ -263,6 +263,11 @@ public final class GenerationPackRepository { throw new IOException("Unable to copy pack entry: " + file, failure); } }); + for (Path copiedFile : copiedFiles) { + try (FileChannel channel = FileChannel.open(copiedFile, StandardOpenOption.WRITE)) { + channel.force(true); + } + } } private static Path copyDestination(Path source, Path target, Path entry) throws IOException { diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index 1c2383d76..3ca1aacdd 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eDie Iris-Ersetzung für {world} wird vorbereitet. Die aktuelle Welt bleibt bis zum Neustart aktiv.", + "iris.runtime.world_replace.progress": "§eErsetzung für {world}: {stage} ({elapsed}s vergangen)", + "iris.runtime.world_replace.stage.datapacks": "Datapacks der Dimension werden geprüft", + "iris.runtime.world_replace.stage.seed": "Seed für die Ersetzung wird vorbereitet", + "iris.runtime.world_replace.stage.pack": "Dimensionspaket wird kopiert und geprüft", + "iris.runtime.world_replace.stage.players": "Schutz beim Welteintritt der Spieler wird gespeichert", + "iris.runtime.world_replace.stage.verify": "Vorbereitete Ersetzung wird geprüft", + "iris.runtime.world_replace.stage.save": "Ersetzung wird für den Neustart gespeichert", + "iris.runtime.world_replace.stage.cleanup": "Fehlgeschlagene Ersetzung wird bereinigt", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fVorgenerierung", "iris.runtime.world_create.pregen.console": "§6Vorgenerierung §e{percent}%", "iris.runtime.chunk_job.title.regen": "Regenerieren", diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index 0d0a72523..3cb6ad977 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§ePreparando el reemplazo de {world} con Iris. El mundo actual permanece activo hasta el reinicio.", + "iris.runtime.world_replace.progress": "§eReemplazo de {world}: {stage} ({elapsed}s transcurridos)", + "iris.runtime.world_replace.stage.datapacks": "Comprobando los paquetes de datos de la dimensión", + "iris.runtime.world_replace.stage.seed": "Preparando la semilla del reemplazo", + "iris.runtime.world_replace.stage.pack": "Copiando y validando el pack de la dimensión", + "iris.runtime.world_replace.stage.players": "Guardando la protección de entrada de los jugadores", + "iris.runtime.world_replace.stage.verify": "Verificando el reemplazo preparado", + "iris.runtime.world_replace.stage.save": "Guardando el reemplazo para el reinicio", + "iris.runtime.world_replace.stage.cleanup": "Limpiando el reemplazo fallido", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPregenerando", "iris.runtime.world_create.pregen.console": "§6Pregenerando §e{percent}%", "iris.runtime.chunk_job.title.regen": "Regenerar", diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index c0a6afc25..e251146d8 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eIris valmistelee maailman {world} korvaamista. Nykyinen maailma pysyy aktiivisena uudelleenkäynnistykseen asti.", + "iris.runtime.world_replace.progress": "§eMaailman {world} korvaaminen: {stage} ({elapsed}s kulunut)", + "iris.runtime.world_replace.stage.datapacks": "Tarkistetaan ulottuvuuden datapaketteja", + "iris.runtime.world_replace.stage.seed": "Valmistellaan korvaavan maailman siementä", + "iris.runtime.world_replace.stage.pack": "Kopioidaan ja tarkistetaan ulottuvuuspakettia", + "iris.runtime.world_replace.stage.players": "Tallennetaan pelaajien turvallisen maailmaan saapumisen suojaus", + "iris.runtime.world_replace.stage.verify": "Tarkistetaan valmisteltua korvaavaa maailmaa", + "iris.runtime.world_replace.stage.save": "Tallennetaan korvaava maailma uudelleenkäynnistystä varten", + "iris.runtime.world_replace.stage.cleanup": "Siivotaan epäonnistunutta korvausyritystä", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fEsituotanto", "iris.runtime.world_create.pregen.console": "§6Esituotanto §e{percent}%", "iris.runtime.chunk_job.title.regen": "Regin", diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index 308c86bd5..7351df21e 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§ePréparation du remplacement de {world} par Iris. Le monde actuel reste actif jusqu’au redémarrage.", + "iris.runtime.world_replace.progress": "§eRemplacement de {world} : {stage} ({elapsed}s écoulées)", + "iris.runtime.world_replace.stage.datapacks": "Vérification des packs de données de la dimension", + "iris.runtime.world_replace.stage.seed": "Préparation de la graine du remplacement", + "iris.runtime.world_replace.stage.pack": "Copie et validation du pack de la dimension", + "iris.runtime.world_replace.stage.players": "Enregistrement de la protection à l’entrée des joueurs", + "iris.runtime.world_replace.stage.verify": "Vérification du remplacement préparé", + "iris.runtime.world_replace.stage.save": "Enregistrement du remplacement pour le redémarrage", + "iris.runtime.world_replace.stage.cleanup": "Nettoyage du remplacement ayant échoué", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPrégénération", "iris.runtime.world_create.pregen.console": "§6Prégénération §e{percent}%", "iris.runtime.chunk_job.title.regen": "Régénérer", diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index 18847253a..025a42d6e 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eמכין החלפה באמצעות Iris עבור {world}. העולם הנוכחי נשאר פעיל עד להפעלה מחדש.", + "iris.runtime.world_replace.progress": "§eהחלפה עבור {world}: {stage} ({elapsed} שניות חלפו)", + "iris.runtime.world_replace.stage.datapacks": "בודק את חבילות הנתונים של הממד", + "iris.runtime.world_replace.stage.seed": "מכין את זרע העולם החלופי", + "iris.runtime.world_replace.stage.pack": "מעתיק ומאמת את חבילת הממד", + "iris.runtime.world_replace.stage.players": "שומר את ההגנה על כניסת השחקנים", + "iris.runtime.world_replace.stage.verify": "מאמת את ההחלפה שהוכנה", + "iris.runtime.world_replace.stage.save": "שומר את ההחלפה להפעלה מחדש", + "iris.runtime.world_replace.stage.cleanup": "מנקה את ההחלפה שנכשלה", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fהפקה מוקדמת", "iris.runtime.world_create.pregen.console": "§6הפקה מוקדמת §e{percent}%", "iris.runtime.chunk_job.title.regen": "מחדש", diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index 4fc6bb62b..d89dd31ec 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§ePreparazione della sostituzione di {world} con Iris. Il mondo attuale rimane attivo fino al riavvio.", + "iris.runtime.world_replace.progress": "§eSostituzione di {world}: {stage} ({elapsed}s trascorsi)", + "iris.runtime.world_replace.stage.datapacks": "Verifica dei pacchetti di dati della dimensione", + "iris.runtime.world_replace.stage.seed": "Preparazione del seme del mondo sostitutivo", + "iris.runtime.world_replace.stage.pack": "Copia e convalida del pacchetto della dimensione", + "iris.runtime.world_replace.stage.players": "Salvataggio della protezione all’ingresso dei giocatori", + "iris.runtime.world_replace.stage.verify": "Verifica della sostituzione preparata", + "iris.runtime.world_replace.stage.save": "Salvataggio della sostituzione per il riavvio", + "iris.runtime.world_replace.stage.cleanup": "Pulizia della sostituzione non riuscita", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPregenerazione", "iris.runtime.world_create.pregen.console": "§6Pregenerazione §e{percent}%", "iris.runtime.chunk_job.title.regen": "Rigenera", diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index cddcef348..7517c1e98 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§e{world} を Iris で置き換える準備をしています。現在のワールドは再起動まで稼働し続けます。", + "iris.runtime.world_replace.progress": "§e{world} の置き換え: {stage} ({elapsed}秒経過)", + "iris.runtime.world_replace.stage.datapacks": "ディメンションのデータパックを確認しています", + "iris.runtime.world_replace.stage.seed": "置き換え先のシード値を準備しています", + "iris.runtime.world_replace.stage.pack": "ディメンションパックをコピーして検証しています", + "iris.runtime.world_replace.stage.players": "プレイヤーのワールド入場時の保護を記録しています", + "iris.runtime.world_replace.stage.verify": "準備した置き換え内容を検証しています", + "iris.runtime.world_replace.stage.save": "再起動時の置き換え内容を保存しています", + "iris.runtime.world_replace.stage.cleanup": "失敗した置き換えのデータを整理しています", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §f事前生成中", "iris.runtime.world_create.pregen.console": "§6事前生成中 §e{percent}%", "iris.runtime.chunk_job.title.regen": "再生成", diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index b0b74c46f..9cdf2c228 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§e{world}를 Iris로 교체할 준비를 하고 있습니다. 현재 월드는 재시작 전까지 계속 활성화됩니다.", + "iris.runtime.world_replace.progress": "§e{world} 교체: {stage} ({elapsed}초 경과)", + "iris.runtime.world_replace.stage.datapacks": "차원 데이터 팩 확인 중", + "iris.runtime.world_replace.stage.seed": "교체할 월드의 시드 준비 중", + "iris.runtime.world_replace.stage.pack": "차원 팩 복사 및 검증 중", + "iris.runtime.world_replace.stage.players": "플레이어의 월드 진입 보호 기록 중", + "iris.runtime.world_replace.stage.verify": "준비된 교체 내용 검증 중", + "iris.runtime.world_replace.stage.save": "재시작 시 적용할 교체 내용 저장 중", + "iris.runtime.world_replace.stage.cleanup": "실패한 교체 작업 정리 중", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §f연구분야", "iris.runtime.world_create.pregen.console": "§6연구분야 §e{percent}%", "iris.runtime.chunk_job.title.regen": "재생성", diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index d5d45cf45..b220c88a2 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eRuošiamas {world} pakeitimas naudojant Iris. Dabartinis pasaulis liks aktyvus iki paleidimo iš naujo.", + "iris.runtime.world_replace.progress": "§e{world} pakeitimas: {stage} (praėjo {elapsed}s)", + "iris.runtime.world_replace.stage.datapacks": "Tikrinami dimensijos duomenų paketai", + "iris.runtime.world_replace.stage.seed": "Ruošiama pakaitinio pasaulio sėkla", + "iris.runtime.world_replace.stage.pack": "Kopijuojamas ir tikrinamas dimensijos paketas", + "iris.runtime.world_replace.stage.players": "Įrašoma žaidėjų įėjimo apsauga", + "iris.runtime.world_replace.stage.verify": "Tikrinamas paruoštas pakeitimas", + "iris.runtime.world_replace.stage.save": "Pakeitimas išsaugomas paleidimui iš naujo", + "iris.runtime.world_replace.stage.cleanup": "Valomi nepavykusio pakeitimo duomenys", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fRegeneravimas", "iris.runtime.world_create.pregen.console": "§6Regeneravimas §e{percent}%", "iris.runtime.chunk_job.title.regen": "Režen", diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index 7eaf92508..3d9a4b356 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eDe vervanging van {world} met Iris wordt voorbereid. De huidige wereld blijft actief tot de herstart.", + "iris.runtime.world_replace.progress": "§eVervanging van {world}: {stage} ({elapsed}s verstreken)", + "iris.runtime.world_replace.stage.datapacks": "Datapacks van de dimensie controleren", + "iris.runtime.world_replace.stage.seed": "Seed voor de vervangende wereld voorbereiden", + "iris.runtime.world_replace.stage.pack": "Dimensiepakket kopiëren en valideren", + "iris.runtime.world_replace.stage.players": "Bescherming bij het betreden van de wereld vastleggen", + "iris.runtime.world_replace.stage.verify": "Voorbereide vervanging controleren", + "iris.runtime.world_replace.stage.save": "Vervanging opslaan voor de herstart", + "iris.runtime.world_replace.stage.cleanup": "Mislukte vervanging opruimen", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPregeneren", "iris.runtime.world_create.pregen.console": "§6Pregeneren §e{percent}%", "iris.runtime.chunk_job.title.regen": "RegenName", diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index 4cc3c2623..d9a905191 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§ePrzygotowywanie zastąpienia {world} przez Iris. Obecny świat pozostaje aktywny do ponownego uruchomienia.", + "iris.runtime.world_replace.progress": "§eZastąpienie {world}: {stage} (upłynęło {elapsed}s)", + "iris.runtime.world_replace.stage.datapacks": "Sprawdzanie pakietów danych wymiaru", + "iris.runtime.world_replace.stage.seed": "Przygotowywanie ziarna nowego świata", + "iris.runtime.world_replace.stage.pack": "Kopiowanie i sprawdzanie pakietu wymiaru", + "iris.runtime.world_replace.stage.players": "Zapisywanie ochrony przy wejściu graczy do świata", + "iris.runtime.world_replace.stage.verify": "Sprawdzanie przygotowanego zastąpienia", + "iris.runtime.world_replace.stage.save": "Zapisywanie zastąpienia do zastosowania po ponownym uruchomieniu", + "iris.runtime.world_replace.stage.cleanup": "Usuwanie pozostałości nieudanego zastąpienia", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPregeneracja", "iris.runtime.world_create.pregen.console": "§6Pregeneracja §e{percent}%", "iris.runtime.chunk_job.title.regen": "Regen.", diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index f9640cf95..af64a5de8 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eA preparar a substituição de {world} pelo Iris. O mundo atual permanece ativo até ao reinício.", + "iris.runtime.world_replace.progress": "§eSubstituição de {world}: {stage} ({elapsed}s decorridos)", + "iris.runtime.world_replace.stage.datapacks": "A verificar os pacotes de dados da dimensão", + "iris.runtime.world_replace.stage.seed": "A preparar a semente do mundo de substituição", + "iris.runtime.world_replace.stage.pack": "A copiar e validar o pacote da dimensão", + "iris.runtime.world_replace.stage.players": "A guardar a proteção de entrada dos jogadores", + "iris.runtime.world_replace.stage.verify": "A verificar a substituição preparada", + "iris.runtime.world_replace.stage.save": "A guardar a substituição para o reinício", + "iris.runtime.world_replace.stage.cleanup": "A limpar a substituição falhada", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPregeneração", "iris.runtime.world_create.pregen.console": "§6Pregeneração §e{percent}%", "iris.runtime.chunk_job.title.regen": "Regen.", diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index 9507d83d5..958d8042f 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eПодготовка замены {world} с помощью Iris. Текущий мир останется активным до перезапуска.", + "iris.runtime.world_replace.progress": "§eЗамена {world}: {stage} (прошло {elapsed}с)", + "iris.runtime.world_replace.stage.datapacks": "Проверка пакетов данных измерения", + "iris.runtime.world_replace.stage.seed": "Подготовка сида заменяющего мира", + "iris.runtime.world_replace.stage.pack": "Копирование и проверка пакета измерения", + "iris.runtime.world_replace.stage.players": "Сохранение защиты при входе игроков в мир", + "iris.runtime.world_replace.stage.verify": "Проверка подготовленной замены", + "iris.runtime.world_replace.stage.save": "Сохранение замены для перезапуска", + "iris.runtime.world_replace.stage.cleanup": "Очистка данных неудавшейся замены", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fгенерирующий", "iris.runtime.world_create.pregen.console": "§6генерирующий §e{percent}%", "iris.runtime.chunk_job.title.regen": "Реген", diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index c7924e3f8..e45953a8a 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§e{world} için Iris ile değiştirme hazırlanıyor. Mevcut dünya yeniden başlatılana kadar etkin kalır.", + "iris.runtime.world_replace.progress": "§e{world} değişimi: {stage} ({elapsed} saniye geçti)", + "iris.runtime.world_replace.stage.datapacks": "Boyut veri paketleri kontrol ediliyor", + "iris.runtime.world_replace.stage.seed": "Yeni dünyanın tohumu hazırlanıyor", + "iris.runtime.world_replace.stage.pack": "Boyut paketi kopyalanıp doğrulanıyor", + "iris.runtime.world_replace.stage.players": "Oyuncuların dünyaya giriş koruması kaydediliyor", + "iris.runtime.world_replace.stage.verify": "Hazırlanan değişim doğrulanıyor", + "iris.runtime.world_replace.stage.save": "Değişim yeniden başlatma için kaydediliyor", + "iris.runtime.world_replace.stage.cleanup": "Başarısız değişim temizleniyor", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fPregeneating", "iris.runtime.world_create.pregen.console": "§6Pregeneating §e{percent}%", "iris.runtime.chunk_job.title.regen": "Rejen", diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index 2f0928daa..d2b79adf3 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§eĐang chuẩn bị thay thế {world} bằng Iris. Thế giới hiện tại vẫn hoạt động cho đến khi máy chủ khởi động lại.", + "iris.runtime.world_replace.progress": "§eThay thế {world}: {stage} (đã qua {elapsed} giây)", + "iris.runtime.world_replace.stage.datapacks": "Đang kiểm tra các gói dữ liệu của chiều không gian", + "iris.runtime.world_replace.stage.seed": "Đang chuẩn bị hạt giống của thế giới thay thế", + "iris.runtime.world_replace.stage.pack": "Đang sao chép và xác thực gói chiều không gian", + "iris.runtime.world_replace.stage.players": "Đang lưu biện pháp bảo vệ khi người chơi vào thế giới", + "iris.runtime.world_replace.stage.verify": "Đang xác minh bản thay thế đã chuẩn bị", + "iris.runtime.world_replace.stage.save": "Đang lưu bản thay thế để áp dụng khi khởi động lại", + "iris.runtime.world_replace.stage.cleanup": "Đang dọn dẹp bản thay thế bị lỗi", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §fTạo ra", "iris.runtime.world_create.pregen.console": "§6Tạo ra §e{percent}%", "iris.runtime.chunk_job.title.regen": "Bản sao", diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index 8ce7aa884..42e6230b7 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§e正在准备使用 Iris 替换 {world}。当前世界将在重启前保持运行。", + "iris.runtime.world_replace.progress": "§e替换 {world}:{stage}(已用时 {elapsed} 秒)", + "iris.runtime.world_replace.stage.datapacks": "正在检查维度数据包", + "iris.runtime.world_replace.stage.seed": "正在准备替换世界的种子", + "iris.runtime.world_replace.stage.pack": "正在复制并验证维度包", + "iris.runtime.world_replace.stage.players": "正在记录玩家进入世界时的保护信息", + "iris.runtime.world_replace.stage.verify": "正在验证已准备的替换内容", + "iris.runtime.world_replace.stage.save": "正在保存替换内容以供重启时应用", + "iris.runtime.world_replace.stage.cleanup": "正在清理失败的替换内容", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §f预生成", "iris.runtime.world_create.pregen.console": "§6预生成 §e{percent}%", "iris.runtime.chunk_job.title.regen": "瑞根,你好吗?", diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index b5fcea44b..9513a8cf3 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -1209,6 +1209,15 @@ "iris.runtime.world_create.stage.pregenerate": "Pregenerating", "iris.runtime.world_create.stage.finalize": "Finalizing", "iris.runtime.world_create.stage.complete": "World ready", + "iris.runtime.world_replace.start": "§e正在準備使用 Iris 替換 {world}。目前世界將在重新啟動前持續運作。", + "iris.runtime.world_replace.progress": "§e替換 {world}:{stage}(已耗時 {elapsed} 秒)", + "iris.runtime.world_replace.stage.datapacks": "正在檢查維度資料包", + "iris.runtime.world_replace.stage.seed": "正在準備替換世界的種子", + "iris.runtime.world_replace.stage.pack": "正在複製並驗證維度包", + "iris.runtime.world_replace.stage.players": "正在記錄玩家進入世界時的保護資訊", + "iris.runtime.world_replace.stage.verify": "正在驗證已準備的替換內容", + "iris.runtime.world_replace.stage.save": "正在儲存替換內容以供重新啟動時套用", + "iris.runtime.world_replace.stage.cleanup": "正在清理失敗的替換內容", "iris.runtime.world_create.pregen.action": "{bar}§7 §e{percent}%§7 | §f預生成", "iris.runtime.world_create.pregen.console": "§6預生成 §e{percent}%", "iris.runtime.chunk_job.title.regen": "瑞根,你好嗎?", diff --git a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageEditorTest.java b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageEditorTest.java index 72d65438d..ba2314ac6 100644 --- a/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageEditorTest.java +++ b/core/src/test/java/art/arcane/iris/core/localization/IrisLanguageEditorTest.java @@ -5,8 +5,11 @@ import art.arcane.volmlib.util.localization.LocalizationSnapshot; import art.arcane.volmlib.util.localization.PluginLanguageEditor; import art.arcane.volmlib.util.localization.PluginLanguageService; import art.arcane.volmlib.util.localization.PluralSelector; +import art.arcane.volmlib.util.localization.RemoteLanguageCatalog; import art.arcane.volmlib.util.localization.TextValue; import art.arcane.volmlib.util.localization.VolmitLocales; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; import org.junit.After; import org.junit.Before; import org.junit.Rule; @@ -14,11 +17,16 @@ import org.junit.Test; import org.junit.rules.TemporaryFolder; import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.InetSocketAddress; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Logger; import static org.junit.Assert.assertArrayEquals; @@ -98,13 +106,51 @@ public class IrisLanguageEditorTest { @Test public void incompleteDownloadedLocaleCanBeEditedWithoutBeingSelected() throws Exception { - Files.writeString(IrisLanguage.remote(directory).cacheFile("fr_FR"), "{\"locale\":\"fr_FR\",\"messages\":{}}"); - PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS); - TextValue value = new TextValue("Langue {locale}"); - PluginLanguageEditor.Document saved = editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(), - original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), value)).get(5, TimeUnit.SECONDS); - assertEquals(value, saved.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS)); - assertEquals("en_US", languages.defaultLocale()); - assertEquals("en_US", IrisLanguage.activeLocale()); + byte[] complete = Files.readAllBytes(Path.of("src/main/resources/languages/fr_FR.json")); + AtomicInteger requests = new AtomicInteger(); + Field remoteField = IrisLanguage.class.getDeclaredField("remote"); + remoteField.setAccessible(true); + RemoteLanguageCatalog previous = IrisLanguage.remote(directory); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + try (RemoteLanguageCatalog catalog = RemoteLanguageCatalog.load(new RemoteLanguageCatalog.Options( + "Iris", + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/"), + "languages", + ".json", + "iris-language-source.properties", + directory.toPath().resolve("languages/downloaded"), + IrisLanguage.class.getClassLoader() + ))) { + server.createContext(catalog.sourceUri("fr_FR").getPath(), + exchange -> respond(exchange, complete, requests)); + server.start(); + remoteField.set(null, catalog); + Path cache = catalog.cacheFile("fr_FR"); + Files.writeString(cache, "{\"locale\":\"fr_FR\",\"messages\":{}}"); + + PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS); + TextValue value = new TextValue("Langue {locale}"); + PluginLanguageEditor.Document saved = editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(), + original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), value)).get(5, TimeUnit.SECONDS); + + assertEquals(value, saved.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS)); + assertEquals("en_US", languages.defaultLocale()); + assertEquals("en_US", IrisLanguage.activeLocale()); + assertArrayEquals(complete, Files.readAllBytes(cache)); + assertEquals(1, requests.get()); + } finally { + remoteField.set(null, previous); + server.stop(0); + } + } + + private static void respond(HttpExchange exchange, byte[] response, AtomicInteger requests) throws IOException { + requests.incrementAndGet(); + try { + exchange.sendResponseHeaders(200, response.length); + exchange.getResponseBody().write(response); + } finally { + exchange.close(); + } } }