From dce7af195556363b683abcfbadea5b93f3da3b6e Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sat, 22 Aug 2026 11:29:37 -0400 Subject: [PATCH] object cleanup --- .../src/main/java/art/arcane/iris/Iris.java | 18 +- .../iris/core/SettingsHotloadWatch.java | 126 ------ .../service/ModdedSettingsHotloadService.java | 94 +---- .../iris/core/SettingsHotloadWatch.java | 294 ++++++++++++++ .../iris/core/localization/IrisLanguage.java | 171 ++++---- .../core/localization/LocaleHotloadGate.java | 100 ----- .../object/IrisObjectPlacementRunner.java | 228 +++++++---- .../iris/engine/object/IrisObjectShaping.java | 8 +- .../iris/core/SettingsHotloadWatchTest.java | 378 ++++++++++++++++++ .../localization/LocaleHotloadGateTest.java | 159 -------- ...isObjectPlacementRunnerRegressionTest.java | 326 +++++++++++++++ ...IrisObjectSurfaceSupportPlacementTest.java | 2 +- 12 files changed, 1257 insertions(+), 647 deletions(-) delete mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java create mode 100644 core/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java delete mode 100644 core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadGate.java create mode 100644 core/src/test/java/art/arcane/iris/core/SettingsHotloadWatchTest.java delete mode 100644 core/src/test/java/art/arcane/iris/core/localization/LocaleHotloadGateTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 40e5da5cc..c75055962 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -96,7 +96,6 @@ import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.exceptions.IrisException; import art.arcane.iris.util.common.format.C; import art.arcane.volmlib.util.function.NastyRunnable; -import art.arcane.volmlib.util.hotload.ConfigHotloadEngine; import art.arcane.volmlib.util.hud.HudActionBar; import art.arcane.volmlib.util.hud.HudBossBarLane; import art.arcane.volmlib.util.io.IO; @@ -165,7 +164,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { public static Bindings.Adventure audiences; public static MultiverseCoreLink linkMultiverseCore; public static IrisCompat compat; - public static ConfigHotloadEngine configHotloadEngine; public static ChunkTickets tickets; private static VolmitSender sender; private static Thread shutdownHook; @@ -663,13 +661,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IrisServices.register(ManagedWorldLoader.class, (ManagedWorldLoader) this::loadManagedWorld); SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("settings.json")); settingsHotloadWatch = watch; - configHotloadEngine = new ConfigHotloadEngine( - watch::isSettingsFile, - watch::knownSettingsFiles, - watch::readSettingsContent, - watch::normalizeSettingsContent - ); - configHotloadEngine.configure(500L, 3_000L, List.of(watch.settingsFile()), List.of()); // Stale-temp cleanup must complete before services enable: StudioSVC.onEnable downloads // packs through cache/temp on an async thread, and a concurrent delete of that folder // truncated pack imports mid-copy (partial packs/ without dimensions/). @@ -714,7 +705,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { pendingWorldReplacements.captureVanillaLevelContext(); pendingWorldReplacements.verifyLoadedPublishedWorlds(); J.a(this::bstats); - J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 10); + J.ar(watch::checkConfigHotload, 10); J.sr(this::tickQueue, 0); J.s(this::setupPapi); if (IrisStartupValidation.isReady()) { @@ -883,9 +874,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { BukkitPlatform.hudBar().shutdown(); BukkitPlatform.hudLanes().shutdown(); } - if (configHotloadEngine != null) { - configHotloadEngine.clear(); - configHotloadEngine = null; + SettingsHotloadWatch activeSettingsHotloadWatch = settingsHotloadWatch; + settingsHotloadWatch = null; + if (activeSettingsHotloadWatch != null) { + activeSettingsHotloadWatch.close(); } // super.onDisable() cancels plugin tasks and unregisters every listener. super.onDisable(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java deleted file mode 100644 index 9ccd9c57a..000000000 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Iris is a World Generator for Minecraft Bukkit 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.core; - -import art.arcane.iris.Iris; -import art.arcane.iris.core.localization.IrisLanguage; -import art.arcane.volmlib.util.hotload.ConfigHotloadEngine; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.List; - -/** - * Identity and hotload handling for settings.json. Supplies the predicates the - * {@link ConfigHotloadEngine} is built from and drains the touched-file queue. - */ -public final class SettingsHotloadWatch { - private static final int MAX_SETTINGS_BYTES = 2 * 1024 * 1024; - - private final File settingsFile; - - public SettingsHotloadWatch(File settingsFile) { - this.settingsFile = settingsFile; - } - - public File settingsFile() { - return settingsFile; - } - - public void checkConfigHotload(ConfigHotloadEngine engine) { - if (engine == null) { - return; - } - - for (ConfigHotloadEngine.StableContentSnapshot snapshot : engine.pollTouchedSnapshots()) { - if ("missing".equals(snapshot.signature())) { - engine.processSnapshotChange(snapshot, ignored -> true, null); - Iris.warn("settings.json was removed; retaining the last valid runtime settings."); - continue; - } - engine.processSnapshotChange( - snapshot, - stable -> applySettingsSnapshot(stable.file(), stable.normalizedContent()), - ignored -> Iris.info("Hotloaded settings.json ") - ); - } - IrisLanguage.update(); - } - - public boolean isSettingsFile(File file) { - if (file == null || settingsFile == null) { - return false; - } - return settingsFile.getAbsoluteFile().equals(file.getAbsoluteFile()); - } - - public List knownSettingsFiles() { - if (settingsFile == null) { - return List.of(); - } - return List.of(settingsFile); - } - - public String readSettingsContent(File file) { - if (file == null || !file.exists() || !file.isFile()) { - return null; - } - - try (InputStream input = Files.newInputStream(file.toPath())) { - byte[] content = input.readNBytes(MAX_SETTINGS_BYTES + 1); - if (content.length > MAX_SETTINGS_BYTES) { - throw new IOException("Settings exceed " + MAX_SETTINGS_BYTES + " bytes: " + file); - } - return new String(content, StandardCharsets.UTF_8); - } catch (Throwable ex) { - Iris.warn("Failed to read settings file %s: %s%s", - file.getAbsolutePath(), - ex.getClass().getSimpleName(), - ex.getMessage() == null ? "" : " - " + ex.getMessage()); - Iris.reportError(ex); - return null; - } - } - - public String normalizeSettingsContent(String text) { - if (text == null) { - return null; - } - - return text.replace("\r\n", "\n").trim(); - } - - private boolean applySettingsSnapshot(File file, String content) { - if (content == null) { - return false; - } - try { - return IrisSettings.applyHotloadSnapshot(content, IrisLanguage::reload); - } catch (RuntimeException failure) { - Iris.warn("Rejected invalid settings hotload from %s: %s", - file.getAbsolutePath(), - failure.getMessage() == null ? failure.getClass().getSimpleName() : failure.getMessage()); - Iris.reportError(failure); - return false; - } - } -} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java index 87a5747a8..1dbc2a679 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java @@ -18,121 +18,45 @@ package art.arcane.iris.modded.service; -import art.arcane.iris.core.IrisSettings; -import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.SettingsHotloadWatch; import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.volmlib.util.hotload.ConfigHotloadEngine; import net.minecraft.server.MinecraftServer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.List; import java.util.concurrent.TimeUnit; public final class ModdedSettingsHotloadService implements ModdedTickableService { - private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); - private static final long POLL_PERIOD_MILLIS = 500L; - private static final long HOTLOAD_COOLDOWN_MILLIS = 3_000L; - private static final int MAX_SETTINGS_BYTES = 2 * 1024 * 1024; - - private ConfigHotloadEngine hotloadEngine; + private SettingsHotloadWatch hotloadWatch; private long lastPollAtNanos; @Override public void onEnable() { - File settingsFile = settingsFile(); - hotloadEngine = new ConfigHotloadEngine( - this::isSettingsFile, - () -> List.of(settingsFile), - this::readSettings, - this::normalizeSettings - ); - hotloadEngine.configure( - POLL_PERIOD_MILLIS, - HOTLOAD_COOLDOWN_MILLIS, - List.of(settingsFile), - List.of() - ); + hotloadWatch = new SettingsHotloadWatch(settingsFile()); lastPollAtNanos = 0L; } @Override public void onDisable() { - ConfigHotloadEngine active = hotloadEngine; - hotloadEngine = null; + SettingsHotloadWatch active = hotloadWatch; + hotloadWatch = null; if (active != null) { - active.clear(); + active.close(); } } @Override public void onServerTick(MinecraftServer server) { - ConfigHotloadEngine active = hotloadEngine; + SettingsHotloadWatch active = hotloadWatch; if (active == null) { return; } long now = System.nanoTime(); - if (now - lastPollAtNanos < TimeUnit.MILLISECONDS.toNanos(POLL_PERIOD_MILLIS)) { + if (now - lastPollAtNanos < TimeUnit.MILLISECONDS.toNanos(SettingsHotloadWatch.POLL_PERIOD_MILLIS)) { return; } lastPollAtNanos = now; - - try { - for (ConfigHotloadEngine.StableContentSnapshot snapshot : active.pollTouchedSnapshots()) { - if ("missing".equals(snapshot.signature())) { - active.processSnapshotChange(snapshot, ignored -> true, null); - LOGGER.warn("settings.json was removed; retaining the last valid runtime settings"); - continue; - } - active.processSnapshotChange(snapshot, this::reloadSettings, ignored -> LOGGER.info("Hotloaded settings.json")); - } - IrisLanguage.update(); - } catch (RuntimeException failure) { - LOGGER.error("Iris settings hotload watcher failed", failure); - } - } - - private boolean reloadSettings(ConfigHotloadEngine.StableContentSnapshot snapshot) { - try { - String content = snapshot.normalizedContent(); - if (content == null) { - return false; - } - return IrisSettings.applyHotloadSnapshot(content, IrisLanguage::reload); - } catch (RuntimeException failure) { - LOGGER.error("Iris settings hotload failed; keeping the previous runtime settings", failure); - return false; - } - } - - private boolean isSettingsFile(File file) { - return file != null && settingsFile().getAbsoluteFile().equals(file.getAbsoluteFile()); - } - - private String readSettings(File file) { - if (file == null || !file.isFile()) { - return null; - } - try (InputStream input = Files.newInputStream(file.toPath())) { - byte[] content = input.readNBytes(MAX_SETTINGS_BYTES + 1); - if (content.length > MAX_SETTINGS_BYTES) { - throw new IOException("Settings exceed " + MAX_SETTINGS_BYTES + " bytes: " + file); - } - return new String(content, StandardCharsets.UTF_8); - } catch (IOException failure) { - throw new UncheckedIOException("Failed to read Iris settings from " + file, failure); - } - } - - private String normalizeSettings(String content) { - return content == null ? null : content.replace("\r\n", "\n").trim(); + active.checkConfigHotload(); } private static File settingsFile() { diff --git a/core/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java b/core/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java new file mode 100644 index 000000000..721fdb759 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/SettingsHotloadWatch.java @@ -0,0 +1,294 @@ +package art.arcane.iris.core; + +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.hotload.ConfigHotloadEngine; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public final class SettingsHotloadWatch implements AutoCloseable { + public static final long POLL_PERIOD_MILLIS = 500L; + public static final long HOTLOAD_COOLDOWN_MILLIS = 3_000L; + private static final int MAX_HOTLOAD_BYTES = 2 * 1024 * 1024; + private static final Timing PRODUCTION_TIMING = new Timing( + POLL_PERIOD_MILLIS, + HOTLOAD_COOLDOWN_MILLIS, + ConfigHotloadEngine.DEFAULT_FULL_WATCH_SCAN_WINDOW_MS, + ConfigHotloadEngine.DEFAULT_SIGNATURE_SCAN_WINDOW_MS + ); + + private final File settingsFile; + private final File localeOverrideFolder; + private final ConfigHotloadEngine hotloadEngine; + private final Map reportedCaptureFailures = new ConcurrentHashMap<>(); + private final Consumer beforeSnapshotApply; + private final BiConsumer manualReloadListener; + private volatile boolean closed; + + public SettingsHotloadWatch(File settingsFile) { + this(settingsFile, PRODUCTION_TIMING); + } + + SettingsHotloadWatch(File settingsFile, Timing timing) { + this(settingsFile, timing, snapshot -> { + }); + } + + SettingsHotloadWatch( + File settingsFile, + Timing timing, + Consumer beforeSnapshotApply + ) { + this.settingsFile = Objects.requireNonNull(settingsFile, "Settings file cannot be null").getAbsoluteFile(); + File dataFolder = Objects.requireNonNull(this.settingsFile.getParentFile(), "Settings data folder cannot be null"); + localeOverrideFolder = new File(dataFolder, "languages/overrides").getAbsoluteFile(); + Timing resolvedTiming = Objects.requireNonNull(timing, "Hotload timing cannot be null"); + this.beforeSnapshotApply = Objects.requireNonNull(beforeSnapshotApply, "Snapshot apply observer cannot be null"); + manualReloadListener = this::acknowledgeManualLocaleReload; + hotloadEngine = new ConfigHotloadEngine( + this::isManagedFile, + this::knownFiles, + this::readContent, + this::normalizeContent, + resolvedTiming.fullScanWindowMillis(), + resolvedTiming.signatureScanWindowMillis() + ); + hotloadEngine.configure( + resolvedTiming.pollPeriodMillis(), + resolvedTiming.cooldownMillis(), + List.of(this.settingsFile), + List.of(localeOverrideFolder) + ); + IrisLanguage.addManualReloadListener(manualReloadListener); + } + + public synchronized void checkConfigHotload() { + if (closed) { + return; + } + synchronized (IrisLanguage.class) { + try { + for (ConfigHotloadEngine.StableContentSnapshot snapshot : hotloadEngine.pollTouchedSnapshots()) { + beforeSnapshotApply.accept(snapshot); + hotloadEngine.processSnapshotChange(snapshot, this::applySnapshot, this::reportApplied); + } + } catch (RuntimeException failure) { + IrisLogging.error("Iris settings and locale hotload watcher failed: " + failureDetail(failure)); + IrisLogging.reportError(failure); + failure.printStackTrace(); + } + } + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + IrisLanguage.removeManualReloadListener(manualReloadListener); + hotloadEngine.clear(); + reportedCaptureFailures.clear(); + } + + boolean applySnapshot(ConfigHotloadEngine.StableContentSnapshot snapshot) { + File file = snapshot.file(); + boolean missing = "missing".equals(snapshot.signature()); + if (isSettingsFile(file)) { + if (missing) { + IrisLogging.warn("settings.json was removed; retaining the last valid runtime settings."); + return true; + } + if (snapshot.normalizedContent() == null) { + reportUnavailableSnapshot(file); + return false; + } + return applySettingsSnapshot(file, snapshot.normalizedContent()); + } + if (!isLocaleOverrideFile(file) || !IrisLanguage.isActiveOverrideFile(file)) { + return true; + } + if (!missing && snapshot.normalizedContent() == null) { + reportUnavailableSnapshot(file); + return false; + } + return applyLocaleSnapshot(file, missing ? null : snapshot.normalizedContent()); + } + + boolean isSettingsFile(File file) { + return file != null && settingsFile.equals(file.getAbsoluteFile()); + } + + boolean isLocaleOverrideFile(File file) { + if (file == null || !file.getName().toLowerCase(Locale.ROOT).endsWith(".json")) { + return false; + } + File parent = file.getAbsoluteFile().getParentFile(); + return localeOverrideFolder.equals(parent); + } + + private boolean isManagedFile(File file) { + return isSettingsFile(file) || isLocaleOverrideFile(file); + } + + private Collection knownFiles() { + List files = new ArrayList<>(); + files.add(settingsFile); + File[] overrides = localeOverrideFolder.listFiles(); + if (overrides == null) { + return files; + } + for (File override : overrides) { + if (isLocaleOverrideFile(override) && override.isFile()) { + files.add(override.getAbsoluteFile()); + } + } + return files; + } + + private String readContent(File file) { + if (file == null) { + return null; + } + if (!file.isFile()) { + clearCaptureFailure(file); + return null; + } + try (InputStream input = Files.newInputStream(file.toPath())) { + byte[] content = input.readNBytes(MAX_HOTLOAD_BYTES + 1); + if (content.length > MAX_HOTLOAD_BYTES) { + throw new IOException("Hotload file exceeds " + MAX_HOTLOAD_BYTES + " bytes: " + file); + } + String decoded = decodeUtf8(content, file); + clearCaptureFailure(file); + return decoded; + } catch (NoSuchFileException missing) { + clearCaptureFailure(file); + return null; + } catch (IOException | SecurityException failure) { + reportCaptureFailure(file, failure); + return null; + } + } + + private String normalizeContent(String content) { + return content == null ? null : content.replace("\r\n", "\n").trim(); + } + + private String decodeUtf8(byte[] content, File file) throws IOException { + try { + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(content)) + .toString(); + } catch (CharacterCodingException failure) { + throw new IOException("Hotload file is not valid UTF-8: " + file, failure); + } + } + + private boolean applySettingsSnapshot(File file, String content) { + try { + return IrisSettings.applyHotloadSnapshot(content, IrisLanguage::reload); + } catch (RuntimeException failure) { + IrisLogging.error("Rejected invalid settings hotload from " + file.getAbsolutePath() + ": " + failureDetail(failure)); + IrisLogging.reportError(failure); + failure.printStackTrace(); + return false; + } + } + + private boolean applyLocaleSnapshot(File file, String content) { + try { + return IrisLanguage.reloadOverride(file, content); + } catch (RuntimeException failure) { + IrisLogging.error("Rejected invalid locale hotload from " + file.getAbsolutePath() + ": " + failureDetail(failure)); + IrisLogging.reportError(failure); + failure.printStackTrace(); + return false; + } + } + + private void acknowledgeManualLocaleReload(File file, String content) { + if (closed) { + return; + } + hotloadEngine.noteSelfWrite(file, content); + clearCaptureFailure(file); + } + + private void reportUnavailableSnapshot(File file) { + if (file.isFile() && file.length() > MAX_HOTLOAD_BYTES) { + reportCaptureFailure( + file, + new IOException("Hotload file exceeds " + MAX_HOTLOAD_BYTES + " bytes: " + file) + ); + } + } + + private void reportCaptureFailure(File file, Throwable failure) { + String path = file.getAbsolutePath(); + String failureKey = failure.getClass().getName() + ":" + failureDetail(failure); + if (Objects.equals(reportedCaptureFailures.put(path, failureKey), failureKey)) { + return; + } + IrisLogging.error("Failed to read watched Iris file " + path + ": " + failureDetail(failure)); + IrisLogging.reportError(failure); + failure.printStackTrace(); + } + + private void clearCaptureFailure(File file) { + reportedCaptureFailures.remove(file.getAbsolutePath()); + } + + private void reportApplied(ConfigHotloadEngine.ContentDelta delta) { + File file = delta.file(); + if (isSettingsFile(file)) { + if (delta.after() != null) { + IrisLogging.info("Hotloaded settings.json"); + } + return; + } + if (IrisLanguage.isActiveOverrideFile(file)) { + IrisLogging.info("Hotloaded locale override " + file.getName()); + } + } + + private String failureDetail(Throwable failure) { + String message = failure.getMessage(); + return failure.getClass().getSimpleName() + (message == null || message.isBlank() ? "" : " - " + message); + } + + record Timing( + long pollPeriodMillis, + long cooldownMillis, + long fullScanWindowMillis, + long signatureScanWindowMillis + ) { + Timing { + if (pollPeriodMillis <= 0L + || cooldownMillis <= 0L + || fullScanWindowMillis <= 0L + || signatureScanWindowMillis <= 0L) { + throw new IllegalArgumentException("Hotload timing values must be positive"); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java index a06c0ed81..6e29a8b08 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java +++ b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java @@ -45,15 +45,13 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; import java.util.regex.Pattern; public final class IrisLanguage { private static final long MAX_LOCALE_BYTES = 2L * 1024L * 1024L; - private static final long HOTLOAD_CONTENT_STABILITY_NANOS = TimeUnit.MILLISECONDS.toNanos(250L); - private static final long HOTLOAD_DELETION_GRACE_NANOS = TimeUnit.SECONDS.toNanos(3L); - private static final long HOTLOAD_COOLDOWN_NANOS = TimeUnit.SECONDS.toNanos(3L); private static final int MAX_REPORTED_ISSUES = 12; private static final Pattern LOCALE_NAME = Pattern.compile("[A-Za-z0-9_-]+"); private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]"); @@ -68,17 +66,9 @@ public final class IrisLanguage { * new snapshot and the whole memo is discarded. Bounded by the catalog size. */ private static final AtomicReference PLAIN_MEMO = new AtomicReference<>(null); - private static final LocaleHotloadGate HOTLOAD_GATE = new LocaleHotloadGate( - new LocaleHotloadGate.Timing( - HOTLOAD_CONTENT_STABILITY_NANOS, - HOTLOAD_DELETION_GRACE_NANOS, - HOTLOAD_COOLDOWN_NANOS - ) - ); - + private static final CopyOnWriteArrayList> MANUAL_RELOAD_LISTENERS = + new CopyOnWriteArrayList<>(); private static volatile File dataFolder; - private static volatile String lastCaptureFailureKey; - private static volatile String lastInvalidHotloadLocale; private static volatile String activeLocale = CATALOG.englishLocale(); private IrisLanguage() { @@ -113,10 +103,22 @@ public final class IrisLanguage { } IrisSettings.IrisSettingsGeneral general = resolvedCandidate.getGeneral(); String locale = general == null ? CATALOG.englishLocale() : general.getLanguage(); - return reload(root, locale); + return reloadResolved(root, locale, false); } public static synchronized boolean reload(File root, String locale) { + return reloadResolved(root, locale, true); + } + + public static void addManualReloadListener(BiConsumer listener) { + MANUAL_RELOAD_LISTENERS.add(Objects.requireNonNull(listener, "Manual reload listener cannot be null")); + } + + public static void removeManualReloadListener(BiConsumer listener) { + MANUAL_RELOAD_LISTENERS.remove(listener); + } + + private static boolean reloadResolved(File root, String locale, boolean notifyManualReload) { File resolvedRoot = root == null ? null : root.getAbsoluteFile(); if (resolvedRoot == null) { throw new IllegalArgumentException("Iris locale data folder cannot be null"); @@ -126,7 +128,6 @@ public final class IrisLanguage { requestedLocale = normalizeLocale(locale); } catch (RuntimeException exception) { dataFolder = resolvedRoot; - HOTLOAD_GATE.reset(null); IrisLogging.error("Rejected locale setting '" + locale + "'; continuing with " + activeLocale + "."); IrisLogging.reportError(exception); exception.printStackTrace(); @@ -135,17 +136,77 @@ public final class IrisLanguage { File override = overrideFile(resolvedRoot, requestedLocale); SnapshotCapture capture = captureForReload(override, requestedLocale); + boolean applied = applyReload(resolvedRoot, requestedLocale, capture); + if (applied && notifyManualReload) { + notifyManualReload(capture.snapshot()); + } + return applied; + } + + public static synchronized boolean reloadOverride(File override, String rawContent) { + File root = dataFolder; + if (root == null && IrisPlatforms.isBound()) { + root = IrisPlatforms.get().dataFolder(); + } + if (root == null || override == null) { + return false; + } + + String configured = configuredLocale(); + String requestedLocale; + try { + requestedLocale = normalizeLocale(configured); + } catch (RuntimeException exception) { + IrisLogging.error("Rejected locale setting '" + configured + "'; continuing with " + activeLocale + "."); + IrisLogging.reportError(exception); + exception.printStackTrace(); + return false; + } + + File expected = overrideFile(root, requestedLocale); + if (!expected.equals(override.getAbsoluteFile())) { + return true; + } + + LocaleHotloadSnapshot snapshot = rawContent == null + ? LocaleHotloadSnapshot.missing(expected, requestedLocale) + : LocaleHotloadSnapshot.present( + expected, + requestedLocale, + rawContent, + sha256(rawContent.getBytes(StandardCharsets.UTF_8)) + ); + return applyReload(root.getAbsoluteFile(), requestedLocale, new SnapshotCapture(snapshot, null)); + } + + public static boolean isActiveOverrideFile(File file) { + if (file == null) { + return false; + } + File root = dataFolder; + if (root == null && IrisPlatforms.isBound()) { + root = IrisPlatforms.get().dataFolder(); + } + if (root == null) { + return false; + } + try { + return overrideFile(root, configuredLocale()).equals(file.getAbsoluteFile()); + } catch (RuntimeException ignored) { + return false; + } + } + + private static boolean applyReload(File root, String requestedLocale, SnapshotCapture capture) { LocalizationReloadResult result; if (capture.failure() == null) { - result = MANAGER.reload(() -> loadCandidate(resolvedRoot, requestedLocale, capture.snapshot())); + result = MANAGER.reload(() -> loadCandidate(root, requestedLocale, capture.snapshot())); } else { result = MANAGER.reload(() -> { throw capture.failure(); }); } - dataFolder = resolvedRoot; - HOTLOAD_GATE.reset(capture.snapshot()); - lastCaptureFailureKey = null; + dataFolder = root; if (!result.applied()) { reportRejectedReload(requestedLocale, result); return false; @@ -158,56 +219,18 @@ public final class IrisLanguage { return true; } - public static synchronized boolean update() { - File root = dataFolder; - if (root == null) { - return initialize(); - } - String locale; - try { - locale = normalizeLocale(configuredLocale()); - } catch (RuntimeException exception) { - String invalidLocale = configuredLocale(); - if (Objects.equals(lastInvalidHotloadLocale, invalidLocale)) { - return false; + private static void notifyManualReload(LocaleHotloadSnapshot snapshot) { + for (BiConsumer listener : MANUAL_RELOAD_LISTENERS) { + try { + listener.accept(snapshot.file(), snapshot.content()); + } catch (RuntimeException failure) { + IrisLogging.error("Failed to acknowledge a manual locale reload: " + + failure.getClass().getSimpleName() + + (failure.getMessage() == null ? "" : " - " + failure.getMessage())); + IrisLogging.reportError(failure); + failure.printStackTrace(); } - lastInvalidHotloadLocale = invalidLocale; - return reload(root, invalidLocale); } - lastInvalidHotloadLocale = null; - File expected = overrideFile(root, locale); - LocaleHotloadSnapshot snapshot; - try { - snapshot = captureHotloadSnapshot(expected, locale); - } catch (IOException | RuntimeException failure) { - HOTLOAD_GATE.unavailable(); - reportCaptureFailure(expected, failure); - return false; - } - if (snapshot == null) { - HOTLOAD_GATE.unavailable(); - return true; - } - lastCaptureFailureKey = null; - - LocaleHotloadGate.Attempt attempt = HOTLOAD_GATE.observe(snapshot, System.nanoTime()); - if (attempt == null) { - return true; - } - - LocalizationReloadResult result = MANAGER.reload(() -> loadCandidate(root, locale, attempt.snapshot())); - boolean applied = result.applied(); - HOTLOAD_GATE.complete(attempt, System.nanoTime(), applied); - if (!applied) { - reportRejectedReload(locale, result); - return false; - } - - activeLocale = locale; - int warnings = result.validation().warnings().size(); - IrisLogging.info("Loaded locale " + locale + " with " + warnings + " fallback " - + (warnings == 1 ? "entry" : "entries") + "."); - return true; } public static String activeLocale() { @@ -606,18 +629,6 @@ public final class IrisLanguage { } } - private static void reportCaptureFailure(File file, Exception failure) { - String detail = failure.getMessage() == null ? failure.getClass().getSimpleName() : failure.getMessage(); - String failureKey = file.getAbsolutePath() + ":" + failure.getClass().getName() + ":" + detail; - if (Objects.equals(failureKey, lastCaptureFailureKey)) { - return; - } - lastCaptureFailureKey = failureKey; - IrisLogging.error("Failed to capture stable locale override " + file.getPath() + ": " + detail); - IrisLogging.reportError(failure); - failure.printStackTrace(); - } - private static void reportRejectedReload(String locale, LocalizationReloadResult result) { IrisLogging.error("Rejected locale reload for " + locale + "; continuing with " + activeLocale + "."); List issues = result.validation().errors(); diff --git a/core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadGate.java b/core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadGate.java deleted file mode 100644 index 08d47d675..000000000 --- a/core/src/main/java/art/arcane/iris/core/localization/LocaleHotloadGate.java +++ /dev/null @@ -1,100 +0,0 @@ -package art.arcane.iris.core.localization; - -import java.util.Objects; - -final class LocaleHotloadGate { - private final Timing timing; - - private long generation; - private LocaleHotloadSnapshot baseline; - private LocaleHotloadSnapshot observed; - private long observedSinceNanos; - private LocaleHotloadSnapshot pending; - private Attempt inFlight; - private long lastCompletedAtNanos; - private boolean hasCompleted; - - LocaleHotloadGate(Timing timing) { - this.timing = Objects.requireNonNull(timing, "Locale hotload timing cannot be null"); - } - - synchronized Attempt observe(LocaleHotloadSnapshot snapshot, long nowNanos) { - LocaleHotloadSnapshot current = Objects.requireNonNull(snapshot, "Locale hotload snapshot cannot be null"); - if (!current.equals(observed)) { - observed = current; - observedSinceNanos = nowNanos; - return null; - } - - long stabilityNanos = current.missing() - ? timing.deletionGraceNanos() - : timing.contentStabilityNanos(); - if (!elapsed(nowNanos, observedSinceNanos, stabilityNanos)) { - return null; - } - - pending = current.equals(baseline) ? null : current; - if (pending == null || inFlight != null || !pending.equals(observed)) { - return null; - } - if (hasCompleted && !elapsed(nowNanos, lastCompletedAtNanos, timing.cooldownNanos())) { - return null; - } - - Attempt attempt = new Attempt(generation, pending); - inFlight = attempt; - return attempt; - } - - synchronized void unavailable() { - observed = null; - observedSinceNanos = 0L; - } - - synchronized void complete(Attempt attempt, long nowNanos, boolean applied) { - if (attempt == null || inFlight == null || !inFlight.equals(attempt) || attempt.generation() != generation) { - return; - } - - inFlight = null; - lastCompletedAtNanos = nowNanos; - hasCompleted = true; - if (!applied) { - return; - } - - baseline = attempt.snapshot(); - if (attempt.snapshot().equals(pending)) { - pending = null; - } - } - - synchronized void reset(LocaleHotloadSnapshot snapshot) { - generation++; - baseline = snapshot; - observed = snapshot; - observedSinceNanos = 0L; - pending = null; - inFlight = null; - lastCompletedAtNanos = 0L; - hasCompleted = false; - } - - private boolean elapsed(long nowNanos, long startNanos, long durationNanos) { - return nowNanos - startNanos >= durationNanos; - } - - record Timing(long contentStabilityNanos, long deletionGraceNanos, long cooldownNanos) { - Timing { - if (contentStabilityNanos < 0L || deletionGraceNanos < 0L || cooldownNanos < 0L) { - throw new IllegalArgumentException("Locale hotload timing cannot be negative"); - } - } - } - - record Attempt(long generation, LocaleHotloadSnapshot snapshot) { - Attempt { - snapshot = Objects.requireNonNull(snapshot, "Locale hotload attempt snapshot cannot be null"); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java index 1cdf98bc1..f8a73780e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java @@ -39,6 +39,7 @@ import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.MatterMarker; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiConsumer; @@ -61,10 +62,14 @@ final class IrisObjectPlacementRunner { } int place(int x, int yv, int z, IObjectPlacer oplacer, IrisObjectPlacement config, RNG rng, BiConsumer listener, CarveResult c, IrisData rdata) { + Objects.requireNonNull(oplacer, "Object placer is required."); + Objects.requireNonNull(config, "Object placement config is required."); + Objects.requireNonNull(rng, "Object placement RNG is required."); + Objects.requireNonNull(rdata, "Object placement data is required."); IObjectPlacer placer = config.getHeightmap() != null ? new HeightmapObjectPlacer(rng, x, yv, z, config, oplacer) : oplacer; boolean evaluateSlopeCondition = !config.isForcePlace() && !config.getSlopeCondition().isDefault(); - if (rdata != null && (evaluateSlopeCondition || config.isRotateTowardsSlope())) { + if (evaluateSlopeCondition || config.isRotateTowardsSlope()) { Engine placementEngine = requireSlopeEngine(placer); if (evaluateSlopeCondition && !config.getSlopeCondition().isValid(placementEngine.getComplex().getSlopeStream().get(x, z))) { @@ -108,7 +113,7 @@ final class IrisObjectPlacementRunner { } if (config.isSmartBore()) { - IrisObjectShaping.ensureSmartBored(self, placer.isDebugSmartBore()); + IrisObjectShaping.ensureSmartBored(self); } boolean warped = !config.getWarp().isFlat(); @@ -148,6 +153,8 @@ final class IrisObjectPlacementRunner { int xx, zz; int yrand = config.getTranslate().getYRandom(); yrand = yrand > 0 ? rng.i(0, yrand) : yrand < 0 ? rng.i(yrand, 0) : yrand; + int warpMargin = warped ? (int) Math.ceil(Math.abs(config.getWarp().getMultiplier()) / 2D) : 0; + TransformedBounds placementBounds = transformedBounds(spin, translating, translateOffset, ceilingHang, warpMargin); boolean bail = false; if (config.isFromBottom()) { @@ -168,14 +175,10 @@ final class IrisObjectPlacementRunner { } } } else if (config.getMode().equals(ObjectPlaceMode.MAX_HEIGHT) || config.getMode().equals(ObjectPlaceMode.STILT)) { - IrisBlockVector offset = new IrisBlockVector(config.getTranslate().getX(), config.getTranslate().getY(), config.getTranslate().getZ()); - IrisBlockVector rotatedDimensions = config.getRotation().rotate(new IrisBlockVector(self.getW(), self.getH(), self.getD()), spinx, spiny, spinz).clone(); - int xLength = (rotatedDimensions.getBlockX() / 2) + offset.getBlockX(); - int minX = Math.min(x - xLength, x + xLength); - int maxX = Math.max(x - xLength, x + xLength); - int zLength = (rotatedDimensions.getBlockZ() / 2) + offset.getBlockZ(); - int minZ = Math.min(z - zLength, z + zLength); - int maxZ = Math.max(z - zLength, z + zLength); + int minX = x + placementBounds.minX(); + int maxX = x + placementBounds.maxX(); + int minZ = z + placementBounds.minZ(); + int maxZ = z + placementBounds.maxZ(); for (int i = minX; i <= maxX; i++) { for (int ii = minZ; ii <= maxZ; ii++) { int h = placer.getHighest(i, ii, self.getLoader(), config.isUnderwater()) + rty; @@ -190,17 +193,12 @@ final class IrisObjectPlacementRunner { } } } else if (config.getMode().equals(ObjectPlaceMode.FAST_MAX_HEIGHT) || config.getMode().equals(ObjectPlaceMode.FAST_STILT)) { - IrisBlockVector offset = new IrisBlockVector(config.getTranslate().getX(), config.getTranslate().getY(), config.getTranslate().getZ()); - IrisBlockVector rotatedDimensions = config.getRotation().rotate(new IrisBlockVector(self.getW(), self.getH(), self.getD()), spinx, spiny, spinz).clone(); - - int xRadius = (rotatedDimensions.getBlockX() / 2); - int xLength = xRadius + offset.getBlockX(); - int minX = Math.min(x - xLength, x + xLength); - int maxX = Math.max(x - xLength, x + xLength); - int zRadius = (rotatedDimensions.getBlockZ() / 2); - int zLength = zRadius + offset.getBlockZ(); - int minZ = Math.min(z - zLength, z + zLength); - int maxZ = Math.max(z - zLength, z + zLength); + int minX = x + placementBounds.minX(); + int maxX = x + placementBounds.maxX(); + int minZ = z + placementBounds.minZ(); + int maxZ = z + placementBounds.maxZ(); + int xRadius = Math.max(0, (maxX - minX) / 2); + int zRadius = Math.max(0, (maxZ - minZ) / 2); for (int i = minX; i <= maxX; i += Math.abs(xRadius) + 1) { for (int ii = minZ; ii <= maxZ; ii += Math.abs(zRadius) + 1) { @@ -216,16 +214,11 @@ final class IrisObjectPlacementRunner { } } } else if (config.getMode().equals(ObjectPlaceMode.MIN_HEIGHT) || config.getMode() == ObjectPlaceMode.MIN_STILT) { - y = rdata.getEngine().getHeight() + 1; - IrisBlockVector offset = new IrisBlockVector(config.getTranslate().getX(), config.getTranslate().getY(), config.getTranslate().getZ()); - IrisBlockVector rotatedDimensions = config.getRotation().rotate(new IrisBlockVector(self.getW(), self.getH(), self.getD()), spinx, spiny, spinz).clone(); - - int xLength = (rotatedDimensions.getBlockX() / 2) + offset.getBlockX(); - int minX = Math.min(x - xLength, x + xLength); - int maxX = Math.max(x - xLength, x + xLength); - int zLength = (rotatedDimensions.getBlockZ() / 2) + offset.getBlockZ(); - int minZ = Math.min(z - zLength, z + zLength); - int maxZ = Math.max(z - zLength, z + zLength); + y = requireDataEngine(rdata, "minimum-height mode").getHeight() + 1; + int minX = x + placementBounds.minX(); + int maxX = x + placementBounds.maxX(); + int minZ = z + placementBounds.minZ(); + int maxZ = z + placementBounds.maxZ(); for (int i = minX; i <= maxX; i++) { for (int ii = minZ; ii <= maxZ; ii++) { int h = placer.getHighest(i, ii, self.getLoader(), config.isUnderwater()) + rty; @@ -241,18 +234,13 @@ final class IrisObjectPlacementRunner { } } } else if (config.getMode().equals(ObjectPlaceMode.FAST_MIN_HEIGHT) || config.getMode() == ObjectPlaceMode.FAST_MIN_STILT) { - y = rdata.getEngine().getHeight() + 1; - IrisBlockVector offset = new IrisBlockVector(config.getTranslate().getX(), config.getTranslate().getY(), config.getTranslate().getZ()); - IrisBlockVector rotatedDimensions = config.getRotation().rotate(new IrisBlockVector(self.getW(), self.getH(), self.getD()), spinx, spiny, spinz).clone(); - - int xRadius = (rotatedDimensions.getBlockX() / 2); - int xLength = xRadius + offset.getBlockX(); - int minX = Math.min(x - xLength, x + xLength); - int maxX = Math.max(x - xLength, x + xLength); - int zRadius = (rotatedDimensions.getBlockZ() / 2); - int zLength = zRadius + offset.getBlockZ(); - int minZ = Math.min(z - zLength, z + zLength); - int maxZ = Math.max(z - zLength, z + zLength); + y = requireDataEngine(rdata, "fast minimum-height mode").getHeight() + 1; + int minX = x + placementBounds.minX(); + int maxX = x + placementBounds.maxX(); + int minZ = z + placementBounds.minZ(); + int maxZ = z + placementBounds.maxZ(); + int xRadius = Math.max(0, (maxX - minX) / 2); + int zRadius = Math.max(0, (maxZ - minZ) / 2); for (int i = minX; i <= maxX; i += Math.abs(xRadius) + 1) { for (int ii = minZ; ii <= maxZ; ii += Math.abs(zRadius) + 1) { @@ -352,18 +340,23 @@ final class IrisObjectPlacementRunner { return -1; } - int warpMargin = warped ? (int) Math.ceil(Math.abs(config.getWarp().getMultiplier()) / 2D) : 0; if (!rawStructurePiece && nativeStructureVetoes(placer, config, spin, translating, translateOffset, ceilingHang, yv < 0 && config.getMode() == ObjectPlaceMode.PAINT, warpMargin, x, y + yrand, z)) { return -1; } + boolean paint = yv < 0 && config.getMode() == ObjectPlaceMode.PAINT; + WorldBounds worldBounds = null; + if (config.isBore() || (!config.isForcePlace() && !rawStructurePiece + && (!config.getAllowedCollisions().isEmpty() || !config.getForbiddenCollisions().isEmpty()))) { + worldBounds = resolveWorldBounds(placer, config, placementBounds, paint, x, y + yrand, z); + } + if (!config.isForcePlace() && !rawStructurePiece && (!config.getAllowedCollisions().isEmpty() || !config.getForbiddenCollisions().isEmpty())) { - Engine engine = rdata.getEngine(); - IrisBlockVector offset = new IrisBlockVector(config.getTranslate().getX(), config.getTranslate().getY(), config.getTranslate().getZ()); - for (int i = x - Math.floorDiv(self.w, 2) + (int) offset.getX(); i <= x + Math.floorDiv(self.w, 2) - (self.w % 2 == 0 ? 1 : 0) + (int) offset.getX(); i++) { - for (int j = y - Math.floorDiv(self.h, 2) + (int) offset.getY(); j <= y + Math.floorDiv(self.h, 2) - (self.h % 2 == 0 ? 1 : 0) + (int) offset.getY(); j++) { - for (int k = z - Math.floorDiv(self.d, 2) + (int) offset.getZ(); k <= z + Math.floorDiv(self.d, 2) - (self.d % 2 == 0 ? 1 : 0) + (int) offset.getZ(); k++) { + Engine engine = requireDataEngine(rdata, "collision settings"); + for (int i = worldBounds.minX(); i <= worldBounds.maxX(); i++) { + for (int j = worldBounds.minY(); j <= worldBounds.maxY(); j++) { + for (int k = worldBounds.minZ(); k <= worldBounds.maxZ(); k++) { PlacedObject p = engine.getObjectPlacement(i, j, k); if (p == null) continue; IrisObject o = p.getObject(); @@ -379,11 +372,12 @@ final class IrisObjectPlacementRunner { } } + y += yrand; + if (config.isBore()) { - IrisBlockVector offset = new IrisBlockVector(config.getTranslate().getX(), config.getTranslate().getY(), config.getTranslate().getZ()); - for (int i = x - Math.floorDiv(self.w, 2) + (int) offset.getX(); i <= x + Math.floorDiv(self.w, 2) - (self.w % 2 == 0 ? 1 : 0) + (int) offset.getX(); i++) { - for (int j = y - Math.floorDiv(self.h, 2) - config.getBoreExtendMinY() + (int) offset.getY(); j <= y + Math.floorDiv(self.h, 2) + config.getBoreExtendMaxY() - (self.h % 2 == 0 ? 1 : 0) + (int) offset.getY(); j++) { - for (int k = z - Math.floorDiv(self.d, 2) + (int) offset.getZ(); k <= z + Math.floorDiv(self.d, 2) - (self.d % 2 == 0 ? 1 : 0) + (int) offset.getZ(); k++) { + for (int i = worldBounds.minX(); i <= worldBounds.maxX(); i++) { + for (int j = worldBounds.minY() - config.getBoreExtendMinY(); j <= worldBounds.maxY() + config.getBoreExtendMaxY(); j++) { + for (int k = worldBounds.minZ(); k <= worldBounds.maxZ(); k++) { placer.set(i, j, k, IrisObject.States.AIR); } } @@ -394,7 +388,6 @@ final class IrisObjectPlacementRunner { int topLayer = Integer.MIN_VALUE; int vacuumLowest = Integer.MAX_VALUE; int vacuumHighest = Integer.MIN_VALUE; - y += yrand; self.readLock.lock(); KMap markers = null; @@ -407,7 +400,7 @@ final class IrisObjectPlacementRunner { if (config.getMarkers().isNotEmpty() && placer.getEngine() != null) { markers = new KMap<>(); - var list = StreamSupport.stream(blocks.keys().spliterator(), false) + KList list = StreamSupport.stream(blocks.keys().spliterator(), false) .collect(KList.collector()); // Marker selection persists into the mantle, so it must be seed-deterministic. // Derive a side stream keyed on the placement position instead of consuming the @@ -476,6 +469,10 @@ final class IrisObjectPlacementRunner { d = IrisObject.States.AIR; } + if (placer.isDebugSmartBore() && IrisObject.States.VAIR.equals(d)) { + d = IrisObject.States.VAIR_DEBUG; + } + PlatformBlockState data = d; IrisBlockVector i = g.clone(); spin.rotate(i); @@ -536,18 +533,6 @@ final class IrisObjectPlacementRunner { yy = (int) Math.round(i.getY()) + Math.floorDiv(self.h, 2) + placer.getHighest(xx, zz, self.getLoader(), config.isUnderwater()); } - if (heightmap != null) { - Position2 pos = new Position2(xx, zz); - - if (!heightmap.containsKey(pos)) { - heightmap.put(pos, yy); - } - - if (heightmap.get(pos) < yy) { - heightmap.put(pos, yy); - } - } - if (config.isMeld() && !rawStructurePiece && !placer.isSolid(xx, yy, zz)) { continue; } @@ -569,6 +554,13 @@ final class IrisObjectPlacementRunner { if (data.isCustom() || place) { placer.set(xx, yy, zz, data); + if (heightmap != null) { + Position2 pos = new Position2(xx, zz); + Integer currentHeight = heightmap.get(pos); + if (currentHeight == null || currentHeight < yy) { + heightmap.put(pos, yy); + } + } if (tile != null) { placer.setTile(xx, yy, zz, tile); } @@ -586,9 +578,6 @@ final class IrisObjectPlacementRunner { } } } - } catch (Throwable e) { - e.printStackTrace(); - IrisLogging.reportError(e); } finally { self.readLock.unlock(); } @@ -832,14 +821,8 @@ final class IrisObjectPlacementRunner { } if (vacuuming && vacuumLowest != Integer.MAX_VALUE && placer.getEngine() != null) { - IrisBlockVector rotDim = config.getRotation().rotate(new IrisBlockVector(self.getW(), self.getH(), self.getD()), spinx, spiny, spinz).clone(); - int lowX = IrisObjectVacuum.footprintLow(rotDim.getBlockX()); - int highX = IrisObjectVacuum.footprintHigh(rotDim.getBlockX()); - int lowZ = IrisObjectVacuum.footprintLow(rotDim.getBlockZ()); - int highZ = IrisObjectVacuum.footprintHigh(rotDim.getBlockZ()); - int centerX = x + config.getTranslate().getX(); - int centerZ = z + config.getTranslate().getZ(); - vacuumTerrain(placer, config, centerX, centerZ, lowX, highX, lowZ, highZ, vacuumLowest, vacuumHighest); + vacuumTerrain(placer, config, x, z, placementBounds.minX(), placementBounds.maxX(), + placementBounds.minZ(), placementBounds.maxZ(), vacuumLowest, vacuumHighest); } if (heightmap != null) { @@ -864,10 +847,91 @@ final class IrisObjectPlacementRunner { return !wouldReplace && (rawStructurePiece || !air); } + private TransformedBounds transformedBounds(SpinKernel spin, boolean translating, IrisBlockVector translateOffset, + boolean ceilingHang, int margin) { + int sourceMinX = -self.getCenter().getBlockX(); + int sourceMaxX = self.getW() - self.getCenter().getBlockX() - 1; + int sourceMinY = -self.getCenter().getBlockY(); + int sourceMaxY = self.getH() - self.getCenter().getBlockY() - 1; + int sourceMinZ = -self.getCenter().getBlockZ(); + int sourceMaxZ = self.getD() - self.getCenter().getBlockZ() - 1; + int minX = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int minY = Integer.MAX_VALUE; + int maxY = Integer.MIN_VALUE; + int minZ = Integer.MAX_VALUE; + int maxZ = Integer.MIN_VALUE; + + for (int xCorner = 0; xCorner < 2; xCorner++) { + int sourceX = xCorner == 0 ? sourceMinX : sourceMaxX; + for (int yCorner = 0; yCorner < 2; yCorner++) { + int sourceY = yCorner == 0 ? sourceMinY : sourceMaxY; + for (int zCorner = 0; zCorner < 2; zCorner++) { + int sourceZ = zCorner == 0 ? sourceMinZ : sourceMaxZ; + IrisBlockVector corner = new IrisBlockVector(sourceX, sourceY, sourceZ); + spin.rotate(corner); + if (ceilingHang) { + corner.setY(-corner.getBlockY()); + } + if (translating) { + corner.add(translateOffset); + } + int transformedX = (int) Math.round(corner.getX()); + int transformedY = (int) Math.round(corner.getY()); + int transformedZ = (int) Math.round(corner.getZ()); + minX = Math.min(minX, transformedX); + maxX = Math.max(maxX, transformedX); + minY = Math.min(minY, transformedY); + maxY = Math.max(maxY, transformedY); + minZ = Math.min(minZ, transformedZ); + maxZ = Math.max(maxZ, transformedZ); + } + } + } + + return new TransformedBounds(minX - margin, maxX + margin, minY, maxY, minZ - margin, maxZ + margin); + } + + private WorldBounds resolveWorldBounds(IObjectPlacer placer, IrisObjectPlacement config, TransformedBounds bounds, + boolean paint, int x, int y, int z) { + int minX = x + bounds.minX(); + int maxX = x + bounds.maxX(); + int minZ = z + bounds.minZ(); + int maxZ = z + bounds.maxZ(); + if (!paint) { + return new WorldBounds(minX, maxX, y + bounds.minY(), y + bounds.maxY(), minZ, maxZ); + } + + int minimumSurface = Integer.MAX_VALUE; + int maximumSurface = Integer.MIN_VALUE; + for (int worldX = minX; worldX <= maxX; worldX++) { + for (int worldZ = minZ; worldZ <= maxZ; worldZ++) { + int surface = placer.getHighest(worldX, worldZ, self.getLoader(), config.isUnderwater()); + minimumSurface = Math.min(minimumSurface, surface); + maximumSurface = Math.max(maximumSurface, surface); + } + } + int paintOffset = Math.floorDiv(self.h, 2); + return new WorldBounds(minX, maxX, minimumSurface + bounds.minY() + paintOffset, + maximumSurface + bounds.maxY() + paintOffset, minZ, maxZ); + } + private static Engine requireSlopeEngine(IObjectPlacer placer) { + return requirePlacementEngine(placer, "slope settings"); + } + + private static Engine requirePlacementEngine(IObjectPlacer placer, String feature) { Engine engine = placer.getEngine(); if (engine == null) { - throw new IllegalStateException("Object placement slope settings require an active Iris engine for the target world."); + throw new IllegalStateException("Object placement requires an active Iris engine for " + feature + "."); + } + return engine; + } + + private static Engine requireDataEngine(IrisData data, String feature) { + Engine engine = data.getEngine(); + if (engine == null) { + throw new IllegalStateException("Object placement requires active Iris data for " + feature + "."); } return engine; } @@ -1229,4 +1293,10 @@ final class IrisObjectPlacementRunner { } } } + + private record TransformedBounds(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) { + } + + private record WorldBounds(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) { + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java index b75d57964..bac39db6f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectShaping.java @@ -41,7 +41,7 @@ final class IrisObjectShaping { private IrisObjectShaping() { } - static void ensureSmartBored(IrisObject self, boolean debug) { + static void ensureSmartBored(IrisObject self) { if (self.smartBored) { return; } @@ -53,15 +53,15 @@ final class IrisObjectShaping { if (self.smartBored) { return; } - ensureSmartBoredLocked(self, debug); + ensureSmartBoredLocked(self); } finally { self.writeLock.unlock(); } } - private static void ensureSmartBoredLocked(IrisObject self, boolean debug) { + private static void ensureSmartBoredLocked(IrisObject self) { PrecisionStopwatch p = PrecisionStopwatch.start(); - PlatformBlockState vair = debug ? IrisObject.States.VAIR_DEBUG : IrisObject.States.VAIR; + PlatformBlockState vair = IrisObject.States.VAIR; AtomicInteger applied = new AtomicInteger(); IrisBlockVector max = new IrisBlockVector(Double.MIN_VALUE, Double.MIN_VALUE, Double.MIN_VALUE); IrisBlockVector min = new IrisBlockVector(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE); diff --git a/core/src/test/java/art/arcane/iris/core/SettingsHotloadWatchTest.java b/core/src/test/java/art/arcane/iris/core/SettingsHotloadWatchTest.java new file mode 100644 index 000000000..0543dde5b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/SettingsHotloadWatchTest.java @@ -0,0 +1,378 @@ +package art.arcane.iris.core; + +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.localization.IrisMessages; +import art.arcane.volmlib.util.hotload.ConfigHotloadEngine; +import art.arcane.volmlib.util.localization.MessageArgument; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class SettingsHotloadWatchTest { + private static final String PERMISSION = "iris.all"; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisSettings previousSettings; + private File dataFolder; + private File settingsFile; + private File overrideFolder; + private SettingsHotloadWatch watch; + + @Before + public void setUp() throws Exception { + previousSettings = IrisSettings.settings; + dataFolder = temporaryFolder.newFolder("iris-hotload"); + settingsFile = new File(dataFolder, "settings.json"); + overrideFolder = new File(dataFolder, "languages/overrides"); + Files.createDirectories(overrideFolder.toPath()); + String settings = settings("en_US"); + Files.writeString(settingsFile.toPath(), settings, StandardCharsets.UTF_8); + IrisSettings.settings = IrisSettings.parseHotloadSnapshot(settings); + assertTrue(IrisLanguage.reload(dataFolder, "en_US")); + watch = new SettingsHotloadWatch( + settingsFile, + new SettingsHotloadWatch.Timing(100L, 100L, 100L, 100L) + ); + } + + @After + public void tearDown() { + if (watch != null) { + watch.close(); + } + IrisLanguage.reload(dataFolder, "en_US"); + IrisSettings.settings = previousSettings; + } + + @Test + public void activeLocaleSnapshotAppliesAndInvalidSnapshotRetainsLastGood() { + File override = override("en_US"); + String valid = locale("en_US", "Active {permission}"); + + assertTrue(watch.applySnapshot(present(override, valid))); + assertEquals("Active " + PERMISSION, permissionMessage()); + + assertFalse(watch.applySnapshot(present(override, "{ invalid"))); + assertEquals("Active " + PERMISSION, permissionMessage()); + } + + @Test + public void activeLocaleDeletionFallsBackToCodeOwnedEnglish() { + File override = override("en_US"); + assertTrue(watch.applySnapshot(present(override, locale("en_US", "Temporary {permission}")))); + assertEquals("Temporary " + PERMISSION, permissionMessage()); + + assertTrue(watch.applySnapshot(missing(override))); + + assertEquals("You lack the permission '" + PERMISSION + "'", permissionMessage()); + assertEquals("en_US", IrisLanguage.activeLocale()); + } + + @Test + public void inactiveLocaleSnapshotDoesNotChangeTheRuntimeCatalog() { + String before = permissionMessage(); + + assertTrue(watch.applySnapshot(present( + override("de_DE"), + locale("de_DE", "Inaktiv {permission}") + ))); + + assertEquals(before, permissionMessage()); + assertEquals("en_US", IrisLanguage.activeLocale()); + } + + @Test + public void settingsSnapshotChangesTheActiveLocaleFromItsImmutableOverride() throws Exception { + Files.writeString( + override("de_DE").toPath(), + locale("de_DE", "Berechtigung {permission}"), + StandardCharsets.UTF_8 + ); + + assertTrue(watch.applySnapshot(present(settingsFile, settings("de_DE")))); + + assertEquals("de_DE", IrisSettings.get().getGeneral().getLanguage()); + assertEquals("de_DE", IrisLanguage.activeLocale()); + assertEquals("Berechtigung " + PERMISSION, permissionMessage()); + } + + @Test + public void invalidLocaleBlocksItsSettingsSwitchAndRetainsThePreviousRuntime() throws Exception { + Files.writeString(override("de_DE").toPath(), "{ invalid", StandardCharsets.UTF_8); + + assertFalse(watch.applySnapshot(present(settingsFile, settings("de_DE")))); + + assertEquals("en_US", IrisSettings.get().getGeneral().getLanguage()); + assertEquals("en_US", IrisLanguage.activeLocale()); + assertEquals("You lack the permission '" + PERMISSION + "'", permissionMessage()); + } + + @Test(timeout = 8_000L) + public void sameMetadataLocaleReplacementStillHotloads() throws Exception { + File override = override("en_US"); + FileTime fixedTime = FileTime.fromMillis(10_000L); + String first = locale("en_US", "First {permission}"); + String second = locale("en_US", "Other {permission}"); + assertEquals(first.getBytes(StandardCharsets.UTF_8).length, second.getBytes(StandardCharsets.UTF_8).length); + + Files.writeString(override.toPath(), first, StandardCharsets.UTF_8); + Files.setLastModifiedTime(override.toPath(), fixedTime); + awaitPermissionMessage("First " + PERMISSION); + + Files.writeString(override.toPath(), second, StandardCharsets.UTF_8); + Files.setLastModifiedTime(override.toPath(), fixedTime); + awaitPermissionMessage("Other " + PERMISSION); + } + + @Test(timeout = 8_000L) + public void closeWaitsForInFlightSnapshotAndStopsLaterApplies() throws Exception { + File override = override("en_US"); + CountDownLatch applyEntered = new CountDownLatch(1); + CountDownLatch releaseApply = new CountDownLatch(1); + AtomicInteger automaticApplies = new AtomicInteger(); + replaceWatch(new SettingsHotloadWatch.Timing(100L, 100L, 100L, 100L), snapshot -> { + if (!override.equals(snapshot.file())) { + return; + } + automaticApplies.incrementAndGet(); + applyEntered.countDown(); + try { + releaseApply.await(); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while holding the hotload apply boundary", failure); + } + }); + Files.writeString(override.toPath(), locale("en_US", "During {permission}"), StandardCharsets.UTF_8); + + Thread checker = new Thread(() -> checkUntilEntered(applyEntered), "Iris-Hotload-Lifecycle-Test"); + checker.start(); + assertTrue(applyEntered.await(5L, TimeUnit.SECONDS)); + + CountDownLatch closeStarted = new CountDownLatch(1); + AtomicBoolean closeCompleted = new AtomicBoolean(); + Thread closer = new Thread(() -> { + closeStarted.countDown(); + watch.close(); + closeCompleted.set(true); + }, "Iris-Hotload-Close-Test"); + closer.start(); + assertTrue(closeStarted.await(1L, TimeUnit.SECONDS)); + awaitThreadState(closer, Thread.State.BLOCKED); + assertFalse(closeCompleted.get()); + + releaseApply.countDown(); + checker.join(2_000L); + closer.join(2_000L); + assertFalse(checker.isAlive()); + assertFalse(closer.isAlive()); + assertTrue(closeCompleted.get()); + assertEquals("During " + PERMISSION, permissionMessage()); + + Files.writeString(override.toPath(), locale("en_US", "After {permission}"), StandardCharsets.UTF_8); + watch.checkConfigHotload(); + assertEquals(1, automaticApplies.get()); + assertEquals("During " + PERMISSION, permissionMessage()); + } + + @Test(timeout = 8_000L) + public void manualReloadInvalidatesPendingAutomaticLocaleWork() throws Exception { + File override = override("en_US"); + AtomicInteger automaticApplies = new AtomicInteger(); + replaceWatch( + new SettingsHotloadWatch.Timing(100L, 400L, 100L, 100L), + snapshot -> { + if (override.equals(snapshot.file())) { + automaticApplies.incrementAndGet(); + } + } + ); + Files.writeString(override.toPath(), locale("en_US", "First {permission}"), StandardCharsets.UTF_8); + awaitPermissionMessage("First " + PERMISSION); + assertEquals(1, automaticApplies.get()); + + Files.writeString(override.toPath(), locale("en_US", "Manual {permission}"), StandardCharsets.UTF_8); + pollFor(125L); + assertEquals("First " + PERMISSION, permissionMessage()); + assertTrue(IrisLanguage.reload(dataFolder, "en_US")); + assertEquals("Manual " + PERMISSION, permissionMessage()); + + pollFor(650L); + assertEquals(1, automaticApplies.get()); + assertEquals("Manual " + PERMISSION, permissionMessage()); + } + + @Test(timeout = 8_000L) + public void oversizedActiveOverrideReportsOnceAndRetainsLastGoodCatalog() throws Exception { + File override = override("en_US"); + assertTrue(watch.applySnapshot(present(override, locale("en_US", "Active {permission}")))); + byte[] oversized = new byte[2 * 1024 * 1024 + 1]; + Files.write(override.toPath(), oversized); + + String diagnostic = captureErrorsWhilePolling("Hotload file exceeds 2097152 bytes", 1_200L); + + assertEquals(1, countOccurrences( + diagnostic, + "Failed to read watched Iris file " + override.getAbsolutePath() + )); + assertEquals("Active " + PERMISSION, permissionMessage()); + } + + @Test(timeout = 8_000L) + public void malformedUtf8FailureIsDeduplicatedAndRetainsLastGoodCatalog() throws Exception { + File override = override("en_US"); + assertTrue(watch.applySnapshot(present(override, locale("en_US", "Active {permission}")))); + Files.write(override.toPath(), new byte[]{(byte) 0xC3, 0x28}); + + String diagnostic = captureErrorsWhilePolling("Hotload file is not valid UTF-8", 1_200L); + + assertEquals(1, countOccurrences( + diagnostic, + "Failed to read watched Iris file " + override.getAbsolutePath() + )); + assertEquals("Active " + PERMISSION, permissionMessage()); + } + + @Test + public void bukkitAndModdedUseTheSameCoreCoordinator() throws Exception { + String bukkit = Files.readString(Path.of( + "../adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java" + )); + String modded = Files.readString(Path.of( + "../adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedSettingsHotloadService.java" + )); + + assertTrue(bukkit.contains("new SettingsHotloadWatch(")); + assertTrue(modded.contains("new SettingsHotloadWatch(")); + assertFalse(bukkit.contains("new ConfigHotloadEngine(")); + assertFalse(modded.contains("new ConfigHotloadEngine(")); + assertFalse(bukkit.contains("IrisLanguage.update()")); + assertFalse(modded.contains("IrisLanguage.update()")); + } + + private void awaitPermissionMessage(String expected) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(6L); + while (System.nanoTime() < deadline) { + watch.checkConfigHotload(); + if (expected.equals(permissionMessage())) { + return; + } + Thread.sleep(25L); + } + assertEquals(expected, permissionMessage()); + } + + private void replaceWatch( + SettingsHotloadWatch.Timing timing, + Consumer beforeSnapshotApply + ) { + watch.close(); + watch = new SettingsHotloadWatch(settingsFile, timing, beforeSnapshotApply); + } + + private void checkUntilEntered(CountDownLatch entered) { + try { + while (entered.getCount() > 0L) { + watch.checkConfigHotload(); + Thread.sleep(10L); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for a hotload snapshot", failure); + } + } + + private void awaitThreadState(Thread thread, Thread.State expected) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1L); + while (System.nanoTime() < deadline && thread.getState() != expected) { + Thread.sleep(5L); + } + assertEquals(expected, thread.getState()); + } + + private void pollFor(long durationMillis) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(durationMillis); + while (System.nanoTime() < deadline) { + watch.checkConfigHotload(); + Thread.sleep(25L); + } + } + + private String captureErrorsWhilePolling(String expected, long durationMillis) throws Exception { + PrintStream originalError = System.err; + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + try (PrintStream capture = new PrintStream(captured, true, StandardCharsets.UTF_8)) { + System.setErr(capture); + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(durationMillis); + boolean found = false; + while (System.nanoTime() < deadline) { + watch.checkConfigHotload(); + found |= captured.toString(StandardCharsets.UTF_8).contains(expected); + Thread.sleep(25L); + } + assertTrue(found); + } finally { + System.setErr(originalError); + } + return captured.toString(StandardCharsets.UTF_8); + } + + private int countOccurrences(String value, String target) { + int count = 0; + int cursor = 0; + while ((cursor = value.indexOf(target, cursor)) >= 0) { + count++; + cursor += target.length(); + } + return count; + } + + private ConfigHotloadEngine.StableContentSnapshot present(File file, String content) { + return new ConfigHotloadEngine.StableContentSnapshot(file, "present", content.trim()); + } + + private ConfigHotloadEngine.StableContentSnapshot missing(File file) { + return new ConfigHotloadEngine.StableContentSnapshot(file, "missing", null); + } + + private File override(String locale) { + return new File(overrideFolder, locale + ".json").getAbsoluteFile(); + } + + private String permissionMessage() { + return IrisLanguage.plain( + IrisMessages.COMMAND_PERMISSION_DENIED, + MessageArgument.untrusted("permission", PERMISSION) + ); + } + + private String settings(String locale) { + return "{\"general\":{\"language\":\"" + locale + "\"}}"; + } + + private String locale(String locale, String permissionMessage) { + return "{\"locale\":\"" + locale + "\",\"messages\":{" + + "\"iris.command.permission_denied\":\"" + permissionMessage + "\"}}"; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/localization/LocaleHotloadGateTest.java b/core/src/test/java/art/arcane/iris/core/localization/LocaleHotloadGateTest.java deleted file mode 100644 index 7155ceb7e..000000000 --- a/core/src/test/java/art/arcane/iris/core/localization/LocaleHotloadGateTest.java +++ /dev/null @@ -1,159 +0,0 @@ -package art.arcane.iris.core.localization; - -import org.junit.Test; - -import java.io.File; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; - -public class LocaleHotloadGateTest { - @Test - public void anchorsCooldownAtCompletedApplication() { - LocaleHotloadGate gate = gate(); - LocaleHotloadSnapshot initial = snapshot("initial"); - LocaleHotloadSnapshot first = snapshot("first"); - LocaleHotloadSnapshot second = snapshot("second"); - gate.reset(initial); - - assertNull(gate.observe(first, 0L)); - LocaleHotloadGate.Attempt firstAttempt = gate.observe(first, 100L); - assertNotNull(firstAttempt); - gate.complete(firstAttempt, 500L, true); - - assertNull(gate.observe(second, 600L)); - assertNull(gate.observe(second, 700L)); - assertNull(gate.observe(second, 3_499L)); - LocaleHotloadGate.Attempt secondAttempt = gate.observe(second, 3_500L); - - assertNotNull(secondAttempt); - assertEquals(second, secondAttempt.snapshot()); - } - - @Test - public void coalescesCooldownBurstToLatestStableSnapshot() { - LocaleHotloadGate gate = gate(); - LocaleHotloadSnapshot first = snapshot("first"); - LocaleHotloadSnapshot intermediate = snapshot("intermediate"); - LocaleHotloadSnapshot latest = snapshot("latest"); - gate.reset(snapshot("initial")); - - assertNull(gate.observe(first, 0L)); - LocaleHotloadGate.Attempt firstAttempt = gate.observe(first, 100L); - assertNotNull(firstAttempt); - gate.complete(firstAttempt, 200L, true); - - assertNull(gate.observe(intermediate, 250L)); - assertNull(gate.observe(intermediate, 350L)); - assertNull(gate.observe(latest, 400L)); - assertNull(gate.observe(latest, 500L)); - assertNull(gate.observe(latest, 3_199L)); - LocaleHotloadGate.Attempt latestAttempt = gate.observe(latest, 3_200L); - - assertNotNull(latestAttempt); - assertEquals(latest, latestAttempt.snapshot()); - } - - @Test - public void requiresDeletionGraceAndCancelsTransientMissingSnapshot() { - LocaleHotloadGate gate = gate(); - LocaleHotloadSnapshot initial = snapshot("initial"); - LocaleHotloadSnapshot replacement = snapshot("replacement"); - LocaleHotloadSnapshot missing = LocaleHotloadSnapshot.missing(initial.file(), "en_US"); - gate.reset(initial); - - assertNull(gate.observe(missing, 0L)); - assertNull(gate.observe(missing, 499L)); - assertNull(gate.observe(replacement, 500L)); - LocaleHotloadGate.Attempt replacementAttempt = gate.observe(replacement, 600L); - assertNotNull(replacementAttempt); - assertEquals(replacement, replacementAttempt.snapshot()); - gate.complete(replacementAttempt, 600L, true); - - assertNull(gate.observe(missing, 700L)); - assertNull(gate.observe(missing, 1_199L)); - assertNull(gate.observe(missing, 1_200L)); - LocaleHotloadGate.Attempt deletionAttempt = gate.observe(missing, 3_600L); - - assertNotNull(deletionAttempt); - assertEquals(missing, deletionAttempt.snapshot()); - } - - @Test - public void unavailableReadBlocksStalePendingSnapshot() { - LocaleHotloadGate gate = gate(); - LocaleHotloadSnapshot first = snapshot("first"); - LocaleHotloadSnapshot pending = snapshot("pending"); - gate.reset(snapshot("initial")); - - assertNull(gate.observe(first, 0L)); - LocaleHotloadGate.Attempt firstAttempt = gate.observe(first, 100L); - assertNotNull(firstAttempt); - gate.complete(firstAttempt, 200L, true); - assertNull(gate.observe(pending, 300L)); - assertNull(gate.observe(pending, 400L)); - - gate.unavailable(); - assertNull(gate.observe(pending, 3_200L)); - LocaleHotloadGate.Attempt recoveredAttempt = gate.observe(pending, 3_300L); - - assertNotNull(recoveredAttempt); - assertEquals(pending, recoveredAttempt.snapshot()); - } - - @Test - public void failedApplicationRetriesOnlyAfterCooldown() { - LocaleHotloadGate gate = gate(); - LocaleHotloadSnapshot changed = snapshot("changed"); - gate.reset(snapshot("initial")); - - assertNull(gate.observe(changed, 0L)); - LocaleHotloadGate.Attempt failedAttempt = gate.observe(changed, 100L); - assertNotNull(failedAttempt); - gate.complete(failedAttempt, 500L, false); - - assertNull(gate.observe(changed, 3_499L)); - LocaleHotloadGate.Attempt retryAttempt = gate.observe(changed, 3_500L); - - assertNotNull(retryAttempt); - assertEquals(changed, retryAttempt.snapshot()); - } - - @Test - public void manualResetInvalidatesInFlightAutomaticAttempt() { - LocaleHotloadGate gate = gate(); - LocaleHotloadSnapshot automatic = snapshot("automatic"); - LocaleHotloadSnapshot manual = snapshot("manual"); - LocaleHotloadSnapshot changed = snapshot("changed"); - gate.reset(snapshot("initial")); - - assertNull(gate.observe(automatic, 0L)); - LocaleHotloadGate.Attempt staleAttempt = gate.observe(automatic, 100L); - assertNotNull(staleAttempt); - gate.reset(manual); - gate.complete(staleAttempt, 200L, true); - - assertNull(gate.observe(manual, 200L)); - assertNull(gate.observe(changed, 300L)); - LocaleHotloadGate.Attempt currentAttempt = gate.observe(changed, 400L); - - assertNotNull(currentAttempt); - assertNotEquals(staleAttempt.generation(), currentAttempt.generation()); - assertEquals(changed, currentAttempt.snapshot()); - } - - private LocaleHotloadGate gate() { - return new LocaleHotloadGate(new LocaleHotloadGate.Timing(100L, 500L, 3_000L)); - } - - private LocaleHotloadSnapshot snapshot(String content) { - return LocaleHotloadSnapshot.present( - new File("locale.json"), - "en_US", - content, - content - ); - } -} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java new file mode 100644 index 000000000..6d061132f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java @@ -0,0 +1,326 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.PlacedObject; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.math.RNG; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisObjectPlacementRunnerRegressionTest { + private static final int ANCHOR_Y = 80; + + private IrisData data; + private Engine engine; + private PlatformBlockState solid; + private PlatformBlockState schematicAir; + + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class); + Map registryStates = new HashMap<>(); + when(registries.block(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0); + return registryStates.computeIfAbsent(key, value -> state(value.toLowerCase(), !value.toLowerCase().contains("air"))); + }); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + + engine = mock(Engine.class); + when(engine.getHeight()).thenReturn(256); + data = mock(IrisData.class); + when(data.getEngine()).thenReturn(engine); + solid = state("minecraft:stone", true); + schematicAir = state("minecraft:air", false); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void placementFailurePropagatesToTheCaller() { + RecordingPlacer placer = new RecordingPlacer(null); + placer.failAfterWrites(1); + IrisObject object = lineObject(3); + + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> object.place(0, ANCHOR_Y, 0, placer, placement(), new RNG(2L), data)); + + assertTrue(error.getMessage().contains("write failed")); + assertEquals(1, placer.writes().size()); + } + + @Test + public void boreUsesRotatedTranslationAndRandomizedAnchor() { + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement placement = placement(); + placement.setBore(true); + placement.setRotation(IrisObjectRotation.of(0, 90, 0)); + placement.setTranslate(new IrisObjectTranslate().setX(4).setYRandom(3)); + + int resultY = lineObject(3).place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), data); + + List airWrites = placer.writesOf(IrisObject.States.AIR); + assertFalse(airWrites.isEmpty()); + assertTrue(airWrites.stream().allMatch(write -> write.y() >= resultY && write.y() <= resultY)); + assertTrue(airWrites.stream().allMatch(write -> write.x() == 0)); + assertTrue(airWrites.stream().allMatch(write -> write.z() <= -3 && write.z() >= -5)); + } + + @Test + public void collisionCheckUsesTheTransformedBounds() { + PlacedObject forbidden = new PlacedObject(null, object("forbidden"), 1, 0, 0); + when(engine.getObjectPlacement(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + int y = invocation.getArgument(1); + int z = invocation.getArgument(2); + return x == 0 && y == ANCHOR_Y && z == -4 ? forbidden : null; + }); + RecordingPlacer placer = new RecordingPlacer(engine); + IrisObjectPlacement placement = placement(); + placement.setRotation(IrisObjectRotation.of(0, 90, 0)); + placement.setTranslate(new IrisObjectTranslate().setX(4)); + placement.getForbiddenCollisions().add("forbidden"); + + int result = lineObject(3).place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), data); + + assertEquals(-1, result); + assertTrue(placer.writes().isEmpty()); + } + + @Test + public void arbitraryRotationSamplesEveryTransformedFootprintColumn() { + RecordingPlacer placer = new RecordingPlacer(null); + IrisObjectPlacement placement = placement(); + placement.setMode(ObjectPlaceMode.MAX_HEIGHT); + placement.setRotation(IrisObjectRotation.of(0, 45, 0)); + + boxObject(5, 1, 3).place(0, -1, 0, placer, placement, new RNG(2L), data); + + assertTrue(placer.sampledColumns().contains("-2:-2")); + assertTrue(placer.sampledColumns().contains("2:2")); + assertTrue(placer.sampledColumns().size() >= 25); + } + + @Test + public void snowUsesOnlyBlocksThatWereWritten() { + RecordingPlacer placer = new RecordingPlacer(null); + IrisObject object = new IrisObject(1, 3, 1); + object.setUnsigned(0, 0, 0, solid); + object.setUnsigned(0, 2, 0, schematicAir); + IrisObjectPlacement placement = placement(); + placement.setSnow(0.1); + + object.place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), data); + + List snowWrites = placer.writesOf(IrisObject.States.SNOW_LAYERS[0]); + assertEquals(1, snowWrites.size()); + assertEquals(ANCHOR_Y, snowWrites.get(0).y()); + } + + @Test + public void debugPlacementDoesNotMutateSmartBoreCache() { + IrisObject object = new IrisObject(1, 1, 1); + object.setUnsigned(0, 0, 0, IrisObject.States.VAIR); + object.setSmartBored(true); + IrisObjectPlacement placement = placement(); + placement.setSmartBore(true); + RecordingPlacer debug = new RecordingPlacer(null); + debug.setDebugSmartBore(true); + RecordingPlacer normal = new RecordingPlacer(null); + + object.place(0, ANCHOR_Y, 0, debug, placement, new RNG(2L), data); + object.place(0, ANCHOR_Y, 0, normal, placement, new RNG(2L), data); + + PlatformBlockState cachedState = object.getBlocks().get(object.getSigned(0, 0, 0)); + assertSame(IrisObject.States.VAIR, cachedState); + if (IrisObject.States.VAIR != IrisObject.States.VAIR_DEBUG) { + assertTrue(cachedState != IrisObject.States.VAIR_DEBUG); + } + } + + @Test + public void placementDataIsRequiredAtTheBoundary() { + NullPointerException error = assertThrows(NullPointerException.class, + () -> lineObject(1).place(0, ANCHOR_Y, 0, new RecordingPlacer(null), placement(), new RNG(2L), null)); + + assertEquals("Object placement data is required.", error.getMessage()); + } + + private IrisObjectPlacement placement() { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setMode(ObjectPlaceMode.CENTER_HEIGHT); + placement.setRequireSurfaceSupport(false); + return placement; + } + + private IrisObject lineObject(int width) { + IrisObject object = new IrisObject(width, 1, 1); + for (int x = 0; x < width; x++) { + object.setUnsigned(x, 0, 0, solid); + } + return object; + } + + private IrisObject boxObject(int width, int height, int depth) { + IrisObject object = new IrisObject(width, height, depth); + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y++) { + for (int z = 0; z < depth; z++) { + object.setUnsigned(x, y, z, solid); + } + } + } + return object; + } + + private IrisObject object(String key) { + IrisObject object = lineObject(1); + object.setLoadKey(key); + return object; + } + + private static PlatformBlockState state(String key, boolean solid) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn(key); + when(state.materialKey()).thenReturn(key); + when(state.isSolid()).thenReturn(solid); + when(state.isOccluding()).thenReturn(solid); + return state; + } + + private static final class RecordingPlacer implements IObjectPlacer { + private final List writes = new ArrayList<>(); + private final Map world = new HashMap<>(); + private final List sampledColumns = new ArrayList<>(); + private final Engine engine; + private int failAfterWrites = Integer.MAX_VALUE; + private boolean debugSmartBore; + + private RecordingPlacer(Engine engine) { + this.engine = engine; + } + + private void failAfterWrites(int writes) { + failAfterWrites = writes; + } + + private void setDebugSmartBore(boolean debugSmartBore) { + this.debugSmartBore = debugSmartBore; + } + + private List writes() { + return writes; + } + + private List writesOf(PlatformBlockState state) { + return writes.stream().filter(write -> write.state() == state).toList(); + } + + private List sampledColumns() { + return sampledColumns; + } + + @Override + public int getHighest(int x, int z, IrisData data) { + sampledColumns.add(x + ":" + z); + return ANCHOR_Y; + } + + @Override + public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + sampledColumns.add(x + ":" + z); + return ANCHOR_Y; + } + + @Override + public void set(int x, int y, int z, PlatformBlockState state) { + if (writes.size() >= failAfterWrites) { + throw new IllegalStateException("write failed"); + } + writes.add(new BlockWrite(x, y, z, state)); + world.put(x + ":" + y + ":" + z, state); + } + + @Override + public PlatformBlockState get(int x, int y, int z) { + return world.get(x + ":" + y + ":" + z); + } + + @Override + public boolean isPreventingDecay() { + return false; + } + + @Override + public boolean isCarved(int x, int y, int z) { + return false; + } + + @Override + public boolean isSolid(int x, int y, int z) { + PlatformBlockState state = get(x, y, z); + return state != null && state.isSolid(); + } + + @Override + public boolean isUnderwater(int x, int z) { + return false; + } + + @Override + public int getFluidHeight() { + return 0; + } + + @Override + public boolean isDebugSmartBore() { + return debugSmartBore; + } + + @Override + public void setTile(int x, int y, int z, TileData tile) { + } + + @Override + public void setData(int x, int y, int z, T data) { + } + + @Override + public T getData(int x, int y, int z, Class type) { + return null; + } + + @Override + public Engine getEngine() { + return engine; + } + } + + private record BlockWrite(int x, int y, int z, PlatformBlockState state) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectSurfaceSupportPlacementTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectSurfaceSupportPlacementTest.java index caa8e6779..08e8050c6 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectSurfaceSupportPlacementTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectSurfaceSupportPlacementTest.java @@ -200,7 +200,7 @@ public class IrisObjectSurfaceSupportPlacementTest { IllegalStateException error = assertThrows(IllegalStateException.class, () -> place(placer, placement, SURFACE_Y)); - assertTrue(error.getMessage().contains("require an active Iris engine")); + assertTrue(error.getMessage().contains("requires an active Iris engine")); assertTrue(placer.written().isEmpty()); verify(data, never()).getEngine(); }