(removed ai file from when i generated docs)
This commit is contained in:
Brian Neumann-Fopiano
2026-08-10 15:47:57 -04:00
parent ebfe278b3b
commit 998a5c9f5f
256 changed files with 49776 additions and 1221 deletions
@@ -1,7 +1,9 @@
package art.arcane.iris.core;
import art.arcane.iris.BuildConstants;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.PackDirectoryResolver;
@@ -12,15 +14,20 @@ import art.arcane.volmlib.util.collection.KSet;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -30,18 +37,105 @@ import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.stream.Stream;
public final class IrisDatapackCompiler {
private static final int INPUT_FINGERPRINT_SCHEMA = 1;
private static final int WORLD_PACK_SCAN_DEPTH = 8;
private static final List<String> INPUT_DIRECTORIES = List.of("dimensions", "biomes", "snippet");
private IrisDatapackCompiler() {
}
public static List<File> collectPackRoots(Path dataDirectory, Path serverRoot) throws IOException {
return collectPackRoots(dataDirectory, serverRoot, true);
}
public static List<File> collectCompilerInputRoots(Path dataDirectory, Path serverRoot) throws IOException {
return collectPackRoots(dataDirectory, serverRoot, false);
}
private static List<File> collectPackRoots(
Path dataDirectory,
Path serverRoot,
boolean validateWholePack
) throws IOException {
LinkedHashMap<Path, File> roots = new LinkedHashMap<>();
collectInstalledPackRoots(dataDirectory.resolve("packs"), roots);
collectWorldPackRoots(serverRoot.resolve("dimensions"), roots);
collectInstalledPackRoots(dataDirectory.resolve("packs"), roots, validateWholePack);
collectWorldPackRoots(serverRoot.resolve("dimensions"), roots, validateWholePack);
return new ArrayList<>(roots.values());
}
public static String computeInputFingerprint(
List<File> packRoots,
IDataFixer fixer,
boolean adjustVanillaHeight
) throws IOException {
Objects.requireNonNull(fixer, "fixer");
return computeInputFingerprint(
packRoots,
adjustVanillaHeight,
compilerIdentity(fixer));
}
static String computeInputFingerprint(
List<File> packRoots,
boolean adjustVanillaHeight,
String compilerIdentity
) throws IOException {
Objects.requireNonNull(packRoots, "packRoots");
Objects.requireNonNull(compilerIdentity, "compilerIdentity");
MessageDigest digest = sha256();
updateDigestString(digest, "iris-datapack-compiler-input");
updateDigestInt(digest, INPUT_FINGERPRINT_SCHEMA);
updateDigestString(digest, compilerIdentity);
digest.update((byte) (adjustVanillaHeight ? 1 : 0));
updateDigestInt(digest, packRoots.size());
for (int index = 0; index < packRoots.size(); index++) {
File packRoot = Objects.requireNonNull(packRoots.get(index), "pack root");
Path normalizedRoot = packRoot.toPath().toAbsolutePath().normalize();
if (!Files.isDirectory(normalizedRoot)) {
throw new IOException("Iris datapack compiler input root is missing or unsafe: " + normalizedRoot);
}
Path realRoot = normalizedRoot.toRealPath();
updateDigestInt(digest, index);
updateDigestString(digest, normalizedRoot.toString());
updateDigestString(digest, realRoot.toString());
boolean active = hasDimensions(normalizedRoot);
digest.update((byte) (active ? 1 : 0));
if (!active) {
continue;
}
List<CompilerInputEntry> entries = collectCompilerInputEntries(normalizedRoot);
updateDigestInt(digest, entries.size());
byte[] buffer = new byte[8192];
for (CompilerInputEntry entry : entries) {
updateDigestString(digest, entry.relativePath());
updateDigestLong(digest, Files.size(entry.source()));
try (InputStream input = Files.newInputStream(entry.source())) {
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
}
}
}
}
}
return HexFormat.of().formatHex(digest.digest());
}
public static String compilerIdentity(IDataFixer fixer) {
IDataFixer requiredFixer = Objects.requireNonNull(fixer, "fixer");
return String.join(
"|",
Integer.toString(INPUT_FINGERPRINT_SCHEMA),
BuildConstants.COMMIT,
BuildConstants.MINECRAFT_VERSION,
requiredFixer.getClass().getName(),
Integer.toString(DataVersion.minSupportedPackFormat()),
Integer.toString(DataVersion.getLatest().getPackFormat()));
}
public static CompilationResult compile(
List<File> packRoots,
KList<File> datapackRoots,
@@ -102,14 +196,22 @@ public final class IrisDatapackCompiler {
return new CompilationResult(packCount, dimensionCount, countBiomes(biomes));
}
private static void collectInstalledPackRoots(Path packsRoot, Map<Path, File> roots) throws IOException {
private static void collectInstalledPackRoots(
Path packsRoot,
Map<Path, File> roots,
boolean validateWholePack
) throws IOException {
List<File> candidates = PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(packsRoot.toFile());
for (File candidate : candidates) {
addPackRoot(candidate.toPath(), roots);
addPackRoot(candidate.toPath(), roots, validateWholePack);
}
}
private static void collectWorldPackRoots(Path dimensionsRoot, Map<Path, File> roots) throws IOException {
private static void collectWorldPackRoots(
Path dimensionsRoot,
Map<Path, File> roots,
boolean validateWholePack
) throws IOException {
if (Files.isSymbolicLink(dimensionsRoot)
|| !Files.isDirectory(dimensionsRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
@@ -133,11 +235,15 @@ public final class IrisDatapackCompiler {
});
candidates.sort(Comparator.comparing(Path::toString));
for (Path candidate : candidates) {
addPackRoot(candidate, roots);
addPackRoot(candidate, roots, validateWholePack);
}
}
private static void addPackRoot(Path root, Map<Path, File> roots) throws IOException {
private static void addPackRoot(
Path root,
Map<Path, File> roots,
boolean validateWholePack
) throws IOException {
if (!hasDimensions(root)) {
return;
}
@@ -145,8 +251,13 @@ public final class IrisDatapackCompiler {
if (!Files.isDirectory(normalized)) {
return;
}
PackDirectoryResolver.requireSafePackTree(normalized.toFile());
Path identity = normalized.toRealPath();
if (!Files.isDirectory(identity, LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (validateWholePack) {
PackDirectoryResolver.requireSafePackTree(normalized.toFile());
}
roots.putIfAbsent(identity, normalized.toFile());
}
@@ -165,6 +276,77 @@ public final class IrisDatapackCompiler {
}
}
private static List<CompilerInputEntry> collectCompilerInputEntries(Path packRoot) throws IOException {
List<CompilerInputEntry> entries = new ArrayList<>();
for (String directoryName : INPUT_DIRECTORIES) {
Path directory = packRoot.resolve(directoryName);
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
if (Files.isSymbolicLink(directory)
|| !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Iris datapack compiler input is missing or unsafe: " + directory);
}
Files.walkFileTree(directory, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path child, BasicFileAttributes attributes) throws IOException {
if (attributes.isSymbolicLink() || Files.isSymbolicLink(child)) {
throw new IOException("Iris datapack compiler input contains a symbolic link: " + child);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
throw new IOException("Iris datapack compiler input contains a symbolic link: " + file);
}
if (!attributes.isRegularFile()) {
throw new IOException("Iris datapack compiler input contains an unsupported entry: " + file);
}
if (file.getFileName().toString().endsWith(".json")) {
String relativePath = packRoot.relativize(file).toString().replace(File.separatorChar, '/');
entries.add(new CompilerInputEntry(file, relativePath));
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
throw new IOException("Unable to inspect Iris datapack compiler input: " + file, failure);
}
});
}
entries.sort(Comparator.comparing(CompilerInputEntry::relativePath));
return entries;
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 not available", exception);
}
}
private static void updateDigestString(MessageDigest digest, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
updateDigestInt(digest, bytes.length);
digest.update(bytes);
}
private static void updateDigestInt(MessageDigest digest, int value) {
for (int shift = Integer.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) {
digest.update((byte) (value >>> shift));
}
}
private static void updateDigestLong(MessageDigest digest, long value) {
for (int shift = Long.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) {
digest.update((byte) (value >>> shift));
}
}
private static void resetOutputRoots(Collection<File> datapackRoots) throws IOException {
for (File datapackRoot : datapackRoots) {
Path root = datapackRoot.toPath().toAbsolutePath().normalize();
@@ -204,6 +386,9 @@ public final class IrisDatapackCompiler {
public record CompilationResult(int packCount, int dimensionCount, int biomeCount) {
}
private record CompilerInputEntry(Path source, String relativePath) {
}
public static final class DimensionHeight {
private final IDataFixer fixer;
private final AtomicIntegerArray[] dimensions = new AtomicIntegerArray[3];
@@ -21,6 +21,7 @@ package art.arcane.iris.core;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.datapack.DatapackIngestService.ReapplyOutcome;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
@@ -73,6 +74,7 @@ import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.stream.Stream;
import art.arcane.iris.core.localization.BukkitRuntimeMessages;
@@ -81,8 +83,18 @@ import art.arcane.volmlib.util.localization.MessageArgument;
public class ServerConfigurator {
private static final Object DATAPACK_INSTALL_LOCK = new Object();
private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace";
private static final String COMPILER_INPUT_FINGERPRINT_CACHE = "datapack-compiler-input-fingerprint";
private static volatile boolean loadedDatapackRuntimeReady;
private static volatile String loadedDatapackCompilerInputFingerprint = "";
private static volatile long loadedDatapackRuntimeGeneration;
private static volatile boolean loadedDatapackRestartRequired;
public static void configure() {
synchronized (DATAPACK_INSTALL_LOCK) {
invalidateLoadedDatapackRuntime();
loadedDatapackCompilerInputFingerprint = "";
loadedDatapackRestartRequired = false;
}
IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration();
if (s.isConfigureSpigotTimeoutTime()) {
J.attempt(ServerConfigurator::increaseKeepAliveSpigot);
@@ -93,15 +105,91 @@ public class ServerConfigurator {
}
if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) {
loadedDatapackRuntimeReady = !IrisSettings.get().getGeneral().adjustVanillaHeight
&& pinLoadedDatapackCompilerInputs(
DefaultPackBootstrapProvisioner.compilerInputFingerprintThisStartup());
IrisLogging.info("Paper loaded the Iris datapack during bootstrap; skipping the legacy startup install.");
} else {
DatapackInstallResult result = installDataPacks(true);
loadedDatapackRuntimeReady = result.succeeded()
&& !result.restartRequired()
&& pinLoadedDatapackCompilerInputs();
if (result.restartRequired() && IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall()) {
restart();
}
}
}
public static boolean isLoadedDatapackRuntimeReady(IrisDimension dimension) {
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "Iris dimension");
if (!loadedDatapackRuntimeReady
|| loadedDatapackRestartRequired
|| !BukkitPlatform.hasPlugin()
|| !BukkitPlatform.plugin().isEnabled()) {
return false;
}
try {
if (!INMS.get().supportsIrisWorldGeneration()
|| INMS.get().missingDimensionTypes(requiredDimension.getDimensionTypeKey())) {
return false;
}
String currentFingerprint = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer());
return reusableRuntimeFingerprint(
loadedDatapackCompilerInputFingerprint,
currentFingerprint);
} catch (IOException | RuntimeException exception) {
IrisLogging.reportError("Unable to verify loaded Iris datapack compiler inputs.", exception);
return false;
}
}
public static LoadedDatapackRuntimeInvalidation invalidateLoadedDatapackRuntime() {
synchronized (DATAPACK_INSTALL_LOCK) {
boolean wasReady = loadedDatapackRuntimeReady;
String fingerprint = loadedDatapackCompilerInputFingerprint;
loadedDatapackRuntimeReady = false;
loadedDatapackRuntimeGeneration++;
return new LoadedDatapackRuntimeInvalidation(
loadedDatapackRuntimeGeneration,
wasReady,
fingerprint);
}
}
public static void requireDatapackRestart() {
synchronized (DATAPACK_INSTALL_LOCK) {
invalidateLoadedDatapackRuntime();
loadedDatapackRestartRequired = true;
}
}
public static void restoreLoadedDatapackRuntimeIfUnchanged(
LoadedDatapackRuntimeInvalidation invalidation
) {
if (invalidation == null
|| !invalidation.wasReady()
|| invalidation.fingerprint().isBlank()) {
return;
}
String currentFingerprint;
try {
currentFingerprint = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer());
} catch (IOException | RuntimeException exception) {
IrisLogging.reportError("Unable to restore loaded Iris datapack runtime readiness.", exception);
return;
}
synchronized (DATAPACK_INSTALL_LOCK) {
if (loadedDatapackRuntimeGeneration != invalidation.generation()
|| loadedDatapackRuntimeReady
|| loadedDatapackRestartRequired
|| !reusableRuntimeFingerprint(invalidation.fingerprint(), currentFingerprint)) {
return;
}
loadedDatapackCompilerInputFingerprint = currentFingerprint;
loadedDatapackRuntimeReady = true;
}
}
private static void increaseKeepAliveSpigot() throws IOException, InvalidConfigurationException {
File spigotConfig = new File("spigot.yml");
FileConfiguration f = new YamlConfiguration();
@@ -156,6 +244,26 @@ public class ServerConfigurator {
}
private static DatapackInstallResult installDataPacksLocked(IDataFixer fixer, boolean fullInstall) {
if (fixer == null) {
IrisLogging.error("Unable to install datapacks, fixer is null!");
return DatapackInstallResult.failedResult();
}
KList<File> datapacksFolders = getDatapacksFolder();
ReapplyOutcome reapply = DatapackIngestService.reapplyFromStaging(datapacksFolders);
if (!reapply.succeeded()) {
return DatapackInstallResult.failedResult();
}
return compileDataPacksLocked(fixer, fullInstall, reapply);
}
private static DatapackInstallResult compileDataPacksLocked(
IDataFixer fixer,
boolean fullInstall,
ReapplyOutcome reapply
) {
if (!Objects.requireNonNull(reapply, "External datapack reapply outcome").succeeded()) {
return DatapackInstallResult.failedResult();
}
if (fixer == null) {
IrisLogging.error("Unable to install datapacks, fixer is null!");
return DatapackInstallResult.failedResult();
@@ -165,18 +273,12 @@ public class ServerConfigurator {
} else {
IrisLogging.debug("Checking Data Packs...");
}
KList<File> datapacksFolders = getDatapacksFolder();
if (!DatapackIngestService.reapplyFromStaging(datapacksFolders)) {
IrisLogging.error("Unable to compile Iris datapacks while external datapack recovery is incomplete.");
return DatapackInstallResult.failedResult();
}
List<File> packRoots;
try (Stream<IrisData> stream = allPacks()) {
packRoots = stream
.map(IrisData::getDataFolder)
.map(File::getAbsoluteFile)
.distinct()
.toList();
try {
packRoots = collectCompilerPackRoots();
} catch (IOException exception) {
IrisLogging.reportError("Unable to resolve Iris datapack compiler roots.", exception);
return DatapackInstallResult.failedResult();
}
KList<File> liveRoots = getIrisDatapackRoots();
@@ -237,7 +339,8 @@ public class ServerConfigurator {
IrisLogging.debug("Data Packs Setup!");
}
boolean restartRequired = fullInstall && verifyDataPacksPost();
boolean verifiedRestartRequired = fullInstall && verifyDataPacksPost();
boolean restartRequired = fullInstall && (reapply.changed() || verifiedRestartRequired);
return restartRequired
? DatapackInstallResult.restartRequiredResult()
: DatapackInstallResult.readyResult();
@@ -264,33 +367,167 @@ public class ServerConfigurator {
}
public static DatapackInstallResult installDataPacksIfChanged(boolean fullInstall) {
return installDataPacksIfChanged(fullInstall, null);
}
public static DatapackInstallResult installDataPacksIfChanged(
boolean fullInstall,
BiConsumer<String, Long> timingConsumer
) {
synchronized (DATAPACK_INSTALL_LOCK) {
File packsDir = IrisPlatforms.get().dataFolder("packs");
File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint");
FingerprintCache cached = readFingerprintCache(cacheFile.toPath());
PackFingerprint fingerprint;
try {
fingerprint = resolvePackFingerprint(packsDir, cached.metadata(), cached.content());
} catch (RuntimeException exception) {
IrisLogging.reportError("Unable to fingerprint Iris packs safely", exception);
long totalStart = System.nanoTime();
File cacheFile = new File(
IrisPlatforms.get().dataFolder("cache"),
COMPILER_INPUT_FINGERPRINT_CACHE);
String cached = readCompilerInputFingerprintCache(cacheFile.toPath());
long recoveryStart = System.nanoTime();
ReapplyOutcome reapply = DatapackIngestService.reapplyFromStaging(getDatapacksFolder());
reportTiming(timingConsumer, "datapack_external_recovery", recoveryStart);
if (!reapply.succeeded()) {
reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart);
return DatapackInstallResult.failedResult();
}
String current = fingerprint.content();
if (!current.isEmpty() && current.equals(cached.content())) {
if (!fingerprint.metadata().equals(cached.metadata())) {
writeFingerprintCache(cacheFile.toPath(), fingerprint);
}
if (fullInstall && loadedDatapackRestartRequired) {
reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart);
return DatapackInstallResult.restartRequiredResult();
}
String current;
long fingerprintStart = System.nanoTime();
try {
current = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer());
} catch (IOException | RuntimeException exception) {
reportTiming(timingConsumer, "datapack_compiler_input_fingerprint", fingerprintStart);
reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart);
IrisLogging.reportError("Unable to fingerprint Iris datapack compiler inputs safely", exception);
return DatapackInstallResult.failedResult();
}
reportTiming(timingConsumer, "datapack_compiler_input_fingerprint", fingerprintStart);
boolean loadedCompilerInputsChanged = !loadedDatapackCompilerInputFingerprint.isBlank()
&& !reusableRuntimeFingerprint(
loadedDatapackCompilerInputFingerprint,
current);
if (!current.isEmpty() && current.equals(cached)) {
IrisLogging.debug("Data packs unchanged, skipping install.");
return DatapackInstallResult.unchangedResult();
DatapackInstallResult result = fullInstall && loadedCompilerInputsChanged
? DatapackInstallResult.restartRequiredResult()
: resultForUnchangedFingerprint(fullInstall, reapply);
if (result.restartRequired()) {
requireDatapackRestart();
}
reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart);
return result;
}
DatapackInstallResult result = installDataPacksLocked(resolveDataFixer(), fullInstall);
if (result.succeeded()) {
writeFingerprintCache(cacheFile.toPath(), fingerprint);
long compileStart = System.nanoTime();
DatapackInstallResult result = compileDataPacksLocked(
resolveDataFixer(),
fullInstall,
reapply);
if (fullInstall && loadedCompilerInputsChanged && result.succeeded()) {
result = DatapackInstallResult.restartRequiredResult();
}
if (result.restartRequired()) {
requireDatapackRestart();
}
reportTiming(timingConsumer, "datapack_compile_publish", compileStart);
if (result.succeeded() && !result.restartRequired()) {
writeCompilerInputFingerprintCache(cacheFile.toPath(), current);
}
reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart);
return result;
}
}
private static List<File> collectCompilerPackRoots() throws IOException {
return IrisDatapackCompiler.collectPackRoots(
IrisPlatforms.get().dataFolder().toPath(),
IrisWorldStorage.levelRoot().toPath());
}
private static String computeCurrentDatapackCompilerInputFingerprint(IDataFixer fixer) throws IOException {
return IrisDatapackCompiler.computeInputFingerprint(
IrisDatapackCompiler.collectCompilerInputRoots(
IrisPlatforms.get().dataFolder().toPath(),
IrisWorldStorage.levelRoot().toPath()),
Objects.requireNonNull(fixer, "Datapack fixer"),
IrisSettings.get().getGeneral().adjustVanillaHeight);
}
private static boolean pinLoadedDatapackCompilerInputs() {
return pinLoadedDatapackCompilerInputs(null);
}
private static boolean pinLoadedDatapackCompilerInputs(String expectedFingerprint) {
if (loadedDatapackRestartRequired) {
return false;
}
try {
String fingerprint = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer());
if (fingerprint.isBlank()
|| expectedFingerprint != null
&& !reusableRuntimeFingerprint(expectedFingerprint, fingerprint)) {
return false;
}
loadedDatapackCompilerInputFingerprint = fingerprint;
File cacheFile = new File(
IrisPlatforms.get().dataFolder("cache"),
COMPILER_INPUT_FINGERPRINT_CACHE);
writeCompilerInputFingerprintCache(cacheFile.toPath(), fingerprint);
return true;
} catch (IOException | RuntimeException exception) {
loadedDatapackCompilerInputFingerprint = "";
IrisLogging.reportError("Unable to pin loaded Iris datapack compiler inputs.", exception);
return false;
}
}
static boolean reusableRuntimeFingerprint(String loadedFingerprint, String currentFingerprint) {
return loadedFingerprint != null
&& !loadedFingerprint.isBlank()
&& loadedFingerprint.equals(currentFingerprint);
}
private static void reportTiming(
BiConsumer<String, Long> timingConsumer,
String phase,
long startedAtNanos
) {
if (timingConsumer == null) {
return;
}
try {
timingConsumer.accept(phase, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos));
} catch (Throwable exception) {
IrisLogging.reportError("Datapack timing consumer failed during phase \"" + phase + "\".", exception);
}
}
static DatapackInstallResult resultForUnchangedFingerprint(
boolean fullInstall,
ReapplyOutcome reapply
) {
if (!Objects.requireNonNull(reapply, "External datapack reapply outcome").succeeded()) {
return DatapackInstallResult.failedResult();
}
if (!reapply.changed()) {
return DatapackInstallResult.unchangedResult();
}
return fullInstall
? DatapackInstallResult.restartRequiredResult()
: DatapackInstallResult.readyResult();
}
static PackFingerprint resolvePostRecoveryPackFingerprint(
File packsDir,
String cachedMetadata,
String cachedContent,
ReapplyOutcome reapply
) {
if (Objects.requireNonNull(reapply, "External datapack reapply outcome").changed()) {
return resolvePackFingerprint(packsDir, "", "");
}
return resolvePackFingerprint(packsDir, cachedMetadata, cachedContent);
}
static PackFingerprint resolvePackFingerprint(File packsDir, String cachedMetadata, String cachedContent) {
String metadata = computePackMetadataDigest(packsDir);
if (!metadata.isEmpty()
@@ -392,6 +629,17 @@ public class ServerConfigurator {
}
}
private static String readCompilerInputFingerprintCache(Path cacheFile) {
if (!Files.isRegularFile(cacheFile)) {
return "";
}
try {
return Files.readString(cacheFile, StandardCharsets.UTF_8).trim();
} catch (IOException exception) {
return "";
}
}
private static void writeFingerprintCache(Path cacheFile, PackFingerprint fingerprint) {
try {
writeFingerprintAtomic(cacheFile, fingerprint.content() + "\n" + fingerprint.metadata());
@@ -400,6 +648,15 @@ public class ServerConfigurator {
}
}
private static void writeCompilerInputFingerprintCache(Path cacheFile, String fingerprint) {
try {
writeFingerprintAtomic(cacheFile, fingerprint);
} catch (IOException exception) {
IrisLogging.warn("Failed to write datapack compiler-input fingerprint cache: "
+ exception.getMessage());
}
}
private static void writeFingerprintAtomic(Path target, String fingerprint) throws IOException {
Path absoluteTarget = target.toAbsolutePath().normalize();
Path parent = absoluteTarget.getParent();
@@ -571,6 +828,7 @@ public class ServerConfigurator {
}
public static void restart(String reason) {
requireDatapackRestart();
LifecycleOperationCoordinator.get().quiesceForRestart(() -> J.s(() -> {
IrisLogging.warn(reason + " Restarting server to restore a safe lifecycle boundary.");
J.s(() -> {
@@ -659,4 +917,14 @@ public class ServerConfigurator {
return key == null ? null : key.toString();
}
public record LoadedDatapackRuntimeInvalidation(
long generation,
boolean wasReady,
String fingerprint
) {
public LoadedDatapackRuntimeInvalidation {
fingerprint = Objects.requireNonNullElse(fingerprint, "");
}
}
}
@@ -0,0 +1,91 @@
package art.arcane.iris.core;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class SnapshotDirectoryTreeDeleter {
private SnapshotDirectoryTreeDeleter() {
}
public static void delete(Path target) throws IOException {
Path root = Objects.requireNonNull(target, "target").toAbsolutePath().normalize();
if (root.getParent() == null) {
throw new IOException("Refusing to delete a filesystem root: " + root);
}
if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) {
return;
}
requireDirectory(root);
deleteDirectory(root);
}
private static void deleteDirectory(Path directory) throws IOException {
requireDirectory(directory);
List<SnapshotEntry> entries = snapshot(directory);
for (SnapshotEntry entry : entries) {
BasicFileAttributes current = requireSafeEntry(entry.path());
if (entry.directory() != current.isDirectory()
|| !sameFile(entry.fileKey(), current.fileKey())) {
throw new IOException("Directory entry changed during deletion: " + entry.path());
}
if (entry.directory()) {
deleteDirectory(entry.path());
} else {
Files.delete(entry.path());
}
}
Files.delete(directory);
}
private static List<SnapshotEntry> snapshot(Path directory) throws IOException {
ArrayList<SnapshotEntry> entries = new ArrayList<>();
try (DirectoryStream<Path> children = Files.newDirectoryStream(directory)) {
for (Path child : children) {
Path normalized = child.toAbsolutePath().normalize();
if (!Objects.equals(normalized.getParent(), directory)) {
throw new IOException("Directory entry escapes its parent: " + child);
}
BasicFileAttributes attributes = requireSafeEntry(normalized);
entries.add(new SnapshotEntry(normalized, attributes.isDirectory(), attributes.fileKey()));
}
}
return List.copyOf(entries);
}
private static BasicFileAttributes requireDirectory(Path directory) throws IOException {
BasicFileAttributes attributes = requireSafeEntry(directory);
if (!attributes.isDirectory()) {
throw new IOException("Deletion target is not a directory: " + directory);
}
return attributes;
}
private static BasicFileAttributes requireSafeEntry(Path entry) throws IOException {
BasicFileAttributes attributes = Files.readAttributes(
entry,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS
);
if (attributes.isSymbolicLink()) {
throw new IOException("Deletion target contains a symbolic link: " + entry);
}
if (!attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Deletion target contains an unsafe filesystem entry: " + entry);
}
return attributes;
}
private static boolean sameFile(Object expected, Object actual) {
return expected == null || actual == null || expected.equals(actual);
}
private record SnapshotEntry(Path path, boolean directory, Object fileKey) {
}
}
@@ -99,6 +99,7 @@ public final class DatapackIngestService {
private static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json";
private static final int OWNERSHIP_SCHEMA = 1;
private static final int TRANSACTION_SCHEMA = 2;
private static final int STRUCTURE_IMPORT_FORMAT_REVISION = 3;
private static final int MAX_REDIRECTS = 5;
private static final int MAX_ARCHIVE_ENTRIES = 100_000;
private static final int MAX_CACHE_FILES = 32;
@@ -157,12 +158,25 @@ public final class DatapackIngestService {
}
public static Report ingest(VolmitSender sender, KList<String> urls, boolean restart) {
ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation =
ServerConfigurator.invalidateLoadedDatapackRuntime();
Report report;
TRANSACTION_LOCK.lock();
try {
return ingestLocked(sender, urls, restart);
report = ingestLocked(sender, urls, restart);
} finally {
TRANSACTION_LOCK.unlock();
}
if (!report.changed() && report.getFailed().isEmpty()) {
ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation);
} else if (report.changed()) {
if (restart) {
ServerConfigurator.restart();
} else {
ServerConfigurator.requireDatapackRestart();
}
}
return report;
}
private static Report ingestLocked(VolmitSender sender, KList<String> urls, boolean restart) {
@@ -266,11 +280,9 @@ public final class DatapackIngestService {
if (report.changed()) {
message(sender, C.YELLOW + "New datapack structures were installed. A server restart is required for them to register and generate.");
message(sender, C.GRAY + "After the restart they generate natively - no import needed. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import <dimension>, or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures.");
message(sender, C.GRAY + "After the restart they generate natively only in Iris dimensions that declare their source URL - no import needed. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import <dimension>, or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures.");
message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; deny non-minecraft datapack and mod structures explicitly with importedStructures.disabled.");
if (restart) {
ServerConfigurator.restart();
} else {
if (!restart) {
message(sender, C.GRAY + "Run with restart=true to restart now, or restart manually. After restart, run /iris structure list <dimension> to see the new keys.");
}
}
@@ -278,23 +290,40 @@ public final class DatapackIngestService {
return report;
}
public static boolean reapplyFromStaging(KList<File> worldFolders) {
public static ReapplyOutcome reapplyFromStaging(KList<File> worldFolders) {
ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation =
ServerConfigurator.invalidateLoadedDatapackRuntime();
ReapplyOutcome outcome;
TRANSACTION_LOCK.lock();
try {
return reapplyFromStagingLocked(worldFolders);
outcome = reapplyFromStagingLocked(worldFolders);
} finally {
TRANSACTION_LOCK.unlock();
}
if (outcome.succeeded() && !outcome.changed()) {
ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation);
} else if (outcome.changed()) {
ServerConfigurator.requireDatapackRestart();
}
return outcome;
}
private static boolean reapplyFromStagingLocked(KList<File> worldFolders) {
private static ReapplyOutcome reapplyFromStagingLocked(KList<File> worldFolders) {
File root = IrisPlatforms.get().dataFolder("datapacks");
if (!recoverBeforeReapply(root, worldFolders)) {
return false;
ReapplyOutcome recovery = recoverBeforeReapplyOutcome(root, worldFolders);
if (!recovery.succeeded()) {
return reportReapplyFailure(recovery);
}
File stagingDir = IrisPlatforms.get().dataFolderNoCreate("datapacks", "staging");
return reapplyStagingRoot(
root, stagingDir, worldFolders, resolveStripOverrides());
ReapplyOutcome repair = reapplyStagingRootOutcome(
root,
stagingDir,
worldFolders,
resolveStripOverrides());
if (!repair.succeeded()) {
return reportReapplyFailure(repair);
}
return ReapplyOutcome.success(recovery.recovered(), repair.repaired());
}
static boolean reapplyStagingRoot(
@@ -302,23 +331,36 @@ public final class DatapackIngestService {
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
return reportReapplyFailure(reapplyStagingRootOutcome(
root,
stagingDir,
worldFolders,
stripOverrides)).succeeded();
}
static ReapplyOutcome reapplyStagingRootOutcome(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
Manifest manifest = readManifest(root);
if (stagingDir == null
|| !Files.exists(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
if (manifest.entries.isEmpty()) {
return true;
return ReapplyOutcome.success(false, false);
}
IrisLogging.error("Managed datapack staging is missing at "
+ (stagingDir == null ? new File(root, "staging").getPath() : stagingDir.getPath()));
return false;
File missing = stagingDir == null ? new File(root, "staging") : stagingDir;
return ReapplyOutcome.failed(new IOException(
"Managed datapack staging is missing at " + missing.getPath()));
}
if (Files.isSymbolicLink(stagingDir.toPath())
|| !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
IrisLogging.error("Managed datapack staging is not a safe directory at " + stagingDir.getPath());
return false;
return ReapplyOutcome.failed(new IOException(
"Managed datapack staging is not a safe directory at " + stagingDir.getPath()));
}
return reapplyStagedDirectories(
return reapplyStagedDirectoriesOutcome(
root, stagingDir, worldFolders, stripOverrides, manifest);
}
@@ -330,14 +372,19 @@ public final class DatapackIngestService {
) {
if (Files.isSymbolicLink(stagingDir.toPath())
|| !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
IrisLogging.error("Managed datapack staging is not a safe directory at " + stagingDir.getPath());
return false;
return reportReapplyFailure(ReapplyOutcome.failed(new IOException(
"Managed datapack staging is not a safe directory at "
+ stagingDir.getPath()))).succeeded();
}
return reapplyStagedDirectories(
root, stagingDir, worldFolders, stripOverrides, readManifest(root));
return reportReapplyFailure(reapplyStagedDirectoriesOutcome(
root,
stagingDir,
worldFolders,
stripOverrides,
readManifest(root))).succeeded();
}
private static boolean reapplyStagedDirectories(
private static ReapplyOutcome reapplyStagedDirectoriesOutcome(
File root,
File stagingDir,
KList<File> worldFolders,
@@ -346,37 +393,57 @@ public final class DatapackIngestService {
) {
File[] staged = stagingDir.listFiles(File::isDirectory);
if (staged == null) {
IrisLogging.error("Unable to enumerate managed datapack staging at " + stagingDir.getPath());
return false;
return ReapplyOutcome.failed(new IOException(
"Unable to enumerate managed datapack staging at " + stagingDir.getPath()));
}
boolean successful = true;
boolean repaired = false;
IOException failure = null;
for (Entry entry : manifest.entries) {
File stagedDir = new File(stagingDir, entry.id);
if (isRecordedUnchangedInstall(stagedDir, worldFolders, entry, stripOverrides)) {
continue;
}
if (!isUsableStaging(stagedDir, entry)) {
IrisLogging.error("Managed datapack staging is unusable for '" + entry.id
+ "' at " + stagedDir.getPath());
forgetInstallMetadata(entry);
successful = false;
failure = appendFailure(failure, new IOException(
"Managed datapack staging is unusable for '" + entry.id
+ "' at " + stagedDir.getPath()));
continue;
}
try {
InstallResult result = install(stagedDir, worldFolders, entry, stripOverrides);
if (result.changed()) {
repaired = true;
IrisLogging.warn("Repaired installed datapack '" + entry.id
+ "' from Iris staging before datapack compilation.");
}
recordInstallMetadata(stagedDir, worldFolders, entry);
} catch (IOException e) {
IrisLogging.reportError(e);
forgetInstallMetadata(entry);
successful = false;
failure = appendFailure(failure, e);
}
}
writeManifest(root, manifest);
return successful;
return failure == null
? ReapplyOutcome.success(false, repaired)
: ReapplyOutcome.failed(failure);
}
private static IOException appendFailure(IOException current, IOException additional) {
if (current == null) {
return additional;
}
current.addSuppressed(additional);
return current;
}
private static ReapplyOutcome reportReapplyFailure(ReapplyOutcome outcome) {
if (!outcome.succeeded()) {
IrisLogging.reportError(
"External datapack recovery or staging repair failed.",
outcome.failure().orElseThrow());
}
return outcome;
}
private static boolean isRecordedUnchangedInstall(
@@ -501,22 +568,30 @@ public final class DatapackIngestService {
}
static boolean recoverBeforeReapply(File root, List<File> worldFolders) {
return reportReapplyFailure(recoverBeforeReapplyOutcome(root, worldFolders)).succeeded();
}
private static ReapplyOutcome recoverBeforeReapplyOutcome(File root, List<File> worldFolders) {
try {
recoverTransactions(root, worldFolders);
return ReapplyOutcome.success(recoverTransactions(root, worldFolders), false);
} catch (IOException e) {
IrisLogging.reportError("Datapack staging reapply blocked by incomplete transaction recovery.", e);
return false;
return ReapplyOutcome.failed(e);
}
return true;
}
public static boolean remove(VolmitSender sender, String id) {
ServerConfigurator.invalidateLoadedDatapackRuntime();
boolean removed;
TRANSACTION_LOCK.lock();
try {
return removeLocked(sender, id);
removed = removeLocked(sender, id);
} finally {
TRANSACTION_LOCK.unlock();
}
if (removed) {
ServerConfigurator.requireDatapackRestart();
}
return removed;
}
private static boolean removeLocked(VolmitSender sender, String id) {
@@ -737,6 +812,7 @@ public final class DatapackIngestService {
List<PreparedEditableImport> prepared = new ArrayList<>();
Set<String> targetIdSet = new TreeSet<>(entry.importedBundles.keySet());
targetIdSet.addAll(entry.importedTargets.keySet());
targetIdSet.addAll(entry.importAttempts.keySet());
List<String> targetIds = new ArrayList<>(targetIdSet);
targetIds.sort(String::compareTo);
try {
@@ -792,7 +868,7 @@ public final class DatapackIngestService {
if (ownedSource.isEmpty() || !sourceClaimsContain(bundle.getValue(), ownedSource.get())) {
continue;
}
removals.add(new StructureTransactionWriter.OwnedRemoval(
removals.add(StructureTransactionWriter.OwnedRemoval.managedDatapack(
targetKey,
ownedSource.get().kind(),
ownedSource.get().key()
@@ -826,6 +902,7 @@ public final class DatapackIngestService {
}
if (candidateRetained) {
candidate.importedTargets.remove(targetId);
candidate.importAttempts.remove(targetId);
candidate.structuresImported = false;
}
}
@@ -963,6 +1040,56 @@ public final class DatapackIngestService {
}
}
public static List<StructureScopeResources> installedStructureScopeResources() throws IOException {
TRANSACTION_LOCK.lock();
try {
File root = IrisPlatforms.get().dataFolder("datapacks");
Manifest manifest = readManifest(root);
KList<File> datapackFolders = ServerConfigurator.getDatapacksFolder();
List<StructureScopeResources> resources = new ArrayList<>();
for (Entry entry : manifest.entries) {
boolean found = false;
for (File datapackFolder : datapackFolders) {
File installedDirectory = new File(datapackFolder, entry.id);
if (!Files.exists(installedDirectory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
continue;
}
resources.add(scanInstalledStructureScope(installedDirectory, entry));
found = true;
}
if (!found) {
throw new IOException("Missing installed Iris-managed datapack '" + entry.id + "'");
}
}
return List.copyOf(resources);
} finally {
TRANSACTION_LOCK.unlock();
}
}
static StructureScopeResources scanInstalledStructureScope(File directory, Entry entry) throws IOException {
Path path = directory.toPath();
if (Files.isSymbolicLink(path)
|| !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid installed Iris-managed datapack directory " + directory.getPath());
}
validatePackMetadata(directory);
rejectSymbolicLinks(directory);
Ownership ownership = readOwnership(directory);
if (!ownershipSourceMatches(ownership, entry)) {
throw new IOException("Installed datapack ownership mismatch at " + directory.getPath());
}
if (!Objects.equals(ownership.contentHash, directoryHash(directory))) {
throw new IOException("Installed Iris-managed datapack is modified or corrupt at "
+ directory.getPath());
}
PackResources resources = scanPackResources(directory);
return new StructureScopeResources(
entry.url,
resources.structureKeys(),
resources.structureSetKeys());
}
private static void ingestSingle(
VolmitSender sender,
String url,
@@ -2258,10 +2385,11 @@ public final class DatapackIngestService {
private static PackResources scanPackResources(File root) throws IOException {
TreeSet<String> structureKeys = new TreeSet<>();
TreeSet<String> structureSetKeys = new TreeSet<>();
TreeSet<String> templateKeys = new TreeSet<>();
Path dataRoot = new File(root, "data").toPath();
if (!Files.isDirectory(dataRoot, LinkOption.NOFOLLOW_LINKS)) {
return new PackResources(new ArrayList<>(), new ArrayList<>());
return new PackResources(new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
}
try (Stream<Path> paths = Files.walk(dataRoot)) {
for (Path path : paths.filter(file -> Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)).toList()) {
@@ -2273,11 +2401,16 @@ public final class DatapackIngestService {
String normalized = relative.subpath(1, relative.getNameCount()).toString().replace(File.separatorChar, '/');
addResourceKey(structureKeys, namespace, normalized, "worldgen/structure/", ".json");
addResourceKey(structureKeys, namespace, normalized, "worldgen/structures/", ".json");
addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_set/", ".json");
addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_sets/", ".json");
addResourceKey(templateKeys, namespace, normalized, "structure/", ".nbt");
addResourceKey(templateKeys, namespace, normalized, "structures/", ".nbt");
}
}
return new PackResources(new ArrayList<>(structureKeys), new ArrayList<>(templateKeys));
return new PackResources(
new ArrayList<>(structureKeys),
new ArrayList<>(structureSetKeys),
new ArrayList<>(templateKeys));
}
private static void addResourceKey(Set<String> keys, String namespace, String path, String prefix, String suffix) {
@@ -2362,13 +2495,13 @@ public final class DatapackIngestService {
Set<String> configured = configuredImports(data);
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url) && entry.importedBundles.containsKey(targetId)) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
cleanupTargets++;
}
}
if (!cleanupRemovedImports(data, targetId, configured, manifest.entries, manifestEntriesByUrl)) {
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url) && entry.importedBundles.containsKey(targetId)) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
failedUrls.add(entry.url);
}
}
@@ -2380,7 +2513,7 @@ public final class DatapackIngestService {
Set<String> pendingUrls = new HashSet<>();
for (String url : configured) {
Entry entry = entriesByUrl.get(url);
if (entry != null && !importRevision(entry).equals(entry.importedTargets.get(targetId))) {
if (entry != null && importPending(entry, targetId)) {
pendingUrls.add(url);
}
}
@@ -2411,20 +2544,13 @@ public final class DatapackIngestService {
}
attemptedPacks++;
try {
BulkStructureImporter.Report report = BulkStructureImporter.importDatapackStructures(
BulkStructureImporter.Report report = BulkStructureImporter.importManagedDatapackStructures(
data,
StructureImporter.Mode.OVERWRITE,
BukkitPlatform.console(),
structureKeys,
templateKeys
);
if (report.failed() > 0) {
IrisLogging.error("Datapack structure import for pack '%s' reported %d failure(s); the manifest remains pending for retry.",
data.getDataFolder().getPath(), report.failed());
failedUrls.addAll(pendingUrls);
reconcileFailedImportInventories(root, manifest, data, targetId, pendingUrls, entriesByUrl);
continue;
}
Set<String> successfulPendingUrls = new HashSet<>(pendingUrls);
Set<String> incompleteUrls = new HashSet<>();
for (String pendingUrl : pendingUrls) {
@@ -2434,13 +2560,25 @@ public final class DatapackIngestService {
successfulPendingUrls.remove(pendingUrl);
incompleteUrls.add(pendingUrl);
failedUrls.add(pendingUrl);
IrisLogging.error("Datapack structure import for '%s' did not prove every requested bundle in pack '%s'; the source remains pending for retry.",
pendingUrl, data.getDataFolder().getPath());
}
}
if (report.failed() > 0 && incompleteUrls.isEmpty()) {
successfulPendingUrls.clear();
incompleteUrls.addAll(pendingUrls);
failedUrls.addAll(pendingUrls);
}
if (!incompleteUrls.isEmpty()) {
reconcileFailedImportInventories(
boolean reconciled = reconcileFailedImportInventories(
root, manifest, data, targetId, incompleteUrls, entriesByUrl);
if (report.retryRequired() || !reconciled) {
IrisLogging.error("Datapack structure import for pack '%s' reported %d incomplete source(s) and remains pending because a retryable runtime failure occurred.",
data.getDataFolder().getPath(), incompleteUrls.size());
} else if (recordDeterministicImportAttempts(
root, manifest, targetId, incompleteUrls, entriesByUrl)) {
IrisLogging.warn("Datapack structure import for pack '"
+ data.getDataFolder().getPath() + "' left " + incompleteUrls.size()
+ " source(s) incomplete after deterministic validation failures. Iris will retain the partial editable imports without retrying until the datapack source, importer format, or target pack changes.");
}
}
Map<String, String> sharedBundles = desiredBundles(configured, entriesByUrl);
boolean packCompleted = false;
@@ -2457,7 +2595,7 @@ public final class DatapackIngestService {
continue;
}
entry.importedBundles.put(targetId, desired);
entry.importedTargets.put(targetId, importRevision(entry));
recordSuccessfulImport(entry, targetId);
completedUrls.add(pendingUrl);
packCompleted = true;
}
@@ -2492,8 +2630,29 @@ public final class DatapackIngestService {
+ " pack(s). Reference the imported keys from a 'structures' placement to position them manually.");
}
private static String importRevision(Entry entry) {
return safe(entry.versionId) + ":" + safe(entry.sha1);
static String importRevision(Entry entry) {
return importRevision(entry, STRUCTURE_IMPORT_FORMAT_REVISION);
}
static String importRevision(Entry entry, int importerFormatRevision) {
return "v" + importerFormatRevision + ":" + safe(entry.versionId) + ":" + safe(entry.sha1);
}
static boolean importPending(Entry entry, String targetId) {
String revision = importRevision(entry);
return !revision.equals(entry.importedTargets.get(targetId))
&& !revision.equals(entry.importAttempts.get(targetId));
}
static void recordDeterministicImportAttempt(Entry entry, String targetId) {
entry.importedTargets.remove(targetId);
entry.importAttempts.put(targetId, importRevision(entry));
entry.structuresImported = false;
}
static void recordSuccessfulImport(Entry entry, String targetId) {
entry.importedTargets.put(targetId, importRevision(entry));
entry.importAttempts.remove(targetId);
}
static void prepareImportRecoveryInventory(Entry entry, String targetId) {
@@ -2502,10 +2661,11 @@ public final class DatapackIngestService {
recovery.putAll(entry.importedBundles.getOrDefault(targetId, Map.of()));
entry.importedBundles.put(targetId, recovery);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
}
private static void reconcileFailedImportInventories(
private static boolean reconcileFailedImportInventories(
File root,
Manifest manifest,
IrisData data,
@@ -2513,11 +2673,13 @@ public final class DatapackIngestService {
Set<String> pendingUrls,
Map<String, Entry> entriesByUrl
) {
boolean reconciled = true;
for (String pendingUrl : pendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
try {
reconcileFailedImportInventory(data, entry, targetId);
} catch (IOException | RuntimeException e) {
reconciled = false;
IrisLogging.reportError("Could not reconcile partial editable structure imports for '"
+ pendingUrl + "' in pack '" + data.getDataFolder().getPath()
+ "'; the conservative recovery inventory remains pending.", e);
@@ -2526,9 +2688,33 @@ public final class DatapackIngestService {
try {
writeManifestChecked(root, manifest);
} catch (IOException e) {
reconciled = false;
IrisLogging.reportError("Could not persist reconciled partial editable structure imports for pack '"
+ data.getDataFolder().getPath() + "'; the earlier recovery inventory remains durable.", e);
}
return reconciled;
}
private static boolean recordDeterministicImportAttempts(
File root,
Manifest manifest,
String targetId,
Set<String> incompleteUrls,
Map<String, Entry> entriesByUrl
) {
for (String incompleteUrl : incompleteUrls) {
recordDeterministicImportAttempt(entriesByUrl.get(incompleteUrl), targetId);
}
try {
writeManifestChecked(root, manifest);
return true;
} catch (IOException e) {
for (String incompleteUrl : incompleteUrls) {
entriesByUrl.get(incompleteUrl).importAttempts.remove(targetId);
}
IrisLogging.reportError("Could not persist deterministic editable structure import attempts; the sources remain pending for retry.", e);
return false;
}
}
private static void reconcileFailedImportInventory(
@@ -2557,6 +2743,7 @@ public final class DatapackIngestService {
entry.importedBundles.put(targetId, reconciled);
}
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
}
@@ -2574,36 +2761,47 @@ public final class DatapackIngestService {
continue;
}
Map<String, String> inventory = entry.importedBundles.get(targetId);
if (inventory == null) {
if (inventory == null && !hasImportState(entry, targetId)) {
continue;
}
Map<String, String> resolvedInventory = inventory == null ? Map.of() : inventory;
for (Entry retainedEntry : entries) {
if (configured.contains(retainedEntry.url)) {
retainedEntry.importedTargets.remove(targetId);
retainedEntry.importAttempts.remove(targetId);
retainedEntry.structuresImported = false;
}
}
Map<String, String> removable = new TreeMap<>(inventory);
Map<String, String> removable = new TreeMap<>(resolvedInventory);
removable.keySet().removeAll(retainedBundles.keySet());
Map<String, String> remaining = cleanupImportedBundles(data, removable);
if (!remaining.isEmpty()) {
Map<String, String> retained = new TreeMap<>();
for (Map.Entry<String, String> bundle : inventory.entrySet()) {
for (Map.Entry<String, String> bundle : resolvedInventory.entrySet()) {
if (retainedBundles.containsKey(bundle.getKey()) || remaining.containsKey(bundle.getKey())) {
retained.put(bundle.getKey(), bundle.getValue());
}
}
entry.importedBundles.put(targetId, retained);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
successful = false;
continue;
}
entry.importedBundles.remove(targetId);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
}
return successful;
}
private static boolean hasImportState(Entry entry, String targetId) {
return entry.importedBundles.containsKey(targetId)
|| entry.importedTargets.containsKey(targetId)
|| entry.importAttempts.containsKey(targetId);
}
private static Map<String, String> cleanupImportedBundles(IrisData data, Map<String, String> inventory) {
Map<String, String> remaining = new TreeMap<>();
StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath());
@@ -2614,7 +2812,11 @@ public final class DatapackIngestService {
sourceKey = StructureKey.parse(bundle.getValue());
StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft")
? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK;
removed |= writer.removeOwned(StructureKey.parse(bundle.getKey()), sourceKind, sourceKey);
removed |= writer.removeManagedDatapackOwned(
StructureKey.parse(bundle.getKey()),
sourceKind,
sourceKey
);
} catch (IOException | RuntimeException e) {
remaining.put(bundle.getKey(), bundle.getValue());
IrisLogging.reportError("Preserving imported structure bundle '" + bundle.getKey()
@@ -2940,6 +3142,8 @@ public final class DatapackIngestService {
resolved.installMetadata, Map::of));
copy.importedTargets = new HashMap<>(Objects.requireNonNullElseGet(
resolved.importedTargets, Map::of));
copy.importAttempts = new HashMap<>(Objects.requireNonNullElseGet(
resolved.importAttempts, Map::of));
copy.importedBundles = new HashMap<>();
if (resolved.importedBundles != null) {
for (Map.Entry<String, Map<String, String>> bundle : resolved.importedBundles.entrySet()) {
@@ -3049,6 +3253,7 @@ public final class DatapackIngestService {
entry.stagingMetadata = entry.stagingMetadata == null ? "" : entry.stagingMetadata.trim();
entry.installMetadata = normalizeImportedTargets(entry.installMetadata);
entry.importedTargets = normalizeImportedTargets(entry.importedTargets);
entry.importAttempts = normalizeImportedTargets(entry.importAttempts);
entry.importedBundles = normalizeImportedBundles(entry.importedBundles);
if (!urls.add(entry.url) || !ids.add(entry.id)) {
IrisLogging.warn("Ignoring duplicate datapack manifest entry for id '" + entry.id + "' and url " + entry.url);
@@ -3361,13 +3566,12 @@ public final class DatapackIngestService {
return path.toRealPath().toString();
}
static void recoverTransactions(File root, List<File> worldFolders) throws IOException {
recoverStagingScratch(new File(root, "staging"));
static boolean recoverTransactions(File root, List<File> worldFolders) throws IOException {
boolean changed = recoverStagingScratch(new File(root, "staging"));
File transactionDirectory = new File(root, TRANSACTION_DIRECTORY);
Path transactionPath = transactionDirectory.toPath();
if (!Files.exists(transactionPath, LinkOption.NOFOLLOW_LINKS)) {
recoverInstallScratch(root, worldFolders);
return;
return recoverInstallScratch(root, worldFolders) | changed;
}
verifyDirectoryContainerIfPresent(transactionDirectory, "datapack transaction");
Manifest committedManifest = readCommittedManifest(root);
@@ -3390,29 +3594,32 @@ public final class DatapackIngestService {
}
for (Path transactionRoot : transactionRoots) {
if (isHarmlessRecoveryArtifact(transactionRoot)) {
Files.deleteIfExists(transactionRoot);
changed |= Files.deleteIfExists(transactionRoot);
continue;
}
recoverTransaction(root, worldFolders, committedManifest, transactionPath, transactionRoot);
changed = true;
}
transactionDirectory.delete();
recoverInstallScratch(root, worldFolders);
changed |= transactionDirectory.delete();
return recoverInstallScratch(root, worldFolders) | changed;
}
private static void recoverInstallScratch(File root, List<File> worldFolders) throws IOException {
private static boolean recoverInstallScratch(File root, List<File> worldFolders) throws IOException {
Set<Path> scratchRoots = new TreeSet<>();
scratchRoots.add(installScratchRoot(new File(root, "staging")).toPath().toAbsolutePath().normalize());
for (File worldFolder : worldFolders) {
scratchRoots.add(installScratchRoot(worldFolder).toPath().toAbsolutePath().normalize());
}
boolean changed = false;
for (Path scratchRoot : scratchRoots) {
recoverInstallScratchRoot(scratchRoot);
changed |= recoverInstallScratchRoot(scratchRoot);
}
return changed;
}
private static void recoverInstallScratchRoot(Path scratchRoot) throws IOException {
private static boolean recoverInstallScratchRoot(Path scratchRoot) throws IOException {
if (!Files.exists(scratchRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
return false;
}
verifyDirectoryContainerIfPresent(scratchRoot.toFile(), "datapack install scratch");
List<Path> children;
@@ -3431,9 +3638,10 @@ public final class DatapackIngestService {
List<StagingScratch> pending = new ArrayList<>();
List<StagingScratch> backups = new ArrayList<>();
boolean changed = false;
for (Path child : children) {
if (isHarmlessRecoveryArtifact(child)) {
Files.deleteIfExists(child);
changed |= Files.deleteIfExists(child);
continue;
}
StagingScratch scratch = parseInstallScratch(scratchRoot, child);
@@ -3456,8 +3664,10 @@ public final class DatapackIngestService {
}
for (StagingScratch scratch : pending) {
deleteInstallScratch(scratch.path().toFile(), "orphan datapack install pending directory");
changed = true;
}
scratchRoot.toFile().delete();
changed |= scratchRoot.toFile().delete();
return changed;
}
private static StagingScratch parseInstallScratch(Path scratchRoot, Path child) throws IOException {
@@ -3486,10 +3696,10 @@ public final class DatapackIngestService {
return new StagingScratch(kind, id, normalized);
}
private static void recoverStagingScratch(File stagingDirectory) throws IOException {
private static boolean recoverStagingScratch(File stagingDirectory) throws IOException {
Path stagingRoot = stagingDirectory.toPath().toAbsolutePath().normalize();
if (!Files.exists(stagingRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
return false;
}
verifyDirectoryContainerIfPresent(stagingDirectory, "datapack staging");
List<Path> children;
@@ -3536,6 +3746,7 @@ public final class DatapackIngestService {
}
}
boolean changed = false;
for (List<StagingScratch> matches : backups.values()) {
StagingScratch backup = matches.getFirst();
Path target = stagingRoot.resolve(backup.id()).normalize();
@@ -3544,11 +3755,14 @@ public final class DatapackIngestService {
} else {
moveNew(backup.path(), target);
}
changed = true;
}
for (StagingScratch scratch : pending) {
deleteVerifiedDirectory(scratch.path().toFile());
changed = true;
}
forceDirectoryIfSupported(stagingRoot);
return changed;
}
private static StagingScratch parseStagingScratch(Path stagingRoot, Path child) throws IOException {
@@ -3825,6 +4039,7 @@ public final class DatapackIngestService {
throw new IOException("Datapack transaction conflicts with the committed editable pack owner");
}
addExistingPackRoots(roots, committed.importedTargets.keySet());
addExistingPackRoots(roots, committed.importAttempts.keySet());
addExistingPackRoots(roots, committed.importedBundles.keySet());
return roots;
}
@@ -4125,6 +4340,7 @@ public final class DatapackIngestService {
),
commit
);
IrisData.invalidateLoadedStructureResources(packRoot.toFile());
} catch (IOException | RuntimeException e) {
IOException participantFailure = e instanceof IOException ioFailure
? ioFailure : new IOException("Failed resolving editable structure participant", e);
@@ -4524,6 +4740,7 @@ public final class DatapackIngestService {
&& copyList(current.structureKeys).equals(copyList(expected.structureKeys))
&& copyList(current.templateKeys).equals(copyList(expected.templateKeys))
&& Objects.equals(current.importedTargets, expected.importedTargets)
&& Objects.equals(current.importAttempts, expected.importAttempts)
&& Objects.equals(current.importedBundles, expected.importedBundles);
}
@@ -4610,6 +4827,7 @@ public final class DatapackIngestService {
public List<String> templateKeys = new ArrayList<>();
public Map<String, String> installMetadata = new HashMap<>();
public Map<String, String> importedTargets = new HashMap<>();
public Map<String, String> importAttempts = new HashMap<>();
public Map<String, Map<String, String>> importedBundles = new HashMap<>();
}
@@ -4656,10 +4874,83 @@ public final class DatapackIngestService {
static record InstallResult(boolean changed) {
}
public record ReapplyOutcome(
ReapplyStatus status,
Optional<Throwable> failure
) {
public ReapplyOutcome {
status = Objects.requireNonNull(status, "External datapack reapply status");
failure = Objects.requireNonNull(failure, "External datapack reapply failure");
if (status == ReapplyStatus.FAILED && failure.isEmpty()) {
throw new IllegalArgumentException("Failed external datapack reapply requires a cause");
}
if (status != ReapplyStatus.FAILED && failure.isPresent()) {
throw new IllegalArgumentException("Successful external datapack reapply cannot carry a cause");
}
}
public static ReapplyOutcome success(boolean recovered, boolean repaired) {
ReapplyStatus status;
if (recovered && repaired) {
status = ReapplyStatus.RECOVERED_AND_REPAIRED;
} else if (recovered) {
status = ReapplyStatus.RECOVERED;
} else if (repaired) {
status = ReapplyStatus.REPAIRED;
} else {
status = ReapplyStatus.UNCHANGED;
}
return new ReapplyOutcome(status, Optional.empty());
}
public static ReapplyOutcome failed(Throwable failure) {
return new ReapplyOutcome(
ReapplyStatus.FAILED,
Optional.of(Objects.requireNonNull(failure, "External datapack reapply failure cause")));
}
public boolean succeeded() {
return status != ReapplyStatus.FAILED;
}
public boolean changed() {
return recovered() || repaired();
}
public boolean recovered() {
return status == ReapplyStatus.RECOVERED
|| status == ReapplyStatus.RECOVERED_AND_REPAIRED;
}
public boolean repaired() {
return status == ReapplyStatus.REPAIRED
|| status == ReapplyStatus.RECOVERED_AND_REPAIRED;
}
}
public enum ReapplyStatus {
UNCHANGED,
RECOVERED,
REPAIRED,
RECOVERED_AND_REPAIRED,
FAILED
}
record InstallExecution(InstallResult result, DatapackCoordinator coordinator) {
}
private record PackResources(List<String> structureKeys, List<String> templateKeys) {
private record PackResources(
List<String> structureKeys,
List<String> structureSetKeys,
List<String> templateKeys
) {
}
public record StructureScopeResources(
String source,
List<String> structureKeys,
List<String> structureSetKeys
) {
}
private static final class EditableImportRemoval {
@@ -0,0 +1,131 @@
package art.arcane.iris.core.datapack;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
public final class DatapackStructureScopeIndex {
private final Map<String, Set<String>> sourcesByStructure;
private final Map<String, Set<String>> sourcesByStructureSet;
private DatapackStructureScopeIndex(
Map<String, Set<String>> sourcesByStructure,
Map<String, Set<String>> sourcesByStructureSet
) {
this.sourcesByStructure = sourcesByStructure;
this.sourcesByStructureSet = sourcesByStructureSet;
}
public static DatapackStructureScopeIndex create(
List<DatapackIngestService.StructureScopeResources> resources
) {
Map<String, Set<String>> mutableSourcesByStructure = new HashMap<>();
Map<String, Set<String>> mutableSourcesBySet = new HashMap<>();
if (resources != null) {
for (DatapackIngestService.StructureScopeResources resource : resources) {
if (resource == null) {
continue;
}
String source = normalizeSource(resource.source());
if (source.isEmpty()) {
continue;
}
addOwnership(mutableSourcesByStructure, resource.structureKeys(), source);
addOwnership(mutableSourcesBySet, resource.structureSetKeys(), source);
}
}
return new DatapackStructureScopeIndex(
immutableOwnership(mutableSourcesByStructure),
immutableOwnership(mutableSourcesBySet));
}
public Set<String> declaredSources(Iterable<String> datapackImports) {
if (datapackImports == null) {
return Set.of();
}
Set<String> declared = new HashSet<>();
for (String source : datapackImports) {
String normalized = normalizeSource(source);
if (!normalized.isEmpty()) {
declared.add(normalized);
}
}
return declared.isEmpty() ? Set.of() : Set.copyOf(declared);
}
public boolean allowsStructureSet(String structureSetKey, Set<String> declaredSources) {
return allows(sourcesByStructureSet, structureSetKey, declaredSources);
}
public boolean allowsStructure(String structureKey, Set<String> declaredSources) {
return allows(sourcesByStructure, structureKey, declaredSources);
}
public boolean isManagedStructureSet(String structureSetKey) {
return sourcesByStructureSet.containsKey(normalizeKey(structureSetKey));
}
public boolean isManagedStructure(String structureKey) {
return sourcesByStructure.containsKey(normalizeKey(structureKey));
}
public int managedStructureCount() {
return sourcesByStructure.size();
}
public int managedStructureSetCount() {
return sourcesByStructureSet.size();
}
public boolean isEmpty() {
return sourcesByStructure.isEmpty() && sourcesByStructureSet.isEmpty();
}
private static void addOwnership(
Map<String, Set<String>> ownership,
List<String> keys,
String source
) {
if (keys == null) {
return;
}
for (String key : keys) {
String normalizedKey = normalizeKey(key);
if (!normalizedKey.isEmpty()) {
ownership.computeIfAbsent(normalizedKey, ignored -> new HashSet<>()).add(source);
}
}
}
private static Map<String, Set<String>> immutableOwnership(Map<String, Set<String>> ownership) {
Map<String, Set<String>> immutable = new HashMap<>(ownership.size());
for (Map.Entry<String, Set<String>> entry : ownership.entrySet()) {
immutable.put(entry.getKey(), Set.copyOf(entry.getValue()));
}
return Map.copyOf(immutable);
}
private static boolean allows(
Map<String, Set<String>> ownership,
String key,
Set<String> declaredSources
) {
Set<String> owners = ownership.get(normalizeKey(key));
if (owners == null) {
return true;
}
return declaredSources != null && declaredSources.containsAll(owners);
}
private static String normalizeSource(String source) {
return source == null ? "" : source.trim();
}
private static String normalizeKey(String key) {
return key == null ? "" : key.trim().toLowerCase(Locale.ROOT);
}
}
@@ -3,6 +3,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.IrisWorlds;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import art.arcane.iris.core.WorldRemovalPathPolicy;
import art.arcane.iris.core.link.MultiverseCoreLink;
import art.arcane.iris.core.runtime.WorldDeletionQueue;
@@ -22,13 +23,10 @@ import org.bukkit.entity.Player;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -1062,7 +1060,7 @@ public final class IrisWorldRemovalService {
private DeleteDisposition deleteQuarantine(Path quarantine) {
try {
deleteTree(quarantine);
SnapshotDirectoryTreeDeleter.delete(quarantine);
return new DeleteDisposition(false, quarantine);
} catch (Throwable deletionFailure) {
IrisLogging.reportError(
@@ -1073,25 +1071,6 @@ public final class IrisWorldRemovalService {
}
}
private static void deleteTree(Path target) throws IOException {
Files.walkFileTree(target, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException {
if (failure != null) {
throw failure;
}
Files.delete(directory);
return FileVisitResult.CONTINUE;
}
});
}
private static <T> CompletableFuture<T> onGlobal(Supplier<T> supplier) {
CompletableFuture<T> result = new CompletableFuture<>();
boolean scheduled = J.runGlobal(() -> {
@@ -118,6 +118,13 @@ public final class WorldLifecycleService {
worldName,
backend.backendName());
WorldUnloadBoundaryRegistry.Boundary rawBoundary;
try {
rawBoundary = WorldUnloadBoundaryRegistry.begin(worldIdentity);
} catch (Throwable e) {
return CompletableFuture.failedFuture(e);
}
CompletableFuture<Boolean> unloadFuture;
try {
unloadFuture = backend.unloadAsync(requiredWorld, save);
@@ -127,6 +134,8 @@ public final class WorldLifecycleService {
} catch (Throwable e) {
unloadFuture = CompletableFuture.failedFuture(e);
}
unloadFuture.whenComplete((unloaded, throwable) ->
WorldUnloadBoundaryRegistry.complete(rawBoundary, unloaded, throwable));
CompletableFuture<Boolean> guardedFuture = guardUnloadCompletion(worldName, unloadFuture);
return guardedFuture.whenComplete((unloaded, throwable) -> {
@@ -0,0 +1,41 @@
package art.arcane.iris.core.lifecycle;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
public final class WorldUnloadBoundaryRegistry {
private static final ConcurrentHashMap<String, Boundary> ACTIVE = new ConcurrentHashMap<>();
private WorldUnloadBoundaryRegistry() {
}
static Boundary begin(String worldIdentity) {
String requiredIdentity = Objects.requireNonNull(worldIdentity, "world identity");
Boundary boundary = new Boundary(requiredIdentity, new CompletableFuture<>());
Boundary existing = ACTIVE.putIfAbsent(requiredIdentity, boundary);
if (existing != null) {
throw new IllegalStateException("World unload is already active for " + requiredIdentity + ".");
}
return boundary;
}
public static CompletionStage<Boolean> claim(String worldIdentity) {
Boundary boundary = ACTIVE.remove(Objects.requireNonNull(worldIdentity, "world identity"));
return boundary == null ? null : boundary.completion();
}
static void complete(Boundary boundary, Boolean unloaded, Throwable failure) {
Objects.requireNonNull(boundary, "world unload boundary");
ACTIVE.remove(boundary.worldIdentity(), boundary);
if (failure == null) {
boundary.completion().complete(Boolean.TRUE.equals(unloaded));
return;
}
boundary.completion().completeExceptionally(WorldLifecycleSupport.unwrap(failure));
}
record Boundary(String worldIdentity, CompletableFuture<Boolean> completion) {
}
}
@@ -153,6 +153,22 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
return Optional.ofNullable(dataLoaders.get(dataFolder));
}
public static boolean invalidateLoadedStructureResources(File dataFolder) {
Path requested = dataFolderIdentity(Objects.requireNonNull(
dataFolder,
"Iris data folder to invalidate"));
boolean invalidated = false;
for (Map.Entry<File, IrisData> entry : dataLoaders.entrySet()) {
Path loaded = dataFolderIdentity(entry.getKey());
if (!loaded.equals(requested)) {
continue;
}
entry.getValue().invalidateStructureResources();
invalidated = true;
}
return invalidated;
}
public static void dereference() {
dataLoaders.values().forEach(IrisData::cleanupEngine);
}
@@ -528,6 +544,17 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
loader.clearList();
}
private static Path dataFolderIdentity(File dataFolder) {
Path normalized = dataFolder.toPath().toAbsolutePath().normalize();
try {
return Files.exists(normalized) ? normalized.toRealPath() : normalized;
} catch (IOException exception) {
IrisLogging.debug("Unable to resolve Iris data folder identity for "
+ normalized + "; using its normalized path: " + exception.getMessage());
return normalized;
}
}
public Set<Class<?>> resolveSnippets() {
var result = new HashSet<Class<?>>();
var processed = new HashSet<Class<?>>();
@@ -0,0 +1,7 @@
package art.arcane.iris.core.nms;
public record DatapackStructureScopeResult(
int retainedManagedSets,
int excludedManagedSets
) {
}
@@ -18,6 +18,7 @@
package art.arcane.iris.core.nms;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import art.arcane.iris.core.lifecycle.WorldLifecycleCaller;
import art.arcane.iris.core.lifecycle.WorldLifecycleRequest;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
@@ -53,6 +54,7 @@ import org.bukkit.inventory.ItemStack;
import java.awt.Color;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
public interface INMSBinding {
@@ -226,6 +228,16 @@ public interface INMSBinding {
void inject(long seed, Engine engine, World world) throws NoSuchFieldException, IllegalAccessException;
DatapackStructureScopeResult scopeDatapackStructures(
World world,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources
) throws NoSuchFieldException, IllegalAccessException;
void completeStudioStructureBootstrap(World world) throws NoSuchFieldException, IllegalAccessException;
void abandonStudioStructureBootstrap(World world);
Vector3d getBoundingbox(org.bukkit.entity.EntityType entity);
String getEntitySpawnCategory(String key);
@@ -18,6 +18,8 @@
package art.arcane.iris.core.nms.v1X;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import art.arcane.iris.core.nms.DatapackStructureScopeResult;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.nms.INMSBinding;
import art.arcane.iris.core.nms.container.BiomeColor;
@@ -45,6 +47,7 @@ import org.bukkit.inventory.ItemStack;
import java.awt.Color;
import java.util.List;
import java.util.Set;
import java.util.stream.StreamSupport;
public class NMSBinding1X implements INMSBinding {
@@ -102,6 +105,23 @@ public class NMSBinding1X implements INMSBinding {
+ "general.disableNMS=true cannot create or initialize an Iris world");
}
@Override
public DatapackStructureScopeResult scopeDatapackStructures(
World world,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources
) {
throw new IllegalStateException("Iris-managed datapack structure isolation requires the supported NMS binding");
}
@Override
public void completeStudioStructureBootstrap(World world) {
}
@Override
public void abandonStudioStructureBootstrap(World world) {
}
public Vector3d getBoundingbox() {
return null;
}
@@ -33,6 +33,7 @@ import java.util.Properties;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
@@ -41,11 +42,13 @@ import java.util.zip.ZipInputStream;
public final class DefaultPackBootstrapProvisioner {
private static final URI DEFAULT_SOURCE = URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip");
private static final String WORLD_DATAPACK_DIRECTORY = "iris";
private static final int MARKER_SCHEMA = 2;
private static final int MARKER_SCHEMA = 3;
private static final int MAX_ARCHIVE_ENTRIES = 100_000;
private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L;
private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L;
private static final AtomicBoolean PROVISIONED_THIS_STARTUP = new AtomicBoolean(false);
private static final AtomicReference<String> PROVISIONED_COMPILER_INPUT_FINGERPRINT =
new AtomicReference<>("");
private DefaultPackBootstrapProvisioner() {
}
@@ -102,6 +105,12 @@ public final class DefaultPackBootstrapProvisioner {
if (!Integer.toString(MARKER_SCHEMA).equals(marker.getProperty("schema"))) {
return false;
}
IDataFixer fixer = DataVersion.getLatest().get();
if (fixer == null
|| !IrisDatapackCompiler.compilerIdentity(fixer)
.equals(marker.getProperty("compilerIdentity"))) {
return false;
}
return directoryFingerprint(packRoot).equals(marker.getProperty("defaultPackFingerprint"))
&& directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
&& datapackRoot.toString().equals(marker.getProperty("datapackPath"))
@@ -118,7 +127,13 @@ public final class DefaultPackBootstrapProvisioner {
return PROVISIONED_THIS_STARTUP.get();
}
public static String compilerInputFingerprintThisStartup() {
return PROVISIONED_COMPILER_INPUT_FINGERPRINT.get();
}
static ProvisionResult provision(Path dataDirectory, Consumer<String> feedback, ProvisionOptions options) throws IOException {
PROVISIONED_THIS_STARTUP.set(false);
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set("");
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
Path packsRoot = normalizedData.resolve("packs");
Path packRoot = packsRoot.resolve("overworld");
@@ -174,20 +189,22 @@ public final class DefaultPackBootstrapProvisioner {
if (packRoots.isEmpty()) {
throw new IOException("No Iris pack roots were available for bootstrap datapack compilation");
}
IDataFixer fixer = DataVersion.getLatest().get();
if (fixer == null) {
throw new IOException("Latest Iris datapack fixer is unavailable during bootstrap");
}
String compilerIdentity = IrisDatapackCompiler.compilerIdentity(fixer);
String aggregateFingerprint = packRootsFingerprint(packRoots);
boolean rebuildDatapack = replacePack
|| !existingDatapack
|| !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint"))
|| !compilerIdentity.equals(previousMarker.getProperty("compilerIdentity"))
|| !datapackRoot.toString().equals(previousMarker.getProperty("datapackPath"))
|| !directoryFingerprint(datapackRoot).equals(previousMarker.getProperty("datapackFingerprint"));
if (rebuildDatapack) {
compileContainer = datapacksRoot.resolve("." + WORLD_DATAPACK_DIRECTORY + "-stage-" + UUID.randomUUID());
Files.createDirectories(compileContainer);
KList<File> outputFolders = new KList<File>().qadd(compileContainer.toFile());
IDataFixer fixer = DataVersion.getLatest().get();
if (fixer == null) {
throw new IOException("Latest Iris datapack fixer is unavailable during bootstrap");
}
IrisDatapackCompiler.compile(packRoots, outputFolders, fixer, false);
if (!isDatapackRoot(compileContainer)) {
throw new IOException("Canonical Iris datapack compiler produced incomplete output at " + compileContainer);
@@ -202,9 +219,14 @@ public final class DefaultPackBootstrapProvisioner {
throw new IOException("Bootstrap datapack output is incomplete at " + datapackRoot);
}
String finalPackFingerprint = directoryFingerprint(packRoot);
String finalAggregateFingerprint = packRootsFingerprint(
IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot())
);
List<File> finalPackRoots = IrisDatapackCompiler.collectPackRoots(
normalizedData,
options.levelRoot());
String finalAggregateFingerprint = packRootsFingerprint(finalPackRoots);
String finalCompilerInputFingerprint = IrisDatapackCompiler.computeInputFingerprint(
finalPackRoots,
fixer,
false);
String finalDatapackFingerprint = directoryFingerprint(datapackRoot);
Properties marker = new Properties();
marker.setProperty("schema", Integer.toString(MARKER_SCHEMA));
@@ -213,10 +235,12 @@ public final class DefaultPackBootstrapProvisioner {
marker.setProperty("managedDefault", Boolean.toString(managedDefault));
marker.setProperty("defaultPackFingerprint", finalPackFingerprint);
marker.setProperty("aggregateFingerprint", finalAggregateFingerprint);
marker.setProperty("compilerIdentity", compilerIdentity);
marker.setProperty("datapackFingerprint", finalDatapackFingerprint);
marker.setProperty("datapackPath", datapackRoot.toString());
marker.setProperty("completedAt", Long.toString(options.clock().millis()));
storePropertiesAtomic(markerFile, marker);
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(finalCompilerInputFingerprint);
PROVISIONED_THIS_STARTUP.set(true);
deleteQuietly(packBackup, feedback);
deleteQuietly(datapackBackup, feedback);
@@ -21,6 +21,7 @@ package art.arcane.iris.core.pack;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.framework.StructureAssembler;
import art.arcane.iris.engine.framework.structure.IrisObjectFrameReader;
import art.arcane.iris.engine.framework.structure.StructureAssemblyResult;
import art.arcane.iris.engine.framework.structure.StructureGraphCompilation;
import art.arcane.iris.engine.framework.structure.StructureGraphCompiler;
import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic;
@@ -31,7 +32,6 @@ import art.arcane.iris.engine.object.IrisJigsawPool;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
@@ -131,13 +131,13 @@ final class StructureGraphPackValidator {
try {
StructureAssembler assembler = StructureAssembler.forCompilation(
compilation, new IrisPosition(0, GEOMETRY_SAMPLE_ORIGIN_Y, 0));
KList<PlacedStructurePiece> pieces = assembler.assemble(new RNG(seed));
if (pieces == null) {
failures.add("seed " + seed + " returned no complete assembly");
} else if (pieces.isEmpty()) {
StructureAssemblyResult result = assembler.assemble(new RNG(seed));
if (result.status().isFailure()) {
failures.add("seed " + seed + " returned " + result.status() + ": " + result.detail());
} else if (!result.hasOutput()) {
outputForEverySample = false;
} else {
sampledVerticalEnvelopes.add(sampleVerticalEnvelope(seed, pieces));
sampledVerticalEnvelopes.add(sampleVerticalEnvelope(seed, result.pieces()));
}
} catch (RuntimeException e) {
failures.add("seed " + seed + " threw " + e.getClass().getSimpleName()
@@ -150,7 +150,7 @@ final class StructureGraphPackValidator {
private static SampledVerticalEnvelope sampleVerticalEnvelope(
long seed,
KList<PlacedStructurePiece> pieces
List<PlacedStructurePiece> pieces
) {
int minimumY = Integer.MAX_VALUE;
int maximumY = Integer.MIN_VALUE;
@@ -187,8 +187,8 @@ final class StructureGraphPackValidator {
continue;
}
failures.add("seed " + sample.seed() + " placed " + sample.outcome().pieceKeys().size()
+ " piece(s), left " + sample.outcome().unresolvedConnectorCount()
+ " unresolved, cap=" + sample.outcome().pieceCapReached());
+ " piece(s), status=" + sample.outcome().status()
+ ": " + sample.outcome().detail());
if (failures.size() == 3) {
break;
}
@@ -7,6 +7,7 @@ import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
@@ -51,8 +52,16 @@ public final class StructurePackageClosure {
}
public static StructurePackageClosure collect(File sourceRoot, Collection<String> rootStructures) {
return collect(sourceRoot, rootStructures, null);
}
public static StructurePackageClosure collect(
File sourceRoot,
Collection<String> rootStructures,
Limits limits
) {
Path normalizedRoot = sourceRoot.toPath().toAbsolutePath().normalize();
MutableClosure closure = new MutableClosure();
MutableClosure closure = new MutableClosure(limits);
if (!Files.isDirectory(normalizedRoot)) {
closure.errors.add("Structure package source is not a directory: " + normalizedRoot);
return new StructurePackageClosure(normalizedRoot, closure);
@@ -127,7 +136,7 @@ public final class StructurePackageClosure {
continue;
}
JSONObject structure = readJson(sourceRoot, STRUCTURES, structureKey, closure.errors);
JSONObject structure = readJson(sourceRoot, STRUCTURES, structureKey, closure.errors, closure.limits);
if (structure == null) {
continue;
}
@@ -148,7 +157,7 @@ public final class StructurePackageClosure {
continue;
}
JSONObject pool = readJson(sourceRoot, POOLS, poolKey, closure.errors);
JSONObject pool = readJson(sourceRoot, POOLS, poolKey, closure.errors, closure.limits);
if (pool == null) {
continue;
}
@@ -184,7 +193,7 @@ public final class StructurePackageClosure {
continue;
}
JSONObject piece = readJson(sourceRoot, PIECES, pieceKey, closure.errors);
JSONObject piece = readJson(sourceRoot, PIECES, pieceKey, closure.errors, closure.limits);
if (piece == null) {
continue;
}
@@ -223,19 +232,38 @@ public final class StructurePackageClosure {
}
}
private static JSONObject readJson(Path sourceRoot, String folder, String key, List<String> errors) {
private static JSONObject readJson(
Path sourceRoot,
String folder,
String key,
List<String> errors,
Limits limits
) {
Path file = resolveExisting(sourceRoot, folder, key, ".json", errors);
if (file == null) {
return null;
}
try {
return new JSONObject(Files.readString(file, StandardCharsets.UTF_8));
String content = limits == null
? Files.readString(file, StandardCharsets.UTF_8)
: new String(readBounded(file, limits.maxJsonBytes()), StandardCharsets.UTF_8);
return new JSONObject(content);
} catch (IOException | RuntimeException e) {
errors.add("Invalid " + folder + " resource '" + key + "': " + describe(e));
return null;
}
}
private static byte[] readBounded(Path file, int maximumBytes) throws IOException {
try (InputStream input = Files.newInputStream(file)) {
byte[] content = input.readNBytes(maximumBytes + 1);
if (content.length > maximumBytes) {
throw new IOException("JSON resource exceeds " + maximumBytes + " bytes");
}
return content;
}
}
private static Path resolveExisting(Path sourceRoot, String folder, String key, String extension,
List<String> errors) {
Path file = resolveResource(sourceRoot, folder, key, extension, errors);
@@ -481,8 +509,16 @@ public final class StructurePackageClosure {
if (!isEmpty) {
return false;
}
if (entry.has("piece")) {
errors.add("Empty piece entry " + index + " in jigsaw pool '" + poolKey + "' cannot define field 'piece'.");
if (!entry.has("piece")) {
return true;
}
Object pieceValue = entry.opt("piece");
if (!(pieceValue instanceof String piece)) {
errors.add("Empty piece entry " + index + " in jigsaw pool '" + poolKey
+ "' requires string field 'piece' when defined.");
} else if (!piece.isBlank()) {
errors.add("Empty piece entry " + index + " in jigsaw pool '" + poolKey
+ "' cannot define non-empty field 'piece'.");
}
return true;
}
@@ -504,15 +540,66 @@ public final class StructurePackageClosure {
return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message;
}
public record Limits(int maxResources, int maxJsonBytes) {
public Limits {
if (maxResources < 1 || maxResources > 100_000) {
throw new IllegalArgumentException("Structure closure resource limit must be between 1 and 100000");
}
if (maxJsonBytes < 1 || maxJsonBytes > 64 * 1024 * 1024) {
throw new IllegalArgumentException("Structure closure JSON limit must be between 1 and 67108864 bytes");
}
}
}
private static final class MutableClosure {
private final Set<String> structures = new LinkedHashSet<>();
private final Set<String> pools = new LinkedHashSet<>();
private final Set<String> pieces = new LinkedHashSet<>();
private final Set<String> objects = new LinkedHashSet<>();
private final Set<String> loot = new LinkedHashSet<>();
private final Limits limits;
private final Set<String> structures;
private final Set<String> pools;
private final Set<String> pieces;
private final Set<String> objects;
private final Set<String> loot;
private final List<String> errors = new ArrayList<>();
private final Deque<String> structureQueue = new ArrayDeque<>();
private final Deque<String> poolQueue = new ArrayDeque<>();
private final Deque<String> pieceQueue = new ArrayDeque<>();
private int resources;
private boolean resourceLimitReported;
private MutableClosure(Limits limits) {
this.limits = limits;
structures = new BudgetedSet(this);
pools = new BudgetedSet(this);
pieces = new BudgetedSet(this);
objects = new BudgetedSet(this);
loot = new BudgetedSet(this);
}
private boolean reserveResource() {
if (limits == null || resources < limits.maxResources()) {
resources++;
return true;
}
if (!resourceLimitReported) {
errors.add("Structure closure exceeds " + limits.maxResources() + " resources.");
resourceLimitReported = true;
}
return false;
}
}
private static final class BudgetedSet extends LinkedHashSet<String> {
private final MutableClosure closure;
private BudgetedSet(MutableClosure closure) {
this.closure = closure;
}
@Override
public boolean add(String value) {
if (contains(value) || !closure.reserveResource()) {
return false;
}
return super.add(value);
}
}
}
@@ -74,20 +74,30 @@ public class IrisProject {
}
public void open(VolmitSender sender) throws IrisException {
open(sender, 1337, (w) ->
open(sender, 1337, StudioOpenCoordinator.StudioOpenKind.STANDARD, (w) ->
{
});
}
public CompletableFuture<StudioOpenCoordinator.StudioOpenResult> open(VolmitSender sender, long seed, Consumer<World> onDone) throws IrisException {
public CompletableFuture<StudioOpenCoordinator.StudioOpenResult> open(
VolmitSender sender,
long seed,
StudioOpenCoordinator.StudioOpenKind openKind,
Consumer<World> onDone
) throws IrisException {
if (isOpen()) {
return close().thenCompose(ignored -> openInternal(sender, seed, onDone));
return close().thenCompose(ignored -> openInternal(sender, seed, openKind, onDone));
}
return openInternal(sender, seed, onDone);
return openInternal(sender, seed, openKind, onDone);
}
private CompletableFuture<StudioOpenCoordinator.StudioOpenResult> openInternal(VolmitSender sender, long seed, Consumer<World> onDone) {
private CompletableFuture<StudioOpenCoordinator.StudioOpenResult> openInternal(
VolmitSender sender,
long seed,
StudioOpenCoordinator.StudioOpenKind openKind,
Consumer<World> onDone
) {
AtomicReference<String> stage = new AtomicReference<>("Queued");
AtomicReference<Double> progress = new AtomicReference<>(0.01D);
AtomicBoolean complete = new AtomicBoolean(false);
@@ -97,6 +107,7 @@ public class IrisProject {
this,
sender,
seed,
openKind,
update -> {
if (update.stage() != null && !update.stage().isBlank()) {
stage.set(update.stage());
@@ -5,6 +5,7 @@ import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.link.MultiverseCoreLink;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
@@ -13,12 +14,15 @@ import art.arcane.iris.core.project.IrisProject;
import art.arcane.iris.core.project.IrisCodeWorkspace;
import art.arcane.iris.core.tools.IrisCreator;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.exceptions.IrisException;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
@@ -33,9 +37,13 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -46,9 +54,14 @@ import java.util.function.Supplier;
public final class StudioOpenCoordinator {
private static final long STUDIO_CLOSE_TIMEOUT_SECONDS = 120L;
private static final long STUDIO_ENTRY_LOAD_TIMEOUT_SECONDS = 30L;
private static final long STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS = 30L;
private static final long STUDIO_ENTRY_CLEANUP_BOUNDARY_SECONDS = 120L;
private static volatile StudioOpenCoordinator instance;
private final EntryLoadRegistry entryLoads;
private StudioOpenCoordinator() {
entryLoads = new EntryLoadRegistry();
}
public static StudioOpenCoordinator get() {
@@ -93,10 +106,11 @@ public final class StudioOpenCoordinator {
private void executeOpen(StudioOpenRequest request, CompletableFuture<StudioOpenResult> future) {
World world = null;
PlatformChunkGenerator provider = null;
CompletableFuture<Void> entryLoadFuture = null;
try {
long openStart = System.currentTimeMillis();
long openStart = System.nanoTime();
long t = openStart;
IrisLogging.debug("[Studio timing] ===== studio open START: " + request.worldName() + " =====");
entryLoads.rejectNewOpen();
updateStage(request, "resolve_dimension", 0.04D);
if (IrisToolbelt.getDimension(request.dimensionKey()) == null) {
throw new IrisException("Dimension cannot be found for id " + request.dimensionKey() + ".");
@@ -104,7 +118,7 @@ public final class StudioOpenCoordinator {
updateStage(request, "prepare_world_pack", 0.10D);
cleanupStaleTransientWorlds(request.worldName());
t = logStudioPhase("resolveDimension + cleanupStaleWorlds", t, openStart);
t = logStudioPhase(request, "resolve_dimension_and_cleanup", t, openStart);
updateStage(request, "install_datapacks", 0.18D);
IrisCreator creator = IrisToolbelt.createWorld()
@@ -113,9 +127,11 @@ public final class StudioOpenCoordinator {
.studio(true)
.name(request.worldName())
.dimension(request.dimensionKey())
.studioProgressConsumer((progress, stage) -> updateStage(request, mapCreatorStage(stage), progress));
.datapackPreparation(request.openKind().datapackPreparation())
.studioProgressConsumer((progress, stage) -> updateStage(request, mapCreatorStage(stage), progress))
.studioTimingConsumer((phase, duration) -> logMeasuredStudioPhase(request, phase, duration));
world = creator.create();
t = logStudioPhase("createWorld (datapacks + bukkit world + engine setup)", t, openStart);
t = logStudioPhase(request, "create_world_total", t, openStart);
provider = IrisToolbelt.access(world);
if (provider == null) {
throw new IllegalStateException("Studio runtime provider is unavailable for world \"" + request.worldName() + "\".");
@@ -128,28 +144,36 @@ public final class StudioOpenCoordinator {
if (rulesApplied != null) {
rulesApplied.get(15L, TimeUnit.SECONDS);
}
t = logStudioPhase("applyStudioWorldRules", t, openStart);
t = logStudioPhase(request, "apply_world_rules", t, openStart);
updateStage(request, "prepare_generator", 0.78D);
WorldRuntimeControlService.get().prepareGenerator(world);
t = logStudioPhase("prepareGenerator", t, openStart);
if (request.openKind().prepareGeneratorState()) {
WorldRuntimeControlService.get().prepareGenerator(world);
}
t = logStudioPhase(request, "prepare_generator", t, openStart);
Location entryAnchor = WorldRuntimeControlService.get().resolveEntryAnchor(world);
if (entryAnchor == null) {
throw new IllegalStateException("Studio entry anchor could not be resolved.");
}
t = logStudioPhase("resolveEntryAnchor", t, openStart);
t = logStudioPhase(request, "resolve_entry_anchor", t, openStart);
updateStage(request, "load_entry_chunk", 0.80D);
int entryChunkX = entryAnchor.getBlockX() >> 4;
int entryChunkZ = entryAnchor.getBlockZ() >> 4;
try {
loadEntryChunk(world, entryChunkX, entryChunkZ).get(30L, TimeUnit.SECONDS);
entryLoadFuture = loadEntryChunk(world, entryChunkX, entryChunkZ);
entryLoads.register(request.worldName(), entryLoadFuture);
entryLoadFuture.get(STUDIO_ENTRY_LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException e) {
throw new IllegalStateException("Studio entry chunk did not load in time at "
+ entryChunkX + "," + entryChunkZ + " — chunk system may be stalled.");
+ entryChunkX + "," + entryChunkZ + " — chunk system may be stalled.", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Studio entry chunk load was interrupted at "
+ entryChunkX + "," + entryChunkZ + ".", e);
}
t = logStudioPhase("loadEntryChunk (generate spawn chunk to FULL)", t, openStart);
t = logStudioPhase(request, "load_entry_chunk", t, openStart);
updateStage(request, "resolve_safe_entry", 0.84D);
Location safeEntry;
@@ -162,9 +186,11 @@ public final class StudioOpenCoordinator {
if (safeEntry == null) {
throw new IllegalStateException("Studio entry point could not be resolved for world \"" + request.worldName() + "\".");
}
t = logStudioPhase("resolveSafeEntry (generates/loads spawn chunk to FULL)", t, openStart);
t = logStudioPhase(request, "resolve_safe_entry", t, openStart);
if (request.playerName() != null && !request.playerName().isBlank()) {
if (request.openKind().teleportThroughStandardEntry()
&& request.playerName() != null
&& !request.playerName().isBlank()) {
updateStage(request, "teleport_player", 0.96D);
Player player = resolvePlayer(request.playerName());
if (player == null) {
@@ -180,75 +206,297 @@ public final class StudioOpenCoordinator {
if (!Boolean.TRUE.equals(teleported)) {
throw new IllegalStateException("Studio teleport did not complete successfully.");
}
t = logStudioPhase("teleportPlayer", t, openStart);
t = logStudioPhase(request, "teleport_standard_entry", t, openStart);
}
endStudioEntryBootstrap(world, provider);
updateStage(request, "finalize_open", 1.00D);
if (request.project() != null) {
request.project().setActiveProvider(provider);
}
if (request.openWorkspace() && request.project() != null) {
if (request.openKind().openWorkspace() && request.project() != null) {
new IrisCodeWorkspace(request.project()).openVSCode(request.sender());
}
if (request.onDone() != null) {
request.onDone().accept(world);
}
t = logStudioPhase("finalize + openVSCode", t, openStart);
t = logStudioPhase(request, "finalize_open", t, openStart);
IrisLogging.info("Studio open: " + world.getName() + " ready in " + (System.currentTimeMillis() - openStart) + "ms");
IrisLogging.info("Studio open: " + world.getName() + " ready in "
+ elapsedMillis(openStart) + "ms");
entryLoads.release(request.worldName(), entryLoadFuture);
future.complete(new StudioOpenResult(world, safeEntry));
} catch (Throwable e) {
abandonStudioEntryBootstrap(world, e);
IrisLogging.reportError("Studio open failed for world \"" + request.worldName() + "\".", e);
if (!request.retainOnFailure()) {
try {
updateStage(request, "cleanup", 1.00D);
StudioCloseResult cleanupResult = closeWorldCoordinated(
updateStage(request, "cleanup", 1.00D);
if (requiresDeferredEntryCleanup(entryLoadFuture)) {
deferFailedOpenCleanup(
entryLoadFuture,
provider,
request.worldName(),
world,
true,
request.project()
).get(45L, TimeUnit.SECONDS);
if (cleanupResult.failureCause() != null) {
throw cleanupResult.failureCause();
request.project());
} else {
try {
CompletableFuture<Void> cleanup = cleanupFailedOpen(
provider,
request.worldName(),
world,
request.project());
entryLoads.releaseAfterSuccessfulCompletion(
request.worldName(),
entryLoadFuture,
cleanup);
cleanup.get(45L, TimeUnit.SECONDS);
} catch (Throwable cleanupError) {
IrisLogging.reportError("Studio cleanup failed for world \""
+ request.worldName() + "\".", unwrapFailure(cleanupError));
}
} catch (Throwable cleanupError) {
IrisLogging.reportError("Studio cleanup failed for world \"" + request.worldName() + "\".", cleanupError);
}
}
future.completeExceptionally(e);
}
}
private long logStudioPhase(String phase, long t, long openStart) {
long now = System.currentTimeMillis();
IrisLogging.debug("[Studio timing] " + phase + " = " + (now - t) + "ms (cumulative " + (now - openStart) + "ms)");
private long logStudioPhase(StudioOpenRequest request, String phase, long t, long openStart) {
long now = System.nanoTime();
IrisLogging.info("[Studio timing] world=%s kind=%s phase=%s duration=%dms cumulative=%dms",
request.worldName(),
request.openKind().name().toLowerCase(Locale.ROOT),
phase,
TimeUnit.NANOSECONDS.toMillis(now - t),
TimeUnit.NANOSECONDS.toMillis(now - openStart));
return now;
}
private long elapsedMillis(long startedAtNanos) {
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos);
}
private void logMeasuredStudioPhase(StudioOpenRequest request, String phase, long duration) {
IrisLogging.info("[Studio timing] world=%s kind=%s phase=%s duration=%dms",
request.worldName(),
request.openKind().name().toLowerCase(Locale.ROOT),
phase,
duration);
}
private CompletableFuture<Void> loadEntryChunk(World world, int chunkX, int chunkZ) {
// A freshly created studio world has no ticking region at the entry
// chunk. On Folia getChunkAtAsync only works from the owning region
// thread, and RegionScheduler.execute never fires for a chunk no region
// owns yet which is why resolveSafeEntry (a region task) would stall
// and time out. A plugin chunk ticket force-loads the chunk and creates
// its ticking region; we then confirm via a region task that the region
// is live before resolving the safe entry / teleporting into it.
if (!J.isFolia()) {
return loadEntryChunkAsync(world, chunkX, chunkZ);
}
return scheduleEntryChunkRetention(world, chunkX, chunkZ);
}
private CompletableFuture<Void> loadEntryChunkAsync(World world, int chunkX, int chunkZ) {
CompletableFuture<Chunk> requested;
try {
requested = WorldRuntimeControlService.get().requestChunkAsync(
world,
chunkX,
chunkZ,
true,
true);
} catch (Throwable throwable) {
return CompletableFuture.failedFuture(throwable);
}
if (requested == null) {
return CompletableFuture.failedFuture(new IllegalStateException(
"Entry-chunk async request did not return a future at " + chunkX + "," + chunkZ + "."));
}
return requested.thenCompose(chunk -> {
if (chunk == null) {
return CompletableFuture.failedFuture(new IllegalStateException(
"Entry-chunk async request returned no chunk at " + chunkX + "," + chunkZ + "."));
}
return scheduleEntryChunkRetention(world, chunkX, chunkZ);
});
}
private CompletableFuture<Void> scheduleEntryChunkRetention(World world, int chunkX, int chunkZ) {
CompletableFuture<Void> loaded = new CompletableFuture<>();
J.s(() -> {
try {
world.addPluginChunkTicket(chunkX, chunkZ, art.arcane.iris.platform.bukkit.BukkitPlatform.plugin());
} catch (Throwable t) {
loaded.completeExceptionally(t);
try {
J.s(() -> retainAndConfirmEntryChunk(world, chunkX, chunkZ)
.whenComplete((ignored, throwable) -> complete(loaded, throwable)));
} catch (Throwable throwable) {
loaded.completeExceptionally(throwable);
}
return loaded;
}
private CompletableFuture<Void> retainAndConfirmEntryChunk(World world, int chunkX, int chunkZ) {
CompletableFuture<Void> confirmed = new CompletableFuture<>();
try {
world.addPluginChunkTicket(
chunkX,
chunkZ,
BukkitPlatform.plugin());
} catch (Throwable throwable) {
confirmed.completeExceptionally(throwable);
return confirmed;
}
if (!J.runRegion(world, chunkX, chunkZ, () -> confirmed.complete(null))) {
confirmed.completeExceptionally(new IllegalStateException(
"Failed to confirm entry-chunk region at " + chunkX + "," + chunkZ + "."));
}
return confirmed;
}
private void complete(CompletableFuture<Void> target, Throwable throwable) {
if (throwable == null) {
target.complete(null);
return;
}
target.completeExceptionally(throwable);
}
private void endStudioEntryBootstrap(World world, PlatformChunkGenerator provider) {
if (!(provider instanceof BukkitChunkGenerator bukkitGenerator)) {
throw new IllegalStateException("Studio runtime provider cannot finish its entry bootstrap.");
}
AtomicBoolean activationClaim = new AtomicBoolean(true);
CompletableFuture<Void> activation = J.sfut(() -> {
if (!activationClaim.compareAndSet(true, false)) {
INMS.get().abandonStudioStructureBootstrap(world);
return;
}
if (!J.runRegion(world, chunkX, chunkZ, () -> loaded.complete(null))) {
loaded.completeExceptionally(new IllegalStateException(
"Failed to confirm entry-chunk region at " + chunkX + "," + chunkZ + "."));
try {
INMS.get().completeStudioStructureBootstrap(world);
bukkitGenerator.endStudioEntryBootstrap();
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(
"Studio native structure state could not be activated after entry bootstrap.", e);
}
});
return loaded;
try {
activation.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
activationClaim.compareAndSet(true, false);
Thread.currentThread().interrupt();
throw new IllegalStateException("Studio native structure activation was interrupted.", e);
} catch (ExecutionException e) {
activationClaim.compareAndSet(true, false);
throw new IllegalStateException("Studio native structure activation did not complete.",
unwrapFailure(e));
} catch (TimeoutException e) {
if (!activationClaim.compareAndSet(true, false)) {
try {
activation.get(5L, TimeUnit.SECONDS);
return;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"Studio native structure activation was interrupted.", interrupted);
} catch (ExecutionException | TimeoutException settlementFailure) {
throw new IllegalStateException(
"Studio native structure activation did not settle after claiming completion.",
unwrapFailure(settlementFailure));
}
}
throw new IllegalStateException("Studio native structure activation did not complete.", e);
}
}
private void abandonStudioEntryBootstrap(World world, Throwable failure) {
if (world == null) {
return;
}
if (J.isPrimaryThread()) {
try {
INMS.get().abandonStudioStructureBootstrap(world);
} catch (Throwable abandonmentFailure) {
failure.addSuppressed(abandonmentFailure);
}
return;
}
CompletableFuture<Void> abandonment = J.sfut(
() -> INMS.get().abandonStudioStructureBootstrap(world));
try {
abandonment.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
failure.addSuppressed(new IllegalStateException(
"Studio native structure abandonment was interrupted.", e));
} catch (ExecutionException | TimeoutException e) {
failure.addSuppressed(new IllegalStateException(
"Studio native structure abandonment did not complete.", unwrapFailure(e)));
}
}
private void deferFailedOpenCleanup(
CompletableFuture<Void> entryLoadFuture,
PlatformChunkGenerator provider,
String worldName,
World world,
IrisProject project
) {
CompletableFuture<Void> cleanup = deferCleanupUntilEntrySettlement(
entryLoadFuture,
() -> cleanupFailedOpen(provider, worldName, world, project));
entryLoads.releaseAfterSuccessfulCompletion(worldName, entryLoadFuture, cleanup);
cleanup.whenComplete((ignored, cleanupFailure) -> {
if (cleanupFailure != null) {
IrisLogging.reportError("Deferred Studio cleanup failed for world \""
+ worldName + "\".", unwrapFailure(cleanupFailure));
}
});
observeDeferredEntryCleanupBoundary(entryLoadFuture, worldName);
}
static CompletableFuture<Void> deferCleanupUntilEntrySettlement(
CompletableFuture<?> entryLoadFuture,
Supplier<CompletableFuture<Void>> cleanup
) {
Objects.requireNonNull(entryLoadFuture, "Studio entry-load future");
Objects.requireNonNull(cleanup, "Studio cleanup");
return entryLoadFuture.handle((ignored, entryFailure) -> null)
.thenCompose(ignored -> invokePhase(cleanup));
}
static boolean requiresDeferredEntryCleanup(CompletableFuture<?> entryLoadFuture) {
return entryLoadFuture != null && !entryLoadFuture.isDone();
}
private CompletableFuture<Void> cleanupFailedOpen(
PlatformChunkGenerator provider,
String worldName,
World world,
IrisProject project
) {
return closeWorldCoordinated(provider, worldName, world, true, project)
.thenCompose(result -> result.failureCause() == null
? CompletableFuture.completedFuture(null)
: CompletableFuture.failedFuture(result.failureCause()));
}
private void observeDeferredEntryCleanupBoundary(
CompletableFuture<?> entryLoadFuture,
String worldName
) {
CompletableFuture.delayedExecutor(
STUDIO_ENTRY_CLEANUP_BOUNDARY_SECONDS,
TimeUnit.SECONDS).execute(() -> {
if (entryLoadFuture.isDone()) {
return;
}
TimeoutException timeout = new TimeoutException(
"Studio entry generation remained active for "
+ STUDIO_ENTRY_CLEANUP_BOUNDARY_SECONDS
+ " seconds after the open timeout for \"" + worldName + "\".");
boolean queued = queueStartupCleanup(worldName, timeout);
String recovery = queued
? " The transient world is queued for deletion at the next clean startup."
: " The transient world could not be queued for startup deletion.";
IrisLogging.reportError(
"Studio world \"" + worldName
+ "\" remains loaded because its entry generation is still active;"
+ " Iris did not unload or close its generator."
+ recovery,
timeout);
});
}
private CompletableFuture<StudioCloseResult> closeWorldCoordinated(
@@ -539,6 +787,11 @@ public final class StudioOpenCoordinator {
}
for (String staleWorldName : staleWorldNames) {
if (entryLoads.isFenced(staleWorldName)) {
IrisLogging.warn("Skipping stale Studio cleanup for \"" + staleWorldName
+ "\" because its open or deferred cleanup is still active.");
continue;
}
try {
StudioCloseResult cleanupResult = closeWorldCoordinated(
null,
@@ -659,12 +912,23 @@ public final class StudioOpenCoordinator {
long seed,
String worldName,
String playerName,
boolean openWorkspace,
StudioOpenKind openKind,
boolean retainOnFailure,
Consumer<StudioOpenProgress> progressConsumer,
Consumer<World> onDone
) {
public static StudioOpenRequest studioProject(IrisProject project, VolmitSender sender, long seed, Consumer<StudioOpenProgress> progressConsumer, Consumer<World> onDone) {
public StudioOpenRequest {
openKind = Objects.requireNonNull(openKind, "Studio open kind");
}
public static StudioOpenRequest studioProject(
IrisProject project,
VolmitSender sender,
long seed,
StudioOpenKind openKind,
Consumer<StudioOpenProgress> progressConsumer,
Consumer<World> onDone
) {
String playerName = sender != null && sender.isPlayer() && sender.player() != null ? sender.player().getName() : null;
return new StudioOpenRequest(
project.getName(),
@@ -673,7 +937,7 @@ public final class StudioOpenCoordinator {
seed,
"iris-" + UUID.randomUUID(),
playerName,
true,
openKind,
false,
progressConsumer,
onDone
@@ -681,6 +945,49 @@ public final class StudioOpenCoordinator {
}
}
public enum StudioOpenKind {
STANDARD(
true,
true,
IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY),
JIGSAW(
false,
false,
IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY);
private final boolean teleportThroughStandardEntry;
private final boolean openWorkspace;
private final IrisCreator.DatapackPreparation datapackPreparation;
StudioOpenKind(
boolean teleportThroughStandardEntry,
boolean openWorkspace,
IrisCreator.DatapackPreparation datapackPreparation
) {
this.teleportThroughStandardEntry = teleportThroughStandardEntry;
this.openWorkspace = openWorkspace;
this.datapackPreparation = Objects.requireNonNull(
datapackPreparation,
"Studio datapack preparation");
}
public boolean teleportThroughStandardEntry() {
return teleportThroughStandardEntry;
}
public boolean openWorkspace() {
return openWorkspace;
}
public boolean prepareGeneratorState() {
return this == STANDARD;
}
public IrisCreator.DatapackPreparation datapackPreparation() {
return datapackPreparation;
}
}
public record StudioOpenProgress(double progress, String stage) {
}
@@ -698,4 +1005,64 @@ public final class StudioOpenCoordinator {
return failureCause == null;
}
}
static final class EntryLoadRegistry {
private final ConcurrentHashMap<String, CompletableFuture<?>> entryLoads;
EntryLoadRegistry() {
entryLoads = new ConcurrentHashMap<>();
}
void register(String worldName, CompletableFuture<?> entryLoadFuture) {
Objects.requireNonNull(worldName, "Studio world name");
Objects.requireNonNull(entryLoadFuture, "Studio entry-load future");
CompletableFuture<?> existing = entryLoads.putIfAbsent(worldName, entryLoadFuture);
if (existing != null) {
throw new IllegalStateException("Studio open or deferred cleanup is already active for \""
+ worldName + "\".");
}
}
void release(
String worldName,
CompletableFuture<?> entryLoadFuture
) {
if (worldName == null || entryLoadFuture == null) {
return;
}
entryLoads.remove(worldName, entryLoadFuture);
}
void releaseAfterSuccessfulCompletion(
String worldName,
CompletableFuture<?> entryLoadFuture,
CompletableFuture<?> completion
) {
Objects.requireNonNull(completion, "Studio lifecycle completion");
completion.whenComplete((ignored, failure) -> {
if (failure == null) {
release(worldName, entryLoadFuture);
}
});
}
void rejectNewOpen() {
ArrayList<String> activeWorlds = new ArrayList<>();
for (Map.Entry<String, CompletableFuture<?>> entry : entryLoads.entrySet()) {
activeWorlds.add(entry.getKey());
}
if (activeWorlds.isEmpty()) {
return;
}
Collections.sort(activeWorlds);
throw new IllegalStateException("A previous Studio open or deferred cleanup is still active for "
+ String.join(", ", activeWorlds)
+ ". Wait for it to settle before opening another Studio; a reported cleanup failure"
+ " requires the queued clean restart.");
}
boolean isFenced(String worldName) {
return entryLoads.containsKey(worldName);
}
}
}
@@ -3,6 +3,7 @@ package art.arcane.iris.core.runtime;
import org.bukkit.Chunk;
import org.bukkit.World;
import java.lang.reflect.Method;
import java.util.OptionalLong;
import java.util.concurrent.CompletableFuture;
@@ -18,4 +19,39 @@ interface WorldRuntimeControlBackend {
void syncTime(World world);
CompletableFuture<Chunk> requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate);
default CompletableFuture<Chunk> requestChunkAsync(
World world,
int chunkX,
int chunkZ,
boolean generate,
boolean urgent
) {
if (!urgent) {
return requestChunkAsync(world, chunkX, chunkZ, generate);
}
if (world == null) {
return CompletableFuture.failedFuture(new IllegalStateException("World is null."));
}
try {
Method method = World.class.getMethod(
"getChunkAtAsync",
int.class,
int.class,
boolean.class,
boolean.class);
Object result = method.invoke(world, chunkX, chunkZ, generate, true);
if (result instanceof CompletableFuture<?> future) {
@SuppressWarnings("unchecked")
CompletableFuture<Chunk> chunkFuture = (CompletableFuture<Chunk>) future;
return chunkFuture;
}
return CompletableFuture.failedFuture(
new IllegalStateException("Paper World#getChunkAtAsync returned a non-future result."));
} catch (NoSuchMethodException exception) {
return requestChunkAsync(world, chunkX, chunkZ, generate);
} catch (Throwable exception) {
return CompletableFuture.failedFuture(exception);
}
}
}
@@ -198,6 +198,16 @@ public final class WorldRuntimeControlService {
return backend.requestChunkAsync(world, chunkX, chunkZ, generate);
}
public CompletableFuture<Chunk> requestChunkAsync(
World world,
int chunkX,
int chunkZ,
boolean generate,
boolean urgent
) {
return backend.requestChunkAsync(world, chunkX, chunkZ, generate, urgent);
}
public void prepareGenerator(World world) {
if (world == null) {
return;
@@ -0,0 +1,78 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import java.util.Objects;
public enum JigsawPlanarArchetype {
BLANK("workcell/blank", JigsawPlanarTopology.BLANK),
END("workcell/end", JigsawPlanarTopology.NORTH_END),
STRAIGHT("workcell/straight", JigsawPlanarTopology.NORTH_SOUTH_STRAIGHT),
CORNER("workcell/corner", JigsawPlanarTopology.NORTH_EAST_CORNER),
TEE("workcell/tee", JigsawPlanarTopology.NORTH_EAST_WEST_TEE),
CROSS("workcell/cross", JigsawPlanarTopology.CROSS);
private final String stableId;
private final JigsawPlanarTopology canonicalTopology;
JigsawPlanarArchetype(String stableId, JigsawPlanarTopology canonicalTopology) {
this.stableId = stableId;
this.canonicalTopology = canonicalTopology;
}
public static JigsawPlanarArchetype fromTopology(JigsawPlanarTopology topology) {
JigsawPlanarTopology source = Objects.requireNonNull(topology, "Planar topology");
return switch (source.kind()) {
case BLANK -> BLANK;
case END -> END;
case STRAIGHT -> STRAIGHT;
case CORNER -> CORNER;
case TEE -> TEE;
case CROSS -> CROSS;
};
}
public static JigsawPlanarArchetype fromModel(IrisJigsawWorkcellArchetype archetype) {
return valueOf(Objects.requireNonNull(archetype, "Planar workcell archetype").name());
}
public String stableId() {
return stableId;
}
public JigsawPlanarTopology canonicalTopology() {
return canonicalTopology;
}
public IrisJigsawWorkcellArchetype modelArchetype() {
return IrisJigsawWorkcellArchetype.valueOf(name());
}
public String displayName() {
return switch (this) {
case BLANK -> "Blank";
case END -> "End Cap";
case STRAIGHT -> "Hallway";
case CORNER -> "L Junction";
case TEE -> "T Junction";
case CROSS -> "Cross Junction";
};
}
public int sourceToCanonicalQuarterTurns(JigsawPlanarTopology sourceTopology) {
JigsawPlanarTopology source = Objects.requireNonNull(sourceTopology, "Source planar topology");
if (fromTopology(source) != this) {
throw new IllegalArgumentException("Topology " + source + " does not belong to archetype " + this);
}
for (int quarterTurns = 0; quarterTurns < 4; quarterTurns++) {
if (source.rotateClockwise(quarterTurns) == canonicalTopology) {
return quarterTurns;
}
}
throw new IllegalStateException("No canonical rotation exists for topology " + source);
}
public int canonicalToSourceQuarterTurns(JigsawPlanarTopology sourceTopology) {
return Math.floorMod(-sourceToCanonicalQuarterTurns(sourceTopology), 4);
}
}
@@ -0,0 +1,38 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.engine.object.IrisDirection;
public enum JigsawPlanarDirection {
NORTH(1),
EAST(2),
SOUTH(4),
WEST(8);
private final int bit;
JigsawPlanarDirection(int bit) {
this.bit = bit;
}
public int bit() {
return bit;
}
public IrisDirection irisDirection() {
return switch (this) {
case NORTH -> IrisDirection.NORTH_NEGATIVE_Z;
case EAST -> IrisDirection.EAST_POSITIVE_X;
case SOUTH -> IrisDirection.SOUTH_POSITIVE_Z;
case WEST -> IrisDirection.WEST_NEGATIVE_X;
};
}
public JigsawPlanarDirection rotateClockwise(int quarterTurns) {
int normalizedTurns = Math.floorMod(quarterTurns, values().length);
return values()[(ordinal() + normalizedTurns) % values().length];
}
public JigsawPlanarDirection opposite() {
return rotateClockwise(2);
}
}
@@ -0,0 +1,100 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
public enum JigsawPlanarTopology {
BLANK(0),
NORTH_END(1),
EAST_END(2),
NORTH_EAST_CORNER(3),
SOUTH_END(4),
NORTH_SOUTH_STRAIGHT(5),
EAST_SOUTH_CORNER(6),
NORTH_EAST_SOUTH_TEE(7),
WEST_END(8),
NORTH_WEST_CORNER(9),
EAST_WEST_STRAIGHT(10),
NORTH_EAST_WEST_TEE(11),
SOUTH_WEST_CORNER(12),
NORTH_SOUTH_WEST_TEE(13),
EAST_SOUTH_WEST_TEE(14),
CROSS(15);
private static final JigsawPlanarTopology[] BY_MASK = buildMaskIndex();
private final int mask;
private final JigsawPlanarTopologyKind kind;
private final Set<JigsawPlanarDirection> directions;
JigsawPlanarTopology(int mask) {
this.mask = mask;
this.kind = resolveKind(mask);
this.directions = Collections.unmodifiableSet(resolveDirections(mask));
}
public static JigsawPlanarTopology fromMask(int mask) {
if (mask < 0 || mask >= BY_MASK.length) {
throw new IllegalArgumentException("Planar topology mask must be between 0 and 15");
}
return BY_MASK[mask];
}
public int mask() {
return mask;
}
public JigsawPlanarTopologyKind kind() {
return kind;
}
public Set<JigsawPlanarDirection> directions() {
return directions;
}
public boolean connects(JigsawPlanarDirection direction) {
return directions.contains(direction);
}
public JigsawPlanarTopology rotateClockwise(int quarterTurns) {
int normalizedTurns = Math.floorMod(quarterTurns, 4);
if (normalizedTurns == 0 || mask == 0 || mask == 15) {
return this;
}
int rotatedMask = ((mask << normalizedTurns) | (mask >>> (4 - normalizedTurns))) & 15;
return fromMask(rotatedMask);
}
private static JigsawPlanarTopology[] buildMaskIndex() {
JigsawPlanarTopology[] index = new JigsawPlanarTopology[16];
for (JigsawPlanarTopology topology : values()) {
index[topology.mask] = topology;
}
return index;
}
private static EnumSet<JigsawPlanarDirection> resolveDirections(int mask) {
EnumSet<JigsawPlanarDirection> result = EnumSet.noneOf(JigsawPlanarDirection.class);
for (JigsawPlanarDirection direction : JigsawPlanarDirection.values()) {
if ((mask & direction.bit()) != 0) {
result.add(direction);
}
}
return result;
}
private static JigsawPlanarTopologyKind resolveKind(int mask) {
int connectionCount = Integer.bitCount(mask);
return switch (connectionCount) {
case 0 -> JigsawPlanarTopologyKind.BLANK;
case 1 -> JigsawPlanarTopologyKind.END;
case 2 -> mask == 5 || mask == 10
? JigsawPlanarTopologyKind.STRAIGHT
: JigsawPlanarTopologyKind.CORNER;
case 3 -> JigsawPlanarTopologyKind.TEE;
case 4 -> JigsawPlanarTopologyKind.CROSS;
default -> throw new IllegalArgumentException("Invalid planar topology mask " + mask);
};
}
}
@@ -0,0 +1,10 @@
package art.arcane.iris.core.runtime.jigsaw;
public enum JigsawPlanarTopologyKind {
BLANK,
END,
STRAIGHT,
CORNER,
TEE,
CROSS
}
@@ -0,0 +1,409 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.loader.IrisData;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
public final class JigsawStudioActivation {
private static final Map<String, ActiveRequest> ACTIVE = new ConcurrentHashMap<>();
private static final AtomicReference<UUID> OPENING_OWNER = new AtomicReference<>();
private static final AtomicReference<StagedState> STAGED = new AtomicReference<>();
private JigsawStudioActivation() {
}
public static Request activate(
String packKey,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions,
IrisData source
) {
JigsawStudioLayout layout = JigsawStudioLayout.create(
mode,
cellDimensions,
JigsawStudioVariantCatalog.empty());
return activate(
packKey,
structureKey,
mode,
compatibilityTarget,
cellDimensions,
source,
layout,
null
);
}
public static Request activate(
String packKey,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions,
IrisData source,
JigsawStudioLayout initialLayout
) {
return activate(
packKey,
structureKey,
mode,
compatibilityTarget,
cellDimensions,
source,
initialLayout,
null
);
}
public static synchronized Request activate(
String packKey,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions,
IrisData source,
JigsawStudioLayout initialLayout,
UUID ownerId
) {
if (STAGED.get() != null) {
throw new IllegalStateException("A Jigsaw Studio replacement is already staged");
}
if (ownerId != null && !ownerId.equals(OPENING_OWNER.get())) {
throw new IllegalStateException("Jigsaw Studio activation does not own the active opening lease");
}
UUID activeOwnerId = activeOwnerId();
if (ownerId != null && activeOwnerId != null && !ownerId.equals(activeOwnerId)) {
throw new IllegalStateException("Jigsaw Studio is owned by another player session");
}
ActiveRequest activeRequest = createActiveRequest(
packKey,
structureKey,
mode,
compatibilityTarget,
cellDimensions,
source,
initialLayout,
ownerId);
ACTIVE.put(normalize(packKey), activeRequest);
return activeRequest.request();
}
public static synchronized StagedActivation stage(
String packKey,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions,
IrisData source,
JigsawStudioLayout initialLayout,
UUID ownerId,
UUID preservedRequestId
) {
UUID openingOwner = OPENING_OWNER.get();
UUID activeOwner = activeOwnerId();
UUID requestedOwner = Objects.requireNonNull(ownerId, "Jigsaw Studio staged owner ID");
if (!requestedOwner.equals(openingOwner)) {
throw new IllegalStateException("Jigsaw Studio staging does not own the active opening lease");
}
if (activeOwner != null && !requestedOwner.equals(activeOwner)) {
throw new IllegalStateException("Jigsaw Studio is owned by another player session");
}
if (STAGED.get() != null) {
throw new IllegalStateException("A Jigsaw Studio replacement is already staged");
}
ActiveRequest preserved = preservedRequestId == null ? null : active(preservedRequestId);
if (preservedRequestId != null && preserved == null) {
throw new IllegalStateException("The Jigsaw Studio being replaced is no longer active");
}
if (activeOwner != null && preserved == null) {
throw new IllegalStateException("The active Jigsaw Studio must be preserved during replacement");
}
if (preserved != null && preserved.request().ownerId() != null
&& !requestedOwner.equals(preserved.request().ownerId())) {
throw new IllegalStateException("The Jigsaw Studio being replaced belongs to another player session");
}
ActiveRequest candidate = createActiveRequest(
packKey,
structureKey,
mode,
compatibilityTarget,
cellDimensions,
source,
initialLayout,
requestedOwner);
StagedActivation staged = new StagedActivation(
UUID.randomUUID(), candidate.request(), candidate.session());
STAGED.set(new StagedState(staged, candidate, preserved, false));
return staged;
}
public static synchronized boolean beginStagedGeneration(StagedActivation expected) {
StagedState state = stagedState(expected);
if (state == null || state.generationVisible()) {
return false;
}
STAGED.set(new StagedState(state.staged(), state.candidate(), state.preserved(), true));
return true;
}
public static synchronized boolean commit(StagedActivation expected) {
StagedState state = stagedState(expected);
if (state == null || !state.generationVisible()) {
return false;
}
if (state.preserved() != null) {
ACTIVE.remove(normalize(state.preserved().request().packKey()), state.preserved());
}
ACTIVE.put(normalize(state.candidate().request().packKey()), state.candidate());
STAGED.set(null);
return true;
}
public static synchronized boolean rollback(StagedActivation expected) {
StagedState state = stagedState(expected);
if (state == null) {
return false;
}
STAGED.set(null);
return true;
}
public static Request getGeneratorRequest(String packKey) {
StagedState state = STAGED.get();
if (state != null && state.generationVisible()
&& normalize(state.candidate().request().packKey()).equals(normalize(packKey))) {
return state.candidate().request();
}
return getRequest(packKey);
}
public static JigsawStudioSession getGeneratorSession(String packKey) {
StagedState state = STAGED.get();
if (state != null && state.generationVisible()
&& normalize(state.candidate().request().packKey()).equals(normalize(packKey))) {
return state.candidate().session();
}
return getSession(packKey);
}
public static boolean tryBeginOpen(UUID ownerId) {
UUID requestedOwner = Objects.requireNonNull(ownerId, "Jigsaw Studio opening owner ID");
UUID activeOwnerId = activeOwnerId();
if (activeOwnerId != null && !requestedOwner.equals(activeOwnerId)) {
return false;
}
return OPENING_OWNER.compareAndSet(null, requestedOwner);
}
public static void finishOpen(UUID ownerId) {
if (ownerId != null) {
OPENING_OWNER.compareAndSet(ownerId, null);
}
}
public static UUID openingOwnerId() {
return OPENING_OWNER.get();
}
public static UUID activeOwnerId() {
for (ActiveRequest activeRequest : ACTIVE.values()) {
UUID ownerId = activeRequest.request().ownerId();
if (ownerId != null) {
return ownerId;
}
}
return null;
}
public static void deactivate(String packKey) {
if (packKey != null) {
ACTIVE.remove(normalize(packKey));
StagedState state = STAGED.get();
if (state != null && (normalize(state.candidate().request().packKey()).equals(normalize(packKey))
|| state.preserved() != null && normalize(state.preserved().request().packKey())
.equals(normalize(packKey)))) {
STAGED.compareAndSet(state, null);
}
}
}
public static boolean deactivate(String packKey, UUID requestId) {
if (packKey == null || requestId == null) {
return false;
}
StagedState staged = STAGED.get();
if (staged != null && staged.preserved() != null
&& requestId.equals(staged.preserved().request().requestId())
&& normalize(packKey).equals(normalize(staged.preserved().request().packKey()))) {
return false;
}
AtomicBoolean removed = new AtomicBoolean(false);
ACTIVE.computeIfPresent(normalize(packKey), (key, activeRequest) -> {
if (!requestId.equals(activeRequest.request().requestId())) {
return activeRequest;
}
removed.set(true);
return null;
});
return removed.get();
}
public static boolean isActive(String packKey) {
return getRequest(packKey) != null;
}
public static Request getRequest(String packKey) {
ActiveRequest activeRequest = active(packKey);
return activeRequest == null ? null : activeRequest.request();
}
public static JigsawStudioSession getSession(String packKey) {
ActiveRequest activeRequest = active(packKey);
return activeRequest == null ? null : activeRequest.session();
}
public static JigsawStudioLayout getLayout(String packKey) {
JigsawStudioSession session = getSession(packKey);
return session == null ? null : session.layout();
}
public static Request getRequest(UUID requestId) {
ActiveRequest activeRequest = active(requestId);
return activeRequest == null ? null : activeRequest.request();
}
public static JigsawStudioSession getSession(UUID requestId) {
ActiveRequest activeRequest = active(requestId);
return activeRequest == null ? null : activeRequest.session();
}
private static ActiveRequest active(String packKey) {
if (packKey == null) {
return null;
}
return ACTIVE.get(normalize(packKey));
}
private static ActiveRequest active(UUID requestId) {
if (requestId == null) {
return null;
}
for (ActiveRequest activeRequest : ACTIVE.values()) {
if (requestId.equals(activeRequest.request().requestId())) {
return activeRequest;
}
}
return null;
}
private static ActiveRequest createActiveRequest(
String packKey,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions,
IrisData source,
JigsawStudioLayout initialLayout,
UUID ownerId
) {
Request request = new Request(
UUID.randomUUID(),
packKey,
structureKey,
mode,
compatibilityTarget,
cellDimensions,
source,
ownerId);
JigsawStudioLayout layout = Objects.requireNonNull(initialLayout, "Initial Jigsaw Studio layout");
if (layout.mode() != mode) {
throw new IllegalArgumentException("Initial Jigsaw Studio layout mode does not match the request");
}
if (!layout.cellDimensions().equals(cellDimensions)) {
throw new IllegalArgumentException("Initial Jigsaw Studio layout cell dimensions do not match the request");
}
JigsawStudioSession session = new JigsawStudioSession(
request.requestId(),
request.packKey(),
request.structureKey(),
layout);
return new ActiveRequest(request, session);
}
private static StagedState stagedState(StagedActivation expected) {
StagedActivation staged = Objects.requireNonNull(expected, "Staged Jigsaw Studio activation");
StagedState state = STAGED.get();
return state != null && state.staged().stageId().equals(staged.stageId()) ? state : null;
}
private static String normalize(String key) {
return key.trim().toLowerCase(Locale.ROOT);
}
private record ActiveRequest(Request request, JigsawStudioSession session) {
}
private record StagedState(
StagedActivation staged,
ActiveRequest candidate,
ActiveRequest preserved,
boolean generationVisible
) {
}
public record StagedActivation(
UUID stageId,
Request request,
JigsawStudioSession session
) {
public StagedActivation {
Objects.requireNonNull(stageId, "Jigsaw Studio stage ID");
Objects.requireNonNull(request, "Jigsaw Studio staged request");
Objects.requireNonNull(session, "Jigsaw Studio staged session");
if (!request.requestId().equals(session.sessionId())) {
throw new IllegalArgumentException("Staged Jigsaw Studio request and session IDs do not match");
}
}
}
public record Request(
UUID requestId,
String packKey,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions,
IrisData source,
UUID ownerId
) {
public Request {
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio request ID");
packKey = requireKey(packKey, "pack");
structureKey = requireKey(structureKey, "structure");
mode = Objects.requireNonNull(mode, "Jigsaw Studio mode");
compatibilityTarget = Objects.requireNonNull(
compatibilityTarget,
"Jigsaw Studio compatibility target"
);
cellDimensions = Objects.requireNonNull(cellDimensions, "Jigsaw Studio cell dimensions");
source = Objects.requireNonNull(source, "Jigsaw Studio source data");
}
private static String requireKey(String value, String name) {
Objects.requireNonNull(value, "Jigsaw Studio " + name + " key");
String normalized = value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio " + name + " key cannot be blank");
}
return normalized;
}
}
}
@@ -0,0 +1,31 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import java.io.IOException;
import java.util.Objects;
public final class JigsawStudioAuthoringAccess {
private JigsawStudioAuthoringAccess() {
}
public static boolean isEditable(StructureOwnershipManifest manifest) {
StructureOwnershipManifest ownership = Objects.requireNonNull(
manifest,
"Jigsaw Studio ownership manifest");
return ownership.provenance().origin() != StructureOwnershipManifest.Origin.MANAGED_DATAPACK;
}
public static StructureOwnershipManifest requireEditable(
StructureOwnershipManifest manifest
) throws IOException {
StructureOwnershipManifest ownership = Objects.requireNonNull(
manifest,
"Jigsaw Studio ownership manifest");
if (!isEditable(ownership)) {
throw new IOException("This graph is read-only because it is managed by datapack ingest; "
+ "adopt or clone it before making authoring changes.");
}
return ownership;
}
}
@@ -0,0 +1,62 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Objects;
import java.util.Optional;
public record JigsawStudioBay(
String stableId,
JigsawStudioBayKind kind,
Optional<JigsawStudioWorkcellSpec> workcellSpec,
String authorDisplayName,
JigsawStudioBounds bounds
) {
public JigsawStudioBay {
stableId = requireStableId(stableId);
kind = Objects.requireNonNull(kind, "Jigsaw Studio bay kind");
workcellSpec = Objects.requireNonNull(workcellSpec, "Jigsaw Studio workcell specification");
authorDisplayName = authorDisplayName == null ? "" : authorDisplayName.trim();
bounds = Objects.requireNonNull(bounds, "Jigsaw Studio bay bounds");
if (kind == JigsawStudioBayKind.PLANAR_WORKCELL && workcellSpec.isEmpty()) {
throw new IllegalArgumentException("Planar Jigsaw Studio workcells require an archetype");
}
if (kind == JigsawStudioBayKind.SPATIAL_WORKCELL && workcellSpec.isPresent()) {
throw new IllegalArgumentException("Spatial Jigsaw Studio workcells cannot declare an archetype");
}
if (workcellSpec.isPresent() && !workcellSpec.get().dimensions().equals(bounds.dimensions())) {
throw new IllegalArgumentException("Jigsaw Studio workcell specification dimensions do not match bounds");
}
}
public Optional<JigsawPlanarArchetype> archetype() {
return workcellSpec.map(JigsawStudioWorkcellSpec::archetype);
}
public boolean enabled() {
return workcellSpec.map(JigsawStudioWorkcellSpec::enabled).orElse(true);
}
public String canonicalDisplayName() {
return archetype().map(JigsawPlanarArchetype::displayName).orElse("Spatial");
}
public String displayName() {
return authorDisplayName.isEmpty() ? canonicalDisplayName() : authorDisplayName;
}
public JigsawStudioCellDimensions capacity() {
return bounds.dimensions();
}
public Optional<JigsawPlanarTopology> topology() {
return archetype().map(JigsawPlanarArchetype::canonicalTopology);
}
private static String requireStableId(String value) {
Objects.requireNonNull(value, "Jigsaw Studio bay stable ID");
String normalized = value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio bay stable ID cannot be blank");
}
return normalized;
}
}
@@ -0,0 +1,6 @@
package art.arcane.iris.core.runtime.jigsaw;
public enum JigsawStudioBayKind {
PLANAR_WORKCELL,
SPATIAL_WORKCELL
}
@@ -0,0 +1,37 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Objects;
public record JigsawStudioBounds(
int originX,
int originY,
int originZ,
JigsawStudioCellDimensions dimensions
) {
public JigsawStudioBounds {
dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio bounds dimensions");
}
public int maxX() {
return originX + dimensions.width() - 1;
}
public int maxY() {
return originY + dimensions.height() - 1;
}
public int maxZ() {
return originZ + dimensions.depth() - 1;
}
public boolean contains(int worldX, int worldY, int worldZ) {
return worldX >= originX && worldX <= maxX()
&& worldY >= originY && worldY <= maxY()
&& worldZ >= originZ && worldZ <= maxZ();
}
public boolean intersectsHorizontal(int minX, int minZ, int maxX, int maxZ) {
return this.maxX() >= minX && originX <= maxX
&& this.maxZ() >= minZ && originZ <= maxZ;
}
}
@@ -0,0 +1,30 @@
package art.arcane.iris.core.runtime.jigsaw;
public record JigsawStudioCellDimensions(int width, int height, int depth) {
public static final int MAX_HORIZONTAL_AXIS = 128;
public static final int MAX_HEIGHT = 192;
public static final long MAX_VOLUME = 2_097_152L;
public JigsawStudioCellDimensions {
if (width < 1 || height < 1 || depth < 1) {
throw new IllegalArgumentException("Jigsaw Studio cell dimensions must be positive");
}
if (width > MAX_HORIZONTAL_AXIS || depth > MAX_HORIZONTAL_AXIS) {
throw new IllegalArgumentException("Jigsaw Studio cell width and depth cannot exceed "
+ MAX_HORIZONTAL_AXIS + " blocks");
}
if (height > MAX_HEIGHT) {
throw new IllegalArgumentException("Jigsaw Studio cell height cannot exceed "
+ MAX_HEIGHT + " blocks");
}
long volume = (long) width * height * depth;
if (volume > MAX_VOLUME) {
throw new IllegalArgumentException("Jigsaw Studio cell volume cannot exceed "
+ MAX_VOLUME + " blocks");
}
}
public long volume() {
return (long) width * height * depth;
}
}
@@ -0,0 +1,6 @@
package art.arcane.iris.core.runtime.jigsaw;
public enum JigsawStudioCompatibilityTarget {
IRIS_EXTENDED,
VANILLA_PORTABLE
}
@@ -0,0 +1,4 @@
package art.arcane.iris.core.runtime.jigsaw;
public record JigsawStudioControlPosition(int worldX, int worldY, int worldZ) {
}
@@ -0,0 +1,381 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawMode;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisJigsawPieceEntry;
import art.arcane.iris.engine.object.IrisJigsawPool;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisStructure;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
public final class JigsawStudioGraphMapper {
private JigsawStudioGraphMapper() {
}
public static JigsawStudioLayout map(IrisData data, IrisStructure structure) {
Objects.requireNonNull(data, "Jigsaw Studio graph mapping requires pack data");
Objects.requireNonNull(structure, "Jigsaw Studio graph mapping requires a structure");
JigsawStudioMode mode = structure.resolvedMode() == IrisJigsawMode.PLANAR_JIGSAW
? JigsawStudioMode.PLANAR_JIGSAW
: JigsawStudioMode.SPATIAL_JIGSAW;
JigsawStudioVariantCatalog catalog = catalog(data, structure, mode);
IrisPosition configuredCell = structure.getCellSize();
JigsawStudioCellDimensions dimensions = configuredCell == null
? new JigsawStudioCellDimensions(16, 16, 16)
: new JigsawStudioCellDimensions(
Math.max(1, configuredCell.getX()),
Math.max(1, configuredCell.getY()),
Math.max(1, configuredCell.getZ()));
if (mode == JigsawStudioMode.SPATIAL_JIGSAW) {
dimensions = expandSpatialDimensions(data, catalog, dimensions);
return JigsawStudioLayout.createSpatial(
dimensions,
catalog,
structure.getSpatialWorkcellDisplayName());
}
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> resolved =
PlanarJigsawWorkcellResolver.resolve(structure);
List<JigsawStudioWorkcellSpec> workcells = new ArrayList<>(resolved.size());
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = resolved.get(archetype.modelArchetype());
workcells.add(new JigsawStudioWorkcellSpec(
archetype,
workcell.displayName(),
new JigsawStudioCellDimensions(
workcell.width(),
workcell.height(),
workcell.depth()),
workcell.enabled()));
}
return JigsawStudioLayout.createPlanar(dimensions, workcells, catalog);
}
public static JigsawStudioVariantCatalog catalog(
IrisData data,
IrisStructure structure,
JigsawStudioMode mode
) {
IrisData source = Objects.requireNonNull(data, "Jigsaw Studio catalog pack data");
IrisStructure root = Objects.requireNonNull(structure, "Jigsaw Studio catalog structure");
JigsawStudioMode activeMode = Objects.requireNonNull(mode, "Jigsaw Studio catalog mode");
OwnedResources owned = ownedResources(source, root);
Map<String, MutableVariant> variants = new LinkedHashMap<>();
Set<String> visitedPools = new LinkedHashSet<>();
Set<String> queuedPools = new HashSet<>();
ArrayDeque<String> pools = new ArrayDeque<>();
enqueuePool(root.getStartPool(), pools, queuedPools);
traversePools(source, activeMode, owned, pools, queuedPools, visitedPools, variants);
for (String poolKey : owned.poolKeys()) {
enqueuePool(poolKey, pools, queuedPools);
}
traversePools(source, activeMode, owned, pools, queuedPools, visitedPools, variants);
for (String pieceKey : owned.pieceKeys()) {
addOwnedVariant(source, activeMode, owned, pieceKey, variants);
}
List<JigsawStudioVariant> built = new ArrayList<>(variants.size());
for (MutableVariant variant : variants.values()) {
built.add(variant.build(source));
}
return new JigsawStudioVariantCatalog(built, owned.editable());
}
public static JigsawPlanarTopology topologyOf(IrisJigsawPiece piece) {
Objects.requireNonNull(piece, "Planar topology requires a jigsaw piece");
int mask = 0;
if (piece.getConnectors() != null) {
for (IrisJigsawConnector connector : piece.getConnectors()) {
if (connector == null || connector.getDirection() == null) {
continue;
}
mask |= directionBit(connector.getDirection());
}
}
return JigsawPlanarTopology.fromMask(mask);
}
static JigsawStudioCellDimensions expandSpatialDimensions(
IrisData data,
JigsawStudioVariantCatalog catalog,
JigsawStudioCellDimensions configured
) {
if (data.getObjectLoader() == null) {
return configured;
}
int width = configured.width();
int height = configured.height();
int depth = configured.depth();
for (JigsawStudioVariant variant : catalog.spatialVariants()) {
IrisObject object = data.getObjectLoader().load(variant.objectKey());
if (object == null) {
continue;
}
int objectWidth = object.getW();
int objectDepth = object.getD();
if (variant.rotatable()) {
int horizontalSpan = Math.max(objectWidth, objectDepth);
objectWidth = horizontalSpan;
objectDepth = horizontalSpan;
}
width = Math.max(width, objectWidth);
height = Math.max(height, object.getH());
depth = Math.max(depth, objectDepth);
}
return new JigsawStudioCellDimensions(width, height, depth);
}
private static void traversePools(
IrisData data,
JigsawStudioMode mode,
OwnedResources owned,
ArrayDeque<String> pools,
Set<String> queuedPools,
Set<String> visitedPools,
Map<String, MutableVariant> variants
) {
while (!pools.isEmpty()) {
String poolKey = pools.removeFirst();
if (!visitedPools.add(poolKey)) {
continue;
}
IrisJigsawPool pool = data.getJigsawPoolLoader().load(poolKey);
if (pool == null) {
continue;
}
enqueuePool(pool.getFallback(), pools, queuedPools);
if (pool.getPieces() == null) {
continue;
}
for (int entryIndex = 0; entryIndex < pool.getPieces().size(); entryIndex++) {
IrisJigsawPieceEntry entry = pool.getPieces().get(entryIndex);
if (entry == null || entry.isEmpty() || entry.getPiece() == null || entry.getPiece().isBlank()) {
continue;
}
String pieceKey = entry.getPiece();
IrisJigsawPiece piece = data.getJigsawPieceLoader().load(pieceKey);
if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) {
continue;
}
MutableVariant variant = variants.computeIfAbsent(
pieceKey,
key -> new MutableVariant(
key,
piece,
mode,
owned.owns(key, piece.getObject())));
variant.addMembership(new JigsawStudioPoolMembership(
poolKey,
entryIndex,
entry.getWeight(),
entry.getChance()));
enqueueConnectorPools(piece, pools, queuedPools);
}
}
}
private static void addOwnedVariant(
IrisData data,
JigsawStudioMode mode,
OwnedResources owned,
String pieceKey,
Map<String, MutableVariant> variants
) {
if (variants.containsKey(pieceKey)) {
return;
}
IrisJigsawPiece piece = data.getJigsawPieceLoader().load(pieceKey);
if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) {
return;
}
variants.put(pieceKey, new MutableVariant(pieceKey, piece, mode, owned.owns(pieceKey, piece.getObject())));
}
private static OwnedResources ownedResources(IrisData data, IrisStructure structure) {
String structureKey = structure.getLoadKey();
File dataFolder = data.getDataFolder();
if (structureKey == null || structureKey.isBlank() || dataFolder == null) {
return OwnedResources.empty();
}
try {
Path root = dataFolder.toPath().toAbsolutePath().normalize();
StructureTransactionWriter writer = new StructureTransactionWriter(root);
Path manifestPath = writer.ownershipManifestPath(StructureKey.parse(structureKey, "iris"));
if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
return OwnedResources.empty();
}
StructureOwnershipManifest manifest = StructureOwnershipManifest.fromJson(Files.readAllBytes(manifestPath));
Set<String> pieceKeys = new TreeSet<>();
Set<String> poolKeys = new TreeSet<>();
Set<String> objectKeys = new TreeSet<>();
for (String path : manifest.resourceHashes().keySet()) {
addOwnedKey(path, "jigsaw-pieces/", ".json", pieceKeys);
addOwnedKey(path, "jigsaw-pools/", ".json", poolKeys);
addOwnedKey(path, "objects/", ".iob", objectKeys);
}
boolean editable = JigsawStudioAuthoringAccess.isEditable(manifest);
return new OwnedResources(pieceKeys, poolKeys, objectKeys, editable);
} catch (Exception exception) {
throw new IllegalStateException(
"Failed to read Jigsaw Studio ownership for structure '" + structureKey + "'",
exception);
}
}
private static void addOwnedKey(String path, String prefix, String suffix, Set<String> keys) {
if (path.startsWith(prefix) && path.endsWith(suffix) && path.length() > prefix.length() + suffix.length()) {
keys.add(path.substring(prefix.length(), path.length() - suffix.length()));
}
}
private static int directionBit(IrisDirection direction) {
return switch (direction) {
case NORTH_NEGATIVE_Z -> JigsawPlanarDirection.NORTH.bit();
case EAST_POSITIVE_X -> JigsawPlanarDirection.EAST.bit();
case SOUTH_POSITIVE_Z -> JigsawPlanarDirection.SOUTH.bit();
case WEST_NEGATIVE_X -> JigsawPlanarDirection.WEST.bit();
case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> 0;
};
}
private static void enqueueConnectorPools(
IrisJigsawPiece piece,
ArrayDeque<String> pools,
Set<String> queuedPools
) {
if (piece.getConnectors() == null) {
return;
}
for (IrisJigsawConnector connector : piece.getConnectors()) {
if (connector != null) {
enqueuePool(connector.getPool(), pools, queuedPools);
}
}
}
private static void enqueuePool(String poolKey, ArrayDeque<String> pools, Set<String> queuedPools) {
if (poolKey == null || poolKey.isBlank() || !queuedPools.add(poolKey)) {
return;
}
pools.addLast(poolKey);
}
private static final class MutableVariant {
private final String pieceKey;
private final IrisJigsawPiece piece;
private final JigsawStudioMode mode;
private final boolean owned;
private final List<JigsawStudioPoolMembership> memberships = new ArrayList<>();
private MutableVariant(
String pieceKey,
IrisJigsawPiece piece,
JigsawStudioMode mode,
boolean owned
) {
this.pieceKey = pieceKey;
this.piece = piece;
this.mode = mode;
this.owned = owned;
}
private void addMembership(JigsawStudioPoolMembership membership) {
memberships.add(membership);
}
private JigsawStudioVariant build(IrisData data) {
Optional<JigsawStudioCellDimensions> dimensions = objectDimensions(data, piece, mode);
return new JigsawStudioVariant(
pieceKey,
piece.getObject(),
piece.getDisplayName(),
dimensions,
mode,
mode == JigsawStudioMode.PLANAR_JIGSAW
? Optional.of(topologyOf(piece))
: Optional.empty(),
piece.isRotatable(),
owned,
piece.getThemes() == null ? List.of() : piece.getThemes(),
JigsawStudioPieceRules.from(piece.resolvedRules()),
memberships);
}
private static Optional<JigsawStudioCellDimensions> objectDimensions(
IrisData data,
IrisJigsawPiece piece,
JigsawStudioMode mode
) {
if (data.getObjectLoader() == null) {
return Optional.empty();
}
IrisObject object = data.getObjectLoader().load(piece.getObject());
if (object == null) {
return Optional.empty();
}
JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions(
object.getW(),
object.getH(),
object.getD());
if (mode == JigsawStudioMode.SPATIAL_JIGSAW) {
return Optional.of(sourceDimensions);
}
JigsawPlanarTopology topology = topologyOf(piece);
int quarterTurns = JigsawPlanarArchetype.fromTopology(topology)
.sourceToCanonicalQuarterTurns(topology);
return Math.floorMod(quarterTurns, 2) == 0
? Optional.of(sourceDimensions)
: Optional.of(new JigsawStudioCellDimensions(
sourceDimensions.depth(),
sourceDimensions.height(),
sourceDimensions.width()));
}
}
private record OwnedResources(
Set<String> pieceKeys,
Set<String> poolKeys,
Set<String> objectKeys,
boolean editable
) {
private OwnedResources {
pieceKeys = Collections.unmodifiableSet(new LinkedHashSet<>(pieceKeys));
poolKeys = Collections.unmodifiableSet(new LinkedHashSet<>(poolKeys));
objectKeys = Collections.unmodifiableSet(new LinkedHashSet<>(objectKeys));
}
private boolean owns(String pieceKey, String objectKey) {
return editable && pieceKeys.contains(pieceKey) && objectKeys.contains(objectKey);
}
private static OwnedResources empty() {
return new OwnedResources(Set.of(), Set.of(), Set.of(), false);
}
}
}
@@ -0,0 +1,298 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public final class JigsawStudioLayout {
public static final int FLOOR_Y = 64;
public static final int PLANAR_COLUMNS = 3;
public static final int PLANAR_GAP = 2;
public static final int MAX_VARIANTS = 512;
public static final String SPATIAL_WORKCELL_ID = "workcell/spatial";
private static final int FIRST_ORIGIN = 16;
private static final JigsawStudioControlPosition CONTROL_POSITION =
new JigsawStudioControlPosition(8, FLOOR_Y + 1, 8);
private final JigsawStudioMode mode;
private final JigsawStudioCellDimensions cellDimensions;
private final int columns;
private final int gap;
private final JigsawStudioVariantCatalog variantCatalog;
private final List<JigsawStudioBay> bays;
private final Map<String, JigsawStudioBay> byStableId;
private JigsawStudioLayout(
JigsawStudioMode mode,
JigsawStudioCellDimensions cellDimensions,
int columns,
int gap,
JigsawStudioVariantCatalog variantCatalog,
List<JigsawStudioBay> bays
) {
this.mode = mode;
this.cellDimensions = cellDimensions;
this.columns = columns;
this.gap = gap;
this.variantCatalog = variantCatalog;
this.bays = Collections.unmodifiableList(new ArrayList<>(bays));
Map<String, JigsawStudioBay> index = new LinkedHashMap<>();
for (JigsawStudioBay bay : bays) {
JigsawStudioBay previous = index.putIfAbsent(bay.stableId(), bay);
if (previous != null) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio workcell stable ID " + bay.stableId());
}
}
this.byStableId = Collections.unmodifiableMap(index);
}
public static JigsawStudioLayout create(
JigsawStudioMode mode,
JigsawStudioCellDimensions cellDimensions,
JigsawStudioVariantCatalog variantCatalog
) {
JigsawStudioMode activeMode = Objects.requireNonNull(mode, "Jigsaw Studio layout mode");
JigsawStudioCellDimensions dimensions = Objects.requireNonNull(
cellDimensions,
"Jigsaw Studio layout cell dimensions");
JigsawStudioVariantCatalog catalog = Objects.requireNonNull(
variantCatalog,
"Jigsaw Studio variant catalog");
validateCatalogMode(activeMode, catalog);
if (activeMode == JigsawStudioMode.PLANAR_JIGSAW) {
return createPlanar(dimensions, uniformSpecs(dimensions), catalog);
}
return createSpatial(dimensions, catalog, "");
}
public static JigsawStudioLayout createSpatial(
JigsawStudioCellDimensions cellDimensions,
JigsawStudioVariantCatalog variantCatalog,
String displayName
) {
JigsawStudioCellDimensions dimensions = Objects.requireNonNull(
cellDimensions,
"Spatial Jigsaw Studio cell dimensions");
JigsawStudioVariantCatalog catalog = Objects.requireNonNull(
variantCatalog,
"Spatial Jigsaw Studio variant catalog");
validateCatalogMode(JigsawStudioMode.SPATIAL_JIGSAW, catalog);
String resolvedDisplayName = displayName == null ? "" : displayName.trim();
List<JigsawStudioBay> workcells = new ArrayList<>();
workcells.add(new JigsawStudioBay(
SPATIAL_WORKCELL_ID,
JigsawStudioBayKind.SPATIAL_WORKCELL,
Optional.empty(),
resolvedDisplayName,
new JigsawStudioBounds(FIRST_ORIGIN, FLOOR_Y + 1, FIRST_ORIGIN, dimensions)));
return new JigsawStudioLayout(
JigsawStudioMode.SPATIAL_JIGSAW,
dimensions,
1,
PLANAR_GAP,
catalog,
workcells);
}
public static JigsawStudioLayout createPlanar(
JigsawStudioCellDimensions defaultDimensions,
List<JigsawStudioWorkcellSpec> workcellSpecs,
JigsawStudioVariantCatalog variantCatalog
) {
JigsawStudioCellDimensions defaults = Objects.requireNonNull(
defaultDimensions,
"Jigsaw Studio default cell dimensions");
JigsawStudioVariantCatalog catalog = Objects.requireNonNull(
variantCatalog,
"Jigsaw Studio variant catalog");
validateCatalogMode(JigsawStudioMode.PLANAR_JIGSAW, catalog);
Map<JigsawPlanarArchetype, JigsawStudioWorkcellSpec> specs = indexSpecs(workcellSpecs);
int[] columnWidths = new int[PLANAR_COLUMNS];
int[] rowDepths = new int[2];
JigsawPlanarArchetype[] archetypes = JigsawPlanarArchetype.values();
for (int index = 0; index < archetypes.length; index++) {
JigsawStudioCellDimensions dimensions = specs.get(archetypes[index]).dimensions();
int column = index % PLANAR_COLUMNS;
int row = index / PLANAR_COLUMNS;
columnWidths[column] = Math.max(columnWidths[column], dimensions.width());
rowDepths[row] = Math.max(rowDepths[row], dimensions.depth());
}
List<JigsawStudioBay> workcells = new ArrayList<>(archetypes.length);
for (int index = 0; index < archetypes.length; index++) {
JigsawPlanarArchetype archetype = archetypes[index];
JigsawStudioWorkcellSpec spec = specs.get(archetype);
workcells.add(new JigsawStudioBay(
archetype.stableId(),
JigsawStudioBayKind.PLANAR_WORKCELL,
Optional.of(spec),
spec.displayName(),
planarBounds(index, spec.dimensions(), columnWidths, rowDepths)));
}
return new JigsawStudioLayout(
JigsawStudioMode.PLANAR_JIGSAW,
defaults,
PLANAR_COLUMNS,
PLANAR_GAP,
catalog,
workcells);
}
public JigsawStudioMode mode() {
return mode;
}
public JigsawStudioCellDimensions cellDimensions() {
return cellDimensions;
}
public int columns() {
return columns;
}
public int gap() {
return gap;
}
public JigsawStudioVariantCatalog variantCatalog() {
return variantCatalog;
}
public List<JigsawStudioBay> bays() {
return bays;
}
public JigsawStudioBay get(String stableId) {
return stableId == null ? null : byStableId.get(stableId);
}
public JigsawStudioBay findAt(int worldX, int worldY, int worldZ) {
for (JigsawStudioBay bay : bays) {
if (bay.bounds().contains(worldX, worldY, worldZ)) {
return bay;
}
}
return null;
}
public List<JigsawStudioVariant> variants(JigsawStudioBay workcell) {
JigsawStudioBay activeWorkcell = requireWorkcell(workcell);
if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) {
return variantCatalog.spatialVariants();
}
return variantCatalog.variants(activeWorkcell.archetype().orElseThrow());
}
public Optional<JigsawStudioVariant> defaultVariant(JigsawStudioBay workcell) {
List<JigsawStudioVariant> variants = variants(workcell);
return variants.isEmpty() ? Optional.empty() : Optional.of(variants.getFirst());
}
public boolean accepts(JigsawStudioBay workcell, JigsawStudioVariant variant) {
JigsawStudioBay activeWorkcell = requireWorkcell(workcell);
JigsawStudioVariant activeVariant = Objects.requireNonNull(variant, "Jigsaw Studio variant");
if (variantCatalog.find(activeVariant.pieceKey()).filter(activeVariant::equals).isEmpty()) {
return false;
}
if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) {
return activeVariant.mode() == JigsawStudioMode.SPATIAL_JIGSAW;
}
return activeVariant.archetype().filter(activeWorkcell.archetype().orElseThrow()::equals).isPresent();
}
public JigsawStudioControlPosition controlPosition() {
return CONTROL_POSITION;
}
public int extentX() {
int maximum = CONTROL_POSITION.worldX();
for (JigsawStudioBay bay : bays) {
maximum = Math.max(maximum, bay.bounds().maxX() + PLANAR_GAP);
}
return maximum;
}
public int extentZ() {
int maximum = CONTROL_POSITION.worldZ();
for (JigsawStudioBay bay : bays) {
maximum = Math.max(maximum, bay.bounds().maxZ() + PLANAR_GAP);
}
return maximum;
}
private JigsawStudioBay requireWorkcell(JigsawStudioBay workcell) {
JigsawStudioBay activeWorkcell = Objects.requireNonNull(workcell, "Jigsaw Studio workcell");
if (byStableId.get(activeWorkcell.stableId()) != activeWorkcell) {
throw new IllegalArgumentException("Workcell does not belong to this Jigsaw Studio layout");
}
return activeWorkcell;
}
private static void validateCatalogMode(JigsawStudioMode mode, JigsawStudioVariantCatalog catalog) {
for (JigsawStudioVariant variant : catalog.variants()) {
if (variant.mode() != mode) {
throw new IllegalArgumentException("Jigsaw Studio variant mode does not match the layout mode");
}
}
}
private static List<JigsawStudioWorkcellSpec> uniformSpecs(JigsawStudioCellDimensions dimensions) {
List<JigsawStudioWorkcellSpec> specs = new ArrayList<>(JigsawPlanarArchetype.values().length);
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
specs.add(new JigsawStudioWorkcellSpec(archetype, "", dimensions, true));
}
return specs;
}
private static Map<JigsawPlanarArchetype, JigsawStudioWorkcellSpec> indexSpecs(
List<JigsawStudioWorkcellSpec> workcellSpecs
) {
List<JigsawStudioWorkcellSpec> source = List.copyOf(Objects.requireNonNull(
workcellSpecs,
"Jigsaw Studio workcell specifications"));
Map<JigsawPlanarArchetype, JigsawStudioWorkcellSpec> specs =
new EnumMap<>(JigsawPlanarArchetype.class);
for (JigsawStudioWorkcellSpec spec : source) {
JigsawStudioWorkcellSpec active = Objects.requireNonNull(
spec,
"Jigsaw Studio workcell specification");
if (specs.putIfAbsent(active.archetype(), active) != null) {
throw new IllegalArgumentException(
"Duplicate Jigsaw Studio workcell specification " + active.archetype());
}
}
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
if (!specs.containsKey(archetype)) {
throw new IllegalArgumentException("Missing Jigsaw Studio workcell specification " + archetype);
}
}
return Collections.unmodifiableMap(specs);
}
private static JigsawStudioBounds planarBounds(
int index,
JigsawStudioCellDimensions dimensions,
int[] columnWidths,
int[] rowDepths
) {
int column = index % PLANAR_COLUMNS;
int row = index / PLANAR_COLUMNS;
int originX = FIRST_ORIGIN;
for (int current = 0; current < column; current++) {
originX = Math.addExact(originX, Math.addExact(columnWidths[current], PLANAR_GAP));
}
int originZ = FIRST_ORIGIN;
for (int current = 0; current < row; current++) {
originZ = Math.addExact(originZ, Math.addExact(rowDepths[current], PLANAR_GAP));
}
return new JigsawStudioBounds(originX, FLOOR_Y + 1, originZ, dimensions);
}
}
@@ -0,0 +1,35 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Pattern;
public final class JigsawStudioMarkerKeyCodec {
private static final String STUDIO_NAMESPACE = "iris:";
private static final Pattern INTERNAL_PATH = Pattern.compile("[a-z0-9._-]+(?:/[a-z0-9._-]+)*");
private JigsawStudioMarkerKeyCodec() {
}
public static String encodePool(String internalPoolKey) {
return STUDIO_NAMESPACE + requireInternalPath(internalPoolKey, "pool");
}
public static String decodePool(String markerPoolKey) {
String markerKey = Objects.requireNonNull(markerPoolKey, "Jigsaw Studio marker pool key").trim();
if (!markerKey.toLowerCase(Locale.ROOT).startsWith(STUDIO_NAMESPACE)) {
throw new IllegalArgumentException(
"Jigsaw Studio marker pools must use iris:<owned-pool-key>, not '" + markerKey + "'");
}
return requireInternalPath(markerKey.substring(STUDIO_NAMESPACE.length()), "pool");
}
public static String requireInternalPath(String value, String kind) {
String path = Objects.requireNonNull(value, "Jigsaw Studio " + kind + " key").trim();
if (!INTERNAL_PATH.matcher(path).matches()) {
throw new IllegalArgumentException("Jigsaw Studio " + kind
+ " keys must use lowercase [a-z0-9._-/] resource-path characters");
}
return path;
}
}
@@ -0,0 +1,6 @@
package art.arcane.iris.core.runtime.jigsaw;
public enum JigsawStudioMode {
PLANAR_JIGSAW,
SPATIAL_JIGSAW
}
@@ -0,0 +1,34 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.engine.object.IrisJigsawPieceRules;
import java.util.Objects;
public record JigsawStudioPieceRules(
int minimumDepth,
int maximumDepth,
int minimumPlacements,
int maximumPlacements,
boolean terminal
) {
public JigsawStudioPieceRules {
if (minimumDepth < 0 || maximumDepth < minimumDepth || maximumDepth > 30) {
throw new IllegalArgumentException("Jigsaw Studio piece depth rules are invalid");
}
if (minimumPlacements < 0 || maximumPlacements < 0
|| minimumPlacements > 512 || maximumPlacements > 512
|| maximumPlacements != 0 && minimumPlacements > maximumPlacements) {
throw new IllegalArgumentException("Jigsaw Studio piece placement-count rules are invalid");
}
}
public static JigsawStudioPieceRules from(IrisJigsawPieceRules rules) {
IrisJigsawPieceRules source = Objects.requireNonNull(rules, "Jigsaw Studio piece rules");
return new JigsawStudioPieceRules(
source.getMinimumDepth(),
source.getMaximumDepth(),
source.getMinimumPlacements(),
source.getMaximumPlacements(),
source.isTerminal());
}
}
@@ -0,0 +1,442 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureHash;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.authoring.StructureWriteOptions;
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.Map;
import java.util.Objects;
public final class JigsawStudioPoolEditor {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private JigsawStudioPoolEditor() {
}
public static WeightUpdate updateWeight(
Path packRoot,
String structureKey,
String poolKey,
String pieceKey,
int weight
) throws IOException {
if (weight < 1) {
throw new IllegalArgumentException("Jigsaw variant weight must be positive");
}
PoolUpdate update = updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateWeight(content, pieceKey, weight, poolPath));
return new WeightUpdate(
update.changed(),
update.changedEntries(),
update.poolPath(),
update.writeResult());
}
public static WeightUpdate updateWeightAtIndex(
Path packRoot,
String structureKey,
String poolKey,
int entryIndex,
String expectedPieceKey,
int weight
) throws IOException {
if (weight < 1) {
throw new IllegalArgumentException("Jigsaw variant weight must be positive");
}
if (entryIndex < 0) {
throw new IllegalArgumentException("Jigsaw pool entry index cannot be negative");
}
PoolUpdate update = updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateWeightAtIndex(
content,
entryIndex,
expectedPieceKey,
weight,
poolPath));
return new WeightUpdate(
update.changed(),
update.changedEntries(),
update.poolPath(),
update.writeResult());
}
public static ChanceUpdate updateChanceAtIndex(
Path packRoot,
String structureKey,
String poolKey,
int entryIndex,
String expectedPieceKey,
double chance
) throws IOException {
if (!Double.isFinite(chance) || chance < 0D || chance > 1D) {
throw new IllegalArgumentException("Jigsaw variant chance must be finite and within 0 and 1");
}
if (entryIndex < 0) {
throw new IllegalArgumentException("Jigsaw pool entry index cannot be negative");
}
PoolUpdate update = updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateChanceAtIndex(
content,
entryIndex,
expectedPieceKey,
chance,
poolPath));
return new ChanceUpdate(
update.changed(),
update.changedEntries(),
update.poolPath(),
update.writeResult());
}
public static PoolUpdate addPiece(
Path packRoot,
String structureKey,
String poolKey,
String pieceKey,
int weight
) throws IOException {
if (weight < 1) {
throw new IllegalArgumentException("Jigsaw piece weight must be positive");
}
return updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateAdd(content, pieceKey, weight, poolPath));
}
public static PoolUpdate removePiece(
Path packRoot,
String structureKey,
String poolKey,
String pieceKey
) throws IOException {
return updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateRemove(content, pieceKey, poolPath));
}
public static PoolUpdate removeEntry(
Path packRoot,
String structureKey,
String poolKey,
int entryIndex,
String expectedPieceKey
) throws IOException {
if (entryIndex < 0) {
throw new IllegalArgumentException("Jigsaw pool entry index cannot be negative");
}
return updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateRemoveAtIndex(
content,
entryIndex,
expectedPieceKey,
poolPath));
}
public static PoolUpdate updateFallback(
Path packRoot,
String structureKey,
String poolKey,
String fallbackPoolKey
) throws IOException {
String fallback = fallbackPoolKey == null ? "" : fallbackPoolKey.trim();
return updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateFallback(content, fallback, poolPath));
}
private static PoolUpdate updateOwnedPool(
Path packRoot,
String structureKey,
String poolKey,
PoolContentEditor editor
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
StructureKey ownershipKey = StructureKey.parse(structureKey, "iris");
StructureTransactionWriter writer = new StructureTransactionWriter(root);
Path manifestPath = writer.ownershipManifestPath(ownershipKey);
if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("This graph is read-only because it is not Studio-owned; create a new Jigsaw Studio project before editing pools");
}
byte[] manifestContent = Files.readAllBytes(manifestPath);
String expectedManifestHash = StructureHash.sha256(manifestContent);
StructureOwnershipManifest manifest = JigsawStudioAuthoringAccess.requireEditable(
StructureOwnershipManifest.fromJson(manifestContent));
String normalizedPool = JigsawStudioProjectCreator.Options.requireResourceKey(poolKey);
String targetResource = "jigsaw-pools/" + normalizedPool + ".json";
Path targetPath = root.resolve(targetResource).normalize();
if (!manifest.resourceHashes().containsKey(targetResource)) {
return new PoolUpdate(false, 0, targetPath, null);
}
StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(manifest.structure())
.source(manifest.source())
.backend(manifest.backend())
.capabilities(manifest.capabilities())
.losses(manifest.losses());
int changedEntries = 0;
for (Map.Entry<String, String> resource : manifest.resourceHashes().entrySet()) {
Path resourcePath = resolveOwnedResource(root, resource.getKey());
byte[] content = Files.readAllBytes(resourcePath);
if (resource.getKey().equals(targetResource)) {
PoolMutation mutation = editor.edit(content, resourcePath);
changedEntries = mutation.changedEntries();
content = mutation.content();
}
bundle.resource(resource.getKey(), content);
}
if (changedEntries == 0) {
return new PoolUpdate(false, 0, targetPath, null);
}
StructureResourceBundle updatedBundle = bundle.build();
StructureResourceBundleGraphCompiler.requireViable(updatedBundle);
StructureWriteResult writeResult = writer.write(
updatedBundle,
StructureWriteOptions.overwriteExpected(expectedManifestHash));
if (!writeResult.successful()) {
String conflict = writeResult.conflicts().isEmpty()
? writeResult.status().name()
: writeResult.conflicts().getFirst().relativePath() + ": "
+ writeResult.conflicts().getFirst().reason();
throw new IOException("Atomic graph update was rejected: " + conflict);
}
return new PoolUpdate(true, changedEntries, targetPath, writeResult);
}
private static Path resolveOwnedResource(Path root, String relativePath) throws IOException {
StructureResourceBundle.validateRelativePath(relativePath);
Path resource = root.resolve(relativePath).normalize();
if (!resource.startsWith(root) || !Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Owned graph resource is missing or unsafe: " + relativePath);
}
return resource;
}
private static PoolMutation mutateWeight(
byte[] content,
String pieceKey,
int weight,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
JsonArray pieces = root.getAsJsonArray("pieces");
int changedEntries = 0;
for (JsonElement element : pieces) {
if (!isPiece(element, pieceKey)) {
continue;
}
element.getAsJsonObject().addProperty("weight", weight);
changedEntries++;
}
return mutation(root, changedEntries);
}
private static PoolMutation mutateWeightAtIndex(
byte[] content,
int entryIndex,
String expectedPieceKey,
int weight,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
JsonArray pieces = root.getAsJsonArray("pieces");
JsonObject entry = requirePieceEntry(pieces, entryIndex, expectedPieceKey, poolPath);
int currentWeight = entry.has("weight") ? entry.get("weight").getAsInt() : 1;
if (currentWeight == weight) {
return mutation(root, 0);
}
entry.addProperty("weight", weight);
return mutation(root, 1);
}
private static PoolMutation mutateChanceAtIndex(
byte[] content,
int entryIndex,
String expectedPieceKey,
double chance,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
JsonArray pieces = root.getAsJsonArray("pieces");
JsonObject entry = requirePieceEntry(pieces, entryIndex, expectedPieceKey, poolPath);
double currentChance = entry.has("chance") ? entry.get("chance").getAsDouble() : 1D;
if (Double.compare(currentChance, chance) == 0) {
return mutation(root, 0);
}
entry.addProperty("chance", chance);
return mutation(root, 1);
}
private static PoolMutation mutateAdd(
byte[] content,
String pieceKey,
int weight,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
JsonArray pieces = root.getAsJsonArray("pieces");
for (JsonElement element : pieces) {
if (isPiece(element, pieceKey)) {
return mutation(root, 0);
}
}
JsonObject entry = new JsonObject();
entry.addProperty("piece", pieceKey);
entry.addProperty("weight", weight);
pieces.add(entry);
return mutation(root, 1);
}
private static PoolMutation mutateRemove(
byte[] content,
String pieceKey,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
JsonArray pieces = root.getAsJsonArray("pieces");
int changedEntries = 0;
for (int index = pieces.size() - 1; index >= 0; index--) {
if (isPiece(pieces.get(index), pieceKey)) {
pieces.remove(index);
changedEntries++;
}
}
return mutation(root, changedEntries);
}
private static PoolMutation mutateRemoveAtIndex(
byte[] content,
int entryIndex,
String expectedPieceKey,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
JsonArray pieces = root.getAsJsonArray("pieces");
requirePieceEntry(pieces, entryIndex, expectedPieceKey, poolPath);
pieces.remove(entryIndex);
return mutation(root, 1);
}
private static PoolMutation mutateFallback(
byte[] content,
String fallbackPoolKey,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
String existing = root.has("fallback") && !root.get("fallback").isJsonNull()
? root.get("fallback").getAsString().trim()
: "";
if (existing.equals(fallbackPoolKey)) {
return mutation(root, 0);
}
root.addProperty("fallback", fallbackPoolKey);
return mutation(root, 1);
}
private static JsonObject parsePool(byte[] content, Path poolPath) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw pool is not a JSON object: " + poolPath);
}
JsonObject root = parsed.getAsJsonObject();
if (!root.has("pieces") || !root.get("pieces").isJsonArray()) {
throw new IOException("Jigsaw pool does not declare a pieces array: " + poolPath);
}
return root;
}
private static boolean isPiece(JsonElement element, String pieceKey) {
return element.isJsonObject()
&& element.getAsJsonObject().has("piece")
&& pieceKey.equals(element.getAsJsonObject().get("piece").getAsString());
}
private static JsonObject requirePieceEntry(
JsonArray pieces,
int entryIndex,
String expectedPieceKey,
Path poolPath
) throws IOException {
if (entryIndex >= pieces.size()) {
throw new IOException("Jigsaw pool entry " + entryIndex + " no longer exists in " + poolPath);
}
JsonElement element = pieces.get(entryIndex);
if (!isPiece(element, expectedPieceKey)) {
throw new IOException("Jigsaw pool entry " + entryIndex + " changed before the update in "
+ poolPath);
}
return element.getAsJsonObject();
}
private static PoolMutation mutation(JsonObject root, int changedEntries) {
return new PoolMutation(
(GSON.toJson(root) + "\n").getBytes(StandardCharsets.UTF_8),
changedEntries);
}
public record WeightUpdate(
boolean changed,
int changedEntries,
Path poolPath,
StructureWriteResult writeResult
) {
}
public record ChanceUpdate(
boolean changed,
int changedEntries,
Path poolPath,
StructureWriteResult writeResult
) {
}
public record PoolUpdate(
boolean changed,
int changedEntries,
Path poolPath,
StructureWriteResult writeResult
) {
}
@FunctionalInterface
private interface PoolContentEditor {
PoolMutation edit(byte[] content, Path poolPath) throws IOException;
}
private record PoolMutation(byte[] content, int changedEntries) {
}
}
@@ -0,0 +1,22 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Objects;
public record JigsawStudioPoolMembership(String poolKey, int entryIndex, int weight, double chance) {
public JigsawStudioPoolMembership {
Objects.requireNonNull(poolKey, "Jigsaw Studio pool key");
poolKey = poolKey.trim();
if (poolKey.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio pool key cannot be blank");
}
if (entryIndex < 0) {
throw new IllegalArgumentException("Jigsaw Studio pool entry index cannot be negative");
}
if (weight < 1) {
throw new IllegalArgumentException("Jigsaw Studio pool membership weight must be positive");
}
if (!Double.isFinite(chance) || chance < 0D || chance > 1D) {
throw new IllegalArgumentException("Jigsaw Studio pool membership chance must be within 0 and 1");
}
}
}
@@ -0,0 +1,227 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.structure.authoring.StructureBackend;
import art.arcane.iris.core.structure.authoring.StructureCapability;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.authoring.StructureWriteMode;
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy;
import art.arcane.iris.engine.object.IrisJigsawCompatibility;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawMode;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisJigsawPieceEntry;
import art.arcane.iris.engine.object.IrisJigsawPieceRules;
import art.arcane.iris.engine.object.IrisJigsawPool;
import art.arcane.iris.engine.object.IrisJigsawThemeSet;
import art.arcane.iris.engine.object.IrisJigsawWorkcell;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.object.JigsawJoint;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Objects;
public final class JigsawStudioProjectCreator {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private JigsawStudioProjectCreator() {
}
public static StructureWriteResult create(Path packRoot, Options options) throws IOException {
Options activeOptions = Objects.requireNonNull(options, "Jigsaw Studio project options");
StructureResourceBundle bundle = bundle(activeOptions);
StructureResourceBundleGraphCompiler.requireViable(bundle);
return new StructureTransactionWriter(packRoot).write(bundle, StructureWriteMode.ADD_ONLY);
}
static StructureResourceBundle bundle(Options options) throws IOException {
String resourceKey = options.structureKey();
StructureKey ownershipKey = new StructureKey("iris", resourceKey);
IrisStructure structure = new IrisStructure()
.setStartPool(resourceKey + "/start")
.setMaxDepth(7)
.setMaxSizeChunks(8)
.setMode(toModelMode(options.mode()))
.setCompatibility(toModelCompatibility(options.compatibilityTarget()))
.setBranchFailurePolicy(toBranchFailurePolicy(options.compatibilityTarget()))
.setCellSize(new IrisPosition(
options.cellDimensions().width(),
options.cellDimensions().height(),
options.cellDimensions().depth()));
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
structure.getThemeSets().add(new IrisJigsawThemeSet("variant-1", 1));
}
StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(ownershipKey)
.source(StructureSource.of(StructureSource.Kind.IRIS, ownershipKey))
.backend(StructureBackend.IRIS_ASSEMBLY)
.capability(StructureCapability.BLOCKS)
.capability(StructureCapability.CONNECTORS)
.capability(StructureCapability.IRIS_PLACEMENT);
IrisJigsawPool pool = new IrisJigsawPool();
if (options.mode() == JigsawStudioMode.PLANAR_JIGSAW) {
addPlanarDefaults(bundle, structure, pool, options);
} else {
addSpatialDefault(bundle, pool, options);
}
bundle.textResource("jigsaw-pools/" + resourceKey + "/start.json", GSON.toJson(pool) + "\n");
bundle.textResource("structures/" + resourceKey + ".json", GSON.toJson(structure) + "\n");
return bundle.build();
}
static byte[] serialize(IrisObject object) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
object.write(output);
return output.toByteArray();
}
private static IrisJigsawMode toModelMode(JigsawStudioMode mode) {
return mode == JigsawStudioMode.PLANAR_JIGSAW
? IrisJigsawMode.PLANAR_JIGSAW
: IrisJigsawMode.SPATIAL_JIGSAW;
}
private static IrisJigsawCompatibility toModelCompatibility(
JigsawStudioCompatibilityTarget compatibilityTarget
) {
return compatibilityTarget == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE
? IrisJigsawCompatibility.VANILLA_PORTABLE
: IrisJigsawCompatibility.IRIS_EXTENDED;
}
private static IrisJigsawBranchFailurePolicy toBranchFailurePolicy(
JigsawStudioCompatibilityTarget compatibilityTarget
) {
return compatibilityTarget == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE
? IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH
: IrisJigsawBranchFailurePolicy.FAIL_ASSEMBLY;
}
private static void addPlanarDefaults(
StructureResourceBundle.Builder bundle,
IrisStructure structure,
IrisJigsawPool pool,
Options options
) throws IOException {
JigsawStudioCellDimensions dimensions = options.cellDimensions();
IrisPosition size = new IrisPosition(dimensions.width(), dimensions.height(), dimensions.depth());
String piecePoolKey = options.structureKey() + "/pieces";
String capPoolKey = options.structureKey() + "/caps";
IrisJigsawPool piecePool = new IrisJigsawPool().setFallback(capPoolKey);
IrisJigsawPool capPool = new IrisJigsawPool();
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
String key = options.structureKey() + "/" + archetype.name().toLowerCase(Locale.ROOT);
IrisJigsawPiece piece = planarPiece(key, piecePoolKey, size, archetype);
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
piece.getThemes().add("variant-1");
}
if (archetype == JigsawPlanarArchetype.CROSS) {
pool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
}
if (archetype != JigsawPlanarArchetype.BLANK) {
piecePool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
}
if (archetype == JigsawPlanarArchetype.END) {
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
piece.setRules(new IrisJigsawPieceRules().setTerminal(true));
}
capPool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
}
structure.getPlanarWorkcells().add(new IrisJigsawWorkcell(
"",
archetype.modelArchetype(),
dimensions.width(),
dimensions.height(),
dimensions.depth(),
true));
bundle.resource("objects/" + key + ".iob", serialize(new IrisObject(
dimensions.width(),
dimensions.height(),
dimensions.depth())));
bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n");
}
capPool.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true));
bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n");
bundle.textResource("jigsaw-pools/" + capPoolKey + ".json", GSON.toJson(capPool) + "\n");
}
private static void addSpatialDefault(
StructureResourceBundle.Builder bundle,
IrisJigsawPool pool,
Options options
) throws IOException {
String key = options.structureKey() + "/start";
JigsawStudioCellDimensions dimensions = options.cellDimensions();
IrisJigsawPiece piece = new IrisJigsawPiece().setObject(key).setRotatable(true);
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
piece.getThemes().add("variant-1");
}
pool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
bundle.resource("objects/" + key + ".iob", serialize(new IrisObject(
dimensions.width(),
dimensions.height(),
dimensions.depth())));
bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n");
}
private static IrisJigsawPiece planarPiece(
String objectKey,
String poolKey,
IrisPosition dimensions,
JigsawPlanarArchetype archetype
) {
IrisJigsawPiece piece = new IrisJigsawPiece().setObject(objectKey).setRotatable(true);
for (JigsawPlanarDirection planarDirection : archetype.canonicalTopology().directions()) {
IrisDirection direction = planarDirection.irisDirection();
piece.getConnectors().add(new IrisJigsawConnector()
.setPosition(IrisJigsawConnector.canonicalPlanarPosition(dimensions, direction))
.setDirection(direction)
.setTop(IrisDirection.UP_POSITIVE_Y)
.setPool(poolKey)
.setName("iris:planar")
.setTargetName("iris:planar")
.setJoint(JigsawJoint.ALIGNED)
.setFinalState("minecraft:structure_void"));
}
return piece;
}
public record Options(
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions
) {
public Options {
structureKey = requireResourceKey(structureKey);
mode = Objects.requireNonNull(mode, "Jigsaw Studio mode");
compatibilityTarget = Objects.requireNonNull(
compatibilityTarget,
"Jigsaw Studio compatibility target");
cellDimensions = Objects.requireNonNull(cellDimensions, "Jigsaw Studio cell dimensions");
if (mode == JigsawStudioMode.PLANAR_JIGSAW
&& (cellDimensions.width() < 3 || cellDimensions.depth() < 3)) {
throw new IllegalArgumentException(
"Planar Jigsaw Studio cells require width and depth of at least 3 blocks");
}
}
static String requireResourceKey(String value) {
String key = JigsawStudioMarkerKeyCodec.requireInternalPath(value, "resource");
StructureResourceBundle.validateRelativePath("structures/" + key + ".json");
new StructureKey("iris", key);
return key;
}
}
}
@@ -0,0 +1,435 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.structure.authoring.StructureHash;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
import java.util.stream.Stream;
public final class JigsawStudioProjectDeletionService {
private static final long MAX_JSON_BYTES = 16L * 1024L * 1024L;
private static final int MAX_SCANNED_FILES = 100_000;
private JigsawStudioProjectDeletionService() {
}
public static DeletionPlan inspect(Path packRoot, String structureKey) throws IOException {
Path root = canonicalRoot(packRoot);
StructureKey key = StructureKey.parse(structureKey, "iris");
ManifestSnapshot snapshot = loadSnapshot(root, key);
List<ReverseReference> blockers = scanReverseReferences(root, snapshot);
return new DeletionPlan(
UUID.randomUUID(),
root,
key,
snapshot.manifest().source(),
snapshot.manifestHash(),
snapshot.sourceHash(),
snapshot.manifest().resourceHashes(),
blockers);
}
public static ProjectDeletionResult delete(DeletionPlan plan) throws IOException {
DeletionPlan expected = Objects.requireNonNull(plan, "Jigsaw Studio project deletion plan");
if (!expected.deletable()) {
throw new IOException("Jigsaw Studio project deletion is blocked by "
+ expected.blockers().size() + " reverse references");
}
Path root = canonicalRoot(expected.packRoot());
StructureTransactionWriter writer = new StructureTransactionWriter(root);
StructureTransactionWriter.OwnedRemoval request = new StructureTransactionWriter.OwnedRemoval(
expected.structureKey(),
expected.expectedSource().kind(),
expected.expectedSource().key(),
Optional.empty(),
Optional.of(expected.expectedManifestHash()));
try (StructureTransactionWriter.PreparedRemoval removal = writer.prepareOwnedRemovals(
List.of(request),
() -> validateDeletion(root, expected))) {
if (!removal.changed()) {
throw new IOException("Jigsaw Studio project no longer exists");
}
removal.markCommitted();
removal.finishCommit();
}
return new ProjectDeletionResult(
expected.planId(),
expected.structureKey(),
expected.expectedResourceHashes().size(),
true);
}
private static void validateDeletion(Path root, DeletionPlan expected) throws IOException {
ManifestSnapshot current = loadSnapshot(root, expected.structureKey());
if (!expected.expectedManifestHash().equals(current.manifestHash())) {
throw new IOException("Jigsaw Studio project changed after deletion was inspected");
}
if (!expected.expectedSource().equals(current.manifest().source())
|| !expected.expectedSourceHash().equals(current.sourceHash())) {
throw new IOException("Jigsaw Studio project source changed after deletion was inspected");
}
if (!expected.expectedResourceHashes().equals(current.manifest().resourceHashes())) {
throw new IOException("Jigsaw Studio project resources changed after deletion was inspected");
}
List<ReverseReference> currentBlockers = scanReverseReferences(root, current);
if (!currentBlockers.isEmpty()) {
throw new IOException("Jigsaw Studio project gained " + currentBlockers.size()
+ " reverse references after deletion was inspected");
}
}
private static Path canonicalRoot(Path packRoot) throws IOException {
Path normalized = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
Path resolved;
try {
resolved = normalized.toRealPath();
} catch (IOException exception) {
throw new IOException("Cannot resolve Jigsaw Studio pack root " + normalized, exception);
}
if (!Files.isDirectory(resolved, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Jigsaw Studio pack root is not a directory: " + resolved);
}
return resolved;
}
private static ManifestSnapshot loadSnapshot(Path root, StructureKey key) throws IOException {
StructureTransactionWriter writer = new StructureTransactionWriter(root);
Path manifestPath = writer.ownershipManifestPath(key);
if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("This graph is read-only because it is not Studio-owned; only Studio-owned projects can be deleted");
}
byte[] manifestContent = readBoundedJson(manifestPath, "Jigsaw Studio ownership manifest");
StructureOwnershipManifest manifest;
try {
manifest = JigsawStudioAuthoringAccess.requireEditable(
StructureOwnershipManifest.fromJson(manifestContent));
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid Jigsaw Studio ownership manifest at " + manifestPath, exception);
}
if (!manifest.structure().equals(key)) {
throw new IOException("Jigsaw Studio ownership manifest belongs to " + manifest.structure());
}
String structureResource = "structures/" + manifest.structure().path() + ".json";
String structureHash = manifest.resourceHashes().get(structureResource);
if (structureHash == null) {
throw new IOException("Jigsaw Studio ownership manifest does not include " + structureResource);
}
for (Map.Entry<String, String> resource : manifest.resourceHashes().entrySet()) {
Path resourcePath = resolveOwnedResource(root, resource.getKey());
String actualHash;
try (InputStream input = Files.newInputStream(resourcePath)) {
actualHash = StructureHash.sha256(input);
}
if (!resource.getValue().equals(actualHash)) {
throw new IOException("Owned graph resource changed outside Studio: " + resource.getKey());
}
}
String sourceHash = manifest.source().contentHash().isEmpty()
? structureHash
: manifest.source().contentHash();
return new ManifestSnapshot(
manifest,
StructureHash.sha256(manifestContent),
sourceHash,
manifestPath);
}
private static Path resolveOwnedResource(Path root, String relativePath) throws IOException {
StructureResourceBundle.validateRelativePath(relativePath);
Path resource = root.resolve(relativePath).normalize();
if (!resource.startsWith(root)
|| !Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Owned graph resource is missing or unsafe: " + relativePath);
}
return resource;
}
private static List<ReverseReference> scanReverseReferences(
Path root,
ManifestSnapshot snapshot
) throws IOException {
Map<String, Set<String>> targetsByKey = referenceTargets(snapshot.manifest());
Set<String> ownedPaths = snapshot.manifest().resourceHashes().keySet();
Set<ReverseReference> references = new LinkedHashSet<>();
int scannedFiles = 0;
try (Stream<Path> paths = Files.walk(root)) {
Iterator<Path> iterator = paths.iterator();
while (iterator.hasNext()) {
Path path = iterator.next();
Path relative = root.relativize(path);
String relativePath = relative.toString().replace(path.getFileSystem().getSeparator(), "/");
if (relativePath.isEmpty() || relativePath.startsWith(".iris/")) {
continue;
}
if (Files.isSymbolicLink(path)) {
if (relativePath.endsWith(".json") || Files.isDirectory(path)) {
throw new IOException("Cannot prove deletion safety through symbolic pack path "
+ relativePath);
}
continue;
}
if (!relativePath.endsWith(".json") || ownedPaths.contains(relativePath)) {
continue;
}
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
scannedFiles++;
if (scannedFiles > MAX_SCANNED_FILES) {
throw new IOException("Jigsaw Studio project deletion scan exceeds "
+ MAX_SCANNED_FILES + " JSON resources");
}
JsonElement json = parseBoundedJson(path, relativePath);
collectReferences(json, "$", relativePath, targetsByKey, references);
}
}
scanOwnershipManifests(root, snapshot, references);
List<ReverseReference> ordered = new ArrayList<>(references);
ordered.sort(Comparator.comparing(ReverseReference::ownerPath)
.thenComparing(ReverseReference::location)
.thenComparing(ReverseReference::targetResourcePath));
return List.copyOf(ordered);
}
private static Map<String, Set<String>> referenceTargets(StructureOwnershipManifest manifest) {
Map<String, Set<String>> targets = new LinkedHashMap<>();
addReferenceTarget(targets, manifest.structure().path(),
"structures/" + manifest.structure().path() + ".json");
addReferenceTarget(targets, manifest.structure().value(),
"structures/" + manifest.structure().path() + ".json");
for (String relativePath : manifest.resourceHashes().keySet()) {
addResourceReferenceTarget(targets, relativePath, "jigsaw-pools/", ".json");
addResourceReferenceTarget(targets, relativePath, "jigsaw-pieces/", ".json");
addResourceReferenceTarget(targets, relativePath, "objects/", ".iob");
}
return targets;
}
private static void addResourceReferenceTarget(
Map<String, Set<String>> targets,
String relativePath,
String prefix,
String suffix
) {
if (!relativePath.startsWith(prefix) || !relativePath.endsWith(suffix)) {
return;
}
String key = relativePath.substring(prefix.length(), relativePath.length() - suffix.length());
addReferenceTarget(targets, key, relativePath);
addReferenceTarget(targets, "iris:" + key, relativePath);
}
private static void addReferenceTarget(
Map<String, Set<String>> targets,
String key,
String relativePath
) {
targets.computeIfAbsent(key, ignored -> new LinkedHashSet<>()).add(relativePath);
}
private static void collectReferences(
JsonElement element,
String location,
String ownerPath,
Map<String, Set<String>> targetsByKey,
Set<ReverseReference> references
) {
if (element == null || element.isJsonNull()) {
return;
}
if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) {
Set<String> targets = targetsByKey.get(element.getAsString());
if (targets != null) {
for (String target : targets) {
references.add(new ReverseReference(ownerPath, location, target));
}
}
return;
}
if (element.isJsonArray()) {
JsonArray array = element.getAsJsonArray();
for (int index = 0; index < array.size(); index++) {
collectReferences(
array.get(index),
location + "[" + index + "]",
ownerPath,
targetsByKey,
references);
}
return;
}
if (element.isJsonObject()) {
JsonObject object = element.getAsJsonObject();
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
collectReferences(
entry.getValue(),
location + "." + entry.getKey(),
ownerPath,
targetsByKey,
references);
}
}
}
private static void scanOwnershipManifests(
Path root,
ManifestSnapshot target,
Set<ReverseReference> references
) throws IOException {
Path manifestRoot = root.resolve(".iris/structure-manifests");
if (!Files.isDirectory(manifestRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try (Stream<Path> paths = Files.list(manifestRoot)) {
Iterator<Path> iterator = paths.iterator();
while (iterator.hasNext()) {
Path path = iterator.next();
if (Files.isSameFile(path, target.manifestPath())
|| !path.getFileName().toString().endsWith(".json")) {
continue;
}
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Cannot prove deletion safety through ownership manifest " + path);
}
StructureOwnershipManifest manifest;
try {
manifest = StructureOwnershipManifest.fromJson(
readBoundedJson(path, "Structure ownership manifest"));
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid structure ownership manifest at " + path, exception);
}
for (String relativePath : target.manifest().resourceHashes().keySet()) {
if (manifest.resourceHashes().containsKey(relativePath)) {
references.add(new ReverseReference(
root.relativize(path).toString().replace(path.getFileSystem().getSeparator(), "/"),
"$.resourceHashes",
relativePath));
}
}
}
}
}
private static JsonElement parseBoundedJson(Path path, String relativePath) throws IOException {
byte[] content = readBoundedJson(path, "JSON resource");
try {
return JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
} catch (RuntimeException exception) {
throw new IOException("Cannot prove deletion safety through invalid JSON resource "
+ relativePath, exception);
}
}
private static byte[] readBoundedJson(Path path, String kind) throws IOException {
long size = Files.size(path);
if (size > MAX_JSON_BYTES) {
throw new IOException(kind + " exceeds " + MAX_JSON_BYTES + " bytes: " + path);
}
return Files.readAllBytes(path);
}
public record DeletionPlan(
UUID planId,
Path packRoot,
StructureKey structureKey,
StructureSource expectedSource,
String expectedManifestHash,
String expectedSourceHash,
Map<String, String> expectedResourceHashes,
List<ReverseReference> blockers
) {
public DeletionPlan {
Objects.requireNonNull(planId, "Jigsaw Studio project deletion plan ID");
packRoot = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
Objects.requireNonNull(structureKey, "Jigsaw Studio project deletion structure key");
Objects.requireNonNull(expectedSource, "Jigsaw Studio project deletion source");
if (!StructureHash.isSha256(expectedManifestHash)) {
throw new IllegalArgumentException("Expected Jigsaw Studio ownership manifest hash must be SHA-256");
}
if (!StructureHash.isSha256(expectedSourceHash)) {
throw new IllegalArgumentException("Expected Jigsaw Studio source hash must be SHA-256");
}
Objects.requireNonNull(expectedResourceHashes, "Jigsaw Studio project deletion resources");
expectedResourceHashes = Collections.unmodifiableMap(new TreeMap<>(expectedResourceHashes));
blockers = List.copyOf(Objects.requireNonNull(
blockers,
"Jigsaw Studio project deletion blockers"));
}
public boolean deletable() {
return blockers.isEmpty();
}
}
public record ReverseReference(
String ownerPath,
String location,
String targetResourcePath
) {
public ReverseReference {
ownerPath = requireNonBlank(ownerPath, "reverse-reference owner path");
location = requireNonBlank(location, "reverse-reference location");
targetResourcePath = StructureResourceBundle.validateRelativePath(targetResourcePath);
}
}
public record ProjectDeletionResult(
UUID planId,
StructureKey structureKey,
int removedResourceCount,
boolean manifestRemoved
) {
public ProjectDeletionResult {
Objects.requireNonNull(planId, "Jigsaw Studio project deletion plan ID");
Objects.requireNonNull(structureKey, "Deleted Jigsaw Studio structure key");
if (removedResourceCount < 0) {
throw new IllegalArgumentException("Removed Jigsaw Studio resource count cannot be negative");
}
}
}
private static String requireNonBlank(String value, String name) {
Objects.requireNonNull(value, name);
String normalized = value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException(name + " cannot be blank");
}
return normalized;
}
private record ManifestSnapshot(
StructureOwnershipManifest manifest,
String manifestHash,
String sourceHash,
Path manifestPath
) {
}
}
@@ -0,0 +1,666 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
public final class JigsawStudioSession {
private final UUID sessionId;
private final String packKey;
private final String structureKey;
private final Map<String, MutableWorkcellState> workcells = new LinkedHashMap<>();
private JigsawStudioLayout layout;
private String selectedBayId;
private long nextLoadGeneration;
private long nextMutationGeneration;
private long nextOperationGeneration;
private long revision;
public JigsawStudioSession(String packKey, String structureKey, JigsawStudioLayout layout) {
this(UUID.randomUUID(), packKey, structureKey, layout);
}
public JigsawStudioSession(
UUID sessionId,
String packKey,
String structureKey,
JigsawStudioLayout layout
) {
this.sessionId = Objects.requireNonNull(sessionId, "Jigsaw Studio session ID");
this.packKey = requireKey(packKey, "pack");
this.structureKey = requireKey(structureKey, "structure");
this.layout = Objects.requireNonNull(layout, "Jigsaw Studio session layout");
initializeWorkcells(layout);
}
public synchronized UUID sessionId() {
return sessionId;
}
public synchronized String packKey() {
return packKey;
}
public synchronized String structureKey() {
return structureKey;
}
public synchronized JigsawStudioLayout layout() {
return layout;
}
public synchronized Optional<String> selectedBayId() {
return Optional.ofNullable(selectedBayId);
}
public synchronized boolean selectBay(String stableId) {
Objects.requireNonNull(stableId, "Jigsaw Studio selected workcell ID");
if (layout.get(stableId) == null) {
return false;
}
selectedBayId = stableId;
return true;
}
public synchronized void clearSelection() {
selectedBayId = null;
}
public synchronized Optional<JigsawStudioVariant> activeVariant(String workcellId) {
MutableWorkcellState state = workcells.get(workcellId);
if (state == null || state.activeVariantKey.isEmpty()) {
return Optional.empty();
}
return layout.variantCatalog().find(state.activeVariantKey);
}
public synchronized WorkcellSnapshot workcellSnapshot(String workcellId) {
MutableWorkcellState state = requireWorkcellState(workcellId);
return state.snapshot(workcellId);
}
public synchronized boolean replaceLayout(JigsawStudioLayout replacement) {
JigsawStudioLayout nextLayout = Objects.requireNonNull(replacement, "Replacement Jigsaw Studio layout");
if (layout.mode() != nextLayout.mode()) {
throw new IllegalArgumentException("Replacement Jigsaw Studio layout mode does not match the session");
}
if (operationInProgress()) {
throw new IllegalStateException("Jigsaw Studio layout cannot change during a save or variant switch");
}
if (layout == nextLayout) {
return false;
}
Map<String, MutableWorkcellState> updated = new LinkedHashMap<>();
for (JigsawStudioBay workcell : nextLayout.bays()) {
MutableWorkcellState previous = workcells.get(workcell.stableId());
String activeVariantKey = retainedVariantKey(nextLayout, workcell, previous);
if (previous != null && previous.activeVariantKey.equals(activeVariantKey)) {
updated.put(workcell.stableId(), previous.copy());
continue;
}
updated.put(workcell.stableId(), new MutableWorkcellState(
activeVariantKey,
nextLoadGeneration(),
nextMutationGeneration(),
false));
}
layout = nextLayout;
workcells.clear();
workcells.putAll(updated);
if (selectedBayId != null && layout.get(selectedBayId) == null) {
selectedBayId = null;
}
revision++;
return true;
}
public synchronized boolean replaceLayoutAndRebind(
JigsawStudioLayout replacement,
Map<String, String> activeVariantsByWorkcell
) {
JigsawStudioLayout nextLayout = Objects.requireNonNull(replacement, "Replacement Jigsaw Studio layout");
Map<String, String> bindings = Map.copyOf(Objects.requireNonNull(
activeVariantsByWorkcell,
"Replacement Jigsaw Studio active variants"));
if (layout.mode() != nextLayout.mode()) {
throw new IllegalArgumentException("Replacement Jigsaw Studio layout mode does not match the session");
}
if (operationInProgress()) {
throw new IllegalStateException("Jigsaw Studio layout cannot change during a save or variant switch");
}
for (Map.Entry<String, String> binding : bindings.entrySet()) {
JigsawStudioBay workcell = nextLayout.get(binding.getKey());
JigsawStudioVariant variant = nextLayout.variantCatalog().find(binding.getValue())
.orElseThrow(() -> new IllegalArgumentException(
"Unknown replacement Jigsaw Studio variant " + binding.getValue()));
if (workcell == null || !nextLayout.accepts(workcell, variant)) {
throw new IllegalArgumentException("Replacement Jigsaw Studio variant " + binding.getValue()
+ " does not belong to workcell " + binding.getKey());
}
}
Map<String, MutableWorkcellState> updated = new LinkedHashMap<>();
boolean changed = layout != nextLayout;
for (JigsawStudioBay workcell : nextLayout.bays()) {
MutableWorkcellState previous = workcells.get(workcell.stableId());
String targetVariantKey = bindings.get(workcell.stableId());
if (targetVariantKey == null) {
targetVariantKey = retainedVariantKey(nextLayout, workcell, previous);
}
if (previous != null && previous.activeVariantKey.equals(targetVariantKey)) {
updated.put(workcell.stableId(), previous.copy());
continue;
}
changed = true;
updated.put(workcell.stableId(), new MutableWorkcellState(
targetVariantKey,
nextLoadGeneration(),
nextMutationGeneration(),
false));
}
layout = nextLayout;
workcells.clear();
workcells.putAll(updated);
if (selectedBayId != null && layout.get(selectedBayId) == null) {
selectedBayId = null;
}
if (changed) {
revision++;
}
return changed;
}
public synchronized SwitchStart beginVariantSwitch(
String workcellId,
String targetPieceKey,
boolean discardDirty
) {
return beginVariantTransition(workcellId, targetPieceKey, discardDirty, false);
}
public synchronized SwitchStart beginVariantReload(String workcellId) {
MutableWorkcellState state = workcells.get(workcellId);
if (state == null) {
return SwitchStart.failure(SwitchStatus.UNKNOWN_WORKCELL);
}
if (state.activeVariantKey.isEmpty()) {
return SwitchStart.failure(SwitchStatus.UNKNOWN_VARIANT);
}
return beginVariantTransition(workcellId, state.activeVariantKey, false, true);
}
private SwitchStart beginVariantTransition(
String workcellId,
String targetPieceKey,
boolean discardDirty,
boolean allowActive
) {
MutableWorkcellState state = workcells.get(workcellId);
if (state == null) {
return SwitchStart.failure(SwitchStatus.UNKNOWN_WORKCELL);
}
Optional<JigsawStudioVariant> target = layout.variantCatalog().find(targetPieceKey);
if (target.isEmpty()) {
return SwitchStart.failure(SwitchStatus.UNKNOWN_VARIANT);
}
JigsawStudioBay workcell = layout.get(workcellId);
if (!layout.accepts(workcell, target.get())) {
return SwitchStart.failure(SwitchStatus.WRONG_WORKCELL);
}
if (state.switchInProgress) {
return SwitchStart.failure(SwitchStatus.SWITCH_IN_PROGRESS);
}
if (state.saveInProgress) {
return SwitchStart.failure(SwitchStatus.SAVE_IN_PROGRESS);
}
if (state.activeVariantKey.equals(targetPieceKey) && !allowActive) {
return SwitchStart.failure(SwitchStatus.ALREADY_ACTIVE);
}
if (state.dirty && !discardDirty) {
return SwitchStart.failure(SwitchStatus.DIRTY);
}
JigsawStudioVariant previous = state.activeVariantKey.isEmpty()
? null
: layout.variantCatalog().find(state.activeVariantKey).orElse(null);
long switchGeneration = nextOperationGeneration();
state.switchInProgress = true;
state.switchGeneration = switchGeneration;
VariantSwitchToken token = new VariantSwitchToken(
sessionId,
workcellId,
Optional.ofNullable(previous),
target.get(),
state.loadGeneration,
state.mutationGeneration,
switchGeneration,
discardDirty);
revision++;
return SwitchStart.started(token);
}
public synchronized boolean completeVariantSwitch(VariantSwitchToken expected) {
VariantSwitchToken token = Objects.requireNonNull(expected, "Jigsaw Studio variant switch token");
MutableWorkcellState state = workcells.get(token.workcellId());
if (!validSwitchToken(state, token)) {
return false;
}
state.activeVariantKey = token.targetVariant().pieceKey();
state.loadGeneration = nextLoadGeneration();
state.mutationGeneration = nextMutationGeneration();
state.dirty = false;
state.switchInProgress = false;
state.switchGeneration = 0L;
revision++;
return true;
}
public synchronized boolean isVariantSwitchCurrent(VariantSwitchToken expected) {
VariantSwitchToken token = Objects.requireNonNull(expected, "Jigsaw Studio variant switch token");
return validSwitchToken(workcells.get(token.workcellId()), token);
}
public synchronized boolean abortVariantSwitch(VariantSwitchToken expected) {
VariantSwitchToken token = Objects.requireNonNull(expected, "Jigsaw Studio variant switch token");
MutableWorkcellState state = workcells.get(token.workcellId());
if (!validSwitchToken(state, token)) {
return false;
}
state.switchInProgress = false;
state.switchGeneration = 0L;
revision++;
return true;
}
public synchronized DirtyMark markWorkcellDirty(String workcellId) {
MutableWorkcellState state = workcells.get(workcellId);
if (state == null) {
return DirtyMark.failure(DirtyStatus.UNKNOWN_WORKCELL);
}
if (state.activeVariantKey.isEmpty()) {
return DirtyMark.failure(DirtyStatus.NO_ACTIVE_VARIANT);
}
if (state.switchInProgress) {
return DirtyMark.failure(DirtyStatus.SWITCH_IN_PROGRESS);
}
state.mutationGeneration = nextMutationGeneration();
boolean newlyDirty = !state.dirty;
state.dirty = true;
revision++;
return DirtyMark.marked(new DirtyIdentity(
sessionId,
workcellId,
state.activeVariantKey,
state.loadGeneration,
state.mutationGeneration), newlyDirty);
}
public synchronized boolean isDirtyCurrent(DirtyIdentity expected) {
DirtyIdentity identity = Objects.requireNonNull(expected, "Jigsaw Studio dirty identity");
MutableWorkcellState state = workcells.get(identity.workcellId());
return sessionId.equals(identity.sessionId())
&& state != null
&& state.dirty
&& state.activeVariantKey.equals(identity.variantKey())
&& state.loadGeneration == identity.loadGeneration()
&& state.mutationGeneration == identity.mutationGeneration();
}
public synchronized SaveStart beginSave(String workcellId) {
MutableWorkcellState state = workcells.get(workcellId);
if (state == null) {
return SaveStart.failure(SaveStatus.UNKNOWN_WORKCELL);
}
if (state.activeVariantKey.isEmpty()) {
return SaveStart.failure(SaveStatus.NO_ACTIVE_VARIANT);
}
if (state.switchInProgress) {
return SaveStart.failure(SaveStatus.SWITCH_IN_PROGRESS);
}
if (state.saveInProgress) {
return SaveStart.failure(SaveStatus.SAVE_IN_PROGRESS);
}
long saveGeneration = nextOperationGeneration();
state.saveInProgress = true;
state.saveGeneration = saveGeneration;
SaveIdentity identity = new SaveIdentity(
sessionId,
workcellId,
state.activeVariantKey,
state.loadGeneration,
state.mutationGeneration,
saveGeneration);
revision++;
return SaveStart.started(identity);
}
public synchronized boolean markWorkcellSaved(SaveIdentity expected) {
SaveIdentity identity = Objects.requireNonNull(expected, "Jigsaw Studio save identity");
MutableWorkcellState state = workcells.get(identity.workcellId());
if (!validSaveReservation(state, identity)) {
return false;
}
boolean unchanged = state.activeVariantKey.equals(identity.variantKey())
&& state.loadGeneration == identity.loadGeneration()
&& state.mutationGeneration == identity.mutationGeneration();
state.saveInProgress = false;
state.saveGeneration = 0L;
if (unchanged) {
state.dirty = false;
}
revision++;
return unchanged;
}
public synchronized boolean isSaveCurrent(SaveIdentity expected) {
SaveIdentity identity = Objects.requireNonNull(expected, "Jigsaw Studio save identity");
MutableWorkcellState state = workcells.get(identity.workcellId());
return validSaveReservation(state, identity)
&& state.activeVariantKey.equals(identity.variantKey())
&& state.loadGeneration == identity.loadGeneration()
&& state.mutationGeneration == identity.mutationGeneration();
}
public synchronized boolean abortSave(SaveIdentity expected) {
SaveIdentity identity = Objects.requireNonNull(expected, "Jigsaw Studio save identity");
MutableWorkcellState state = workcells.get(identity.workcellId());
if (!validSaveReservation(state, identity)) {
return false;
}
state.saveInProgress = false;
state.saveGeneration = 0L;
revision++;
return true;
}
public synchronized boolean isDirty() {
for (MutableWorkcellState state : workcells.values()) {
if (state.dirty) {
return true;
}
}
return false;
}
public synchronized List<String> dirtyWorkcellIds() {
List<String> dirty = new ArrayList<>();
for (Map.Entry<String, MutableWorkcellState> entry : workcells.entrySet()) {
if (entry.getValue().dirty) {
dirty.add(entry.getKey());
}
}
return List.copyOf(dirty);
}
public synchronized boolean operationInProgress() {
for (MutableWorkcellState state : workcells.values()) {
if (state.saveInProgress || state.switchInProgress) {
return true;
}
}
return false;
}
public synchronized long revision() {
return revision;
}
private void initializeWorkcells(JigsawStudioLayout initialLayout) {
for (JigsawStudioBay workcell : initialLayout.bays()) {
String variantKey = initialLayout.defaultVariant(workcell)
.map(JigsawStudioVariant::pieceKey)
.orElse("");
workcells.put(workcell.stableId(), new MutableWorkcellState(
variantKey,
nextLoadGeneration(),
nextMutationGeneration(),
false));
}
}
private String retainedVariantKey(
JigsawStudioLayout nextLayout,
JigsawStudioBay workcell,
MutableWorkcellState previous
) {
if (previous != null && previous.activeVariantKey.isEmpty()) {
return "";
}
if (previous != null) {
Optional<JigsawStudioVariant> retained = nextLayout.variantCatalog().find(previous.activeVariantKey);
if (retained.isPresent() && nextLayout.accepts(workcell, retained.get())) {
return previous.activeVariantKey;
}
}
return nextLayout.defaultVariant(workcell).map(JigsawStudioVariant::pieceKey).orElse("");
}
private boolean validSwitchToken(MutableWorkcellState state, VariantSwitchToken token) {
if (!sessionId.equals(token.sessionId()) || state == null || !state.switchInProgress) {
return false;
}
String previousKey = token.previousVariant().map(JigsawStudioVariant::pieceKey).orElse("");
return state.switchGeneration == token.switchGeneration()
&& state.activeVariantKey.equals(previousKey)
&& state.loadGeneration == token.loadGeneration()
&& state.mutationGeneration == token.mutationGeneration();
}
private boolean validSaveReservation(MutableWorkcellState state, SaveIdentity identity) {
return sessionId.equals(identity.sessionId())
&& state != null
&& state.saveInProgress
&& state.saveGeneration == identity.saveGeneration();
}
private MutableWorkcellState requireWorkcellState(String workcellId) {
MutableWorkcellState state = workcells.get(Objects.requireNonNull(workcellId, "Jigsaw Studio workcell ID"));
if (state == null) {
throw new IllegalArgumentException("Unknown Jigsaw Studio workcell " + workcellId);
}
return state;
}
private long nextLoadGeneration() {
return nextLoadGeneration = Math.incrementExact(nextLoadGeneration);
}
private long nextMutationGeneration() {
return nextMutationGeneration = Math.incrementExact(nextMutationGeneration);
}
private long nextOperationGeneration() {
return nextOperationGeneration = Math.incrementExact(nextOperationGeneration);
}
private static String requireKey(String value, String name) {
Objects.requireNonNull(value, "Jigsaw Studio " + name + " key");
String normalized = value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio " + name + " key cannot be blank");
}
return normalized;
}
public enum SwitchStatus {
STARTED,
UNKNOWN_WORKCELL,
UNKNOWN_VARIANT,
WRONG_WORKCELL,
ALREADY_ACTIVE,
DIRTY,
SAVE_IN_PROGRESS,
SWITCH_IN_PROGRESS
}
public enum SaveStatus {
STARTED,
UNKNOWN_WORKCELL,
NO_ACTIVE_VARIANT,
SWITCH_IN_PROGRESS,
SAVE_IN_PROGRESS
}
public enum DirtyStatus {
MARKED,
UNKNOWN_WORKCELL,
NO_ACTIVE_VARIANT,
SWITCH_IN_PROGRESS
}
public record WorkcellSnapshot(
String workcellId,
String activeVariantKey,
long loadGeneration,
long mutationGeneration,
boolean dirty,
boolean saveInProgress,
boolean switchInProgress
) {
}
public record DirtyIdentity(
UUID sessionId,
String workcellId,
String variantKey,
long loadGeneration,
long mutationGeneration
) {
public DirtyIdentity {
Objects.requireNonNull(sessionId, "Jigsaw Studio dirty session ID");
Objects.requireNonNull(workcellId, "Jigsaw Studio dirty workcell ID");
Objects.requireNonNull(variantKey, "Jigsaw Studio dirty variant key");
}
}
public record DirtyMark(
DirtyStatus status,
Optional<DirtyIdentity> identity,
boolean newlyDirty
) {
public DirtyMark {
status = Objects.requireNonNull(status, "Jigsaw Studio dirty status");
identity = Objects.requireNonNull(identity, "Jigsaw Studio dirty identity");
if (status == DirtyStatus.MARKED && identity.isEmpty()) {
throw new IllegalArgumentException("A marked Jigsaw Studio workcell requires a dirty identity");
}
if (status != DirtyStatus.MARKED && (identity.isPresent() || newlyDirty)) {
throw new IllegalArgumentException("A rejected Jigsaw Studio dirty mark cannot carry an identity");
}
}
private static DirtyMark marked(DirtyIdentity identity, boolean newlyDirty) {
return new DirtyMark(DirtyStatus.MARKED, Optional.of(identity), newlyDirty);
}
private static DirtyMark failure(DirtyStatus status) {
return new DirtyMark(status, Optional.empty(), false);
}
}
public record VariantSwitchToken(
UUID sessionId,
String workcellId,
Optional<JigsawStudioVariant> previousVariant,
JigsawStudioVariant targetVariant,
long loadGeneration,
long mutationGeneration,
long switchGeneration,
boolean discardDirty
) {
public VariantSwitchToken {
Objects.requireNonNull(sessionId, "Jigsaw Studio switch session ID");
Objects.requireNonNull(workcellId, "Jigsaw Studio switch workcell ID");
previousVariant = Objects.requireNonNull(previousVariant, "Jigsaw Studio previous variant");
targetVariant = Objects.requireNonNull(targetVariant, "Jigsaw Studio target variant");
}
}
public record SwitchStart(SwitchStatus status, Optional<VariantSwitchToken> token) {
public SwitchStart {
status = Objects.requireNonNull(status, "Jigsaw Studio switch status");
token = Objects.requireNonNull(token, "Jigsaw Studio switch token");
}
private static SwitchStart started(VariantSwitchToken token) {
return new SwitchStart(SwitchStatus.STARTED, Optional.of(token));
}
private static SwitchStart failure(SwitchStatus status) {
return new SwitchStart(status, Optional.empty());
}
}
public record SaveIdentity(
UUID sessionId,
String workcellId,
String variantKey,
long loadGeneration,
long mutationGeneration,
long saveGeneration
) {
public SaveIdentity {
Objects.requireNonNull(sessionId, "Jigsaw Studio save session ID");
Objects.requireNonNull(workcellId, "Jigsaw Studio save workcell ID");
Objects.requireNonNull(variantKey, "Jigsaw Studio save variant key");
}
}
public record SaveStart(SaveStatus status, Optional<SaveIdentity> identity) {
public SaveStart {
status = Objects.requireNonNull(status, "Jigsaw Studio save status");
identity = Objects.requireNonNull(identity, "Jigsaw Studio save identity");
}
private static SaveStart started(SaveIdentity identity) {
return new SaveStart(SaveStatus.STARTED, Optional.of(identity));
}
private static SaveStart failure(SaveStatus status) {
return new SaveStart(status, Optional.empty());
}
}
private static final class MutableWorkcellState {
private String activeVariantKey;
private long loadGeneration;
private long mutationGeneration;
private boolean dirty;
private boolean saveInProgress;
private long saveGeneration;
private boolean switchInProgress;
private long switchGeneration;
private MutableWorkcellState(
String activeVariantKey,
long loadGeneration,
long mutationGeneration,
boolean dirty
) {
this.activeVariantKey = activeVariantKey;
this.loadGeneration = loadGeneration;
this.mutationGeneration = mutationGeneration;
this.dirty = dirty;
}
private MutableWorkcellState copy() {
return new MutableWorkcellState(activeVariantKey, loadGeneration, mutationGeneration, dirty);
}
private WorkcellSnapshot snapshot(String workcellId) {
return new WorkcellSnapshot(
workcellId,
activeVariantKey,
loadGeneration,
mutationGeneration,
dirty,
saveInProgress,
switchInProgress);
}
}
}
@@ -0,0 +1,465 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureHash;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.authoring.StructureWriteOptions;
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver;
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
import art.arcane.iris.engine.object.IrisJigsawMode;
import art.arcane.iris.engine.object.IrisJigsawThemeSet;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.util.common.math.IrisBlockVector;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public final class JigsawStudioStructureEditor {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private JigsawStudioStructureEditor() {
}
public static StructureWriteResult updateCellSize(
Path packRoot,
String structureKey,
JigsawStudioCellDimensions dimensions
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
ManifestSnapshot snapshot = loadManifest(root, structureKey);
requireOwnedObjectsFit(root, snapshot.manifest(), dimensions);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateCellSize(content, dimensions, structurePath));
}
public static StructureWriteResult updateLimits(
Path packRoot,
String structureKey,
int maxDepth,
int maxSizeChunks
) throws IOException {
if (maxDepth < 1 || maxDepth > 30) {
throw new IllegalArgumentException("Jigsaw max depth must be between 1 and 30");
}
if (maxSizeChunks < 1 || maxSizeChunks > 32) {
throw new IllegalArgumentException("Jigsaw maximum size must be between 1 and 32 chunks");
}
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
ManifestSnapshot snapshot = loadManifest(root, structureKey);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateLimits(
content, maxDepth, maxSizeChunks, structurePath));
}
public static StructureWriteResult updateThemeSets(
Path packRoot,
String structureKey,
List<IrisJigsawThemeSet> themeSets
) throws IOException {
List<IrisJigsawThemeSet> normalizedThemeSets = normalizeThemeSets(themeSets);
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
ManifestSnapshot snapshot = loadManifest(root, structureKey);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateThemeSets(
content,
normalizedThemeSets,
structurePath));
}
public static StructureWriteResult updateRequireCaps(
Path packRoot,
String structureKey,
boolean requireCaps
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
ManifestSnapshot snapshot = loadManifest(root, structureKey);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateRequireCaps(
content,
requireCaps,
structurePath));
}
public static StructureWriteResult updateWorkcellEnabled(
Path packRoot,
String structureKey,
JigsawPlanarArchetype archetype,
boolean enabled
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
JigsawPlanarArchetype target = Objects.requireNonNull(archetype, "Planar Jigsaw Studio archetype");
ManifestSnapshot snapshot = loadManifest(root, structureKey);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateWorkcell(
content,
target,
null,
enabled,
null,
structurePath));
}
public static StructureWriteResult updateWorkcellDimensions(
Path packRoot,
String structureKey,
JigsawPlanarArchetype archetype,
JigsawStudioCellDimensions dimensions
) throws IOException {
return JigsawStudioGraphEditor.updatePlanarWorkcellCapacity(
packRoot,
structureKey,
archetype,
dimensions).writeResult();
}
public static StructureWriteResult updateWorkcellDisplayName(
Path packRoot,
String structureKey,
JigsawPlanarArchetype archetype,
String displayName
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
JigsawPlanarArchetype target = Objects.requireNonNull(archetype, "Planar Jigsaw Studio archetype");
String normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName);
ManifestSnapshot snapshot = loadManifest(root, structureKey);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateWorkcell(
content,
target,
null,
null,
normalizedName,
structurePath));
}
public static StructureWriteResult updateSpatialWorkcellDisplayName(
Path packRoot,
String structureKey,
String displayName
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
String normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName);
ManifestSnapshot snapshot = loadManifest(root, structureKey);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateSpatialWorkcellDisplayName(
content,
normalizedName,
structurePath));
}
private static ManifestSnapshot loadManifest(Path root, String structureKey) throws IOException {
StructureKey ownershipKey = StructureKey.parse(structureKey, "iris");
StructureTransactionWriter writer = new StructureTransactionWriter(root);
Path manifestPath = writer.ownershipManifestPath(ownershipKey);
if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("This graph is read-only because it is not Studio-owned; create a new Jigsaw Studio project before editing rules");
}
byte[] manifestContent = Files.readAllBytes(manifestPath);
return new ManifestSnapshot(
JigsawStudioAuthoringAccess.requireEditable(
StructureOwnershipManifest.fromJson(manifestContent)),
StructureHash.sha256(manifestContent));
}
private static StructureWriteResult updateOwnedStructure(
Path root,
String structureKey,
ManifestSnapshot snapshot,
StructureContentEditor editor
) throws IOException {
StructureTransactionWriter writer = new StructureTransactionWriter(root);
StructureOwnershipManifest manifest = snapshot.manifest();
String normalizedStructure = JigsawStudioProjectCreator.Options.requireResourceKey(structureKey);
String targetResource = "structures/" + normalizedStructure + ".json";
if (!manifest.resourceHashes().containsKey(targetResource)) {
throw new IOException("The owned graph manifest does not include " + targetResource);
}
StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(manifest.structure())
.source(manifest.source())
.backend(manifest.backend())
.capabilities(manifest.capabilities())
.losses(manifest.losses());
for (Map.Entry<String, String> resource : manifest.resourceHashes().entrySet()) {
Path resourcePath = resolveOwnedResource(root, resource.getKey());
byte[] content = Files.readAllBytes(resourcePath);
if (resource.getKey().equals(targetResource)) {
content = editor.edit(content, resourcePath);
}
bundle.resource(resource.getKey(), content);
}
StructureResourceBundle updatedBundle = bundle.build();
StructureResourceBundleGraphCompiler.requireViable(updatedBundle);
StructureWriteResult result = writer.write(
updatedBundle,
StructureWriteOptions.overwriteExpected(snapshot.expectedManifestHash()));
if (!result.successful()) {
String conflict = result.conflicts().isEmpty()
? result.status().name()
: result.conflicts().getFirst().relativePath() + ": "
+ result.conflicts().getFirst().reason();
throw new IOException("Atomic graph structure update was rejected: " + conflict);
}
return result;
}
private static Path resolveOwnedResource(Path root, String relativePath) throws IOException {
StructureResourceBundle.validateRelativePath(relativePath);
Path resource = root.resolve(relativePath).normalize();
if (!resource.startsWith(root) || !Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Owned graph resource is missing or unsafe: " + relativePath);
}
return resource;
}
private static void requireOwnedObjectsFit(
Path root,
StructureOwnershipManifest manifest,
JigsawStudioCellDimensions dimensions
) throws IOException {
for (String relativePath : manifest.resourceHashes().keySet()) {
if (!relativePath.startsWith("objects/") || !relativePath.endsWith(".iob")) {
continue;
}
Path objectPath = resolveOwnedResource(root, relativePath);
IrisBlockVector size = IrisObject.sampleSize(objectPath.toFile());
if (size.getBlockX() > dimensions.width()
|| size.getBlockY() > dimensions.height()
|| size.getBlockZ() > dimensions.depth()) {
throw new IOException("Owned object '" + relativePath + "' is "
+ size.getBlockX() + "x" + size.getBlockY() + "x" + size.getBlockZ()
+ " and does not fit the requested cell bounds");
}
}
}
private static byte[] updateCellSize(
byte[] content,
JigsawStudioCellDimensions dimensions,
Path structurePath
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw structure is not a JSON object: " + structurePath);
}
JsonObject cellSize = new JsonObject();
JsonObject structure = parsed.getAsJsonObject();
if (structure.has("mode")
&& "PLANAR_JIGSAW".equals(structure.get("mode").getAsString())
&& dimensions.width() != dimensions.depth()) {
throw new IOException("Planar Jigsaw Studio cells require equal width and depth");
}
cellSize.addProperty("x", dimensions.width());
cellSize.addProperty("y", dimensions.height());
cellSize.addProperty("z", dimensions.depth());
structure.add("cellSize", cellSize);
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
}
private static byte[] updateLimits(
byte[] content,
int maxDepth,
int maxSizeChunks,
Path structurePath
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw structure is not a JSON object: " + structurePath);
}
JsonObject structure = parsed.getAsJsonObject();
structure.addProperty("maxDepth", maxDepth);
structure.addProperty("maxSizeChunks", maxSizeChunks);
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
}
private static List<IrisJigsawThemeSet> normalizeThemeSets(List<IrisJigsawThemeSet> themeSets) {
Objects.requireNonNull(themeSets, "Jigsaw Studio theme sets");
List<IrisJigsawThemeSet> normalized = new ArrayList<>(themeSets.size());
Set<String> keys = new LinkedHashSet<>();
for (IrisJigsawThemeSet themeSet : themeSets) {
IrisJigsawThemeSet source = Objects.requireNonNull(themeSet, "Jigsaw Studio theme set");
String key = source.getKey() == null ? "" : source.getKey().trim();
if (key.isEmpty() || !key.equals(source.getKey())) {
throw new IllegalArgumentException(
"Jigsaw Studio theme keys must be non-blank and whitespace-normalized");
}
if (!keys.add(key)) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio theme key '" + key + "'");
}
if (source.getWeight() < 1) {
throw new IllegalArgumentException("Jigsaw Studio theme weights must be positive");
}
normalized.add(new IrisJigsawThemeSet(key, source.getWeight()));
}
return List.copyOf(normalized);
}
private static byte[] updateThemeSets(
byte[] content,
List<IrisJigsawThemeSet> themeSets,
Path structurePath
) throws IOException {
JsonObject structure = parseStructure(content, structurePath);
JsonArray values = new JsonArray();
for (IrisJigsawThemeSet themeSet : themeSets) {
JsonObject value = new JsonObject();
value.addProperty("key", themeSet.getKey());
value.addProperty("weight", themeSet.getWeight());
values.add(value);
}
structure.add("themeSets", values);
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
}
private static byte[] updateRequireCaps(
byte[] content,
boolean requireCaps,
Path structurePath
) throws IOException {
JsonObject structure = parseStructure(content, structurePath);
structure.addProperty("requireCaps", requireCaps);
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
}
private static byte[] updateSpatialWorkcellDisplayName(
byte[] content,
String displayName,
Path structurePath
) throws IOException {
JsonObject structure = parseStructure(content, structurePath);
IrisStructure model;
try {
model = GSON.fromJson(structure, IrisStructure.class);
} catch (RuntimeException exception) {
throw new IOException("Jigsaw structure is not valid JSON: " + structurePath, exception);
}
if (model == null || model.resolvedMode() != IrisJigsawMode.SPATIAL_JIGSAW) {
throw new IOException("Spatial workcell labels require a spatial Jigsaw Studio structure");
}
if (displayName.isEmpty()) {
structure.remove("spatialWorkcellDisplayName");
} else {
structure.addProperty("spatialWorkcellDisplayName", displayName);
}
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
}
private static JsonObject parseStructure(byte[] content, Path structurePath) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw structure is not a JSON object: " + structurePath);
}
return parsed.getAsJsonObject();
}
static byte[] updateWorkcell(
byte[] content,
JigsawPlanarArchetype archetype,
JigsawStudioCellDimensions dimensions,
Boolean enabled,
String displayName,
Path structurePath
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw structure is not a JSON object: " + structurePath);
}
JsonObject structureJson = parsed.getAsJsonObject();
IrisStructure structure;
try {
structure = GSON.fromJson(structureJson, IrisStructure.class);
} catch (RuntimeException exception) {
throw new IOException("Jigsaw structure is not valid JSON: " + structurePath, exception);
}
if (structure == null || structure.resolvedMode() != IrisJigsawMode.PLANAR_JIGSAW) {
throw new IOException("Workcell settings require a planar Jigsaw Studio structure");
}
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> resolved;
try {
resolved = PlanarJigsawWorkcellResolver.resolve(structure);
} catch (IllegalArgumentException exception) {
throw new IOException("Planar workcell configuration is invalid: " + exception.getMessage(), exception);
}
JsonArray workcells = new JsonArray();
for (JigsawPlanarArchetype current : JigsawPlanarArchetype.values()) {
PlanarJigsawWorkcellResolver.ResolvedWorkcell source = resolved.get(current.modelArchetype());
JsonObject workcell = new JsonObject();
workcell.addProperty("archetype", current.name());
String resolvedDisplayName = current == archetype && displayName != null
? displayName : source.displayName();
if (!resolvedDisplayName.isEmpty()) {
workcell.addProperty("displayName", resolvedDisplayName);
}
workcell.addProperty("width", current == archetype && dimensions != null
? dimensions.width() : source.width());
workcell.addProperty("height", current == archetype && dimensions != null
? dimensions.height() : source.height());
workcell.addProperty("depth", current == archetype && dimensions != null
? dimensions.depth() : source.depth());
workcell.addProperty("enabled", current == archetype && enabled != null
? enabled : source.enabled());
workcells.add(workcell);
}
structureJson.add("planarWorkcells", workcells);
return (GSON.toJson(structureJson) + "\n").getBytes(StandardCharsets.UTF_8);
}
@FunctionalInterface
private interface StructureContentEditor {
byte[] edit(byte[] content, Path structurePath) throws IOException;
}
private record ManifestSnapshot(
StructureOwnershipManifest manifest,
String expectedManifestHash
) {
}
}
@@ -0,0 +1,45 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Objects;
public enum JigsawStudioToolAction {
OPEN_MENU("Open Control Menu", false),
SELECT_WORKCELL("Select Workcell", false),
TOGGLE_WORKCELL("Toggle Workcell", false),
LOAD_VARIANT("Load Variant", false),
CREATE_VARIANT("New Blank Variant", false),
DUPLICATE_VARIANT("Duplicate This Cell's Variant", false),
DUPLICATE_FAMILY("Duplicate All Enabled Cells as Family", false),
PREVIEW_GRAPH("Go to Preview", false),
FLUSH_AUTOSAVE("Flush Autosave", false),
TOGGLE_ROTATION("Toggle Rotation", false),
EXPAND_TO_CELL("Resize Variant to Capacity", false),
RESIZE_VARIANT("Resize This Variant", false),
RESIZE_WORKCELL("Resize Workcell Capacity", false),
RENAME_VARIANT("Rename This Variant", false),
RENAME_WORKCELL("Rename This Workcell", false),
ADJUST_VARIANT_WEIGHT("Adjust Variant Weight", false),
ADJUST_VARIANT_CHANCE("Adjust Variant Chance", false),
SET_THEME("Set Theme", false),
SET_PIECE_RULES("Set Piece Rules", false),
TOGGLE_REQUIRE_CAPS("Toggle Required Caps", false),
UNLINK_MEMBERSHIP("Unlink Pool Entry", true),
DELETE_VARIANT("Delete Variant", true),
DELETE_PROJECT("Delete Project", true);
private final String displayName;
private final boolean destructive;
JigsawStudioToolAction(String displayName, boolean destructive) {
this.displayName = Objects.requireNonNull(displayName, "Jigsaw Studio tool display name");
this.destructive = destructive;
}
public String displayName() {
return displayName;
}
public boolean destructive() {
return destructive;
}
}
@@ -0,0 +1,111 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Objects;
import java.util.UUID;
public record JigsawStudioToolPayload(
int schemaVersion,
JigsawStudioToolAction action,
UUID requestId,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex,
int amount
) {
public static final int CURRENT_SCHEMA_VERSION = 2;
public static final int NO_ENTRY_INDEX = -1;
private static final int MAX_FIELD_LENGTH = 512;
public JigsawStudioToolPayload {
if (schemaVersion < 1) {
throw new IllegalArgumentException("Jigsaw Studio tool schema version must be positive");
}
action = Objects.requireNonNull(action, "Jigsaw Studio tool action");
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio tool request ID");
workcellId = normalize(workcellId, "workcell ID");
pieceKey = normalize(pieceKey, "piece key");
poolKey = normalize(poolKey, "pool key");
if (entryIndex < NO_ENTRY_INDEX) {
throw new IllegalArgumentException("Jigsaw Studio tool entry index cannot be lower than -1");
}
}
public static JigsawStudioToolPayload request(
JigsawStudioToolAction action,
UUID requestId
) {
return new JigsawStudioToolPayload(
CURRENT_SCHEMA_VERSION,
action,
requestId,
"",
"",
"",
NO_ENTRY_INDEX,
0);
}
public static JigsawStudioToolPayload workcell(
JigsawStudioToolAction action,
UUID requestId,
String workcellId
) {
return new JigsawStudioToolPayload(
CURRENT_SCHEMA_VERSION,
action,
requestId,
workcellId,
"",
"",
NO_ENTRY_INDEX,
0);
}
public static JigsawStudioToolPayload variant(
JigsawStudioToolAction action,
UUID requestId,
String workcellId,
String pieceKey
) {
return new JigsawStudioToolPayload(
CURRENT_SCHEMA_VERSION,
action,
requestId,
workcellId,
pieceKey,
"",
NO_ENTRY_INDEX,
0);
}
public static JigsawStudioToolPayload membership(
JigsawStudioToolAction action,
UUID requestId,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex,
int amount
) {
return new JigsawStudioToolPayload(
CURRENT_SCHEMA_VERSION,
action,
requestId,
workcellId,
pieceKey,
poolKey,
entryIndex,
amount);
}
private static String normalize(String value, String fieldName) {
String normalized = value == null ? "" : value.trim();
if (normalized.length() > MAX_FIELD_LENGTH) {
throw new IllegalArgumentException("Jigsaw Studio tool " + fieldName
+ " cannot exceed " + MAX_FIELD_LENGTH + " characters");
}
return normalized;
}
}
@@ -0,0 +1,112 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
public final class JigsawStudioTripleSneakTracker {
public static final long DEFAULT_WINDOW_NANOS = 1_500_000_000L;
private static final int REQUIRED_SNEAKS = 3;
private final long windowNanos;
private final Map<UUID, GestureState> gestures = new HashMap<>();
public JigsawStudioTripleSneakTracker() {
this(DEFAULT_WINDOW_NANOS);
}
public JigsawStudioTripleSneakTracker(long windowNanos) {
if (windowNanos <= 0L) {
throw new IllegalArgumentException("Jigsaw Studio triple-sneak window must be positive");
}
this.windowNanos = windowNanos;
}
public synchronized Progress recordSneak(
UUID playerId,
UUID worldId,
UUID requestId,
long nowNanos
) {
UUID player = Objects.requireNonNull(playerId, "Jigsaw Studio gesture player ID");
UUID world = Objects.requireNonNull(worldId, "Jigsaw Studio gesture world ID");
UUID request = Objects.requireNonNull(requestId, "Jigsaw Studio gesture request ID");
GestureState previous = gestures.get(player);
if (previous == null
|| !previous.worldId().equals(world)
|| !previous.requestId().equals(request)
|| expired(previous, nowNanos)) {
gestures.put(player, new GestureState(world, request, nowNanos, nowNanos, 1));
return Progress.FIRST;
}
int count = previous.count() + 1;
if (count >= REQUIRED_SNEAKS) {
gestures.remove(player);
return Progress.TRIGGERED;
}
gestures.put(player, new GestureState(
world,
request,
previous.startedAtNanos(),
nowNanos,
count));
return Progress.SECOND;
}
public synchronized void clearPlayer(UUID playerId) {
if (playerId != null) {
gestures.remove(playerId);
}
}
public synchronized int clearRequest(UUID requestId) {
if (requestId == null) {
return 0;
}
int removed = 0;
Iterator<GestureState> states = gestures.values().iterator();
while (states.hasNext()) {
GestureState state = states.next();
if (state.requestId().equals(requestId)) {
states.remove();
removed++;
}
}
return removed;
}
public synchronized void clearAll() {
gestures.clear();
}
public synchronized int trackedPlayers() {
return gestures.size();
}
private boolean expired(GestureState state, long nowNanos) {
long elapsedSinceStart = nowNanos - state.startedAtNanos();
long elapsedSinceLast = nowNanos - state.lastSneakAtNanos();
return elapsedSinceStart < 0L
|| elapsedSinceLast < 0L
|| elapsedSinceStart > windowNanos;
}
public enum Progress {
FIRST,
SECOND,
TRIGGERED
}
private record GestureState(
UUID worldId,
UUID requestId,
long startedAtNanos,
long lastSneakAtNanos,
int count
) {
}
}
@@ -0,0 +1,141 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.engine.object.IrisPosition;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public record JigsawStudioVariant(
String pieceKey,
String objectKey,
String displayName,
Optional<JigsawStudioCellDimensions> dimensions,
JigsawStudioMode mode,
Optional<JigsawPlanarTopology> sourceTopology,
boolean rotatable,
boolean owned,
List<String> themes,
JigsawStudioPieceRules rules,
List<JigsawStudioPoolMembership> memberships
) {
public JigsawStudioVariant {
pieceKey = requireKey(pieceKey, "piece");
objectKey = requireKey(objectKey, "object");
displayName = displayName == null ? "" : displayName.trim();
dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio variant dimensions");
mode = Objects.requireNonNull(mode, "Jigsaw Studio variant mode");
sourceTopology = Objects.requireNonNull(sourceTopology, "Jigsaw Studio variant topology");
themes = List.copyOf(Objects.requireNonNull(themes, "Jigsaw Studio variant themes"));
rules = Objects.requireNonNull(rules, "Jigsaw Studio variant rules");
memberships = List.copyOf(Objects.requireNonNull(memberships, "Jigsaw Studio pool memberships"));
if (mode == JigsawStudioMode.PLANAR_JIGSAW && sourceTopology.isEmpty()) {
throw new IllegalArgumentException("Planar Jigsaw Studio variants require a topology");
}
if (mode == JigsawStudioMode.SPATIAL_JIGSAW && sourceTopology.isPresent()) {
throw new IllegalArgumentException("Spatial Jigsaw Studio variants cannot declare a planar topology");
}
}
public String resolvedDisplayName() {
if (!displayName.isEmpty()) {
return displayName;
}
int separator = Math.max(pieceKey.lastIndexOf('/'), pieceKey.lastIndexOf(':'));
return separator < 0 ? pieceKey : pieceKey.substring(separator + 1);
}
public Optional<JigsawPlanarArchetype> archetype() {
return sourceTopology.map(JigsawPlanarArchetype::fromTopology);
}
public int sourceToCanonicalQuarterTurns() {
JigsawPlanarTopology topology = sourceTopology.orElse(null);
return topology == null ? 0 : JigsawPlanarArchetype.fromTopology(topology)
.sourceToCanonicalQuarterTurns(topology);
}
public int canonicalToSourceQuarterTurns() {
JigsawPlanarTopology topology = sourceTopology.orElse(null);
return topology == null ? 0 : JigsawPlanarArchetype.fromTopology(topology)
.canonicalToSourceQuarterTurns(topology);
}
public IrisPosition sourceToCanonicalPosition(
IrisPosition sourcePosition,
JigsawStudioCellDimensions sourceDimensions
) {
return rotatePosition(
sourcePosition,
sourceDimensions,
sourceToCanonicalQuarterTurns());
}
public IrisPosition canonicalToSourcePosition(
IrisPosition canonicalPosition,
JigsawStudioCellDimensions sourceDimensions
) {
JigsawStudioCellDimensions canonicalDimensions = canonicalDimensions(sourceDimensions);
return rotatePosition(
canonicalPosition,
canonicalDimensions,
canonicalToSourceQuarterTurns());
}
public JigsawStudioCellDimensions canonicalDimensions(JigsawStudioCellDimensions sourceDimensions) {
JigsawStudioCellDimensions dimensions = Objects.requireNonNull(
sourceDimensions,
"Jigsaw Studio source dimensions");
return Math.floorMod(sourceToCanonicalQuarterTurns(), 2) == 0
? dimensions
: new JigsawStudioCellDimensions(
dimensions.depth(),
dimensions.height(),
dimensions.width());
}
public boolean assigned() {
return !memberships.isEmpty();
}
private static IrisPosition rotatePosition(
IrisPosition position,
JigsawStudioCellDimensions dimensions,
int quarterTurns
) {
IrisPosition source = Objects.requireNonNull(position, "Jigsaw Studio variant position");
JigsawStudioCellDimensions bounds = Objects.requireNonNull(
dimensions,
"Jigsaw Studio variant position bounds");
if (source.getX() < 0 || source.getX() >= bounds.width()
|| source.getY() < 0 || source.getY() >= bounds.height()
|| source.getZ() < 0 || source.getZ() >= bounds.depth()) {
throw new IllegalArgumentException("Jigsaw Studio variant position is outside its object bounds");
}
return switch (Math.floorMod(quarterTurns, 4)) {
case 0 -> new IrisPosition(source.getX(), source.getY(), source.getZ());
case 1 -> new IrisPosition(
bounds.depth() - 1 - source.getZ(),
source.getY(),
source.getX());
case 2 -> new IrisPosition(
bounds.width() - 1 - source.getX(),
source.getY(),
bounds.depth() - 1 - source.getZ());
case 3 -> new IrisPosition(
source.getZ(),
source.getY(),
bounds.width() - 1 - source.getX());
default -> throw new IllegalStateException("Unreachable Jigsaw Studio rotation");
};
}
private static String requireKey(String value, String name) {
Objects.requireNonNull(value, "Jigsaw Studio " + name + " key");
String normalized = value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio " + name + " key cannot be blank");
}
return normalized;
}
}
@@ -0,0 +1,95 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public final class JigsawStudioVariantCatalog {
private final List<JigsawStudioVariant> variants;
private final Map<String, JigsawStudioVariant> byPieceKey;
private final Map<JigsawPlanarArchetype, List<JigsawStudioVariant>> byArchetype;
private final List<JigsawStudioVariant> spatialVariants;
private final boolean editableGraph;
public JigsawStudioVariantCatalog(List<JigsawStudioVariant> variants) {
this(variants, true);
}
public JigsawStudioVariantCatalog(
List<JigsawStudioVariant> variants,
boolean editableGraph
) {
Objects.requireNonNull(variants, "Jigsaw Studio variants");
if (variants.size() > JigsawStudioLayout.MAX_VARIANTS) {
throw new IllegalArgumentException("Jigsaw Studio catalogs cannot exceed "
+ JigsawStudioLayout.MAX_VARIANTS + " variants");
}
List<JigsawStudioVariant> copied = List.copyOf(variants);
Map<String, JigsawStudioVariant> pieceIndex = new LinkedHashMap<>();
Map<JigsawPlanarArchetype, List<JigsawStudioVariant>> archetypeIndex =
new EnumMap<>(JigsawPlanarArchetype.class);
List<JigsawStudioVariant> spatial = new ArrayList<>();
for (JigsawStudioVariant variant : copied) {
JigsawStudioVariant activeVariant = Objects.requireNonNull(variant, "Jigsaw Studio variant");
JigsawStudioVariant previous = pieceIndex.putIfAbsent(activeVariant.pieceKey(), activeVariant);
if (previous != null) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio variant piece key "
+ activeVariant.pieceKey());
}
Optional<JigsawPlanarArchetype> archetype = activeVariant.archetype();
if (archetype.isPresent()) {
archetypeIndex.computeIfAbsent(archetype.get(), key -> new ArrayList<>()).add(activeVariant);
} else {
spatial.add(activeVariant);
}
}
Map<JigsawPlanarArchetype, List<JigsawStudioVariant>> immutableArchetypes =
new EnumMap<>(JigsawPlanarArchetype.class);
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
immutableArchetypes.put(
archetype,
List.copyOf(archetypeIndex.getOrDefault(archetype, List.of())));
}
this.variants = copied;
this.byPieceKey = Collections.unmodifiableMap(pieceIndex);
this.byArchetype = Collections.unmodifiableMap(immutableArchetypes);
this.spatialVariants = List.copyOf(spatial);
this.editableGraph = editableGraph;
}
public static JigsawStudioVariantCatalog empty() {
return new JigsawStudioVariantCatalog(List.of());
}
public List<JigsawStudioVariant> variants() {
return variants;
}
public Optional<JigsawStudioVariant> find(String pieceKey) {
if (pieceKey == null) {
return Optional.empty();
}
return Optional.ofNullable(byPieceKey.get(pieceKey));
}
public List<JigsawStudioVariant> variants(JigsawPlanarArchetype archetype) {
return byArchetype.get(Objects.requireNonNull(archetype, "Planar archetype"));
}
public List<JigsawStudioVariant> spatialVariants() {
return spatialVariants;
}
public boolean editableGraph() {
return editableGraph;
}
public int size() {
return variants.size();
}
}
@@ -0,0 +1,20 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Objects;
public record JigsawStudioWorkcellSpec(
JigsawPlanarArchetype archetype,
String displayName,
JigsawStudioCellDimensions dimensions,
boolean enabled
) {
public JigsawStudioWorkcellSpec {
archetype = Objects.requireNonNull(archetype, "Jigsaw Studio workcell archetype");
displayName = displayName == null ? "" : displayName.trim();
dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio workcell dimensions");
}
public String resolvedDisplayName() {
return displayName.isEmpty() ? archetype.displayName() : displayName;
}
}
@@ -36,13 +36,13 @@ import art.arcane.iris.util.common.plugin.IrisService;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.matter.MatterCavern;
import art.arcane.volmlib.util.localization.MessageArgument;
import lombok.Data;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
@@ -57,9 +57,15 @@ import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
public class BoardSVC implements IrisService, BoardProvider {
private static final String SEPARATOR = "&7&m-------------------";
private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]");
private final Map<Player, PlayerBoard> boards = new ConcurrentHashMap<>();
private final Map<UUID, JigsawStudioBoardContext> jigsawContexts = new ConcurrentHashMap<>();
private final Map<UUID, UUID> yieldedWorlds = new ConcurrentHashMap<>();
private final Set<UUID> hiddenPlayers = ConcurrentHashMap.newKeySet();
private volatile BoardSettings settings;
private volatile boolean boardEnabled;
@@ -111,28 +117,32 @@ public class BoardSVC implements IrisService, BoardProvider {
board.cancel();
}
boards.clear();
jigsawContexts.clear();
yieldedWorlds.clear();
hiddenPlayers.clear();
settings = null;
}
@EventHandler
@EventHandler(priority = EventPriority.HIGHEST)
public void on(PlayerChangedWorldEvent e) {
J.runEntity(e.getPlayer(), () -> updatePlayer(e.getPlayer()));
}
@EventHandler
@EventHandler(priority = EventPriority.HIGHEST)
public void on(PlayerJoinEvent e) {
J.runEntity(e.getPlayer(), () -> updatePlayer(e.getPlayer()));
}
@EventHandler
@EventHandler(priority = EventPriority.HIGHEST)
public void on(PlayerQuitEvent e) {
remove(e.getPlayer());
jigsawContexts.remove(e.getPlayer().getUniqueId());
yieldedWorlds.remove(e.getPlayer().getUniqueId());
clearPlayerPreference(e.getPlayer().getUniqueId());
}
public void updatePlayer(Player p) {
if (!boardEnabled || settings == null) {
if (p == null || !boardEnabled || settings == null) {
return;
}
@@ -141,12 +151,72 @@ public class BoardSVC implements IrisService, BoardProvider {
return;
}
if (isEligibleWorld(p)) {
boards.computeIfAbsent(p, PlayerBoard::new);
UUID playerId = p.getUniqueId();
UUID worldId = p.getWorld().getUID();
UUID yieldedWorld = yieldedWorlds.get(playerId);
if (yieldedWorld != null) {
if (yieldedWorld.equals(worldId)) {
remove(p);
return;
}
yieldedWorlds.remove(playerId, yieldedWorld);
}
if (!isEligibleWorld(p)) {
jigsawContexts.remove(playerId);
remove(p);
return;
}
remove(p);
PlayerBoard playerBoard = boards.computeIfAbsent(p, PlayerBoard::new);
JigsawStudioBoardContext jigsawContext = currentJigsawContext(p);
if (jigsawContext != null) {
playerBoard.showJigsaw(jigsawContext);
} else {
playerBoard.showOrdinary();
}
}
public void applyJigsawContext(Player player, JigsawStudioBoardContext context) {
Objects.requireNonNull(player, "Jigsaw Studio board player");
Objects.requireNonNull(context, "Jigsaw Studio board context");
if (!J.isOwnedByCurrentRegion(player)) {
J.runEntity(player, () -> applyJigsawContext(player, context));
return;
}
if (!player.isOnline()) {
return;
}
jigsawContexts.put(player.getUniqueId(), context);
if (context.worldId().equals(player.getWorld().getUID())) {
updatePlayer(player);
}
}
public void clearJigsawContext(Player player) {
Objects.requireNonNull(player, "Jigsaw Studio board player");
if (!J.isOwnedByCurrentRegion(player)) {
J.runEntity(player, () -> clearJigsawContext(player));
return;
}
jigsawContexts.remove(player.getUniqueId());
updatePlayer(player);
}
public void refreshOrdinaryContext(Player player) {
Objects.requireNonNull(player, "Studio board player");
if (!J.isOwnedByCurrentRegion(player)) {
J.runEntity(player, () -> refreshOrdinaryContext(player));
return;
}
PlayerBoard previousBoard = boards.get(player);
boolean alreadyOrdinary = previousBoard != null && previousBoard.isOrdinary();
jigsawContexts.remove(player.getUniqueId());
updatePlayer(player);
PlayerBoard playerBoard = boards.get(player);
if (alreadyOrdinary && playerBoard == previousBoard) {
playerBoard.refreshOrdinary();
}
}
private void remove(Player player) {
@@ -168,6 +238,9 @@ public class BoardSVC implements IrisService, BoardProvider {
public boolean toggle(Player player) {
Objects.requireNonNull(player, "player");
boolean visible = togglePlayerBoard(player.getUniqueId());
if (visible) {
yieldedWorlds.remove(player.getUniqueId());
}
updatePlayer(player);
return visible;
}
@@ -202,6 +275,53 @@ public class BoardSVC implements IrisService, BoardProvider {
&& generator.getEngine() != null;
}
static List<String> jigsawLines(JigsawStudioBoardContext context) {
Objects.requireNonNull(context, "Jigsaw Studio board context");
List<String> lines = new ArrayList<>(11);
lines.add(SEPARATOR);
lines.add("&dJigsaw Studio");
lines.add("&bStructure&7: " + untrustedBoardValue(context.structureKey()));
if (!context.insideWorkcell()) {
lines.add("&bMode&7: " + context.modeDisplayName());
lines.add(SEPARATOR);
lines.add("&eWalk into a workcell");
if (!context.controlHint().isEmpty()) {
lines.add("&7" + untrustedBoardValue(context.controlHint()));
}
lines.add(SEPARATOR);
return List.copyOf(lines);
}
lines.add("&bWorkcell&7: " + untrustedBoardValue(context.workcellName()));
if (!context.workcellRole().isEmpty() && !context.workcellRole().equals(context.workcellName())) {
lines.add("&bRole&7: " + untrustedBoardValue(context.workcellRole()));
}
lines.add("&bVariant&7: " + untrustedBoardValue(
context.variantName().isEmpty() ? "None" : context.variantName()));
lines.add("&bState&7: " + context.state().displayName());
lines.add(SEPARATOR);
if (!context.controlHint().isEmpty()) {
lines.add("&e" + untrustedBoardValue(context.controlHint()));
}
lines.add(SEPARATOR);
return List.copyOf(lines);
}
static boolean shouldRenderJigsaw(
JigsawStudioBoardContext previous,
JigsawStudioBoardContext next
) {
return !Objects.equals(previous, next);
}
static String untrustedBoardValue(String value) {
String normalized = value == null ? "" : value.replace('\n', ' ').replace('\r', ' ');
return LEGACY_COLOR.matcher(normalized).replaceAll("")
.replace("&", "")
.replace("<", "")
.replace(">", "");
}
boolean isPlayerBoardEnabled(UUID playerId) {
return playerId != null && !hiddenPlayers.contains(playerId);
}
@@ -222,62 +342,120 @@ public class BoardSVC implements IrisService, BoardProvider {
}
}
static Scoreboard selectScoreboardToRestore(Scoreboard active, Scoreboard iris, Scoreboard previous) {
return Objects.equals(active, iris) ? previous : active;
private JigsawStudioBoardContext currentJigsawContext(Player player) {
JigsawStudioBoardContext context = jigsawContexts.get(player.getUniqueId());
if (context == null || !context.worldId().equals(player.getWorld().getUID())) {
return null;
}
return context;
}
@Data
public class PlayerBoard {
private final Player player;
private final Board board;
private final Scoreboard previousScoreboard;
private final Scoreboard irisScoreboard;
private volatile List<String> lines;
private volatile JigsawStudioBoardContext jigsawContext;
private volatile BoardView view;
private volatile boolean cancelled;
private volatile boolean ordinaryTickScheduled;
public PlayerBoard(Player player) {
this.player = player;
Scoreboard previous = null;
Scoreboard assigned = null;
try {
previous = player.getScoreboard();
if (Bukkit.getScoreboardManager() != null
&& Objects.equals(previous, Bukkit.getScoreboardManager().getMainScoreboard())) {
player.setScoreboard(Bukkit.getScoreboardManager().getNewScoreboard());
}
assigned = player.getScoreboard();
} catch (Throwable e) {
IrisLogging.reportError("Failed to prepare the Studio scoreboard for " + player.getName() + ".", e);
}
this.previousScoreboard = previous;
this.irisScoreboard = assigned;
this.board = new Board(player, settings);
this.lines = new ArrayList<>();
this.lines = List.of();
this.jigsawContext = null;
this.view = BoardView.NONE;
this.cancelled = false;
schedule(0);
this.ordinaryTickScheduled = false;
}
private void schedule(int delayTicks) {
if (cancelled || !boardEnabled || !player.isOnline()) {
private void showOrdinary() {
if (cancelled) {
return;
}
J.runEntity(player, this::tick, delayTicks);
if (!board.ownsScoreboardAssignment()) {
yieldBoard(player, this);
return;
}
boolean switched = view != BoardView.ORDINARY;
view = BoardView.ORDINARY;
jigsawContext = null;
if (switched) {
updateOrdinary();
board.update();
}
scheduleOrdinaryTick();
}
private void tick() {
if (cancelled || !boardEnabled || !player.isOnline()) {
private void refreshOrdinary() {
if (cancelled || view != BoardView.ORDINARY || !board.ownsScoreboardAssignment()) {
return;
}
updateOrdinary();
board.update();
}
private boolean isOrdinary() {
return !cancelled && view == BoardView.ORDINARY;
}
private void showJigsaw(JigsawStudioBoardContext context) {
if (cancelled) {
return;
}
if (!board.ownsScoreboardAssignment()) {
yieldBoard(player, this);
return;
}
if (view == BoardView.JIGSAW && !shouldRenderJigsaw(jigsawContext, context)) {
return;
}
view = BoardView.JIGSAW;
jigsawContext = context;
lines = jigsawLines(context);
board.update();
}
private void scheduleOrdinaryTick() {
if (ordinaryTickScheduled || cancelled || view != BoardView.ORDINARY
|| !boardEnabled || !player.isOnline()) {
return;
}
ordinaryTickScheduled = true;
boolean scheduled = J.runEntity(
player,
() -> {
ordinaryTickScheduled = false;
ordinaryTick();
},
20,
() -> ordinaryTickScheduled = false);
if (!scheduled) {
ordinaryTickScheduled = false;
}
}
private void ordinaryTick() {
if (cancelled || view != BoardView.ORDINARY || !boardEnabled || !player.isOnline()) {
return;
}
if (!isEligibleWorld(player)) {
boards.remove(player, this);
cancel();
return;
}
update();
JigsawStudioBoardContext context = currentJigsawContext(player);
if (context != null) {
showJigsaw(context);
return;
}
if (!board.ownsScoreboardAssignment()) {
yieldBoard(player, this);
return;
}
updateOrdinary();
board.update();
schedule(20);
scheduleOrdinaryTick();
}
public void cancel() {
@@ -293,30 +471,14 @@ public class BoardSVC implements IrisService, BoardProvider {
}
private void removeNow() {
Scoreboard activeScoreboard = null;
try {
activeScoreboard = player.getScoreboard();
board.remove();
if (!player.isOnline()) {
return;
}
Scoreboard restore = selectScoreboardToRestore(
activeScoreboard,
irisScoreboard,
previousScoreboard);
if (restore != null && !Objects.equals(player.getScoreboard(), restore)) {
player.setScoreboard(restore);
}
} catch (Throwable e) {
IrisLogging.reportError("Failed to remove the Studio scoreboard for " + player.getName() + ".", e);
if (activeScoreboard != null && player.isOnline()) {
player.setScoreboard(activeScoreboard);
}
}
}
public void update() {
private void updateOrdinary() {
World world = player.getWorld();
Location loc = player.getLocation();
@@ -362,4 +524,16 @@ public class BoardSVC implements IrisService, BoardProvider {
this.lines = lines;
}
}
private void yieldBoard(Player player, PlayerBoard playerBoard) {
yieldedWorlds.put(player.getUniqueId(), player.getWorld().getUID());
boards.remove(player, playerBoard);
playerBoard.cancel();
}
private enum BoardView {
NONE,
ORDINARY,
JIGSAW
}
}
@@ -0,0 +1,56 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import java.util.Objects;
import java.util.UUID;
public record JigsawStudioBoardContext(
UUID worldId,
UUID requestId,
String structureKey,
JigsawStudioMode mode,
String workcellRole,
String workcellName,
String variantName,
JigsawStudioBoardState state,
String controlHint
) {
public JigsawStudioBoardContext {
worldId = Objects.requireNonNull(worldId, "Jigsaw Studio board world ID");
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio board request ID");
structureKey = requireText(structureKey, "structure key");
mode = Objects.requireNonNull(mode, "Jigsaw Studio board mode");
workcellRole = optionalText(workcellRole);
workcellName = optionalText(workcellName);
variantName = optionalText(variantName);
state = Objects.requireNonNull(state, "Jigsaw Studio board state");
controlHint = optionalText(controlHint);
if (workcellName.isEmpty() && (!workcellRole.isEmpty() || !variantName.isEmpty())) {
throw new IllegalArgumentException("Jigsaw Studio board variants require a workcell");
}
}
public boolean insideWorkcell() {
return !workcellName.isEmpty();
}
public String modeDisplayName() {
return switch (mode) {
case PLANAR_JIGSAW -> "Planar";
case SPATIAL_JIGSAW -> "Spatial";
};
}
private static String requireText(String value, String name) {
String normalized = optionalText(value);
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio board " + name + " cannot be blank");
}
return normalized;
}
private static String optionalText(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,21 @@
package art.arcane.iris.core.service;
public enum JigsawStudioBoardState {
LOADING("Loading"),
SAVED("Saved"),
UNSAVED("Unsaved"),
SAVING("Saving"),
DISABLED("Disabled"),
INVALID("Invalid"),
READ_ONLY("Read-only");
private final String displayName;
JigsawStudioBoardState(String displayName) {
this.displayName = displayName;
}
public String displayName() {
return displayName;
}
}
@@ -0,0 +1,272 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.BlockDisplay;
import org.bukkit.entity.Display;
import org.bukkit.util.Transformation;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
public final class JigsawStudioDisabledWorkcellRenderer {
private static final String ENTITY_TAG = "iris_jigsaw_disabled_workcell";
private final Map<UUID, RequestDisplays> requests = new HashMap<>();
public void reconcile(World world, UUID requestId, JigsawStudioLayout layout) {
World activeWorld = Objects.requireNonNull(world, "Jigsaw Studio display world");
UUID activeRequestId = Objects.requireNonNull(requestId, "Jigsaw Studio display request ID");
Map<String, Descriptor> desired = descriptors(Objects.requireNonNull(
layout,
"Jigsaw Studio display layout"));
List<BlockDisplay> removals = new ArrayList<>();
long generation;
synchronized (this) {
RequestDisplays state = requests.computeIfAbsent(
activeRequestId,
ignored -> new RequestDisplays(activeWorld.getUID()));
if (!state.worldId.equals(activeWorld.getUID())) {
removals.addAll(state.entities.values());
state = new RequestDisplays(activeWorld.getUID());
requests.put(activeRequestId, state);
}
generation = Math.incrementExact(state.generation);
state.generation = generation;
state.desired.clear();
state.desired.putAll(desired);
for (Map.Entry<String, BlockDisplay> entry : new ArrayList<>(state.entities.entrySet())) {
Descriptor descriptor = desired.get(entry.getKey());
if (descriptor == null || !descriptor.equals(state.rendered.get(entry.getKey()))) {
state.entities.remove(entry.getKey());
state.rendered.remove(entry.getKey());
removals.add(entry.getValue());
}
}
}
remove(removals);
for (Descriptor descriptor : desired.values()) {
scheduleSpawn(activeWorld, activeRequestId, generation, descriptor);
}
}
public void unloadChunk(UUID requestId, int chunkX, int chunkZ) {
if (requestId == null) {
return;
}
List<BlockDisplay> removals;
synchronized (this) {
RequestDisplays state = requests.get(requestId);
if (state == null) {
return;
}
removals = detachChunkDisplays(state.entities, state.rendered, chunkX, chunkZ);
}
remove(removals);
}
public void removeRequest(UUID requestId) {
if (requestId == null) {
return;
}
RequestDisplays removed;
synchronized (this) {
removed = requests.remove(requestId);
}
if (removed != null) {
remove(new ArrayList<>(removed.entities.values()));
}
}
public void removeAll() {
List<BlockDisplay> removals = new ArrayList<>();
synchronized (this) {
for (RequestDisplays state : requests.values()) {
removals.addAll(state.entities.values());
}
requests.clear();
}
remove(removals);
}
static Map<String, Descriptor> descriptors(JigsawStudioLayout layout) {
Map<String, Descriptor> descriptors = new LinkedHashMap<>();
for (JigsawStudioBay bay : layout.bays()) {
if (bay.enabled()) {
continue;
}
JigsawStudioBounds bounds = bay.bounds();
descriptors.put(bay.stableId(), new Descriptor(
bay.stableId(),
bounds.originX(),
bounds.originY(),
bounds.originZ(),
bounds.dimensions().width(),
bounds.dimensions().height(),
bounds.dimensions().depth()));
}
return Map.copyOf(descriptors);
}
synchronized int activeDisplayCount(UUID requestId) {
RequestDisplays state = requests.get(requestId);
return state == null ? 0 : state.entities.size();
}
static List<BlockDisplay> detachChunkDisplays(
Map<String, BlockDisplay> entities,
Map<String, Descriptor> rendered,
int chunkX,
int chunkZ
) {
List<BlockDisplay> removals = new ArrayList<>();
for (Map.Entry<String, BlockDisplay> entry : new ArrayList<>(entities.entrySet())) {
Descriptor descriptor = rendered.get(entry.getKey());
if (descriptor == null
|| descriptor.originX() >> 4 != chunkX
|| descriptor.originZ() >> 4 != chunkZ) {
continue;
}
entities.remove(entry.getKey());
rendered.remove(entry.getKey());
removals.add(entry.getValue());
}
return List.copyOf(removals);
}
private void scheduleSpawn(
World world,
UUID requestId,
long generation,
Descriptor descriptor
) {
synchronized (this) {
RequestDisplays state = requests.get(requestId);
if (state == null
|| state.generation != generation
|| state.entities.containsKey(descriptor.workcellId())
|| !descriptor.equals(state.desired.get(descriptor.workcellId()))) {
return;
}
}
J.runRegion(
world,
descriptor.originX() >> 4,
descriptor.originZ() >> 4,
() -> spawn(world, requestId, generation, descriptor));
}
private void spawn(
World world,
UUID requestId,
long generation,
Descriptor descriptor
) {
if (!world.isChunkLoaded(descriptor.originX() >> 4, descriptor.originZ() >> 4)) {
return;
}
synchronized (this) {
RequestDisplays state = requests.get(requestId);
if (state == null
|| state.generation != generation
|| state.entities.containsKey(descriptor.workcellId())
|| !descriptor.equals(state.desired.get(descriptor.workcellId()))) {
return;
}
}
BlockDisplay display = world.spawn(
new Location(world, descriptor.originX(), descriptor.originY(), descriptor.originZ()),
BlockDisplay.class,
entity -> configure(entity, descriptor));
boolean retained;
synchronized (this) {
RequestDisplays state = requests.get(requestId);
retained = state != null
&& state.generation == generation
&& !state.entities.containsKey(descriptor.workcellId())
&& descriptor.equals(state.desired.get(descriptor.workcellId()));
if (retained) {
state.entities.put(descriptor.workcellId(), display);
state.rendered.put(descriptor.workcellId(), descriptor);
}
}
if (!retained) {
remove(display);
}
}
private static void configure(BlockDisplay display, Descriptor descriptor) {
display.setBlock(Material.RED_STAINED_GLASS.createBlockData());
display.setTransformation(new Transformation(
new Vector3f(),
new Quaternionf(),
new Vector3f(descriptor.width(), descriptor.height(), descriptor.depth()),
new Quaternionf()));
display.setBrightness(new Display.Brightness(15, 15));
display.setDisplayWidth(Math.max(descriptor.width(), descriptor.depth()));
display.setDisplayHeight(descriptor.height());
display.setViewRange(128.0F);
display.setShadowRadius(0.0F);
display.setShadowStrength(0.0F);
display.setInterpolationDuration(0);
display.setTeleportDuration(0);
display.setPersistent(false);
display.setInvulnerable(true);
display.setGravity(false);
display.setSilent(true);
display.addScoreboardTag(ENTITY_TAG);
}
private static void remove(List<BlockDisplay> displays) {
for (BlockDisplay display : displays) {
remove(display);
}
}
private static void remove(BlockDisplay display) {
if (display != null) {
J.runEntity(display, display::remove);
}
}
record Descriptor(
String workcellId,
int originX,
int originY,
int originZ,
int width,
int height,
int depth
) {
Descriptor {
workcellId = Objects.requireNonNull(workcellId, "Jigsaw Studio display workcell ID");
if (width < 1 || height < 1 || depth < 1) {
throw new IllegalArgumentException("Jigsaw Studio display dimensions must be positive");
}
}
}
private static final class RequestDisplays {
private final UUID worldId;
private final Map<String, Descriptor> desired = new HashMap<>();
private final Map<String, Descriptor> rendered = new HashMap<>();
private final Map<String, BlockDisplay> entities = new HashMap<>();
private long generation;
private RequestDisplays(UUID worldId) {
this.worldId = worldId;
}
}
}
@@ -0,0 +1,9 @@
package art.arcane.iris.core.service;
public enum JigsawStudioEvaluationState {
PENDING,
VALID,
WARNING,
INVALID,
STALE
}
@@ -0,0 +1,50 @@
package art.arcane.iris.core.service;
import java.util.Objects;
import java.util.UUID;
public record JigsawStudioGraphEvaluation(
UUID requestId,
long generation,
long seed,
JigsawStudioEvaluationState state,
String selectedTheme,
int pieceCount,
String detail,
JigsawStudioPreviewRenderer.PreviewBounds previewBounds
) {
public JigsawStudioGraphEvaluation {
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio evaluation request ID");
if (generation < 1L) {
throw new IllegalArgumentException("Jigsaw Studio evaluation generation must be positive");
}
state = Objects.requireNonNull(state, "Jigsaw Studio evaluation state");
selectedTheme = normalize(selectedTheme);
if (pieceCount < 0) {
throw new IllegalArgumentException("Jigsaw Studio evaluation piece count cannot be negative");
}
detail = normalize(detail);
previewBounds = Objects.requireNonNull(previewBounds, "Jigsaw Studio evaluation preview bounds");
}
public JigsawStudioGraphEvaluation stale(String reason) {
return new JigsawStudioGraphEvaluation(
requestId,
generation,
seed,
JigsawStudioEvaluationState.STALE,
selectedTheme,
pieceCount,
reason,
previewBounds);
}
public boolean successful() {
return state == JigsawStudioEvaluationState.VALID
|| state == JigsawStudioEvaluationState.WARNING;
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,105 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMarkerKeyCodec;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.JigsawJoint;
import org.bukkit.block.Orientation;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
final class JigsawStudioMarkerParser {
private JigsawStudioMarkerParser() {
}
static IrisJigsawConnector parse(
Map<String, Object> nbt,
Orientation orientation,
int x,
int y,
int z
) {
Map<String, Object> properties = Objects.requireNonNull(nbt, "Jigsaw marker NBT");
Directions directions = directions(orientation);
String jointName = requiredString(properties, "joint").toUpperCase(Locale.ROOT);
JigsawJoint joint;
try {
joint = JigsawJoint.valueOf(jointName);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("Unsupported jigsaw joint '" + jointName + "'", exception);
}
return new IrisJigsawConnector()
.setPosition(new IrisPosition(x, y, z))
.setDirection(directions.front())
.setTop(directions.top())
.setPool(JigsawStudioMarkerKeyCodec.decodePool(requiredString(properties, "pool")))
.setName(requiredString(properties, "name"))
.setTargetName(requiredString(properties, "target"))
.setChannel(optionalString(properties, "channel"))
.setJoint(joint)
.setFinalState(requiredString(properties, "final_state"))
.setSelectionPriority(optionalInt(properties, "selection_priority"))
.setPlacementPriority(optionalInt(properties, "placement_priority"));
}
static Directions directions(Orientation orientation) {
return switch (Objects.requireNonNull(orientation, "Jigsaw orientation")) {
case DOWN_EAST -> new Directions(IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.EAST_POSITIVE_X);
case DOWN_NORTH -> new Directions(IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.NORTH_NEGATIVE_Z);
case DOWN_SOUTH -> new Directions(IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.SOUTH_POSITIVE_Z);
case DOWN_WEST -> new Directions(IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.WEST_NEGATIVE_X);
case UP_EAST -> new Directions(IrisDirection.UP_POSITIVE_Y, IrisDirection.EAST_POSITIVE_X);
case UP_NORTH -> new Directions(IrisDirection.UP_POSITIVE_Y, IrisDirection.NORTH_NEGATIVE_Z);
case UP_SOUTH -> new Directions(IrisDirection.UP_POSITIVE_Y, IrisDirection.SOUTH_POSITIVE_Z);
case UP_WEST -> new Directions(IrisDirection.UP_POSITIVE_Y, IrisDirection.WEST_NEGATIVE_X);
case WEST_UP -> new Directions(IrisDirection.WEST_NEGATIVE_X, IrisDirection.UP_POSITIVE_Y);
case EAST_UP -> new Directions(IrisDirection.EAST_POSITIVE_X, IrisDirection.UP_POSITIVE_Y);
case NORTH_UP -> new Directions(IrisDirection.NORTH_NEGATIVE_Z, IrisDirection.UP_POSITIVE_Y);
case SOUTH_UP -> new Directions(IrisDirection.SOUTH_POSITIVE_Z, IrisDirection.UP_POSITIVE_Y);
};
}
private static String requiredString(Map<String, Object> properties, String key) {
Object value = properties.get(key);
if (!(value instanceof String stringValue) || stringValue.isBlank()) {
throw new IllegalArgumentException("Jigsaw marker requires non-empty string NBT '" + key + "'");
}
return stringValue.trim();
}
private static String optionalString(Map<String, Object> properties, String key) {
Object value = properties.get(key);
if (value == null) {
return "";
}
if (!(value instanceof String stringValue)) {
throw new IllegalArgumentException("Jigsaw marker NBT '" + key + "' must be a string");
}
return stringValue.trim();
}
private static int optionalInt(Map<String, Object> properties, String key) {
Object value = properties.get(key);
if (value == null) {
return 0;
}
if (!(value instanceof Number number)) {
throw new IllegalArgumentException("Jigsaw marker NBT '" + key + "' must be an integer");
}
long longValue = number.longValue();
if (longValue < Integer.MIN_VALUE || longValue > Integer.MAX_VALUE) {
throw new IllegalArgumentException("Jigsaw marker NBT '" + key + "' is outside the integer range");
}
return (int) longValue;
}
record Directions(IrisDirection front, IrisDirection top) {
Directions {
Objects.requireNonNull(front, "Jigsaw front direction");
Objects.requireNonNull(top, "Jigsaw top direction");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,289 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public record JigsawStudioMenuState(
UUID worldId,
UUID requestId,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
boolean requireCaps,
List<ThemeSet> themeSets,
String selectedWorkcellId,
Evaluation evaluation,
List<Workcell> workcells
) {
public JigsawStudioMenuState {
worldId = Objects.requireNonNull(worldId, "Jigsaw Studio menu world ID");
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio menu request ID");
structureKey = requireText(structureKey, "structure key");
mode = Objects.requireNonNull(mode, "Jigsaw Studio menu mode");
compatibilityTarget = Objects.requireNonNull(
compatibilityTarget,
"Jigsaw Studio menu compatibility target");
themeSets = List.copyOf(Objects.requireNonNull(themeSets, "Jigsaw Studio menu theme sets"));
selectedWorkcellId = optionalText(selectedWorkcellId);
evaluation = Objects.requireNonNull(evaluation, "Jigsaw Studio menu evaluation");
workcells = List.copyOf(Objects.requireNonNull(workcells, "Jigsaw Studio menu workcells"));
Set<String> workcellIds = new HashSet<>();
for (Workcell workcell : workcells) {
Workcell resolved = Objects.requireNonNull(workcell, "Jigsaw Studio menu workcell");
if (!workcellIds.add(resolved.stableId())) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio menu workcell " + resolved.stableId());
}
}
if (!selectedWorkcellId.isEmpty() && !workcellIds.contains(selectedWorkcellId)) {
throw new IllegalArgumentException("Selected Jigsaw Studio menu workcell is not present");
}
Set<String> themeKeys = new HashSet<>();
for (ThemeSet themeSet : themeSets) {
ThemeSet resolved = Objects.requireNonNull(themeSet, "Jigsaw Studio menu theme set");
if (!themeKeys.add(resolved.key())) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio menu theme " + resolved.key());
}
}
}
public Workcell selectedWorkcell() {
if (selectedWorkcellId.isEmpty()) {
return null;
}
return workcell(selectedWorkcellId);
}
public boolean irisExtended() {
return compatibilityTarget == JigsawStudioCompatibilityTarget.IRIS_EXTENDED;
}
public Workcell workcell(String stableId) {
if (stableId == null) {
return null;
}
for (Workcell workcell : workcells) {
if (workcell.stableId().equals(stableId)) {
return workcell;
}
}
return null;
}
public ThemeSet themeSet(String key) {
if (key == null) {
return null;
}
for (ThemeSet themeSet : themeSets) {
if (themeSet.key().equals(key)) {
return themeSet;
}
}
return null;
}
public record ThemeSet(String key, int weight) {
public ThemeSet {
key = requireText(key, "theme key");
if (weight < 1) {
throw new IllegalArgumentException("Jigsaw Studio menu theme weight must be positive");
}
}
}
public record Evaluation(
JigsawStudioEvaluationState state,
long generation,
long seed,
String selectedTheme,
int pieceCount,
String detail
) {
public Evaluation {
state = Objects.requireNonNull(state, "Jigsaw Studio evaluation state");
if (generation < 0L) {
throw new IllegalArgumentException("Jigsaw Studio evaluation generation cannot be negative");
}
selectedTheme = optionalText(selectedTheme);
if (pieceCount < 0) {
throw new IllegalArgumentException("Jigsaw Studio evaluation piece count cannot be negative");
}
detail = optionalText(detail);
}
public static Evaluation pending() {
return new Evaluation(
JigsawStudioEvaluationState.PENDING,
0L,
1337L,
"",
0,
"Iris evaluates the graph automatically as authoring state changes.");
}
public static Evaluation from(JigsawStudioGraphEvaluation evaluation) {
JigsawStudioGraphEvaluation source = Objects.requireNonNull(
evaluation,
"Jigsaw Studio graph evaluation");
return new Evaluation(
source.state(),
source.generation(),
source.seed(),
source.selectedTheme(),
source.pieceCount(),
source.detail());
}
}
public record Workcell(
String stableId,
String canonicalName,
String displayName,
JigsawStudioCellDimensions capacity,
boolean enabled,
String activeVariantKey,
boolean dirty,
boolean saving,
boolean loading,
List<Variant> variants
) {
public Workcell {
stableId = requireText(stableId, "workcell ID");
canonicalName = requireText(canonicalName, "workcell canonical name");
displayName = requireText(displayName, "workcell display name");
capacity = Objects.requireNonNull(capacity, "Jigsaw Studio menu workcell capacity");
activeVariantKey = optionalText(activeVariantKey);
variants = List.copyOf(Objects.requireNonNull(variants, "Jigsaw Studio menu variants"));
Set<String> variantKeys = new HashSet<>();
int activeVariants = 0;
for (Variant variant : variants) {
Variant resolved = Objects.requireNonNull(variant, "Jigsaw Studio menu variant");
if (!variantKeys.add(resolved.pieceKey())) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio menu variant " + resolved.pieceKey());
}
if (resolved.active()) {
activeVariants++;
if (!resolved.pieceKey().equals(activeVariantKey)) {
throw new IllegalArgumentException("Active Jigsaw Studio menu variant key does not match");
}
}
}
if ((activeVariantKey.isEmpty() && activeVariants != 0)
|| (!activeVariantKey.isEmpty() && activeVariants != 1)) {
throw new IllegalArgumentException("Jigsaw Studio menu workcell active variant is inconsistent");
}
}
public Variant activeVariant() {
if (activeVariantKey.isEmpty()) {
return null;
}
for (Variant variant : variants) {
if (variant.pieceKey().equals(activeVariantKey)) {
return variant;
}
}
return null;
}
public boolean busy() {
return saving || loading;
}
}
public record Variant(
String pieceKey,
String displayName,
Optional<JigsawStudioCellDimensions> dimensions,
boolean active,
boolean owned,
boolean rotatable,
boolean rotationEditable,
boolean resizableToCapacity,
List<String> themes,
JigsawStudioPieceRules rules,
List<Membership> memberships
) {
public Variant {
pieceKey = requireText(pieceKey, "variant piece key");
displayName = requireText(displayName, "variant display name");
dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio menu variant dimensions");
List<String> resolvedThemes = new ArrayList<>();
Set<String> uniqueThemes = new HashSet<>();
for (String theme : Objects.requireNonNull(themes, "Jigsaw Studio menu variant themes")) {
String resolvedTheme = requireText(theme, "variant theme");
if (!uniqueThemes.add(resolvedTheme)) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio menu variant theme "
+ resolvedTheme);
}
resolvedThemes.add(resolvedTheme);
}
themes = List.copyOf(resolvedThemes);
rules = Objects.requireNonNull(rules, "Jigsaw Studio menu variant rules");
memberships = List.copyOf(Objects.requireNonNull(
memberships,
"Jigsaw Studio menu variant memberships"));
if (rotationEditable && !owned) {
throw new IllegalArgumentException("Read-only Jigsaw Studio variants cannot edit rotation");
}
if (resizableToCapacity && (!owned || !active)) {
throw new IllegalArgumentException(
"Only the active owned Jigsaw Studio variant can resize to its workcell capacity");
}
Set<MembershipIdentity> identities = new HashSet<>();
for (Membership membership : memberships) {
Membership resolved = Objects.requireNonNull(
membership,
"Jigsaw Studio menu variant membership");
MembershipIdentity identity = new MembershipIdentity(resolved.poolKey(), resolved.entryIndex());
if (!identities.add(identity)) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio menu membership "
+ resolved.poolKey() + "[" + resolved.entryIndex() + "]");
}
}
}
}
public record Membership(String poolKey, int entryIndex, int weight, double chance) {
public Membership {
poolKey = requireText(poolKey, "membership pool key");
if (entryIndex < 0) {
throw new IllegalArgumentException("Jigsaw Studio menu membership index cannot be negative");
}
if (weight < 1) {
throw new IllegalArgumentException("Jigsaw Studio menu membership weight must be positive");
}
if (!Double.isFinite(chance) || chance < 0D || chance > 1D) {
throw new IllegalArgumentException("Jigsaw Studio menu membership chance must be within 0 and 1");
}
}
}
private record MembershipIdentity(String poolKey, int entryIndex) {
}
private static String requireText(String value, String name) {
String normalized = optionalText(value);
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio menu " + name + " cannot be blank");
}
return normalized;
}
private static String optionalText(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,455 @@
package art.arcane.iris.core.service;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisObjectRotation;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.data.B;
import art.arcane.iris.util.common.math.IrisBlockVector;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
public final class JigsawStudioPreviewRenderer {
private static final int MAX_BLOCKS = 250_000;
private static final String AIR = "minecraft:air";
private static final String STRUCTURE_VOID = "minecraft:structure_void";
private final Map<UUID, RenderState> requests = new HashMap<>();
public static PreviewPlan plan(List<PlacedStructurePiece> pieces) throws IOException {
List<PlacedStructurePiece> source = List.copyOf(Objects.requireNonNull(
pieces,
"Jigsaw Studio preview pieces"));
if (source.isEmpty()) {
return PreviewPlan.empty();
}
Map<BlockPosition, String> blocks = new LinkedHashMap<>();
int minimumX = Integer.MAX_VALUE;
int minimumY = Integer.MAX_VALUE;
int minimumZ = Integer.MAX_VALUE;
int maximumX = Integer.MIN_VALUE;
int maximumY = Integer.MIN_VALUE;
int maximumZ = Integer.MIN_VALUE;
for (PlacedStructurePiece piece : source) {
if (piece == null || piece.getObject() == null || piece.getPiece() == null
|| piece.getRotation() == null) {
throw new IOException("Jigsaw Studio preview contains an incomplete placed piece");
}
minimumX = Math.min(minimumX, piece.getMinX());
minimumY = Math.min(minimumY, piece.getMinY());
minimumZ = Math.min(minimumZ, piece.getMinZ());
maximumX = Math.max(maximumX, piece.getMaxX());
maximumY = Math.max(maximumY, piece.getMaxY());
maximumZ = Math.max(maximumZ, piece.getMaxZ());
appendObject(blocks, piece);
appendFinalStates(blocks, piece);
if (blocks.size() > MAX_BLOCKS) {
throw new IOException("The seed-1337 preview exceeds the Studio render limit of "
+ MAX_BLOCKS + " explicit blocks");
}
}
return new PreviewPlan(
Map.copyOf(blocks),
new PreviewBounds(minimumX, minimumY, minimumZ, maximumX, maximumY, maximumZ));
}
public void render(
World world,
UUID requestId,
long generation,
PreviewPlan plan,
Consumer<RenderResult> completion
) {
World activeWorld = Objects.requireNonNull(world, "Jigsaw Studio preview world");
UUID activeRequestId = Objects.requireNonNull(requestId, "Jigsaw Studio preview request ID");
PreviewPlan activePlan = Objects.requireNonNull(plan, "Jigsaw Studio preview plan");
Consumer<RenderResult> callback = Objects.requireNonNull(completion, "Jigsaw Studio preview callback");
Map<BlockPosition, String> previous;
Set<BlockPosition> uncertain;
Map<Long, List<BlockUpdate>> updates;
synchronized (this) {
RenderState old = requests.get(activeRequestId);
previous = old == null || !old.worldId().equals(activeWorld.getUID())
? Map.of()
: old.blocks();
uncertain = old == null || !old.worldId().equals(activeWorld.getUID())
? Set.of()
: Set.copyOf(old.pending());
updates = updates(previous, activePlan.blocks(), uncertain);
Set<BlockPosition> pending = new HashSet<>();
for (List<BlockUpdate> chunkUpdates : updates.values()) {
for (BlockUpdate update : chunkUpdates) {
pending.add(update.position());
}
}
requests.put(activeRequestId, new RenderState(
activeWorld.getUID(), generation, activePlan.blocks(), activePlan.bounds(), pending));
}
if (updates.isEmpty()) {
callback.accept(new RenderResult(true, 0, ""));
return;
}
AtomicInteger remaining = new AtomicInteger(updates.size());
AtomicBoolean failed = new AtomicBoolean();
for (Map.Entry<Long, List<BlockUpdate>> chunk : updates.entrySet()) {
int chunkX = chunkX(chunk.getKey());
int chunkZ = chunkZ(chunk.getKey());
boolean scheduled = J.runRegion(
activeWorld,
chunkX,
chunkZ,
() -> applyChunk(
activeWorld,
activeRequestId,
generation,
chunk.getValue(),
remaining,
failed,
callback));
if (!scheduled) {
failed.set(true);
if (remaining.decrementAndGet() == 0) {
callback.accept(new RenderResult(
false,
activePlan.blocks().size(),
"One or more preview chunks could not be scheduled"));
}
}
}
}
public synchronized PreviewBounds bounds(UUID requestId) {
RenderState state = requests.get(requestId);
return state == null ? null : state.bounds();
}
public synchronized boolean contains(UUID requestId, int x, int y, int z) {
RenderState state = requests.get(requestId);
return state != null && state.bounds().contains(x, y, z);
}
public void removeRequest(UUID requestId) {
if (requestId == null) {
return;
}
RenderState removed;
synchronized (this) {
removed = requests.remove(requestId);
}
if (removed != null) {
World world = Bukkit.getWorld(removed.worldId());
if (world != null) {
clear(world, removalPositions(removed));
}
}
}
void forgetRequest(UUID requestId) {
if (requestId == null) {
return;
}
synchronized (this) {
requests.remove(requestId);
}
}
public void removeAll() {
Map<UUID, RenderState> removed;
synchronized (this) {
removed = Map.copyOf(requests);
requests.clear();
}
for (RenderState state : removed.values()) {
World world = Bukkit.getWorld(state.worldId());
if (world != null) {
clear(world, removalPositions(state));
}
}
}
private static void appendObject(
Map<BlockPosition, String> blocks,
PlacedStructurePiece piece
) throws IOException {
IrisObject object = piece.getObject();
IrisObjectRotation rotation = piece.getRotation();
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : object.getBlocks()) {
IrisBlockVector rotated = rotation.rotate(entry.getKey());
PlatformBlockState state = rotation.rotate(entry.getValue(), 0, 0, 0);
putState(
blocks,
new BlockPosition(
piece.getX() + rotated.getBlockX(),
piece.getY() + rotated.getBlockY(),
piece.getZ() + rotated.getBlockZ()),
state,
"object block");
}
}
private static void appendFinalStates(
Map<BlockPosition, String> blocks,
PlacedStructurePiece piece
) throws IOException {
IrisObject object = piece.getObject();
IrisObjectRotation rotation = piece.getRotation();
if (piece.getPiece().getConnectors() == null) {
return;
}
for (IrisJigsawConnector connector : piece.getPiece().getConnectors()) {
if (connector == null || connector.getPosition() == null) {
throw new IOException("Jigsaw Studio preview contains an incomplete connector");
}
IrisBlockVector signed = new IrisBlockVector(
connector.getPosition().getX() - object.getCenter().getBlockX(),
connector.getPosition().getY() - object.getCenter().getBlockY(),
connector.getPosition().getZ() - object.getCenter().getBlockZ());
IrisBlockVector rotated = rotation.rotate(signed);
PlatformBlockState source = B.getStateOrNull(connector.getFinalState(), false);
PlatformBlockState state = source == null ? null : rotation.rotate(source, 0, 0, 0);
putState(
blocks,
new BlockPosition(
piece.getX() + rotated.getBlockX(),
piece.getY() + rotated.getBlockY(),
piece.getZ() + rotated.getBlockZ()),
state,
"connector final state");
}
}
private static void putState(
Map<BlockPosition, String> blocks,
BlockPosition position,
PlatformBlockState state,
String source
) throws IOException {
if (state == null || state.key() == null || state.key().isBlank()) {
throw new IOException("Jigsaw Studio preview could not resolve a " + source);
}
String key = state.key();
if (AIR.equals(key) || STRUCTURE_VOID.equals(key)) {
blocks.remove(position);
return;
}
blocks.put(position, key);
}
private void applyChunk(
World world,
UUID requestId,
long generation,
List<BlockUpdate> updates,
AtomicInteger remaining,
AtomicBoolean failed,
Consumer<RenderResult> completion
) {
int changed = 0;
List<BlockPosition> applied = new ArrayList<>(updates.size());
try {
if (!isCurrent(world.getUID(), requestId, generation)) {
return;
}
for (BlockUpdate update : updates) {
Block block = world.getBlockAt(update.position().x(), update.position().y(), update.position().z());
BlockData data = Bukkit.createBlockData(update.stateKey());
block.setBlockData(data, false);
applied.add(update.position());
changed++;
}
} catch (RuntimeException exception) {
failed.set(true);
IrisLogging.reportError(exception);
} finally {
synchronized (this) {
RenderState active = requests.get(requestId);
if (active != null
&& active.worldId().equals(world.getUID())
&& active.generation() == generation) {
active.pending().removeAll(applied);
}
}
if (remaining.decrementAndGet() == 0) {
RenderState state;
boolean pendingEmpty;
synchronized (this) {
state = requests.get(requestId);
pendingEmpty = state != null && state.pending().isEmpty();
}
boolean current = state != null
&& state.worldId().equals(world.getUID())
&& state.generation() == generation;
completion.accept(new RenderResult(
current && pendingEmpty && !failed.get(),
current ? state.blocks().size() : changed,
failed.get() || current && !pendingEmpty
? "One or more preview blocks could not be rendered"
: ""));
}
}
}
private synchronized boolean isCurrent(UUID worldId, UUID requestId, long generation) {
RenderState state = requests.get(requestId);
return state != null && state.worldId().equals(worldId) && state.generation() == generation;
}
private static Map<Long, List<BlockUpdate>> updates(
Map<BlockPosition, String> previous,
Map<BlockPosition, String> next,
Set<BlockPosition> uncertain
) {
Set<BlockPosition> positions = new HashSet<>(previous.keySet());
positions.addAll(next.keySet());
positions.addAll(uncertain);
Map<Long, List<BlockUpdate>> updates = new HashMap<>();
for (BlockPosition position : positions) {
String nextState = next.getOrDefault(position, AIR);
if (!requiresUpdate(position, previous, next, uncertain)) {
continue;
}
updates.computeIfAbsent(
chunkKey(position.x() >> 4, position.z() >> 4),
ignored -> new ArrayList<>()).add(new BlockUpdate(position, nextState));
}
return updates;
}
static boolean requiresUpdate(
BlockPosition position,
Map<BlockPosition, String> previous,
Map<BlockPosition, String> next,
Set<BlockPosition> uncertain
) {
return uncertain.contains(position)
|| !next.getOrDefault(position, AIR).equals(previous.get(position));
}
private static void clear(World world, Set<BlockPosition> positions) {
Map<Long, List<BlockUpdate>> updates = new HashMap<>();
for (BlockPosition position : positions) {
updates.computeIfAbsent(
chunkKey(position.x() >> 4, position.z() >> 4),
ignored -> new ArrayList<>()).add(new BlockUpdate(position, AIR));
}
for (Map.Entry<Long, List<BlockUpdate>> chunk : updates.entrySet()) {
J.runRegion(world, chunkX(chunk.getKey()), chunkZ(chunk.getKey()), () -> {
for (BlockUpdate update : chunk.getValue()) {
world.getBlockAt(
update.position().x(),
update.position().y(),
update.position().z())
.setType(Material.AIR, false);
}
});
}
}
private static Set<BlockPosition> removalPositions(RenderState state) {
Set<BlockPosition> positions = new HashSet<>(state.blocks().keySet());
positions.addAll(state.pending());
return Set.copyOf(positions);
}
private static long chunkKey(int chunkX, int chunkZ) {
return ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL);
}
private static int chunkX(long chunkKey) {
return (int) (chunkKey >> 32);
}
private static int chunkZ(long chunkKey) {
return (int) chunkKey;
}
public record PreviewPlan(Map<BlockPosition, String> blocks, PreviewBounds bounds) {
public PreviewPlan {
blocks = Map.copyOf(Objects.requireNonNull(blocks, "Jigsaw Studio preview blocks"));
bounds = Objects.requireNonNull(bounds, "Jigsaw Studio preview bounds");
}
static PreviewPlan empty() {
return new PreviewPlan(Map.of(), PreviewBounds.empty());
}
}
public record PreviewBounds(
int minimumX,
int minimumY,
int minimumZ,
int maximumX,
int maximumY,
int maximumZ
) {
static PreviewBounds empty() {
return new PreviewBounds(0, 0, 0, -1, -1, -1);
}
public boolean isEmpty() {
return maximumX < minimumX || maximumY < minimumY || maximumZ < minimumZ;
}
public int centerX() {
return isEmpty() ? 0 : minimumX + (maximumX - minimumX) / 2;
}
public int centerZ() {
return isEmpty() ? 0 : minimumZ + (maximumZ - minimumZ) / 2;
}
public boolean contains(int x, int y, int z) {
return !isEmpty()
&& x >= minimumX && x <= maximumX
&& y >= minimumY && y <= maximumY
&& z >= minimumZ && z <= maximumZ;
}
}
public record BlockPosition(int x, int y, int z) {
}
public record RenderResult(boolean successful, int blockCount, String failure) {
public RenderResult {
failure = failure == null ? "" : failure;
}
}
private record RenderState(
UUID worldId,
long generation,
Map<BlockPosition, String> blocks,
PreviewBounds bounds,
Set<BlockPosition> pending
) {
private RenderState {
Objects.requireNonNull(worldId, "Jigsaw Studio preview world ID");
blocks = Map.copyOf(Objects.requireNonNull(blocks, "Jigsaw Studio preview state blocks"));
bounds = Objects.requireNonNull(bounds, "Jigsaw Studio preview state bounds");
pending = Objects.requireNonNull(pending, "Jigsaw Studio preview pending blocks");
}
}
private record BlockUpdate(BlockPosition position, String stateKey) {
}
}
@@ -0,0 +1,185 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.pack.StructurePackageClosure;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioAuthoringAccess;
import art.arcane.iris.core.structure.authoring.StructureCapability;
import art.arcane.iris.core.structure.authoring.StructureHash;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.volmlib.util.collection.KList;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
final class JigsawStudioResourceBundleAssembler {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private JigsawStudioResourceBundleAssembler() {
}
static Assembly assemble(
Path packRoot,
String structureKey,
String pieceKey,
byte[] objectContent,
List<IrisJigsawConnector> connectors,
boolean hasBlockEntities
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root").toAbsolutePath().normalize();
String rootStructure = requireResourceKey(structureKey, "structure");
String editedPiece = requireResourceKey(pieceKey, "piece");
StructureKey ownershipKey = new StructureKey("iris", rootStructure);
StructureTransactionWriter writer = new StructureTransactionWriter(root);
Path manifestPath = writer.ownershipManifestPath(ownershipKey);
if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Jigsaw Studio cannot save '" + rootStructure
+ "' because it is not Studio-owned. Existing unowned graphs are read-only; create a new Jigsaw Studio project to author in-game.");
}
byte[] manifestContent = Files.readAllBytes(manifestPath);
StructureOwnershipManifest manifest;
try {
manifest = StructureOwnershipManifest.fromJson(manifestContent);
} catch (RuntimeException exception) {
throw new IOException("Jigsaw Studio cannot read the ownership manifest for '" + rootStructure + "'", exception);
}
JigsawStudioAuthoringAccess.requireEditable(manifest);
if (!manifest.structure().equals(ownershipKey)) {
throw new IOException("Jigsaw Studio ownership manifest belongs to " + manifest.structure()
+ ", not " + ownershipKey);
}
StructurePackageClosure closure = StructurePackageClosure.collect(root.toFile(), List.of(rootStructure));
if (!closure.isValid()) {
throw new IOException("Jigsaw Studio cannot save an invalid structure graph: "
+ String.join("; ", closure.errors()));
}
Set<String> reachablePaths = resourcePaths(closure);
for (String relativePath : reachablePaths) {
if (!manifest.resourceHashes().containsKey(relativePath)) {
throw new IOException("Jigsaw Studio ownership conflict: reachable resource '" + relativePath
+ "' is not owned by structure '" + rootStructure + "'.");
}
}
String piecePath = "jigsaw-pieces/" + editedPiece + ".json";
if (!manifest.resourceHashes().containsKey(piecePath)) {
throw new IOException("Jigsaw Studio ownership conflict: piece '" + editedPiece
+ "' is not owned by structure '" + rootStructure + "'.");
}
Path absolutePiecePath = resolveOwnedResource(root, piecePath);
IrisJigsawPiece piece;
JsonObject pieceJson;
try {
JsonElement parsed = GSON.fromJson(
Files.readString(absolutePiecePath, StandardCharsets.UTF_8),
JsonElement.class);
if (parsed == null || !parsed.isJsonObject()) {
throw new IllegalArgumentException("Jigsaw piece is not a JSON object");
}
pieceJson = parsed.getAsJsonObject();
piece = GSON.fromJson(pieceJson, IrisJigsawPiece.class);
} catch (RuntimeException exception) {
throw new IOException("Jigsaw Studio cannot parse piece '" + editedPiece + "'", exception);
}
if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) {
throw new IOException("Jigsaw Studio piece '" + editedPiece + "' does not declare an object");
}
String objectKey = requireResourceKey(piece.getObject(), "object");
String objectPath = "objects/" + objectKey + ".iob";
if (!manifest.resourceHashes().containsKey(objectPath)) {
throw new IOException("Jigsaw Studio ownership conflict: object '" + objectKey
+ "' is not owned by structure '" + rootStructure + "'.");
}
piece.setConnectors(new KList<>(List.copyOf(connectors)));
pieceJson.add("connectors", GSON.toJsonTree(piece.getConnectors()));
StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(ownershipKey)
.source(manifest.source())
.backend(manifest.backend())
.capabilities(manifest.capabilities())
.losses(manifest.losses())
.capability(StructureCapability.CONNECTORS);
if (hasBlockEntities) {
bundle.capability(StructureCapability.BLOCK_ENTITIES);
}
for (String relativePath : manifest.resourceHashes().keySet()) {
if (relativePath.equals(piecePath)) {
bundle.textResource(relativePath, GSON.toJson(pieceJson) + "\n");
} else if (relativePath.equals(objectPath)) {
bundle.resource(relativePath, objectContent);
} else {
Path resource = resolveOwnedResource(root, relativePath);
bundle.resource(relativePath, Files.readAllBytes(resource));
}
}
return new Assembly(bundle.build(), objectKey, piece, StructureHash.sha256(manifestContent));
}
private static Set<String> resourcePaths(StructurePackageClosure closure) {
Set<String> resources = new LinkedHashSet<>();
addPaths(resources, "structures", closure.structures(), ".json");
addPaths(resources, "jigsaw-pools", closure.pools(), ".json");
addPaths(resources, "jigsaw-pieces", closure.pieces(), ".json");
addPaths(resources, "objects", closure.objects(), ".iob");
addPaths(resources, "loot", closure.loot(), ".json");
return resources;
}
private static void addPaths(Set<String> resources, String folder, Set<String> keys, String extension) {
for (String key : keys) {
resources.add(folder + "/" + key + extension);
}
}
private static Path resolveOwnedResource(Path root, String relativePath) throws IOException {
StructureResourceBundle.validateRelativePath(relativePath);
Path resource = root.resolve(relativePath).normalize();
if (!resource.startsWith(root)) {
throw new IOException("Jigsaw Studio resource escapes its pack root: " + relativePath);
}
if (!Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Jigsaw Studio owned resource is missing or not a regular file: " + relativePath);
}
Path realRoot = root.toRealPath();
Path realResource = resource.toRealPath();
if (!realResource.startsWith(realRoot)) {
throw new IOException("Jigsaw Studio owned resource escapes through a symbolic link: " + relativePath);
}
return resource;
}
private static String requireResourceKey(String key, String kind) {
String normalized = Objects.requireNonNull(key, "Jigsaw Studio " + kind + " key").trim();
StructureResourceBundle.validateRelativePath(normalized);
return normalized;
}
record Assembly(
StructureResourceBundle bundle,
String objectKey,
IrisJigsawPiece piece,
String expectedManifestHash
) {
Assembly {
Objects.requireNonNull(bundle, "Jigsaw Studio resource bundle");
Objects.requireNonNull(objectKey, "Jigsaw Studio object key");
Objects.requireNonNull(piece, "Jigsaw Studio piece");
Objects.requireNonNull(expectedManifestHash, "Jigsaw Studio expected manifest hash");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolAction;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
public final class JigsawStudioToolCodec {
static final NamespacedKey SCHEMA_KEY = new NamespacedKey("iris", "jigsaw_tool_schema");
static final NamespacedKey ACTION_KEY = new NamespacedKey("iris", "jigsaw_tool_action");
static final NamespacedKey REQUEST_KEY = new NamespacedKey("iris", "jigsaw_tool_request");
static final NamespacedKey WORKCELL_KEY = new NamespacedKey("iris", "jigsaw_tool_workcell");
static final NamespacedKey PIECE_KEY = new NamespacedKey("iris", "jigsaw_tool_piece");
static final NamespacedKey POOL_KEY = new NamespacedKey("iris", "jigsaw_tool_pool");
static final NamespacedKey ENTRY_INDEX_KEY = new NamespacedKey("iris", "jigsaw_tool_entry_index");
static final NamespacedKey AMOUNT_KEY = new NamespacedKey("iris", "jigsaw_tool_amount");
public ItemStack create(JigsawStudioToolPayload payload) {
ItemStack tool = new ItemStack(Material.STICK);
bind(tool, payload);
return tool;
}
public void bind(ItemStack tool, JigsawStudioToolPayload payload) {
ItemStack item = Objects.requireNonNull(tool, "Jigsaw Studio tool item");
JigsawStudioToolPayload binding = requireCurrentSchema(payload);
if (item.getType() != Material.STICK) {
throw new IllegalArgumentException("Jigsaw Studio tools must use a stick");
}
ItemMeta meta = item.getItemMeta();
if (meta == null) {
throw new IllegalStateException("Jigsaw Studio tool stick has no item metadata");
}
write(meta.getPersistentDataContainer(), binding);
meta.setDisplayName(ChatColor.AQUA + "Jigsaw Studio: " + binding.action().displayName());
meta.setLore(lore(binding));
if (!item.setItemMeta(meta)) {
throw new IllegalStateException("Jigsaw Studio tool metadata was rejected");
}
}
public Optional<JigsawStudioToolPayload> decode(ItemStack tool) {
if (tool == null || tool.getType() != Material.STICK || !tool.hasItemMeta()) {
return Optional.empty();
}
ItemMeta meta = tool.getItemMeta();
return meta == null ? Optional.empty() : decode(meta.getPersistentDataContainer());
}
public boolean isTool(ItemStack tool) {
return decode(tool).isPresent();
}
void write(PersistentDataContainer container, JigsawStudioToolPayload payload) {
PersistentDataContainer data = Objects.requireNonNull(
container,
"Jigsaw Studio tool persistent data");
JigsawStudioToolPayload binding = requireCurrentSchema(payload);
data.set(SCHEMA_KEY, PersistentDataType.INTEGER, binding.schemaVersion());
data.set(ACTION_KEY, PersistentDataType.STRING, binding.action().name());
data.set(REQUEST_KEY, PersistentDataType.STRING, binding.requestId().toString());
data.set(WORKCELL_KEY, PersistentDataType.STRING, binding.workcellId());
data.set(PIECE_KEY, PersistentDataType.STRING, binding.pieceKey());
data.set(POOL_KEY, PersistentDataType.STRING, binding.poolKey());
data.set(ENTRY_INDEX_KEY, PersistentDataType.INTEGER, binding.entryIndex());
data.set(AMOUNT_KEY, PersistentDataType.INTEGER, binding.amount());
}
Optional<JigsawStudioToolPayload> decode(PersistentDataContainer container) {
if (container == null) {
return Optional.empty();
}
Integer schemaVersion = container.get(SCHEMA_KEY, PersistentDataType.INTEGER);
String actionName = container.get(ACTION_KEY, PersistentDataType.STRING);
String requestValue = container.get(REQUEST_KEY, PersistentDataType.STRING);
if (schemaVersion == null
|| schemaVersion != JigsawStudioToolPayload.CURRENT_SCHEMA_VERSION
|| actionName == null
|| requestValue == null) {
return Optional.empty();
}
try {
JigsawStudioToolAction action = JigsawStudioToolAction.valueOf(actionName);
UUID requestId = UUID.fromString(requestValue);
String workcellId = optionalString(container, WORKCELL_KEY);
String pieceKey = optionalString(container, PIECE_KEY);
String poolKey = optionalString(container, POOL_KEY);
Integer entryIndex = container.get(ENTRY_INDEX_KEY, PersistentDataType.INTEGER);
Integer amount = container.get(AMOUNT_KEY, PersistentDataType.INTEGER);
return Optional.of(new JigsawStudioToolPayload(
schemaVersion,
action,
requestId,
workcellId,
pieceKey,
poolKey,
entryIndex == null ? JigsawStudioToolPayload.NO_ENTRY_INDEX : entryIndex,
amount == null ? 0 : amount));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
private static JigsawStudioToolPayload requireCurrentSchema(JigsawStudioToolPayload payload) {
JigsawStudioToolPayload binding = Objects.requireNonNull(payload, "Jigsaw Studio tool payload");
if (binding.schemaVersion() != JigsawStudioToolPayload.CURRENT_SCHEMA_VERSION) {
throw new IllegalArgumentException("Unsupported Jigsaw Studio tool schema "
+ binding.schemaVersion());
}
return binding;
}
private static String optionalString(PersistentDataContainer container, NamespacedKey key) {
String value = container.get(key, PersistentDataType.STRING);
return value == null ? "" : value;
}
private static List<String> lore(JigsawStudioToolPayload payload) {
List<String> lore = new ArrayList<>();
if (!payload.workcellId().isEmpty()) {
lore.add(ChatColor.GRAY + "Workcell: " + payload.workcellId());
}
if (!payload.pieceKey().isEmpty()) {
lore.add(ChatColor.GRAY + "Variant: " + payload.pieceKey());
}
if (!payload.poolKey().isEmpty()) {
lore.add(ChatColor.GRAY + "Pool: " + payload.poolKey()
+ (payload.entryIndex() < 0 ? "" : " [" + payload.entryIndex() + "]"));
}
if (payload.amount() != 0) {
lore.add(ChatColor.GRAY + "Amount: " + payload.amount());
}
lore.add(payload.action().destructive()
? ChatColor.RED + "Right-click twice to confirm"
: ChatColor.YELLOW + "Right-click to use");
return List.copyOf(lore);
}
}
@@ -40,6 +40,7 @@ import art.arcane.iris.core.project.IrisProjectCopier;
import art.arcane.iris.core.runtime.StudioOpenCoordinator;
import art.arcane.iris.core.runtime.TransientWorldCleanupSupport;
import art.arcane.iris.core.runtime.WorldDeletionQueue;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.iris.engine.object.IrisDimension;
@@ -497,6 +498,81 @@ public class StudioSVC implements IrisService {
});
}
public CompletableFuture<StudioOpenCoordinator.StudioOpenResult> openTracked(
VolmitSender sender,
long seed,
String dimension,
StudioOpenCoordinator.StudioOpenKind openKind,
Runnable beforeOpen,
Consumer<World> onDone
) {
if (blockIfPackBroken(sender, dimension)) {
return CompletableFuture.failedFuture(
new IllegalStateException("Studio pack '" + dimension + "' has blocking validation errors."));
}
return studioTransitions.submit(() -> replaceActiveProjectTracked(
sender,
seed,
dimension,
Objects.requireNonNull(openKind, "Studio open kind"),
Objects.requireNonNull(beforeOpen, "Studio before-open callback"),
Objects.requireNonNull(onDone, "Studio open completion callback")));
}
private CompletableFuture<StudioOpenCoordinator.StudioOpenResult> replaceActiveProjectTracked(
VolmitSender sender,
long seed,
String dimension,
StudioOpenCoordinator.StudioOpenKind openKind,
Runnable beforeOpen,
Consumer<World> onDone
) {
return closeActiveProject().thenCompose(closeResult -> {
if (closeResult == null) {
return CompletableFuture.failedFuture(
new IllegalStateException("Studio close completed without a result."));
}
if (closeResult.failureCause() != null) {
return CompletableFuture.failedFuture(closeResult.failureCause());
}
beforeOpen.run();
return beginStudioOpenTracked(sender, seed, dimension, openKind, onDone);
});
}
private CompletableFuture<StudioOpenCoordinator.StudioOpenResult> beginStudioOpenTracked(
VolmitSender sender,
long seed,
String dimension,
StudioOpenCoordinator.StudioOpenKind openKind,
Consumer<World> onDone
) {
IrisProject project = new IrisProject(new File(getWorkspaceFolder(), dimension));
activeProject = project;
CompletableFuture<StudioOpenCoordinator.StudioOpenResult> opening;
try {
opening = project.open(sender, seed, openKind, onDone);
} catch (IrisException exception) {
if (activeProject == project) {
activeProject = null;
}
return CompletableFuture.failedFuture(exception);
}
activeOpen = opening;
return opening.thenApply(result -> Objects.requireNonNull(
result,
"Studio open completed without a result."))
.whenComplete((result, throwable) -> {
if (activeOpen == opening) {
activeOpen = null;
}
if (throwable != null && activeProject == project && !project.isOpen()) {
activeProject = null;
}
});
}
private CompletableFuture<Void> replaceActiveProject(
VolmitSender sender,
long seed,
@@ -543,7 +619,11 @@ public class StudioSVC implements IrisService {
activeProject = project;
CompletableFuture<StudioOpenCoordinator.StudioOpenResult> opening;
try {
opening = project.open(sender, seed, onDone);
opening = project.open(
sender,
seed,
StudioOpenCoordinator.StudioOpenKind.STANDARD,
onDone);
} catch (IrisException e) {
if (activeProject == project) {
activeProject = null;
@@ -594,6 +674,15 @@ public class StudioSVC implements IrisService {
));
}
JigsawStudioActivation.Request jigsawRequest = JigsawStudioActivation.getRequest(project.getName());
if (jigsawRequest != null) {
String protectionFailure = JigsawStudioService.get()
.closeProtectionFailure(jigsawRequest.requestId());
if (protectionFailure != null) {
return CompletableFuture.failedFuture(new IllegalStateException(protectionFailure));
}
}
IrisLogging.debug("Closing Active Project");
CompletableFuture<StudioOpenCoordinator.StudioCloseResult> closing = project.close();
return closing.whenComplete((result, throwable) -> {
@@ -41,13 +41,31 @@ import art.arcane.iris.core.localization.BukkitRuntimeMessages;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.volmlib.util.localization.MessageArgument;
public final class BulkStructureImporter {
public record Report(int total, int imported, int skipped, int failed, Map<String, String> successfulBundles) {
public record Report(
int total,
int imported,
int skipped,
int failed,
Map<String, String> successfulBundles,
boolean retryRequired,
Set<String> captureCandidates
) {
public Report(int total, int imported, int skipped, int failed) {
this(total, imported, skipped, failed, Map.of());
this(total, imported, skipped, failed, Map.of(), false, Set.of());
}
public Report(int total, int imported, int skipped, int failed, Map<String, String> successfulBundles) {
this(total, imported, skipped, failed, successfulBundles, false, Set.of());
}
public Report(int total, int imported, int skipped, int failed,
Map<String, String> successfulBundles, boolean retryRequired) {
this(total, imported, skipped, failed, successfulBundles, retryRequired, Set.of());
}
public Report {
successfulBundles = Collections.unmodifiableMap(new TreeMap<>(successfulBundles));
captureCandidates = Collections.unmodifiableSet(new TreeSet<>(captureCandidates));
}
}
@@ -68,6 +86,7 @@ public final class BulkStructureImporter {
int imported = 0;
int skipped = 0;
int failed = 0;
TreeSet<String> captureCandidates = new TreeSet<>();
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_IMPORTING_VANILLA_DATAPACK_STRUCTURES_MODE_INCLUDENONJIGSAW, MessageArgument.untrusted("total", String.valueOf(total)), MessageArgument.untrusted("mode", String.valueOf(mode)), MessageArgument.untrusted("includeNonJigsaw", String.valueOf(includeNonJigsaw))));
@@ -100,8 +119,9 @@ public final class BulkStructureImporter {
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_SINGLE, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("name", String.valueOf(name))));
} else if (single.message() != null && single.message().startsWith("Skipped")) {
skipped++;
} else if (single.message() != null && single.message().contains("No loadable structure NBT")) {
} else if (isCaptureCandidate(message, single.message())) {
skipped++;
captureCandidates.add(nk.toString());
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_SKIP_NO_SINGLE_TEMPLATE_NBT_VANILLA_BUILDS_THIS_CODE_FROM_SEPARATE_PIECE, MessageArgument.untrusted("keyString", String.valueOf(keyString))));
} else {
failed++;
@@ -126,7 +146,7 @@ public final class BulkStructureImporter {
StructureIndexService.write(data);
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_BULK_IMPORT_COMPLETE_IMPORTED_SKIPPED_FAILED_TOTAL, MessageArgument.untrusted("imported", String.valueOf(imported)), MessageArgument.untrusted("skipped", String.valueOf(skipped)), MessageArgument.untrusted("failed", String.valueOf(failed)), MessageArgument.untrusted("total", String.valueOf(total))));
return new Report(total, imported, skipped, failed);
return new Report(total, imported, skipped, failed, Map.of(), false, captureCandidates);
}
public static Report importTemplateGroups(IrisData data, StructureImporter.Mode mode, VolmitSender sender) {
@@ -231,15 +251,40 @@ public final class BulkStructureImporter {
}
public static Report importDatapackStructures(IrisData data, StructureImporter.Mode mode, VolmitSender sender) {
return importDatapackStructures(data, mode, sender, null, null);
return importDatapackStructures(
data,
mode,
sender,
null,
null,
StructureImporter.Ownership.EDITABLE
);
}
public static Report importDatapackStructures(
public static Report importManagedDatapackStructures(
IrisData data,
StructureImporter.Mode mode,
VolmitSender sender,
Set<String> allowedStructureKeys,
Set<String> allowedTemplateKeys
) {
return importDatapackStructures(
data,
mode,
sender,
allowedStructureKeys,
allowedTemplateKeys,
StructureImporter.Ownership.MANAGED_DATAPACK
);
}
private static Report importDatapackStructures(
IrisData data,
StructureImporter.Mode mode,
VolmitSender sender,
Set<String> allowedStructureKeys,
Set<String> allowedTemplateKeys,
StructureImporter.Ownership ownership
) {
KList<String> keys = INMS.get().getStructureKeys();
KeySelection structureSelection = selectDatapackKeys(keys, allowedStructureKeys);
@@ -250,6 +295,7 @@ public final class BulkStructureImporter {
int imported = 0;
int skipped = 0;
int failed = structureSelection.missing().size();
boolean retryRequired = false;
Map<String, String> successfulBundles = new TreeMap<>();
if (structureAttempts == 0) {
@@ -269,17 +315,31 @@ public final class BulkStructureImporter {
String name = StructureImporter.deriveName(nk);
try {
VillageImporter.Result jigsaw = VillageImporter.importVillage(data, nk, name, mode);
VillageImporter.Result jigsaw = VillageImporter.importVillage(
data,
nk,
name,
mode,
ownership
);
if (jigsaw.success()) {
imported++;
successfulBundles.put(bundleKey(name), keyString);
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_JIGSAW_2, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("name", String.valueOf(name))));
continue;
}
retryRequired |= jigsaw.retryableFailure();
String message = jigsaw.message() == null ? "" : jigsaw.message();
if (message.contains("is not a jigsaw structure")) {
StructureImporter.Result single = StructureImporter.importStructure(data, nk, name, mode);
StructureImporter.Result single = StructureImporter.importStructure(
data,
nk,
name,
mode,
false,
ownership
);
if (single.success()) {
imported++;
successfulBundles.put(bundleKey(name), keyString);
@@ -290,6 +350,7 @@ public final class BulkStructureImporter {
skipped++;
} else {
failed++;
retryRequired |= single.retryableFailure();
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_7, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("message", String.valueOf(single.message()))));
}
continue;
@@ -304,6 +365,7 @@ public final class BulkStructureImporter {
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_8, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("message", String.valueOf(message))));
} catch (Throwable e) {
failed++;
retryRequired = true;
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_9, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("error", String.valueOf(e.getMessage()))));
}
}
@@ -318,6 +380,7 @@ public final class BulkStructureImporter {
} catch (Throwable e) {
templateAttempts++;
failed++;
retryRequired = true;
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_COULD_NOT_ENUMERATE_DATAPACK_TEMPLATES_VIA_SERVER_RESOURCEMANAGER, MessageArgument.untrusted("error", String.valueOf(e.getMessage()))));
}
if (enumerationSucceeded) {
@@ -339,7 +402,14 @@ public final class BulkStructureImporter {
}
String name = templateNameFor(keyString);
try {
StructureImporter.Result result = StructureImporter.importStructure(data, nk, name, mode, true);
StructureImporter.Result result = StructureImporter.importStructure(
data,
nk,
name,
mode,
true,
ownership
);
if (result.success()) {
imported++;
successfulBundles.put(bundleKey(name), keyString);
@@ -347,10 +417,12 @@ public final class BulkStructureImporter {
skipped++;
} else {
failed++;
retryRequired |= result.retryableFailure();
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_10, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("message", String.valueOf(result.message()))));
}
} catch (Throwable e) {
failed++;
retryRequired = true;
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_11, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("error", String.valueOf(e.getMessage()))));
}
}
@@ -360,13 +432,27 @@ public final class BulkStructureImporter {
StructureIndexService.write(data);
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_DATAPACK_STRUCTURE_IMPORT_COMPLETE_IMPORTED_SKIPPED_FAILED, MessageArgument.untrusted("imported", String.valueOf(imported)), MessageArgument.untrusted("skipped", String.valueOf(skipped)), MessageArgument.untrusted("failed", String.valueOf(failed))));
return datapackReport(structureAttempts, templateAttempts, imported, skipped, failed, successfulBundles);
return datapackReport(
structureAttempts,
templateAttempts,
imported,
skipped,
failed,
successfulBundles,
retryRequired);
}
static boolean isAllowedDatapackKey(String key, Set<String> allowedKeys) {
return allowedKeys == null ? !key.startsWith("minecraft:") : allowedKeys.contains(key);
}
static boolean isCaptureCandidate(String jigsawMessage, String singleMessage) {
return jigsawMessage != null
&& jigsawMessage.contains("is not a jigsaw structure")
&& singleMessage != null
&& singleMessage.contains("No loadable structure NBT");
}
static KeySelection selectDatapackKeys(Iterable<String> discoveredKeys, Set<String> allowedKeys) {
TreeSet<String> discovered = normalizeKeys(discoveredKeys);
TreeSet<String> present = new TreeSet<>();
@@ -401,8 +487,26 @@ public final class BulkStructureImporter {
return new Report(structureAttempts + templateAttempts, imported, skipped, failed, successfulBundles);
}
static Report datapackReport(
int structureAttempts,
int templateAttempts,
int imported,
int skipped,
int failed,
Map<String, String> successfulBundles,
boolean retryRequired
) {
return new Report(
structureAttempts + templateAttempts,
imported,
skipped,
failed,
successfulBundles,
retryRequired);
}
static Report enumerationFailureReport() {
return new Report(1, 0, 0, 1);
return new Report(1, 0, 0, 1, Map.of(), true);
}
public static String templateNameFor(String key) {
@@ -31,6 +31,11 @@ import org.bukkit.World;
import org.bukkit.block.Block;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
@@ -51,6 +56,11 @@ public final class StructureCaptureImporter {
}
public static Report importAllStructures(IrisData data, StructureImporter.Mode mode, VolmitSender sender) {
return importStructures(data, mode, sender, null);
}
public static Report importStructures(IrisData data, StructureImporter.Mode mode, VolmitSender sender,
Set<String> allowedStructureKeys) {
StructureImporter.Mode activeMode = mode == null ? StructureImporter.Mode.ADD_ONLY : mode;
if (!INMS.get().supportsStructureCapture()) {
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STRUCTURE_CAPTURE_IMPORTER_STRUCTURE_CAPTURE_IS_NOT_SUPPORTED_BY_ACTIVE_NMS_BINDING_SKIPPING_CAPTURE_PASS));
@@ -62,11 +72,8 @@ public final class StructureCaptureImporter {
return new Report(0, 0, 0, 0);
}
KList<String> targets = new KList<>();
for (String key : keys) {
if (key == null || key.isEmpty()) {
continue;
}
ArrayList<String> targets = new ArrayList<>();
for (String key : selectCaptureKeys(keys, allowedStructureKeys)) {
String name = StructureImporter.deriveName(key);
File structureFile = new File(data.getDataFolder(), "structures/" + name + ".json");
if (structureFile.exists() && activeMode != StructureImporter.Mode.OVERWRITE) {
@@ -133,6 +140,35 @@ public final class StructureCaptureImporter {
return new Report(total, imported, skipped, failed);
}
static List<String> selectCaptureKeys(Iterable<String> keys, Set<String> allowedStructureKeys) {
TreeSet<String> allowed = null;
if (allowedStructureKeys != null) {
allowed = normalizeKeys(allowedStructureKeys);
}
TreeSet<String> selected = new TreeSet<>();
for (String key : keys) {
if (key == null || key.isBlank()) {
continue;
}
String normalized = key.trim().toLowerCase(Locale.ROOT);
if (allowed == null || allowed.contains(normalized)) {
selected.add(normalized);
}
}
return new ArrayList<>(selected);
}
private static TreeSet<String> normalizeKeys(Iterable<String> keys) {
TreeSet<String> normalized = new TreeSet<>();
for (String key : keys) {
if (key == null || key.isBlank()) {
continue;
}
normalized.add(key.trim().toLowerCase(Locale.ROOT));
}
return normalized;
}
private static IrisObject captureOne(World world, String key, int cellIndex) {
int originX = (cellIndex % CELL_COLUMNS) * CELL_STRIDE;
int originZ = (cellIndex / CELL_COLUMNS) * CELL_STRIDE;
@@ -29,6 +29,7 @@ import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.authoring.StructureWriteMode;
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
import art.arcane.iris.engine.framework.structure.StructureGraphValidationException;
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.LegacyTileData;
@@ -66,7 +67,33 @@ public final class StructureImporter {
ADD_ONLY
}
public record Result(boolean success, String message, int blocks, List<StructureLoss> losses) {
enum Ownership {
EDITABLE,
MANAGED_DATAPACK;
StructureWriteResult write(
IrisData data,
StructureResourceBundle bundle,
StructureWriteMode mode
) {
StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath());
return this == MANAGED_DATAPACK
? writer.writeManagedDatapack(bundle, mode)
: writer.write(bundle, mode);
}
}
public record Result(
boolean success,
String message,
int blocks,
List<StructureLoss> losses,
boolean retryableFailure
) {
public Result(boolean success, String message, int blocks, List<StructureLoss> losses) {
this(success, message, blocks, losses, false);
}
public Result {
losses = List.copyOf(losses);
}
@@ -75,6 +102,7 @@ public final class StructureImporter {
record CapturedStructure(
IrisObject object,
int blocks,
int nonAirBlocks,
int tiles,
int width,
int height,
@@ -113,10 +141,21 @@ public final class StructureImporter {
}
public static Result importStructure(IrisData data, NamespacedKey key, String name, Mode mode) {
return importStructure(data, key, name, mode, false);
return importStructure(data, key, name, mode, false, Ownership.EDITABLE);
}
public static Result importStructure(IrisData data, NamespacedKey key, String name, Mode mode, boolean objectOnly) {
return importStructure(data, key, name, mode, objectOnly, Ownership.EDITABLE);
}
static Result importStructure(
IrisData data,
NamespacedKey key,
String name,
Mode mode,
boolean objectOnly,
Ownership ownership
) {
Mode activeMode = mode == null ? Mode.ADD_ONLY : mode;
Structure structure;
try {
@@ -124,7 +163,7 @@ public final class StructureImporter {
} catch (Throwable e) {
IrisLogging.reportError(e);
e.printStackTrace();
return new Result(false, "Failed to load structure " + key + ": " + e.getMessage(), 0, List.of());
return new Result(false, "Failed to load structure " + key + ": " + e.getMessage(), 0, List.of(), true);
}
if (structure == null || structure.getPalettes().isEmpty()) {
return new Result(false, "No loadable structure NBT for key " + key + " (jigsaw structures must be imported by their piece keys)", 0, List.of());
@@ -136,7 +175,7 @@ public final class StructureImporter {
} catch (Throwable e) {
IrisLogging.reportError(e);
e.printStackTrace();
return new Result(false, "Failed to capture structure " + key + ": " + e.getMessage(), 0, List.of());
return new Result(false, "Failed to capture structure " + key + ": " + e.getMessage(), 0, List.of(), true);
}
IrisObject object = captured.object();
@@ -168,20 +207,23 @@ public final class StructureImporter {
}
StructureWriteMode writeMode = activeMode == Mode.ADD_ONLY
? StructureWriteMode.ADD_ONLY : StructureWriteMode.OVERWRITE;
StructureWriteResult writeResult = new StructureTransactionWriter(data.getDataFolder().toPath())
.write(bundle, writeMode);
StructureWriteResult writeResult = ownership.write(data, bundle, writeMode);
reportWriteFailure(writeResult);
if (!writeResult.successful()) {
return new Result(false, writeFailureMessage(name, activeMode, writeResult), count, losses);
return new Result(false, writeFailureMessage(name, activeMode, writeResult), count, losses,
writeResult.failure().isPresent());
}
if (writeResult.committed()) {
data.invalidateStructureResources();
}
writeNote = writeResultNote(writeResult);
} catch (StructureGraphValidationException e) {
return new Result(false, "Failed writing import for '" + name + "': " + e.getMessage(), count, losses);
} catch (Throwable e) {
IrisLogging.reportError(e);
e.printStackTrace();
return new Result(false, "Failed writing import for '" + name + "': " + e.getMessage(), count, losses);
return new Result(false, "Failed writing import for '" + name + "': " + e.getMessage(), count, losses,
true);
}
String lossSummary = losses.isEmpty() ? "" : ", " + losses.size() + " fidelity warning(s) recorded";
@@ -201,8 +243,10 @@ public final class StructureImporter {
int d = Math.max(1, size.getBlockZ());
IrisObject object = new IrisObject(w, h, d);
int count = 0;
int nonAirBlocks = 0;
int tiles = 0;
int structureMarkers = 0;
int invalidFinalStates = 0;
Palette palette = activeStructure.getPalettes().get(0);
for (BlockState block : palette.getBlocks()) {
Location loc = block.getLocation();
@@ -222,7 +266,11 @@ public final class StructureImporter {
structureMarkers++;
}
if (mat == Material.JIGSAW) {
BlockData resolved = readJigsawFinalState(block);
FinalStateResult finalState = readJigsawFinalState(block);
BlockData resolved = finalState.blockData();
if (finalState.invalidSourceState()) {
invalidFinalStates++;
}
if (resolved == null || isAir(resolved)) {
continue;
}
@@ -232,6 +280,9 @@ public final class StructureImporter {
}
object.setUnsigned(x, y, z, art.arcane.iris.platform.bukkit.BukkitBlockState.of(blockData));
count++;
if (!isAir(blockData)) {
nonAirBlocks++;
}
if (!structural) {
LegacyTileData tile = captureTile(block);
if (tile != null) {
@@ -249,13 +300,14 @@ public final class StructureImporter {
return new CapturedStructure(
object,
count,
nonAirBlocks,
tiles,
w,
h,
d,
structureMarkers,
capabilities,
importLosses(activeStructure, structureMarkers)
importLosses(activeStructure, structureMarkers, invalidFinalStates)
);
}
@@ -264,7 +316,11 @@ public final class StructureImporter {
return m == Material.AIR || m == Material.CAVE_AIR || m == Material.VOID_AIR;
}
private static List<StructureLoss> importLosses(Structure structure, int structureMarkers) {
private static List<StructureLoss> importLosses(
Structure structure,
int structureMarkers,
int invalidFinalStates
) {
List<StructureLoss> losses = new ArrayList<>();
if (structure.getPaletteCount() > 1) {
losses.add(StructureLoss.warning(
@@ -285,6 +341,12 @@ public final class StructureImporter {
"structure_markers_resolved",
structureMarkers + " jigsaw or structure marker(s) were resolved to final blocks or omitted from the Iris snapshot."));
}
if (invalidFinalStates > 0) {
losses.add(StructureLoss.warning(
StructureCapability.BLOCKS,
"invalid_jigsaw_final_state",
invalidFinalStates + " jigsaw final-state value(s) were invalid and omitted from the Iris snapshot."));
}
return losses;
}
@@ -313,28 +375,56 @@ public final class StructureImporter {
return "Failed writing import for '" + name + "' in " + mode.name().toLowerCase() + " mode: " + failure;
}
private static BlockData readJigsawFinalState(BlockState block) {
private static FinalStateResult readJigsawFinalState(BlockState block) {
String finalState;
try {
Object nbt = block.getClass().getMethod("getSnapshotNBT").invoke(block);
if (nbt == null) {
return null;
return new FinalStateResult(null, false);
}
Object res = nbt.getClass().getMethod("getString", String.class).invoke(nbt, "final_state");
String finalState = null;
finalState = null;
if (res instanceof String s) {
finalState = s;
} else if (res instanceof Optional<?> o && o.isPresent()) {
finalState = String.valueOf(o.get());
}
if (finalState == null || finalState.isBlank()) {
return null;
return new FinalStateResult(null, false);
}
return Bukkit.createBlockData(finalState);
} catch (Throwable e) {
IrisLogging.reportError(e);
e.printStackTrace();
if (VillageImporter.shouldPrintFullTrace(e)) {
e.printStackTrace();
}
return new FinalStateResult(null, false);
}
try {
return new FinalStateResult(Bukkit.createBlockData(normalizeJigsawFinalState(finalState)), false);
} catch (IllegalArgumentException e) {
return new FinalStateResult(null, true);
}
}
static String normalizeJigsawFinalState(String finalState) {
if (finalState == null) {
return null;
}
int propertiesStart = finalState.indexOf('[');
String blockId = propertiesStart < 0 ? finalState : finalState.substring(0, propertiesStart);
if (blockId.equals("minecraft:chisled_polished_blackstone")) {
finalState = "minecraft:chiseled_polished_blackstone"
+ (propertiesStart < 0 ? "" : finalState.substring(propertiesStart));
propertiesStart = finalState.indexOf('[');
}
if (propertiesStart < 0 || !finalState.substring(0, propertiesStart).endsWith("_slab")
|| finalState.contains("type=")) {
return finalState;
}
return finalState.replaceAll("([\\[,])half=(top|bottom)(?=[,\\]])", "$1type=$2");
}
private record FinalStateResult(BlockData blockData, boolean invalidSourceState) {
}
private static LegacyTileData captureTile(BlockState block) {
@@ -376,7 +466,8 @@ public final class StructureImporter {
} catch (IOException e) {
IrisLogging.reportError(e);
e.printStackTrace();
return new Result(false, "Cannot read imported object '" + rel + "': " + e.getMessage(), 0, List.of());
return new Result(false, "Cannot read imported object '" + rel + "': " + e.getMessage(), 0,
List.of(), true);
}
}
@@ -407,7 +498,8 @@ public final class StructureImporter {
.write(bundle, writeMode);
reportWriteFailure(writeResult);
if (!writeResult.successful()) {
return new Result(false, writeFailureMessage(groupName, activeMode, writeResult), 0, List.of());
return new Result(false, writeFailureMessage(groupName, activeMode, writeResult), 0, List.of(),
writeResult.failure().isPresent());
}
if (writeResult.committed()) {
data.invalidateStructureResources();
@@ -417,7 +509,8 @@ public final class StructureImporter {
} catch (Throwable e) {
IrisLogging.reportError(e);
e.printStackTrace();
return new Result(false, "Failed writing group structure '" + groupName + "': " + e.getMessage(), 0, List.of());
return new Result(false, "Failed writing group structure '" + groupName + "': " + e.getMessage(), 0,
List.of(), true);
}
}
@@ -493,17 +586,21 @@ public final class StructureImporter {
.write(bundle, writeMode);
reportWriteFailure(writeResult);
if (!writeResult.successful()) {
return new Result(false, writeFailureMessage(name, activeMode, writeResult), 0, List.of());
return new Result(false, writeFailureMessage(name, activeMode, writeResult), 0, List.of(),
writeResult.failure().isPresent());
}
if (writeResult.committed()) {
data.invalidateStructureResources();
}
return new Result(true, "Captured '" + vanillaSource + "' as '" + name + "'"
+ writeResultNote(writeResult), object.getBlocks().size(), List.of());
} catch (StructureGraphValidationException e) {
return new Result(false, "Failed writing capture for '" + name + "': " + e.getMessage(), 0, List.of());
} catch (Throwable e) {
IrisLogging.reportError(e);
e.printStackTrace();
return new Result(false, "Failed writing capture for '" + name + "': " + e.getMessage(), 0, List.of());
return new Result(false, "Failed writing capture for '" + name + "': " + e.getMessage(), 0, List.of(),
true);
}
}
@@ -25,10 +25,11 @@ import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureLoss;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.authoring.StructureWriteMode;
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
import art.arcane.iris.engine.framework.structure.StructureGraphValidationException;
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.spi.IrisLogging;
import com.google.gson.Gson;
@@ -44,11 +45,13 @@ import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -56,7 +59,18 @@ public final class VillageImporter {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Set<String> PRINTED_FAILURE_SIGNATURES = ConcurrentHashMap.newKeySet();
public record Result(boolean success, String message, int pools, int pieces, List<StructureLoss> losses) {
public record Result(
boolean success,
String message,
int pools,
int pieces,
List<StructureLoss> losses,
boolean retryableFailure
) {
public Result(boolean success, String message, int pools, int pieces, List<StructureLoss> losses) {
this(success, message, pools, pieces, losses, false);
}
public Result {
losses = List.copyOf(losses);
}
@@ -66,8 +80,19 @@ public final class VillageImporter {
}
public static Result importVillage(IrisData data, NamespacedKey structureKey, String name, StructureImporter.Mode mode) {
return importVillage(data, structureKey, name, mode, StructureImporter.Ownership.EDITABLE);
}
static Result importVillage(
IrisData data,
NamespacedKey structureKey,
String name,
StructureImporter.Mode mode,
StructureImporter.Ownership ownership
) {
StructureImporter.Mode activeMode = mode == null ? StructureImporter.Mode.ADD_ONLY : mode;
List<StructureLoss> losses = new ArrayList<>();
boolean retryableFailure = false;
Object server;
Object registryAccess;
Object structureManager;
@@ -80,10 +105,10 @@ public final class VillageImporter {
structureManager = invoke(server, "getStructureManager");
} catch (Throwable e) {
reportFailure(e);
return failed("Failed to access server registries via reflection: " + e, losses);
return failed("Failed to access server registries via reflection: " + e, losses, true);
}
if (registryAccess == null) {
return failed("Could not resolve RegistryAccess from the server", losses);
return failed("Could not resolve RegistryAccess from the server", losses, true);
}
Object startPool;
@@ -105,7 +130,7 @@ public final class VillageImporter {
maxDistanceFromCenter = readIntMember(structure, "maxDistanceFromCenter");
} catch (Throwable e) {
reportFailure(e);
return failed("Failed to read jigsaw structure graph: " + e, losses);
return failed("Failed to read jigsaw structure graph: " + e, losses, true);
}
Object templatePoolRegistry;
@@ -115,7 +140,7 @@ public final class VillageImporter {
random = new java.util.Random(structureKey.hashCode());
} catch (Throwable e) {
reportFailure(e);
return failed("Failed to access TEMPLATE_POOL registry: " + e, losses);
return failed("Failed to access TEMPLATE_POOL registry: " + e, losses, true);
}
String startPoolKey;
@@ -123,7 +148,7 @@ public final class VillageImporter {
startPoolKey = registryKeyOf(templatePoolRegistry, startPool);
} catch (Throwable e) {
reportFailure(e);
return failed("Could not resolve the start pool key for " + structureKey + ": " + e, losses);
return failed("Could not resolve the start pool key for " + structureKey + ": " + e, losses, true);
}
if (startPoolKey == null) {
return failed("Could not resolve the start pool key for " + structureKey, losses);
@@ -136,6 +161,7 @@ public final class VillageImporter {
Map<String, Map<String, Object>> emittedPools = new LinkedHashMap<>();
Map<String, Map<String, Object>> emittedPieces = new LinkedHashMap<>();
Map<String, IrisObject> emittedObjects = new LinkedHashMap<>();
Map<String, ImportedTemplate> importedTemplates = new LinkedHashMap<>();
Set<StructureCapability> capabilities = new HashSet<>();
capabilities.add(StructureCapability.BLOCKS);
capabilities.add(StructureCapability.CONNECTORS);
@@ -146,6 +172,7 @@ public final class VillageImporter {
"native_placement_settings_not_imported",
"Native jigsaw placement settings other than the start pool, maximum depth, and maximum distance are not represented by the Iris assembly."));
int pieceBlocks = 0;
int emittedPoolMembers = 0;
while (!poolQueue.isEmpty()) {
String poolKey = poolQueue.poll();
@@ -157,6 +184,7 @@ public final class VillageImporter {
pool = registryGetByKey(templatePoolRegistry, poolKey);
} catch (Throwable e) {
reportFailure(e);
retryableFailure = true;
fatalErrors.add("pool " + poolKey + ": " + e.getMessage());
continue;
}
@@ -175,6 +203,7 @@ public final class VillageImporter {
fallbackKey = registryKeyOf(templatePoolRegistry, fallbackPool);
} catch (Throwable e) {
reportFailure(e);
retryableFailure = true;
losses.add(StructureLoss.warning(
StructureCapability.CONNECTORS,
"fallback_pool_not_imported",
@@ -190,6 +219,7 @@ public final class VillageImporter {
templates = (List<?>) invoke(pool, "getTemplates");
} catch (Throwable e) {
reportFailure(e);
retryableFailure = true;
fatalErrors.add("templates " + poolKey + ": " + e.getMessage());
templates = List.of();
}
@@ -203,6 +233,7 @@ public final class VillageImporter {
weight = second instanceof Number ? Math.max(1, ((Number) second).intValue()) : 1;
} catch (Throwable e) {
reportFailure(e);
retryableFailure = true;
losses.add(StructureLoss.warning(
StructureCapability.LIST_ELEMENTS,
"pool_entry_not_imported",
@@ -213,11 +244,12 @@ public final class VillageImporter {
if (element == null) {
continue;
}
String templateLocation;
PoolElementResolution elementResolution;
try {
templateLocation = templateLocationOf(element);
elementResolution = resolvePoolElement(element);
} catch (Throwable e) {
reportFailure(e);
retryableFailure = true;
losses.add(StructureLoss.warning(
StructureCapability.BLOCKS,
"template_location_not_imported",
@@ -225,13 +257,19 @@ public final class VillageImporter {
.affecting("jigsaw-pools/" + irisPoolName + ".json"));
continue;
}
if (elementResolution.omittedElements() > 0) {
losses.add(listElementFallbackLoss(elementResolution, poolKey)
.affecting("jigsaw-pools/" + irisPoolName + ".json"));
}
Object physicalElement = elementResolution.physicalElement();
String templateLocation = elementResolution.templateLocation();
if (templateLocation == null) {
String elementType = element.getClass().getSimpleName();
String elementType = physicalElement == null
? element.getClass().getSimpleName()
: physicalElement.getClass().getSimpleName();
if (elementType.endsWith("EmptyPoolElement")) {
Map<String, Object> emptyEntry = new LinkedHashMap<>();
emptyEntry.put("empty", true);
emptyEntry.put("weight", weight);
pieceEntries.add(emptyEntry);
pieceEntries.add(emptyPoolEntry(weight));
emittedPoolMembers++;
} else {
StructureCapability unsupportedCapability = unsupportedCapability(elementType);
losses.add(StructureLoss.warning(
@@ -249,12 +287,14 @@ public final class VillageImporter {
}
String irisPieceName = pieceName(name, templateLocation);
if (!emittedPieces.containsKey(irisPieceName)) {
ImportedTemplate importedTemplate = importedTemplates.get(irisPieceName);
if (importedTemplate == null) {
Structure sourceTemplate;
try {
sourceTemplate = Bukkit.getStructureManager().loadStructure(pieceNbtKey);
} catch (Throwable e) {
reportFailure(e);
retryableFailure = true;
fatalErrors.add(templateLocation + ": failed to load structure template: " + failureDetail(e));
continue;
}
@@ -268,27 +308,53 @@ public final class VillageImporter {
captured = StructureImporter.captureStructure(sourceTemplate);
} catch (Throwable e) {
reportFailure(e);
retryableFailure = true;
fatalErrors.add(templateLocation + ": failed to capture structure template: " + failureDetail(e));
continue;
}
pieceBlocks += captured.blocks();
capabilities.addAll(captured.capabilities());
for (StructureLoss loss : captured.losses()) {
losses.add(loss.affecting("objects/" + irisPieceName + ".iob"));
}
emittedObjects.put(irisPieceName, captured.object());
Connectors result = readConnectors(element, structureManager, random, name, irisPieceName);
emittedPieces.put(irisPieceName, pieceJson(irisPieceName, result.json()));
retryableFailure |= result.retryableFailure();
importedTemplate = new ImportedTemplate(
captured.object(),
captured.blocks(),
captured.nonAirBlocks(),
result.json(),
captured.capabilities());
importedTemplates.put(irisPieceName, importedTemplate);
poolQueue.addAll(result.targetPoolKeys());
losses.addAll(result.losses());
}
if (emittedPieces.containsKey(irisPieceName)) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("piece", irisPieceName);
entry.put("weight", weight);
pieceEntries.add(entry);
PoolMemberNormalization normalization = normalizePoolMember(
poolKey,
irisPoolName,
poolKey.equals(startPoolKey),
templates.size(),
fallbackKey,
templateLocation,
irisPieceName,
weight,
importedTemplate.nonAirBlocks(),
importedTemplate.connectors());
losses.addAll(normalization.losses());
if (!emittedPieces.containsKey(irisPieceName)) {
pieceBlocks += importedTemplate.emittedBlocks(normalization);
capabilities.addAll(importedTemplate.emittedCapabilities(normalization));
if (normalization.disposition() == PoolMemberDisposition.PHYSICAL) {
emittedObjects.put(irisPieceName, importedTemplate.object());
emittedPieces.put(irisPieceName, pieceJson(
irisPieceName,
importedTemplate.connectors(),
importedTemplate.nonAirBlocks()));
}
}
if (!normalization.poolEntry().isEmpty()) {
pieceEntries.add(normalization.poolEntry());
emittedPoolMembers++;
}
}
@@ -302,10 +368,11 @@ public final class VillageImporter {
if (!fatalErrors.isEmpty()) {
return failed("Failed to capture the complete graph for " + structureKey + ": " + fatalErrors.getFirst()
+ (fatalErrors.size() == 1 ? "" : " (" + (fatalErrors.size() - 1) + " more)"), losses);
+ (fatalErrors.size() == 1 ? "" : " (" + (fatalErrors.size() - 1) + " more)"), losses,
retryableFailure);
}
if (emittedPieces.isEmpty()) {
return failed("Imported 0 pieces for " + structureKey, losses);
if (emittedPoolMembers == 0) {
return failed("Imported 0 attachable or empty pool members for " + structureKey, losses);
}
for (StructureLoss loss : losses) {
if (loss.capability() == StructureCapability.CONNECTORS) {
@@ -340,21 +407,23 @@ public final class VillageImporter {
StructureResourceBundleGraphCompiler.requireViable(bundle);
StructureWriteMode writeMode = activeMode == StructureImporter.Mode.OVERWRITE
? StructureWriteMode.OVERWRITE : StructureWriteMode.ADD_ONLY;
StructureWriteResult writeResult = new StructureTransactionWriter(data.getDataFolder().toPath())
.write(bundle, writeMode);
StructureWriteResult writeResult = ownership.write(data, bundle, writeMode);
reportWriteFailure(writeResult);
if (!writeResult.successful()) {
return new Result(false, writeFailureMessage(name, writeResult), emittedPools.size(),
emittedPieces.size(), losses);
emittedPieces.size(), losses, writeResult.failure().isPresent());
}
if (writeResult.committed()) {
data.invalidateStructureResources();
}
writeNote = writeResultNote(writeResult);
} catch (StructureGraphValidationException e) {
return new Result(false, "Failed writing jigsaw resources for '" + name + "': " + e.getMessage(),
emittedPools.size(), emittedPieces.size(), losses, retryableFailure);
} catch (Throwable e) {
reportFailure(e);
return new Result(false, "Failed writing jigsaw resources for '" + name + "': " + e,
emittedPools.size(), emittedPieces.size(), losses);
emittedPools.size(), emittedPieces.size(), losses, true);
}
String msg = "Imported village " + structureKey + " as '" + name + "': " + emittedPieces.size() + " pieces, " + emittedPools.size() + " pools, " + pieceBlocks + " blocks";
@@ -395,8 +464,47 @@ public final class VillageImporter {
private record Connectors(
List<Map<String, Object>> json,
Set<String> targetPoolKeys,
List<StructureLoss> losses,
boolean retryableFailure
) {
}
record ImportedTemplate(
IrisObject object,
int blocks,
int nonAirBlocks,
List<Map<String, Object>> connectors,
List<StructureCapability> capabilities
) {
ImportedTemplate {
connectors = List.copyOf(connectors);
capabilities = List.copyOf(capabilities);
}
int emittedBlocks(PoolMemberNormalization normalization) {
return normalization.disposition() == PoolMemberDisposition.PHYSICAL ? blocks : 0;
}
List<StructureCapability> emittedCapabilities(PoolMemberNormalization normalization) {
return normalization.disposition() == PoolMemberDisposition.PHYSICAL ? capabilities : List.of();
}
}
enum PoolMemberDisposition {
PHYSICAL,
EMPTY,
OMITTED
}
record PoolMemberNormalization(
PoolMemberDisposition disposition,
Map<String, Object> poolEntry,
List<StructureLoss> losses
) {
PoolMemberNormalization {
poolEntry = Collections.unmodifiableMap(new LinkedHashMap<>(poolEntry));
losses = List.copyOf(losses);
}
}
private static Connectors readConnectors(
@@ -409,6 +517,7 @@ public final class VillageImporter {
List<Map<String, Object>> connectors = new ArrayList<>();
Set<String> targets = new HashSet<>();
List<StructureLoss> losses = new ArrayList<>();
boolean retryableFailure = false;
String affectedResource = "jigsaw-pieces/" + pieceName + ".json";
try {
Object zero = staticField("net.minecraft.core.BlockPos", "ZERO");
@@ -420,7 +529,7 @@ public final class VillageImporter {
"connector_extraction_unavailable",
"The source pool element does not expose jigsaw connector extraction on this server version.")
.affecting(affectedResource));
return new Connectors(connectors, targets, losses);
return new Connectors(connectors, targets, losses, false);
}
m.setAccessible(true);
Object random0 = freshRandomSource(random);
@@ -431,7 +540,7 @@ public final class VillageImporter {
"connector_extraction_returned_null",
"The source pool element returned no connector collection.")
.affecting(affectedResource));
return new Connectors(connectors, targets, losses);
return new Connectors(connectors, targets, losses, false);
}
for (Object jigsaw : blocks) {
String[] rawPoolKey = new String[1];
@@ -443,6 +552,7 @@ public final class VillageImporter {
}
} catch (Throwable e) {
reportFailure(e);
retryableFailure = true;
losses.add(StructureLoss.warning(
StructureCapability.CONNECTORS,
"connector_not_imported",
@@ -452,13 +562,14 @@ public final class VillageImporter {
}
} catch (Throwable e) {
reportFailure(e);
retryableFailure = true;
losses.add(StructureLoss.warning(
StructureCapability.CONNECTORS,
"connector_extraction_failed",
"Source jigsaw connectors could not be extracted: " + failureDetail(e))
.affecting(affectedResource));
}
return new Connectors(connectors, targets, losses);
return new Connectors(connectors, targets, losses, retryableFailure);
}
private static Map<String, Object> connectorFrom(Object jigsaw, String baseName, String[] rawPoolKeyOut) throws Exception {
@@ -479,6 +590,35 @@ public final class VillageImporter {
String top = topFacing(blockState);
rawPoolKeyOut[0] = poolId;
ConnectorMetadata metadata = readConnectorMetadata(jigsaw, info);
return connectorJson(
x,
y,
z,
front,
top,
poolId,
baseName,
identifierString(nameId),
identifierString(targetId),
jointType,
metadata
);
}
static Map<String, Object> connectorJson(
int x,
int y,
int z,
String front,
String top,
String poolId,
String baseName,
String nameId,
String targetId,
Object jointType,
ConnectorMetadata metadata
) {
Map<String, Object> connector = new LinkedHashMap<>();
Map<String, Object> position = new LinkedHashMap<>();
position.put("x", x);
@@ -488,12 +628,60 @@ public final class VillageImporter {
connector.put("direction", irisDirection(front));
connector.put("top", irisDirection(top));
connector.put("pool", poolId == null ? "" : poolName(baseName, poolId));
connector.put("name", identifierString(nameId));
connector.put("targetName", identifierString(targetId));
connector.put("name", nameId);
connector.put("targetName", targetId);
connector.put("joint", jointType != null && jointType.toString().toUpperCase().contains("ALIGN") ? "ALIGNED" : "ROLLABLE");
connector.put("finalState", metadata.finalState());
connector.put("selectionPriority", metadata.selectionPriority());
connector.put("placementPriority", metadata.placementPriority());
return connector;
}
static ConnectorMetadata readConnectorMetadata(Object jigsaw, Object info) throws Exception {
Object nbt = invoke(info, "nbt");
String finalState = readNbtString(nbt, "final_state");
String normalizedFinalState = finalState == null || finalState.isBlank()
? "minecraft:air"
: StructureImporter.normalizeJigsawFinalState(finalState);
return new ConnectorMetadata(
normalizedFinalState,
readIntAccessor(jigsaw, "selectionPriority"),
readIntAccessor(jigsaw, "placementPriority")
);
}
private static String readNbtString(Object nbt, String key) throws Exception {
if (nbt == null) {
return null;
}
Method method = findMethod(nbt.getClass(), "getString", 1);
if (method == null) {
throw new NoSuchMethodException("getString(String) on " + nbt.getClass().getName());
}
method.setAccessible(true);
Object value = method.invoke(nbt, key);
if (value instanceof String string) {
return string;
}
if (value instanceof Optional<?> optional) {
return optional.isPresent() ? String.valueOf(optional.get()) : null;
}
return value == null ? null : String.valueOf(value);
}
private static int readIntAccessor(Object value, String accessor) throws Exception {
Method method = findMethod(value.getClass(), accessor);
if (method == null) {
throw new NoSuchMethodException(accessor + "() on " + value.getClass().getName());
}
method.setAccessible(true);
Object result = method.invoke(value);
if (result instanceof Number number) {
return number.intValue();
}
throw new IllegalStateException(accessor + " on " + value.getClass().getName() + " is not numeric");
}
private static String frontFacing(Object blockState) throws Exception {
Class<?> jigsawBlock = Class.forName("net.minecraft.world.level.block.JigsawBlock");
Method getFront = jigsawBlock.getMethod("getFrontFacing", Class.forName("net.minecraft.world.level.block.state.BlockState"));
@@ -539,6 +727,42 @@ public final class VillageImporter {
return identifierString(id);
}
static PoolElementResolution resolvePoolElement(Object element) throws Exception {
if (element == null) {
return new PoolElementResolution(null, null, 0, 0);
}
if (!element.getClass().getSimpleName().endsWith("ListPoolElement")) {
return new PoolElementResolution(element, templateLocationOf(element), 0, 0);
}
Object rawElements = invoke(element, "getElements");
if (!(rawElements instanceof List<?> elements)) {
throw new IllegalStateException("getElements on " + element.getClass().getName() + " is not a list");
}
if (elements.isEmpty()) {
return new PoolElementResolution(null, null, 1, 0);
}
PoolElementResolution primary = resolvePoolElement(elements.getFirst());
return new PoolElementResolution(
primary.physicalElement(),
primary.templateLocation(),
primary.listLevels() + 1,
primary.omittedElements() + elements.size() - 1
);
}
static StructureLoss listElementFallbackLoss(PoolElementResolution resolution, String poolKey) {
int omitted = resolution.omittedElements();
String elementLabel = omitted == 1 ? "element" : "elements";
String levelLabel = resolution.listLevels() == 1 ? "list level" : "nested list levels";
return StructureLoss.warning(
StructureCapability.LIST_ELEMENTS,
"list_pool_overlays_not_imported",
"Converted the first physical template from a ListPoolElement in source pool " + poolKey
+ " and omitted " + omitted + " colocated " + elementLabel
+ ", including their processors, across " + resolution.listLevels() + " " + levelLabel + "."
);
}
private static Object resolveRegistryAccess(Object server) {
try {
Class<?> frozen = Class.forName("net.minecraft.core.RegistryAccess$Frozen");
@@ -789,17 +1013,21 @@ public final class VillageImporter {
}
private static Method findMethod4(Class<?> type, String name) {
return findMethod(type, name, 4);
}
private static Method findMethod(Class<?> type, String name, int parameterCount) {
Class<?> c = type;
while (c != null) {
for (Method m : c.getDeclaredMethods()) {
if (m.getName().equals(name) && m.getParameterCount() == 4) {
if (m.getName().equals(name) && m.getParameterCount() == parameterCount) {
return m;
}
}
c = c.getSuperclass();
}
for (Method m : type.getMethods()) {
if (m.getName().equals(name) && m.getParameterCount() == 4) {
if (m.getName().equals(name) && m.getParameterCount() == parameterCount) {
return m;
}
}
@@ -822,14 +1050,116 @@ public final class VillageImporter {
return base + "/piece/" + key.namespace() + "/" + key.path();
}
private static Map<String, Object> pieceJson(String pieceName, List<Map<String, Object>> connectors) {
static Map<String, Object> pieceJson(
String pieceName,
List<Map<String, Object>> connectors,
int nonAirBlocks
) {
Map<String, Object> piece = new LinkedHashMap<>();
piece.put("object", pieceName);
piece.put("connectors", connectors);
piece.put("rotatable", true);
if (nonAirBlocks == 0) {
piece.put("collidable", false);
}
return piece;
}
static PoolMemberNormalization normalizePoolMember(
String sourcePoolKey,
String irisPoolName,
boolean startPoolMember,
int sourcePoolMembershipCount,
String sourceFallbackKey,
String templateLocation,
String irisPieceName,
int weight,
int nonAirBlocks,
List<Map<String, Object>> connectors
) {
if (!connectors.isEmpty() || startPoolMember) {
return new PoolMemberNormalization(
PoolMemberDisposition.PHYSICAL,
piecePoolEntry(irisPieceName, weight),
List.of());
}
if (sourceFallbackKey != null
&& !sourceFallbackKey.isBlank()
&& !sourceFallbackKey.equals(sourcePoolKey)) {
return new PoolMemberNormalization(
PoolMemberDisposition.PHYSICAL,
piecePoolEntry(irisPieceName, weight),
List.of());
}
String affectedResource = "jigsaw-pools/" + irisPoolName + ".json";
if (nonAirBlocks == 0 && sourcePoolMembershipCount == 1) {
StructureLoss loss = StructureLoss.warning(
StructureCapability.IRIS_PLACEMENT,
"connectorless_all_air_member_normalized_empty",
"Source pool member " + templateLocation + " in " + sourcePoolKey
+ " captured no non-air blocks and exposed no jigsaw connectors;"
+ " its singleton membership was normalized to an explicit empty Iris pool entry"
+ fallbackContext(sourcePoolKey, sourceFallbackKey) + ".")
.affecting(affectedResource);
return new PoolMemberNormalization(
PoolMemberDisposition.EMPTY,
emptyPoolEntry(weight),
List.of(loss));
}
if (nonAirBlocks == 0) {
StructureLoss loss = StructureLoss.warning(
StructureCapability.IRIS_PLACEMENT,
"connectorless_all_air_mixed_member_omitted",
"Source pool member " + templateLocation + " in " + sourcePoolKey
+ " captured no non-air blocks and exposed no jigsaw connectors;"
+ " it was omitted from the mixed " + sourcePoolMembershipCount + "-member pool"
+ fallbackContext(sourcePoolKey, sourceFallbackKey)
+ ", changing source selection weights and RNG consumption.")
.affecting(affectedResource);
return new PoolMemberNormalization(
PoolMemberDisposition.OMITTED,
Map.of(),
List.of(loss));
}
StructureLoss loss = StructureLoss.warning(
StructureCapability.BLOCKS,
"connectorless_non_air_member_omitted",
"Source pool member " + templateLocation + " in " + sourcePoolKey
+ " captured " + nonAirBlocks + " non-air block(s) but exposed no jigsaw connectors;"
+ " it was omitted because it cannot attach to the Iris assembly graph"
+ fallbackContext(sourcePoolKey, sourceFallbackKey)
+ ", changing source selection weights and RNG consumption while dropping those blocks.")
.affecting(affectedResource);
return new PoolMemberNormalization(
PoolMemberDisposition.OMITTED,
Map.of(),
List.of(loss));
}
private static String fallbackContext(String sourcePoolKey, String sourceFallbackKey) {
if (sourceFallbackKey == null || sourceFallbackKey.isBlank()) {
return " with no source fallback";
}
if (sourceFallbackKey.equals(sourcePoolKey)) {
return " with its source self-fallback";
}
return " before source fallback " + sourceFallbackKey;
}
static Map<String, Object> emptyPoolEntry(int weight) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("empty", true);
entry.put("weight", weight);
return entry;
}
static Map<String, Object> piecePoolEntry(String pieceName, int weight) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("piece", pieceName);
entry.put("weight", weight);
return entry;
}
static Map<String, Object> structureJson(
String source,
String startPool,
@@ -842,6 +1172,7 @@ public final class VillageImporter {
int maxSizeChunks = Math.max(1, Math.min(32, (Math.max(1, maxDistanceFromCenter) + 15) / 16));
root.put("maxSizeChunks", maxSizeChunks);
root.put("placeMode", "STRUCTURE_PIECE");
root.put("branchFailurePolicy", IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH.name());
root.put("vanillaSource", source);
return root;
}
@@ -866,6 +1197,10 @@ public final class VillageImporter {
return new Result(false, message, 0, 0, losses);
}
private static Result failed(String message, List<StructureLoss> losses, boolean retryableFailure) {
return new Result(false, message, 0, 0, losses, retryableFailure);
}
private static void reportFailure(Throwable failure) {
IrisLogging.reportError(failure);
if (shouldPrintFullTrace(failure)) {
@@ -918,4 +1253,15 @@ public final class VillageImporter {
String message = failure.getMessage();
return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message;
}
record ConnectorMetadata(String finalState, int selectionPriority, int placementPriority) {
}
record PoolElementResolution(
Object physicalElement,
String templateLocation,
int listLevels,
int omittedElements
) {
}
}
@@ -30,6 +30,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.UUID;
public record StructureOwnershipManifest(
int schemaVersion,
@@ -38,7 +39,8 @@ public record StructureOwnershipManifest(
StructureBackend backend,
List<StructureCapability> capabilities,
List<StructureLoss> losses,
Map<String, String> resourceHashes
Map<String, String> resourceHashes,
Provenance provenance
) {
public static final int CURRENT_SCHEMA_VERSION = 1;
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
@@ -53,6 +55,7 @@ public record StructureOwnershipManifest(
Objects.requireNonNull(capabilities, "capabilities");
Objects.requireNonNull(losses, "losses");
Objects.requireNonNull(resourceHashes, "resourceHashes");
provenance = provenance == null ? Provenance.created() : provenance;
ArrayList<StructureCapability> orderedCapabilities = new ArrayList<>(capabilities);
orderedCapabilities.sort(Comparator.naturalOrder());
capabilities = List.copyOf(orderedCapabilities);
@@ -77,8 +80,25 @@ public record StructureOwnershipManifest(
resourceHashes = Collections.unmodifiableMap(orderedHashes);
}
public StructureOwnershipManifest(
int schemaVersion,
StructureKey structure,
StructureSource source,
StructureBackend backend,
List<StructureCapability> capabilities,
List<StructureLoss> losses,
Map<String, String> resourceHashes
) {
this(schemaVersion, structure, source, backend, capabilities, losses, resourceHashes, Provenance.created());
}
public static StructureOwnershipManifest from(StructureResourceBundle bundle) {
return from(bundle, Provenance.created());
}
public static StructureOwnershipManifest from(StructureResourceBundle bundle, Provenance provenance) {
Objects.requireNonNull(bundle, "bundle");
Objects.requireNonNull(provenance, "provenance");
TreeMap<String, String> hashes = new TreeMap<>();
for (StructureResourceBundle.Resource resource : bundle.resources().values()) {
hashes.put(resource.relativePath(), resource.contentHash());
@@ -90,7 +110,8 @@ public record StructureOwnershipManifest(
bundle.backend(),
new ArrayList<>(bundle.capabilities()),
bundle.losses(),
hashes
hashes,
provenance
);
}
@@ -119,4 +140,121 @@ public record StructureOwnershipManifest(
String identityHash = StructureHash.sha256(structure.value().getBytes(StandardCharsets.UTF_8));
return ".iris/structure-manifests/key-" + identityHash + ".json";
}
public record Provenance(
Origin origin,
String receiptId,
String planHash,
String sourceClosureHash,
long appliedAtEpochMilli,
Map<String, String> sourceResourceHashes,
Map<String, String> sourceToTargetPaths,
RollbackDisposition rollbackDisposition
) {
public Provenance {
origin = origin == null ? Origin.CREATED : origin;
receiptId = normalize(receiptId);
planHash = normalize(planHash);
sourceClosureHash = normalize(sourceClosureHash);
sourceResourceHashes = immutableHashes(sourceResourceHashes);
sourceToTargetPaths = immutableMappings(sourceToTargetPaths);
rollbackDisposition = rollbackDisposition == null
? RollbackDisposition.NONE
: rollbackDisposition;
if (origin == Origin.CREATED) {
if (!receiptId.isEmpty() || !planHash.isEmpty() || !sourceClosureHash.isEmpty()
|| appliedAtEpochMilli != 0L || !sourceResourceHashes.isEmpty()
|| !sourceToTargetPaths.isEmpty() || rollbackDisposition != RollbackDisposition.NONE) {
throw new IllegalArgumentException("Created structure provenance cannot declare adoption metadata");
}
} else {
requireUuid(receiptId);
requireHash(planHash, "plan");
requireHash(sourceClosureHash, "source closure");
if (appliedAtEpochMilli <= 0L) {
throw new IllegalArgumentException("Adoption provenance requires a positive application time");
}
if (sourceResourceHashes.isEmpty() || sourceToTargetPaths.isEmpty()) {
throw new IllegalArgumentException("Adoption provenance requires source hashes and path mappings");
}
}
}
public static Provenance created() {
return new Provenance(
Origin.CREATED,
"",
"",
"",
0L,
Map.of(),
Map.of(),
RollbackDisposition.NONE
);
}
public boolean adopted() {
return origin != Origin.CREATED;
}
private static Map<String, String> immutableHashes(Map<String, String> hashes) {
if (hashes == null || hashes.isEmpty()) {
return Map.of();
}
TreeMap<String, String> ordered = new TreeMap<>();
for (Map.Entry<String, String> entry : hashes.entrySet()) {
String relativePath = StructureResourceBundle.validateRelativePath(entry.getKey());
String hash = Objects.requireNonNull(entry.getValue(), "source resource hash");
requireHash(hash, "source resource");
ordered.put(relativePath, hash);
}
return Collections.unmodifiableMap(ordered);
}
private static Map<String, String> immutableMappings(Map<String, String> mappings) {
if (mappings == null || mappings.isEmpty()) {
return Map.of();
}
TreeMap<String, String> ordered = new TreeMap<>();
for (Map.Entry<String, String> entry : mappings.entrySet()) {
String sourcePath = StructureResourceBundle.validateRelativePath(entry.getKey());
String targetPath = StructureResourceBundle.validateRelativePath(
Objects.requireNonNull(entry.getValue(), "target resource path"));
ordered.put(sourcePath, targetPath);
}
return Collections.unmodifiableMap(ordered);
}
private static void requireUuid(String value) {
try {
UUID.fromString(value);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("Adoption receipt ID must be a UUID", exception);
}
}
private static void requireHash(String value, String kind) {
if (!StructureHash.isSha256(value)) {
throw new IllegalArgumentException("Adoption " + kind + " hash must be SHA-256");
}
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
}
public enum Origin {
CREATED,
ADOPTED_EXISTING,
ADOPTED_CLONE,
ADOPTED_MANAGED_CLONE,
CONVERTED,
MANAGED_DATAPACK
}
public enum RollbackDisposition {
NONE,
DELETE_CREATED_IF_UNCHANGED
}
}
@@ -0,0 +1,147 @@
package art.arcane.iris.core.structure.authoring;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
public record StructureTransactionReadSet(
Map<String, String> fileHashes,
Set<String> absentPaths,
Map<String, List<String>> directoryEntries
) {
public static final int MAX_ENTRIES = 100_000;
public StructureTransactionReadSet {
Objects.requireNonNull(fileHashes, "fileHashes");
Objects.requireNonNull(absentPaths, "absentPaths");
Objects.requireNonNull(directoryEntries, "directoryEntries");
TreeMap<String, String> orderedHashes = new TreeMap<>();
for (Map.Entry<String, String> entry : fileHashes.entrySet()) {
String path = validatePath(entry.getKey(), false);
String hash = Objects.requireNonNull(entry.getValue(), "read-set file hash");
if (!StructureHash.isSha256(hash)) {
throw new IllegalArgumentException("Read-set file hash must be SHA-256 for " + path);
}
orderedHashes.put(path, hash);
}
TreeSet<String> orderedAbsent = new TreeSet<>();
for (String path : absentPaths) {
orderedAbsent.add(validatePath(path, false));
}
for (String path : orderedHashes.keySet()) {
if (orderedAbsent.contains(path)) {
throw new IllegalArgumentException("Read-set path cannot be both present and absent: " + path);
}
}
TreeMap<String, List<String>> orderedDirectories = new TreeMap<>();
TreeSet<String> uniqueEntries = new TreeSet<>(orderedHashes.keySet());
uniqueEntries.addAll(orderedAbsent);
for (Map.Entry<String, List<String>> entry : directoryEntries.entrySet()) {
String directory = validatePath(entry.getKey(), true);
uniqueEntries.add(directory);
Objects.requireNonNull(entry.getValue(), "read-set directory entries");
TreeSet<String> entries = new TreeSet<>();
for (String child : entry.getValue()) {
String childPath = validatePath(child, false);
if (!childPath.startsWith(directory + "/")) {
throw new IllegalArgumentException(
"Read-set directory entry is outside " + directory + ": " + childPath);
}
entries.add(childPath);
}
uniqueEntries.addAll(entries);
orderedDirectories.put(directory, List.copyOf(entries));
}
if (uniqueEntries.size() > MAX_ENTRIES) {
throw new IllegalArgumentException("Read set exceeds " + MAX_ENTRIES + " entries");
}
fileHashes = Collections.unmodifiableMap(orderedHashes);
absentPaths = Collections.unmodifiableSet(orderedAbsent);
directoryEntries = Collections.unmodifiableMap(orderedDirectories);
}
public static StructureTransactionReadSet empty() {
return new StructureTransactionReadSet(Map.of(), Set.of(), Map.of());
}
public boolean isEmpty() {
return fileHashes.isEmpty() && absentPaths.isEmpty() && directoryEntries.isEmpty();
}
public List<String> paths() {
TreeSet<String> paths = new TreeSet<>(fileHashes.keySet());
paths.addAll(absentPaths);
for (List<String> entries : directoryEntries.values()) {
paths.addAll(entries);
}
return List.copyOf(paths);
}
public static Builder builder() {
return new Builder();
}
private static String validatePath(String value, boolean directory) {
Objects.requireNonNull(value, "read-set path");
String normalized = value.trim();
if (normalized.isEmpty() || !normalized.equals(value) || normalized.startsWith("/")
|| normalized.endsWith("/") || normalized.indexOf('\\') >= 0 || normalized.contains(":")) {
throw new IllegalArgumentException("Invalid read-set path: " + value);
}
Path path = Path.of(normalized).normalize();
if (path.isAbsolute() || path.getNameCount() == 0 || path.startsWith("..")
|| path.toString().equals(".")) {
throw new IllegalArgumentException("Invalid read-set path: " + value);
}
String portable = path.toString().replace('\\', '/');
if (!portable.equals(normalized)) {
throw new IllegalArgumentException("Read-set path is not normalized: " + value);
}
if (!directory && normalized.endsWith("/.")) {
throw new IllegalArgumentException("Invalid read-set file path: " + value);
}
return normalized;
}
public static final class Builder {
private final Map<String, String> fileHashes = new TreeMap<>();
private final Set<String> absentPaths = new TreeSet<>();
private final Map<String, List<String>> directoryEntries = new TreeMap<>();
public Builder file(String relativePath, String contentHash) {
fileHashes.put(relativePath, contentHash);
return this;
}
public Builder files(Map<String, String> hashes) {
fileHashes.putAll(Objects.requireNonNull(hashes, "read-set files"));
return this;
}
public Builder absent(String relativePath) {
absentPaths.add(relativePath);
return this;
}
public Builder absent(Collection<String> relativePaths) {
absentPaths.addAll(Objects.requireNonNull(relativePaths, "absent read-set paths"));
return this;
}
public Builder directory(String relativePath, Collection<String> entries) {
directoryEntries.put(relativePath, new ArrayList<>(Objects.requireNonNull(entries, "directory entries")));
return this;
}
public StructureTransactionReadSet build() {
return new StructureTransactionReadSet(fileHashes, absentPaths, directoryEntries);
}
}
}
@@ -36,6 +36,7 @@ import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -57,7 +58,10 @@ public final class StructureTransactionWriter {
private static final int MAX_RECOVERY_CLAIM_BYTES = 64 * 1024;
private static final int MAX_COORDINATOR_JOURNAL_BYTES = 4 * 1024 * 1024;
private static final int MAX_STRUCTURE_STATE_BYTES = 64 * 1024 * 1024;
private static final long MAX_VERIFIED_READ_FILE_BYTES = 256L * 1024L * 1024L;
private static final int MAX_RECOVERY_TRANSACTIONS = 1_024;
private static final LockedRemovalValidator NO_REMOVAL_VALIDATION = () -> {
};
private static final ConcurrentMap<Path, ReentrantLock> ROOT_LOCKS = new ConcurrentHashMap<>();
private static final Gson GSON = new Gson();
@@ -139,7 +143,23 @@ public final class StructureTransactionWriter {
}
public boolean removeOwned(StructureKey key, StructureSource.Kind sourceKind, StructureKey sourceKey) throws IOException {
OwnedRemoval request = new OwnedRemoval(key, sourceKind, sourceKey);
return removeOwned(new OwnedRemoval(
key,
sourceKind,
sourceKey,
Optional.empty(),
Optional.empty()));
}
public boolean removeManagedDatapackOwned(
StructureKey key,
StructureSource.Kind sourceKind,
StructureKey sourceKey
) throws IOException {
return removeOwned(OwnedRemoval.managedDatapack(key, sourceKind, sourceKey));
}
private boolean removeOwned(OwnedRemoval request) throws IOException {
try (PreparedRemoval removal = prepareOwnedRemovals(List.of(request))) {
boolean changed = removal.changed();
removal.markCommitted();
@@ -149,19 +169,30 @@ public final class StructureTransactionWriter {
}
public PreparedRemoval prepareOwnedRemovals(List<OwnedRemoval> removals) throws IOException {
return prepareOwnedRemovals(removals, false);
return prepareOwnedRemovals(removals, NO_REMOVAL_VALIDATION);
}
public PreparedRemoval prepareOwnedRemovals(
List<OwnedRemoval> removals,
LockedRemovalValidator validator
) throws IOException {
return prepareOwnedRemovals(removals, false, validator);
}
public PreparedRemoval prepareMatchingOwnedRemovals(List<OwnedRemoval> removals) throws IOException {
return prepareOwnedRemovals(removals, true);
return prepareOwnedRemovals(removals, true, NO_REMOVAL_VALIDATION);
}
private PreparedRemoval prepareOwnedRemovals(
List<OwnedRemoval> removals,
boolean skipOwnershipMismatches
boolean skipOwnershipMismatches,
LockedRemovalValidator validator
) throws IOException {
Objects.requireNonNull(removals, "removals");
List<OwnedRemoval> requests = List.copyOf(removals);
LockedRemovalValidator requiredValidator = Objects.requireNonNull(
validator,
"locked removal validator");
rootLock.lock();
ProcessLock processLock = null;
Path transactionRoot = null;
@@ -172,6 +203,7 @@ public final class StructureTransactionWriter {
if (!recovery.successful()) {
throw recoveryFailure(recovery);
}
requiredValidator.validate();
RemovalPlan plan = buildRemovalPlan(requests, skipOwnershipMismatches);
if (plan.targets().isEmpty()) {
return new PreparedRemoval(null, null, backups, processLock, false);
@@ -216,13 +248,40 @@ public final class StructureTransactionWriter {
public record OwnedRemoval(
StructureKey key,
StructureSource.Kind sourceKind,
StructureKey sourceKey
StructureKey sourceKey,
Optional<StructureOwnershipManifest.Origin> requiredOrigin,
Optional<String> expectedManifestHash
) {
public OwnedRemoval {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(sourceKind, "sourceKind");
Objects.requireNonNull(sourceKey, "sourceKey");
requiredOrigin = Objects.requireNonNull(requiredOrigin, "requiredOrigin");
expectedManifestHash = Objects.requireNonNull(expectedManifestHash, "expectedManifestHash");
if (expectedManifestHash.isPresent()
&& !StructureHash.isSha256(expectedManifestHash.get())) {
throw new IllegalArgumentException("Expected ownership manifest hash must be SHA-256");
}
}
public static OwnedRemoval managedDatapack(
StructureKey key,
StructureSource.Kind sourceKind,
StructureKey sourceKey
) {
return new OwnedRemoval(
key,
sourceKind,
sourceKey,
Optional.of(StructureOwnershipManifest.Origin.MANAGED_DATAPACK),
Optional.empty()
);
}
}
@FunctionalInterface
public interface LockedRemovalValidator {
void validate() throws IOException;
}
public record PreparedRemovalToken(Path packRoot, UUID transactionId) {
@@ -491,16 +550,121 @@ public final class StructureTransactionWriter {
return write(bundle, StructureWriteOptions.preview(mode));
}
public StructureWriteResult claimExisting(
StructureOwnershipManifest manifest,
StructureTransactionReadSet readSet
) {
Objects.requireNonNull(manifest, "manifest");
Objects.requireNonNull(readSet, "readSet");
for (Map.Entry<String, String> resource : manifest.resourceHashes().entrySet()) {
String expectedHash = readSet.fileHashes().get(resource.getKey());
if (!resource.getValue().equals(expectedHash)) {
throw new IllegalArgumentException(
"Claim read set does not pin owned resource " + resource.getKey());
}
}
rootLock.lock();
try (ProcessLock ignored = acquireProcessLock()) {
StructureRecoveryResult recovery = recoverIncompleteTransactionsLocked();
if (!recovery.successful()) {
return claimResult(
StructureWriteResult.Status.FAILED,
manifest,
List.of(),
Optional.of(recoveryFailure(recovery))
);
}
List<StructureWriteResult.Conflict> conflicts = verifyReadSet(readSet);
Path manifestPath = ownershipManifestPath(manifest.structure());
if (files.exists(manifestPath)) {
ArrayList<StructureWriteResult.Conflict> updated = new ArrayList<>(conflicts);
updated.add(StructureWriteResult.Conflict.at(
manifest.relativePath(),
StructureWriteResult.ConflictReason.MANIFEST_EXISTS));
conflicts = orderedConflicts(updated);
}
if (!conflicts.isEmpty()) {
StructureWriteResult.Status status = conflicts.stream().anyMatch(conflict ->
conflict.reason() == StructureWriteResult.ConflictReason.MANIFEST_EXISTS)
? StructureWriteResult.Status.ADD_ONLY_CONFLICT
: StructureWriteResult.Status.OWNERSHIP_CONFLICT;
return claimResult(status, manifest, conflicts, Optional.empty());
}
return commitClaim(manifest, readSet);
} catch (IOException | RuntimeException exception) {
return claimResult(
StructureWriteResult.Status.FAILED,
manifest,
List.of(),
Optional.of(exception)
);
} finally {
rootLock.unlock();
}
}
public StructureWriteResult write(StructureResourceBundle bundle, StructureWriteOptions options) {
return writeVerified(
bundle,
options,
StructureTransactionReadSet.empty(),
StructureOwnershipManifest.Provenance.created()
);
}
public StructureWriteResult writeManagedDatapack(
StructureResourceBundle bundle,
StructureWriteMode mode
) {
Objects.requireNonNull(bundle, "bundle");
Objects.requireNonNull(mode, "mode");
if (bundle.source().kind() != StructureSource.Kind.DATAPACK
&& bundle.source().kind() != StructureSource.Kind.VANILLA) {
throw new IllegalArgumentException("Managed datapack writes require a datapack or vanilla source");
}
return writeVerified(
bundle,
new StructureWriteOptions(mode, false),
StructureTransactionReadSet.empty(),
managedDatapackProvenance(bundle),
ExistingProvenancePolicy.INSTALL_MANAGED_DATAPACK
);
}
public StructureWriteResult writeVerified(
StructureResourceBundle bundle,
StructureWriteOptions options,
StructureTransactionReadSet readSet,
StructureOwnershipManifest.Provenance provenance
) {
return writeVerified(
bundle,
options,
readSet,
provenance,
ExistingProvenancePolicy.PRESERVE
);
}
private StructureWriteResult writeVerified(
StructureResourceBundle bundle,
StructureWriteOptions options,
StructureTransactionReadSet readSet,
StructureOwnershipManifest.Provenance provenance,
ExistingProvenancePolicy provenancePolicy
) {
Objects.requireNonNull(bundle, "bundle");
Objects.requireNonNull(options, "options");
Objects.requireNonNull(readSet, "readSet");
Objects.requireNonNull(provenance, "provenance");
Objects.requireNonNull(provenancePolicy, "provenancePolicy");
rootLock.lock();
try {
if (options.dryRun()) {
return writeLocked(bundle, options);
return writeLocked(bundle, options, readSet, provenance, provenancePolicy);
}
try (ProcessLock ignored = acquireProcessLock()) {
return writeLocked(bundle, options);
return writeLocked(bundle, options, readSet, provenance, provenancePolicy);
}
} catch (IOException | RuntimeException e) {
return failedResult(bundle, e);
@@ -509,7 +673,13 @@ public final class StructureTransactionWriter {
}
}
private StructureWriteResult writeLocked(StructureResourceBundle bundle, StructureWriteOptions options)
private StructureWriteResult writeLocked(
StructureResourceBundle bundle,
StructureWriteOptions options,
StructureTransactionReadSet readSet,
StructureOwnershipManifest.Provenance provenance,
ExistingProvenancePolicy provenancePolicy
)
throws IOException {
if (!options.dryRun()) {
StructureRecoveryResult recovery = recoverIncompleteTransactionsLocked();
@@ -517,7 +687,11 @@ public final class StructureTransactionWriter {
return failedResult(bundle, recoveryFailure(recovery));
}
}
WritePlan plan = buildPlan(bundle, options.mode());
List<StructureWriteResult.Conflict> readSetConflicts = verifyReadSet(readSet);
if (!readSetConflicts.isEmpty()) {
return readSetConflictResult(bundle, readSetConflicts);
}
WritePlan plan = buildPlan(bundle, options, provenance, provenancePolicy);
if (!plan.conflicts().isEmpty()) {
StructureWriteResult.Status status = options.mode() == StructureWriteMode.ADD_ONLY
? StructureWriteResult.Status.ADD_ONLY_CONFLICT
@@ -530,7 +704,7 @@ public final class StructureTransactionWriter {
if (plan.action() == StructureWriteResult.Action.NONE) {
return result(StructureWriteResult.Status.UNCHANGED, plan, Optional.empty());
}
return commit(plan);
return commit(plan, readSet);
}
private RemovalPlan buildRemovalPlan(
@@ -551,6 +725,12 @@ public final class StructureTransactionWriter {
MAX_STRUCTURE_STATE_BYTES,
"Structure ownership manifest"
);
String manifestHash = StructureHash.sha256(manifestContent);
if (removal.expectedManifestHash().isPresent()
&& !removal.expectedManifestHash().get().equals(manifestHash)) {
throw new IOException("Structure ownership manifest changed after removal was planned: "
+ removal.key());
}
StructureOwnershipManifest manifest;
try {
manifest = StructureOwnershipManifest.fromJson(manifestContent);
@@ -560,11 +740,18 @@ public final class StructureTransactionWriter {
if (!manifest.structure().equals(removal.key())) {
throw new IOException("Structure ownership manifest belongs to " + manifest.structure());
}
if (manifest.source().kind() != removal.sourceKind()
|| !manifest.source().key().equals(removal.sourceKey())) {
boolean sourceMismatch = manifest.source().kind() != removal.sourceKind()
|| !manifest.source().key().equals(removal.sourceKey());
boolean originMismatch = removal.requiredOrigin().isPresent()
&& manifest.provenance().origin() != removal.requiredOrigin().get();
if (sourceMismatch || originMismatch) {
if (skipOwnershipMismatches) {
continue;
}
if (originMismatch) {
throw new IOException("Structure '" + removal.key()
+ "' is not owned by managed datapack ingest");
}
throw new IOException("Structure '" + removal.key() + "' is owned by source "
+ manifest.source().key() + " (" + manifest.source().kind() + "), not "
+ removal.sourceKey() + " (" + removal.sourceKind() + ")");
@@ -1036,8 +1223,61 @@ public final class StructureTransactionWriter {
return failure;
}
private WritePlan buildPlan(StructureResourceBundle bundle, StructureWriteMode mode) throws IOException {
StructureOwnershipManifest nextManifest = StructureOwnershipManifest.from(bundle);
private StructureOwnershipManifest.Provenance managedDatapackProvenance(
StructureResourceBundle bundle
) {
TreeMap<String, String> hashes = new TreeMap<>();
TreeMap<String, String> mappings = new TreeMap<>();
StringBuilder closure = new StringBuilder();
for (StructureResourceBundle.Resource resource : bundle.resources().values()) {
hashes.put(resource.relativePath(), resource.contentHash());
mappings.put(resource.relativePath(), resource.relativePath());
closure.append(resource.relativePath())
.append('=')
.append(resource.contentHash())
.append('\n');
}
String closureHash = StructureHash.sha256(closure.toString().getBytes(StandardCharsets.UTF_8));
StructureSource source = bundle.source();
String planInput = "managed-datapack\n"
+ source.kind() + '\n'
+ source.key().value() + '\n'
+ source.version() + '\n'
+ source.contentHash() + '\n'
+ closureHash;
return new StructureOwnershipManifest.Provenance(
StructureOwnershipManifest.Origin.MANAGED_DATAPACK,
UUID.randomUUID().toString(),
StructureHash.sha256(planInput.getBytes(StandardCharsets.UTF_8)),
closureHash,
Math.max(1L, System.currentTimeMillis()),
hashes,
mappings,
StructureOwnershipManifest.RollbackDisposition.NONE
);
}
private boolean managedDatapackUpgradeAllowed(
StructureOwnershipManifest previousManifest,
StructureSource nextSource
) {
StructureOwnershipManifest.Origin origin = previousManifest.provenance().origin();
if (origin != StructureOwnershipManifest.Origin.CREATED
&& origin != StructureOwnershipManifest.Origin.MANAGED_DATAPACK) {
return false;
}
return previousManifest.source().kind() == nextSource.kind()
&& previousManifest.source().key().equals(nextSource.key());
}
private WritePlan buildPlan(
StructureResourceBundle bundle,
StructureWriteOptions options,
StructureOwnershipManifest.Provenance provenance,
ExistingProvenancePolicy provenancePolicy
) throws IOException {
StructureWriteMode mode = options.mode();
StructureOwnershipManifest nextManifest = StructureOwnershipManifest.from(bundle, provenance);
String manifestRelativePath = nextManifest.relativePath();
Path manifestPath = resolveTarget(manifestRelativePath);
ArrayList<StructureWriteResult.Conflict> conflicts = new ArrayList<>();
@@ -1061,6 +1301,12 @@ public final class StructureTransactionWriter {
}
if (!files.exists(manifestPath)) {
if (!options.expectedManifestHash().isEmpty()) {
conflicts.add(StructureWriteResult.Conflict.staleManifest(
manifestRelativePath,
options.expectedManifestHash(),
""));
}
findUnownedResources(bundle, previousResourceHashes, conflicts);
return createPlan(
bundle,
@@ -1085,13 +1331,45 @@ public final class StructureTransactionWriter {
);
}
StructureOwnershipManifest previousManifest;
byte[] manifestContent;
try {
previousManifest = StructureOwnershipManifest.fromJson(readBoundedBytes(
manifestContent = readBoundedBytes(
manifestPath,
MAX_STRUCTURE_STATE_BYTES,
"Structure ownership manifest"
));
);
} catch (IOException exception) {
conflicts.add(StructureWriteResult.Conflict.invalidManifest(
manifestRelativePath,
exception.toString()));
return createPlan(
bundle,
nextManifest,
previousResourceHashes,
StructureWriteResult.Action.OVERWRITE,
conflicts
);
}
if (!options.expectedManifestHash().isEmpty()) {
String actualManifestHash = StructureHash.sha256(manifestContent);
if (!options.expectedManifestHash().equals(actualManifestHash)) {
conflicts.add(StructureWriteResult.Conflict.staleManifest(
manifestRelativePath,
options.expectedManifestHash(),
actualManifestHash));
return createPlan(
bundle,
nextManifest,
previousResourceHashes,
StructureWriteResult.Action.OVERWRITE,
conflicts
);
}
}
StructureOwnershipManifest previousManifest;
try {
previousManifest = StructureOwnershipManifest.fromJson(manifestContent);
} catch (RuntimeException e) {
conflicts.add(StructureWriteResult.Conflict.invalidManifest(manifestRelativePath, e.toString()));
return createPlan(
@@ -1117,6 +1395,24 @@ public final class StructureTransactionWriter {
);
}
if (provenancePolicy == ExistingProvenancePolicy.PRESERVE) {
nextManifest = StructureOwnershipManifest.from(bundle, previousManifest.provenance());
} else if (!managedDatapackUpgradeAllowed(previousManifest, bundle.source())) {
conflicts.add(new StructureWriteResult.Conflict(
manifestRelativePath,
StructureWriteResult.ConflictReason.PROVENANCE_MISMATCH,
"",
"",
"Managed datapack import cannot replace non-managed structure provenance"
));
return createPlan(
bundle,
nextManifest,
previousResourceHashes,
StructureWriteResult.Action.OVERWRITE,
conflicts
);
}
previousResourceHashes.putAll(previousManifest.resourceHashes());
verifyOwnedResources(previousResourceHashes, conflicts);
findUnownedResources(bundle, previousResourceHashes, conflicts);
@@ -1205,7 +1501,296 @@ public final class StructureTransactionWriter {
);
}
private StructureWriteResult commit(WritePlan plan) {
private List<StructureWriteResult.Conflict> verifyReadSet(StructureTransactionReadSet readSet) {
if (readSet.isEmpty()) {
return List.of();
}
ArrayList<StructureWriteResult.Conflict> conflicts = new ArrayList<>();
for (Map.Entry<String, String> entry : readSet.fileHashes().entrySet()) {
String relativePath = entry.getKey();
String expectedHash = entry.getValue();
try {
Path target = resolveTarget(relativePath);
if (!files.exists(target)) {
conflicts.add(StructureWriteResult.Conflict.staleReadSet(
relativePath,
expectedHash,
"",
"Read-set file is missing"));
} else if (!files.isRegularFile(target)) {
conflicts.add(StructureWriteResult.Conflict.staleReadSet(
relativePath,
expectedHash,
"",
"Read-set path is not a regular file"));
} else {
String actualHash = sha256ReadSetTarget(target);
if (!expectedHash.equals(actualHash)) {
conflicts.add(StructureWriteResult.Conflict.staleReadSet(
relativePath,
expectedHash,
actualHash,
"Read-set file content changed"));
}
}
} catch (IOException | RuntimeException exception) {
conflicts.add(StructureWriteResult.Conflict.staleReadSet(
relativePath,
expectedHash,
"",
"Cannot verify read-set file: " + describe(exception)));
}
}
for (String relativePath : readSet.absentPaths()) {
try {
Path target = resolveTarget(relativePath);
if (files.exists(target)) {
conflicts.add(StructureWriteResult.Conflict.staleReadSet(
relativePath,
"",
"",
"Read-set path was expected to remain absent"));
}
} catch (RuntimeException exception) {
conflicts.add(StructureWriteResult.Conflict.staleReadSet(
relativePath,
"",
"",
"Cannot verify absent read-set path: " + describe(exception)));
}
}
for (Map.Entry<String, List<String>> entry : readSet.directoryEntries().entrySet()) {
String relativePath = entry.getKey();
List<String> expectedEntries = entry.getValue();
try {
List<String> actualEntries = actualDirectoryEntries(relativePath);
if (!expectedEntries.equals(actualEntries)) {
String expectedHash = StructureHash.sha256(
String.join("\n", expectedEntries).getBytes(StandardCharsets.UTF_8));
String actualHash = StructureHash.sha256(
String.join("\n", actualEntries).getBytes(StandardCharsets.UTF_8));
conflicts.add(StructureWriteResult.Conflict.staleReadSet(
relativePath,
expectedHash,
actualHash,
"Read-set directory membership changed"));
}
} catch (IOException | RuntimeException exception) {
conflicts.add(StructureWriteResult.Conflict.staleReadSet(
relativePath,
"",
"",
"Cannot verify read-set directory: " + describe(exception)));
}
}
return orderedConflicts(conflicts);
}
private List<String> actualDirectoryEntries(String relativePath) throws IOException {
Path directory = resolveTarget(relativePath);
if (!files.exists(directory)) {
return List.of();
}
if (!files.isDirectory(directory)) {
throw new IOException("Read-set directory is not a directory: " + relativePath);
}
ArrayList<String> entries = new ArrayList<>();
try (Stream<Path> paths = Files.walk(directory)) {
Iterator<Path> iterator = paths.iterator();
while (iterator.hasNext()) {
Path path = iterator.next();
if (path.equals(directory)) {
continue;
}
if (Files.isSymbolicLink(path)) {
throw new IOException("Read-set directory contains a symbolic link: " + path);
}
if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Read-set directory contains a non-file entry: " + path);
}
entries.add(packRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/'));
if (entries.size() > StructureTransactionReadSet.MAX_ENTRIES) {
throw new IOException("Read-set directory exceeds "
+ StructureTransactionReadSet.MAX_ENTRIES + " entries");
}
}
}
Collections.sort(entries);
return List.copyOf(entries);
}
private String sha256ReadSetTarget(Path target) throws IOException {
try (InputStream input = Files.newInputStream(
target,
StandardOpenOption.READ,
LinkOption.NOFOLLOW_LINKS)) {
return StructureHash.sha256(new BoundedReadSetInputStream(input));
}
}
private void requireReadSetUnchanged(StructureTransactionReadSet readSet) throws IOException {
List<StructureWriteResult.Conflict> conflicts = verifyReadSet(readSet);
if (conflicts.isEmpty()) {
return;
}
StructureWriteResult.Conflict conflict = conflicts.getFirst();
throw new ReadSetChangedException(conflict);
}
private StructureWriteResult readSetConflictResult(
StructureResourceBundle bundle,
List<StructureWriteResult.Conflict> conflicts
) {
StructureOwnershipManifest manifest = StructureOwnershipManifest.from(bundle);
TreeSet<String> affectedResources = new TreeSet<>(bundle.resources().keySet());
affectedResources.addAll(conflicts.stream().map(StructureWriteResult.Conflict::relativePath).toList());
affectedResources.add(manifest.relativePath());
return new StructureWriteResult(
StructureWriteResult.Status.OWNERSHIP_CONFLICT,
StructureWriteResult.Action.NONE,
orderedConflicts(conflicts),
List.copyOf(affectedResources),
manifest.relativePath(),
Optional.empty()
);
}
private List<StructureWriteResult.Conflict> orderedConflicts(
List<StructureWriteResult.Conflict> conflicts
) {
ArrayList<StructureWriteResult.Conflict> ordered = new ArrayList<>(conflicts);
ordered.sort((left, right) -> left.relativePath().compareTo(right.relativePath()));
return List.copyOf(ordered);
}
private StructureWriteResult commitClaim(
StructureOwnershipManifest manifest,
StructureTransactionReadSet readSet
) {
UUID transactionId = UUID.randomUUID();
Path transactionRoot = stagingRoot().resolve(transactionId.toString()).normalize();
Path stagedRoot = transactionRoot.resolve("staged");
Path backupRoot = transactionRoot.resolve("backup");
Path stagedManifest = stagedRoot.resolve("ownership-manifest.json");
byte[] manifestContent = manifest.toJson();
ArrayList<InstalledTarget> installedTargets = new ArrayList<>();
StructureTransactionJournal journal;
try {
if (manifestContent.length > MAX_STRUCTURE_STATE_BYTES) {
throw new IOException("Structure ownership manifest exceeds "
+ MAX_STRUCTURE_STATE_BYTES + " bytes");
}
files.createDirectories(stagedRoot);
files.createDirectories(backupRoot);
files.writeNew(stagedManifest, manifestContent);
files.forceFile(stagedManifest);
StructureTransactionJournal.Target target = new StructureTransactionJournal.Target(
manifest.relativePath(),
false,
"",
StructureHash.sha256(manifestContent));
journal = StructureTransactionJournal.prepared(transactionId, List.of(target));
writeJournal(transactionRoot, journal);
files.forceDirectory(transactionRoot);
files.forceDirectory(stagingRoot());
requireReadSetUnchanged(readSet);
verifyTargetSnapshot(journal);
Path manifestTarget = resolveTarget(manifest.relativePath());
files.createDirectories(Objects.requireNonNull(manifestTarget.getParent(), "manifest target parent"));
files.moveNew(stagedManifest, manifestTarget);
installedTargets.add(new InstalledTarget(manifestTarget, StructureHash.sha256(manifestContent)));
files.forceFile(manifestTarget);
files.forceDirectory(Objects.requireNonNull(manifestTarget.getParent(), "manifest target parent"));
} catch (IOException | RuntimeException commitFailure) {
return rollbackClaimResult(manifest, transactionRoot, installedTargets, commitFailure);
}
boolean committedJournalWritten = false;
try {
writeJournal(transactionRoot, journal.committed());
committedJournalWritten = true;
files.forceDirectory(transactionRoot);
} catch (IOException | RuntimeException commitPhaseFailure) {
if (committedJournalWritten || isCommittedJournal(transactionRoot, commitPhaseFailure)) {
return claimResult(
StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED,
manifest,
List.of(),
Optional.of(commitPhaseFailure));
}
return rollbackClaimResult(manifest, transactionRoot, installedTargets, commitPhaseFailure);
}
try {
cleanupTransaction(transactionRoot);
} catch (IOException | RuntimeException cleanupFailure) {
return claimResult(
StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED,
manifest,
List.of(),
Optional.of(cleanupFailure));
}
return claimResult(
StructureWriteResult.Status.ADDED,
manifest,
List.of(),
Optional.empty());
}
private StructureWriteResult rollbackClaimResult(
StructureOwnershipManifest manifest,
Path transactionRoot,
List<InstalledTarget> installedTargets,
Throwable failure
) {
Optional<Throwable> rollbackFailure = rollback(Map.of(), installedTargets);
if (rollbackFailure.isPresent()) {
failure.addSuppressed(new IOException(
"Transaction recovery data retained at " + transactionRoot,
rollbackFailure.get()));
return claimResult(
StructureWriteResult.Status.FAILED,
manifest,
List.of(),
Optional.of(failure));
}
cleanupAfterFailure(transactionRoot, failure);
if (failure instanceof ReadSetChangedException readSetChanged) {
return claimResult(
StructureWriteResult.Status.OWNERSHIP_CONFLICT,
manifest,
List.of(readSetChanged.conflict()),
Optional.empty());
}
return claimResult(
StructureWriteResult.Status.ROLLED_BACK,
manifest,
List.of(),
Optional.of(failure));
}
private StructureWriteResult claimResult(
StructureWriteResult.Status status,
StructureOwnershipManifest manifest,
List<StructureWriteResult.Conflict> conflicts,
Optional<Throwable> failure
) {
TreeSet<String> affectedResources = new TreeSet<>(manifest.resourceHashes().keySet());
affectedResources.add(manifest.relativePath());
return new StructureWriteResult(
status,
StructureWriteResult.Action.ADD,
orderedConflicts(conflicts),
List.copyOf(affectedResources),
manifest.relativePath(),
failure
);
}
private StructureWriteResult commit(WritePlan plan, StructureTransactionReadSet readSet) {
UUID transactionId = UUID.randomUUID();
Path transactionRoot = stagingRoot().resolve(transactionId.toString()).normalize();
Path stagedRoot = transactionRoot.resolve("staged");
@@ -1223,6 +1808,7 @@ public final class StructureTransactionWriter {
writeJournal(transactionRoot, journal);
files.forceDirectory(transactionRoot);
files.forceDirectory(stagingRoot());
requireReadSetUnchanged(readSet);
verifyTargetSnapshot(journal);
backupTargets(journal, backupRoot, backups);
installResources(plan, stagedRoot, stagedManifest, installedTargets);
@@ -1401,6 +1987,9 @@ public final class StructureTransactionWriter {
return result(StructureWriteResult.Status.FAILED, plan, Optional.of(commitFailure));
}
cleanupAfterFailure(transactionRoot, commitFailure);
if (commitFailure instanceof ReadSetChangedException readSetChanged) {
return readSetConflictResult(plan.bundle(), List.of(readSetChanged.conflict()));
}
return result(StructureWriteResult.Status.ROLLED_BACK, plan, Optional.of(commitFailure));
}
@@ -1466,6 +2055,11 @@ public final class StructureTransactionWriter {
return existing;
}
private String describe(Throwable throwable) {
String message = throwable.getMessage();
return message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message;
}
private void verifyOriginalTarget(
Path target,
StructureTransactionJournal.Target state
@@ -1685,6 +2279,11 @@ public final class StructureTransactionWriter {
CLEANED_ORPHAN
}
private enum ExistingProvenancePolicy {
PRESERVE,
INSTALL_MANAGED_DATAPACK
}
private record InstalledTarget(Path path, String contentHash) {
private InstalledTarget {
Objects.requireNonNull(path, "path");
@@ -1694,6 +2293,55 @@ public final class StructureTransactionWriter {
}
}
private static final class ReadSetChangedException extends IOException {
private final StructureWriteResult.Conflict conflict;
private ReadSetChangedException(StructureWriteResult.Conflict conflict) {
super("Structure transaction read set changed at " + conflict.relativePath()
+ ": " + conflict.detail());
this.conflict = conflict;
}
private StructureWriteResult.Conflict conflict() {
return conflict;
}
}
private static final class BoundedReadSetInputStream extends InputStream {
private final InputStream delegate;
private long consumed;
private BoundedReadSetInputStream(InputStream delegate) {
this.delegate = delegate;
}
@Override
public int read() throws IOException {
int value = delegate.read();
if (value >= 0) {
recordBytes(1L);
}
return value;
}
@Override
public int read(byte[] buffer, int offset, int length) throws IOException {
int read = delegate.read(buffer, offset, length);
if (read > 0) {
recordBytes(read);
}
return read;
}
private void recordBytes(long bytes) throws IOException {
consumed = Math.addExact(consumed, bytes);
if (consumed > MAX_VERIFIED_READ_FILE_BYTES) {
throw new IOException("Verified read-set file exceeds "
+ MAX_VERIFIED_READ_FILE_BYTES + " bytes");
}
}
}
private record RemovalPlan(List<StructureTransactionJournal.Target> targets) {
}
@@ -20,9 +20,20 @@ package art.arcane.iris.core.structure.authoring;
import java.util.Objects;
public record StructureWriteOptions(StructureWriteMode mode, boolean dryRun) {
public record StructureWriteOptions(StructureWriteMode mode, boolean dryRun, String expectedManifestHash) {
public StructureWriteOptions {
Objects.requireNonNull(mode, "mode");
expectedManifestHash = expectedManifestHash == null ? "" : expectedManifestHash.trim();
if (!expectedManifestHash.isEmpty() && !StructureHash.isSha256(expectedManifestHash)) {
throw new IllegalArgumentException("Expected structure manifest hash must be SHA-256");
}
if (!expectedManifestHash.isEmpty() && mode != StructureWriteMode.OVERWRITE) {
throw new IllegalArgumentException("Expected structure manifest hashes require overwrite mode");
}
}
public StructureWriteOptions(StructureWriteMode mode, boolean dryRun) {
this(mode, dryRun, "");
}
public static StructureWriteOptions addOnly() {
@@ -36,4 +47,8 @@ public record StructureWriteOptions(StructureWriteMode mode, boolean dryRun) {
public static StructureWriteOptions preview(StructureWriteMode mode) {
return new StructureWriteOptions(mode, true);
}
public static StructureWriteOptions overwriteExpected(String manifestHash) {
return new StructureWriteOptions(StructureWriteMode.OVERWRITE, false, manifestHash);
}
}
@@ -80,7 +80,10 @@ public record StructureWriteResult(
MODIFIED_RESOURCE,
MISSING_OWNED_RESOURCE,
NON_FILE_RESOURCE,
INVALID_MANIFEST
INVALID_MANIFEST,
PROVENANCE_MISMATCH,
STALE_MANIFEST,
STALE_READ_SET
}
public record Conflict(
@@ -115,5 +118,30 @@ public record StructureWriteResult(
public static Conflict invalidManifest(String relativePath, String detail) {
return new Conflict(relativePath, ConflictReason.INVALID_MANIFEST, "", "", detail);
}
public static Conflict staleManifest(String relativePath, String expectedHash, String actualHash) {
return new Conflict(
relativePath,
ConflictReason.STALE_MANIFEST,
expectedHash,
actualHash,
"Ownership manifest changed after the graph edit was loaded"
);
}
public static Conflict staleReadSet(
String relativePath,
String expectedHash,
String actualHash,
String detail
) {
return new Conflict(
relativePath,
ConflictReason.STALE_READ_SET,
expectedHash,
actualHash,
detail
);
}
}
}
@@ -0,0 +1,88 @@
package art.arcane.iris.core.structure.conversion;
import java.util.Objects;
public record IrisStructureAdoptionDiagnostic(
Severity severity,
Code code,
String resource,
String detail,
String recommendation
) implements Comparable<IrisStructureAdoptionDiagnostic> {
public IrisStructureAdoptionDiagnostic {
Objects.requireNonNull(severity, "severity");
Objects.requireNonNull(code, "code");
resource = normalize(resource);
detail = requireText(detail, "detail");
recommendation = normalize(recommendation);
}
public String summary() {
String location = resource.isEmpty() ? "" : " [" + resource + "]";
String action = recommendation.isEmpty() ? "" : " " + recommendation;
return severity + " " + code + location + ": " + detail + action;
}
public boolean blocking() {
return severity == Severity.ERROR;
}
@Override
public int compareTo(IrisStructureAdoptionDiagnostic other) {
int severityComparison = severity.compareTo(other.severity);
if (severityComparison != 0) {
return severityComparison;
}
int codeComparison = code.compareTo(other.code);
if (codeComparison != 0) {
return codeComparison;
}
int resourceComparison = resource.compareTo(other.resource);
if (resourceComparison != 0) {
return resourceComparison;
}
return detail.compareTo(other.detail);
}
private static String requireText(String value, String name) {
String normalized = normalize(value);
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Adoption diagnostic " + name + " cannot be blank");
}
return normalized;
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
public enum Severity {
ERROR,
WARNING,
INFO
}
public enum Code {
SOURCE_GRAPH_INVALID,
SOURCE_ALREADY_OWNED,
SOURCE_RESOURCE_UNSAFE,
SOURCE_RESOURCE_LIMIT,
SOURCE_RESOURCE_CHANGED,
TARGET_NAMESPACE_UNSUPPORTED,
TARGET_RESOURCE_EXISTS,
TARGET_RESOURCE_UNSAFE,
TARGET_MAPPING_COLLISION,
TARGET_REQUIRED_FOR_CLONE,
MANAGED_INPUT_REQUIRES_CLONE,
SHARED_DEPENDENCY,
EXCLUSIVITY_UNPROVEN,
IN_PLACE_AVAILABLE,
CLONE_SELECTED,
PLAN_BLOCKED,
PLAN_EXPIRED,
PLAN_UNKNOWN,
PLAN_STALE,
TRANSACTION_FAILED,
APPLIED
}
}
@@ -0,0 +1,7 @@
package art.arcane.iris.core.structure.conversion;
public enum IrisStructureAdoptionDisposition {
IN_PLACE,
CLONE_REQUIRED,
BLOCKED
}
@@ -0,0 +1,6 @@
package art.arcane.iris.core.structure.conversion;
public enum IrisStructureAdoptionInputKind {
UNOWNED_IRIS,
MANAGED_DATAPACK
}
@@ -0,0 +1,56 @@
package art.arcane.iris.core.structure.conversion;
import java.time.Duration;
import java.util.Objects;
public record IrisStructureAdoptionLimits(
int maxResources,
int maxJsonBytes,
int maxBinaryBytes,
long maxTotalBytes,
int maxStructuresScanned,
int maxDiagnostics,
int maxActivePlans,
Duration planTtl
) {
public IrisStructureAdoptionLimits {
if (maxResources < 1 || maxResources > 100_000) {
throw new IllegalArgumentException("Adoption resource limit must be between 1 and 100000");
}
if (maxJsonBytes < 1 || maxJsonBytes > 64 * 1024 * 1024) {
throw new IllegalArgumentException("Adoption JSON limit must be between 1 and 67108864 bytes");
}
if (maxBinaryBytes < 1 || maxBinaryBytes > 256 * 1024 * 1024) {
throw new IllegalArgumentException("Adoption binary limit must be between 1 and 268435456 bytes");
}
if (maxTotalBytes < 1L || maxTotalBytes > 1024L * 1024L * 1024L) {
throw new IllegalArgumentException("Adoption aggregate limit must be between 1 and 1073741824 bytes");
}
if (maxStructuresScanned < 1 || maxStructuresScanned > 100_000) {
throw new IllegalArgumentException("Adoption structure scan limit must be between 1 and 100000");
}
if (maxDiagnostics < 1 || maxDiagnostics > 10_000) {
throw new IllegalArgumentException("Adoption diagnostic limit must be between 1 and 10000");
}
if (maxActivePlans < 1 || maxActivePlans > 10_000) {
throw new IllegalArgumentException("Adoption active-plan limit must be between 1 and 10000");
}
planTtl = Objects.requireNonNull(planTtl, "planTtl");
if (planTtl.isNegative() || planTtl.isZero() || planTtl.compareTo(Duration.ofHours(24L)) > 0) {
throw new IllegalArgumentException("Adoption plan TTL must be between one nanosecond and 24 hours");
}
}
public static IrisStructureAdoptionLimits defaults() {
return new IrisStructureAdoptionLimits(
10_000,
8 * 1024 * 1024,
64 * 1024 * 1024,
1024L * 1024L * 1024L,
10_000,
1_000,
1_024,
Duration.ofMinutes(15L)
);
}
}
@@ -0,0 +1,107 @@
package art.arcane.iris.core.structure.conversion;
import art.arcane.iris.core.structure.authoring.StructureHash;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureTransactionReadSet;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.UUID;
public record IrisStructureAdoptionPlan(
UUID planId,
Instant createdAt,
Instant expiresAt,
IrisStructureAdoptionRequest request,
StructureKey targetStructure,
IrisStructureAdoptionDisposition disposition,
List<IrisStructureAdoptionDiagnostic> diagnostics,
Map<String, String> sourceResourceHashes,
Map<String, String> sourceToTargetPaths,
StructureTransactionReadSet readSet,
long totalSourceBytes,
String sourceClosureHash,
String planHash
) {
public IrisStructureAdoptionPlan {
Objects.requireNonNull(planId, "planId");
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(expiresAt, "expiresAt");
Objects.requireNonNull(request, "request");
Objects.requireNonNull(targetStructure, "targetStructure");
Objects.requireNonNull(disposition, "disposition");
Objects.requireNonNull(diagnostics, "diagnostics");
diagnostics = sortedDiagnostics(diagnostics);
sourceResourceHashes = immutableMap(sourceResourceHashes);
sourceToTargetPaths = immutableMap(sourceToTargetPaths);
readSet = Objects.requireNonNull(readSet, "readSet");
if (!expiresAt.isAfter(createdAt)) {
throw new IllegalArgumentException("Adoption plan expiry must be after creation");
}
if (totalSourceBytes < 0L) {
throw new IllegalArgumentException("Adoption plan source bytes cannot be negative");
}
requireHash(sourceClosureHash, "source closure");
requireHash(planHash, "plan");
if (sourceResourceHashes.isEmpty() && disposition != IrisStructureAdoptionDisposition.BLOCKED) {
throw new IllegalArgumentException("Applicable adoption plan requires source resources");
}
}
public boolean canApply() {
return disposition != IrisStructureAdoptionDisposition.BLOCKED
&& diagnostics.stream().noneMatch(IrisStructureAdoptionDiagnostic::blocking);
}
public boolean expiredAt(Instant instant) {
return !Objects.requireNonNull(instant, "instant").isBefore(expiresAt);
}
public int resourceCount() {
return sourceResourceHashes.size();
}
public long errorCount() {
return diagnostics.stream().filter(IrisStructureAdoptionDiagnostic::blocking).count();
}
public long warningCount() {
return diagnostics.stream().filter(diagnostic ->
diagnostic.severity() == IrisStructureAdoptionDiagnostic.Severity.WARNING).count();
}
public List<String> summaryLines() {
ArrayList<String> lines = new ArrayList<>();
lines.add("Plan " + planId + " -> " + disposition + " for " + targetStructure.value());
lines.add(resourceCount() + " resources, " + totalSourceBytes + " bytes, "
+ errorCount() + " errors, " + warningCount() + " warnings");
for (IrisStructureAdoptionDiagnostic diagnostic : diagnostics) {
lines.add(diagnostic.summary());
}
return List.copyOf(lines);
}
private static List<IrisStructureAdoptionDiagnostic> sortedDiagnostics(
List<IrisStructureAdoptionDiagnostic> values
) {
ArrayList<IrisStructureAdoptionDiagnostic> ordered = new ArrayList<>(values);
ordered.sort(IrisStructureAdoptionDiagnostic::compareTo);
return List.copyOf(ordered);
}
private static Map<String, String> immutableMap(Map<String, String> values) {
Objects.requireNonNull(values, "plan values");
return Collections.unmodifiableMap(new TreeMap<>(values));
}
private static void requireHash(String value, String kind) {
if (!StructureHash.isSha256(value)) {
throw new IllegalArgumentException("Adoption " + kind + " hash must be SHA-256");
}
}
}
@@ -0,0 +1,82 @@
package art.arcane.iris.core.structure.conversion;
import art.arcane.iris.core.structure.authoring.StructureHash;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.UUID;
public record IrisStructureAdoptionReceipt(
UUID receiptId,
UUID planId,
StructureOwnershipManifest.Origin origin,
Instant appliedAt,
StructureKey sourceStructure,
StructureKey targetStructure,
String sourceClosureHash,
String planHash,
Map<String, String> sourceResourceHashes,
Map<String, String> targetResourceHashes,
Map<String, String> sourceToTargetPaths,
StructureOwnershipManifest.RollbackDisposition rollbackDisposition
) {
public IrisStructureAdoptionReceipt {
Objects.requireNonNull(receiptId, "receiptId");
Objects.requireNonNull(planId, "planId");
Objects.requireNonNull(origin, "origin");
Objects.requireNonNull(appliedAt, "appliedAt");
Objects.requireNonNull(sourceStructure, "sourceStructure");
Objects.requireNonNull(targetStructure, "targetStructure");
requireHash(sourceClosureHash, "source closure");
requireHash(planHash, "plan");
sourceResourceHashes = immutableMap(sourceResourceHashes, true);
targetResourceHashes = immutableMap(targetResourceHashes, true);
sourceToTargetPaths = immutableMap(sourceToTargetPaths, false);
rollbackDisposition = Objects.requireNonNull(rollbackDisposition, "rollbackDisposition");
if (sourceResourceHashes.isEmpty() || targetResourceHashes.isEmpty() || sourceToTargetPaths.isEmpty()) {
throw new IllegalArgumentException("Adoption receipt requires source, target, and mapping entries");
}
}
public StructureOwnershipManifest.Provenance provenance() {
return new StructureOwnershipManifest.Provenance(
origin,
receiptId.toString(),
planHash,
sourceClosureHash,
appliedAt.toEpochMilli(),
sourceResourceHashes,
sourceToTargetPaths,
rollbackDisposition
);
}
public boolean rollbackAvailable() {
return rollbackDisposition != StructureOwnershipManifest.RollbackDisposition.NONE;
}
private static Map<String, String> immutableMap(Map<String, String> values, boolean hashes) {
Objects.requireNonNull(values, "receipt values");
TreeMap<String, String> ordered = new TreeMap<>();
for (Map.Entry<String, String> entry : values.entrySet()) {
String key = Objects.requireNonNull(entry.getKey(), "receipt map key");
String value = Objects.requireNonNull(entry.getValue(), "receipt map value");
if (hashes) {
requireHash(value, "resource");
}
ordered.put(key, value);
}
return Collections.unmodifiableMap(ordered);
}
private static void requireHash(String value, String kind) {
if (!StructureHash.isSha256(value)) {
throw new IllegalArgumentException("Adoption receipt " + kind + " hash must be SHA-256");
}
}
}
@@ -0,0 +1,54 @@
package art.arcane.iris.core.structure.conversion;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import java.util.Objects;
import java.util.Optional;
public record IrisStructureAdoptionRequest(
String sourceStructure,
Optional<StructureKey> requestedTarget,
IrisStructureAdoptionStrategy strategy,
IrisStructureAdoptionInputKind inputKind
) {
public IrisStructureAdoptionRequest {
sourceStructure = requireInternalKey(sourceStructure);
requestedTarget = Objects.requireNonNull(requestedTarget, "requestedTarget");
strategy = Objects.requireNonNull(strategy, "strategy");
inputKind = Objects.requireNonNull(inputKind, "inputKind");
}
public static IrisStructureAdoptionRequest unowned(String sourceStructure) {
return new IrisStructureAdoptionRequest(
sourceStructure,
Optional.empty(),
IrisStructureAdoptionStrategy.AUTO,
IrisStructureAdoptionInputKind.UNOWNED_IRIS
);
}
public static IrisStructureAdoptionRequest cloneTo(String sourceStructure, StructureKey target) {
return new IrisStructureAdoptionRequest(
sourceStructure,
Optional.of(Objects.requireNonNull(target, "target")),
IrisStructureAdoptionStrategy.CLONE,
IrisStructureAdoptionInputKind.UNOWNED_IRIS
);
}
public StructureKey sourceOwnershipKey() {
return new StructureKey("iris", sourceStructure);
}
private static String requireInternalKey(String value) {
Objects.requireNonNull(value, "sourceStructure");
String normalized = value.trim();
if (!normalized.equals(value)) {
throw new IllegalArgumentException("Source structure key cannot contain surrounding whitespace");
}
StructureResourceBundle.validateRelativePath("structures/" + normalized + ".json");
new StructureKey("iris", normalized);
return normalized;
}
}
@@ -0,0 +1,54 @@
package art.arcane.iris.core.structure.conversion;
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
public record IrisStructureAdoptionResult(
Status status,
UUID planId,
List<IrisStructureAdoptionDiagnostic> diagnostics,
Optional<IrisStructureAdoptionReceipt> receipt,
Optional<StructureWriteResult> writeResult
) {
public IrisStructureAdoptionResult {
Objects.requireNonNull(status, "status");
Objects.requireNonNull(planId, "planId");
Objects.requireNonNull(diagnostics, "diagnostics");
Objects.requireNonNull(receipt, "receipt");
Objects.requireNonNull(writeResult, "writeResult");
diagnostics = List.copyOf(diagnostics);
if (status == Status.APPLIED && receipt.isEmpty()) {
throw new IllegalArgumentException("Applied adoption result requires a receipt");
}
if (status != Status.APPLIED && receipt.isPresent()) {
throw new IllegalArgumentException("Failed adoption result cannot expose a receipt");
}
}
public boolean successful() {
return status == Status.APPLIED;
}
public List<String> summaryLines() {
ArrayList<String> lines = new ArrayList<>();
lines.add("Adoption plan " + planId + ": " + status);
for (IrisStructureAdoptionDiagnostic diagnostic : diagnostics) {
lines.add(diagnostic.summary());
}
return List.copyOf(lines);
}
public enum Status {
APPLIED,
BLOCKED,
EXPIRED,
UNKNOWN_PLAN,
STALE,
FAILED
}
}
@@ -0,0 +1,7 @@
package art.arcane.iris.core.structure.conversion;
public enum IrisStructureAdoptionStrategy {
AUTO,
IN_PLACE,
CLONE
}
@@ -0,0 +1,160 @@
package art.arcane.iris.core.structure.export;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import java.util.stream.Stream;
final class AtomicDatapackPublisher {
Publication publish(
Map<String, byte[]> resources,
Path output,
VanillaJigsawExportFormat format,
boolean replaceExisting
) throws IOException {
Path parent = output.getParent();
if (parent == null) {
throw new IOException("Export output has no parent directory: " + output);
}
Files.createDirectories(parent);
Path staging = Files.createTempDirectory(parent, ".iris-jigsaw-export-");
List<String> cleanupWarnings = new ArrayList<>();
try {
Path artifact = format == VanillaJigsawExportFormat.DIRECTORY
? createDirectoryArtifact(staging, resources)
: createZipArtifact(staging, resources);
replaceAtomically(artifact, output, replaceExisting, cleanupWarnings);
} finally {
try {
deleteTree(staging);
} catch (IOException exception) {
cleanupWarnings.add("Could not remove export staging path '" + staging + "': " + exception.getMessage());
}
}
return new Publication(List.copyOf(cleanupWarnings));
}
private Path createDirectoryArtifact(Path staging, Map<String, byte[]> resources) throws IOException {
Path root = staging.resolve("pack");
Files.createDirectory(root);
for (String resource : sortedPaths(resources)) {
Path target = root.resolve(resource).normalize();
if (!target.startsWith(root)) {
throw new IOException("Export resource escapes staging root: " + resource);
}
Files.createDirectories(target.getParent());
Files.write(target, resources.get(resource));
}
return root;
}
private Path createZipArtifact(Path staging, Map<String, byte[]> resources) throws IOException {
Path zip = staging.resolve("pack.zip");
try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(zip))) {
for (String resource : sortedPaths(resources)) {
ZipEntry entry = new ZipEntry(resource);
entry.setTime(0L);
output.putNextEntry(entry);
output.write(resources.get(resource));
output.closeEntry();
}
}
return zip;
}
private void replaceAtomically(
Path artifact,
Path output,
boolean replaceExisting,
List<String> cleanupWarnings
) throws IOException {
Path backup = null;
if (Files.exists(output)) {
if (!replaceExisting) {
throw new IOException("Export output already exists: " + output);
}
backup = uniqueBackup(output);
atomicMove(output, backup);
}
try {
atomicMove(artifact, output);
} catch (IOException publicationFailure) {
if (backup != null && Files.exists(backup) && !Files.exists(output)) {
try {
atomicMove(backup, output);
} catch (IOException restoreFailure) {
publicationFailure.addSuppressed(restoreFailure);
}
}
throw publicationFailure;
}
if (backup != null) {
try {
deleteTree(backup);
} catch (IOException exception) {
cleanupWarnings.add("Export succeeded, but the replaced output backup remains at '"
+ backup + "': " + exception.getMessage());
}
}
}
private void atomicMove(Path source, Path target) throws IOException {
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
throw new IOException("Filesystem does not support an atomic export move from '"
+ source + "' to '" + target + "'.", exception);
}
}
private Path uniqueBackup(Path output) {
return output.resolveSibling("." + output.getFileName() + ".iris-backup-" + UUID.randomUUID());
}
private List<String> sortedPaths(Map<String, byte[]> resources) {
List<String> paths = new ArrayList<>(resources.keySet());
paths.sort(String::compareTo);
return paths;
}
private void deleteTree(Path root) throws IOException {
if (!Files.exists(root)) {
return;
}
try (Stream<Path> paths = Files.walk(root)) {
List<Path> ordered = paths.sorted(Comparator.reverseOrder()).toList();
IOException failure = null;
for (Path path : ordered) {
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
if (failure == null) {
failure = exception;
} else {
failure.addSuppressed(exception);
}
}
}
if (failure != null) {
throw failure;
}
}
}
record Publication(List<String> cleanupWarnings) {
Publication {
cleanupWarnings = List.copyOf(cleanupWarnings);
}
}
}
@@ -0,0 +1,88 @@
package art.arcane.iris.core.structure.export;
import art.arcane.volmlib.util.nbt.tag.CompoundTag;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Pattern;
record VanillaBlockState(String name, Map<String, String> properties) {
private static final Pattern PROPERTY_NAME = Pattern.compile("[a-z0-9_.-]+");
private static final Pattern PROPERTY_VALUE = Pattern.compile("[a-z0-9_.-]+");
VanillaBlockState {
properties = Collections.unmodifiableMap(new LinkedHashMap<>(properties));
}
static VanillaBlockState parse(String value) {
if (value == null) {
throw new IllegalArgumentException("Block state must not be null");
}
String source = value.trim();
int propertiesStart = source.indexOf('[');
String name = propertiesStart < 0 ? source : source.substring(0, propertiesStart);
if (!VanillaResourceIdentifier.validIdentifier(name)) {
throw new IllegalArgumentException("Invalid block identifier '" + name + "'");
}
if (!name.startsWith("minecraft:")) {
throw new IllegalArgumentException("Vanilla cannot resolve non-Minecraft block '" + name + "'");
}
if (propertiesStart < 0) {
return new VanillaBlockState(name, Map.of());
}
if (!source.endsWith("]") || source.indexOf('[', propertiesStart + 1) >= 0) {
throw new IllegalArgumentException("Invalid property block in '" + value + "'");
}
String body = source.substring(propertiesStart + 1, source.length() - 1);
if (body.isEmpty()) {
throw new IllegalArgumentException("Empty property block in '" + value + "'");
}
Map<String, String> properties = new TreeMap<>();
for (String property : body.split(",", -1)) {
int separator = property.indexOf('=');
if (separator <= 0 || separator != property.lastIndexOf('=') || separator == property.length() - 1) {
throw new IllegalArgumentException("Invalid block property '" + property + "'");
}
String propertyName = property.substring(0, separator);
String propertyValue = property.substring(separator + 1);
if (!PROPERTY_NAME.matcher(propertyName).matches() || !PROPERTY_VALUE.matcher(propertyValue).matches()) {
throw new IllegalArgumentException("Invalid block property '" + property + "'");
}
if (properties.put(propertyName, propertyValue) != null) {
throw new IllegalArgumentException("Duplicate block property '" + propertyName + "'");
}
}
return new VanillaBlockState(name, properties);
}
String canonical() {
if (properties.isEmpty()) {
return name;
}
StringBuilder builder = new StringBuilder(name).append('[');
boolean first = true;
for (Map.Entry<String, String> property : properties.entrySet()) {
if (!first) {
builder.append(',');
}
builder.append(property.getKey()).append('=').append(property.getValue());
first = false;
}
return builder.append(']').toString();
}
CompoundTag toNbt() {
CompoundTag state = new CompoundTag();
state.putString("Name", name);
if (!properties.isEmpty()) {
CompoundTag propertyTag = new CompoundTag();
for (Map.Entry<String, String> property : properties.entrySet()) {
propertyTag.putString(property.getKey(), property.getValue());
}
state.put("Properties", propertyTag);
}
return state;
}
}
@@ -0,0 +1,74 @@
package art.arcane.iris.core.structure.export;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
public final class VanillaJigsawDatapackExporter {
public VanillaJigsawExportValidation validate(VanillaJigsawExportRequest request) {
VanillaJigsawExportCompiler.Compilation compilation = new VanillaJigsawExportCompiler().compile(request);
List<String> resources = new ArrayList<>(compilation.resources().keySet());
resources.sort(String::compareTo);
return new VanillaJigsawExportValidation(resources, compilation.diagnostics());
}
public VanillaJigsawExportResult export(VanillaJigsawExportRequest request) {
if (Files.exists(request.output()) && !request.replaceExisting()) {
VanillaJigsawExportDiagnostic diagnostic = new VanillaJigsawExportDiagnostic(
VanillaJigsawExportDiagnostic.Severity.ERROR,
VanillaJigsawExportDiagnostic.Code.OUTPUT_EXISTS,
request.output().toString(),
"Export output already exists and replaceExisting is false.");
return new VanillaJigsawExportResult(
VanillaJigsawExportResult.Status.REJECTED,
request.output(),
List.of(),
List.of(diagnostic));
}
VanillaJigsawExportCompiler.Compilation compilation = new VanillaJigsawExportCompiler().compile(request);
if (compilation.hasErrors()) {
return new VanillaJigsawExportResult(
VanillaJigsawExportResult.Status.REJECTED,
request.output(),
List.of(),
compilation.diagnostics());
}
List<VanillaJigsawExportDiagnostic> diagnostics = new ArrayList<>(compilation.diagnostics());
try {
AtomicDatapackPublisher.Publication publication = new AtomicDatapackPublisher().publish(
compilation.resources(),
request.output(),
request.format(),
request.replaceExisting());
for (String warning : publication.cleanupWarnings()) {
diagnostics.add(new VanillaJigsawExportDiagnostic(
VanillaJigsawExportDiagnostic.Severity.WARNING,
VanillaJigsawExportDiagnostic.Code.CLEANUP_FAILED,
request.output().toString(),
warning));
}
} catch (IOException exception) {
diagnostics.add(new VanillaJigsawExportDiagnostic(
VanillaJigsawExportDiagnostic.Severity.ERROR,
VanillaJigsawExportDiagnostic.Code.PUBLICATION_FAILED,
request.output().toString(),
"Atomic datapack publication failed: " + exception.getMessage()));
return new VanillaJigsawExportResult(
VanillaJigsawExportResult.Status.FAILED,
request.output(),
List.of(),
diagnostics);
}
List<String> resources = new ArrayList<>(compilation.resources().keySet());
resources.sort(String::compareTo);
return new VanillaJigsawExportResult(
VanillaJigsawExportResult.Status.EXPORTED,
request.output(),
resources,
diagnostics);
}
}
@@ -0,0 +1,815 @@
package art.arcane.iris.core.structure.export;
import art.arcane.iris.engine.framework.structure.CompiledStructureGraph;
import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver;
import art.arcane.iris.engine.framework.structure.StructureGraphCompilation;
import art.arcane.iris.engine.framework.structure.StructureGraphCompiler;
import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic;
import art.arcane.iris.engine.object.IrisJigsawCompatibility;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawMode;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisJigsawPieceEntry;
import art.arcane.iris.engine.object.IrisJigsawPieceRules;
import art.arcane.iris.engine.object.IrisJigsawPool;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.object.ObjectPlaceMode;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.math.IrisBlockVector;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
final class VanillaJigsawExportCompiler {
private static final int MAX_DEPTH = 20;
private static final int MAX_HORIZONTAL_DISTANCE = 128;
private static final int MAX_VERTICAL_DISTANCE = 4064;
private static final int MIN_ABSOLUTE_Y = -2032;
private static final int MAX_ABSOLUTE_Y = 2031;
private static final int MAX_POOL_WEIGHT = 150;
private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create();
Compilation compile(VanillaJigsawExportRequest request) {
List<VanillaJigsawExportDiagnostic> diagnostics = new ArrayList<>();
validateIdentity(request, diagnostics);
validateSettings(request.settings(), diagnostics);
IrisStructure structure;
try {
structure = request.source().loadStructure();
} catch (RuntimeException exception) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.SOURCE_STRUCTURE_MISSING,
request.source().structureKey(),
"Could not load Iris structure '" + request.source().structureKey() + "': "
+ exception.getMessage());
return rejected(diagnostics);
}
if (structure == null) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.SOURCE_STRUCTURE_MISSING,
request.source().structureKey(),
"Iris structure '" + request.source().structureKey() + "' does not exist.");
return rejected(diagnostics);
}
validateStructure(structure, request.settings(), diagnostics);
StructureGraphCompilation graphCompilation;
try {
graphCompilation = StructureGraphCompiler.compile(structure, request.source().resolver());
} catch (RuntimeException exception) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.GRAPH_VALIDATION,
request.source().structureKey(),
"Structure graph compilation failed: " + exception.getMessage());
return rejected(diagnostics);
}
addGraphDiagnostics(graphCompilation, diagnostics);
CompiledStructureGraph graph = graphCompilation.getGraph();
validateGraph(graph, diagnostics);
if (hasErrors(diagnostics)) {
return rejected(diagnostics);
}
try {
Map<String, byte[]> resources = createResources(request, graph);
return new Compilation(resources, List.copyOf(diagnostics));
} catch (IOException | RuntimeException exception) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.SERIALIZATION_FAILED,
request.source().structureKey(),
"Vanilla datapack serialization failed: " + exception.getMessage());
return rejected(diagnostics);
}
}
private void validateIdentity(
VanillaJigsawExportRequest request,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
if (!VanillaResourceIdentifier.validNamespace(request.namespace())) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_NAMESPACE,
request.namespace(),
"Datapack namespace must match [a-z0-9_.-]+.");
}
if (!VanillaResourceIdentifier.validPath(request.resourcePath())) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH,
request.resourcePath(),
"Datapack resource path must use lowercase resource-path characters without traversal.");
}
}
private void validateSettings(
VanillaJigsawExportSettings settings,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
if (settings.biomes().isEmpty()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_BIOME,
"biomes",
"At least one biome identifier is required.");
}
for (String biome : settings.biomes()) {
if (!VanillaResourceIdentifier.validIdentifier(biome)) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_BIOME,
biome,
"Biome identifier '" + biome + "' is not a valid namespaced identifier.");
}
}
if (settings.startHeight() < MIN_ABSOLUTE_Y || settings.startHeight() > MAX_ABSOLUTE_Y) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_SETTINGS,
"start_height",
"Absolute start height must be between " + MIN_ABSOLUTE_Y + " and " + MAX_ABSOLUTE_Y + ".");
}
if (settings.maxDistanceVertical() < 1 || settings.maxDistanceVertical() > MAX_VERTICAL_DISTANCE) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DISTANCE,
"max_distance_from_center.vertical",
"Vertical maximum distance must be between 1 and " + MAX_VERTICAL_DISTANCE + ".");
}
if (settings.spacing() < 0 || settings.spacing() > 4096
|| settings.separation() < 0 || settings.separation() > 4096
|| settings.spacing() <= settings.separation()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_SETTINGS,
"random_spread",
"Random-spread spacing and separation must be within 0..4096, with spacing greater than separation.");
}
if (settings.salt() < 0) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_SETTINGS,
"random_spread.salt",
"Random-spread salt must be non-negative.");
}
if (!Float.isFinite(settings.frequency()) || settings.frequency() < 0.0F || settings.frequency() > 1.0F) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_SETTINGS,
"random_spread.frequency",
"Random-spread frequency must be finite and within 0..1.");
}
}
private void validateStructure(
IrisStructure structure,
VanillaJigsawExportSettings settings,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
if (structure.resolvedCompatibility() != IrisJigsawCompatibility.VANILLA_PORTABLE) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_COMPATIBILITY,
structureKey(structure),
"Strict export requires compatibility VANILLA_PORTABLE.");
}
if (structure.getPlaceMode() != ObjectPlaceMode.STRUCTURE_PIECE) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_PLACE_MODE,
structureKey(structure),
"Vanilla export requires placeMode STRUCTURE_PIECE; Iris terrain and stilt modes have no vanilla jigsaw equivalent.");
}
if (structure.getEdit() != null && !structure.getEdit().isEmpty()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_EDIT,
structureKey(structure),
"Structure-wide Iris block edits cannot be represented losslessly by vanilla template pools.");
}
if (structure.getLoot() != null && !structure.getLoot().isEmpty()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_LOOT,
structureKey(structure),
"Structure-wide Iris loot injection cannot be represented losslessly by this vanilla exporter.");
}
if (structure.getThemeSets() != null && !structure.getThemeSets().isEmpty()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_THEME_METADATA,
structureKey(structure),
"Coherent Iris theme selection has no lossless vanilla jigsaw representation.");
}
if (structure.isRequireCaps()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_REQUIRED_CAPS,
structureKey(structure),
"Vanilla jigsaws cannot enforce Iris requireCaps terminal-closure semantics.");
}
if (structure.getMaxDepth() < 1 || structure.getMaxDepth() > MAX_DEPTH) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DEPTH,
structureKey(structure),
"Minecraft 26.2 jigsaw size must be within 0..20; Iris export requires 1..20.");
}
long horizontalDistance = (long) structure.getMaxSizeChunks() * 16L;
if (horizontalDistance < 1L || horizontalDistance > MAX_HORIZONTAL_DISTANCE) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DISTANCE,
structureKey(structure),
"Iris maxSizeChunks maps to " + horizontalDistance
+ " blocks, outside Minecraft 26.2's 1..128 horizontal limit.");
}
int terrainPadding = settings.terrainAdaptation() == VanillaJigsawExportSettings.TerrainAdaptation.NONE
? 0 : 12;
if (horizontalDistance + terrainPadding > MAX_HORIZONTAL_DISTANCE) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DISTANCE,
structureKey(structure),
"Horizontal distance plus vanilla terrain-adaptation padding must not exceed 128 blocks.");
}
}
private void addGraphDiagnostics(
StructureGraphCompilation graphCompilation,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
for (StructureGraphDiagnostic diagnostic : graphCompilation.getDiagnostics()) {
VanillaJigsawExportDiagnostic.Severity severity = diagnostic.severity()
== StructureGraphDiagnostic.Severity.ERROR
? VanillaJigsawExportDiagnostic.Severity.ERROR
: VanillaJigsawExportDiagnostic.Severity.WARNING;
diagnostics.add(new VanillaJigsawExportDiagnostic(
severity,
VanillaJigsawExportDiagnostic.Code.GRAPH_VALIDATION,
diagnostic.code().name(),
diagnostic.message()));
}
}
private void validateGraph(
CompiledStructureGraph graph,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> workcells =
resolvedWorkcells(graph.getStructure());
for (Map.Entry<String, IrisJigsawPool> poolEntry : graph.getPools().entrySet()) {
String poolKey = poolEntry.getKey();
if (!VanillaResourceIdentifier.validPath(poolKey)) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH,
poolKey,
"Iris pool keys must be valid lowercase resource paths for vanilla export.");
}
validatePool(graph, workcells, poolKey, poolEntry.getValue(), diagnostics);
}
for (Map.Entry<String, IrisJigsawPiece> pieceEntry : graph.getPieces().entrySet()) {
if (!pieceEnabled(workcells, pieceEntry.getValue())) {
continue;
}
String pieceKey = pieceEntry.getKey();
if (!VanillaResourceIdentifier.validPath(pieceKey)) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH,
pieceKey,
"Iris piece keys must be valid lowercase resource paths for vanilla export.");
}
validatePiece(graph, pieceKey, pieceEntry.getValue(), diagnostics);
}
}
private void validatePool(
CompiledStructureGraph graph,
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> workcells,
String poolKey,
IrisJigsawPool pool,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
if (pool.isMandatoryFallback()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_REQUIRED_CAPS,
poolKey,
"Vanilla template pools cannot enforce Iris mandatoryFallback terminal closure.");
}
if (pool.getFallback() != null && !pool.getFallback().isBlank()
&& !VanillaResourceIdentifier.validPath(pool.getFallback().trim())) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH,
poolKey,
"Fallback pool key '" + pool.getFallback() + "' is not a valid vanilla resource path.");
}
if (pool.getPieces() == null) {
return;
}
for (int index = 0; index < pool.getPieces().size(); index++) {
IrisJigsawPieceEntry entry = pool.getPieces().get(index);
if (entry == null) {
continue;
}
if (!entry.isEmpty()
&& !pieceEnabled(workcells, graph.getPieces().get(trim(entry.getPiece())))) {
continue;
}
if (entry.getWeight() < 1 || entry.getWeight() > MAX_POOL_WEIGHT) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_POOL_WEIGHT,
poolKey + "/pieces[" + index + "]",
"Minecraft 26.2 template-pool weights must be within 1..150.");
}
if (entry.getChance() != 1D) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_CHANCE,
poolKey + "/pieces[" + index + "]",
"Independent Iris membership chance cannot be represented by vanilla pool weights.");
}
if (!entry.isEmpty() && !VanillaResourceIdentifier.validPath(trim(entry.getPiece()))) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH,
poolKey + "/pieces[" + index + "]",
"Piece key '" + entry.getPiece() + "' is not a valid vanilla resource path.");
}
}
}
private void validatePiece(
CompiledStructureGraph graph,
String pieceKey,
IrisJigsawPiece piece,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
if (piece.getThemes() != null && !piece.getThemes().isEmpty()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_THEME_METADATA,
pieceKey,
"Iris piece theme membership has no vanilla template-pool equivalent.");
}
IrisJigsawPieceRules rules = piece.resolvedRules();
if (rules.getMinimumDepth() != 0
|| rules.getMaximumDepth() != 30
|| rules.getMinimumPlacements() != 0
|| rules.getMaximumPlacements() != 0
|| rules.isTerminal()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_PIECE_RULES,
pieceKey,
"Iris depth, placement-count, and terminal rules cannot be serialized losslessly to vanilla.");
}
if (!piece.isRotatable()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_FIXED_ROTATION,
pieceKey,
"Vanilla template pools do not provide an exact fixed-rotation equivalent for Iris rotatable=false.");
}
if (!piece.isCollidable()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_NON_COLLIDABLE_PIECE,
pieceKey,
"Vanilla template pools cannot serialize Iris collidable=false assembly metadata.");
}
IrisObject object = graph.getObjects().get(trim(piece.getObject()));
if (object == null) {
return;
}
validateObject(pieceKey, object, diagnostics);
validateConnectors(graph, pieceKey, piece, object, diagnostics);
}
private void validateObject(
String pieceKey,
IrisObject object,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
if (!object.getStates().isEmpty()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_TILE_DATA,
pieceKey,
"The .iob contains tile payloads; exact registry-aware block-entity NBT export is not available in core.");
}
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : object.getBlocks()) {
PlatformBlockState state = entry.getValue();
String resource = pieceKey + "@" + entry.getKey();
IrisBlockVector position = entry.getKey();
int x = position.getBlockX() + object.getCenter().getX();
int y = position.getBlockY() + object.getCenter().getY();
int z = position.getBlockZ() + object.getCenter().getZ();
if (x < 0 || x >= object.getW() || y < 0 || y >= object.getH() || z < 0 || z >= object.getD()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_BLOCK_STATE,
resource,
"The .iob contains a block outside its declared dimensions.");
continue;
}
if (state.isCustom()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_CUSTOM_BLOCK,
resource,
"Custom-content block '" + state.key() + "' is not available in unmodded vanilla.");
continue;
}
if (state.hasTileEntity()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_BLOCK_ENTITY,
resource,
"Block '" + state.key() + "' requires block-entity NBT that core cannot export losslessly.");
}
try {
VanillaBlockState parsed = VanillaBlockState.parse(state.key());
if (parsed.name().equals("minecraft:jigsaw")
|| parsed.name().equals("minecraft:structure_block")
|| parsed.name().equals("minecraft:structure_void")) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_MARKER_BLOCK,
resource,
"Marker block '" + parsed.name() + "' must be represented by Iris connector metadata, not object blocks.");
}
} catch (IllegalArgumentException exception) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_BLOCK_STATE,
resource,
exception.getMessage());
}
}
}
private void validateConnectors(
CompiledStructureGraph graph,
String pieceKey,
IrisJigsawPiece piece,
IrisObject object,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
if (piece.getConnectors() == null) {
return;
}
Set<String> positions = new LinkedHashSet<>();
for (int index = 0; index < piece.getConnectors().size(); index++) {
IrisJigsawConnector connector = piece.getConnectors().get(index);
if (connector == null) {
continue;
}
String resource = pieceKey + "/connectors[" + index + "]";
if (connector.getChannel() != null && !connector.getChannel().isBlank()) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_CHANNEL,
resource,
"Iris connector channels have no vanilla jigsaw NBT equivalent.");
}
validateConnectorIdentifier(connector.getName(), resource + "/name", diagnostics);
validateConnectorIdentifier(connector.getTargetName(), resource + "/target", diagnostics);
String poolKey = trim(connector.getPool());
if (!VanillaResourceIdentifier.validPath(poolKey) || !graph.getPools().containsKey(poolKey)) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH,
resource + "/pool",
"Connector pool '" + connector.getPool() + "' cannot be mapped to an exported template pool.");
}
try {
VanillaStructureTemplateEncoder.orientation(connector.getDirection(), connector.getTop());
} catch (IllegalArgumentException | NullPointerException exception) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_CONNECTOR_ORIENTATION,
resource,
exception.getMessage() == null ? "Connector orientation is incomplete." : exception.getMessage());
}
IrisPosition position = connector.getPosition();
if (position == null || !inside(position, object)) {
continue;
}
String positionKey = position.getX() + "," + position.getY() + "," + position.getZ();
if (!positions.add(positionKey)) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.DUPLICATE_CONNECTOR_POSITION,
resource,
"Multiple vanilla jigsaw block entities cannot occupy " + positionKey + ".");
}
validateFinalState(object, connector, resource, diagnostics);
}
}
private void validateConnectorIdentifier(
String value,
String resource,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
try {
VanillaResourceIdentifier.normalizeConnectorIdentifier(value);
} catch (IllegalArgumentException exception) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_CONNECTOR_ID,
resource,
exception.getMessage());
}
}
private void validateFinalState(
IrisObject object,
IrisJigsawConnector connector,
String resource,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
VanillaBlockState configured;
try {
configured = VanillaBlockState.parse(connector.getFinalState());
} catch (IllegalArgumentException exception) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_CONNECTOR_FINAL_STATE,
resource,
exception.getMessage());
return;
}
IrisPosition position = connector.getPosition();
PlatformBlockState objectState = object.getBlocks().get(
object.getSigned(position.getX(), position.getY(), position.getZ()));
String expectedSource = objectState == null ? "minecraft:structure_void" : objectState.key();
try {
VanillaBlockState expected = VanillaBlockState.parse(expectedSource);
if (!configured.canonical().equals(expected.canonical())) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_CONNECTOR_FINAL_STATE,
resource,
"Connector finalState '" + configured.canonical() + "' does not match the .iob block '"
+ expected.canonical() + "' at that position. Use minecraft:structure_void for an absent block.");
}
} catch (IllegalArgumentException exception) {
addError(diagnostics,
VanillaJigsawExportDiagnostic.Code.INVALID_CONNECTOR_FINAL_STATE,
resource,
"The .iob block under the connector is not vanilla-compatible: " + exception.getMessage());
}
}
private Map<String, byte[]> createResources(
VanillaJigsawExportRequest request,
CompiledStructureGraph graph
) throws IOException {
Map<String, byte[]> resources = new LinkedHashMap<>();
putJson(resources, "pack.mcmeta", packMetadata(request));
putJson(resources, biomeTagPath(request), biomeTag(request));
putJson(resources, processorPath(request), processorList());
putJson(resources, structurePath(request), structure(request, graph.getStructure()));
putJson(resources, structureSetPath(request), structureSet(request));
for (Map.Entry<String, IrisJigsawPool> poolEntry : graph.getPools().entrySet()) {
putJson(resources,
poolPath(request, poolEntry.getKey()),
templatePool(request, graph, poolEntry.getValue()));
}
VanillaStructureTemplateEncoder encoder = new VanillaStructureTemplateEncoder();
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> workcells =
resolvedWorkcells(graph.getStructure());
for (Map.Entry<String, IrisJigsawPiece> pieceEntry : graph.getPieces().entrySet()) {
if (!pieceEnabled(workcells, pieceEntry.getValue())) {
continue;
}
IrisObject object = graph.getObjects().get(trim(pieceEntry.getValue().getObject()));
byte[] template = encoder.encode(
object,
pieceEntry.getValue(),
poolKey -> poolIdentifier(request, trim(poolKey)));
resources.put(templatePath(request, pieceEntry.getKey()), template);
}
return resources;
}
private JsonObject packMetadata(VanillaJigsawExportRequest request) {
JsonObject pack = new JsonObject();
pack.addProperty("description", request.description());
JsonArray minimum = new JsonArray();
minimum.add(107);
minimum.add(1);
pack.add("min_format", minimum);
pack.addProperty("max_format", 107);
JsonObject root = new JsonObject();
root.add("pack", pack);
return root;
}
private JsonObject biomeTag(VanillaJigsawExportRequest request) {
JsonObject root = new JsonObject();
root.addProperty("replace", false);
JsonArray values = new JsonArray();
for (String biome : request.settings().biomes()) {
values.add(biome);
}
root.add("values", values);
return root;
}
private JsonObject processorList() {
JsonObject root = new JsonObject();
root.add("processors", new JsonArray());
return root;
}
private JsonObject structure(VanillaJigsawExportRequest request, IrisStructure structure) {
VanillaJigsawExportSettings settings = request.settings();
JsonObject root = new JsonObject();
root.addProperty("type", "minecraft:jigsaw");
root.addProperty("biomes", "#" + request.namespace() + ":" + request.resourcePath());
int horizontalDistance = structure.getMaxSizeChunks() * 16;
if (horizontalDistance == settings.maxDistanceVertical()) {
root.addProperty("max_distance_from_center", horizontalDistance);
} else {
JsonObject distance = new JsonObject();
distance.addProperty("horizontal", horizontalDistance);
distance.addProperty("vertical", settings.maxDistanceVertical());
root.add("max_distance_from_center", distance);
}
if (settings.projectHeightmap() != VanillaJigsawExportSettings.ProjectHeightmap.NONE) {
root.addProperty("project_start_to_heightmap", settings.projectHeightmap().serializedName());
}
root.addProperty("size", structure.getMaxDepth());
root.add("spawn_overrides", new JsonObject());
JsonObject startHeight = new JsonObject();
startHeight.addProperty("absolute", settings.startHeight());
root.add("start_height", startHeight);
root.addProperty("start_pool", poolIdentifier(request, trim(structure.getStartPool())));
root.addProperty("step", settings.generationStep().serializedName());
root.addProperty("terrain_adaptation", settings.terrainAdaptation().serializedName());
root.addProperty("use_expansion_hack", settings.expansionHack());
return root;
}
private JsonObject structureSet(VanillaJigsawExportRequest request) {
JsonObject structureEntry = new JsonObject();
structureEntry.addProperty("structure", structureIdentifier(request));
structureEntry.addProperty("weight", 1);
JsonArray structures = new JsonArray();
structures.add(structureEntry);
VanillaJigsawExportSettings settings = request.settings();
JsonObject placement = new JsonObject();
placement.addProperty("type", "minecraft:random_spread");
placement.addProperty("frequency", settings.frequency());
placement.addProperty("salt", settings.salt());
placement.addProperty("separation", settings.separation());
placement.addProperty("spacing", settings.spacing());
placement.addProperty("spread_type", settings.spreadType().serializedName());
JsonObject root = new JsonObject();
root.add("placement", placement);
root.add("structures", structures);
return root;
}
private JsonObject templatePool(
VanillaJigsawExportRequest request,
CompiledStructureGraph graph,
IrisJigsawPool pool
) {
JsonObject root = new JsonObject();
String fallback = trim(pool.getFallback());
root.addProperty("fallback", fallback.isEmpty() ? "minecraft:empty" : poolIdentifier(request, fallback));
JsonArray elements = new JsonArray();
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> workcells =
resolvedWorkcells(graph.getStructure());
if (pool.getPieces() != null) {
for (IrisJigsawPieceEntry entry : pool.getPieces()) {
if (!entry.isEmpty()
&& !pieceEnabled(workcells, graph.getPieces().get(trim(entry.getPiece())))) {
continue;
}
JsonObject weightedElement = new JsonObject();
JsonObject element = new JsonObject();
if (entry.isEmpty()) {
element.addProperty("element_type", "minecraft:empty_pool_element");
} else {
element.addProperty("element_type", "minecraft:single_pool_element");
element.addProperty("location", templateIdentifier(request, trim(entry.getPiece())));
element.addProperty("processors", processorIdentifier(request));
element.addProperty("projection", "rigid");
}
weightedElement.add("element", element);
weightedElement.addProperty("weight", entry.getWeight());
elements.add(weightedElement);
}
}
root.add("elements", elements);
return root;
}
private static Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell>
resolvedWorkcells(IrisStructure structure) {
return structure.resolvedMode() == IrisJigsawMode.PLANAR_JIGSAW
? PlanarJigsawWorkcellResolver.resolve(structure)
: Map.of();
}
private static boolean pieceEnabled(
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> workcells,
IrisJigsawPiece piece
) {
return workcells.isEmpty()
|| piece == null
|| PlanarJigsawWorkcellResolver.workcell(workcells, piece).enabled();
}
private void putJson(Map<String, byte[]> resources, String path, JsonObject value) {
resources.put(path, (GSON.toJson(value) + "\n").getBytes(StandardCharsets.UTF_8));
}
private String biomeTagPath(VanillaJigsawExportRequest request) {
return "data/" + request.namespace() + "/tags/worldgen/biome/" + request.resourcePath() + ".json";
}
private String processorPath(VanillaJigsawExportRequest request) {
return "data/" + request.namespace() + "/worldgen/processor_list/"
+ request.resourcePath() + "/empty.json";
}
private String structurePath(VanillaJigsawExportRequest request) {
return "data/" + request.namespace() + "/worldgen/structure/" + request.resourcePath() + ".json";
}
private String structureSetPath(VanillaJigsawExportRequest request) {
return "data/" + request.namespace() + "/worldgen/structure_set/" + request.resourcePath() + ".json";
}
private String poolPath(VanillaJigsawExportRequest request, String poolKey) {
return "data/" + request.namespace() + "/worldgen/template_pool/"
+ request.resourcePath() + "/pool/" + poolKey + ".json";
}
private String templatePath(VanillaJigsawExportRequest request, String pieceKey) {
return "data/" + request.namespace() + "/structure/"
+ request.resourcePath() + "/piece/" + pieceKey + ".nbt";
}
private String structureIdentifier(VanillaJigsawExportRequest request) {
return request.namespace() + ":" + request.resourcePath();
}
private String processorIdentifier(VanillaJigsawExportRequest request) {
return request.namespace() + ":" + request.resourcePath() + "/empty";
}
private String poolIdentifier(VanillaJigsawExportRequest request, String poolKey) {
return request.namespace() + ":" + request.resourcePath() + "/pool/" + poolKey;
}
private String templateIdentifier(VanillaJigsawExportRequest request, String pieceKey) {
return request.namespace() + ":" + request.resourcePath() + "/piece/" + pieceKey;
}
private boolean inside(IrisPosition position, IrisObject object) {
return position.getX() >= 0 && position.getX() < object.getW()
&& position.getY() >= 0 && position.getY() < object.getH()
&& position.getZ() >= 0 && position.getZ() < object.getD();
}
private boolean hasErrors(List<VanillaJigsawExportDiagnostic> diagnostics) {
for (VanillaJigsawExportDiagnostic diagnostic : diagnostics) {
if (diagnostic.isBlocking()) {
return true;
}
}
return false;
}
private Compilation rejected(List<VanillaJigsawExportDiagnostic> diagnostics) {
return new Compilation(Map.of(), List.copyOf(diagnostics));
}
private void addError(
List<VanillaJigsawExportDiagnostic> diagnostics,
VanillaJigsawExportDiagnostic.Code code,
String resource,
String message
) {
diagnostics.add(new VanillaJigsawExportDiagnostic(
VanillaJigsawExportDiagnostic.Severity.ERROR,
code,
resource,
message));
}
private String structureKey(IrisStructure structure) {
String key = structure.getLoadKey();
return key == null || key.isBlank() ? "<unloaded>" : key;
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
record Compilation(
Map<String, byte[]> resources,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
Compilation {
resources = Map.copyOf(resources);
diagnostics = List.copyOf(diagnostics);
}
boolean hasErrors() {
for (VanillaJigsawExportDiagnostic diagnostic : diagnostics) {
if (diagnostic.isBlocking()) {
return true;
}
}
return false;
}
}
}
@@ -0,0 +1,58 @@
package art.arcane.iris.core.structure.export;
import java.util.Objects;
public record VanillaJigsawExportDiagnostic(Severity severity, Code code, String resource, String message) {
public VanillaJigsawExportDiagnostic {
Objects.requireNonNull(severity);
Objects.requireNonNull(code);
resource = resource == null ? "" : resource;
Objects.requireNonNull(message);
}
public boolean isBlocking() {
return severity == Severity.ERROR;
}
public enum Severity {
ERROR,
WARNING
}
public enum Code {
SOURCE_STRUCTURE_MISSING,
GRAPH_VALIDATION,
INVALID_NAMESPACE,
INVALID_RESOURCE_PATH,
INVALID_BIOME,
INVALID_SETTINGS,
UNSUPPORTED_COMPATIBILITY,
UNSUPPORTED_PLACE_MODE,
UNSUPPORTED_EDIT,
UNSUPPORTED_LOOT,
UNSUPPORTED_THEME_METADATA,
UNSUPPORTED_CHANCE,
UNSUPPORTED_PIECE_RULES,
UNSUPPORTED_REQUIRED_CAPS,
UNSUPPORTED_CHANNEL,
UNSUPPORTED_FIXED_ROTATION,
UNSUPPORTED_NON_COLLIDABLE_PIECE,
UNSUPPORTED_TILE_DATA,
UNSUPPORTED_BLOCK_ENTITY,
UNSUPPORTED_CUSTOM_BLOCK,
UNSUPPORTED_MARKER_BLOCK,
INVALID_MAX_DEPTH,
INVALID_MAX_DISTANCE,
INVALID_POOL_WEIGHT,
INVALID_CONNECTOR,
INVALID_CONNECTOR_ID,
INVALID_CONNECTOR_ORIENTATION,
INVALID_CONNECTOR_FINAL_STATE,
DUPLICATE_CONNECTOR_POSITION,
INVALID_BLOCK_STATE,
OUTPUT_EXISTS,
SERIALIZATION_FAILED,
PUBLICATION_FAILED,
CLEANUP_FAILED
}
}
@@ -0,0 +1,6 @@
package art.arcane.iris.core.structure.export;
public enum VanillaJigsawExportFormat {
DIRECTORY,
ZIP
}
@@ -0,0 +1,113 @@
package art.arcane.iris.core.structure.export;
import java.nio.file.Path;
import java.util.Objects;
public final class VanillaJigsawExportRequest {
private final VanillaJigsawExportSource source;
private final Path output;
private final String namespace;
private final String resourcePath;
private final String description;
private final VanillaJigsawExportFormat format;
private final VanillaJigsawExportSettings settings;
private final boolean replaceExisting;
private VanillaJigsawExportRequest(Builder builder) {
source = builder.source;
output = builder.output.toAbsolutePath().normalize();
namespace = builder.namespace;
resourcePath = builder.resourcePath;
description = builder.description;
format = builder.format;
settings = builder.settings;
replaceExisting = builder.replaceExisting;
}
public static Builder builder(VanillaJigsawExportSource source, Path output) {
return new Builder(source, output);
}
public VanillaJigsawExportSource source() {
return source;
}
public Path output() {
return output;
}
public String namespace() {
return namespace;
}
public String resourcePath() {
return resourcePath;
}
public String description() {
return description;
}
public VanillaJigsawExportFormat format() {
return format;
}
public VanillaJigsawExportSettings settings() {
return settings;
}
public boolean replaceExisting() {
return replaceExisting;
}
public static final class Builder {
private final VanillaJigsawExportSource source;
private final Path output;
private String namespace = "iris";
private String resourcePath;
private String description = "Iris vanilla jigsaw export for Minecraft 26.2";
private VanillaJigsawExportFormat format = VanillaJigsawExportFormat.DIRECTORY;
private VanillaJigsawExportSettings settings = VanillaJigsawExportSettings.defaults();
private boolean replaceExisting;
private Builder(VanillaJigsawExportSource source, Path output) {
this.source = Objects.requireNonNull(source);
this.output = Objects.requireNonNull(output);
resourcePath = source.structureKey();
}
public Builder namespace(String value) {
namespace = Objects.requireNonNull(value).trim();
return this;
}
public Builder resourcePath(String value) {
resourcePath = Objects.requireNonNull(value).trim();
return this;
}
public Builder description(String value) {
description = Objects.requireNonNull(value);
return this;
}
public Builder format(VanillaJigsawExportFormat value) {
format = Objects.requireNonNull(value);
return this;
}
public Builder settings(VanillaJigsawExportSettings value) {
settings = Objects.requireNonNull(value);
return this;
}
public Builder replaceExisting(boolean value) {
replaceExisting = value;
return this;
}
public VanillaJigsawExportRequest build() {
return new VanillaJigsawExportRequest(this);
}
}
}
@@ -0,0 +1,38 @@
package art.arcane.iris.core.structure.export;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
public record VanillaJigsawExportResult(
Status status,
Path output,
List<String> resources,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
public VanillaJigsawExportResult {
Objects.requireNonNull(status);
Objects.requireNonNull(output);
resources = List.copyOf(resources);
diagnostics = List.copyOf(diagnostics);
}
public boolean isSuccess() {
return status == Status.EXPORTED;
}
public boolean hasBlockingDiagnostics() {
for (VanillaJigsawExportDiagnostic diagnostic : diagnostics) {
if (diagnostic.isBlocking()) {
return true;
}
}
return false;
}
public enum Status {
EXPORTED,
REJECTED,
FAILED
}
}
@@ -0,0 +1,243 @@
package art.arcane.iris.core.structure.export;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class VanillaJigsawExportSettings {
private final List<String> biomes;
private final int startHeight;
private final ProjectHeightmap projectHeightmap;
private final GenerationStep generationStep;
private final TerrainAdaptation terrainAdaptation;
private final boolean expansionHack;
private final int maxDistanceVertical;
private final int spacing;
private final int separation;
private final int salt;
private final float frequency;
private final SpreadType spreadType;
private VanillaJigsawExportSettings(Builder builder) {
biomes = List.copyOf(builder.biomes);
startHeight = builder.startHeight;
projectHeightmap = builder.projectHeightmap;
generationStep = builder.generationStep;
terrainAdaptation = builder.terrainAdaptation;
expansionHack = builder.expansionHack;
maxDistanceVertical = builder.maxDistanceVertical;
spacing = builder.spacing;
separation = builder.separation;
salt = builder.salt;
frequency = builder.frequency;
spreadType = builder.spreadType;
}
public static Builder builder() {
return new Builder();
}
public static VanillaJigsawExportSettings defaults() {
return builder().build();
}
public List<String> biomes() {
return biomes;
}
public int startHeight() {
return startHeight;
}
public ProjectHeightmap projectHeightmap() {
return projectHeightmap;
}
public GenerationStep generationStep() {
return generationStep;
}
public TerrainAdaptation terrainAdaptation() {
return terrainAdaptation;
}
public boolean expansionHack() {
return expansionHack;
}
public int maxDistanceVertical() {
return maxDistanceVertical;
}
public int spacing() {
return spacing;
}
public int separation() {
return separation;
}
public int salt() {
return salt;
}
public float frequency() {
return frequency;
}
public SpreadType spreadType() {
return spreadType;
}
public enum ProjectHeightmap {
NONE(null),
WORLD_SURFACE_WG("WORLD_SURFACE_WG"),
WORLD_SURFACE("WORLD_SURFACE"),
OCEAN_FLOOR_WG("OCEAN_FLOOR_WG"),
OCEAN_FLOOR("OCEAN_FLOOR"),
MOTION_BLOCKING("MOTION_BLOCKING"),
MOTION_BLOCKING_NO_LEAVES("MOTION_BLOCKING_NO_LEAVES");
private final String serializedName;
ProjectHeightmap(String serializedName) {
this.serializedName = serializedName;
}
public String serializedName() {
return serializedName;
}
}
public enum GenerationStep {
RAW_GENERATION("raw_generation"),
LAKES("lakes"),
LOCAL_MODIFICATIONS("local_modifications"),
UNDERGROUND_STRUCTURES("underground_structures"),
SURFACE_STRUCTURES("surface_structures"),
STRONGHOLDS("strongholds"),
UNDERGROUND_ORES("underground_ores"),
UNDERGROUND_DECORATION("underground_decoration"),
FLUID_SPRINGS("fluid_springs"),
VEGETAL_DECORATION("vegetal_decoration"),
TOP_LAYER_MODIFICATION("top_layer_modification");
private final String serializedName;
GenerationStep(String serializedName) {
this.serializedName = serializedName;
}
public String serializedName() {
return serializedName;
}
}
public enum TerrainAdaptation {
NONE("none"),
BURY("bury"),
BEARD_THIN("beard_thin"),
BEARD_BOX("beard_box"),
ENCAPSULATE("encapsulate");
private final String serializedName;
TerrainAdaptation(String serializedName) {
this.serializedName = serializedName;
}
public String serializedName() {
return serializedName;
}
}
public enum SpreadType {
LINEAR("linear"),
TRIANGULAR("triangular");
private final String serializedName;
SpreadType(String serializedName) {
this.serializedName = serializedName;
}
public String serializedName() {
return serializedName;
}
}
public static final class Builder {
private final List<String> biomes = new ArrayList<>(List.of("minecraft:plains"));
private int startHeight;
private ProjectHeightmap projectHeightmap = ProjectHeightmap.WORLD_SURFACE_WG;
private GenerationStep generationStep = GenerationStep.SURFACE_STRUCTURES;
private TerrainAdaptation terrainAdaptation = TerrainAdaptation.NONE;
private boolean expansionHack;
private int maxDistanceVertical = 4064;
private int spacing = 32;
private int separation = 8;
private int salt;
private float frequency = 1.0F;
private SpreadType spreadType = SpreadType.LINEAR;
private Builder() {
}
public Builder biomes(List<String> values) {
biomes.clear();
biomes.addAll(Objects.requireNonNull(values));
return this;
}
public Builder startHeight(int value) {
startHeight = value;
return this;
}
public Builder projectHeightmap(ProjectHeightmap value) {
projectHeightmap = Objects.requireNonNull(value);
return this;
}
public Builder generationStep(GenerationStep value) {
generationStep = Objects.requireNonNull(value);
return this;
}
public Builder terrainAdaptation(TerrainAdaptation value) {
terrainAdaptation = Objects.requireNonNull(value);
return this;
}
public Builder expansionHack(boolean value) {
expansionHack = value;
return this;
}
public Builder maxDistanceVertical(int value) {
maxDistanceVertical = value;
return this;
}
public Builder randomSpread(int spacingValue, int separationValue, int saltValue) {
spacing = spacingValue;
separation = separationValue;
salt = saltValue;
return this;
}
public Builder frequency(float value) {
frequency = value;
return this;
}
public Builder spreadType(SpreadType value) {
spreadType = Objects.requireNonNull(value);
return this;
}
public VanillaJigsawExportSettings build() {
return new VanillaJigsawExportSettings(this);
}
}
}
@@ -0,0 +1,69 @@
package art.arcane.iris.core.structure.export;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.structure.StructureGraphResolver;
import art.arcane.iris.engine.object.IrisStructure;
import java.util.Objects;
public final class VanillaJigsawExportSource {
private final String structureKey;
private final IrisData data;
private final IrisStructure structure;
private final StructureGraphResolver resolver;
private VanillaJigsawExportSource(
String structureKey,
IrisData data,
IrisStructure structure,
StructureGraphResolver resolver
) {
this.structureKey = requireKey(structureKey);
this.data = data;
this.structure = structure;
this.resolver = resolver;
}
public static VanillaJigsawExportSource forData(IrisData data, String structureKey) {
return new VanillaJigsawExportSource(
structureKey,
Objects.requireNonNull(data),
null,
StructureGraphResolver.forData(data));
}
public static VanillaJigsawExportSource forStructure(
String structureKey,
IrisStructure structure,
StructureGraphResolver resolver
) {
return new VanillaJigsawExportSource(
structureKey,
null,
Objects.requireNonNull(structure),
Objects.requireNonNull(resolver));
}
public String structureKey() {
return structureKey;
}
public IrisStructure loadStructure() {
if (structure != null) {
return structure;
}
return data.load(IrisStructure.class, structureKey, false);
}
public StructureGraphResolver resolver() {
return resolver;
}
private static String requireKey(String value) {
String key = Objects.requireNonNull(value).trim();
if (key.isEmpty()) {
throw new IllegalArgumentException("Structure key must not be blank");
}
return key;
}
}
@@ -0,0 +1,22 @@
package art.arcane.iris.core.structure.export;
import java.util.List;
public record VanillaJigsawExportValidation(
List<String> plannedResources,
List<VanillaJigsawExportDiagnostic> diagnostics
) {
public VanillaJigsawExportValidation {
plannedResources = List.copyOf(plannedResources);
diagnostics = List.copyOf(diagnostics);
}
public boolean isExportable() {
for (VanillaJigsawExportDiagnostic diagnostic : diagnostics) {
if (diagnostic.isBlocking()) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,56 @@
package art.arcane.iris.core.structure.export;
import java.util.regex.Pattern;
final class VanillaResourceIdentifier {
private static final Pattern NAMESPACE = Pattern.compile("[a-z0-9_.-]+");
private static final Pattern PATH = Pattern.compile("[a-z0-9/._-]+");
private VanillaResourceIdentifier() {
}
static boolean validNamespace(String value) {
return value != null && NAMESPACE.matcher(value).matches();
}
static boolean validPath(String value) {
if (value == null
|| !PATH.matcher(value).matches()
|| value.startsWith("/")
|| value.endsWith("/")
|| value.contains("//")) {
return false;
}
for (String segment : value.split("/")) {
if (segment.equals(".") || segment.equals("..")) {
return false;
}
}
return true;
}
static boolean validIdentifier(String value) {
if (value == null) {
return false;
}
int separator = value.indexOf(':');
if (separator <= 0 || separator != value.lastIndexOf(':')) {
return false;
}
return validNamespace(value.substring(0, separator)) && validPath(value.substring(separator + 1));
}
static String normalizeConnectorIdentifier(String value) {
if (value == null || value.isBlank()) {
return "minecraft:empty";
}
String normalized = value.trim();
if (normalized.indexOf(':') < 0) {
normalized = "minecraft:" + normalized;
}
if (!validIdentifier(normalized)) {
throw new IllegalArgumentException("Invalid resource identifier '" + value + "'");
}
return normalized;
}
}
@@ -0,0 +1,176 @@
package art.arcane.iris.core.structure.export;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.math.IrisBlockVector;
import art.arcane.iris.util.common.math.Vector3i;
import art.arcane.volmlib.util.nbt.io.NBTUtil;
import art.arcane.volmlib.util.nbt.io.NamedTag;
import art.arcane.volmlib.util.nbt.tag.CompoundTag;
import art.arcane.volmlib.util.nbt.tag.IntTag;
import art.arcane.volmlib.util.nbt.tag.ListTag;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
final class VanillaStructureTemplateEncoder {
private static final int DATA_VERSION_26_2 = 4903;
private static final Comparator<BlockEntry> BLOCK_ORDER = Comparator
.comparingInt((BlockEntry entry) -> entry.position().y())
.thenComparingInt(entry -> entry.position().x())
.thenComparingInt(entry -> entry.position().z());
byte[] encode(
IrisObject object,
IrisJigsawPiece piece,
Function<String, String> poolIdentifier
) throws IOException {
Map<BlockPosition, BlockEntry> blocks = objectBlocks(object);
addConnectors(blocks, piece, poolIdentifier);
List<BlockEntry> orderedBlocks = new ArrayList<>(blocks.values());
orderedBlocks.sort(BLOCK_ORDER);
Map<String, Integer> paletteIndexes = new LinkedHashMap<>();
List<VanillaBlockState> paletteStates = new ArrayList<>();
for (BlockEntry block : orderedBlocks) {
String key = block.state().canonical();
if (!paletteIndexes.containsKey(key)) {
paletteIndexes.put(key, paletteStates.size());
paletteStates.add(block.state());
}
}
CompoundTag root = new CompoundTag();
root.put("size", intList(object.getW(), object.getH(), object.getD()));
root.put("palette", palette(paletteStates));
root.put("blocks", blocks(orderedBlocks, paletteIndexes));
root.put("entities", new ListTag<>(CompoundTag.class));
root.putInt("DataVersion", DATA_VERSION_26_2);
ByteArrayOutputStream output = new ByteArrayOutputStream();
NBTUtil.write(new NamedTag("", root), output, true);
return output.toByteArray();
}
private Map<BlockPosition, BlockEntry> objectBlocks(IrisObject object) {
Map<BlockPosition, BlockEntry> blocks = new LinkedHashMap<>();
Vector3i center = object.getCenter();
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : object.getBlocks()) {
IrisBlockVector signed = entry.getKey();
BlockPosition position = new BlockPosition(
signed.getBlockX() + center.getX(),
signed.getBlockY() + center.getY(),
signed.getBlockZ() + center.getZ());
VanillaBlockState state = VanillaBlockState.parse(entry.getValue().key());
blocks.put(position, new BlockEntry(position, state, null));
}
return blocks;
}
private void addConnectors(
Map<BlockPosition, BlockEntry> blocks,
IrisJigsawPiece piece,
Function<String, String> poolIdentifier
) {
for (IrisJigsawConnector connector : piece.getConnectors()) {
IrisPosition sourcePosition = connector.getPosition();
BlockPosition position = new BlockPosition(
sourcePosition.getX(), sourcePosition.getY(), sourcePosition.getZ());
String orientation = orientation(connector.getDirection(), connector.getTop());
VanillaBlockState state = VanillaBlockState.parse(
"minecraft:jigsaw[orientation=" + orientation + "]");
CompoundTag nbt = connectorNbt(connector, poolIdentifier.apply(connector.getPool()));
blocks.put(position, new BlockEntry(position, state, nbt));
}
}
private CompoundTag connectorNbt(IrisJigsawConnector connector, String poolIdentifier) {
CompoundTag nbt = new CompoundTag();
nbt.putString("id", "minecraft:jigsaw");
nbt.putString("name", VanillaResourceIdentifier.normalizeConnectorIdentifier(connector.getName()));
nbt.putString("target", VanillaResourceIdentifier.normalizeConnectorIdentifier(connector.getTargetName()));
nbt.putString("pool", poolIdentifier);
nbt.putString("final_state", VanillaBlockState.parse(connector.getFinalState()).canonical());
nbt.putString("joint", connector.getJoint().name().toLowerCase(Locale.ROOT));
nbt.putInt("selection_priority", connector.getSelectionPriority());
nbt.putInt("placement_priority", connector.getPlacementPriority());
return nbt;
}
static String orientation(IrisDirection front, IrisDirection top) {
if (front == IrisDirection.UP_POSITIVE_Y || front == IrisDirection.DOWN_NEGATIVE_Y) {
if (top == IrisDirection.NORTH_NEGATIVE_Z
|| top == IrisDirection.SOUTH_POSITIVE_Z
|| top == IrisDirection.EAST_POSITIVE_X
|| top == IrisDirection.WEST_NEGATIVE_X) {
return directionName(front) + "_" + directionName(top);
}
throw new IllegalArgumentException("Vertical jigsaw fronts require a horizontal top direction");
}
if (top != IrisDirection.UP_POSITIVE_Y) {
throw new IllegalArgumentException("Horizontal jigsaw fronts require an upward top direction");
}
return directionName(front) + "_up";
}
private static String directionName(IrisDirection direction) {
return switch (direction) {
case UP_POSITIVE_Y -> "up";
case DOWN_NEGATIVE_Y -> "down";
case NORTH_NEGATIVE_Z -> "north";
case SOUTH_POSITIVE_Z -> "south";
case EAST_POSITIVE_X -> "east";
case WEST_NEGATIVE_X -> "west";
};
}
private ListTag<CompoundTag> palette(List<VanillaBlockState> states) {
ListTag<CompoundTag> palette = new ListTag<>(CompoundTag.class);
for (VanillaBlockState state : states) {
palette.add(state.toNbt());
}
return palette;
}
private ListTag<CompoundTag> blocks(
List<BlockEntry> entries,
Map<String, Integer> paletteIndexes
) {
ListTag<CompoundTag> blocks = new ListTag<>(CompoundTag.class);
for (BlockEntry entry : entries) {
CompoundTag block = new CompoundTag();
block.put("pos", intList(entry.position().x(), entry.position().y(), entry.position().z()));
block.putInt("state", paletteIndexes.get(entry.state().canonical()));
if (entry.nbt() != null) {
block.put("nbt", entry.nbt());
}
blocks.add(block);
}
return blocks;
}
private ListTag<IntTag> intList(int... values) {
ListTag<IntTag> list = new ListTag<>(IntTag.class);
for (int value : values) {
list.add(new IntTag(value));
}
return list;
}
private record BlockPosition(int x, int y, int z) {
}
private record BlockEntry(BlockPosition position, VanillaBlockState state, CompoundTag nbt) {
}
}
@@ -129,6 +129,8 @@ public class IrisCreator {
*/
private boolean benchmark = false;
private BiConsumer<Double, String> studioProgressConsumer;
private BiConsumer<String, Long> studioTimingConsumer;
private DatapackPreparation datapackPreparation = DatapackPreparation.INSTALL_IF_CHANGED;
public static boolean removeFromBukkitYml(String name) throws IOException {
return BukkitWorldConfiguration.remove(BUKKIT_YML, name);
@@ -185,7 +187,6 @@ public class IrisCreator {
}
private World createReserved(NamespacedKey worldKey, IrisDimension resolvedDimension) throws IrisException {
long createStart = System.currentTimeMillis();
File dimensionRoot;
try {
dimensionRoot = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
@@ -204,7 +205,7 @@ public class IrisCreator {
try {
reportStudioProgress(0.08D, "resolve_dimension");
reportStudioProgress(0.16D, "prepare_world_pack");
DatapackInstallResult datapackResult = ServerConfigurator.installDataPacksIfChanged(true);
DatapackInstallResult datapackResult = prepareDatapacks(resolvedDimension);
if (!datapackResult.succeeded()) {
throw new IrisException("Failed to compile datapacks for dimension \"" + dimension() + "\".");
}
@@ -232,13 +233,14 @@ public class IrisCreator {
reportStudioProgress(0.28D, "install_datapacks");
AtomicDouble pp = new AtomicDouble(0);
AtomicBoolean done = new AtomicBoolean(false);
long generatorPrepareStart = System.nanoTime();
WorldCreator wc = new IrisWorldCreator()
.dimension(installedDimension)
.name(name)
.seed(seed)
.studio(studio)
.create();
IrisLogging.debug("[Studio timing] create.packPrep + datapacks = " + (System.currentTimeMillis() - createStart) + "ms (cumulative in create)");
reportStudioTiming("prepare_studio_generator", generatorPrepareStart);
reportStudioProgress(0.40D, "install_datapacks");
PlatformChunkGenerator access = (PlatformChunkGenerator) wc.generator();
@@ -251,14 +253,13 @@ public class IrisCreator {
AtomicInteger createProgressTask = startCreateProgressReporter(access, done, createClaim);
reportStudioProgress(0.46D, "create_world");
long nmsStart = System.currentTimeMillis();
long nmsStartNanos = System.nanoTime();
try {
WorldLifecycleCaller callerKind = benchmark ? WorldLifecycleCaller.BENCHMARK : studio() ? WorldLifecycleCaller.STUDIO : WorldLifecycleCaller.CREATE;
WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(wc, studio(), benchmark, callerKind);
world = J.sfut(() -> INMS.get().createWorldAsync(wc, request))
.thenCompose(Function.identity())
.get(WORLD_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
IrisLogging.debug("[Studio timing] create.createWorldAsync (NMS bukkit world load + spawn prep) = " + (System.currentTimeMillis() - nmsStart) + "ms");
} catch (Throwable e) {
done.set(true);
cancelRepeatingTask(createProgressTask);
@@ -275,6 +276,8 @@ public class IrisCreator {
+ "Iris queued a restart; run the command again after the server returns.", e);
}
throw new IrisException("Failed to create world with backend family " + WorldLifecycleService.get().capabilities().serverFamily().id() + "!", e);
} finally {
reportStudioTiming("create_bukkit_world", nmsStartNanos);
}
done.set(true);
@@ -417,6 +420,34 @@ public class IrisCreator {
}
}
private void reportStudioTiming(String phase, long startedAtNanos) {
BiConsumer<String, Long> consumer = studioTimingConsumer;
if (consumer == null) {
return;
}
long duration = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos);
try {
consumer.accept(phase, duration);
} catch (Throwable e) {
IrisLogging.reportError("Studio timing consumer failed for world \"" + name() + "\".", e);
}
}
private DatapackInstallResult prepareDatapacks(IrisDimension resolvedDimension) {
DatapackPreparation preparation = Objects.requireNonNull(
datapackPreparation,
"Datapack preparation mode");
boolean runtimeReady = preparation == DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY
&& ServerConfigurator.isLoadedDatapackRuntimeReady(resolvedDimension);
if (!preparation.requiresInstall(runtimeReady)) {
long reuseStart = System.nanoTime();
reportStudioTiming("datapack_reuse_loaded_runtime", reuseStart);
return DatapackInstallResult.unchangedResult();
}
return ServerConfigurator.installDataPacksIfChanged(true, studioTimingConsumer);
}
private AtomicInteger startCreateProgressReporter(PlatformChunkGenerator access, AtomicBoolean done, HudSlotClaim claim) {
AtomicInteger taskId = new AtomicInteger(-1);
if (benchmark) {
@@ -727,4 +758,19 @@ public class IrisCreator {
}
return cursor;
}
public enum DatapackPreparation {
INSTALL_IF_CHANGED(false),
REUSE_LOADED_RUNTIME_IF_READY(true);
private final boolean reusesLoadedRuntime;
DatapackPreparation(boolean reusesLoadedRuntime) {
this.reusesLoadedRuntime = reusesLoadedRuntime;
}
boolean requiresInstall(boolean runtimeReady) {
return !reusesLoadedRuntime || !runtimeReady;
}
}
}
@@ -58,6 +58,7 @@ final class EngineHotloader {
void hotloadComplex() {
synchronized (engine.lifecycleLock) {
engine.requireRunning("rebuild the biome complex");
engine.awaitNativeStructureBootstrap("complex hotload");
engine.lifecycleState = LifecycleState.HOTLOADING;
EngineRuntime previous = engine.runtime;
IrisComplex nextComplex = null;
@@ -105,6 +106,7 @@ final class EngineHotloader {
void hotloadSilently() {
synchronized (engine.lifecycleLock) {
engine.requireRunning("hotload");
engine.awaitNativeStructureBootstrap("hotload");
engine.lifecycleState = LifecycleState.HOTLOADING;
EngineRuntime previousRuntime = engine.runtime;
IrisDimension previousDimension = engine.getDimension();
@@ -50,6 +50,7 @@ final class EngineShutdownSequence {
if (engine.closed) {
return;
}
engine.awaitNativeStructureBootstrap("close");
engine.lifecycleState = LifecycleState.CLOSING;
engine.getClosing().set(true);
engine.backgroundTasks.closeBackgroundTaskAdmission();
@@ -67,11 +67,15 @@ import lombok.Getter;
import lombok.Setter;
import java.util.HashSet;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Supplier;
@Data
public class IrisEngine implements Engine {
@@ -84,6 +88,7 @@ public class IrisEngine implements Engine {
private final AtomicDouble perSecond;
private final AtomicLong lastGPS;
private final EngineTarget target;
private final InitializationMode initializationMode;
private final EngineMantle mantle;
private final ChronoLatch perSecondLatch;
private final ChronoLatch perSecondBudLatch;
@@ -113,6 +118,9 @@ public class IrisEngine implements Engine {
final EngineHotloader hotloader = new EngineHotloader(this);
@Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE)
final NativeStructureBootstrapBarrier nativeStructureBootstrapBarrier = new NativeStructureBootstrapBarrier();
@Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE)
final EngineMetricsReport metricsReport = new EngineMetricsReport(this);
private final AtomicBoolean cleaning;
private final ChronoLatch cleanLatch;
@@ -123,6 +131,7 @@ public class IrisEngine implements Engine {
@Setter(AccessLevel.NONE)
private final NativeStructureVolumeMemo nativeStructureVolumeMemo = new NativeStructureVolumeMemo();
private final AtomicBoolean closing;
private final AtomicBoolean nativeStructureVolumeQueriesEnabled;
@Setter(AccessLevel.NONE)
volatile IrisEngineData engineData;
@Getter(AccessLevel.NONE)
@@ -158,13 +167,16 @@ public class IrisEngine implements Engine {
return System.identityHashCode(this);
}
public IrisEngine(EngineTarget target, boolean studio) {
this.studio = studio;
public IrisEngine(EngineTarget target, InitializationMode initializationMode) {
InitializationMode requiredMode = Objects.requireNonNull(initializationMode, "initialization mode");
this.initializationMode = requiredMode;
this.studio = requiredMode.studio();
this.target = target;
this.publishedTarget = target;
this.platformHooks = IrisServices.get(EnginePlatformHooks.class);
this.generationSessions = new GenerationSessionManager();
this.closing = new AtomicBoolean(true);
this.nativeStructureVolumeQueriesEnabled = new AtomicBoolean(!requiredMode.studio());
this.lifecycleState = LifecycleState.INITIALIZING;
this.closed = false;
this.failing = false;
@@ -204,8 +216,10 @@ public class IrisEngine implements Engine {
}
getData().registerEngine(this);
_t0 = M.ms();
long phaseStartedAt = System.nanoTime();
getData().loadPrefetch(this);
IrisLogging.debug("[IrisEngine timing] loadPrefetch=" + (M.ms() - _t0) + "ms");
logStudioInitializationPhase("load_prefetch", phaseStartedAt, false);
try {
StructureIndexService.writeOnce(getData());
} catch (Throwable e) {
@@ -214,12 +228,21 @@ public class IrisEngine implements Engine {
}
IrisLogging.info("Engine init: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed());
_t0 = M.ms();
phaseStartedAt = System.nanoTime();
EngineRuntime initialRuntime = runtimeBuilder.buildRuntime();
runtimeBuilder.publishRuntime(initialRuntime, null);
IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms");
logStudioInitializationPhase("build_runtime", phaseStartedAt, false);
_t0 = M.ms();
GenerationCacheWarmer.warm(this);
phaseStartedAt = System.nanoTime();
if (requiredMode.warmGenerationCaches()) {
GenerationCacheWarmer.warm(this);
}
IrisLogging.debug("[IrisEngine timing] cache warm total=" + (M.ms() - _t0) + "ms");
logStudioInitializationPhase(
"generation_cache_warm",
phaseStartedAt,
!requiredMode.warmGenerationCaches());
EngineTickRegistry.registerTicking(this);
} catch (Throwable e) {
shutdownSequence.cleanupFailedConstruction(e);
@@ -228,6 +251,18 @@ public class IrisEngine implements Engine {
IrisLogging.debug("Engine Initialized " + getCacheID());
}
private void logStudioInitializationPhase(String phase, long startedAtNanos, boolean skipped) {
if (!studio) {
return;
}
IrisLogging.info("[Studio engine timing] world=%s kind=%s phase=%s duration=%dms skipped=%s",
target.getWorld().name(),
initializationMode.name().toLowerCase(Locale.ROOT),
phase,
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos),
Boolean.toString(skipped));
}
private void verifySeed() {
if (getEngineData().getSeed() != null && getEngineData().getSeed() != target.getWorld().getRawWorldSeed()) {
target.getWorld().setRawWorldSeed(getEngineData().getSeed());
@@ -270,6 +305,23 @@ public class IrisEngine implements Engine {
}
}
public CompletableFuture<Void> startNativeStructureBootstrap(
Runnable claim,
Supplier<CompletableFuture<Void>> starter,
Runnable activation
) {
synchronized (lifecycleLock) {
requireRunning("prepare native structure placements");
CompletableFuture<Void> completion = nativeStructureBootstrapBarrier.start(claim, starter);
activation.run();
return completion;
}
}
void awaitNativeStructureBootstrap(String transition) {
nativeStructureBootstrapBarrier.await(transition);
}
@Override
public void generateMatter(int x, int z, boolean multicore, ChunkContext context) {
try (GenerationSessionLease lease = acquireGenerationLease("matter_generate");
@@ -328,9 +380,22 @@ public class IrisEngine implements Engine {
@Override
public KList<NativeStructureVolume> getNativeStructureVolumes(int minX, int minZ, int maxX, int maxZ) {
if (!nativeStructureVolumeQueriesEnabled.get()) {
return NativeStructureVolume.NONE;
}
return nativeStructureVolumeMemo.volumes(this, platformHooks, minX, minZ, maxX, maxZ);
}
public void setNativeStructureVolumeQueriesEnabled(boolean enabled) {
if (!enabled) {
nativeStructureVolumeQueriesEnabled.set(false);
nativeStructureVolumeMemo.clear();
return;
}
nativeStructureVolumeMemo.clear();
nativeStructureVolumeQueriesEnabled.set(true);
}
@Override
public IrisEngineData getEngineData() {
return engineDataStore.getEngineData();
@@ -637,4 +702,26 @@ public class IrisEngine implements Engine {
FAILED
}
public enum InitializationMode {
RUNTIME(false, true),
STUDIO(true, true),
JIGSAW_STUDIO(true, false);
private final boolean studio;
private final boolean warmGenerationCaches;
InitializationMode(boolean studio, boolean warmGenerationCaches) {
this.studio = studio;
this.warmGenerationCaches = warmGenerationCaches;
}
public boolean studio() {
return studio;
}
public boolean warmGenerationCaches() {
return warmGenerationCaches;
}
}
}
@@ -0,0 +1,95 @@
package art.arcane.iris.engine;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;
final class NativeStructureBootstrapBarrier {
private static final long TRANSITION_TIMEOUT_SECONDS = 120L;
private CompletableFuture<Void> active;
private CompletableFuture<Void> poisoned;
synchronized CompletableFuture<Void> start(
Runnable claim,
Supplier<CompletableFuture<Void>> starter
) {
Objects.requireNonNull(claim, "Native structure bootstrap claim");
Objects.requireNonNull(starter, "Native structure bootstrap starter");
if (active != null) {
throw new IllegalStateException("Native structure bootstrap is already active.");
}
claim.run();
CompletableFuture<Void> bridge = new CompletableFuture<>();
active = bridge;
bridge.whenComplete((ignored, failure) -> clearSuccessful(bridge, failure));
try {
CompletableFuture<Void> completion = Objects.requireNonNull(
starter.get(),
"Native structure bootstrap completion");
completion.whenComplete((ignored, failure) -> completeBridge(bridge, failure));
return bridge;
} catch (Throwable failure) {
poisoned = bridge;
bridge.completeExceptionally(failure);
if (failure instanceof RuntimeException runtimeFailure) {
throw runtimeFailure;
}
if (failure instanceof Error error) {
throw error;
}
throw new IllegalStateException("Native structure bootstrap could not start.", failure);
}
}
synchronized void await(String transition) {
CompletableFuture<Void> completion = active;
if (completion == null) {
return;
}
try {
completion.get(TRANSITION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
active = null;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"Interrupted while waiting for native structure bootstrap before " + transition + ".", e);
} catch (ExecutionException e) {
if (poisoned != completion) {
active = null;
}
Throwable cause = e.getCause() == null ? e : e.getCause();
throw new IllegalStateException(
"Native structure bootstrap failed before " + transition + ".", cause);
} catch (TimeoutException e) {
throw new IllegalStateException(
"Native structure bootstrap did not finish before " + transition + " within "
+ TRANSITION_TIMEOUT_SECONDS + " seconds.", e);
}
}
synchronized boolean isActive() {
return active != null;
}
synchronized boolean isPoisoned() {
return poisoned != null;
}
private void completeBridge(CompletableFuture<Void> bridge, Throwable failure) {
if (failure == null) {
bridge.complete(null);
return;
}
bridge.completeExceptionally(failure);
}
private synchronized void clearSuccessful(CompletableFuture<Void> completion, Throwable failure) {
if (failure == null && active == completion) {
active = null;
}
}
}

Some files were not shown because too many files have changed in this diff Show More