mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 12:41:43 +00:00
fix
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
+19
-3
@@ -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;
|
||||
}
|
||||
|
||||
+79
-12
@@ -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(
|
||||
|
||||
-272
@@ -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,
|
||||
|
||||
+37
-22
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+34
-2
@@ -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
|
||||
|
||||
+63
-5
@@ -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,
|
||||
|
||||
-89
@@ -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 =
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+40
-4
@@ -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");
|
||||
|
||||
+28
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user