mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 12:41:43 +00:00
dwa
(removed ai file from when i generated docs)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package art.arcane.iris.core.runtime.jigsaw;
|
||||
|
||||
public enum JigsawStudioCompatibilityTarget {
|
||||
IRIS_EXTENDED,
|
||||
VANILLA_PORTABLE
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package art.arcane.iris.core.runtime.jigsaw;
|
||||
|
||||
public record JigsawStudioControlPosition(int worldX, int worldY, int worldZ) {
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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) {
|
||||
}
|
||||
}
|
||||
+22
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+227
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+435
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+465
@@ -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;
|
||||
}
|
||||
}
|
||||
+112
@@ -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;
|
||||
}
|
||||
}
|
||||
+95
@@ -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;
|
||||
}
|
||||
}
|
||||
+272
@@ -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) {
|
||||
}
|
||||
}
|
||||
+185
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
+140
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+147
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+666
-18
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-1
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
@@ -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
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package art.arcane.iris.core.structure.conversion;
|
||||
|
||||
public enum IrisStructureAdoptionDisposition {
|
||||
IN_PLACE,
|
||||
CLONE_REQUIRED,
|
||||
BLOCKED
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package art.arcane.iris.core.structure.conversion;
|
||||
|
||||
public enum IrisStructureAdoptionInputKind {
|
||||
UNOWNED_IRIS,
|
||||
MANAGED_DATAPACK
|
||||
}
|
||||
+56
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
+107
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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;
|
||||
}
|
||||
}
|
||||
+54
@@ -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
|
||||
}
|
||||
}
|
||||
+1313
File diff suppressed because it is too large
Load Diff
+7
@@ -0,0 +1,7 @@
|
||||
package art.arcane.iris.core.structure.conversion;
|
||||
|
||||
public enum IrisStructureAdoptionStrategy {
|
||||
AUTO,
|
||||
IN_PLACE,
|
||||
CLONE
|
||||
}
|
||||
+160
@@ -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;
|
||||
}
|
||||
}
|
||||
+74
@@ -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);
|
||||
}
|
||||
}
|
||||
+815
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package art.arcane.iris.core.structure.export;
|
||||
|
||||
public enum VanillaJigsawExportFormat {
|
||||
DIRECTORY,
|
||||
ZIP
|
||||
}
|
||||
+113
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
+243
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
}
|
||||
+176
@@ -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
Reference in New Issue
Block a user