From 369c864e71822d820dc4a7b59366efd85829403a Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 15 Oct 2026 12:53:58 -0400 Subject: [PATCH] Created modded worlds off-thread - /iris create, /iris world create, /iris world enable and /iris world replace-overworld acknowledge at once, build the world in the background, and report success or the existing failure messages to the sender when it finishes - Pack validation, the pack dimension check, generation history creation, pack data loading and the engine build run off the server thread; the server thread only registers the world, creates the level and publishes it - A second create of a world that is still being created, and delete, disable or update of that world, are refused until it is live - Server shutdown cancels in-flight creations, waits for an engine that is still building, and rolls back its level and registry entry - Startup restore and world creation share one dimension injection orchestrator, and abandoning an injection also closes an engine that was already built - Added the creating-in-background message to every locale file --- .../modded/MissingPackDimensionException.java | 38 ++ .../iris/modded/ModdedDimensionCreations.java | 148 ++++++ .../iris/modded/ModdedDimensionInjection.java | 434 ++++++++++++++++++ .../iris/modded/ModdedDimensionManager.java | 134 +++--- .../iris/modded/ModdedDimensionRestore.java | 233 ---------- .../iris/modded/ModdedEngineBootstrap.java | 1 + .../ModdedGenerationHistoryStorage.java | 44 +- .../art/arcane/iris/modded/ModdedStartup.java | 33 +- .../iris/modded/ModdedWorldEngines.java | 21 +- .../modded/command/ModdedWorldCommands.java | 128 ++++-- .../localization/ModdedCommandMessages.java | 5 + ...java => ModdedDimensionInjectionTest.java} | 185 +++++++- .../ModdedStartupReinjectFailureTest.java | 6 +- .../command/ModdedWorldCreateCommandTest.java | 154 +++++++ core/src/main/resources/languages/de_DE.toml | 1 + core/src/main/resources/languages/es_ES.toml | 1 + core/src/main/resources/languages/fi_FI.toml | 1 + core/src/main/resources/languages/fr_FR.toml | 1 + core/src/main/resources/languages/he_IL.toml | 1 + core/src/main/resources/languages/it_IT.toml | 1 + core/src/main/resources/languages/ja-JP.toml | 1 + core/src/main/resources/languages/ko_KR.toml | 1 + core/src/main/resources/languages/lt_LT.toml | 1 + core/src/main/resources/languages/nl_NL.toml | 1 + core/src/main/resources/languages/pl_PL.toml | 1 + core/src/main/resources/languages/pt_PT.toml | 1 + core/src/main/resources/languages/ru_RU.toml | 1 + core/src/main/resources/languages/tr_TR.toml | 1 + core/src/main/resources/languages/vi_VI.toml | 1 + core/src/main/resources/languages/zh_CN.toml | 1 + core/src/main/resources/languages/zh_TW.toml | 1 + 31 files changed, 1203 insertions(+), 378 deletions(-) create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/MissingPackDimensionException.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionCreations.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionInjection.java delete mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRestore.java rename adapters/modded-common/src/test/java/art/arcane/iris/modded/{ModdedDimensionRestoreTest.java => ModdedDimensionInjectionTest.java} (54%) create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedWorldCreateCommandTest.java diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/MissingPackDimensionException.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/MissingPackDimensionException.java new file mode 100644 index 000000000..ccb89d699 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/MissingPackDimensionException.java @@ -0,0 +1,38 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded; + +public final class MissingPackDimensionException extends IllegalStateException { + private final String pack; + private final String dimensionKey; + + public MissingPackDimensionException(String pack, String dimensionKey) { + super("Pack '" + pack + "' does not contain dimensions/" + dimensionKey + ".json"); + this.pack = pack; + this.dimensionKey = dimensionKey; + } + + public String pack() { + return pack; + } + + public String dimensionKey() { + return dimensionKey; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionCreations.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionCreations.java new file mode 100644 index 000000000..8e0b3bd7a --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionCreations.java @@ -0,0 +1,148 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.modded.command.ModdedPackCommands; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +public final class ModdedDimensionCreations { + private static final Map> ACTIVE = new ConcurrentHashMap<>(); + + private ModdedDimensionCreations() { + } + + public static CompletableFuture create( + NativeModdedServer server, + String dimensionId, + String pack, + String packDimensionKey, + long seed + ) { + if (ModdedDimensionRegistryStore.get(server, dimensionId) != null) { + return CompletableFuture.failedFuture(new IllegalStateException( + ModdedDimensionManager.alreadyExists(dimensionId))); + } + ModdedDimensionRegistryStore.PersistentDimension dimension = + new ModdedDimensionRegistryStore.PersistentDimension(dimensionId, pack, packDimensionKey, seed); + ModdedDimensionInjection.Async creation = + ModdedDimensionInjection.async(dimension, new CreationSteps(server), server::execute); + if (ACTIVE.putIfAbsent(dimensionId, creation) != null) { + return CompletableFuture.failedFuture(new IllegalStateException(beingCreated(dimensionId))); + } + CompletableFuture created = creation.result().handle( + (ModdedDimensionInjection.Result result, Throwable failure) -> settle(creation, dimension, result)); + creation.start(); + return created; + } + + public static boolean isCreating(String dimensionId) { + return ACTIVE.containsKey(dimensionId); + } + + public static void requireNotCreating(String dimensionId) { + if (isCreating(dimensionId)) { + throw new IllegalStateException(beingCreated(dimensionId)); + } + } + + public static void cancelAll() { + List> active = new ArrayList<>(ACTIVE.values()); + for (ModdedDimensionInjection.Async creation : active) { + creation.cancel(); + } + } + + private static Void settle( + ModdedDimensionInjection.Async creation, + ModdedDimensionRegistryStore.PersistentDimension dimension, + ModdedDimensionInjection.Result result + ) { + ACTIVE.remove(dimension.id(), creation); + Throwable cause = result == null + ? new IllegalStateException("Iris dimension creation for '" + dimension.id() + "' ended without a result") + : result.failure(); + if (cause instanceof Error fatal) { + throw fatal; + } + if (cause != null) { + throw cause instanceof RuntimeException runtimeException ? runtimeException : new IllegalStateException(cause); + } + ModdedIrisLog.info("Iris created runtime dimension '{}' (pack={} dim={}): prepared {}ms, engine {}ms", + dimension.id(), dimension.pack(), dimension.dimension(), result.preparedMillis(), result.builtMillis()); + return null; + } + + private static String beingCreated(String dimensionId) { + return "Iris dimension '" + dimensionId + "' is still being created. Try again when it is live."; + } + + private record CreationSteps(NativeModdedServer server) + implements ModdedDimensionInjection.Steps { + @Override + public ModdedWorldEngines.PreparedPack prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) { + ModdedStartup.requirePackForWorldCreation(dimension.pack()); + IrisData data = IrisData.get(new File(ModdedPackCommands.packsRoot(), dimension.pack())); + if (data.getDimensionLoader().load(dimension.dimension()) == null) { + throw new MissingPackDimensionException(dimension.pack(), dimension.dimension()); + } + return ModdedWorldEngines.prepareCreated(server, dimension); + } + + @Override + public ModdedDimensionManager.PendingInjection begin( + ModdedDimensionRegistryStore.PersistentDimension dimension, + ModdedWorldEngines.PreparedPack prepared + ) { + return ModdedDimensionManager.beginCreation(server, dimension, prepared.active()); + } + + @Override + public Engine build(ModdedDimensionManager.PendingInjection level, ModdedWorldEngines.PreparedPack prepared) { + if (level.present()) { + prepared.close(); + return null; + } + return ModdedWorldEngines.createPrepared(level.handle().level(), level.handle().seed(), prepared); + } + + @Override + public void complete(ModdedDimensionManager.PendingInjection level, Engine engine) { + ModdedDimensionManager.completeCreation(server, level, engine); + } + + @Override + public void abandon(ModdedDimensionManager.PendingInjection level, Engine engine, Throwable failure) { + ModdedDimensionManager.abandonCreation(server, level, engine, failure); + } + + @Override + public void discard(ModdedWorldEngines.PreparedPack prepared) { + prepared.close(); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionInjection.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionInjection.java new file mode 100644 index 000000000..9a59ba78f --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionInjection.java @@ -0,0 +1,434 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +final class ModdedDimensionInjection implements AutoCloseable { + private static final int MINIMUM_WORKERS = 2; + private static final long CANCEL_BUILD_WAIT_SECONDS = 120L; + + private final List> preparations; + private final Steps steps; + private final ExecutorService workers; + private final AtomicBoolean finished = new AtomicBoolean(); + + private ModdedDimensionInjection(List> preparations, Steps steps, ExecutorService workers) { + this.preparations = preparations; + this.steps = steps; + this.workers = workers; + } + + static ModdedDimensionInjection start( + List dimensions, + Steps steps + ) { + Steps requiredSteps = Objects.requireNonNull(steps, "steps"); + ExecutorService workers = Executors.newFixedThreadPool(workerCount(dimensions.size()), + Thread.ofPlatform().name("Iris Dimension Restore ", 1L).daemon(true).factory()); + List> preparations = new ArrayList<>(dimensions.size()); + for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) { + preparations.add(new Preparation<>(dimension, CompletableFuture.supplyAsync( + () -> timed(() -> requiredSteps.prepare(dimension)), workers))); + } + return new ModdedDimensionInjection<>(List.copyOf(preparations), requiredSteps, workers); + } + + static Async async( + ModdedDimensionRegistryStore.PersistentDimension dimension, + Steps steps, + Executor serverThread + ) { + return new Async<>(dimension, steps, serverThread); + } + + void awaitPreparations() { + List>> futures = new ArrayList<>(preparations.size()); + for (Preparation

preparation : preparations) { + futures.add(preparation.future()); + } + CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)) + .handle((Void ignored, Throwable failure) -> null) + .join(); + } + + List finish() { + if (!finished.compareAndSet(false, true)) { + throw new IllegalStateException("Iris dimension injection already finished"); + } + try { + List> building = new ArrayList<>(preparations.size()); + Result[] results = new Result[preparations.size()]; + for (int index = 0; index < preparations.size(); index++) { + Preparation

preparation = preparations.get(index); + Timed

prepared; + try { + prepared = join(preparation.future()); + } catch (Throwable failure) { + results[index] = new Result(preparation.dimension(), failure, 0L, 0L); + continue; + } + L level; + try { + level = steps.begin(preparation.dimension(), prepared.value()); + } catch (Throwable failure) { + discard(steps, prepared.value(), failure); + results[index] = new Result(preparation.dimension(), failure, prepared.millis(), 0L); + continue; + } + CompletableFuture> engine = CompletableFuture.supplyAsync( + () -> timed(() -> steps.build(level, prepared.value())), workers); + building.add(new Building<>(index, preparation.dimension(), prepared.millis(), level, engine)); + } + for (Building pending : building) { + results[pending.index()] = complete(pending); + } + return List.of(results); + } finally { + workers.shutdown(); + } + } + + @Override + public void close() { + if (finished.compareAndSet(false, true)) { + for (Preparation

preparation : preparations) { + preparation.future().whenComplete((Timed

prepared, Throwable failure) -> { + if (prepared != null) { + discard(steps, prepared.value(), null); + } + }); + } + } + workers.shutdown(); + } + + private Result complete(Building pending) { + Timed engine; + try { + engine = join(pending.engine()); + } catch (Throwable failure) { + abandon(steps, pending.level(), null, failure); + return new Result(pending.dimension(), failure, pending.preparedMillis(), 0L); + } + try { + steps.complete(pending.level(), engine.value()); + return new Result(pending.dimension(), null, pending.preparedMillis(), engine.millis()); + } catch (Throwable failure) { + return new Result(pending.dimension(), failure, pending.preparedMillis(), engine.millis()); + } + } + + private static void abandon(Steps steps, L level, E engine, Throwable failure) { + try { + steps.abandon(level, engine, failure); + } catch (Throwable cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + + private static void discard(Steps steps, P prepared, Throwable failure) { + try { + steps.discard(prepared); + } catch (Throwable cleanupFailure) { + if (failure != null) { + failure.addSuppressed(cleanupFailure); + return; + } + ModdedIrisLog.error("Iris could not release an unused dimension preparation", cleanupFailure); + } + } + + private static int workerCount(int dimensions) { + int processors = Runtime.getRuntime().availableProcessors(); + return Math.max(1, Math.min(dimensions, Math.max(MINIMUM_WORKERS, processors / 2))); + } + + private static Timed timed(Step step) { + long startedAt = System.nanoTime(); + try { + T value = step.run(); + return new Timed<>(value, (System.nanoTime() - startedAt) / 1_000_000L); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Exception failure) { + throw new CompletionException(failure); + } + } + + private static Throwable unwrap(Throwable failure) { + Throwable current = failure; + while (current instanceof CompletionException && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private static T join(CompletableFuture future) throws Throwable { + try { + return future.get(); + } catch (ExecutionException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof CompletionException wrapped && wrapped.getCause() != null) { + throw wrapped.getCause(); + } + throw cause == null ? failure : cause; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while injecting Iris dimensions", interrupted); + } + } + + interface Steps { + P prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) throws Exception; + + L begin(ModdedDimensionRegistryStore.PersistentDimension dimension, P prepared) throws Exception; + + E build(L level, P prepared) throws Exception; + + void complete(L level, E engine) throws Exception; + + void abandon(L level, E engine, Throwable failure); + + void discard(P prepared); + } + + record Result( + ModdedDimensionRegistryStore.PersistentDimension dimension, + Throwable failure, + long preparedMillis, + long builtMillis + ) { + } + + static final class Async { + private final ModdedDimensionRegistryStore.PersistentDimension dimension; + private final Steps steps; + private final Executor serverThread; + private final ExecutorService worker; + private final CompletableFuture result = new CompletableFuture<>(); + private State state = State.UNSTARTED; + private Timed

prepared; + private L level; + private CompletableFuture> engine; + + private Async( + ModdedDimensionRegistryStore.PersistentDimension dimension, + Steps steps, + Executor serverThread + ) { + this.dimension = Objects.requireNonNull(dimension, "dimension"); + this.steps = Objects.requireNonNull(steps, "steps"); + this.serverThread = Objects.requireNonNull(serverThread, "serverThread"); + this.worker = Executors.newSingleThreadExecutor( + Thread.ofPlatform().name("Iris Dimension Create " + dimension.id()).daemon(true).factory()); + } + + CompletableFuture result() { + return result; + } + + synchronized void start() { + if (state != State.UNSTARTED) { + throw new IllegalStateException("Iris dimension injection for '" + dimension.id() + "' already started"); + } + state = State.PREPARING; + CompletableFuture.supplyAsync(() -> timed(() -> steps.prepare(dimension)), worker) + .whenComplete(this::prepared); + } + + void cancel() { + Timed

unused = null; + CompletableFuture> building = null; + L created = null; + synchronized (this) { + if (state == State.DONE || state == State.CANCELLED) { + return; + } + if (state == State.PREPARED) { + unused = prepared; + } else if (state == State.BUILDING) { + building = engine; + created = level; + } + state = State.CANCELLED; + } + CancellationException cancellation = new CancellationException("Iris dimension injection for '" + + dimension.id() + "' was cancelled because the server is stopping"); + long preparedMillis = 0L; + if (unused != null) { + discard(steps, unused.value(), cancellation); + preparedMillis = unused.millis(); + } + if (building != null) { + abandon(steps, created, awaitCancelledEngine(building, cancellation), cancellation); + } + done(new Result(dimension, cancellation, preparedMillis, 0L)); + } + + private void prepared(Timed

value, Throwable failure) { + synchronized (this) { + if (state == State.CANCELLED) { + if (value != null) { + discard(steps, value.value(), null); + } + worker.shutdown(); + return; + } + if (failure != null) { + state = State.DONE; + } else { + prepared = value; + state = State.PREPARED; + } + } + if (failure != null) { + done(new Result(dimension, unwrap(failure), 0L, 0L)); + return; + } + serverThread.execute(this::begin); + } + + private void begin() { + Timed

ready; + synchronized (this) { + if (state != State.PREPARED) { + return; + } + ready = prepared; + state = State.BEGINNING; + } + L created; + try { + created = steps.begin(dimension, ready.value()); + } catch (Throwable failure) { + discard(steps, ready.value(), failure); + synchronized (this) { + state = State.DONE; + } + done(new Result(dimension, failure, ready.millis(), 0L)); + return; + } + CompletableFuture> building; + synchronized (this) { + level = created; + state = State.BUILDING; + building = CompletableFuture.supplyAsync(() -> timed(() -> steps.build(created, ready.value())), worker); + engine = building; + } + building.whenComplete((Timed built, Throwable failure) -> serverThread.execute(this::complete)); + } + + private void complete() { + CompletableFuture> building; + L created; + long preparedMillis; + synchronized (this) { + if (state != State.BUILDING) { + return; + } + state = State.DONE; + building = engine; + created = level; + preparedMillis = prepared.millis(); + } + Timed built; + try { + built = join(building); + } catch (Throwable failure) { + abandon(steps, created, null, failure); + done(new Result(dimension, failure, preparedMillis, 0L)); + return; + } + try { + steps.complete(created, built.value()); + done(new Result(dimension, null, preparedMillis, built.millis())); + } catch (Throwable failure) { + done(new Result(dimension, failure, preparedMillis, built.millis())); + } + } + + private E awaitCancelledEngine(CompletableFuture> building, CancellationException cancellation) { + try { + return building.get(CANCEL_BUILD_WAIT_SECONDS, TimeUnit.SECONDS).value(); + } catch (ExecutionException failure) { + return null; + } catch (TimeoutException failure) { + ModdedIrisLog.error("Iris dimension engine for '{}' did not finish within {}s of shutdown; abandoning its level", + dimension.id(), CANCEL_BUILD_WAIT_SECONDS); + cancellation.addSuppressed(failure); + return null; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + cancellation.addSuppressed(interrupted); + return null; + } + } + + private void done(Result outcome) { + worker.shutdown(); + result.complete(outcome); + } + + private enum State { + UNSTARTED, + PREPARING, + PREPARED, + BEGINNING, + BUILDING, + DONE, + CANCELLED + } + } + + @FunctionalInterface + private interface Step { + T run() throws Exception; + } + + private record Timed(T value, long millis) { + } + + private record Preparation

( + ModdedDimensionRegistryStore.PersistentDimension dimension, + CompletableFuture> future + ) { + } + + private record Building( + int index, + ModdedDimensionRegistryStore.PersistentDimension dimension, + long preparedMillis, + L level, + CompletableFuture> engine + ) { + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java index d4303bea0..8e9ec2266 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java @@ -112,52 +112,108 @@ public final class ModdedDimensionManager { ); } - static RestoringLevel beginRestore( + static PendingInjection beginInjection( NativeModdedServer server, ModdedDimensionRegistryStore.PersistentDimension dimension, - ModdedGenerationHistoryStorage.ActivePack active + ModdedGenerationHistoryStorage.ActivePack active, + ModdedGenerationMode generationMode ) { NativeDimensionRuntime serverAccess = requireAccess(); DimensionRequest requested = new DimensionRequest(dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed()); synchronized (LOCK) { Handle present = presentHandle(server, serverAccess, requested); if (present != null) { - return new RestoringLevel(present, true); + return new PendingInjection(present, true); } try { - RuntimePack runtimePack = persistentRuntimePack(active, ModdedGenerationMode.PERSISTENT_RESTORE); - return new RestoringLevel(createLevel(server, serverAccess, requested, runtimePack), false); + RuntimePack runtimePack = persistentRuntimePack(active, generationMode); + return new PendingInjection(createLevel(server, serverAccess, requested, runtimePack), false); } catch (Throwable e) { throw injectionFailure(requested, e); } } } - static Handle completeRestore(NativeModdedServer server, RestoringLevel restoring, Engine engine) { - if (restoring.present()) { - return restoring.handle(); + static Handle completeInjection(NativeModdedServer server, PendingInjection pending, Engine engine) { + if (pending.present()) { + return pending.handle(); } NativeDimensionRuntime serverAccess = requireAccess(); synchronized (LOCK) { try { - return register(publishLevel(server, serverAccess, restoring.handle(), engine)); + return register(publishLevel(server, serverAccess, pending.handle(), engine)); } catch (Throwable e) { - throw injectionFailure(DimensionRequest.of(restoring.handle()), e); + throw injectionFailure(DimensionRequest.of(pending.handle()), e); } } } - static void abandonRestore(NativeModdedServer server, RestoringLevel restoring, Throwable failure) { - if (restoring.present()) { + static void abandonInjection(NativeModdedServer server, PendingInjection pending, Engine engine, Throwable failure) { + if (engine != null) { + try { + ModdedWorldEngines.closeUnregistered(engine); + } catch (Throwable cleanupFailure) { + failure.addSuppressed(cleanupFailure); + ModdedIrisLog.error("Iris failed to close the abandoned engine for '{}'", + pending.handle().dimensionId(), cleanupFailure); + } + } + if (pending.present()) { return; } NativeDimensionRuntime serverAccess = requireAccess(); - Handle handle = restoring.handle(); + Handle handle = pending.handle(); synchronized (LOCK) { rollbackInjection(server, serverAccess, handle.dimensionId(), handle.level(), handle.generator(), false, failure); } } + static PendingInjection beginCreation( + NativeModdedServer server, + ModdedDimensionRegistryStore.PersistentDimension dimension, + ModdedGenerationHistoryStorage.ActivePack active + ) { + synchronized (LOCK) { + if (ModdedDimensionRegistryStore.get(server, dimension.id()) != null) { + throw new IllegalStateException(alreadyExists(dimension.id())); + } + ModdedDimensionRegistryStore.put(server, dimension); + try { + return beginInjection(server, dimension, active, ModdedGenerationMode.PERSISTENT_CREATE); + } catch (RuntimeException | Error failure) { + forgetCreation(server, dimension.id(), failure); + throw failure; + } + } + } + + static Handle completeCreation(NativeModdedServer server, PendingInjection pending, Engine engine) { + try { + return completeInjection(server, pending, engine); + } catch (RuntimeException | Error failure) { + forgetCreation(server, pending.handle().dimensionId(), failure); + throw failure; + } + } + + static void abandonCreation(NativeModdedServer server, PendingInjection pending, Engine engine, Throwable failure) { + abandonInjection(server, pending, engine, failure); + forgetCreation(server, pending.handle().dimensionId(), failure); + } + + static String alreadyExists(String dimensionId) { + return "Iris dimension '" + dimensionId + + "' already exists. Use the Iris world update command to stage a pack update."; + } + + private static void forgetCreation(NativeModdedServer server, String dimensionId, Throwable failure) { + try { + ModdedDimensionRegistryStore.remove(server, dimensionId); + } catch (Throwable rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + } + private static Handle create(NativeModdedServer server, DimensionRequest requested, RuntimePackSource source) { NativeDimensionRuntime serverAccess = requireAccess(); synchronized (LOCK) { @@ -210,58 +266,13 @@ public final class ModdedDimensionManager { return new IllegalStateException("Iris runtime dimension injection failed for " + requested.dimensionId(), failure); } - public static Handle createPersistent(NativeModdedServer server, String dimensionId, String pack, String packDimensionKey, long seed) { - synchronized (LOCK) { - ModdedDimensionRegistryStore.PersistentDimension previous = - ModdedDimensionRegistryStore.get(server, dimensionId); - if (previous != null) { - throw new IllegalStateException("Iris dimension '" + dimensionId - + "' already exists. Use the Iris world update command to stage a pack update."); - } - ModdedDimensionRegistryStore.put(server, new ModdedDimensionRegistryStore.PersistentDimension( - dimensionId, - pack, - packDimensionKey, - seed - )); - try { - return create( - server, - new DimensionRequest(dimensionId, pack, packDimensionKey, seed), - () -> persistentRuntimePack( - ModdedGenerationHistoryStorage.createOrStage( - server, - dimensionId, - pack, - packDimensionKey, - seed - ), - ModdedGenerationMode.PERSISTENT_CREATE - ) - ); - } catch (Throwable e) { - try { - ModdedDimensionRegistryStore.remove(server, dimensionId); - } catch (Throwable rollbackFailure) { - e.addSuppressed(rollbackFailure); - } - if (e instanceof RuntimeException runtimeException) { - throw runtimeException; - } - if (e instanceof Error fatalError) { - throw fatalError; - } - throw new IllegalStateException("Iris runtime dimension injection failed for " + dimensionId, e); - } - } - } - public static UpdateResult stagePersistentUpdate( NativeModdedServer server, String dimensionId, String pack, String packDimensionKey ) { + ModdedDimensionCreations.requireNotCreating(dimensionId); synchronized (LOCK) { ModdedDimensionRegistryStore.PersistentDimension previous = ModdedDimensionRegistryStore.get(server, dimensionId); @@ -324,6 +335,7 @@ public final class ModdedDimensionManager { } public static boolean removePersistent(NativeModdedServer server, String dimensionId, boolean wipeStorage) { + ModdedDimensionCreations.requireNotCreating(dimensionId); ModdedDimensionRegistryStore.PersistentDimension previous = ModdedDimensionRegistryStore.get(server, dimensionId); ModdedDimensionRegistryStore.remove(server, dimensionId); @@ -592,8 +604,8 @@ public final class ModdedDimensionManager { public record Handle(String dimensionId, String pack, String packDimensionKey, long seed, NativeWorld level, IrisModdedChunkGenerator generator) { } - record RestoringLevel(Handle handle, boolean present) { - RestoringLevel { + record PendingInjection(Handle handle, boolean present) { + PendingInjection { handle = Objects.requireNonNull(handle, "handle"); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRestore.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRestore.java deleted file mode 100644 index ec1d9c7e2..000000000 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRestore.java +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Servers - * Copyright (c) 2026 Arcane Arts (Volmit Software) - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package art.arcane.iris.modded; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; - -final class ModdedDimensionRestore implements AutoCloseable { - private static final int MINIMUM_WORKERS = 2; - - private final List> preparations; - private final Steps steps; - private final ExecutorService workers; - private final AtomicBoolean finished = new AtomicBoolean(); - - private ModdedDimensionRestore(List> preparations, Steps steps, ExecutorService workers) { - this.preparations = preparations; - this.steps = steps; - this.workers = workers; - } - - static ModdedDimensionRestore start( - List dimensions, - Steps steps - ) { - Steps requiredSteps = Objects.requireNonNull(steps, "steps"); - ExecutorService workers = Executors.newFixedThreadPool(workerCount(dimensions.size()), - Thread.ofPlatform().name("Iris Dimension Restore ", 1L).daemon(true).factory()); - List> preparations = new ArrayList<>(dimensions.size()); - for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) { - preparations.add(new Preparation<>(dimension, CompletableFuture.supplyAsync( - () -> timed(() -> requiredSteps.prepare(dimension)), workers))); - } - return new ModdedDimensionRestore<>(List.copyOf(preparations), requiredSteps, workers); - } - - void awaitPreparations() { - List>> futures = new ArrayList<>(preparations.size()); - for (Preparation

preparation : preparations) { - futures.add(preparation.future()); - } - CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)) - .handle((Void ignored, Throwable failure) -> null) - .join(); - } - - List finish() { - if (!finished.compareAndSet(false, true)) { - throw new IllegalStateException("Iris dimension restore already finished"); - } - try { - List> building = new ArrayList<>(preparations.size()); - Result[] results = new Result[preparations.size()]; - for (int index = 0; index < preparations.size(); index++) { - Preparation

preparation = preparations.get(index); - Timed

prepared; - try { - prepared = join(preparation.future()); - } catch (Throwable failure) { - results[index] = new Result(preparation.dimension(), failure, 0L, 0L); - continue; - } - L level; - try { - level = steps.begin(preparation.dimension(), prepared.value()); - } catch (Throwable failure) { - discard(prepared.value(), failure); - results[index] = new Result(preparation.dimension(), failure, prepared.millis(), 0L); - continue; - } - CompletableFuture> engine = CompletableFuture.supplyAsync( - () -> timed(() -> steps.build(level, prepared.value())), workers); - building.add(new Building<>(index, preparation.dimension(), prepared.millis(), level, engine)); - } - for (Building pending : building) { - results[pending.index()] = complete(pending); - } - return List.of(results); - } finally { - workers.shutdown(); - } - } - - @Override - public void close() { - if (finished.compareAndSet(false, true)) { - for (Preparation

preparation : preparations) { - preparation.future().whenComplete((Timed

prepared, Throwable failure) -> { - if (prepared != null) { - discard(prepared.value(), null); - } - }); - } - } - workers.shutdown(); - } - - private Result complete(Building pending) { - Timed engine; - try { - engine = join(pending.engine()); - } catch (Throwable failure) { - abandon(pending.level(), failure); - return new Result(pending.dimension(), failure, pending.preparedMillis(), 0L); - } - try { - steps.complete(pending.level(), engine.value()); - return new Result(pending.dimension(), null, pending.preparedMillis(), engine.millis()); - } catch (Throwable failure) { - return new Result(pending.dimension(), failure, pending.preparedMillis(), engine.millis()); - } - } - - private void abandon(L level, Throwable failure) { - try { - steps.abandon(level, failure); - } catch (Throwable cleanupFailure) { - failure.addSuppressed(cleanupFailure); - } - } - - private void discard(P prepared, Throwable failure) { - try { - steps.discard(prepared); - } catch (Throwable cleanupFailure) { - if (failure != null) { - failure.addSuppressed(cleanupFailure); - return; - } - ModdedIrisLog.error("Iris could not release an unused dimension restore preparation", cleanupFailure); - } - } - - private static int workerCount(int dimensions) { - int processors = Runtime.getRuntime().availableProcessors(); - return Math.max(1, Math.min(dimensions, Math.max(MINIMUM_WORKERS, processors / 2))); - } - - private static Timed timed(Step step) { - long startedAt = System.nanoTime(); - try { - T value = step.run(); - return new Timed<>(value, (System.nanoTime() - startedAt) / 1_000_000L); - } catch (RuntimeException | Error failure) { - throw failure; - } catch (Exception failure) { - throw new CompletionException(failure); - } - } - - private static T join(CompletableFuture future) throws Throwable { - try { - return future.get(); - } catch (ExecutionException failure) { - Throwable cause = failure.getCause(); - if (cause instanceof CompletionException wrapped && wrapped.getCause() != null) { - throw wrapped.getCause(); - } - throw cause == null ? failure : cause; - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Interrupted while restoring Iris dimensions", interrupted); - } - } - - interface Steps { - P prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) throws Exception; - - L begin(ModdedDimensionRegistryStore.PersistentDimension dimension, P prepared) throws Exception; - - E build(L level, P prepared) throws Exception; - - void complete(L level, E engine) throws Exception; - - void abandon(L level, Throwable failure); - - void discard(P prepared); - } - - record Result( - ModdedDimensionRegistryStore.PersistentDimension dimension, - Throwable failure, - long preparedMillis, - long builtMillis - ) { - } - - @FunctionalInterface - private interface Step { - T run() throws Exception; - } - - private record Timed(T value, long millis) { - } - - private record Preparation

( - ModdedDimensionRegistryStore.PersistentDimension dimension, - CompletableFuture> future - ) { - } - - private record Building( - int index, - ModdedDimensionRegistryStore.PersistentDimension dimension, - long preparedMillis, - L level, - CompletableFuture> engine - ) { - } -} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index a559615af..30d00bac0 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -212,6 +212,7 @@ public final class ModdedEngineBootstrap { stoppedPending = true; Throwable failure = null; failure = runStopStage(failure, "pack downloads", IrisModdedCommands::shutdownDownloads); + failure = runStopStage(failure, "dimension creations", ModdedDimensionCreations::cancelAll); failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer)); failure = runStopStage(failure, "protocol", ModdedProtocolHandler::stop); failure = runStopStage(failure, "pregenerator", ModdedPregenJob::shutdown); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java index 4ce134029..3dfa86329 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java @@ -86,19 +86,47 @@ final class ModdedGenerationHistoryStorage { Path dimensionRoot = dimensionRoot(server, levelKey); try { GenerationHistory history = openForRestore(dimensionRoot, selectedPack, selectedDimensionKey, worldSeed); - GenerationAdmission.RuntimeLease lease = history.retainRuntime(); - try { - history.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); - return new PreparedHistory(resolveActive(history, installedPackSource(selectedPack)), lease); - } catch (IOException | RuntimeException | Error failure) { - lease.close(); - throw failure; - } + return prepare(history, installedPackSource(selectedPack)); } catch (IOException failure) { throw historyFailure(levelKey, failure); } } + static PreparedHistory prepareCreate( + NativeModdedServer server, + String levelKey, + String selectedPack, + String selectedDimensionKey, + long worldSeed + ) { + Path dimensionRoot = dimensionRoot(server, levelKey); + try { + Path source = selectedPackSource(selectedPack, selectedDimensionKey); + GenerationHistory history = createOrStage( + dimensionRoot, + source, + worldSeed, + captureCandidate(source, selectedDimensionKey), + ModdedGenerationHistoryStorage::requireRegistryDefinitions, + IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks() + ); + return prepare(history, source); + } catch (IOException failure) { + throw historyFailure(levelKey, failure); + } + } + + private static PreparedHistory prepare(GenerationHistory history, Path validatedSource) throws IOException { + GenerationAdmission.RuntimeLease lease = history.retainRuntime(); + try { + history.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); + return new PreparedHistory(resolveActive(history, validatedSource), lease); + } catch (IOException | RuntimeException | Error failure) { + lease.close(); + throw failure; + } + } + private static GenerationHistory openForRestore( Path dimensionRoot, String selectedPack, diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index a8d0bf13b..f5bf2c458 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -47,7 +47,7 @@ public final class ModdedStartup { private static final AtomicBoolean PREPARED = new AtomicBoolean(false); private static final AtomicBoolean STARTED = new AtomicBoolean(false); private static final AtomicBoolean RESTORE_BEGUN = new AtomicBoolean(false); - private static final AtomicReference> RESTORE = + private static final AtomicReference> RESTORE = new AtomicReference<>(); private static final List DIMENSION_FAILURES = new CopyOnWriteArrayList<>(); @@ -55,7 +55,7 @@ public final class ModdedStartup { } public static void reset() { - ModdedDimensionRestore restore = + ModdedDimensionInjection restore = RESTORE.getAndSet(null); if (restore != null) { restore.close(); @@ -83,8 +83,8 @@ public final class ModdedStartup { return; } long startedAt = System.currentTimeMillis(); - ModdedDimensionRestore restore = - ModdedDimensionRestore.start(dimensions, new PersistentRestoreSteps(server)); + ModdedDimensionInjection restore = + ModdedDimensionInjection.start(dimensions, new PersistentRestoreSteps(server)); RESTORE.set(restore); restore.awaitPreparations(); ModdedIrisLog.info("Iris prepared {} persistent dimension(s) during world load in {}ms", @@ -249,7 +249,7 @@ public final class ModdedStartup { static void reinjectPersistentDimensions(NativeModdedServer server) { prepareDimensionRestore(server); - ModdedDimensionRestore restore = + ModdedDimensionInjection restore = RESTORE.getAndSet(null); if (restore == null) { return; @@ -257,9 +257,9 @@ public final class ModdedStartup { finishDimensionRestore(restore); } - static void finishDimensionRestore(ModdedDimensionRestore restore) { + static void finishDimensionRestore(ModdedDimensionInjection restore) { long startedAt = System.currentTimeMillis(); - List results; + List results; try (restore) { results = restore.finish(); } @@ -267,7 +267,7 @@ public final class ModdedStartup { int injected = 0; int index = 0; OutOfMemoryError outOfMemory = null; - for (ModdedDimensionRestore.Result result : results) { + for (ModdedDimensionInjection.Result result : results) { index++; ModdedDimensionRegistryStore.PersistentDimension dimension = result.dimension(); Throwable failure = result.failure(); @@ -342,22 +342,23 @@ public final class ModdedStartup { } private record PersistentRestoreSteps(NativeModdedServer server) - implements ModdedDimensionRestore.Steps { + implements ModdedDimensionInjection.Steps { @Override public ModdedWorldEngines.PreparedPack prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) { return ModdedWorldEngines.preparePersistent(server, dimension); } @Override - public ModdedDimensionManager.RestoringLevel begin( + public ModdedDimensionManager.PendingInjection begin( ModdedDimensionRegistryStore.PersistentDimension dimension, ModdedWorldEngines.PreparedPack prepared ) { - return ModdedDimensionManager.beginRestore(server, dimension, prepared.active()); + return ModdedDimensionManager.beginInjection( + server, dimension, prepared.active(), ModdedGenerationMode.PERSISTENT_RESTORE); } @Override - public Engine build(ModdedDimensionManager.RestoringLevel level, ModdedWorldEngines.PreparedPack prepared) { + public Engine build(ModdedDimensionManager.PendingInjection level, ModdedWorldEngines.PreparedPack prepared) { if (level.present()) { prepared.close(); return null; @@ -366,13 +367,13 @@ public final class ModdedStartup { } @Override - public void complete(ModdedDimensionManager.RestoringLevel level, Engine engine) { - ModdedDimensionManager.completeRestore(server, level, engine); + public void complete(ModdedDimensionManager.PendingInjection level, Engine engine) { + ModdedDimensionManager.completeInjection(server, level, engine); } @Override - public void abandon(ModdedDimensionManager.RestoringLevel level, Throwable failure) { - ModdedDimensionManager.abandonRestore(server, level, failure); + public void abandon(ModdedDimensionManager.PendingInjection level, Engine engine, Throwable failure) { + ModdedDimensionManager.abandonInjection(server, level, engine, failure); } @Override diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index 4b2a7e375..fb739ddbf 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -134,13 +134,30 @@ public final class ModdedWorldEngines { ModdedDimensionRegistryStore.PersistentDimension dimension ) { ModdedEngineBootstrap.bind(); - ModdedGenerationHistoryStorage.PreparedHistory prepared = ModdedGenerationHistoryStorage.prepareRestore( + return prepared(ModdedGenerationHistoryStorage.prepareRestore( server, dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed() - ); + )); + } + + static PreparedPack prepareCreated( + NativeModdedServer server, + ModdedDimensionRegistryStore.PersistentDimension dimension + ) { + ModdedEngineBootstrap.bind(); + return prepared(ModdedGenerationHistoryStorage.prepareCreate( + server, + dimension.id(), + dimension.pack(), + dimension.dimension(), + dimension.seed() + )); + } + + private static PreparedPack prepared(ModdedGenerationHistoryStorage.PreparedHistory prepared) { try { ModdedDimensionMetadata.warmConfiguredPack(prepared.active().packRoot().toFile(), prepared.active().dimensionKey()); return new PreparedPack(prepared.active(), prepared.lease(), loadPack(prepared.active())); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java index e1d9f758f..9f13e588c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java @@ -26,6 +26,8 @@ import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators; import art.arcane.iris.modded.MainWorldService; +import art.arcane.iris.modded.MissingPackDimensionException; +import art.arcane.iris.modded.ModdedDimensionCreations; import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedModConfig; @@ -47,12 +49,16 @@ import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ThreadLocalRandom; import java.util.function.Predicate; import art.arcane.iris.localization.IrisLanguage; import art.arcane.iris.modded.localization.ModdedCommandMessages; import art.arcane.iris.localization.RuntimeUiMessages; +import art.arcane.volmlib.util.localization.LanguageAudience; import art.arcane.volmlib.util.localization.MessageArgument; public final class ModdedWorldCommands { private static final Predicate GATE = NativeCommandRegistration.GAMEMASTERS; @@ -193,24 +199,75 @@ public final class ModdedWorldCommands { } private static int enableInstalled(NativeCommandSource source, NativeModdedServer server, String dimensionId, String pack, String packDimension, long seed) { - if (blockIfPackBroken(source, dimensionId, pack)) { - return 0; + return create(source, server, new CreationRequest(dimensionId, pack, packDimension, seed), ModdedWorldCommands::reportWorldCreation); + } + + private static int create(NativeCommandSource source, NativeModdedServer server, CreationRequest request, CreationReport report) { + CompletableFuture creation = ModdedDimensionCreations.create( + server, request.dimensionId(), request.pack(), request.packDimension(), request.seed()); + if (creation.isDone()) { + Throwable failure = creation.isCompletedExceptionally() ? creation.exceptionNow() : null; + report.report(source, request, failure); + return failure == null ? 1 : 0; } - if (!loadPackDimension(source, pack, packDimension)) { - return 0; - } - try { - ModdedDimensionManager.createPersistent(server, dimensionId, pack, packDimension, seed); - } catch (Throwable e) { - ModdedIrisLog.error("Iris world injection failed for {} (pack={} dim={})", dimensionId, pack, packDimension, e); - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_FAILED_INJECT_IRIS_WORLD, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e)))); - return 0; - } - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_CREATED_IRIS_WORLD_FROM_PACK_DIMENSION_SEED, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("packDimension", packDimension), MessageArgument.untrusted("seed", seed))); - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_IT_IS_LIVE_NOW_RE_INJECTED_ON_EVERY_STARTUP_TELEPORT)); + IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_CREATING_IRIS_WORLD_IN_BACKGROUND, MessageArgument.untrusted("dimensionId", request.dimensionId()), MessageArgument.untrusted("pack", request.pack()), MessageArgument.untrusted("packDimension", request.packDimension()), MessageArgument.untrusted("seed", request.seed()))); + UUID audience = source.playerId(); + creation.whenComplete((Void ignored, Throwable failure) -> server.execute( + () -> LanguageAudience.run(audience, () -> report.report(source, request, failure)))); return 1; } + private static void reportWorldCreation(NativeCommandSource source, CreationRequest request, Throwable failure) { + Throwable cause = creationCause(failure); + if (cause == null) { + IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_CREATED_IRIS_WORLD_FROM_PACK_DIMENSION_SEED, MessageArgument.untrusted("dimensionId", request.dimensionId()), MessageArgument.untrusted("pack", request.pack()), MessageArgument.untrusted("packDimension", request.packDimension()), MessageArgument.untrusted("seed", request.seed()))); + IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_IT_IS_LIVE_NOW_RE_INJECTED_ON_EVERY_STARTUP_TELEPORT)); + return; + } + if (reportPackRefusal(source, request, cause)) { + return; + } + ModdedIrisLog.error("Iris world injection failed for {} (pack={} dim={})", request.dimensionId(), request.pack(), request.packDimension(), cause); + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_FAILED_INJECT_IRIS_WORLD, MessageArgument.untrusted("dimensionId", request.dimensionId()), MessageArgument.untrusted("value", cause.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(cause)))); + } + + private static void reportPrimaryCreation(NativeCommandSource source, CreationRequest request, Throwable failure) { + Throwable cause = creationCause(failure); + if (cause == null) { + ModdedModConfig.setPrimaryWorld(request.dimensionId()); + ModdedPrimaryWorldRouter.clear(); + IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_IRIS_PRIMARY_WORLD_SET_PACK_DIMENSION_SEED, MessageArgument.untrusted("dimensionId", request.dimensionId()), MessageArgument.untrusted("pack", request.pack()), MessageArgument.untrusted("packDimension", request.packDimension()), MessageArgument.untrusted("seed", request.seed()))); + IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_VANILLA_OVERWORLD_GENERATOR_CANNOT_BE_HOT_SWAPPED_SO_THIS_DOES)); + IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_INSTEAD_IS_NOW_CONFIGURED_PRIMARY_WORLD_PLAYERS_VANILLA_OVERWORLD_ARE, MessageArgument.untrusted("dimensionId", request.dimensionId()))); + return; + } + if (reportPackRefusal(source, request, cause)) { + return; + } + ModdedIrisLog.error("Iris primary world injection failed for {} (pack={} dim={})", request.dimensionId(), request.pack(), request.packDimension(), cause); + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_FAILED_INJECT_IRIS_PRIMARY_WORLD, MessageArgument.untrusted("value", cause.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(cause)))); + } + + private static boolean reportPackRefusal(NativeCommandSource source, CreationRequest request, Throwable cause) { + if (cause instanceof BrokenPackException broken) { + reportBrokenPack(source, request.dimensionId(), request.pack(), broken); + return true; + } + if (cause instanceof MissingPackDimensionException missing) { + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_DOES_NOT_CONTAIN_DIMENSIONS_JSON, MessageArgument.untrusted("pack", missing.pack()), MessageArgument.untrusted("packDimension", missing.dimensionKey()))); + return true; + } + return false; + } + + private static Throwable creationCause(Throwable failure) { + Throwable cause = failure; + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } + return cause; + } + private static int update(NativeCommandSource source, String targetDimension, String packRaw) { String dimensionId; try { @@ -265,25 +322,12 @@ public final class ModdedWorldCommands { return 0; } String dimensionId = DEFAULT_NAMESPACE + ":primary"; - if (blockIfPackBroken(source, dimensionId, pack)) { + File packFolder = new File(ModdedPackCommands.packsRoot(), pack); + if (!packFolder.isDirectory()) { + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_WAS_NOT_FOUND_UNDER, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("value", ModdedPackCommands.packsRoot().getAbsolutePath()))); return 0; } - if (!loadPackDimension(source, pack, packDimension)) { - return 0; - } - try { - ModdedDimensionManager.createPersistent(server, dimensionId, pack, packDimension, seed); - } catch (Throwable e) { - ModdedIrisLog.error("Iris primary world injection failed for {} (pack={} dim={})", dimensionId, pack, packDimension, e); - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_FAILED_INJECT_IRIS_PRIMARY_WORLD, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e)))); - return 0; - } - ModdedModConfig.setPrimaryWorld(dimensionId); - ModdedPrimaryWorldRouter.clear(); - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_IRIS_PRIMARY_WORLD_SET_PACK_DIMENSION_SEED, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("packDimension", packDimension), MessageArgument.untrusted("seed", seed))); - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_VANILLA_OVERWORLD_GENERATOR_CANNOT_BE_HOT_SWAPPED_SO_THIS_DOES)); - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_INSTEAD_IS_NOW_CONFIGURED_PRIMARY_WORLD_PLAYERS_VANILLA_OVERWORLD_ARE, MessageArgument.untrusted("dimensionId", dimensionId))); - return 1; + return create(source, server, new CreationRequest(dimensionId, pack, packDimension, seed), ModdedWorldCommands::reportPrimaryCreation); } private static int clearMainWorld(NativeCommandSource source) { @@ -368,15 +412,19 @@ public final class ModdedWorldCommands { ModdedStartup.requirePackForWorldCreation(pack); return false; } catch (BrokenPackException e) { - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_REFUSING_CREATE_WORLD_USING_PACK_BECAUSE_REQUIRED_VALIDATION_FAILED, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("pack", pack))); - for (String reason : e.getReasons()) { - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_MESSAGE, MessageArgument.untrusted("reason", reason))); - } - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_FIX_PACK_RUN_IRIS_PACK_VALIDATE_REVALIDATE, MessageArgument.untrusted("pack", pack))); + reportBrokenPack(source, dimensionId, pack, e); return true; } } + private static void reportBrokenPack(NativeCommandSource source, String dimensionId, String pack, BrokenPackException broken) { + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_REFUSING_CREATE_WORLD_USING_PACK_BECAUSE_REQUIRED_VALIDATION_FAILED, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("pack", pack))); + for (String reason : broken.getReasons()) { + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_MESSAGE, MessageArgument.untrusted("reason", reason))); + } + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_FIX_PACK_RUN_IRIS_PACK_VALIDATE_REVALIDATE, MessageArgument.untrusted("pack", pack))); + } + private static boolean loadPackDimension(NativeCommandSource source, String pack, String packDimension) { File packFolder = new File(ModdedPackCommands.packsRoot(), pack); if (!packFolder.isDirectory()) { @@ -532,4 +580,12 @@ public final class ModdedWorldCommands { } return namespace + ":" + path; } + + @FunctionalInterface + private interface CreationReport { + void report(NativeCommandSource source, CreationRequest request, Throwable failure); + } + + private record CreationRequest(String dimensionId, String pack, String packDimension, long seed) { + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java index f6b6779cb..c798ffbf3 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java @@ -1124,6 +1124,10 @@ public final class ModdedCommandMessages { "iris.modded.moddedworldcommands.failed_inject_iris_world", "Failed to inject Iris world '" + "{dimensionId}" + "': " + "{value}" + "{errorMessage}" ); + public static final TextKey MODDED_WORLD_COMMANDS_CREATING_IRIS_WORLD_IN_BACKGROUND = TextKey.of( + "iris.modded.moddedworldcommands.creating_iris_world_in_background", + "Creating Iris world " + "{dimensionId}" + " from pack '" + "{pack}" + "' dimension '" + "{packDimension}" + "' (seed " + "{seed}" + ") in the background. You will be told when it is live." + ); public static final TextKey MODDED_WORLD_COMMANDS_CREATED_IRIS_WORLD_FROM_PACK_DIMENSION_SEED = TextKey.of( "iris.modded.moddedworldcommands.created_iris_world_from_pack_dimension_seed", "Created Iris world " + "{dimensionId}" + " from pack '" + "{pack}" + "' dimension '" + "{packDimension}" + "' (seed " + "{seed}" + ")." @@ -1541,6 +1545,7 @@ public final class ModdedCommandMessages { MODDED_STUDIO_COMMANDS_REGION_SAMPLING_FAILED, MODDED_WORLD_COMMANDS_REQUIRED_PACK_IS_NOT_INSTALLED_INSTALL_THEN_RESTART, MODDED_WORLD_COMMANDS_FAILED_INJECT_IRIS_WORLD, + MODDED_WORLD_COMMANDS_CREATING_IRIS_WORLD_IN_BACKGROUND, MODDED_WORLD_COMMANDS_CREATED_IRIS_WORLD_FROM_PACK_DIMENSION_SEED, MODDED_WORLD_COMMANDS_IT_IS_LIVE_NOW_RE_INJECTED_ON_EVERY_STARTUP_TELEPORT, MODDED_WORLD_COMMANDS_FAILED_INJECT_IRIS_PRIMARY_WORLD, diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionRestoreTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionInjectionTest.java similarity index 54% rename from adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionRestoreTest.java rename to adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionInjectionTest.java index 55755744f..c568b9833 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionRestoreTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionInjectionTest.java @@ -7,7 +7,11 @@ import java.util.Collections; import java.util.List; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -17,7 +21,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; -public class ModdedDimensionRestoreTest { +public class ModdedDimensionInjectionTest { private static final long STEP_TIMEOUT_SECONDS = 10L; @Test @@ -26,13 +30,13 @@ public class ModdedDimensionRestoreTest { RecordingSteps steps = new RecordingSteps(dimensions.size()); steps.prepareBarrier = new CountDownLatch(dimensions.size()); - List results; - try (ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps)) { + List results; + try (ModdedDimensionInjection restore = ModdedDimensionInjection.start(dimensions, steps)) { results = restore.finish(); } assertEquals(dimensions.size(), results.size()); - for (ModdedDimensionRestore.Result result : results) { + for (ModdedDimensionInjection.Result result : results) { assertNull(result.failure()); } assertFalse(steps.prepareThreads.contains(Thread.currentThread())); @@ -44,7 +48,7 @@ public class ModdedDimensionRestoreTest { RecordingSteps steps = new RecordingSteps(dimensions.size()); steps.buildBarrier = new CountDownLatch(dimensions.size()); - try (ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps)) { + try (ModdedDimensionInjection restore = ModdedDimensionInjection.start(dimensions, steps)) { restore.finish(); } @@ -61,8 +65,8 @@ public class ModdedDimensionRestoreTest { steps.prepareFailure = failure; steps.failingIndex = 1; - List results; - try (ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps)) { + List results; + try (ModdedDimensionInjection restore = ModdedDimensionInjection.start(dimensions, steps)) { results = restore.finish(); } @@ -82,14 +86,14 @@ public class ModdedDimensionRestoreTest { steps.buildFailure = failure; steps.failingIndex = 0; - List results; - try (ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps)) { + List results; + try (ModdedDimensionInjection restore = ModdedDimensionInjection.start(dimensions, steps)) { results = restore.finish(); } assertSame(failure, results.get(0).failure()); assertNull(results.get(1).failure()); - assertEquals(List.of("level-0"), steps.abandoned); + assertEquals(List.of("level-0:null"), steps.abandoned); assertEquals(List.of("level-1:engine-1"), steps.completed); } @@ -101,8 +105,8 @@ public class ModdedDimensionRestoreTest { steps.beginFailure = failure; steps.failingIndex = 1; - List results; - try (ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps)) { + List results; + try (ModdedDimensionInjection restore = ModdedDimensionInjection.start(dimensions, steps)) { results = restore.finish(); } @@ -126,7 +130,7 @@ public class ModdedDimensionRestoreTest { release.countDown(); }); - try (ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps)) { + try (ModdedDimensionInjection restore = ModdedDimensionInjection.start(dimensions, steps)) { restore.awaitPreparations(); assertEquals(dimensions.size(), steps.prepared.get()); @@ -143,7 +147,7 @@ public class ModdedDimensionRestoreTest { List dimensions = dimensions(3); RecordingSteps steps = new RecordingSteps(dimensions.size()); - ModdedDimensionRestore restore = ModdedDimensionRestore.start(dimensions, steps); + ModdedDimensionInjection restore = ModdedDimensionInjection.start(dimensions, steps); restore.close(); assertTrue(steps.discardedAll.await(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)); @@ -151,6 +155,143 @@ public class ModdedDimensionRestoreTest { assertEquals(Set.of("prepared-0", "prepared-1", "prepared-2"), Set.copyOf(steps.discarded)); } + @Test + public void asyncInjectionReturnsAtOnceAndMutatesLevelsOnlyOnTheServerExecutor() throws Exception { + ModdedDimensionRegistryStore.PersistentDimension dimension = dimensions(1).getFirst(); + RecordingSteps steps = new RecordingSteps(1); + CountDownLatch release = new CountDownLatch(1); + steps.prepareGate = release; + ExecutorService server = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("test-server").factory()); + try { + Thread serverThread = server.submit(Thread::currentThread).get(); + ModdedDimensionInjection.Async async = + ModdedDimensionInjection.async(dimension, steps, server); + async.start(); + + assertFalse(async.result().isDone()); + release.countDown(); + ModdedDimensionInjection.Result result = async.result().get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertNull(result.failure()); + assertSame(dimension, result.dimension()); + assertEquals(List.of("level-0:engine-0"), steps.completed); + assertEquals(Set.of(serverThread), steps.serverThreads); + assertFalse(steps.prepareThreads.contains(serverThread)); + assertFalse(steps.prepareThreads.contains(Thread.currentThread())); + assertFalse(steps.buildThreads.contains(serverThread)); + } finally { + server.shutdownNow(); + } + } + + @Test + public void asyncPreparationFailureCompletesWithoutCreatingALevel() throws Exception { + ModdedDimensionRegistryStore.PersistentDimension dimension = dimensions(1).getFirst(); + RecordingSteps steps = new RecordingSteps(1); + IllegalStateException failure = new IllegalStateException("pack refused"); + steps.prepareFailure = failure; + steps.failingIndex = 0; + ExecutorService server = Executors.newSingleThreadExecutor(); + try { + ModdedDimensionInjection.Async async = + ModdedDimensionInjection.async(dimension, steps, server); + async.start(); + + ModdedDimensionInjection.Result result = async.result().get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertSame(failure, result.failure()); + assertTrue(steps.begun.isEmpty()); + assertTrue(steps.completed.isEmpty()); + } finally { + server.shutdownNow(); + } + } + + @Test + public void asyncEngineFailureAbandonsTheCreatedLevel() throws Exception { + ModdedDimensionRegistryStore.PersistentDimension dimension = dimensions(1).getFirst(); + RecordingSteps steps = new RecordingSteps(1); + IllegalStateException failure = new IllegalStateException("engine refused"); + steps.buildFailure = failure; + steps.failingIndex = 0; + ExecutorService server = Executors.newSingleThreadExecutor(); + try { + ModdedDimensionInjection.Async async = + ModdedDimensionInjection.async(dimension, steps, server); + async.start(); + + ModdedDimensionInjection.Result result = async.result().get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertSame(failure, result.failure()); + assertEquals(List.of("level-0:null"), steps.abandoned); + assertTrue(steps.completed.isEmpty()); + } finally { + server.shutdownNow(); + } + } + + @Test + public void cancellingWhilePreparingDiscardsThePreparationWithoutCreatingALevel() throws Exception { + ModdedDimensionRegistryStore.PersistentDimension dimension = dimensions(1).getFirst(); + RecordingSteps steps = new RecordingSteps(1); + CountDownLatch release = new CountDownLatch(1); + steps.prepareGate = release; + ExecutorService server = Executors.newSingleThreadExecutor(); + try { + ModdedDimensionInjection.Async async = + ModdedDimensionInjection.async(dimension, steps, server); + async.start(); + + server.submit(async::cancel).get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + release.countDown(); + ModdedDimensionInjection.Result result = async.result().get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertTrue(result.failure() instanceof CancellationException); + assertTrue(steps.discardedAll.await(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + assertEquals(List.of("prepared-0"), steps.discarded); + server.submit(() -> null).get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertTrue(steps.begun.isEmpty()); + } finally { + server.shutdownNow(); + } + } + + @Test + public void cancellingWhileBuildingWaitsForTheEngineAndAbandonsTheLevelWithIt() throws Exception { + ModdedDimensionRegistryStore.PersistentDimension dimension = dimensions(1).getFirst(); + RecordingSteps steps = new RecordingSteps(1); + CountDownLatch building = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + steps.buildStarted = building; + steps.buildGate = release; + ExecutorService server = Executors.newSingleThreadExecutor(); + try { + ModdedDimensionInjection.Async async = + ModdedDimensionInjection.async(dimension, steps, server); + async.start(); + assertTrue(building.await(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + Future cancelled = server.submit(async::cancel); + Thread.ofPlatform().daemon(true).start(() -> { + try { + Thread.sleep(200L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + release.countDown(); + }); + cancelled.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + ModdedDimensionInjection.Result result = async.result().get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + server.submit(() -> null).get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertTrue(result.failure() instanceof CancellationException); + assertEquals(List.of("level-0:engine-0"), steps.abandoned); + assertTrue(steps.completed.isEmpty()); + } finally { + server.shutdownNow(); + } + } + private static List dimensions(int count) { List dimensions = new ArrayList<>(count); for (int index = 0; index < count; index++) { @@ -189,15 +330,15 @@ public class ModdedDimensionRestoreTest { } try { if (!gate.await(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { - throw new IllegalStateException("Preparation gate never opened"); + throw new IllegalStateException("Restore step gate never opened"); } } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); - throw new IllegalStateException("Interrupted while waiting for the preparation gate", interrupted); + throw new IllegalStateException("Interrupted while waiting for a restore step gate", interrupted); } } - private static final class RecordingSteps implements ModdedDimensionRestore.Steps { + private static final class RecordingSteps implements ModdedDimensionInjection.Steps { private final Set prepareThreads = ConcurrentHashMap.newKeySet(); private final Set buildThreads = ConcurrentHashMap.newKeySet(); private final Set serverThreads = ConcurrentHashMap.newKeySet(); @@ -210,6 +351,8 @@ public class ModdedDimensionRestoreTest { private CountDownLatch prepareGate; private CountDownLatch prepareBarrier; private CountDownLatch buildBarrier; + private CountDownLatch buildStarted; + private CountDownLatch buildGate; private RuntimeException prepareFailure; private RuntimeException beginFailure; private RuntimeException buildFailure; @@ -248,6 +391,10 @@ public class ModdedDimensionRestoreTest { public String build(String level, String prepared) { buildThreads.add(Thread.currentThread()); await(buildBarrier); + if (buildStarted != null) { + buildStarted.countDown(); + } + awaitGate(buildGate); int index = index(level); if (buildFailure != null && index == failingIndex) { throw buildFailure; @@ -262,9 +409,9 @@ public class ModdedDimensionRestoreTest { } @Override - public void abandon(String level, Throwable failure) { + public void abandon(String level, String engine, Throwable failure) { serverThreads.add(Thread.currentThread()); - abandoned.add(level); + abandoned.add(level + ":" + engine); } @Override diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java index 6810a25c3..ab5004cf9 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java @@ -36,7 +36,7 @@ public class ModdedStartupReinjectFailureTest { IllegalStateException failure = new IllegalStateException("Iris generation history is unusable for 'iris:moon'"); try (MockedStatic log = mockStatic(ModdedIrisLog.class)) { - ModdedStartup.finishDimensionRestore(ModdedDimensionRestore.start(List.of(moon), new FailingSteps(failure))); + ModdedStartup.finishDimensionRestore(ModdedDimensionInjection.start(List.of(moon), new FailingSteps(failure))); log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.contains("iris:moon") && line.startsWith("Iris refused"))), times(1)); @@ -66,7 +66,7 @@ public class ModdedStartupReinjectFailureTest { verify(laterOperator, never()).sendMessage(any()); } - private record FailingSteps(RuntimeException failure) implements ModdedDimensionRestore.Steps { + private record FailingSteps(RuntimeException failure) implements ModdedDimensionInjection.Steps { @Override public String prepare(ModdedDimensionRegistryStore.PersistentDimension dimension) { throw failure; @@ -88,7 +88,7 @@ public class ModdedStartupReinjectFailureTest { } @Override - public void abandon(String level, Throwable failure) { + public void abandon(String level, String engine, Throwable failure) { throw new AssertionError("A failed preparation has no level to abandon"); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedWorldCreateCommandTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedWorldCreateCommandTest.java new file mode 100644 index 000000000..69af628b2 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedWorldCreateCommandTest.java @@ -0,0 +1,154 @@ +package art.arcane.iris.modded.command; + +import art.arcane.iris.modded.MissingPackDimensionException; +import art.arcane.iris.modded.ModdedDimensionCreations; +import art.arcane.iris.pack.BrokenPackException; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandText; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; +import org.mockito.invocation.InvocationOnMock; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class ModdedWorldCreateCommandTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void createAcknowledgesAtOnceAndReportsTheLiveWorldFromALaterServerTask() throws Exception { + CompletableFuture creation = new CompletableFuture<>(); + Harness harness = harness(); + + try (MockedStatic packs = mockStatic(ModdedPackCommands.class); + MockedStatic creations = mockStatic(ModdedDimensionCreations.class)) { + packs.when(ModdedPackCommands::packsRoot).thenReturn(harness.packs()); + creations.when(() -> ModdedDimensionCreations.create(harness.server(), "irisworldgen:moon", "overworld", "overworld", 7L)) + .thenReturn(creation); + + assertEquals(1, ModdedWorldCommands.createWorld(harness.source(), "moon", "overworld", 7L)); + assertTrue(String.join("\n", harness.success()), harness.success().stream() + .anyMatch((String line) -> line.contains("irisworldgen:moon") && !line.startsWith("Created"))); + assertFalse(harness.success().stream().anyMatch((String line) -> line.startsWith("Created Iris world"))); + + creation.complete(null); + harness.runServerTasks(); + } + + assertTrue(String.join("\n", harness.success()), harness.success().stream() + .anyMatch((String line) -> line.startsWith("Created Iris world irisworldgen:moon from pack 'overworld'"))); + assertTrue(harness.failure().isEmpty()); + } + + @Test + public void creationFailureIsReportedAsAFailedInjection() throws Exception { + Harness harness = harness(); + List failures = run(harness, new IllegalStateException("Iris generation history is unusable for 'irisworldgen:moon'.")); + + assertTrue(String.join("\n", failures), failures.stream().anyMatch((String line) -> + line.startsWith("Failed to inject Iris world 'irisworldgen:moon'") && line.contains("generation history is unusable"))); + } + + @Test + public void brokenPackIsReportedWithItsValidationReasons() throws Exception { + Harness harness = harness(); + List failures = run(harness, new BrokenPackException("overworld", List.of("biomes/plains.json is invalid"))); + + assertTrue(String.join("\n", failures), failures.stream().anyMatch((String line) -> line.contains("irisworldgen:moon") + && line.contains("overworld"))); + assertTrue(String.join("\n", failures), failures.stream().anyMatch((String line) -> line.contains("biomes/plains.json is invalid"))); + } + + @Test + public void missingPackDimensionIsReportedAsAMissingDimensionFile() throws Exception { + Harness harness = harness(); + List failures = run(harness, new MissingPackDimensionException("overworld", "overworld")); + + assertTrue(String.join("\n", failures), failures.stream().anyMatch((String line) -> + line.contains("dimensions/overworld.json"))); + } + + private List run(Harness harness, RuntimeException failure) throws Exception { + CompletableFuture creation = new CompletableFuture<>(); + try (MockedStatic packs = mockStatic(ModdedPackCommands.class); + MockedStatic creations = mockStatic(ModdedDimensionCreations.class)) { + packs.when(ModdedPackCommands::packsRoot).thenReturn(harness.packs()); + creations.when(() -> ModdedDimensionCreations.create(harness.server(), "irisworldgen:moon", "overworld", "overworld", 7L)) + .thenReturn(creation); + + assertEquals(1, ModdedWorldCommands.createWorld(harness.source(), "moon", "overworld", 7L)); + assertTrue(harness.failure().isEmpty()); + creation.completeExceptionally(failure); + harness.runServerTasks(); + } + return harness.failure(); + } + + private Harness harness() throws Exception { + File packs = temporaryFolder.newFolder("packs"); + assertTrue(new File(packs, "overworld").mkdirs()); + NativeModdedServer server = mock(NativeModdedServer.class); + List serverTasks = new ArrayList<>(); + doAnswer((InvocationOnMock invocation) -> { + serverTasks.add(invocation.getArgument(0)); + return null; + }).when(server).execute(any()); + List success = new ArrayList<>(); + List failure = new ArrayList<>(); + NativeCommandSource source = mock(NativeCommandSource.class); + when(source.server()).thenReturn(server); + when(source.isGameMaster()).thenReturn(true); + doAnswer((InvocationOnMock invocation) -> { + Supplier text = invocation.getArgument(0); + success.add(text.get().getString()); + return null; + }).when(source).sendSuccess(any(), anyBoolean()); + doAnswer((InvocationOnMock invocation) -> { + NativeCommandText text = invocation.getArgument(0); + failure.add(text.getString()); + return null; + }).when(source).sendFailure(any()); + return new Harness(packs, server, source, serverTasks, success, failure); + } + + private record Harness( + File packs, + NativeModdedServer server, + NativeCommandSource source, + List serverTasks, + List success, + List failure + ) { + void runServerTasks() { + while (!serverTasks.isEmpty()) { + serverTasks.removeFirst().run(); + } + } + } +} diff --git a/core/src/main/resources/languages/de_DE.toml b/core/src/main/resources/languages/de_DE.toml index f59feba8e..32fce3ed2 100644 --- a/core/src/main/resources/languages/de_DE.toml +++ b/core/src/main/resources/languages/de_DE.toml @@ -1077,6 +1077,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "Das erforderliche Pack '{pack}' ist nicht installiert. Installiere es mit /iris download und starte den Server anschließend neu." failed_inject_iris_world = "Iris-Welt '{dimensionId}' konnte nicht eingebunden werden: {value}{errorMessage}" +creating_iris_world_in_background = "Iris-Welt {dimensionId} wird aus Pack '{pack}', Dimension '{packDimension}' (Seed {seed}) im Hintergrund erstellt. Du wirst benachrichtigt, sobald sie bereit ist." created_iris_world_from_pack_dimension_seed = "Iris-Welt {dimensionId} aus Pack '{pack}', Dimension '{packDimension}', erstellt (Seed {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "Sie ist jetzt aktiv und wird bei jedem Start erneut eingebunden. Teleportiere dich über /iris world status oder ein Portal hinein; ein Neustart ist nicht erforderlich." failed_inject_iris_primary_world = "Primäre Iris-Welt konnte nicht eingebunden werden: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/es_ES.toml b/core/src/main/resources/languages/es_ES.toml index 49d86cb38..57c9e6398 100644 --- a/core/src/main/resources/languages/es_ES.toml +++ b/core/src/main/resources/languages/es_ES.toml @@ -1077,6 +1077,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "El pack requerido '{pack}' no está instalado. Instálalo con /iris download y después reinicia el servidor." failed_inject_iris_world = "No se pudo inyectar el mundo de Iris '{dimensionId}': {value}{errorMessage}" +creating_iris_world_in_background = "Creando el mundo de Iris {dimensionId} desde el pack '{pack}', dimensión '{packDimension}' (semilla {seed}) en segundo plano. Se te avisará cuando esté listo." created_iris_world_from_pack_dimension_seed = "Se creó el mundo de Iris {dimensionId} desde el pack '{pack}', dimensión '{packDimension}' (semilla {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "Ya está activo y se vuelve a inyectar en cada inicio. Teletranspórtate con /iris world status o mediante un portal; no es necesario reiniciar." failed_inject_iris_primary_world = "No se pudo inyectar el mundo principal de Iris: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/fi_FI.toml b/core/src/main/resources/languages/fi_FI.toml index 7331c9545..b046ca2ee 100644 --- a/core/src/main/resources/languages/fi_FI.toml +++ b/core/src/main/resources/languages/fi_FI.toml @@ -1082,6 +1082,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "Vaadittua pakettia '{pack}' ei ole asennettu. Asenna se komennolla /iris download ja käynnistä palvelin sitten uudelleen." failed_inject_iris_world = "Injektio epäonnistui Iris maailma{dimensionId}': {value}{errorMessage}" +creating_iris_world_in_background = "Luodaan Iris-maailmaa {dimensionId} pakkauksesta '{pack}', ulottuvuus '{packDimension}' (siemen {seed}), taustalla. Saat ilmoituksen, kun se on valmis." created_iris_world_from_pack_dimension_seed = "Luotu Iris maailma {dimensionId} pakkauksesta \"{pack}' ulottuvuudella'{packDimension}\" (siemen {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "Se on nyt suorassa lähetyksessä. Teleportata /iris world status tai portaali; uudelleenkäynnistystä ei tarvita." failed_inject_iris_primary_world = "Injektio epäonnistui Iris ensisijainen maailma: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/fr_FR.toml b/core/src/main/resources/languages/fr_FR.toml index 3a9d49a4c..ac02f4ed2 100644 --- a/core/src/main/resources/languages/fr_FR.toml +++ b/core/src/main/resources/languages/fr_FR.toml @@ -1077,6 +1077,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "Le pack requis '{pack}' n’est pas installé. Installez-le avec /iris download, puis redémarrez le serveur." failed_inject_iris_world = "Impossible d'injecter le monde Iris '{dimensionId}' : {value}{errorMessage}" +creating_iris_world_in_background = "Création du monde Iris {dimensionId} depuis le pack '{pack}', dimension '{packDimension}' (graine {seed}) en arrière-plan. Vous serez prévenu dès qu'il sera prêt." created_iris_world_from_pack_dimension_seed = "Monde Iris {dimensionId} créé depuis le pack '{pack}', dimension '{packDimension}' (graine {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "Il est maintenant actif et réinjecté à chaque démarrage. Téléportez-vous avec /iris world status ou un portail ; aucun redémarrage nécessaire." failed_inject_iris_primary_world = "Impossible d'injecter le monde principal Iris : {value}{errorMessage}" diff --git a/core/src/main/resources/languages/he_IL.toml b/core/src/main/resources/languages/he_IL.toml index fa8a790f2..a2d4fc6ed 100644 --- a/core/src/main/resources/languages/he_IL.toml +++ b/core/src/main/resources/languages/he_IL.toml @@ -1082,6 +1082,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "החבילה הנדרשת '{pack}' אינה מותקנת. התקן אותה באמצעות /iris download ולאחר מכן הפעל מחדש את השרת." failed_inject_iris_world = "נכשל להזרק Iris עולם »{dimensionId}': {value}{errorMessage}" +creating_iris_world_in_background = "יוצר את עולם Iris {dimensionId} מהחבילה '{pack}', מימד '{packDimension}' (זרע {seed}) ברקע. תקבל הודעה כשהוא יהיה זמין." created_iris_world_from_pack_dimension_seed = "נוצר Iris עולם העולם {dimensionId} מתוך \"חבילה\"{pack}מימד \"{packDimension}(זה {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "הוא חי עכשיו ומופץ מחדש על כל סטארט-אפ. טלפורט עם /iris world status או פורטל, אין צורך מחדש." failed_inject_iris_primary_world = "נכשל להזרק Iris עולם ראשון: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/it_IT.toml b/core/src/main/resources/languages/it_IT.toml index 8b8a2ea36..a16875d36 100644 --- a/core/src/main/resources/languages/it_IT.toml +++ b/core/src/main/resources/languages/it_IT.toml @@ -1077,6 +1077,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "Il pack richiesto '{pack}' non è installato. Installalo con /iris download, quindi riavvia il server." failed_inject_iris_world = "Iniezione del mondo Iris '{dimensionId}' non riuscita: {value}{errorMessage}" +creating_iris_world_in_background = "Creazione del mondo Iris {dimensionId} dal pack '{pack}', dimensione '{packDimension}' (seed {seed}) in background. Riceverai un avviso quando sarà pronto." created_iris_world_from_pack_dimension_seed = "Creato il mondo Iris {dimensionId} dal Pack '{pack}', dimensione '{packDimension}' (seed {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "Ora è attivo e viene iniettato a ogni avvio. Teletrasportati con /iris world status o tramite un portale; non è necessario riavviare." failed_inject_iris_primary_world = "Iniezione del mondo Iris principale non riuscita: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/ja-JP.toml b/core/src/main/resources/languages/ja-JP.toml index cc14b6a4d..efc1d6bae 100644 --- a/core/src/main/resources/languages/ja-JP.toml +++ b/core/src/main/resources/languages/ja-JP.toml @@ -1080,6 +1080,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "必要なパック '{pack}' がインストールされていません。/iris download でインストールしてからサーバーを再起動してください。" failed_inject_iris_world = "Iris ワールド '{dimensionId}' を注入できませんでした: {value}{errorMessage}" +creating_iris_world_in_background = "パック '{pack}' のディメンション '{packDimension}'(シード {seed})から Iris ワールド {dimensionId} をバックグラウンドで作成しています。準備ができたらお知らせします。" created_iris_world_from_pack_dimension_seed = "Iris ワールド {dimensionId} を、パック '{pack}' のディメンション '{packDimension}'(シード {seed})から作成しました。" it_is_live_now_re_injected_on_every_startup_teleport = "現在使用可能で、起動するたびに再注入されます。/iris world status またはポータルからテレポートできます。再起動は不要です。" failed_inject_iris_primary_world = "Iris プライマリーワールドを注入できませんでした: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/ko_KR.toml b/core/src/main/resources/languages/ko_KR.toml index a67aeddd8..6596e77df 100644 --- a/core/src/main/resources/languages/ko_KR.toml +++ b/core/src/main/resources/languages/ko_KR.toml @@ -1080,6 +1080,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "필수 팩 '{pack}'이(가) 설치되어 있지 않습니다. /iris download로 설치한 다음 서버를 다시 시작하세요." failed_inject_iris_world = "Iris 월드 '{dimensionId}' 주입 실패: {value}{errorMessage}" +creating_iris_world_in_background = "팩 '{pack}'의 차원 '{packDimension}'(시드 {seed})에서 Iris 월드 {dimensionId}을(를) 백그라운드에서 만드는 중입니다. 준비되면 알려 드립니다." created_iris_world_from_pack_dimension_seed = "팩 '{pack}'의 차원 '{packDimension}'(시드 {seed})에서 Iris 월드 {dimensionId}을(를) 만들었습니다." it_is_live_now_re_injected_on_every_startup_teleport = "그것은 지금 살고 모든 시작에 거부. Teleport와 함께 /iris world status 또는 포털; 재시작 필요 없음." failed_inject_iris_primary_world = "Iris 기본 월드 주입 실패: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/lt_LT.toml b/core/src/main/resources/languages/lt_LT.toml index 2d1ce337a..fc58ff650 100644 --- a/core/src/main/resources/languages/lt_LT.toml +++ b/core/src/main/resources/languages/lt_LT.toml @@ -1082,6 +1082,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "Reikalingas paketas '{pack}' neįdiegtas. Įdiekite jį naudodami /iris download, tada paleiskite serverį iš naujo." failed_inject_iris_world = "Nepavyko sušvirkšti Iris pasaulis \"{dimensionId}': {value}{errorMessage}" +creating_iris_world_in_background = "Iris pasaulis {dimensionId} kuriamas fone iš paketo '{pack}', dimensija '{packDimension}' (sėkla {seed}). Pranešime, kai jis bus paruoštas." created_iris_world_from_pack_dimension_seed = "Sukurta Iris pasaulis {dimensionId} iš pakuotės \"{pack}\"matmuo\"{packDimension}\"(sėklos {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "Jis gyvena dabar ir pakartotinai įšvirkščiamas į kiekvieną paleidimą. Teleportas su /iris world status arba portalas; nereikia paleisti iš naujo." failed_inject_iris_primary_world = "Nepavyko sušvirkšti Iris pirminis pasaulis: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/nl_NL.toml b/core/src/main/resources/languages/nl_NL.toml index 22e589492..d73f0012f 100644 --- a/core/src/main/resources/languages/nl_NL.toml +++ b/core/src/main/resources/languages/nl_NL.toml @@ -1077,6 +1077,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "Het vereiste pack '{pack}' is niet geïnstalleerd. Installeer het met /iris download en start de server daarna opnieuw." failed_inject_iris_world = "Spuiten mislukt Iris wereld{dimensionId}': {value}{errorMessage}" +creating_iris_world_in_background = "Iris-wereld {dimensionId} wordt op de achtergrond aangemaakt uit pack '{pack}', dimensie '{packDimension}' (seed {seed}). Je krijgt een melding zodra hij klaar is." created_iris_world_from_pack_dimension_seed = "Aangemaakt Iris wereld {dimensionId} uit verpakking{pack}' dimensie'{packDimension}\" (zaad {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "Het is nu live en opnieuw bij elke opstart. Teleporteren met /iris world status of een portaal; geen herstart vereist." failed_inject_iris_primary_world = "Spuiten mislukt Iris primaire wereld: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/pl_PL.toml b/core/src/main/resources/languages/pl_PL.toml index 99a973149..efc686ba8 100644 --- a/core/src/main/resources/languages/pl_PL.toml +++ b/core/src/main/resources/languages/pl_PL.toml @@ -1082,6 +1082,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "Wymagany pakiet '{pack}' nie jest zainstalowany. Zainstaluj go poleceniem /iris download, a następnie uruchom serwer ponownie." failed_inject_iris_world = "Nie można wykonać wstrzyknięcia Iris Świat \"{dimensionId}': {value}{errorMessage}" +creating_iris_world_in_background = "Tworzenie świata Iris {dimensionId} z paczki '{pack}', wymiar '{packDimension}' (ziarno {seed}) w tle. Otrzymasz powiadomienie, gdy będzie gotowy." created_iris_world_from_pack_dimension_seed = "Stworzony Iris świat {dimensionId} od opakowania \"{pack}\"wymiar\"{packDimension}\"(nasiona {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "To jest teraz na żywo i ponownie wstrzyknięte na każdym startup. Teleport /iris world status lub portalu; nie jest wymagane ponowne uruchomienie." failed_inject_iris_primary_world = "Nie można wykonać wstrzyknięcia Iris świat pierwotny: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/pt_PT.toml b/core/src/main/resources/languages/pt_PT.toml index 28f9263cc..b3d7d3271 100644 --- a/core/src/main/resources/languages/pt_PT.toml +++ b/core/src/main/resources/languages/pt_PT.toml @@ -1077,6 +1077,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "O pacote necessário '{pack}' não está instalado. Instale-o com /iris download e depois reinicie o servidor." failed_inject_iris_world = "Não foi possível injectar Iris mundo'{dimensionId}': {value}{errorMessage}" +creating_iris_world_in_background = "A criar o mundo Iris {dimensionId} a partir do pack '{pack}', dimensão '{packDimension}' (semente {seed}) em segundo plano. Serás avisado quando estiver pronto." created_iris_world_from_pack_dimension_seed = "Criado Iris mundo {dimensionId} da pack{pack}« dimensão »{packDimension}« (sementes {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "É ao vivo agora e reinjectado em cada startup. Teletransporte com /iris world status ou um portal; não é necessário reiniciar." failed_inject_iris_primary_world = "Não foi possível injectar Iris mundo primário: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/ru_RU.toml b/core/src/main/resources/languages/ru_RU.toml index 2d1be8afc..6a9367c2c 100644 --- a/core/src/main/resources/languages/ru_RU.toml +++ b/core/src/main/resources/languages/ru_RU.toml @@ -1082,6 +1082,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "Необходимый пакет '{pack}' не установлен. Установите его командой /iris download, затем перезапустите сервер." failed_inject_iris_world = "Не удалось ввести Iris мир{dimensionId}': {value}{errorMessage}" +creating_iris_world_in_background = "Мир Iris {dimensionId} создаётся в фоне из пакета '{pack}', измерение '{packDimension}' (сид {seed}). Вы получите сообщение, когда он будет готов." created_iris_world_from_pack_dimension_seed = "созданный Iris мир {dimensionId} из пакета{pack}Измерение{packDimension}' (seed {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "Теперь он жив и повторно вводится в каждый стартап. Телепортируйте с /iris world status Портал; перезагрузка не требуется." failed_inject_iris_primary_world = "Не удалось ввести Iris Первичный мир: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/tr_TR.toml b/core/src/main/resources/languages/tr_TR.toml index d95fae2e3..80f3631ef 100644 --- a/core/src/main/resources/languages/tr_TR.toml +++ b/core/src/main/resources/languages/tr_TR.toml @@ -1082,6 +1082,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "Gerekli '{pack}' paketi kurulu değil. /iris download ile kurun, ardından sunucuyu yeniden başlatın." failed_inject_iris_world = "enjekte etmek için başarısız oldu Iris Dünya{dimensionId}': {value}{errorMessage}" +creating_iris_world_in_background = "Iris dünyası {dimensionId}, '{pack}' paketinin '{packDimension}' boyutundan (tohum {seed}) arka planda oluşturuluyor. Hazır olduğunda bilgilendirileceksin." created_iris_world_from_pack_dimension_seed = "Oluşturuluş Iris dünya dünyası {dimensionId} paketten ‘{pack}‘ Boyut’{packDimension}\"(s. {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "Şimdi yaşıyor ve her başlangıçta yeniden canlanıyor. Teleport ile birlikte /iris world status veya bir portal; hiçbir yeniden başlama gerekli değildir." failed_inject_iris_primary_world = "enjekte etmek için başarısız oldu Iris birincil dünya: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/vi_VI.toml b/core/src/main/resources/languages/vi_VI.toml index 86a373f1b..e272f6b4b 100644 --- a/core/src/main/resources/languages/vi_VI.toml +++ b/core/src/main/resources/languages/vi_VI.toml @@ -1080,6 +1080,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "Gói bắt buộc '{pack}' chưa được cài đặt. Hãy cài bằng /iris download, rồi khởi động lại máy chủ." failed_inject_iris_world = "Lỗi khi tiêm Iris Thế giới{dimensionId}': {value}{errorMessage}" +creating_iris_world_in_background = "Đang tạo thế giới Iris {dimensionId} từ gói '{pack}', chiều '{packDimension}' (hạt giống {seed}) trong nền. Bạn sẽ được thông báo khi nó sẵn sàng." created_iris_world_from_pack_dimension_seed = "Tạo Iris thế giới {dimensionId} từ gói '{pack}Không gian{packDimension}' (dòng dõi {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "Nó được truyền hình trực tiếp và phát lại vào mỗi lần khởi động. Name /iris world status hoặc cổng; không cần khởi động lại." failed_inject_iris_primary_world = "Lỗi khi tiêm Iris Thế giới chính: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/zh_CN.toml b/core/src/main/resources/languages/zh_CN.toml index 99070694e..eab713f9e 100644 --- a/core/src/main/resources/languages/zh_CN.toml +++ b/core/src/main/resources/languages/zh_CN.toml @@ -1080,6 +1080,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "所需维度包“{pack}”尚未安装。请使用 /iris download 安装,然后重启服务器。" failed_inject_iris_world = "注入失败 Iris 世界{dimensionId}': {value}{errorMessage}" +creating_iris_world_in_background = "正在后台从包 '{pack}' 的维度 '{packDimension}'(种子 {seed})创建 Iris 世界 {dimensionId}。完成后会通知你。" created_iris_world_from_pack_dimension_seed = "创建 Iris 世界 {dimensionId} 从包\"{pack}\" 维度 \"{packDimension}'(种子 {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "它现在正在直播,并在每次启动时重新注入。 传送到 /iris world status 或门户网站;不需要重新启动。" failed_inject_iris_primary_world = "注入失败 Iris 初级世界: {value}{errorMessage}" diff --git a/core/src/main/resources/languages/zh_TW.toml b/core/src/main/resources/languages/zh_TW.toml index edb721831..b8261511c 100644 --- a/core/src/main/resources/languages/zh_TW.toml +++ b/core/src/main/resources/languages/zh_TW.toml @@ -1080,6 +1080,7 @@ opening_image_map_studio_on_server_display = "Opening the Image Map Studio for { [iris.modded.moddedworldcommands] required_pack_is_not_installed_install_then_restart = "所需維度包「{pack}」尚未安裝。請使用 /iris download 安裝,然後重新啟動伺服器。" failed_inject_iris_world = "注入失敗 Iris 世界{dimensionId}': {value}{errorMessage}" +creating_iris_world_in_background = "正在背景從包 '{pack}' 的維度 '{packDimension}'(種子 {seed})建立 Iris 世界 {dimensionId}。完成後會通知你。" created_iris_world_from_pack_dimension_seed = "建立 Iris 世界 {dimensionId} 從包\"{pack}\" 維度 \"{packDimension}'(種子 {seed})." it_is_live_now_re_injected_on_every_startup_teleport = "它現在正在直播,並在每次啟動時重新注入。 傳送到 /iris world status 或入口網站;不需要重新啟動。" failed_inject_iris_primary_world = "注入失敗 Iris 初級世界: {value}{errorMessage}"