This commit is contained in:
Brian Neumann-Fopiano
2026-08-11 14:54:53 -04:00
parent dfae45663a
commit 82290a3090
60 changed files with 2901 additions and 716 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ art/arcane/iris/core/service/ExternalDataSVC.java
art/arcane/iris/core/service/GlobalCacheSVC.java
art/arcane/iris/core/service/JigsawStudioMarkerParser.java
art/arcane/iris/core/service/JigsawStudioMenuController.java
art/arcane/iris/core/service/JigsawStudioDisabledWorkcellRenderer.java
art/arcane/iris/core/service/JigsawStudioBoundsRenderer.java
art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java
art/arcane/iris/core/service/JigsawStudioService.java
art/arcane/iris/core/service/JigsawStudioToolCodec.java
@@ -0,0 +1,154 @@
package art.arcane.iris.core;
import java.util.List;
import java.util.Optional;
public final class IrisStartupValidation {
private static volatile Snapshot snapshot = Snapshot.disabled();
private IrisStartupValidation() {
}
public static synchronized void begin() {
snapshot = new Snapshot(true, ValidationState.PENDING, ValidationState.PENDING, List.of(), List.of());
}
public static synchronized void disable() {
snapshot = Snapshot.disabled();
}
public static synchronized void beginDatapackValidation() {
if (!snapshot.enforced() || snapshot.datapacks() == ValidationState.RESTART_REQUIRED) {
return;
}
snapshot = new Snapshot(true, ValidationState.PENDING, snapshot.packs(), List.of(), snapshot.packFailures());
}
public static synchronized void markDatapacksReady() {
if (!snapshot.enforced() || snapshot.datapacks() == ValidationState.RESTART_REQUIRED) {
return;
}
snapshot = new Snapshot(true, ValidationState.READY, snapshot.packs(), List.of(), snapshot.packFailures());
}
public static synchronized void markDatapacksInvalid(String failure) {
if (!snapshot.enforced()) {
return;
}
snapshot = new Snapshot(
true,
ValidationState.INVALID,
snapshot.packs(),
List.of(normalizeFailure(failure, "External datapack validation failed.")),
snapshot.packFailures());
}
public static synchronized void requireRestart(String reason) {
if (!snapshot.enforced()) {
return;
}
snapshot = new Snapshot(
true,
ValidationState.RESTART_REQUIRED,
snapshot.packs(),
List.of(normalizeFailure(reason, "A restart is required to load validated datapacks.")),
snapshot.packFailures());
}
public static synchronized void markPacksReady() {
if (!snapshot.enforced()) {
return;
}
snapshot = new Snapshot(true, snapshot.datapacks(), ValidationState.READY, snapshot.datapackFailures(), List.of());
}
public static synchronized void markPacksInvalid(List<String> failures) {
if (!snapshot.enforced()) {
return;
}
List<String> normalized = failures == null || failures.isEmpty()
? List.of("Iris dimension-pack validation failed.")
: failures.stream()
.map(failure -> normalizeFailure(failure, "Iris dimension-pack validation failed."))
.toList();
snapshot = new Snapshot(true, snapshot.datapacks(), ValidationState.INVALID,
snapshot.datapackFailures(), normalized);
}
public static boolean isReady() {
return isReady(snapshot);
}
public static Optional<String> denialReason() {
Snapshot current = snapshot;
if (!current.enforced() || isReady(current)) {
return Optional.empty();
}
if (current.datapacks() == ValidationState.RESTART_REQUIRED) {
return Optional.of(firstFailure(current.datapackFailures(),
"Iris installed validated datapacks and requires a restart before the server is safe."));
}
if (current.datapacks() == ValidationState.INVALID) {
return Optional.of(firstFailure(current.datapackFailures(),
"Iris external datapack validation failed."));
}
if (current.datapacks() == ValidationState.PENDING) {
return Optional.of("Iris is still validating external datapacks.");
}
if (current.packs() == ValidationState.INVALID) {
return Optional.of(firstFailure(current.packFailures(),
"Iris dimension-pack validation failed."));
}
return Optional.of("Iris is still validating dimension packs.");
}
public static void requireWorldCreationReady() {
Optional<String> denial = denialReason();
if (denial.isPresent()) {
throw new IllegalStateException("Iris world creation is locked: " + denial.get());
}
}
static Snapshot snapshot() {
return snapshot;
}
private static String normalizeFailure(String failure, String fallback) {
return failure == null || failure.isBlank() ? fallback : failure.trim();
}
private static String firstFailure(List<String> failures, String fallback) {
return failures == null || failures.isEmpty() ? fallback : failures.getFirst();
}
private static boolean isReady(Snapshot current) {
return !current.enforced()
|| current.datapacks() == ValidationState.READY
&& current.packs() == ValidationState.READY;
}
enum ValidationState {
PENDING,
READY,
INVALID,
RESTART_REQUIRED,
DISABLED
}
record Snapshot(
boolean enforced,
ValidationState datapacks,
ValidationState packs,
List<String> datapackFailures,
List<String> packFailures
) {
Snapshot {
datapackFailures = List.copyOf(datapackFailures);
packFailures = List.copyOf(packFailures);
}
private static Snapshot disabled() {
return new Snapshot(false, ValidationState.DISABLED, ValidationState.DISABLED, List.of(), List.of());
}
}
}
@@ -161,6 +161,8 @@ public class ServerConfigurator {
invalidateLoadedDatapackRuntime();
loadedDatapackRestartRequired = true;
}
IrisStartupValidation.requireRestart(
"Iris datapack changes require a restart before player admission or world creation.");
}
public static void restoreLoadedDatapackRuntimeIfUnchanged(
@@ -21,6 +21,7 @@ package art.arcane.iris.core.datapack;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.datapack.ModrinthResolver.ResolvedDatapack;
@@ -62,6 +63,7 @@ import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileStore;
import java.nio.file.FileSystems;
import java.nio.file.LinkOption;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -97,8 +99,10 @@ public final class DatapackIngestService {
private static final String TRANSACTION_DIRECTORY = ".iris-datapack-transactions";
private static final String TRANSACTION_JOURNAL = "journal.json";
private static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json";
private static final String STARTUP_VALIDATION_CACHE = "startup-validation.json";
private static final int OWNERSHIP_SCHEMA = 1;
private static final int TRANSACTION_SCHEMA = 2;
private static final int STARTUP_VALIDATION_SCHEMA = 1;
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;
@@ -113,9 +117,12 @@ public final class DatapackIngestService {
private static final long MAX_METADATA_BYTES = 1024L * 1024L;
private static final long MAX_OWNERSHIP_BYTES = 1024L * 1024L;
private static final int MAX_TRANSACTION_COUNT = 1_024;
private static final int MAX_SCRATCH_DELETE_ATTEMPTS = 3;
private static final int WINDOWS_LEGACY_PATH_LIMIT = 247;
private static final Set<String> RESERVED_IDS = Set.of("iris");
private static final ReentrantLock TRANSACTION_LOCK = new ReentrantLock();
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static volatile StartupValidationCache activeStartupValidation;
private DatapackIngestService() {
}
@@ -125,19 +132,367 @@ public final class DatapackIngestService {
}
public static void autoIngestOnStartup() {
boolean restarting = false;
if (IrisSettings.get().getGeneral().autoIngestDatapacks) {
KList<String> urls = collectConfiguredImports();
if (!urls.isEmpty()) {
IrisLogging.info("Auto-ingesting " + urls.size() + " external datapack import(s) from pack datapackImports...");
Report report = ingest(null, urls, true);
restarting = report.changed();
StartupValidationOutcome outcome = validateOnStartup();
if (outcome == StartupValidationOutcome.READY) {
runPostStartupTasks();
}
}
public static StartupValidationOutcome validateOnStartup() {
activeStartupValidation = null;
IrisStartupValidation.beginDatapackValidation();
KList<String> configured = collectConfiguredImports();
List<String> urls = configured.stream().sorted().toList();
boolean autoIngest = IrisSettings.get().getGeneral().autoIngestDatapacks;
boolean stripOverrides = resolveStripOverrides();
String mcVersion = serverMcVersion();
int irisVersion = IrisPlatforms.get().irisVersionNumber();
File root = IrisPlatforms.get().dataFolder("datapacks");
KList<File> worldFolders = ServerConfigurator.getDatapacksFolder();
Path cacheFile = new File(root, STARTUP_VALIDATION_CACHE).toPath();
try {
String localFingerprint = startupValidationFingerprint(root, worldFolders);
StartupValidationCache cached = readStartupValidationCache(cacheFile);
if (startupValidationCacheMatches(
cached,
mcVersion,
irisVersion,
autoIngest,
stripOverrides,
urls,
localFingerprint)) {
activeStartupValidation = cached;
IrisLogging.info("External datapacks match the persisted startup validation; remote resolution and full revalidation were skipped.");
IrisStartupValidation.markDatapacksReady();
return StartupValidationOutcome.READY;
}
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Persisted external datapack validation could not be reused: "
+ failureMessage(exception));
}
if (!restarting) {
refreshWorkspaces();
autoImportDatapackStructures();
if (autoIngest && !configured.isEmpty()) {
IrisLogging.info("Validating " + configured.size()
+ " configured external datapack import(s) before player admission...");
Report report = ingest(null, configured, true);
if (!report.getFailed().isEmpty()) {
String failure = report.getFailed().getFirst();
IrisStartupValidation.markDatapacksInvalid(failure);
return StartupValidationOutcome.FAILED;
}
StartupValidationOutcome outcome = report.changed()
? StartupValidationOutcome.RESTART_REQUIRED
: StartupValidationOutcome.READY;
activeStartupValidation = cacheStartupValidation(root, worldFolders, cacheFile, mcVersion, irisVersion,
autoIngest, stripOverrides, urls);
if (outcome == StartupValidationOutcome.RESTART_REQUIRED) {
IrisStartupValidation.requireRestart(
"Iris installed updated external datapacks; restart must complete before player admission or world creation.");
} else {
IrisStartupValidation.markDatapacksReady();
}
return outcome;
}
ReapplyOutcome reapply = reapplyFromStaging(worldFolders);
if (!reapply.succeeded()) {
String failure = reapply.failure()
.map(DatapackIngestService::failureMessage)
.orElse("External datapack recovery failed.");
IrisStartupValidation.markDatapacksInvalid(failure);
return StartupValidationOutcome.FAILED;
}
activeStartupValidation = cacheStartupValidation(root, worldFolders, cacheFile, mcVersion, irisVersion,
autoIngest, stripOverrides, urls);
if (reapply.changed()) {
IrisStartupValidation.requireRestart(
"Iris repaired external datapack files; restart must complete before player admission or world creation.");
return StartupValidationOutcome.RESTART_REQUIRED;
}
IrisStartupValidation.markDatapacksReady();
return StartupValidationOutcome.READY;
}
public static void runPostStartupTasks() {
refreshWorkspaces();
autoImportDatapackStructures();
refreshStartupValidationAfterMaintenance();
}
private static StartupValidationCache cacheStartupValidation(
File root,
KList<File> worldFolders,
Path cacheFile,
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls
) {
try {
StartupValidationCache cache = createStartupValidationCache(
mcVersion,
irisVersion,
autoIngest,
stripOverrides,
urls,
startupValidationFingerprint(root, worldFolders));
writeStartupValidationCache(cacheFile, cache);
return cache;
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Could not persist external datapack startup validation: "
+ failureMessage(exception));
return null;
}
}
private static void refreshStartupValidationAfterMaintenance() {
StartupValidationCache validated = activeStartupValidation;
if (validated == null || !IrisStartupValidation.isReady()) {
return;
}
KList<String> configured = collectConfiguredImports();
List<String> urls = configured.stream().sorted().toList();
boolean autoIngest = IrisSettings.get().getGeneral().autoIngestDatapacks;
boolean stripOverrides = resolveStripOverrides();
String mcVersion = serverMcVersion();
int irisVersion = IrisPlatforms.get().irisVersionNumber();
if (!startupValidationContextMatches(
validated, mcVersion, irisVersion, autoIngest, stripOverrides, urls)) {
return;
}
File root = IrisPlatforms.get().dataFolder("datapacks");
KList<File> worldFolders = ServerConfigurator.getDatapacksFolder();
Path cacheFile = new File(root, STARTUP_VALIDATION_CACHE).toPath();
TRANSACTION_LOCK.lock();
try {
recoverTransactions(root, worldFolders);
StartupValidationCache refreshed = refreshStartupValidationCache(
validated, root, worldFolders);
writeStartupValidationCache(cacheFile, refreshed);
activeStartupValidation = refreshed;
} catch (IOException | RuntimeException exception) {
IrisLogging.warn("Could not refresh external datapack validation after startup maintenance: "
+ failureMessage(exception));
} finally {
TRANSACTION_LOCK.unlock();
}
}
static StartupValidationCache refreshStartupValidationCache(
StartupValidationCache validated,
File root,
KList<File> worldFolders
) throws IOException {
Objects.requireNonNull(validated, "Validated external datapack startup state");
return createStartupValidationCache(
validated.minecraftVersion,
validated.irisVersion,
validated.autoIngest,
validated.stripOverrides,
validated.urls,
startupValidationFingerprint(root, worldFolders));
}
private static StartupValidationCache createStartupValidationCache(
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls,
String localFingerprint
) {
StartupValidationCache cache = new StartupValidationCache();
cache.schemaVersion = STARTUP_VALIDATION_SCHEMA;
cache.minecraftVersion = Objects.requireNonNullElse(mcVersion, "");
cache.irisVersion = irisVersion;
cache.autoIngest = autoIngest;
cache.stripOverrides = stripOverrides;
cache.urls = List.copyOf(urls);
cache.localFingerprint = localFingerprint;
return cache;
}
static boolean startupValidationContextMatches(
StartupValidationCache cache,
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls
) {
return cache != null
&& cache.schemaVersion == STARTUP_VALIDATION_SCHEMA
&& Objects.equals(cache.minecraftVersion, Objects.requireNonNullElse(mcVersion, ""))
&& cache.irisVersion == irisVersion
&& cache.autoIngest == autoIngest
&& cache.stripOverrides == stripOverrides
&& Objects.equals(cache.urls, urls);
}
static boolean startupValidationCacheMatches(
StartupValidationCache cache,
String mcVersion,
int irisVersion,
boolean autoIngest,
boolean stripOverrides,
List<String> urls,
String localFingerprint
) {
return startupValidationContextMatches(
cache, mcVersion, irisVersion, autoIngest, stripOverrides, urls)
&& localFingerprint != null && !localFingerprint.isBlank()
&& Objects.equals(cache.localFingerprint, localFingerprint);
}
static StartupValidationCache readStartupValidationCache(Path cacheFile) {
if (cacheFile == null
|| Files.isSymbolicLink(cacheFile)
|| !Files.isRegularFile(cacheFile, LinkOption.NOFOLLOW_LINKS)) {
return null;
}
try {
if (Files.size(cacheFile) > MAX_METADATA_BYTES) {
return null;
}
StartupValidationCache cache = GSON.fromJson(
readBoundedUtf8(cacheFile, MAX_METADATA_BYTES, "External datapack startup validation"),
StartupValidationCache.class);
if (cache == null || cache.urls == null) {
return null;
}
List<String> sortedUrls = cache.urls.stream()
.filter(Objects::nonNull)
.sorted()
.toList();
if (sortedUrls.size() != cache.urls.size()
|| new HashSet<>(sortedUrls).size() != sortedUrls.size()
|| !sortedUrls.equals(cache.urls)) {
return null;
}
cache.urls = sortedUrls;
return cache;
} catch (IOException | RuntimeException exception) {
return null;
}
}
private static void writeStartupValidationCache(Path cacheFile, StartupValidationCache cache) throws IOException {
Path absolute = cacheFile.toAbsolutePath().normalize();
Path parent = Objects.requireNonNull(absolute.getParent(), "External datapack startup validation parent");
Files.createDirectories(parent);
Path staged = Files.createTempFile(parent, ".startup-validation-", ".tmp");
try {
byte[] content = GSON.toJson(cache).getBytes(StandardCharsets.UTF_8);
if (content.length > MAX_METADATA_BYTES) {
throw new IOException("External datapack startup validation exceeds " + MAX_METADATA_BYTES + " bytes");
}
Files.write(staged, content, StandardOpenOption.TRUNCATE_EXISTING);
forceFile(staged);
try {
Files.move(staged, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(staged, absolute, StandardCopyOption.REPLACE_EXISTING);
}
forceDirectoryIfSupported(parent);
} finally {
Files.deleteIfExists(staged);
}
}
static String startupValidationFingerprint(File root, KList<File> worldFolders) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path manifestPath = new File(root, "manifest.json").toPath();
updateFingerprintValue(digest, "manifest");
if (Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(manifestPath)
|| !Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid datapack manifest path " + manifestPath);
}
byte[] manifest = readBoundedBytes(
manifestPath, MAX_MANIFEST_BYTES, "Datapack manifest fingerprint");
updateDigestLong(digest, manifest.length);
digest.update(manifest);
} else if (Files.notExists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
updateDigestLong(digest, -1L);
} else {
throw new IOException("Cannot determine datapack manifest state at " + manifestPath);
}
Manifest manifest = readCommittedManifest(root);
updateDirectoryFingerprint(digest, "staging", new File(root, "staging"));
updateDirectoryFingerprint(digest, "transactions", new File(root, TRANSACTION_DIRECTORY));
updateDirectoryFingerprint(
digest,
"storage-install-scratch",
installScratchRoot(new File(root, "staging")));
List<Entry> entries = new ArrayList<>(manifest.entries);
entries.sort(Comparator.comparing(entry -> entry.id));
List<File> targets = new ArrayList<>(worldFolders == null ? List.of() : worldFolders);
targets.sort(Comparator.comparing(file -> file.toPath().toAbsolutePath().normalize().toString()));
for (File worldFolder : targets) {
String worldIdentity = worldFolder.toPath().toAbsolutePath().normalize().toString();
updateFingerprintValue(digest, "world:" + worldIdentity);
updateDirectoryFingerprint(
digest,
"world-install-scratch:" + worldIdentity,
installScratchRoot(worldFolder));
for (Entry entry : entries) {
updateDirectoryFingerprint(
digest,
"world-pack:" + worldIdentity + ":" + entry.id,
new File(worldFolder, entry.id));
}
}
return hex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IOException("SHA-256 algorithm unavailable", exception);
}
}
private static void updateDirectoryFingerprint(
MessageDigest digest,
String identity,
File directory
) throws IOException {
updateFingerprintValue(digest, identity);
Path path = directory.toPath();
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
updateFingerprintValue(digest, "missing");
return;
}
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(path)
|| !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid external datapack validation directory " + path);
}
updateFingerprintValue(digest, directoryHash(directory));
Path ownership = new File(directory, OWNERSHIP_MARKER).toPath();
if (Files.isRegularFile(ownership, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(ownership)) {
byte[] marker = readBoundedBytes(
ownership, MAX_OWNERSHIP_BYTES, "External datapack ownership fingerprint");
updateDigestLong(digest, marker.length);
digest.update(marker);
} else {
updateDigestLong(digest, -1L);
}
}
private static void updateFingerprintValue(MessageDigest digest, String value) {
byte[] bytes = Objects.requireNonNullElse(value, "").getBytes(StandardCharsets.UTF_8);
updateDigestInt(digest, bytes.length);
digest.update(bytes);
}
private static String failureMessage(Throwable exception) {
if (exception == null) {
return "unknown failure";
}
String message = exception.getMessage();
return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message;
}
public static void refreshWorkspaces() {
@@ -158,6 +513,7 @@ public final class DatapackIngestService {
}
public static Report ingest(VolmitSender sender, KList<String> urls, boolean restart) {
IrisStartupValidation.beginDatapackValidation();
ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation =
ServerConfigurator.invalidateLoadedDatapackRuntime();
Report report;
@@ -176,6 +532,11 @@ public final class DatapackIngestService {
ServerConfigurator.requireDatapackRestart();
}
}
if (!report.getFailed().isEmpty()) {
IrisStartupValidation.markDatapacksInvalid(report.getFailed().getFirst());
} else if (!report.changed()) {
IrisStartupValidation.markDatapacksReady();
}
return report;
}
@@ -304,6 +665,10 @@ public final class DatapackIngestService {
ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation);
} else if (outcome.changed()) {
ServerConfigurator.requireDatapackRestart();
} else {
IrisStartupValidation.markDatapacksInvalid(outcome.failure()
.map(DatapackIngestService::failureMessage)
.orElse("External datapack recovery failed."));
}
return outcome;
}
@@ -1279,7 +1644,7 @@ public final class DatapackIngestService {
verifyPendingExtractionName(normalizedSource.getFileName().toString(), entry.id);
validateManagedDirectory(verifiedSource, entry.id);
validateScratchTree(normalizedSource);
if (!Objects.equals(Files.getFileStore(normalizedSource), Files.getFileStore(normalizedStagingRoot))) {
if (!sameScratchVolume(normalizedSource, normalizedStagingRoot)) {
throw new IOException("Verified datapack extraction crosses a filesystem boundary");
}
String desiredHash = directoryHash(verifiedSource);
@@ -1338,8 +1703,9 @@ public final class DatapackIngestService {
private static Path requireDirectoryIdentity(File directory, String purpose) throws IOException {
Path normalized = directory.toPath().toAbsolutePath().normalize();
if (Files.isSymbolicLink(normalized)
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)) {
BasicFileAttributes attributes = Files.readAttributes(
normalized, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (!isSupportedScratchDirectory(attributes)) {
throw new IOException("Invalid " + purpose + " " + normalized);
}
normalized.toRealPath();
@@ -1350,9 +1716,17 @@ public final class DatapackIngestService {
Path current = normalized.getRoot();
for (Path component : normalized) {
current = current == null ? component : current.resolve(component);
if (Files.isSymbolicLink(current)) {
BasicFileAttributes attributes = Files.readAttributes(
current, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (attributes.isSymbolicLink()) {
throw new IOException("Refusing symbolic-link component in " + purpose + " " + normalized);
}
if (attributes.isOther()) {
throw new IOException("Refusing special filesystem component in " + purpose + " " + normalized);
}
if (!isSupportedScratchDirectory(attributes)) {
throw new IOException("Refusing unsupported component in " + purpose + " " + normalized);
}
}
}
@@ -1677,7 +2051,7 @@ public final class DatapackIngestService {
private static void validateInstallTree(File directory, File storeAnchor, String purpose) throws IOException {
validateScratchTree(directory.toPath());
if (!Objects.equals(Files.getFileStore(directory.toPath()), Files.getFileStore(storeAnchor.toPath()))) {
if (!sameScratchVolume(directory.toPath(), storeAnchor.toPath())) {
throw new IOException(purpose + " crosses a filesystem boundary at " + directory.getPath());
}
}
@@ -1990,21 +2364,26 @@ public final class DatapackIngestService {
plan.pendingRoot.delete();
}
private static void deleteInstallScratch(File scratch, String purpose) throws IOException {
if (Files.notExists(scratch.toPath(), LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (!Files.exists(scratch.toPath(), LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(scratch.toPath())
|| !Files.isDirectory(scratch.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to remove unsafe " + purpose + " " + scratch.getPath());
}
static void deleteInstallScratch(File scratch, String purpose) throws IOException {
Path scratchPath = scratch.toPath();
File parent = Objects.requireNonNull(scratch.getParentFile(), "datapack scratch parent");
validateInstallTree(scratch, parent, purpose);
IO.delete(scratch);
if (Files.exists(scratch.toPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Could not remove " + purpose + " " + scratch.getPath());
for (int attempt = 0; attempt < MAX_SCRATCH_DELETE_ATTEMPTS; attempt++) {
if (Files.notExists(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (!Files.exists(scratchPath, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(scratchPath)
|| !Files.isDirectory(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Refusing to remove unsafe " + purpose + " " + scratch.getPath());
}
validateInstallTree(scratch, parent, purpose);
removeFinderMetadata(scratch);
IO.delete(scratch);
if (Files.notExists(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
return;
}
}
throw new IOException("Could not remove " + purpose + " " + scratch.getPath());
}
private static boolean resolveStripOverrides() {
@@ -2271,6 +2650,7 @@ public final class DatapackIngestService {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Path rootPath = root.toPath().toAbsolutePath().normalize();
Path rootMarker = rootPath.resolve(OWNERSHIP_MARKER);
FileStore rootStore = Files.getFileStore(rootPath);
List<Path> entries = new ArrayList<>();
try (Stream<Path> paths = Files.walk(rootPath)) {
Iterator<Path> iterator = paths.iterator();
@@ -2315,9 +2695,14 @@ public final class DatapackIngestService {
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS
);
if (!attributes.isDirectory() && !attributes.isRegularFile()) {
if (attributes.isSymbolicLink()
|| attributes.isOther()
|| !attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Datapack entry changed while hashing: " + relative);
}
if (!sameScratchVolume(rootPath, rootStore, entry, Files.getFileStore(entry))) {
throw new IOException("Datapack entry crosses a filesystem boundary: " + entry);
}
boolean directory = attributes.isDirectory();
digest.update((byte) (directory ? 1 : 2));
updateDigestInt(digest, relativeBytes.length);
@@ -3809,16 +4194,78 @@ public final class DatapackIngestService {
BasicFileAttributes attributes = Files.readAttributes(
entry, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
if (attributes.isSymbolicLink()
|| attributes.isOther()
|| (!attributes.isDirectory() && !attributes.isRegularFile())) {
throw new IOException("Datapack scratch contains an unsupported file: " + entry);
}
if (!Objects.equals(rootStore, Files.getFileStore(entry))) {
if (!sameScratchVolume(root, rootStore, entry, Files.getFileStore(entry))) {
throw new IOException("Datapack scratch crosses a filesystem boundary: " + entry);
}
}
}
}
private static boolean sameScratchVolume(Path first, Path second) throws IOException {
return sameScratchVolume(first, Files.getFileStore(first), second, Files.getFileStore(second));
}
static boolean sameScratchVolume(
Path first,
FileStore firstStore,
Path second,
FileStore secondStore
) {
if (Objects.equals(firstStore, secondStore)) {
return true;
}
if (!isDefaultWindowsPath(first) || !isDefaultWindowsPath(second)) {
return false;
}
Path firstAbsolute = first.toAbsolutePath().normalize();
Path secondAbsolute = second.toAbsolutePath().normalize();
if ((firstAbsolute.toString().length() > WINDOWS_LEGACY_PATH_LIMIT)
== (secondAbsolute.toString().length() > WINDOWS_LEGACY_PATH_LIMIT)) {
return false;
}
Path firstRoot = firstAbsolute.getRoot();
Path secondRoot = secondAbsolute.getRoot();
if (firstRoot == null || secondRoot == null) {
return false;
}
return sameWindowsVolume(
firstStore, firstRoot.toString(), secondStore, secondRoot.toString());
}
static boolean sameWindowsVolume(
FileStore firstStore,
String firstRoot,
FileStore secondStore,
String secondRoot
) {
if (firstRoot == null || secondRoot == null || !firstRoot.equalsIgnoreCase(secondRoot)) {
return false;
}
try {
Object firstSerial = firstStore.getAttribute("volume:vsn");
Object secondSerial = secondStore.getAttribute("volume:vsn");
return firstSerial != null && firstSerial.equals(secondSerial);
} catch (IOException | RuntimeException exception) {
return false;
}
}
static boolean isSupportedScratchDirectory(BasicFileAttributes attributes) {
return attributes != null
&& attributes.isDirectory()
&& !attributes.isSymbolicLink()
&& !attributes.isOther();
}
private static boolean isDefaultWindowsPath(Path path) {
return File.separatorChar == '\\'
&& path.getFileSystem().equals(FileSystems.getDefault());
}
private static Ownership verifyManagedScratchDirectory(File directory, String id) throws IOException {
validateManagedDirectory(directory, id);
Ownership ownership = readOwnership(directory);
@@ -4752,9 +5199,8 @@ public final class DatapackIngestService {
if (!Objects.equals(legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot)
|| !Files.isSameFile(
legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot)
|| !Objects.equals(
Files.getFileStore(legacyStagingSnapshot.normalizedTarget()),
Files.getFileStore(normalizedStagingRoot))) {
|| !sameScratchVolume(
legacyStagingSnapshot.normalizedTarget(), normalizedStagingRoot)) {
throw new IOException("Changed or unsafe canonical legacy datapack staging target for " + id);
}
verifyDirectorySnapshot(
@@ -4831,6 +5277,22 @@ public final class DatapackIngestService {
public Map<String, Map<String, String>> importedBundles = new HashMap<>();
}
static final class StartupValidationCache {
int schemaVersion;
String minecraftVersion;
int irisVersion;
boolean autoIngest;
boolean stripOverrides;
List<String> urls;
String localFingerprint;
}
public enum StartupValidationOutcome {
READY,
RESTART_REQUIRED,
FAILED
}
private static final class Manifest {
private List<Entry> entries = new ArrayList<>();
@@ -1,5 +1,6 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.tools.IrisToolbelt;
@@ -76,6 +77,7 @@ public final class WorldLifecycleService {
public CompletableFuture<World> create(WorldLifecycleRequest request) {
WorldLifecycleBackend backend;
try {
IrisStartupValidation.requireWorldCreationReady();
backend = selectCreateBackend(request);
} catch (Throwable e) {
IrisLogging.reportError("WorldLifecycle create backend selection failed for world=\"" + request.worldName()
@@ -0,0 +1,201 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.spi.PlatformStructureHooks;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
public final class PackValidationCache {
private static final int SCHEMA_VERSION = 1;
private static final long MAX_CACHE_BYTES = 16L * 1024L * 1024L;
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private PackValidationCache() {
}
public static String contentFingerprint(File packsRoot) {
return ServerConfigurator.computePackFingerprint(packsRoot);
}
public static String contextFingerprint() {
if (!IrisPlatforms.isBound()) {
return "";
}
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
IrisPlatform platform = IrisPlatforms.get();
update(digest, platform.platformName());
update(digest, platform.minecraftVersion());
update(digest, Integer.toString(platform.irisVersionNumber()));
update(digest, Boolean.toString(ContentKeyValidator.strictContent()));
PlatformRegistries registries = Objects.requireNonNull(
platform.registries(), "Pack validation platform registries");
updateSorted(digest, registries.blockKeys());
updateSorted(digest, registries.biomeKeys());
updateSorted(digest, registries.itemKeys());
updateSorted(digest, registries.entityKeys());
PlatformStructureHooks hooks = Objects.requireNonNull(
platform.structureHooks(), "Pack validation structure hooks");
updateSorted(digest, hooks.structureKeys());
updateSorted(digest, hooks.jigsawStructureKeys());
updateSorted(digest, hooks.templatePoolKeys());
updateSorted(digest, hooks.structureSetKeys());
updateSorted(digest, hooks.objectFeatureKeys());
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
public static Optional<List<PackValidationResult>> load(
Path cacheFile,
String contentFingerprint,
String contextFingerprint,
List<String> expectedPackNames
) {
if (cacheFile == null
|| contentFingerprint == null || contentFingerprint.isBlank()
|| contextFingerprint == null || contextFingerprint.isBlank()
|| !Files.isRegularFile(cacheFile, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(cacheFile)) {
return Optional.empty();
}
try {
if (Files.size(cacheFile) > MAX_CACHE_BYTES) {
return Optional.empty();
}
CacheState state = GSON.fromJson(Files.readString(cacheFile, StandardCharsets.UTF_8), CacheState.class);
if (state == null
|| state.schemaVersion != SCHEMA_VERSION
|| !contentFingerprint.equals(state.contentFingerprint)
|| !contextFingerprint.equals(state.contextFingerprint)
|| state.results == null) {
return Optional.empty();
}
Set<String> expected = new HashSet<>(expectedPackNames == null ? List.of() : expectedPackNames);
List<PackValidationResult> results = new ArrayList<>(state.results.size());
Set<String> actual = new HashSet<>();
for (CachedResult cached : state.results) {
if (cached == null || cached.packName == null || cached.packName.isBlank()
|| cached.blockingErrors == null || cached.warnings == null
|| !actual.add(cached.packName)) {
return Optional.empty();
}
results.add(new PackValidationResult(
cached.packName,
cached.blockingErrors,
cached.warnings,
cached.validatedAtMillis));
}
if (!expected.equals(actual)) {
return Optional.empty();
}
results.sort(Comparator.comparing(PackValidationResult::getPackName));
return Optional.of(List.copyOf(results));
} catch (IOException | RuntimeException exception) {
return Optional.empty();
}
}
public static void save(
Path cacheFile,
String contentFingerprint,
String contextFingerprint,
List<PackValidationResult> results
) throws IOException {
if (cacheFile == null || contentFingerprint == null || contentFingerprint.isBlank()
|| contextFingerprint == null || contextFingerprint.isBlank()) {
return;
}
List<PackValidationResult> sorted = new ArrayList<>(results == null ? List.of() : results);
sorted.sort(Comparator.comparing(PackValidationResult::getPackName));
CacheState state = new CacheState();
state.schemaVersion = SCHEMA_VERSION;
state.contentFingerprint = contentFingerprint;
state.contextFingerprint = contextFingerprint;
state.results = new ArrayList<>(sorted.size());
for (PackValidationResult result : sorted) {
CachedResult cached = new CachedResult();
cached.packName = result.getPackName();
cached.blockingErrors = List.copyOf(result.getBlockingErrors());
cached.warnings = List.copyOf(result.getWarnings());
cached.validatedAtMillis = result.getValidatedAtMillis();
state.results.add(cached);
}
Path absolute = cacheFile.toAbsolutePath().normalize();
Path parent = Objects.requireNonNull(absolute.getParent(), "Pack validation cache parent");
Files.createDirectories(parent);
Path staged = Files.createTempFile(parent, ".pack-validation-", ".tmp");
try {
byte[] content = GSON.toJson(state).getBytes(StandardCharsets.UTF_8);
if (content.length > MAX_CACHE_BYTES) {
throw new IOException("Pack validation cache exceeds " + MAX_CACHE_BYTES + " bytes");
}
Files.write(staged, content);
try {
Files.move(staged, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(staged, absolute, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(staged);
}
}
private static void updateSorted(MessageDigest digest, List<String> values) {
List<String> sorted = new ArrayList<>(Objects.requireNonNull(values, "Pack validation registry keys"));
sorted.sort(String::compareTo);
update(digest, Integer.toString(sorted.size()));
for (String value : sorted) {
update(digest, value);
}
}
private static void update(MessageDigest digest, String value) {
byte[] bytes = Objects.requireNonNullElse(value, "").getBytes(StandardCharsets.UTF_8);
digest.update((byte) (bytes.length >>> 24));
digest.update((byte) (bytes.length >>> 16));
digest.update((byte) (bytes.length >>> 8));
digest.update((byte) bytes.length);
digest.update(bytes);
}
private static final class CacheState {
private int schemaVersion;
private String contentFingerprint;
private String contextFingerprint;
private List<CachedResult> results;
}
private static final class CachedResult {
private String packName;
private List<String> blockingErrors;
private List<String> warnings;
private long validatedAtMillis;
}
}
@@ -218,9 +218,7 @@ public final class StudioOpenCoordinator {
if (request.openKind().openWorkspace() && request.project() != null) {
new IrisCodeWorkspace(request.project()).openVSCode(request.sender());
}
if (request.onDone() != null) {
request.onDone().accept(world);
}
runOpenFinalizer(request.onDone(), world);
t = logStudioPhase(request, "finalize_open", t, openStart);
IrisLogging.info("Studio open: " + world.getName() + " ready in "
@@ -272,6 +270,20 @@ public final class StudioOpenCoordinator {
return now;
}
private void runOpenFinalizer(Consumer<World> finalizer, World world)
throws InterruptedException, ExecutionException, TimeoutException {
if (finalizer == null) {
return;
}
if (J.isPrimaryThread()) {
finalizer.accept(world);
return;
}
CompletableFuture<Void> completion = J.sfut(() -> finalizer.accept(world));
completion.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
private long elapsedMillis(long startedAtNanos) {
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos);
}
@@ -157,7 +157,9 @@ public final class JigsawStudioGraphEditor {
IrisStructure structure = readStructure(
resolveOwnedResource(graph.root(), structureResource),
structureResource);
Map<String, JigsawPlanarArchetype> expectedSources = expectedThemeSetSources(structure);
Map<String, JigsawPlanarArchetype> expectedSources = expectedThemeSetSources(
structure,
requestedSources.keySet());
requireExactThemeSetSources(requestedSources, expectedSources.keySet());
Map<String, byte[]> resources = readOwnedResources(graph);
@@ -200,7 +202,9 @@ public final class JigsawStudioGraphEditor {
+ "' is not owned by this jigsaw project");
}
String variantFolder = expectedArchetype == null
? expectedSources.size() == 1
? "spatial"
: "spatial/" + sourcePieceKey
: expectedArchetype.name().toLowerCase(Locale.ROOT);
String targetPieceKey = graph.manifest().structure().path()
+ "/variants/" + variantFolder + "/" + themeKey;
@@ -686,11 +690,23 @@ public final class JigsawStudioGraphEditor {
}
private static Map<String, JigsawPlanarArchetype> expectedThemeSetSources(
IrisStructure structure
IrisStructure structure,
Set<String> requestedStableIds
) throws IOException {
Map<String, JigsawPlanarArchetype> expected = new LinkedHashMap<>();
if (structure.resolvedMode() == IrisJigsawMode.SPATIAL_JIGSAW) {
expected.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID, null);
if (requestedStableIds.isEmpty()) {
throw new IOException("A spatial theme set requires at least one workcell source");
}
List<String> stableIds = new ArrayList<>(requestedStableIds);
stableIds.sort(Comparator.naturalOrder());
for (String stableId : stableIds) {
if (!stableId.equals(JigsawStudioLayout.SPATIAL_WORKCELL_ID)
&& !stableId.startsWith(JigsawStudioLayout.SPATIAL_WORKCELL_ID + "/")) {
throw new IOException("Invalid spatial workcell source '" + stableId + "'");
}
expected.put(stableId, null);
}
return expected;
}
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> workcells;
@@ -27,6 +27,7 @@ public final class JigsawStudioLayout {
private final JigsawStudioVariantCatalog variantCatalog;
private final List<JigsawStudioBay> bays;
private final Map<String, JigsawStudioBay> byStableId;
private final Map<String, String> spatialVariantByBay;
private JigsawStudioLayout(
JigsawStudioMode mode,
@@ -34,7 +35,8 @@ public final class JigsawStudioLayout {
int columns,
int gap,
JigsawStudioVariantCatalog variantCatalog,
List<JigsawStudioBay> bays
List<JigsawStudioBay> bays,
Map<String, String> spatialVariantByBay
) {
this.mode = mode;
this.cellDimensions = cellDimensions;
@@ -50,6 +52,7 @@ public final class JigsawStudioLayout {
}
}
this.byStableId = Collections.unmodifiableMap(index);
this.spatialVariantByBay = Collections.unmodifiableMap(new LinkedHashMap<>(spatialVariantByBay));
}
public static JigsawStudioLayout create(
@@ -87,20 +90,41 @@ public final class JigsawStudioLayout {
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)));
List<JigsawStudioVariant> variants = catalog.spatialVariants();
List<JigsawStudioBay> workcells = new ArrayList<>(Math.max(1, variants.size()));
Map<String, String> variantsByWorkcell = new LinkedHashMap<>();
if (variants.isEmpty()) {
workcells.add(new JigsawStudioBay(
SPATIAL_WORKCELL_ID,
JigsawStudioBayKind.SPATIAL_WORKCELL,
Optional.empty(),
resolvedDisplayName,
new JigsawStudioBounds(FIRST_ORIGIN, FLOOR_Y + 1, FIRST_ORIGIN, dimensions)));
} else {
int originX = FIRST_ORIGIN;
for (int index = 0; index < variants.size(); index++) {
JigsawStudioVariant variant = variants.get(index);
String stableId = index == 0
? SPATIAL_WORKCELL_ID
: SPATIAL_WORKCELL_ID + "/" + variant.pieceKey();
workcells.add(new JigsawStudioBay(
stableId,
JigsawStudioBayKind.SPATIAL_WORKCELL,
Optional.empty(),
variant.resolvedDisplayName(),
new JigsawStudioBounds(originX, FLOOR_Y + 1, FIRST_ORIGIN, dimensions)));
variantsByWorkcell.put(stableId, variant.pieceKey());
originX = Math.addExact(originX, Math.addExact(dimensions.width(), PLANAR_GAP));
}
}
return new JigsawStudioLayout(
JigsawStudioMode.SPATIAL_JIGSAW,
dimensions,
1,
workcells.size(),
PLANAR_GAP,
catalog,
workcells);
workcells,
variantsByWorkcell);
}
public static JigsawStudioLayout createPlanar(
@@ -143,7 +167,8 @@ public final class JigsawStudioLayout {
PLANAR_COLUMNS,
PLANAR_GAP,
catalog,
workcells);
workcells,
Map.of());
}
public JigsawStudioMode mode() {
@@ -186,7 +211,11 @@ public final class JigsawStudioLayout {
public List<JigsawStudioVariant> variants(JigsawStudioBay workcell) {
JigsawStudioBay activeWorkcell = requireWorkcell(workcell);
if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) {
return variantCatalog.spatialVariants();
String pieceKey = spatialVariantByBay.get(activeWorkcell.stableId());
if (pieceKey == null) {
return variantCatalog.spatialVariants();
}
return variantCatalog.find(pieceKey).map(List::of).orElseGet(List::of);
}
return variantCatalog.variants(activeWorkcell.archetype().orElseThrow());
}
@@ -203,11 +232,28 @@ public final class JigsawStudioLayout {
return false;
}
if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) {
return activeVariant.mode() == JigsawStudioMode.SPATIAL_JIGSAW;
return activeVariant.mode() == JigsawStudioMode.SPATIAL_JIGSAW
&& variants(activeWorkcell).contains(activeVariant);
}
return activeVariant.archetype().filter(activeWorkcell.archetype().orElseThrow()::equals).isPresent();
}
public Optional<JigsawStudioBay> workcellForVariant(String pieceKey) {
Optional<JigsawStudioVariant> variant = variantCatalog.find(pieceKey);
if (variant.isEmpty()) {
return Optional.empty();
}
if (mode == JigsawStudioMode.PLANAR_JIGSAW) {
return variant.get().archetype().map(archetype -> get(archetype.stableId()));
}
for (Map.Entry<String, String> entry : spatialVariantByBay.entrySet()) {
if (entry.getValue().equals(variant.get().pieceKey())) {
return Optional.ofNullable(get(entry.getKey()));
}
}
return Optional.ofNullable(get(SPATIAL_WORKCELL_ID));
}
public JigsawStudioControlPosition controlPosition() {
return CONTROL_POSITION;
}
@@ -30,11 +30,19 @@ import com.google.gson.GsonBuilder;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
public final class JigsawStudioProjectCreator {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final List<IrisDirection> SPATIAL_CONNECTOR_ORDER = List.of(
IrisDirection.NORTH_NEGATIVE_Z,
IrisDirection.SOUTH_POSITIVE_Z,
IrisDirection.EAST_POSITIVE_X,
IrisDirection.WEST_NEGATIVE_X,
IrisDirection.UP_POSITIVE_Y,
IrisDirection.DOWN_NEGATIVE_Y);
private JigsawStudioProjectCreator() {
}
@@ -73,7 +81,7 @@ public final class JigsawStudioProjectCreator {
if (options.mode() == JigsawStudioMode.PLANAR_JIGSAW) {
addPlanarDefaults(bundle, structure, pool, options);
} else {
addSpatialDefault(bundle, pool, options);
addSpatialDefaults(bundle, pool, options);
}
bundle.textResource("jigsaw-pools/" + resourceKey + "/start.json", GSON.toJson(pool) + "\n");
bundle.textResource("structures/" + resourceKey + ".json", GSON.toJson(structure) + "\n");
@@ -156,23 +164,82 @@ public final class JigsawStudioProjectCreator {
bundle.textResource("jigsaw-pools/" + capPoolKey + ".json", GSON.toJson(capPool) + "\n");
}
private static void addSpatialDefault(
private static void addSpatialDefaults(
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");
String piecePoolKey = options.structureKey() + "/pieces";
IrisJigsawPool piecePool = new IrisJigsawPool();
for (int connectorCount = 0; connectorCount <= SPATIAL_CONNECTOR_ORDER.size(); connectorCount++) {
String key = options.structureKey() + "/"
+ (connectorCount == 0 ? "start" : "connectors-" + connectorCount);
IrisJigsawPiece piece = spatialPiece(
key,
piecePoolKey,
dimensions,
connectorCount);
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
piece.getThemes().add("variant-1");
}
pool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
if (connectorCount > 0) {
piecePool.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");
}
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");
piecePool.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true));
bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n");
}
private static IrisJigsawPiece spatialPiece(
String objectKey,
String poolKey,
JigsawStudioCellDimensions dimensions,
int connectorCount
) {
IrisJigsawPiece piece = new IrisJigsawPiece()
.setDisplayName(connectorCount + (connectorCount == 1 ? " Connector" : " Connectors"))
.setObject(objectKey)
.setRotatable(true)
.setRules(new IrisJigsawPieceRules().setMaximumPlacements(16));
for (int index = 0; index < connectorCount; index++) {
IrisDirection direction = SPATIAL_CONNECTOR_ORDER.get(index);
piece.getConnectors().add(new IrisJigsawConnector()
.setPosition(spatialConnectorPosition(dimensions, direction))
.setDirection(direction)
.setTop(direction.isVertical()
? IrisDirection.NORTH_NEGATIVE_Z
: IrisDirection.UP_POSITIVE_Y)
.setPool(poolKey)
.setName("iris:spatial")
.setTargetName("iris:spatial")
.setJoint(JigsawJoint.ROLLABLE)
.setFinalState("minecraft:structure_void"));
}
return piece;
}
private static IrisPosition spatialConnectorPosition(
JigsawStudioCellDimensions dimensions,
IrisDirection direction
) {
int centerX = dimensions.width() / 2;
int centerY = dimensions.height() / 2;
int centerZ = dimensions.depth() / 2;
return switch (direction) {
case NORTH_NEGATIVE_Z -> new IrisPosition(centerX, centerY, 0);
case SOUTH_POSITIVE_Z -> new IrisPosition(centerX, centerY, dimensions.depth() - 1);
case EAST_POSITIVE_X -> new IrisPosition(dimensions.width() - 1, centerY, centerZ);
case WEST_NEGATIVE_X -> new IrisPosition(0, centerY, centerZ);
case UP_POSITIVE_Y -> new IrisPosition(centerX, dimensions.height() - 1, centerZ);
case DOWN_NEGATIVE_Y -> new IrisPosition(centerX, 0, centerZ);
};
}
private static IrisJigsawPiece planarPiece(
@@ -1,272 +0,0 @@
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;
}
}
}
@@ -36,6 +36,8 @@ public final class JigsawStudioMenuController {
static final int PLACEMENT_RULE_SHIFT_STEP = 16;
private static final long DESTRUCTIVE_CONFIRM_NANOS = 10_000_000_000L;
private static final int WORKCELL_RESIZE_REFRESH_TICKS = 2;
private static final int WORKCELL_RESIZE_REFRESH_ATTEMPTS = 200;
private static final int[] GRID_POSITIONS = {-3, -2, -1, 0, 1, 2, 3};
private final JavaPlugin plugin;
@@ -44,6 +46,7 @@ public final class JigsawStudioMenuController {
private final Map<UUID, PendingUnlink> pendingUnlinks = new ConcurrentHashMap<>();
private final Map<UUID, PendingDelete> pendingDeletes = new ConcurrentHashMap<>();
private final Map<UUID, PendingProjectDelete> pendingProjectDeletes = new ConcurrentHashMap<>();
private final Map<UUID, PendingWorkcellResize> pendingWorkcellResizes = new ConcurrentHashMap<>();
public JigsawStudioMenuController(JavaPlugin plugin, Actions actions) {
this.plugin = Objects.requireNonNull(plugin, "Jigsaw Studio menu plugin");
@@ -100,11 +103,13 @@ public final class JigsawStudioMenuController {
pendingUnlinks.remove(playerId);
pendingDeletes.remove(playerId);
pendingProjectDeletes.remove(playerId);
pendingWorkcellResizes.remove(playerId);
});
windows.put(playerId, window);
pendingUnlinks.remove(playerId);
pendingDeletes.remove(playerId);
pendingProjectDeletes.remove(playerId);
pendingWorkcellResizes.remove(playerId);
renderMain(window, state, selected, 0);
window.open();
return true;
@@ -242,6 +247,7 @@ public final class JigsawStudioMenuController {
pendingUnlinks.clear();
pendingDeletes.clear();
pendingProjectDeletes.clear();
pendingWorkcellResizes.clear();
for (UIWindow window : activeWindows) {
Player player = window.getViewer();
if (J.isOwnedByCurrentRegion(player)) {
@@ -508,26 +514,38 @@ public final class JigsawStudioMenuController {
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell
) {
PendingWorkcellResize pendingResize = pendingWorkcellResize(
window.getViewer().getUniqueId(),
state.requestId(),
workcell.stableId());
JigsawStudioMenuState.Workcell renderedWorkcell = pendingResize == null
? workcell
: withCapacity(workcell, pendingResize.dimensions());
window.batch(() -> {
window.clearElements();
UIElement back = element("settings-back", Material.ARROW, ChatColor.YELLOW + "Back to Variants");
back.onLeftClick(clicked -> refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
back.onLeftClick(clicked -> leaveWorkcellSettings(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
window.setElement(-4, 0, back);
UIElement identity = element(
"settings-identity",
workcell.enabled() ? Material.LIME_WOOL : Material.GRAY_WOOL,
ChatColor.AQUA + safe(workcell.displayName()));
identity.addLore(ChatColor.DARK_GRAY + safe(workcell.stableId()));
identity.addLore(workcell.enabled()
renderedWorkcell.enabled() ? Material.LIME_WOOL : Material.GRAY_WOOL,
ChatColor.AQUA + safe(renderedWorkcell.displayName()));
identity.addLore(ChatColor.DARK_GRAY + safe(renderedWorkcell.stableId()));
identity.addLore(renderedWorkcell.enabled()
? ChatColor.GREEN + "Enabled"
: ChatColor.RED + "Disabled for assembly and export");
if (!workcell.canonicalName().equals(workcell.displayName())) {
identity.addLore(ChatColor.GRAY + "Solver role: " + safe(workcell.canonicalName()));
if (!renderedWorkcell.canonicalName().equals(renderedWorkcell.displayName())) {
identity.addLore(ChatColor.GRAY + "Solver role: " + safe(renderedWorkcell.canonicalName()));
}
identity.addLore(ChatColor.GRAY + "Capacity: " + dimensions(renderedWorkcell.capacity()));
if (pendingResize != null) {
identity.addLore(pendingResize.applying()
? ChatColor.YELLOW + "Applying one live relayout"
: ChatColor.GOLD + "Pending; apply when all dimensions are ready");
}
identity.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity()));
identity.addLore(ChatColor.YELLOW + "Left-click for a rename stick");
identity.addLore(ChatColor.GRAY + "Rename that stick in an anvil, then right-click it.");
identity.addLore(ChatColor.GRAY + "Sneak-right-click the stick to reset this label.");
@@ -536,25 +554,25 @@ public final class JigsawStudioMenuController {
JigsawStudioToolPayload.workcell(
JigsawStudioToolAction.RENAME_WORKCELL,
state.requestId(),
workcell.stableId())));
renderedWorkcell.stableId())));
window.setElement(0, 0, identity);
window.setElement(4, 0, evaluationElement(state.evaluation()));
if (state.mode() == JigsawStudioMode.PLANAR_JIGSAW) {
UIElement enabled = element(
"settings-enabled",
workcell.enabled() ? Material.LEVER : Material.REDSTONE_TORCH,
workcell.enabled()
renderedWorkcell.enabled() ? Material.LEVER : Material.REDSTONE_TORCH,
renderedWorkcell.enabled()
? ChatColor.GREEN + "Workcell Enabled"
: ChatColor.RED + "Workcell Disabled");
enabled.addLore(ChatColor.GRAY + "Disabled workcells remain editable and keep their size.");
enabled.addLore(ChatColor.YELLOW + "Left-click to "
+ (workcell.enabled() ? "disable" : "enable"));
+ (renderedWorkcell.enabled() ? "disable" : "enable"));
enabled.onLeftClick(clicked -> setWorkcellEnabled(
window.getViewer(),
state.requestId(),
workcell.stableId(),
!workcell.enabled()));
renderedWorkcell.stableId(),
!renderedWorkcell.enabled()));
window.setElement(0, 1, enabled);
} else {
UIElement spatial = element(
@@ -568,18 +586,18 @@ public final class JigsawStudioMenuController {
UIElement connectors = element(
"settings-connectors",
workcell.connectorsVisible() ? Material.JIGSAW : Material.STRUCTURE_VOID,
workcell.connectorsVisible()
renderedWorkcell.connectorsVisible() ? Material.JIGSAW : Material.STRUCTURE_VOID,
renderedWorkcell.connectorsVisible()
? ChatColor.GREEN + "Connector Blocks Visible"
: ChatColor.YELLOW + "Connector Blocks Hidden");
connectors.addLore(ChatColor.GRAY + "Hidden connectors retain their metadata and final block state.");
connectors.addLore(ChatColor.YELLOW + "Left-click to "
+ (workcell.connectorsVisible() ? "hide" : "show") + " connector blocks");
+ (renderedWorkcell.connectorsVisible() ? "hide" : "show") + " connector blocks");
connectors.onLeftClick(clicked -> toggleConnectorBlocks(
window.getViewer(),
state.requestId(),
workcell.stableId(),
!workcell.connectorsVisible()));
renderedWorkcell.stableId(),
!renderedWorkcell.connectorsVisible()));
window.setElement(2, 1, connectors);
UIElement resetConnectors = element(
@@ -591,31 +609,57 @@ public final class JigsawStudioMenuController {
resetConnectors.onLeftClick(clicked -> resetConnectorBlocks(
window.getViewer(),
state.requestId(),
workcell.stableId()));
renderedWorkcell.stableId()));
window.setElement(4, 1, resetConnectors);
window.setElement(-2, 2, axisElement(
window,
state,
workcell,
renderedWorkcell,
DimensionAxis.WIDTH,
Material.IRON_INGOT));
window.setElement(0, 2, axisElement(
window,
state,
workcell,
renderedWorkcell,
DimensionAxis.HEIGHT,
Material.GOLD_INGOT));
window.setElement(2, 2, axisElement(
window,
state,
workcell,
renderedWorkcell,
DimensionAxis.DEPTH,
Material.COPPER_INGOT));
if (pendingResize != null) {
UIElement apply = element(
"settings-apply-capacity",
pendingResize.applying() ? Material.CLOCK : Material.EMERALD_BLOCK,
pendingResize.applying()
? ChatColor.YELLOW + "Applying Cell Size"
: ChatColor.GREEN + "Apply Cell Size");
apply.addLore(ChatColor.WHITE + dimensions(pendingResize.dimensions()));
apply.addLore(ChatColor.GRAY + "Regenerates the layout once after all size edits.");
if (!pendingResize.applying()) {
apply.onLeftClick(clicked -> applyWorkcellResize(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
}
window.setElement(0, 3, apply);
if (!pendingResize.applying()) {
UIElement discard = element(
"settings-discard-capacity",
Material.BARRIER,
ChatColor.RED + "Discard Size Changes");
discard.onLeftClick(clicked -> discardWorkcellResize(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
window.setElement(2, 3, discard);
}
}
UIElement footerBack = element("settings-footer-back", Material.ARROW, ChatColor.YELLOW + "Back");
footerBack.onLeftClick(clicked -> refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
footerBack.onLeftClick(clicked -> leaveWorkcellSettings(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
window.setElement(-4, 5, footerBack);
UIElement undo = element(
@@ -629,7 +673,7 @@ public final class JigsawStudioMenuController {
state.requestId()));
window.setElement(0, 5, undo);
if (workcell.dirty() && !workcell.saving()) {
if (renderedWorkcell.dirty() && !renderedWorkcell.saving()) {
UIElement saveNow = element(
"save-now",
Material.EMERALD,
@@ -637,7 +681,7 @@ public final class JigsawStudioMenuController {
saveNow.addLore(ChatColor.GRAY + "Autosave is automatic.");
saveNow.addLore(ChatColor.GRAY + "Use this only to flush pending work or recover immediately.");
saveNow.onLeftClick(clicked -> flushNow(
window.getViewer(), state.requestId(), workcell.stableId()));
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
window.setElement(2, 5, saveNow);
}
@@ -650,7 +694,7 @@ public final class JigsawStudioMenuController {
UIElement toolbox = element("settings-toolbox", Material.STICK, ChatColor.AQUA + "Toolbox");
toolbox.onLeftClick(clicked -> openToolbox(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.getViewer(), state.requestId(), renderedWorkcell.stableId(), 0));
window.setElement(4, 5, toolbox);
});
}
@@ -802,6 +846,7 @@ public final class JigsawStudioMenuController {
if (themeEditingAvailable) {
createTheme.addLore(ChatColor.GRAY + "Creates one new owned variant for every enabled workcell.");
createTheme.addLore(ChatColor.GRAY + "All created variants join " + safe(nextThemeKey) + ".");
createTheme.addLore(ChatColor.GRAY + "One family is selected for the complete assembly.");
createTheme.addLore(ChatColor.YELLOW + "Left-click to create the complete set");
createTheme.onLeftClick(clicked -> duplicateActiveFamily(
window.getViewer(), state.requestId(), nextThemeKey));
@@ -884,6 +929,8 @@ public final class JigsawStudioMenuController {
state.irisExtended() ? Material.PURPLE_DYE : Material.GRAY_DYE,
(state.irisExtended() ? ChatColor.LIGHT_PURPLE : ChatColor.GRAY) + safe(themeSet.key()));
element.addLore(ChatColor.WHITE + "Selection weight: " + themeSet.weight());
element.addLore(ChatColor.WHITE + "Whole-assembly chance: "
+ themeSelectionPercent(state.themeSets(), themeSet));
if (state.irisExtended()) {
element.addLore(ChatColor.GREEN + "Left-click: weight +1");
element.addLore(ChatColor.YELLOW + "Right-click: weight -1");
@@ -919,7 +966,7 @@ public final class JigsawStudioMenuController {
element.addLore(ChatColor.YELLOW + "Right-click: -1");
element.addLore(ChatColor.GREEN + "Shift-left: +8");
element.addLore(ChatColor.YELLOW + "Shift-right: -8");
element.addLore(ChatColor.GRAY + "The Studio layout regenerates after resizing.");
element.addLore(ChatColor.GRAY + "Changes stay in this menu until Apply Cell Size.");
element.onLeftClick(clicked -> resizeWorkcell(
window.getViewer(), state.requestId(), workcell.stableId(), axis, 1));
element.onRightClick(clicked -> resizeWorkcell(
@@ -1396,6 +1443,7 @@ public final class JigsawStudioMenuController {
element.addLore(member
? ChatColor.GREEN + "This variant belongs to the theme."
: ChatColor.GRAY + "This variant does not belong to the theme.");
element.addLore(ChatColor.GRAY + "Only variants in the selected family are eligible.");
element.addLore(ChatColor.YELLOW + "Left-click to toggle membership");
element.onLeftClick(clicked -> toggleVariantTheme(
window.getViewer(), state.requestId(), workcell, variant, themeSet.key()));
@@ -1702,17 +1750,162 @@ public final class JigsawStudioMenuController {
stale(player);
return;
}
UUID playerId = player.getUniqueId();
PendingWorkcellResize existing = pendingWorkcellResize(playerId, requestId, workcellId);
if (existing != null && existing.applying()) {
player.sendMessage(ChatColor.YELLOW + "That cell size is already being applied.");
return;
}
JigsawStudioCellDimensions base = existing == null
? workcell.capacity()
: existing.dimensions();
Optional<JigsawStudioCellDimensions> adjusted = adjustedDimensions(
workcell.capacity(), axis, delta);
base, axis, delta);
if (adjusted.isEmpty()) {
player.sendMessage(ChatColor.RED + "That workcell size is outside Iris limits.");
return;
}
if (actions.updateWorkcellDimensions(player, workcellId, adjusted.get())) {
closeAfterAction(player);
PendingWorkcellResize pending = new PendingWorkcellResize(
requestId,
workcellId,
adjusted.get(),
false);
pendingWorkcellResizes.put(playerId, pending);
UIWindow window = windows.get(playerId);
if (window != null) {
renderWorkcellSettings(window, current.get(), withCapacity(workcell, pending.dimensions()));
}
}
private void applyWorkcellResize(Player player, UUID requestId, String workcellId) {
Optional<JigsawStudioMenuState> current = matchingState(player, requestId, true);
if (current.isEmpty()) {
return;
}
UUID playerId = player.getUniqueId();
PendingWorkcellResize pending = pendingWorkcellResize(playerId, requestId, workcellId);
JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId);
if (pending == null || pending.applying() || workcell == null) {
return;
}
if (workcell.capacity().equals(pending.dimensions())) {
pendingWorkcellResizes.remove(playerId, pending);
refreshWorkcellSettings(player, requestId, workcellId);
return;
}
PendingWorkcellResize applying = new PendingWorkcellResize(
requestId,
workcellId,
pending.dimensions(),
true);
pendingWorkcellResizes.put(playerId, applying);
UIWindow window = windows.get(playerId);
if (window != null) {
renderWorkcellSettings(window, current.get(), withCapacity(workcell, applying.dimensions()));
}
if (!actions.updateWorkcellDimensions(player, workcellId, applying.dimensions())) {
pendingWorkcellResizes.replace(playerId, applying, pending);
refreshWorkcellSettings(player, requestId, workcellId);
return;
}
scheduleWorkcellResizeRefresh(player, applying, WORKCELL_RESIZE_REFRESH_ATTEMPTS);
}
private void scheduleWorkcellResizeRefresh(
Player player,
PendingWorkcellResize pending,
int attemptsRemaining
) {
boolean scheduled = J.runEntity(
player,
() -> refreshAppliedWorkcellResize(player, pending, attemptsRemaining),
WORKCELL_RESIZE_REFRESH_TICKS);
if (!scheduled) {
pendingWorkcellResizes.remove(player.getUniqueId(), pending);
}
}
private void refreshAppliedWorkcellResize(
Player player,
PendingWorkcellResize pending,
int attemptsRemaining
) {
UUID playerId = player.getUniqueId();
if (!pending.equals(pendingWorkcellResizes.get(playerId))) {
return;
}
Optional<JigsawStudioMenuState> current = matchingState(player, pending.requestId(), false);
JigsawStudioMenuState.Workcell workcell = current
.map(state -> state.workcell(pending.workcellId()))
.orElse(null);
if (workcell == null) {
pendingWorkcellResizes.remove(playerId, pending);
return;
}
if (workcell.capacity().equals(pending.dimensions())) {
pendingWorkcellResizes.remove(playerId, pending);
UIWindow window = windows.get(playerId);
if (window != null) {
renderWorkcellSettings(window, current.orElseThrow(), workcell);
}
return;
}
if (attemptsRemaining > 0) {
scheduleWorkcellResizeRefresh(player, pending, attemptsRemaining - 1);
return;
}
PendingWorkcellResize retry = new PendingWorkcellResize(
pending.requestId(),
pending.workcellId(),
pending.dimensions(),
false);
pendingWorkcellResizes.replace(playerId, pending, retry);
UIWindow window = windows.get(playerId);
if (window != null) {
renderWorkcellSettings(window, current.orElseThrow(), withCapacity(workcell, retry.dimensions()));
}
player.sendMessage(ChatColor.YELLOW
+ "Cell resizing is still pending; use Apply Cell Size to retry after the current operation settles.");
}
private void discardWorkcellResize(Player player, UUID requestId, String workcellId) {
PendingWorkcellResize pending = pendingWorkcellResize(player.getUniqueId(), requestId, workcellId);
if (pending != null && !pending.applying()) {
pendingWorkcellResizes.remove(player.getUniqueId(), pending);
}
refreshWorkcellSettings(player, requestId, workcellId);
}
private void leaveWorkcellSettings(Player player, UUID requestId, String workcellId) {
PendingWorkcellResize pending = pendingWorkcellResize(player.getUniqueId(), requestId, workcellId);
if (pending != null && !pending.applying()) {
pendingWorkcellResizes.remove(player.getUniqueId(), pending);
}
refreshMain(player, requestId, workcellId, 0);
}
private void refreshWorkcellSettings(Player player, UUID requestId, String workcellId) {
Optional<JigsawStudioMenuState> current = matchingState(player, requestId, true);
UIWindow window = windows.get(player.getUniqueId());
JigsawStudioMenuState.Workcell workcell = current
.map(state -> state.workcell(workcellId))
.orElse(null);
if (window == null || workcell == null) {
return;
}
renderWorkcellSettings(window, current.orElseThrow(), workcell);
}
private PendingWorkcellResize pendingWorkcellResize(UUID playerId, UUID requestId, String workcellId) {
PendingWorkcellResize pending = pendingWorkcellResizes.get(playerId);
if (pending == null
|| !pending.requestId().equals(requestId)
|| !pending.workcellId().equals(workcellId)) {
return null;
}
return pending;
}
private void resizeVariantAxis(
Player player,
UUID requestId,
@@ -2400,6 +2593,31 @@ public final class JigsawStudioMenuController {
throw new IllegalStateException("Jigsaw Studio cannot allocate another numbered theme set");
}
static String themeSelectionPercent(
List<JigsawStudioMenuState.ThemeSet> themeSets,
JigsawStudioMenuState.ThemeSet target
) {
List<JigsawStudioMenuState.ThemeSet> activeThemeSets = Objects.requireNonNull(
themeSets,
"Jigsaw Studio theme sets");
JigsawStudioMenuState.ThemeSet activeTarget = Objects.requireNonNull(
target,
"Jigsaw Studio target theme set");
int totalWeight = 0;
for (JigsawStudioMenuState.ThemeSet themeSet : activeThemeSets) {
totalWeight = Math.addExact(totalWeight, Objects.requireNonNull(
themeSet,
"Jigsaw Studio theme set").weight());
}
if (totalWeight < 1) {
return "0.0%";
}
return String.format(
Locale.ROOT,
"%.1f%%",
activeTarget.weight() * 100.0D / totalWeight);
}
static Optional<Integer> adjustedPositiveValue(int value, int delta) {
if (value < 1 || delta == 0) {
throw new IllegalArgumentException("Jigsaw Studio positive value adjustment is invalid");
@@ -2503,6 +2721,27 @@ public final class JigsawStudioMenuController {
}
}
static JigsawStudioMenuState.Workcell withCapacity(
JigsawStudioMenuState.Workcell workcell,
JigsawStudioCellDimensions capacity
) {
JigsawStudioMenuState.Workcell source = Objects.requireNonNull(
workcell,
"Jigsaw Studio menu workcell");
return new JigsawStudioMenuState.Workcell(
source.stableId(),
source.canonicalName(),
source.displayName(),
Objects.requireNonNull(capacity, "Jigsaw Studio staged workcell capacity"),
source.enabled(),
source.activeVariantKey(),
source.dirty(),
source.saving(),
source.loading(),
source.connectorsVisible(),
source.variants());
}
static List<ToolboxTool> toolboxTools(
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell
@@ -3138,4 +3377,17 @@ public final class JigsawStudioMenuController {
private record PendingProjectDelete(UUID requestId, long expiresAtNanos) {
}
private record PendingWorkcellResize(
UUID requestId,
String workcellId,
JigsawStudioCellDimensions dimensions,
boolean applying
) {
private PendingWorkcellResize {
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio resize request ID");
workcellId = Objects.requireNonNull(workcellId, "Jigsaw Studio resize workcell ID");
dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio resize dimensions");
}
}
}
@@ -67,6 +67,7 @@ import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Chunk;
import org.bukkit.Color;
import org.bukkit.GameRules;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
@@ -100,6 +101,7 @@ import org.bukkit.event.block.BrewingStartEvent;
import org.bukkit.event.block.CrafterCraftEvent;
import org.bukkit.event.entity.EntityChangeBlockEvent;
import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.event.inventory.BrewEvent;
import org.bukkit.event.inventory.BrewingStandFuelEvent;
import org.bukkit.event.inventory.FurnaceBurnEvent;
@@ -123,7 +125,6 @@ import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.player.PlayerToggleSneakEvent;
import org.bukkit.event.server.ServerCommandEvent;
import org.bukkit.event.world.ChunkLoadEvent;
import org.bukkit.event.world.ChunkUnloadEvent;
import org.bukkit.event.world.StructureGrowEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import org.bukkit.inventory.EquipmentSlot;
@@ -161,6 +162,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private static final List<Integer> AUTOSAVE_PERSISTENT_RETRY_DELAYS =
List.of(40, 80, 160, 320, 600);
private static final long PREVIEW_SEED = 1337L;
private static final int SPATIAL_PREVIEW_BASE_Y = JigsawStudioLayout.FLOOR_Y + 48;
private static final JigsawStudioPieceRules DEFAULT_PIECE_RULES =
new JigsawStudioPieceRules(0, 30, 0, 0, false);
private static final long TOOL_CONFIRM_NANOS = 10_000_000_000L;
@@ -207,8 +209,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private final Map<UUID, JigsawStudioGraphEvaluation> evaluations = new ConcurrentHashMap<>();
private final JigsawStudioTripleSneakTracker tripleSneakTracker = new JigsawStudioTripleSneakTracker();
private final JigsawStudioToolCodec toolCodec = new JigsawStudioToolCodec();
private final JigsawStudioDisabledWorkcellRenderer disabledWorkcellRenderer =
new JigsawStudioDisabledWorkcellRenderer();
private final JigsawStudioPreviewRenderer previewRenderer = new JigsawStudioPreviewRenderer();
private final Object saveLifecycleLock = new Object();
private final Set<UUID> savesInProgress = new HashSet<>();
@@ -258,7 +258,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
jigsawTileWatches.clear();
toolConfirmations.clear();
tripleSneakTracker.clearAll();
disabledWorkcellRenderer.removeAll();
evaluations.clear();
previewRenderer.removeAll();
reopenRequiredRequests.clear();
@@ -329,16 +328,11 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
unregisterRetries.remove(displacedRequestId);
unregisterDrainWarnings.remove(displacedRequestId);
tripleSneakTracker.clearRequest(displacedRequestId);
disabledWorkcellRenderer.removeRequest(displacedRequestId);
evaluations.remove(displacedRequestId);
previewRenderer.removeRequest(displacedRequestId);
}
IrisLogging.info("Jigsaw Studio authoring registered: world=%s structure=%s bays=%d",
world.getName(), activeGenerator.getSession().structureKey(), activeGenerator.getLayout().bays().size());
disabledWorkcellRenderer.reconcile(
world,
activeGenerator.getRequest().requestId(),
activeGenerator.getLayout());
scheduleInitialEvaluation(next);
scheduleOnlinePlayers(world.getUID());
}
@@ -351,6 +345,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
if (studio == null || !requestId.equals(studio.generator().getRequest().requestId())) {
return;
}
disableNaturalStudioSpawning(world);
scheduleInitialEvaluation(studio);
}
@@ -429,7 +424,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
unregisterDrainWarnings.remove(request.requestId());
toolConfirmations.entrySet().removeIf(entry -> entry.getValue().payload().requestId().equals(request.requestId()));
tripleSneakTracker.clearRequest(request.requestId());
disabledWorkcellRenderer.removeRequest(request.requestId());
evaluations.remove(request.requestId());
previewRenderer.forgetRequest(request.requestId());
JigsawStudioActivation.deactivate(request.packKey(), request.requestId());
@@ -1309,9 +1303,22 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
sourcePieceKey,
pieceKey);
}
JigsawStudioLayout updatedLayout = loadMappedLayout(studio);
String targetWorkcellId = updatedLayout.workcellForVariant(pieceKey)
.map(JigsawStudioBay::stableId)
.orElseThrow(() -> new IOException(
"Created variant '" + pieceKey + "' has no Studio workcell"));
if (updatedLayout.mode() == JigsawStudioMode.SPATIAL_JIGSAW) {
return new CommandGraphMutationResult(
updatedLayout,
"",
"",
(duplicateActive ? "Duplicated" : "Created") + " variant '" + pieceKey
+ "' in " + targetWorkcellId + ".");
}
return new CommandGraphMutationResult(
loadMappedLayout(studio),
workcell.stableId(),
updatedLayout,
targetWorkcellId,
pieceKey,
(duplicateActive ? "Duplicated" : "Created") + " variant '" + pieceKey + "'.");
});
@@ -1548,11 +1555,15 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
throw new IOException("Variant-family transaction failed with "
+ creation.writeResult().status());
}
JigsawStudioLayout updatedLayout = loadMappedLayout(studio);
Map<String, String> rebinds = updatedLayout.mode() == JigsawStudioMode.SPATIAL_JIGSAW
? Map.of()
: creation.pieceKeysByWorkcell();
return new CommandGraphMutationResult(
loadMappedLayout(studio),
updatedLayout,
"",
"",
creation.pieceKeysByWorkcell(),
rebinds,
Optional.empty(),
"Duplicated every enabled workcell as coherent family '" + themeKey + "' with "
+ creation.pieceKeysByWorkcell().size() + " variant(s).");
@@ -2201,7 +2212,8 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
String activePieceKey = session.activeVariant(workcellId)
.map(JigsawStudioVariant::pieceKey)
.orElse("");
if (activePieceKey.equals(pieceKey)) {
if (session.layout().mode() == JigsawStudioMode.PLANAR_JIGSAW
&& activePieceKey.equals(pieceKey)) {
message(player, "Load another variant in this workcell before deleting '" + pieceKey + "'.");
return false;
}
@@ -2575,7 +2587,12 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
IrisPosition origin = previewOrigin(studio.generator().getLayout(), structure);
StructureAssembler assembler = StructureAssembler.forCompilation(compilation, origin);
StructureAssemblyResult assembly = assembler.assemble(new RNG(PREVIEW_SEED));
computation = evaluationForAssembly(requestId, generation, compilation, assembly);
computation = evaluationForAssembly(
requestId,
generation,
compilation,
assembly,
studio.generator().getLayout().mode());
}
} catch (Throwable exception) {
IrisLogging.reportError(exception);
@@ -2588,7 +2605,8 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
UUID requestId,
long generation,
StructureGraphCompilation compilation,
StructureAssemblyResult assembly
StructureAssemblyResult assembly,
JigsawStudioMode mode
) throws IOException {
if (assembly.status().isFailure()) {
return invalidEvaluation(
@@ -2611,7 +2629,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
JigsawStudioPreviewRenderer.PreviewBounds.empty()),
JigsawStudioPreviewRenderer.PreviewPlan.empty());
}
List<PlacedStructurePiece> aligned = alignPreviewPieces(assembly.pieces());
List<PlacedStructurePiece> aligned = alignPreviewPieces(assembly.pieces(), mode);
JigsawStudioPreviewRenderer.PreviewPlan plan = JigsawStudioPreviewRenderer.plan(aligned);
StructureGraphDiagnostic firstWarning = firstDiagnostic(
compilation,
@@ -2713,12 +2731,18 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
Math.max(16, layout.extentZ() / 2));
}
private static List<PlacedStructurePiece> alignPreviewPieces(List<PlacedStructurePiece> pieces) {
static List<PlacedStructurePiece> alignPreviewPieces(
List<PlacedStructurePiece> pieces,
JigsawStudioMode mode
) {
int minimumY = Integer.MAX_VALUE;
for (PlacedStructurePiece piece : pieces) {
minimumY = Math.min(minimumY, piece.getMinY());
}
int shiftY = JigsawStudioLayout.FLOOR_Y + 1 - minimumY;
int baseY = mode == JigsawStudioMode.SPATIAL_JIGSAW
? SPATIAL_PREVIEW_BASE_Y
: JigsawStudioLayout.FLOOR_Y + 1;
int shiftY = baseY - minimumY;
List<PlacedStructurePiece> aligned = new ArrayList<>(pieces.size());
for (PlacedStructurePiece piece : pieces) {
aligned.add(new PlacedStructurePiece(
@@ -3534,7 +3558,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
UUID requestId,
CommandGraphMutationResult result
) {
disabledWorkcellRenderer.reconcile(studio.world(), requestId, result.layout());
for (JigsawStudioBay workcell : result.layout().bays()) {
refreshWorkcellContext(studio.worldId(), workcell.stableId());
}
@@ -3801,28 +3824,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
ActiveStudio studio = studios.get(event.getWorld().getUID());
if (studio != null) {
markChunkAvailable(studio, event.getChunk().getX(), event.getChunk().getZ());
for (JigsawStudioBay bay : studio.generator().getLayout().bays()) {
if (!bay.enabled()
&& bay.bounds().originX() >> 4 == event.getChunk().getX()
&& bay.bounds().originZ() >> 4 == event.getChunk().getZ()) {
disabledWorkcellRenderer.reconcile(
studio.world(),
studio.generator().getRequest().requestId(),
studio.generator().getLayout());
break;
}
}
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onChunkUnload(ChunkUnloadEvent event) {
ActiveStudio studio = studios.get(event.getWorld().getUID());
if (studio != null) {
disabledWorkcellRenderer.unloadChunk(
studio.generator().getRequest().requestId(),
event.getChunk().getX(),
event.getChunk().getZ());
}
}
@@ -3922,6 +3923,14 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
reconcilePlayerContext(event.getPlayer(), event.getRespawnLocation());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onNaturalCreatureSpawn(CreatureSpawnEvent event) {
if (studios.containsKey(event.getLocation().getWorld().getUID())
&& isNaturalStudioSpawn(event.getSpawnReason())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onUnauthorizedBlockPlace(BlockPlaceEvent event) {
if (isUnauthorizedStudioEdit(event.getPlayer(), event.getBlockPlaced())) {
@@ -5947,6 +5956,11 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
+ " connector(s) from " + snapshots.size()
+ " chunk(s); validating and writing the owned structure graph...");
persistCapture(coordinator, capture, connectors);
} catch (WorkcellTopologyException exception) {
coordinator.failPersistent(
"Jigsaw Studio cannot autosave this planar workcell: "
+ failureMessage(exception),
null);
} catch (Throwable exception) {
coordinator.failPersistent(
"Jigsaw Studio capture assembly failed: " + failureMessage(exception),
@@ -6025,6 +6039,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
String mutationNotice = unchanged ? "" : " Newer edits remain unsaved.";
message(player, "Saved piece '" + coordinator.saveIdentity().variantKey() + "' and object '"
+ assembly.objectKey() + "' atomically." + mutationNotice + cleanup);
playSaveSound(player);
if (unchanged) {
scheduleEvaluation(studio);
}
@@ -6518,21 +6533,29 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
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 -> throw new IOException(
"Planar workcells cannot save vertical connectors");
case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> throw new WorkcellTopologyException(
"Planar workcell '" + workcell.stableId()
+ "' cannot save a vertical connector. Remove it or use Reset Connector Blocks.");
};
}
JigsawPlanarTopology sourceTopology = JigsawPlanarTopology.fromMask(mask);
JigsawPlanarTopology displayedTopology = sourceTopology.rotateClockwise(displayRotationQuarterTurns);
if (displayedTopology != expected.canonicalTopology()) {
throw new IOException("Workcell '" + workcell.stableId() + "' requires "
+ expected.canonicalTopology().name().toLowerCase(Locale.ROOT)
+ " connector orientation, but the edited markers form "
+ displayedTopology.name().toLowerCase(Locale.ROOT)
+ ". Keep the workcell's red floor glyph orientation; Iris rotates the saved variant automatically.");
throw new WorkcellTopologyException("Workcell '" + workcell.stableId() + "' requires "
+ topologyDescription(expected.canonicalTopology())
+ ", but the edited markers form "
+ topologyDescription(displayedTopology)
+ ". Use Reset Connector Blocks to restore the saved topology, or edit the markers to match the floor glyph.");
}
}
private static String topologyDescription(JigsawPlanarTopology topology) {
int connectorCount = topology.directions().size();
return topology.name().toLowerCase(Locale.ROOT).replace('_', ' ')
+ " (" + connectorCount + " horizontal connector"
+ (connectorCount == 1 ? "" : "s") + ")";
}
static void storeConnectorFinalState(
IrisObject object,
int x,
@@ -7039,10 +7062,6 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
private void reloadSessionLayout(ActiveStudio studio) throws IOException {
JigsawStudioLayout layout = loadMappedLayout(studio);
studio.generator().getSession().replaceLayout(layout);
disabledWorkcellRenderer.reconcile(
studio.world(),
studio.generator().getRequest().requestId(),
layout);
for (JigsawStudioBay workcell : layout.bays()) {
studio.generator().invalidateRender(workcell.stableId());
}
@@ -7080,7 +7099,8 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().findAt(
target.getBlockX(), target.getBlockY(), target.getBlockZ());
selectEnteredWorkcell(session, workcell, ownerMatches(player, studio));
boolean owner = ownerMatches(player, studio);
selectEnteredWorkcell(session, workcell, owner);
String workcellId = workcell == null ? "" : workcell.stableId();
playerWorkcells.put(player.getUniqueId(), new PlayerWorkcellContext(
studio.worldId(),
@@ -7115,6 +7135,14 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
return owner && workcell != null && session.selectBay(workcell.stableId());
}
static boolean isNaturalStudioSpawn(CreatureSpawnEvent.SpawnReason reason) {
return reason == CreatureSpawnEvent.SpawnReason.NATURAL;
}
static void disableNaturalStudioSpawning(World world) {
Objects.requireNonNull(world, "world").setGameRule(GameRules.SPAWN_MOBS, false);
}
public void refreshWorkcellContext(UUID worldId, String workcellId) {
if (worldId == null || workcellId == null) {
return;
@@ -7708,9 +7736,7 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
if (!variant.pieceKey().equalsIgnoreCase(key)) {
continue;
}
return variant.archetype()
.map(archetype -> layout.get(archetype.stableId()))
.orElse(layout.get(JigsawStudioLayout.SPATIAL_WORKCELL_ID));
return layout.workcellForVariant(variant.pieceKey()).orElse(null);
}
return null;
}
@@ -7738,6 +7764,16 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
}
}
static void playSaveSound(Player player) {
if (player != null) {
J.runEntity(player, () -> player.playSound(
player.getLocation(),
"minecraft:block.note_block.bell",
0.65F,
1.65F));
}
}
private static void report(Player player, boolean enabled, String text) {
if (enabled) {
message(player, text);
@@ -8722,6 +8758,12 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
}
}
static final class WorkcellTopologyException extends IOException {
WorkcellTopologyException(String message) {
super(message);
}
}
private static final class ParticleBudget {
private int remaining;
@@ -22,6 +22,7 @@ import com.google.gson.JsonSyntaxException;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.DatapackInstallResult;
@@ -33,6 +34,7 @@ import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
import art.arcane.iris.core.project.IrisProject;
import art.arcane.iris.core.project.IrisPackageCompiler;
import art.arcane.iris.core.project.IrisCodeWorkspace;
@@ -69,6 +71,7 @@ import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Locale;
import java.util.Objects;
import java.util.UUID;
@@ -471,12 +474,24 @@ public class StudioSVC implements IrisService {
}
private static boolean blockIfPackBroken(VolmitSender sender, String dimm) {
PackValidationResult validation = PackValidationRegistry.get(dimm);
if (validation == null || validation.isLoadable()) {
Optional<String> startupDenial = IrisStartupValidation.denialReason();
if (startupDenial.isPresent()) {
sender.sendMessage(startupDenial.get());
return true;
}
IrisDimension dimension = IrisToolbelt.getDimension(dimm);
String packName = dimension == null || dimension.getLoader() == null
? dimm
: dimension.getLoader().getDataFolder().getName();
PackValidationResult validation = PackValidationRegistry.get(packName);
if (validation != null && validation.isLoadable()) {
return false;
}
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_CANNOT_OPEN_STUDIO_PACK_HAS_BLOCKING_ERRORS, MessageArgument.untrusted("dimm", String.valueOf(dimm))));
for (String reason : validation.getBlockingErrors()) {
List<String> failures = validation == null
? List.of("Required pack validation has not completed. Studio creation fails closed until validation succeeds.")
: validation.getBlockingErrors();
for (String reason : failures) {
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_MESSAGE, MessageArgument.untrusted("reason", String.valueOf(reason))));
}
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FIX_PACK_RUN_IRIS_PACK_VALIDATE_REVALIDATE, MessageArgument.untrusted("dimm", String.valueOf(dimm))));
@@ -829,6 +844,11 @@ public class StudioSVC implements IrisService {
}
private void createProject(VolmitSender sender, String requestedName, String requestedTemplate, File selectedTemplatePack) {
Optional<String> startupDenial = IrisStartupValidation.denialReason();
if (startupDenial.isPresent()) {
sender.sendMessage("Studio project creation refused: " + startupDenial.get());
return;
}
String normalizedName;
String templateName;
File workspace;
@@ -881,12 +901,24 @@ public class StudioSVC implements IrisService {
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_MISSING_IMPORTED_DIMENSION_FILE));
return;
}
PackValidationRegistry.requireLoadable(importPack.getName());
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_IMPORTING_INTO_NEW_PROJECT, MessageArgument.untrusted("downloadable", String.valueOf(templateName)), MessageArgument.untrusted("s", String.valueOf(projectName))));
createFrom(importPack, templateName, projectName);
}
projectPublished = true;
PackValidationResult createdValidation = PackValidator.validate(newPack);
PackValidationRegistry.publish(createdValidation);
if (!createdValidation.isLoadable()) {
rollbackCreatedProject(sender, newPack,
"Studio project validation failed; the new project was rolled back.");
for (String reason : createdValidation.getBlockingErrors()) {
sender.sendMessage(reason);
}
return;
}
DatapackInstallResult installResult = ServerConfigurator.installDataPacksIfChanged(true);
CreationOutcome installOutcome = switch (installResult.status()) {
case FAILED -> CreationOutcome.FAILED;
@@ -26,6 +26,7 @@ import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.link.MultiverseCoreLink;
import art.arcane.iris.core.IrisRuntimeSchedulerMode;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.DatapackInstallResult;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.IrisWorlds;
@@ -41,6 +42,7 @@ import art.arcane.iris.core.localization.RuntimeProgressMessages;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.pregenerator.PregenTask;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.runtime.WorldDeletionQueue;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.engine.object.IrisDimension;
@@ -172,6 +174,9 @@ public class IrisCreator {
if (resolvedDimension == null) {
throw new IrisException("Dimension cannot be found for id " + dimension());
}
IrisStartupValidation.requireWorldCreationReady();
PackValidationRegistry.requireLoadable(
resolvedDimension.getLoader().getDataFolder().getName());
worldLease = coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
@@ -73,7 +73,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
session,
B.getState("minecraft:smooth_stone"),
B.getState("minecraft:polished_deepslate"),
B.getState("minecraft:smooth_quartz"),
B.getState("minecraft:white_concrete"),
B.getState("minecraft:light_gray_wool"),
B.getState("minecraft:red_wool"),
B.getState("minecraft:sea_lantern"),
@@ -289,41 +289,56 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
int chunkWorldZ
) {
JigsawStudioBounds bounds = workcell.bounds();
int minX = bounds.originX() - 1;
int maxX = bounds.maxX() + 1;
int minZ = bounds.originZ() - 1;
int maxZ = bounds.maxZ() + 1;
int minimumX = bounds.originX() - 1;
int maximumX = bounds.maxX() + 1;
int minimumZ = bounds.originZ() - 1;
int maximumZ = bounds.maxZ() + 1;
int bottomY = bounds.originY();
int topY = bounds.maxY() + 1;
paintRectangle(terrainChunk, minX, maxX, bottomY, minZ, maxZ, frame, chunkWorldX, chunkWorldZ);
paintRectangle(terrainChunk, minX, maxX, topY, minZ, maxZ, frame, chunkWorldX, chunkWorldZ);
paintRectangle(
terrainChunk,
minimumX,
maximumX,
bottomY,
minimumZ,
maximumZ,
chunkWorldX,
chunkWorldZ);
paintRectangle(
terrainChunk,
minimumX,
maximumX,
topY,
minimumZ,
maximumZ,
chunkWorldX,
chunkWorldZ);
for (int y = bottomY + 1; y < topY; y++) {
setWorldBlock(terrainChunk, minX, y, minZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, maxX, y, minZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, minX, y, maxZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, maxX, y, maxZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, minimumX, y, minimumZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, maximumX, y, minimumZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, minimumX, y, maximumZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, maximumX, y, maximumZ, frame, chunkWorldX, chunkWorldZ);
}
}
private void paintRectangle(
TerrainChunk terrainChunk,
int minX,
int maxX,
int minimumX,
int maximumX,
int y,
int minZ,
int maxZ,
PlatformBlockState block,
int minimumZ,
int maximumZ,
int chunkWorldX,
int chunkWorldZ
) {
for (int x = minX; x <= maxX; x++) {
setWorldBlock(terrainChunk, x, y, minZ, block, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, x, y, maxZ, block, chunkWorldX, chunkWorldZ);
for (int x = minimumX; x <= maximumX; x++) {
setWorldBlock(terrainChunk, x, y, minimumZ, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, x, y, maximumZ, frame, chunkWorldX, chunkWorldZ);
}
for (int z = minZ + 1; z < maxZ; z++) {
setWorldBlock(terrainChunk, minX, y, z, block, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, maxX, y, z, block, chunkWorldX, chunkWorldZ);
for (int z = minimumZ + 1; z < maximumZ; z++) {
setWorldBlock(terrainChunk, minimumX, y, z, frame, chunkWorldX, chunkWorldZ);
setWorldBlock(terrainChunk, maximumX, y, z, frame, chunkWorldX, chunkWorldZ);
}
}
@@ -0,0 +1,97 @@
package art.arcane.iris.core;
import org.junit.After;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class IrisStartupValidationTest {
@After
public void disableValidation() {
IrisStartupValidation.disable();
}
@Test
public void disabledValidationAllowsCreation() {
IrisStartupValidation.disable();
assertTrue(IrisStartupValidation.isReady());
assertTrue(IrisStartupValidation.denialReason().isEmpty());
IrisStartupValidation.requireWorldCreationReady();
}
@Test
public void pendingValidationDeniesCreation() {
IrisStartupValidation.begin();
assertFalse(IrisStartupValidation.isReady());
assertTrue(IrisStartupValidation.denialReason().orElseThrow().contains("external datapacks"));
try {
IrisStartupValidation.requireWorldCreationReady();
fail("Expected pending startup validation to lock world creation");
} catch (IllegalStateException expected) {
assertTrue(expected.getMessage().contains("world creation is locked"));
}
}
@Test
public void bothValidationPhasesMustComplete() {
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksReady();
assertFalse(IrisStartupValidation.isReady());
assertTrue(IrisStartupValidation.denialReason().orElseThrow().contains("dimension packs"));
IrisStartupValidation.markPacksReady();
assertTrue(IrisStartupValidation.isReady());
assertTrue(IrisStartupValidation.denialReason().isEmpty());
}
@Test
public void invalidDatapacksExposeTheFailure() {
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksInvalid("broken managed datapack");
IrisStartupValidation.markPacksReady();
assertEquals("broken managed datapack", IrisStartupValidation.denialReason().orElseThrow());
}
@Test
public void restartRequirementCannotBeDowngradedToReady() {
IrisStartupValidation.begin();
IrisStartupValidation.requireRestart("restart required for registry load");
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksReady();
assertFalse(IrisStartupValidation.isReady());
assertEquals("restart required for registry load", IrisStartupValidation.denialReason().orElseThrow());
}
@Test
public void repeatedValidationCannotClearRestartRequirement() {
IrisStartupValidation.begin();
IrisStartupValidation.requireRestart("restart boundary");
IrisStartupValidation.beginDatapackValidation();
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksReady();
assertFalse(IrisStartupValidation.isReady());
assertEquals("restart boundary", IrisStartupValidation.denialReason().orElseThrow());
}
@Test
public void packValidationInfrastructureFailureDeniesCreation() {
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksInvalid(List.of("pack registry unavailable"));
assertEquals("pack registry unavailable", IrisStartupValidation.denialReason().orElseThrow());
}
}
@@ -29,9 +29,11 @@ import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.channels.ServerSocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileStore;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -75,6 +77,174 @@ public class DatapackIngestServiceTest {
assertEquals("1.21.4", DatapackIngestService.serverMcVersion(server));
}
@Test
public void windowsLongPathAliasAcceptsTheSameVolumeSerialAndRoot() throws Exception {
FileStore shortPathStore = mock(FileStore.class);
FileStore longPathStore = mock(FileStore.class);
when(shortPathStore.getAttribute("volume:vsn")).thenReturn(41234L);
when(longPathStore.getAttribute("volume:vsn")).thenReturn(41234L);
assertTrue(DatapackIngestService.sameWindowsVolume(
shortPathStore, "C:\\", longPathStore, "c:\\"));
}
@Test
public void windowsLongPathAliasRejectsDifferentRootsAndVolumeSerials() throws Exception {
FileStore firstStore = mock(FileStore.class);
FileStore secondStore = mock(FileStore.class);
when(firstStore.getAttribute("volume:vsn")).thenReturn(1L);
when(secondStore.getAttribute("volume:vsn")).thenReturn(2L);
assertFalse(DatapackIngestService.sameWindowsVolume(
firstStore, "C:\\", secondStore, "C:\\"));
when(secondStore.getAttribute("volume:vsn")).thenReturn(1L);
assertFalse(DatapackIngestService.sameWindowsVolume(
firstStore, "C:\\", secondStore, "D:\\"));
}
@Test
public void scratchDirectoryRejectsJunctionLikeOtherAttributes() {
BasicFileAttributes attributes = mock(BasicFileAttributes.class);
when(attributes.isDirectory()).thenReturn(true);
when(attributes.isOther()).thenReturn(true);
assertFalse(DatapackIngestService.isSupportedScratchDirectory(attributes));
when(attributes.isOther()).thenReturn(false);
assertTrue(DatapackIngestService.isSupportedScratchDirectory(attributes));
}
@Test
public void startupValidationCacheRequiresEveryInputAndLocalFingerprint() {
DatapackIngestService.StartupValidationCache cache = new DatapackIngestService.StartupValidationCache();
cache.schemaVersion = 1;
cache.minecraftVersion = "26.2";
cache.irisVersion = 4000;
cache.autoIngest = true;
cache.stripOverrides = false;
cache.urls = List.of("https://modrinth.com/datapack/example");
cache.localFingerprint = "fingerprint";
assertTrue(DatapackIngestService.startupValidationCacheMatches(
cache,
"26.2",
4000,
true,
false,
List.of("https://modrinth.com/datapack/example"),
"fingerprint"));
assertFalse(DatapackIngestService.startupValidationCacheMatches(
cache,
"26.3",
4000,
true,
false,
cache.urls,
"fingerprint"));
assertFalse(DatapackIngestService.startupValidationCacheMatches(
cache,
"26.2",
4000,
true,
true,
cache.urls,
"fingerprint"));
assertFalse(DatapackIngestService.startupValidationCacheMatches(
cache,
"26.2",
4000,
true,
false,
List.of("https://modrinth.com/datapack/changed"),
"fingerprint"));
assertFalse(DatapackIngestService.startupValidationCacheMatches(
cache,
"26.2",
4000,
true,
false,
cache.urls,
"changed"));
}
@Test
public void startupValidationFingerprintChangesWithStagedContent() throws Exception {
File root = temporaryFolder.newFolder("startup-validation-fingerprint");
File staged = new File(root, "staging/managed");
assertTrue(staged.mkdirs());
Path content = new File(staged, "value.txt").toPath();
Files.writeString(content, "alpha", StandardCharsets.UTF_8);
KList<File> worldFolders = new KList<>();
String before = DatapackIngestService.startupValidationFingerprint(root, worldFolders);
Files.writeString(content, "bravo", StandardCharsets.UTF_8);
String after = DatapackIngestService.startupValidationFingerprint(root, worldFolders);
assertFalse(before.equals(after));
}
@Test
public void authorizedStartupMaintenanceRefreshesOnlyTheLocalFingerprint() throws Exception {
File root = temporaryFolder.newFolder("startup-validation-maintenance");
File staging = new File(root, "staging/managed");
assertTrue(staging.mkdirs());
Path content = new File(staging, "value.txt").toPath();
Files.writeString(content, "before", StandardCharsets.UTF_8);
KList<File> worldFolders = new KList<>();
DatapackIngestService.StartupValidationCache validated = new DatapackIngestService.StartupValidationCache();
validated.schemaVersion = 1;
validated.minecraftVersion = "26.2";
validated.irisVersion = 4000;
validated.autoIngest = true;
validated.stripOverrides = false;
validated.urls = List.of("https://modrinth.com/datapack/example");
validated.localFingerprint = DatapackIngestService.startupValidationFingerprint(root, worldFolders);
Files.writeString(content, "after", StandardCharsets.UTF_8);
assertFalse(DatapackIngestService.startupValidationCacheMatches(
validated,
"26.2",
4000,
true,
false,
validated.urls,
DatapackIngestService.startupValidationFingerprint(root, worldFolders)));
DatapackIngestService.StartupValidationCache refreshed =
DatapackIngestService.refreshStartupValidationCache(validated, root, worldFolders);
assertTrue(DatapackIngestService.startupValidationCacheMatches(
refreshed,
"26.2",
4000,
true,
false,
validated.urls,
DatapackIngestService.startupValidationFingerprint(root, worldFolders)));
assertEquals(validated.urls, refreshed.urls);
}
@Test
public void startupMaintenanceDoesNotAuthorizeChangedValidationInputs() {
DatapackIngestService.StartupValidationCache validated = new DatapackIngestService.StartupValidationCache();
validated.schemaVersion = 1;
validated.minecraftVersion = "26.2";
validated.irisVersion = 4000;
validated.autoIngest = true;
validated.stripOverrides = false;
validated.urls = List.of("https://modrinth.com/datapack/example");
assertTrue(DatapackIngestService.startupValidationContextMatches(
validated, "26.2", 4000, true, false, validated.urls));
assertFalse(DatapackIngestService.startupValidationContextMatches(
validated, "26.2", 4000, true, false,
List.of("https://modrinth.com/datapack/changed")));
assertFalse(DatapackIngestService.startupValidationContextMatches(
validated, "26.2", 4000, true, true, validated.urls));
}
@Test
public void packMetadataMustContainAValidPackContract() throws Exception {
File valid = temporaryFolder.newFolder("valid");
@@ -1974,6 +2144,38 @@ public class DatapackIngestServiceTest {
assertFalse(scratch.exists());
}
@Test
public void installScratchDeletionRetriesATransientDirectoryFailure() throws Exception {
File root = temporaryFolder.newFolder("transient-install-scratch-delete-root");
DeleteAttemptFile scratch = new DeleteAttemptFile(
new File(root, "managed-" + UUID.randomUUID()).getPath(), 2);
assertTrue(scratch.mkdir());
Files.writeString(new File(scratch, ".DS_Store").toPath(), "finder", StandardCharsets.UTF_8);
DatapackIngestService.deleteInstallScratch(scratch, "test datapack install scratch");
assertFalse(scratch.exists());
assertEquals(2, scratch.deleteAttempts());
}
@Test
public void installScratchDeletionStillFailsAfterBoundedRetries() throws Exception {
File root = temporaryFolder.newFolder("persistent-install-scratch-delete-root");
DeleteAttemptFile scratch = new DeleteAttemptFile(
new File(root, "managed-" + UUID.randomUUID()).getPath(), Integer.MAX_VALUE);
assertTrue(scratch.mkdir());
try {
DatapackIngestService.deleteInstallScratch(scratch, "test datapack install scratch");
fail("Expected persistent scratch deletion failure");
} catch (IOException expected) {
assertTrue(expected.getMessage().contains("Could not remove test datapack install scratch"));
}
assertTrue(scratch.exists());
assertEquals(3, scratch.deleteAttempts());
}
@Test
public void recoveryRejectsFinderMetadataDirectoryInInstallScratch() throws Exception {
File root = temporaryFolder.newFolder("orphan-install-finder-directory-root");
@@ -3173,4 +3375,26 @@ public class DatapackIngestServiceTest {
DatapackIngestService.VerifiedStagingInstall authorization
) {
}
private static final class DeleteAttemptFile extends File {
private static final long serialVersionUID = 1L;
private final int successfulAttempt;
private int deleteAttempts;
private DeleteAttemptFile(String pathname, int successfulAttempt) {
super(pathname);
this.successfulAttempt = successfulAttempt;
}
@Override
public boolean delete() {
deleteAttempts++;
return deleteAttempts >= successfulAttempt && super.delete();
}
private int deleteAttempts() {
return deleteAttempts;
}
}
}
@@ -1,12 +1,24 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisStartupValidation;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.junit.After;
import org.junit.Test;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
public class WorldLifecycleSelectionTest {
@After
public void disableStartupValidation() {
IrisStartupValidation.disable();
}
@Test
public void studioSelectsPaperLikeBackendOnPaper() {
WorldLifecycleService service = new WorldLifecycleService(CapabilitySnapshot.forTesting(ServerFamily.PAPER, false, false, true));
@@ -86,4 +98,67 @@ public class WorldLifecycleSelectionTest {
service.rememberBackend(NamespacedKey.minecraft("studio"), "paper_like_runtime");
assertEquals("paper_like_runtime", service.selectUnloadBackend("minecraft:studio").backendName());
}
@Test
public void pendingStartupValidationStopsCreateBeforeBackendSelection() {
CountingBackend selected = new CountingBackend("selected", true);
CountingBackend inactive = new CountingBackend("inactive", false);
WorldLifecycleService service = new WorldLifecycleService(
CapabilitySnapshot.forTesting(ServerFamily.PAPER, false, false, true),
selected,
inactive,
inactive);
WorldLifecycleRequest request = new WorldLifecycleRequest(
"blocked",
NamespacedKey.minecraft("blocked"),
World.Environment.NORMAL,
null,
null,
null,
true,
false,
1337L,
false,
false,
WorldLifecycleCaller.CREATE);
IrisStartupValidation.begin();
CompletionException failure = assertThrows(CompletionException.class, () -> service.create(request).join());
assertEquals(IllegalStateException.class, failure.getCause().getClass());
assertEquals(0, selected.createCount.get());
}
private static final class CountingBackend implements WorldLifecycleBackend {
private final String name;
private final boolean supported;
private final AtomicInteger createCount;
private CountingBackend(String name, boolean supported) {
this.name = name;
this.supported = supported;
this.createCount = new AtomicInteger();
}
@Override
public boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities) {
return supported;
}
@Override
public CompletableFuture<World> create(WorldLifecycleRequest request) {
createCount.incrementAndGet();
return CompletableFuture.completedFuture(null);
}
@Override
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
return CompletableFuture.completedFuture(false);
}
@Override
public String backendName() {
return name;
}
}
}
@@ -0,0 +1,146 @@
package art.arcane.iris.core.pack;
import org.junit.Rule;
import org.junit.Test;
import org.junit.Assume;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.List;
import java.util.Optional;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class PackValidationCacheTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void cacheRoundTripsSuccessfulAndFailedResultsInStableOrder() throws Exception {
Path cache = new File(temporaryFolder.newFolder("round-trip"), "validation.json").toPath();
PackValidationResult valid = new PackValidationResult(
"z-valid", List.of(), List.of("warning"), 20L);
PackValidationResult invalid = new PackValidationResult(
"a-invalid", List.of("broken reference"), List.of(), 10L);
PackValidationCache.save(cache, "content", "context", List.of(valid, invalid));
List<PackValidationResult> loaded = PackValidationCache.load(
cache,
"content",
"context",
List.of("z-valid", "a-invalid")).orElseThrow();
assertEquals(List.of("a-invalid", "z-valid"), loaded.stream()
.map(PackValidationResult::getPackName)
.toList());
assertFalse(loaded.getFirst().isLoadable());
assertEquals(List.of("broken reference"), loaded.getFirst().getBlockingErrors());
assertTrue(loaded.getLast().isLoadable());
assertEquals(List.of("warning"), loaded.getLast().getWarnings());
}
@Test
public void cacheRejectsContentContextAndPackSetChanges() throws Exception {
Path cache = new File(temporaryFolder.newFolder("mismatch"), "validation.json").toPath();
PackValidationCache.save(cache, "content", "context", List.of(
new PackValidationResult("overworld", List.of(), List.of(), 1L)));
assertTrue(PackValidationCache.load(
cache, "changed", "context", List.of("overworld")).isEmpty());
assertTrue(PackValidationCache.load(
cache, "content", "changed", List.of("overworld")).isEmpty());
assertTrue(PackValidationCache.load(
cache, "content", "context", List.of("other")).isEmpty());
}
@Test
public void corruptCacheIsIgnored() throws Exception {
Path cache = new File(temporaryFolder.newFolder("corrupt"), "validation.json").toPath();
Files.writeString(cache, "not-json", StandardCharsets.UTF_8);
Optional<List<PackValidationResult>> loaded = PackValidationCache.load(
cache, "content", "context", List.of());
assertTrue(loaded.isEmpty());
}
@Test
public void duplicateResultsAreRejected() throws Exception {
Path cache = new File(temporaryFolder.newFolder("duplicate"), "validation.json").toPath();
Files.writeString(cache, """
{
"schemaVersion": 1,
"contentFingerprint": "content",
"contextFingerprint": "context",
"results": [
{"packName":"overworld","blockingErrors":[],"warnings":[],"validatedAtMillis":1},
{"packName":"overworld","blockingErrors":[],"warnings":[],"validatedAtMillis":2}
]
}
""", StandardCharsets.UTF_8);
assertTrue(PackValidationCache.load(
cache, "content", "context", List.of("overworld")).isEmpty());
}
@Test
public void symbolicLinkCacheIsRejected() throws Exception {
Path directory = temporaryFolder.newFolder("symbolic-cache").toPath();
Path target = directory.resolve("target.json");
Files.writeString(target, "{}", StandardCharsets.UTF_8);
Path link = directory.resolve("validation.json");
try {
Files.createSymbolicLink(link, target.getFileName());
} catch (Exception unavailable) {
Assume.assumeNoException(unavailable);
}
assertTrue(PackValidationCache.load(
link, "content", "context", List.of()).isEmpty());
}
@Test
public void contentFingerprintChangesWhenSizeAndTimestampAreRestored() throws Exception {
File packsRoot = temporaryFolder.newFolder("content-fingerprint");
File pack = new File(packsRoot, "overworld");
assertTrue(pack.mkdirs());
Path dimension = new File(pack, "dimensions/overworld.json").toPath();
Files.createDirectories(dimension.getParent());
Files.writeString(dimension, "alpha", StandardCharsets.UTF_8);
FileTime originalTime = Files.getLastModifiedTime(dimension);
String before = PackValidationCache.contentFingerprint(packsRoot);
Files.writeString(dimension, "bravo", StandardCharsets.UTF_8);
Files.setLastModifiedTime(dimension, originalTime);
String after = PackValidationCache.contentFingerprint(packsRoot);
assertNotEquals(before, after);
}
@Test
public void cachedFailureRemainsFailClosedWhenPublished() throws Exception {
Path cache = new File(temporaryFolder.newFolder("failed-result"), "validation.json").toPath();
PackValidationCache.save(cache, "content", "context", List.of(
new PackValidationResult("overworld", List.of("missing structure"), List.of(), 1L)));
PackValidationResult loaded = PackValidationCache.load(
cache, "content", "context", List.of("overworld")).orElseThrow().getFirst();
PackValidationRegistry.clear();
PackValidationRegistry.publish(loaded);
try {
PackValidationRegistry.requireLoadable("overworld");
fail("Expected a persisted failed validation to remain blocking");
} catch (BrokenPackException expected) {
assertEquals(List.of("missing structure"), expected.getReasons());
} finally {
PackValidationRegistry.clear();
}
}
}
@@ -131,4 +131,24 @@ public class StudioOpenCoordinatorOpenKindTest {
assertTrue(method.contains("CompletableFuture<Void> abandonment = J.sfut("));
assertTrue(method.contains("abandonment.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS"));
}
@Test
public void openFinalizerReturnsToTheServerThreadBeforeCompletion() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java"));
int finalizerCall = source.indexOf("runOpenFinalizer(request.onDone(), world);");
int futureCompletion = source.indexOf(
"future.complete(new StudioOpenResult(world, safeEntry))", finalizerCall);
int methodStart = source.indexOf(
"private void runOpenFinalizer(Consumer<World> finalizer, World world)");
int methodEnd = source.indexOf("private long elapsedMillis", methodStart);
String method = source.substring(methodStart, methodEnd);
assertTrue(finalizerCall >= 0);
assertTrue(futureCompletion > finalizerCall);
assertTrue(method.contains("if (J.isPrimaryThread())"));
assertTrue(method.contains("J.sfut(() -> finalizer.accept(world))"));
assertTrue(method.contains(
"completion.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)"));
}
}
@@ -96,20 +96,31 @@ public class JigsawStudioLayoutTest {
}
@Test
public void spatialLayoutHasOneActiveWorkcell() {
public void spatialVariantsReceiveDedicatedAdjacentWorkcells() {
JigsawStudioVariant hall = spatialVariant("stronghold/hall");
JigsawStudioVariant stairs = spatialVariant("stronghold/stairs");
JigsawStudioVariant tower = spatialVariant("stronghold/tower");
JigsawStudioLayout layout = JigsawStudioLayout.create(
JigsawStudioMode.SPATIAL_JIGSAW,
CELL,
new JigsawStudioVariantCatalog(List.of(hall)));
new JigsawStudioVariantCatalog(List.of(hall, stairs, tower)));
assertEquals(1, layout.bays().size());
assertEquals(3, layout.bays().size());
JigsawStudioBay workcell = layout.bays().getFirst();
JigsawStudioBay second = layout.bays().get(1);
JigsawStudioBay third = layout.bays().get(2);
assertEquals(JigsawStudioLayout.SPATIAL_WORKCELL_ID, workcell.stableId());
assertEquals(JigsawStudioBayKind.SPATIAL_WORKCELL, workcell.kind());
assertTrue(workcell.archetype().isEmpty());
assertTrue(workcell.topology().isEmpty());
assertSame(hall, layout.defaultVariant(workcell).orElseThrow());
assertSame(stairs, layout.defaultVariant(second).orElseThrow());
assertSame(tower, layout.defaultVariant(third).orElseThrow());
assertEquals(1, second.bounds().originX() - workcell.bounds().maxX() - 1);
assertEquals(1, third.bounds().originX() - second.bounds().maxX() - 1);
assertTrue(layout.accepts(second, stairs));
assertFalse(layout.accepts(second, hall));
assertSame(second, layout.workcellForVariant(stairs.pieceKey()).orElseThrow());
assertNull(layout.findAt(-1, JigsawStudioLayout.FLOOR_Y + 1, -1));
}
@@ -448,7 +448,7 @@ public class JigsawStudioPersistenceEditorsTest {
}
@Test
public void createsSpatialThemeSetFromTheSingleSpatialWorkcellSource() throws Exception {
public void createsSpatialThemeSetFromTheSelectedSpatialWorkcellSource() throws Exception {
Path packRoot = temporaryFolder.newFolder("spatial-theme").toPath();
JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options(
"spatial/theme",
@@ -468,7 +468,39 @@ public class JigsawStudioPersistenceEditorsTest {
"spatial/theme/variants/spatial/variant-2",
creation.pieceKeysByWorkcell().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID));
JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/spatial/theme/start.json"));
assertEquals(2, pool.getAsJsonArray("pieces").size());
assertEquals(8, pool.getAsJsonArray("pieces").size());
}
@Test
public void createsSpatialThemeSetAcrossEveryDedicatedSpatialWorkcell() throws Exception {
Path packRoot = temporaryFolder.newFolder("spatial-theme-row").toPath();
JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options(
"spatial/row",
JigsawStudioMode.SPATIAL_JIGSAW,
JigsawStudioCompatibilityTarget.IRIS_EXTENDED,
new JigsawStudioCellDimensions(15, 15, 15));
assertTrue(JigsawStudioProjectCreator.create(packRoot, options).successful());
Map<String, String> sources = new LinkedHashMap<>();
sources.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID, "spatial/row/start");
for (int connectorCount = 1; connectorCount <= 6; connectorCount++) {
String pieceKey = "spatial/row/connectors-" + connectorCount;
sources.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID + "/" + pieceKey, pieceKey);
}
JigsawStudioGraphEditor.VariantFamilyCreation creation = JigsawStudioGraphEditor.duplicateActiveFamily(
packRoot,
"spatial/row",
sources,
"variant-2");
assertTrue(creation.writeResult().successful());
assertEquals(7, creation.pieceKeysByWorkcell().size());
for (String targetPieceKey : creation.pieceKeysByWorkcell().values()) {
assertTrue(Files.isRegularFile(packRoot.resolve("jigsaw-pieces/" + targetPieceKey + ".json")));
assertTrue(Files.isRegularFile(packRoot.resolve("objects/" + targetPieceKey + ".iob")));
}
JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/spatial/row/start.json"));
assertEquals(14, pool.getAsJsonArray("pieces").size());
}
@Test
@@ -206,6 +206,64 @@ public class JigsawStudioProjectCreatorTest {
}
}
@Test
public void spatialProjectSeedsZeroThroughSixConnectorVariants() throws Exception {
Path temporaryDirectory = temporaryFolder.getRoot().toPath();
JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options(
"stronghold/gallery",
JigsawStudioMode.SPATIAL_JIGSAW,
JigsawStudioCompatibilityTarget.IRIS_EXTENDED,
new JigsawStudioCellDimensions(15, 15, 15));
StructureWriteResult result = JigsawStudioProjectCreator.create(temporaryDirectory, options);
assertTrue(result.successful());
JsonObject startPool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/stronghold/gallery/start.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
JsonObject piecePool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/stronghold/gallery/pieces.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(7, startPool.getAsJsonArray("pieces").size());
assertEquals(7, piecePool.getAsJsonArray("pieces").size());
assertEquals("stronghold/gallery/connectors-1", piecePool.getAsJsonArray("pieces").get(0)
.getAsJsonObject().get("piece").getAsString());
assertTrue(piecePool.getAsJsonArray("pieces").get(6).getAsJsonObject()
.get("empty").getAsBoolean());
for (int connectorCount = 0; connectorCount <= 6; connectorCount++) {
String pieceName = connectorCount == 0 ? "start" : "connectors-" + connectorCount;
JsonObject piece = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/"
+ pieceName + ".json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(connectorCount, piece.getAsJsonArray("connectors").size());
assertEquals(connectorCount + (connectorCount == 1 ? " Connector" : " Connectors"),
piece.get("displayName").getAsString());
assertEquals(16, piece.getAsJsonObject("rules").get("maximumPlacements").getAsInt());
assertEquals(new IrisBlockVector(15, 15, 15), IrisObject.sampleSize(
temporaryDirectory.resolve("objects/stronghold/gallery/"
+ pieceName + ".iob").toFile()));
}
JsonObject six = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/connectors-6.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals("NORTH_NEGATIVE_Z", six.getAsJsonArray("connectors").get(0)
.getAsJsonObject().get("direction").getAsString());
assertEquals("DOWN_NEGATIVE_Y", six.getAsJsonArray("connectors").get(5)
.getAsJsonObject().get("direction").getAsString());
assertEquals(0, six.getAsJsonArray("connectors").get(5)
.getAsJsonObject().getAsJsonObject("position").get("y").getAsInt());
StructureGraphCompilation compilation = StructureResourceBundleGraphCompiler.compile(
JigsawStudioProjectCreator.bundle(options)).getFirst();
StructureAssemblyResult preview = StructureAssembler.forCompilation(
compilation,
new IrisPosition(0, 0, 0)).assemble(new RNG(1337L));
assertEquals(preview.detail(), StructureAssemblyStatus.COMPLETE, preview.status());
assertTrue(preview.pieces().size() > 1);
assertTrue(preview.pieces().size() <= 96);
}
@Test
public void updatesOwnedPoolThroughWholeGraphTransaction() throws Exception {
Path temporaryDirectory = temporaryFolder.getRoot().toPath();
@@ -337,10 +395,10 @@ public class JigsawStudioProjectCreatorTest {
JsonObject pool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/stronghold/test/start.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(2, pool.getAsJsonArray("pieces").size());
assertEquals("stronghold/test/hall", pool.getAsJsonArray("pieces").get(1)
assertEquals(8, pool.getAsJsonArray("pieces").size());
assertEquals("stronghold/test/hall", pool.getAsJsonArray("pieces").get(7)
.getAsJsonObject().get("piece").getAsString());
assertEquals(3, pool.getAsJsonArray("pieces").get(1)
assertEquals(3, pool.getAsJsonArray("pieces").get(7)
.getAsJsonObject().get("weight").getAsInt());
StructureWriteResult resized = JigsawStudioStructureEditor.updateCellSize(
@@ -360,10 +418,10 @@ public class JigsawStudioProjectCreatorTest {
new JigsawStudioCellDimensions(16, 12, 18)).writeResult().successful());
IrisBlockVector hallSize = IrisObject.sampleSize(
temporaryDirectory.resolve("objects/stronghold/test/hall.iob").toFile());
IrisBlockVector startSize = IrisObject.sampleSize(
IrisBlockVector starterSize = IrisObject.sampleSize(
temporaryDirectory.resolve("objects/stronghold/test/start.iob").toFile());
assertEquals(new IrisBlockVector(16, 12, 18), hallSize);
assertEquals(new IrisBlockVector(12, 10, 14), startSize);
assertEquals(new IrisBlockVector(12, 10, 14), starterSize);
StructureWriteResult limited = JigsawStudioStructureEditor.updateLimits(
temporaryDirectory,
@@ -1,89 +0,0 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariantCatalog;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioWorkcellSpec;
import org.bukkit.entity.BlockDisplay;
import org.junit.Test;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
public class JigsawStudioDisabledWorkcellRendererTest {
@Test
public void disabledWorkcellProducesOneFullVolumeDescriptor() {
JigsawStudioLayout layout = layoutWithDisabled(JigsawPlanarArchetype.TEE);
Map<String, JigsawStudioDisabledWorkcellRenderer.Descriptor> descriptors =
JigsawStudioDisabledWorkcellRenderer.descriptors(layout);
assertEquals(1, descriptors.size());
JigsawStudioDisabledWorkcellRenderer.Descriptor descriptor =
descriptors.get(JigsawPlanarArchetype.TEE.stableId());
assertEquals(11, descriptor.width());
assertEquals(5, descriptor.height());
assertEquals(7, descriptor.depth());
assertEquals(JigsawStudioLayout.FLOOR_Y + 1, descriptor.originY());
}
@Test
public void enabledWorkcellsNeverProduceRedGlassDescriptors() {
JigsawStudioLayout layout = layoutWithDisabled(null);
Map<String, JigsawStudioDisabledWorkcellRenderer.Descriptor> descriptors =
JigsawStudioDisabledWorkcellRenderer.descriptors(layout);
assertTrue(descriptors.isEmpty());
assertFalse(layout.bays().isEmpty());
}
@Test
public void chunkUnloadDetachesOnlyDisplaysWhoseOriginsBelongToThatChunk() {
BlockDisplay target = mock(BlockDisplay.class);
BlockDisplay retained = mock(BlockDisplay.class);
JigsawStudioDisabledWorkcellRenderer.Descriptor targetDescriptor =
new JigsawStudioDisabledWorkcellRenderer.Descriptor("workcell/tee", 31, 65, -1, 3, 3, 3);
JigsawStudioDisabledWorkcellRenderer.Descriptor retainedDescriptor =
new JigsawStudioDisabledWorkcellRenderer.Descriptor("workcell/cross", 32, 65, -1, 3, 3, 3);
Map<String, BlockDisplay> entities = new HashMap<>(Map.of(
targetDescriptor.workcellId(), target,
retainedDescriptor.workcellId(), retained));
Map<String, JigsawStudioDisabledWorkcellRenderer.Descriptor> rendered = new HashMap<>(Map.of(
targetDescriptor.workcellId(), targetDescriptor,
retainedDescriptor.workcellId(), retainedDescriptor));
List<BlockDisplay> removals = JigsawStudioDisabledWorkcellRenderer.detachChunkDisplays(
entities, rendered, 1, -1);
assertEquals(List.of(target), removals);
assertFalse(entities.containsKey(targetDescriptor.workcellId()));
assertFalse(rendered.containsKey(targetDescriptor.workcellId()));
assertEquals(retained, entities.get(retainedDescriptor.workcellId()));
assertEquals(retainedDescriptor, rendered.get(retainedDescriptor.workcellId()));
verifyNoInteractions(target, retained);
}
private static JigsawStudioLayout layoutWithDisabled(JigsawPlanarArchetype disabled) {
List<JigsawStudioWorkcellSpec> specs = new ArrayList<>();
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
JigsawStudioCellDimensions dimensions = archetype == JigsawPlanarArchetype.TEE
? new JigsawStudioCellDimensions(11, 5, 7)
: new JigsawStudioCellDimensions(3, 3, 3);
specs.add(new JigsawStudioWorkcellSpec(archetype, "", dimensions, archetype != disabled));
}
return JigsawStudioLayout.createPlanar(
new JigsawStudioCellDimensions(3, 3, 3),
specs,
JigsawStudioVariantCatalog.empty());
}
}
@@ -16,12 +16,16 @@ import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.collection.KMap;
import org.bukkit.World;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.junit.After;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -70,6 +74,33 @@ public class JigsawStudioLifecycleTest {
assertFalse(JigsawStudioActivation.tryBeginOpen(OTHER_OWNER));
}
@Test
public void studioRejectsNaturalCreatureSpawns() {
assertTrue(JigsawStudioService.isNaturalStudioSpawn(
CreatureSpawnEvent.SpawnReason.NATURAL));
assertFalse(JigsawStudioService.isNaturalStudioSpawn(
CreatureSpawnEvent.SpawnReason.CUSTOM));
assertFalse(JigsawStudioService.isNaturalStudioSpawn(
CreatureSpawnEvent.SpawnReason.SPAWNER));
}
@Test
public void committedStudioActivationDisablesNaturalMobSpawning() throws IOException {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/service/JigsawStudioService.java"));
int registerStart = source.indexOf("public void register(");
int commitStart = source.indexOf("public void activationCommitted(", registerStart);
int commitEnd = source.indexOf("public void markChunkGenerated(", commitStart);
int helperStart = source.indexOf("static void disableNaturalStudioSpawning(", commitEnd);
String register = source.substring(registerStart, commitStart);
String commit = source.substring(commitStart, commitEnd);
String helper = source.substring(helperStart);
assertFalse(register.contains("disableNaturalStudioSpawning(world)"));
assertTrue(commit.contains("disableNaturalStudioSpawning(world)"));
assertTrue(helper.contains("setGameRule(GameRules.SPAWN_MOBS, false)"));
}
@Test
public void closeAuthorizationChecksOwnerDirtyStateAndSaveBarrierAtomically() {
assertTrue(JigsawStudioActivation.tryBeginOpen(OWNER));
@@ -91,10 +122,10 @@ public class JigsawStudioLifecycleTest {
service.tryBeginClose(request.requestId(), OWNER, false));
assertTrue(service.closeProtectionFailure(request.requestId()).contains("autosave"));
JigsawStudioSession.VariantSwitchToken switchToken = session.beginVariantSwitch(
JigsawStudioLayout.SPATIAL_WORKCELL_ID,
"stronghold/tower",
true).token().orElseThrow();
String towerWorkcellId = session.layout().workcellForVariant("stronghold/tower")
.orElseThrow().stableId();
JigsawStudioSession.VariantSwitchToken switchToken = session.beginVariantReload(
towerWorkcellId).token().orElseThrow();
assertEquals(
JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS,
service.tryBeginClose(request.requestId(), OWNER, true));
@@ -19,6 +19,7 @@ import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -30,9 +31,11 @@ import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -239,6 +242,87 @@ public class JigsawStudioMenuControllerTest {
0));
}
@Test
public void stagesMultipleCellSizeEditsAndAppliesOneRelayoutWithoutClosing() throws Exception {
UUID playerId = UUID.fromString("55555555-5555-5555-5555-555555555555");
Player player = mock(Player.class);
JigsawStudioMenuController.Actions actions = mock(JigsawStudioMenuController.Actions.class);
JigsawStudioMenuState.Variant active = variant("pieces/corner", true, List.of(), List.of());
JigsawStudioMenuState.Workcell workcell = workcell(active);
JigsawStudioMenuState state = state(JigsawStudioMenuState.Evaluation.pending(), workcell);
JigsawStudioMenuController controller = new JigsawStudioMenuController(
mock(JavaPlugin.class),
actions);
Method resize = JigsawStudioMenuController.class.getDeclaredMethod(
"resizeWorkcell",
Player.class,
UUID.class,
String.class,
JigsawStudioMenuController.DimensionAxis.class,
int.class);
Method apply = JigsawStudioMenuController.class.getDeclaredMethod(
"applyWorkcellResize",
Player.class,
UUID.class,
String.class);
resize.setAccessible(true);
apply.setAccessible(true);
when(player.getUniqueId()).thenReturn(playerId);
when(actions.menuState(player)).thenReturn(Optional.of(state));
when(actions.updateWorkcellDimensions(
player,
workcell.stableId(),
new JigsawStudioCellDimensions(25, 20, 18))).thenReturn(true);
resize.invoke(
controller,
player,
REQUEST_ID,
workcell.stableId(),
JigsawStudioMenuController.DimensionAxis.WIDTH,
1);
resize.invoke(
controller,
player,
REQUEST_ID,
workcell.stableId(),
JigsawStudioMenuController.DimensionAxis.HEIGHT,
8);
verify(actions, never()).updateWorkcellDimensions(
player,
workcell.stableId(),
new JigsawStudioCellDimensions(25, 20, 18));
try (MockedStatic<J> scheduling = mockStatic(J.class)) {
scheduling.when(() -> J.runEntity(
same(player),
any(Runnable.class),
eq(2))).thenReturn(true);
apply.invoke(controller, player, REQUEST_ID, workcell.stableId());
verify(actions).updateWorkcellDimensions(
player,
workcell.stableId(),
new JigsawStudioCellDimensions(25, 20, 18));
}
}
@Test
public void stagedCapacityPreservesEveryOtherWorkcellProperty() {
JigsawStudioMenuState.Variant active = variant("pieces/corner", true, List.of(), List.of());
JigsawStudioMenuState.Workcell source = workcell(active);
JigsawStudioCellDimensions capacity = new JigsawStudioCellDimensions(31, 19, 27);
JigsawStudioMenuState.Workcell staged = JigsawStudioMenuController.withCapacity(source, capacity);
assertEquals(capacity, staged.capacity());
assertEquals(source.stableId(), staged.stableId());
assertEquals(source.enabled(), staged.enabled());
assertEquals(source.activeVariantKey(), staged.activeVariantKey());
assertEquals(source.variants(), staged.variants());
}
@Test
public void allocatesNumberedThemeSetsAndAdjustsPositiveWeights() {
List<JigsawStudioMenuState.ThemeSet> themeSets = List.of(
@@ -254,6 +338,20 @@ public class JigsawStudioMenuControllerTest {
() -> JigsawStudioMenuController.adjustedPositiveValue(4, 0));
}
@Test
public void themeWeightsExposeWholeAssemblySelectionChance() {
List<JigsawStudioMenuState.ThemeSet> themes = List.of(
new JigsawStudioMenuState.ThemeSet("variant-1", 3),
new JigsawStudioMenuState.ThemeSet("variant-2", 1));
assertEquals("75.0%", JigsawStudioMenuController.themeSelectionPercent(
themes,
themes.getFirst()));
assertEquals("25.0%", JigsawStudioMenuController.themeSelectionPercent(
themes,
themes.getLast()));
}
@Test
public void editsPieceRuleFieldsWithinRuntimeBounds() {
assertEquals(
@@ -1,5 +1,7 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisObject;
@@ -68,6 +70,28 @@ public class JigsawStudioPreviewRendererTest {
assertTrue(plan.bounds().isEmpty());
}
@Test
public void spatialPreviewFloatsAboveThePlanarEditingFloor() {
PlacedStructurePiece source = piece(
new IrisObject(3, 3, 3),
10,
10,
10,
IrisObjectRotation.of(0, 0, 0));
PlacedStructurePiece planar = JigsawStudioService.alignPreviewPieces(
List.of(source),
JigsawStudioMode.PLANAR_JIGSAW).getFirst();
PlacedStructurePiece spatial = JigsawStudioService.alignPreviewPieces(
List.of(source),
JigsawStudioMode.SPATIAL_JIGSAW).getFirst();
assertEquals(JigsawStudioLayout.FLOOR_Y + 1, planar.getMinY());
assertEquals(JigsawStudioLayout.FLOOR_Y + 48, spatial.getMinY());
assertEquals(planar.getMinX(), spatial.getMinX());
assertEquals(planar.getMinZ(), spatial.getMinZ());
}
@Test
public void uncertainPositionIsReappliedWhenSuccessivePlansMatch() {
JigsawStudioPreviewRenderer.BlockPosition position =
@@ -63,7 +63,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
List.of(connector),
false);
assertEquals(4, assembly.bundle().resources().size());
assertEquals(17, assembly.bundle().resources().size());
assertEquals("fort/start", assembly.objectKey());
assertEquals(1, assembly.piece().getConnectors().size());
StructureWriteResult result = new StructureTransactionWriter(packRoot)
@@ -41,6 +41,7 @@ import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
@@ -103,6 +104,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
@@ -112,6 +114,24 @@ import static org.mockito.Mockito.when;
public class JigsawStudioServiceCaptureTest {
@Test
public void successfulStudioSavePlaysOneOwnerLocalBell() {
Player player = mock(Player.class);
Location location = mock(Location.class);
when(player.getLocation()).thenReturn(location);
try (MockedStatic<J> scheduling = mockStatic(J.class)) {
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class))).thenAnswer(invocation -> {
invocation.getArgument(1, Runnable.class).run();
return true;
});
JigsawStudioService.playSaveSound(player);
verify(player).playSound(location, "minecraft:block.note_block.bell", 0.65F, 1.65F);
}
}
@Test
public void hiddenConnectorResetRestoresOnlyItsSavedOrdinaryBlock() throws Exception {
World world = mock(World.class);
@@ -741,10 +761,23 @@ public class JigsawStudioServiceCaptureTest {
layout.get("workcell/end"),
List.of(eastSource),
3);
assertThrows(IOException.class, () -> JigsawStudioService.requireWorkcellTopology(
layout.get("workcell/end"),
List.of(southDisplayed),
0));
JigsawStudioService.WorkcellTopologyException wrongDirection = assertThrows(
JigsawStudioService.WorkcellTopologyException.class,
() -> JigsawStudioService.requireWorkcellTopology(
layout.get("workcell/end"),
List.of(southDisplayed),
0));
assertTrue(wrongDirection.getMessage().contains("south end (1 horizontal connector)"));
assertTrue(wrongDirection.getMessage().contains("Reset Connector Blocks"));
JigsawStudioService.WorkcellTopologyException missingTee = assertThrows(
JigsawStudioService.WorkcellTopologyException.class,
() -> JigsawStudioService.requireWorkcellTopology(
layout.get("workcell/tee"),
List.of(),
0));
assertTrue(missingTee.getMessage().contains("north east west tee (3 horizontal connectors)"));
assertTrue(missingTee.getMessage().contains("blank (0 horizontal connectors)"));
}
@Test
@@ -1233,6 +1266,9 @@ public class JigsawStudioServiceCaptureTest {
when(world.getUID()).thenReturn(worldId);
JigsawStudioGenerator generator = mock(JigsawStudioGenerator.class);
when(generator.getLayout()).thenReturn(layout);
JigsawStudioActivation.Request request = mock(JigsawStudioActivation.Request.class);
when(request.requestId()).thenReturn(UUID.randomUUID());
when(generator.getRequest()).thenReturn(request);
when(generator.renderBay(any(JigsawStudioBay.class)))
.thenReturn(JigsawStudioGenerator.RenderedBay.empty(dimensions));
Class<?> studioType = Class.forName(JigsawStudioService.class.getName() + "$ActiveStudio");
@@ -212,6 +212,32 @@ public class JigsawStudioGeneratorTest {
fixture.generator(), control.worldX(), control.worldY(), control.worldZ()));
}
@Test
public void paintsEveryWorkcellAsAWhiteConcreteEdgeCuboid() {
GeneratorFixture fixture = fixture(
JigsawStudioMode.PLANAR_JIGSAW,
new JigsawStudioCellDimensions(5, 4, 3),
JigsawStudioVariantCatalog.empty());
JigsawStudioBay workcell = fixture.layout().get("workcell/blank");
int minimumX = workcell.bounds().originX() - 1;
int maximumX = workcell.bounds().maxX() + 1;
int minimumZ = workcell.bounds().originZ() - 1;
int maximumZ = workcell.bounds().maxZ() + 1;
int bottomY = workcell.bounds().originY();
int topY = workcell.bounds().maxY() + 1;
assertSame(fixture.frame(), stateAt(fixture.generator(), minimumX, bottomY, minimumZ));
assertSame(fixture.frame(), stateAt(fixture.generator(), maximumX, bottomY, maximumZ));
assertSame(fixture.frame(), stateAt(fixture.generator(), minimumX, topY, maximumZ));
assertSame(fixture.frame(), stateAt(fixture.generator(), maximumX, topY, minimumZ));
assertSame(fixture.frame(), stateAt(fixture.generator(), minimumX, bottomY + 1, minimumZ));
assertFalse(fixture.frame() == stateAt(
fixture.generator(),
workcell.bounds().originX(),
bottomY + 1,
workcell.bounds().originZ()));
}
@Test
public void glyphMathHandlesAllArchetypesAndSmallEvenAndOddCells() {
int[] sizes = {1, 2, 3, 4, 7, 16};
@@ -521,6 +547,7 @@ public class JigsawStudioGeneratorTest {
return new GeneratorFixture(
generator,
layout,
frame,
topologyBase,
topologyPath,
connectorCap,
@@ -559,6 +586,7 @@ public class JigsawStudioGeneratorTest {
private record GeneratorFixture(
JigsawStudioGenerator generator,
JigsawStudioLayout layout,
PlatformBlockState frame,
PlatformBlockState topologyBase,
PlatformBlockState topologyPath,
PlatformBlockState connectorCap,