This commit is contained in:
Brian Neumann-Fopiano
2026-08-21 02:03:24 -04:00
parent f43df6c232
commit 34092da6be
43 changed files with 1308 additions and 279 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ plugins {
def lib = 'art.arcane.iris.util'
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
.orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')
.orElse('com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186')
.get()
String sentryAuthToken = findProperty('sentry.auth.token') as String ?: System.getenv('SENTRY_AUTH_TOKEN')
boolean hasSentryAuthToken = sentryAuthToken != null && !sentryAuthToken.isBlank()
-1
View File
@@ -63,7 +63,6 @@ art/arcane/iris/core/service/ExternalDataSVC.java
art/arcane/iris/core/service/GlobalCacheSVC.java
art/arcane/iris/core/service/JigsawStudioMarkerParser.java
art/arcane/iris/core/service/JigsawStudioMenuController.java
art/arcane/iris/core/service/JigsawStudioBoundsRenderer.java
art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java
art/arcane/iris/core/service/JigsawStudioService.java
art/arcane/iris/core/service/JigsawStudioToolCodec.java
@@ -30,6 +30,8 @@ import lombok.Data;
import java.io.File;
import java.io.IOException;
import java.util.Objects;
import java.util.function.Predicate;
@Data
public class IrisSettings {
@@ -136,6 +138,46 @@ public class IrisSettings {
}
}
public static IrisSettings installHotloadSnapshot(String rawJson) {
IrisSettings parsed = parseHotloadSnapshot(rawJson);
synchronized (SETTINGS_LOCK) {
settings = parsed;
}
return parsed;
}
public static boolean applyHotloadSnapshot(String rawJson, Predicate<IrisSettings> candidateActivation) {
Objects.requireNonNull(candidateActivation, "candidateActivation");
IrisSettings parsed = parseHotloadSnapshot(rawJson);
if (!candidateActivation.test(parsed)) {
return false;
}
synchronized (SETTINGS_LOCK) {
settings = parsed;
}
return true;
}
public static IrisSettings parseHotloadSnapshot(String rawJson) {
if (rawJson == null || rawJson.isBlank()) {
throw new IllegalArgumentException("Iris settings snapshot is empty");
}
IrisSettings parsed;
try {
parsed = new Gson().fromJson(rawJson, IrisSettings.class);
if (parsed == null) {
throw new IllegalArgumentException("Iris settings snapshot did not contain an object");
}
migrateLegacyKeys(parsed, rawJson);
} catch (RuntimeException failure) {
throw new IllegalArgumentException("Iris settings snapshot is invalid", failure);
}
parsed.fillMissingSections();
return parsed;
}
public void forceSave() {
File s = IrisPlatforms.get().dataFile("settings.json");
@@ -147,6 +189,20 @@ public class IrisSettings {
}
}
private void fillMissingSections() {
general = general == null ? new IrisSettingsGeneral() : general;
world = world == null ? new IrisSettingsWorld() : world;
gui = gui == null ? new IrisSettingsGUI() : gui;
autoConfiguration = autoConfiguration == null ? new IrisSettingsAutoconfiguration() : autoConfiguration;
generator = generator == null ? new IrisSettingsGenerator() : generator;
concurrency = concurrency == null ? new IrisSettingsConcurrency() : concurrency;
studio = studio == null ? new IrisSettingsStudio() : studio;
performance = performance == null ? new IrisSettingsPerformance() : performance;
pregen = pregen == null ? new IrisSettingsPregen() : pregen;
sentry = sentry == null ? new IrisSettingsSentry() : sentry;
treeFeller = treeFeller == null ? new IrisSettingsTreeFeller() : treeFeller;
}
@Data
public static class IrisSettingsAutoconfiguration {
public boolean configureSpigotTimeoutTime = true;
@@ -273,6 +329,11 @@ public class IrisSettings {
public boolean splashLogoStartup = true;
public boolean useConsoleCustomColors = true;
public boolean useCustomColorsIngame = true;
/**
* Boss bar progress loaders for jobs, studio opens, world creation, chunk jobs and pack
* downloads. Turning this off keeps the action bar progress line; only the bar goes away.
*/
public boolean progressBossBar = true;
public boolean adjustVanillaHeight = false;
public boolean autoIngestDatapacks = true;
/**
@@ -28,19 +28,32 @@ import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
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.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
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.atomic.AtomicReference;
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]");
@@ -55,10 +68,17 @@ public final class IrisLanguage {
* new snapshot and the whole memo is discarded. Bounded by the catalog size.
*/
private static final AtomicReference<PlainMemo> 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 volatile File dataFolder;
private static volatile File watchedFile;
private static volatile long watchedSignature = Long.MIN_VALUE;
private static volatile String lastCaptureFailureKey;
private static volatile String lastInvalidHotloadLocale;
private static volatile String activeLocale = CATALOG.englishLocale();
private IrisLanguage() {
@@ -82,6 +102,20 @@ public final class IrisLanguage {
return reload(root, configuredLocale());
}
public static synchronized boolean reload(IrisSettings candidate) {
IrisSettings resolvedCandidate = Objects.requireNonNull(candidate, "Candidate settings cannot be null");
File root = dataFolder;
if (root == null && IrisPlatforms.isBound()) {
root = IrisPlatforms.get().dataFolder();
}
if (root == null) {
return false;
}
IrisSettings.IrisSettingsGeneral general = resolvedCandidate.getGeneral();
String locale = general == null ? CATALOG.englishLocale() : general.getLanguage();
return reload(root, locale);
}
public static synchronized boolean reload(File root, String locale) {
File resolvedRoot = root == null ? null : root.getAbsoluteFile();
if (resolvedRoot == null) {
@@ -92,16 +126,26 @@ 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();
return false;
}
LocalizationReloadResult result = MANAGER.reload(() -> loadCandidate(resolvedRoot, requestedLocale));
dataFolder = resolvedRoot;
File override = overrideFile(resolvedRoot, requestedLocale);
watchedFile = override;
watchedSignature = signature(override);
SnapshotCapture capture = captureForReload(override, requestedLocale);
LocalizationReloadResult result;
if (capture.failure() == null) {
result = MANAGER.reload(() -> loadCandidate(resolvedRoot, requestedLocale, capture.snapshot()));
} else {
result = MANAGER.reload(() -> {
throw capture.failure();
});
}
dataFolder = resolvedRoot;
HOTLOAD_GATE.reset(capture.snapshot());
lastCaptureFailureKey = null;
if (!result.applied()) {
reportRejectedReload(requestedLocale, result);
return false;
@@ -123,14 +167,47 @@ public final class IrisLanguage {
try {
locale = normalizeLocale(configuredLocale());
} catch (RuntimeException exception) {
return reload(root, configuredLocale());
String invalidLocale = configuredLocale();
if (Objects.equals(lastInvalidHotloadLocale, invalidLocale)) {
return false;
}
lastInvalidHotloadLocale = invalidLocale;
return reload(root, invalidLocale);
}
lastInvalidHotloadLocale = null;
File expected = overrideFile(root, locale);
long signature = signature(expected);
if (expected.equals(watchedFile) && signature == watchedSignature) {
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;
}
return reload(root, locale);
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() {
@@ -229,13 +306,16 @@ public final class IrisLanguage {
throw new IllegalArgumentException("Unsupported Iris message key: " + key.id());
}
private static LocalizationCandidate loadCandidate(File root, String locale) throws Exception {
private static LocalizationCandidate loadCandidate(
File root,
String locale,
LocaleHotloadSnapshot snapshot
) throws Exception {
File folder = new File(root, "languages/overrides");
Files.createDirectories(folder.toPath());
List<LocaleOverlay> overlays = new ArrayList<>(2);
File override = overrideFile(root, locale);
if (override.exists()) {
overlays.add(loadFileOverlay(override, locale));
if (!snapshot.missing()) {
overlays.add(parseOverlay(snapshot.file().getPath(), locale, snapshot.content()));
}
if (!CATALOG.englishLocale().equals(locale)) {
@@ -269,17 +349,6 @@ public final class IrisLanguage {
}
}
private static LocaleOverlay loadFileOverlay(File override, String locale) throws Exception {
if (!override.isFile()) {
throw new IllegalArgumentException("Locale override is not a regular file: " + override.getPath());
}
if (override.length() > MAX_LOCALE_BYTES) {
throw new IllegalArgumentException("Locale override is too large: " + override.getPath());
}
String raw = Files.readString(override.toPath(), StandardCharsets.UTF_8);
return parseOverlay(override.getPath(), locale, raw);
}
private static LocaleOverlay parseOverlay(String source, String locale, String raw) {
JsonElement parsed = JsonParser.parseString(raw == null || raw.isBlank() ? "{}" : raw);
if (!parsed.isJsonObject()) {
@@ -461,11 +530,92 @@ public final class IrisLanguage {
return new File(new File(root, "languages/overrides"), normalizeLocale(locale) + ".json").getAbsoluteFile();
}
private static long signature(File file) {
if (file == null || !file.exists()) {
return 0L;
static LocaleHotloadSnapshot captureHotloadSnapshot(File file, String locale) throws IOException {
File resolvedFile = Objects.requireNonNull(file, "Locale override file cannot be null").getAbsoluteFile();
String resolvedLocale = normalizeLocale(locale);
BasicFileAttributes before;
try {
before = Files.readAttributes(resolvedFile.toPath(), BasicFileAttributes.class);
} catch (NoSuchFileException failure) {
return LocaleHotloadSnapshot.missing(resolvedFile, resolvedLocale);
}
return file.lastModified() * 31L + file.length();
if (!before.isRegularFile()) {
throw new IllegalArgumentException("Locale override is not a regular file: " + resolvedFile.getPath());
}
if (before.size() > MAX_LOCALE_BYTES) {
throw new IllegalArgumentException("Locale override is too large: " + resolvedFile.getPath());
}
byte[] bytes;
try (InputStream input = Files.newInputStream(resolvedFile.toPath())) {
bytes = input.readNBytes((int) MAX_LOCALE_BYTES + 1);
} catch (NoSuchFileException failure) {
return null;
}
if (bytes.length > MAX_LOCALE_BYTES) {
throw new IllegalArgumentException("Locale override is too large: " + resolvedFile.getPath());
}
BasicFileAttributes after;
try {
after = Files.readAttributes(resolvedFile.toPath(), BasicFileAttributes.class);
} catch (NoSuchFileException failure) {
return null;
}
if (!sameIdentity(before, after) || bytes.length != after.size()) {
return null;
}
String content;
try {
content = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException failure) {
throw new IOException("Locale override is not valid UTF-8: " + resolvedFile.getPath(), failure);
}
return LocaleHotloadSnapshot.present(resolvedFile, resolvedLocale, content, sha256(bytes));
}
private static SnapshotCapture captureForReload(File file, String locale) {
try {
LocaleHotloadSnapshot snapshot = captureHotloadSnapshot(file, locale);
if (snapshot == null) {
return new SnapshotCapture(
null,
new IOException("Locale override changed while being read: " + file.getPath())
);
}
return new SnapshotCapture(snapshot, null);
} catch (Exception failure) {
return new SnapshotCapture(null, failure);
}
}
private static boolean sameIdentity(BasicFileAttributes before, BasicFileAttributes after) {
return before.isRegularFile() == after.isRegularFile()
&& before.size() == after.size()
&& before.lastModifiedTime().equals(after.lastModifiedTime())
&& Objects.equals(before.fileKey(), after.fileKey());
}
private static String sha256(byte[] content) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(content));
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is unavailable", failure);
}
}
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) {
@@ -489,4 +639,7 @@ public final class IrisLanguage {
private record PlainMemo(LocalizationSnapshot snapshot, Map<String, String> values) {
}
private record SnapshotCapture(LocaleHotloadSnapshot snapshot, Exception failure) {
}
}
@@ -0,0 +1,100 @@
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");
}
}
}
@@ -0,0 +1,29 @@
package art.arcane.iris.core.localization;
import java.io.File;
import java.util.Objects;
record LocaleHotloadSnapshot(File file, String locale, String content, String sha256) {
LocaleHotloadSnapshot {
file = Objects.requireNonNull(file, "Locale override file cannot be null").getAbsoluteFile();
locale = Objects.requireNonNull(locale, "Locale cannot be null");
sha256 = Objects.requireNonNull(sha256, "Locale content hash cannot be null");
}
static LocaleHotloadSnapshot missing(File file, String locale) {
return new LocaleHotloadSnapshot(file, locale, null, "missing");
}
static LocaleHotloadSnapshot present(File file, String locale, String content, String sha256) {
return new LocaleHotloadSnapshot(
file,
locale,
Objects.requireNonNull(content, "Locale content cannot be null"),
sha256
);
}
boolean missing() {
return content == null;
}
}
@@ -19,6 +19,7 @@
package art.arcane.iris.core.project;
import art.arcane.iris.core.localization.BukkitRuntimeMessages;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.RuntimeProgressMessages;
import art.arcane.iris.util.common.format.C;
@@ -46,7 +47,8 @@ final class StudioOpenProgressReporter {
AtomicInteger taskId = new AtomicInteger(-1);
org.bukkit.boss.BossBar bossBar;
if (sender.isPlayer() && sender.player() != null) {
if (sender.isPlayer() && sender.player() != null
&& IrisSettings.get().getGeneral().isProgressBossBar()) {
bossBar = Bukkit.createBossBar(
IrisLanguage.text(RuntimeProgressMessages.STUDIO_OPENING),
org.bukkit.boss.BarColor.BLUE,
@@ -18,6 +18,7 @@
package art.arcane.iris.core.runtime;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.RuntimeProgressMessages;
import art.arcane.iris.spi.IrisLogging;
@@ -109,8 +110,9 @@ public final class ChunkJobReporter {
}
private void startReporter() {
boolean player = sender.isPlayer() && sender.player() != null;
BossBar bossBar = player
boolean showBossBar = sender.isPlayer() && sender.player() != null
&& IrisSettings.get().getGeneral().isProgressBossBar();
BossBar bossBar = showBossBar
? Bukkit.createBossBar(IrisLanguage.text(
RuntimeProgressMessages.CHUNK_BOSSBAR_WORKING,
MessageArgument.trusted("title", title)
@@ -1,5 +1,6 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.PackDownloadMessages;
import art.arcane.iris.core.pack.PackDownloader;
@@ -246,15 +247,17 @@ final class PackDownloadProgressReporter implements PackDownloader.DownloadProgr
private void renderHudPulse(HudSnapshot snapshot) {
try {
BukkitPlatform.hudLanes().show(
player,
hudLaneId,
snapshot.line(),
snapshot.progress(),
BarColor.BLUE,
BarStyle.SEGMENTED_20,
1_500L
);
if (IrisSettings.get().getGeneral().isProgressBossBar()) {
BukkitPlatform.hudLanes().show(
player,
hudLaneId,
snapshot.line(),
snapshot.progress(),
BarColor.BLUE,
BarStyle.SEGMENTED_20,
1_500L
);
}
sender.sendAction(snapshot.line());
} catch (RuntimeException failure) {
disableHud(failure);
@@ -310,15 +313,17 @@ final class PackDownloadProgressReporter implements PackDownloader.DownloadProgr
Runnable retiredCleanup = () -> retireHudLane(cleaned);
Runnable display = () -> {
try {
BukkitPlatform.hudLanes().show(
player,
hudLaneId,
message,
progress,
color,
BarStyle.SOLID,
4_000L
);
if (IrisSettings.get().getGeneral().isProgressBossBar()) {
BukkitPlatform.hudLanes().show(
player,
hudLaneId,
message,
progress,
color,
BarStyle.SOLID,
4_000L
);
}
sender.sendAction(message);
} finally {
if (!J.runEntity(player, cleanup, HUD_TERMINAL_TICKS, retiredCleanup)) {
@@ -488,33 +488,50 @@ public class StudioSVC implements IrisService {
}
}
private static boolean blockIfPackBroken(VolmitSender sender, String dimm) {
private static BrokenPackException reportPackAdmissionFailure(
VolmitSender sender, String dimm) {
Optional<String> startupDenial = IrisStartupValidation.denialReason();
if (startupDenial.isPresent()) {
sender.sendMessage(startupDenial.get());
return true;
}
IrisDimension dimension = IrisToolbelt.getDimension(dimm);
String packName = dimension == null || dimension.getLoader() == null
? dimm
: dimension.getLoader().getDataFolder().getName();
PackValidationResult validation = PackValidationRegistry.get(packName);
if (validation != null && validation.isLoadable()) {
return false;
BrokenPackException failure = resolvePackAdmissionFailure(
packName, startupDenial, validation);
if (failure == null) {
return null;
}
if (startupDenial.isPresent()) {
sender.sendMessage(startupDenial.get());
return failure;
}
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_CANNOT_OPEN_STUDIO_PACK_HAS_BLOCKING_ERRORS, MessageArgument.untrusted("dimm", String.valueOf(dimm))));
for (String reason : failure.getReasons()) {
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_MESSAGE, MessageArgument.untrusted("reason", String.valueOf(reason))));
}
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FIX_PACK_RUN_IRIS_PACK_VALIDATE_REVALIDATE, MessageArgument.untrusted("dimm", String.valueOf(packName))));
return failure;
}
static BrokenPackException resolvePackAdmissionFailure(
String packName,
Optional<String> startupDenial,
PackValidationResult validation
) {
if (startupDenial.isPresent()) {
return new BrokenPackException(packName, List.of(startupDenial.get()));
}
if (validation != null && validation.isLoadable()) {
return null;
}
List<String> failures = validation == null
? List.of("Required pack validation has not completed. Studio creation fails closed until validation succeeds.")
: validation.getBlockingErrors();
for (String reason : failures) {
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_MESSAGE, MessageArgument.untrusted("reason", String.valueOf(reason))));
}
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FIX_PACK_RUN_IRIS_PACK_VALIDATE_REVALIDATE, MessageArgument.untrusted("dimm", String.valueOf(dimm))));
return true;
return new BrokenPackException(packName, failures);
}
public void open(VolmitSender sender, long seed, String dimm, Consumer<World> onDone) throws IrisException {
if (blockIfPackBroken(sender, dimm)) {
if (reportPackAdmissionFailure(sender, dimm) != null) {
return;
}
studioTransitions.submit(() -> replaceActiveProject(sender, seed, dimm, onDone))
@@ -537,9 +554,9 @@ public class StudioSVC implements IrisService {
Runnable beforeOpen,
Consumer<World> onDone
) {
if (blockIfPackBroken(sender, dimension)) {
return CompletableFuture.failedFuture(
new IllegalStateException("Studio pack '" + dimension + "' has blocking validation errors."));
BrokenPackException failure = reportPackAdmissionFailure(sender, dimension);
if (failure != null) {
return CompletableFuture.failedFuture(failure);
}
return studioTransitions.submit(() -> replaceActiveProjectTracked(
sender,
@@ -426,7 +426,7 @@ public final class VillageImporter {
emittedPools.size(), emittedPieces.size(), losses, true);
}
String msg = "Imported village " + structureKey + " as '" + name + "': " + emittedPieces.size() + " pieces, " + emittedPools.size() + " pools, " + pieceBlocks + " blocks";
String msg = "Imported jigsaw structure " + structureKey + " as '" + name + "': " + emittedPieces.size() + " pieces, " + emittedPools.size() + " pools, " + pieceBlocks + " blocks";
if (!losses.isEmpty()) {
msg += " (" + losses.size() + " fidelity warning(s) recorded)";
}
@@ -624,11 +624,13 @@ public class IrisCreator {
return;
}
if (showLoaderHud) {
BukkitPlatform.hudLanes().show(sender.player(), "iris:pregen", IrisLanguage.text(
RuntimeProgressMessages.WORLD_PREGEN_ACTION,
MessageArgument.trusted("bar", ""),
MessageArgument.trusted("percent", percent)
), p, BarColor.GREEN, BarStyle.SOLID, 4000L);
if (IrisSettings.get().getGeneral().isProgressBossBar()) {
BukkitPlatform.hudLanes().show(sender.player(), "iris:pregen", IrisLanguage.text(
RuntimeProgressMessages.WORLD_PREGEN_ACTION,
MessageArgument.trusted("bar", ""),
MessageArgument.trusted("percent", percent)
), p, BarColor.GREEN, BarStyle.SOLID, 4000L);
}
int barWidth = 44;
int filled = (int) Math.round(Math.max(0.0D, Math.min(1.0D, p)) * barWidth);
StringBuilder bar = new StringBuilder(barWidth * 3 + 4);
@@ -18,6 +18,7 @@
package art.arcane.iris.core.tools;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.RuntimeProgressMessages;
import art.arcane.iris.spi.IrisLogging;
@@ -78,7 +79,8 @@ final class WorldCreationProgressReporter {
static WorldCreationProgressReporter start(VolmitSender sender, String worldName) {
WorldCreationProgressReporter reporter = new WorldCreationProgressReporter(sender, worldName);
if (sender.isPlayer() && sender.player() != null) {
if (sender.isPlayer() && sender.player() != null
&& IrisSettings.get().getGeneral().isProgressBossBar()) {
try {
J.sfut(reporter::initializePlayerHud).get(5L, TimeUnit.SECONDS);
} catch (Throwable failure) {
@@ -281,8 +281,8 @@ public final class IrisObjectIO {
AtomicReference<IOException> ref = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
new Job() {
private int total = self.blocks.size() * 3 + self.states.size();
private int c = 0;
private volatile int total = self.blocks.size() * 3 + self.states.size();
private volatile int c = 0;
@Override
public String getName() {
@@ -147,7 +147,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
this.dimensionKey = dimensionKey;
this.folder = new ReactiveFolder(
dataLocation,
(_a, _b, _c) -> hotload(),
(_a, _b, _c) -> hotloadFromWatcher(),
new KList<>(".iob", ".json"),
new KList<>(".iris"),
new KList<>()
@@ -542,6 +542,13 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
withExclusiveControl(() -> getEngine().hotload());
}
private void hotloadFromWatcher() {
if (!shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) {
return;
}
withExclusiveControlFuture(() -> getEngine().hotload(), 30L, TimeUnit.SECONDS).join();
}
@Override
public CompletableFuture<Void> hotloadComplexAsync(long acquisitionTimeout, TimeUnit unit) {
Engine activeEngine = getEngine();
@@ -18,6 +18,7 @@
package art.arcane.iris.platform.bukkit;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.nms.MinecraftVersion;
import art.arcane.iris.engine.object.IrisPosition;
@@ -161,6 +162,9 @@ public final class BukkitPlatform implements IrisPlatform {
}
public static void showProgressLane(Player player, String laneId, String title, double progress, long staleMillis) {
if (!IrisSettings.get().getGeneral().isProgressBossBar()) {
return;
}
hudLanes().show(player, laneId, title, progress, BarColor.BLUE, BarStyle.SOLID, staleMillis);
}
@@ -30,8 +30,8 @@ import java.net.URI;
public class DownloadJob implements Job {
private final DL.Download download;
private int tw;
private int cw;
private volatile int tw;
private volatile int cw;
public DownloadJob(String url, File destination) throws MalformedURLException {
tw = 1;
@@ -23,7 +23,7 @@ import art.arcane.volmlib.util.collection.KList;
public class JobCollection implements Job {
private final String name;
private final KList<Job> jobs;
private String status;
private volatile String status;
public JobCollection(String name, Job... jobs) {
this(name, new KList<>(jobs));
@@ -25,7 +25,7 @@ import java.util.concurrent.atomic.AtomicInteger;
public abstract class QueueJob<T> implements Job {
final KList<T> queue;
private final AtomicInteger completed;
protected int totalWork;
protected volatile int totalWork;
public QueueJob() {
totalWork = 0;
@@ -21,7 +21,7 @@ package art.arcane.iris.util.common.scheduling.jobs;
public class SingleJob implements Job {
private final String name;
private final Runnable runnable;
private boolean done;
private volatile boolean done;
public SingleJob(String name, Runnable runnable) {
this.name = name;
@@ -6,7 +6,10 @@ import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThrows;
public class IrisSettingsDefaultsTest {
@Test
@@ -34,4 +37,64 @@ public class IrisSettingsDefaultsTest {
assertTrue(settings.isAutoIngestDatapacks());
assertFalse(settings.isAutoImportDatapackStructures());
}
@Test
public void hotloadSnapshotIsValidatedBeforeReplacingLiveSettings() {
IrisSettings previous = IrisSettings.settings;
IrisSettings live = new IrisSettings();
IrisSettings.settings = live;
try {
assertThrows(IllegalArgumentException.class,
() -> IrisSettings.installHotloadSnapshot("{\"general\":"));
assertSame(live, IrisSettings.settings);
IrisSettings installed = IrisSettings.installHotloadSnapshot(
"{\"general\":{\"language\":\"fr_FR\"},\"world\":null}");
assertSame(installed, IrisSettings.settings);
assertEquals("fr_FR", installed.getGeneral().getLanguage());
assertNotNull(installed.getWorld());
} finally {
IrisSettings.settings = previous;
}
}
@Test
public void rejectedHotloadActivationKeepsPreviousLiveSettings() {
IrisSettings previous = IrisSettings.settings;
IrisSettings live = new IrisSettings();
live.getGeneral().setLanguage("en_US");
IrisSettings.settings = live;
try {
boolean applied = IrisSettings.applyHotloadSnapshot(
"{\"general\":{\"language\":\"de_DE\"}}",
candidate -> false
);
assertFalse(applied);
assertSame(live, IrisSettings.settings);
assertEquals("en_US", IrisSettings.settings.getGeneral().getLanguage());
} finally {
IrisSettings.settings = previous;
}
}
@Test
public void successfulHotloadActivationPublishesCandidateSettings() {
IrisSettings previous = IrisSettings.settings;
IrisSettings live = new IrisSettings();
IrisSettings.settings = live;
try {
boolean applied = IrisSettings.applyHotloadSnapshot(
"{\"general\":{\"language\":\"de_DE\"},\"world\":null}",
candidate -> "de_DE".equals(candidate.getGeneral().getLanguage())
);
assertTrue(applied);
assertNotSame(live, IrisSettings.settings);
assertEquals("de_DE", IrisSettings.settings.getGeneral().getLanguage());
assertNotNull(IrisSettings.settings.getWorld());
} finally {
IrisSettings.settings = previous;
}
}
}
@@ -0,0 +1,89 @@
package art.arcane.iris.core;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class ProgressBossBarToggleTest {
@Test
public void progressBossBarsAreEnabledByDefault() {
assertTrue(new IrisSettings.IrisSettingsGeneral().isProgressBossBar());
}
@Test
public void showProgressLaneNeverTouchesTheLaneServiceWhenDisabled() {
withProgressBossBar(false, () ->
BukkitPlatform.showProgressLane(null, "iris:job", "Working", 0.5D, 4000L));
}
@Test
public void showProgressLaneStillReachesTheLaneServiceWhenEnabled() {
withProgressBossBar(true, () -> assertThrows(IllegalStateException.class,
() -> BukkitPlatform.showProgressLane(null, "iris:job", "Working", 0.5D, 4000L)));
}
@Test
public void studioOpenBossBarIsGatedBySettings() throws Exception {
assertGated("core/project/StudioOpenProgressReporter.java");
}
@Test
public void worldCreationBossBarIsGatedBySettings() throws Exception {
assertGated("core/tools/WorldCreationProgressReporter.java");
}
@Test
public void chunkJobBossBarIsGatedBySettings() throws Exception {
assertGated("core/runtime/ChunkJobReporter.java");
}
@Test
public void packDownloadLaneIsGatedWhileTheActionBarSurvives() throws Exception {
String source = source("core/service/PackDownloadProgressReporter.java");
assertTrue(source.contains("isProgressBossBar()"));
assertTrue("the action bar must keep reporting when boss bars are off",
source.contains("sender.sendAction(snapshot.line())"));
}
@Test
public void pregenLaneIsGatedWhileTheActionBarSurvives() throws Exception {
String source = source("core/tools/IrisCreator.java");
int lane = source.indexOf("\"iris:pregen\"");
int gate = source.lastIndexOf("isProgressBossBar()", lane);
assertTrue("the pregen boss bar lane must sit behind general.progressBossBar",
gate > 0 && lane - gate < 200);
assertTrue("the action bar must keep reporting when boss bars are off",
source.contains("RuntimeProgressMessages.WORLD_PREGEN_ACTION"));
}
private void assertGated(String relativePath) throws Exception {
assertTrue(relativePath + " must consult general.progressBossBar before creating a boss bar",
source(relativePath).contains("isProgressBossBar()"));
}
private String source(String relativePath) throws Exception {
return Files.readString(Path.of("src/main/java/art/arcane/iris").resolve(relativePath));
}
private void withProgressBossBar(boolean enabled, Runnable body) {
IrisSettings previous = IrisSettings.settings;
try {
IrisSettings live = new IrisSettings();
live.getGeneral().setProgressBossBar(enabled);
IrisSettings.settings = live;
assertFalse("another test hosted a HUD; this test needs an unhosted platform",
BukkitPlatform.hasHud());
body.run();
} finally {
IrisSettings.settings = previous;
}
}
}
@@ -0,0 +1,55 @@
package art.arcane.iris.core.localization;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.attribute.FileTime;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
public class IrisLanguageHotloadSnapshotTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void capturesMissingOverrideAsStableTombstone() throws Exception {
File override = new File(temporaryFolder.getRoot(), "languages/overrides/en_US.json");
LocaleHotloadSnapshot snapshot = IrisLanguage.captureHotloadSnapshot(override, "en_US");
assertTrue(snapshot.missing());
assertEquals("missing", snapshot.sha256());
}
@Test
public void detectsSameMetadataContentReplacementBySha256() throws Exception {
File override = new File(temporaryFolder.getRoot(), "languages/overrides/en_US.json");
Files.createDirectories(override.toPath().getParent());
String firstContent = "{\"messages\":{\"a\":\"1\"}}";
String secondContent = "{\"messages\":{\"a\":\"2\"}}";
Files.writeString(override.toPath(), firstContent, StandardCharsets.UTF_8);
FileTime fixedTime = FileTime.fromMillis(10_000L);
Files.setLastModifiedTime(override.toPath(), fixedTime);
LocaleHotloadSnapshot first = IrisLanguage.captureHotloadSnapshot(override, "en_US");
Files.writeString(override.toPath(), secondContent, StandardCharsets.UTF_8);
Files.setLastModifiedTime(override.toPath(), fixedTime);
LocaleHotloadSnapshot second = IrisLanguage.captureHotloadSnapshot(override, "en_US");
assertFalse(first.missing());
assertFalse(second.missing());
assertEquals(firstContent.length(), secondContent.length());
assertEquals(first.file(), second.file());
assertEquals(firstContent, first.content());
assertEquals(secondContent, second.content());
assertNotEquals(first.sha256(), second.sha256());
assertNotEquals(first, second);
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.core.localization;
import art.arcane.iris.core.IrisSettings;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.localization.LocaleOverlay;
import art.arcane.volmlib.util.localization.LocalizationValidationResult;
@@ -35,6 +36,7 @@ import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class IrisLanguageTest {
@@ -237,6 +239,26 @@ public class IrisLanguageTest {
);
}
@Test
public void rejectedCandidateLocaleLeavesPreviousSettingsAndLanguageActive() {
IrisSettings previous = IrisSettings.settings;
IrisSettings live = new IrisSettings();
live.getGeneral().setLanguage("en_US");
IrisSettings.settings = live;
try {
boolean applied = IrisSettings.applyHotloadSnapshot(
"{\"general\":{\"language\":\"../invalid\"}}",
IrisLanguage::reload
);
assertFalse(applied);
assertSame(live, IrisSettings.settings);
assertEquals("en_US", IrisLanguage.activeLocale());
} finally {
IrisSettings.settings = previous;
}
}
@Test
public void untrustedArgumentsCannotInjectLegacyOrMiniMessageFormatting() throws Exception {
writeOverride("de_DE", """
@@ -0,0 +1,159 @@
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
);
}
}
@@ -0,0 +1,60 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackValidationResult;
import org.junit.Test;
import java.util.List;
import java.util.Optional;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
public class StudioSVCPackAdmissionTest {
@Test
public void loadablePackIsAdmitted() {
PackValidationResult validation = new PackValidationResult(
"overworld", List.of(), List.of(), 1L);
assertNull(StudioSVC.resolvePackAdmissionFailure(
"overworld", Optional.empty(), validation));
}
@Test
public void missingValidationFailsClosedWithTheResolvedPackName() {
BrokenPackException failure = StudioSVC.resolvePackAdmissionFailure(
"overworld-pack", Optional.empty(), null);
assertEquals("overworld-pack", failure.getPackName());
assertEquals(List.of(
"Required pack validation has not completed. Studio creation fails closed until validation succeeds."),
failure.getReasons());
}
@Test
public void blockingValidationPreservesEveryReasonInOrder() {
List<String> reasons = List.of(
"Biome 'broken' has no resolvable regions.",
"Structure 'castle' references missing pool 'castle/start'.");
PackValidationResult validation = new PackValidationResult(
"overworld", reasons, List.of(), 1L);
BrokenPackException failure = StudioSVC.resolvePackAdmissionFailure(
"overworld", Optional.empty(), validation);
assertEquals("overworld", failure.getPackName());
assertEquals(reasons, failure.getReasons());
}
@Test
public void startupDenialTakesPriorityOverCachedPackValidation() {
String denial = "Restart the server after changing external datapacks.";
PackValidationResult validation = new PackValidationResult(
"overworld", List.of(), List.of(), 1L);
BrokenPackException failure = StudioSVC.resolvePackAdmissionFailure(
"overworld", Optional.of(denial), validation);
assertEquals(List.of(denial), failure.getReasons());
}
}
@@ -112,9 +112,9 @@ public class VillageImporterBundleTest {
}
@Test
public void nativeMaximumDistanceControlsIrisAssemblyRadius() {
public void registeredJigsawMetadataIncludesAncientCitySourceAndAssemblyContract() {
Map<String, Object> structure = VillageImporter.structureJson(
"minecraft:village_plains",
"minecraft:ancient_city",
"village/pool/minecraft/start",
6,
81
@@ -122,8 +122,10 @@ public class VillageImporterBundleTest {
assertEquals(6, structure.get("maxDepth"));
assertEquals(6, structure.get("maxSizeChunks"));
assertEquals("STRUCTURE_PIECE", structure.get("placeMode"));
assertEquals(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH.name(),
structure.get("branchFailurePolicy"));
assertEquals("minecraft:ancient_city", structure.get("vanillaSource"));
}
private StructureResourceBundle bundle(String objectContent) {