mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-25 21:40:05 +00:00
Merge master into 3D terrain
- Resolve IrisTerrainNormalActuator by keeping master's primitive riverWaterSurfaceStream lookup and primarySurfaceLayerOrNull accessors while retaining the terrain column, span, layer surface and ceiling locals that write air through terrain3D gaps - Resolve HydrologyFootprintCompiler by keeping the MaterializedSurface planned-surface record with terrain ownership beside master's package-private SurfaceRasterIndex, which retains its ownsTerrain override - Resolve HydrologyCaveContainmentPlanner by dropping the nested RecordingCaveVoxelView, SurfaceObservations and ViewObservations types that master promoted to their own files - Port the surface-observation ownership check into CaveSurfaceObservations so cached cave validations replay both height and ownership observations through Observation.matches - Resolve PackExportClosureTest by keeping master's retirement of the source-text packager guard while retaining the snippet export tests, and restore the Files and Path imports those tests use - Resolve IrisBiomeLayerSlopeSourceTest by folding the lower-ledge slope palette case into master's lockLayers parameterization - Recapture the studio schema digest for the terrain3D biome property, its IrisTerrain3D definition, the terrain-3d snippet enumeration and the reworded upperDimensionCarving description, verified by reconstructing the previous digest from the stripped schema
This commit is contained in:
commit
98e51cba8d
520 files changed
+45623
-40734
No files matched your search
+6
-11
@@ -29,6 +29,7 @@ import java.nio.charset.StandardCharsets
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'java-library'
|
||||
id 'java-test-fixtures'
|
||||
alias(libs.plugins.shadow)
|
||||
alias(libs.plugins.slimjar)
|
||||
}
|
||||
@@ -130,6 +131,10 @@ dependencies {
|
||||
slim(libs.dom4j)
|
||||
slim(libs.jaxen)
|
||||
|
||||
testFixturesImplementation('junit:junit:4.13.2')
|
||||
testFixturesImplementation('org.mockito:mockito-core:5.23.0')
|
||||
testFixturesCompileOnly(libs.paper.api)
|
||||
|
||||
testImplementation('junit:junit:4.13.2')
|
||||
testImplementation('org.mockito:mockito-core:5.23.0')
|
||||
testImplementation(libs.paper.api)
|
||||
@@ -232,7 +237,7 @@ tasks.named('compileJava', JavaCompile).configure {
|
||||
tasks.named('test', Test).configure {
|
||||
jvmArgs('--add-modules', 'jdk.incubator.vector')
|
||||
dependsOn(languageSourceManifestTask)
|
||||
classpath = files(layout.buildDirectory.dir('generated/language-source')) + files('src/main/resources') + classpath
|
||||
classpath = files(layout.buildDirectory.dir('generated/language-source')) + files('src/main/resources') + sourceSets.main.output + classpath
|
||||
}
|
||||
|
||||
configurations.matching { it.name.startsWith('slim') }.all { }
|
||||
@@ -264,8 +269,6 @@ List<String> supersededVolmLibPackages = [
|
||||
'art/arcane/volmlib/util/reflect/**',
|
||||
'art/arcane/volmlib/util/documentation/**',
|
||||
'art/arcane/iris/core/report/**',
|
||||
'art/arcane/iris/util/common/inventorygui/**',
|
||||
'art/arcane/iris/util/common/board/**',
|
||||
'art/arcane/volmlib/integration/VaultEconomy*.class'
|
||||
]
|
||||
|
||||
@@ -318,8 +321,6 @@ List<String> unusedVolmLibClasses = [
|
||||
'art/arcane/volmlib/util/director/DirectorNodeBase',
|
||||
'art/arcane/volmlib/util/director/DirectorParameterBase',
|
||||
'art/arcane/volmlib/util/director/theme/DirectorThemes',
|
||||
'art/arcane/volmlib/util/interpolation/InterpolationMethod',
|
||||
'art/arcane/volmlib/util/interpolation/IrisInterpolation',
|
||||
'art/arcane/volmlib/util/math/INode',
|
||||
'art/arcane/volmlib/util/math/IrisMathHelper',
|
||||
'art/arcane/volmlib/util/math/KochanekBartelsInterpolation',
|
||||
@@ -329,11 +330,6 @@ List<String> unusedVolmLibClasses = [
|
||||
'art/arcane/volmlib/util/board/BoardUpdateTask',
|
||||
'art/arcane/volmlib/util/bukkit/Placeholders',
|
||||
'art/arcane/volmlib/util/hunk/HunkFactory',
|
||||
'art/arcane/volmlib/util/hunk/storage/ArrayHunk',
|
||||
'art/arcane/volmlib/util/hunk/storage/AtomicDoubleHunk',
|
||||
'art/arcane/volmlib/util/hunk/storage/AtomicIntegerHunk',
|
||||
'art/arcane/volmlib/util/hunk/storage/AtomicLongHunk',
|
||||
'art/arcane/volmlib/util/hunk/storage/SynchronizedArrayHunk',
|
||||
'art/arcane/volmlib/util/hunk/view/BiomeForceBridge',
|
||||
'art/arcane/volmlib/util/hunk/view/BiomeGridForceSupport',
|
||||
'art/arcane/volmlib/util/hunk/view/BiomeGridHunkHolder',
|
||||
@@ -393,7 +389,6 @@ List<String> unusedVolmLibClasses = [
|
||||
'art/arcane/iris/engine/object/IPostBlockAccess',
|
||||
'art/arcane/iris/engine/object/IrisPotionEffect',
|
||||
'art/arcane/iris/engine/platform/DummyChunkGenerator',
|
||||
'art/arcane/iris/util/common/math/VectorMath',
|
||||
'art/arcane/iris/util/common/parallel/GridLock',
|
||||
'art/arcane/iris/util/common/reflect/W',
|
||||
'art/arcane/iris/util/slimjar/BuildConstants',
|
||||
|
||||
@@ -62,11 +62,37 @@ art/arcane/iris/core/service/BoardSVC.java
|
||||
art/arcane/iris/core/service/EntityRiseSVC.java
|
||||
art/arcane/iris/core/service/ExternalDataSVC.java
|
||||
art/arcane/iris/core/service/GlobalCacheSVC.java
|
||||
art/arcane/iris/core/service/JigsawStudioAutosaveScheduler.java
|
||||
art/arcane/iris/core/service/JigsawStudioCapture.java
|
||||
art/arcane/iris/core/service/JigsawStudioCaptureScheduler.java
|
||||
art/arcane/iris/core/service/JigsawStudioChunkWriter.java
|
||||
art/arcane/iris/core/service/JigsawStudioDetailsMenu.java
|
||||
art/arcane/iris/core/service/JigsawStudioDirtyTracker.java
|
||||
art/arcane/iris/core/service/JigsawStudioGraphMutations.java
|
||||
art/arcane/iris/core/service/JigsawStudioMainMenu.java
|
||||
art/arcane/iris/core/service/JigsawStudioMarkerParser.java
|
||||
art/arcane/iris/core/service/JigsawStudioMaterializer.java
|
||||
art/arcane/iris/core/service/JigsawStudioMenuActions.java
|
||||
art/arcane/iris/core/service/JigsawStudioMenuController.java
|
||||
art/arcane/iris/core/service/JigsawStudioMenuFormat.java
|
||||
art/arcane/iris/core/service/JigsawStudioPlayerContext.java
|
||||
art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java
|
||||
art/arcane/iris/core/service/JigsawStudioProtection.java
|
||||
art/arcane/iris/core/service/JigsawStudioProtectionListener.java
|
||||
art/arcane/iris/core/service/JigsawStudioRegistry.java
|
||||
art/arcane/iris/core/service/JigsawStudioSaveLifecycle.java
|
||||
art/arcane/iris/core/service/JigsawStudioService.java
|
||||
art/arcane/iris/core/service/JigsawStudioStructureMenu.java
|
||||
art/arcane/iris/core/service/JigsawStudioTileWatcher.java
|
||||
art/arcane/iris/core/service/JigsawStudioToolCodec.java
|
||||
art/arcane/iris/core/service/JigsawStudioToolbelt.java
|
||||
art/arcane/iris/core/service/JigsawStudioToolboxMenu.java
|
||||
art/arcane/iris/core/service/JigsawStudioVariantEditor.java
|
||||
art/arcane/iris/core/service/JigsawStudioVariantMenu.java
|
||||
art/arcane/iris/core/service/JigsawStudioVariantProperties.java
|
||||
art/arcane/iris/core/service/JigsawStudioVisualization.java
|
||||
art/arcane/iris/core/service/JigsawStudioWorkcellEditor.java
|
||||
art/arcane/iris/core/service/JigsawStudioWorkcellMenu.java
|
||||
art/arcane/iris/core/service/MultiverseSVC.java
|
||||
art/arcane/iris/core/service/ObjectSVC.java
|
||||
art/arcane/iris/core/service/ObjectStudioSaveService.java
|
||||
@@ -162,9 +188,6 @@ art/arcane/iris/util/common/director/handlers/VectorHandler.java
|
||||
art/arcane/iris/util/common/director/handlers/WorldHandler.java
|
||||
art/arcane/iris/util/common/director/specialhandlers/NullablePlayerHandler.java
|
||||
art/arcane/iris/util/common/format/C.java
|
||||
art/arcane/iris/util/common/inventorygui/UIStaticDecorator.java
|
||||
art/arcane/iris/util/common/inventorygui/Window.java
|
||||
art/arcane/iris/util/common/inventorygui/WindowResolution.java
|
||||
art/arcane/iris/util/common/math/AxisAlignedBB.java
|
||||
art/arcane/iris/util/common/math/Direction.java
|
||||
art/arcane/iris/util/common/math/VectorMath.java
|
||||
|
||||
@@ -107,6 +107,8 @@ public class IrisSettings {
|
||||
try {
|
||||
IO.writeAll(s, new JSONObject(new Gson().toJson(loaded)).toString(4));
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError("Could not rewrite " + s.getAbsolutePath()
|
||||
+ "; settings changes made in game will not survive a restart.", e);
|
||||
}
|
||||
} catch (Throwable ee) {
|
||||
// IrisLogging.reportError(ee); causes a self-reference & stackoverflow
|
||||
@@ -306,6 +308,7 @@ public class IrisSettings {
|
||||
public boolean debug = false;
|
||||
public boolean dumpMantleOnError = false;
|
||||
public boolean disableNMS = false;
|
||||
public boolean eagerRuntimeInjection = false;
|
||||
public boolean splashLogoStartup = true;
|
||||
public boolean useConsoleCustomColors = true;
|
||||
public boolean useCustomColorsIngame = true;
|
||||
|
||||
@@ -158,6 +158,16 @@ public class ServerConfigurator {
|
||||
}
|
||||
}
|
||||
|
||||
public static void resetLoadedDatapackRuntime() {
|
||||
synchronized (DATAPACK_INSTALL_LOCK) {
|
||||
loadedDatapackRuntimeReady = false;
|
||||
loadedDatapackCompilerInputFingerprint = "";
|
||||
loadedDatapackRegistryRequirements = Map.of();
|
||||
loadedDatapackRuntimeGeneration++;
|
||||
loadedDatapackRestartRequired = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static LoadedDatapackRuntimeInvalidation invalidateLoadedDatapackRuntime() {
|
||||
synchronized (DATAPACK_INSTALL_LOCK) {
|
||||
boolean wasReady = loadedDatapackRuntimeReady;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
|
||||
final class CoordinatorDirectory {
|
||||
String target;
|
||||
String pending;
|
||||
String backup;
|
||||
boolean hadTarget;
|
||||
String originalHash;
|
||||
String desiredHash;
|
||||
String originalMarkerHash;
|
||||
String desiredMarkerHash;
|
||||
String originalIdentity;
|
||||
String desiredIdentity;
|
||||
String targetRoot;
|
||||
String scratchRoot;
|
||||
String targetRootIdentity;
|
||||
String scratchRootIdentity;
|
||||
}
|
||||
+7
-8
@@ -16,12 +16,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.util.project.interpolation;
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
public enum InterpolationType {
|
||||
LINEAR,
|
||||
PARAMETRIC_2,
|
||||
PARAMETRIC_4,
|
||||
BEZIER,
|
||||
NONE
|
||||
}
|
||||
|
||||
final class CoordinatorEditable {
|
||||
String packRoot;
|
||||
String transactionId;
|
||||
String claimId;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class CoordinatorJournal {
|
||||
int schemaVersion;
|
||||
String transactionId;
|
||||
CoordinatorOperation operation;
|
||||
CoordinatorPhase phase;
|
||||
String id;
|
||||
String url;
|
||||
String versionId;
|
||||
String versionNumber;
|
||||
String sha1;
|
||||
boolean manifestAlreadyMatched;
|
||||
List<CoordinatorDirectory> directories = new ArrayList<>();
|
||||
List<CoordinatorEditable> editables = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
|
||||
enum CoordinatorOperation {
|
||||
INSTALL,
|
||||
REMOVE
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
|
||||
enum CoordinatorPhase {
|
||||
PREPARED,
|
||||
PUBLISHING,
|
||||
PUBLISHED,
|
||||
COMMITTED;
|
||||
|
||||
boolean published() {
|
||||
return this == PUBLISHED || this == COMMITTED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.core.datapack.ModrinthResolver.ResolvedDatapack;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import art.arcane.volmlib.util.io.ZipUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
final class DatapackArchive {
|
||||
static final String USER_AGENT = "VolmitSoftware/Iris (datapack-ingest)";
|
||||
|
||||
static final int MAX_REDIRECTS = 5;
|
||||
|
||||
static final int MAX_ARCHIVE_ENTRIES = 100_000;
|
||||
|
||||
static final int MAX_CACHE_FILES = 32;
|
||||
|
||||
static final long MAX_DOWNLOAD_BYTES = 256L * 1024L * 1024L;
|
||||
|
||||
static final long MAX_EXPANDED_BYTES = 1024L * 1024L * 1024L;
|
||||
|
||||
static final long MAX_ENTRY_BYTES = 256L * 1024L * 1024L;
|
||||
|
||||
static final long MAX_CACHE_BYTES = 1024L * 1024L * 1024L;
|
||||
|
||||
static final Set<String> RESERVED_IDS = Set.of("iris");
|
||||
|
||||
private DatapackArchive() {
|
||||
}
|
||||
|
||||
static File extractArchive(File zip, File stagingRoot, Entry entry) throws IOException {
|
||||
DatapackSupport.ensureScratchDirectory(stagingRoot, "datapack staging");
|
||||
File pending = new File(stagingRoot, ".pending-" + entry.id + "-" + UUID.randomUUID());
|
||||
try {
|
||||
if (!pending.mkdirs() && !pending.isDirectory()) {
|
||||
throw new IOException("Couldn't create datapack extraction directory " + pending.getPath());
|
||||
}
|
||||
ZipUtils.unzipFile(zip, pending, MAX_ARCHIVE_ENTRIES, MAX_EXPANDED_BYTES, MAX_ENTRY_BYTES);
|
||||
flattenIfWrapped(pending);
|
||||
DatapackPackMetadata.validatePackMetadata(pending);
|
||||
PackResources resources = DatapackOwnership.scanPackResources(pending);
|
||||
entry.structureKeys = resources.structureKeys();
|
||||
entry.templateKeys = resources.templateKeys();
|
||||
DatapackOwnership.writeOwnership(pending, entry);
|
||||
return pending;
|
||||
} catch (UncheckedIOException e) {
|
||||
IOException failure = e.getCause();
|
||||
cleanupFailedExtraction(pending, failure);
|
||||
throw failure;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
cleanupFailedExtraction(pending, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static void cleanupFailedExtraction(File pending, Throwable failure) {
|
||||
try {
|
||||
DatapackInstall.deleteInstallScratch(pending, "failed datapack extraction");
|
||||
} catch (IOException cleanupFailure) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
static void cleanupExtractedStaging(File extractedDir) {
|
||||
try {
|
||||
DatapackInstall.deleteInstallScratch(extractedDir, "extracted datapack staging");
|
||||
} catch (IOException e) {
|
||||
IrisLogging.warn("Preserving extracted datapack staging for restart cleanup: "
|
||||
+ extractedDir.getPath() + " (" + e.getMessage() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
static void flattenIfWrapped(File dir) throws IOException {
|
||||
if (new File(dir, "pack.mcmeta").isFile()) {
|
||||
return;
|
||||
}
|
||||
File[] children = dir.listFiles();
|
||||
if (children == null) {
|
||||
return;
|
||||
}
|
||||
File singleDir = null;
|
||||
int dirCount = 0;
|
||||
int fileCount = 0;
|
||||
for (File child : children) {
|
||||
if (child.isDirectory()) {
|
||||
dirCount++;
|
||||
singleDir = child;
|
||||
} else {
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
if (dirCount != 1 || fileCount != 0 || singleDir == null || !new File(singleDir, "pack.mcmeta").isFile()) {
|
||||
return;
|
||||
}
|
||||
File[] inner = singleDir.listFiles();
|
||||
if (inner != null) {
|
||||
for (File item : inner) {
|
||||
File moved = new File(dir, item.getName());
|
||||
if (item.renameTo(moved)) {
|
||||
continue;
|
||||
}
|
||||
if (item.isDirectory()) {
|
||||
IO.copyDirectory(item.toPath(), moved.toPath());
|
||||
} else {
|
||||
Files.copy(item.toPath(), moved.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
}
|
||||
DatapackInstall.deleteInstallScratch(singleDir, "wrapped datapack extraction");
|
||||
}
|
||||
|
||||
static DownloadResult download(String url, File dest, String etag, String lastModified) throws IOException {
|
||||
URI current = DatapackStartupValidation.parseSourceUri(url);
|
||||
if (current == null) {
|
||||
throw new IOException("Empty datapack URL");
|
||||
}
|
||||
if ("file".equalsIgnoreCase(current.getScheme())) {
|
||||
return copyLocalDatapack(current, dest);
|
||||
}
|
||||
for (int attempt = 0; attempt < MAX_REDIRECTS; attempt++) {
|
||||
if (!"http".equalsIgnoreCase(current.getScheme()) && !"https".equalsIgnoreCase(current.getScheme())) {
|
||||
throw new IOException("Datapack URL must use HTTP or HTTPS: " + current);
|
||||
}
|
||||
URL target = current.toURL();
|
||||
HttpURLConnection connection = (HttpURLConnection) target.openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setRequestProperty("User-Agent", USER_AGENT);
|
||||
if (etag != null && !etag.isBlank()) {
|
||||
connection.setRequestProperty("If-None-Match", etag);
|
||||
}
|
||||
if (lastModified != null && !lastModified.isBlank()) {
|
||||
connection.setRequestProperty("If-Modified-Since", lastModified);
|
||||
}
|
||||
connection.setConnectTimeout(20000);
|
||||
connection.setReadTimeout(60000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
try {
|
||||
int code = connection.getResponseCode();
|
||||
if (code == HttpURLConnection.HTTP_NOT_MODIFIED) {
|
||||
String responseEtag = headerOrFallback(connection, "ETag", etag);
|
||||
String responseLastModified = headerOrFallback(connection, "Last-Modified", lastModified);
|
||||
return new DownloadResult(true, responseEtag, responseLastModified);
|
||||
}
|
||||
if (code / 100 == 3) {
|
||||
String location = connection.getHeaderField("Location");
|
||||
if (location == null || location.isBlank()) {
|
||||
throw new IOException("Redirect without a location header from " + current);
|
||||
}
|
||||
try {
|
||||
current = current.resolve(new URI(location));
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IOException("Invalid redirect location from " + current + ": " + location, e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (code != 200) {
|
||||
throw new IOException("HTTP " + code + " downloading " + current);
|
||||
}
|
||||
long declaredLength = connection.getContentLengthLong();
|
||||
if (declaredLength > MAX_DOWNLOAD_BYTES) {
|
||||
throw new IOException("Datapack download exceeds " + MAX_DOWNLOAD_BYTES + " bytes");
|
||||
}
|
||||
|
||||
File parent = dest.getParentFile();
|
||||
Path parentPath = parent == null ? Path.of(".").toAbsolutePath().normalize() : parent.toPath();
|
||||
DatapackSupport.ensureScratchDirectory(parentPath.toFile(), "datapack download cache");
|
||||
Path temporary = Files.createTempFile(parentPath, dest.getName() + "-", ".part");
|
||||
try {
|
||||
long downloaded = 0;
|
||||
String responseEtag = connection.getHeaderField("ETag");
|
||||
String responseLastModified = connection.getHeaderField("Last-Modified");
|
||||
try (InputStream in = connection.getInputStream();
|
||||
OutputStream out = Files.newOutputStream(temporary)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int length;
|
||||
while ((length = in.read(buffer)) > 0) {
|
||||
downloaded += length;
|
||||
if (downloaded > MAX_DOWNLOAD_BYTES) {
|
||||
throw new IOException("Datapack download exceeds " + MAX_DOWNLOAD_BYTES + " bytes");
|
||||
}
|
||||
out.write(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
DatapackSupport.move(temporary, dest.toPath());
|
||||
return new DownloadResult(false, responseEtag, responseLastModified);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
throw new IOException("Too many redirects downloading " + url);
|
||||
}
|
||||
|
||||
static DownloadResult copyLocalDatapack(URI source, File destination) throws IOException {
|
||||
Path sourcePath = requireLocalDatapackPath(source);
|
||||
Path destinationPath = destination.toPath().toAbsolutePath().normalize();
|
||||
if (Files.exists(destinationPath, LinkOption.NOFOLLOW_LINKS)
|
||||
&& Files.isSameFile(sourcePath, destinationPath)) {
|
||||
throw new IOException("Local datapack source and cache destination are the same file: " + sourcePath);
|
||||
}
|
||||
Path parent = destinationPath.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("Local datapack cache destination has no parent: " + destinationPath);
|
||||
}
|
||||
DatapackSupport.ensureScratchDirectory(parent.toFile(), "datapack download cache");
|
||||
BasicFileAttributes before = Files.readAttributes(
|
||||
sourcePath,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
Path temporary = Files.createTempFile(parent, destination.getName() + "-", ".part");
|
||||
try {
|
||||
long copied = 0;
|
||||
try (InputStream input = Files.newInputStream(
|
||||
sourcePath,
|
||||
StandardOpenOption.READ,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
OutputStream output = Files.newOutputStream(temporary)) {
|
||||
byte[] buffer = new byte[DatapackOwnership.HASH_BUFFER_BYTES];
|
||||
int length;
|
||||
while ((length = input.read(buffer)) > 0) {
|
||||
copied += length;
|
||||
if (copied > MAX_DOWNLOAD_BYTES) {
|
||||
throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + sourcePath);
|
||||
}
|
||||
output.write(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
BasicFileAttributes after = Files.readAttributes(
|
||||
sourcePath,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
if (!after.isRegularFile()
|
||||
|| before.size() != after.size()
|
||||
|| !before.lastModifiedTime().equals(after.lastModifiedTime())
|
||||
|| !Objects.equals(before.fileKey(), after.fileKey())
|
||||
|| copied != after.size()) {
|
||||
throw new IOException("Local datapack changed while Iris was copying it: " + sourcePath);
|
||||
}
|
||||
DatapackSupport.move(temporary, destinationPath);
|
||||
return new DownloadResult(false, null, null);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
static Path requireLocalDatapackPath(URI source) throws IOException {
|
||||
if (source == null
|
||||
|| !"file".equalsIgnoreCase(source.getScheme())
|
||||
|| source.isOpaque()
|
||||
|| source.getRawAuthority() != null
|
||||
|| source.getRawQuery() != null
|
||||
|| source.getRawFragment() != null) {
|
||||
throw new IOException("Datapack file URL must be an absolute local file URI without authority, query, or fragment: " + source);
|
||||
}
|
||||
Path path;
|
||||
try {
|
||||
path = Path.of(source).toAbsolutePath().normalize();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid local datapack file URL: " + source, exception);
|
||||
}
|
||||
if (Files.isSymbolicLink(path)
|
||||
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Local datapack is not a regular non-symbolic-link file: " + path);
|
||||
}
|
||||
long size = Files.size(path);
|
||||
if (size > MAX_DOWNLOAD_BYTES) {
|
||||
throw new IOException("Local datapack exceeds " + MAX_DOWNLOAD_BYTES + " bytes: " + path);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
static String headerOrFallback(HttpURLConnection connection, String name, String fallback) {
|
||||
String value = connection.getHeaderField(name);
|
||||
return value == null || value.isBlank() ? fallback : value;
|
||||
}
|
||||
|
||||
static String sha1(File file) throws IOException {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-1");
|
||||
try (InputStream in = new FileInputStream(file)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int length;
|
||||
while ((length = in.read(buffer)) > 0) {
|
||||
digest.update(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
byte[] hash = digest.digest();
|
||||
StringBuilder builder = new StringBuilder(hash.length * 2);
|
||||
for (byte value : hash) {
|
||||
builder.append(String.format("%02x", value));
|
||||
}
|
||||
return builder.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IOException("SHA-1 algorithm unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
static String deriveId(ResolvedDatapack resolved) {
|
||||
String base = resolved.getProjectSlug();
|
||||
if (base == null || base.isBlank()) {
|
||||
base = resolved.getFileName();
|
||||
int dot = base.lastIndexOf('.');
|
||||
if (dot > 0) {
|
||||
base = base.substring(0, dot);
|
||||
}
|
||||
}
|
||||
String id = sanitizeId(base);
|
||||
if (resolved.isDirect()) {
|
||||
return id + "-" + ModrinthResolver.directIdentity(resolved.getDownloadUrl());
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
static String sanitizeId(String value) {
|
||||
if (value == null) {
|
||||
return "datapack";
|
||||
}
|
||||
String lower = value.toLowerCase(Locale.ROOT).trim();
|
||||
StringBuilder builder = new StringBuilder(lower.length());
|
||||
for (int i = 0; i < lower.length(); i++) {
|
||||
char c = lower.charAt(i);
|
||||
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.') {
|
||||
builder.append(c);
|
||||
} else if (c == ' ' || c == '/' || c == '\\') {
|
||||
builder.append('-');
|
||||
}
|
||||
}
|
||||
String cleaned = builder.toString().replaceAll("-+", "-");
|
||||
cleaned = cleaned.replaceAll("^[-_.]+", "").replaceAll("[-_.]+$", "");
|
||||
return cleaned.isBlank() ? "datapack" : cleaned;
|
||||
}
|
||||
|
||||
static boolean isValidManagedId(String value) {
|
||||
return value != null && !value.isBlank() && value.equals(sanitizeId(value))
|
||||
&& !RESERVED_IDS.contains(value);
|
||||
}
|
||||
|
||||
static void pruneCache(File cacheDir) {
|
||||
File[] files = cacheDir.listFiles(File::isFile);
|
||||
if (files == null) {
|
||||
return;
|
||||
}
|
||||
List<File> archives = new ArrayList<>();
|
||||
long totalBytes = 0;
|
||||
for (File file : files) {
|
||||
if (file.getName().endsWith(".part")) {
|
||||
IO.delete(file);
|
||||
continue;
|
||||
}
|
||||
if (file.getName().endsWith(".zip")) {
|
||||
archives.add(file);
|
||||
totalBytes += Math.max(0, file.length());
|
||||
}
|
||||
}
|
||||
archives.sort(Comparator.comparingLong(File::lastModified));
|
||||
int remaining = archives.size();
|
||||
for (File archive : archives) {
|
||||
if (remaining <= MAX_CACHE_FILES && totalBytes <= MAX_CACHE_BYTES) {
|
||||
break;
|
||||
}
|
||||
long size = Math.max(0, archive.length());
|
||||
IO.delete(archive);
|
||||
if (!archive.exists()) {
|
||||
remaining--;
|
||||
totalBytes -= size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-15
@@ -16,25 +16,26 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.util.common.inventorygui;
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.volmlib.util.inventorygui.WindowDecorator;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import art.arcane.volmlib.util.inventorygui.Element;
|
||||
final class DatapackCoordinator {
|
||||
private final Path transactionRoot;
|
||||
final CoordinatorJournal journal;
|
||||
|
||||
import art.arcane.volmlib.util.data.MaterialBlock;
|
||||
import org.bukkit.Material;
|
||||
|
||||
@SuppressWarnings("ClassCanBeRecord")
|
||||
public class UIStaticDecorator implements WindowDecorator {
|
||||
private final Element element;
|
||||
|
||||
public UIStaticDecorator(Element element) {
|
||||
this.element = element == null ? new art.arcane.volmlib.util.inventorygui.UIElement("bg").setMaterial(new MaterialBlock(Material.AIR)) : element;
|
||||
DatapackCoordinator(Path transactionRoot, CoordinatorJournal journal) {
|
||||
this.transactionRoot = transactionRoot;
|
||||
this.journal = journal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Element onDecorateBackground(art.arcane.volmlib.util.inventorygui.Window window, int position, int row) {
|
||||
return element;
|
||||
void phase(CoordinatorPhase phase) throws IOException {
|
||||
journal.phase = phase;
|
||||
DatapackTransactions.writeCoordinatorJournal(transactionRoot, journal);
|
||||
}
|
||||
|
||||
void finish() throws IOException {
|
||||
DatapackTransactions.deleteCoordinatorTransaction(transactionRoot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
final class DatapackCoordinatorResolution {
|
||||
|
||||
private DatapackCoordinatorResolution() {
|
||||
}
|
||||
|
||||
static Set<Path> authoritativeEditableRoots(
|
||||
Manifest committedManifest,
|
||||
CoordinatorJournal journal
|
||||
) throws IOException {
|
||||
Set<Path> roots = new HashSet<>();
|
||||
Entry committed = committedManifest.findById(journal.id);
|
||||
if (committed != null) {
|
||||
if (!Objects.equals(committed.url, journal.url)) {
|
||||
throw new IOException("Datapack transaction conflicts with the committed editable pack owner");
|
||||
}
|
||||
addExistingPackRoots(roots, committed.importedTargets.keySet());
|
||||
addExistingPackRoots(roots, committed.importAttempts.keySet());
|
||||
addExistingPackRoots(roots, committed.importedBundles.keySet());
|
||||
return roots;
|
||||
}
|
||||
if (journal.operation != CoordinatorOperation.REMOVE || !journal.phase.published()) {
|
||||
throw new IOException("Datapack transaction has no committed editable pack authority");
|
||||
}
|
||||
for (CoordinatorEditable editable : journal.editables) {
|
||||
Path packRoot = DatapackTransactions.coordinatorPath(editable.packRoot, "editable pack root");
|
||||
if (Files.isSymbolicLink(packRoot) || !Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid editable pack root in datapack transaction: " + packRoot);
|
||||
}
|
||||
Path realPackRoot = packRoot.toRealPath();
|
||||
if (!packRoot.equals(realPackRoot)) {
|
||||
throw new IOException("Editable pack root changed before datapack recovery: " + packRoot);
|
||||
}
|
||||
roots.add(realPackRoot);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
static void addExistingPackRoots(Set<Path> roots, Set<String> paths) throws IOException {
|
||||
for (String path : paths) {
|
||||
try {
|
||||
Path root = Path.of(path).toAbsolutePath().normalize();
|
||||
if (Files.isDirectory(root)) {
|
||||
roots.add(root.toRealPath());
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid editable pack root in the datapack manifest: " + path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void validateCoordinatorDirectory(
|
||||
CoordinatorDirectory directory,
|
||||
CoordinatorOperation operation,
|
||||
Set<Path> allowedTargets,
|
||||
Set<Path> seenTargets,
|
||||
Set<Path> seenTargetIdentities
|
||||
) throws IOException {
|
||||
if (directory == null || directory.target == null || directory.backup == null
|
||||
|| directory.originalHash == null || directory.desiredHash == null
|
||||
|| directory.originalMarkerHash == null || directory.desiredMarkerHash == null
|
||||
|| directory.originalIdentity == null || directory.desiredIdentity == null
|
||||
|| directory.targetRoot == null || directory.scratchRoot == null
|
||||
|| directory.targetRootIdentity == null || directory.scratchRootIdentity == null) {
|
||||
throw new IOException("Incomplete directory participant in datapack transaction");
|
||||
}
|
||||
Path target = DatapackTransactions.coordinatorPath(directory.target, "target");
|
||||
if (!allowedTargets.contains(target) || !seenTargets.add(target)) {
|
||||
throw new IOException("Datapack transaction target is outside its configured roots: " + target);
|
||||
}
|
||||
File targetFile = target.toFile();
|
||||
File targetParent = targetFile.getParentFile();
|
||||
Path targetRootIdentity = DatapackTransactions.coordinatorPath(directory.targetRoot, "target root");
|
||||
if (!seenTargetIdentities.add(targetRootIdentity.resolve(target.getFileName()).normalize())) {
|
||||
throw new IOException("Aliased datapack transaction target " + target);
|
||||
}
|
||||
validateRecoveryContainer(
|
||||
targetParent.toPath(),
|
||||
targetRootIdentity,
|
||||
true,
|
||||
"datapack target root"
|
||||
);
|
||||
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
|
||||
targetParent, directory.targetRoot, directory.targetRootIdentity, "datapack target root");
|
||||
File scratchRoot = new File(
|
||||
targetParent.getParentFile() == null ? targetParent : targetParent.getParentFile(),
|
||||
operation == CoordinatorOperation.INSTALL ? ".iris-datapack-install" : ".iris-datapack-remove"
|
||||
);
|
||||
Path expectedScratch = scratchRoot.toPath().toAbsolutePath().normalize();
|
||||
Path backup = DatapackTransactions.coordinatorPath(directory.backup, "backup");
|
||||
if (!Objects.equals(backup.getParent(), expectedScratch) || Files.isSymbolicLink(backup)) {
|
||||
throw new IOException("Invalid datapack transaction backup path " + backup);
|
||||
}
|
||||
Path pending = null;
|
||||
if (operation == CoordinatorOperation.INSTALL) {
|
||||
if (directory.pending == null || directory.pending.isBlank()) {
|
||||
throw new IOException("Install transaction is missing its pending directory");
|
||||
}
|
||||
pending = DatapackTransactions.coordinatorPath(directory.pending, "pending directory");
|
||||
if (!Objects.equals(pending.getParent(), expectedScratch) || Files.isSymbolicLink(pending)) {
|
||||
throw new IOException("Invalid datapack transaction pending path " + pending);
|
||||
}
|
||||
} else if (directory.pending != null && !directory.pending.isBlank()) {
|
||||
throw new IOException("Removal transaction unexpectedly contains a pending directory");
|
||||
}
|
||||
boolean scratchRequired = Files.exists(backup, LinkOption.NOFOLLOW_LINKS)
|
||||
|| pending != null && Files.exists(pending, LinkOption.NOFOLLOW_LINKS);
|
||||
validateRecoveryContainer(
|
||||
expectedScratch,
|
||||
DatapackTransactions.coordinatorPath(directory.scratchRoot, "scratch root"),
|
||||
scratchRequired,
|
||||
"datapack transaction scratch root"
|
||||
);
|
||||
if (scratchRequired) {
|
||||
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
|
||||
expectedScratch.toFile(), directory.scratchRoot,
|
||||
directory.scratchRootIdentity, "datapack transaction scratch root");
|
||||
}
|
||||
}
|
||||
|
||||
static void validateRecoveryContainer(
|
||||
Path container,
|
||||
Path expectedRealPath,
|
||||
boolean required,
|
||||
String purpose
|
||||
) throws IOException {
|
||||
Path normalized = container.toAbsolutePath().normalize();
|
||||
Path normalizedExpected = expectedRealPath.toAbsolutePath().normalize();
|
||||
if (!Files.exists(normalized, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (required) {
|
||||
throw new IOException("Missing " + purpose + " " + normalized);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Files.isSymbolicLink(normalized)
|
||||
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)
|
||||
|| !Objects.equals(normalized.toRealPath(), normalizedExpected)) {
|
||||
throw new IOException("Changed or unsafe " + purpose + " " + normalized);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean coordinatorCommitDecision(Manifest manifest, CoordinatorJournal journal) throws IOException {
|
||||
Entry committed = manifest.findById(journal.id);
|
||||
if (journal.operation == CoordinatorOperation.REMOVE) {
|
||||
if (committed == null) {
|
||||
if (!journal.phase.published()) {
|
||||
throw new IOException("Datapack removal manifest changed before every participant was published");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!Objects.equals(committed.url, journal.url) || journal.phase == CoordinatorPhase.COMMITTED) {
|
||||
throw new IOException("Datapack removal journal conflicts with the committed manifest");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean matches = committed != null
|
||||
&& Objects.equals(committed.url, journal.url)
|
||||
&& Objects.equals(committed.versionId, journal.versionId)
|
||||
&& Objects.equals(committed.versionNumber, journal.versionNumber)
|
||||
&& Objects.equals(committed.sha1, journal.sha1);
|
||||
if (journal.manifestAlreadyMatched) {
|
||||
if (!matches) {
|
||||
throw new IOException("Committed datapack changed while an install transaction was incomplete");
|
||||
}
|
||||
return journal.phase.published();
|
||||
}
|
||||
if (matches) {
|
||||
if (!journal.phase.published()) {
|
||||
throw new IOException("Datapack install manifest changed before every target was published");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (journal.phase == CoordinatorPhase.COMMITTED) {
|
||||
throw new IOException("Committed datapack install journal conflicts with the manifest");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void resolveCoordinatorDirectories(CoordinatorJournal journal, boolean commit) throws IOException {
|
||||
List<CoordinatorDirectory> directories = new ArrayList<>(journal.directories);
|
||||
if (!commit) {
|
||||
Collections.reverse(directories);
|
||||
}
|
||||
IOException failure = null;
|
||||
for (CoordinatorDirectory directory : directories) {
|
||||
try {
|
||||
if (journal.operation == CoordinatorOperation.INSTALL) {
|
||||
resolveInstallDirectory(journal, directory, commit);
|
||||
} else {
|
||||
resolveRemovalDirectory(directory, commit);
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
IOException participantFailure = e instanceof IOException ioFailure
|
||||
? ioFailure : new IOException("Failed resolving datapack directory participant", e);
|
||||
if (failure == null) {
|
||||
failure = participantFailure;
|
||||
} else {
|
||||
failure.addSuppressed(participantFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
static void resolveInstallDirectory(
|
||||
CoordinatorJournal journal,
|
||||
CoordinatorDirectory directory,
|
||||
boolean commit
|
||||
) throws IOException {
|
||||
File target = new File(directory.target);
|
||||
File pending = new File(directory.pending);
|
||||
File backup = new File(directory.backup);
|
||||
File targetRoot = new File(directory.targetRoot);
|
||||
if (commit) {
|
||||
verifyDesiredDirectory(
|
||||
target, targetRoot, journal, directory.desiredHash,
|
||||
directory.desiredMarkerHash, directory.desiredIdentity);
|
||||
deleteOriginalBackupIfPresent(
|
||||
backup, targetRoot, directory.originalHash,
|
||||
directory.originalMarkerHash, directory.originalIdentity);
|
||||
deleteDesiredDirectoryIfPresent(
|
||||
pending, targetRoot, journal, directory.desiredHash,
|
||||
directory.desiredMarkerHash, directory.desiredIdentity);
|
||||
cleanupScratchParent(backup);
|
||||
return;
|
||||
}
|
||||
|
||||
if (directory.hadTarget) {
|
||||
if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
DatapackInstallPlanner.verifyDirectorySnapshot(
|
||||
backup, targetRoot, directory.originalHash,
|
||||
directory.originalMarkerHash, directory.originalIdentity,
|
||||
"datapack install backup");
|
||||
deleteDesiredDirectoryIfPresent(
|
||||
target, targetRoot, journal, directory.desiredHash,
|
||||
directory.desiredMarkerHash, directory.desiredIdentity);
|
||||
DatapackSupport.moveNew(backup.toPath(), target.toPath());
|
||||
DatapackSupport.forceDirectoryIfSupported(target.getParentFile().toPath());
|
||||
DatapackSupport.forceDirectoryIfSupported(backup.getParentFile().toPath());
|
||||
} else {
|
||||
DatapackInstallPlanner.verifyDirectorySnapshot(
|
||||
target, targetRoot, directory.originalHash,
|
||||
directory.originalMarkerHash, directory.originalIdentity,
|
||||
"original datapack target");
|
||||
}
|
||||
} else {
|
||||
if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Unexpected backup for newly-installed datapack " + backup.getPath());
|
||||
}
|
||||
deleteDesiredDirectoryIfPresent(
|
||||
target, targetRoot, journal, directory.desiredHash,
|
||||
directory.desiredMarkerHash, directory.desiredIdentity);
|
||||
}
|
||||
deleteDesiredDirectoryIfPresent(
|
||||
pending, targetRoot, journal, directory.desiredHash,
|
||||
directory.desiredMarkerHash, directory.desiredIdentity);
|
||||
cleanupScratchParent(backup);
|
||||
}
|
||||
|
||||
static void resolveRemovalDirectory(CoordinatorDirectory directory, boolean commit) throws IOException {
|
||||
File target = new File(directory.target);
|
||||
File backup = new File(directory.backup);
|
||||
File targetRoot = new File(directory.targetRoot);
|
||||
if (commit) {
|
||||
if (Files.exists(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Removed datapack target reappeared before transaction cleanup: " + target.getPath());
|
||||
}
|
||||
deleteOriginalBackupIfPresent(
|
||||
backup, targetRoot, directory.originalHash,
|
||||
directory.originalMarkerHash, directory.originalIdentity);
|
||||
cleanupScratchParent(backup);
|
||||
return;
|
||||
}
|
||||
if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
DatapackInstallPlanner.verifyDirectorySnapshot(
|
||||
backup, targetRoot, directory.originalHash,
|
||||
directory.originalMarkerHash, directory.originalIdentity,
|
||||
"datapack removal backup");
|
||||
if (Files.exists(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Datapack removal target was concurrently recreated: " + target.getPath());
|
||||
}
|
||||
DatapackSupport.moveNew(backup.toPath(), target.toPath());
|
||||
DatapackSupport.forceDirectoryIfSupported(target.getParentFile().toPath());
|
||||
DatapackSupport.forceDirectoryIfSupported(backup.getParentFile().toPath());
|
||||
} else {
|
||||
DatapackInstallPlanner.verifyDirectorySnapshot(
|
||||
target, targetRoot, directory.originalHash,
|
||||
directory.originalMarkerHash, directory.originalIdentity,
|
||||
"original datapack target");
|
||||
}
|
||||
cleanupScratchParent(backup);
|
||||
}
|
||||
|
||||
static void resolveCoordinatorEditables(
|
||||
CoordinatorJournal journal,
|
||||
Path transactionRoot,
|
||||
boolean commit
|
||||
) throws IOException {
|
||||
IOException failure = null;
|
||||
for (CoordinatorEditable editable : journal.editables) {
|
||||
try {
|
||||
Path packRoot = DatapackTransactions.coordinatorPath(editable.packRoot, "editable pack root");
|
||||
StructureTransactionWriter writer = new StructureTransactionWriter(packRoot);
|
||||
writer.resolvePreparedRemoval(
|
||||
new StructureTransactionWriter.PreparedRemovalToken(
|
||||
packRoot,
|
||||
UUID.fromString(editable.transactionId)
|
||||
),
|
||||
new StructureTransactionWriter.RecoveryOwner(
|
||||
transactionRoot,
|
||||
UUID.fromString(journal.transactionId),
|
||||
UUID.fromString(editable.claimId)
|
||||
),
|
||||
commit
|
||||
);
|
||||
IrisData.invalidateLoadedStructureResources(packRoot.toFile());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
IOException participantFailure = e instanceof IOException ioFailure
|
||||
? ioFailure : new IOException("Failed resolving editable structure participant", e);
|
||||
if (failure == null) {
|
||||
failure = participantFailure;
|
||||
} else {
|
||||
failure.addSuppressed(participantFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
static void verifyDesiredDirectory(
|
||||
File directory,
|
||||
File targetRoot,
|
||||
CoordinatorJournal journal,
|
||||
String expectedHash,
|
||||
String expectedMarkerHash,
|
||||
String expectedIdentity
|
||||
) throws IOException {
|
||||
DatapackInstallPlanner.verifyDirectorySnapshot(
|
||||
directory, targetRoot, expectedHash, expectedMarkerHash,
|
||||
expectedIdentity, "installed datapack target");
|
||||
Ownership ownership = DatapackOwnership.readOwnership(directory);
|
||||
if (!Objects.equals(ownership.id, journal.id) || !Objects.equals(ownership.url, journal.url)
|
||||
|| !Objects.equals(ownership.contentHash, expectedHash)) {
|
||||
throw new IOException("Installed datapack ownership does not match its transaction at " + directory.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
static void deleteDesiredDirectoryIfPresent(
|
||||
File directory,
|
||||
File targetRoot,
|
||||
CoordinatorJournal journal,
|
||||
String expectedHash,
|
||||
String expectedMarkerHash,
|
||||
String expectedIdentity
|
||||
) throws IOException {
|
||||
if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
verifyDesiredDirectory(
|
||||
directory, targetRoot, journal, expectedHash,
|
||||
expectedMarkerHash, expectedIdentity);
|
||||
deleteVerifiedDirectory(directory);
|
||||
}
|
||||
|
||||
static void deleteOriginalBackupIfPresent(
|
||||
File backup,
|
||||
File targetRoot,
|
||||
String expectedHash,
|
||||
String expectedMarkerHash,
|
||||
String expectedIdentity
|
||||
) throws IOException {
|
||||
if (!Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
DatapackInstallPlanner.verifyDirectorySnapshot(
|
||||
backup, targetRoot, expectedHash, expectedMarkerHash,
|
||||
expectedIdentity, "datapack transaction backup");
|
||||
deleteVerifiedDirectory(backup);
|
||||
}
|
||||
|
||||
static void deleteVerifiedDirectory(File directory) throws IOException {
|
||||
File parent = Objects.requireNonNull(directory.getParentFile(), "datapack transaction parent");
|
||||
DatapackInstall.validateInstallTree(directory, parent, "Datapack transaction directory");
|
||||
IO.delete(directory);
|
||||
if (Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Could not delete datapack transaction directory " + directory.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
static void cleanupScratchParent(File participant) {
|
||||
File parent = participant.getParentFile();
|
||||
if (parent != null) {
|
||||
parent.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
final class DatapackImportSources {
|
||||
static final String LOCAL_IMPORT_DIRECTORY = "imports";
|
||||
|
||||
private DatapackImportSources() {
|
||||
}
|
||||
|
||||
static Set<String> mergeConfiguredImports(
|
||||
Iterable<String> explicit,
|
||||
Iterable<String> discovered
|
||||
) {
|
||||
LinkedHashSet<String> sources = new LinkedHashSet<>();
|
||||
if (explicit != null) {
|
||||
addImports(explicit, sources);
|
||||
}
|
||||
if (discovered != null) {
|
||||
addImports(discovered, sources);
|
||||
}
|
||||
return sources.isEmpty() ? Set.of() : Set.copyOf(sources);
|
||||
}
|
||||
|
||||
static List<String> discoverLocalDatapackImports(File directory) throws IOException {
|
||||
Path root = directory.toPath().toAbsolutePath().normalize();
|
||||
if (Files.notExists(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectories(root);
|
||||
}
|
||||
if (Files.isSymbolicLink(root)
|
||||
|| !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Local datapack import path is not a safe directory: " + root);
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(root)) {
|
||||
return stream
|
||||
.filter(path -> !Files.isSymbolicLink(path))
|
||||
.filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS))
|
||||
.filter(DatapackImportSources::isLocalDatapackArchive)
|
||||
.sorted(Comparator.comparing(
|
||||
(Path path) -> path.getFileName().toString(),
|
||||
String.CASE_INSENSITIVE_ORDER)
|
||||
.thenComparing((Path path) -> path.getFileName().toString()))
|
||||
.map(path -> path.toAbsolutePath().normalize().toUri().toASCIIString())
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isLocalDatapackArchive(Path path) {
|
||||
String name = path.getFileName().toString();
|
||||
return !name.startsWith(".") && name.toLowerCase(Locale.ROOT).endsWith(".zip");
|
||||
}
|
||||
|
||||
static List<String> localDatapackImports() {
|
||||
File directory = IrisPlatforms.get().dataFolder("datapacks", LOCAL_IMPORT_DIRECTORY);
|
||||
try {
|
||||
return discoverLocalDatapackImports(directory);
|
||||
} catch (IOException exception) {
|
||||
IrisLogging.reportError("Could not discover local datapack imports under "
|
||||
+ directory.getPath() + ".", exception);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
static void collectImports(IrisData data, LinkedHashSet<String> urls) {
|
||||
if (data == null || data.getDimensionLoader() == null) {
|
||||
return;
|
||||
}
|
||||
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
|
||||
if (dimension == null) {
|
||||
continue;
|
||||
}
|
||||
KList<String> imports = dimension.getDatapackImports();
|
||||
if (imports == null) {
|
||||
continue;
|
||||
}
|
||||
addImports(imports, urls);
|
||||
}
|
||||
}
|
||||
|
||||
static void addImports(Iterable<String> imports, LinkedHashSet<String> sources) {
|
||||
for (String source : imports) {
|
||||
if (source != null && !source.isBlank()) {
|
||||
sources.add(normalizeConfiguredSource(source));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String normalizeConfiguredSource(String source) {
|
||||
String normalized = source.trim();
|
||||
try {
|
||||
URI uri = new URI(normalized);
|
||||
if ("file".equalsIgnoreCase(uri.getScheme())) {
|
||||
return Path.of(uri).toAbsolutePath().normalize().toUri().toASCIIString();
|
||||
}
|
||||
} catch (IllegalArgumentException | URISyntaxException ignored) {
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static Set<String> configuredImports(IrisData data) {
|
||||
LinkedHashSet<String> urls = new LinkedHashSet<>();
|
||||
collectImports(data, urls);
|
||||
urls.addAll(localDatapackImports());
|
||||
return urls;
|
||||
}
|
||||
|
||||
static boolean hasImports(IrisData data) {
|
||||
return !configuredImports(data).isEmpty();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
final class DatapackInstall {
|
||||
static final int MAX_SCRATCH_DELETE_ATTEMPTS = 3;
|
||||
|
||||
private DatapackInstall() {
|
||||
}
|
||||
|
||||
static InstallResult install(File stagedDir, KList<File> worldFolders, Entry entry, boolean stripOverrides) throws IOException {
|
||||
File root = inferDatapackRoot(stagedDir);
|
||||
DatapackScratchRecovery.recoverTransactions(root, worldFolders);
|
||||
InstallExecution execution = prepareInstallExecution(
|
||||
stagedDir,
|
||||
worldFolders,
|
||||
entry,
|
||||
stripOverrides,
|
||||
root
|
||||
);
|
||||
try {
|
||||
verifyInstallExecution(execution);
|
||||
} catch (IOException failure) {
|
||||
rollbackInstallExecutions(List.of(execution), failure);
|
||||
throw failure;
|
||||
}
|
||||
finishInstallExecution(execution);
|
||||
return execution.result();
|
||||
}
|
||||
|
||||
static InstallExecution prepareInstallExecution(
|
||||
File stagedDir,
|
||||
KList<File> worldFolders,
|
||||
Entry entry,
|
||||
boolean stripOverrides,
|
||||
File root
|
||||
) throws IOException {
|
||||
return prepareInstallExecution(
|
||||
stagedDir, worldFolders, entry, stripOverrides, root, null);
|
||||
}
|
||||
|
||||
static InstallExecution prepareInstallExecution(
|
||||
File stagedDir,
|
||||
KList<File> worldFolders,
|
||||
Entry entry,
|
||||
boolean stripOverrides,
|
||||
File root,
|
||||
VerifiedStagingInstall verifiedStagingInstall
|
||||
) throws IOException {
|
||||
DatapackOwnership.validateManagedDirectory(stagedDir, entry.id);
|
||||
Ownership stagedOwnership = DatapackOwnership.readOwnership(stagedDir);
|
||||
String stagedHash = DatapackOwnership.directoryHash(stagedDir);
|
||||
if (!ownershipMetadataMatches(stagedOwnership, entry, stagedHash)) {
|
||||
throw new IOException("Iris datapack staging does not match the committed manifest entry for " + entry.id);
|
||||
}
|
||||
DatapackStagingGuard.validateInstallParticipants(worldFolders, verifiedStagingInstall, entry);
|
||||
List<InstallPlan> plans = new ArrayList<>();
|
||||
try {
|
||||
for (File worldFolder : worldFolders) {
|
||||
plans.add(DatapackInstallPlanner.prepareInstall(
|
||||
stagedDir, worldFolder, entry, stagedHash, stripOverrides, verifiedStagingInstall));
|
||||
}
|
||||
if (verifiedStagingInstall != null) {
|
||||
plans.add(DatapackInstallPlanner.prepareInstall(
|
||||
stagedDir,
|
||||
verifiedStagingInstall.stagingRoot().toFile(),
|
||||
entry,
|
||||
stagedHash,
|
||||
false,
|
||||
verifiedStagingInstall));
|
||||
}
|
||||
} catch (IOException | RuntimeException preparationFailure) {
|
||||
for (InstallPlan plan : plans) {
|
||||
try {
|
||||
DatapackInstallPlanner.cleanupInstallPlan(plan, false);
|
||||
} catch (IOException cleanupFailure) {
|
||||
preparationFailure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
if (preparationFailure instanceof UncheckedIOException unchecked) {
|
||||
throw unchecked.getCause();
|
||||
}
|
||||
if (preparationFailure instanceof IOException ioFailure) {
|
||||
throw ioFailure;
|
||||
}
|
||||
throw preparationFailure;
|
||||
}
|
||||
|
||||
boolean changed = false;
|
||||
List<InstallPlan> publishPlans = new ArrayList<>();
|
||||
List<InstallPlan> unchangedPlans = new ArrayList<>();
|
||||
try {
|
||||
for (InstallPlan plan : plans) {
|
||||
changed |= plan.contentChanged();
|
||||
if (plan.publishRequired()) {
|
||||
publishPlans.add(plan);
|
||||
} else {
|
||||
unchangedPlans.add(plan);
|
||||
DatapackInstallPlanner.cleanupInstallPlan(plan, true);
|
||||
}
|
||||
}
|
||||
} catch (IOException cleanupFailure) {
|
||||
for (InstallPlan plan : plans) {
|
||||
try {
|
||||
DatapackInstallPlanner.cleanupInstallPlan(plan, false);
|
||||
} catch (IOException additionalFailure) {
|
||||
cleanupFailure.addSuppressed(additionalFailure);
|
||||
}
|
||||
}
|
||||
throw cleanupFailure;
|
||||
}
|
||||
if (publishPlans.isEmpty()) {
|
||||
return new InstallExecution(
|
||||
new InstallResult(changed),
|
||||
null,
|
||||
stagedDir,
|
||||
entry,
|
||||
stagedHash,
|
||||
verifiedStagingInstall == null,
|
||||
publishPlans,
|
||||
unchangedPlans);
|
||||
}
|
||||
|
||||
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(root);
|
||||
boolean manifestAlreadyMatched = manifestEntryMatches(committedManifest.findById(entry.id), entry);
|
||||
DatapackCoordinator coordinator;
|
||||
try {
|
||||
coordinator = DatapackTransactions.createInstallCoordinator(root, entry, publishPlans, manifestAlreadyMatched);
|
||||
} catch (IOException | RuntimeException coordinatorFailure) {
|
||||
for (InstallPlan plan : publishPlans) {
|
||||
try {
|
||||
DatapackInstallPlanner.cleanupInstallPlan(plan, false);
|
||||
} catch (IOException cleanupFailure) {
|
||||
coordinatorFailure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
if (coordinatorFailure instanceof IOException ioFailure) {
|
||||
throw ioFailure;
|
||||
}
|
||||
throw coordinatorFailure;
|
||||
}
|
||||
|
||||
try {
|
||||
coordinator.phase(CoordinatorPhase.PUBLISHING);
|
||||
for (InstallPlan plan : publishPlans) {
|
||||
DatapackInstallPlanner.publishInstallPlan(plan);
|
||||
}
|
||||
coordinator.phase(CoordinatorPhase.PUBLISHED);
|
||||
} catch (IOException | RuntimeException publishFailure) {
|
||||
try {
|
||||
DatapackCoordinatorResolution.resolveCoordinatorDirectories(coordinator.journal, false);
|
||||
coordinator.finish();
|
||||
} catch (IOException rollbackFailure) {
|
||||
publishFailure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
if (publishFailure instanceof IOException ioFailure) {
|
||||
throw ioFailure;
|
||||
}
|
||||
throw publishFailure;
|
||||
}
|
||||
return new InstallExecution(
|
||||
new InstallResult(changed),
|
||||
coordinator,
|
||||
stagedDir,
|
||||
entry,
|
||||
stagedHash,
|
||||
verifiedStagingInstall == null,
|
||||
publishPlans,
|
||||
unchangedPlans);
|
||||
}
|
||||
|
||||
static void verifyInstallExecution(InstallExecution execution) throws IOException {
|
||||
if (execution.verified()) {
|
||||
return;
|
||||
}
|
||||
if (execution.verifyStagedSource()) {
|
||||
DatapackOwnership.validateManagedDirectory(execution.stagedDir(), execution.entry().id);
|
||||
Ownership stagedOwnership = DatapackOwnership.readOwnership(execution.stagedDir());
|
||||
String stagedHash = DatapackOwnership.directoryHash(execution.stagedDir());
|
||||
if (!Objects.equals(stagedHash, execution.stagedHash())
|
||||
|| !ownershipMetadataMatches(stagedOwnership, execution.entry(), stagedHash)) {
|
||||
throw new IOException("Iris datapack staging changed before installation commit for "
|
||||
+ execution.entry().id);
|
||||
}
|
||||
}
|
||||
for (InstallPlan plan : execution.publishedPlans()) {
|
||||
DatapackInstallPlanner.verifyDesiredInstallSnapshot(plan.target(), plan, "published datapack target");
|
||||
}
|
||||
for (InstallPlan plan : execution.unchangedPlans()) {
|
||||
DatapackInstallPlanner.verifyOriginalInstallSnapshot(plan.target(), plan, "unchanged datapack target");
|
||||
}
|
||||
execution.markVerified();
|
||||
}
|
||||
|
||||
static void finishInstallExecution(InstallExecution execution) throws IOException {
|
||||
if (!execution.verified()) {
|
||||
throw new IOException("Datapack install cannot commit before final verification");
|
||||
}
|
||||
if (execution.coordinator() == null) {
|
||||
return;
|
||||
}
|
||||
execution.coordinator().phase(CoordinatorPhase.COMMITTED);
|
||||
DatapackCoordinatorResolution.resolveCoordinatorDirectories(execution.coordinator().journal, true);
|
||||
execution.coordinator().finish();
|
||||
}
|
||||
|
||||
static void rollbackInstallExecutions(List<InstallExecution> executions, Throwable failure) {
|
||||
List<InstallExecution> reversed = new ArrayList<>(executions);
|
||||
Collections.reverse(reversed);
|
||||
for (InstallExecution execution : reversed) {
|
||||
if (execution.coordinator() == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
DatapackCoordinatorResolution.resolveCoordinatorDirectories(execution.coordinator().journal, false);
|
||||
execution.coordinator().finish();
|
||||
} catch (IOException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static File inferDatapackRoot(File stagedDir) {
|
||||
File parent = stagedDir.getParentFile();
|
||||
if (parent != null && "staging".equals(parent.getName()) && parent.getParentFile() != null) {
|
||||
return parent.getParentFile();
|
||||
}
|
||||
return parent == null ? stagedDir : parent;
|
||||
}
|
||||
|
||||
static boolean manifestEntryMatches(Entry committed, Entry desired) {
|
||||
return committed != null
|
||||
&& Objects.equals(committed.id, desired.id)
|
||||
&& Objects.equals(committed.url, desired.url)
|
||||
&& Objects.equals(committed.versionId, desired.versionId)
|
||||
&& Objects.equals(committed.versionNumber, desired.versionNumber)
|
||||
&& Objects.equals(committed.sha1, desired.sha1);
|
||||
}
|
||||
|
||||
static void validateInstallTree(File directory, File storeAnchor, String purpose) throws IOException {
|
||||
DatapackScratchRecovery.validateScratchTree(directory.toPath());
|
||||
if (!DatapackScratchRecovery.sameScratchVolume(directory.toPath(), storeAnchor.toPath())) {
|
||||
throw new IOException(purpose + " crosses a filesystem boundary at " + directory.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
static File installScratchRoot(File targetFolder) {
|
||||
File parent = targetFolder.getParentFile();
|
||||
return new File(parent == null ? targetFolder : parent, ".iris-datapack-install");
|
||||
}
|
||||
|
||||
static void cleanupPreparedInstall(File pending, File pendingRoot, Throwable failure) {
|
||||
try {
|
||||
deleteInstallScratch(pending, "prepared datapack install");
|
||||
} catch (IOException cleanupFailure) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
pendingRoot.delete();
|
||||
}
|
||||
|
||||
static boolean ownershipMetadataMatches(Ownership ownership, Entry entry, String contentHash) {
|
||||
return ownership.schemaVersion == DatapackOwnership.OWNERSHIP_SCHEMA
|
||||
&& Objects.equals(ownership.id, entry.id)
|
||||
&& Objects.equals(ownership.url, entry.url)
|
||||
&& Objects.equals(ownership.versionId, entry.versionId)
|
||||
&& Objects.equals(ownership.versionNumber, entry.versionNumber)
|
||||
&& Objects.equals(ownership.sha1, entry.sha1)
|
||||
&& Objects.equals(ownership.contentHash, contentHash)
|
||||
&& DatapackManifestStore.copyList(ownership.structureKeys).equals(DatapackManifestStore.copyList(entry.structureKeys))
|
||||
&& DatapackManifestStore.copyList(ownership.templateKeys).equals(DatapackManifestStore.copyList(entry.templateKeys));
|
||||
}
|
||||
|
||||
static boolean ownershipSourceMatches(Ownership ownership, Entry entry) {
|
||||
return ownership.schemaVersion == DatapackOwnership.OWNERSHIP_SCHEMA
|
||||
&& Objects.equals(ownership.id, entry.id)
|
||||
&& Objects.equals(ownership.url, entry.url);
|
||||
}
|
||||
|
||||
static void deleteInstallScratch(File scratch, String purpose) throws IOException {
|
||||
Path scratchPath = scratch.toPath();
|
||||
File parent = Objects.requireNonNull(scratch.getParentFile(), "datapack scratch parent");
|
||||
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);
|
||||
DatapackOwnership.removeFinderMetadata(scratch);
|
||||
IO.delete(scratch);
|
||||
if (Files.notExists(scratchPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new IOException("Could not remove " + purpose + " " + scratch.getPath());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
final class DatapackInstallPlanner {
|
||||
|
||||
private DatapackInstallPlanner() {
|
||||
}
|
||||
|
||||
static InstallPlan prepareInstall(
|
||||
File stagedDir,
|
||||
File worldFolder,
|
||||
Entry entry,
|
||||
String stagedHash,
|
||||
boolean stripOverrides,
|
||||
VerifiedStagingInstall verifiedStagingInstall
|
||||
) throws IOException {
|
||||
DatapackStagingGuard.ensureInstallTargetRoot(worldFolder);
|
||||
File target = new File(worldFolder, entry.id);
|
||||
InstallPlan unchanged = tryPrepareUnchangedManagedInstall(
|
||||
stagedDir,
|
||||
worldFolder,
|
||||
target,
|
||||
entry,
|
||||
stagedHash,
|
||||
stripOverrides,
|
||||
verifiedStagingInstall);
|
||||
if (unchanged != null) {
|
||||
return unchanged;
|
||||
}
|
||||
boolean canonicalStagingInstall = verifiedStagingInstall != null
|
||||
&& verifiedStagingInstall.isCanonicalInstall(worldFolder, target);
|
||||
boolean legacyReplacementAuthorized = canonicalStagingInstall
|
||||
&& verifiedStagingInstall.consume(stagedDir, worldFolder, target, entry, stagedHash);
|
||||
VerifiedStagingInstall legacyWorldAuthorization = null;
|
||||
File pendingRoot = DatapackInstall.installScratchRoot(worldFolder);
|
||||
File pending = new File(pendingRoot, entry.id + "-" + UUID.randomUUID());
|
||||
File backup = new File(pendingRoot, entry.id + "-backup-" + UUID.randomUUID());
|
||||
try {
|
||||
DatapackSupport.ensureScratchDirectory(pendingRoot, "datapack install staging");
|
||||
String targetRootIdentity = DatapackSupport.realDirectoryPath(worldFolder, "datapack target root");
|
||||
String scratchRootIdentity = DatapackSupport.realDirectoryPath(pendingRoot, "datapack install scratch root");
|
||||
String targetRootFileIdentity = DatapackSupport.directoryIdentity(worldFolder);
|
||||
String scratchRootFileIdentity = DatapackSupport.directoryIdentity(pendingRoot);
|
||||
IO.copyDirectory(stagedDir.toPath(), pending.toPath());
|
||||
Files.deleteIfExists(new File(pending, DatapackOwnership.OWNERSHIP_MARKER).toPath());
|
||||
String copiedHash = DatapackOwnership.directoryHash(pending);
|
||||
if (!Objects.equals(stagedHash, copiedHash)) {
|
||||
throw new IOException("Datapack staging changed or copied incompletely while preparing " + entry.id);
|
||||
}
|
||||
boolean removedOverrideMarker = Files.deleteIfExists(
|
||||
new File(pending, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER).toPath());
|
||||
if (stripOverrides) {
|
||||
DatapackPackMetadata.stripVanillaStructureOverrides(pending);
|
||||
DatapackPackMetadata.writeMarker(new File(pending, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER));
|
||||
}
|
||||
DatapackPackMetadata.validatePackMetadata(pending);
|
||||
if (!stripOverrides && !removedOverrideMarker) {
|
||||
DatapackOwnership.writeOwnership(pending, entry, copiedHash);
|
||||
} else {
|
||||
DatapackOwnership.writeOwnership(pending, entry);
|
||||
}
|
||||
DatapackInstall.validateInstallTree(pending, worldFolder, "Prepared datapack install");
|
||||
Ownership desiredOwnership = DatapackOwnership.readOwnership(pending);
|
||||
String desiredHash = desiredOwnership.contentHash;
|
||||
String desiredMarkerHash = DatapackOwnership.ownershipMarkerFingerprint(pending);
|
||||
String desiredIdentity = DatapackSupport.directoryIdentity(pending);
|
||||
boolean hadTarget = DatapackSupport.pathExists(target.toPath(), "datapack install target");
|
||||
String originalHash = "";
|
||||
String originalMarkerHash = "absent";
|
||||
String originalIdentity = "";
|
||||
boolean contentChanged = !hadTarget;
|
||||
boolean publishRequired = !hadTarget;
|
||||
if (hadTarget) {
|
||||
if (Files.isSymbolicLink(target.toPath())
|
||||
|| !Files.isDirectory(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Refusing to replace non-directory or symbolic-link datapack " + target.getPath());
|
||||
}
|
||||
DatapackInstall.validateInstallTree(target, worldFolder, "Existing datapack install");
|
||||
Ownership ownership = DatapackOwnership.readOwnershipOrNull(target);
|
||||
if (ownership != null) {
|
||||
DatapackOwnership.removeFinderMetadata(target);
|
||||
}
|
||||
String currentHash = DatapackOwnership.directoryHash(target);
|
||||
originalHash = currentHash;
|
||||
originalMarkerHash = DatapackOwnership.ownershipMarkerFingerprint(target);
|
||||
originalIdentity = DatapackSupport.directoryIdentity(target);
|
||||
if (ownership == null) {
|
||||
boolean differingLegacyTarget = !Objects.equals(currentHash, desiredHash);
|
||||
if (differingLegacyTarget && !legacyReplacementAuthorized
|
||||
&& verifiedStagingInstall != null
|
||||
&& verifiedStagingInstall.authorizeLegacyWorldReplacement(
|
||||
stagedDir,
|
||||
worldFolder,
|
||||
target,
|
||||
entry,
|
||||
stagedHash,
|
||||
currentHash,
|
||||
originalMarkerHash)) {
|
||||
legacyReplacementAuthorized = true;
|
||||
legacyWorldAuthorization = verifiedStagingInstall;
|
||||
}
|
||||
if (differingLegacyTarget && !legacyReplacementAuthorized) {
|
||||
throw new IOException("Refusing to replace unmanaged datapack at " + target.getPath());
|
||||
}
|
||||
if (differingLegacyTarget && (originalIdentity.isEmpty()
|
||||
|| targetRootFileIdentity.isEmpty()
|
||||
|| scratchRootFileIdentity.isEmpty()
|
||||
|| verifiedStagingInstall == null
|
||||
|| !verifiedStagingInstall.hasStablePathIdentities())) {
|
||||
throw new IOException("Cannot safely identify legacy datapack staging at " + target.getPath());
|
||||
}
|
||||
publishRequired = true;
|
||||
} else {
|
||||
if (!entry.id.equals(ownership.id)) {
|
||||
throw new IOException("Datapack ownership mismatch at " + target.getPath());
|
||||
}
|
||||
publishRequired = !Objects.equals(ownership.contentHash, currentHash)
|
||||
|| !DatapackInstall.ownershipMetadataMatches(ownership, entry, desiredHash);
|
||||
}
|
||||
contentChanged = !Objects.equals(currentHash, desiredHash);
|
||||
publishRequired |= contentChanged;
|
||||
if (ownership != null && !Objects.equals(ownership.contentHash, currentHash)) {
|
||||
IrisLogging.warn("Repairing modified or corrupt Iris-managed datapack at " + target.getPath());
|
||||
}
|
||||
}
|
||||
return new InstallPlan(
|
||||
target,
|
||||
pending,
|
||||
backup,
|
||||
pendingRoot,
|
||||
hadTarget,
|
||||
publishRequired,
|
||||
contentChanged,
|
||||
originalHash,
|
||||
desiredHash,
|
||||
originalMarkerHash,
|
||||
desiredMarkerHash,
|
||||
originalIdentity,
|
||||
desiredIdentity,
|
||||
targetRootIdentity,
|
||||
scratchRootIdentity,
|
||||
targetRootFileIdentity,
|
||||
scratchRootFileIdentity,
|
||||
entry.id,
|
||||
entry.url,
|
||||
legacyWorldAuthorization
|
||||
);
|
||||
} catch (UncheckedIOException e) {
|
||||
IOException cause = e.getCause();
|
||||
DatapackInstall.cleanupPreparedInstall(pending, pendingRoot, cause);
|
||||
throw cause;
|
||||
} catch (IOException e) {
|
||||
DatapackInstall.cleanupPreparedInstall(pending, pendingRoot, e);
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
DatapackInstall.cleanupPreparedInstall(pending, pendingRoot, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static InstallPlan tryPrepareUnchangedManagedInstall(
|
||||
File stagedDir,
|
||||
File worldFolder,
|
||||
File target,
|
||||
Entry entry,
|
||||
String stagedHash,
|
||||
boolean stripOverrides,
|
||||
VerifiedStagingInstall verifiedStagingInstall
|
||||
) throws IOException {
|
||||
if (verifiedStagingInstall != null
|
||||
|| stripOverrides
|
||||
|| DatapackSupport.pathExists(new File(stagedDir, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER).toPath(),
|
||||
"staged datapack override marker")
|
||||
|| !DatapackSupport.pathExists(target.toPath(), "datapack install target")) {
|
||||
return null;
|
||||
}
|
||||
if (Files.isSymbolicLink(target.toPath())
|
||||
|| !Files.isDirectory(target.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Refusing to replace non-directory or symbolic-link datapack " + target.getPath());
|
||||
}
|
||||
Ownership ownership = DatapackOwnership.readOwnershipOrNull(target);
|
||||
if (ownership == null) {
|
||||
return null;
|
||||
}
|
||||
if (!entry.id.equals(ownership.id)) {
|
||||
throw new IOException("Datapack ownership mismatch at " + target.getPath());
|
||||
}
|
||||
if (!DatapackInstall.ownershipMetadataMatches(ownership, entry, stagedHash)) {
|
||||
return null;
|
||||
}
|
||||
DatapackInstall.validateInstallTree(target, worldFolder, "Existing datapack install");
|
||||
DatapackOwnership.removeFinderMetadata(target);
|
||||
String currentHash = DatapackOwnership.directoryHash(target);
|
||||
if (!Objects.equals(currentHash, stagedHash)) {
|
||||
return null;
|
||||
}
|
||||
String markerHash = DatapackOwnership.ownershipMarkerFingerprint(target);
|
||||
String identity = DatapackSupport.directoryIdentity(target);
|
||||
File pendingRoot = DatapackInstall.installScratchRoot(worldFolder);
|
||||
return new InstallPlan(
|
||||
target,
|
||||
new File(pendingRoot, entry.id + "-" + UUID.randomUUID()),
|
||||
new File(pendingRoot, entry.id + "-backup-" + UUID.randomUUID()),
|
||||
pendingRoot,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
currentHash,
|
||||
currentHash,
|
||||
markerHash,
|
||||
markerHash,
|
||||
identity,
|
||||
identity,
|
||||
DatapackSupport.realDirectoryPath(worldFolder, "datapack target root"),
|
||||
"",
|
||||
DatapackSupport.directoryIdentity(worldFolder),
|
||||
"",
|
||||
entry.id,
|
||||
entry.url,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
static void publishInstallPlan(InstallPlan plan) throws IOException {
|
||||
verifyDirectoryContainerIdentity(
|
||||
plan.target().getParentFile(), plan.targetRootIdentity(),
|
||||
plan.targetRootFileIdentity(), "datapack target root");
|
||||
verifyDirectoryContainerIdentity(
|
||||
plan.pendingRoot(), plan.scratchRootIdentity(),
|
||||
plan.scratchRootFileIdentity(), "datapack install scratch root");
|
||||
verifyDesiredInstallSnapshot(plan.pending(), plan, "prepared datapack install");
|
||||
if (plan.hadTarget()) {
|
||||
verifyOriginalInstallSnapshot(plan.target(), plan, "original datapack target");
|
||||
} else if (DatapackSupport.pathExists(plan.target().toPath(), "new datapack target")) {
|
||||
throw new IOException("Datapack install target was concurrently created at " + plan.target().getPath());
|
||||
}
|
||||
if (plan.legacyWorldAuthorization() != null) {
|
||||
plan.legacyWorldAuthorization().verifyLegacyWorldSnapshot();
|
||||
}
|
||||
try {
|
||||
if (plan.hadTarget()) {
|
||||
DatapackSupport.moveNew(plan.target().toPath(), plan.backup().toPath());
|
||||
verifyOriginalInstallSnapshot(plan.backup(), plan, "datapack install backup");
|
||||
if (DatapackSupport.pathExists(plan.target().toPath(), "moved datapack target")) {
|
||||
throw new IOException("Datapack install target reappeared after backup at " + plan.target().getPath());
|
||||
}
|
||||
forceInstallMoveDirectories(plan);
|
||||
}
|
||||
DatapackSupport.moveNew(plan.pending().toPath(), plan.target().toPath());
|
||||
verifyDesiredInstallSnapshot(plan.target(), plan, "installed datapack target");
|
||||
forceInstallMoveDirectories(plan);
|
||||
} catch (IOException publishFailure) {
|
||||
if (plan.hadTarget()
|
||||
&& Files.exists(plan.backup().toPath(), LinkOption.NOFOLLOW_LINKS)
|
||||
&& Files.exists(plan.pending().toPath(), LinkOption.NOFOLLOW_LINKS)
|
||||
&& Files.notExists(plan.target().toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
try {
|
||||
verifyOriginalInstallSnapshot(plan.backup(), plan, "datapack install backup");
|
||||
DatapackSupport.moveNew(plan.backup().toPath(), plan.target().toPath());
|
||||
forceInstallMoveDirectories(plan);
|
||||
} catch (IOException restoreFailure) {
|
||||
publishFailure.addSuppressed(restoreFailure);
|
||||
}
|
||||
}
|
||||
throw publishFailure;
|
||||
}
|
||||
}
|
||||
|
||||
static void forceInstallMoveDirectories(InstallPlan plan) throws IOException {
|
||||
DatapackSupport.forceDirectoryIfSupported(plan.target().getParentFile().toPath());
|
||||
DatapackSupport.forceDirectoryIfSupported(plan.pendingRoot().toPath());
|
||||
}
|
||||
|
||||
static void verifyDirectoryContainerIdentity(
|
||||
File directory,
|
||||
String expectedRealPath,
|
||||
String expectedFileIdentity,
|
||||
String purpose
|
||||
) throws IOException {
|
||||
Path normalized = directory.toPath().toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(normalized)
|
||||
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)
|
||||
|| !Objects.equals(normalized.toRealPath(), Path.of(expectedRealPath))
|
||||
|| (!expectedFileIdentity.isEmpty()
|
||||
&& !Objects.equals(DatapackSupport.directoryIdentity(directory), expectedFileIdentity))) {
|
||||
throw new IOException("Changed or unsafe " + purpose + " " + normalized);
|
||||
}
|
||||
}
|
||||
|
||||
static void verifyOriginalInstallSnapshot(
|
||||
File directory,
|
||||
InstallPlan plan,
|
||||
String purpose
|
||||
) throws IOException {
|
||||
verifyDirectorySnapshot(
|
||||
directory,
|
||||
plan.target().getParentFile(),
|
||||
plan.originalHash(),
|
||||
plan.originalMarkerHash(),
|
||||
plan.originalIdentity(),
|
||||
purpose
|
||||
);
|
||||
}
|
||||
|
||||
static void verifyDesiredInstallSnapshot(
|
||||
File directory,
|
||||
InstallPlan plan,
|
||||
String purpose
|
||||
) throws IOException {
|
||||
verifyDirectorySnapshot(
|
||||
directory,
|
||||
plan.target().getParentFile(),
|
||||
plan.desiredHash(),
|
||||
plan.desiredMarkerHash(),
|
||||
plan.desiredIdentity(),
|
||||
purpose
|
||||
);
|
||||
Ownership ownership = DatapackOwnership.readOwnership(directory);
|
||||
if (!Objects.equals(ownership.id, plan.id())
|
||||
|| !Objects.equals(ownership.url, plan.url())
|
||||
|| !Objects.equals(ownership.contentHash, plan.desiredHash())) {
|
||||
throw new IOException("Datapack ownership changed in " + purpose + " at " + directory.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
static void verifyDirectorySnapshot(
|
||||
File directory,
|
||||
File storeAnchor,
|
||||
String expectedHash,
|
||||
String expectedMarkerHash,
|
||||
String expectedIdentity,
|
||||
String purpose
|
||||
) throws IOException {
|
||||
if (!DatapackSupport.pathExists(directory.toPath(), purpose)
|
||||
|| Files.isSymbolicLink(directory.toPath())
|
||||
|| !Files.isDirectory(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Missing or unsafe " + purpose + " at " + directory.getPath());
|
||||
}
|
||||
DatapackInstall.validateInstallTree(directory, storeAnchor, purpose);
|
||||
if (Files.exists(new File(directory, DatapackOwnership.OWNERSHIP_MARKER).toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
DatapackOwnership.removeFinderMetadata(directory);
|
||||
}
|
||||
if (!expectedIdentity.isEmpty()
|
||||
&& !Objects.equals(DatapackSupport.directoryIdentity(directory), expectedIdentity)) {
|
||||
throw new IOException("Datapack directory identity changed in " + purpose + " at " + directory.getPath());
|
||||
}
|
||||
if (!Objects.equals(expectedHash, DatapackOwnership.directoryHash(directory))
|
||||
|| !Objects.equals(expectedMarkerHash, DatapackOwnership.ownershipMarkerFingerprint(directory))) {
|
||||
throw new IOException("Datapack content changed in " + purpose + " at " + directory.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
static void cleanupInstallPlan(InstallPlan plan, boolean committed) throws IOException {
|
||||
DatapackInstall.deleteInstallScratch(plan.pending(), "datapack install pending directory");
|
||||
if (committed) {
|
||||
DatapackInstall.deleteInstallScratch(plan.backup(), "datapack install backup");
|
||||
}
|
||||
if (Files.exists(plan.backup().toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Preserving prior datapack backup after incomplete install: "
|
||||
+ plan.backup().getPath());
|
||||
}
|
||||
plan.pendingRoot().delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.TreeMap;
|
||||
|
||||
final class DatapackManifestStore {
|
||||
static final long MAX_MANIFEST_BYTES = 16L * 1024L * 1024L;
|
||||
|
||||
private DatapackManifestStore() {
|
||||
}
|
||||
|
||||
static List<String> copyList(List<String> values) {
|
||||
return values == null ? List.of() : List.copyOf(values);
|
||||
}
|
||||
|
||||
static Entry copyEntry(Entry source) {
|
||||
Entry resolved = Objects.requireNonNull(source, "Datapack manifest entry must not be null");
|
||||
Entry copy = new Entry();
|
||||
copy.url = resolved.url;
|
||||
copy.id = resolved.id;
|
||||
copy.versionId = resolved.versionId;
|
||||
copy.versionNumber = resolved.versionNumber;
|
||||
copy.sha1 = resolved.sha1;
|
||||
copy.filename = resolved.filename;
|
||||
copy.etag = resolved.etag;
|
||||
copy.lastModified = resolved.lastModified;
|
||||
copy.installedEpoch = resolved.installedEpoch;
|
||||
copy.structuresImported = resolved.structuresImported;
|
||||
copy.stagingMetadata = resolved.stagingMetadata;
|
||||
copy.structureKeys = new ArrayList<>(copyList(resolved.structureKeys));
|
||||
copy.templateKeys = new ArrayList<>(copyList(resolved.templateKeys));
|
||||
copy.installMetadata = new HashMap<>(Objects.requireNonNullElseGet(
|
||||
resolved.installMetadata, Map::of));
|
||||
copy.importedTargets = new HashMap<>(Objects.requireNonNullElseGet(
|
||||
resolved.importedTargets, Map::of));
|
||||
copy.importAttempts = new HashMap<>(Objects.requireNonNullElseGet(
|
||||
resolved.importAttempts, Map::of));
|
||||
copy.importedBundles = new HashMap<>();
|
||||
if (resolved.importedBundles != null) {
|
||||
for (Map.Entry<String, Map<String, String>> bundle : resolved.importedBundles.entrySet()) {
|
||||
copy.importedBundles.put(bundle.getKey(), new HashMap<>(bundle.getValue()));
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
static Manifest readManifest(File root) {
|
||||
File file = new File(root, "manifest.json");
|
||||
Manifest manifest = null;
|
||||
boolean recoverFromStaging = false;
|
||||
Path path = file.toPath();
|
||||
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
manifest = new Manifest();
|
||||
} else if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
IrisLogging.error("Unreadable datapack manifest " + file.getPath()
|
||||
+ "; moving the non-regular path aside before recovering Iris-managed staging");
|
||||
recoverFromStaging = quarantine(path);
|
||||
} else {
|
||||
try {
|
||||
String json = DatapackSupport.readBoundedUtf8(path, MAX_MANIFEST_BYTES, "Datapack manifest");
|
||||
manifest = DatapackSupport.GSON.fromJson(json, Manifest.class);
|
||||
if (manifest == null) {
|
||||
throw new IOException("Datapack manifest is empty");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
IrisLogging.reportError("Unreadable datapack manifest " + file.getPath()
|
||||
+ "; moving it aside before recovering Iris-managed staging", e);
|
||||
recoverFromStaging = quarantine(file.toPath());
|
||||
}
|
||||
}
|
||||
if (manifest == null) {
|
||||
manifest = new Manifest();
|
||||
}
|
||||
normalizeManifest(manifest);
|
||||
if (recoverFromStaging) {
|
||||
recoverManifestFromStaging(root, manifest);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
static boolean quarantine(Path file) {
|
||||
try {
|
||||
DatapackSupport.move(file, file.resolveSibling(file.getFileName().toString() + ".corrupt-" + System.currentTimeMillis()));
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError("Failed to move aside corrupt datapack manifest " + file, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void normalizeManifest(Manifest manifest) {
|
||||
if (manifest.entries == null) {
|
||||
manifest.entries = new ArrayList<>();
|
||||
return;
|
||||
}
|
||||
List<Entry> normalized = new ArrayList<>();
|
||||
Set<String> urls = new HashSet<>();
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (Entry entry : manifest.entries) {
|
||||
if (entry == null || entry.url == null || entry.url.isBlank() || entry.id == null || entry.id.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
entry.url = entry.url.trim();
|
||||
if (!DatapackArchive.isValidManagedId(entry.id)) {
|
||||
IrisLogging.warn("Ignoring datapack manifest entry with invalid id '" + entry.id + "'");
|
||||
continue;
|
||||
}
|
||||
entry.structureKeys = normalizeKeys(entry.structureKeys);
|
||||
entry.templateKeys = normalizeKeys(entry.templateKeys);
|
||||
entry.stagingMetadata = entry.stagingMetadata == null ? "" : entry.stagingMetadata.trim();
|
||||
entry.installMetadata = normalizeImportedTargets(entry.installMetadata);
|
||||
entry.importedTargets = normalizeImportedTargets(entry.importedTargets);
|
||||
entry.importAttempts = normalizeImportedTargets(entry.importAttempts);
|
||||
entry.importedBundles = normalizeImportedBundles(entry.importedBundles);
|
||||
if (!urls.add(entry.url) || !ids.add(entry.id)) {
|
||||
IrisLogging.warn("Ignoring duplicate datapack manifest entry for id '" + entry.id + "' and url " + entry.url);
|
||||
continue;
|
||||
}
|
||||
normalized.add(entry);
|
||||
}
|
||||
manifest.entries = normalized;
|
||||
}
|
||||
|
||||
static void recoverManifestFromStaging(File root, Manifest manifest) {
|
||||
File staging = new File(root, "staging");
|
||||
File[] directories = staging.listFiles(File::isDirectory);
|
||||
if (directories == null) {
|
||||
return;
|
||||
}
|
||||
for (File directory : directories) {
|
||||
try {
|
||||
Ownership ownership = DatapackOwnership.readOwnershipOrNull(directory);
|
||||
if (ownership == null || !directory.getName().equals(ownership.id)
|
||||
|| manifest.find(ownership.url) != null || manifest.findById(ownership.id) != null) {
|
||||
continue;
|
||||
}
|
||||
DatapackOwnership.validateManagedDirectory(directory, ownership.id);
|
||||
if (!Objects.equals(ownership.contentHash, DatapackOwnership.directoryHash(directory))) {
|
||||
IrisLogging.warn("Ignoring corrupt Iris-managed datapack staging at " + directory.getPath());
|
||||
continue;
|
||||
}
|
||||
Entry recovered = ownership.toEntry();
|
||||
recovered.installedEpoch = directory.lastModified();
|
||||
manifest.put(recovered);
|
||||
IrisLogging.warn("Recovered Iris-managed datapack manifest entry '" + recovered.id + "' from staging.");
|
||||
} catch (IOException e) {
|
||||
IrisLogging.warn("Ignoring orphan datapack staging at " + directory.getPath() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> normalizeKeys(List<String> keys) {
|
||||
TreeSet<String> normalized = new TreeSet<>();
|
||||
if (keys != null) {
|
||||
for (String key : keys) {
|
||||
if (key != null && !key.isBlank()) {
|
||||
normalized.add(key.trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(normalized);
|
||||
}
|
||||
|
||||
static Map<String, String> normalizeImportedTargets(Map<String, String> targets) {
|
||||
Map<String, String> normalized = new HashMap<>();
|
||||
if (targets == null) {
|
||||
return normalized;
|
||||
}
|
||||
for (Map.Entry<String, String> target : targets.entrySet()) {
|
||||
if (target.getKey() != null && !target.getKey().isBlank()
|
||||
&& target.getValue() != null && !target.getValue().isBlank()) {
|
||||
normalized.put(target.getKey(), target.getValue());
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static Map<String, Map<String, String>> normalizeImportedBundles(
|
||||
Map<String, Map<String, String>> targets
|
||||
) {
|
||||
Map<String, Map<String, String>> normalized = new HashMap<>();
|
||||
if (targets == null) {
|
||||
return normalized;
|
||||
}
|
||||
for (Map.Entry<String, Map<String, String>> target : targets.entrySet()) {
|
||||
if (target.getKey() == null || target.getKey().isBlank() || target.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> bundles = new TreeMap<>();
|
||||
for (Map.Entry<String, String> bundle : target.getValue().entrySet()) {
|
||||
if (bundle.getKey() != null && !bundle.getKey().isBlank()
|
||||
&& bundle.getValue() != null && !bundle.getValue().isBlank()) {
|
||||
bundles.put(bundle.getKey(), bundle.getValue());
|
||||
}
|
||||
}
|
||||
normalized.put(target.getKey(), bundles);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static void writeManifest(File root, Manifest manifest) {
|
||||
try {
|
||||
writeManifestChecked(root, manifest);
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError("Failed to write datapack manifest "
|
||||
+ new File(root, "manifest.json").toPath(), e);
|
||||
}
|
||||
}
|
||||
|
||||
static void writeManifestChecked(File root, Manifest manifest) throws IOException {
|
||||
try (ManifestWrite write = prepareManifestWrite(root, manifest)) {
|
||||
write.publish();
|
||||
}
|
||||
}
|
||||
|
||||
static ManifestWrite prepareManifestWrite(File root, Manifest manifest) throws IOException {
|
||||
Path file = new File(root, "manifest.json").toPath();
|
||||
Path parent = file.getParent();
|
||||
Files.createDirectories(parent);
|
||||
Path temp = Files.createTempFile(parent, "manifest", ".json.tmp");
|
||||
Path rollback = null;
|
||||
try {
|
||||
byte[] content = DatapackSupport.GSON.toJson(manifest).getBytes(StandardCharsets.UTF_8);
|
||||
if (content.length > MAX_MANIFEST_BYTES) {
|
||||
throw new IOException("Datapack manifest exceeds " + MAX_MANIFEST_BYTES + " bytes");
|
||||
}
|
||||
Files.write(temp, content, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
DatapackSupport.forceFile(temp);
|
||||
if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file)) {
|
||||
throw new IOException("Datapack manifest is not a regular file: " + file);
|
||||
}
|
||||
rollback = Files.createTempFile(parent, "manifest-rollback", ".json.tmp");
|
||||
Files.copy(file, rollback, StandardCopyOption.REPLACE_EXISTING);
|
||||
DatapackSupport.forceFile(rollback);
|
||||
}
|
||||
return new ManifestWrite(temp, file, rollback);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
try {
|
||||
Files.deleteIfExists(temp);
|
||||
} catch (IOException cleanupFailure) {
|
||||
e.addSuppressed(cleanupFailure);
|
||||
}
|
||||
if (rollback != null) {
|
||||
try {
|
||||
Files.deleteIfExists(rollback);
|
||||
} catch (IOException cleanupFailure) {
|
||||
e.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static Manifest readCommittedManifest(File root) throws IOException {
|
||||
Path manifestPath = new File(root, "manifest.json").toPath();
|
||||
if (!Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return new Manifest();
|
||||
}
|
||||
if (Files.isSymbolicLink(manifestPath)
|
||||
|| !Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Datapack manifest is not a regular file: " + manifestPath);
|
||||
}
|
||||
Manifest manifest;
|
||||
try {
|
||||
manifest = DatapackSupport.GSON.fromJson(
|
||||
DatapackSupport.readBoundedUtf8(manifestPath, MAX_MANIFEST_BYTES, "Datapack manifest"),
|
||||
Manifest.class
|
||||
);
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid datapack manifest " + manifestPath, e);
|
||||
}
|
||||
if (manifest == null) {
|
||||
throw new IOException("Empty datapack manifest " + manifestPath);
|
||||
}
|
||||
normalizeManifest(manifest);
|
||||
return manifest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileStore;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
final class DatapackOwnership {
|
||||
static final String FINDER_METADATA = ".DS_Store";
|
||||
|
||||
static final String OWNERSHIP_MARKER = ".iris-managed.json";
|
||||
|
||||
static final int OWNERSHIP_SCHEMA = 1;
|
||||
|
||||
static final int MAX_MANAGED_PATHS = DatapackArchive.MAX_ARCHIVE_ENTRIES + 16;
|
||||
|
||||
static final long MAX_OWNERSHIP_BYTES = 1024L * 1024L;
|
||||
|
||||
static final int HASH_BUFFER_BYTES = 64 * 1024;
|
||||
|
||||
private DatapackOwnership() {
|
||||
}
|
||||
|
||||
static void writeOwnership(File directory, Entry entry) throws IOException {
|
||||
writeOwnership(directory, entry, directoryHash(directory));
|
||||
}
|
||||
|
||||
static void writeOwnership(File directory, Entry entry, String contentHash) throws IOException {
|
||||
if (!DatapackArchive.isValidManagedId(entry.id) || entry.url == null || entry.url.isBlank()) {
|
||||
throw new IOException("Invalid Iris datapack ownership identity for " + directory.getPath());
|
||||
}
|
||||
if (contentHash == null || contentHash.isBlank()) {
|
||||
throw new IOException("Missing Iris datapack ownership content hash for " + directory.getPath());
|
||||
}
|
||||
Ownership ownership = new Ownership(
|
||||
OWNERSHIP_SCHEMA,
|
||||
entry.id,
|
||||
entry.url,
|
||||
entry.versionId,
|
||||
entry.versionNumber,
|
||||
entry.sha1,
|
||||
contentHash,
|
||||
DatapackManifestStore.copyList(entry.structureKeys),
|
||||
DatapackManifestStore.copyList(entry.templateKeys)
|
||||
);
|
||||
Path marker = new File(directory, OWNERSHIP_MARKER).toPath();
|
||||
Path temporary = Files.createTempFile(directory.toPath(), OWNERSHIP_MARKER, ".tmp");
|
||||
try {
|
||||
Files.writeString(
|
||||
temporary,
|
||||
DatapackSupport.GSON.toJson(ownership),
|
||||
StandardCharsets.UTF_8,
|
||||
StandardOpenOption.TRUNCATE_EXISTING
|
||||
);
|
||||
DatapackSupport.move(temporary, marker);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
static Ownership readOwnership(File directory) throws IOException {
|
||||
Ownership ownership = readOwnershipOrNull(directory);
|
||||
if (ownership == null) {
|
||||
throw new IOException("Missing Iris datapack ownership marker in " + directory.getPath());
|
||||
}
|
||||
return ownership;
|
||||
}
|
||||
|
||||
static Ownership readOwnershipOrNull(File directory) throws IOException {
|
||||
File marker = new File(directory, OWNERSHIP_MARKER);
|
||||
Path markerPath = marker.toPath();
|
||||
if (Files.notExists(markerPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return null;
|
||||
}
|
||||
if (!Files.exists(markerPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Cannot determine Iris datapack ownership marker state in " + directory.getPath());
|
||||
}
|
||||
if (Files.isSymbolicLink(markerPath)
|
||||
|| !Files.isRegularFile(markerPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath());
|
||||
}
|
||||
if (marker.length() > MAX_OWNERSHIP_BYTES) {
|
||||
throw new IOException("Oversized Iris datapack ownership marker in " + directory.getPath());
|
||||
}
|
||||
try {
|
||||
Ownership ownership = DatapackSupport.GSON.fromJson(DatapackSupport.readBoundedUtf8(
|
||||
marker.toPath(), MAX_OWNERSHIP_BYTES, "Iris datapack ownership marker"), Ownership.class);
|
||||
if (ownership == null || ownership.schemaVersion != OWNERSHIP_SCHEMA
|
||||
|| !DatapackArchive.isValidManagedId(ownership.id) || ownership.url == null || ownership.url.isBlank()
|
||||
|| ownership.contentHash == null || ownership.contentHash.isBlank()) {
|
||||
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath());
|
||||
}
|
||||
return ownership;
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath(), e);
|
||||
}
|
||||
}
|
||||
|
||||
static String ownershipMarkerFingerprint(File directory) throws IOException {
|
||||
Path marker = new File(directory, OWNERSHIP_MARKER).toPath();
|
||||
if (Files.notExists(marker, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return "absent";
|
||||
}
|
||||
if (!Files.exists(marker, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Cannot determine Iris datapack ownership marker state in " + directory.getPath());
|
||||
}
|
||||
if (Files.isSymbolicLink(marker)
|
||||
|| !Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath());
|
||||
}
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return "sha256:" + DatapackSupport.hex(digest.digest(DatapackSupport.readBoundedBytes(
|
||||
marker, MAX_OWNERSHIP_BYTES, "Iris datapack ownership marker")));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IOException("SHA-256 algorithm unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
static void validateManagedDirectory(File directory, String id) throws IOException {
|
||||
Path path = directory.toPath();
|
||||
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Missing datapack directory " + directory.getPath());
|
||||
}
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new IOException("Refusing symbolic-link datapack directory " + directory.getPath());
|
||||
}
|
||||
DatapackPackMetadata.validatePackMetadata(directory);
|
||||
rejectSymbolicLinks(directory);
|
||||
Ownership ownership = readOwnershipOrNull(directory);
|
||||
if (ownership == null) {
|
||||
throw new IOException("Datapack is not Iris-managed: " + directory.getPath());
|
||||
}
|
||||
if (!id.equals(ownership.id)) {
|
||||
throw new IOException("Datapack ownership mismatch at " + directory.getPath());
|
||||
}
|
||||
removeFinderMetadata(directory);
|
||||
}
|
||||
|
||||
static void rejectSymbolicLinks(File root) throws IOException {
|
||||
try (Stream<Path> paths = Files.walk(root.toPath())) {
|
||||
Path symbolicLink = paths.filter(Files::isSymbolicLink).findFirst().orElse(null);
|
||||
if (symbolicLink != null) {
|
||||
throw new IOException("Datapack contains a symbolic link: " + symbolicLink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static boolean sameDatapackVolume(
|
||||
Path root,
|
||||
FileStore rootStore,
|
||||
Path entry
|
||||
) throws IOException {
|
||||
return DatapackScratchRecovery.sameScratchVolume(root, rootStore, entry, Files.getFileStore(entry));
|
||||
}
|
||||
|
||||
static String directoryHash(File root) throws IOException {
|
||||
try {
|
||||
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();
|
||||
int pathCount = 0;
|
||||
while (iterator.hasNext()) {
|
||||
Path path = iterator.next();
|
||||
if (path.equals(rootPath) || path.equals(rootMarker)) {
|
||||
continue;
|
||||
}
|
||||
if (isFinderMetadata(path)) {
|
||||
if (Files.isSymbolicLink(path)
|
||||
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Suspicious Finder metadata in datapack: " + path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pathCount++;
|
||||
if (pathCount > MAX_MANAGED_PATHS) {
|
||||
throw new IOException("Datapack contains more than " + MAX_MANAGED_PATHS + " paths");
|
||||
}
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new IOException("Datapack contains a symbolic link: " + path);
|
||||
}
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Datapack contains an unsupported filesystem entry: " + path);
|
||||
}
|
||||
entries.add(path);
|
||||
}
|
||||
}
|
||||
entries.sort(Comparator.comparing(path -> rootPath.relativize(path).toString()));
|
||||
byte[] buffer = new byte[HASH_BUFFER_BYTES];
|
||||
long totalBytes = 0;
|
||||
for (Path entry : entries) {
|
||||
String relative = rootPath.relativize(entry).toString().replace(File.separatorChar, '/');
|
||||
byte[] relativeBytes = relative.getBytes(StandardCharsets.UTF_8);
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
entry,
|
||||
BasicFileAttributes.class,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
);
|
||||
if (attributes.isSymbolicLink()
|
||||
|| attributes.isOther()
|
||||
|| !attributes.isDirectory() && !attributes.isRegularFile()) {
|
||||
throw new IOException("Datapack entry changed while hashing: " + relative);
|
||||
}
|
||||
if (!DatapackScratchRecovery.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));
|
||||
DatapackSupport.updateDigestInt(digest, relativeBytes.length);
|
||||
digest.update(relativeBytes);
|
||||
if (!directory) {
|
||||
long expectedBytes = attributes.size();
|
||||
if (expectedBytes > DatapackArchive.MAX_ENTRY_BYTES) {
|
||||
throw new IOException("Datapack file exceeds " + DatapackArchive.MAX_ENTRY_BYTES + " bytes: " + relative);
|
||||
}
|
||||
totalBytes += expectedBytes;
|
||||
if (totalBytes > DatapackArchive.MAX_EXPANDED_BYTES) {
|
||||
throw new IOException("Datapack contents exceed " + DatapackArchive.MAX_EXPANDED_BYTES + " bytes");
|
||||
}
|
||||
DatapackSupport.updateDigestLong(digest, expectedBytes);
|
||||
long entryBytes = 0;
|
||||
try (InputStream input = Files.newInputStream(
|
||||
entry,
|
||||
StandardOpenOption.READ,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
)) {
|
||||
int length;
|
||||
while ((length = input.read(buffer)) > 0) {
|
||||
entryBytes += length;
|
||||
if (entryBytes > expectedBytes) {
|
||||
throw new IOException("Datapack file changed while hashing: " + relative);
|
||||
}
|
||||
digest.update(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
if (entryBytes != expectedBytes) {
|
||||
throw new IOException("Datapack file changed while hashing: " + relative);
|
||||
}
|
||||
}
|
||||
}
|
||||
return DatapackSupport.hex(digest.digest());
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IOException("SHA-256 algorithm unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
static void removeFinderMetadata(File root) throws IOException {
|
||||
List<Path> entries;
|
||||
try (Stream<Path> paths = Files.walk(root.toPath())) {
|
||||
entries = paths.limit(MAX_MANAGED_PATHS + 1L).toList();
|
||||
}
|
||||
if (entries.size() > MAX_MANAGED_PATHS) {
|
||||
throw new IOException("Datapack contains more than " + MAX_MANAGED_PATHS + " paths");
|
||||
}
|
||||
for (Path path : entries) {
|
||||
if (!isFinderMetadata(path)) {
|
||||
continue;
|
||||
}
|
||||
if (Files.isSymbolicLink(path)
|
||||
|| !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Suspicious Finder metadata in datapack: " + path);
|
||||
}
|
||||
Files.delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isFinderMetadata(Path path) {
|
||||
Path fileName = path.getFileName();
|
||||
return fileName != null && FINDER_METADATA.equals(fileName.toString());
|
||||
}
|
||||
|
||||
static PackResources scanPackResources(File root) throws IOException {
|
||||
TreeSet<String> structureKeys = new TreeSet<>();
|
||||
TreeSet<String> structureSetKeys = new TreeSet<>();
|
||||
TreeSet<String> templateKeys = new TreeSet<>();
|
||||
Path dataRoot = new File(root, "data").toPath();
|
||||
if (!Files.isDirectory(dataRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return new PackResources(new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
|
||||
}
|
||||
try (Stream<Path> paths = Files.walk(dataRoot)) {
|
||||
for (Path path : paths.filter(file -> Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)).toList()) {
|
||||
Path relative = dataRoot.relativize(path);
|
||||
if (relative.getNameCount() < 3) {
|
||||
continue;
|
||||
}
|
||||
String namespace = relative.getName(0).toString().toLowerCase(Locale.ROOT);
|
||||
String normalized = relative.subpath(1, relative.getNameCount()).toString().replace(File.separatorChar, '/');
|
||||
addResourceKey(structureKeys, namespace, normalized, "worldgen/structure/", ".json");
|
||||
addResourceKey(structureKeys, namespace, normalized, "worldgen/structures/", ".json");
|
||||
addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_set/", ".json");
|
||||
addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_sets/", ".json");
|
||||
addResourceKey(templateKeys, namespace, normalized, "structure/", ".nbt");
|
||||
addResourceKey(templateKeys, namespace, normalized, "structures/", ".nbt");
|
||||
}
|
||||
}
|
||||
return new PackResources(
|
||||
new ArrayList<>(structureKeys),
|
||||
new ArrayList<>(structureSetKeys),
|
||||
new ArrayList<>(templateKeys));
|
||||
}
|
||||
|
||||
static void addResourceKey(Set<String> keys, String namespace, String path, String prefix, String suffix) {
|
||||
if (!path.startsWith(prefix) || !path.endsWith(suffix)) {
|
||||
return;
|
||||
}
|
||||
String resourcePath = path.substring(prefix.length(), path.length() - suffix.length());
|
||||
if (!resourcePath.isBlank()) {
|
||||
keys.add(namespace + ":" + resourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean deleteOwnedDirectory(File directory, String id) throws IOException {
|
||||
Path path = directory.toPath();
|
||||
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Refusing non-directory or symbolic-link target " + directory.getPath());
|
||||
}
|
||||
Ownership ownership = readOwnership(directory);
|
||||
if (!id.equals(ownership.id)) {
|
||||
throw new IOException("Ownership marker belongs to '" + ownership.id + "'");
|
||||
}
|
||||
removeFinderMetadata(directory);
|
||||
if (!Objects.equals(ownership.contentHash, directoryHash(directory))) {
|
||||
throw new IOException("Refusing to delete modified or corrupt Iris-managed datapack " + directory.getPath());
|
||||
}
|
||||
File parent = Objects.requireNonNull(directory.getParentFile(), "managed datapack parent");
|
||||
DatapackInstall.validateInstallTree(directory, parent, "Managed datapack deletion");
|
||||
IO.delete(directory);
|
||||
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Could not delete " + directory.getPath());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisImportedStructureControl;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
final class DatapackPackMetadata {
|
||||
static final String OVERRIDES_STRIPPED_MARKER = ".iris-overrides-stripped";
|
||||
|
||||
static final long MAX_METADATA_BYTES = 1024L * 1024L;
|
||||
|
||||
private DatapackPackMetadata() {
|
||||
}
|
||||
|
||||
static boolean resolveStripOverrides() {
|
||||
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
|
||||
return stream.anyMatch(DatapackPackMetadata::packDisablesOverrides);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean packDisablesOverrides(IrisData data) {
|
||||
if (data == null || data.getDimensionLoader() == null) {
|
||||
return false;
|
||||
}
|
||||
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
|
||||
if (dimension == null) {
|
||||
continue;
|
||||
}
|
||||
IrisImportedStructureControl control = dimension.getImportedStructures();
|
||||
if (control != null && !control.isDatapackOverrides()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void stripVanillaStructureOverrides(File datapackRoot) throws IOException {
|
||||
stripVanillaStructureOverrides(datapackRoot, IO::delete);
|
||||
}
|
||||
|
||||
static void stripVanillaStructureOverrides(
|
||||
File datapackRoot,
|
||||
DirectoryDeleter deleter
|
||||
) throws IOException {
|
||||
File minecraftData = new File(new File(datapackRoot, "data"), "minecraft");
|
||||
if (!minecraftData.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
String[] relativeTrees = {
|
||||
"worldgen" + File.separator + "structure_set",
|
||||
"worldgen" + File.separator + "structure",
|
||||
"worldgen" + File.separator + "template_pool",
|
||||
"structure"
|
||||
};
|
||||
for (String tree : relativeTrees) {
|
||||
File dir = new File(minecraftData, tree);
|
||||
if (dir.exists()) {
|
||||
deleter.delete(dir);
|
||||
if (Files.exists(dir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Could not remove vanilla structure override tree " + dir.getPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void writeMarker(File marker) throws IOException {
|
||||
Files.writeString(marker.toPath(), "stripped", StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
static void validatePackMetadata(File datapackRoot) throws IOException {
|
||||
File metadata = new File(datapackRoot, "pack.mcmeta");
|
||||
if (!metadata.isFile() || Files.isSymbolicLink(metadata.toPath())) {
|
||||
throw new IOException("Datapack is missing a regular pack.mcmeta");
|
||||
}
|
||||
if (metadata.length() > MAX_METADATA_BYTES) {
|
||||
throw new IOException("Datapack pack.mcmeta exceeds 1 MiB");
|
||||
}
|
||||
JsonElement parsed;
|
||||
try {
|
||||
parsed = JsonParser.parseString(DatapackSupport.readBoundedUtf8(
|
||||
metadata.toPath(), MAX_METADATA_BYTES, "Datapack pack.mcmeta"));
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Datapack pack.mcmeta is not valid JSON", e);
|
||||
}
|
||||
if (!parsed.isJsonObject()) {
|
||||
throw new IOException("Datapack pack.mcmeta root must be an object");
|
||||
}
|
||||
JsonObject root = parsed.getAsJsonObject();
|
||||
if (!root.has("pack") || !root.get("pack").isJsonObject()) {
|
||||
throw new IOException("Datapack pack.mcmeta must contain a pack object");
|
||||
}
|
||||
JsonObject pack = root.getAsJsonObject("pack");
|
||||
boolean hasPackFormat = pack.has("pack_format") && pack.get("pack_format").isJsonPrimitive()
|
||||
&& pack.getAsJsonPrimitive("pack_format").isNumber();
|
||||
boolean hasRange = validFormatValue(pack.get("min_format")) && validFormatValue(pack.get("max_format"));
|
||||
if (!hasPackFormat && !hasRange) {
|
||||
throw new IOException("Datapack pack.mcmeta must declare numeric pack_format or valid min_format/max_format");
|
||||
}
|
||||
if (!pack.has("description") || pack.get("description").isJsonNull()) {
|
||||
throw new IOException("Datapack pack.mcmeta must declare a description");
|
||||
}
|
||||
File data = new File(datapackRoot, "data");
|
||||
if (data.exists() && (!data.isDirectory() || Files.isSymbolicLink(data.toPath()))) {
|
||||
throw new IOException("Datapack data path is not a regular directory");
|
||||
}
|
||||
}
|
||||
|
||||
static boolean validFormatValue(JsonElement value) {
|
||||
if (value == null || value.isJsonNull()) {
|
||||
return false;
|
||||
}
|
||||
if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber()) {
|
||||
return true;
|
||||
}
|
||||
if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) {
|
||||
return false;
|
||||
}
|
||||
for (JsonElement component : value.getAsJsonArray()) {
|
||||
if (!component.isJsonPrimitive() || !component.getAsJsonPrimitive().isNumber()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.structure.authoring.StructureKey;
|
||||
import art.arcane.iris.core.structure.authoring.StructureSource;
|
||||
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
|
||||
final class DatapackRemoval {
|
||||
|
||||
private DatapackRemoval() {
|
||||
}
|
||||
|
||||
static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id) {
|
||||
File root = IrisPlatforms.get().dataFolder("datapacks");
|
||||
return removeOutcomeLocked(sender, id, root, ServerConfigurator.getDatapacksFolder());
|
||||
}
|
||||
|
||||
static boolean removeLocked(VolmitSender sender, String id, File root, List<File> worldFolders) {
|
||||
return removeOutcomeLocked(sender, id, root, worldFolders) == RemoveOutcome.REMOVED;
|
||||
}
|
||||
|
||||
static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id, File root, List<File> worldFolders) {
|
||||
String requested = id == null ? "" : id.trim().toLowerCase(Locale.ROOT);
|
||||
String cleaned = DatapackArchive.sanitizeId(id);
|
||||
if (requested.isBlank() || !requested.equals(cleaned) || DatapackArchive.RESERVED_IDS.contains(cleaned)) {
|
||||
DatapackSupport.message(sender, C.RED + "Invalid Iris-managed datapack id '" + requested + "'. Run /iris datapack list and use the exact listed id.");
|
||||
return RemoveOutcome.REJECTED;
|
||||
}
|
||||
try {
|
||||
DatapackScratchRecovery.recoverTransactions(root, worldFolders);
|
||||
} catch (IOException e) {
|
||||
DatapackSupport.message(sender, C.RED + "Datapack removal blocked by incomplete transaction recovery: " + e.getMessage());
|
||||
IrisLogging.reportError(e);
|
||||
return RemoveOutcome.REJECTED;
|
||||
}
|
||||
Manifest manifest = DatapackManifestStore.readManifest(root);
|
||||
Entry ownedEntry = manifest.findById(cleaned);
|
||||
if (ownedEntry == null) {
|
||||
DatapackSupport.message(sender, C.YELLOW + "No Iris-managed datapack named '" + cleaned + "'. Unmanaged world datapacks are never removed by Iris.");
|
||||
return RemoveOutcome.REJECTED;
|
||||
}
|
||||
|
||||
List<File> targets;
|
||||
try {
|
||||
targets = preflightRemovalTargets(root, worldFolders, ownedEntry);
|
||||
} catch (IOException e) {
|
||||
DatapackSupport.message(sender, C.RED + "Refused to remove datapack '" + cleaned + "': " + e.getMessage());
|
||||
IrisLogging.reportError(e);
|
||||
return RemoveOutcome.REJECTED;
|
||||
}
|
||||
|
||||
EditableImportRemoval editableRemoval = null;
|
||||
DirectoryRemoval directoryRemoval = null;
|
||||
ManifestWrite manifestWrite = null;
|
||||
DatapackCoordinator coordinator = null;
|
||||
try {
|
||||
editableRemoval = prepareEntryImportRemoval(ownedEntry, manifest.entries);
|
||||
directoryRemoval = prepareOwnedDirectories(targets);
|
||||
if (!manifest.removeById(cleaned)) {
|
||||
throw new IOException("Datapack manifest entry disappeared during removal");
|
||||
}
|
||||
manifestWrite = DatapackManifestStore.prepareManifestWrite(root, manifest);
|
||||
coordinator = DatapackTransactions.createRemovalCoordinator(root, ownedEntry, directoryRemoval, editableRemoval);
|
||||
coordinator.phase(CoordinatorPhase.PUBLISHING);
|
||||
directoryRemoval.prepare();
|
||||
coordinator.phase(CoordinatorPhase.PUBLISHED);
|
||||
manifestWrite.publish();
|
||||
} catch (IOException | RuntimeException removalFailure) {
|
||||
boolean manifestCommitted = manifestWrite != null && manifestWrite.published();
|
||||
if (manifestCommitted) {
|
||||
finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator,
|
||||
removalFailure);
|
||||
DatapackSupport.message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN
|
||||
+ "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone.");
|
||||
return RemoveOutcome.REMOVED;
|
||||
}
|
||||
boolean restored = rollbackRemoval(manifestWrite, directoryRemoval, editableRemoval, removalFailure);
|
||||
if (restored && coordinator != null) {
|
||||
try {
|
||||
coordinator.finish();
|
||||
} catch (IOException cleanupFailure) {
|
||||
removalFailure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
DatapackSupport.message(sender, C.RED + "Failed to remove datapack '" + cleaned
|
||||
+ "'; Iris attempted to restore every prior location: " + removalFailure.getMessage());
|
||||
IrisLogging.reportError(removalFailure);
|
||||
// A mutation was attempted; even a successful rollback is not provably identical
|
||||
// (the fingerprint does not cover datapacks/), so stay conservatively invalidated.
|
||||
return RemoveOutcome.FAILED_AFTER_MUTATION;
|
||||
}
|
||||
finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator, null);
|
||||
DatapackSupport.message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN
|
||||
+ "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone.");
|
||||
return RemoveOutcome.REMOVED;
|
||||
}
|
||||
|
||||
static void finishCommittedRemoval(
|
||||
String id,
|
||||
ManifestWrite manifestWrite,
|
||||
DirectoryRemoval directoryRemoval,
|
||||
EditableImportRemoval editableRemoval,
|
||||
DatapackCoordinator coordinator,
|
||||
Throwable priorFailure
|
||||
) {
|
||||
if (priorFailure != null) {
|
||||
IOException recoveryFailure = new IOException(
|
||||
"Datapack manifest committed before publication durability was confirmed",
|
||||
priorFailure
|
||||
);
|
||||
if (editableRemoval != null) {
|
||||
try {
|
||||
editableRemoval.leaveForRecovery();
|
||||
} catch (IOException releaseFailure) {
|
||||
recoveryFailure.addSuppressed(releaseFailure);
|
||||
}
|
||||
}
|
||||
try {
|
||||
manifestWrite.discard();
|
||||
} catch (IOException cleanupFailure) {
|
||||
recoveryFailure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
IrisLogging.reportError("Datapack '" + id
|
||||
+ "' was removed but transaction cleanup requires restart recovery.", recoveryFailure);
|
||||
return;
|
||||
}
|
||||
|
||||
IOException failure = null;
|
||||
if (coordinator != null) {
|
||||
try {
|
||||
coordinator.phase(CoordinatorPhase.COMMITTED);
|
||||
} catch (IOException phaseFailure) {
|
||||
failure = DatapackSupport.appendIOException(failure, phaseFailure);
|
||||
if (editableRemoval != null) {
|
||||
try {
|
||||
editableRemoval.leaveForRecovery();
|
||||
} catch (IOException releaseFailure) {
|
||||
failure = DatapackSupport.appendIOException(failure, releaseFailure);
|
||||
}
|
||||
}
|
||||
try {
|
||||
manifestWrite.discard();
|
||||
} catch (IOException cleanupFailure) {
|
||||
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
|
||||
}
|
||||
IrisLogging.reportError("Datapack '" + id
|
||||
+ "' was removed but transaction cleanup requires restart recovery.", failure);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (editableRemoval != null) {
|
||||
try {
|
||||
editableRemoval.markCommitted();
|
||||
editableRemoval.finishCommit();
|
||||
} catch (IOException | RuntimeException cleanupFailure) {
|
||||
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
|
||||
try {
|
||||
editableRemoval.leaveForRecovery();
|
||||
} catch (IOException releaseFailure) {
|
||||
failure = DatapackSupport.appendIOException(failure, releaseFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (directoryRemoval != null) {
|
||||
try {
|
||||
directoryRemoval.finishCommit();
|
||||
} catch (IOException | RuntimeException cleanupFailure) {
|
||||
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
|
||||
}
|
||||
}
|
||||
try {
|
||||
manifestWrite.discard();
|
||||
} catch (IOException cleanupFailure) {
|
||||
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
|
||||
}
|
||||
if (failure == null && coordinator != null) {
|
||||
try {
|
||||
coordinator.finish();
|
||||
} catch (IOException cleanupFailure) {
|
||||
failure = cleanupFailure;
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
IrisLogging.reportError("Datapack '" + id
|
||||
+ "' was removed but transaction cleanup requires restart recovery.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
static List<File> preflightRemovalTargets(File root, List<File> worldFolders, Entry entry) throws IOException {
|
||||
List<File> targets = new ArrayList<>();
|
||||
Set<Path> seen = new HashSet<>();
|
||||
File staging = new File(root, "staging");
|
||||
DatapackSupport.verifyDirectoryContainerIfPresent(staging, "datapack staging");
|
||||
File stagedDir = new File(staging, entry.id);
|
||||
verifyAndCollectRemovalTarget(stagedDir, entry, targets, seen);
|
||||
for (File worldFolder : worldFolders) {
|
||||
DatapackSupport.verifyDirectoryContainerIfPresent(worldFolder, "world datapacks");
|
||||
verifyAndCollectRemovalTarget(new File(worldFolder, entry.id), entry, targets, seen);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
static void verifyAndCollectRemovalTarget(
|
||||
File target,
|
||||
Entry entry,
|
||||
List<File> targets,
|
||||
Set<Path> seen
|
||||
) throws IOException {
|
||||
verifyOwnedDirectoryIfPresent(target, entry);
|
||||
Path normalized = target.toPath().toAbsolutePath().normalize();
|
||||
if (DatapackSupport.pathExists(normalized, "datapack removal target")) {
|
||||
Path identity = normalized.toRealPath();
|
||||
if (!seen.add(identity)) {
|
||||
throw new IOException("Aliased datapack removal target " + normalized);
|
||||
}
|
||||
targets.add(target);
|
||||
}
|
||||
}
|
||||
|
||||
static EditableImportRemoval prepareEntryImportRemoval(
|
||||
Entry entry,
|
||||
List<Entry> manifestEntries
|
||||
) throws IOException {
|
||||
List<PreparedEditableImport> prepared = new ArrayList<>();
|
||||
Set<String> targetIdSet = new TreeSet<>(entry.importedBundles.keySet());
|
||||
targetIdSet.addAll(entry.importedTargets.keySet());
|
||||
targetIdSet.addAll(entry.importAttempts.keySet());
|
||||
List<String> targetIds = new ArrayList<>(targetIdSet);
|
||||
targetIds.sort(String::compareTo);
|
||||
try {
|
||||
for (String targetId : targetIds) {
|
||||
Set<String> retainedKeys = invalidateRetainedImportClaims(
|
||||
entry, targetId, manifestEntries);
|
||||
File dataFolder = new File(targetId);
|
||||
if (!dataFolder.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
StructureTransactionWriter writer = new StructureTransactionWriter(dataFolder.toPath());
|
||||
List<StructureTransactionWriter.OwnedRemoval> removals = ownedImportRemovals(
|
||||
writer,
|
||||
entry,
|
||||
targetId,
|
||||
retainedKeys
|
||||
);
|
||||
StructureTransactionWriter.PreparedRemoval removal =
|
||||
writer.prepareMatchingOwnedRemovals(removals);
|
||||
prepared.add(new PreparedEditableImport(dataFolder, removal));
|
||||
}
|
||||
return new EditableImportRemoval(prepared);
|
||||
} catch (IOException | RuntimeException preparationFailure) {
|
||||
IOException failure = preparationFailure instanceof IOException ioFailure
|
||||
? ioFailure
|
||||
: new IOException("Failed preparing editable datapack import cleanup", preparationFailure);
|
||||
for (int i = prepared.size() - 1; i >= 0; i--) {
|
||||
try {
|
||||
prepared.get(i).removal().rollback();
|
||||
} catch (IOException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
static List<StructureTransactionWriter.OwnedRemoval> ownedImportRemovals(
|
||||
StructureTransactionWriter writer,
|
||||
Entry removingEntry,
|
||||
String targetId,
|
||||
Set<String> retainedKeys
|
||||
) throws IOException {
|
||||
List<StructureTransactionWriter.OwnedRemoval> removals = new ArrayList<>();
|
||||
Map<String, Set<String>> removingClaims = importBundleClaims(removingEntry, targetId);
|
||||
for (Map.Entry<String, Set<String>> bundle : removingClaims.entrySet()) {
|
||||
try {
|
||||
StructureKey targetKey = StructureKey.parse(bundle.getKey());
|
||||
if (retainedKeys.contains(bundle.getKey())) {
|
||||
continue;
|
||||
}
|
||||
Optional<StructureSource> ownedSource = writer.ownedSource(targetKey);
|
||||
if (ownedSource.isEmpty() || !sourceClaimsContain(bundle.getValue(), ownedSource.get())) {
|
||||
continue;
|
||||
}
|
||||
removals.add(StructureTransactionWriter.OwnedRemoval.managedDatapack(
|
||||
targetKey,
|
||||
ownedSource.get().kind(),
|
||||
ownedSource.get().key()
|
||||
));
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid editable structure import inventory entry '"
|
||||
+ bundle.getKey() + "' -> '" + bundle.getValue() + "'", e);
|
||||
}
|
||||
}
|
||||
return List.copyOf(removals);
|
||||
}
|
||||
|
||||
static Set<String> invalidateRetainedImportClaims(
|
||||
Entry removingEntry,
|
||||
String targetId,
|
||||
List<Entry> manifestEntries
|
||||
) {
|
||||
Set<String> removingKeys = importBundleClaims(removingEntry, targetId).keySet();
|
||||
Set<String> retainedKeys = new TreeSet<>();
|
||||
for (Entry candidate : manifestEntries) {
|
||||
if (candidate == removingEntry) {
|
||||
continue;
|
||||
}
|
||||
Set<String> candidateKeys = importBundleClaims(candidate, targetId).keySet();
|
||||
boolean candidateRetained = false;
|
||||
for (String candidateKey : candidateKeys) {
|
||||
if (removingKeys.contains(candidateKey)) {
|
||||
retainedKeys.add(candidateKey);
|
||||
candidateRetained = true;
|
||||
}
|
||||
}
|
||||
if (candidateRetained) {
|
||||
candidate.importedTargets.remove(targetId);
|
||||
candidate.importAttempts.remove(targetId);
|
||||
candidate.structuresImported = false;
|
||||
}
|
||||
}
|
||||
return Set.copyOf(retainedKeys);
|
||||
}
|
||||
|
||||
static Map<String, Set<String>> importBundleClaims(Entry entry, String targetId) {
|
||||
Map<String, Set<String>> claims = new TreeMap<>();
|
||||
addImportBundleClaims(claims, entry.importedBundles.getOrDefault(targetId, Map.of()));
|
||||
if (entry.importedBundles.containsKey(targetId) || entry.importedTargets.containsKey(targetId)) {
|
||||
addImportBundleClaims(claims, DatapackStructureImports.importBundleInventory(entry));
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
static void addImportBundleClaims(
|
||||
Map<String, Set<String>> claims,
|
||||
Map<String, String> inventory
|
||||
) {
|
||||
for (Map.Entry<String, String> bundle : inventory.entrySet()) {
|
||||
claims.computeIfAbsent(bundle.getKey(), ignored -> new TreeSet<>()).add(bundle.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
static boolean sourceClaimsContain(Set<String> claims, StructureSource source) throws IOException {
|
||||
for (String claimedKey : claims) {
|
||||
try {
|
||||
StructureKey sourceKey = StructureKey.parse(claimedKey);
|
||||
StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft")
|
||||
? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK;
|
||||
if (source.kind() == sourceKind && source.key().equals(sourceKey)) {
|
||||
return true;
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid editable structure source key '" + claimedKey + "'", e);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static DirectoryRemoval prepareOwnedDirectories(List<File> targets) throws IOException {
|
||||
List<DirectoryMove> planned = new ArrayList<>();
|
||||
for (File target : targets) {
|
||||
File parent = target.getParentFile();
|
||||
File backupRoot = new File(parent.getParentFile() == null ? parent : parent.getParentFile(), ".iris-datapack-remove");
|
||||
File backup = new File(backupRoot, target.getName() + "-" + UUID.randomUUID());
|
||||
DatapackSupport.ensureScratchDirectory(backupRoot, "datapack removal backup");
|
||||
DatapackInstall.validateInstallTree(target, parent, "Datapack removal target");
|
||||
planned.add(new DirectoryMove(
|
||||
target,
|
||||
backup,
|
||||
DatapackOwnership.directoryHash(target),
|
||||
DatapackOwnership.ownershipMarkerFingerprint(target),
|
||||
DatapackSupport.directoryIdentity(target),
|
||||
DatapackSupport.realDirectoryPath(parent, "datapack target root"),
|
||||
DatapackSupport.realDirectoryPath(backupRoot, "datapack removal scratch root"),
|
||||
DatapackSupport.directoryIdentity(parent),
|
||||
DatapackSupport.directoryIdentity(backupRoot)
|
||||
));
|
||||
}
|
||||
return new DirectoryRemoval(planned);
|
||||
}
|
||||
|
||||
static boolean rollbackRemoval(
|
||||
ManifestWrite manifestWrite,
|
||||
DirectoryRemoval directoryRemoval,
|
||||
EditableImportRemoval editableRemoval,
|
||||
Throwable removalFailure
|
||||
) {
|
||||
if (manifestWrite != null) {
|
||||
try {
|
||||
manifestWrite.discard();
|
||||
} catch (IOException discardFailure) {
|
||||
removalFailure.addSuppressed(discardFailure);
|
||||
}
|
||||
}
|
||||
if (directoryRemoval != null) {
|
||||
try {
|
||||
directoryRemoval.rollback();
|
||||
} catch (IOException rollbackFailure) {
|
||||
removalFailure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
if (editableRemoval != null) {
|
||||
try {
|
||||
editableRemoval.rollback();
|
||||
} catch (IOException rollbackFailure) {
|
||||
removalFailure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
return removalFailure.getSuppressed().length == 0;
|
||||
}
|
||||
|
||||
static void verifyOwnedDirectoryIfPresent(File directory, Entry entry) throws IOException {
|
||||
if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
if (Files.isSymbolicLink(directory.toPath())
|
||||
|| !Files.isDirectory(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Refusing non-directory or symbolic-link target " + directory.getPath());
|
||||
}
|
||||
Ownership ownership = DatapackOwnership.readOwnership(directory);
|
||||
if (!DatapackInstall.ownershipSourceMatches(ownership, entry)) {
|
||||
throw new IOException("Ownership marker at " + directory.getPath() + " belongs to '" + ownership.id + "'");
|
||||
}
|
||||
DatapackOwnership.removeFinderMetadata(directory);
|
||||
if (!Objects.equals(ownership.contentHash, DatapackOwnership.directoryHash(directory))) {
|
||||
throw new IOException("Refusing to remove modified or corrupt Iris-managed datapack " + directory.getPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileVisitResult;
|
||||
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;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
final class DatapackScratchRecovery {
|
||||
static final int WINDOWS_LEGACY_PATH_LIMIT = 247;
|
||||
|
||||
private DatapackScratchRecovery() {
|
||||
}
|
||||
|
||||
static boolean recoverTransactions(File root, List<File> worldFolders) throws IOException {
|
||||
boolean changed = recoverStagingScratch(new File(root, "staging"));
|
||||
File transactionDirectory = new File(root, DatapackTransactions.TRANSACTION_DIRECTORY);
|
||||
Path transactionPath = transactionDirectory.toPath();
|
||||
if (!Files.exists(transactionPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return recoverInstallScratch(root, worldFolders) | changed;
|
||||
}
|
||||
DatapackSupport.verifyDirectoryContainerIfPresent(transactionDirectory, "datapack transaction");
|
||||
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(root);
|
||||
List<Path> transactionRoots;
|
||||
try (Stream<Path> paths = Files.list(transactionPath)) {
|
||||
transactionRoots = paths.limit(DatapackTransactions.MAX_TRANSACTION_COUNT + 2L).sorted().toList();
|
||||
}
|
||||
if (transactionRoots.size() == DatapackTransactions.MAX_TRANSACTION_COUNT + 2) {
|
||||
throw new IOException("Datapack transaction directory contains too many entries");
|
||||
}
|
||||
int transactionCount = 0;
|
||||
for (Path transactionRoot : transactionRoots) {
|
||||
if (isHarmlessRecoveryArtifact(transactionRoot)) {
|
||||
continue;
|
||||
}
|
||||
transactionCount++;
|
||||
if (transactionCount > DatapackTransactions.MAX_TRANSACTION_COUNT) {
|
||||
throw new IOException("Datapack transaction count exceeds " + DatapackTransactions.MAX_TRANSACTION_COUNT);
|
||||
}
|
||||
}
|
||||
for (Path transactionRoot : transactionRoots) {
|
||||
if (isHarmlessRecoveryArtifact(transactionRoot)) {
|
||||
changed |= Files.deleteIfExists(transactionRoot);
|
||||
continue;
|
||||
}
|
||||
recoverTransaction(root, worldFolders, committedManifest, transactionPath, transactionRoot);
|
||||
changed = true;
|
||||
}
|
||||
changed |= transactionDirectory.delete();
|
||||
return recoverInstallScratch(root, worldFolders) | changed;
|
||||
}
|
||||
|
||||
static boolean recoverInstallScratch(File root, List<File> worldFolders) throws IOException {
|
||||
Set<Path> scratchRoots = new TreeSet<>();
|
||||
scratchRoots.add(DatapackInstall.installScratchRoot(new File(root, "staging")).toPath().toAbsolutePath().normalize());
|
||||
for (File worldFolder : worldFolders) {
|
||||
scratchRoots.add(DatapackInstall.installScratchRoot(worldFolder).toPath().toAbsolutePath().normalize());
|
||||
}
|
||||
boolean changed = false;
|
||||
for (Path scratchRoot : scratchRoots) {
|
||||
changed |= recoverInstallScratchRoot(scratchRoot);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
static boolean recoverInstallScratchRoot(Path scratchRoot) throws IOException {
|
||||
if (!Files.exists(scratchRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
DatapackSupport.verifyDirectoryContainerIfPresent(scratchRoot.toFile(), "datapack install scratch");
|
||||
List<Path> children;
|
||||
try (Stream<Path> paths = Files.list(scratchRoot)) {
|
||||
children = paths.limit(DatapackOwnership.MAX_MANAGED_PATHS + 2L).sorted().toList();
|
||||
}
|
||||
int managedEntries = 0;
|
||||
for (Path child : children) {
|
||||
if (!isHarmlessRecoveryArtifact(child)) {
|
||||
managedEntries++;
|
||||
}
|
||||
if (managedEntries > DatapackOwnership.MAX_MANAGED_PATHS) {
|
||||
throw new IOException("Datapack install scratch contains too many entries");
|
||||
}
|
||||
}
|
||||
|
||||
List<StagingScratch> pending = new ArrayList<>();
|
||||
List<StagingScratch> backups = new ArrayList<>();
|
||||
boolean changed = false;
|
||||
for (Path child : children) {
|
||||
if (isHarmlessRecoveryArtifact(child)) {
|
||||
changed |= Files.deleteIfExists(child);
|
||||
continue;
|
||||
}
|
||||
StagingScratch scratch = parseInstallScratch(scratchRoot, child);
|
||||
if (scratch == null) {
|
||||
throw new IOException("Unexpected datapack install scratch artifact " + child);
|
||||
}
|
||||
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid datapack install scratch artifact " + child);
|
||||
}
|
||||
validateScratchTree(child);
|
||||
if (scratch.kind() == StagingScratchKind.BACKUP) {
|
||||
backups.add(scratch);
|
||||
} else {
|
||||
pending.add(scratch);
|
||||
}
|
||||
}
|
||||
if (!backups.isEmpty()) {
|
||||
throw new IOException("Preserving unjournaled datapack install backup "
|
||||
+ backups.getFirst().path());
|
||||
}
|
||||
for (StagingScratch scratch : pending) {
|
||||
DatapackInstall.deleteInstallScratch(scratch.path().toFile(), "orphan datapack install pending directory");
|
||||
changed = true;
|
||||
}
|
||||
changed |= scratchRoot.toFile().delete();
|
||||
return changed;
|
||||
}
|
||||
|
||||
static StagingScratch parseInstallScratch(Path scratchRoot, Path child) throws IOException {
|
||||
Path normalized = child.toAbsolutePath().normalize();
|
||||
if (!Objects.equals(normalized.getParent(), scratchRoot)) {
|
||||
throw new IOException("Datapack install scratch artifact escapes its root: " + child);
|
||||
}
|
||||
String name = normalized.getFileName().toString();
|
||||
int uuidStart = name.length() - 36;
|
||||
if (uuidStart <= 1 || name.charAt(uuidStart - 1) != '-') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
UUID.fromString(name.substring(uuidStart));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
String stem = name.substring(0, uuidStart - 1);
|
||||
StagingScratchKind kind = stem.endsWith("-backup")
|
||||
? StagingScratchKind.BACKUP : StagingScratchKind.PENDING;
|
||||
String id = kind == StagingScratchKind.BACKUP
|
||||
? stem.substring(0, stem.length() - "-backup".length()) : stem;
|
||||
if (id.isBlank() || !id.equals(DatapackArchive.sanitizeId(id)) || DatapackArchive.RESERVED_IDS.contains(id)) {
|
||||
return null;
|
||||
}
|
||||
return new StagingScratch(kind, id, normalized);
|
||||
}
|
||||
|
||||
static boolean recoverStagingScratch(File stagingDirectory) throws IOException {
|
||||
Path stagingRoot = stagingDirectory.toPath().toAbsolutePath().normalize();
|
||||
if (!Files.exists(stagingRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
DatapackSupport.verifyDirectoryContainerIfPresent(stagingDirectory, "datapack staging");
|
||||
List<Path> children;
|
||||
try (Stream<Path> paths = Files.list(stagingRoot)) {
|
||||
children = paths.limit(DatapackOwnership.MAX_MANAGED_PATHS + 1L).sorted().toList();
|
||||
}
|
||||
if (children.size() > DatapackOwnership.MAX_MANAGED_PATHS) {
|
||||
throw new IOException("Datapack staging contains too many entries");
|
||||
}
|
||||
|
||||
List<StagingScratch> pending = new ArrayList<>();
|
||||
Map<String, List<StagingScratch>> backups = new TreeMap<>();
|
||||
for (Path child : children) {
|
||||
StagingScratch scratch = parseStagingScratch(stagingRoot, child);
|
||||
if (scratch == null) {
|
||||
continue;
|
||||
}
|
||||
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid datapack staging scratch artifact " + child);
|
||||
}
|
||||
if (scratch.kind() == StagingScratchKind.PENDING) {
|
||||
validateScratchTree(child);
|
||||
pending.add(scratch);
|
||||
} else {
|
||||
backups.computeIfAbsent(scratch.id(), ignored -> new ArrayList<>()).add(scratch);
|
||||
}
|
||||
}
|
||||
|
||||
for (Map.Entry<String, List<StagingScratch>> entry : backups.entrySet()) {
|
||||
if (entry.getValue().size() != 1) {
|
||||
throw new IOException("Ambiguous datapack staging backups for '" + entry.getKey() + "'");
|
||||
}
|
||||
StagingScratch backup = entry.getValue().getFirst();
|
||||
Ownership backupOwnership = verifyManagedScratchDirectory(backup.path().toFile(), backup.id());
|
||||
Path target = stagingRoot.resolve(backup.id()).normalize();
|
||||
if (!Objects.equals(target.getParent(), stagingRoot)) {
|
||||
throw new IOException("Datapack staging backup target escapes its root");
|
||||
}
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Ownership targetOwnership = verifyManagedScratchDirectory(target.toFile(), backup.id());
|
||||
if (!Objects.equals(backupOwnership.url, targetOwnership.url)) {
|
||||
throw new IOException("Datapack staging backup source does not match its target: " + target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boolean changed = false;
|
||||
for (List<StagingScratch> matches : backups.values()) {
|
||||
StagingScratch backup = matches.getFirst();
|
||||
Path target = stagingRoot.resolve(backup.id()).normalize();
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
DatapackCoordinatorResolution.deleteVerifiedDirectory(backup.path().toFile());
|
||||
} else {
|
||||
DatapackSupport.moveNew(backup.path(), target);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
for (StagingScratch scratch : pending) {
|
||||
DatapackCoordinatorResolution.deleteVerifiedDirectory(scratch.path().toFile());
|
||||
changed = true;
|
||||
}
|
||||
DatapackSupport.forceDirectoryIfSupported(stagingRoot);
|
||||
return changed;
|
||||
}
|
||||
|
||||
static StagingScratch parseStagingScratch(Path stagingRoot, Path child) throws IOException {
|
||||
Path normalized = child.toAbsolutePath().normalize();
|
||||
if (!Objects.equals(normalized.getParent(), stagingRoot)) {
|
||||
throw new IOException("Datapack staging artifact escapes its root: " + child);
|
||||
}
|
||||
String name = normalized.getFileName().toString();
|
||||
StagingScratchKind kind;
|
||||
String prefix;
|
||||
if (name.startsWith(".pending-")) {
|
||||
kind = StagingScratchKind.PENDING;
|
||||
prefix = ".pending-";
|
||||
} else if (name.startsWith(".backup-")) {
|
||||
kind = StagingScratchKind.BACKUP;
|
||||
prefix = ".backup-";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
int uuidStart = name.length() - 36;
|
||||
if (uuidStart <= prefix.length() || name.charAt(uuidStart - 1) != '-') {
|
||||
return null;
|
||||
}
|
||||
String id = name.substring(prefix.length(), uuidStart - 1);
|
||||
if (!id.equals(DatapackArchive.sanitizeId(id)) || DatapackArchive.RESERVED_IDS.contains(id)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
UUID.fromString(name.substring(uuidStart));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
return new StagingScratch(kind, id, normalized);
|
||||
}
|
||||
|
||||
static void validateScratchTree(Path root) throws IOException {
|
||||
FileStore rootStore = Files.getFileStore(root);
|
||||
int[] pathCount = new int[]{0};
|
||||
Files.walkFileTree(root, new SimpleFileVisitor<>() {
|
||||
private FileVisitResult inspect(Path entry, BasicFileAttributes attributes) throws IOException {
|
||||
pathCount[0]++;
|
||||
if (pathCount[0] > DatapackOwnership.MAX_MANAGED_PATHS) {
|
||||
throw new IOException("Datapack scratch contains too many paths: " + root);
|
||||
}
|
||||
if (attributes.isSymbolicLink()
|
||||
|| attributes.isOther()
|
||||
|| (!attributes.isDirectory() && !attributes.isRegularFile())) {
|
||||
throw new IOException("Datapack scratch contains an unsupported file: " + entry);
|
||||
}
|
||||
if (!DatapackOwnership.sameDatapackVolume(root, rootStore, entry)) {
|
||||
throw new IOException("Datapack scratch crosses a filesystem boundary: " + entry);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(
|
||||
Path directory,
|
||||
BasicFileAttributes attributes
|
||||
) throws IOException {
|
||||
return inspect(directory, attributes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
|
||||
return inspect(file, attributes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
|
||||
throw new IOException("Unable to inspect datapack scratch entry: " + file, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
static boolean isDefaultWindowsPath(Path path) {
|
||||
return File.separatorChar == '\\'
|
||||
&& path.getFileSystem().equals(FileSystems.getDefault());
|
||||
}
|
||||
|
||||
static Ownership verifyManagedScratchDirectory(File directory, String id) throws IOException {
|
||||
DatapackOwnership.validateManagedDirectory(directory, id);
|
||||
Ownership ownership = DatapackOwnership.readOwnership(directory);
|
||||
if (!Objects.equals(ownership.contentHash, DatapackOwnership.directoryHash(directory))) {
|
||||
throw new IOException("Datapack staging backup is modified or corrupt: " + directory.getPath());
|
||||
}
|
||||
return ownership;
|
||||
}
|
||||
|
||||
static boolean isHarmlessRecoveryArtifact(Path path) throws IOException {
|
||||
if (!DatapackOwnership.isFinderMetadata(path)) {
|
||||
return false;
|
||||
}
|
||||
if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Suspicious datapack recovery artifact " + path);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void recoverTransaction(
|
||||
File root,
|
||||
List<File> worldFolders,
|
||||
Manifest committedManifest,
|
||||
Path transactionDirectory,
|
||||
Path transactionRoot
|
||||
) throws IOException {
|
||||
Path normalizedRoot = transactionRoot.toAbsolutePath().normalize();
|
||||
if (!Objects.equals(normalizedRoot.getParent(), transactionDirectory.toAbsolutePath().normalize())
|
||||
|| Files.isSymbolicLink(normalizedRoot)
|
||||
|| !Files.isDirectory(normalizedRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid datapack transaction directory " + transactionRoot);
|
||||
}
|
||||
try {
|
||||
UUID.fromString(normalizedRoot.getFileName().toString());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IOException("Invalid datapack transaction directory name " + transactionRoot, e);
|
||||
}
|
||||
CoordinatorJournal journal = DatapackTransactions.readCoordinatorJournal(normalizedRoot);
|
||||
if (journal == null) {
|
||||
DatapackTransactions.deleteCoordinatorTransaction(normalizedRoot);
|
||||
return;
|
||||
}
|
||||
DatapackTransactions.validateCoordinatorJournal(root, worldFolders, committedManifest, normalizedRoot, journal);
|
||||
boolean commit = DatapackCoordinatorResolution.coordinatorCommitDecision(committedManifest, journal);
|
||||
DatapackCoordinatorResolution.resolveCoordinatorDirectories(journal, commit);
|
||||
DatapackCoordinatorResolution.resolveCoordinatorEditables(journal, normalizedRoot, commit);
|
||||
DatapackTransactions.deleteCoordinatorTransaction(normalizedRoot);
|
||||
}
|
||||
|
||||
enum StagingScratchKind {
|
||||
PENDING,
|
||||
BACKUP
|
||||
}
|
||||
|
||||
record StagingScratch(StagingScratchKind kind, String id, Path path) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Report;
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
final class DatapackStagingGuard {
|
||||
|
||||
private DatapackStagingGuard() {
|
||||
}
|
||||
|
||||
static VerifiedStagingInstall authorizeVerifiedStagingInstall(
|
||||
File root,
|
||||
File stagingRoot,
|
||||
File verifiedSource,
|
||||
Entry entry
|
||||
) throws IOException {
|
||||
Path normalizedRoot = DatapackSupport.requireDirectoryIdentity(root, "datapack storage root");
|
||||
Path normalizedStagingRoot = DatapackSupport.requireDirectoryIdentity(stagingRoot, "datapack staging root");
|
||||
DatapackSupport.requireNoSymbolicLinkComponents(normalizedRoot, "datapack storage root");
|
||||
DatapackSupport.requireNoSymbolicLinkComponents(normalizedStagingRoot, "datapack staging root");
|
||||
Path expectedStagingRoot = normalizedRoot.resolve("staging").normalize();
|
||||
if (!normalizedStagingRoot.equals(expectedStagingRoot)
|
||||
|| !Files.isSameFile(normalizedStagingRoot, expectedStagingRoot)) {
|
||||
throw new IOException("Verified datapack staging root is not Iris's canonical staging directory");
|
||||
}
|
||||
Path normalizedSource = DatapackSupport.requireDirectoryIdentity(verifiedSource, "verified datapack extraction");
|
||||
DatapackSupport.requireNoSymbolicLinkComponents(normalizedSource, "verified datapack extraction");
|
||||
if (!Objects.equals(normalizedSource.getParent(), normalizedStagingRoot)
|
||||
|| !Files.isSameFile(normalizedSource.getParent(), normalizedStagingRoot)) {
|
||||
throw new IOException("Verified datapack extraction is outside Iris's canonical staging directory");
|
||||
}
|
||||
verifyPendingExtractionName(normalizedSource.getFileName().toString(), entry.id);
|
||||
DatapackOwnership.validateManagedDirectory(verifiedSource, entry.id);
|
||||
DatapackScratchRecovery.validateScratchTree(normalizedSource);
|
||||
if (!DatapackScratchRecovery.sameScratchVolume(normalizedSource, normalizedStagingRoot)) {
|
||||
throw new IOException("Verified datapack extraction crosses a filesystem boundary");
|
||||
}
|
||||
String desiredHash = DatapackOwnership.directoryHash(verifiedSource);
|
||||
Ownership ownership = DatapackOwnership.readOwnership(verifiedSource);
|
||||
if (!DatapackInstall.ownershipMetadataMatches(ownership, entry, desiredHash)) {
|
||||
throw new IOException("Verified datapack extraction does not match the resolved archive metadata");
|
||||
}
|
||||
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(root);
|
||||
Entry committed = committedManifest.findById(entry.id);
|
||||
boolean legacyReplacementAuthorized = committed != null
|
||||
&& Objects.equals(committed.id, entry.id)
|
||||
&& Objects.equals(committed.url, entry.url);
|
||||
LegacyStagingSnapshot legacyStagingSnapshot = captureLegacyStagingSnapshot(
|
||||
normalizedStagingRoot, entry.id, legacyReplacementAuthorized);
|
||||
return new VerifiedStagingInstall(
|
||||
normalizedRoot,
|
||||
normalizedStagingRoot,
|
||||
normalizedSource,
|
||||
entry,
|
||||
desiredHash,
|
||||
committed,
|
||||
legacyReplacementAuthorized,
|
||||
legacyStagingSnapshot
|
||||
);
|
||||
}
|
||||
|
||||
static LegacyStagingSnapshot captureLegacyStagingSnapshot(
|
||||
Path normalizedStagingRoot,
|
||||
String id,
|
||||
boolean legacyReplacementAuthorized
|
||||
) throws IOException {
|
||||
if (!legacyReplacementAuthorized) {
|
||||
return null;
|
||||
}
|
||||
Path target = normalizedStagingRoot.resolve(id).normalize();
|
||||
if (!DatapackSupport.pathExists(target, "legacy datapack staging target")) {
|
||||
return null;
|
||||
}
|
||||
if (Files.isSymbolicLink(target)
|
||||
|| !Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid legacy datapack staging target " + target);
|
||||
}
|
||||
DatapackInstall.validateInstallTree(target.toFile(), normalizedStagingRoot.toFile(), "Legacy datapack staging");
|
||||
String markerHash = DatapackOwnership.ownershipMarkerFingerprint(target.toFile());
|
||||
if (!"absent".equals(markerHash)) {
|
||||
return null;
|
||||
}
|
||||
return new LegacyStagingSnapshot(
|
||||
target,
|
||||
target.toRealPath(),
|
||||
DatapackSupport.directoryIdentity(target.toFile()),
|
||||
DatapackOwnership.directoryHash(target.toFile()),
|
||||
markerHash
|
||||
);
|
||||
}
|
||||
|
||||
static void verifyPendingExtractionName(String name, String id) throws IOException {
|
||||
String prefix = ".pending-" + id + "-";
|
||||
if (!name.startsWith(prefix)) {
|
||||
throw new IOException("Verified datapack extraction has an invalid staging name " + name);
|
||||
}
|
||||
try {
|
||||
UUID.fromString(name.substring(prefix.length()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IOException("Verified datapack extraction has an invalid staging identity " + name, e);
|
||||
}
|
||||
}
|
||||
|
||||
static void validateInstallParticipants(
|
||||
List<File> worldFolders,
|
||||
VerifiedStagingInstall verifiedStagingInstall,
|
||||
Entry entry
|
||||
) throws IOException {
|
||||
List<File> roots = new ArrayList<>(worldFolders);
|
||||
if (verifiedStagingInstall != null) {
|
||||
roots.add(verifiedStagingInstall.stagingRoot().toFile());
|
||||
}
|
||||
Set<Path> normalizedTargets = new HashSet<>();
|
||||
Set<Path> realRoots = new HashSet<>();
|
||||
Set<Path> targetIdentities = new HashSet<>();
|
||||
List<Path> existingTargets = new ArrayList<>();
|
||||
for (File folder : roots) {
|
||||
ensureInstallTargetRoot(folder);
|
||||
Path normalizedRoot = folder.toPath().toAbsolutePath().normalize();
|
||||
Path realRoot = normalizedRoot.toRealPath();
|
||||
Path normalizedTarget = normalizedRoot.resolve(entry.id).normalize();
|
||||
if (!realRoots.add(realRoot) || !normalizedTargets.add(normalizedTarget)) {
|
||||
throw new IOException("Duplicate datapack install target " + normalizedTarget);
|
||||
}
|
||||
Path targetIdentity = realRoot.resolve(entry.id).normalize();
|
||||
if (Files.exists(normalizedTarget, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.isSymbolicLink(normalizedTarget)
|
||||
|| !Files.isDirectory(normalizedTarget, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Refusing invalid datapack install target " + normalizedTarget);
|
||||
}
|
||||
targetIdentity = normalizedTarget.toRealPath();
|
||||
for (Path existingTarget : existingTargets) {
|
||||
if (Files.isSameFile(normalizedTarget, existingTarget)) {
|
||||
throw new IOException("Aliased datapack install target " + normalizedTarget);
|
||||
}
|
||||
}
|
||||
existingTargets.add(normalizedTarget);
|
||||
}
|
||||
if (!targetIdentities.add(targetIdentity)) {
|
||||
throw new IOException("Aliased datapack install target " + normalizedTarget);
|
||||
}
|
||||
}
|
||||
if (verifiedStagingInstall != null) {
|
||||
verifiedStagingInstall.verifyStagingRoot();
|
||||
}
|
||||
}
|
||||
|
||||
static void ensureInstallTargetRoot(File directory) throws IOException {
|
||||
if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectories(directory.toPath());
|
||||
}
|
||||
DatapackSupport.requireDirectoryIdentity(directory, "datapack install root");
|
||||
}
|
||||
|
||||
static void recordInstallResult(
|
||||
VolmitSender sender,
|
||||
Report report,
|
||||
File stagedDir,
|
||||
KList<File> worldFolders,
|
||||
Entry entry,
|
||||
InstallResult result,
|
||||
String versionNumber
|
||||
) {
|
||||
DatapackStagingReapply.recordInstallMetadata(stagedDir, worldFolders, entry);
|
||||
if (result.changed()) {
|
||||
report.updated.add(entry.id + " (" + DatapackSupport.safe(versionNumber) + ")");
|
||||
report.requiresRestart = true;
|
||||
DatapackSupport.message(sender, C.GREEN + " Repaired " + C.WHITE + entry.id + C.GREEN + " " + DatapackSupport.safe(versionNumber));
|
||||
return;
|
||||
}
|
||||
report.upToDate.add(entry.id + " (" + DatapackSupport.safe(versionNumber) + ")");
|
||||
DatapackSupport.message(sender, C.GRAY + " Up to date: " + C.WHITE + entry.id + C.GRAY + " " + DatapackSupport.safe(versionNumber));
|
||||
}
|
||||
|
||||
static boolean isUsableStaging(File stagedDir, Entry entry) {
|
||||
return inspectUsableStaging(stagedDir, entry).usable();
|
||||
}
|
||||
|
||||
static StagingInspection inspectUsableStaging(File stagedDir, Entry entry) {
|
||||
try {
|
||||
DatapackOwnership.validateManagedDirectory(stagedDir, entry.id);
|
||||
Ownership ownership = DatapackOwnership.readOwnership(stagedDir);
|
||||
String contentHash = DatapackOwnership.directoryHash(stagedDir);
|
||||
if (!DatapackInstall.ownershipSourceMatches(ownership, entry)
|
||||
|| !Objects.equals(ownership.versionId, entry.versionId)
|
||||
|| !Objects.equals(ownership.versionNumber, entry.versionNumber)
|
||||
|| !Objects.equals(ownership.sha1, entry.sha1)
|
||||
|| !Objects.equals(ownership.contentHash, contentHash)) {
|
||||
return new StagingInspection(false, false, false);
|
||||
}
|
||||
PackResources resources = DatapackOwnership.scanPackResources(stagedDir);
|
||||
List<String> previousStructureKeys = DatapackManifestStore.copyList(entry.structureKeys);
|
||||
List<String> previousTemplateKeys = DatapackManifestStore.copyList(entry.templateKeys);
|
||||
boolean ownershipCorrected = false;
|
||||
if (!DatapackManifestStore.copyList(resources.structureKeys()).equals(DatapackManifestStore.copyList(ownership.structureKeys))
|
||||
|| !DatapackManifestStore.copyList(resources.templateKeys()).equals(DatapackManifestStore.copyList(ownership.templateKeys))) {
|
||||
Entry corrected = DatapackManifestStore.copyEntry(entry);
|
||||
corrected.structureKeys = resources.structureKeys();
|
||||
corrected.templateKeys = resources.templateKeys();
|
||||
DatapackOwnership.writeOwnership(stagedDir, corrected);
|
||||
ownershipCorrected = true;
|
||||
}
|
||||
entry.structureKeys = resources.structureKeys();
|
||||
entry.templateKeys = resources.templateKeys();
|
||||
boolean manifestChanged = !previousStructureKeys.equals(DatapackManifestStore.copyList(entry.structureKeys))
|
||||
|| !previousTemplateKeys.equals(DatapackManifestStore.copyList(entry.templateKeys));
|
||||
return new StagingInspection(true, ownershipCorrected, manifestChanged);
|
||||
} catch (IOException e) {
|
||||
IrisLogging.warn("Ignoring unusable Iris datapack staging at " + stagedDir.getPath() + ": " + e.getMessage());
|
||||
return new StagingInspection(false, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
record LegacyStagingSnapshot(
|
||||
Path normalizedTarget,
|
||||
Path realTarget,
|
||||
String targetIdentity,
|
||||
String contentHash,
|
||||
String markerHash
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.ReapplyOutcome;
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
final class DatapackStagingReapply {
|
||||
|
||||
private DatapackStagingReapply() {
|
||||
}
|
||||
|
||||
static ReapplyOutcome reapplyFromStagingLocked(KList<File> worldFolders) {
|
||||
File root = IrisPlatforms.get().dataFolder("datapacks");
|
||||
ReapplyOutcome recovery = recoverBeforeReapplyOutcome(root, worldFolders);
|
||||
if (!recovery.succeeded()) {
|
||||
return reportReapplyFailure(recovery);
|
||||
}
|
||||
File stagingDir = IrisPlatforms.get().dataFolderNoCreate("datapacks", "staging");
|
||||
ReapplyOutcome repair = reapplyStagingRootOutcome(
|
||||
root,
|
||||
stagingDir,
|
||||
worldFolders,
|
||||
DatapackPackMetadata.resolveStripOverrides());
|
||||
if (!repair.succeeded()) {
|
||||
return reportReapplyFailure(repair);
|
||||
}
|
||||
return ReapplyOutcome.success(recovery.recovered(), repair.repaired());
|
||||
}
|
||||
|
||||
static boolean reapplyStagingRoot(
|
||||
File root,
|
||||
File stagingDir,
|
||||
KList<File> worldFolders,
|
||||
boolean stripOverrides
|
||||
) {
|
||||
return reportReapplyFailure(reapplyStagingRootOutcome(
|
||||
root,
|
||||
stagingDir,
|
||||
worldFolders,
|
||||
stripOverrides)).succeeded();
|
||||
}
|
||||
|
||||
static ReapplyOutcome reapplyStagingRootOutcome(
|
||||
File root,
|
||||
File stagingDir,
|
||||
KList<File> worldFolders,
|
||||
boolean stripOverrides
|
||||
) {
|
||||
Manifest manifest = DatapackManifestStore.readManifest(root);
|
||||
if (stagingDir == null
|
||||
|| !Files.exists(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (manifest.entries.isEmpty()) {
|
||||
return ReapplyOutcome.success(false, false);
|
||||
}
|
||||
File missing = stagingDir == null ? new File(root, "staging") : stagingDir;
|
||||
return ReapplyOutcome.failed(new IOException(
|
||||
"Managed datapack staging is missing at " + missing.getPath()));
|
||||
}
|
||||
if (Files.isSymbolicLink(stagingDir.toPath())
|
||||
|| !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
return ReapplyOutcome.failed(new IOException(
|
||||
"Managed datapack staging is not a safe directory at " + stagingDir.getPath()));
|
||||
}
|
||||
return reapplyStagedDirectoriesOutcome(
|
||||
root, stagingDir, worldFolders, stripOverrides, manifest);
|
||||
}
|
||||
|
||||
static boolean reapplyStagedDirectories(
|
||||
File root,
|
||||
File stagingDir,
|
||||
KList<File> worldFolders,
|
||||
boolean stripOverrides
|
||||
) {
|
||||
if (Files.isSymbolicLink(stagingDir.toPath())
|
||||
|| !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
return reportReapplyFailure(ReapplyOutcome.failed(new IOException(
|
||||
"Managed datapack staging is not a safe directory at "
|
||||
+ stagingDir.getPath()))).succeeded();
|
||||
}
|
||||
return reportReapplyFailure(reapplyStagedDirectoriesOutcome(
|
||||
root,
|
||||
stagingDir,
|
||||
worldFolders,
|
||||
stripOverrides,
|
||||
DatapackManifestStore.readManifest(root))).succeeded();
|
||||
}
|
||||
|
||||
static ReapplyOutcome reapplyStagedDirectoriesOutcome(
|
||||
File root,
|
||||
File stagingDir,
|
||||
KList<File> worldFolders,
|
||||
boolean stripOverrides,
|
||||
Manifest manifest
|
||||
) {
|
||||
File[] staged = stagingDir.listFiles(File::isDirectory);
|
||||
if (staged == null) {
|
||||
return ReapplyOutcome.failed(new IOException(
|
||||
"Unable to enumerate managed datapack staging at " + stagingDir.getPath()));
|
||||
}
|
||||
boolean repaired = false;
|
||||
IOException failure = null;
|
||||
for (Entry entry : manifest.entries) {
|
||||
File stagedDir = new File(stagingDir, entry.id);
|
||||
if (isRecordedUnchangedInstall(stagedDir, worldFolders, entry, stripOverrides)) {
|
||||
continue;
|
||||
}
|
||||
if (!DatapackStagingGuard.isUsableStaging(stagedDir, entry)) {
|
||||
forgetInstallMetadata(entry);
|
||||
failure = DatapackSupport.appendFailure(failure, new IOException(
|
||||
"Managed datapack staging is unusable for '" + entry.id
|
||||
+ "' at " + stagedDir.getPath()));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
InstallResult result = DatapackInstall.install(stagedDir, worldFolders, entry, stripOverrides);
|
||||
if (result.changed()) {
|
||||
repaired = true;
|
||||
IrisLogging.warn("Repaired installed datapack '" + entry.id
|
||||
+ "' from Iris staging before datapack compilation.");
|
||||
}
|
||||
recordInstallMetadata(stagedDir, worldFolders, entry);
|
||||
} catch (IOException e) {
|
||||
forgetInstallMetadata(entry);
|
||||
failure = DatapackSupport.appendFailure(failure, e);
|
||||
}
|
||||
}
|
||||
DatapackManifestStore.writeManifest(root, manifest);
|
||||
return failure == null
|
||||
? ReapplyOutcome.success(false, repaired)
|
||||
: ReapplyOutcome.failed(failure);
|
||||
}
|
||||
|
||||
static ReapplyOutcome reportReapplyFailure(ReapplyOutcome outcome) {
|
||||
if (!outcome.succeeded()) {
|
||||
IrisLogging.reportError(
|
||||
"External datapack recovery or staging repair failed.",
|
||||
outcome.failure().orElseThrow());
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
static boolean isRecordedUnchangedInstall(
|
||||
File stagedDir,
|
||||
KList<File> worldFolders,
|
||||
Entry entry,
|
||||
boolean stripOverrides
|
||||
) {
|
||||
if (entry.stagingMetadata == null || entry.stagingMetadata.isBlank()
|
||||
|| entry.installMetadata == null || entry.installMetadata.size() != worldFolders.size()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!isRecordedManagedDirectory(stagedDir, entry)
|
||||
|| !entry.stagingMetadata.equals(metadataDigest(stagedDir))) {
|
||||
return false;
|
||||
}
|
||||
for (File worldFolder : worldFolders) {
|
||||
File target = new File(worldFolder, entry.id);
|
||||
if (!isRecordedManagedDirectory(target, entry)
|
||||
|| new File(target, DatapackPackMetadata.OVERRIDES_STRIPPED_MARKER).isFile() != stripOverrides) {
|
||||
return false;
|
||||
}
|
||||
String recorded = entry.installMetadata.get(installMetadataKey(target));
|
||||
if (recorded == null || !recorded.equals(metadataDigest(target))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError("Managed datapack '" + entry.id
|
||||
+ "' requires full verification", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isRecordedManagedDirectory(File directory, Entry entry) throws IOException {
|
||||
Path path = directory.toPath();
|
||||
if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)) {
|
||||
return false;
|
||||
}
|
||||
if (!new File(directory, "pack.mcmeta").isFile()) {
|
||||
return false;
|
||||
}
|
||||
Ownership ownership = DatapackOwnership.readOwnershipOrNull(directory);
|
||||
return ownership != null
|
||||
&& DatapackInstall.ownershipSourceMatches(ownership, entry)
|
||||
&& Objects.equals(ownership.versionId, entry.versionId)
|
||||
&& Objects.equals(ownership.versionNumber, entry.versionNumber)
|
||||
&& Objects.equals(ownership.sha1, entry.sha1);
|
||||
}
|
||||
|
||||
static void recordInstallMetadata(File stagedDir, KList<File> worldFolders, Entry entry) {
|
||||
try {
|
||||
Map<String, String> recorded = new HashMap<>();
|
||||
for (File worldFolder : worldFolders) {
|
||||
File target = new File(worldFolder, entry.id);
|
||||
recorded.put(installMetadataKey(target), metadataDigest(target));
|
||||
}
|
||||
entry.stagingMetadata = metadataDigest(stagedDir);
|
||||
entry.installMetadata = recorded;
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError("Unable to record managed datapack metadata for '" + entry.id + "'", e);
|
||||
forgetInstallMetadata(entry);
|
||||
}
|
||||
}
|
||||
|
||||
static void forgetInstallMetadata(Entry entry) {
|
||||
entry.stagingMetadata = "";
|
||||
entry.installMetadata = new HashMap<>();
|
||||
}
|
||||
|
||||
static String installMetadataKey(File target) {
|
||||
return target.toPath().toAbsolutePath().normalize().toString();
|
||||
}
|
||||
|
||||
static String metadataDigest(File root) throws IOException {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
Path rootPath = root.toPath().toAbsolutePath().normalize();
|
||||
List<MetadataEntry> entries = new ArrayList<>();
|
||||
try (Stream<Path> paths = Files.walk(rootPath)) {
|
||||
Iterator<Path> iterator = paths.iterator();
|
||||
int pathCount = 0;
|
||||
while (iterator.hasNext()) {
|
||||
Path path = iterator.next();
|
||||
if (path.equals(rootPath) || DatapackOwnership.isFinderMetadata(path)) {
|
||||
continue;
|
||||
}
|
||||
pathCount++;
|
||||
if (pathCount > DatapackOwnership.MAX_MANAGED_PATHS) {
|
||||
throw new IOException("Datapack contains more than " + DatapackOwnership.MAX_MANAGED_PATHS + " paths");
|
||||
}
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new IOException("Datapack contains a symbolic link: " + path);
|
||||
}
|
||||
String relativePath = rootPath.relativize(path).toString();
|
||||
entries.add(new MetadataEntry(path, relativePath));
|
||||
}
|
||||
}
|
||||
entries.sort(Comparator.comparing(MetadataEntry::relativePath));
|
||||
for (MetadataEntry entry : entries) {
|
||||
String relative = entry.relativePath().replace(File.separatorChar, '/');
|
||||
byte[] relativeBytes = relative.getBytes(StandardCharsets.UTF_8);
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
entry.path(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
if (!attributes.isDirectory() && !attributes.isRegularFile()) {
|
||||
throw new IOException("Datapack contains an unsupported filesystem entry: " + entry.path());
|
||||
}
|
||||
digest.update((byte) (attributes.isDirectory() ? 1 : 2));
|
||||
DatapackSupport.updateDigestInt(digest, relativeBytes.length);
|
||||
digest.update(relativeBytes);
|
||||
if (!attributes.isDirectory()) {
|
||||
DatapackSupport.updateDigestLong(digest, attributes.size());
|
||||
DatapackSupport.updateDigestLong(digest, attributes.lastModifiedTime().toMillis());
|
||||
}
|
||||
}
|
||||
return DatapackSupport.hex(digest.digest());
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IOException("SHA-256 algorithm unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean recoverBeforeReapply(File root, List<File> worldFolders) {
|
||||
return reportReapplyFailure(recoverBeforeReapplyOutcome(root, worldFolders)).succeeded();
|
||||
}
|
||||
|
||||
static ReapplyOutcome recoverBeforeReapplyOutcome(File root, List<File> worldFolders) {
|
||||
try {
|
||||
return ReapplyOutcome.success(DatapackScratchRecovery.recoverTransactions(root, worldFolders), false);
|
||||
} catch (IOException e) {
|
||||
return ReapplyOutcome.failed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.core.IrisStartupValidation;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
final class DatapackStartupValidation {
|
||||
static final String STARTUP_VALIDATION_CACHE = "startup-validation.json";
|
||||
|
||||
static final int STARTUP_VALIDATION_SCHEMA = 1;
|
||||
|
||||
static volatile StartupValidationCache activeStartupValidation;
|
||||
|
||||
private DatapackStartupValidation() {
|
||||
}
|
||||
|
||||
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, urls));
|
||||
writeStartupValidationCache(cacheFile, cache);
|
||||
return cache;
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
IrisLogging.warn("Could not persist external datapack startup validation: "
|
||||
+ DatapackSupport.failureMessage(exception));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void refreshStartupValidationAfterMaintenance(boolean maintenanceChanged) {
|
||||
if (!maintenanceChanged) {
|
||||
return;
|
||||
}
|
||||
StartupValidationCache validated = activeStartupValidation;
|
||||
if (validated == null || !IrisStartupValidation.isReady()) {
|
||||
return;
|
||||
}
|
||||
KList<String> configured = DatapackIngestService.collectConfiguredImports();
|
||||
List<String> urls = configured.stream().sorted().toList();
|
||||
boolean autoIngest = IrisSettings.get().getGeneral().autoIngestDatapacks;
|
||||
boolean stripOverrides = DatapackPackMetadata.resolveStripOverrides();
|
||||
String mcVersion = DatapackIngestService.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();
|
||||
DatapackTransactions.TRANSACTION_LOCK.lock();
|
||||
try {
|
||||
DatapackScratchRecovery.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: "
|
||||
+ DatapackSupport.failureMessage(exception));
|
||||
} finally {
|
||||
DatapackTransactions.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, validated.urls));
|
||||
}
|
||||
|
||||
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) > DatapackPackMetadata.MAX_METADATA_BYTES) {
|
||||
return null;
|
||||
}
|
||||
StartupValidationCache cache = DatapackSupport.GSON.fromJson(
|
||||
DatapackSupport.readBoundedUtf8(cacheFile, DatapackPackMetadata.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;
|
||||
}
|
||||
}
|
||||
|
||||
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 = DatapackSupport.GSON.toJson(cache).getBytes(StandardCharsets.UTF_8);
|
||||
if (content.length > DatapackPackMetadata.MAX_METADATA_BYTES) {
|
||||
throw new IOException("External datapack startup validation exceeds " + DatapackPackMetadata.MAX_METADATA_BYTES + " bytes");
|
||||
}
|
||||
Files.write(staged, content, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
DatapackSupport.forceFile(staged);
|
||||
try {
|
||||
Files.move(staged, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(staged, absolute, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
DatapackSupport.forceDirectoryIfSupported(parent);
|
||||
} finally {
|
||||
Files.deleteIfExists(staged);
|
||||
}
|
||||
}
|
||||
|
||||
static String startupValidationFingerprint(File root, KList<File> worldFolders) throws IOException {
|
||||
return startupValidationFingerprint(root, worldFolders, List.of());
|
||||
}
|
||||
|
||||
static String startupValidationFingerprint(
|
||||
File root,
|
||||
KList<File> worldFolders,
|
||||
Iterable<String> sources
|
||||
) 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 = DatapackSupport.readBoundedBytes(
|
||||
manifestPath, DatapackManifestStore.MAX_MANIFEST_BYTES, "Datapack manifest fingerprint");
|
||||
DatapackSupport.updateDigestLong(digest, manifest.length);
|
||||
digest.update(manifest);
|
||||
} else if (Files.notExists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
DatapackSupport.updateDigestLong(digest, -1L);
|
||||
} else {
|
||||
throw new IOException("Cannot determine datapack manifest state at " + manifestPath);
|
||||
}
|
||||
|
||||
Manifest manifest = DatapackManifestStore.readCommittedManifest(root);
|
||||
updateDirectoryFingerprint(digest, "staging", new File(root, "staging"));
|
||||
updateDirectoryFingerprint(digest, "transactions", new File(root, DatapackTransactions.TRANSACTION_DIRECTORY));
|
||||
updateDirectoryFingerprint(
|
||||
digest,
|
||||
"storage-install-scratch",
|
||||
DatapackInstall.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,
|
||||
DatapackInstall.installScratchRoot(worldFolder));
|
||||
for (Entry entry : entries) {
|
||||
updateDirectoryFingerprint(
|
||||
digest,
|
||||
"world-pack:" + worldIdentity + ":" + entry.id,
|
||||
new File(worldFolder, entry.id));
|
||||
}
|
||||
}
|
||||
updateLocalSourceFingerprint(digest, sources);
|
||||
return DatapackSupport.hex(digest.digest());
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IOException("SHA-256 algorithm unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
static void updateLocalSourceFingerprint(
|
||||
MessageDigest digest,
|
||||
Iterable<String> sources
|
||||
) throws IOException {
|
||||
if (sources == null) {
|
||||
return;
|
||||
}
|
||||
List<String> localSources = new ArrayList<>();
|
||||
for (String source : sources) {
|
||||
URI uri = parseSourceUri(source);
|
||||
if (uri != null && "file".equalsIgnoreCase(uri.getScheme())) {
|
||||
localSources.add(uri.normalize().toASCIIString());
|
||||
}
|
||||
}
|
||||
localSources.sort(String::compareTo);
|
||||
byte[] buffer = new byte[DatapackOwnership.HASH_BUFFER_BYTES];
|
||||
for (String source : localSources) {
|
||||
Path path = DatapackArchive.requireLocalDatapackPath(parseSourceUri(source));
|
||||
updateFingerprintValue(digest, "local-source:" + source);
|
||||
try (InputStream input = Files.newInputStream(
|
||||
path,
|
||||
StandardOpenOption.READ,
|
||||
LinkOption.NOFOLLOW_LINKS)) {
|
||||
long bytes = 0;
|
||||
int length;
|
||||
while ((length = input.read(buffer)) > 0) {
|
||||
bytes += length;
|
||||
if (bytes > DatapackArchive.MAX_DOWNLOAD_BYTES) {
|
||||
throw new IOException("Local datapack exceeds " + DatapackArchive.MAX_DOWNLOAD_BYTES + " bytes: " + path);
|
||||
}
|
||||
digest.update(buffer, 0, length);
|
||||
}
|
||||
DatapackSupport.updateDigestLong(digest, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static URI parseSourceUri(String source) throws IOException {
|
||||
if (source == null || source.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URI(source.trim());
|
||||
} catch (URISyntaxException exception) {
|
||||
throw new IOException("Invalid datapack URL " + source, exception);
|
||||
}
|
||||
}
|
||||
|
||||
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, DatapackOwnership.directoryHash(directory));
|
||||
Path ownership = new File(directory, DatapackOwnership.OWNERSHIP_MARKER).toPath();
|
||||
if (Files.isRegularFile(ownership, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(ownership)) {
|
||||
byte[] marker = DatapackSupport.readBoundedBytes(
|
||||
ownership, DatapackOwnership.MAX_OWNERSHIP_BYTES, "External datapack ownership fingerprint");
|
||||
DatapackSupport.updateDigestLong(digest, marker.length);
|
||||
digest.update(marker);
|
||||
} else {
|
||||
DatapackSupport.updateDigestLong(digest, -1L);
|
||||
}
|
||||
}
|
||||
|
||||
static void updateFingerprintValue(MessageDigest digest, String value) {
|
||||
byte[] bytes = Objects.requireNonNullElse(value, "").getBytes(StandardCharsets.UTF_8);
|
||||
DatapackSupport.updateDigestInt(digest, bytes.length);
|
||||
digest.update(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.structure.BulkStructureImporter;
|
||||
import art.arcane.iris.core.structure.StructureImporter;
|
||||
import art.arcane.iris.core.structure.authoring.StructureKey;
|
||||
import art.arcane.iris.core.structure.authoring.StructureSource;
|
||||
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
final class DatapackStructureImports {
|
||||
static final int STRUCTURE_IMPORT_FORMAT_REVISION = 3;
|
||||
|
||||
private DatapackStructureImports() {
|
||||
}
|
||||
|
||||
static boolean autoImportDatapackStructures() {
|
||||
DatapackTransactions.TRANSACTION_LOCK.lock();
|
||||
try {
|
||||
return autoImportDatapackStructuresLocked();
|
||||
} finally {
|
||||
DatapackTransactions.TRANSACTION_LOCK.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
static boolean autoImportDatapackStructuresLocked() {
|
||||
boolean autoImportEnabled = IrisSettings.get().getGeneral().autoImportDatapackStructures;
|
||||
File root = IrisPlatforms.get().dataFolder("datapacks");
|
||||
boolean recovered;
|
||||
try {
|
||||
recovered = DatapackScratchRecovery.recoverTransactions(root, ServerConfigurator.getDatapacksFolder());
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError("Automatic datapack structure import blocked by incomplete transaction recovery.", e);
|
||||
return false;
|
||||
}
|
||||
Manifest manifest = DatapackManifestStore.readManifest(root);
|
||||
if (manifest.entries.isEmpty()) {
|
||||
return recovered;
|
||||
}
|
||||
Map<String, Entry> manifestEntriesByUrl = new HashMap<>();
|
||||
for (Entry entry : manifest.entries) {
|
||||
if (entry.url != null) {
|
||||
manifestEntriesByUrl.put(entry.url, entry);
|
||||
}
|
||||
}
|
||||
|
||||
List<IrisData> packs;
|
||||
try (Stream<IrisData> stream = ServerConfigurator.allPacks()) {
|
||||
packs = stream.filter(Objects::nonNull).toList();
|
||||
}
|
||||
if (!autoImportEnabled && !hasRemovedImportState(packs, manifest.entries)) {
|
||||
return recovered;
|
||||
}
|
||||
|
||||
Map<String, Entry> entriesByUrl = new HashMap<>();
|
||||
File stagingRoot = new File(root, "staging");
|
||||
boolean stagingStateChanged = false;
|
||||
boolean manifestStagingMetadataChanged = false;
|
||||
for (Entry entry : manifest.entries) {
|
||||
if (entry.url == null) {
|
||||
continue;
|
||||
}
|
||||
StagingInspection inspection = DatapackStagingGuard.inspectUsableStaging(new File(stagingRoot, entry.id), entry);
|
||||
stagingStateChanged |= inspection.ownershipCorrected();
|
||||
manifestStagingMetadataChanged |= inspection.manifestChanged();
|
||||
if (inspection.usable()) {
|
||||
entriesByUrl.put(entry.url, entry);
|
||||
}
|
||||
}
|
||||
|
||||
int attemptedPacks = 0;
|
||||
int completedPacks = 0;
|
||||
int cleanupTargets = 0;
|
||||
Set<String> completedUrls = new HashSet<>();
|
||||
Set<String> failedUrls = new HashSet<>();
|
||||
for (IrisData data : packs) {
|
||||
Set<String> configured = DatapackImportSources.configuredImports(data);
|
||||
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
|
||||
for (Entry entry : manifest.entries) {
|
||||
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
|
||||
cleanupTargets++;
|
||||
}
|
||||
}
|
||||
if (!cleanupRemovedImports(data, targetId, configured, manifest.entries, manifestEntriesByUrl)) {
|
||||
for (Entry entry : manifest.entries) {
|
||||
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
|
||||
failedUrls.add(entry.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!autoImportEnabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Set<String> pendingUrls = new HashSet<>();
|
||||
for (String url : configured) {
|
||||
Entry entry = entriesByUrl.get(url);
|
||||
if (entry != null && importPending(entry, targetId)) {
|
||||
pendingUrls.add(url);
|
||||
}
|
||||
}
|
||||
if (pendingUrls.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (String pendingUrl : pendingUrls) {
|
||||
Entry entry = entriesByUrl.get(pendingUrl);
|
||||
prepareImportRecoveryInventory(entry, targetId);
|
||||
}
|
||||
try {
|
||||
DatapackManifestStore.writeManifestChecked(root, manifest);
|
||||
} catch (IOException e) {
|
||||
failedUrls.addAll(pendingUrls);
|
||||
IrisLogging.reportError("Datapack structure import for pack '"
|
||||
+ data.getDataFolder().getPath()
|
||||
+ "' was blocked because its recovery inventory could not be persisted.", e);
|
||||
continue;
|
||||
}
|
||||
Set<String> structureKeys = new TreeSet<>();
|
||||
Set<String> templateKeys = new TreeSet<>();
|
||||
for (Entry entry : manifest.entries) {
|
||||
if (!configured.contains(entry.url)) {
|
||||
continue;
|
||||
}
|
||||
structureKeys.addAll(DatapackManifestStore.copyList(entry.structureKeys));
|
||||
templateKeys.addAll(DatapackManifestStore.copyList(entry.templateKeys));
|
||||
}
|
||||
attemptedPacks++;
|
||||
try {
|
||||
BulkStructureImporter.Report report = BulkStructureImporter.importManagedDatapackStructures(
|
||||
data,
|
||||
StructureImporter.Mode.OVERWRITE,
|
||||
BukkitPlatform.console(),
|
||||
structureKeys,
|
||||
templateKeys
|
||||
);
|
||||
Set<String> successfulPendingUrls = new HashSet<>(pendingUrls);
|
||||
Set<String> incompleteUrls = new HashSet<>();
|
||||
for (String pendingUrl : pendingUrls) {
|
||||
Entry entry = entriesByUrl.get(pendingUrl);
|
||||
Map<String, String> desired = importBundleInventory(entry);
|
||||
if (!report.successfulBundles().entrySet().containsAll(desired.entrySet())) {
|
||||
successfulPendingUrls.remove(pendingUrl);
|
||||
incompleteUrls.add(pendingUrl);
|
||||
failedUrls.add(pendingUrl);
|
||||
}
|
||||
}
|
||||
if (report.failed() > 0 && incompleteUrls.isEmpty()) {
|
||||
successfulPendingUrls.clear();
|
||||
incompleteUrls.addAll(pendingUrls);
|
||||
failedUrls.addAll(pendingUrls);
|
||||
}
|
||||
if (!incompleteUrls.isEmpty()) {
|
||||
boolean reconciled = reconcileFailedImportInventories(
|
||||
root, manifest, data, targetId, incompleteUrls, entriesByUrl);
|
||||
if (report.retryRequired() || !reconciled) {
|
||||
IrisLogging.error("Datapack structure import for pack '%s' reported %d incomplete source(s) and remains pending because a retryable runtime failure occurred.",
|
||||
data.getDataFolder().getPath(), incompleteUrls.size());
|
||||
} else if (recordDeterministicImportAttempts(
|
||||
root, manifest, targetId, incompleteUrls, entriesByUrl)) {
|
||||
IrisLogging.warn("Datapack structure import for pack '"
|
||||
+ data.getDataFolder().getPath() + "' left " + incompleteUrls.size()
|
||||
+ " source(s) incomplete after deterministic validation failures. Iris will retain the partial editable imports without retrying until the datapack source, importer format, or target pack changes.");
|
||||
}
|
||||
}
|
||||
Map<String, String> sharedBundles = desiredBundles(configured, entriesByUrl);
|
||||
boolean packCompleted = false;
|
||||
for (String pendingUrl : successfulPendingUrls) {
|
||||
Entry entry = entriesByUrl.get(pendingUrl);
|
||||
Map<String, String> desired = importBundleInventory(entry);
|
||||
Map<String, String> previous = entry.importedBundles.getOrDefault(targetId, Map.of());
|
||||
Map<String, String> stale = new TreeMap<>(previous);
|
||||
stale.keySet().removeAll(desired.keySet());
|
||||
stale.keySet().removeAll(sharedBundles.keySet());
|
||||
Map<String, String> remaining = cleanupImportedBundles(data, stale);
|
||||
if (!remaining.isEmpty()) {
|
||||
failedUrls.add(pendingUrl);
|
||||
continue;
|
||||
}
|
||||
entry.importedBundles.put(targetId, desired);
|
||||
recordSuccessfulImport(entry, targetId);
|
||||
completedUrls.add(pendingUrl);
|
||||
packCompleted = true;
|
||||
}
|
||||
if (packCompleted) {
|
||||
completedPacks++;
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
failedUrls.addAll(pendingUrls);
|
||||
IrisLogging.reportError("Datapack structure import failed for pack '"
|
||||
+ data.getDataFolder().getPath() + "'; the manifest remains pending for retry.", e);
|
||||
reconcileFailedImportInventories(root, manifest, data, targetId, pendingUrls, entriesByUrl);
|
||||
}
|
||||
}
|
||||
|
||||
for (Entry entry : manifest.entries) {
|
||||
if (failedUrls.contains(entry.url)) {
|
||||
entry.structuresImported = false;
|
||||
} else if (completedUrls.contains(entry.url)) {
|
||||
entry.structuresImported = true;
|
||||
}
|
||||
}
|
||||
if (attemptedPacks == 0 && cleanupTargets == 0) {
|
||||
if (manifestStagingMetadataChanged) {
|
||||
DatapackManifestStore.writeManifest(root, manifest);
|
||||
}
|
||||
return recovered || stagingStateChanged || manifestStagingMetadataChanged;
|
||||
}
|
||||
DatapackManifestStore.writeManifest(root, manifest);
|
||||
if (attemptedPacks == 0) {
|
||||
IrisLogging.info("Datapack editable-import cleanup reconciled " + cleanupTargets + " removed source target(s).");
|
||||
return true;
|
||||
}
|
||||
IrisLogging.info("Datapack structure import refreshed " + completedUrls.size() + " source(s) across "
|
||||
+ completedPacks + "/" + attemptedPacks
|
||||
+ " pack(s). Reference the imported keys from a 'structures' placement to position them manually.");
|
||||
return true;
|
||||
}
|
||||
|
||||
static boolean hasRemovedImportState(List<IrisData> packs, List<Entry> entries) {
|
||||
for (IrisData data : packs) {
|
||||
Set<String> configured = DatapackImportSources.configuredImports(data);
|
||||
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
|
||||
for (Entry entry : entries) {
|
||||
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static String importRevision(Entry entry) {
|
||||
return importRevision(entry, STRUCTURE_IMPORT_FORMAT_REVISION);
|
||||
}
|
||||
|
||||
static String importRevision(Entry entry, int importerFormatRevision) {
|
||||
return "v" + importerFormatRevision + ":" + DatapackSupport.safe(entry.versionId) + ":" + DatapackSupport.safe(entry.sha1);
|
||||
}
|
||||
|
||||
static boolean importPending(Entry entry, String targetId) {
|
||||
String revision = importRevision(entry);
|
||||
return !revision.equals(entry.importedTargets.get(targetId))
|
||||
&& !revision.equals(entry.importAttempts.get(targetId));
|
||||
}
|
||||
|
||||
static void recordDeterministicImportAttempt(Entry entry, String targetId) {
|
||||
entry.importedTargets.remove(targetId);
|
||||
entry.importAttempts.put(targetId, importRevision(entry));
|
||||
entry.structuresImported = false;
|
||||
}
|
||||
|
||||
static void recordSuccessfulImport(Entry entry, String targetId) {
|
||||
entry.importedTargets.put(targetId, importRevision(entry));
|
||||
entry.importAttempts.remove(targetId);
|
||||
}
|
||||
|
||||
static void prepareImportRecoveryInventory(Entry entry, String targetId) {
|
||||
Map<String, String> desired = importBundleInventory(entry);
|
||||
Map<String, String> recovery = new TreeMap<>(desired);
|
||||
recovery.putAll(entry.importedBundles.getOrDefault(targetId, Map.of()));
|
||||
entry.importedBundles.put(targetId, recovery);
|
||||
entry.importedTargets.remove(targetId);
|
||||
entry.importAttempts.remove(targetId);
|
||||
entry.structuresImported = false;
|
||||
}
|
||||
|
||||
static boolean reconcileFailedImportInventories(
|
||||
File root,
|
||||
Manifest manifest,
|
||||
IrisData data,
|
||||
String targetId,
|
||||
Set<String> pendingUrls,
|
||||
Map<String, Entry> entriesByUrl
|
||||
) {
|
||||
boolean reconciled = true;
|
||||
for (String pendingUrl : pendingUrls) {
|
||||
Entry entry = entriesByUrl.get(pendingUrl);
|
||||
try {
|
||||
reconcileFailedImportInventory(data, entry, targetId);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
reconciled = false;
|
||||
IrisLogging.reportError("Could not reconcile partial editable structure imports for '"
|
||||
+ pendingUrl + "' in pack '" + data.getDataFolder().getPath()
|
||||
+ "'; the conservative recovery inventory remains pending.", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
DatapackManifestStore.writeManifestChecked(root, manifest);
|
||||
} catch (IOException e) {
|
||||
reconciled = false;
|
||||
IrisLogging.reportError("Could not persist reconciled partial editable structure imports for pack '"
|
||||
+ data.getDataFolder().getPath() + "'; the earlier recovery inventory remains durable.", e);
|
||||
}
|
||||
return reconciled;
|
||||
}
|
||||
|
||||
static boolean recordDeterministicImportAttempts(
|
||||
File root,
|
||||
Manifest manifest,
|
||||
String targetId,
|
||||
Set<String> incompleteUrls,
|
||||
Map<String, Entry> entriesByUrl
|
||||
) {
|
||||
for (String incompleteUrl : incompleteUrls) {
|
||||
recordDeterministicImportAttempt(entriesByUrl.get(incompleteUrl), targetId);
|
||||
}
|
||||
try {
|
||||
DatapackManifestStore.writeManifestChecked(root, manifest);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
for (String incompleteUrl : incompleteUrls) {
|
||||
entriesByUrl.get(incompleteUrl).importAttempts.remove(targetId);
|
||||
}
|
||||
IrisLogging.reportError("Could not persist deterministic editable structure import attempts; the sources remain pending for retry.", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void reconcileFailedImportInventory(
|
||||
IrisData data,
|
||||
Entry entry,
|
||||
String targetId
|
||||
) throws IOException {
|
||||
Map<String, Set<String>> claims = DatapackRemoval.importBundleClaims(entry, targetId);
|
||||
Map<String, String> reconciled = new TreeMap<>();
|
||||
StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath());
|
||||
for (Map.Entry<String, Set<String>> bundle : claims.entrySet()) {
|
||||
StructureKey targetKey;
|
||||
try {
|
||||
targetKey = StructureKey.parse(bundle.getKey());
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid editable structure target key '" + bundle.getKey() + "'", e);
|
||||
}
|
||||
Optional<StructureSource> source = writer.ownedSource(targetKey);
|
||||
if (source.isPresent() && DatapackRemoval.sourceClaimsContain(bundle.getValue(), source.get())) {
|
||||
reconciled.put(bundle.getKey(), source.get().key().value());
|
||||
}
|
||||
}
|
||||
if (reconciled.isEmpty()) {
|
||||
entry.importedBundles.remove(targetId);
|
||||
} else {
|
||||
entry.importedBundles.put(targetId, reconciled);
|
||||
}
|
||||
entry.importedTargets.remove(targetId);
|
||||
entry.importAttempts.remove(targetId);
|
||||
entry.structuresImported = false;
|
||||
}
|
||||
|
||||
static boolean cleanupRemovedImports(
|
||||
IrisData data,
|
||||
String targetId,
|
||||
Set<String> configured,
|
||||
List<Entry> entries,
|
||||
Map<String, Entry> entriesByUrl
|
||||
) {
|
||||
boolean successful = true;
|
||||
Map<String, String> retainedBundles = desiredBundles(configured, entriesByUrl);
|
||||
for (Entry entry : entries) {
|
||||
if (configured.contains(entry.url)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> inventory = entry.importedBundles.get(targetId);
|
||||
if (inventory == null && !hasImportState(entry, targetId)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> resolvedInventory = inventory == null ? Map.of() : inventory;
|
||||
for (Entry retainedEntry : entries) {
|
||||
if (configured.contains(retainedEntry.url)) {
|
||||
retainedEntry.importedTargets.remove(targetId);
|
||||
retainedEntry.importAttempts.remove(targetId);
|
||||
retainedEntry.structuresImported = false;
|
||||
}
|
||||
}
|
||||
Map<String, String> removable = new TreeMap<>(resolvedInventory);
|
||||
removable.keySet().removeAll(retainedBundles.keySet());
|
||||
Map<String, String> remaining = cleanupImportedBundles(data, removable);
|
||||
if (!remaining.isEmpty()) {
|
||||
Map<String, String> retained = new TreeMap<>();
|
||||
for (Map.Entry<String, String> bundle : resolvedInventory.entrySet()) {
|
||||
if (retainedBundles.containsKey(bundle.getKey()) || remaining.containsKey(bundle.getKey())) {
|
||||
retained.put(bundle.getKey(), bundle.getValue());
|
||||
}
|
||||
}
|
||||
entry.importedBundles.put(targetId, retained);
|
||||
entry.importedTargets.remove(targetId);
|
||||
entry.importAttempts.remove(targetId);
|
||||
entry.structuresImported = false;
|
||||
successful = false;
|
||||
continue;
|
||||
}
|
||||
entry.importedBundles.remove(targetId);
|
||||
entry.importedTargets.remove(targetId);
|
||||
entry.importAttempts.remove(targetId);
|
||||
}
|
||||
return successful;
|
||||
}
|
||||
|
||||
static boolean hasImportState(Entry entry, String targetId) {
|
||||
return entry.importedBundles.containsKey(targetId)
|
||||
|| entry.importedTargets.containsKey(targetId)
|
||||
|| entry.importAttempts.containsKey(targetId);
|
||||
}
|
||||
|
||||
static Map<String, String> cleanupImportedBundles(IrisData data, Map<String, String> inventory) {
|
||||
Map<String, String> remaining = new TreeMap<>();
|
||||
StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath());
|
||||
boolean removed = false;
|
||||
for (Map.Entry<String, String> bundle : inventory.entrySet()) {
|
||||
StructureKey sourceKey;
|
||||
try {
|
||||
sourceKey = StructureKey.parse(bundle.getValue());
|
||||
StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft")
|
||||
? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK;
|
||||
removed |= writer.removeManagedDatapackOwned(
|
||||
StructureKey.parse(bundle.getKey()),
|
||||
sourceKind,
|
||||
sourceKey
|
||||
);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
remaining.put(bundle.getKey(), bundle.getValue());
|
||||
IrisLogging.reportError("Preserving imported structure bundle '" + bundle.getKey()
|
||||
+ "' in pack '" + data.getDataFolder().getPath()
|
||||
+ "' because ownership-safe cleanup failed.", e);
|
||||
}
|
||||
}
|
||||
if (removed) {
|
||||
data.invalidateStructureResources();
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
static Map<String, String> desiredBundles(Set<String> configured, Map<String, Entry> entriesByUrl) {
|
||||
Map<String, String> bundles = new TreeMap<>();
|
||||
for (String url : configured) {
|
||||
Entry entry = entriesByUrl.get(url);
|
||||
if (entry != null) {
|
||||
bundles.putAll(importBundleInventory(entry));
|
||||
}
|
||||
}
|
||||
return bundles;
|
||||
}
|
||||
|
||||
static Map<String, String> importBundleInventory(Entry entry) {
|
||||
Map<String, String> bundles = new TreeMap<>();
|
||||
for (String structureKey : DatapackManifestStore.copyList(entry.structureKeys)) {
|
||||
bundles.put("iris:" + StructureImporter.deriveName(structureKey), structureKey);
|
||||
}
|
||||
for (String templateKey : DatapackManifestStore.copyList(entry.templateKeys)) {
|
||||
bundles.put("iris:" + BulkStructureImporter.templateNameFor(templateKey), templateKey);
|
||||
}
|
||||
return bundles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.io.Durability;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
final class DatapackSupport {
|
||||
static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private DatapackSupport() {
|
||||
}
|
||||
|
||||
static String failureMessage(Throwable exception) {
|
||||
if (exception == null) {
|
||||
return "unknown failure";
|
||||
}
|
||||
String message = exception.getMessage();
|
||||
return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message;
|
||||
}
|
||||
|
||||
static IOException appendFailure(IOException current, IOException additional) {
|
||||
if (current == null) {
|
||||
return additional;
|
||||
}
|
||||
current.addSuppressed(additional);
|
||||
return current;
|
||||
}
|
||||
|
||||
static IOException appendIOException(IOException current, Throwable failure) {
|
||||
IOException next = failure instanceof IOException ioFailure
|
||||
? ioFailure : new IOException("Datapack transaction participant failed", failure);
|
||||
if (current == null) {
|
||||
return next;
|
||||
}
|
||||
current.addSuppressed(next);
|
||||
return current;
|
||||
}
|
||||
|
||||
static Path requireDirectoryIdentity(File directory, String purpose) throws IOException {
|
||||
Path normalized = directory.toPath().toAbsolutePath().normalize();
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
normalized, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
if (!DatapackScratchRecovery.isSupportedScratchDirectory(attributes)) {
|
||||
throw new IOException("Invalid " + purpose + " " + normalized);
|
||||
}
|
||||
normalized.toRealPath();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static void requireNoSymbolicLinkComponents(Path normalized, String purpose) throws IOException {
|
||||
Path current = normalized.getRoot();
|
||||
for (Path component : normalized) {
|
||||
current = current == null ? component : current.resolve(component);
|
||||
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 (!DatapackScratchRecovery.isSupportedScratchDirectory(attributes)) {
|
||||
throw new IOException("Refusing unsupported component in " + purpose + " " + normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String directoryIdentity(File directory) throws IOException {
|
||||
BasicFileAttributes attributes = Files.readAttributes(
|
||||
directory.toPath(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
|
||||
throw new IOException("Invalid datapack directory identity at " + directory.getPath());
|
||||
}
|
||||
Object fileKey = attributes.fileKey();
|
||||
if (fileKey != null) {
|
||||
return "key:" + fileKey;
|
||||
}
|
||||
// Windows exposes no inode-equivalent for a directory, and returning nothing here fails
|
||||
// the identity gate closed, which disables legacy staging replacement on that platform
|
||||
// outright. Creation time is the one stable discriminator the filesystem still offers: a
|
||||
// directory swapped in at the same path brings its own, so a swap is still caught. It is
|
||||
// a weaker guarantee than an inode, so it is only ever the fallback.
|
||||
return "created:" + attributes.creationTime();
|
||||
}
|
||||
|
||||
static boolean pathExists(Path path, String purpose) throws IOException {
|
||||
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return true;
|
||||
}
|
||||
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
throw new IOException("Cannot determine " + purpose + " state at " + path);
|
||||
}
|
||||
|
||||
static String safe(String value) {
|
||||
return value == null || value.isBlank() ? "?" : value;
|
||||
}
|
||||
|
||||
static String safeFile(String value) {
|
||||
return value == null || value.isBlank() ? "unknown" : value.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
}
|
||||
|
||||
static String hex(byte[] hash) {
|
||||
StringBuilder builder = new StringBuilder(hash.length * 2);
|
||||
for (byte value : hash) {
|
||||
builder.append(String.format("%02x", value));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
static void updateDigestInt(MessageDigest digest, int value) {
|
||||
digest.update((byte) (value >>> 24));
|
||||
digest.update((byte) (value >>> 16));
|
||||
digest.update((byte) (value >>> 8));
|
||||
digest.update((byte) value);
|
||||
}
|
||||
|
||||
static void updateDigestLong(MessageDigest digest, long value) {
|
||||
digest.update((byte) (value >>> 56));
|
||||
digest.update((byte) (value >>> 48));
|
||||
digest.update((byte) (value >>> 40));
|
||||
digest.update((byte) (value >>> 32));
|
||||
digest.update((byte) (value >>> 24));
|
||||
digest.update((byte) (value >>> 16));
|
||||
digest.update((byte) (value >>> 8));
|
||||
digest.update((byte) value);
|
||||
}
|
||||
|
||||
static void message(VolmitSender sender, String text) {
|
||||
if (sender != null) {
|
||||
sender.sendMessage(text);
|
||||
return;
|
||||
}
|
||||
IrisLogging.info(text);
|
||||
}
|
||||
|
||||
static void move(Path source, Path target) throws IOException {
|
||||
try {
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
static void moveNew(Path source, Path target) throws IOException {
|
||||
if (pathExists(target, "move target")) {
|
||||
throw new IOException("Refusing to replace concurrently-created path " + target);
|
||||
}
|
||||
Files.move(source, target);
|
||||
}
|
||||
|
||||
static void ensureScratchDirectory(File directory, String purpose) throws IOException {
|
||||
Path path = directory.toPath();
|
||||
if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Refusing invalid " + purpose + " directory " + directory.getPath());
|
||||
}
|
||||
return;
|
||||
}
|
||||
Files.createDirectories(path);
|
||||
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Could not create a safe " + purpose + " directory " + directory.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
static void verifyDirectoryContainerIfPresent(File directory, String purpose) throws IOException {
|
||||
Path path = directory.toPath();
|
||||
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Refusing invalid " + purpose + " directory " + directory.getPath());
|
||||
}
|
||||
}
|
||||
|
||||
static String normalizedPath(File file) {
|
||||
return file.toPath().toAbsolutePath().normalize().toString();
|
||||
}
|
||||
|
||||
static String realDirectoryPath(File directory, String purpose) throws IOException {
|
||||
Path path = directory.toPath().toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid " + purpose + " " + path);
|
||||
}
|
||||
return path.toRealPath().toString();
|
||||
}
|
||||
|
||||
static String readBoundedUtf8(Path path, long maxBytes, String purpose) throws IOException {
|
||||
return new String(readBoundedBytes(path, maxBytes, purpose), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
static byte[] readBoundedBytes(Path path, long maxBytes, String purpose) throws IOException {
|
||||
int readLimit = Math.toIntExact(maxBytes + 1);
|
||||
byte[] content;
|
||||
try (InputStream input = Files.newInputStream(
|
||||
path,
|
||||
StandardOpenOption.READ,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
)) {
|
||||
content = input.readNBytes(readLimit);
|
||||
}
|
||||
if (content.length > maxBytes) {
|
||||
throw new IOException(purpose + " exceeds " + maxBytes + " bytes");
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
static void forceFile(Path file) throws IOException {
|
||||
if (!Durability.enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) {
|
||||
Durability.force(channel);
|
||||
}
|
||||
}
|
||||
|
||||
static void forceDirectoryIfSupported(Path directory) throws IOException {
|
||||
if (!Durability.enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Files.getFileStore(directory).supportsFileAttributeView("posix")) {
|
||||
return;
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
Durability.force(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
final class DatapackTransactions {
|
||||
static final String TRANSACTION_DIRECTORY = ".iris-datapack-transactions";
|
||||
|
||||
static final String TRANSACTION_JOURNAL = "journal.json";
|
||||
|
||||
static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json";
|
||||
|
||||
static final int TRANSACTION_SCHEMA = 2;
|
||||
|
||||
static final long MAX_TRANSACTION_JOURNAL_BYTES = 4L * 1024L * 1024L;
|
||||
|
||||
static final int MAX_TRANSACTION_COUNT = 1_024;
|
||||
|
||||
static final ReentrantLock TRANSACTION_LOCK = new ReentrantLock();
|
||||
|
||||
private DatapackTransactions() {
|
||||
}
|
||||
|
||||
static DatapackCoordinator createInstallCoordinator(
|
||||
File root,
|
||||
Entry entry,
|
||||
List<InstallPlan> plans,
|
||||
boolean manifestAlreadyMatched
|
||||
) throws IOException {
|
||||
CoordinatorJournal journal = newCoordinatorJournal(
|
||||
CoordinatorOperation.INSTALL,
|
||||
entry,
|
||||
manifestAlreadyMatched
|
||||
);
|
||||
for (InstallPlan plan : plans) {
|
||||
if (!plan.publishRequired()) {
|
||||
continue;
|
||||
}
|
||||
CoordinatorDirectory directory = new CoordinatorDirectory();
|
||||
directory.target = DatapackSupport.normalizedPath(plan.target());
|
||||
directory.pending = DatapackSupport.normalizedPath(plan.pending());
|
||||
directory.backup = DatapackSupport.normalizedPath(plan.backup());
|
||||
directory.hadTarget = plan.hadTarget();
|
||||
directory.originalHash = plan.originalHash();
|
||||
directory.desiredHash = plan.desiredHash();
|
||||
directory.originalMarkerHash = plan.originalMarkerHash();
|
||||
directory.desiredMarkerHash = plan.desiredMarkerHash();
|
||||
directory.originalIdentity = plan.originalIdentity();
|
||||
directory.desiredIdentity = plan.desiredIdentity();
|
||||
directory.targetRoot = plan.targetRootIdentity();
|
||||
directory.scratchRoot = plan.scratchRootIdentity();
|
||||
directory.targetRootIdentity = plan.targetRootFileIdentity();
|
||||
directory.scratchRootIdentity = plan.scratchRootFileIdentity();
|
||||
journal.directories.add(directory);
|
||||
}
|
||||
return createCoordinator(root, journal);
|
||||
}
|
||||
|
||||
static DatapackCoordinator createRemovalCoordinator(
|
||||
File root,
|
||||
Entry entry,
|
||||
DirectoryRemoval directoryRemoval,
|
||||
EditableImportRemoval editableRemoval
|
||||
) throws IOException {
|
||||
CoordinatorJournal journal = newCoordinatorJournal(CoordinatorOperation.REMOVE, entry, true);
|
||||
for (DirectoryMove move : directoryRemoval.moves()) {
|
||||
CoordinatorDirectory directory = new CoordinatorDirectory();
|
||||
directory.target = DatapackSupport.normalizedPath(move.target());
|
||||
directory.pending = "";
|
||||
directory.backup = DatapackSupport.normalizedPath(move.backup());
|
||||
directory.hadTarget = true;
|
||||
directory.originalHash = move.originalHash();
|
||||
directory.desiredHash = "";
|
||||
directory.originalMarkerHash = move.originalMarkerHash();
|
||||
directory.desiredMarkerHash = "";
|
||||
directory.originalIdentity = move.originalIdentity();
|
||||
directory.desiredIdentity = "";
|
||||
directory.targetRoot = move.targetRootIdentity();
|
||||
directory.scratchRoot = move.scratchRootIdentity();
|
||||
directory.targetRootIdentity = move.targetRootFileIdentity();
|
||||
directory.scratchRootIdentity = move.scratchRootFileIdentity();
|
||||
journal.directories.add(directory);
|
||||
}
|
||||
for (StructureTransactionWriter.PreparedRemovalToken token : editableRemoval.recoveryTokens()) {
|
||||
CoordinatorEditable editable = new CoordinatorEditable();
|
||||
editable.packRoot = token.packRoot().toString();
|
||||
editable.transactionId = token.transactionId().toString();
|
||||
editable.claimId = UUID.randomUUID().toString();
|
||||
journal.editables.add(editable);
|
||||
}
|
||||
Path transactionRoot = coordinatorTransactionRoot(root, journal);
|
||||
try {
|
||||
editableRemoval.claimRecoveryOwners(transactionRoot, journal);
|
||||
writeCoordinatorJournal(transactionRoot, journal);
|
||||
return new DatapackCoordinator(transactionRoot, journal);
|
||||
} catch (IOException | RuntimeException creationFailure) {
|
||||
try {
|
||||
DatapackInstall.deleteInstallScratch(transactionRoot.toFile(), "incomplete datapack transaction");
|
||||
} catch (IOException cleanupFailure) {
|
||||
creationFailure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
if (creationFailure instanceof IOException ioFailure) {
|
||||
throw ioFailure;
|
||||
}
|
||||
throw creationFailure;
|
||||
}
|
||||
}
|
||||
|
||||
static CoordinatorJournal newCoordinatorJournal(
|
||||
CoordinatorOperation operation,
|
||||
Entry entry,
|
||||
boolean manifestAlreadyMatched
|
||||
) {
|
||||
CoordinatorJournal journal = new CoordinatorJournal();
|
||||
journal.schemaVersion = TRANSACTION_SCHEMA;
|
||||
journal.transactionId = UUID.randomUUID().toString();
|
||||
journal.operation = operation;
|
||||
journal.phase = CoordinatorPhase.PREPARED;
|
||||
journal.id = entry.id;
|
||||
journal.url = entry.url;
|
||||
journal.versionId = entry.versionId;
|
||||
journal.versionNumber = entry.versionNumber;
|
||||
journal.sha1 = entry.sha1;
|
||||
journal.manifestAlreadyMatched = manifestAlreadyMatched;
|
||||
return journal;
|
||||
}
|
||||
|
||||
static DatapackCoordinator createCoordinator(File root, CoordinatorJournal journal) throws IOException {
|
||||
Path transactionRoot = coordinatorTransactionRoot(root, journal);
|
||||
writeCoordinatorJournal(transactionRoot, journal);
|
||||
return new DatapackCoordinator(transactionRoot, journal);
|
||||
}
|
||||
|
||||
static Path coordinatorTransactionRoot(File root, CoordinatorJournal journal) throws IOException {
|
||||
File transactionDirectory = new File(root, TRANSACTION_DIRECTORY);
|
||||
DatapackSupport.ensureScratchDirectory(transactionDirectory, "datapack transaction");
|
||||
Path transactionRoot = new File(transactionDirectory, journal.transactionId).toPath().toAbsolutePath().normalize();
|
||||
if (Files.exists(transactionRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Datapack transaction id already exists: " + journal.transactionId);
|
||||
}
|
||||
return transactionRoot;
|
||||
}
|
||||
|
||||
static CoordinatorJournal readCoordinatorJournal(Path transactionRoot) throws IOException {
|
||||
Path committed = transactionRoot.resolve(TRANSACTION_JOURNAL);
|
||||
Path next = transactionRoot.resolve(TRANSACTION_JOURNAL_NEXT);
|
||||
if (Files.exists(committed, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return parseCoordinatorJournal(committed);
|
||||
}
|
||||
if (!Files.exists(next, LinkOption.NOFOLLOW_LINKS)) {
|
||||
try (Stream<Path> entries = Files.list(transactionRoot)) {
|
||||
if (entries.findAny().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
throw new IOException("Missing datapack transaction journal " + committed);
|
||||
}
|
||||
try {
|
||||
return parseCoordinatorJournal(next);
|
||||
} catch (IOException firstWriteFailure) {
|
||||
try (Stream<Path> entries = Files.list(transactionRoot)) {
|
||||
List<Path> contents = entries.limit(2).toList();
|
||||
if (contents.size() == 1 && Objects.equals(contents.getFirst(), next)
|
||||
&& !Files.isSymbolicLink(next)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
throw firstWriteFailure;
|
||||
}
|
||||
}
|
||||
|
||||
static CoordinatorJournal parseCoordinatorJournal(Path journalPath) throws IOException {
|
||||
if (Files.isSymbolicLink(journalPath)
|
||||
|| !Files.isRegularFile(journalPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Invalid datapack transaction journal " + journalPath);
|
||||
}
|
||||
try {
|
||||
CoordinatorJournal journal = DatapackSupport.GSON.fromJson(
|
||||
DatapackSupport.readBoundedUtf8(
|
||||
journalPath,
|
||||
MAX_TRANSACTION_JOURNAL_BYTES,
|
||||
"Datapack transaction journal"
|
||||
),
|
||||
CoordinatorJournal.class
|
||||
);
|
||||
if (journal == null) {
|
||||
throw new IOException("Empty datapack transaction journal " + journalPath);
|
||||
}
|
||||
return journal;
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid datapack transaction journal " + journalPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
static void validateCoordinatorJournal(
|
||||
File root,
|
||||
List<File> worldFolders,
|
||||
Manifest committedManifest,
|
||||
Path transactionRoot,
|
||||
CoordinatorJournal journal
|
||||
) throws IOException {
|
||||
if (journal.schemaVersion != TRANSACTION_SCHEMA || journal.transactionId == null
|
||||
|| journal.operation == null || journal.phase == null || journal.id == null
|
||||
|| journal.url == null || journal.directories == null || journal.editables == null) {
|
||||
throw new IOException("Incomplete datapack transaction journal at " + transactionRoot);
|
||||
}
|
||||
UUID transactionId;
|
||||
try {
|
||||
transactionId = UUID.fromString(journal.transactionId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IOException("Invalid datapack transaction id " + journal.transactionId, e);
|
||||
}
|
||||
if (!transactionId.toString().equals(transactionRoot.getFileName().toString())) {
|
||||
throw new IOException("Datapack transaction id does not match its directory");
|
||||
}
|
||||
if (!journal.id.equals(DatapackArchive.sanitizeId(journal.id)) || DatapackArchive.RESERVED_IDS.contains(journal.id)) {
|
||||
throw new IOException("Invalid datapack transaction entry id " + journal.id);
|
||||
}
|
||||
if (journal.directories.size() > worldFolders.size() + 1 || journal.editables.size() > 10_000) {
|
||||
throw new IOException("Datapack transaction journal contains too many participants");
|
||||
}
|
||||
if (journal.operation == CoordinatorOperation.INSTALL && !journal.editables.isEmpty()) {
|
||||
throw new IOException("Datapack install transaction unexpectedly contains editable participants");
|
||||
}
|
||||
|
||||
Set<Path> allowedTargets = new HashSet<>();
|
||||
allowedTargets.add(new File(new File(root, "staging"), journal.id).toPath().toAbsolutePath().normalize());
|
||||
for (File worldFolder : worldFolders) {
|
||||
allowedTargets.add(new File(worldFolder, journal.id).toPath().toAbsolutePath().normalize());
|
||||
}
|
||||
Set<Path> seenTargets = new HashSet<>();
|
||||
Set<Path> seenTargetIdentities = new HashSet<>();
|
||||
for (CoordinatorDirectory directory : journal.directories) {
|
||||
DatapackCoordinatorResolution.validateCoordinatorDirectory(
|
||||
directory, journal.operation, allowedTargets, seenTargets, seenTargetIdentities);
|
||||
}
|
||||
Set<String> seenEditables = new HashSet<>();
|
||||
Set<Path> allowedEditableRoots = journal.editables.isEmpty()
|
||||
? Set.of() : DatapackCoordinatorResolution.authoritativeEditableRoots(committedManifest, journal);
|
||||
for (CoordinatorEditable editable : journal.editables) {
|
||||
if (editable == null || editable.packRoot == null || editable.transactionId == null
|
||||
|| editable.claimId == null
|
||||
|| !seenEditables.add(editable.packRoot)) {
|
||||
throw new IOException("Invalid duplicate editable participant in datapack transaction");
|
||||
}
|
||||
Path packRoot = coordinatorPath(editable.packRoot, "editable pack root");
|
||||
Path realPackRoot = Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)
|
||||
? packRoot.toRealPath() : packRoot;
|
||||
if (Files.isSymbolicLink(packRoot) || !Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)
|
||||
|| !packRoot.equals(realPackRoot) || !allowedEditableRoots.contains(realPackRoot)) {
|
||||
throw new IOException("Invalid editable pack root in datapack transaction: " + packRoot);
|
||||
}
|
||||
try {
|
||||
UUID.fromString(editable.transactionId);
|
||||
UUID.fromString(editable.claimId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IOException("Invalid editable transaction recovery identity", e);
|
||||
}
|
||||
StructureTransactionWriter writer = new StructureTransactionWriter(packRoot);
|
||||
boolean recoveryDataPresent = writer.verifyRecoveryOwner(
|
||||
new StructureTransactionWriter.PreparedRemovalToken(
|
||||
packRoot,
|
||||
UUID.fromString(editable.transactionId)
|
||||
),
|
||||
new StructureTransactionWriter.RecoveryOwner(
|
||||
transactionRoot,
|
||||
UUID.fromString(journal.transactionId),
|
||||
UUID.fromString(editable.claimId)
|
||||
),
|
||||
committedManifest.findById(journal.id) == null
|
||||
&& journal.phase == CoordinatorPhase.PUBLISHED
|
||||
);
|
||||
if (!recoveryDataPresent && journal.phase != CoordinatorPhase.COMMITTED) {
|
||||
throw new IOException("Editable structure recovery data disappeared before commit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void deleteCoordinatorTransaction(Path transactionRoot) throws IOException {
|
||||
DatapackInstall.deleteInstallScratch(transactionRoot.toFile(), "completed datapack transaction");
|
||||
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(transactionRoot.getParent(), "transaction parent"));
|
||||
}
|
||||
|
||||
static void writeCoordinatorJournal(Path transactionRoot, CoordinatorJournal journal) throws IOException {
|
||||
byte[] content = DatapackSupport.GSON.toJson(journal).getBytes(StandardCharsets.UTF_8);
|
||||
if (content.length > MAX_TRANSACTION_JOURNAL_BYTES) {
|
||||
throw new IOException("Datapack transaction journal exceeds " + MAX_TRANSACTION_JOURNAL_BYTES + " bytes");
|
||||
}
|
||||
Files.createDirectories(transactionRoot);
|
||||
Path next = transactionRoot.resolve(TRANSACTION_JOURNAL_NEXT);
|
||||
if (Files.isSymbolicLink(next)) {
|
||||
throw new IOException("Datapack transaction journal cannot be a symbolic link: " + next);
|
||||
}
|
||||
Files.deleteIfExists(next);
|
||||
Files.write(next, content, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
|
||||
DatapackSupport.forceFile(next);
|
||||
DatapackSupport.move(next, transactionRoot.resolve(TRANSACTION_JOURNAL));
|
||||
DatapackSupport.forceDirectoryIfSupported(transactionRoot);
|
||||
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(transactionRoot.getParent(), "transaction parent"));
|
||||
}
|
||||
|
||||
static Path coordinatorPath(String value, String purpose) throws IOException {
|
||||
try {
|
||||
return Path.of(value).toAbsolutePath().normalize();
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid datapack transaction " + purpose + " path", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
@FunctionalInterface
|
||||
interface DirectoryDeleter {
|
||||
void delete(File directory);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
record DirectoryMove(
|
||||
File target,
|
||||
File backup,
|
||||
String originalHash,
|
||||
String originalMarkerHash,
|
||||
String originalIdentity,
|
||||
String targetRootIdentity,
|
||||
String scratchRootIdentity,
|
||||
String targetRootFileIdentity,
|
||||
String scratchRootFileIdentity
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
|
||||
final class DirectoryRemoval {
|
||||
private final List<DirectoryMove> moved;
|
||||
private int prepared;
|
||||
private boolean closed;
|
||||
|
||||
DirectoryRemoval(List<DirectoryMove> moved) {
|
||||
this.moved = List.copyOf(moved);
|
||||
}
|
||||
|
||||
List<DirectoryMove> moves() {
|
||||
return moved;
|
||||
}
|
||||
|
||||
void prepare() throws IOException {
|
||||
if (closed || prepared > 0) {
|
||||
throw new IllegalStateException("Datapack directory removal transaction is not new");
|
||||
}
|
||||
try {
|
||||
for (DirectoryMove directoryMove : moved) {
|
||||
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
|
||||
directoryMove.target().getParentFile(),
|
||||
directoryMove.targetRootIdentity(),
|
||||
directoryMove.targetRootFileIdentity(),
|
||||
"datapack removal target root"
|
||||
);
|
||||
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
|
||||
directoryMove.backup().getParentFile(),
|
||||
directoryMove.scratchRootIdentity(),
|
||||
directoryMove.scratchRootFileIdentity(),
|
||||
"datapack removal scratch root"
|
||||
);
|
||||
DatapackInstallPlanner.verifyDirectorySnapshot(
|
||||
directoryMove.target(),
|
||||
new File(directoryMove.targetRootIdentity()),
|
||||
directoryMove.originalHash(),
|
||||
directoryMove.originalMarkerHash(),
|
||||
directoryMove.originalIdentity(),
|
||||
"datapack removal target"
|
||||
);
|
||||
DatapackSupport.moveNew(directoryMove.target().toPath(), directoryMove.backup().toPath());
|
||||
prepared++;
|
||||
DatapackSupport.forceDirectoryIfSupported(directoryMove.target().getParentFile().toPath());
|
||||
DatapackSupport.forceDirectoryIfSupported(directoryMove.backup().getParentFile().toPath());
|
||||
DatapackInstallPlanner.verifyDirectorySnapshot(
|
||||
directoryMove.backup(),
|
||||
new File(directoryMove.targetRootIdentity()),
|
||||
directoryMove.originalHash(),
|
||||
directoryMove.originalMarkerHash(),
|
||||
directoryMove.originalIdentity(),
|
||||
"datapack removal backup"
|
||||
);
|
||||
}
|
||||
} catch (IOException removalFailure) {
|
||||
try {
|
||||
rollback();
|
||||
} catch (IOException restoreFailure) {
|
||||
removalFailure.addSuppressed(restoreFailure);
|
||||
}
|
||||
throw removalFailure;
|
||||
}
|
||||
}
|
||||
|
||||
void rollback() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
IOException failure = null;
|
||||
for (int i = prepared - 1; i >= 0; i--) {
|
||||
DirectoryMove directoryMove = moved.get(i);
|
||||
if (!Files.exists(directoryMove.backup().toPath(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
|
||||
directoryMove.target().getParentFile(),
|
||||
directoryMove.targetRootIdentity(),
|
||||
directoryMove.targetRootFileIdentity(),
|
||||
"datapack removal target root"
|
||||
);
|
||||
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
|
||||
directoryMove.backup().getParentFile(),
|
||||
directoryMove.scratchRootIdentity(),
|
||||
directoryMove.scratchRootFileIdentity(),
|
||||
"datapack removal scratch root"
|
||||
);
|
||||
DatapackInstallPlanner.verifyDirectorySnapshot(
|
||||
directoryMove.backup(),
|
||||
new File(directoryMove.targetRootIdentity()),
|
||||
directoryMove.originalHash(),
|
||||
directoryMove.originalMarkerHash(),
|
||||
directoryMove.originalIdentity(),
|
||||
"datapack removal backup"
|
||||
);
|
||||
if (DatapackSupport.pathExists(directoryMove.target().toPath(), "datapack removal target")) {
|
||||
throw new IOException("Datapack removal target was concurrently recreated at "
|
||||
+ directoryMove.target().getPath());
|
||||
}
|
||||
DatapackSupport.moveNew(
|
||||
directoryMove.backup().toPath(),
|
||||
directoryMove.target().toPath()
|
||||
);
|
||||
DatapackSupport.forceDirectoryIfSupported(directoryMove.target().getParentFile().toPath());
|
||||
DatapackSupport.forceDirectoryIfSupported(directoryMove.backup().getParentFile().toPath());
|
||||
} catch (IOException restoreFailure) {
|
||||
if (failure == null) {
|
||||
failure = restoreFailure;
|
||||
} else {
|
||||
failure.addSuppressed(restoreFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < prepared; i++) {
|
||||
DirectoryMove directoryMove = moved.get(i);
|
||||
directoryMove.backup().getParentFile().delete();
|
||||
}
|
||||
closed = true;
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
void finishCommit() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
IOException failure = null;
|
||||
for (int i = 0; i < prepared; i++) {
|
||||
DirectoryMove directoryMove = moved.get(i);
|
||||
try {
|
||||
DatapackInstallPlanner.verifyDirectoryContainerIdentity(
|
||||
directoryMove.backup().getParentFile(),
|
||||
directoryMove.scratchRootIdentity(),
|
||||
directoryMove.scratchRootFileIdentity(),
|
||||
"datapack removal scratch root"
|
||||
);
|
||||
DatapackCoordinatorResolution.deleteOriginalBackupIfPresent(
|
||||
directoryMove.backup(),
|
||||
new File(directoryMove.targetRootIdentity()),
|
||||
directoryMove.originalHash(),
|
||||
directoryMove.originalMarkerHash(),
|
||||
directoryMove.originalIdentity()
|
||||
);
|
||||
} catch (IOException cleanupFailure) {
|
||||
failure = DatapackSupport.appendIOException(failure, cleanupFailure);
|
||||
}
|
||||
directoryMove.backup().getParentFile().delete();
|
||||
}
|
||||
closed = true;
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
|
||||
record DownloadResult(boolean notModified, String etag, String lastModified) {
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
final class EditableImportRemoval {
|
||||
private final List<PreparedEditableImport> prepared;
|
||||
private boolean closed;
|
||||
|
||||
EditableImportRemoval(List<PreparedEditableImport> prepared) {
|
||||
this.prepared = List.copyOf(prepared);
|
||||
}
|
||||
|
||||
List<StructureTransactionWriter.PreparedRemovalToken> recoveryTokens() {
|
||||
List<StructureTransactionWriter.PreparedRemovalToken> tokens = new ArrayList<>();
|
||||
for (PreparedEditableImport editableImport : prepared) {
|
||||
editableImport.removal().recoveryToken().ifPresent(tokens::add);
|
||||
}
|
||||
return List.copyOf(tokens);
|
||||
}
|
||||
|
||||
void claimRecoveryOwners(Path transactionRoot, CoordinatorJournal journal) throws IOException {
|
||||
int ownerIndex = 0;
|
||||
for (PreparedEditableImport editableImport : prepared) {
|
||||
Optional<StructureTransactionWriter.PreparedRemovalToken> token =
|
||||
editableImport.removal().recoveryToken();
|
||||
if (token.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (ownerIndex >= journal.editables.size()) {
|
||||
throw new IOException("Missing datapack coordinator claim for editable structure removal");
|
||||
}
|
||||
CoordinatorEditable editable = journal.editables.get(ownerIndex++);
|
||||
StructureTransactionWriter.PreparedRemovalToken recoveryToken = token.get();
|
||||
if (!Objects.equals(recoveryToken.packRoot().toString(), editable.packRoot)
|
||||
|| !Objects.equals(recoveryToken.transactionId().toString(), editable.transactionId)) {
|
||||
throw new IOException("Datapack coordinator editable claim order changed during preparation");
|
||||
}
|
||||
editableImport.removal().claimRecoveryOwner(
|
||||
new StructureTransactionWriter.RecoveryOwner(
|
||||
transactionRoot,
|
||||
UUID.fromString(journal.transactionId),
|
||||
UUID.fromString(editable.claimId)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (ownerIndex != journal.editables.size()) {
|
||||
throw new IOException("Unexpected datapack coordinator editable recovery claim");
|
||||
}
|
||||
}
|
||||
|
||||
void markCommitted() throws IOException {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Editable import removal transaction is closed");
|
||||
}
|
||||
for (PreparedEditableImport editableImport : prepared) {
|
||||
editableImport.removal().markCommitted();
|
||||
}
|
||||
}
|
||||
|
||||
void finishCommit() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
IOException failure = null;
|
||||
for (PreparedEditableImport editableImport : prepared) {
|
||||
try {
|
||||
editableImport.removal().finishCommit();
|
||||
} catch (IOException | RuntimeException cleanupFailure) {
|
||||
IOException transactionFailure = cleanupFailure instanceof IOException ioFailure
|
||||
? ioFailure
|
||||
: new IOException("Failed finalizing editable import removal", cleanupFailure);
|
||||
if (failure == null) {
|
||||
failure = transactionFailure;
|
||||
} else {
|
||||
failure.addSuppressed(transactionFailure);
|
||||
}
|
||||
}
|
||||
if (editableImport.removal().changed()) {
|
||||
try {
|
||||
IrisData.getLoaded(editableImport.dataFolder())
|
||||
.ifPresent(IrisData::invalidateStructureResources);
|
||||
} catch (RuntimeException invalidationFailure) {
|
||||
IOException transactionFailure = new IOException(
|
||||
"Failed invalidating editable import structure resources",
|
||||
invalidationFailure
|
||||
);
|
||||
if (failure == null) {
|
||||
failure = transactionFailure;
|
||||
} else {
|
||||
failure.addSuppressed(transactionFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
closed = true;
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
void rollback() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
IOException failure = null;
|
||||
for (int i = prepared.size() - 1; i >= 0; i--) {
|
||||
try {
|
||||
prepared.get(i).removal().rollback();
|
||||
} catch (IOException rollbackFailure) {
|
||||
if (failure == null) {
|
||||
failure = rollbackFailure;
|
||||
} else {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
closed = true;
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
void leaveForRecovery() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
IOException failure = null;
|
||||
for (PreparedEditableImport editableImport : prepared) {
|
||||
try {
|
||||
editableImport.removal().leaveForRecovery();
|
||||
} catch (IOException releaseFailure) {
|
||||
if (failure == null) {
|
||||
failure = releaseFailure;
|
||||
} else {
|
||||
failure.addSuppressed(releaseFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
closed = true;
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
|
||||
final class InstallExecution {
|
||||
private final InstallResult result;
|
||||
private final DatapackCoordinator coordinator;
|
||||
private final File stagedDir;
|
||||
private final Entry entry;
|
||||
private final String stagedHash;
|
||||
private final boolean verifyStagedSource;
|
||||
private final List<InstallPlan> publishedPlans;
|
||||
private final List<InstallPlan> unchangedPlans;
|
||||
private boolean verified;
|
||||
|
||||
InstallExecution(
|
||||
InstallResult result,
|
||||
DatapackCoordinator coordinator,
|
||||
File stagedDir,
|
||||
Entry entry,
|
||||
String stagedHash,
|
||||
boolean verifyStagedSource,
|
||||
List<InstallPlan> publishedPlans,
|
||||
List<InstallPlan> unchangedPlans
|
||||
) {
|
||||
this.result = result;
|
||||
this.coordinator = coordinator;
|
||||
this.stagedDir = stagedDir;
|
||||
this.entry = DatapackManifestStore.copyEntry(entry);
|
||||
this.stagedHash = stagedHash;
|
||||
this.verifyStagedSource = verifyStagedSource;
|
||||
this.publishedPlans = List.copyOf(publishedPlans);
|
||||
this.unchangedPlans = List.copyOf(unchangedPlans);
|
||||
}
|
||||
|
||||
InstallResult result() {
|
||||
return result;
|
||||
}
|
||||
|
||||
DatapackCoordinator coordinator() {
|
||||
return coordinator;
|
||||
}
|
||||
|
||||
File stagedDir() {
|
||||
return stagedDir;
|
||||
}
|
||||
|
||||
Entry entry() {
|
||||
return entry;
|
||||
}
|
||||
|
||||
String stagedHash() {
|
||||
return stagedHash;
|
||||
}
|
||||
|
||||
boolean verifyStagedSource() {
|
||||
return verifyStagedSource;
|
||||
}
|
||||
|
||||
List<InstallPlan> publishedPlans() {
|
||||
return publishedPlans;
|
||||
}
|
||||
|
||||
List<InstallPlan> unchangedPlans() {
|
||||
return unchangedPlans;
|
||||
}
|
||||
|
||||
boolean verified() {
|
||||
return verified;
|
||||
}
|
||||
|
||||
void markVerified() {
|
||||
verified = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
record InstallPlan(
|
||||
File target,
|
||||
File pending,
|
||||
File backup,
|
||||
File pendingRoot,
|
||||
boolean hadTarget,
|
||||
boolean publishRequired,
|
||||
boolean contentChanged,
|
||||
String originalHash,
|
||||
String desiredHash,
|
||||
String originalMarkerHash,
|
||||
String desiredMarkerHash,
|
||||
String originalIdentity,
|
||||
String desiredIdentity,
|
||||
String targetRootIdentity,
|
||||
String scratchRootIdentity,
|
||||
String targetRootFileIdentity,
|
||||
String scratchRootFileIdentity,
|
||||
String id,
|
||||
String url,
|
||||
VerifiedStagingInstall legacyWorldAuthorization
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
|
||||
record InstallResult(boolean changed) {
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class Manifest {
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
|
||||
Entry find(String url) {
|
||||
for (Entry entry : entries) {
|
||||
if (entry.url != null && entry.url.equals(url)) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Entry findById(String id) {
|
||||
for (Entry entry : entries) {
|
||||
if (entry.id != null && entry.id.equals(id)) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void put(Entry entry) {
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
Entry current = entries.get(i);
|
||||
if (current.url != null && current.url.equals(entry.url)) {
|
||||
entries.set(i, entry);
|
||||
return;
|
||||
}
|
||||
}
|
||||
entries.add(entry);
|
||||
}
|
||||
|
||||
boolean removeById(String id) {
|
||||
return entries.removeIf(entry -> id.equals(entry.id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
final class ManifestWrite implements AutoCloseable {
|
||||
private final Path staged;
|
||||
private final Path target;
|
||||
private final Path rollback;
|
||||
private boolean published;
|
||||
|
||||
ManifestWrite(Path staged, Path target, Path rollback) {
|
||||
this.staged = staged;
|
||||
this.target = target;
|
||||
this.rollback = rollback;
|
||||
}
|
||||
|
||||
void publish() throws IOException {
|
||||
if (published) {
|
||||
throw new IllegalStateException("Datapack manifest write was already published");
|
||||
}
|
||||
try {
|
||||
DatapackSupport.move(staged, target);
|
||||
published = true;
|
||||
} catch (IOException publishFailure) {
|
||||
try {
|
||||
restoreOriginal();
|
||||
} catch (IOException restoreFailure) {
|
||||
publishFailure.addSuppressed(restoreFailure);
|
||||
}
|
||||
throw publishFailure;
|
||||
}
|
||||
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(target.getParent(), "manifest parent"));
|
||||
}
|
||||
|
||||
boolean published() {
|
||||
return published;
|
||||
}
|
||||
|
||||
void discard() throws IOException {
|
||||
IOException failure = null;
|
||||
try {
|
||||
Files.deleteIfExists(staged);
|
||||
} catch (IOException cleanupFailure) {
|
||||
failure = cleanupFailure;
|
||||
}
|
||||
if (rollback != null) {
|
||||
try {
|
||||
Files.deleteIfExists(rollback);
|
||||
} catch (IOException cleanupFailure) {
|
||||
if (failure == null) {
|
||||
failure = cleanupFailure;
|
||||
} else {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
discard();
|
||||
}
|
||||
|
||||
private void restoreOriginal() throws IOException {
|
||||
if (rollback == null) {
|
||||
Files.deleteIfExists(target);
|
||||
} else {
|
||||
DatapackSupport.move(rollback, target);
|
||||
}
|
||||
DatapackSupport.forceDirectoryIfSupported(Objects.requireNonNull(target.getParent(), "manifest parent"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
record MetadataEntry(Path path, String relativePath) {
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
final class Ownership {
|
||||
final int schemaVersion;
|
||||
final String id;
|
||||
final String url;
|
||||
final String versionId;
|
||||
final String versionNumber;
|
||||
final String sha1;
|
||||
final String contentHash;
|
||||
final List<String> structureKeys;
|
||||
final List<String> templateKeys;
|
||||
|
||||
Ownership(
|
||||
int schemaVersion,
|
||||
String id,
|
||||
String url,
|
||||
String versionId,
|
||||
String versionNumber,
|
||||
String sha1,
|
||||
String contentHash,
|
||||
List<String> structureKeys,
|
||||
List<String> templateKeys
|
||||
) {
|
||||
this.schemaVersion = schemaVersion;
|
||||
this.id = id;
|
||||
this.url = url;
|
||||
this.versionId = versionId;
|
||||
this.versionNumber = versionNumber;
|
||||
this.sha1 = sha1;
|
||||
this.contentHash = contentHash;
|
||||
this.structureKeys = List.copyOf(structureKeys);
|
||||
this.templateKeys = List.copyOf(templateKeys);
|
||||
}
|
||||
|
||||
Entry toEntry() {
|
||||
Entry entry = new Entry();
|
||||
entry.id = id;
|
||||
entry.url = url;
|
||||
entry.versionId = versionId;
|
||||
entry.versionNumber = versionNumber;
|
||||
entry.sha1 = sha1;
|
||||
entry.structureKeys = DatapackManifestStore.normalizeKeys(structureKeys);
|
||||
entry.templateKeys = DatapackManifestStore.normalizeKeys(templateKeys);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
record PackResources(
|
||||
List<String> structureKeys,
|
||||
List<String> structureSetKeys,
|
||||
List<String> templateKeys
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
record PreparedEditableImport(
|
||||
File dataFolder,
|
||||
StructureTransactionWriter.PreparedRemoval removal
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
|
||||
enum RemoveOutcome {
|
||||
REMOVED,
|
||||
REJECTED,
|
||||
FAILED_AFTER_MUTATION
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
|
||||
record StagingInspection(
|
||||
boolean usable,
|
||||
boolean ownershipCorrected,
|
||||
boolean manifestChanged
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
final class StartupValidationCache {
|
||||
int schemaVersion;
|
||||
String minecraftVersion;
|
||||
int irisVersion;
|
||||
boolean autoIngest;
|
||||
boolean stripOverrides;
|
||||
List<String> urls;
|
||||
String localFingerprint;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.datapack;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.Entry;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
final class VerifiedStagingInstall {
|
||||
private final Path normalizedRoot;
|
||||
private final Path realRoot;
|
||||
private final String rootIdentity;
|
||||
private final Path normalizedStagingRoot;
|
||||
private final Path realStagingRoot;
|
||||
private final String stagingRootIdentity;
|
||||
private final Path normalizedSource;
|
||||
private final Path realSource;
|
||||
private final String sourceIdentity;
|
||||
private final String id;
|
||||
private final String url;
|
||||
private final String versionId;
|
||||
private final String versionNumber;
|
||||
private final String sha1;
|
||||
private final String desiredHash;
|
||||
private final Entry committedEntrySnapshot;
|
||||
private final boolean legacyReplacementAuthorized;
|
||||
private final DatapackStagingGuard.LegacyStagingSnapshot legacyStagingSnapshot;
|
||||
private boolean consumed;
|
||||
|
||||
VerifiedStagingInstall(
|
||||
Path normalizedRoot,
|
||||
Path normalizedStagingRoot,
|
||||
Path normalizedSource,
|
||||
Entry entry,
|
||||
String desiredHash,
|
||||
Entry committedEntrySnapshot,
|
||||
boolean legacyReplacementAuthorized,
|
||||
DatapackStagingGuard.LegacyStagingSnapshot legacyStagingSnapshot
|
||||
) throws IOException {
|
||||
this.normalizedRoot = normalizedRoot;
|
||||
this.realRoot = normalizedRoot.toRealPath();
|
||||
this.rootIdentity = DatapackSupport.directoryIdentity(normalizedRoot.toFile());
|
||||
this.normalizedStagingRoot = normalizedStagingRoot;
|
||||
this.realStagingRoot = normalizedStagingRoot.toRealPath();
|
||||
this.stagingRootIdentity = DatapackSupport.directoryIdentity(normalizedStagingRoot.toFile());
|
||||
this.normalizedSource = normalizedSource;
|
||||
this.realSource = normalizedSource.toRealPath();
|
||||
this.sourceIdentity = DatapackSupport.directoryIdentity(normalizedSource.toFile());
|
||||
this.id = entry.id;
|
||||
this.url = entry.url;
|
||||
this.versionId = entry.versionId;
|
||||
this.versionNumber = entry.versionNumber;
|
||||
this.sha1 = entry.sha1;
|
||||
this.desiredHash = desiredHash;
|
||||
this.committedEntrySnapshot = committedEntrySnapshot == null ? null : DatapackManifestStore.copyEntry(committedEntrySnapshot);
|
||||
this.legacyReplacementAuthorized = legacyReplacementAuthorized;
|
||||
this.legacyStagingSnapshot = legacyStagingSnapshot;
|
||||
}
|
||||
|
||||
Path stagingRoot() {
|
||||
return normalizedStagingRoot;
|
||||
}
|
||||
|
||||
boolean hasStablePathIdentities() {
|
||||
return !rootIdentity.isEmpty()
|
||||
&& !stagingRootIdentity.isEmpty()
|
||||
&& !sourceIdentity.isEmpty();
|
||||
}
|
||||
|
||||
boolean isCanonicalInstall(File installRoot, File target) {
|
||||
Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize();
|
||||
Path suppliedTarget = target.toPath().toAbsolutePath().normalize();
|
||||
return suppliedRoot.equals(normalizedStagingRoot)
|
||||
&& suppliedTarget.equals(normalizedStagingRoot.resolve(id).normalize());
|
||||
}
|
||||
|
||||
void verifyStagingRoot() throws IOException {
|
||||
verifyPathIdentity(normalizedRoot, realRoot, rootIdentity, "datapack storage root");
|
||||
verifyPathIdentity(
|
||||
normalizedStagingRoot, realStagingRoot, stagingRootIdentity, "datapack staging root");
|
||||
if (!Objects.equals(normalizedStagingRoot.getParent(), normalizedRoot)
|
||||
|| !Files.isSameFile(normalizedStagingRoot.getParent(), normalizedRoot)) {
|
||||
throw new IOException("Verified datapack staging root changed identity");
|
||||
}
|
||||
}
|
||||
|
||||
boolean consume(
|
||||
File source,
|
||||
File installRoot,
|
||||
File target,
|
||||
Entry entry,
|
||||
String stagedHash
|
||||
) throws IOException {
|
||||
if (consumed) {
|
||||
throw new IOException("Verified datapack staging authorization was already consumed");
|
||||
}
|
||||
consumed = true;
|
||||
Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize();
|
||||
Path suppliedTarget = target.toPath().toAbsolutePath().normalize();
|
||||
if (!suppliedRoot.equals(normalizedStagingRoot)
|
||||
|| !suppliedTarget.equals(normalizedStagingRoot.resolve(id).normalize())) {
|
||||
throw new IOException("Verified datapack staging authorization was paired with a different path");
|
||||
}
|
||||
verifyAuthority(source, entry, stagedHash);
|
||||
return legacyReplacementAuthorized;
|
||||
}
|
||||
|
||||
boolean authorizeLegacyWorldReplacement(
|
||||
File source,
|
||||
File installRoot,
|
||||
File target,
|
||||
Entry entry,
|
||||
String stagedHash,
|
||||
String currentHash,
|
||||
String currentMarkerHash
|
||||
) throws IOException {
|
||||
if (legacyStagingSnapshot == null
|
||||
|| !"absent".equals(currentMarkerHash)
|
||||
|| !Objects.equals(currentHash, legacyStagingSnapshot.contentHash())
|
||||
|| legacyStagingSnapshot.targetIdentity().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize();
|
||||
Path suppliedTarget = target.toPath().toAbsolutePath().normalize();
|
||||
if (!Objects.equals(suppliedTarget.getParent(), suppliedRoot)
|
||||
|| !Objects.equals(suppliedTarget.getFileName().toString(), id)
|
||||
|| Files.isSameFile(suppliedRoot, normalizedStagingRoot)
|
||||
|| Files.isSameFile(suppliedTarget, legacyStagingSnapshot.normalizedTarget())) {
|
||||
return false;
|
||||
}
|
||||
verifyAuthority(source, entry, stagedHash);
|
||||
verifyLegacyWorldSnapshot();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void verifyAuthority(File source, Entry entry, String stagedHash) throws IOException {
|
||||
verifyAuthorityState();
|
||||
Path suppliedSource = source.toPath().toAbsolutePath().normalize();
|
||||
if (!suppliedSource.equals(normalizedSource)) {
|
||||
throw new IOException("Verified datapack staging authorization was paired with a different path");
|
||||
}
|
||||
if (!Objects.equals(entry.id, id)
|
||||
|| !Objects.equals(entry.url, url)
|
||||
|| !Objects.equals(entry.versionId, versionId)
|
||||
|| !Objects.equals(entry.versionNumber, versionNumber)
|
||||
|| !Objects.equals(entry.sha1, sha1)
|
||||
|| !Objects.equals(stagedHash, desiredHash)) {
|
||||
throw new IOException("Verified datapack staging authorization was paired with different metadata");
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyAuthorityState() throws IOException {
|
||||
verifyStagingRoot();
|
||||
verifyPathIdentity(normalizedSource, realSource, sourceIdentity, "verified datapack extraction");
|
||||
Manifest committedManifest = DatapackManifestStore.readCommittedManifest(normalizedRoot.toFile());
|
||||
Entry committed = committedManifest.findById(id);
|
||||
if (!manifestEntrySnapshotMatches(committed, committedEntrySnapshot)) {
|
||||
throw new IOException("Committed datapack staging authority changed before installation");
|
||||
}
|
||||
if (committedEntrySnapshot != null && !legacyReplacementAuthorized) {
|
||||
throw new IOException("Committed datapack staging authority conflicts with " + id);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean manifestEntrySnapshotMatches(Entry current, Entry expected) {
|
||||
if (current == null || expected == null) {
|
||||
return current == expected;
|
||||
}
|
||||
return Objects.equals(current.id, expected.id)
|
||||
&& Objects.equals(current.url, expected.url)
|
||||
&& Objects.equals(current.versionId, expected.versionId)
|
||||
&& Objects.equals(current.versionNumber, expected.versionNumber)
|
||||
&& Objects.equals(current.sha1, expected.sha1)
|
||||
&& Objects.equals(current.filename, expected.filename)
|
||||
&& Objects.equals(current.etag, expected.etag)
|
||||
&& Objects.equals(current.lastModified, expected.lastModified)
|
||||
&& current.installedEpoch == expected.installedEpoch
|
||||
&& current.structuresImported == expected.structuresImported
|
||||
&& DatapackManifestStore.copyList(current.structureKeys).equals(DatapackManifestStore.copyList(expected.structureKeys))
|
||||
&& DatapackManifestStore.copyList(current.templateKeys).equals(DatapackManifestStore.copyList(expected.templateKeys))
|
||||
&& Objects.equals(current.importedTargets, expected.importedTargets)
|
||||
&& Objects.equals(current.importAttempts, expected.importAttempts)
|
||||
&& Objects.equals(current.importedBundles, expected.importedBundles);
|
||||
}
|
||||
|
||||
void verifyLegacyWorldSnapshot() throws IOException {
|
||||
verifyAuthorityState();
|
||||
if (legacyStagingSnapshot == null || legacyStagingSnapshot.targetIdentity().isEmpty()) {
|
||||
throw new IOException("Missing stable canonical legacy datapack staging snapshot for " + id);
|
||||
}
|
||||
if (!Objects.equals(legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot)
|
||||
|| !Files.isSameFile(
|
||||
legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot)
|
||||
|| !DatapackScratchRecovery.sameScratchVolume(
|
||||
legacyStagingSnapshot.normalizedTarget(), normalizedStagingRoot)) {
|
||||
throw new IOException("Changed or unsafe canonical legacy datapack staging target for " + id);
|
||||
}
|
||||
DatapackInstallPlanner.verifyDirectorySnapshot(
|
||||
legacyStagingSnapshot.normalizedTarget().toFile(),
|
||||
normalizedStagingRoot.toFile(),
|
||||
legacyStagingSnapshot.contentHash(),
|
||||
legacyStagingSnapshot.markerHash(),
|
||||
legacyStagingSnapshot.targetIdentity(),
|
||||
"canonical legacy datapack staging target"
|
||||
);
|
||||
if (!Objects.equals(
|
||||
legacyStagingSnapshot.normalizedTarget().toRealPath(),
|
||||
legacyStagingSnapshot.realTarget())) {
|
||||
throw new IOException("Changed canonical legacy datapack staging target identity for " + id);
|
||||
}
|
||||
}
|
||||
|
||||
private static void verifyPathIdentity(
|
||||
Path normalized,
|
||||
Path expectedReal,
|
||||
String expectedIdentity,
|
||||
String purpose
|
||||
) throws IOException {
|
||||
if (Files.isSymbolicLink(normalized)
|
||||
|| !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)
|
||||
|| !Objects.equals(normalized.toRealPath(), expectedReal)
|
||||
|| (!expectedIdentity.isEmpty()
|
||||
&& !Objects.equals(DatapackSupport.directoryIdentity(normalized.toFile()), expectedIdentity))) {
|
||||
throw new IOException("Changed or unsafe " + purpose + " " + normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.spi.CapabilityProbe;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.scheduling.FoliaScheduler;
|
||||
import org.bukkit.Bukkit;
|
||||
@@ -202,33 +203,28 @@ public final class CapabilitySnapshot {
|
||||
removeLevelMethod = null;
|
||||
}
|
||||
|
||||
Method unloadWorldAsyncMethod = null;
|
||||
try {
|
||||
if (bukkitServer != null) {
|
||||
unloadWorldAsyncMethod = CapabilityResolution.resolveMethod(bukkitServer.getClass(), "unloadWorldAsync", method -> {
|
||||
Object resolvedServer = bukkitServer;
|
||||
Method unloadWorldAsyncMethod = resolvedServer == null ? null : CapabilityProbe.attempt(
|
||||
"server#unloadWorldAsync",
|
||||
() -> CapabilityResolution.resolveMethod(resolvedServer.getClass(), "unloadWorldAsync", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 3
|
||||
&& World.class.equals(params[0])
|
||||
&& boolean.class.equals(params[1])
|
||||
&& "Consumer".equals(params[2].getSimpleName());
|
||||
});
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
unloadWorldAsyncMethod = null;
|
||||
}
|
||||
}),
|
||||
null);
|
||||
|
||||
Method chunkAtAsyncMethod = null;
|
||||
try {
|
||||
chunkAtAsyncMethod = CapabilityResolution.resolveMethod(World.class, "getChunkAtAsync", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 3
|
||||
&& int.class.equals(params[0])
|
||||
&& int.class.equals(params[1])
|
||||
&& boolean.class.equals(params[2]);
|
||||
});
|
||||
} catch (Throwable ignored) {
|
||||
chunkAtAsyncMethod = null;
|
||||
}
|
||||
Method chunkAtAsyncMethod = CapabilityProbe.attempt(
|
||||
"world#getChunkAtAsync",
|
||||
() -> CapabilityResolution.resolveMethod(World.class, "getChunkAtAsync", method -> {
|
||||
Class<?>[] params = method.getParameterTypes();
|
||||
return params.length == 3
|
||||
&& int.class.equals(params[0])
|
||||
&& int.class.equals(params[1])
|
||||
&& boolean.class.equals(params[2]);
|
||||
}),
|
||||
null);
|
||||
|
||||
return new CapabilitySnapshot(
|
||||
serverFamily,
|
||||
@@ -436,12 +432,8 @@ public final class CapabilitySnapshot {
|
||||
}
|
||||
|
||||
private static boolean hasCanvasRuntime() {
|
||||
try {
|
||||
Class.forName("io.canvasmc.canvas.region.WorldRegionizer");
|
||||
return true;
|
||||
} catch (Throwable ignored) {
|
||||
return false;
|
||||
}
|
||||
return CapabilityProbe.succeeds("io.canvasmc.canvas.region.WorldRegionizer",
|
||||
() -> Class.forName("io.canvasmc.canvas.region.WorldRegionizer"));
|
||||
}
|
||||
|
||||
private static boolean containsIgnoreCase(String value, String needle) {
|
||||
@@ -452,14 +444,10 @@ public final class CapabilitySnapshot {
|
||||
}
|
||||
|
||||
private static Object[] resolveWorldsProvider() throws Throwable {
|
||||
try {
|
||||
Class<?> worldsProviderClass = Class.forName("net.thenextlvl.worlds.api.WorldsProvider");
|
||||
Class<?> levelStemClass = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem");
|
||||
Class<?> generatorTypeClass = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType");
|
||||
Object provider = Bukkit.getServicesManager().load(worldsProviderClass);
|
||||
String resolution = provider == null ? "inactive(service not registered)" : "active(service=" + provider.getClass().getName() + ")";
|
||||
return new Object[]{provider, levelStemClass, generatorTypeClass, resolution};
|
||||
} catch (Throwable ignored) {
|
||||
Object[] direct = CapabilityProbe.attempt("net.thenextlvl.worlds.api.WorldsProvider",
|
||||
CapabilitySnapshot::loadWorldsProvider, null);
|
||||
if (direct != null) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
Collection<Class<?>> knownServices = Bukkit.getServicesManager().getKnownServices();
|
||||
@@ -490,4 +478,13 @@ public final class CapabilitySnapshot {
|
||||
return new Object[]{null, null, null, "inactive(service scan found nothing)"};
|
||||
}
|
||||
|
||||
private static Object[] loadWorldsProvider() throws ClassNotFoundException {
|
||||
Class<?> worldsProviderClass = Class.forName("net.thenextlvl.worlds.api.WorldsProvider");
|
||||
Class<?> levelStemClass = Class.forName("net.thenextlvl.worlds.api.generator.LevelStem");
|
||||
Class<?> generatorTypeClass = Class.forName("net.thenextlvl.worlds.api.generator.GeneratorType");
|
||||
Object provider = Bukkit.getServicesManager().load(worldsProviderClass);
|
||||
String resolution = provider == null ? "inactive(service not registered)" : "active(service=" + provider.getClass().getName() + ")";
|
||||
return new Object[]{provider, levelStemClass, generatorTypeClass, resolution};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import art.arcane.iris.util.common.io.Durability;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
@@ -19,11 +20,15 @@ final class DirectoryDurability {
|
||||
}
|
||||
|
||||
static void forceDirectoryRequired(Path directory) throws IOException {
|
||||
if (!Durability.enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (File.separatorChar == '\\') {
|
||||
return;
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
Durability.force(channel);
|
||||
} catch (UnsupportedOperationException failure) {
|
||||
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.localization.IrisLanguage;
|
||||
import art.arcane.iris.core.runtime.RuntimeInjection;
|
||||
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
|
||||
public final class IrisRuntimeStatics {
|
||||
private IrisRuntimeStatics() {
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
IrisServices.clear();
|
||||
WorldLifecycleService.reset();
|
||||
WorldRuntimeControlService.reset();
|
||||
RuntimeInjection.reset();
|
||||
ServerConfigurator.resetLoadedDatapackRuntime();
|
||||
IrisLanguage.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.runtime.RuntimeInjection;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
@@ -51,6 +52,12 @@ public final class WorldLifecycleService {
|
||||
this.worldBackendByKey = new ConcurrentHashMap<>();
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
synchronized (WorldLifecycleService.class) {
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
public static WorldLifecycleService get() {
|
||||
WorldLifecycleService current = instance;
|
||||
if (current != null) {
|
||||
@@ -76,6 +83,7 @@ public final class WorldLifecycleService {
|
||||
public CompletableFuture<World> create(WorldLifecycleRequest request) {
|
||||
WorldLifecycleBackend backend;
|
||||
try {
|
||||
RuntimeInjection.installIfDeferred();
|
||||
if (request.callerKind() == WorldLifecycleCaller.FORCED_STUDIO) {
|
||||
ServerConfigurator.requireWorldCreationReady(true);
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,8 @@ import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.INMSBinding;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.spi.CapabilityProbe;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import art.arcane.volmlib.util.scheduling.FoliaScheduler;
|
||||
@@ -412,7 +414,9 @@ final class WorldLifecycleSupport {
|
||||
try {
|
||||
Method closeMethod = levelStorageAccess.getClass().getMethod("close");
|
||||
closeMethod.invoke(levelStorageAccess);
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Failed to close the level storage access; the world session lock may still be held.",
|
||||
unwrap(failure));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,11 +716,8 @@ final class WorldLifecycleSupport {
|
||||
if (server == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Method method = server.getClass().getMethod("isGlobalTickThread");
|
||||
return Boolean.TRUE.equals(method.invoke(server));
|
||||
} catch (Throwable ignored) {
|
||||
return false;
|
||||
}
|
||||
return CapabilityProbe.attempt("server#isGlobalTickThread",
|
||||
() -> Boolean.TRUE.equals(server.getClass().getMethod("isGlobalTickThread").invoke(server)),
|
||||
Boolean.FALSE);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.WorldSlotKey;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
|
||||
|
||||
import art.arcane.iris.util.common.io.Durability;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -345,7 +346,7 @@ public final class WorldReplacementJournal {
|
||||
while (buffer.hasRemaining()) {
|
||||
channel.write(buffer);
|
||||
}
|
||||
channel.force(true);
|
||||
Durability.force(channel);
|
||||
}
|
||||
forceDirectoryRequired(parent);
|
||||
try {
|
||||
|
||||
@@ -204,8 +204,10 @@ public class MultiverseCoreLink {
|
||||
try {
|
||||
worldKey = WorldIdentity.parse(entry.getKey());
|
||||
worldName = IrisWorldStorage.configuredWorldName(worldKey, levelName);
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable unreadable) {
|
||||
// Not a world with a Bukkit startup name; Multiverse cannot be holding an entry for it.
|
||||
IrisLogging.debug("Skipping Iris world registry entry " + entry.getKey()
|
||||
+ " for Multiverse reconciliation: " + describe(unreadable));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -324,8 +326,8 @@ public class MultiverseCoreLink {
|
||||
try {
|
||||
return IrisWorlds.get().getWorlds().get(worldKey.toString());
|
||||
} catch (Throwable unreadable) {
|
||||
IrisLogging.debug("Could not read the Iris world registry for " + worldKey + ": "
|
||||
+ describe(unreadable));
|
||||
IrisLogging.reportError("Could not read the Iris world registry for " + worldKey
|
||||
+ "; Multiverse keeps whatever generator it already recorded.", unreadable);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -382,7 +384,8 @@ public class MultiverseCoreLink {
|
||||
IrisDimension dimension = IrisWorlds.get().getDimension(worldKey.toString(), pack);
|
||||
return dimension == null ? null : BukkitEnvironment.from(dimension.getEnvironment());
|
||||
} catch (Throwable unreadable) {
|
||||
IrisLogging.debug("Could not read the Iris dimension for " + worldKey + ": " + describe(unreadable));
|
||||
IrisLogging.reportError("Could not read the Iris dimension for " + worldKey
|
||||
+ "; Multiverse keeps whatever environment it already recorded.", unreadable);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,7 +471,9 @@ public final class MultiverseGuardListener implements Listener {
|
||||
for (String line : lines) {
|
||||
target.sendMessage(line);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable undeliverable) {
|
||||
IrisLogging.debug("Falling back to plain delivery for a Multiverse guard message: "
|
||||
+ describe(undeliverable));
|
||||
for (String line : lines) {
|
||||
ComponentMessenger.sendSection(sender, line);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.core.link;
|
||||
|
||||
import art.arcane.iris.spi.CapabilityProbe;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.engine.data.cache.AtomicCache;
|
||||
import art.arcane.volmlib.util.data.Cuboid;
|
||||
@@ -63,12 +64,7 @@ public class WorldEditLink {
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean present;
|
||||
try {
|
||||
present = detector.getAsBoolean();
|
||||
} catch (Throwable ignored) {
|
||||
return false;
|
||||
}
|
||||
boolean present = CapabilityProbe.attempt("WorldEdit", detector::getAsBoolean, Boolean.FALSE);
|
||||
|
||||
if (present) {
|
||||
active.aquire(() -> Boolean.TRUE);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.core.localization;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.spi.CapabilityProbe;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.volmlib.util.director.DirectorTextResolver;
|
||||
@@ -212,11 +213,10 @@ public final class IrisLanguage {
|
||||
if (root == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return overrideFile(root, configuredLocale()).equals(file.getAbsoluteFile());
|
||||
} catch (RuntimeException ignored) {
|
||||
return false;
|
||||
}
|
||||
File overrideRoot = root;
|
||||
return CapabilityProbe.attempt("locale override path",
|
||||
() -> overrideFile(overrideRoot, configuredLocale()).equals(file.getAbsoluteFile()),
|
||||
Boolean.FALSE);
|
||||
}
|
||||
|
||||
private static boolean applyReload(File root, String requestedLocale, SnapshotCapture capture) {
|
||||
@@ -429,6 +429,8 @@ public final class IrisLanguage {
|
||||
}
|
||||
|
||||
public static synchronized void shutdown() {
|
||||
dataFolder = null;
|
||||
PLAIN_MEMO.set(null);
|
||||
PluginLanguageService current = selections;
|
||||
selections = null;
|
||||
if (current != null) {
|
||||
|
||||
@@ -35,6 +35,9 @@ import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.scheduling.Looper;
|
||||
import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.Condition;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -43,6 +46,8 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
public class IrisPregenerator {
|
||||
private static final double INVALID = 9223372036854775807d;
|
||||
private static final AtomicLong JOB_SEQUENCE = new AtomicLong(0L);
|
||||
private static final long GATE_WAIT_MS = 50L;
|
||||
private static final long RECLAIM_WAIT_MS = 150L;
|
||||
private final long jobId;
|
||||
private final PregenTask task;
|
||||
private final PregeneratorMethod generator;
|
||||
@@ -50,6 +55,8 @@ public class IrisPregenerator {
|
||||
private final Looper ticker;
|
||||
private final AtomicBoolean paused;
|
||||
private final AtomicBoolean shutdown;
|
||||
private final ReentrantLock stateLock = new ReentrantLock();
|
||||
private final Condition stateChanged = stateLock.newCondition();
|
||||
private final PregenRateTracker rateTracker;
|
||||
private final KList<Integer> chunksPerSecondHistory;
|
||||
private final AtomicLong generated;
|
||||
@@ -193,6 +200,7 @@ public class IrisPregenerator {
|
||||
|
||||
public void close() {
|
||||
shutdown.set(true);
|
||||
signalState();
|
||||
}
|
||||
|
||||
public void start() {
|
||||
@@ -295,6 +303,7 @@ public class IrisPregenerator {
|
||||
reclaimTectonicPlates(mantle);
|
||||
}
|
||||
});
|
||||
shutdownStep("parallelism", PregenPerformanceProfile::restore);
|
||||
shutdownStep("protocol", () -> {
|
||||
IrisProtocolServer protocolServer = IrisServices.getOrNull(IrisProtocolServer.class);
|
||||
if (protocolServer != null) {
|
||||
@@ -337,7 +346,7 @@ public class IrisPregenerator {
|
||||
}
|
||||
|
||||
previousLoaded = loaded;
|
||||
J.sleep(150);
|
||||
awaitStateChange(RECLAIM_WAIT_MS);
|
||||
}
|
||||
|
||||
int loadedAfter = mantle.getLoadedRegionCount();
|
||||
@@ -348,7 +357,7 @@ public class IrisPregenerator {
|
||||
|
||||
private void visitRegion(int x, int z, boolean regions) {
|
||||
while (paused.get() && !shutdown.get()) {
|
||||
J.sleep(50);
|
||||
awaitStateChange(GATE_WAIT_MS);
|
||||
}
|
||||
|
||||
if (shutdown.get()) {
|
||||
@@ -376,7 +385,7 @@ public class IrisPregenerator {
|
||||
if (!paused.get()) {
|
||||
reclaimHeapPressure();
|
||||
}
|
||||
J.sleep(50);
|
||||
awaitStateChange(GATE_WAIT_MS);
|
||||
}
|
||||
|
||||
if (shutdown.get()) {
|
||||
@@ -439,10 +448,32 @@ public class IrisPregenerator {
|
||||
|
||||
public void pause() {
|
||||
paused.set(true);
|
||||
signalState();
|
||||
}
|
||||
|
||||
public void resume() {
|
||||
paused.set(false);
|
||||
signalState();
|
||||
}
|
||||
|
||||
private void signalState() {
|
||||
stateLock.lock();
|
||||
try {
|
||||
stateChanged.signalAll();
|
||||
} finally {
|
||||
stateLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitStateChange(long millis) {
|
||||
stateLock.lock();
|
||||
try {
|
||||
stateChanged.await(millis, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
stateLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private PregenListener listenify(PregenListener listener) {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.pregenerator;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.Condition;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
public final class PregenAdmissionGate {
|
||||
public record Wait(long adaptiveMs, long permitMs) {
|
||||
}
|
||||
|
||||
private final int permits;
|
||||
private final long boundMs;
|
||||
private final LongSupplier clock;
|
||||
private final ReentrantLock lock = new ReentrantLock(true);
|
||||
private final Condition changed = lock.newCondition();
|
||||
private int free;
|
||||
private volatile int freeSnapshot;
|
||||
|
||||
public PregenAdmissionGate(int permits, long boundMs, LongSupplier clock) {
|
||||
if (permits < 1) {
|
||||
throw new IllegalArgumentException("A pregen admission gate needs at least one permit");
|
||||
}
|
||||
if (boundMs < 1L) {
|
||||
throw new IllegalArgumentException("A pregen admission gate needs a positive wait bound");
|
||||
}
|
||||
this.permits = permits;
|
||||
this.boundMs = boundMs;
|
||||
this.clock = clock;
|
||||
this.free = permits;
|
||||
this.freeSnapshot = permits;
|
||||
}
|
||||
|
||||
public int permits() {
|
||||
return permits;
|
||||
}
|
||||
|
||||
public int availablePermits() {
|
||||
return freeSnapshot;
|
||||
}
|
||||
|
||||
public Wait admit(BooleanSupplier overAdaptiveLimit, BooleanSupplier cancelled) throws InterruptedException {
|
||||
long adaptiveMs = 0L;
|
||||
long permitMs = 0L;
|
||||
lock.lock();
|
||||
try {
|
||||
while (true) {
|
||||
if (cancelled.getAsBoolean()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean blockedByLimit = overAdaptiveLimit.getAsBoolean();
|
||||
if (!blockedByLimit && free > 0) {
|
||||
free--;
|
||||
freeSnapshot = free;
|
||||
return new Wait(adaptiveMs, permitMs);
|
||||
}
|
||||
|
||||
long start = clock.getAsLong();
|
||||
changed.await(boundMs, TimeUnit.MILLISECONDS);
|
||||
long waited = Math.max(0L, clock.getAsLong() - start);
|
||||
if (blockedByLimit) {
|
||||
adaptiveMs += waited;
|
||||
} else {
|
||||
permitMs += waited;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public void release() {
|
||||
lock.lock();
|
||||
try {
|
||||
if (free < permits) {
|
||||
free++;
|
||||
freeSnapshot = free;
|
||||
}
|
||||
changed.signalAll();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public void wake() {
|
||||
lock.lock();
|
||||
try {
|
||||
changed.signalAll();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean awaitDrain(long warningInterval, TimeUnit timeUnit, Runnable onWait) {
|
||||
boolean interrupted = false;
|
||||
lock.lock();
|
||||
try {
|
||||
while (free < permits) {
|
||||
boolean signalled = false;
|
||||
try {
|
||||
signalled = changed.await(warningInterval, timeUnit);
|
||||
} catch (InterruptedException e) {
|
||||
interrupted = true;
|
||||
continue;
|
||||
}
|
||||
if (!signalled && free < permits) {
|
||||
onWait.run();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
return interrupted;
|
||||
}
|
||||
}
|
||||
+17
-16
@@ -16,27 +16,28 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.util.project.hunk;
|
||||
package art.arcane.iris.core.pregenerator;
|
||||
|
||||
public enum HunkFace {
|
||||
TOP(art.arcane.volmlib.util.hunk.HunkFace.TOP),
|
||||
BOTTOM(art.arcane.volmlib.util.hunk.HunkFace.BOTTOM),
|
||||
EAST(art.arcane.volmlib.util.hunk.HunkFace.EAST),
|
||||
WEST(art.arcane.volmlib.util.hunk.HunkFace.WEST),
|
||||
NORTH(art.arcane.volmlib.util.hunk.HunkFace.NORTH),
|
||||
SOUTH(art.arcane.volmlib.util.hunk.HunkFace.SOUTH);
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
private final art.arcane.volmlib.util.hunk.HunkFace shared;
|
||||
|
||||
HunkFace(art.arcane.volmlib.util.hunk.HunkFace shared) {
|
||||
this.shared = shared;
|
||||
public final class PregenDiagnostics {
|
||||
private PregenDiagnostics() {
|
||||
}
|
||||
|
||||
public art.arcane.volmlib.util.hunk.HunkFace shared() {
|
||||
return shared;
|
||||
public static void probeFailed(String probe, Throwable failure) {
|
||||
IrisLogging.debug("Pregen probe " + describeProbe(probe) + " failed: " + describeFailure(failure));
|
||||
}
|
||||
|
||||
public static HunkFace fromShared(art.arcane.volmlib.util.hunk.HunkFace shared) {
|
||||
return HunkFace.valueOf(shared.name());
|
||||
public static String describeProbe(String probe) {
|
||||
return probe == null || probe.isBlank() ? "unnamed" : probe;
|
||||
}
|
||||
|
||||
public static String describeFailure(Throwable failure) {
|
||||
if (failure == null) {
|
||||
return "no throwable reported";
|
||||
}
|
||||
|
||||
String message = failure.getMessage();
|
||||
return message == null ? failure.getClass().getName() : failure.getClass().getName() + ": " + message;
|
||||
}
|
||||
}
|
||||
+33
-10
@@ -22,6 +22,9 @@ import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.math.M;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.Condition;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@@ -33,6 +36,8 @@ public final class PregenMantleBackpressure {
|
||||
private final Runnable onBudgetTimeout;
|
||||
private final Supplier<String> diagnostics;
|
||||
private final BooleanSupplier cancelled;
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final Condition progressed = lock.newCondition();
|
||||
|
||||
public PregenMantleBackpressure(Supplier<Mantle> mantleSupplier, int maxResidentTectonicPlates, int waitMs, long timeoutMs, Runnable onBudgetTimeout, Supplier<String> diagnostics) {
|
||||
this(mantleSupplier, maxResidentTectonicPlates, waitMs, timeoutMs, onBudgetTimeout, diagnostics, () -> false);
|
||||
@@ -108,10 +113,7 @@ public final class PregenMantleBackpressure {
|
||||
+ "), freed " + freed + " last pass, waited " + elapsed + "ms.");
|
||||
}
|
||||
|
||||
try {
|
||||
Thread.sleep(waitMs);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
if (!awaitProgress()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -154,15 +156,34 @@ public final class PregenMantleBackpressure {
|
||||
+ (mantle != null ? " (" + mantle.getLoadedRegionCount() + " plates resident)" : "") + ".");
|
||||
}
|
||||
|
||||
try {
|
||||
Thread.sleep(waitMs);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
if (!awaitProgress()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void signalProgress() {
|
||||
lock.lock();
|
||||
try {
|
||||
progressed.signalAll();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean awaitProgress() {
|
||||
lock.lock();
|
||||
try {
|
||||
progressed.await(waitMs, TimeUnit.MILLISECONDS);
|
||||
return true;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCancelled() {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
return true;
|
||||
@@ -170,7 +191,8 @@ public final class PregenMantleBackpressure {
|
||||
|
||||
try {
|
||||
return cancelled.getAsBoolean();
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable e) {
|
||||
PregenDiagnostics.probeFailed("pregen cancellation state", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -178,7 +200,8 @@ public final class PregenMantleBackpressure {
|
||||
private Mantle resolveMantle() {
|
||||
try {
|
||||
return mantleSupplier.get();
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable e) {
|
||||
PregenDiagnostics.probeFailed("mantle handle for backpressure", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,11 @@ import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import art.arcane.iris.util.project.stream.utility.CachedDoubleStream2D;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public final class PregenPerformanceProfile {
|
||||
private static final AtomicInteger PARALLELISM_HOLDERS = new AtomicInteger();
|
||||
|
||||
private PregenPerformanceProfile() {
|
||||
}
|
||||
|
||||
@@ -45,12 +48,33 @@ public final class PregenPerformanceProfile {
|
||||
}
|
||||
|
||||
if (MultiBurst.burst.raiseParallelism(pregenBurstParallelism(Runtime.getRuntime().availableProcessors()))) {
|
||||
PARALLELISM_HOLDERS.incrementAndGet();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases one hold on the pregeneration burst parallelism. The pool returns to the parallelism it had
|
||||
* before the first pregeneration raised it once the last concurrent job releases its hold.
|
||||
*/
|
||||
public static boolean restore() {
|
||||
while (true) {
|
||||
int holders = PARALLELISM_HOLDERS.get();
|
||||
if (holders <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (PARALLELISM_HOLDERS.compareAndSet(holders, holders - 1)) {
|
||||
return holders == 1 && MultiBurst.burst.restoreParallelism();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int parallelismHolders() {
|
||||
return PARALLELISM_HOLDERS.get();
|
||||
}
|
||||
|
||||
public static void apply(Engine engine) {
|
||||
apply();
|
||||
if (engine != null) {
|
||||
|
||||
+1
-11
@@ -6,10 +6,6 @@ import art.arcane.volmlib.util.documentation.RegionCoordinates;
|
||||
import java.io.File;
|
||||
|
||||
public interface PregenCache {
|
||||
default boolean isThreadSafe() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ChunkCoordinates
|
||||
boolean isChunkCached(int x, int z);
|
||||
|
||||
@@ -32,16 +28,10 @@ public interface PregenCache {
|
||||
}
|
||||
|
||||
default PregenCache sync() {
|
||||
if (isThreadSafe()) return this;
|
||||
return new SynchronizedCache(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
PregenCache EMPTY = new PregenCache() {
|
||||
@Override
|
||||
public boolean isThreadSafe() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isChunkCached(int x, int z) {
|
||||
return false;
|
||||
|
||||
+217
-149
@@ -26,6 +26,9 @@ import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLongArray;
|
||||
import java.util.concurrent.atomic.AtomicReferenceArray;
|
||||
|
||||
public class PregenCacheImpl implements PregenCache {
|
||||
// Daemon threads: this static pool is never shut down, and non-daemon threads here block JVM exit
|
||||
@@ -36,12 +39,15 @@ public class PregenCacheImpl implements PregenCache {
|
||||
return thread;
|
||||
});
|
||||
private static final AtomicBoolean DISPATCHER_REGISTERED = new AtomicBoolean();
|
||||
private static final short SIZE = 1024;
|
||||
private static final int SIZE = 1024;
|
||||
private static final int RETIRED_WRITE_RETRIES = 4;
|
||||
|
||||
private final File directory;
|
||||
private final int maxSize;
|
||||
private final AtomicBoolean missingStorageReported = new AtomicBoolean();
|
||||
private final Object residencyLock = new Object();
|
||||
private final Long2ObjectLinkedOpenHashMap<Plate> cache = new Long2ObjectLinkedOpenHashMap<>();
|
||||
private volatile Plate recent;
|
||||
|
||||
public PregenCacheImpl(File directory, int maxSize) {
|
||||
this.directory = directory;
|
||||
@@ -78,35 +84,119 @@ public class PregenCacheImpl implements PregenCache {
|
||||
@Override
|
||||
@ChunkCoordinates
|
||||
public void cacheChunk(int x, int z) {
|
||||
getPlate(PowerOfTwoCoordinates.floorDivPow2(x, 10), PowerOfTwoCoordinates.floorDivPow2(z, 10)).cache(
|
||||
PowerOfTwoCoordinates.localMaskPow2(PowerOfTwoCoordinates.floorDivPow2(x, 5), PowerOfTwoCoordinates.REGION_CHUNK_BITS),
|
||||
PowerOfTwoCoordinates.localMaskPow2(PowerOfTwoCoordinates.floorDivPow2(z, 5), PowerOfTwoCoordinates.REGION_CHUNK_BITS),
|
||||
region -> region.cache(
|
||||
PowerOfTwoCoordinates.localMaskPow2(x, PowerOfTwoCoordinates.REGION_CHUNK_BITS),
|
||||
PowerOfTwoCoordinates.localMaskPow2(z, PowerOfTwoCoordinates.REGION_CHUNK_BITS)
|
||||
)
|
||||
);
|
||||
int plateX = PowerOfTwoCoordinates.floorDivPow2(x, 10);
|
||||
int plateZ = PowerOfTwoCoordinates.floorDivPow2(z, 10);
|
||||
int regionX = PowerOfTwoCoordinates.localMaskPow2(PowerOfTwoCoordinates.floorDivPow2(x, 5), PowerOfTwoCoordinates.REGION_CHUNK_BITS);
|
||||
int regionZ = PowerOfTwoCoordinates.localMaskPow2(PowerOfTwoCoordinates.floorDivPow2(z, 5), PowerOfTwoCoordinates.REGION_CHUNK_BITS);
|
||||
int localX = PowerOfTwoCoordinates.localMaskPow2(x, PowerOfTwoCoordinates.REGION_CHUNK_BITS);
|
||||
int localZ = PowerOfTwoCoordinates.localMaskPow2(z, PowerOfTwoCoordinates.REGION_CHUNK_BITS);
|
||||
|
||||
for (int attempt = 0; attempt < RETIRED_WRITE_RETRIES; attempt++) {
|
||||
Plate plate = getPlate(plateX, plateZ);
|
||||
plate.cache(regionX, regionZ, region -> region.cache(localX, localZ));
|
||||
if (!plate.retired) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@RegionCoordinates
|
||||
public void cacheRegion(int x, int z) {
|
||||
getPlate(PowerOfTwoCoordinates.floorDivPow2(x, 5), PowerOfTwoCoordinates.floorDivPow2(z, 5)).cache(
|
||||
PowerOfTwoCoordinates.localMaskPow2(x, PowerOfTwoCoordinates.REGION_CHUNK_BITS),
|
||||
PowerOfTwoCoordinates.localMaskPow2(z, PowerOfTwoCoordinates.REGION_CHUNK_BITS),
|
||||
Region::cache
|
||||
);
|
||||
int plateX = PowerOfTwoCoordinates.floorDivPow2(x, 5);
|
||||
int plateZ = PowerOfTwoCoordinates.floorDivPow2(z, 5);
|
||||
int regionX = PowerOfTwoCoordinates.localMaskPow2(x, PowerOfTwoCoordinates.REGION_CHUNK_BITS);
|
||||
int regionZ = PowerOfTwoCoordinates.localMaskPow2(z, PowerOfTwoCoordinates.REGION_CHUNK_BITS);
|
||||
|
||||
for (int attempt = 0; attempt < RETIRED_WRITE_RETRIES; attempt++) {
|
||||
Plate plate = getPlate(plateX, plateZ);
|
||||
plate.cache(regionX, regionZ, Region::cache);
|
||||
if (!plate.retired) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write() {
|
||||
if (cache.isEmpty()) {
|
||||
List<Plate> resident;
|
||||
synchronized (residencyLock) {
|
||||
if (cache.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
resident = new ArrayList<>(cache.values());
|
||||
}
|
||||
|
||||
writePlates(resident);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trim(long unloadDuration) {
|
||||
synchronized (residencyLock) {
|
||||
if (cache.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
long threshold = System.currentTimeMillis() - unloadDuration;
|
||||
List<Plate> resident = new ArrayList<>(cache.size());
|
||||
Iterator<Plate> iterator = cache.values().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Plate plate = iterator.next();
|
||||
if (plate.lastAccess < threshold) {
|
||||
iterator.remove();
|
||||
plate.retired = true;
|
||||
}
|
||||
resident.add(plate);
|
||||
}
|
||||
|
||||
writePlates(resident);
|
||||
}
|
||||
}
|
||||
|
||||
private Plate getPlate(int x, int z) {
|
||||
long key = key(x, z);
|
||||
Plate memo = recent;
|
||||
if (memo != null && memo.key == key && !memo.retired) {
|
||||
return memo;
|
||||
}
|
||||
|
||||
return residePlate(key, x, z);
|
||||
}
|
||||
|
||||
private Plate residePlate(long key, int x, int z) {
|
||||
Plate resolved;
|
||||
synchronized (residencyLock) {
|
||||
resolved = cache.getAndMoveToFirst(key);
|
||||
if (resolved == null) {
|
||||
resolved = readPlate(key, x, z);
|
||||
cache.putAndMoveToFirst(key, resolved);
|
||||
List<Plate> evicted = null;
|
||||
while (cache.size() > maxSize) {
|
||||
Plate removed = cache.removeLast();
|
||||
removed.retired = true;
|
||||
if (evicted == null) {
|
||||
evicted = new ArrayList<>(2);
|
||||
}
|
||||
evicted.add(removed);
|
||||
}
|
||||
if (evicted != null) {
|
||||
writePlates(evicted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recent = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private void writePlates(List<Plate> plates) {
|
||||
if (plates.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<CompletableFuture<Void>> futures = new ArrayList<>(cache.size());
|
||||
for (Plate plate : cache.values()) {
|
||||
if (!plate.dirty) {
|
||||
List<CompletableFuture<Void>> futures = new ArrayList<>(plates.size());
|
||||
for (Plate plate : plates) {
|
||||
if (!plate.dirty.get()) {
|
||||
continue;
|
||||
}
|
||||
futures.add(CompletableFuture.runAsync(() -> writePlate(plate), DISPATCHER));
|
||||
@@ -117,84 +207,41 @@ public class PregenCacheImpl implements PregenCache {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trim(long unloadDuration) {
|
||||
if (cache.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
long threshold = System.currentTimeMillis() - unloadDuration;
|
||||
List<CompletableFuture<Void>> futures = new ArrayList<>(cache.size());
|
||||
Iterator<Plate> iterator = cache.values().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Plate plate = iterator.next();
|
||||
if (plate.lastAccess < threshold) {
|
||||
iterator.remove();
|
||||
}
|
||||
futures.add(CompletableFuture.runAsync(() -> writePlate(plate), DISPATCHER));
|
||||
}
|
||||
|
||||
for (CompletableFuture<Void> future : futures) {
|
||||
future.join();
|
||||
}
|
||||
}
|
||||
|
||||
private Plate getPlate(int x, int z) {
|
||||
long key = key(x, z);
|
||||
Plate plate = cache.getAndMoveToFirst(key);
|
||||
if (plate != null) {
|
||||
return plate;
|
||||
}
|
||||
|
||||
Plate loaded = readPlate(x, z);
|
||||
cache.putAndMoveToFirst(key, loaded);
|
||||
|
||||
if (cache.size() > maxSize) {
|
||||
List<CompletableFuture<Void>> futures = new ArrayList<>(cache.size() - maxSize);
|
||||
while (cache.size() > maxSize) {
|
||||
Plate evicted = cache.removeLast();
|
||||
futures.add(CompletableFuture.runAsync(() -> writePlate(evicted), DISPATCHER));
|
||||
}
|
||||
for (CompletableFuture<Void> future : futures) {
|
||||
future.join();
|
||||
}
|
||||
}
|
||||
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private Plate readPlate(int x, int z) {
|
||||
private Plate readPlate(long key, int x, int z) {
|
||||
File file = fileForPlate(x, z);
|
||||
if (!file.exists()) {
|
||||
return new Plate(x, z);
|
||||
return new Plate(key, x, z);
|
||||
}
|
||||
|
||||
try (DataInputStream input = new DataInputStream(new LZ4BlockInputStream(new FileInputStream(file)))) {
|
||||
return readPlate(x, z, input);
|
||||
return readPlate(key, x, z, input);
|
||||
} catch (IOException e) {
|
||||
IrisLogging.error("Failed to read pregen cache " + file);
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
|
||||
return new Plate(x, z);
|
||||
return new Plate(key, x, z);
|
||||
}
|
||||
|
||||
private void writePlate(Plate plate) {
|
||||
if (!plate.dirty) {
|
||||
return;
|
||||
}
|
||||
if (!prepareCacheDirectory()) {
|
||||
return;
|
||||
}
|
||||
synchronized (plate) {
|
||||
if (!plate.dirty.compareAndSet(true, false)) {
|
||||
return;
|
||||
}
|
||||
if (!prepareCacheDirectory()) {
|
||||
plate.dirty.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
File file = null;
|
||||
try {
|
||||
file = fileForPlate(plate.x, plate.z);
|
||||
IO.write(file, output -> new DataOutputStream(new LZ4BlockOutputStream(output)), plate::write);
|
||||
plate.dirty = false;
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.error("Failed to write pregen cache " + (file != null ? file : "c." + plate.x + "." + plate.z));
|
||||
IrisLogging.reportError(e);
|
||||
File file = null;
|
||||
try {
|
||||
file = fileForPlate(plate.x, plate.z);
|
||||
IO.write(file, output -> new DataOutputStream(new LZ4BlockOutputStream(output)), plate::write);
|
||||
} catch (Throwable e) {
|
||||
plate.dirty.set(true);
|
||||
IrisLogging.error("Failed to write pregen cache " + (file != null ? file : "c." + plate.x + "." + plate.z));
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,36 +290,48 @@ public class PregenCacheImpl implements PregenCache {
|
||||
}
|
||||
|
||||
private static class Plate {
|
||||
private final long key;
|
||||
private final int x;
|
||||
private final int z;
|
||||
private short count;
|
||||
private Region[] regions;
|
||||
private boolean dirty;
|
||||
private long lastAccess;
|
||||
private final AtomicInteger count;
|
||||
private final AtomicReferenceArray<Region> regions;
|
||||
private final AtomicBoolean dirty = new AtomicBoolean();
|
||||
private volatile long lastAccess;
|
||||
private volatile boolean retired;
|
||||
|
||||
private Plate(int x, int z) {
|
||||
this(x, z, (short) 0, new Region[1024]);
|
||||
private Plate(long key, int x, int z) {
|
||||
this(key, x, z, 0, new AtomicReferenceArray<>(SIZE));
|
||||
}
|
||||
|
||||
private Plate(int x, int z, short count, Region[] regions) {
|
||||
private Plate(long key, int x, int z, int count, AtomicReferenceArray<Region> regions) {
|
||||
this.key = key;
|
||||
this.x = x;
|
||||
this.z = z;
|
||||
this.count = count;
|
||||
this.count = new AtomicInteger(count);
|
||||
this.regions = regions;
|
||||
this.lastAccess = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
private int completed() {
|
||||
return Math.min(count.get(), SIZE);
|
||||
}
|
||||
|
||||
private boolean cache(int x, int z, RegionCacheOp op) {
|
||||
lastAccess = System.currentTimeMillis();
|
||||
if (count == SIZE) {
|
||||
if (completed() == SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
AtomicReferenceArray<Region> current = regions;
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int index = PowerOfTwoCoordinates.packLocal32(x, z);
|
||||
Region region = regions[index];
|
||||
Region region = current.get(index);
|
||||
if (region == null) {
|
||||
region = new Region();
|
||||
regions[index] = region;
|
||||
Region created = new Region();
|
||||
region = current.compareAndSet(index, null, created) ? created : current.get(index);
|
||||
}
|
||||
|
||||
CacheResult result = op.apply(region);
|
||||
@@ -280,25 +339,26 @@ public class PregenCacheImpl implements PregenCache {
|
||||
return false;
|
||||
}
|
||||
|
||||
dirty = true;
|
||||
dirty.set(true);
|
||||
if (result != CacheResult.COMPLETED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
count++;
|
||||
if (count == SIZE) {
|
||||
regions = null;
|
||||
}
|
||||
return true;
|
||||
return count.incrementAndGet() >= SIZE;
|
||||
}
|
||||
|
||||
private boolean isCached(int x, int z, RegionPredicate predicate) {
|
||||
lastAccess = System.currentTimeMillis();
|
||||
if (count == SIZE) {
|
||||
if (completed() == SIZE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Region region = regions[PowerOfTwoCoordinates.packLocal32(x, z)];
|
||||
AtomicReferenceArray<Region> current = regions;
|
||||
if (current == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Region region = current.get(PowerOfTwoCoordinates.packLocal32(x, z));
|
||||
if (region == null) {
|
||||
return false;
|
||||
}
|
||||
@@ -306,12 +366,15 @@ public class PregenCacheImpl implements PregenCache {
|
||||
}
|
||||
|
||||
private void write(DataOutput output) throws IOException {
|
||||
Varint.writeSignedVarInt(count, output);
|
||||
if (regions == null) {
|
||||
int total = completed();
|
||||
Varint.writeSignedVarInt(total, output);
|
||||
AtomicReferenceArray<Region> current = regions;
|
||||
if (total == SIZE || current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Region region : regions) {
|
||||
for (int index = 0; index < SIZE; index++) {
|
||||
Region region = current.get(index);
|
||||
output.writeBoolean(region == null);
|
||||
if (region != null) {
|
||||
region.write(output);
|
||||
@@ -321,33 +384,32 @@ public class PregenCacheImpl implements PregenCache {
|
||||
}
|
||||
|
||||
private static class Region {
|
||||
private short count;
|
||||
private long[] words;
|
||||
private final AtomicInteger count;
|
||||
private final AtomicLongArray words;
|
||||
|
||||
private Region() {
|
||||
this((short) 0, new long[64]);
|
||||
this(0, new AtomicLongArray(64));
|
||||
}
|
||||
|
||||
private Region(short count, long[] words) {
|
||||
this.count = count;
|
||||
private Region(int count, AtomicLongArray words) {
|
||||
this.count = new AtomicInteger(count);
|
||||
this.words = words;
|
||||
}
|
||||
|
||||
private int completed() {
|
||||
return Math.min(count.get(), SIZE);
|
||||
}
|
||||
|
||||
private CacheResult cache() {
|
||||
if (count == SIZE) {
|
||||
return CacheResult.NOOP;
|
||||
}
|
||||
count = SIZE;
|
||||
words = null;
|
||||
return CacheResult.COMPLETED;
|
||||
return count.getAndSet(SIZE) >= SIZE ? CacheResult.NOOP : CacheResult.COMPLETED;
|
||||
}
|
||||
|
||||
private CacheResult cache(int x, int z) {
|
||||
if (count == SIZE) {
|
||||
if (completed() == SIZE) {
|
||||
return CacheResult.NOOP;
|
||||
}
|
||||
|
||||
long[] value = words;
|
||||
AtomicLongArray value = words;
|
||||
if (value == null) {
|
||||
return CacheResult.NOOP;
|
||||
}
|
||||
@@ -355,73 +417,79 @@ public class PregenCacheImpl implements PregenCache {
|
||||
int index = PowerOfTwoCoordinates.packLocal32(x, z);
|
||||
int wordIndex = index >> 6;
|
||||
long bit = 1L << (index & 63);
|
||||
boolean current = (value[wordIndex] & bit) != 0L;
|
||||
if (current) {
|
||||
return CacheResult.NOOP;
|
||||
while (true) {
|
||||
long current = value.get(wordIndex);
|
||||
if ((current & bit) != 0L) {
|
||||
return CacheResult.NOOP;
|
||||
}
|
||||
if (value.compareAndSet(wordIndex, current, current | bit)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
count++;
|
||||
if (count == SIZE) {
|
||||
words = null;
|
||||
return CacheResult.COMPLETED;
|
||||
}
|
||||
|
||||
value[wordIndex] = value[wordIndex] | bit;
|
||||
return CacheResult.SET;
|
||||
return count.incrementAndGet() >= SIZE ? CacheResult.COMPLETED : CacheResult.SET;
|
||||
}
|
||||
|
||||
private boolean isCached() {
|
||||
return count == SIZE;
|
||||
return completed() == SIZE;
|
||||
}
|
||||
|
||||
private boolean isCached(int x, int z) {
|
||||
int index = PowerOfTwoCoordinates.packLocal32(x, z);
|
||||
if (count == SIZE) {
|
||||
if (completed() == SIZE) {
|
||||
return true;
|
||||
}
|
||||
return (words[index >> 6] & (1L << (index & 63))) != 0L;
|
||||
|
||||
AtomicLongArray value = words;
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
int index = PowerOfTwoCoordinates.packLocal32(x, z);
|
||||
return (value.get(index >> 6) & (1L << (index & 63))) != 0L;
|
||||
}
|
||||
|
||||
private void write(DataOutput output) throws IOException {
|
||||
Varint.writeSignedVarInt(count, output);
|
||||
if (words == null) {
|
||||
int total = completed();
|
||||
Varint.writeSignedVarInt(total, output);
|
||||
AtomicLongArray value = words;
|
||||
if (total == SIZE || value == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (long word : words) {
|
||||
Varint.writeUnsignedVarLong(word, output);
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
Varint.writeUnsignedVarLong(value.get(index), output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Plate readPlate(int x, int z, DataInput input) throws IOException {
|
||||
private static Plate readPlate(long key, int x, int z, DataInput input) throws IOException {
|
||||
int count = Varint.readSignedVarInt(input);
|
||||
if (count == 1024) {
|
||||
return new Plate(x, z, SIZE, null);
|
||||
if (count == SIZE) {
|
||||
return new Plate(key, x, z, SIZE, null);
|
||||
}
|
||||
|
||||
Region[] regions = new Region[1024];
|
||||
for (int i = 0; i < regions.length; i++) {
|
||||
AtomicReferenceArray<Region> regions = new AtomicReferenceArray<>(SIZE);
|
||||
for (int index = 0; index < SIZE; index++) {
|
||||
boolean isNull = input.readBoolean();
|
||||
if (!isNull) {
|
||||
regions[i] = readRegion(input);
|
||||
regions.set(index, readRegion(input));
|
||||
}
|
||||
}
|
||||
|
||||
return new Plate(x, z, (short) count, regions);
|
||||
return new Plate(key, x, z, count, regions);
|
||||
}
|
||||
|
||||
private static Region readRegion(DataInput input) throws IOException {
|
||||
int count = Varint.readSignedVarInt(input);
|
||||
if (count == 1024) {
|
||||
if (count == SIZE) {
|
||||
return new Region(SIZE, null);
|
||||
}
|
||||
|
||||
long[] words = new long[64];
|
||||
for (int i = 0; i < words.length; i++) {
|
||||
words[i] = Varint.readUnsignedVarLong(input);
|
||||
AtomicLongArray words = new AtomicLongArray(64);
|
||||
for (int index = 0; index < words.length(); index++) {
|
||||
words.set(index, Varint.readUnsignedVarLong(input));
|
||||
}
|
||||
|
||||
return new Region((short) count, words);
|
||||
return new Region(count, words);
|
||||
}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
package art.arcane.iris.core.pregenerator.cache;
|
||||
|
||||
record SynchronizedCache(PregenCache cache) implements PregenCache {
|
||||
@Override
|
||||
public boolean isThreadSafe() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isChunkCached(int x, int z) {
|
||||
synchronized (cache) {
|
||||
return cache.isChunkCached(x, z);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRegionCached(int x, int z) {
|
||||
synchronized (cache) {
|
||||
return cache.isRegionCached(x, z);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cacheChunk(int x, int z) {
|
||||
synchronized (cache) {
|
||||
cache.cacheChunk(x, z);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cacheRegion(int x, int z) {
|
||||
synchronized (cache) {
|
||||
cache.cacheRegion(x, z);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write() {
|
||||
synchronized (cache) {
|
||||
cache.write();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void trim(long unloadDuration) {
|
||||
synchronized (cache) {
|
||||
cache.trim(unloadDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-77
@@ -22,6 +22,8 @@ import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.core.IrisPaperLikeBackendMode;
|
||||
import art.arcane.iris.core.IrisRuntimeSchedulerMode;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.pregenerator.PregenAdmissionGate;
|
||||
import art.arcane.iris.core.pregenerator.PregenDiagnostics;
|
||||
import art.arcane.iris.core.pregenerator.PregenListener;
|
||||
import art.arcane.iris.core.pregenerator.PregenMantleBackpressure;
|
||||
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
|
||||
@@ -50,7 +52,6 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@@ -67,6 +68,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
private static final int ADAPTIVE_RECOVERY_INTERVAL = 8;
|
||||
private static final long CLOSE_DRAIN_WARNING_SECONDS = 60L;
|
||||
private static final long FLUSH_TIMEOUT_SECONDS = 120L;
|
||||
private static final long ADMISSION_WAIT_BOUND_MS = 500L;
|
||||
private final World world;
|
||||
private final IrisRuntimeSchedulerMode runtimeSchedulerMode;
|
||||
private final IrisPaperLikeBackendMode paperLikeBackendMode;
|
||||
@@ -81,7 +83,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
private final String chunkAccessMode;
|
||||
private final ChunkRequestExecutor executor;
|
||||
private final Executor slowRequestExecutor;
|
||||
private final Semaphore semaphore;
|
||||
private final PregenAdmissionGate admission;
|
||||
private final int threads;
|
||||
private final int slowRequestWarningSeconds;
|
||||
private final int slowRequestWarnIntervalMs;
|
||||
@@ -110,7 +112,6 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
private final AtomicLong lastProgressAt = new AtomicLong(M.ms());
|
||||
private final AtomicBoolean closing = new AtomicBoolean();
|
||||
private final AtomicBoolean holdsWorkerBoost = new AtomicBoolean();
|
||||
private final Object permitMonitor = new Object();
|
||||
private volatile Engine metricsEngine;
|
||||
private volatile Mantle cachedMantle;
|
||||
private final PregenMantleBackpressure backpressure;
|
||||
@@ -160,7 +161,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
this.runtimeCpuThreads = detectedCpuThreads;
|
||||
this.effectiveWorkerThreads = workerThreadsForCap;
|
||||
this.recommendedRuntimeConcurrencyCap = configuredThreads;
|
||||
this.semaphore = new Semaphore(this.threads, true);
|
||||
this.admission = new PregenAdmissionGate(this.threads, ADMISSION_WAIT_BOUND_MS, M::ms);
|
||||
this.slowRequestWarningSeconds = pregen.getChunkLoadTimeoutSeconds();
|
||||
this.slowRequestExecutor = CompletableFuture.delayedExecutor(this.slowRequestWarningSeconds, TimeUnit.SECONDS);
|
||||
this.slowRequestWarnIntervalMs = pregen.getTimeoutWarnIntervalMs();
|
||||
@@ -212,7 +213,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
if (coreThreads instanceof Thread[] threadsArray) {
|
||||
return threadsArray.length;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable e) {
|
||||
PregenDiagnostics.probeFailed("moonrise worker pool size", e);
|
||||
}
|
||||
|
||||
return -1;
|
||||
@@ -238,7 +240,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
int resolved = radius > 0 ? Math.max(1, (int) Math.ceil(radius / 32.0)) : 2;
|
||||
evictionWindowRegions = resolved;
|
||||
return resolved;
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable e) {
|
||||
PregenDiagnostics.probeFailed("mantle radius for eviction window", e);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -431,7 +434,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
private void unloadChunkSafely(int cx, int cz) {
|
||||
try {
|
||||
world.removePluginChunkTicket(cx, cz, BukkitPlatform.plugin());
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("Async pregen could not release the plugin chunk ticket at " + cx + "," + cz
|
||||
+ " in world " + world.getName() + "; the chunk stays pinned in memory.", e);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -492,11 +497,12 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
}
|
||||
|
||||
private Chunk onChunkFutureFailure(int x, int z, Throwable throwable) {
|
||||
try {
|
||||
IrisLogging.warn("Failed async pregen chunk load at " + x + "," + z + ". " + metricsSnapshot());
|
||||
IrisLogging.reportError("Failed async pregen chunk load at " + x + "," + z + ".", throwable);
|
||||
|
||||
IrisLogging.reportError(throwable);
|
||||
try {
|
||||
IrisLogging.warn("Async pregen state at the failed chunk " + x + "," + z + ". " + metricsSnapshot());
|
||||
} catch (Throwable e) {
|
||||
PregenDiagnostics.probeFailed("pregen metrics snapshot", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -521,11 +527,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
} finally {
|
||||
try {
|
||||
markFinished(success);
|
||||
} finally {
|
||||
semaphore.release();
|
||||
}
|
||||
markFinished(success);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,7 +583,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
int next = Math.max(adaptiveMinInFlightLimit, current - 1);
|
||||
if (adaptiveInFlightLimit.compareAndSet(current, next)) {
|
||||
logAdaptiveLimit("decrease", next);
|
||||
notifyPermitWaiters();
|
||||
admission.wake();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -597,7 +599,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
int next = nextAdaptiveInFlightLimit(current, threads);
|
||||
if (adaptiveInFlightLimit.compareAndSet(current, next)) {
|
||||
logAdaptiveLimit("increase", next);
|
||||
notifyPermitWaiters();
|
||||
admission.wake();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -683,7 +685,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
private String metricsSnapshot() {
|
||||
long stalledFor = Math.max(0L, M.ms() - lastProgressAt.get());
|
||||
return "world=" + world.getName()
|
||||
+ " permits=" + semaphore.availablePermits() + "/" + threads
|
||||
+ " permits=" + admission.availablePermits() + "/" + threads
|
||||
+ " adaptiveLimit=" + adaptiveInFlightLimit.get()
|
||||
+ " inFlight=" + inFlight.get()
|
||||
+ " submitted=" + submitted.get()
|
||||
@@ -710,13 +712,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
if (after < 0) {
|
||||
inFlight.compareAndSet(after, 0);
|
||||
}
|
||||
notifyPermitWaiters();
|
||||
}
|
||||
|
||||
private void notifyPermitWaiters() {
|
||||
synchronized (permitMonitor) {
|
||||
permitMonitor.notifyAll();
|
||||
}
|
||||
admission.release();
|
||||
backpressure.signalProgress();
|
||||
}
|
||||
|
||||
private void recordAdaptiveWait(long waitedMs) {
|
||||
@@ -738,7 +735,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
if (engine != null) {
|
||||
try {
|
||||
engine.getMantle().cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned);
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("Async pregen mantle cleanup failed at chunk " + x + "," + z
|
||||
+ " in world " + world.getName() + "; tectonic plates for that chunk stay resident.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -759,7 +758,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
metricsEngine = resolvedEngine;
|
||||
}
|
||||
return resolvedEngine;
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable e) {
|
||||
PregenDiagnostics.probeFailed("engine access for world " + world.getName(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -809,14 +809,12 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
@Override
|
||||
public void close() {
|
||||
closing.set(true);
|
||||
notifyPermitWaiters();
|
||||
admission.wake();
|
||||
|
||||
// A stop request interrupts the pregen worker; shield the drain and flush so chunks still hit disk.
|
||||
boolean interrupted = Thread.interrupted();
|
||||
try {
|
||||
interrupted |= awaitDrain(
|
||||
semaphore,
|
||||
threads,
|
||||
interrupted |= admission.awaitDrain(
|
||||
CLOSE_DRAIN_WARNING_SECONDS,
|
||||
TimeUnit.SECONDS,
|
||||
() -> IrisLogging.warn("Async pregen is still draining outstanding chunks. " + metricsSnapshot())
|
||||
@@ -834,26 +832,6 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
}
|
||||
}
|
||||
|
||||
static boolean awaitDrain(
|
||||
Semaphore semaphore,
|
||||
int permits,
|
||||
long warningInterval,
|
||||
TimeUnit timeUnit,
|
||||
Runnable onWait
|
||||
) {
|
||||
boolean interrupted = false;
|
||||
while (true) {
|
||||
try {
|
||||
if (semaphore.tryAcquire(permits, warningInterval, timeUnit)) {
|
||||
return interrupted;
|
||||
}
|
||||
onWait.run();
|
||||
} catch (InterruptedException e) {
|
||||
interrupted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCancelled() {
|
||||
return closing.get() || Thread.currentThread().isInterrupted();
|
||||
}
|
||||
@@ -881,37 +859,26 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
return;
|
||||
}
|
||||
|
||||
PregenAdmissionGate.Wait waited;
|
||||
try {
|
||||
long waitStart = M.ms();
|
||||
synchronized (permitMonitor) {
|
||||
while (inFlight.get() >= adaptiveInFlightLimit.get()) {
|
||||
if (isCancelled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
permitMonitor.wait(500L);
|
||||
}
|
||||
}
|
||||
long adaptiveWait = Math.max(0L, M.ms() - waitStart);
|
||||
if (adaptiveWait > 0L) {
|
||||
recordAdaptiveWait(adaptiveWait);
|
||||
}
|
||||
|
||||
long permitWaitStart = M.ms();
|
||||
while (!semaphore.tryAcquire(1, TimeUnit.SECONDS)) {
|
||||
if (isCancelled()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
long permitWait = Math.max(0L, M.ms() - permitWaitStart);
|
||||
if (permitWait > 0L) {
|
||||
recordPermitWait(permitWait);
|
||||
}
|
||||
waited = admission.admit(() -> inFlight.get() >= adaptiveInFlightLimit.get(), this::isCancelled);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
|
||||
if (waited == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (waited.adaptiveMs() > 0L) {
|
||||
recordAdaptiveWait(waited.adaptiveMs());
|
||||
}
|
||||
|
||||
if (waited.permitMs() > 0L) {
|
||||
recordPermitWait(waited.permitMs());
|
||||
}
|
||||
|
||||
regionPending.computeIfAbsent(rkey(x >> 5, z >> 5), k -> new AtomicInteger(1)).incrementAndGet();
|
||||
markSubmitted();
|
||||
executor.generate(x, z, listener);
|
||||
@@ -1094,7 +1061,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
requestMonitoredChunkAsync(x, z)
|
||||
.whenComplete((chunk, throwable) -> completeChunk(x, z, listener, chunk, throwable));
|
||||
return;
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable e) {
|
||||
PregenDiagnostics.probeFailed("folia direct chunk request at " + x + "," + z
|
||||
+ ", falling back to a region task", e);
|
||||
}
|
||||
|
||||
Runnable regionTask = () -> {
|
||||
|
||||
+3
-1
@@ -20,6 +20,7 @@ package art.arcane.iris.core.pregenerator.methods;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.pregenerator.PregenDiagnostics;
|
||||
import art.arcane.iris.core.pregenerator.PregenListener;
|
||||
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
@@ -102,7 +103,8 @@ public class MedievalPregenMethod implements PregeneratorMethod {
|
||||
if (cached != null) {
|
||||
cachedEngine = cached;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable e) {
|
||||
PregenDiagnostics.probeFailed("engine access for world " + world.getName(), e);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.project;
|
||||
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockProperty;
|
||||
import art.arcane.iris.spi.PlatformNumericRange;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
final class SchemaBlockStates {
|
||||
private final SchemaBuilder builder;
|
||||
|
||||
SchemaBlockStates(SchemaBuilder builder) {
|
||||
this.builder = builder;
|
||||
}
|
||||
|
||||
void applyBlockStateMap(JSONObject prop, Field k, String blockType) {
|
||||
prop.put("!top", true);
|
||||
JSONArray any = new JSONArray();
|
||||
prop.put("anyOf", any);
|
||||
|
||||
for (BlockStateGroup group : reconstructBlockStateGroups()) {
|
||||
List<String> blocks = group.blocks();
|
||||
if (blocks.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String raw = blocks.get(0).replace(':', '_');
|
||||
String enumKey = "enum-block-state-" + raw;
|
||||
String propertiesKey = "obj-block-state-" + raw;
|
||||
|
||||
any.put(new JSONObject()
|
||||
.put("if", new JSONObject()
|
||||
.put("properties", new JSONObject()
|
||||
.put(blockType, new JSONObject()
|
||||
.put("type", "string")
|
||||
.put("$ref", definitionReference(enumKey)))))
|
||||
.put("then", new JSONObject()
|
||||
.put("properties", new JSONObject()
|
||||
.put(k.getName(), new JSONObject()
|
||||
.put("type", "object")
|
||||
.put("$ref", definitionReference(propertiesKey)))))
|
||||
.put("else", false));
|
||||
|
||||
if (!builder.definitions.containsKey(enumKey)) {
|
||||
JSONArray filters = new JSONArray();
|
||||
blocks.forEach(filters::put);
|
||||
|
||||
builder.definitions.put(enumKey, new JSONObject()
|
||||
.put("type", "string")
|
||||
.put("enum", filters));
|
||||
}
|
||||
|
||||
if (!builder.definitions.containsKey(propertiesKey)) {
|
||||
JSONObject props = new JSONObject();
|
||||
for (PlatformBlockProperty property : group.properties()) {
|
||||
props.put(property.name(), buildBlockPropertyJson(property));
|
||||
}
|
||||
|
||||
builder.definitions.put(propertiesKey, new JSONObject()
|
||||
.put("type", "object")
|
||||
.put("properties", props));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String definitionReference(String key) {
|
||||
return "#/definitions/" + key.replace("~", "~0").replace("/", "~1");
|
||||
}
|
||||
|
||||
private static JSONObject buildBlockPropertyJson(PlatformBlockProperty property) {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("type", property.jsonType());
|
||||
json.put("default", property.defaultValue());
|
||||
List<Object> allowed = property.allowedValues();
|
||||
if (!allowed.isEmpty()) {
|
||||
json.put("enum", new JSONArray(allowed));
|
||||
}
|
||||
if (property.hasNumericRange()) {
|
||||
PlatformNumericRange range = property.numericRange();
|
||||
if ("integer".equals(property.jsonType())) {
|
||||
json.put("minimum", (long) range.minimum());
|
||||
json.put("maximum", (long) range.maximum());
|
||||
} else {
|
||||
json.put("minimum", range.minimum());
|
||||
json.put("maximum", range.maximum());
|
||||
}
|
||||
json.put("exclusiveMinimum", range.exclusiveMinimum());
|
||||
json.put("exclusiveMaximum", range.exclusiveMaximum());
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
private static List<BlockStateGroup> reconstructBlockStateGroups() {
|
||||
List<BlockStateGroup> groups = new ArrayList<>();
|
||||
List<PlatformBlockProperty> currentProperties = null;
|
||||
List<String> currentBlocks = null;
|
||||
for (Map.Entry<String, List<PlatformBlockProperty>> entry : IrisPlatforms.get().registries().blockStateProperties().entrySet()) {
|
||||
List<PlatformBlockProperty> value = entry.getValue();
|
||||
if (currentProperties != null && value == currentProperties) {
|
||||
currentBlocks.add(entry.getKey());
|
||||
} else {
|
||||
currentBlocks = new ArrayList<>();
|
||||
currentBlocks.add(entry.getKey());
|
||||
currentProperties = value;
|
||||
groups.add(new BlockStateGroup(currentBlocks, value));
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
private record BlockStateGroup(List<String> blocks, List<PlatformBlockProperty> properties) {
|
||||
}
|
||||
}
|
||||
@@ -19,74 +19,36 @@
|
||||
package art.arcane.iris.core.project;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockProperty;
|
||||
import art.arcane.iris.spi.PlatformNumericRange;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.IrisRegistrant;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.core.structure.StructureSchemaKeys;
|
||||
import art.arcane.iris.engine.framework.ListFunction;
|
||||
import art.arcane.iris.engine.object.annotations.ArrayType;
|
||||
import art.arcane.iris.engine.object.annotations.Desc;
|
||||
import art.arcane.iris.engine.object.annotations.MaxNumber;
|
||||
import art.arcane.iris.engine.object.annotations.MinNumber;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListBiome;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListBlockType;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListEnchantment;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListEntityType;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListFont;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListFunction;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListItemType;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListNativeJigsawPool;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListPotionEffect;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListResource;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListSpecialEntity;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListStructure;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructureSet;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryMapBlockState;
|
||||
import art.arcane.iris.engine.object.annotations.Required;
|
||||
import art.arcane.iris.engine.object.annotations.Snippet;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.awt.GraphicsEnvironment;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InaccessibleObjectException;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public class SchemaBuilder {
|
||||
private static final String SYMBOL_LIMIT__N = "*";
|
||||
private static final String SYMBOL_TYPE__N = "";
|
||||
private static final String MINECRAFT_NAMESPACE = "minecraft:";
|
||||
/** Namespaced key or family/namespace prefix: "minecraft:village_plains", "minecraft:village", "nova_structures:". */
|
||||
private static final String VANILLA_STRUCTURE_PREFIX_PATTERN = "^[a-z0-9_.-]+:[a-z0-9_./-]*$";
|
||||
private static volatile JSONArray fontTypes;
|
||||
private final KMap<String, JSONObject> definitions;
|
||||
static final String SYMBOL_LIMIT__N = "*";
|
||||
static final String SYMBOL_TYPE__N = "";
|
||||
final KMap<String, JSONObject> definitions;
|
||||
final KList<String> warnings;
|
||||
final IrisData data;
|
||||
private final Class<?> root;
|
||||
private final KList<String> warnings;
|
||||
private final IrisData data;
|
||||
private JSONArray potionTypes;
|
||||
private JSONArray enchantTypes;
|
||||
private final SchemaPropertyBuilder propertyBuilder;
|
||||
|
||||
public SchemaBuilder(Class<?> root, IrisData data) {
|
||||
this.data = data;
|
||||
warnings = new KList<>();
|
||||
this.definitions = new KMap<>();
|
||||
this.root = root;
|
||||
this.propertyBuilder = new SchemaPropertyBuilder(this);
|
||||
}
|
||||
|
||||
public JSONObject construct() {
|
||||
@@ -117,213 +79,7 @@ public class SchemaBuilder {
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font families are only used for schema completion. Enumerating them touches AWT, which can fail outright on a
|
||||
* headless or mod-loader JVM - a failure degrades to no completion, never to a broken schema.
|
||||
*/
|
||||
private static JSONArray fontTypes() {
|
||||
JSONArray cached = fontTypes;
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
JSONArray built;
|
||||
try {
|
||||
built = new JSONArray(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames());
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.debug("Schema font family enumeration unavailable: " + e.getMessage());
|
||||
built = new JSONArray();
|
||||
}
|
||||
fontTypes = built;
|
||||
return built;
|
||||
}
|
||||
|
||||
private JSONArray potionTypes() {
|
||||
if (potionTypes == null) {
|
||||
potionTypes = registryKeyForms(IrisPlatforms.get().registries().potionEffectKeys(), true);
|
||||
}
|
||||
return potionTypes;
|
||||
}
|
||||
|
||||
private JSONArray enchantTypes() {
|
||||
if (enchantTypes == null) {
|
||||
enchantTypes = registryKeyForms(IrisPlatforms.get().registries().enchantmentKeys(), false);
|
||||
}
|
||||
return enchantTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the full namespaced key for every registry entry, so mod and datapack content is addressable
|
||||
* unambiguously, plus the legacy short form for the vanilla namespace so existing packs stay valid.
|
||||
*/
|
||||
private static JSONArray registryKeyForms(List<String> keys, boolean upperCaseLegacy) {
|
||||
JSONArray a = new JSONArray();
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
if (keys != null) {
|
||||
for (String key : keys) {
|
||||
if (key == null || key.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
if (key.startsWith(MINECRAFT_NAMESPACE) || key.indexOf(':') < 0) {
|
||||
String path = stripNamespace(key);
|
||||
seen.add(upperCaseLegacy ? path.toUpperCase(Locale.ROOT).replace(' ', '_') : path);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String key : seen) {
|
||||
a.put(key);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Biome derivatives resolve through NamespacedKey.fromString, which accepts a full key or a bare vanilla path,
|
||||
* so both forms are offered.
|
||||
*/
|
||||
private JSONArray biomeTypes() {
|
||||
return registryKeyForms(IrisPlatforms.get().registries().biomeKeys(), false);
|
||||
}
|
||||
|
||||
private JSONArray entityTypes() {
|
||||
return keysAsArray(IrisPlatforms.get().registries().entityKeys());
|
||||
}
|
||||
|
||||
private JSONArray specialEntityTypes() {
|
||||
return keysAsArray(IrisPlatforms.get().registries().specialEntityKeys());
|
||||
}
|
||||
|
||||
private JSONArray vanillaStructures() {
|
||||
return keysAsArray(IrisPlatforms.get().registries().structureKeys());
|
||||
}
|
||||
|
||||
private JSONArray vanillaStructureSets() {
|
||||
if (IrisPlatforms.get().structureHooks() == null) {
|
||||
return new JSONArray();
|
||||
}
|
||||
List<String> keys = IrisPlatforms.get().structureHooks().structureSetKeys();
|
||||
return keysAsArray(keys == null ? List.of() : keys);
|
||||
}
|
||||
|
||||
private JSONArray nativeJigsawPools() {
|
||||
return keysAsArray(templatePoolKeys());
|
||||
}
|
||||
|
||||
private static JSONArray keysAsArray(List<String> keys) {
|
||||
JSONArray a = new JSONArray();
|
||||
if (keys != null) {
|
||||
for (String key : keys) {
|
||||
if (key != null && !key.isBlank()) {
|
||||
a.put(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a registry-backed enum definition under {@code definitionKey} and points {@code target} at it.
|
||||
* <p>
|
||||
* An empty key list means the registry has nothing to offer yet - the server is still booting, a modded registry
|
||||
* has not been frozen, or the host simply does not expose that catalog. Emitting {@code "enum": []} in that case
|
||||
* writes a schema that rejects every value the author could possibly type, turning a missing autocomplete list
|
||||
* into a pack that reads as broken in the editor. The reference is omitted instead, leaving the field
|
||||
* unconstrained, and the values are only computed when the definition does not exist yet.
|
||||
*/
|
||||
private void putRegistryEnumRef(JSONObject target, String definitionKey, Supplier<JSONArray> values) {
|
||||
if (!definitions.containsKey(definitionKey)) {
|
||||
JSONArray built = values.get();
|
||||
if (built == null || built.length() == 0) {
|
||||
IrisLogging.debug("Schema enum '" + definitionKey + "' omitted: the registry returned no keys");
|
||||
return;
|
||||
}
|
||||
JSONObject definition = new JSONObject();
|
||||
definition.put("enum", built);
|
||||
definitions.put(definitionKey, definition);
|
||||
}
|
||||
target.put("$ref", "#/definitions/" + definitionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link #putRegistryEnumRef(JSONObject, String, Supplier)} for a list-typed property. When the enum is omitted no
|
||||
* {@code items} schema is written at all, which is valid and simply means "any element".
|
||||
*/
|
||||
private void putRegistryEnumItems(JSONObject prop, String definitionKey, Supplier<JSONArray> values) {
|
||||
JSONObject items = new JSONObject();
|
||||
putRegistryEnumRef(items, definitionKey, values);
|
||||
if (items.has("$ref")) {
|
||||
prop.put("items", items);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry enum that ALSO accepts family/namespace prefixes ("minecraft:village",
|
||||
* "nova_structures:") — the runtime prefix-matching contract of importedStructures.disabled and
|
||||
* adjustments[].match. Emitted as anyOf(enum, pattern) so autocomplete still offers registered
|
||||
* keys while prefix entries validate instead of being rejected.
|
||||
*/
|
||||
private void putRegistryEnumOrPrefixRef(JSONObject target, String definitionKey,
|
||||
String enumDefinitionKey, Supplier<JSONArray> values,
|
||||
String pattern) {
|
||||
if (!definitions.containsKey(definitionKey)) {
|
||||
JSONArray anyOf = new JSONArray();
|
||||
JSONObject enumRef = new JSONObject();
|
||||
try {
|
||||
putRegistryEnumRef(enumRef, enumDefinitionKey, values);
|
||||
} catch (RuntimeException e) {
|
||||
IrisLogging.debug("Schema enum '" + enumDefinitionKey + "' unavailable ("
|
||||
+ e.getMessage() + "); emitting prefix pattern only");
|
||||
}
|
||||
if (enumRef.has("$ref")) {
|
||||
anyOf.put(enumRef);
|
||||
}
|
||||
JSONObject prefix = new JSONObject();
|
||||
prefix.put("type", "string");
|
||||
prefix.put("pattern", pattern);
|
||||
anyOf.put(prefix);
|
||||
JSONObject definition = new JSONObject();
|
||||
definition.put("anyOf", anyOf);
|
||||
definitions.put(definitionKey, definition);
|
||||
}
|
||||
target.put("$ref", "#/definitions/" + definitionKey);
|
||||
}
|
||||
|
||||
private void putRegistryEnumOrPrefixItems(JSONObject prop, String definitionKey,
|
||||
String enumDefinitionKey, Supplier<JSONArray> values,
|
||||
String pattern) {
|
||||
JSONObject items = new JSONObject();
|
||||
putRegistryEnumOrPrefixRef(items, definitionKey, enumDefinitionKey, values, pattern);
|
||||
prop.put("items", items);
|
||||
}
|
||||
|
||||
private JSONArray itemTypes() {
|
||||
JSONArray a = new JSONArray();
|
||||
for (String key : IrisPlatforms.get().registries().itemKeys()) {
|
||||
a.put(key.startsWith(MINECRAFT_NAMESPACE) ? key.substring(MINECRAFT_NAMESPACE.length()) : key);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
private JSONArray blockTypes() {
|
||||
JSONArray a = new JSONArray();
|
||||
for (String i : data.getBlockLoader().getPossibleKeys()) {
|
||||
a.put(i);
|
||||
}
|
||||
for (String i : IrisPlatforms.get().registries().blockTypeKeys()) {
|
||||
a.put(i);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
private static String definitionReference(String key) {
|
||||
return "#/definitions/" + key.replace("~", "~0").replace("/", "~1");
|
||||
}
|
||||
|
||||
private static String stripNamespace(String key) {
|
||||
int idx = key.indexOf(':');
|
||||
return idx < 0 ? key : key.substring(idx + 1);
|
||||
}
|
||||
|
||||
private JSONObject buildProperties(Class<?> c) {
|
||||
JSONObject buildProperties(Class<?> c) {
|
||||
JSONObject o = new JSONObject();
|
||||
JSONObject properties = new JSONObject();
|
||||
String desc = getDescription(c);
|
||||
@@ -366,7 +122,7 @@ public class SchemaBuilder {
|
||||
continue;
|
||||
}
|
||||
|
||||
JSONObject property = buildProperty(k, c);
|
||||
JSONObject property = propertyBuilder.buildProperty(k, c);
|
||||
|
||||
if (Boolean.TRUE == property.remove("!top")) {
|
||||
extended.put(property);
|
||||
@@ -381,437 +137,7 @@ public class SchemaBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject buildProperty(Field k, Class<?> cl) {
|
||||
JSONObject prop = new JSONObject();
|
||||
String type = getType(k.getType());
|
||||
KList<String> description = new KList<>();
|
||||
prop.put("!required", k.isAnnotationPresent(Required.class));
|
||||
prop.put("type", type);
|
||||
String fancyType = "Unknown Type";
|
||||
|
||||
switch (type) {
|
||||
case "boolean" -> fancyType = "Boolean";
|
||||
case "integer" -> {
|
||||
fancyType = "Integer";
|
||||
if (k.isAnnotationPresent(MinNumber.class)) {
|
||||
int min = (int) k.getDeclaredAnnotation(MinNumber.class).value();
|
||||
prop.put("minimum", min);
|
||||
description.add(SYMBOL_LIMIT__N + " Minimum allowed is " + min);
|
||||
}
|
||||
if (k.isAnnotationPresent(MaxNumber.class)) {
|
||||
int max = (int) k.getDeclaredAnnotation(MaxNumber.class).value();
|
||||
prop.put("maximum", max);
|
||||
description.add(SYMBOL_LIMIT__N + " Maximum allowed is " + max);
|
||||
}
|
||||
}
|
||||
case "number" -> {
|
||||
fancyType = "Number";
|
||||
if (k.isAnnotationPresent(MinNumber.class)) {
|
||||
double min = k.getDeclaredAnnotation(MinNumber.class).value();
|
||||
prop.put("minimum", min);
|
||||
description.add(SYMBOL_LIMIT__N + " Minimum allowed is " + min);
|
||||
}
|
||||
if (k.isAnnotationPresent(MaxNumber.class)) {
|
||||
double max = k.getDeclaredAnnotation(MaxNumber.class).value();
|
||||
prop.put("maximum", max);
|
||||
description.add(SYMBOL_LIMIT__N + " Maximum allowed is " + max);
|
||||
}
|
||||
}
|
||||
case "string" -> {
|
||||
fancyType = "Text";
|
||||
if (k.isAnnotationPresent(MinNumber.class)) {
|
||||
int min = (int) k.getDeclaredAnnotation(MinNumber.class).value();
|
||||
prop.put("minLength", min);
|
||||
description.add(SYMBOL_LIMIT__N + " Minimum Length allowed is " + min);
|
||||
}
|
||||
if (k.isAnnotationPresent(MaxNumber.class)) {
|
||||
int max = (int) k.getDeclaredAnnotation(MaxNumber.class).value();
|
||||
prop.put("maxLength", max);
|
||||
description.add(SYMBOL_LIMIT__N + " Maximum Length allowed is " + max);
|
||||
}
|
||||
|
||||
|
||||
if (k.isAnnotationPresent(RegistryListResource.class)) {
|
||||
RegistryListResource rr = k.getDeclaredAnnotation(RegistryListResource.class);
|
||||
ResourceLoader<?> loader = data.getLoaders().get(rr.value());
|
||||
|
||||
if (loader != null) {
|
||||
String key = "erz" + loader.getFolderName();
|
||||
|
||||
if (!definitions.containsKey(key)) {
|
||||
JSONObject j = new JSONObject();
|
||||
j.put("enum", new JSONArray(loader.getPossibleKeys()));
|
||||
definitions.put(key, j);
|
||||
}
|
||||
|
||||
fancyType = "Iris " + loader.getResourceTypeName();
|
||||
prop.put("$ref", "#/definitions/" + key);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid " + loader.getFolderName() + " (use ctrl+space for auto complete!)");
|
||||
} else {
|
||||
IrisLogging.error("Cannot find Registry Loader for type " + rr.value() + " used in " + k.getDeclaringClass().getCanonicalName() + " in field " + k.getName());
|
||||
}
|
||||
} else if (k.isAnnotationPresent(RegistryListStructure.class)) {
|
||||
String key = "enum-iris-structure-placement";
|
||||
|
||||
if (!definitions.containsKey(key)) {
|
||||
JSONObject j = new JSONObject();
|
||||
j.put("enum", new JSONArray(StructureSchemaKeys.collect(
|
||||
Arrays.asList(data.getStructureLoader().getPossibleKeys()),
|
||||
Arrays.asList(data.getJigsawPieceLoader().getPossibleKeys())).toArray(new String[0])));
|
||||
definitions.put(key, j);
|
||||
}
|
||||
|
||||
fancyType = "Structure";
|
||||
prop.put("$ref", "#/definitions/" + key);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)");
|
||||
|
||||
} else if (k.isAnnotationPresent(RegistryListBlockType.class)) {
|
||||
fancyType = "Block Type";
|
||||
putRegistryEnumRef(prop, "enum-block-type", this::blockTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)");
|
||||
|
||||
} else if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) {
|
||||
fancyType = "Native Jigsaw Pool";
|
||||
putRegistryEnumRef(prop, "enum-native-jigsaw-pool", this::nativeJigsawPools);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)");
|
||||
|
||||
} else if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) {
|
||||
fancyType = "Vanilla Structure";
|
||||
if (k.getAnnotation(RegistryListVanillaStructure.class).prefixes()) {
|
||||
putRegistryEnumOrPrefixRef(prop, "enum-vanilla-structure-or-prefix",
|
||||
"enum-vanilla-structure", this::vanillaStructures, VANILLA_STRUCTURE_PREFIX_PATTERN);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a vanilla/datapack structure key or a family/namespace prefix like 'minecraft:village' or 'nova_structures:' (use ctrl+space for auto complete!)");
|
||||
} else {
|
||||
putRegistryEnumRef(prop, "enum-vanilla-structure", this::vanillaStructures);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)");
|
||||
}
|
||||
|
||||
} else if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) {
|
||||
fancyType = "Vanilla Structure Set";
|
||||
putRegistryEnumRef(prop, "enum-vanilla-structure-set", this::vanillaStructureSets);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure SET key (use ctrl+space for auto complete!)");
|
||||
|
||||
} else if (k.isAnnotationPresent(RegistryListItemType.class)) {
|
||||
fancyType = "Item Type";
|
||||
putRegistryEnumRef(prop, "enum-item-type", this::itemTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)");
|
||||
|
||||
} else if (k.isAnnotationPresent(RegistryListEntityType.class)) {
|
||||
fancyType = "Entity Type";
|
||||
putRegistryEnumRef(prop, "enum-entity-type", this::entityTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)");
|
||||
|
||||
} else if (k.isAnnotationPresent(RegistryListBiome.class)) {
|
||||
fancyType = "Biome Type";
|
||||
putRegistryEnumRef(prop, "enum-biome-type", this::biomeTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)");
|
||||
|
||||
} else if (k.isAnnotationPresent(RegistryListSpecialEntity.class)) {
|
||||
fancyType = "Custom Mob Type";
|
||||
putRegistryEnumRef(prop, "enum-reg-specialentity", this::specialEntityTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Custom Mob Type (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListFont.class)) {
|
||||
fancyType = "Font Family";
|
||||
putRegistryEnumRef(prop, "enum-font", SchemaBuilder::fontTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)");
|
||||
|
||||
} else if (k.isAnnotationPresent(RegistryListEnchantment.class)) {
|
||||
fancyType = "Enchantment Type";
|
||||
putRegistryEnumRef(prop, "enum-enchantment", this::enchantTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListPotionEffect.class)) {
|
||||
fancyType = "Potion Effect Type";
|
||||
putRegistryEnumRef(prop, "enum-potion-effect-type", this::potionTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListFunction.class)) {
|
||||
Class<? extends ListFunction<KList<String>>> functionClass = k.getDeclaredAnnotation(RegistryListFunction.class).value();
|
||||
try {
|
||||
ListFunction<KList<String>> instance = functionClass.getDeclaredConstructor().newInstance();
|
||||
String key = instance.key();
|
||||
fancyType = instance.fancyName();
|
||||
|
||||
if (!definitions.containsKey(key)) {
|
||||
JSONObject j = new JSONObject();
|
||||
j.put("enum", instance.apply(data));
|
||||
definitions.put(key, j);
|
||||
}
|
||||
|
||||
prop.put("$ref", "#/definitions/" + key);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid " + fancyType + " (use ctrl+space for auto complete!)");
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.error("Could not execute apply method in " + functionClass.getName());
|
||||
}
|
||||
} else if (SchemaKeyedTypes.isKeyed(k.getType())) {
|
||||
fancyType = addEnum(k.getType(), prop, description, SchemaKeyedTypes.values(k.getType()), Function.identity());
|
||||
} else if (k.getType().isEnum()) {
|
||||
fancyType = addEnum(k.getType(), prop, description, enumNames(k.getType()), Function.identity());
|
||||
}
|
||||
}
|
||||
case "object" -> {
|
||||
if (k.isAnnotationPresent(RegistryMapBlockState.class)) {
|
||||
String blockType = k.getDeclaredAnnotation(RegistryMapBlockState.class).value();
|
||||
fancyType = "Block State";
|
||||
prop.put("!top", true);
|
||||
JSONArray any = new JSONArray();
|
||||
prop.put("anyOf", any);
|
||||
|
||||
for (BlockStateGroup group : reconstructBlockStateGroups()) {
|
||||
List<String> blocks = group.blocks();
|
||||
if (blocks.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String raw = blocks.get(0).replace(':', '_');
|
||||
String enumKey = "enum-block-state-" + raw;
|
||||
String propertiesKey = "obj-block-state-" + raw;
|
||||
|
||||
any.put(new JSONObject()
|
||||
.put("if", new JSONObject()
|
||||
.put("properties", new JSONObject()
|
||||
.put(blockType, new JSONObject()
|
||||
.put("type", "string")
|
||||
.put("$ref", definitionReference(enumKey)))))
|
||||
.put("then", new JSONObject()
|
||||
.put("properties", new JSONObject()
|
||||
.put(k.getName(), new JSONObject()
|
||||
.put("type", "object")
|
||||
.put("$ref", definitionReference(propertiesKey)))))
|
||||
.put("else", false));
|
||||
|
||||
if (!definitions.containsKey(enumKey)) {
|
||||
JSONArray filters = new JSONArray();
|
||||
blocks.forEach(filters::put);
|
||||
|
||||
definitions.put(enumKey, new JSONObject()
|
||||
.put("type", "string")
|
||||
.put("enum", filters));
|
||||
}
|
||||
|
||||
if (!definitions.containsKey(propertiesKey)) {
|
||||
JSONObject props = new JSONObject();
|
||||
for (PlatformBlockProperty property : group.properties()) {
|
||||
props.put(property.name(), buildBlockPropertyJson(property));
|
||||
}
|
||||
|
||||
definitions.put(propertiesKey, new JSONObject()
|
||||
.put("type", "object")
|
||||
.put("properties", props));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fancyType = k.getType().getSimpleName().replaceAll("\\QIris\\E", "") + " (Object)";
|
||||
String key = "obj-" + k.getType().getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
|
||||
if (!definitions.containsKey(key)) {
|
||||
definitions.put(key, new JSONObject());
|
||||
definitions.put(key, buildProperties(k.getType()));
|
||||
}
|
||||
prop.put("$ref", "#/definitions/" + key);
|
||||
}
|
||||
}
|
||||
case "array" -> {
|
||||
fancyType = "List of Something...?";
|
||||
ArrayType t = k.getDeclaredAnnotation(ArrayType.class);
|
||||
if (t != null) {
|
||||
if (t.min() > 0) {
|
||||
prop.put("minItems", t.min());
|
||||
if (t.min() == 1) {
|
||||
description.add(SYMBOL_LIMIT__N + " At least one entry must be defined, or just remove this list.");
|
||||
} else {
|
||||
description.add(SYMBOL_LIMIT__N + " Requires at least " + t.min() + " entries.");
|
||||
}
|
||||
}
|
||||
|
||||
String arrayType = getType(t.type());
|
||||
|
||||
switch (arrayType) {
|
||||
case "integer" -> fancyType = "List of Integers";
|
||||
case "number" -> fancyType = "List of Numbers";
|
||||
case "object" -> {
|
||||
fancyType = "List of " + t.type().getSimpleName().replaceAll("\\QIris\\E", "") + "s (Objects)";
|
||||
String key = "obj-" + t.type().getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
|
||||
if (!definitions.containsKey(key)) {
|
||||
definitions.put(key, new JSONObject());
|
||||
definitions.put(key, buildProperties(t.type()));
|
||||
}
|
||||
JSONObject items = new JSONObject();
|
||||
items.put("$ref", "#/definitions/" + key);
|
||||
prop.put("items", items);
|
||||
}
|
||||
case "string" -> {
|
||||
fancyType = "List of Text";
|
||||
|
||||
if (k.isAnnotationPresent(RegistryListResource.class)) {
|
||||
RegistryListResource rr = k.getDeclaredAnnotation(RegistryListResource.class);
|
||||
ResourceLoader<?> loader = data.getLoaders().get(rr.value());
|
||||
|
||||
if (loader != null) {
|
||||
fancyType = "List<" + loader.getResourceTypeName() + ">";
|
||||
String key = "erz" + loader.getFolderName();
|
||||
|
||||
if (!definitions.containsKey(key)) {
|
||||
JSONObject j = new JSONObject();
|
||||
j.put("enum", new JSONArray(loader.getPossibleKeys()));
|
||||
definitions.put(key, j);
|
||||
}
|
||||
|
||||
JSONObject items = new JSONObject();
|
||||
items.put("$ref", "#/definitions/" + key);
|
||||
prop.put("items", items);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid " + loader.getResourceTypeName() + " (use ctrl+space for auto complete!)");
|
||||
} else {
|
||||
IrisLogging.error("Cannot find Registry Loader for type (list schema) " + rr.value() + " used in " + k.getDeclaringClass().getCanonicalName() + " in field " + k.getName());
|
||||
}
|
||||
} else if (k.isAnnotationPresent(RegistryListStructure.class)) {
|
||||
fancyType = "List<Structure>";
|
||||
String key = "enum-iris-structure-placement";
|
||||
|
||||
if (!definitions.containsKey(key)) {
|
||||
JSONObject j = new JSONObject();
|
||||
j.put("enum", new JSONArray(StructureSchemaKeys.collect(
|
||||
Arrays.asList(data.getStructureLoader().getPossibleKeys()),
|
||||
Arrays.asList(data.getJigsawPieceLoader().getPossibleKeys())).toArray(new String[0])));
|
||||
definitions.put(key, j);
|
||||
}
|
||||
|
||||
JSONObject items = new JSONObject();
|
||||
items.put("$ref", "#/definitions/" + key);
|
||||
prop.put("items", items);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) {
|
||||
fancyType = "List<Native Jigsaw Pool>";
|
||||
putRegistryEnumItems(prop, "enum-native-jigsaw-pool", this::nativeJigsawPools);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) {
|
||||
fancyType = "List<Vanilla Structure>";
|
||||
if (k.getAnnotation(RegistryListVanillaStructure.class).prefixes()) {
|
||||
putRegistryEnumOrPrefixItems(prop, "enum-vanilla-structure-or-prefix",
|
||||
"enum-vanilla-structure", this::vanillaStructures, VANILLA_STRUCTURE_PREFIX_PATTERN);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a vanilla/datapack structure key or a family/namespace prefix like 'minecraft:village' or 'nova_structures:' (use ctrl+space for auto complete!)");
|
||||
} else {
|
||||
putRegistryEnumItems(prop, "enum-vanilla-structure", this::vanillaStructures);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)");
|
||||
}
|
||||
} else if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) {
|
||||
fancyType = "List<Vanilla Structure Set>";
|
||||
putRegistryEnumItems(prop, "enum-vanilla-structure-set", this::vanillaStructureSets);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure set key (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListBlockType.class)) {
|
||||
fancyType = "List of Block Types";
|
||||
putRegistryEnumItems(prop, "enum-block-type", this::blockTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListItemType.class)) {
|
||||
fancyType = "List of Item Types";
|
||||
putRegistryEnumItems(prop, "enum-item-type", this::itemTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListEntityType.class)) {
|
||||
fancyType = "List of Entity Types";
|
||||
putRegistryEnumItems(prop, "enum-entity-type", this::entityTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListBiome.class)) {
|
||||
fancyType = "List of Biome Types";
|
||||
putRegistryEnumItems(prop, "enum-biome-type", this::biomeTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListFont.class)) {
|
||||
fancyType = "List of Font Families";
|
||||
putRegistryEnumItems(prop, "enum-font", SchemaBuilder::fontTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListEnchantment.class)) {
|
||||
fancyType = "List of Enchantment Types";
|
||||
putRegistryEnumItems(prop, "enum-enchantment", this::enchantTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListPotionEffect.class)) {
|
||||
fancyType = "List of Potion Effect Types";
|
||||
putRegistryEnumItems(prop, "enum-potion-effect-type", this::potionTypes);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)");
|
||||
} else if (k.isAnnotationPresent(RegistryListFunction.class)) {
|
||||
Class<? extends ListFunction<KList<String>>> functionClass = k.getDeclaredAnnotation(RegistryListFunction.class).value();
|
||||
try {
|
||||
ListFunction<KList<String>> instance = functionClass.getDeclaredConstructor().newInstance();
|
||||
String key = instance.key();
|
||||
fancyType = instance.fancyName();
|
||||
|
||||
if (!definitions.containsKey(key)) {
|
||||
JSONObject j = new JSONObject();
|
||||
j.put("enum", instance.apply(data));
|
||||
definitions.put(key, j);
|
||||
}
|
||||
|
||||
JSONObject items = new JSONObject();
|
||||
items.put("$ref", "#/definitions/" + key);
|
||||
prop.put("items", items);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid " + fancyType + " (use ctrl+space for auto complete!)");
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.error("Could not execute apply method in " + functionClass.getName());
|
||||
}
|
||||
} else if (SchemaKeyedTypes.isKeyed(t.type())) {
|
||||
fancyType = addEnumList(prop, description, t, SchemaKeyedTypes.values(t.type()), Function.identity());
|
||||
} else if (t.type().isEnum()) {
|
||||
fancyType = addEnumList(prop, description, t, enumNames(t.type()), Function.identity());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warnings.add("Undefined array type for field " + k.getName() + " (" + k.getType().getSimpleName() + ") in class " + cl.getSimpleName());
|
||||
}
|
||||
}
|
||||
default ->
|
||||
warnings.add("Unexpected Schema Type: " + type + " for field " + k.getName() + " (" + k.getType().getSimpleName() + ") in class " + cl.getSimpleName());
|
||||
}
|
||||
|
||||
KList<String> d = new KList<>();
|
||||
d.add("<h>" + k.getName() + "</h>");
|
||||
d.add(getFieldDescription(k) + "<hr></hr>");
|
||||
d.add("<h>" + fancyType + "</h>");
|
||||
String typeDesc = getDescription(k.getType());
|
||||
boolean present = !typeDesc.isBlank();
|
||||
if (present) d.add(typeDesc);
|
||||
|
||||
Snippet snippet = k.getType().getDeclaredAnnotation(Snippet.class);
|
||||
if (snippet == null) {
|
||||
ArrayType array = k.getType().getDeclaredAnnotation(ArrayType.class);
|
||||
if (array != null) {
|
||||
snippet = array.type().getDeclaredAnnotation(Snippet.class);
|
||||
}
|
||||
}
|
||||
|
||||
if (snippet != null) {
|
||||
String sm = snippet.value();
|
||||
if (present) d.add(" ");
|
||||
d.add("You can instead specify \"snippet/" + sm + "/some-name.json\" to use a snippet file instead of specifying it here.");
|
||||
present = false;
|
||||
}
|
||||
|
||||
try {
|
||||
k.setAccessible(true);
|
||||
Object value = k.get(cl.getDeclaredConstructor().newInstance());
|
||||
|
||||
if (value != null) {
|
||||
if (present) d.add(" ");
|
||||
if (value instanceof List) {
|
||||
d.add(SYMBOL_LIMIT__N + " Default Value is an empty list");
|
||||
} else if (!k.getType().isPrimitive() && !(value instanceof Number) && !(value instanceof String) && !(value instanceof Enum<?>) && !SchemaKeyedTypes.isKeyed(k.getType())) {
|
||||
d.add(SYMBOL_LIMIT__N + " Default Value is a default object (create this object to see default properties)");
|
||||
} else {
|
||||
d.add(SYMBOL_LIMIT__N + " Default Value is " + value);
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
|
||||
}
|
||||
|
||||
description.forEach((g) -> d.add(g.trim()));
|
||||
String desc = d.toString("\n")
|
||||
.replace("<hr></hr>", "\n")
|
||||
.replace("<h>", "")
|
||||
.replace("</h>", "");
|
||||
String hDesc = d.toString("<br>");
|
||||
prop.put("type", type);
|
||||
prop.put("description", desc);
|
||||
prop.put("x-intellij-html-description", hDesc);
|
||||
return buildSnippet(prop, k.getType());
|
||||
}
|
||||
|
||||
private JSONObject buildSnippet(JSONObject prop, Class<?> type) {
|
||||
JSONObject buildSnippet(JSONObject prop, Class<?> type) {
|
||||
Snippet snippet = type.getDeclaredAnnotation(Snippet.class);
|
||||
if (snippet == null) return prop;
|
||||
|
||||
@@ -850,117 +176,7 @@ public class SchemaBuilder {
|
||||
return anyOf;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private <T> String addEnumList(JSONObject prop, KList<String> description, ArrayType t, T[] values, Function<T, String> function) {
|
||||
JSONObject items = new JSONObject();
|
||||
String s = addEnum(t.type(), items, description, values, function);
|
||||
prop.put("items", items);
|
||||
|
||||
return "List of " + s + "s";
|
||||
}
|
||||
|
||||
private static String[] enumNames(Class<?> enumType) {
|
||||
try {
|
||||
Object[] constants = enumType.getEnumConstants();
|
||||
String[] names = new String[constants.length];
|
||||
for (int index = 0; index < constants.length; index++) {
|
||||
names[index] = ((Enum<?>) constants[index]).name();
|
||||
}
|
||||
return names;
|
||||
} catch (LinkageError error) {
|
||||
return Arrays.stream(enumType.getDeclaredFields())
|
||||
.filter(Field::isEnumConstant)
|
||||
.map(Field::getName)
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private <T> String addEnum(Class<?> type, JSONObject prop, KList<String> description, T[] values, Function<T, String> function) {
|
||||
JSONArray a = new JSONArray();
|
||||
boolean advanced = type.isAnnotationPresent(Desc.class);
|
||||
for (T gg : values) {
|
||||
if (advanced) {
|
||||
try {
|
||||
JSONObject j = new JSONObject();
|
||||
String name = function.apply(gg);
|
||||
j.put("const", name);
|
||||
Desc dd = type.getField(name).getAnnotation(Desc.class);
|
||||
String desc = dd == null ? ("No Description for " + name) : dd.value();
|
||||
j.put("description", desc);
|
||||
j.put("x-intellij-html-description", desc.replace("\n", "<br>"));
|
||||
a.put(j);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
} else {
|
||||
a.put(function.apply(gg));
|
||||
}
|
||||
}
|
||||
|
||||
String key = (advanced ? "oneof-" : "") + "enum-" + type.getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
|
||||
|
||||
if (!definitions.containsKey(key)) {
|
||||
JSONObject j = new JSONObject();
|
||||
j.put(advanced ? "oneOf" : "enum", a);
|
||||
definitions.put(key, j);
|
||||
}
|
||||
|
||||
prop.put("$ref", "#/definitions/" + key);
|
||||
description.add(SYMBOL_TYPE__N + " Must be a valid " + type.getSimpleName().replaceAll("\\QIris\\E", "") + " (use ctrl+space for auto complete!)");
|
||||
return type.getSimpleName().replaceAll("\\QIris\\E", "");
|
||||
}
|
||||
|
||||
private JSONObject buildBlockPropertyJson(PlatformBlockProperty property) {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("type", property.jsonType());
|
||||
json.put("default", property.defaultValue());
|
||||
List<Object> allowed = property.allowedValues();
|
||||
if (!allowed.isEmpty()) {
|
||||
json.put("enum", new JSONArray(allowed));
|
||||
}
|
||||
if (property.hasNumericRange()) {
|
||||
PlatformNumericRange range = property.numericRange();
|
||||
if ("integer".equals(property.jsonType())) {
|
||||
json.put("minimum", (long) range.minimum());
|
||||
json.put("maximum", (long) range.maximum());
|
||||
} else {
|
||||
json.put("minimum", range.minimum());
|
||||
json.put("maximum", range.maximum());
|
||||
}
|
||||
json.put("exclusiveMinimum", range.exclusiveMinimum());
|
||||
json.put("exclusiveMaximum", range.exclusiveMaximum());
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
private List<BlockStateGroup> reconstructBlockStateGroups() {
|
||||
List<BlockStateGroup> groups = new ArrayList<>();
|
||||
List<PlatformBlockProperty> currentProperties = null;
|
||||
List<String> currentBlocks = null;
|
||||
for (Map.Entry<String, List<PlatformBlockProperty>> entry : IrisPlatforms.get().registries().blockStateProperties().entrySet()) {
|
||||
List<PlatformBlockProperty> value = entry.getValue();
|
||||
if (currentProperties != null && value == currentProperties) {
|
||||
currentBlocks.add(entry.getKey());
|
||||
} else {
|
||||
currentBlocks = new ArrayList<>();
|
||||
currentBlocks.add(entry.getKey());
|
||||
currentProperties = value;
|
||||
groups.add(new BlockStateGroup(currentBlocks, value));
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
private List<String> templatePoolKeys() {
|
||||
if (IrisPlatforms.get().structureHooks() == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> keys = IrisPlatforms.get().structureHooks().templatePoolKeys();
|
||||
return keys == null ? List.of() : keys;
|
||||
}
|
||||
|
||||
private String getType(Class<?> c) {
|
||||
String getType(Class<?> c) {
|
||||
if (c.equals(int.class) || c.equals(Integer.class) || c.equals(long.class) || c.equals(Long.class)) {
|
||||
return "integer";
|
||||
}
|
||||
@@ -992,7 +208,7 @@ public class SchemaBuilder {
|
||||
return "object";
|
||||
}
|
||||
|
||||
private String getFieldDescription(Field r) {
|
||||
String getFieldDescription(Field r) {
|
||||
|
||||
if (r.isAnnotationPresent(Desc.class)) {
|
||||
return r.getDeclaredAnnotation(Desc.class).value();
|
||||
@@ -1002,7 +218,7 @@ public class SchemaBuilder {
|
||||
return "No Field Description";
|
||||
}
|
||||
|
||||
private String getDescription(Class<?> r) {
|
||||
String getDescription(Class<?> r) {
|
||||
if (r.isAnnotationPresent(Desc.class)) {
|
||||
return r.getDeclaredAnnotation(Desc.class).value();
|
||||
}
|
||||
@@ -1012,7 +228,4 @@ public class SchemaBuilder {
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private record BlockStateGroup(List<String> blocks, List<PlatformBlockProperty> properties) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.project;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.engine.object.annotations.ArrayType;
|
||||
import art.arcane.iris.engine.object.annotations.Desc;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Arrays;
|
||||
import java.util.function.Function;
|
||||
|
||||
final class SchemaEnums {
|
||||
private final SchemaBuilder builder;
|
||||
|
||||
SchemaEnums(SchemaBuilder builder) {
|
||||
this.builder = builder;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
<T> String addEnumList(JSONObject prop, KList<String> description, ArrayType t, T[] values, Function<T, String> function) {
|
||||
JSONObject items = new JSONObject();
|
||||
String s = addEnum(t.type(), items, description, values, function);
|
||||
prop.put("items", items);
|
||||
|
||||
return "List of " + s + "s";
|
||||
}
|
||||
|
||||
static String[] enumNames(Class<?> enumType) {
|
||||
try {
|
||||
Object[] constants = enumType.getEnumConstants();
|
||||
String[] names = new String[constants.length];
|
||||
for (int index = 0; index < constants.length; index++) {
|
||||
names[index] = ((Enum<?>) constants[index]).name();
|
||||
}
|
||||
return names;
|
||||
} catch (LinkageError error) {
|
||||
return Arrays.stream(enumType.getDeclaredFields())
|
||||
.filter(Field::isEnumConstant)
|
||||
.map(Field::getName)
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
<T> String addEnum(Class<?> type, JSONObject prop, KList<String> description, T[] values, Function<T, String> function) {
|
||||
JSONArray a = new JSONArray();
|
||||
boolean advanced = type.isAnnotationPresent(Desc.class);
|
||||
for (T gg : values) {
|
||||
if (advanced) {
|
||||
try {
|
||||
JSONObject j = new JSONObject();
|
||||
String name = function.apply(gg);
|
||||
j.put("const", name);
|
||||
Desc dd = type.getField(name).getAnnotation(Desc.class);
|
||||
String desc = dd == null ? ("No Description for " + name) : dd.value();
|
||||
j.put("description", desc);
|
||||
j.put("x-intellij-html-description", desc.replace("\n", "<br>"));
|
||||
a.put(j);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
} else {
|
||||
a.put(function.apply(gg));
|
||||
}
|
||||
}
|
||||
|
||||
String key = (advanced ? "oneof-" : "") + "enum-" + type.getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
|
||||
|
||||
if (!builder.definitions.containsKey(key)) {
|
||||
JSONObject j = new JSONObject();
|
||||
j.put(advanced ? "oneOf" : "enum", a);
|
||||
builder.definitions.put(key, j);
|
||||
}
|
||||
|
||||
prop.put("$ref", "#/definitions/" + key);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid " + type.getSimpleName().replaceAll("\\QIris\\E", "") + " (use ctrl+space for auto complete!)");
|
||||
return type.getSimpleName().replaceAll("\\QIris\\E", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.project;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.engine.object.annotations.ArrayType;
|
||||
import art.arcane.iris.engine.object.annotations.MaxNumber;
|
||||
import art.arcane.iris.engine.object.annotations.MinNumber;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryMapBlockState;
|
||||
import art.arcane.iris.engine.object.annotations.Required;
|
||||
import art.arcane.iris.engine.object.annotations.Snippet;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
final class SchemaPropertyBuilder {
|
||||
private final SchemaBuilder builder;
|
||||
private final SchemaEnums enums;
|
||||
private final SchemaBlockStates blockStates;
|
||||
private final SchemaRegistryProperties registryProperties;
|
||||
|
||||
SchemaPropertyBuilder(SchemaBuilder builder) {
|
||||
this.builder = builder;
|
||||
SchemaRegistryCatalog catalog = new SchemaRegistryCatalog(builder);
|
||||
this.enums = new SchemaEnums(builder);
|
||||
this.blockStates = new SchemaBlockStates(builder);
|
||||
this.registryProperties = new SchemaRegistryProperties(builder, catalog);
|
||||
}
|
||||
|
||||
JSONObject buildProperty(Field k, Class<?> cl) {
|
||||
JSONObject prop = new JSONObject();
|
||||
String type = builder.getType(k.getType());
|
||||
KList<String> description = new KList<>();
|
||||
prop.put("!required", k.isAnnotationPresent(Required.class));
|
||||
prop.put("type", type);
|
||||
|
||||
String fancyType = switch (type) {
|
||||
case "boolean" -> "Boolean";
|
||||
case "integer" -> integerProperty(k, prop, description);
|
||||
case "number" -> numberProperty(k, prop, description);
|
||||
case "string" -> stringProperty(k, prop, description);
|
||||
case "object" -> objectProperty(k, prop);
|
||||
case "array" -> arrayProperty(k, cl, prop, description);
|
||||
default -> unexpectedType(type, k, cl);
|
||||
};
|
||||
|
||||
return describeProperty(k, cl, prop, type, fancyType, description);
|
||||
}
|
||||
|
||||
private String integerProperty(Field k, JSONObject prop, KList<String> description) {
|
||||
if (k.isAnnotationPresent(MinNumber.class)) {
|
||||
int min = (int) k.getDeclaredAnnotation(MinNumber.class).value();
|
||||
prop.put("minimum", min);
|
||||
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Minimum allowed is " + min);
|
||||
}
|
||||
if (k.isAnnotationPresent(MaxNumber.class)) {
|
||||
int max = (int) k.getDeclaredAnnotation(MaxNumber.class).value();
|
||||
prop.put("maximum", max);
|
||||
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Maximum allowed is " + max);
|
||||
}
|
||||
return "Integer";
|
||||
}
|
||||
|
||||
private String numberProperty(Field k, JSONObject prop, KList<String> description) {
|
||||
if (k.isAnnotationPresent(MinNumber.class)) {
|
||||
double min = k.getDeclaredAnnotation(MinNumber.class).value();
|
||||
prop.put("minimum", min);
|
||||
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Minimum allowed is " + min);
|
||||
}
|
||||
if (k.isAnnotationPresent(MaxNumber.class)) {
|
||||
double max = k.getDeclaredAnnotation(MaxNumber.class).value();
|
||||
prop.put("maximum", max);
|
||||
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Maximum allowed is " + max);
|
||||
}
|
||||
return "Number";
|
||||
}
|
||||
|
||||
private String stringProperty(Field k, JSONObject prop, KList<String> description) {
|
||||
if (k.isAnnotationPresent(MinNumber.class)) {
|
||||
int min = (int) k.getDeclaredAnnotation(MinNumber.class).value();
|
||||
prop.put("minLength", min);
|
||||
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Minimum Length allowed is " + min);
|
||||
}
|
||||
if (k.isAnnotationPresent(MaxNumber.class)) {
|
||||
int max = (int) k.getDeclaredAnnotation(MaxNumber.class).value();
|
||||
prop.put("maxLength", max);
|
||||
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Maximum Length allowed is " + max);
|
||||
}
|
||||
|
||||
String registryType = registryProperties.stringRegistryType(k, prop, description);
|
||||
if (registryType != null) {
|
||||
return registryType;
|
||||
}
|
||||
if (SchemaKeyedTypes.isKeyed(k.getType())) {
|
||||
return enums.addEnum(k.getType(), prop, description, SchemaKeyedTypes.values(k.getType()), Function.identity());
|
||||
}
|
||||
if (k.getType().isEnum()) {
|
||||
return enums.addEnum(k.getType(), prop, description, SchemaEnums.enumNames(k.getType()), Function.identity());
|
||||
}
|
||||
return "Text";
|
||||
}
|
||||
|
||||
private String objectProperty(Field k, JSONObject prop) {
|
||||
if (k.isAnnotationPresent(RegistryMapBlockState.class)) {
|
||||
blockStates.applyBlockStateMap(prop, k, k.getDeclaredAnnotation(RegistryMapBlockState.class).value());
|
||||
return "Block State";
|
||||
}
|
||||
|
||||
String key = "obj-" + k.getType().getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
|
||||
if (!builder.definitions.containsKey(key)) {
|
||||
builder.definitions.put(key, new JSONObject());
|
||||
builder.definitions.put(key, builder.buildProperties(k.getType()));
|
||||
}
|
||||
prop.put("$ref", "#/definitions/" + key);
|
||||
return k.getType().getSimpleName().replaceAll("\\QIris\\E", "") + " (Object)";
|
||||
}
|
||||
|
||||
private String arrayProperty(Field k, Class<?> cl, JSONObject prop, KList<String> description) {
|
||||
ArrayType t = k.getDeclaredAnnotation(ArrayType.class);
|
||||
if (t == null) {
|
||||
builder.warnings.add("Undefined array type for field " + k.getName() + " (" + k.getType().getSimpleName() + ") in class " + cl.getSimpleName());
|
||||
return "List of Something...?";
|
||||
}
|
||||
|
||||
if (t.min() > 0) {
|
||||
prop.put("minItems", t.min());
|
||||
if (t.min() == 1) {
|
||||
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " At least one entry must be defined, or just remove this list.");
|
||||
} else {
|
||||
description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Requires at least " + t.min() + " entries.");
|
||||
}
|
||||
}
|
||||
|
||||
return switch (builder.getType(t.type())) {
|
||||
case "integer" -> "List of Integers";
|
||||
case "number" -> "List of Numbers";
|
||||
case "object" -> objectArrayProperty(t, prop);
|
||||
case "string" -> stringArrayProperty(k, t, prop, description);
|
||||
default -> "List of Something...?";
|
||||
};
|
||||
}
|
||||
|
||||
private String objectArrayProperty(ArrayType t, JSONObject prop) {
|
||||
String key = "obj-" + t.type().getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
|
||||
if (!builder.definitions.containsKey(key)) {
|
||||
builder.definitions.put(key, new JSONObject());
|
||||
builder.definitions.put(key, builder.buildProperties(t.type()));
|
||||
}
|
||||
JSONObject items = new JSONObject();
|
||||
items.put("$ref", "#/definitions/" + key);
|
||||
prop.put("items", items);
|
||||
return "List of " + t.type().getSimpleName().replaceAll("\\QIris\\E", "") + "s (Objects)";
|
||||
}
|
||||
|
||||
private String stringArrayProperty(Field k, ArrayType t, JSONObject prop, KList<String> description) {
|
||||
String registryType = registryProperties.stringArrayRegistryType(k, prop, description);
|
||||
if (registryType != null) {
|
||||
return registryType;
|
||||
}
|
||||
if (SchemaKeyedTypes.isKeyed(t.type())) {
|
||||
return enums.addEnumList(prop, description, t, SchemaKeyedTypes.values(t.type()), Function.identity());
|
||||
}
|
||||
if (t.type().isEnum()) {
|
||||
return enums.addEnumList(prop, description, t, SchemaEnums.enumNames(t.type()), Function.identity());
|
||||
}
|
||||
return "List of Text";
|
||||
}
|
||||
|
||||
private String unexpectedType(String type, Field k, Class<?> cl) {
|
||||
builder.warnings.add("Unexpected Schema Type: " + type + " for field " + k.getName() + " (" + k.getType().getSimpleName() + ") in class " + cl.getSimpleName());
|
||||
return "Unknown Type";
|
||||
}
|
||||
|
||||
private JSONObject describeProperty(Field k, Class<?> cl, JSONObject prop, String type, String fancyType, KList<String> description) {
|
||||
KList<String> d = new KList<>();
|
||||
d.add("<h>" + k.getName() + "</h>");
|
||||
d.add(builder.getFieldDescription(k) + "<hr></hr>");
|
||||
d.add("<h>" + fancyType + "</h>");
|
||||
String typeDesc = builder.getDescription(k.getType());
|
||||
boolean present = !typeDesc.isBlank();
|
||||
if (present) {
|
||||
d.add(typeDesc);
|
||||
}
|
||||
|
||||
present = appendSnippetHint(k, d, present);
|
||||
appendDefaultValue(k, cl, d, present);
|
||||
|
||||
description.forEach((g) -> d.add(g.trim()));
|
||||
String desc = d.toString("\n")
|
||||
.replace("<hr></hr>", "\n")
|
||||
.replace("<h>", "")
|
||||
.replace("</h>", "");
|
||||
String hDesc = d.toString("<br>");
|
||||
prop.put("type", type);
|
||||
prop.put("description", desc);
|
||||
prop.put("x-intellij-html-description", hDesc);
|
||||
return builder.buildSnippet(prop, k.getType());
|
||||
}
|
||||
|
||||
private boolean appendSnippetHint(Field k, KList<String> d, boolean present) {
|
||||
Snippet snippet = k.getType().getDeclaredAnnotation(Snippet.class);
|
||||
if (snippet == null) {
|
||||
ArrayType array = k.getType().getDeclaredAnnotation(ArrayType.class);
|
||||
if (array != null) {
|
||||
snippet = array.type().getDeclaredAnnotation(Snippet.class);
|
||||
}
|
||||
}
|
||||
|
||||
if (snippet == null) {
|
||||
return present;
|
||||
}
|
||||
|
||||
String sm = snippet.value();
|
||||
if (present) {
|
||||
d.add(" ");
|
||||
}
|
||||
d.add("You can instead specify \"snippet/" + sm + "/some-name.json\" to use a snippet file instead of specifying it here.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private void appendDefaultValue(Field k, Class<?> cl, KList<String> d, boolean present) {
|
||||
Object instance;
|
||||
try {
|
||||
instance = cl.getDeclaredConstructor().newInstance();
|
||||
} catch (ReflectiveOperationException | LinkageError e) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
k.setAccessible(true);
|
||||
Object value = k.get(instance);
|
||||
|
||||
if (value != null) {
|
||||
if (present) {
|
||||
d.add(" ");
|
||||
}
|
||||
if (value instanceof List) {
|
||||
d.add(SchemaBuilder.SYMBOL_LIMIT__N + " Default Value is an empty list");
|
||||
} else if (!k.getType().isPrimitive() && !(value instanceof Number) && !(value instanceof String) && !(value instanceof Enum<?>) && !SchemaKeyedTypes.isKeyed(k.getType())) {
|
||||
d.add(SchemaBuilder.SYMBOL_LIMIT__N + " Default Value is a default object (create this object to see default properties)");
|
||||
} else {
|
||||
d.add(SchemaBuilder.SYMBOL_LIMIT__N + " Default Value is " + value);
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("Schema default value unavailable for "
|
||||
+ cl.getCanonicalName() + "." + k.getName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.project;
|
||||
|
||||
import art.arcane.iris.spi.CapabilityProbe;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.awt.GraphicsEnvironment;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
final class SchemaRegistryCatalog {
|
||||
/** Namespaced key or family/namespace prefix: "minecraft:village_plains", "minecraft:village", "nova_structures:". */
|
||||
static final String VANILLA_STRUCTURE_PREFIX_PATTERN = "^[a-z0-9_.-]+:[a-z0-9_./-]*$";
|
||||
private static final String MINECRAFT_NAMESPACE = "minecraft:";
|
||||
private static volatile JSONArray fontTypes;
|
||||
private final SchemaBuilder builder;
|
||||
private JSONArray potionTypes;
|
||||
private JSONArray enchantTypes;
|
||||
|
||||
SchemaRegistryCatalog(SchemaBuilder builder) {
|
||||
this.builder = builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font families are only used for schema completion. Enumerating them touches AWT, which can fail outright on a
|
||||
* headless or mod-loader JVM - a failure degrades to no completion, never to a broken schema.
|
||||
*/
|
||||
static JSONArray fontTypes() {
|
||||
JSONArray cached = fontTypes;
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
JSONArray built = CapabilityProbe.attempt("java.awt font families",
|
||||
() -> new JSONArray(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames()),
|
||||
new JSONArray());
|
||||
fontTypes = built;
|
||||
return built;
|
||||
}
|
||||
|
||||
JSONArray potionTypes() {
|
||||
if (potionTypes == null) {
|
||||
potionTypes = registryKeyForms(IrisPlatforms.get().registries().potionEffectKeys(), true);
|
||||
}
|
||||
return potionTypes;
|
||||
}
|
||||
|
||||
JSONArray enchantTypes() {
|
||||
if (enchantTypes == null) {
|
||||
enchantTypes = registryKeyForms(IrisPlatforms.get().registries().enchantmentKeys(), false);
|
||||
}
|
||||
return enchantTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the full namespaced key for every registry entry, so mod and datapack content is addressable
|
||||
* unambiguously, plus the legacy short form for the vanilla namespace so existing packs stay valid.
|
||||
*/
|
||||
private static JSONArray registryKeyForms(List<String> keys, boolean upperCaseLegacy) {
|
||||
JSONArray a = new JSONArray();
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
if (keys != null) {
|
||||
for (String key : keys) {
|
||||
if (key == null || key.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
if (key.startsWith(MINECRAFT_NAMESPACE) || key.indexOf(':') < 0) {
|
||||
String path = stripNamespace(key);
|
||||
seen.add(upperCaseLegacy ? path.toUpperCase(Locale.ROOT).replace(' ', '_') : path);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String key : seen) {
|
||||
a.put(key);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Biome derivatives resolve through NamespacedKey.fromString, which accepts a full key or a bare vanilla path,
|
||||
* so both forms are offered.
|
||||
*/
|
||||
JSONArray biomeTypes() {
|
||||
return registryKeyForms(IrisPlatforms.get().registries().biomeKeys(), false);
|
||||
}
|
||||
|
||||
JSONArray entityTypes() {
|
||||
return keysAsArray(IrisPlatforms.get().registries().entityKeys());
|
||||
}
|
||||
|
||||
JSONArray specialEntityTypes() {
|
||||
return keysAsArray(IrisPlatforms.get().registries().specialEntityKeys());
|
||||
}
|
||||
|
||||
JSONArray vanillaStructures() {
|
||||
return keysAsArray(IrisPlatforms.get().registries().structureKeys());
|
||||
}
|
||||
|
||||
JSONArray vanillaStructureSets() {
|
||||
if (IrisPlatforms.get().structureHooks() == null) {
|
||||
return new JSONArray();
|
||||
}
|
||||
List<String> keys = IrisPlatforms.get().structureHooks().structureSetKeys();
|
||||
return keysAsArray(keys == null ? List.of() : keys);
|
||||
}
|
||||
|
||||
JSONArray nativeJigsawPools() {
|
||||
return keysAsArray(templatePoolKeys());
|
||||
}
|
||||
|
||||
private static JSONArray keysAsArray(List<String> keys) {
|
||||
JSONArray a = new JSONArray();
|
||||
if (keys != null) {
|
||||
for (String key : keys) {
|
||||
if (key != null && !key.isBlank()) {
|
||||
a.put(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a registry-backed enum definition under {@code definitionKey} and points {@code target} at it.
|
||||
* <p>
|
||||
* An empty key list means the registry has nothing to offer yet - the server is still booting, a modded registry
|
||||
* has not been frozen, or the host simply does not expose that catalog. Emitting {@code "enum": []} in that case
|
||||
* writes a schema that rejects every value the author could possibly type, turning a missing autocomplete list
|
||||
* into a pack that reads as broken in the editor. The reference is omitted instead, leaving the field
|
||||
* unconstrained, and the values are only computed when the definition does not exist yet.
|
||||
*/
|
||||
void putRegistryEnumRef(JSONObject target, String definitionKey, Supplier<JSONArray> values) {
|
||||
if (!builder.definitions.containsKey(definitionKey)) {
|
||||
JSONArray built = values.get();
|
||||
if (built == null || built.length() == 0) {
|
||||
IrisLogging.debug("Schema enum '" + definitionKey + "' omitted: the registry returned no keys");
|
||||
return;
|
||||
}
|
||||
JSONObject definition = new JSONObject();
|
||||
definition.put("enum", built);
|
||||
builder.definitions.put(definitionKey, definition);
|
||||
}
|
||||
target.put("$ref", "#/definitions/" + definitionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link #putRegistryEnumRef(JSONObject, String, Supplier)} for a list-typed property. When the enum is omitted no
|
||||
* {@code items} schema is written at all, which is valid and simply means "any element".
|
||||
*/
|
||||
void putRegistryEnumItems(JSONObject prop, String definitionKey, Supplier<JSONArray> values) {
|
||||
JSONObject items = new JSONObject();
|
||||
putRegistryEnumRef(items, definitionKey, values);
|
||||
if (items.has("$ref")) {
|
||||
prop.put("items", items);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry enum that ALSO accepts family/namespace prefixes ("minecraft:village",
|
||||
* "nova_structures:") — the runtime prefix-matching contract of importedStructures.disabled and
|
||||
* adjustments[].match. Emitted as anyOf(enum, pattern) so autocomplete still offers registered
|
||||
* keys while prefix entries validate instead of being rejected.
|
||||
*/
|
||||
void putRegistryEnumOrPrefixRef(JSONObject target, String definitionKey,
|
||||
String enumDefinitionKey, Supplier<JSONArray> values,
|
||||
String pattern) {
|
||||
if (!builder.definitions.containsKey(definitionKey)) {
|
||||
JSONArray anyOf = new JSONArray();
|
||||
JSONObject enumRef = new JSONObject();
|
||||
try {
|
||||
putRegistryEnumRef(enumRef, enumDefinitionKey, values);
|
||||
} catch (RuntimeException e) {
|
||||
IrisLogging.reportError("Schema enum '" + enumDefinitionKey
|
||||
+ "' unavailable; emitting prefix pattern only", e);
|
||||
}
|
||||
if (enumRef.has("$ref")) {
|
||||
anyOf.put(enumRef);
|
||||
}
|
||||
JSONObject prefix = new JSONObject();
|
||||
prefix.put("type", "string");
|
||||
prefix.put("pattern", pattern);
|
||||
anyOf.put(prefix);
|
||||
JSONObject definition = new JSONObject();
|
||||
definition.put("anyOf", anyOf);
|
||||
builder.definitions.put(definitionKey, definition);
|
||||
}
|
||||
target.put("$ref", "#/definitions/" + definitionKey);
|
||||
}
|
||||
|
||||
void putRegistryEnumOrPrefixItems(JSONObject prop, String definitionKey,
|
||||
String enumDefinitionKey, Supplier<JSONArray> values,
|
||||
String pattern) {
|
||||
JSONObject items = new JSONObject();
|
||||
putRegistryEnumOrPrefixRef(items, definitionKey, enumDefinitionKey, values, pattern);
|
||||
prop.put("items", items);
|
||||
}
|
||||
|
||||
JSONArray itemTypes() {
|
||||
JSONArray a = new JSONArray();
|
||||
for (String key : IrisPlatforms.get().registries().itemKeys()) {
|
||||
a.put(key.startsWith(MINECRAFT_NAMESPACE) ? key.substring(MINECRAFT_NAMESPACE.length()) : key);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
JSONArray blockTypes() {
|
||||
JSONArray a = new JSONArray();
|
||||
for (String i : builder.data.getBlockLoader().getPossibleKeys()) {
|
||||
a.put(i);
|
||||
}
|
||||
for (String i : IrisPlatforms.get().registries().blockTypeKeys()) {
|
||||
a.put(i);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
private static String stripNamespace(String key) {
|
||||
int idx = key.indexOf(':');
|
||||
return idx < 0 ? key : key.substring(idx + 1);
|
||||
}
|
||||
|
||||
private List<String> templatePoolKeys() {
|
||||
if (IrisPlatforms.get().structureHooks() == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> keys = IrisPlatforms.get().structureHooks().templatePoolKeys();
|
||||
return keys == null ? List.of() : keys;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.project;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.core.structure.StructureSchemaKeys;
|
||||
import art.arcane.iris.engine.framework.ListFunction;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListBiome;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListBlockType;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListEnchantment;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListEntityType;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListFont;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListFunction;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListItemType;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListNativeJigsawPool;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListPotionEffect;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListResource;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListSpecialEntity;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListStructure;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructureSet;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Arrays;
|
||||
|
||||
final class SchemaRegistryProperties {
|
||||
private static final String IRIS_STRUCTURE_DEFINITION = "enum-iris-structure-placement";
|
||||
private final SchemaBuilder builder;
|
||||
private final SchemaRegistryCatalog catalog;
|
||||
|
||||
SchemaRegistryProperties(SchemaBuilder builder, SchemaRegistryCatalog catalog) {
|
||||
this.builder = builder;
|
||||
this.catalog = catalog;
|
||||
}
|
||||
|
||||
String stringRegistryType(Field k, JSONObject prop, KList<String> description) {
|
||||
if (k.isAnnotationPresent(RegistryListResource.class)) {
|
||||
return resourceReference(k, prop, description);
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListStructure.class)) {
|
||||
putIrisStructureDefinition();
|
||||
prop.put("$ref", "#/definitions/" + IRIS_STRUCTURE_DEFINITION);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)");
|
||||
return "Structure";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListBlockType.class)) {
|
||||
catalog.putRegistryEnumRef(prop, "enum-block-type", catalog::blockTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)");
|
||||
return "Block Type";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) {
|
||||
catalog.putRegistryEnumRef(prop, "enum-native-jigsaw-pool", catalog::nativeJigsawPools);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)");
|
||||
return "Native Jigsaw Pool";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) {
|
||||
if (k.getAnnotation(RegistryListVanillaStructure.class).prefixes()) {
|
||||
catalog.putRegistryEnumOrPrefixRef(prop, "enum-vanilla-structure-or-prefix",
|
||||
"enum-vanilla-structure", catalog::vanillaStructures, SchemaRegistryCatalog.VANILLA_STRUCTURE_PREFIX_PATTERN);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a vanilla/datapack structure key or a family/namespace prefix like 'minecraft:village' or 'nova_structures:' (use ctrl+space for auto complete!)");
|
||||
} else {
|
||||
catalog.putRegistryEnumRef(prop, "enum-vanilla-structure", catalog::vanillaStructures);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)");
|
||||
}
|
||||
return "Vanilla Structure";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) {
|
||||
catalog.putRegistryEnumRef(prop, "enum-vanilla-structure-set", catalog::vanillaStructureSets);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure SET key (use ctrl+space for auto complete!)");
|
||||
return "Vanilla Structure Set";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListItemType.class)) {
|
||||
catalog.putRegistryEnumRef(prop, "enum-item-type", catalog::itemTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)");
|
||||
return "Item Type";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListEntityType.class)) {
|
||||
catalog.putRegistryEnumRef(prop, "enum-entity-type", catalog::entityTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)");
|
||||
return "Entity Type";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListBiome.class)) {
|
||||
catalog.putRegistryEnumRef(prop, "enum-biome-type", catalog::biomeTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)");
|
||||
return "Biome Type";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListSpecialEntity.class)) {
|
||||
catalog.putRegistryEnumRef(prop, "enum-reg-specialentity", catalog::specialEntityTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Custom Mob Type (use ctrl+space for auto complete!)");
|
||||
return "Custom Mob Type";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListFont.class)) {
|
||||
catalog.putRegistryEnumRef(prop, "enum-font", SchemaRegistryCatalog::fontTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)");
|
||||
return "Font Family";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListEnchantment.class)) {
|
||||
catalog.putRegistryEnumRef(prop, "enum-enchantment", catalog::enchantTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)");
|
||||
return "Enchantment Type";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListPotionEffect.class)) {
|
||||
catalog.putRegistryEnumRef(prop, "enum-potion-effect-type", catalog::potionTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)");
|
||||
return "Potion Effect Type";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListFunction.class)) {
|
||||
return functionReference(k, prop, description, "Text", false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String stringArrayRegistryType(Field k, JSONObject prop, KList<String> description) {
|
||||
if (k.isAnnotationPresent(RegistryListResource.class)) {
|
||||
return resourceItems(k, prop, description);
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListStructure.class)) {
|
||||
putIrisStructureDefinition();
|
||||
putItemsReference(prop, IRIS_STRUCTURE_DEFINITION);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)");
|
||||
return "List<Structure>";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) {
|
||||
catalog.putRegistryEnumItems(prop, "enum-native-jigsaw-pool", catalog::nativeJigsawPools);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)");
|
||||
return "List<Native Jigsaw Pool>";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) {
|
||||
if (k.getAnnotation(RegistryListVanillaStructure.class).prefixes()) {
|
||||
catalog.putRegistryEnumOrPrefixItems(prop, "enum-vanilla-structure-or-prefix",
|
||||
"enum-vanilla-structure", catalog::vanillaStructures, SchemaRegistryCatalog.VANILLA_STRUCTURE_PREFIX_PATTERN);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a vanilla/datapack structure key or a family/namespace prefix like 'minecraft:village' or 'nova_structures:' (use ctrl+space for auto complete!)");
|
||||
} else {
|
||||
catalog.putRegistryEnumItems(prop, "enum-vanilla-structure", catalog::vanillaStructures);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)");
|
||||
}
|
||||
return "List<Vanilla Structure>";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) {
|
||||
catalog.putRegistryEnumItems(prop, "enum-vanilla-structure-set", catalog::vanillaStructureSets);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure set key (use ctrl+space for auto complete!)");
|
||||
return "List<Vanilla Structure Set>";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListBlockType.class)) {
|
||||
catalog.putRegistryEnumItems(prop, "enum-block-type", catalog::blockTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)");
|
||||
return "List of Block Types";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListItemType.class)) {
|
||||
catalog.putRegistryEnumItems(prop, "enum-item-type", catalog::itemTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)");
|
||||
return "List of Item Types";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListEntityType.class)) {
|
||||
catalog.putRegistryEnumItems(prop, "enum-entity-type", catalog::entityTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)");
|
||||
return "List of Entity Types";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListBiome.class)) {
|
||||
catalog.putRegistryEnumItems(prop, "enum-biome-type", catalog::biomeTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)");
|
||||
return "List of Biome Types";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListFont.class)) {
|
||||
catalog.putRegistryEnumItems(prop, "enum-font", SchemaRegistryCatalog::fontTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)");
|
||||
return "List of Font Families";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListEnchantment.class)) {
|
||||
catalog.putRegistryEnumItems(prop, "enum-enchantment", catalog::enchantTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)");
|
||||
return "List of Enchantment Types";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListPotionEffect.class)) {
|
||||
catalog.putRegistryEnumItems(prop, "enum-potion-effect-type", catalog::potionTypes);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)");
|
||||
return "List of Potion Effect Types";
|
||||
}
|
||||
if (k.isAnnotationPresent(RegistryListFunction.class)) {
|
||||
return functionReference(k, prop, description, "List of Text", true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resourceReference(Field k, JSONObject prop, KList<String> description) {
|
||||
RegistryListResource rr = k.getDeclaredAnnotation(RegistryListResource.class);
|
||||
ResourceLoader<?> loader = builder.data.getLoaders().get(rr.value());
|
||||
|
||||
if (loader == null) {
|
||||
IrisLogging.error("Cannot find Registry Loader for type " + rr.value() + " used in " + k.getDeclaringClass().getCanonicalName() + " in field " + k.getName());
|
||||
return "Text";
|
||||
}
|
||||
|
||||
String key = "erz" + loader.getFolderName();
|
||||
putResourceDefinition(key, loader);
|
||||
prop.put("$ref", "#/definitions/" + key);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid " + loader.getFolderName() + " (use ctrl+space for auto complete!)");
|
||||
return "Iris " + loader.getResourceTypeName();
|
||||
}
|
||||
|
||||
private String resourceItems(Field k, JSONObject prop, KList<String> description) {
|
||||
RegistryListResource rr = k.getDeclaredAnnotation(RegistryListResource.class);
|
||||
ResourceLoader<?> loader = builder.data.getLoaders().get(rr.value());
|
||||
|
||||
if (loader == null) {
|
||||
IrisLogging.error("Cannot find Registry Loader for type (list schema) " + rr.value() + " used in " + k.getDeclaringClass().getCanonicalName() + " in field " + k.getName());
|
||||
return "List of Text";
|
||||
}
|
||||
|
||||
String key = "erz" + loader.getFolderName();
|
||||
putResourceDefinition(key, loader);
|
||||
putItemsReference(prop, key);
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid " + loader.getResourceTypeName() + " (use ctrl+space for auto complete!)");
|
||||
return "List<" + loader.getResourceTypeName() + ">";
|
||||
}
|
||||
|
||||
private String functionReference(Field k, JSONObject prop, KList<String> description, String fallbackType, boolean asItems) {
|
||||
Class<? extends ListFunction<KList<String>>> functionClass = k.getDeclaredAnnotation(RegistryListFunction.class).value();
|
||||
String fancyType = fallbackType;
|
||||
|
||||
try {
|
||||
ListFunction<KList<String>> instance = functionClass.getDeclaredConstructor().newInstance();
|
||||
String key = instance.key();
|
||||
fancyType = instance.fancyName();
|
||||
|
||||
if (!builder.definitions.containsKey(key)) {
|
||||
JSONObject j = new JSONObject();
|
||||
j.put("enum", instance.apply(builder.data));
|
||||
builder.definitions.put(key, j);
|
||||
}
|
||||
|
||||
if (asItems) {
|
||||
putItemsReference(prop, key);
|
||||
} else {
|
||||
prop.put("$ref", "#/definitions/" + key);
|
||||
}
|
||||
description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid " + fancyType + " (use ctrl+space for auto complete!)");
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("Could not execute apply method in " + functionClass.getName(), e);
|
||||
}
|
||||
|
||||
return fancyType;
|
||||
}
|
||||
|
||||
private void putResourceDefinition(String key, ResourceLoader<?> loader) {
|
||||
if (!builder.definitions.containsKey(key)) {
|
||||
JSONObject j = new JSONObject();
|
||||
j.put("enum", new JSONArray(loader.getPossibleKeys()));
|
||||
builder.definitions.put(key, j);
|
||||
}
|
||||
}
|
||||
|
||||
private void putIrisStructureDefinition() {
|
||||
if (!builder.definitions.containsKey(IRIS_STRUCTURE_DEFINITION)) {
|
||||
JSONObject j = new JSONObject();
|
||||
j.put("enum", new JSONArray(StructureSchemaKeys.collect(
|
||||
Arrays.asList(builder.data.getStructureLoader().getPossibleKeys()),
|
||||
Arrays.asList(builder.data.getJigsawPieceLoader().getPossibleKeys())).toArray(new String[0])));
|
||||
builder.definitions.put(IRIS_STRUCTURE_DEFINITION, j);
|
||||
}
|
||||
}
|
||||
|
||||
private void putItemsReference(JSONObject prop, String key) {
|
||||
JSONObject items = new JSONObject();
|
||||
items.put("$ref", "#/definitions/" + key);
|
||||
prop.put("items", items);
|
||||
}
|
||||
}
|
||||
+7
-8
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.CapabilitySnapshot;
|
||||
import art.arcane.iris.spi.CapabilityProbe;
|
||||
import io.papermc.lib.PaperLib;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.World;
|
||||
@@ -56,14 +57,12 @@ final class BukkitPublicRuntimeControlBackend implements WorldRuntimeControlBack
|
||||
}
|
||||
|
||||
if (capabilities.chunkAtAsyncMethod() != null) {
|
||||
try {
|
||||
Object result = capabilities.chunkAtAsyncMethod().invoke(world, chunkX, chunkZ, generate);
|
||||
if (result instanceof CompletableFuture<?>) {
|
||||
@SuppressWarnings("unchecked")
|
||||
CompletableFuture<Chunk> future = (CompletableFuture<Chunk>) result;
|
||||
return future;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
Object result = CapabilityProbe.attempt("world#getChunkAtAsync",
|
||||
() -> capabilities.chunkAtAsyncMethod().invoke(world, chunkX, chunkZ, generate), null);
|
||||
if (result instanceof CompletableFuture<?>) {
|
||||
@SuppressWarnings("unchecked")
|
||||
CompletableFuture<Chunk> future = (CompletableFuture<Chunk>) result;
|
||||
return future;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+45
-43
@@ -1,6 +1,8 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.CapabilitySnapshot;
|
||||
import art.arcane.iris.spi.CapabilityProbe;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import io.papermc.lib.PaperLib;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
@@ -57,7 +59,9 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
|
||||
if (value instanceof Number number) {
|
||||
return OptionalLong.of(number.longValue());
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Failed to read the runtime day time of world \"" + world.getName()
|
||||
+ "\" through " + strategy.description() + ".", failure);
|
||||
}
|
||||
|
||||
return OptionalLong.empty();
|
||||
@@ -107,7 +111,9 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
|
||||
}
|
||||
|
||||
strategy.syncMethod().invoke(serverHandle);
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Failed to push the runtime day time of world \""
|
||||
+ (world == null ? "unknown" : world.getName()) + "\" to its players.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,42 +167,42 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
|
||||
return TimeAccessStrategy.unsupported();
|
||||
}
|
||||
|
||||
try {
|
||||
Method handleMethod = resolveZeroArgMethod(world.getClass(), "getHandle");
|
||||
if (handleMethod == null) {
|
||||
return TimeAccessStrategy.unsupported();
|
||||
}
|
||||
return CapabilityProbe.attempt("runtime world clock", () -> resolveTimeAccess(world),
|
||||
TimeAccessStrategy.unsupported());
|
||||
}
|
||||
|
||||
Object handle = handleMethod.invoke(world);
|
||||
if (handle == null) {
|
||||
return TimeAccessStrategy.unsupported();
|
||||
}
|
||||
|
||||
Method readMethod = resolveZeroArgMethod(handle.getClass(), "getDayTime");
|
||||
Method writeMethod = resolveLongArgMethod(handle.getClass(), "setDayTime");
|
||||
if (readMethod != null && writeMethod != null) {
|
||||
return TimeAccessStrategy.forHandle(handleMethod, readMethod, writeMethod, "runtime_handle#setDayTime");
|
||||
}
|
||||
|
||||
Method levelDataMethod = resolveZeroArgMethod(handle.getClass(), "serverLevelData");
|
||||
if (levelDataMethod == null) {
|
||||
levelDataMethod = resolveZeroArgMethod(handle.getClass(), "getLevelData");
|
||||
}
|
||||
if (levelDataMethod != null) {
|
||||
Object levelData = levelDataMethod.invoke(handle);
|
||||
if (levelData != null) {
|
||||
Method levelDataReadMethod = resolveZeroArgMethod(levelData.getClass(), "getDayTime");
|
||||
Method levelDataWriteMethod = resolveLongArgMethod(levelData.getClass(), "setDayTime");
|
||||
if (levelDataReadMethod != null && levelDataWriteMethod != null) {
|
||||
return TimeAccessStrategy.forLevelData(handleMethod, levelDataMethod, levelDataReadMethod, levelDataWriteMethod, "world_data#setDayTime");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TimeAccessStrategy.unsupported(handleMethod);
|
||||
} catch (Throwable ignored) {
|
||||
private TimeAccessStrategy resolveTimeAccess(World world) throws ReflectiveOperationException {
|
||||
Method handleMethod = resolveZeroArgMethod(world.getClass(), "getHandle");
|
||||
if (handleMethod == null) {
|
||||
return TimeAccessStrategy.unsupported();
|
||||
}
|
||||
|
||||
Object handle = handleMethod.invoke(world);
|
||||
if (handle == null) {
|
||||
return TimeAccessStrategy.unsupported();
|
||||
}
|
||||
|
||||
Method readMethod = resolveZeroArgMethod(handle.getClass(), "getDayTime");
|
||||
Method writeMethod = resolveLongArgMethod(handle.getClass(), "setDayTime");
|
||||
if (readMethod != null && writeMethod != null) {
|
||||
return TimeAccessStrategy.forHandle(handleMethod, readMethod, writeMethod, "runtime_handle#setDayTime");
|
||||
}
|
||||
|
||||
Method levelDataMethod = resolveZeroArgMethod(handle.getClass(), "serverLevelData");
|
||||
if (levelDataMethod == null) {
|
||||
levelDataMethod = resolveZeroArgMethod(handle.getClass(), "getLevelData");
|
||||
}
|
||||
if (levelDataMethod != null) {
|
||||
Object levelData = levelDataMethod.invoke(handle);
|
||||
if (levelData != null) {
|
||||
Method levelDataReadMethod = resolveZeroArgMethod(levelData.getClass(), "getDayTime");
|
||||
Method levelDataWriteMethod = resolveLongArgMethod(levelData.getClass(), "setDayTime");
|
||||
if (levelDataReadMethod != null && levelDataWriteMethod != null) {
|
||||
return TimeAccessStrategy.forLevelData(handleMethod, levelDataMethod, levelDataReadMethod, levelDataWriteMethod, "world_data#setDayTime");
|
||||
}
|
||||
}
|
||||
}
|
||||
return TimeAccessStrategy.unsupported(handleMethod);
|
||||
}
|
||||
|
||||
private static Method resolveZeroArgMethod(Class<?> type, String name) {
|
||||
@@ -283,13 +289,11 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
|
||||
}
|
||||
|
||||
private static Method resolveCraftServerMethod(String name) {
|
||||
try {
|
||||
return CapabilityProbe.attempt("craft server#" + name, () -> {
|
||||
Method method = Bukkit.getServer().getClass().getMethod(name);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
}, null);
|
||||
}
|
||||
|
||||
private static Method resolveServerMethod(Method serverHandleMethod, String name) {
|
||||
@@ -297,7 +301,7 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return CapabilityProbe.attempt("minecraft server#" + name, () -> {
|
||||
Object craftServer = Bukkit.getServer();
|
||||
if (craftServer == null) {
|
||||
return null;
|
||||
@@ -311,9 +315,7 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
|
||||
Method method = serverHandle.getClass().getMethod(name);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
}, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.core.IrisStartupValidation;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.v1X.NMSBinding1X;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.project.agent.Agent;
|
||||
|
||||
public final class RuntimeInjection {
|
||||
private static final Object LOCK = new Object();
|
||||
|
||||
private static volatile Outcome outcome;
|
||||
|
||||
private RuntimeInjection() {
|
||||
}
|
||||
|
||||
public enum Failure {
|
||||
NONE,
|
||||
NO_BINDING,
|
||||
AGENT,
|
||||
INJECTION
|
||||
}
|
||||
|
||||
public record Outcome(boolean installed, Failure failure, String lockReason) {
|
||||
}
|
||||
|
||||
public static Outcome install() {
|
||||
Outcome current = outcome;
|
||||
if (current != null) {
|
||||
return current;
|
||||
}
|
||||
|
||||
synchronized (LOCK) {
|
||||
if (outcome != null) {
|
||||
return outcome;
|
||||
}
|
||||
Outcome resolved = attemptInstall();
|
||||
outcome = resolved;
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
public static void installIfDeferred() {
|
||||
Outcome resolved = install();
|
||||
if (resolved.installed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
IrisLogging.error("Iris runtime injection is unavailable: " + resolved.lockReason());
|
||||
IrisStartupValidation.markRuntimeInvalid(resolved.lockReason());
|
||||
}
|
||||
|
||||
public static boolean isInstalled() {
|
||||
Outcome current = outcome;
|
||||
return current != null && current.installed();
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
synchronized (LOCK) {
|
||||
outcome = null;
|
||||
}
|
||||
}
|
||||
|
||||
static String agentLockReason() {
|
||||
return "Iris Java agent is unavailable. Add -javaagent:" + Agent.AGENT_JAR.getPath()
|
||||
+ " to the JVM arguments before -jar and restart the server.";
|
||||
}
|
||||
|
||||
private static Outcome attemptInstall() {
|
||||
if (!INMS.isBound() || INMS.get() instanceof NMSBinding1X) {
|
||||
return new Outcome(false, Failure.NO_BINDING,
|
||||
"Iris cannot use this server's NMS runtime. Resolve the Server Version or NMS Disabled error above and restart the server.");
|
||||
}
|
||||
|
||||
if (!Agent.install()) {
|
||||
return new Outcome(false, Failure.AGENT, agentLockReason());
|
||||
}
|
||||
|
||||
if (!INMS.get().injectBukkit()) {
|
||||
return new Outcome(false, Failure.INJECTION,
|
||||
"Iris runtime injection failed. Resolve the startup errors and restart the server.");
|
||||
}
|
||||
|
||||
return new Outcome(true, Failure.NONE, null);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.spi.CapabilityProbe;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
@@ -30,7 +31,6 @@ import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.bukkit.util.VoxelShape;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Objects;
|
||||
@@ -76,6 +76,12 @@ public final class WorldRuntimeControlService {
|
||||
+ ", " + backend.describeCapabilities();
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
synchronized (WorldRuntimeControlService.class) {
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
public static WorldRuntimeControlService get() {
|
||||
WorldRuntimeControlService current = instance;
|
||||
if (current != null) {
|
||||
@@ -192,7 +198,8 @@ public final class WorldRuntimeControlService {
|
||||
backend.syncTime(world);
|
||||
return true;
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.debug("Runtime time lock skipped for world \"" + world.getName() + "\": " + e.getMessage());
|
||||
IrisLogging.reportError("Failed to lock the clock of world \"" + world.getName()
|
||||
+ "\" to noon; the world keeps its own time.", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -621,21 +628,16 @@ public final class WorldRuntimeControlService {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Field field = GameRule.class.getField(name);
|
||||
Object value = field.get(null);
|
||||
if (value instanceof GameRule<?> gameRule && Integer.class.equals(gameRule.getType())) {
|
||||
return (GameRule<Integer>) gameRule;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
Object declared = CapabilityProbe.attempt("GameRule." + name,
|
||||
() -> GameRule.class.getField(name).get(null), null);
|
||||
if (declared instanceof GameRule<?> gameRule && Integer.class.equals(gameRule.getType())) {
|
||||
return (GameRule<Integer>) gameRule;
|
||||
}
|
||||
|
||||
try {
|
||||
GameRule<?> byName = GameRule.getByName(name);
|
||||
if (byName != null && Integer.class.equals(byName.getType())) {
|
||||
return (GameRule<Integer>) byName;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
GameRule<?> byName = CapabilityProbe.attempt("GameRule#getByName(" + name + ")",
|
||||
() -> GameRule.getByName(name), null);
|
||||
if (byName != null && Integer.class.equals(byName.getType())) {
|
||||
return (GameRule<Integer>) byName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,12 +659,10 @@ public final class WorldRuntimeControlService {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
GameRule<?> byName = GameRule.getByName(availableRule);
|
||||
if (byName != null && Integer.class.equals(byName.getType())) {
|
||||
return (GameRule<Integer>) byName;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
GameRule<?> byName = CapabilityProbe.attempt("GameRule#getByName(" + availableRule + ")",
|
||||
() -> GameRule.getByName(availableRule), null);
|
||||
if (byName != null && Integer.class.equals(byName.getType())) {
|
||||
return (GameRule<Integer>) byName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,21 +681,16 @@ public final class WorldRuntimeControlService {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Field field = GameRule.class.getField(name);
|
||||
Object value = field.get(null);
|
||||
if (value instanceof GameRule<?> gameRule && Boolean.class.equals(gameRule.getType())) {
|
||||
return (GameRule<Boolean>) gameRule;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
Object declared = CapabilityProbe.attempt("GameRule." + name,
|
||||
() -> GameRule.class.getField(name).get(null), null);
|
||||
if (declared instanceof GameRule<?> gameRule && Boolean.class.equals(gameRule.getType())) {
|
||||
return (GameRule<Boolean>) gameRule;
|
||||
}
|
||||
|
||||
try {
|
||||
GameRule<?> byName = GameRule.getByName(name);
|
||||
if (byName != null && Boolean.class.equals(byName.getType())) {
|
||||
return (GameRule<Boolean>) byName;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
GameRule<?> byName = CapabilityProbe.attempt("GameRule#getByName(" + name + ")",
|
||||
() -> GameRule.getByName(name), null);
|
||||
if (byName != null && Boolean.class.equals(byName.getType())) {
|
||||
return (GameRule<Boolean>) byName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,12 +712,10 @@ public final class WorldRuntimeControlService {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
GameRule<?> byName = GameRule.getByName(availableRule);
|
||||
if (byName != null && Boolean.class.equals(byName.getType())) {
|
||||
return (GameRule<Boolean>) byName;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
GameRule<?> byName = CapabilityProbe.attempt("GameRule#getByName(" + availableRule + ")",
|
||||
() -> GameRule.getByName(availableRule), null);
|
||||
if (byName != null && Boolean.class.equals(byName.getType())) {
|
||||
return (GameRule<Boolean>) byName;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
package art.arcane.iris.core.runtime.jigsaw;
|
||||
|
||||
import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver;
|
||||
import art.arcane.iris.engine.object.IrisJigsawMode;
|
||||
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
|
||||
import art.arcane.iris.engine.object.IrisStructure;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
final class JigsawStudioGraphDocuments {
|
||||
static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private JigsawStudioGraphDocuments() {
|
||||
}
|
||||
|
||||
static Map<String, JigsawPlanarArchetype> expectedThemeSetSources(
|
||||
IrisStructure structure,
|
||||
Set<String> requestedStableIds
|
||||
) throws IOException {
|
||||
Map<String, JigsawPlanarArchetype> expected = new LinkedHashMap<>();
|
||||
if (structure.resolvedMode() == IrisJigsawMode.SPATIAL_JIGSAW) {
|
||||
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;
|
||||
try {
|
||||
workcells = PlanarJigsawWorkcellResolver.resolve(structure);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Planar workcell configuration is invalid: "
|
||||
+ exception.getMessage(), exception);
|
||||
}
|
||||
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
|
||||
PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = workcells.get(archetype.modelArchetype());
|
||||
if (workcell != null && workcell.enabled()) {
|
||||
expected.put(archetype.stableId(), archetype);
|
||||
}
|
||||
}
|
||||
if (expected.isEmpty()) {
|
||||
throw new IOException("A planar theme set requires at least one enabled workcell");
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
static void requireExactThemeSetSources(
|
||||
Map<String, String> requestedSources,
|
||||
Set<String> expectedStableIds
|
||||
) throws IOException {
|
||||
Set<String> missing = new LinkedHashSet<>(expectedStableIds);
|
||||
missing.removeAll(requestedSources.keySet());
|
||||
Set<String> unexpected = new LinkedHashSet<>(requestedSources.keySet());
|
||||
unexpected.removeAll(expectedStableIds);
|
||||
if (!missing.isEmpty() || !unexpected.isEmpty()) {
|
||||
throw new IOException("Theme-set sources must match enabled workcells exactly; missing="
|
||||
+ missing + ", unexpected=" + unexpected);
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] appendThemeSet(
|
||||
byte[] content,
|
||||
String themeKey,
|
||||
String structureResource
|
||||
) throws IOException {
|
||||
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
|
||||
if (!parsed.isJsonObject()) {
|
||||
throw new IOException("Jigsaw structure is not a JSON object: " + structureResource);
|
||||
}
|
||||
JsonObject structure = parsed.getAsJsonObject();
|
||||
JsonArray themeSets;
|
||||
if (!structure.has("themeSets")) {
|
||||
themeSets = new JsonArray();
|
||||
structure.add("themeSets", themeSets);
|
||||
} else if (!structure.get("themeSets").isJsonArray()) {
|
||||
throw new IOException("Jigsaw structure has an invalid themeSets value: " + structureResource);
|
||||
} else {
|
||||
themeSets = structure.getAsJsonArray("themeSets");
|
||||
}
|
||||
for (JsonElement element : themeSets) {
|
||||
if (element.isJsonObject()
|
||||
&& element.getAsJsonObject().has("key")
|
||||
&& themeKey.equals(element.getAsJsonObject().get("key").getAsString())) {
|
||||
throw new IOException("Jigsaw structure already declares theme '" + themeKey + "'");
|
||||
}
|
||||
}
|
||||
JsonObject theme = new JsonObject();
|
||||
theme.addProperty("key", themeKey);
|
||||
theme.addProperty("weight", 1);
|
||||
themeSets.add(theme);
|
||||
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
static byte[] duplicatePieceForTheme(
|
||||
byte[] content,
|
||||
String targetPieceKey,
|
||||
String themeKey,
|
||||
String sourcePieceResource
|
||||
) throws IOException {
|
||||
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
|
||||
if (!parsed.isJsonObject()) {
|
||||
throw new IOException("Jigsaw piece is not a JSON object: " + sourcePieceResource);
|
||||
}
|
||||
JsonObject piece = parsed.getAsJsonObject().deepCopy();
|
||||
piece.addProperty("object", targetPieceKey);
|
||||
JsonArray themes = new JsonArray();
|
||||
themes.add(themeKey);
|
||||
piece.add("themes", themes);
|
||||
return (GSON.toJson(piece) + "\n").getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
static byte[] duplicatePieceForVariant(
|
||||
byte[] content,
|
||||
String targetPieceKey,
|
||||
String sourcePieceResource
|
||||
) throws IOException {
|
||||
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
|
||||
if (!parsed.isJsonObject()) {
|
||||
throw new IOException("Jigsaw piece is not a JSON object: " + sourcePieceResource);
|
||||
}
|
||||
JsonObject piece = parsed.getAsJsonObject().deepCopy();
|
||||
piece.addProperty("object", targetPieceKey);
|
||||
return (GSON.toJson(piece) + "\n").getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
static PoolMembershipDuplication duplicatePoolMemberships(
|
||||
byte[] content,
|
||||
Map<String, String> targetPieceKeysBySource,
|
||||
String poolResource
|
||||
) throws IOException {
|
||||
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
|
||||
if (!parsed.isJsonObject()) {
|
||||
throw new IOException("Jigsaw pool is not a JSON object: " + poolResource);
|
||||
}
|
||||
JsonObject pool = parsed.getAsJsonObject();
|
||||
JsonArray pieces = pool.getAsJsonArray("pieces");
|
||||
if (pieces == null) {
|
||||
throw new IOException("Jigsaw pool does not declare a pieces array: " + poolResource);
|
||||
}
|
||||
JsonArray expanded = new JsonArray();
|
||||
int duplicatedEntries = 0;
|
||||
for (JsonElement element : pieces) {
|
||||
expanded.add(element.deepCopy());
|
||||
if (!element.isJsonObject() || !element.getAsJsonObject().has("piece")) {
|
||||
continue;
|
||||
}
|
||||
String sourcePieceKey = element.getAsJsonObject().get("piece").getAsString();
|
||||
String targetPieceKey = targetPieceKeysBySource.get(sourcePieceKey);
|
||||
if (targetPieceKey == null) {
|
||||
continue;
|
||||
}
|
||||
JsonObject duplicate = element.getAsJsonObject().deepCopy();
|
||||
duplicate.addProperty("piece", targetPieceKey);
|
||||
expanded.add(duplicate);
|
||||
duplicatedEntries++;
|
||||
}
|
||||
if (duplicatedEntries == 0) {
|
||||
return new PoolMembershipDuplication(content, 0);
|
||||
}
|
||||
pool.add("pieces", expanded);
|
||||
return new PoolMembershipDuplication(
|
||||
(GSON.toJson(pool) + "\n").getBytes(StandardCharsets.UTF_8),
|
||||
duplicatedEntries);
|
||||
}
|
||||
|
||||
static List<String> normalizeThemes(List<String> themes) {
|
||||
Objects.requireNonNull(themes, "Jigsaw Studio piece themes");
|
||||
Set<String> normalized = new LinkedHashSet<>();
|
||||
for (String theme : themes) {
|
||||
Objects.requireNonNull(theme, "Jigsaw Studio piece theme");
|
||||
String key = theme.trim();
|
||||
if (key.isEmpty() || !key.equals(theme)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Jigsaw Studio piece themes must be non-blank and whitespace-normalized");
|
||||
}
|
||||
if (!normalized.add(key)) {
|
||||
throw new IllegalArgumentException("Duplicate Jigsaw Studio piece theme '" + key + "'");
|
||||
}
|
||||
}
|
||||
return List.copyOf(normalized);
|
||||
}
|
||||
|
||||
static PoolEntryRemoval removePoolEntries(
|
||||
byte[] content,
|
||||
String pieceKey,
|
||||
String poolResource
|
||||
) throws IOException {
|
||||
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
|
||||
if (!parsed.isJsonObject()) {
|
||||
throw new IOException("Jigsaw pool is not a JSON object: " + poolResource);
|
||||
}
|
||||
JsonObject pool = parsed.getAsJsonObject();
|
||||
JsonArray pieces = pool.getAsJsonArray("pieces");
|
||||
if (pieces == null) {
|
||||
throw new IOException("Jigsaw pool does not declare a pieces array: " + poolResource);
|
||||
}
|
||||
int removed = 0;
|
||||
for (int index = pieces.size() - 1; index >= 0; index--) {
|
||||
JsonElement entry = pieces.get(index);
|
||||
if (entry.isJsonObject()
|
||||
&& entry.getAsJsonObject().has("piece")
|
||||
&& pieceKey.equals(entry.getAsJsonObject().get("piece").getAsString())) {
|
||||
pieces.remove(index);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
if (removed == 0) {
|
||||
return new PoolEntryRemoval(content, 0);
|
||||
}
|
||||
return new PoolEntryRemoval(
|
||||
(GSON.toJson(pool) + "\n").getBytes(StandardCharsets.UTF_8),
|
||||
removed);
|
||||
}
|
||||
|
||||
static String resourceKey(String relativePath, String prefix, String suffix) {
|
||||
return relativePath.substring(prefix.length(), relativePath.length() - suffix.length());
|
||||
}
|
||||
|
||||
static byte[] addPoolEntry(
|
||||
byte[] content,
|
||||
String pieceKey,
|
||||
int weight,
|
||||
Path poolPath
|
||||
) throws IOException {
|
||||
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
|
||||
if (!parsed.isJsonObject()) {
|
||||
throw new IOException("Jigsaw pool is not a JSON object: " + poolPath);
|
||||
}
|
||||
JsonObject pool = parsed.getAsJsonObject();
|
||||
if (!pool.has("pieces") || !pool.get("pieces").isJsonArray()) {
|
||||
throw new IOException("Jigsaw pool does not declare a pieces array: " + poolPath);
|
||||
}
|
||||
JsonArray pieces = pool.getAsJsonArray("pieces");
|
||||
for (JsonElement element : pieces) {
|
||||
if (element.isJsonObject()
|
||||
&& element.getAsJsonObject().has("piece")
|
||||
&& pieceKey.equals(element.getAsJsonObject().get("piece").getAsString())) {
|
||||
throw new IOException("Pool already references piece '" + pieceKey + "'");
|
||||
}
|
||||
}
|
||||
JsonObject entry = new JsonObject();
|
||||
entry.addProperty("piece", pieceKey);
|
||||
entry.addProperty("weight", weight);
|
||||
pieces.add(entry);
|
||||
return (GSON.toJson(pool) + "\n").getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
static String normalizeChannel(String channel) {
|
||||
String normalized = channel == null ? "" : channel.trim();
|
||||
if (normalized.equalsIgnoreCase("none")) {
|
||||
return "";
|
||||
}
|
||||
if (normalized.length() > 128) {
|
||||
throw new IllegalArgumentException("Jigsaw connector channels cannot exceed 128 characters");
|
||||
}
|
||||
for (int index = 0; index < normalized.length(); index++) {
|
||||
if (Character.isWhitespace(normalized.charAt(index))) {
|
||||
throw new IllegalArgumentException("Jigsaw connector channels cannot contain whitespace");
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
record PoolEntryRemoval(byte[] content, int removedEntries) {
|
||||
PoolEntryRemoval {
|
||||
Objects.requireNonNull(content, "Jigsaw Studio pool entry removal content");
|
||||
if (removedEntries < 0) {
|
||||
throw new IllegalArgumentException("Removed jigsaw pool entry count cannot be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record PoolMembershipDuplication(byte[] content, int duplicatedEntries) {
|
||||
PoolMembershipDuplication {
|
||||
Objects.requireNonNull(content, "Jigsaw Studio duplicated pool content");
|
||||
if (duplicatedEntries < 0) {
|
||||
throw new IllegalArgumentException("Duplicated pool membership count cannot be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-977
File diff suppressed because it is too large.
Load diff
+153
@@ -0,0 +1,153 @@
|
||||
package art.arcane.iris.core.runtime.jigsaw;
|
||||
|
||||
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
|
||||
import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver;
|
||||
import art.arcane.iris.engine.object.IrisJigsawMode;
|
||||
import art.arcane.iris.engine.object.IrisJigsawPiece;
|
||||
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
|
||||
import art.arcane.iris.engine.object.IrisStructure;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
final class JigsawStudioGraphInspector {
|
||||
private JigsawStudioGraphInspector() {
|
||||
}
|
||||
|
||||
static void requireAvailableVariantTarget(
|
||||
JigsawStudioGraphEditor.OwnedGraph graph,
|
||||
String relativePath
|
||||
) throws IOException {
|
||||
StructureResourceBundle.validateRelativePath(relativePath);
|
||||
Path target = graph.root().resolve(relativePath).normalize();
|
||||
if (!target.startsWith(graph.root())) {
|
||||
throw new IOException("Variant target escapes the pack root: " + relativePath);
|
||||
}
|
||||
if (graph.manifest().resourceHashes().containsKey(relativePath)
|
||||
|| Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Variant target already exists: " + relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
static void requireAvailableThemeSetTarget(
|
||||
JigsawStudioGraphEditor.OwnedGraph graph,
|
||||
String relativePath
|
||||
) throws IOException {
|
||||
StructureResourceBundle.validateRelativePath(relativePath);
|
||||
Path target = graph.root().resolve(relativePath).normalize();
|
||||
if (!target.startsWith(graph.root())) {
|
||||
throw new IOException("Theme-set target escapes the pack root: " + relativePath);
|
||||
}
|
||||
if (graph.manifest().resourceHashes().containsKey(relativePath)
|
||||
|| Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Theme-set target already exists: " + relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean otherOwnedPieceReferencesObject(
|
||||
JigsawStudioGraphEditor.OwnedGraph graph,
|
||||
String targetPieceKey,
|
||||
String objectKey
|
||||
) throws IOException {
|
||||
for (String relativePath : graph.manifest().resourceHashes().keySet()) {
|
||||
if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
String pieceKey = JigsawStudioGraphDocuments.resourceKey(relativePath, "jigsaw-pieces/", ".json");
|
||||
if (pieceKey.equals(targetPieceKey)) {
|
||||
continue;
|
||||
}
|
||||
IrisJigsawPiece piece = JigsawStudioGraphEditor.readPiece(JigsawStudioGraphEditor.resolveOwnedResource(graph.root(), relativePath), relativePath);
|
||||
if (objectKey.equals(piece.getObject())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void requireDeletableArchetype(
|
||||
JigsawStudioGraphEditor.OwnedGraph graph,
|
||||
String targetPieceKey,
|
||||
IrisJigsawPiece targetPiece
|
||||
) throws IOException {
|
||||
String structureResource = "structures/" + graph.manifest().structure().path() + ".json";
|
||||
if (!graph.manifest().resourceHashes().containsKey(structureResource)) {
|
||||
throw new IOException("The owned graph manifest does not include " + structureResource);
|
||||
}
|
||||
IrisStructure structure = JigsawStudioGraphEditor.readStructure(
|
||||
JigsawStudioGraphEditor.resolveOwnedResource(graph.root(), structureResource),
|
||||
structureResource);
|
||||
if (structure.resolvedMode() == IrisJigsawMode.SPATIAL_JIGSAW) {
|
||||
if (countOtherOwnedPieces(graph, targetPieceKey, null) == 0) {
|
||||
throw new IOException("Cannot delete the final spatial jigsaw variant");
|
||||
}
|
||||
return;
|
||||
}
|
||||
IrisJigsawWorkcellArchetype archetype = IrisJigsawWorkcellArchetype.fromPiece(targetPiece);
|
||||
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> workcells;
|
||||
try {
|
||||
workcells = PlanarJigsawWorkcellResolver.resolve(structure);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Planar workcell configuration is invalid: "
|
||||
+ exception.getMessage(), exception);
|
||||
}
|
||||
PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = workcells.get(archetype);
|
||||
if (workcell != null && workcell.enabled()
|
||||
&& countOtherOwnedPieces(graph, targetPieceKey, archetype) == 0) {
|
||||
throw new IOException("Cannot delete the final variant for enabled planar workcell "
|
||||
+ JigsawPlanarArchetype.fromModel(archetype).stableId());
|
||||
}
|
||||
}
|
||||
|
||||
static int countOtherOwnedPieces(
|
||||
JigsawStudioGraphEditor.OwnedGraph graph,
|
||||
String targetPieceKey,
|
||||
IrisJigsawWorkcellArchetype archetype
|
||||
) throws IOException {
|
||||
int count = 0;
|
||||
for (String relativePath : graph.manifest().resourceHashes().keySet()) {
|
||||
if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
String pieceKey = JigsawStudioGraphDocuments.resourceKey(relativePath, "jigsaw-pieces/", ".json");
|
||||
if (pieceKey.equals(targetPieceKey)) {
|
||||
continue;
|
||||
}
|
||||
if (archetype == null) {
|
||||
count++;
|
||||
continue;
|
||||
}
|
||||
IrisJigsawPiece piece = JigsawStudioGraphEditor.readPiece(JigsawStudioGraphEditor.resolveOwnedResource(graph.root(), relativePath), relativePath);
|
||||
if (IrisJigsawWorkcellArchetype.fromPiece(piece) == archetype) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
static void requireExclusiveObjectReference(
|
||||
JigsawStudioGraphEditor.OwnedGraph graph,
|
||||
String activePieceKey,
|
||||
String objectKey
|
||||
) throws IOException {
|
||||
for (String relativePath : graph.manifest().resourceHashes().keySet()) {
|
||||
if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
String pieceKey = relativePath.substring(
|
||||
"jigsaw-pieces/".length(),
|
||||
relativePath.length() - ".json".length());
|
||||
if (pieceKey.equals(activePieceKey)) {
|
||||
continue;
|
||||
}
|
||||
IrisJigsawPiece piece = JigsawStudioGraphEditor.readPiece(JigsawStudioGraphEditor.resolveOwnedResource(graph.root(), relativePath), relativePath);
|
||||
if (objectKey.equals(piece.getObject())) {
|
||||
throw new IOException("Object '" + objectKey + "' is shared by piece '" + pieceKey
|
||||
+ "'; duplicate the active variant before resizing its object bounds");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
package art.arcane.iris.core.runtime.jigsaw;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisDirection;
|
||||
import art.arcane.iris.engine.object.IrisJigsawConnector;
|
||||
import art.arcane.iris.engine.object.IrisJigsawPiece;
|
||||
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
|
||||
import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
import art.arcane.iris.engine.object.TileData;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.math.IrisBlockVector;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
final class JigsawStudioObjectResizer {
|
||||
private JigsawStudioObjectResizer() {
|
||||
}
|
||||
|
||||
static PlanarPieceObjectResize resizePlanarPieceObject(
|
||||
IrisObject source,
|
||||
IrisJigsawPiece piece,
|
||||
JigsawPlanarArchetype archetype,
|
||||
JigsawStudioCellDimensions dimensions,
|
||||
String pieceKey
|
||||
) throws IOException {
|
||||
IrisObject sourceObject = Objects.requireNonNull(source, "Planar Jigsaw Studio source object");
|
||||
IrisJigsawPiece targetPiece = Objects.requireNonNull(piece, "Planar Jigsaw Studio piece");
|
||||
JigsawPlanarArchetype targetArchetype = Objects.requireNonNull(
|
||||
archetype,
|
||||
"Planar Jigsaw Studio archetype");
|
||||
JigsawStudioCellDimensions targetDimensions = Objects.requireNonNull(
|
||||
dimensions,
|
||||
"Planar Jigsaw Studio target dimensions");
|
||||
String targetPieceKey = pieceKey == null || pieceKey.isBlank() ? "unknown" : pieceKey;
|
||||
if (targetDimensions.width() < 3 || targetDimensions.depth() < 3) {
|
||||
throw new IllegalArgumentException(
|
||||
"Planar Jigsaw Studio workcell width and depth must each be at least 3 blocks");
|
||||
}
|
||||
if (sourceObject.getW() < 1 || sourceObject.getH() < 1 || sourceObject.getD() < 1) {
|
||||
throw new IOException("Planar piece '" + targetPieceKey + "' has invalid object dimensions");
|
||||
}
|
||||
if (IrisJigsawWorkcellArchetype.fromPiece(targetPiece) != targetArchetype.modelArchetype()) {
|
||||
throw new IOException("Planar piece '" + targetPieceKey + "' does not belong to "
|
||||
+ targetArchetype.stableId());
|
||||
}
|
||||
|
||||
int quarterTurns = targetArchetype.modelArchetype().sourceToCanonicalQuarterTurns(targetPiece);
|
||||
JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions(
|
||||
sourceObject.getW(),
|
||||
sourceObject.getH(),
|
||||
sourceObject.getD());
|
||||
JigsawStudioCellDimensions canonicalDimensions = canonicalDimensions(sourceDimensions, quarterTurns);
|
||||
Map<LocalPosition, PlatformBlockState> canonicalBlocks = canonicalBlocks(
|
||||
sourceObject,
|
||||
quarterTurns,
|
||||
targetPieceKey);
|
||||
Map<LocalPosition, TileData> canonicalTiles = canonicalTiles(
|
||||
sourceObject,
|
||||
quarterTurns,
|
||||
targetPieceKey);
|
||||
List<IrisJigsawConnector> connectors = targetPiece.getConnectors();
|
||||
if (connectors == null) {
|
||||
throw new IOException("Planar piece '" + targetPieceKey + "' has no connector list");
|
||||
}
|
||||
List<ConnectorResize> connectorResizes = planConnectorResizes(
|
||||
connectors,
|
||||
sourceDimensions,
|
||||
canonicalDimensions,
|
||||
targetDimensions,
|
||||
quarterTurns,
|
||||
targetPieceKey);
|
||||
relocateConnectorPayloads(canonicalBlocks, canonicalTiles, connectorResizes, targetPieceKey);
|
||||
requireContentInsideTarget(canonicalBlocks, canonicalTiles, targetDimensions, targetPieceKey);
|
||||
|
||||
JigsawStudioCellDimensions resizedSourceDimensions = sourceDimensions(targetDimensions, quarterTurns);
|
||||
IrisObject resizedObject = rebuildSourceObject(
|
||||
canonicalBlocks,
|
||||
canonicalTiles,
|
||||
resizedSourceDimensions,
|
||||
quarterTurns,
|
||||
targetPieceKey);
|
||||
int relocatedConnectors = 0;
|
||||
for (ConnectorResize connectorResize : connectorResizes) {
|
||||
LocalPosition sourcePosition = toSource(
|
||||
connectorResize.targetCanonical(),
|
||||
resizedSourceDimensions,
|
||||
quarterTurns);
|
||||
connectorResize.connector().setPosition(sourcePosition.toIrisPosition());
|
||||
if (!connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) {
|
||||
relocatedConnectors++;
|
||||
}
|
||||
}
|
||||
return new PlanarPieceObjectResize(resizedObject, relocatedConnectors);
|
||||
}
|
||||
|
||||
private static Map<LocalPosition, PlatformBlockState> canonicalBlocks(
|
||||
IrisObject source,
|
||||
int quarterTurns,
|
||||
String pieceKey
|
||||
) throws IOException {
|
||||
Map<LocalPosition, PlatformBlockState> blocks = new LinkedHashMap<>();
|
||||
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : source.getBlocks()) {
|
||||
LocalPosition sourcePosition = unsignedPosition(entry.getKey(), source);
|
||||
requireInside(sourcePosition, source.getW(), source.getH(), source.getD(),
|
||||
"stored block", pieceKey);
|
||||
PlatformBlockState state = entry.getValue();
|
||||
if (state == null) {
|
||||
throw new IOException("Planar piece '" + pieceKey + "' contains a null stored block state");
|
||||
}
|
||||
LocalPosition canonicalPosition = toCanonical(
|
||||
sourcePosition,
|
||||
source.getW(),
|
||||
source.getD(),
|
||||
quarterTurns);
|
||||
if (blocks.putIfAbsent(canonicalPosition, state) != null) {
|
||||
throw new IOException("Planar piece '" + pieceKey
|
||||
+ "' maps more than one stored block to " + canonicalPosition.describe());
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private static Map<LocalPosition, TileData> canonicalTiles(
|
||||
IrisObject source,
|
||||
int quarterTurns,
|
||||
String pieceKey
|
||||
) throws IOException {
|
||||
Map<LocalPosition, TileData> tiles = new LinkedHashMap<>();
|
||||
for (Map.Entry<IrisBlockVector, TileData> entry : source.getStates()) {
|
||||
LocalPosition sourcePosition = unsignedPosition(entry.getKey(), source);
|
||||
requireInside(sourcePosition, source.getW(), source.getH(), source.getD(),
|
||||
"tile data", pieceKey);
|
||||
TileData tile = entry.getValue();
|
||||
if (tile == null) {
|
||||
throw new IOException("Planar piece '" + pieceKey + "' contains null tile data");
|
||||
}
|
||||
LocalPosition canonicalPosition = toCanonical(
|
||||
sourcePosition,
|
||||
source.getW(),
|
||||
source.getD(),
|
||||
quarterTurns);
|
||||
if (tiles.putIfAbsent(canonicalPosition, tile.clone()) != null) {
|
||||
throw new IOException("Planar piece '" + pieceKey
|
||||
+ "' maps more than one tile payload to " + canonicalPosition.describe());
|
||||
}
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private static List<ConnectorResize> planConnectorResizes(
|
||||
List<IrisJigsawConnector> connectors,
|
||||
JigsawStudioCellDimensions sourceDimensions,
|
||||
JigsawStudioCellDimensions canonicalDimensions,
|
||||
JigsawStudioCellDimensions targetDimensions,
|
||||
int quarterTurns,
|
||||
String pieceKey
|
||||
) throws IOException {
|
||||
List<ConnectorResize> planned = new ArrayList<>(connectors.size());
|
||||
Set<LocalPosition> sourcePositions = new LinkedHashSet<>();
|
||||
Set<LocalPosition> targetPositions = new LinkedHashSet<>();
|
||||
IrisPosition sourceSize = dimensionsPosition(sourceDimensions);
|
||||
IrisPosition canonicalSize = dimensionsPosition(canonicalDimensions);
|
||||
IrisPosition targetSize = dimensionsPosition(targetDimensions);
|
||||
for (int index = 0; index < connectors.size(); index++) {
|
||||
IrisJigsawConnector connector = connectors.get(index);
|
||||
if (connector == null || connector.getPosition() == null || connector.getDirection() == null
|
||||
|| connector.getDirection().isVertical()) {
|
||||
throw new IOException("Planar piece '" + pieceKey + "' contains invalid connector " + index);
|
||||
}
|
||||
LocalPosition sourcePosition = LocalPosition.from(connector.getPosition());
|
||||
requireInside(
|
||||
sourcePosition,
|
||||
sourceDimensions.width(),
|
||||
sourceDimensions.height(),
|
||||
sourceDimensions.depth(),
|
||||
"connector " + index,
|
||||
pieceKey);
|
||||
if (!sourcePositions.add(sourcePosition)) {
|
||||
throw new IOException("Planar piece '" + pieceKey
|
||||
+ "' has multiple connectors at " + sourcePosition.describe());
|
||||
}
|
||||
LocalPosition sourceCanonical = toCanonical(
|
||||
sourcePosition,
|
||||
sourceDimensions.width(),
|
||||
sourceDimensions.depth(),
|
||||
quarterTurns);
|
||||
IrisDirection canonicalDirection = rotateHorizontalDirection(
|
||||
connector.getDirection(),
|
||||
quarterTurns);
|
||||
LocalPosition expectedSource = LocalPosition.from(IrisJigsawConnector.canonicalPlanarPosition(
|
||||
sourceSize,
|
||||
connector.getDirection()));
|
||||
LocalPosition expectedCanonical = LocalPosition.from(IrisJigsawConnector.canonicalPlanarPosition(
|
||||
canonicalSize,
|
||||
canonicalDirection));
|
||||
boolean canonicalConnector = sourcePosition.equals(expectedSource)
|
||||
|| sourceCanonical.equals(expectedCanonical);
|
||||
LocalPosition targetCanonical = canonicalConnector
|
||||
? LocalPosition.from(IrisJigsawConnector.canonicalPlanarPosition(
|
||||
targetSize,
|
||||
canonicalDirection))
|
||||
: sourceCanonical;
|
||||
if (!inside(targetCanonical, targetDimensions)) {
|
||||
throw new IOException("Planar piece '" + pieceKey + "' connector " + index + " at "
|
||||
+ sourceCanonical.describe() + " would be cropped by the requested workcell bounds");
|
||||
}
|
||||
if (!targetPositions.add(targetCanonical)) {
|
||||
throw new IOException("Planar piece '" + pieceKey
|
||||
+ "' would place multiple connectors at " + targetCanonical.describe());
|
||||
}
|
||||
planned.add(new ConnectorResize(connector, sourceCanonical, targetCanonical));
|
||||
}
|
||||
return List.copyOf(planned);
|
||||
}
|
||||
|
||||
private static void relocateConnectorPayloads(
|
||||
Map<LocalPosition, PlatformBlockState> blocks,
|
||||
Map<LocalPosition, TileData> tiles,
|
||||
List<ConnectorResize> connectorResizes,
|
||||
String pieceKey
|
||||
) throws IOException {
|
||||
Set<LocalPosition> relocatedSources = new LinkedHashSet<>();
|
||||
for (ConnectorResize connectorResize : connectorResizes) {
|
||||
if (tiles.containsKey(connectorResize.sourceCanonical())
|
||||
|| tiles.containsKey(connectorResize.targetCanonical())) {
|
||||
throw new IOException("Planar piece '" + pieceKey + "' has tile data at connector position "
|
||||
+ connectorResize.sourceCanonical().describe()
|
||||
+ "; connector tile data cannot be resized safely");
|
||||
}
|
||||
if (!connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) {
|
||||
relocatedSources.add(connectorResize.sourceCanonical());
|
||||
}
|
||||
}
|
||||
for (ConnectorResize connectorResize : connectorResizes) {
|
||||
if (connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) {
|
||||
continue;
|
||||
}
|
||||
if (blocks.containsKey(connectorResize.targetCanonical())
|
||||
&& !relocatedSources.contains(connectorResize.targetCanonical())) {
|
||||
throw new IOException("Planar piece '" + pieceKey + "' cannot relocate connector from "
|
||||
+ connectorResize.sourceCanonical().describe() + " to "
|
||||
+ connectorResize.targetCanonical().describe()
|
||||
+ " because the destination contains a stored block");
|
||||
}
|
||||
}
|
||||
List<BlockRelocation> payloads = new ArrayList<>(relocatedSources.size());
|
||||
for (ConnectorResize connectorResize : connectorResizes) {
|
||||
if (connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) {
|
||||
continue;
|
||||
}
|
||||
boolean present = blocks.containsKey(connectorResize.sourceCanonical());
|
||||
PlatformBlockState state = blocks.remove(connectorResize.sourceCanonical());
|
||||
payloads.add(new BlockRelocation(connectorResize.targetCanonical(), state, present));
|
||||
}
|
||||
for (BlockRelocation payload : payloads) {
|
||||
if (!payload.present()) {
|
||||
continue;
|
||||
}
|
||||
if (blocks.putIfAbsent(payload.target(), payload.state()) != null) {
|
||||
throw new IOException("Planar piece '" + pieceKey
|
||||
+ "' has colliding connector block payloads at " + payload.target().describe());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireContentInsideTarget(
|
||||
Map<LocalPosition, PlatformBlockState> blocks,
|
||||
Map<LocalPosition, TileData> tiles,
|
||||
JigsawStudioCellDimensions dimensions,
|
||||
String pieceKey
|
||||
) throws IOException {
|
||||
for (LocalPosition position : blocks.keySet()) {
|
||||
if (!inside(position, dimensions)) {
|
||||
throw new IOException("Planar piece '" + pieceKey + "' has a stored block, including explicit air, at "
|
||||
+ position.describe() + " that would be cropped by the requested workcell bounds");
|
||||
}
|
||||
}
|
||||
for (LocalPosition position : tiles.keySet()) {
|
||||
if (!inside(position, dimensions)) {
|
||||
throw new IOException("Planar piece '" + pieceKey + "' has tile data at "
|
||||
+ position.describe() + " that would be cropped by the requested workcell bounds");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisObject rebuildSourceObject(
|
||||
Map<LocalPosition, PlatformBlockState> canonicalBlocks,
|
||||
Map<LocalPosition, TileData> canonicalTiles,
|
||||
JigsawStudioCellDimensions sourceDimensions,
|
||||
int quarterTurns,
|
||||
String pieceKey
|
||||
) throws IOException {
|
||||
IrisObject resized = new IrisObject(
|
||||
sourceDimensions.width(),
|
||||
sourceDimensions.height(),
|
||||
sourceDimensions.depth());
|
||||
Set<LocalPosition> sourcePositions = new LinkedHashSet<>();
|
||||
for (Map.Entry<LocalPosition, PlatformBlockState> entry : canonicalBlocks.entrySet()) {
|
||||
LocalPosition sourcePosition = toSource(entry.getKey(), sourceDimensions, quarterTurns);
|
||||
if (!sourcePositions.add(sourcePosition)) {
|
||||
throw new IOException("Planar piece '" + pieceKey
|
||||
+ "' maps multiple stored blocks to " + sourcePosition.describe());
|
||||
}
|
||||
resized.setUnsigned(
|
||||
sourcePosition.x(),
|
||||
sourcePosition.y(),
|
||||
sourcePosition.z(),
|
||||
entry.getValue());
|
||||
}
|
||||
sourcePositions.clear();
|
||||
for (Map.Entry<LocalPosition, TileData> entry : canonicalTiles.entrySet()) {
|
||||
LocalPosition sourcePosition = toSource(entry.getKey(), sourceDimensions, quarterTurns);
|
||||
if (!sourcePositions.add(sourcePosition)) {
|
||||
throw new IOException("Planar piece '" + pieceKey
|
||||
+ "' maps multiple tile payloads to " + sourcePosition.describe());
|
||||
}
|
||||
resized.setUnsignedTile(
|
||||
sourcePosition.x(),
|
||||
sourcePosition.y(),
|
||||
sourcePosition.z(),
|
||||
entry.getValue().clone());
|
||||
}
|
||||
return resized;
|
||||
}
|
||||
|
||||
private static LocalPosition unsignedPosition(IrisBlockVector signed, IrisObject object) {
|
||||
return new LocalPosition(
|
||||
signed.getBlockX() + object.getCenter().getBlockX(),
|
||||
signed.getBlockY() + object.getCenter().getBlockY(),
|
||||
signed.getBlockZ() + object.getCenter().getBlockZ());
|
||||
}
|
||||
|
||||
static JigsawStudioCellDimensions canonicalDimensions(
|
||||
JigsawStudioCellDimensions source,
|
||||
int quarterTurns
|
||||
) {
|
||||
return Math.floorMod(quarterTurns, 2) == 0
|
||||
? source
|
||||
: new JigsawStudioCellDimensions(source.depth(), source.height(), source.width());
|
||||
}
|
||||
|
||||
private static JigsawStudioCellDimensions sourceDimensions(
|
||||
JigsawStudioCellDimensions canonical,
|
||||
int quarterTurns
|
||||
) {
|
||||
return Math.floorMod(quarterTurns, 2) == 0
|
||||
? canonical
|
||||
: new JigsawStudioCellDimensions(canonical.depth(), canonical.height(), canonical.width());
|
||||
}
|
||||
|
||||
private static LocalPosition toCanonical(
|
||||
LocalPosition source,
|
||||
int sourceWidth,
|
||||
int sourceDepth,
|
||||
int quarterTurns
|
||||
) {
|
||||
return switch (Math.floorMod(quarterTurns, 4)) {
|
||||
case 0 -> source;
|
||||
case 1 -> new LocalPosition(sourceDepth - 1 - source.z(), source.y(), source.x());
|
||||
case 2 -> new LocalPosition(
|
||||
sourceWidth - 1 - source.x(),
|
||||
source.y(),
|
||||
sourceDepth - 1 - source.z());
|
||||
case 3 -> new LocalPosition(source.z(), source.y(), sourceWidth - 1 - source.x());
|
||||
default -> throw new IllegalStateException("Unreachable planar object rotation");
|
||||
};
|
||||
}
|
||||
|
||||
private static LocalPosition toSource(
|
||||
LocalPosition canonical,
|
||||
JigsawStudioCellDimensions sourceDimensions,
|
||||
int quarterTurns
|
||||
) {
|
||||
return switch (Math.floorMod(quarterTurns, 4)) {
|
||||
case 0 -> canonical;
|
||||
case 1 -> new LocalPosition(
|
||||
canonical.z(),
|
||||
canonical.y(),
|
||||
sourceDimensions.depth() - 1 - canonical.x());
|
||||
case 2 -> new LocalPosition(
|
||||
sourceDimensions.width() - 1 - canonical.x(),
|
||||
canonical.y(),
|
||||
sourceDimensions.depth() - 1 - canonical.z());
|
||||
case 3 -> new LocalPosition(
|
||||
sourceDimensions.width() - 1 - canonical.z(),
|
||||
canonical.y(),
|
||||
canonical.x());
|
||||
default -> throw new IllegalStateException("Unreachable planar object rotation");
|
||||
};
|
||||
}
|
||||
|
||||
private static IrisDirection rotateHorizontalDirection(IrisDirection direction, int quarterTurns) {
|
||||
JigsawPlanarDirection planarDirection = switch (direction) {
|
||||
case NORTH_NEGATIVE_Z -> JigsawPlanarDirection.NORTH;
|
||||
case EAST_POSITIVE_X -> JigsawPlanarDirection.EAST;
|
||||
case SOUTH_POSITIVE_Z -> JigsawPlanarDirection.SOUTH;
|
||||
case WEST_NEGATIVE_X -> JigsawPlanarDirection.WEST;
|
||||
case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> throw new IllegalArgumentException(
|
||||
"Planar connector direction must be horizontal");
|
||||
};
|
||||
return planarDirection.rotateClockwise(quarterTurns).irisDirection();
|
||||
}
|
||||
|
||||
static IrisPosition dimensionsPosition(JigsawStudioCellDimensions dimensions) {
|
||||
return new IrisPosition(dimensions.width(), dimensions.height(), dimensions.depth());
|
||||
}
|
||||
|
||||
private static boolean inside(LocalPosition position, JigsawStudioCellDimensions dimensions) {
|
||||
return inside(position, dimensions.width(), dimensions.height(), dimensions.depth());
|
||||
}
|
||||
|
||||
private static boolean inside(LocalPosition position, int width, int height, int depth) {
|
||||
return position.x() >= 0 && position.x() < width
|
||||
&& position.y() >= 0 && position.y() < height
|
||||
&& position.z() >= 0 && position.z() < depth;
|
||||
}
|
||||
|
||||
private static void requireInside(
|
||||
LocalPosition position,
|
||||
int width,
|
||||
int height,
|
||||
int depth,
|
||||
String content,
|
||||
String pieceKey
|
||||
) throws IOException {
|
||||
if (!inside(position, width, height, depth)) {
|
||||
throw new IOException("Planar piece '" + pieceKey + "' has " + content + " at "
|
||||
+ position.describe() + " outside its object bounds");
|
||||
}
|
||||
}
|
||||
|
||||
static IrisObject resizeObject(
|
||||
IrisObject source,
|
||||
JigsawStudioCellDimensions dimensions,
|
||||
String pieceKey
|
||||
) throws IOException {
|
||||
IrisObject object = Objects.requireNonNull(source, "Jigsaw Studio source object");
|
||||
JigsawStudioCellDimensions target = Objects.requireNonNull(
|
||||
dimensions,
|
||||
"Jigsaw Studio target object dimensions");
|
||||
String normalizedPiece = pieceKey == null || pieceKey.isBlank() ? "unknown" : pieceKey;
|
||||
IrisObject resized = new IrisObject(target.width(), target.height(), target.depth());
|
||||
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : object.getBlocks()) {
|
||||
IrisBlockVector position = entry.getKey();
|
||||
LocalPosition unsigned = unsignedPosition(position, object);
|
||||
if (!inside(unsigned, target)) {
|
||||
throw new IOException("Spatial piece '" + normalizedPiece
|
||||
+ "' has a stored block, including explicit air, at " + unsigned.describe()
|
||||
+ " that would be cropped by the requested variant size");
|
||||
}
|
||||
resized.setUnsigned(
|
||||
unsigned.x(),
|
||||
unsigned.y(),
|
||||
unsigned.z(),
|
||||
entry.getValue());
|
||||
}
|
||||
for (Map.Entry<IrisBlockVector, TileData> entry : object.getStates()) {
|
||||
IrisBlockVector position = entry.getKey();
|
||||
LocalPosition unsigned = unsignedPosition(position, object);
|
||||
if (!inside(unsigned, target)) {
|
||||
throw new IOException("Spatial piece '" + normalizedPiece + "' has tile data at "
|
||||
+ unsigned.describe() + " that would be cropped by the requested variant size");
|
||||
}
|
||||
resized.setUnsignedTile(
|
||||
unsigned.x(),
|
||||
unsigned.y(),
|
||||
unsigned.z(),
|
||||
entry.getValue().clone());
|
||||
}
|
||||
return resized;
|
||||
}
|
||||
|
||||
static void requireConnectorsInside(
|
||||
IrisJigsawPiece piece,
|
||||
JigsawStudioCellDimensions dimensions,
|
||||
String pieceKey
|
||||
) throws IOException {
|
||||
if (piece.getConnectors() == null) {
|
||||
throw new IOException("Spatial piece '" + pieceKey + "' has no connector list");
|
||||
}
|
||||
for (int index = 0; index < piece.getConnectors().size(); index++) {
|
||||
IrisJigsawConnector connector = piece.getConnectors().get(index);
|
||||
if (connector == null || connector.getPosition() == null) {
|
||||
throw new IOException("Spatial piece '" + pieceKey + "' contains invalid connector " + index);
|
||||
}
|
||||
LocalPosition position = LocalPosition.from(connector.getPosition());
|
||||
if (!inside(position, dimensions)) {
|
||||
throw new IOException("Spatial piece '" + pieceKey + "' connector " + index + " at "
|
||||
+ position.describe() + " would be cropped by the requested variant size");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String describeDimensions(JigsawStudioCellDimensions dimensions) {
|
||||
return dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth();
|
||||
}
|
||||
|
||||
record PlanarPieceObjectResize(IrisObject object, int relocatedConnectors) {
|
||||
PlanarPieceObjectResize {
|
||||
Objects.requireNonNull(object, "Resized planar Jigsaw Studio object");
|
||||
if (relocatedConnectors < 0) {
|
||||
throw new IllegalArgumentException("Relocated connector count cannot be negative");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record ConnectorResize(
|
||||
IrisJigsawConnector connector,
|
||||
LocalPosition sourceCanonical,
|
||||
LocalPosition targetCanonical
|
||||
) {
|
||||
private ConnectorResize {
|
||||
Objects.requireNonNull(connector, "Planar Jigsaw Studio connector");
|
||||
Objects.requireNonNull(sourceCanonical, "Planar Jigsaw Studio source connector position");
|
||||
Objects.requireNonNull(targetCanonical, "Planar Jigsaw Studio target connector position");
|
||||
}
|
||||
}
|
||||
|
||||
private record BlockRelocation(LocalPosition target, PlatformBlockState state, boolean present) {
|
||||
private BlockRelocation {
|
||||
Objects.requireNonNull(target, "Planar Jigsaw Studio connector block target");
|
||||
if (present) {
|
||||
Objects.requireNonNull(state, "Planar Jigsaw Studio connector block state");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record LocalPosition(int x, int y, int z) {
|
||||
private static LocalPosition from(IrisPosition position) {
|
||||
return new LocalPosition(position.getX(), position.getY(), position.getZ());
|
||||
}
|
||||
|
||||
private IrisPosition toIrisPosition() {
|
||||
return new IrisPosition(x, y, z);
|
||||
}
|
||||
|
||||
private String describe() {
|
||||
return x + "," + y + "," + z;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package art.arcane.iris.core.safeguard;
|
||||
|
||||
import art.arcane.iris.core.IrisStartupValidation;
|
||||
import art.arcane.iris.core.safeguard.task.CheckResult;
|
||||
import art.arcane.iris.core.safeguard.task.Diagnostic;
|
||||
import art.arcane.iris.core.safeguard.task.Task;
|
||||
import art.arcane.iris.core.safeguard.task.Tasks;
|
||||
import art.arcane.iris.core.safeguard.task.ValueWithDiagnostics;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
|
||||
@@ -15,7 +15,10 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class IrisSafeguard {
|
||||
private static Map<Task, ValueWithDiagnostics<Mode>> results = Collections.emptyMap();
|
||||
private static final String GENERIC_LOCK_REASON =
|
||||
"An Iris startup check failed. Resolve the startup errors and restart the server.";
|
||||
|
||||
private static Map<Task, CheckResult> results = Collections.emptyMap();
|
||||
private static Map<String, String> context = Collections.emptyMap();
|
||||
private static Map<String, List<String>> attachment = Collections.emptyMap();
|
||||
private static Mode mode = Mode.STABLE;
|
||||
@@ -27,40 +30,40 @@ public final class IrisSafeguard {
|
||||
public static void execute() {
|
||||
IrisStartupValidation.beginRuntimeValidation();
|
||||
List<Task> tasks = Tasks.getTasks();
|
||||
LinkedHashMap<Task, ValueWithDiagnostics<Mode>> resultValues = new LinkedHashMap<>(tasks.size());
|
||||
LinkedHashMap<Task, CheckResult> resultValues = new LinkedHashMap<>(tasks.size());
|
||||
LinkedHashMap<String, String> contextValues = new LinkedHashMap<>(tasks.size());
|
||||
LinkedHashMap<String, List<String>> attachmentValues = new LinkedHashMap<>(tasks.size());
|
||||
Mode currentMode = Mode.STABLE;
|
||||
String lockReason = null;
|
||||
int issueCount = 0;
|
||||
|
||||
for (Task task : tasks) {
|
||||
ValueWithDiagnostics<Mode> result;
|
||||
CheckResult result;
|
||||
try {
|
||||
result = task.run();
|
||||
} catch (Throwable e) {
|
||||
boolean injectionFailure = "injection".equals(task.getId());
|
||||
if (injectionFailure) {
|
||||
IrisStartupValidation.markRuntimeInvalid("Iris runtime injection failed. Resolve the startup errors and restart the server.");
|
||||
}
|
||||
IrisLogging.reportError(e);
|
||||
result = new ValueWithDiagnostics<>(
|
||||
injectionFailure ? Mode.UNSTABLE : Mode.WARNING,
|
||||
new Diagnostic(Diagnostic.Logger.ERROR, "Error while running task " + task.getId(), e)
|
||||
);
|
||||
IrisLogging.reportError("Iris startup check \"" + task.getId() + "\" failed to run.", e);
|
||||
Diagnostic diagnostic = new Diagnostic(Diagnostic.Logger.ERROR,
|
||||
"Error while running task " + task.getId(), e);
|
||||
result = task.failureMode() == Mode.UNSTABLE
|
||||
? CheckResult.danger(failureLockReason(task), diagnostic)
|
||||
: CheckResult.warning(diagnostic);
|
||||
}
|
||||
|
||||
currentMode = currentMode.highest(result.getValue());
|
||||
currentMode = currentMode.highest(result.mode());
|
||||
if (result.mode() == Mode.UNSTABLE && lockReason == null) {
|
||||
lockReason = result.lockReason();
|
||||
}
|
||||
resultValues.put(task, result);
|
||||
contextValues.put(task.getId(), result.getValue().getId());
|
||||
contextValues.put(task.getId(), result.mode().getId());
|
||||
|
||||
List<String> lines = new ArrayList<>();
|
||||
for (Diagnostic diagnostic : result.getDiagnostics()) {
|
||||
String[] split = diagnostic.toString().split("\\n");
|
||||
Collections.addAll(lines, split);
|
||||
for (Diagnostic diagnostic : result.diagnostics()) {
|
||||
Collections.addAll(lines, diagnostic.toString().split("\\n"));
|
||||
}
|
||||
attachmentValues.put(task.getId(), lines);
|
||||
|
||||
if (result.getValue() != Mode.STABLE) {
|
||||
if (result.mode() != Mode.STABLE) {
|
||||
issueCount++;
|
||||
}
|
||||
}
|
||||
@@ -70,6 +73,13 @@ public final class IrisSafeguard {
|
||||
attachment = Collections.unmodifiableMap(attachmentValues);
|
||||
mode = currentMode;
|
||||
count = issueCount;
|
||||
|
||||
if (currentMode == Mode.UNSTABLE) {
|
||||
IrisStartupValidation.markRuntimeInvalid(lockReason == null ? GENERIC_LOCK_REASON : lockReason);
|
||||
return;
|
||||
}
|
||||
|
||||
IrisStartupValidation.markRuntimeReady();
|
||||
}
|
||||
|
||||
public static Mode mode() {
|
||||
@@ -84,6 +94,19 @@ public final class IrisSafeguard {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
public static String debugReport() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("Startup safeguard: ").append(mode.getId())
|
||||
.append(" (").append(count).append(" issues)");
|
||||
for (Map.Entry<String, String> entry : context.entrySet()) {
|
||||
builder.append('\n').append(" ").append(entry.getKey()).append(": ").append(entry.getValue());
|
||||
for (String line : attachment.getOrDefault(entry.getKey(), List.of())) {
|
||||
builder.append('\n').append(" ").append(line);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public static void printReports() {
|
||||
switch (mode) {
|
||||
case STABLE -> IrisLogging.info(C.BLUE + "0 Conflicts found");
|
||||
@@ -91,7 +114,7 @@ public final class IrisSafeguard {
|
||||
case UNSTABLE -> IrisLogging.error(C.DARK_RED + "%s Issues found", count);
|
||||
}
|
||||
|
||||
for (ValueWithDiagnostics<Mode> value : results.values()) {
|
||||
for (CheckResult value : results.values()) {
|
||||
// Without the stack trace: Diagnostic.Logger splits on newlines, so a trace became one log
|
||||
// record per frame at the diagnostic's own severity. Traces go through reportError.
|
||||
value.log(true, false);
|
||||
@@ -106,6 +129,11 @@ public final class IrisSafeguard {
|
||||
}
|
||||
}
|
||||
|
||||
private static String failureLockReason(Task task) {
|
||||
String declared = task.failureLockReason();
|
||||
return declared == null || declared.isBlank() ? GENERIC_LOCK_REASON : declared;
|
||||
}
|
||||
|
||||
// A log record carries a level, so a blank record renders as an empty [WARN] line and a rule of
|
||||
// dashes renders as a [SEVERE] one. Spacing belongs to a console, not to the server log.
|
||||
private static void warning() {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package art.arcane.iris.core.safeguard;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class RuntimeLockNotice {
|
||||
private RuntimeLockNotice() {
|
||||
}
|
||||
|
||||
public static List<String> compose(String denialReason, boolean hasManagedWorldStorage) {
|
||||
if (denialReason == null || denialReason.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
return List.of(
|
||||
"Iris enabled with a locked runtime: " + denialReason.trim(),
|
||||
hasManagedWorldStorage
|
||||
? "Every configured Iris world is generation-locked and refuses to generate terrain"
|
||||
+ " until this is resolved and the server restarts."
|
||||
: "This server has no Iris world storage, so nothing is generation-locked;"
|
||||
+ " world creation and player login stay refused until this is resolved.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package art.arcane.iris.core.safeguard.task;
|
||||
|
||||
import art.arcane.iris.core.safeguard.Mode;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record CheckResult(Mode mode, String lockReason, List<Diagnostic> diagnostics) {
|
||||
public CheckResult {
|
||||
if (mode == null) {
|
||||
throw new IllegalArgumentException("A startup check result needs a mode");
|
||||
}
|
||||
if (mode == Mode.UNSTABLE && (lockReason == null || lockReason.isBlank())) {
|
||||
throw new IllegalArgumentException("A Danger startup check result needs a lock reason");
|
||||
}
|
||||
diagnostics = List.copyOf(diagnostics);
|
||||
}
|
||||
|
||||
public static CheckResult stable(Diagnostic... diagnostics) {
|
||||
return new CheckResult(Mode.STABLE, null, List.of(diagnostics));
|
||||
}
|
||||
|
||||
public static CheckResult warning(Diagnostic... diagnostics) {
|
||||
return new CheckResult(Mode.WARNING, null, List.of(diagnostics));
|
||||
}
|
||||
|
||||
public static CheckResult warning(List<Diagnostic> diagnostics) {
|
||||
return new CheckResult(Mode.WARNING, null, diagnostics);
|
||||
}
|
||||
|
||||
public static CheckResult danger(String lockReason, Diagnostic... diagnostics) {
|
||||
return new CheckResult(Mode.UNSTABLE, lockReason, List.of(diagnostics));
|
||||
}
|
||||
|
||||
public void log(boolean withException, boolean withStackTrace) {
|
||||
for (Diagnostic diagnostic : diagnostics) {
|
||||
diagnostic.log(withException, withStackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,47 @@
|
||||
package art.arcane.iris.core.safeguard.task;
|
||||
|
||||
import art.arcane.iris.core.safeguard.Mode;
|
||||
import art.arcane.volmlib.util.format.Form;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public abstract class Task {
|
||||
private final String id;
|
||||
private final String name;
|
||||
private final Mode failureMode;
|
||||
private final String failureLockReason;
|
||||
|
||||
public Task(String id) {
|
||||
this(id, Form.capitalizeWords(id.replace(" ", "_").toLowerCase(Locale.ROOT)));
|
||||
}
|
||||
|
||||
public Task(String id, String name) {
|
||||
protected Task(String id, Mode failureMode, String failureLockReason) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.failureMode = failureMode;
|
||||
this.failureLockReason = failureLockReason;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
public Mode failureMode() {
|
||||
return failureMode;
|
||||
}
|
||||
|
||||
public abstract ValueWithDiagnostics<Mode> run();
|
||||
public String failureLockReason() {
|
||||
return failureLockReason;
|
||||
}
|
||||
|
||||
public static Task of(String id, String name, Supplier<ValueWithDiagnostics<Mode>> action) {
|
||||
return new Task(id, name) {
|
||||
public abstract CheckResult run();
|
||||
|
||||
public static Task advisory(String id, Supplier<CheckResult> action) {
|
||||
return new Task(id, Mode.WARNING, null) {
|
||||
@Override
|
||||
public ValueWithDiagnostics<Mode> run() {
|
||||
public CheckResult run() {
|
||||
return action.get();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static Task of(String id, Supplier<ValueWithDiagnostics<Mode>> action) {
|
||||
return new Task(id) {
|
||||
public static Task critical(String id, String throwLockReason, Supplier<CheckResult> action) {
|
||||
return new Task(id, Mode.UNSTABLE, throwLockReason) {
|
||||
@Override
|
||||
public ValueWithDiagnostics<Mode> run() {
|
||||
public CheckResult run() {
|
||||
return action.get();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,54 +1,65 @@
|
||||
package art.arcane.iris.core.safeguard.task;
|
||||
|
||||
import art.arcane.iris.BuildConstants;
|
||||
import art.arcane.iris.core.IrisStartupValidation;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.IrisWorlds;
|
||||
import art.arcane.iris.core.runtime.RuntimeInjection;
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.v1X.NMSBinding1X;
|
||||
import art.arcane.iris.core.safeguard.Mode;
|
||||
import art.arcane.iris.core.splash.IrisSplashComposer;
|
||||
import art.arcane.iris.spi.CapabilityProbe;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.util.common.misc.getHardware;
|
||||
import art.arcane.iris.util.project.agent.Agent;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class Tasks {
|
||||
private static final Task MEMORY = Task.of("memory", () -> {
|
||||
static final String NMS_LOCK_REASON =
|
||||
"Iris cannot use this server's NMS runtime. Resolve the Server Version or NMS Disabled error above and restart the server.";
|
||||
static final String INJECTION_LOCK_REASON =
|
||||
"Iris runtime injection failed. Resolve the startup errors and restart the server.";
|
||||
static final String DIMENSION_TYPE_LOCK_REASON =
|
||||
"Iris dimension types were not registered. Restart the server so the registries reload.";
|
||||
|
||||
private static final Set<String> INCOMPATIBLE_PLUGINS = Set.of("dynmap", "stratos");
|
||||
|
||||
private static final Task MEMORY = Task.advisory("memory", () -> {
|
||||
long mem = getHardware.getProcessMemory();
|
||||
if (mem >= 3072L) {
|
||||
return withDiagnostics(Mode.STABLE);
|
||||
return CheckResult.stable();
|
||||
}
|
||||
|
||||
if (mem > 2048L) {
|
||||
return withDiagnostics(Mode.STABLE,
|
||||
return CheckResult.stable(
|
||||
Diagnostic.Logger.INFO.create("Memory Recommendation"),
|
||||
Diagnostic.Logger.INFO.create("- 3GB+ process memory is recommended for Iris."),
|
||||
Diagnostic.Logger.INFO.create("- Process Memory: " + mem + " MB"));
|
||||
Diagnostic.Logger.INFO.create("- A 3GB+ JVM maximum heap is recommended for Iris."),
|
||||
Diagnostic.Logger.INFO.create("- JVM maximum heap: " + mem + " MB"));
|
||||
}
|
||||
|
||||
return withDiagnostics(Mode.WARNING,
|
||||
return CheckResult.warning(
|
||||
Diagnostic.Logger.WARN.create("Low Memory"),
|
||||
Diagnostic.Logger.WARN.create("- Iris is running with 2GB or less process memory."),
|
||||
Diagnostic.Logger.WARN.create("- 3GB+ process memory is recommended for Iris."),
|
||||
Diagnostic.Logger.WARN.create("- Process Memory: " + mem + " MB"));
|
||||
Diagnostic.Logger.WARN.create("- Iris is running with a 2GB or smaller JVM maximum heap."),
|
||||
Diagnostic.Logger.WARN.create("- A 3GB+ JVM maximum heap is recommended for Iris."),
|
||||
Diagnostic.Logger.WARN.create("- JVM maximum heap: " + mem + " MB"));
|
||||
});
|
||||
|
||||
private static final Task INCOMPATIBILITIES = Task.of("incompatibilities", () -> {
|
||||
Set<String> plugins = new HashSet<>(Set.of("dynmap", "Stratos"));
|
||||
plugins.removeIf(name -> server().getPluginManager().getPlugin(name) == null);
|
||||
private static final Task INCOMPATIBILITIES = Task.advisory("incompatibilities", () -> {
|
||||
Set<String> plugins = installedIncompatiblePlugins();
|
||||
|
||||
if (plugins.isEmpty()) {
|
||||
return withDiagnostics(Mode.STABLE);
|
||||
return CheckResult.stable();
|
||||
}
|
||||
|
||||
List<Diagnostic> diagnostics = new ArrayList<>();
|
||||
@@ -58,15 +69,15 @@ public final class Tasks {
|
||||
Diagnostic.Logger.ERROR.create("- The plugin Dynmap is not compatible with the server."),
|
||||
Diagnostic.Logger.ERROR.create("- If you want to have a map plugin like Dynmap, consider Bluemap."));
|
||||
}
|
||||
if (plugins.contains("Stratos")) {
|
||||
if (plugins.contains("stratos")) {
|
||||
addAllDiagnostics(diagnostics,
|
||||
Diagnostic.Logger.ERROR.create("Stratos"),
|
||||
Diagnostic.Logger.ERROR.create("- Iris is not compatible with other worldgen plugins."));
|
||||
}
|
||||
return withDiagnostics(Mode.WARNING, diagnostics);
|
||||
return CheckResult.warning(diagnostics);
|
||||
});
|
||||
|
||||
private static final Task SOFTWARE = Task.of("software", () -> {
|
||||
private static final Task SOFTWARE = Task.advisory("software", () -> {
|
||||
Set<String> supported = Set.of("canvas", "folia", "purpur", "pufferfish", "leaf", "paper", "spigot", "bukkit");
|
||||
String serverName = server().getName().toLowerCase(Locale.ROOT);
|
||||
boolean supportedServer = isCanvasServer();
|
||||
@@ -80,15 +91,15 @@ public final class Tasks {
|
||||
}
|
||||
|
||||
if (supportedServer) {
|
||||
return withDiagnostics(Mode.STABLE);
|
||||
return CheckResult.stable();
|
||||
}
|
||||
|
||||
return withDiagnostics(Mode.WARNING,
|
||||
return CheckResult.warning(
|
||||
Diagnostic.Logger.WARN.create("Unsupported Server Software"),
|
||||
Diagnostic.Logger.WARN.create("- Please consider using Canvas, Folia, Leaf, Paper, or Purpur instead."));
|
||||
});
|
||||
|
||||
private static final Task VERSION = Task.of("version", () -> {
|
||||
private static final Task VERSION = Task.critical("version", NMS_LOCK_REASON, () -> {
|
||||
String[] parts = BukkitPlatform.plugin().getDescription().getVersion().split("-");
|
||||
String supportedVersions;
|
||||
if (parts.length >= 3) {
|
||||
@@ -103,84 +114,95 @@ public final class Tasks {
|
||||
|
||||
if (!INMS.isBound()) {
|
||||
String cause = INMS.bindFailure() == null ? "Unknown NMS bind failure" : INMS.bindFailure().getMessage();
|
||||
return withDiagnostics(Mode.UNSTABLE,
|
||||
return CheckResult.danger(NMS_LOCK_REASON,
|
||||
Diagnostic.Logger.ERROR.create("Server Version"),
|
||||
Diagnostic.Logger.ERROR.create("- " + cause),
|
||||
Diagnostic.Logger.ERROR.create("- Iris only supports " + supportedVersions));
|
||||
}
|
||||
|
||||
if (!(INMS.get() instanceof NMSBinding1X)) {
|
||||
return withDiagnostics(Mode.STABLE);
|
||||
return CheckResult.stable();
|
||||
}
|
||||
|
||||
return withDiagnostics(Mode.UNSTABLE,
|
||||
return CheckResult.danger(NMS_LOCK_REASON,
|
||||
Diagnostic.Logger.ERROR.create("NMS Disabled"),
|
||||
Diagnostic.Logger.ERROR.create("- NMS support is disabled (general.disableNMS); Iris world creation is unavailable."));
|
||||
});
|
||||
|
||||
private static final Task INJECTION = Task.of("injection", () -> {
|
||||
if (!Agent.install()) {
|
||||
IrisStartupValidation.markRuntimeInvalid("Iris Java agent is unavailable. Add -javaagent:"
|
||||
+ Agent.AGENT_JAR.getPath() + " to the JVM arguments before -jar and restart the server.");
|
||||
return withDiagnostics(Mode.UNSTABLE,
|
||||
private static final Task INJECTION = Task.critical("injection", INJECTION_LOCK_REASON, () -> {
|
||||
if (!IrisSettings.get().getGeneral().isEagerRuntimeInjection()) {
|
||||
return CheckResult.stable(
|
||||
Diagnostic.Logger.INFO.create("Runtime Injection"),
|
||||
Diagnostic.Logger.INFO.create("- Deferred to the first world load. Set general.eagerRuntimeInjection to verify it during startup."));
|
||||
}
|
||||
|
||||
RuntimeInjection.Outcome outcome = RuntimeInjection.install();
|
||||
if (outcome.installed()) {
|
||||
return CheckResult.stable();
|
||||
}
|
||||
|
||||
return switch (outcome.failure()) {
|
||||
case NO_BINDING -> CheckResult.danger(outcome.lockReason(),
|
||||
Diagnostic.Logger.ERROR.create("Code Injection"),
|
||||
Diagnostic.Logger.ERROR.create("- Runtime injection was skipped because Iris has no usable NMS binding."));
|
||||
case AGENT -> CheckResult.danger(outcome.lockReason(),
|
||||
Diagnostic.Logger.ERROR.create("Java Agent"),
|
||||
Diagnostic.Logger.ERROR.create("- Add -javaagent:" + Agent.AGENT_JAR.getPath() + " before -jar in your startup command and restart the server."),
|
||||
Diagnostic.Logger.ERROR.create("- Dynamic attachment requires -XX:+EnableDynamicAgentLoading and a host that permits JVM attachment."));
|
||||
}
|
||||
|
||||
if (!INMS.get().injectBukkit()) {
|
||||
IrisStartupValidation.markRuntimeInvalid("Iris runtime injection failed. Resolve the startup errors and restart the server.");
|
||||
return withDiagnostics(Mode.UNSTABLE,
|
||||
case INJECTION, NONE -> CheckResult.danger(INJECTION_LOCK_REASON,
|
||||
Diagnostic.Logger.ERROR.create("Code Injection"),
|
||||
Diagnostic.Logger.ERROR.create("- Failed to inject code. Please contact support"));
|
||||
}
|
||||
|
||||
IrisStartupValidation.markRuntimeReady();
|
||||
return withDiagnostics(Mode.STABLE);
|
||||
};
|
||||
});
|
||||
|
||||
private static final Task DIMENSION_TYPES = Task.of("dimensionTypes", () -> {
|
||||
Set<String> keys = IrisWorlds.get().getDimensions().map(IrisDimension::getDimensionTypeKey).collect(Collectors.toSet());
|
||||
if (!INMS.get().missingDimensionTypes(keys.toArray(String[]::new))) {
|
||||
return withDiagnostics(Mode.STABLE);
|
||||
private static final Task DIMENSION_TYPES = Task.critical("dimensionTypes", DIMENSION_TYPE_LOCK_REASON, () -> {
|
||||
if (!INMS.isBound()) {
|
||||
return CheckResult.danger(NMS_LOCK_REASON,
|
||||
Diagnostic.Logger.ERROR.create("Dimension Types"),
|
||||
Diagnostic.Logger.ERROR.create("- Dimension types could not be checked because Iris has no NMS binding."));
|
||||
}
|
||||
|
||||
return withDiagnostics(Mode.UNSTABLE,
|
||||
Set<String> keys = IrisWorlds.get().getDimensions().map(IrisDimension::getDimensionTypeKey).collect(Collectors.toSet());
|
||||
if (!INMS.get().missingDimensionTypes(keys.toArray(String[]::new))) {
|
||||
return CheckResult.stable();
|
||||
}
|
||||
|
||||
return CheckResult.danger(DIMENSION_TYPE_LOCK_REASON,
|
||||
Diagnostic.Logger.ERROR.create("Dimension Types"),
|
||||
Diagnostic.Logger.ERROR.create("- Required Iris dimension types were not loaded."),
|
||||
Diagnostic.Logger.ERROR.create("- If this still happens after a restart please contact support."));
|
||||
});
|
||||
|
||||
private static final Task DISK_SPACE = Task.of("diskSpace", () -> {
|
||||
private static final Task DISK_SPACE = Task.advisory("diskSpace", () -> {
|
||||
double freeGiB = IrisWorldStorage.levelRoot().getFreeSpace() / (double) 0x4000_0000;
|
||||
if (freeGiB > 3.0) {
|
||||
return withDiagnostics(Mode.STABLE);
|
||||
return CheckResult.stable();
|
||||
}
|
||||
|
||||
return withDiagnostics(Mode.WARNING,
|
||||
return CheckResult.warning(
|
||||
Diagnostic.Logger.WARN.create("Insufficient Disk Space"),
|
||||
Diagnostic.Logger.WARN.create("- 3GB of free space is required for Iris to function."));
|
||||
});
|
||||
|
||||
private static final Task JAVA = Task.of("java", () -> {
|
||||
private static final Task JAVA = Task.advisory("java", () -> {
|
||||
int version = IrisSplashComposer.javaVersion();
|
||||
if (version < 0) {
|
||||
return withDiagnostics(Mode.WARNING,
|
||||
return CheckResult.warning(
|
||||
Diagnostic.Logger.WARN.create("Java Runtime"),
|
||||
Diagnostic.Logger.WARN.create("- Java version could not be determined (java.version="
|
||||
+ System.getProperty("java.version") + ")."));
|
||||
}
|
||||
if (version == 25) {
|
||||
return withDiagnostics(Mode.STABLE);
|
||||
return CheckResult.stable();
|
||||
}
|
||||
|
||||
if (version > 25) {
|
||||
return withDiagnostics(Mode.STABLE,
|
||||
return CheckResult.stable(
|
||||
Diagnostic.Logger.INFO.create("Java Runtime"),
|
||||
Diagnostic.Logger.INFO.create("- Running Java " + version + ". Iris is tested primarily on Java 25."));
|
||||
}
|
||||
|
||||
return withDiagnostics(Mode.WARNING,
|
||||
return CheckResult.warning(
|
||||
Diagnostic.Logger.WARN.create("Unsupported Java version"),
|
||||
Diagnostic.Logger.WARN.create("- Java 25+ is recommended. Current runtime: Java " + version));
|
||||
});
|
||||
@@ -207,14 +229,32 @@ public final class Tasks {
|
||||
return Bukkit.getServer();
|
||||
}
|
||||
|
||||
private static Set<String> installedIncompatiblePlugins() {
|
||||
Set<String> found = new LinkedHashSet<>();
|
||||
Plugin[] plugins = server().getPluginManager().getPlugins();
|
||||
if (plugins == null) {
|
||||
return found;
|
||||
}
|
||||
for (Plugin plugin : plugins) {
|
||||
String name = plugin == null ? null : plugin.getName();
|
||||
if (name == null) {
|
||||
continue;
|
||||
}
|
||||
String normalized = name.toLowerCase(Locale.ROOT);
|
||||
if (INCOMPATIBLE_PLUGINS.contains(normalized)) {
|
||||
found.add(normalized);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private static boolean isCanvasServer() {
|
||||
ClassLoader loader = server().getClass().getClassLoader();
|
||||
try {
|
||||
Class.forName("io.canvasmc.canvas.region.WorldRegionizer", false, loader);
|
||||
if (CapabilityProbe.succeeds("io.canvasmc.canvas.region.WorldRegionizer",
|
||||
() -> Class.forName("io.canvasmc.canvas.region.WorldRegionizer", false, loader))) {
|
||||
return true;
|
||||
} catch (Throwable ignored) {
|
||||
return server().getName().toLowerCase(Locale.ROOT).contains("canvas");
|
||||
}
|
||||
return server().getName().toLowerCase(Locale.ROOT).contains("canvas");
|
||||
}
|
||||
|
||||
private static void addAllDiagnostics(List<Diagnostic> diagnostics, Diagnostic... values) {
|
||||
@@ -222,13 +262,4 @@ public final class Tasks {
|
||||
diagnostics.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
private static ValueWithDiagnostics<Mode> withDiagnostics(Mode mode, Diagnostic... diagnostics) {
|
||||
return new ValueWithDiagnostics<>(mode, diagnostics);
|
||||
}
|
||||
|
||||
private static ValueWithDiagnostics<Mode> withDiagnostics(Mode mode, List<Diagnostic> diagnostics) {
|
||||
return new ValueWithDiagnostics<>(mode, diagnostics);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package art.arcane.iris.core.safeguard.task;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class ValueWithDiagnostics<T> {
|
||||
private final T value;
|
||||
private final List<Diagnostic> diagnostics;
|
||||
|
||||
public ValueWithDiagnostics(T value, List<Diagnostic> diagnostics) {
|
||||
this.value = value;
|
||||
this.diagnostics = List.copyOf(diagnostics);
|
||||
}
|
||||
|
||||
public ValueWithDiagnostics(T value, Diagnostic... diagnostics) {
|
||||
this.value = value;
|
||||
this.diagnostics = List.of(diagnostics);
|
||||
}
|
||||
|
||||
public T getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public List<Diagnostic> getDiagnostics() {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
public void log() {
|
||||
log(true, false);
|
||||
}
|
||||
|
||||
public void log(boolean withException) {
|
||||
log(withException, false);
|
||||
}
|
||||
|
||||
public void log(boolean withException, boolean withStackTrace) {
|
||||
for (Diagnostic diagnostic : diagnostics) {
|
||||
diagnostic.log(withException, withStackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,9 @@ public class GlobalCacheSVC implements IrisService {
|
||||
activeTrimmer.interrupt();
|
||||
try {
|
||||
activeTrimmer.join(TRIMMER_JOIN_MS);
|
||||
} catch (InterruptedException ignored) {
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
IrisLogging.warn("Interrupted while waiting for the global cache trimmer to stop.");
|
||||
}
|
||||
if (activeTrimmer.isAlive()) {
|
||||
IrisLogging.warn("Global cache trimmer did not stop within " + TRIMMER_JOIN_MS + "ms.");
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
|
||||
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.core.runtime.jigsaw.JigsawStudioSession;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
|
||||
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkCaptureArea;
|
||||
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkSnapshot;
|
||||
import art.arcane.iris.core.service.JigsawStudioCaptureScheduler.CaptureCoordinator;
|
||||
import art.arcane.iris.core.service.JigsawStudioCaptureScheduler.CaptureTarget;
|
||||
import art.arcane.iris.core.service.JigsawStudioCaptureScheduler.CaptureWork;
|
||||
import art.arcane.iris.core.service.JigsawStudioMaterializer.VariantReloadRequest;
|
||||
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.BayReadiness;
|
||||
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.SaveAttempt;
|
||||
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.SaveStart;
|
||||
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
|
||||
import art.arcane.iris.core.service.JigsawStudioService.CommandGraphMutationResult;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static art.arcane.iris.core.service.JigsawStudioCapture.captureChunkIntersection;
|
||||
import static art.arcane.iris.core.service.JigsawStudioCapture.chunkIntersections;
|
||||
import static art.arcane.iris.core.service.JigsawStudioCaptureScheduler.resolveCaptureTarget;
|
||||
import static art.arcane.iris.core.service.JigsawStudioGraphMutations.loadMappedLayout;
|
||||
import static art.arcane.iris.core.service.JigsawStudioProtection.authorizeOwner;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.AUTOSAVE_RETRY_TICKS;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.message;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.writeFailure;
|
||||
|
||||
final class JigsawStudioAutosaveScheduler {
|
||||
static final int AUTOSAVE_DEBOUNCE_TICKS = 40;
|
||||
|
||||
private static final List<Integer> AUTOSAVE_PERSISTENT_RETRY_DELAYS =
|
||||
List.of(40, 80, 160, 320, 600);
|
||||
|
||||
private final JigsawStudioService service;
|
||||
final Map<AutosaveKey, AutosaveTicket> autosaves = new ConcurrentHashMap<>();
|
||||
|
||||
JigsawStudioAutosaveScheduler(JigsawStudioService service) {
|
||||
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
|
||||
}
|
||||
|
||||
boolean undoAutosave(Player player) {
|
||||
if (player == null) {
|
||||
return false;
|
||||
}
|
||||
if (!J.isOwnedByCurrentRegion(player)) {
|
||||
return J.runEntity(player, () -> undoAutosave(player));
|
||||
}
|
||||
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
|
||||
if (studio == null || !authorizeOwner(player, studio)) {
|
||||
return false;
|
||||
}
|
||||
JigsawStudioSession session = studio.generator().getSession();
|
||||
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
|
||||
if (!reservationFailure.isEmpty()) {
|
||||
message(player, reservationFailure);
|
||||
return false;
|
||||
}
|
||||
Map<String, VariantReloadRequest> activeReloads = new HashMap<>();
|
||||
for (JigsawStudioBay workcell : session.layout().bays()) {
|
||||
session.activeVariant(workcell.stableId()).ifPresent(variant -> activeReloads.put(
|
||||
variant.pieceKey(),
|
||||
new VariantReloadRequest(
|
||||
workcell.stableId(),
|
||||
studio.generator().renderBay(workcell))));
|
||||
}
|
||||
JigsawStudioActivation.Request request = studio.generator().getRequest();
|
||||
message(player, "Restoring the previous Jigsaw Studio autosave iteration...");
|
||||
return service.graphMutations.scheduleGraphMutation(
|
||||
player,
|
||||
studio,
|
||||
() -> {
|
||||
Path packRoot = request.source().getDataFolder().toPath();
|
||||
JigsawStudioHistoryStore.UndoResult result = new JigsawStudioHistoryStore(
|
||||
packRoot,
|
||||
request.structureKey()).undoLatest();
|
||||
if (!result.available()) {
|
||||
return new CommandGraphMutationResult(
|
||||
session.layout(),
|
||||
"",
|
||||
"",
|
||||
"No earlier autosave iteration is available.");
|
||||
}
|
||||
if (!result.successful()) {
|
||||
throw new IOException("Jigsaw Studio undo failed: "
|
||||
+ writeFailure(result.writeResult()));
|
||||
}
|
||||
request.source().invalidateStructureResources();
|
||||
JigsawStudioLayout restoredLayout = loadMappedLayout(studio);
|
||||
VariantReloadRequest reload = activeReloads.get(result.pieceKey());
|
||||
Optional<VariantReloadRequest> activeReload = reload == null
|
||||
|| restoredLayout.get(reload.workcellId()) == null
|
||||
? Optional.empty()
|
||||
: Optional.of(reload);
|
||||
String warning = result.warning().isEmpty()
|
||||
? ""
|
||||
: " History cleanup warning: " + result.warning();
|
||||
return new CommandGraphMutationResult(
|
||||
restoredLayout,
|
||||
"",
|
||||
"",
|
||||
Map.of(),
|
||||
activeReload,
|
||||
"Restored the previous autosave iteration. "
|
||||
+ result.remainingIterations() + " earlier iteration(s) remain."
|
||||
+ warning);
|
||||
});
|
||||
}
|
||||
|
||||
boolean flushAutosave(Player player, String workcellId) {
|
||||
if (player == null) {
|
||||
return false;
|
||||
}
|
||||
if (!J.isOwnedByCurrentRegion(player)) {
|
||||
return J.runEntity(player, () -> flushAutosave(player, workcellId));
|
||||
}
|
||||
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
|
||||
if (studio == null || !authorizeOwner(player, studio)) {
|
||||
return false;
|
||||
}
|
||||
JigsawStudioSession session = studio.generator().getSession();
|
||||
String targetId = workcellId == null || workcellId.isBlank()
|
||||
? session.selectedBayId().orElse("")
|
||||
: workcellId;
|
||||
JigsawStudioBay bay = session.layout().get(targetId);
|
||||
if (bay == null) {
|
||||
message(player, "Select a Jigsaw Studio workcell before saving now.");
|
||||
return false;
|
||||
}
|
||||
UUID requestId = studio.generator().getRequest().requestId();
|
||||
service.tileWatcher.finalizeJigsawTileWatches(requestId);
|
||||
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
|
||||
message(player, "Wait for the open vanilla jigsaw-block editor to finish its final snapshot.");
|
||||
return false;
|
||||
}
|
||||
SaveAttempt attempt = service.saveLifecycle.startSave(studio, studio.world(), bay, player, true);
|
||||
if (attempt == SaveAttempt.STARTED) {
|
||||
return true;
|
||||
}
|
||||
if (attempt == SaveAttempt.PERSISTENT_FAILURE) {
|
||||
return false;
|
||||
}
|
||||
AutosaveTicket ticket = autosaves.get(new AutosaveKey(requestId, bay.stableId()));
|
||||
if (ticket != null) {
|
||||
expediteAutosave(
|
||||
ticket,
|
||||
attempt == SaveAttempt.RETRY
|
||||
? AUTOSAVE_RETRY_TICKS
|
||||
: AUTOSAVE_DEBOUNCE_TICKS);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void scheduleAutosave(
|
||||
ActiveStudio studio,
|
||||
JigsawStudioBay bay,
|
||||
JigsawStudioSession.DirtyIdentity identity,
|
||||
int delayTicks
|
||||
) {
|
||||
UUID requestId = studio.generator().getRequest().requestId();
|
||||
AutosaveKey key = new AutosaveKey(requestId, bay.stableId());
|
||||
AutosaveTicket ticket = new AutosaveTicket(
|
||||
key,
|
||||
studio,
|
||||
identity,
|
||||
new AtomicBoolean(false),
|
||||
new AtomicBoolean(false));
|
||||
autosaves.put(key, ticket);
|
||||
scheduleAutosave(ticket, delayTicks);
|
||||
}
|
||||
|
||||
void scheduleAutosave(AutosaveTicket ticket, int delayTicks) {
|
||||
if (autosaves.get(ticket.key()) != ticket
|
||||
|| !ticket.scheduled().compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
JigsawStudioBay bay = resolveAutosaveBay(ticket);
|
||||
if (bay == null) {
|
||||
ticket.scheduled().set(false);
|
||||
return;
|
||||
}
|
||||
JigsawStudioBounds bounds = bay.bounds();
|
||||
boolean scheduled = J.runRegion(
|
||||
ticket.studio().world(),
|
||||
bounds.originX() >> 4,
|
||||
bounds.originZ() >> 4,
|
||||
() -> runAutosave(ticket),
|
||||
delayTicks);
|
||||
if (!scheduled) {
|
||||
ticket.scheduled().set(false);
|
||||
if (ticket.scheduleFailureLogged().compareAndSet(false, true)) {
|
||||
IrisLogging.warn("Jigsaw Studio autosave could not schedule workcell %s for request %s; Iris will keep retrying",
|
||||
ticket.key().workcellId(), ticket.key().requestId());
|
||||
}
|
||||
scheduleAutosaveReconciliation(ticket);
|
||||
}
|
||||
}
|
||||
|
||||
private JigsawStudioBay resolveAutosaveBay(AutosaveTicket ticket) {
|
||||
if (autosaves.get(ticket.key()) != ticket) {
|
||||
return null;
|
||||
}
|
||||
JigsawStudioBay bay = resolveCurrentAutosaveBay(
|
||||
ticket.studio().generator().getSession(),
|
||||
ticket.key().workcellId());
|
||||
if (bay != null) {
|
||||
return bay;
|
||||
}
|
||||
if (autosaves.remove(ticket.key(), ticket)) {
|
||||
reportAutosaveFailure(
|
||||
ticket,
|
||||
new AutosavePersistentFailure(
|
||||
"workcell is absent from the current Studio layout; pending ticket retired",
|
||||
null));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static JigsawStudioBay resolveCurrentAutosaveBay(
|
||||
JigsawStudioSession session,
|
||||
String workcellId
|
||||
) {
|
||||
JigsawStudioSession activeSession = Objects.requireNonNull(
|
||||
session,
|
||||
"Jigsaw Studio autosave session");
|
||||
return activeSession.layout().get(Objects.requireNonNull(
|
||||
workcellId,
|
||||
"Jigsaw Studio autosave workcell ID"));
|
||||
}
|
||||
|
||||
boolean retainCurrentAutosaveFailure(
|
||||
ActiveStudio studio,
|
||||
String workcellId,
|
||||
AutosavePersistentFailure failure
|
||||
) {
|
||||
AutosaveKey key = new AutosaveKey(
|
||||
studio.generator().getRequest().requestId(),
|
||||
workcellId);
|
||||
AutosaveTicket ticket = autosaves.get(key);
|
||||
return ticket != null && retainPersistentAutosaveFailure(ticket, failure);
|
||||
}
|
||||
|
||||
AutosaveFailureState autosaveFailureState(
|
||||
ActiveStudio studio,
|
||||
JigsawStudioSession.SaveIdentity saveIdentity
|
||||
) {
|
||||
AutosaveKey key = new AutosaveKey(
|
||||
studio.generator().getRequest().requestId(),
|
||||
saveIdentity.workcellId());
|
||||
AutosaveTicket ticket = autosaves.get(key);
|
||||
return ticket != null && matchesSaveIdentity(ticket.identity(), saveIdentity)
|
||||
? ticket.failureState()
|
||||
: new AutosaveFailureState();
|
||||
}
|
||||
|
||||
private boolean retainPersistentAutosaveFailure(
|
||||
AutosaveTicket ticket,
|
||||
AutosavePersistentFailure failure
|
||||
) {
|
||||
if (autosaves.get(ticket.key()) != ticket
|
||||
|| !service.isCurrentRequest(ticket.studio(), ticket.key().requestId())
|
||||
|| !ticket.studio().generator().getSession().isDirtyCurrent(ticket.identity())) {
|
||||
return false;
|
||||
}
|
||||
AutosaveFailureDecision decision = reportAutosaveFailure(ticket, failure);
|
||||
expediteAutosave(ticket, decision.retryTicks());
|
||||
return true;
|
||||
}
|
||||
|
||||
private static AutosaveFailureDecision reportAutosaveFailure(
|
||||
AutosaveTicket ticket,
|
||||
AutosavePersistentFailure failure
|
||||
) {
|
||||
JigsawStudioActivation.Request request = ticket.studio().generator().getRequest();
|
||||
return recordPersistentAutosaveFailure(
|
||||
ticket.failureState(),
|
||||
ticket.key().requestId(),
|
||||
request.structureKey(),
|
||||
ticket.key().workcellId(),
|
||||
ticket.identity().variantKey(),
|
||||
failure.detail(),
|
||||
failure.cause());
|
||||
}
|
||||
|
||||
private static void reportPersistentSaveFailure(
|
||||
ActiveStudio studio,
|
||||
JigsawStudioSession.SaveIdentity saveIdentity,
|
||||
AutosaveFailureState failureState,
|
||||
AutosavePersistentFailure failure
|
||||
) {
|
||||
JigsawStudioActivation.Request request = studio.generator().getRequest();
|
||||
recordPersistentAutosaveFailure(
|
||||
failureState,
|
||||
request.requestId(),
|
||||
request.structureKey(),
|
||||
saveIdentity.workcellId(),
|
||||
saveIdentity.variantKey(),
|
||||
failure.detail(),
|
||||
failure.cause());
|
||||
}
|
||||
|
||||
static AutosaveFailureDecision recordPersistentAutosaveFailure(
|
||||
AutosaveFailureState state,
|
||||
UUID requestId,
|
||||
String structureKey,
|
||||
String workcellId,
|
||||
String pieceKey,
|
||||
String detail,
|
||||
Throwable cause
|
||||
) {
|
||||
AutosaveFailureState activeState = Objects.requireNonNull(
|
||||
state,
|
||||
"Jigsaw Studio autosave failure state");
|
||||
AutosaveFailureDecision decision = activeState.recordPersistentFailure();
|
||||
if (!decision.logFailure()) {
|
||||
return decision;
|
||||
}
|
||||
String context = autosaveFailureContext(
|
||||
requestId,
|
||||
structureKey,
|
||||
workcellId,
|
||||
pieceKey,
|
||||
detail);
|
||||
if (cause == null) {
|
||||
IrisLogging.warn("%s", context);
|
||||
} else {
|
||||
IrisLogging.reportError(context, cause);
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
|
||||
static String autosaveFailureContext(
|
||||
UUID requestId,
|
||||
String structureKey,
|
||||
String workcellId,
|
||||
String pieceKey,
|
||||
String detail
|
||||
) {
|
||||
return "Jigsaw Studio save failure: request="
|
||||
+ Objects.requireNonNull(requestId, "Jigsaw Studio autosave request ID")
|
||||
+ " structure="
|
||||
+ Objects.requireNonNull(structureKey, "Jigsaw Studio autosave structure key")
|
||||
+ " workcell="
|
||||
+ Objects.requireNonNull(workcellId, "Jigsaw Studio autosave workcell ID")
|
||||
+ " piece="
|
||||
+ Objects.requireNonNull(pieceKey, "Jigsaw Studio autosave piece key")
|
||||
+ " failure="
|
||||
+ Objects.requireNonNull(detail, "Jigsaw Studio autosave failure detail");
|
||||
}
|
||||
|
||||
static int persistentAutosaveRetryTicks(int failureCount) {
|
||||
int index = Math.max(0, Math.min(
|
||||
failureCount - 1,
|
||||
AUTOSAVE_PERSISTENT_RETRY_DELAYS.size() - 1));
|
||||
return AUTOSAVE_PERSISTENT_RETRY_DELAYS.get(index);
|
||||
}
|
||||
|
||||
private void scheduleAutosaveReconciliation(AutosaveTicket ticket) {
|
||||
try {
|
||||
J.s(() -> {
|
||||
if (autosaves.get(ticket.key()) == ticket
|
||||
&& !ticket.scheduled().get()) {
|
||||
scheduleAutosave(ticket, AUTOSAVE_RETRY_TICKS);
|
||||
}
|
||||
}, AUTOSAVE_RETRY_TICKS);
|
||||
} catch (Throwable exception) {
|
||||
IrisLogging.reportError(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void runAutosave(AutosaveTicket ticket) {
|
||||
if (autosaves.get(ticket.key()) != ticket) {
|
||||
return;
|
||||
}
|
||||
ticket.scheduled().set(false);
|
||||
ActiveStudio studio = ticket.studio();
|
||||
JigsawStudioSession session = studio.generator().getSession();
|
||||
if (!service.isCurrentRequest(studio, ticket.key().requestId())) {
|
||||
autosaves.remove(ticket.key(), ticket);
|
||||
return;
|
||||
}
|
||||
JigsawStudioBay bay = resolveAutosaveBay(ticket);
|
||||
if (bay == null) {
|
||||
return;
|
||||
}
|
||||
if (!session.isDirtyCurrent(ticket.identity())) {
|
||||
autosaves.remove(ticket.key(), ticket);
|
||||
return;
|
||||
}
|
||||
SaveAttempt attempt = service.saveLifecycle.startSave(studio, studio.world(), bay, null, false);
|
||||
if (attempt == SaveAttempt.STARTED || attempt == SaveAttempt.PERSISTENT_FAILURE) {
|
||||
return;
|
||||
}
|
||||
scheduleAutosave(
|
||||
ticket,
|
||||
attempt == SaveAttempt.RETRY
|
||||
? AUTOSAVE_RETRY_TICKS
|
||||
: AUTOSAVE_DEBOUNCE_TICKS);
|
||||
}
|
||||
|
||||
void completeAutosaveAttempt(
|
||||
ActiveStudio studio,
|
||||
JigsawStudioSession.SaveIdentity saveIdentity,
|
||||
AutosaveFailureState failureState,
|
||||
AutosavePersistentFailure persistentFailure
|
||||
) {
|
||||
AutosaveKey key = new AutosaveKey(
|
||||
studio.generator().getRequest().requestId(),
|
||||
saveIdentity.workcellId());
|
||||
AutosaveTicket ticket = autosaves.get(key);
|
||||
if (ticket == null || !matchesSaveIdentity(ticket.identity(), saveIdentity)) {
|
||||
if (persistentFailure != null) {
|
||||
reportPersistentSaveFailure(studio, saveIdentity, failureState, persistentFailure);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (resolveAutosaveBay(ticket) == null) {
|
||||
return;
|
||||
}
|
||||
if (!studio.generator().getSession().isDirtyCurrent(ticket.identity())) {
|
||||
autosaves.remove(key, ticket);
|
||||
return;
|
||||
}
|
||||
if (persistentFailure != null) {
|
||||
retainPersistentAutosaveFailure(ticket, persistentFailure);
|
||||
return;
|
||||
}
|
||||
scheduleAutosave(ticket, AUTOSAVE_RETRY_TICKS);
|
||||
}
|
||||
|
||||
private static boolean matchesSaveIdentity(
|
||||
JigsawStudioSession.DirtyIdentity dirtyIdentity,
|
||||
JigsawStudioSession.SaveIdentity saveIdentity
|
||||
) {
|
||||
return dirtyIdentity.sessionId().equals(saveIdentity.sessionId())
|
||||
&& dirtyIdentity.workcellId().equals(saveIdentity.workcellId())
|
||||
&& dirtyIdentity.variantKey().equals(saveIdentity.variantKey())
|
||||
&& dirtyIdentity.loadGeneration() == saveIdentity.loadGeneration()
|
||||
&& dirtyIdentity.mutationGeneration() == saveIdentity.mutationGeneration();
|
||||
}
|
||||
|
||||
void drainAutosavesBeforeDisable() {
|
||||
for (ActiveStudio studio : List.copyOf(service.studios.values())) {
|
||||
try {
|
||||
drainAutosavesBeforeRemoval(studio);
|
||||
} catch (Throwable exception) {
|
||||
IrisLogging.reportError(
|
||||
"Failed to drain Jigsaw Studio autosaves in world "
|
||||
+ studio.worldId() + " during shutdown.",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
if (!autosaves.isEmpty()) {
|
||||
IrisLogging.warn("Jigsaw Studio disabled with %d autosave operation(s) still pending after the final drain attempt",
|
||||
autosaves.size());
|
||||
}
|
||||
}
|
||||
|
||||
void drainAutosavesBeforeRemoval(ActiveStudio studio) {
|
||||
if (studio == null) {
|
||||
return;
|
||||
}
|
||||
UUID requestId = studio.generator().getRequest().requestId();
|
||||
if (service.saveLifecycle.discardingRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
if (J.isFolia() || !J.isPrimaryThread()) {
|
||||
expediteAutosaves(requestId);
|
||||
return;
|
||||
}
|
||||
for (AutosaveTicket ticket : List.copyOf(autosaves.values())) {
|
||||
if (requestId.equals(ticket.key().requestId())) {
|
||||
drainAutosaveSynchronously(ticket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean drainAutosaveSynchronously(AutosaveTicket ticket) {
|
||||
if (autosaves.get(ticket.key()) != ticket) {
|
||||
return true;
|
||||
}
|
||||
ActiveStudio studio = ticket.studio();
|
||||
JigsawStudioSession session = studio.generator().getSession();
|
||||
if (!service.isCurrentRequest(studio, ticket.key().requestId())) {
|
||||
autosaves.remove(ticket.key(), ticket);
|
||||
return true;
|
||||
}
|
||||
JigsawStudioBay bay = resolveAutosaveBay(ticket);
|
||||
if (bay == null) {
|
||||
return false;
|
||||
}
|
||||
if (!session.isDirtyCurrent(ticket.identity())) {
|
||||
autosaves.remove(ticket.key(), ticket);
|
||||
return true;
|
||||
}
|
||||
JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null);
|
||||
if (activeVariant == null || !activeVariant.owned()) {
|
||||
return false;
|
||||
}
|
||||
BayReadiness readiness = studio.population(bay).readiness();
|
||||
if (!readiness.ready()) {
|
||||
return false;
|
||||
}
|
||||
CaptureTarget captureTarget;
|
||||
try {
|
||||
captureTarget = resolveCaptureTarget(studio, bay, activeVariant);
|
||||
} catch (IOException exception) {
|
||||
retainPersistentAutosaveFailure(
|
||||
ticket,
|
||||
new AutosavePersistentFailure(
|
||||
"Jigsaw Studio cannot capture this workcell: " + failureMessage(exception),
|
||||
exception));
|
||||
return false;
|
||||
}
|
||||
JigsawStudioSession.SaveStart sessionSave = session.beginSave(bay.stableId());
|
||||
if (sessionSave.status() != JigsawStudioSession.SaveStatus.STARTED) {
|
||||
return false;
|
||||
}
|
||||
JigsawStudioSession.SaveIdentity saveIdentity = sessionSave.identity().orElseThrow();
|
||||
if (service.saveLifecycle.tryBeginSave(ticket.key().requestId()) != SaveStart.STARTED) {
|
||||
session.abortSave(saveIdentity);
|
||||
return false;
|
||||
}
|
||||
List<ChunkCaptureArea> areas = chunkIntersections(captureTarget.bounds());
|
||||
CaptureWork work = new CaptureWork(
|
||||
studio,
|
||||
studio.world(),
|
||||
bay,
|
||||
captureTarget,
|
||||
null,
|
||||
ticket.key().requestId(),
|
||||
saveIdentity,
|
||||
ticket.failureState());
|
||||
CaptureCoordinator coordinator =
|
||||
service.captureScheduler.coordinator(work, areas);
|
||||
List<ChunkSnapshot> snapshots = new ArrayList<>(areas.size());
|
||||
try {
|
||||
for (ChunkCaptureArea area : areas) {
|
||||
if (!studio.world().isChunkLoaded(area.chunkX(), area.chunkZ())) {
|
||||
throw new IOException("Workcell chunk " + area.chunkX() + "," + area.chunkZ()
|
||||
+ " is not loaded during the final autosave drain");
|
||||
}
|
||||
snapshots.add(captureChunkIntersection(
|
||||
studio.world(),
|
||||
captureTarget.bounds(),
|
||||
captureTarget.piece(),
|
||||
captureTarget.object(),
|
||||
area,
|
||||
captureTarget.displayRotationQuarterTurns(),
|
||||
captureTarget.connectorsVisible()));
|
||||
}
|
||||
service.captureScheduler.assembleAndPersist(coordinator, snapshots);
|
||||
} catch (Throwable exception) {
|
||||
coordinator.fail("Jigsaw Studio final autosave drain failed: "
|
||||
+ failureMessage(exception), exception);
|
||||
}
|
||||
return !session.isDirtyCurrent(ticket.identity());
|
||||
}
|
||||
|
||||
void expediteAutosaves(UUID requestId) {
|
||||
if (requestId == null) {
|
||||
return;
|
||||
}
|
||||
for (AutosaveTicket current : List.copyOf(autosaves.values())) {
|
||||
if (!requestId.equals(current.key().requestId())) {
|
||||
continue;
|
||||
}
|
||||
expediteAutosave(current, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void expediteAutosave(AutosaveTicket current, int delayTicks) {
|
||||
AutosaveTicket expedited = new AutosaveTicket(
|
||||
current.key(),
|
||||
current.studio(),
|
||||
current.identity(),
|
||||
new AtomicBoolean(false),
|
||||
current.scheduleFailureLogged(),
|
||||
current.failureState());
|
||||
if (autosaves.replace(current.key(), current, expedited)) {
|
||||
scheduleAutosave(expedited, delayTicks);
|
||||
}
|
||||
}
|
||||
|
||||
void clearAutosaves(UUID requestId) {
|
||||
if (requestId != null) {
|
||||
autosaves.keySet().removeIf(key -> key.requestId().equals(requestId));
|
||||
}
|
||||
}
|
||||
|
||||
record AutosaveKey(UUID requestId, String workcellId) {
|
||||
AutosaveKey {
|
||||
Objects.requireNonNull(requestId, "Jigsaw Studio autosave request ID");
|
||||
Objects.requireNonNull(workcellId, "Jigsaw Studio autosave workcell ID");
|
||||
}
|
||||
}
|
||||
|
||||
static final class AutosaveFailureState {
|
||||
private final AtomicInteger persistentFailures = new AtomicInteger();
|
||||
private final AtomicBoolean failureLogged = new AtomicBoolean(false);
|
||||
|
||||
AutosaveFailureDecision recordPersistentFailure() {
|
||||
int failureCount = persistentFailures.incrementAndGet();
|
||||
return new AutosaveFailureDecision(
|
||||
persistentAutosaveRetryTicks(failureCount),
|
||||
failureLogged.compareAndSet(false, true));
|
||||
}
|
||||
}
|
||||
|
||||
record AutosaveFailureDecision(int retryTicks, boolean logFailure) {
|
||||
}
|
||||
|
||||
record AutosavePersistentFailure(String detail, Throwable cause) {
|
||||
AutosavePersistentFailure {
|
||||
Objects.requireNonNull(detail, "Jigsaw Studio persistent autosave failure detail");
|
||||
}
|
||||
}
|
||||
|
||||
private record AutosaveTicket(
|
||||
AutosaveKey key,
|
||||
ActiveStudio studio,
|
||||
JigsawStudioSession.DirtyIdentity identity,
|
||||
AtomicBoolean scheduled,
|
||||
AtomicBoolean scheduleFailureLogged,
|
||||
AutosaveFailureState failureState
|
||||
) {
|
||||
private AutosaveTicket(
|
||||
AutosaveKey key,
|
||||
ActiveStudio studio,
|
||||
JigsawStudioSession.DirtyIdentity identity,
|
||||
AtomicBoolean scheduled,
|
||||
AtomicBoolean scheduleFailureLogged
|
||||
) {
|
||||
this(
|
||||
key,
|
||||
studio,
|
||||
identity,
|
||||
scheduled,
|
||||
scheduleFailureLogged,
|
||||
new AutosaveFailureState());
|
||||
}
|
||||
|
||||
private AutosaveTicket {
|
||||
Objects.requireNonNull(key, "Jigsaw Studio autosave key");
|
||||
Objects.requireNonNull(studio, "Jigsaw Studio autosave studio");
|
||||
Objects.requireNonNull(identity, "Jigsaw Studio autosave dirty identity");
|
||||
Objects.requireNonNull(scheduled, "Jigsaw Studio autosave schedule state");
|
||||
Objects.requireNonNull(scheduleFailureLogged, "Jigsaw Studio autosave schedule warning state");
|
||||
Objects.requireNonNull(failureState, "Jigsaw Studio autosave failure state");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarDirection;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarTopology;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
|
||||
import art.arcane.iris.engine.object.IrisDirection;
|
||||
import art.arcane.iris.engine.object.IrisJigsawConnector;
|
||||
import art.arcane.iris.engine.object.IrisJigsawPiece;
|
||||
import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.engine.object.IrisObjectRotation;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
import art.arcane.iris.engine.object.JigsawJoint;
|
||||
import art.arcane.iris.engine.object.TileData;
|
||||
import art.arcane.iris.platform.bukkit.BukkitBlockState;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.type.Jigsaw;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.restoreCapturedMetadataForDisplay;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.chunkKey;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
|
||||
|
||||
final class JigsawStudioCapture {
|
||||
private static final Comparator<IrisJigsawConnector> NEW_CONNECTOR_SOURCE_POSITION_ORDER = Comparator
|
||||
.comparingInt((IrisJigsawConnector connector) -> connector.getPosition().getX())
|
||||
.thenComparingInt(connector -> connector.getPosition().getY())
|
||||
.thenComparingInt(connector -> connector.getPosition().getZ());
|
||||
|
||||
private JigsawStudioCapture() {
|
||||
}
|
||||
|
||||
static List<ChunkCaptureArea> chunkIntersections(JigsawStudioBounds bounds) {
|
||||
JigsawStudioBounds captureBounds = Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds");
|
||||
List<ChunkCaptureArea> areas = new ArrayList<>();
|
||||
int minimumChunkX = captureBounds.originX() >> 4;
|
||||
int maximumChunkX = captureBounds.maxX() >> 4;
|
||||
int minimumChunkZ = captureBounds.originZ() >> 4;
|
||||
int maximumChunkZ = captureBounds.maxZ() >> 4;
|
||||
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
|
||||
int chunkOriginX = chunkX << 4;
|
||||
int minimumX = Math.max(0, chunkOriginX - captureBounds.originX());
|
||||
int maximumX = Math.min(
|
||||
captureBounds.dimensions().width(),
|
||||
chunkOriginX + 16 - captureBounds.originX());
|
||||
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
|
||||
int chunkOriginZ = chunkZ << 4;
|
||||
int minimumZ = Math.max(0, chunkOriginZ - captureBounds.originZ());
|
||||
int maximumZ = Math.min(
|
||||
captureBounds.dimensions().depth(),
|
||||
chunkOriginZ + 16 - captureBounds.originZ());
|
||||
areas.add(new ChunkCaptureArea(
|
||||
chunkX,
|
||||
chunkZ,
|
||||
minimumX,
|
||||
maximumX,
|
||||
minimumZ,
|
||||
maximumZ));
|
||||
}
|
||||
}
|
||||
return List.copyOf(areas);
|
||||
}
|
||||
|
||||
static ChunkSnapshot captureChunkIntersection(
|
||||
World world,
|
||||
JigsawStudioBounds bounds,
|
||||
IrisJigsawPiece sourcePiece,
|
||||
IrisObject sourceObject,
|
||||
ChunkCaptureArea area
|
||||
) throws IOException {
|
||||
return captureChunkIntersection(world, bounds, sourcePiece, sourceObject, area, 0);
|
||||
}
|
||||
|
||||
static ChunkSnapshot captureChunkIntersection(
|
||||
World world,
|
||||
JigsawStudioBounds bounds,
|
||||
IrisJigsawPiece sourcePiece,
|
||||
IrisObject sourceObject,
|
||||
ChunkCaptureArea area,
|
||||
int displayRotationQuarterTurns
|
||||
) throws IOException {
|
||||
return captureChunkIntersection(
|
||||
world,
|
||||
bounds,
|
||||
sourcePiece,
|
||||
sourceObject,
|
||||
area,
|
||||
displayRotationQuarterTurns,
|
||||
true);
|
||||
}
|
||||
|
||||
static ChunkSnapshot captureChunkIntersection(
|
||||
World world,
|
||||
JigsawStudioBounds bounds,
|
||||
IrisJigsawPiece sourcePiece,
|
||||
IrisObject sourceObject,
|
||||
ChunkCaptureArea area,
|
||||
int displayRotationQuarterTurns,
|
||||
boolean connectorsVisible
|
||||
) throws IOException {
|
||||
World captureWorld = Objects.requireNonNull(world, "Jigsaw Studio capture world");
|
||||
JigsawStudioBounds captureBounds = Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds");
|
||||
IrisObject captureSource = Objects.requireNonNull(sourceObject, "Jigsaw Studio source object");
|
||||
ChunkCaptureArea captureArea = Objects.requireNonNull(area, "Jigsaw Studio chunk capture area");
|
||||
int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
|
||||
int sourceWidth = (quarterTurns & 1) == 0
|
||||
? captureBounds.dimensions().width()
|
||||
: captureBounds.dimensions().depth();
|
||||
int sourceDepth = (quarterTurns & 1) == 0
|
||||
? captureBounds.dimensions().depth()
|
||||
: captureBounds.dimensions().width();
|
||||
List<CapturedBlock> blocks = new ArrayList<>();
|
||||
List<CapturedConnector> connectors = new ArrayList<>();
|
||||
Map<LocalPosition, IrisJigsawConnector> hiddenConnectors = connectorsVisible
|
||||
? Map.of()
|
||||
: displayedSourceConnectors(sourcePiece, captureBounds.dimensions(), quarterTurns);
|
||||
for (int x = captureArea.minimumX(); x < captureArea.maximumX(); x++) {
|
||||
for (int y = 0; y < bounds.dimensions().height(); y++) {
|
||||
for (int z = captureArea.minimumZ(); z < captureArea.maximumZ(); z++) {
|
||||
Block block = captureWorld.getBlockAt(
|
||||
captureBounds.originX() + x,
|
||||
captureBounds.originY() + y,
|
||||
captureBounds.originZ() + z);
|
||||
BlockData blockData = block.getBlockData();
|
||||
IrisJigsawConnector hiddenConnector = hiddenConnectors.get(new LocalPosition(x, y, z));
|
||||
if (hiddenConnector != null) {
|
||||
hiddenConnector.setFinalState(blockData.getAsString());
|
||||
connectors.add(CapturedConnector.from(hiddenConnector));
|
||||
}
|
||||
if (hiddenConnector == null && blockData instanceof Jigsaw jigsaw) {
|
||||
KMap<String, Object> nbt = BukkitPlatform.serializeTile(block.getLocation());
|
||||
if (nbt == null) {
|
||||
throw new IOException("Cannot read jigsaw marker NBT at "
|
||||
+ block.getX() + "," + block.getY() + "," + block.getZ()
|
||||
+ ". The active NMS binding must support tile serialization.");
|
||||
}
|
||||
IrisJigsawConnector connector;
|
||||
try {
|
||||
connector = JigsawStudioMarkerParser.parse(nbt, jigsaw.getOrientation(), x, y, z);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid jigsaw marker at "
|
||||
+ block.getX() + "," + block.getY() + "," + block.getZ()
|
||||
+ ": " + failureMessage(exception), exception);
|
||||
}
|
||||
restoreCapturedMetadataForDisplay(
|
||||
connector,
|
||||
sourcePiece,
|
||||
captureBounds.dimensions(),
|
||||
quarterTurns);
|
||||
BlockData finalData;
|
||||
try {
|
||||
finalData = Bukkit.createBlockData(connector.getFinalState());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid final_state '" + connector.getFinalState()
|
||||
+ "' at " + block.getX() + "," + block.getY() + "," + block.getZ(), exception);
|
||||
}
|
||||
connector.setFinalState(finalData.getAsString());
|
||||
connectors.add(CapturedConnector.from(connector));
|
||||
if (finalData.getMaterial() != Material.STRUCTURE_VOID) {
|
||||
blocks.add(new CapturedBlock(
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
BukkitBlockState.of(finalData),
|
||||
null));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isAir(blockData.getMaterial())) {
|
||||
LocalPosition sourcePosition = inversePosition(
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
sourceWidth,
|
||||
sourceDepth,
|
||||
quarterTurns);
|
||||
PlatformBlockState retainedAir = retainedSourceAir(
|
||||
captureSource,
|
||||
sourcePosition.x(),
|
||||
sourcePosition.y(),
|
||||
sourcePosition.z(),
|
||||
blockData);
|
||||
if (retainedAir != null) {
|
||||
blocks.add(new CapturedBlock(x, y, z, retainedAir, null));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (blockData.getMaterial() == Material.STRUCTURE_VOID) {
|
||||
continue;
|
||||
}
|
||||
blocks.add(new CapturedBlock(
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
BukkitBlockState.of(blockData),
|
||||
TileData.getTileState(block, false)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ChunkSnapshot(captureArea, blocks, connectors);
|
||||
}
|
||||
|
||||
private static Map<LocalPosition, IrisJigsawConnector> displayedSourceConnectors(
|
||||
IrisJigsawPiece sourcePiece,
|
||||
JigsawStudioCellDimensions displayDimensions,
|
||||
int displayRotationQuarterTurns
|
||||
) throws IOException {
|
||||
IrisJigsawPiece source = Objects.requireNonNull(sourcePiece, "Jigsaw Studio source piece");
|
||||
if (source.getConnectors() == null) {
|
||||
throw new IOException("Jigsaw Studio source piece has no connector list");
|
||||
}
|
||||
int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
|
||||
int sourceWidth = (quarterTurns & 1) == 0
|
||||
? displayDimensions.width()
|
||||
: displayDimensions.depth();
|
||||
int sourceDepth = (quarterTurns & 1) == 0
|
||||
? displayDimensions.depth()
|
||||
: displayDimensions.width();
|
||||
IrisObjectRotation rotation = IrisObjectRotation.of(0, -90.0D * quarterTurns, 0);
|
||||
Map<LocalPosition, IrisJigsawConnector> displayed = new HashMap<>(source.getConnectors().size());
|
||||
for (IrisJigsawConnector sourceConnector : source.getConnectors()) {
|
||||
LocalPosition sourcePosition = connectorPosition(sourceConnector, "source piece");
|
||||
LocalPosition position = forwardPosition(
|
||||
sourcePosition.x(),
|
||||
sourcePosition.y(),
|
||||
sourcePosition.z(),
|
||||
sourceWidth,
|
||||
sourceDepth,
|
||||
quarterTurns);
|
||||
IrisJigsawConnector connector = CapturedConnector.from(sourceConnector).toConnector()
|
||||
.setPosition(new IrisPosition(position.x(), position.y(), position.z()))
|
||||
.setDirection(rotation.rotate(sourceConnector.getDirection()))
|
||||
.setTop(rotation.rotate(sourceConnector.getTop()));
|
||||
if (displayed.put(position, connector) != null) {
|
||||
throw new IOException("Jigsaw Studio source piece has duplicate displayed connector position "
|
||||
+ connectorLocation(position));
|
||||
}
|
||||
}
|
||||
return Map.copyOf(displayed);
|
||||
}
|
||||
|
||||
static PlatformBlockState retainedSourceAir(
|
||||
IrisObject sourceObject,
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
BlockData current
|
||||
) {
|
||||
IrisObject source = Objects.requireNonNull(sourceObject, "Jigsaw Studio source object");
|
||||
BlockData currentState = Objects.requireNonNull(current, "Jigsaw Studio current block state");
|
||||
if (!isAir(currentState.getMaterial())
|
||||
|| x < 0
|
||||
|| y < 0
|
||||
|| z < 0
|
||||
|| x >= source.getW()
|
||||
|| y >= source.getH()
|
||||
|| z >= source.getD()) {
|
||||
return null;
|
||||
}
|
||||
PlatformBlockState original = source.getBlocks().get(source.getSigned(x, y, z));
|
||||
return original != null && original.isAir() ? original : null;
|
||||
}
|
||||
|
||||
private static boolean isAir(Material material) {
|
||||
return material == Material.AIR || material == Material.CAVE_AIR || material == Material.VOID_AIR;
|
||||
}
|
||||
|
||||
static Capture aggregateSnapshots(
|
||||
JigsawStudioBounds bounds,
|
||||
List<ChunkCaptureArea> expectedAreas,
|
||||
List<ChunkSnapshot> snapshots
|
||||
) throws IOException {
|
||||
return aggregateSnapshots(bounds, expectedAreas, snapshots, 0);
|
||||
}
|
||||
|
||||
static Capture aggregateSnapshots(
|
||||
JigsawStudioBounds bounds,
|
||||
List<ChunkCaptureArea> expectedAreas,
|
||||
List<ChunkSnapshot> snapshots,
|
||||
int displayRotationQuarterTurns
|
||||
) throws IOException {
|
||||
JigsawStudioBounds captureBounds = Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds");
|
||||
int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
|
||||
List<ChunkCaptureArea> requiredAreas = List.copyOf(expectedAreas);
|
||||
List<ChunkSnapshot> capturedSnapshots = List.copyOf(snapshots);
|
||||
Map<Long, ChunkCaptureArea> requiredByChunk = new HashMap<>();
|
||||
for (ChunkCaptureArea area : requiredAreas) {
|
||||
if (requiredByChunk.put(chunkKey(area.chunkX(), area.chunkZ()), area) != null) {
|
||||
throw new IOException("Duplicate required bay chunk " + area.chunkX() + "," + area.chunkZ());
|
||||
}
|
||||
}
|
||||
if (capturedSnapshots.size() != requiredByChunk.size()) {
|
||||
throw new IOException("Expected " + requiredByChunk.size() + " bay chunk snapshot(s), received "
|
||||
+ capturedSnapshots.size());
|
||||
}
|
||||
Map<Long, ChunkSnapshot> capturedByChunk = new HashMap<>();
|
||||
for (ChunkSnapshot snapshot : capturedSnapshots) {
|
||||
ChunkCaptureArea area = snapshot.area();
|
||||
long key = chunkKey(area.chunkX(), area.chunkZ());
|
||||
ChunkCaptureArea required = requiredByChunk.get(key);
|
||||
if (!area.equals(required)) {
|
||||
throw new IOException("Unexpected bay chunk intersection for "
|
||||
+ area.chunkX() + "," + area.chunkZ());
|
||||
}
|
||||
if (capturedByChunk.put(key, snapshot) != null) {
|
||||
throw new IOException("Duplicate bay chunk snapshot " + area.chunkX() + "," + area.chunkZ());
|
||||
}
|
||||
}
|
||||
if (!capturedByChunk.keySet().containsAll(requiredByChunk.keySet())) {
|
||||
throw new IOException("One or more required bay chunk snapshots are missing");
|
||||
}
|
||||
|
||||
List<ChunkSnapshot> ordered = new ArrayList<>(capturedSnapshots);
|
||||
ordered.sort(Comparator
|
||||
.comparingInt((ChunkSnapshot snapshot) -> snapshot.area().chunkX())
|
||||
.thenComparingInt(snapshot -> snapshot.area().chunkZ()));
|
||||
int sourceWidth = (quarterTurns & 1) == 0
|
||||
? captureBounds.dimensions().width()
|
||||
: captureBounds.dimensions().depth();
|
||||
int sourceDepth = (quarterTurns & 1) == 0
|
||||
? captureBounds.dimensions().depth()
|
||||
: captureBounds.dimensions().width();
|
||||
IrisObject object = new IrisObject(
|
||||
sourceWidth,
|
||||
captureBounds.dimensions().height(),
|
||||
sourceDepth);
|
||||
IrisObjectRotation inverseRotation = IrisObjectRotation.of(0, 90.0D * quarterTurns, 0);
|
||||
List<IrisJigsawConnector> connectors = new ArrayList<>();
|
||||
Set<LocalPosition> capturedBlocks = new HashSet<>();
|
||||
Set<LocalPosition> capturedConnectors = new HashSet<>();
|
||||
boolean hasBlockEntities = false;
|
||||
for (ChunkSnapshot snapshot : ordered) {
|
||||
ChunkCaptureArea area = snapshot.area();
|
||||
for (CapturedBlock block : snapshot.blocks()) {
|
||||
if (!area.contains(block.x(), block.z())
|
||||
|| block.y() < 0
|
||||
|| block.y() >= captureBounds.dimensions().height()) {
|
||||
throw new IOException("Captured block falls outside bay chunk "
|
||||
+ area.chunkX() + "," + area.chunkZ());
|
||||
}
|
||||
LocalPosition position = inversePosition(
|
||||
block.x(), block.y(), block.z(), sourceWidth, sourceDepth, quarterTurns);
|
||||
if (!capturedBlocks.add(position)) {
|
||||
throw new IOException("Duplicate captured block at "
|
||||
+ block.x() + "," + block.y() + "," + block.z());
|
||||
}
|
||||
PlatformBlockState sourceState = quarterTurns == 0
|
||||
? block.state()
|
||||
: inverseRotation.rotate(block.state(), 0, 0, 0);
|
||||
if (sourceState == null) {
|
||||
throw new IOException("Captured block state cannot be inverse-rotated at "
|
||||
+ block.x() + "," + block.y() + "," + block.z());
|
||||
}
|
||||
object.setUnsigned(position.x(), position.y(), position.z(), sourceState);
|
||||
TileData tileData = block.tileData();
|
||||
if (tileData != null) {
|
||||
object.setUnsignedTile(position.x(), position.y(), position.z(), tileData.clone());
|
||||
hasBlockEntities = true;
|
||||
}
|
||||
}
|
||||
for (CapturedConnector capturedConnector : snapshot.connectors()) {
|
||||
if (!area.contains(capturedConnector.x(), capturedConnector.z())
|
||||
|| capturedConnector.y() < 0
|
||||
|| capturedConnector.y() >= captureBounds.dimensions().height()) {
|
||||
throw new IOException("Captured connector falls outside bay chunk "
|
||||
+ area.chunkX() + "," + area.chunkZ());
|
||||
}
|
||||
LocalPosition position = inversePosition(
|
||||
capturedConnector.x(),
|
||||
capturedConnector.y(),
|
||||
capturedConnector.z(),
|
||||
sourceWidth,
|
||||
sourceDepth,
|
||||
quarterTurns);
|
||||
if (!capturedConnectors.add(position)) {
|
||||
throw new IOException("Duplicate captured connector at "
|
||||
+ capturedConnector.x() + "," + capturedConnector.y() + ","
|
||||
+ capturedConnector.z());
|
||||
}
|
||||
IrisJigsawConnector connector = capturedConnector.toConnector();
|
||||
if (quarterTurns != 0) {
|
||||
connector.setPosition(new IrisPosition(position.x(), position.y(), position.z()));
|
||||
connector.setDirection(inverseRotation.rotate(connector.getDirection()));
|
||||
connector.setTop(inverseRotation.rotate(connector.getTop()));
|
||||
PlatformBlockState finalState = B.getStateOrNull(connector.getFinalState(), false);
|
||||
if (finalState == null) {
|
||||
throw new IOException("Captured connector final state cannot be parsed at "
|
||||
+ capturedConnector.x() + "," + capturedConnector.y() + ","
|
||||
+ capturedConnector.z());
|
||||
}
|
||||
PlatformBlockState sourceFinalState = inverseRotation.rotate(finalState, 0, 0, 0);
|
||||
if (sourceFinalState == null) {
|
||||
throw new IOException("Captured connector final state cannot be inverse-rotated at "
|
||||
+ capturedConnector.x() + "," + capturedConnector.y() + ","
|
||||
+ capturedConnector.z());
|
||||
}
|
||||
connector.setFinalState(sourceFinalState.key());
|
||||
}
|
||||
connectors.add(connector);
|
||||
}
|
||||
}
|
||||
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||
object.write(output);
|
||||
return new Capture(output.toByteArray(), connectors, hasBlockEntities);
|
||||
}
|
||||
}
|
||||
|
||||
static List<IrisJigsawConnector> preserveCapturedConnectorOrder(
|
||||
IrisJigsawPiece sourcePiece,
|
||||
List<IrisJigsawConnector> capturedConnectors
|
||||
) throws IOException {
|
||||
IrisJigsawPiece source = Objects.requireNonNull(sourcePiece, "Jigsaw Studio source piece");
|
||||
List<IrisJigsawConnector> sourceConnectors = source.getConnectors();
|
||||
if (sourceConnectors == null) {
|
||||
throw new IOException("Jigsaw Studio source piece has no connector list");
|
||||
}
|
||||
List<IrisJigsawConnector> captured = new ArrayList<>(Objects.requireNonNull(
|
||||
capturedConnectors,
|
||||
"Jigsaw Studio captured connectors"));
|
||||
Map<LocalPosition, IrisJigsawConnector> capturedByPosition = new HashMap<>(captured.size());
|
||||
for (IrisJigsawConnector connector : captured) {
|
||||
LocalPosition position = connectorPosition(connector, "captured");
|
||||
if (capturedByPosition.put(position, connector) != null) {
|
||||
throw new IOException("Jigsaw Studio capture has duplicate connector position "
|
||||
+ connectorLocation(position));
|
||||
}
|
||||
}
|
||||
|
||||
List<IrisJigsawConnector> ordered = new ArrayList<>(captured.size());
|
||||
Set<LocalPosition> sourcePositions = new HashSet<>(sourceConnectors.size());
|
||||
for (IrisJigsawConnector connector : sourceConnectors) {
|
||||
LocalPosition position = connectorPosition(connector, "source piece");
|
||||
if (!sourcePositions.add(position)) {
|
||||
throw new IOException("Jigsaw Studio source piece has duplicate connector position "
|
||||
+ connectorLocation(position));
|
||||
}
|
||||
IrisJigsawConnector matched = capturedByPosition.remove(position);
|
||||
if (matched != null) {
|
||||
ordered.add(matched);
|
||||
}
|
||||
}
|
||||
|
||||
List<IrisJigsawConnector> appended = new ArrayList<>(capturedByPosition.values());
|
||||
appended.sort(NEW_CONNECTOR_SOURCE_POSITION_ORDER);
|
||||
ordered.addAll(appended);
|
||||
return List.copyOf(ordered);
|
||||
}
|
||||
|
||||
private static LocalPosition connectorPosition(IrisJigsawConnector connector, String owner) throws IOException {
|
||||
if (connector == null || connector.getPosition() == null) {
|
||||
throw new IOException("Jigsaw Studio " + owner + " contains a connector without a position");
|
||||
}
|
||||
IrisPosition position = connector.getPosition();
|
||||
return new LocalPosition(position.getX(), position.getY(), position.getZ());
|
||||
}
|
||||
|
||||
private static String connectorLocation(LocalPosition position) {
|
||||
return position.x() + "," + position.y() + "," + position.z();
|
||||
}
|
||||
|
||||
static LocalPosition inversePosition(
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
int sourceWidth,
|
||||
int sourceDepth,
|
||||
int displayRotationQuarterTurns
|
||||
) {
|
||||
return switch (Math.floorMod(displayRotationQuarterTurns, 4)) {
|
||||
case 0 -> new LocalPosition(x, y, z);
|
||||
case 1 -> new LocalPosition(z, y, sourceDepth - 1 - x);
|
||||
case 2 -> new LocalPosition(sourceWidth - 1 - x, y, sourceDepth - 1 - z);
|
||||
case 3 -> new LocalPosition(sourceWidth - 1 - z, y, x);
|
||||
default -> throw new IllegalStateException("Unreachable Jigsaw Studio inverse rotation");
|
||||
};
|
||||
}
|
||||
|
||||
private static LocalPosition forwardPosition(
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
int sourceWidth,
|
||||
int sourceDepth,
|
||||
int displayRotationQuarterTurns
|
||||
) {
|
||||
return switch (Math.floorMod(displayRotationQuarterTurns, 4)) {
|
||||
case 0 -> new LocalPosition(x, y, z);
|
||||
case 1 -> new LocalPosition(sourceDepth - 1 - z, y, x);
|
||||
case 2 -> new LocalPosition(sourceWidth - 1 - x, y, sourceDepth - 1 - z);
|
||||
case 3 -> new LocalPosition(z, y, sourceWidth - 1 - x);
|
||||
default -> throw new IllegalStateException("Unreachable Jigsaw Studio display rotation");
|
||||
};
|
||||
}
|
||||
|
||||
static void requireWorkcellTopology(
|
||||
JigsawStudioBay workcell,
|
||||
List<IrisJigsawConnector> connectors,
|
||||
int displayRotationQuarterTurns
|
||||
) throws IOException {
|
||||
JigsawPlanarArchetype expected = workcell.archetype().orElse(null);
|
||||
if (expected == null) {
|
||||
return;
|
||||
}
|
||||
int mask = 0;
|
||||
for (IrisJigsawConnector connector : connectors) {
|
||||
mask |= switch (connector.getDirection()) {
|
||||
case NORTH_NEGATIVE_Z -> JigsawPlanarDirection.NORTH.bit();
|
||||
case EAST_POSITIVE_X -> JigsawPlanarDirection.EAST.bit();
|
||||
case SOUTH_POSITIVE_Z -> JigsawPlanarDirection.SOUTH.bit();
|
||||
case WEST_NEGATIVE_X -> JigsawPlanarDirection.WEST.bit();
|
||||
case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> 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 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,
|
||||
int y,
|
||||
int z,
|
||||
BlockData finalData
|
||||
) {
|
||||
Objects.requireNonNull(object, "Jigsaw Studio captured object");
|
||||
BlockData activeFinalData = Objects.requireNonNull(finalData, "Jigsaw connector final state");
|
||||
if (activeFinalData.getMaterial() != Material.STRUCTURE_VOID) {
|
||||
object.setUnsigned(x, y, z, BukkitBlockState.of(activeFinalData));
|
||||
}
|
||||
}
|
||||
|
||||
record ChunkCaptureArea(
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
int minimumX,
|
||||
int maximumX,
|
||||
int minimumZ,
|
||||
int maximumZ
|
||||
) {
|
||||
ChunkCaptureArea {
|
||||
if (minimumX < 0 || minimumZ < 0 || maximumX <= minimumX || maximumZ <= minimumZ) {
|
||||
throw new IllegalArgumentException("Jigsaw Studio chunk capture intersection is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
boolean contains(int x, int z) {
|
||||
return x >= minimumX && x < maximumX && z >= minimumZ && z < maximumZ;
|
||||
}
|
||||
}
|
||||
|
||||
record CapturedBlock(
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
PlatformBlockState state,
|
||||
TileData tileData
|
||||
) {
|
||||
CapturedBlock {
|
||||
Objects.requireNonNull(state, "Jigsaw Studio captured block state");
|
||||
tileData = tileData == null ? null : tileData.clone();
|
||||
}
|
||||
|
||||
public TileData tileData() {
|
||||
return tileData == null ? null : tileData.clone();
|
||||
}
|
||||
}
|
||||
|
||||
record CapturedConnector(
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
IrisDirection direction,
|
||||
IrisDirection top,
|
||||
String pool,
|
||||
String name,
|
||||
String targetName,
|
||||
String channel,
|
||||
JigsawJoint joint,
|
||||
String finalState,
|
||||
int selectionPriority,
|
||||
int placementPriority
|
||||
) {
|
||||
CapturedConnector {
|
||||
Objects.requireNonNull(direction, "Jigsaw Studio captured connector direction");
|
||||
Objects.requireNonNull(top, "Jigsaw Studio captured connector top");
|
||||
Objects.requireNonNull(pool, "Jigsaw Studio captured connector pool");
|
||||
Objects.requireNonNull(name, "Jigsaw Studio captured connector name");
|
||||
Objects.requireNonNull(targetName, "Jigsaw Studio captured connector target");
|
||||
channel = channel == null ? "" : channel;
|
||||
Objects.requireNonNull(joint, "Jigsaw Studio captured connector joint");
|
||||
Objects.requireNonNull(finalState, "Jigsaw Studio captured connector final state");
|
||||
}
|
||||
|
||||
static CapturedConnector from(IrisJigsawConnector connector) {
|
||||
IrisJigsawConnector source = Objects.requireNonNull(connector, "Jigsaw Studio captured connector");
|
||||
IrisPosition position = source.getPosition();
|
||||
return new CapturedConnector(
|
||||
position.getX(),
|
||||
position.getY(),
|
||||
position.getZ(),
|
||||
source.getDirection(),
|
||||
source.getTop(),
|
||||
source.getPool(),
|
||||
source.getName(),
|
||||
source.getTargetName(),
|
||||
source.getChannel(),
|
||||
source.getJoint(),
|
||||
source.getFinalState(),
|
||||
source.getSelectionPriority(),
|
||||
source.getPlacementPriority());
|
||||
}
|
||||
|
||||
IrisJigsawConnector toConnector() {
|
||||
return new IrisJigsawConnector()
|
||||
.setPosition(new IrisPosition(x, y, z))
|
||||
.setDirection(direction)
|
||||
.setTop(top)
|
||||
.setPool(pool)
|
||||
.setName(name)
|
||||
.setTargetName(targetName)
|
||||
.setChannel(channel)
|
||||
.setJoint(joint)
|
||||
.setFinalState(finalState)
|
||||
.setSelectionPriority(selectionPriority)
|
||||
.setPlacementPriority(placementPriority);
|
||||
}
|
||||
}
|
||||
|
||||
record ChunkSnapshot(
|
||||
ChunkCaptureArea area,
|
||||
List<CapturedBlock> blocks,
|
||||
List<CapturedConnector> connectors
|
||||
) {
|
||||
ChunkSnapshot {
|
||||
Objects.requireNonNull(area, "Jigsaw Studio captured chunk area");
|
||||
blocks = List.copyOf(blocks);
|
||||
connectors = List.copyOf(connectors);
|
||||
}
|
||||
}
|
||||
|
||||
record LocalPosition(int x, int y, int z) {
|
||||
}
|
||||
|
||||
record Capture(byte[] objectContent, List<IrisJigsawConnector> connectors, boolean hasBlockEntities) {
|
||||
Capture {
|
||||
objectContent = Objects.requireNonNull(objectContent, "Jigsaw Studio object content").clone();
|
||||
connectors = List.copyOf(connectors);
|
||||
}
|
||||
|
||||
public byte[] objectContent() {
|
||||
return objectContent.clone();
|
||||
}
|
||||
}
|
||||
|
||||
static final class WorkcellTopologyException extends IOException {
|
||||
WorkcellTopologyException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
|
||||
import art.arcane.iris.core.service.JigsawStudioAutosaveScheduler.AutosaveFailureState;
|
||||
import art.arcane.iris.core.service.JigsawStudioAutosaveScheduler.AutosavePersistentFailure;
|
||||
import art.arcane.iris.core.service.JigsawStudioCapture.Capture;
|
||||
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkCaptureArea;
|
||||
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkSnapshot;
|
||||
import art.arcane.iris.core.service.JigsawStudioCapture.WorkcellTopologyException;
|
||||
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
|
||||
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
|
||||
import art.arcane.iris.core.structure.authoring.StructureWriteOptions;
|
||||
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
|
||||
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
|
||||
import art.arcane.iris.engine.object.IrisJigsawConnector;
|
||||
import art.arcane.iris.engine.object.IrisJigsawPiece;
|
||||
import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static art.arcane.iris.core.service.JigsawStudioCapture.aggregateSnapshots;
|
||||
import static art.arcane.iris.core.service.JigsawStudioCapture.captureChunkIntersection;
|
||||
import static art.arcane.iris.core.service.JigsawStudioCapture.chunkIntersections;
|
||||
import static art.arcane.iris.core.service.JigsawStudioCapture.preserveCapturedConnectorOrder;
|
||||
import static art.arcane.iris.core.service.JigsawStudioCapture.requireWorkcellTopology;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.chunkKey;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.message;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.playSaveSound;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.writeFailure;
|
||||
|
||||
final class JigsawStudioCaptureScheduler {
|
||||
private final JigsawStudioService service;
|
||||
|
||||
JigsawStudioCaptureScheduler(JigsawStudioService service) {
|
||||
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
|
||||
}
|
||||
|
||||
boolean scheduleCapture(
|
||||
ActiveStudio studio,
|
||||
World world,
|
||||
JigsawStudioBay bay,
|
||||
CaptureTarget captureTarget,
|
||||
Player player,
|
||||
UUID requestId,
|
||||
JigsawStudioSession.SaveIdentity saveIdentity,
|
||||
AutosaveFailureState autosaveFailureState
|
||||
) {
|
||||
List<ChunkCaptureArea> areas = chunkIntersections(captureTarget.bounds());
|
||||
CaptureWork work = new CaptureWork(
|
||||
studio,
|
||||
world,
|
||||
bay,
|
||||
captureTarget,
|
||||
player,
|
||||
requestId,
|
||||
saveIdentity,
|
||||
autosaveFailureState);
|
||||
CaptureCoordinator coordinator = new CaptureCoordinator(
|
||||
work,
|
||||
areas);
|
||||
for (ChunkCaptureArea area : areas) {
|
||||
if (coordinator.stopped()) {
|
||||
return false;
|
||||
}
|
||||
boolean scheduled = J.runRegion(
|
||||
world,
|
||||
area.chunkX(),
|
||||
area.chunkZ(),
|
||||
() -> captureChunk(coordinator, area));
|
||||
if (!scheduled) {
|
||||
coordinator.fail("Iris could not schedule bay chunk "
|
||||
+ area.chunkX() + "," + area.chunkZ() + " on its owning region.", null);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return !coordinator.stopped();
|
||||
}
|
||||
|
||||
private void captureChunk(CaptureCoordinator coordinator, ChunkCaptureArea area) {
|
||||
if (coordinator.stopped()) {
|
||||
return;
|
||||
}
|
||||
ActiveStudio studio = coordinator.studio();
|
||||
World world = coordinator.world();
|
||||
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
|
||||
coordinator.fail("Jigsaw Studio changed before every bay chunk could be captured; save cancelled.", null);
|
||||
return;
|
||||
}
|
||||
if (!world.isChunkLoaded(area.chunkX(), area.chunkZ())) {
|
||||
coordinator.fail("Bay chunk " + area.chunkX() + "," + area.chunkZ()
|
||||
+ " is not loaded. Visit the whole bay and try again.", null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ChunkSnapshot snapshot = captureChunkIntersection(
|
||||
world,
|
||||
coordinator.captureTarget().bounds(),
|
||||
coordinator.captureTarget().piece(),
|
||||
coordinator.captureTarget().object(),
|
||||
area,
|
||||
coordinator.captureTarget().displayRotationQuarterTurns(),
|
||||
coordinator.captureTarget().connectorsVisible());
|
||||
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
|
||||
coordinator.fail("Jigsaw Studio changed while bay chunks were being captured; save cancelled.", null);
|
||||
return;
|
||||
}
|
||||
coordinator.accept(snapshot);
|
||||
} catch (Throwable exception) {
|
||||
coordinator.fail("Jigsaw Studio capture failed in chunk "
|
||||
+ area.chunkX() + "," + area.chunkZ() + ": " + failureMessage(exception), exception);
|
||||
}
|
||||
}
|
||||
|
||||
void assembleAndPersist(CaptureCoordinator coordinator, List<ChunkSnapshot> snapshots) {
|
||||
if (coordinator.failed()) {
|
||||
return;
|
||||
}
|
||||
ActiveStudio studio = coordinator.studio();
|
||||
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
|
||||
coordinator.fail("Jigsaw Studio changed before captured chunks could be assembled; save cancelled.", null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Capture capture = aggregateSnapshots(
|
||||
coordinator.captureTarget().bounds(),
|
||||
coordinator.areas(),
|
||||
snapshots,
|
||||
coordinator.captureTarget().displayRotationQuarterTurns());
|
||||
List<IrisJigsawConnector> connectors = preserveCapturedConnectorOrder(
|
||||
coordinator.captureTarget().piece(),
|
||||
capture.connectors());
|
||||
requireWorkcellTopology(
|
||||
coordinator.bay(),
|
||||
connectors,
|
||||
coordinator.captureTarget().displayRotationQuarterTurns());
|
||||
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
|
||||
coordinator.fail("Jigsaw Studio changed before the captured bay could be written; save cancelled.", null);
|
||||
return;
|
||||
}
|
||||
message(coordinator.player(), "Captured " + connectors.size()
|
||||
+ " 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),
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void persistCapture(
|
||||
CaptureCoordinator coordinator,
|
||||
Capture capture,
|
||||
List<IrisJigsawConnector> connectors
|
||||
) {
|
||||
ActiveStudio studio = coordinator.studio();
|
||||
JigsawStudioBay bay = coordinator.bay();
|
||||
Player player = coordinator.player();
|
||||
try {
|
||||
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
|
||||
message(player, "Jigsaw Studio changed before the captured bay could be written; save cancelled.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioActivation.Request request = studio.generator().getRequest();
|
||||
IrisData source = request.source();
|
||||
Path packRoot = source.getDataFolder().toPath();
|
||||
JigsawStudioResourceBundleAssembler.Assembly assembly =
|
||||
JigsawStudioResourceBundleAssembler.assemble(
|
||||
packRoot,
|
||||
request.structureKey(),
|
||||
coordinator.saveIdentity().variantKey(),
|
||||
capture.objectContent(),
|
||||
connectors,
|
||||
capture.hasBlockEntities());
|
||||
StructureResourceBundleGraphCompiler.requireViable(assembly.bundle());
|
||||
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
|
||||
message(player, "Jigsaw Studio changed during validation; save cancelled before writing.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioHistoryStore historyStore = new JigsawStudioHistoryStore(
|
||||
packRoot,
|
||||
request.structureKey());
|
||||
JigsawStudioHistoryStore.Snapshot previous = historyStore.snapshotCurrent(
|
||||
coordinator.saveIdentity().variantKey());
|
||||
if (!previous.matches(assembly.bundle())) {
|
||||
historyStore.append(previous);
|
||||
}
|
||||
StructureWriteResult result;
|
||||
synchronized (service.saveLifecycleLock) {
|
||||
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
|
||||
message(player, "Jigsaw Studio changed before the validated save entered the writer; save cancelled.");
|
||||
return;
|
||||
}
|
||||
result = new StructureTransactionWriter(packRoot)
|
||||
.write(assembly.bundle(), StructureWriteOptions.overwriteExpected(
|
||||
assembly.expectedManifestHash()));
|
||||
}
|
||||
if (!result.successful()) {
|
||||
String failure = writeFailure(result);
|
||||
coordinator.recordPersistentFailure(failure, result.failure().orElse(null));
|
||||
message(player, failure);
|
||||
return;
|
||||
}
|
||||
source.invalidateStructureResources();
|
||||
studio.generator().invalidateRender(bay.stableId(), coordinator.saveIdentity().variantKey());
|
||||
JigsawStudioSession session = studio.generator().getSession();
|
||||
boolean unchanged = session.markWorkcellSaved(coordinator.saveIdentity());
|
||||
try {
|
||||
service.reloadSessionLayout(studio);
|
||||
} catch (IOException | RuntimeException refreshFailure) {
|
||||
IrisLogging.reportError(refreshFailure);
|
||||
message(player, "The graph saved, but Studio could not refresh its variant catalog. "
|
||||
+ "Close and reopen this project before continuing: "
|
||||
+ failureMessage(refreshFailure));
|
||||
}
|
||||
String cleanup = result.status() == StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED
|
||||
? " The graph committed, but transaction cleanup requires operator attention in the console."
|
||||
: "";
|
||||
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) {
|
||||
service.evaluator.scheduleEvaluation(studio);
|
||||
}
|
||||
IrisLogging.debug("Jigsaw Studio saved: structure=%s piece=%s object=%s connectors=%d status=%s",
|
||||
request.structureKey(), coordinator.saveIdentity().variantKey(), assembly.objectKey(),
|
||||
connectors.size(), result.status());
|
||||
} catch (Throwable exception) {
|
||||
String failure = "Jigsaw Studio save failed: " + failureMessage(exception);
|
||||
coordinator.recordPersistentFailure(failure, exception);
|
||||
message(player, failure);
|
||||
} finally {
|
||||
coordinator.complete();
|
||||
}
|
||||
}
|
||||
|
||||
static Set<Long> requiredChunks(
|
||||
JigsawStudioBay bay,
|
||||
JigsawStudioGenerator.RenderedBay rendered
|
||||
) {
|
||||
Set<Long> chunks = new HashSet<>();
|
||||
if (!rendered.valid()) {
|
||||
return chunks;
|
||||
}
|
||||
JigsawStudioBounds bounds = bay.bounds();
|
||||
int minimumChunkX = bounds.originX() >> 4;
|
||||
int maximumChunkX = bounds.maxX() >> 4;
|
||||
int minimumChunkZ = bounds.originZ() >> 4;
|
||||
int maximumChunkZ = bounds.maxZ() >> 4;
|
||||
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
|
||||
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
|
||||
chunks.add(chunkKey(chunkX, chunkZ));
|
||||
}
|
||||
}
|
||||
return Set.copyOf(chunks);
|
||||
}
|
||||
|
||||
static CaptureTarget resolveCaptureTarget(
|
||||
ActiveStudio studio,
|
||||
JigsawStudioBay bay,
|
||||
JigsawStudioVariant variant
|
||||
)
|
||||
throws IOException {
|
||||
IrisData source = studio.generator().getRequest().source();
|
||||
JigsawStudioLayout currentLayout = studio.generator().getSession().layout();
|
||||
JigsawStudioBay currentBay = currentLayout.get(bay.stableId());
|
||||
if (currentBay == null) {
|
||||
throw new IOException("workcell '" + bay.stableId() + "' is absent from the current Studio layout");
|
||||
}
|
||||
JigsawStudioVariant activeVariant = Objects.requireNonNull(variant, "Jigsaw Studio capture variant");
|
||||
if (!currentLayout.accepts(currentBay, activeVariant)) {
|
||||
throw new IOException("variant '" + activeVariant.pieceKey()
|
||||
+ "' does not belong to workcell '" + currentBay.stableId() + "'");
|
||||
}
|
||||
IrisJigsawPiece piece = source.getJigsawPieceLoader().load(activeVariant.pieceKey(), false);
|
||||
if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) {
|
||||
throw new IOException("piece '" + activeVariant.pieceKey() + "' is missing or has no object");
|
||||
}
|
||||
IrisObject object = source.getObjectLoader().load(piece.getObject(), false);
|
||||
if (object == null || object.getW() < 1 || object.getH() < 1 || object.getD() < 1) {
|
||||
throw new IOException("object '" + piece.getObject() + "' is missing or has invalid dimensions");
|
||||
}
|
||||
JigsawStudioBounds bayBounds = currentBay.bounds();
|
||||
JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions(
|
||||
object.getW(), object.getH(), object.getD());
|
||||
JigsawStudioCellDimensions canonicalDimensions = activeVariant.canonicalDimensions(sourceDimensions);
|
||||
if (canonicalDimensions.width() > bayBounds.dimensions().width()
|
||||
|| canonicalDimensions.height() > bayBounds.dimensions().height()
|
||||
|| canonicalDimensions.depth() > bayBounds.dimensions().depth()) {
|
||||
throw new IOException("object '" + piece.getObject() + "' is "
|
||||
+ canonicalDimensions.width() + "x" + canonicalDimensions.height() + "x"
|
||||
+ canonicalDimensions.depth() + " in its displayed orientation"
|
||||
+ " but bay '" + currentBay.stableId() + "' is only "
|
||||
+ bayBounds.dimensions().width() + "x" + bayBounds.dimensions().height() + "x"
|
||||
+ bayBounds.dimensions().depth());
|
||||
}
|
||||
return new CaptureTarget(
|
||||
new JigsawStudioBounds(
|
||||
bayBounds.originX(),
|
||||
bayBounds.originY(),
|
||||
bayBounds.originZ(),
|
||||
canonicalDimensions),
|
||||
piece,
|
||||
object,
|
||||
activeVariant.sourceToCanonicalQuarterTurns(),
|
||||
studio.generator().getSession().workcellSnapshot(currentBay.stableId()).connectorsVisible());
|
||||
}
|
||||
|
||||
CaptureCoordinator coordinator(CaptureWork work, List<ChunkCaptureArea> areas) {
|
||||
return new CaptureCoordinator(work, areas);
|
||||
}
|
||||
|
||||
final class CaptureCoordinator {
|
||||
private final CaptureWork work;
|
||||
private final List<ChunkCaptureArea> areas;
|
||||
private final Map<Long, ChunkSnapshot> snapshots = new ConcurrentHashMap<>();
|
||||
private final AtomicInteger remaining;
|
||||
private final AtomicBoolean failed = new AtomicBoolean(false);
|
||||
private final AtomicBoolean assemblyStarted = new AtomicBoolean(false);
|
||||
private final AtomicBoolean operationFinished = new AtomicBoolean(false);
|
||||
private volatile AutosavePersistentFailure persistentFailure;
|
||||
|
||||
CaptureCoordinator(CaptureWork work, List<ChunkCaptureArea> areas) {
|
||||
this.work = Objects.requireNonNull(work, "Jigsaw Studio capture work");
|
||||
this.areas = List.copyOf(areas);
|
||||
this.remaining = new AtomicInteger(this.areas.size());
|
||||
}
|
||||
|
||||
private ActiveStudio studio() {
|
||||
return work.studio();
|
||||
}
|
||||
|
||||
private World world() {
|
||||
return work.world();
|
||||
}
|
||||
|
||||
private JigsawStudioBay bay() {
|
||||
return work.bay();
|
||||
}
|
||||
|
||||
private CaptureTarget captureTarget() {
|
||||
return work.captureTarget();
|
||||
}
|
||||
|
||||
private Player player() {
|
||||
return work.player();
|
||||
}
|
||||
|
||||
private UUID requestId() {
|
||||
return work.requestId();
|
||||
}
|
||||
|
||||
private JigsawStudioSession.SaveIdentity saveIdentity() {
|
||||
return work.saveIdentity();
|
||||
}
|
||||
|
||||
private List<ChunkCaptureArea> areas() {
|
||||
return areas;
|
||||
}
|
||||
|
||||
private boolean stopped() {
|
||||
return failed.get();
|
||||
}
|
||||
|
||||
private boolean failed() {
|
||||
return failed.get();
|
||||
}
|
||||
|
||||
private void accept(ChunkSnapshot snapshot) {
|
||||
if (failed.get() || assemblyStarted.get()) {
|
||||
return;
|
||||
}
|
||||
ChunkCaptureArea area = snapshot.area();
|
||||
long key = chunkKey(area.chunkX(), area.chunkZ());
|
||||
if (snapshots.putIfAbsent(key, snapshot) != null) {
|
||||
fail("Jigsaw Studio received duplicate snapshot for bay chunk "
|
||||
+ area.chunkX() + "," + area.chunkZ() + ".", null);
|
||||
return;
|
||||
}
|
||||
int incomplete = remaining.decrementAndGet();
|
||||
if (incomplete < 0) {
|
||||
fail("Jigsaw Studio received more bay chunk snapshots than expected.", null);
|
||||
return;
|
||||
}
|
||||
if (incomplete == 0 && assemblyStarted.compareAndSet(false, true)) {
|
||||
List<ChunkSnapshot> completed = List.copyOf(snapshots.values());
|
||||
J.a(() -> assembleAndPersist(this, completed));
|
||||
}
|
||||
}
|
||||
|
||||
void fail(String failure, Throwable exception) {
|
||||
if (!failed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
if (exception != null) {
|
||||
IrisLogging.reportError(exception);
|
||||
}
|
||||
complete();
|
||||
message(player(), failure);
|
||||
}
|
||||
|
||||
private void failPersistent(String failure, Throwable exception) {
|
||||
if (!failed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
recordPersistentFailure(failure, exception);
|
||||
complete();
|
||||
message(player(), failure);
|
||||
}
|
||||
|
||||
private void recordPersistentFailure(String failure, Throwable exception) {
|
||||
persistentFailure = new AutosavePersistentFailure(failure, exception);
|
||||
}
|
||||
|
||||
private void complete() {
|
||||
if (operationFinished.compareAndSet(false, true)) {
|
||||
studio().generator().getSession().abortSave(saveIdentity());
|
||||
service.saveLifecycle.finishSave(requestId());
|
||||
service.autosaveScheduler.completeAutosaveAttempt(
|
||||
studio(),
|
||||
saveIdentity(),
|
||||
work.autosaveFailureState(),
|
||||
persistentFailure);
|
||||
service.playerContext.refreshWorkcellContext(studio().worldId(), saveIdentity().workcellId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record CaptureWork(
|
||||
ActiveStudio studio,
|
||||
World world,
|
||||
JigsawStudioBay bay,
|
||||
CaptureTarget captureTarget,
|
||||
Player player,
|
||||
UUID requestId,
|
||||
JigsawStudioSession.SaveIdentity saveIdentity,
|
||||
AutosaveFailureState autosaveFailureState
|
||||
) {
|
||||
CaptureWork {
|
||||
Objects.requireNonNull(studio, "Jigsaw Studio capture studio");
|
||||
Objects.requireNonNull(world, "Jigsaw Studio capture world");
|
||||
Objects.requireNonNull(bay, "Jigsaw Studio capture bay");
|
||||
Objects.requireNonNull(captureTarget, "Jigsaw Studio capture target");
|
||||
Objects.requireNonNull(requestId, "Jigsaw Studio capture request ID");
|
||||
Objects.requireNonNull(saveIdentity, "Jigsaw Studio capture save identity");
|
||||
Objects.requireNonNull(autosaveFailureState, "Jigsaw Studio capture autosave failure state");
|
||||
}
|
||||
}
|
||||
|
||||
record CaptureTarget(
|
||||
JigsawStudioBounds bounds,
|
||||
IrisJigsawPiece piece,
|
||||
IrisObject object,
|
||||
int displayRotationQuarterTurns,
|
||||
boolean connectorsVisible
|
||||
) {
|
||||
CaptureTarget {
|
||||
Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds");
|
||||
Objects.requireNonNull(piece, "Jigsaw Studio capture piece");
|
||||
Objects.requireNonNull(object, "Jigsaw Studio capture object");
|
||||
displayRotationQuarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
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.JigsawStudioCellDimensions;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMarkerKeyCodec;
|
||||
import art.arcane.iris.core.service.JigsawStudioCapture.CapturedConnector;
|
||||
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkCaptureArea;
|
||||
import art.arcane.iris.core.service.JigsawStudioCapture.LocalPosition;
|
||||
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.BayReadiness;
|
||||
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
|
||||
import art.arcane.iris.engine.object.IrisJigsawConnector;
|
||||
import art.arcane.iris.engine.object.IrisJigsawPiece;
|
||||
import art.arcane.iris.engine.object.IrisObjectRotation;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
import art.arcane.iris.engine.object.TileData;
|
||||
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.block.data.type.Jigsaw;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static art.arcane.iris.core.service.JigsawStudioCapture.inversePosition;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.chunkKey;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
|
||||
|
||||
final class JigsawStudioChunkWriter {
|
||||
private JigsawStudioChunkWriter() {
|
||||
}
|
||||
|
||||
static void restoreConnectorChunk(
|
||||
World world,
|
||||
JigsawStudioBay workcell,
|
||||
List<JigsawStudioGenerator.RenderedConnector> connectors,
|
||||
Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> renderedBlocks,
|
||||
boolean connectorsVisible
|
||||
) throws IOException {
|
||||
JigsawStudioBounds bounds = workcell.bounds();
|
||||
for (JigsawStudioGenerator.RenderedConnector connector : connectors) {
|
||||
LocalPosition position = new LocalPosition(connector.x(), connector.y(), connector.z());
|
||||
Block target = world.getBlockAt(
|
||||
bounds.originX() + connector.x(),
|
||||
bounds.originY() + connector.y(),
|
||||
bounds.originZ() + connector.z());
|
||||
if (connectorsVisible) {
|
||||
BlockData marker;
|
||||
try {
|
||||
marker = Bukkit.createBlockData(
|
||||
"minecraft:jigsaw[orientation=" + connector.orientation() + "]");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid saved connector orientation '"
|
||||
+ connector.orientation() + "'", exception);
|
||||
}
|
||||
target.setBlockData(marker, false);
|
||||
BukkitPlatform.deserializeTile(markerNbt(connector.connector()), target.getLocation());
|
||||
continue;
|
||||
}
|
||||
JigsawStudioGenerator.RenderedBlock renderedBlock = renderedBlocks.get(position);
|
||||
BlockData restored;
|
||||
if (renderedBlock != null) {
|
||||
if (renderedBlock.state().isCustom()
|
||||
|| !(renderedBlock.state().nativeHandle() instanceof BlockData blockData)) {
|
||||
throw new IOException("Saved connector block '" + renderedBlock.state().key()
|
||||
+ "' cannot be restored directly in Bukkit Studio");
|
||||
}
|
||||
restored = blockData;
|
||||
} else {
|
||||
try {
|
||||
restored = Bukkit.createBlockData(connector.connector().getFinalState());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid saved connector final state '"
|
||||
+ connector.connector().getFinalState() + "'", exception);
|
||||
}
|
||||
}
|
||||
target.setBlockData(restored, false);
|
||||
if (renderedBlock != null && renderedBlock.tileData() != null) {
|
||||
TileData tileData = renderedBlock.tileData();
|
||||
if (!tileData.isApplicable(target.getBlockData()) || !tileData.toBukkitTry(target)) {
|
||||
throw new IOException("Saved connector tile data could not be restored at "
|
||||
+ target.getX() + "," + target.getY() + "," + target.getZ());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String validateMaterialization(JigsawStudioGenerator.RenderedBay rendered) {
|
||||
if (!rendered.valid()) {
|
||||
return rendered.failure();
|
||||
}
|
||||
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
|
||||
PlatformBlockState state = block.state();
|
||||
if (state.isCustom()) {
|
||||
return "custom block '" + state.key()
|
||||
+ "' requires provider-owned placement and cannot be swapped live in Studio";
|
||||
}
|
||||
if (!(state.nativeHandle() instanceof BlockData)) {
|
||||
return "block '" + state.key() + "' has no Bukkit block-data representation";
|
||||
}
|
||||
}
|
||||
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
|
||||
try {
|
||||
BlockData data = Bukkit.createBlockData(
|
||||
"minecraft:jigsaw[orientation=" + connector.orientation() + "]");
|
||||
if (!(data instanceof Jigsaw)) {
|
||||
return "connector orientation '" + connector.orientation()
|
||||
+ "' did not resolve to a jigsaw marker";
|
||||
}
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return "connector orientation '" + connector.orientation() + "' is invalid";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static void writeMaterializedChunk(
|
||||
World world,
|
||||
JigsawStudioBay workcell,
|
||||
JigsawStudioGenerator.RenderedBay rendered,
|
||||
ChunkCaptureArea area,
|
||||
boolean connectorsVisible
|
||||
) throws IOException {
|
||||
JigsawStudioBounds bounds = workcell.bounds();
|
||||
Map<LocalPosition, BlockData> expected = materializedBlockData(rendered, connectorsVisible);
|
||||
BlockData air = Material.AIR.createBlockData();
|
||||
for (int x = area.minimumX(); x < area.maximumX(); x++) {
|
||||
for (int y = 0; y < bounds.dimensions().height(); y++) {
|
||||
for (int z = area.minimumZ(); z < area.maximumZ(); z++) {
|
||||
Block target = world.getBlockAt(
|
||||
bounds.originX() + x,
|
||||
bounds.originY() + y,
|
||||
bounds.originZ() + z);
|
||||
BlockData blockData = expected.getOrDefault(new LocalPosition(x, y, z), air);
|
||||
target.setBlockData(blockData, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
applyRenderedBayChunk(world, workcell, rendered, area.chunkX(), area.chunkZ(), connectorsVisible);
|
||||
verifyMaterializedChunk(world, workcell, rendered, area, expected, connectorsVisible);
|
||||
}
|
||||
|
||||
private static Map<LocalPosition, BlockData> materializedBlockData(
|
||||
JigsawStudioGenerator.RenderedBay rendered,
|
||||
boolean connectorsVisible
|
||||
) throws IOException {
|
||||
Map<LocalPosition, BlockData> expected = new HashMap<>();
|
||||
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
|
||||
if (block.state().isCustom() || !(block.state().nativeHandle() instanceof BlockData blockData)) {
|
||||
throw new IOException("Rendered block '" + block.state().key()
|
||||
+ "' cannot be placed directly in Bukkit Studio");
|
||||
}
|
||||
expected.put(new LocalPosition(block.x(), block.y(), block.z()), blockData);
|
||||
}
|
||||
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
|
||||
if (!connectorsVisible && expected.containsKey(
|
||||
new LocalPosition(connector.x(), connector.y(), connector.z()))) {
|
||||
continue;
|
||||
}
|
||||
BlockData marker;
|
||||
try {
|
||||
marker = Bukkit.createBlockData(connectorsVisible
|
||||
? "minecraft:jigsaw[orientation=" + connector.orientation() + "]"
|
||||
: connector.connector().getFinalState());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid rendered connector orientation '"
|
||||
+ connector.orientation() + "'", exception);
|
||||
}
|
||||
expected.put(new LocalPosition(connector.x(), connector.y(), connector.z()), marker);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
private static void verifyMaterializedChunk(
|
||||
World world,
|
||||
JigsawStudioBay workcell,
|
||||
JigsawStudioGenerator.RenderedBay rendered,
|
||||
ChunkCaptureArea area,
|
||||
Map<LocalPosition, BlockData> expected,
|
||||
boolean connectorsVisible
|
||||
) throws IOException {
|
||||
JigsawStudioBounds bounds = workcell.bounds();
|
||||
BlockData air = Material.AIR.createBlockData();
|
||||
for (int x = area.minimumX(); x < area.maximumX(); x++) {
|
||||
for (int y = 0; y < bounds.dimensions().height(); y++) {
|
||||
for (int z = area.minimumZ(); z < area.maximumZ(); z++) {
|
||||
Block target = world.getBlockAt(
|
||||
bounds.originX() + x,
|
||||
bounds.originY() + y,
|
||||
bounds.originZ() + z);
|
||||
BlockData expectedData = expected.getOrDefault(new LocalPosition(x, y, z), air);
|
||||
if (!target.getBlockData().equals(expectedData)) {
|
||||
throw new IOException("Workcell block did not materialize at "
|
||||
+ target.getX() + "," + target.getY() + "," + target.getZ());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
verifyRenderedBayChunk(world, workcell, rendered, area.chunkX(), area.chunkZ(), connectorsVisible);
|
||||
}
|
||||
|
||||
static boolean hydrateChunk(
|
||||
ActiveStudio studio,
|
||||
World world,
|
||||
int chunkX,
|
||||
int chunkZ
|
||||
) {
|
||||
long key = chunkKey(chunkX, chunkZ);
|
||||
boolean verificationRequired = false;
|
||||
for (JigsawStudioBay bay : studio.generator().getLayout().bays()) {
|
||||
BayPopulation population = studio.population(bay);
|
||||
if (!population.needsApplication(key) && !population.needsVerification(key)) {
|
||||
continue;
|
||||
}
|
||||
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(bay);
|
||||
if (!rendered.valid()) {
|
||||
population.fail(rendered.failure());
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (population.needsApplication(key)) {
|
||||
applyRenderedBayChunk(
|
||||
world,
|
||||
bay,
|
||||
rendered,
|
||||
chunkX,
|
||||
chunkZ,
|
||||
studio.generator().getSession().workcellSnapshot(bay.stableId()).connectorsVisible());
|
||||
population.markApplied(key);
|
||||
verificationRequired = true;
|
||||
} else {
|
||||
verifyRenderedBayChunk(
|
||||
world,
|
||||
bay,
|
||||
rendered,
|
||||
chunkX,
|
||||
chunkZ,
|
||||
studio.generator().getSession().workcellSnapshot(bay.stableId()).connectorsVisible());
|
||||
population.markHydrated(key);
|
||||
}
|
||||
} catch (Throwable exception) {
|
||||
if (population.fail("chunk " + chunkX + "," + chunkZ
|
||||
+ " could not hydrate: " + failureMessage(exception))) {
|
||||
IrisLogging.reportError(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
return verificationRequired;
|
||||
}
|
||||
|
||||
static void applyRenderedBayChunk(
|
||||
World world,
|
||||
JigsawStudioBay bay,
|
||||
JigsawStudioGenerator.RenderedBay rendered,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
boolean connectorsVisible
|
||||
) throws IOException {
|
||||
JigsawStudioBounds bounds = bay.bounds();
|
||||
Set<LocalPosition> connectorPositions = new HashSet<>(rendered.connectors().size());
|
||||
if (connectorsVisible) {
|
||||
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
|
||||
connectorPositions.add(new LocalPosition(connector.x(), connector.y(), connector.z()));
|
||||
}
|
||||
}
|
||||
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
|
||||
TileData tileData = block.tileData();
|
||||
if (tileData == null
|
||||
|| connectorPositions.contains(new LocalPosition(block.x(), block.y(), block.z()))) {
|
||||
continue;
|
||||
}
|
||||
int worldX = bounds.originX() + block.x();
|
||||
int worldZ = bounds.originZ() + block.z();
|
||||
if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) {
|
||||
continue;
|
||||
}
|
||||
Block target = world.getBlockAt(worldX, bounds.originY() + block.y(), worldZ);
|
||||
if (!tileData.isApplicable(target.getBlockData())) {
|
||||
throw new IOException("source tile state does not match rendered block at "
|
||||
+ target.getX() + "," + target.getY() + "," + target.getZ());
|
||||
}
|
||||
if (!tileData.toBukkitTry(target)) {
|
||||
throw new IOException("source tile state could not hydrate at "
|
||||
+ target.getX() + "," + target.getY() + "," + target.getZ());
|
||||
}
|
||||
}
|
||||
if (!connectorsVisible) {
|
||||
return;
|
||||
}
|
||||
for (JigsawStudioGenerator.RenderedConnector renderedConnector : rendered.connectors()) {
|
||||
int worldX = bounds.originX() + renderedConnector.x();
|
||||
int worldZ = bounds.originZ() + renderedConnector.z();
|
||||
if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) {
|
||||
continue;
|
||||
}
|
||||
Block target = world.getBlockAt(
|
||||
worldX,
|
||||
bounds.originY() + renderedConnector.y(),
|
||||
worldZ);
|
||||
BlockData blockData = target.getBlockData();
|
||||
if (!(blockData instanceof Jigsaw jigsaw)) {
|
||||
throw new IOException("expected a jigsaw marker at "
|
||||
+ target.getX() + "," + target.getY() + "," + target.getZ());
|
||||
}
|
||||
String actualOrientation = jigsaw.getOrientation().name().toLowerCase(Locale.ROOT);
|
||||
if (!actualOrientation.equals(renderedConnector.orientation())) {
|
||||
throw new IOException("jigsaw marker orientation at "
|
||||
+ target.getX() + "," + target.getY() + "," + target.getZ()
|
||||
+ " is " + actualOrientation + " instead of " + renderedConnector.orientation());
|
||||
}
|
||||
KMap<String, Object> expected = markerNbt(renderedConnector.connector());
|
||||
BukkitPlatform.deserializeTile(expected, target.getLocation());
|
||||
}
|
||||
}
|
||||
|
||||
static void verifyRenderedBayChunk(
|
||||
World world,
|
||||
JigsawStudioBay bay,
|
||||
JigsawStudioGenerator.RenderedBay rendered,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
boolean connectorsVisible
|
||||
) throws IOException {
|
||||
JigsawStudioBounds bounds = bay.bounds();
|
||||
Set<LocalPosition> connectorPositions = new HashSet<>(rendered.connectors().size());
|
||||
if (connectorsVisible) {
|
||||
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
|
||||
connectorPositions.add(new LocalPosition(connector.x(), connector.y(), connector.z()));
|
||||
}
|
||||
}
|
||||
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
|
||||
TileData tileData = block.tileData();
|
||||
if (tileData == null
|
||||
|| connectorPositions.contains(new LocalPosition(block.x(), block.y(), block.z()))) {
|
||||
continue;
|
||||
}
|
||||
int worldX = bounds.originX() + block.x();
|
||||
int worldZ = bounds.originZ() + block.z();
|
||||
if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) {
|
||||
continue;
|
||||
}
|
||||
Block target = world.getBlockAt(worldX, bounds.originY() + block.y(), worldZ);
|
||||
if (!tileData.isApplicable(target.getBlockData())) {
|
||||
throw new IOException("hydrated tile state no longer matches its block at "
|
||||
+ target.getX() + "," + target.getY() + "," + target.getZ());
|
||||
}
|
||||
KMap<String, Object> hydrated = BukkitPlatform.serializeTile(target.getLocation());
|
||||
if (hydrated == null
|
||||
|| tileData.getProperties() != null
|
||||
&& !nbtContains(tileData.getProperties(), hydrated)) {
|
||||
throw new IOException("the active NMS binding did not preserve source tile NBT at "
|
||||
+ target.getX() + "," + target.getY() + "," + target.getZ());
|
||||
}
|
||||
}
|
||||
if (!connectorsVisible) {
|
||||
return;
|
||||
}
|
||||
for (JigsawStudioGenerator.RenderedConnector renderedConnector : rendered.connectors()) {
|
||||
int worldX = bounds.originX() + renderedConnector.x();
|
||||
int worldZ = bounds.originZ() + renderedConnector.z();
|
||||
if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) {
|
||||
continue;
|
||||
}
|
||||
Block target = world.getBlockAt(
|
||||
worldX,
|
||||
bounds.originY() + renderedConnector.y(),
|
||||
worldZ);
|
||||
BlockData blockData = target.getBlockData();
|
||||
if (!(blockData instanceof Jigsaw jigsaw)
|
||||
|| !jigsaw.getOrientation().name().toLowerCase(Locale.ROOT)
|
||||
.equals(renderedConnector.orientation())) {
|
||||
throw new IOException("jigsaw marker changed before hydration completed at "
|
||||
+ target.getX() + "," + target.getY() + "," + target.getZ());
|
||||
}
|
||||
KMap<String, Object> expected = markerNbt(renderedConnector.connector());
|
||||
KMap<String, Object> hydrated = BukkitPlatform.serializeTile(target.getLocation());
|
||||
if (!markerNbtMatches(expected, hydrated)) {
|
||||
throw new IOException("the active NMS binding did not preserve jigsaw marker NBT at "
|
||||
+ target.getX() + "," + target.getY() + "," + target.getZ());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static KMap<String, Object> markerNbt(IrisJigsawConnector connector) {
|
||||
IrisJigsawConnector activeConnector = Objects.requireNonNull(connector, "Jigsaw Studio connector");
|
||||
KMap<String, Object> nbt = new KMap<>();
|
||||
nbt.put("name", studioIdentifier(activeConnector.getName()));
|
||||
nbt.put("target", studioIdentifier(activeConnector.getTargetName()));
|
||||
nbt.put("pool", JigsawStudioMarkerKeyCodec.encodePool(activeConnector.getPool()));
|
||||
nbt.put("final_state", activeConnector.getFinalState());
|
||||
nbt.put("joint", activeConnector.getJoint().name().toLowerCase(Locale.ROOT));
|
||||
nbt.put("selection_priority", activeConnector.getSelectionPriority());
|
||||
nbt.put("placement_priority", activeConnector.getPlacementPriority());
|
||||
return nbt;
|
||||
}
|
||||
|
||||
private static boolean markerNbtMatches(
|
||||
Map<String, Object> expected,
|
||||
Map<String, Object> hydrated
|
||||
) {
|
||||
if (hydrated == null) {
|
||||
return false;
|
||||
}
|
||||
for (Map.Entry<String, Object> entry : expected.entrySet()) {
|
||||
Object actual = hydrated.get(entry.getKey());
|
||||
if (entry.getValue() instanceof Number expectedNumber) {
|
||||
if (!(actual instanceof Number actualNumber)
|
||||
|| expectedNumber.longValue() != actualNumber.longValue()) {
|
||||
return false;
|
||||
}
|
||||
} else if (!Objects.equals(entry.getValue(), actual)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean nbtContains(Object expected, Object actual) {
|
||||
if (expected instanceof Number expectedNumber) {
|
||||
if (!(actual instanceof Number actualNumber)) {
|
||||
return false;
|
||||
}
|
||||
if (isIntegral(expectedNumber) && isIntegral(actualNumber)) {
|
||||
return expectedNumber.longValue() == actualNumber.longValue();
|
||||
}
|
||||
return Double.compare(expectedNumber.doubleValue(), actualNumber.doubleValue()) == 0;
|
||||
}
|
||||
if (expected instanceof Map<?, ?> expectedMap) {
|
||||
if (!(actual instanceof Map<?, ?> actualMap)) {
|
||||
return false;
|
||||
}
|
||||
for (Map.Entry<?, ?> entry : expectedMap.entrySet()) {
|
||||
if (!actualMap.containsKey(entry.getKey())
|
||||
|| !nbtContains(entry.getValue(), actualMap.get(entry.getKey()))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (expected instanceof List<?> expectedList) {
|
||||
if (!(actual instanceof List<?> actualList) || expectedList.size() != actualList.size()) {
|
||||
return false;
|
||||
}
|
||||
for (int index = 0; index < expectedList.size(); index++) {
|
||||
if (!nbtContains(expectedList.get(index), actualList.get(index))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return Objects.equals(expected, actual);
|
||||
}
|
||||
|
||||
private static boolean isIntegral(Number number) {
|
||||
return number instanceof Byte
|
||||
|| number instanceof Short
|
||||
|| number instanceof Integer
|
||||
|| number instanceof Long;
|
||||
}
|
||||
|
||||
static void restoreCapturedMetadata(
|
||||
IrisJigsawConnector captured,
|
||||
IrisJigsawPiece sourcePiece
|
||||
) {
|
||||
IrisJigsawConnector activeCaptured = Objects.requireNonNull(captured, "Captured jigsaw connector");
|
||||
IrisJigsawConnector original = connectorAt(sourcePiece, activeCaptured.getPosition());
|
||||
if (original == null) {
|
||||
return;
|
||||
}
|
||||
activeCaptured.setName(restoreIdentifier(activeCaptured.getName(), original.getName()));
|
||||
activeCaptured.setTargetName(restoreIdentifier(
|
||||
activeCaptured.getTargetName(),
|
||||
original.getTargetName()));
|
||||
activeCaptured.setChannel(original.getChannel() == null ? "" : original.getChannel());
|
||||
}
|
||||
|
||||
static void restoreCapturedMetadataForDisplay(
|
||||
IrisJigsawConnector captured,
|
||||
IrisJigsawPiece sourcePiece,
|
||||
JigsawStudioCellDimensions displayDimensions,
|
||||
int displayRotationQuarterTurns
|
||||
) {
|
||||
if (Math.floorMod(displayRotationQuarterTurns, 4) == 0) {
|
||||
restoreCapturedMetadata(captured, sourcePiece);
|
||||
return;
|
||||
}
|
||||
int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
|
||||
int sourceWidth = (quarterTurns & 1) == 0
|
||||
? displayDimensions.width()
|
||||
: displayDimensions.depth();
|
||||
int sourceDepth = (quarterTurns & 1) == 0
|
||||
? displayDimensions.depth()
|
||||
: displayDimensions.width();
|
||||
LocalPosition position = inversePosition(
|
||||
captured.getPosition().getX(),
|
||||
captured.getPosition().getY(),
|
||||
captured.getPosition().getZ(),
|
||||
sourceWidth,
|
||||
sourceDepth,
|
||||
quarterTurns);
|
||||
IrisObjectRotation inverseRotation = IrisObjectRotation.of(0, 90.0D * quarterTurns, 0);
|
||||
IrisJigsawConnector sourceOriented = CapturedConnector.from(captured).toConnector()
|
||||
.setPosition(new IrisPosition(position.x(), position.y(), position.z()))
|
||||
.setDirection(inverseRotation.rotate(captured.getDirection()))
|
||||
.setTop(inverseRotation.rotate(captured.getTop()));
|
||||
restoreCapturedMetadata(sourceOriented, sourcePiece);
|
||||
captured.setName(sourceOriented.getName());
|
||||
captured.setTargetName(sourceOriented.getTargetName());
|
||||
captured.setChannel(sourceOriented.getChannel());
|
||||
}
|
||||
|
||||
private static IrisJigsawConnector connectorAt(IrisJigsawPiece piece, IrisPosition position) {
|
||||
if (piece == null || piece.getConnectors() == null || position == null) {
|
||||
return null;
|
||||
}
|
||||
for (IrisJigsawConnector connector : piece.getConnectors()) {
|
||||
if (connector != null && position.equals(connector.getPosition())) {
|
||||
return connector;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String restoreIdentifier(String captured, String original) {
|
||||
if (original != null && studioIdentifier(original).equals(captured)) {
|
||||
return original;
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
private static String studioIdentifier(String value) {
|
||||
String normalized = Objects.requireNonNull(value, "Jigsaw Studio marker identifier").trim();
|
||||
return normalized.indexOf(':') < 0 ? "iris:" + normalized : normalized;
|
||||
}
|
||||
|
||||
static final class BayPopulation {
|
||||
private final Set<Long> requiredChunks;
|
||||
private final Set<Long> generatedChunks = ConcurrentHashMap.newKeySet();
|
||||
private final Set<Long> appliedChunks = ConcurrentHashMap.newKeySet();
|
||||
private final Set<Long> hydratedChunks = ConcurrentHashMap.newKeySet();
|
||||
private volatile String failure;
|
||||
|
||||
BayPopulation(Set<Long> requiredChunks, String failure) {
|
||||
this.requiredChunks = Set.copyOf(requiredChunks);
|
||||
this.failure = failure == null ? "" : failure;
|
||||
}
|
||||
|
||||
boolean markGenerated(long chunk) {
|
||||
if (!requiredChunks.contains(chunk)) {
|
||||
return false;
|
||||
}
|
||||
generatedChunks.add(chunk);
|
||||
return !hydratedChunks.contains(chunk) && failure.isEmpty();
|
||||
}
|
||||
|
||||
boolean needsApplication(long chunk) {
|
||||
return failure.isEmpty()
|
||||
&& requiredChunks.contains(chunk)
|
||||
&& generatedChunks.contains(chunk)
|
||||
&& !appliedChunks.contains(chunk);
|
||||
}
|
||||
|
||||
boolean needsVerification(long chunk) {
|
||||
return failure.isEmpty()
|
||||
&& requiredChunks.contains(chunk)
|
||||
&& generatedChunks.contains(chunk)
|
||||
&& appliedChunks.contains(chunk)
|
||||
&& !hydratedChunks.contains(chunk);
|
||||
}
|
||||
|
||||
void markApplied(long chunk) {
|
||||
if (requiredChunks.contains(chunk)) {
|
||||
appliedChunks.add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
void markHydrated(long chunk) {
|
||||
if (requiredChunks.contains(chunk)) {
|
||||
hydratedChunks.add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
void markFullyReady() {
|
||||
generatedChunks.addAll(requiredChunks);
|
||||
appliedChunks.addAll(requiredChunks);
|
||||
hydratedChunks.addAll(requiredChunks);
|
||||
}
|
||||
|
||||
synchronized boolean fail(String reason) {
|
||||
if (!failure.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
failure = reason == null || reason.isBlank() ? "unknown hydration failure" : reason;
|
||||
return true;
|
||||
}
|
||||
|
||||
BayReadiness readiness() {
|
||||
boolean ready = failure.isEmpty()
|
||||
&& !requiredChunks.isEmpty()
|
||||
&& generatedChunks.containsAll(requiredChunks)
|
||||
&& hydratedChunks.containsAll(requiredChunks);
|
||||
return new BayReadiness(
|
||||
ready,
|
||||
failure,
|
||||
requiredChunks.size(),
|
||||
generatedChunks.size(),
|
||||
hydratedChunks.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolAction;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload;
|
||||
import art.arcane.volmlib.util.inventorygui.UIElement;
|
||||
import art.arcane.volmlib.util.inventorygui.UIWindow;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Material;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuController.CHANCE_STEP_PERCENTAGE_POINTS;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuController.GRID_POSITIONS;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuController.MEMBERSHIPS_PER_PAGE;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.chance;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.clampPage;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.dimensions;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.displayKey;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.element;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.evaluationElement;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.maximumPlacements;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.page;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.pageCount;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.safe;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.themes;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.yesNo;
|
||||
|
||||
final class JigsawStudioDetailsMenu {
|
||||
private final JigsawStudioMenuController controller;
|
||||
|
||||
JigsawStudioDetailsMenu(JigsawStudioMenuController controller) {
|
||||
this.controller = Objects.requireNonNull(controller, "Jigsaw Studio menu controller");
|
||||
}
|
||||
|
||||
void render(
|
||||
UIWindow window,
|
||||
JigsawStudioMenuState state,
|
||||
JigsawStudioMenuState.Workcell workcell,
|
||||
JigsawStudioMenuState.Variant variant,
|
||||
int requestedPage
|
||||
) {
|
||||
int page = clampPage(requestedPage, variant.memberships().size(), MEMBERSHIPS_PER_PAGE);
|
||||
controller.purgeExpiredConfirmations(window.getViewer().getUniqueId());
|
||||
window.batch(() -> {
|
||||
window.clearElements();
|
||||
addDetailsHeader(window, state, workcell, variant, page);
|
||||
addMemberships(window, state, workcell, variant, page);
|
||||
addDetailsFooter(window, state, workcell, variant, page);
|
||||
});
|
||||
}
|
||||
|
||||
private void addDetailsHeader(
|
||||
UIWindow window,
|
||||
JigsawStudioMenuState state,
|
||||
JigsawStudioMenuState.Workcell workcell,
|
||||
JigsawStudioMenuState.Variant variant,
|
||||
int page
|
||||
) {
|
||||
UIElement back = element("back", Material.ARROW, ChatColor.YELLOW + "Back to Variants");
|
||||
back.onLeftClick(clicked -> controller.refreshMain(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), 0));
|
||||
window.setElement(-4, 0, back);
|
||||
|
||||
UIElement identity = element("identity", Material.NAME_TAG, ChatColor.AQUA + safe(variant.displayName()));
|
||||
identity.addLore(ChatColor.DARK_GRAY + safe(variant.pieceKey()));
|
||||
identity.addLore(variant.owned()
|
||||
? ChatColor.GREEN + "Owned variant"
|
||||
: ChatColor.GRAY + "Read-only variant");
|
||||
identity.addLore(variant.active()
|
||||
? ChatColor.GREEN + "Loaded in this workcell"
|
||||
: ChatColor.GRAY + "Not currently loaded");
|
||||
identity.addLore(ChatColor.GRAY + "Size: " + variant.dimensions()
|
||||
.map(JigsawStudioMenuFormat::dimensions)
|
||||
.orElse("Object missing"));
|
||||
identity.addLore(ChatColor.GRAY + "Themes: " + themes(variant.themes()));
|
||||
identity.addLore(ChatColor.GRAY + "Depth: " + variant.rules().minimumDepth()
|
||||
+ "-" + variant.rules().maximumDepth());
|
||||
identity.addLore(ChatColor.GRAY + "Placements: " + variant.rules().minimumPlacements()
|
||||
+ "-" + maximumPlacements(variant.rules().maximumPlacements()));
|
||||
identity.addLore(ChatColor.GRAY + "Terminal: " + yesNo(variant.rules().terminal()));
|
||||
identity.addLore(ChatColor.GRAY + "Pool entries: " + variant.memberships().size());
|
||||
if (variant.owned()) {
|
||||
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.");
|
||||
identity.onLeftClick(clicked -> controller.actions.giveTool(
|
||||
window.getViewer(),
|
||||
JigsawStudioToolPayload.variant(
|
||||
JigsawStudioToolAction.RENAME_VARIANT,
|
||||
state.requestId(),
|
||||
workcell.stableId(),
|
||||
variant.pieceKey())));
|
||||
}
|
||||
window.setElement(0, 0, identity);
|
||||
|
||||
boolean activeOwned = variant.active() && variant.owned();
|
||||
boolean irisRuleEditing = activeOwned && state.irisExtended();
|
||||
UIElement settings = element(
|
||||
"variant-settings",
|
||||
irisRuleEditing ? Material.COMPARATOR : Material.GRAY_DYE,
|
||||
irisRuleEditing
|
||||
? ChatColor.GOLD + "Themes & Piece Rules"
|
||||
: ChatColor.GRAY + "Themes & Rules Unavailable");
|
||||
settings.addLore(irisRuleEditing
|
||||
? ChatColor.GRAY + "Edit this loaded variant's theme membership and placement rules."
|
||||
: state.irisExtended()
|
||||
? ChatColor.GRAY + "Load an owned variant before editing its rules."
|
||||
: ChatColor.GRAY + "Vanilla-portable pieces cannot encode Iris theme or rule metadata.");
|
||||
if (irisRuleEditing) {
|
||||
settings.addLore(ChatColor.YELLOW + "Left-click to edit");
|
||||
settings.onLeftClick(clicked -> controller.openVariantSettings(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), 0));
|
||||
}
|
||||
window.setElement(-2, 0, settings);
|
||||
|
||||
boolean rotationEditable = variant.active() && variant.rotationEditable();
|
||||
UIElement rotation = element(
|
||||
"rotation",
|
||||
rotationEditable ? Material.REPEATER : Material.BARRIER,
|
||||
rotationEditable
|
||||
? ChatColor.LIGHT_PURPLE + "Toggle Rotation"
|
||||
: ChatColor.GRAY + "Rotation is Read-only");
|
||||
rotation.addLore(ChatColor.GRAY + "Currently: " + (variant.rotatable() ? "Enabled" : "Disabled"));
|
||||
if (rotationEditable) {
|
||||
rotation.addLore(ChatColor.YELLOW + "Left-click to toggle");
|
||||
rotation.onLeftClick(clicked -> controller.toggleRotation(
|
||||
window.getViewer(), state.requestId(), workcell.stableId()));
|
||||
} else if (!variant.active()) {
|
||||
rotation.addLore(ChatColor.GRAY + "Load this variant before editing rotation.");
|
||||
} else if (variant.owned() && variant.rotatable()) {
|
||||
rotation.addLore(ChatColor.GRAY + "Vanilla-portable variants must remain rotatable.");
|
||||
}
|
||||
window.setElement(3, 0, rotation);
|
||||
|
||||
boolean sizeEditable = variant.owned() && variant.dimensions().isPresent();
|
||||
UIElement size = element(
|
||||
"variant-size",
|
||||
sizeEditable ? Material.SCAFFOLDING : Material.BARRIER,
|
||||
sizeEditable
|
||||
? ChatColor.GREEN + "Edit This Variant's Size"
|
||||
: ChatColor.GRAY + "Variant Size is Read-only");
|
||||
size.addLore(ChatColor.GRAY + "Current: " + variant.dimensions()
|
||||
.map(JigsawStudioMenuFormat::dimensions)
|
||||
.orElse("Object missing"));
|
||||
size.addLore(ChatColor.GRAY + "Workcell capacity: " + dimensions(workcell.capacity()));
|
||||
if (sizeEditable) {
|
||||
size.addLore(ChatColor.YELLOW + "Left-click for width, height, and depth controls");
|
||||
size.onLeftClick(clicked -> controller.openVariantSizeSettings(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey()));
|
||||
if (variant.resizableToCapacity()) {
|
||||
size.addLore(ChatColor.YELLOW + "Shift-left: resize this variant to capacity");
|
||||
size.onShiftLeftClick(clicked -> controller.resizeVariant(
|
||||
window.getViewer(),
|
||||
state.requestId(),
|
||||
workcell.stableId(),
|
||||
variant.pieceKey(),
|
||||
workcell.capacity()));
|
||||
}
|
||||
}
|
||||
window.setElement(2, 0, size);
|
||||
|
||||
boolean lastVariant = workcell.variants().size() <= 1;
|
||||
boolean deletionAvailable = variant.owned() && !variant.active() && !lastVariant;
|
||||
boolean confirmed = deletionAvailable && controller.isDeleteConfirmed(
|
||||
window.getViewer().getUniqueId(),
|
||||
state.requestId(),
|
||||
workcell.stableId(),
|
||||
variant.pieceKey());
|
||||
UIElement delete = element(
|
||||
"delete-variant",
|
||||
deletionAvailable ? confirmed ? Material.REDSTONE : Material.LAVA_BUCKET : Material.BARRIER,
|
||||
deletionAvailable
|
||||
? confirmed ? ChatColor.RED + "Confirm Delete Variant" : ChatColor.RED + "Delete Variant"
|
||||
: variant.active()
|
||||
? ChatColor.GRAY + "Loaded Variant Cannot Be Deleted"
|
||||
: lastVariant
|
||||
? ChatColor.GRAY + "Last Variant Cannot Be Deleted"
|
||||
: ChatColor.GRAY + "Deletion is Read-only")
|
||||
.setEnchanted(confirmed);
|
||||
if (deletionAvailable) {
|
||||
delete.addLore(confirmed
|
||||
? ChatColor.RED + "Click again to permanently delete this owned variant."
|
||||
: ChatColor.RED + "Click twice within 10 seconds to confirm.");
|
||||
delete.onLeftClick(clicked -> controller.confirmOrDeleteVariant(
|
||||
window.getViewer(),
|
||||
state.requestId(),
|
||||
workcell.stableId(),
|
||||
variant.pieceKey(),
|
||||
page));
|
||||
} else if (variant.active()) {
|
||||
delete.addLore(ChatColor.GRAY + "Load another variant in this workcell first.");
|
||||
} else if (lastVariant) {
|
||||
delete.addLore(ChatColor.GRAY + "Create another variant before deleting this one.");
|
||||
}
|
||||
window.setElement(4, 0, delete);
|
||||
}
|
||||
|
||||
private void addMemberships(
|
||||
UIWindow window,
|
||||
JigsawStudioMenuState state,
|
||||
JigsawStudioMenuState.Workcell workcell,
|
||||
JigsawStudioMenuState.Variant variant,
|
||||
int page
|
||||
) {
|
||||
List<JigsawStudioMenuState.Membership> memberships = page(
|
||||
variant.memberships(), page, MEMBERSHIPS_PER_PAGE);
|
||||
if (memberships.isEmpty()) {
|
||||
UIElement empty = element("no-memberships", Material.BARRIER, ChatColor.RED + "No Pool Entries");
|
||||
empty.addLore(ChatColor.GRAY + "This variant is not linked from an owned pool.");
|
||||
window.setElement(0, 2, empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int index = 0; index < memberships.size(); index++) {
|
||||
JigsawStudioMenuState.Membership membership = memberships.get(index);
|
||||
int position = GRID_POSITIONS[index % GRID_POSITIONS.length];
|
||||
int row = 1 + index / GRID_POSITIONS.length;
|
||||
boolean confirmed = variant.active() && variant.owned() && controller.isUnlinkConfirmed(
|
||||
window.getViewer().getUniqueId(),
|
||||
state.requestId(),
|
||||
workcell.stableId(),
|
||||
variant.pieceKey(),
|
||||
membership);
|
||||
UIElement element = element(
|
||||
"membership-" + index,
|
||||
confirmed ? Material.REDSTONE : Material.GOLD_NUGGET,
|
||||
(confirmed ? ChatColor.RED : ChatColor.GOLD)
|
||||
+ safe(displayKey(membership.poolKey()))
|
||||
+ " [" + membership.entryIndex() + "]")
|
||||
.setEnchanted(confirmed);
|
||||
element.addLore(ChatColor.DARK_GRAY + safe(membership.poolKey()));
|
||||
element.addLore(ChatColor.GRAY + "Exact entry: " + membership.entryIndex());
|
||||
element.addLore(ChatColor.WHITE + "Weight: " + membership.weight());
|
||||
element.addLore((state.irisExtended() ? ChatColor.WHITE : ChatColor.GRAY)
|
||||
+ "Chance: " + chance(membership.chance())
|
||||
+ (state.irisExtended() ? "" : " (Iris only)"));
|
||||
if (variant.active() && variant.owned()) {
|
||||
element.addLore(ChatColor.GREEN + "Left-click: weight +1");
|
||||
element.addLore(ChatColor.YELLOW + "Right-click: weight -1");
|
||||
if (state.irisExtended()) {
|
||||
element.addLore(ChatColor.GREEN + "Shift-left: chance +"
|
||||
+ CHANCE_STEP_PERCENTAGE_POINTS + "%");
|
||||
element.addLore(ChatColor.YELLOW + "Shift-right: chance -"
|
||||
+ CHANCE_STEP_PERCENTAGE_POINTS + "%");
|
||||
} else {
|
||||
element.addLore(ChatColor.GRAY + "Vanilla-portable pool entries always have 100% chance.");
|
||||
}
|
||||
element.addLore(confirmed
|
||||
? ChatColor.RED + "Middle-click again to unlink"
|
||||
: ChatColor.RED + "Middle-click twice to unlink");
|
||||
element.onLeftClick(clicked -> controller.adjustMembershipWeight(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), membership, 1));
|
||||
element.onRightClick(clicked -> controller.adjustMembershipWeight(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), membership, -1));
|
||||
if (state.irisExtended()) {
|
||||
element.onShiftLeftClick(clicked -> controller.adjustMembershipChance(
|
||||
window.getViewer(),
|
||||
state.requestId(),
|
||||
workcell.stableId(),
|
||||
membership,
|
||||
CHANCE_STEP_PERCENTAGE_POINTS));
|
||||
element.onShiftRightClick(clicked -> controller.adjustMembershipChance(
|
||||
window.getViewer(),
|
||||
state.requestId(),
|
||||
workcell.stableId(),
|
||||
membership,
|
||||
-CHANCE_STEP_PERCENTAGE_POINTS));
|
||||
}
|
||||
element.onMiddleClick(clicked -> controller.confirmOrUnlink(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), membership, page));
|
||||
} else {
|
||||
element.addLore(variant.active()
|
||||
? ChatColor.GRAY + "Read-only pool entry"
|
||||
: ChatColor.GRAY + "Load this variant to edit its pool entry");
|
||||
}
|
||||
window.setElement(position, row, element);
|
||||
}
|
||||
}
|
||||
|
||||
private void addDetailsFooter(
|
||||
UIWindow window,
|
||||
JigsawStudioMenuState state,
|
||||
JigsawStudioMenuState.Workcell workcell,
|
||||
JigsawStudioMenuState.Variant variant,
|
||||
int page
|
||||
) {
|
||||
UIElement back = element("footer-back", Material.ARROW, ChatColor.YELLOW + "Back");
|
||||
back.onLeftClick(clicked -> controller.refreshMain(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), 0));
|
||||
window.setElement(-4, 5, back);
|
||||
|
||||
int pageCount = pageCount(variant.memberships().size(), MEMBERSHIPS_PER_PAGE);
|
||||
if (page > 0) {
|
||||
UIElement previous = element("membership-previous", Material.ARROW, ChatColor.YELLOW + "Previous Page");
|
||||
previous.onLeftClick(clicked -> controller.refreshDetails(
|
||||
window.getViewer(),
|
||||
state.requestId(),
|
||||
workcell.stableId(),
|
||||
variant.pieceKey(),
|
||||
page - 1));
|
||||
window.setElement(-1, 5, previous);
|
||||
}
|
||||
UIElement indicator = element("membership-page", Material.BOOK, ChatColor.WHITE + "Page "
|
||||
+ (page + 1) + "/" + pageCount);
|
||||
indicator.addLore(ChatColor.GRAY + "" + variant.memberships().size() + " exact pool entries");
|
||||
window.setElement(0, 5, indicator);
|
||||
if (page + 1 < pageCount) {
|
||||
UIElement next = element("membership-next", Material.ARROW, ChatColor.YELLOW + "Next Page");
|
||||
next.onLeftClick(clicked -> controller.refreshDetails(
|
||||
window.getViewer(),
|
||||
state.requestId(),
|
||||
workcell.stableId(),
|
||||
variant.pieceKey(),
|
||||
page + 1));
|
||||
window.setElement(1, 5, next);
|
||||
}
|
||||
window.setElement(2, 5, evaluationElement(state.evaluation()));
|
||||
|
||||
UIElement toolbox = element("details-toolbox", Material.STICK, ChatColor.AQUA + "Toolbox");
|
||||
toolbox.onLeftClick(clicked -> controller.openToolbox(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), 0));
|
||||
window.setElement(4, 5, toolbox);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
|
||||
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static art.arcane.iris.core.service.JigsawStudioAutosaveScheduler.AUTOSAVE_DEBOUNCE_TICKS;
|
||||
import static art.arcane.iris.core.service.JigsawStudioProtection.inventoryBlocks;
|
||||
|
||||
final class JigsawStudioDirtyTracker {
|
||||
private final JigsawStudioService service;
|
||||
|
||||
JigsawStudioDirtyTracker(JigsawStudioService service) {
|
||||
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
|
||||
}
|
||||
|
||||
boolean markDirty(World world, int worldX, int worldY, int worldZ) {
|
||||
if (world == null) {
|
||||
return false;
|
||||
}
|
||||
ActiveStudio studio = service.studios.get(world.getUID());
|
||||
if (studio == null) {
|
||||
return false;
|
||||
}
|
||||
if (service.saveLifecycle.reopenRequiredRequests.contains(studio.generator().getRequest().requestId())) {
|
||||
return false;
|
||||
}
|
||||
JigsawStudioSession session = studio.generator().getSession();
|
||||
JigsawStudioBay bay = session.layout().findAt(worldX, worldY, worldZ);
|
||||
if (bay == null) {
|
||||
return false;
|
||||
}
|
||||
JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null);
|
||||
if (activeVariant == null || !activeVariant.owned()) {
|
||||
return false;
|
||||
}
|
||||
JigsawStudioSession.DirtyMark dirtyMark = session.markWorkcellDirty(bay.stableId());
|
||||
if (dirtyMark.status() != JigsawStudioSession.DirtyStatus.MARKED) {
|
||||
return false;
|
||||
}
|
||||
service.autosaveScheduler.scheduleAutosave(
|
||||
studio,
|
||||
bay,
|
||||
dirtyMark.identity().orElseThrow(),
|
||||
AUTOSAVE_DEBOUNCE_TICKS);
|
||||
service.markEvaluationStale(studio);
|
||||
if (dirtyMark.newlyDirty()) {
|
||||
service.playerContext.refreshWorkcellContext(studio.worldId(), bay.stableId());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int markAllDirty(World world) {
|
||||
if (world == null) {
|
||||
return 0;
|
||||
}
|
||||
ActiveStudio studio = service.studios.get(world.getUID());
|
||||
return studio == null ? 0 : markAllDirty(studio);
|
||||
}
|
||||
|
||||
void markDirty(Block block) {
|
||||
if (block != null) {
|
||||
markDirty(block.getWorld(), block.getX(), block.getY(), block.getZ());
|
||||
}
|
||||
}
|
||||
|
||||
void markDirty(List<Block> blocks) {
|
||||
for (Block block : blocks) {
|
||||
markDirty(block);
|
||||
}
|
||||
}
|
||||
|
||||
void markDirty(Inventory inventory) {
|
||||
for (Block block : inventoryBlocks(inventory)) {
|
||||
markDirty(block);
|
||||
}
|
||||
}
|
||||
|
||||
void markAllDirty() {
|
||||
for (ActiveStudio studio : service.studios.values()) {
|
||||
markAllDirty(studio);
|
||||
}
|
||||
}
|
||||
|
||||
int markAllDirty(ActiveStudio studio) {
|
||||
int changed = 0;
|
||||
JigsawStudioSession session = studio.generator().getSession();
|
||||
for (JigsawStudioBay bay : session.layout().bays()) {
|
||||
JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null);
|
||||
if (activeVariant == null || !activeVariant.owned()) {
|
||||
continue;
|
||||
}
|
||||
JigsawStudioSession.DirtyMark dirtyMark = session.markWorkcellDirty(bay.stableId());
|
||||
if (dirtyMark.status() == JigsawStudioSession.DirtyStatus.MARKED) {
|
||||
service.autosaveScheduler.scheduleAutosave(
|
||||
studio,
|
||||
bay,
|
||||
dirtyMark.identity().orElseThrow(),
|
||||
AUTOSAVE_DEBOUNCE_TICKS);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
if (changed > 0) {
|
||||
service.markEvaluationStale(studio);
|
||||
for (JigsawStudioBay bay : session.layout().bays()) {
|
||||
service.playerContext.refreshWorkcellContext(studio.worldId(), bay.stableId());
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
|
||||
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
|
||||
import art.arcane.iris.engine.framework.PlacedStructurePiece;
|
||||
import art.arcane.iris.engine.framework.StructureAssembler;
|
||||
import art.arcane.iris.engine.framework.structure.StructureAssemblyResult;
|
||||
import art.arcane.iris.engine.framework.structure.StructureGraphCompilation;
|
||||
import art.arcane.iris.engine.framework.structure.StructureGraphCompiler;
|
||||
import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic;
|
||||
import art.arcane.iris.engine.framework.structure.StructureGraphResolver;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
import art.arcane.iris.engine.object.IrisStructure;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
|
||||
|
||||
final class JigsawStudioEvaluator {
|
||||
private static final long PREVIEW_SEED = 1337L;
|
||||
private static final int SPATIAL_PREVIEW_BASE_Y = JigsawStudioLayout.FLOOR_Y + 48;
|
||||
|
||||
private final JigsawStudioService service;
|
||||
final Map<UUID, JigsawStudioGraphEvaluation> evaluations = new ConcurrentHashMap<>();
|
||||
|
||||
JigsawStudioEvaluator(JigsawStudioService service) {
|
||||
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
|
||||
}
|
||||
|
||||
void scheduleInitialEvaluation(ActiveStudio studio) {
|
||||
if (studio == null) {
|
||||
return;
|
||||
}
|
||||
JigsawStudioActivation.Request request = studio.generator().getRequest();
|
||||
UUID requestId = request.requestId();
|
||||
if (!claimInitialEvaluation(
|
||||
studio.evaluationGeneration(),
|
||||
service.isCurrentRequest(studio, requestId))) {
|
||||
return;
|
||||
}
|
||||
publishEvaluation(studio, requestId, 1L);
|
||||
}
|
||||
|
||||
static boolean claimInitialEvaluation(AtomicLong generation, boolean currentRequest) {
|
||||
return currentRequest
|
||||
&& Objects.requireNonNull(generation, "Jigsaw Studio evaluation generation")
|
||||
.compareAndSet(0L, 1L);
|
||||
}
|
||||
|
||||
void scheduleEvaluation(ActiveStudio studio) {
|
||||
if (studio == null) {
|
||||
return;
|
||||
}
|
||||
JigsawStudioActivation.Request request = studio.generator().getRequest();
|
||||
UUID requestId = request.requestId();
|
||||
if (!service.isCurrentRequest(studio, requestId)) {
|
||||
return;
|
||||
}
|
||||
publishEvaluation(studio, requestId, studio.evaluationGeneration().incrementAndGet());
|
||||
}
|
||||
|
||||
private void publishEvaluation(ActiveStudio studio, UUID requestId, long generation) {
|
||||
JigsawStudioGraphEvaluation previous = evaluations.get(requestId);
|
||||
JigsawStudioPreviewRenderer.PreviewBounds previousBounds = previous == null
|
||||
? JigsawStudioPreviewRenderer.PreviewBounds.empty()
|
||||
: previous.previewBounds();
|
||||
evaluations.put(requestId, new JigsawStudioGraphEvaluation(
|
||||
requestId,
|
||||
generation,
|
||||
PREVIEW_SEED,
|
||||
JigsawStudioEvaluationState.PENDING,
|
||||
previous == null ? "" : previous.selectedTheme(),
|
||||
previous == null ? 0 : previous.pieceCount(),
|
||||
"Compiling the committed graph and assembling seed 1337",
|
||||
previousBounds));
|
||||
service.playerContext.refreshAllWorkcellContexts(studio);
|
||||
J.a(() -> evaluateGraph(studio, requestId, generation));
|
||||
}
|
||||
|
||||
private void evaluateGraph(ActiveStudio studio, UUID requestId, long generation) {
|
||||
EvaluationComputation computation;
|
||||
try {
|
||||
JigsawStudioActivation.Request request = studio.generator().getRequest();
|
||||
IrisStructure structure = request.source().load(
|
||||
IrisStructure.class,
|
||||
request.structureKey(),
|
||||
false);
|
||||
if (structure == null) {
|
||||
throw new IOException("The active structure resource no longer exists");
|
||||
}
|
||||
StructureGraphCompilation compilation = StructureGraphCompiler.compile(
|
||||
structure,
|
||||
StructureGraphResolver.forData(request.source()));
|
||||
StructureGraphDiagnostic firstError = firstDiagnostic(
|
||||
compilation,
|
||||
StructureGraphDiagnostic.Severity.ERROR);
|
||||
if (firstError != null) {
|
||||
computation = invalidEvaluation(
|
||||
requestId,
|
||||
generation,
|
||||
firstError.code() + ": " + firstError.message());
|
||||
} else {
|
||||
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,
|
||||
studio.generator().getLayout().mode());
|
||||
}
|
||||
} catch (Throwable exception) {
|
||||
IrisLogging.reportError(exception);
|
||||
computation = invalidEvaluation(requestId, generation, failureMessage(exception));
|
||||
}
|
||||
completeEvaluation(studio, computation);
|
||||
}
|
||||
|
||||
private EvaluationComputation evaluationForAssembly(
|
||||
UUID requestId,
|
||||
long generation,
|
||||
StructureGraphCompilation compilation,
|
||||
StructureAssemblyResult assembly,
|
||||
JigsawStudioMode mode
|
||||
) throws IOException {
|
||||
if (assembly.status().isFailure()) {
|
||||
return invalidEvaluation(
|
||||
requestId,
|
||||
generation,
|
||||
assembly.status().name().toLowerCase(Locale.ROOT) + ": " + assembly.detail());
|
||||
}
|
||||
if (!assembly.hasOutput()) {
|
||||
return new EvaluationComputation(
|
||||
new JigsawStudioGraphEvaluation(
|
||||
requestId,
|
||||
generation,
|
||||
PREVIEW_SEED,
|
||||
JigsawStudioEvaluationState.WARNING,
|
||||
assembly.selectedTheme(),
|
||||
0,
|
||||
assembly.detail().isEmpty()
|
||||
? "Seed 1337 intentionally generated no structure"
|
||||
: assembly.detail(),
|
||||
JigsawStudioPreviewRenderer.PreviewBounds.empty()),
|
||||
JigsawStudioPreviewRenderer.PreviewPlan.empty());
|
||||
}
|
||||
List<PlacedStructurePiece> aligned = alignPreviewPieces(assembly.pieces(), mode);
|
||||
JigsawStudioPreviewRenderer.PreviewPlan plan = JigsawStudioPreviewRenderer.plan(aligned);
|
||||
StructureGraphDiagnostic firstWarning = firstDiagnostic(
|
||||
compilation,
|
||||
StructureGraphDiagnostic.Severity.WARNING);
|
||||
JigsawStudioEvaluationState state = firstWarning == null
|
||||
? JigsawStudioEvaluationState.VALID
|
||||
: JigsawStudioEvaluationState.WARNING;
|
||||
String detail = firstWarning == null
|
||||
? "Seed 1337 assembled " + aligned.size() + " piece(s)"
|
||||
: firstWarning.code() + ": " + firstWarning.message();
|
||||
return new EvaluationComputation(
|
||||
new JigsawStudioGraphEvaluation(
|
||||
requestId,
|
||||
generation,
|
||||
PREVIEW_SEED,
|
||||
state,
|
||||
assembly.selectedTheme(),
|
||||
aligned.size(),
|
||||
detail,
|
||||
plan.bounds()),
|
||||
plan);
|
||||
}
|
||||
|
||||
private void completeEvaluation(ActiveStudio studio, EvaluationComputation computation) {
|
||||
JigsawStudioGraphEvaluation evaluated = computation.evaluation();
|
||||
UUID requestId = evaluated.requestId();
|
||||
if (!isCurrentEvaluation(studio, requestId, evaluated.generation())) {
|
||||
return;
|
||||
}
|
||||
service.previewRenderer.render(
|
||||
studio.world(),
|
||||
requestId,
|
||||
evaluated.generation(),
|
||||
computation.plan(),
|
||||
result -> {
|
||||
if (!isCurrentEvaluation(studio, requestId, evaluated.generation())) {
|
||||
return;
|
||||
}
|
||||
JigsawStudioGraphEvaluation completed = result.successful()
|
||||
? evaluated
|
||||
: new JigsawStudioGraphEvaluation(
|
||||
requestId,
|
||||
evaluated.generation(),
|
||||
PREVIEW_SEED,
|
||||
JigsawStudioEvaluationState.WARNING,
|
||||
evaluated.selectedTheme(),
|
||||
evaluated.pieceCount(),
|
||||
evaluated.detail() + "; " + result.failure(),
|
||||
evaluated.previewBounds());
|
||||
evaluations.put(requestId, completed);
|
||||
service.playerContext.refreshAllWorkcellContexts(studio);
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isCurrentEvaluation(ActiveStudio studio, UUID requestId, long generation) {
|
||||
if (!service.isCurrentRequest(studio, requestId)
|
||||
|| studio.evaluationGeneration().get() != generation) {
|
||||
return false;
|
||||
}
|
||||
JigsawStudioGraphEvaluation current = evaluations.get(requestId);
|
||||
return current != null && current.generation() == generation;
|
||||
}
|
||||
|
||||
private static EvaluationComputation invalidEvaluation(
|
||||
UUID requestId,
|
||||
long generation,
|
||||
String detail
|
||||
) {
|
||||
return new EvaluationComputation(
|
||||
new JigsawStudioGraphEvaluation(
|
||||
requestId,
|
||||
generation,
|
||||
PREVIEW_SEED,
|
||||
JigsawStudioEvaluationState.INVALID,
|
||||
"",
|
||||
0,
|
||||
detail,
|
||||
JigsawStudioPreviewRenderer.PreviewBounds.empty()),
|
||||
JigsawStudioPreviewRenderer.PreviewPlan.empty());
|
||||
}
|
||||
|
||||
private static StructureGraphDiagnostic firstDiagnostic(
|
||||
StructureGraphCompilation compilation,
|
||||
StructureGraphDiagnostic.Severity severity
|
||||
) {
|
||||
for (StructureGraphDiagnostic diagnostic : compilation.getDiagnostics()) {
|
||||
if (diagnostic.severity() == severity) {
|
||||
return diagnostic;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IrisPosition previewOrigin(JigsawStudioLayout layout, IrisStructure structure) {
|
||||
int radius = Math.max(1, structure.getMaxSizeChunks()) * 16;
|
||||
return new IrisPosition(
|
||||
-radius - 32,
|
||||
0,
|
||||
Math.max(16, layout.extentZ() / 2));
|
||||
}
|
||||
|
||||
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 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(
|
||||
piece.getPiece(),
|
||||
piece.getObject(),
|
||||
piece.getX(),
|
||||
piece.getY() + shiftY,
|
||||
piece.getZ(),
|
||||
piece.getRotation(),
|
||||
piece.getMinX(),
|
||||
piece.getMinY() + shiftY,
|
||||
piece.getMinZ(),
|
||||
piece.getMaxX(),
|
||||
piece.getMaxY() + shiftY,
|
||||
piece.getMaxZ()));
|
||||
}
|
||||
return List.copyOf(aligned);
|
||||
}
|
||||
|
||||
static IrisStructure loadStudioStructure(ActiveStudio studio) {
|
||||
JigsawStudioActivation.Request request = studio.generator().getRequest();
|
||||
return request.source().load(IrisStructure.class, request.structureKey(), false);
|
||||
}
|
||||
|
||||
private record EvaluationComputation(
|
||||
JigsawStudioGraphEvaluation evaluation,
|
||||
JigsawStudioPreviewRenderer.PreviewPlan plan
|
||||
) {
|
||||
private EvaluationComputation {
|
||||
Objects.requireNonNull(evaluation, "Jigsaw Studio graph evaluation");
|
||||
Objects.requireNonNull(plan, "Jigsaw Studio evaluation preview plan");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.runtime.InPlaceChunkRegenerator;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphMapper;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
|
||||
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
|
||||
import art.arcane.iris.core.service.JigsawStudioService.CommandGraphMutation;
|
||||
import art.arcane.iris.core.service.JigsawStudioService.CommandGraphMutationResult;
|
||||
import art.arcane.iris.engine.data.chunk.TerrainChunk;
|
||||
import art.arcane.iris.engine.object.IrisStructure;
|
||||
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.applyRenderedBayChunk;
|
||||
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.verifyRenderedBayChunk;
|
||||
import static art.arcane.iris.core.service.JigsawStudioProtection.authorizeOwner;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.message;
|
||||
|
||||
final class JigsawStudioGraphMutations {
|
||||
private final JigsawStudioService service;
|
||||
final Set<UUID> graphMutationsInProgress = new HashSet<>();
|
||||
|
||||
JigsawStudioGraphMutations(JigsawStudioService service) {
|
||||
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
|
||||
}
|
||||
|
||||
String beginGraphMutation(
|
||||
ActiveStudio studio,
|
||||
JigsawStudioSession session
|
||||
) {
|
||||
UUID requestId = studio.generator().getRequest().requestId();
|
||||
service.tileWatcher.finalizeJigsawTileWatches(requestId);
|
||||
synchronized (service.saveLifecycleLock) {
|
||||
if (service.saveLifecycle.closingRequests.contains(requestId)) {
|
||||
return "This Jigsaw Studio is closing and cannot update its graph.";
|
||||
}
|
||||
if (service.saveLifecycle.savesInProgress.contains(requestId)) {
|
||||
return "Wait for the current Jigsaw Studio save to finish.";
|
||||
}
|
||||
if (graphMutationsInProgress.contains(requestId)) {
|
||||
return "A Jigsaw Studio graph update is already running.";
|
||||
}
|
||||
if (service.saveLifecycle.exportsInProgress.contains(requestId)) {
|
||||
return "Wait for the current Jigsaw Studio export to finish.";
|
||||
}
|
||||
if (service.materializer.materializationsInProgress.contains(requestId)) {
|
||||
return "Wait for the current Jigsaw Studio variant load or rollback to finish.";
|
||||
}
|
||||
if (session.operationInProgress()) {
|
||||
return "Wait for the current workcell operation to finish.";
|
||||
}
|
||||
if (service.saveLifecycle.reopenRequiredRequests.contains(requestId)) {
|
||||
return "Close and reopen Jigsaw Studio before making another graph change.";
|
||||
}
|
||||
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
|
||||
return "Finish or close the open vanilla jigsaw-block editor before changing graph metadata.";
|
||||
}
|
||||
if (session.isDirty()) {
|
||||
return "Wait for every dirty workcell to finish autosaving before changing graph metadata.";
|
||||
}
|
||||
graphMutationsInProgress.add(requestId);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
boolean runCommandGraphMutation(
|
||||
Player player,
|
||||
UUID expectedRequestId,
|
||||
CommandGraphMutation task
|
||||
) {
|
||||
Objects.requireNonNull(expectedRequestId, "Jigsaw Studio command graph request ID");
|
||||
Objects.requireNonNull(task, "Jigsaw Studio command graph mutation");
|
||||
if (player == null) {
|
||||
return false;
|
||||
}
|
||||
if (!J.isOwnedByCurrentRegion(player)) {
|
||||
return J.runEntity(player, () -> runCommandGraphMutation(player, expectedRequestId, task));
|
||||
}
|
||||
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
|
||||
if (studio == null
|
||||
|| !studio.generator().getRequest().requestId().equals(expectedRequestId)) {
|
||||
message(player, "This Jigsaw Studio session is no longer active.");
|
||||
return false;
|
||||
}
|
||||
if (!authorizeOwner(player, studio)) {
|
||||
return false;
|
||||
}
|
||||
String reservationFailure = beginGraphMutation(studio, studio.generator().getSession());
|
||||
if (!reservationFailure.isEmpty()) {
|
||||
message(player, reservationFailure);
|
||||
return false;
|
||||
}
|
||||
return scheduleGraphMutation(player, studio, task);
|
||||
}
|
||||
|
||||
boolean scheduleGraphMutation(
|
||||
Player player,
|
||||
ActiveStudio studio,
|
||||
CommandGraphMutation task
|
||||
) {
|
||||
UUID requestId = studio.generator().getRequest().requestId();
|
||||
J.a(() -> {
|
||||
CommandGraphMutationResult result = null;
|
||||
Throwable failure = null;
|
||||
try {
|
||||
result = task.run();
|
||||
} catch (Throwable exception) {
|
||||
failure = exception;
|
||||
}
|
||||
CommandGraphMutationResult completedResult = result;
|
||||
Throwable completedFailure = failure;
|
||||
boolean scheduled = J.runEntity(
|
||||
player,
|
||||
() -> completeGraphMutation(
|
||||
player,
|
||||
studio,
|
||||
requestId,
|
||||
completedResult,
|
||||
completedFailure),
|
||||
0,
|
||||
() -> finishGraphMutation(requestId));
|
||||
if (!scheduled) {
|
||||
finishGraphMutation(requestId);
|
||||
if (completedFailure != null) {
|
||||
IrisLogging.reportError(completedFailure);
|
||||
}
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private void completeGraphMutation(
|
||||
Player player,
|
||||
ActiveStudio studio,
|
||||
UUID requestId,
|
||||
CommandGraphMutationResult result,
|
||||
Throwable failure
|
||||
) {
|
||||
try {
|
||||
if (failure != null) {
|
||||
IrisLogging.reportError(failure);
|
||||
message(player, "Graph update failed: " + failureMessage(failure));
|
||||
finishGraphMutation(requestId);
|
||||
return;
|
||||
}
|
||||
if (result == null) {
|
||||
message(player, "Graph update completed without a result; no Studio state changed.");
|
||||
finishGraphMutation(requestId);
|
||||
return;
|
||||
}
|
||||
if (!service.isCurrentRequest(studio, requestId)) {
|
||||
message(player, "The graph updated on disk after this Studio session changed; reopen it to continue.");
|
||||
finishGraphMutation(requestId);
|
||||
return;
|
||||
}
|
||||
JigsawStudioSession session = studio.generator().getSession();
|
||||
JigsawStudioLayout previousLayout = session.layout();
|
||||
if (result.rebindActiveVariants().isEmpty()) {
|
||||
session.replaceLayout(result.layout());
|
||||
} else {
|
||||
session.replaceLayoutAndRebind(result.layout(), result.rebindActiveVariants());
|
||||
}
|
||||
for (JigsawStudioBay workcell : result.layout().bays()) {
|
||||
studio.generator().invalidateRender(workcell.stableId());
|
||||
}
|
||||
if (layoutGeometryChanged(previousLayout, result.layout())) {
|
||||
scheduleLiveRelayout(player, studio, requestId, previousLayout, result);
|
||||
return;
|
||||
}
|
||||
finishGraphMutationSuccess(player, studio, requestId, result);
|
||||
} catch (Throwable exception) {
|
||||
IrisLogging.reportError(exception);
|
||||
message(player, "The graph updated on disk, but Studio could not refresh: "
|
||||
+ failureMessage(exception) + ". Reopen this project before editing further.");
|
||||
service.saveLifecycle.reopenRequiredRequests.add(requestId);
|
||||
finishGraphMutation(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private void finishGraphMutationSuccess(
|
||||
Player player,
|
||||
ActiveStudio studio,
|
||||
UUID requestId,
|
||||
CommandGraphMutationResult result
|
||||
) {
|
||||
for (JigsawStudioBay workcell : result.layout().bays()) {
|
||||
service.playerContext.refreshWorkcellContext(studio.worldId(), workcell.stableId());
|
||||
}
|
||||
service.evaluator.scheduleEvaluation(studio);
|
||||
message(player, result.message());
|
||||
finishGraphMutation(requestId);
|
||||
if (!result.activatePieceKey().isEmpty()) {
|
||||
service.variantEditor.switchVariant(
|
||||
player,
|
||||
result.activateWorkcellId(),
|
||||
result.activatePieceKey(),
|
||||
false);
|
||||
} else if (result.reload().isPresent()) {
|
||||
service.variantEditor.reloadActiveVariant(player, studio, result.reload().orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleLiveRelayout(
|
||||
Player player,
|
||||
ActiveStudio studio,
|
||||
UUID requestId,
|
||||
JigsawStudioLayout previousLayout,
|
||||
CommandGraphMutationResult result
|
||||
) {
|
||||
Set<Long> chunks = relayoutChunks(previousLayout, result.layout());
|
||||
AtomicInteger remaining = new AtomicInteger(chunks.size());
|
||||
AtomicReference<String> failure = new AtomicReference<>("");
|
||||
AtomicReference<Throwable> cause = new AtomicReference<>();
|
||||
if (chunks.isEmpty()) {
|
||||
completeLiveRelayout(player, studio, requestId, result, "", null);
|
||||
return;
|
||||
}
|
||||
for (long chunkKey : chunks) {
|
||||
int chunkX = (int) (chunkKey >> 32);
|
||||
int chunkZ = (int) chunkKey;
|
||||
boolean scheduled = J.runRegion(studio.world(), chunkX, chunkZ, () -> {
|
||||
try {
|
||||
repaintStudioChunk(studio, chunkX, chunkZ);
|
||||
} catch (Throwable exception) {
|
||||
failure.compareAndSet("", "chunk " + chunkX + "," + chunkZ
|
||||
+ " could not regenerate: " + failureMessage(exception));
|
||||
cause.compareAndSet(null, exception);
|
||||
}
|
||||
if (remaining.decrementAndGet() == 0) {
|
||||
scheduleLiveRelayoutCompletion(
|
||||
player,
|
||||
studio,
|
||||
requestId,
|
||||
result,
|
||||
failure.get(),
|
||||
cause.get());
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
failure.compareAndSet("", "chunk " + chunkX + "," + chunkZ
|
||||
+ " could not be scheduled on its owning region");
|
||||
if (remaining.decrementAndGet() == 0) {
|
||||
scheduleLiveRelayoutCompletion(
|
||||
player,
|
||||
studio,
|
||||
requestId,
|
||||
result,
|
||||
failure.get(),
|
||||
cause.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleLiveRelayoutCompletion(
|
||||
Player player,
|
||||
ActiveStudio studio,
|
||||
UUID requestId,
|
||||
CommandGraphMutationResult result,
|
||||
String failure,
|
||||
Throwable cause
|
||||
) {
|
||||
boolean scheduled = J.runEntity(
|
||||
player,
|
||||
() -> completeLiveRelayout(player, studio, requestId, result, failure, cause));
|
||||
if (!scheduled) {
|
||||
service.saveLifecycle.reopenRequiredRequests.add(requestId);
|
||||
finishGraphMutation(requestId);
|
||||
if (cause != null) {
|
||||
IrisLogging.reportError(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void completeLiveRelayout(
|
||||
Player player,
|
||||
ActiveStudio studio,
|
||||
UUID requestId,
|
||||
CommandGraphMutationResult result,
|
||||
String failure,
|
||||
Throwable cause
|
||||
) {
|
||||
if (!failure.isEmpty() || !service.isCurrentRequest(studio, requestId)) {
|
||||
service.saveLifecycle.reopenRequiredRequests.add(requestId);
|
||||
studio.populations().clear();
|
||||
if (cause != null) {
|
||||
IrisLogging.reportError(cause);
|
||||
}
|
||||
message(player, "The workcell size was saved, but live regeneration failed"
|
||||
+ (failure.isEmpty() ? "." : ": " + failure + ".")
|
||||
+ " Close and reopen this project before editing further.");
|
||||
finishGraphMutation(requestId);
|
||||
return;
|
||||
}
|
||||
studio.populations().clear();
|
||||
for (JigsawStudioBay workcell : result.layout().bays()) {
|
||||
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(workcell);
|
||||
studio.replacePopulation(
|
||||
workcell,
|
||||
rendered,
|
||||
rendered.valid() ? "" : rendered.failure(),
|
||||
rendered.valid());
|
||||
}
|
||||
service.saveLifecycle.reopenRequiredRequests.remove(requestId);
|
||||
finishGraphMutationSuccess(player, studio, requestId, result);
|
||||
studio.generator().getSession().selectedBayId().ifPresent(workcellId -> service.teleportTo(player, workcellId));
|
||||
}
|
||||
|
||||
static boolean layoutGeometryChanged(
|
||||
JigsawStudioLayout previous,
|
||||
JigsawStudioLayout current
|
||||
) {
|
||||
if (previous.bays().size() != current.bays().size()) {
|
||||
return true;
|
||||
}
|
||||
for (JigsawStudioBay previousBay : previous.bays()) {
|
||||
JigsawStudioBay currentBay = current.get(previousBay.stableId());
|
||||
if (currentBay == null || !previousBay.bounds().equals(currentBay.bounds())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Set<Long> relayoutChunks(
|
||||
JigsawStudioLayout previous,
|
||||
JigsawStudioLayout current
|
||||
) {
|
||||
Set<Long> chunks = new HashSet<>();
|
||||
addRelayoutChunks(chunks, previous);
|
||||
addRelayoutChunks(chunks, current);
|
||||
return Set.copyOf(chunks);
|
||||
}
|
||||
|
||||
private static void addRelayoutChunks(Set<Long> chunks, JigsawStudioLayout layout) {
|
||||
for (JigsawStudioBay workcell : layout.bays()) {
|
||||
JigsawStudioBounds bounds = workcell.bounds();
|
||||
int minimumChunkX = (bounds.originX() - 1) >> 4;
|
||||
int maximumChunkX = (bounds.maxX() + 1) >> 4;
|
||||
int minimumChunkZ = (bounds.originZ() - 1) >> 4;
|
||||
int maximumChunkZ = (bounds.maxZ() + 1) >> 4;
|
||||
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
|
||||
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
|
||||
chunks.add(JigsawStudioService.chunkKey(chunkX, chunkZ));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void repaintStudioChunk(ActiveStudio studio, int chunkX, int chunkZ) throws IOException {
|
||||
World world = studio.world();
|
||||
Chunk chunk = world.getChunkAt(chunkX, chunkZ);
|
||||
TerrainChunk generated = TerrainChunk.create(world);
|
||||
studio.generator().paintChunk(generated, chunkX, chunkZ);
|
||||
if (!INMS.get().applyChunkBlocks(chunk, generated)) {
|
||||
InPlaceChunkRegenerator.applyBlockDiffs(
|
||||
chunk,
|
||||
generated.getChunkData(),
|
||||
world.getMinHeight(),
|
||||
world.getMaxHeight());
|
||||
}
|
||||
for (JigsawStudioBay workcell : studio.generator().getLayout().bays()) {
|
||||
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(workcell);
|
||||
if (!rendered.valid()) {
|
||||
continue;
|
||||
}
|
||||
boolean connectorsVisible = studio.generator().getSession()
|
||||
.workcellSnapshot(workcell.stableId())
|
||||
.connectorsVisible();
|
||||
applyRenderedBayChunk(world, workcell, rendered, chunkX, chunkZ, connectorsVisible);
|
||||
verifyRenderedBayChunk(world, workcell, rendered, chunkX, chunkZ, connectorsVisible);
|
||||
}
|
||||
world.refreshChunk(chunkX, chunkZ);
|
||||
}
|
||||
|
||||
boolean graphMutationInProgress(UUID requestId) {
|
||||
synchronized (service.saveLifecycleLock) {
|
||||
return graphMutationsInProgress.contains(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private void finishGraphMutation(UUID requestId) {
|
||||
synchronized (service.saveLifecycleLock) {
|
||||
graphMutationsInProgress.remove(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
static JigsawStudioLayout loadMappedLayout(ActiveStudio studio) throws IOException {
|
||||
JigsawStudioActivation.Request request = studio.generator().getRequest();
|
||||
IrisData source = request.source();
|
||||
source.invalidateStructureResources();
|
||||
IrisStructure structure = source.load(IrisStructure.class, request.structureKey(), false);
|
||||
if (structure == null) {
|
||||
throw new IOException("The structure could not be reloaded after the graph transaction");
|
||||
}
|
||||
return JigsawStudioGraphMapper.map(source, structure);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
|
||||
import art.arcane.iris.core.structure.authoring.StructureWriteOptions;
|
||||
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
|
||||
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
|
||||
import art.arcane.iris.util.common.io.Durability;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
|
||||
@@ -303,7 +304,7 @@ final class JigsawStudioHistoryStore {
|
||||
while (buffer.hasRemaining()) {
|
||||
channel.write(buffer);
|
||||
}
|
||||
channel.force(true);
|
||||
Durability.force(channel);
|
||||
}
|
||||
try {
|
||||
Files.move(
|
||||
@@ -374,6 +375,10 @@ final class JigsawStudioHistoryStore {
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
if (!Durability.enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A directory can only be opened and fsynced on a POSIX filesystem; Windows rejects the
|
||||
// open outright. Matches DirectoryDurability and DatapackIngestService, which already
|
||||
// skip the barrier there.
|
||||
@@ -381,7 +386,7 @@ final class JigsawStudioHistoryStore {
|
||||
return;
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
Durability.force(channel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.inventorygui.UIElement;
|
||||
import art.arcane.volmlib.util.inventorygui.UIWindow;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Material;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuController.GRID_POSITIONS;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuController.VARIANTS_PER_PAGE;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.clampPage;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.compatibilityName;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.dimensions;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.element;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.evaluationElement;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.nextThemeSetKey;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.page;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.pageCount;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.safe;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.themes;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.variantMaterial;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.workcellMaterial;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.workcellStatus;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.yesNo;
|
||||
|
||||
final class JigsawStudioMainMenu {
|
||||
private final JigsawStudioMenuController controller;
|
||||
|
||||
JigsawStudioMainMenu(JigsawStudioMenuController controller) {
|
||||
this.controller = Objects.requireNonNull(controller, "Jigsaw Studio menu controller");
|
||||
}
|
||||
|
||||
void render(
|
||||
UIWindow window,
|
||||
JigsawStudioMenuState state,
|
||||
JigsawStudioMenuState.Workcell selected,
|
||||
int requestedPage
|
||||
) {
|
||||
int page = clampPage(requestedPage, selected.variants().size(), VARIANTS_PER_PAGE);
|
||||
window.batch(() -> {
|
||||
window.clearElements();
|
||||
addWorkcells(window, state, selected.stableId());
|
||||
addFamilyControl(window, state);
|
||||
addStructureControl(window, state, selected.stableId());
|
||||
addVariants(window, state, selected, page);
|
||||
addMainActions(window, state, selected, page);
|
||||
});
|
||||
}
|
||||
|
||||
private void addFamilyControl(UIWindow window, JigsawStudioMenuState state) {
|
||||
String nextThemeKey = nextThemeSetKey(state.themeSets());
|
||||
boolean available = state.irisExtended() && state.workcells().stream()
|
||||
.filter(JigsawStudioMenuState.Workcell::enabled)
|
||||
.allMatch(workcell -> workcell.activeVariant() != null && workcell.activeVariant().owned());
|
||||
UIElement family = element(
|
||||
"duplicate-family",
|
||||
available ? Material.PURPLE_DYE : Material.GRAY_DYE,
|
||||
available
|
||||
? ChatColor.LIGHT_PURPLE + "Duplicate All Enabled Cells as Family"
|
||||
: ChatColor.GRAY + "Duplicate All Enabled Cells as Family");
|
||||
family.addLore(ChatColor.GRAY + "Copies each enabled cell's loaded variant into one coherent family.");
|
||||
family.addLore(ChatColor.GRAY + "New family: " + safe(nextThemeKey));
|
||||
if (available) {
|
||||
family.addLore(ChatColor.YELLOW + "Left-click to duplicate the complete family atomically");
|
||||
family.onLeftClick(clicked -> controller.duplicateActiveFamily(
|
||||
window.getViewer(), state.requestId(), nextThemeKey));
|
||||
} else if (!state.irisExtended()) {
|
||||
family.addLore(ChatColor.GRAY + "Coherent families require Iris compatibility.");
|
||||
} else {
|
||||
family.addLore(ChatColor.GRAY + "Load an owned variant in every enabled workcell first.");
|
||||
}
|
||||
window.setElement(3, 0, family);
|
||||
}
|
||||
|
||||
private void addStructureControl(
|
||||
UIWindow window,
|
||||
JigsawStudioMenuState state,
|
||||
String selectedWorkcellId
|
||||
) {
|
||||
UIElement structure = element(
|
||||
"structure-rules",
|
||||
state.irisExtended()
|
||||
? state.requireCaps() ? Material.IRON_BARS : Material.TRIPWIRE_HOOK
|
||||
: Material.BARRIER,
|
||||
ChatColor.LIGHT_PURPLE + "Structure Rules");
|
||||
structure.addLore(ChatColor.GRAY + "Mandatory caps: " + yesNo(state.requireCaps()));
|
||||
structure.addLore(ChatColor.GRAY + "Theme sets: " + state.themeSets().size());
|
||||
structure.addLore(ChatColor.GRAY + "Compatibility: " + compatibilityName(state.compatibilityTarget()));
|
||||
structure.addLore(ChatColor.YELLOW + "Left-click for themes and rules");
|
||||
structure.addLore(state.irisExtended()
|
||||
? ChatColor.YELLOW + "Right-click to toggle mandatory caps"
|
||||
: ChatColor.GRAY + "Mandatory caps and themes require Iris compatibility");
|
||||
structure.onLeftClick(clicked -> controller.openStructureSettings(
|
||||
window.getViewer(), state.requestId(), selectedWorkcellId, 0));
|
||||
if (state.irisExtended()) {
|
||||
structure.onRightClick(clicked -> controller.setRequireCaps(
|
||||
window.getViewer(), state.requestId(), !state.requireCaps()));
|
||||
}
|
||||
window.setElement(4, 0, structure);
|
||||
}
|
||||
|
||||
private void addWorkcells(UIWindow window, JigsawStudioMenuState state, String selectedWorkcellId) {
|
||||
int count = state.workcells().size();
|
||||
int start = count == 1 ? 0 : -(count / 2);
|
||||
for (int index = 0; index < count; index++) {
|
||||
JigsawStudioMenuState.Workcell workcell = state.workcells().get(index);
|
||||
boolean selected = workcell.stableId().equals(selectedWorkcellId);
|
||||
UIElement element = element(
|
||||
"workcell-" + index,
|
||||
workcellMaterial(workcell, selected),
|
||||
(selected ? ChatColor.AQUA : workcell.enabled() ? ChatColor.WHITE : ChatColor.GRAY)
|
||||
+ safe(workcell.displayName()))
|
||||
.setEnchanted(selected);
|
||||
element.addLore(workcell.enabled()
|
||||
? ChatColor.GREEN + "Enabled"
|
||||
: ChatColor.RED + "Disabled for assembly and export");
|
||||
if (!workcell.canonicalName().equals(workcell.displayName())) {
|
||||
element.addLore(ChatColor.GRAY + "Solver role: " + safe(workcell.canonicalName()));
|
||||
}
|
||||
element.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity()));
|
||||
element.addLore(ChatColor.GRAY + "Variants: " + workcell.variants().size());
|
||||
JigsawStudioMenuState.Variant active = workcell.activeVariant();
|
||||
element.addLore(ChatColor.GRAY + "Loaded: "
|
||||
+ (active == null ? "None" : safe(active.displayName())));
|
||||
element.addLore(workcellStatus(workcell));
|
||||
element.addLore(ChatColor.GRAY + "Connector blocks: "
|
||||
+ (workcell.connectorsVisible() ? "Visible" : "Hidden"));
|
||||
element.addLore(ChatColor.YELLOW + "Left-click to select and teleport");
|
||||
element.addLore(ChatColor.YELLOW + "Right-click for workcell settings");
|
||||
if (workcell.dirty() && !workcell.saving()) {
|
||||
element.addLore(ChatColor.GOLD + "Shift-left: Flush Autosave Now");
|
||||
element.onShiftLeftClick(clicked -> controller.flushNow(
|
||||
window.getViewer(), state.requestId(), workcell.stableId()));
|
||||
}
|
||||
element.onLeftClick(clicked -> controller.selectWorkcell(
|
||||
window.getViewer(),
|
||||
state.requestId(),
|
||||
workcell.stableId()));
|
||||
element.onRightClick(clicked -> J.runEntity(
|
||||
window.getViewer(),
|
||||
() -> controller.openWorkcellSettings(
|
||||
window.getViewer(),
|
||||
state.requestId(),
|
||||
workcell.stableId()),
|
||||
1));
|
||||
window.setElement(start + index, 0, element);
|
||||
}
|
||||
}
|
||||
|
||||
private void addVariants(
|
||||
UIWindow window,
|
||||
JigsawStudioMenuState state,
|
||||
JigsawStudioMenuState.Workcell workcell,
|
||||
int page
|
||||
) {
|
||||
List<JigsawStudioMenuState.Variant> variants = page(
|
||||
workcell.variants(), page, VARIANTS_PER_PAGE);
|
||||
if (variants.isEmpty()) {
|
||||
UIElement empty = element("no-variants", Material.BARRIER, ChatColor.RED + "No variants");
|
||||
empty.addLore(ChatColor.GRAY + "Create a variant to begin authoring this workcell.");
|
||||
window.setElement(0, 2, empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int index = 0; index < variants.size(); index++) {
|
||||
JigsawStudioMenuState.Variant variant = variants.get(index);
|
||||
int position = GRID_POSITIONS[index % GRID_POSITIONS.length];
|
||||
int row = 1 + index / GRID_POSITIONS.length;
|
||||
UIElement element = element(
|
||||
"variant-" + index,
|
||||
variantMaterial(variant),
|
||||
(variant.active() ? ChatColor.GREEN : ChatColor.WHITE)
|
||||
+ safe(variant.displayName())
|
||||
+ (variant.active() ? ChatColor.AQUA + " [Loaded]" : ""))
|
||||
.setEnchanted(variant.active());
|
||||
element.addLore(ChatColor.DARK_GRAY + safe(variant.pieceKey()));
|
||||
element.addLore(variant.owned()
|
||||
? ChatColor.GREEN + "Owned"
|
||||
: ChatColor.GRAY + "Read-only");
|
||||
element.addLore(ChatColor.GRAY + "Rotation: " + (variant.rotatable() ? "Enabled" : "Disabled"));
|
||||
element.addLore(ChatColor.GRAY + "Size: " + variant.dimensions()
|
||||
.map(JigsawStudioMenuFormat::dimensions)
|
||||
.orElse("Object missing"));
|
||||
element.addLore(ChatColor.GRAY + "Themes: " + themes(variant.themes()));
|
||||
element.addLore(ChatColor.GRAY + "Pool entries: " + variant.memberships().size());
|
||||
element.addLore(variant.active()
|
||||
? ChatColor.YELLOW + "Loaded; right-click for details"
|
||||
: ChatColor.YELLOW + "Left-click to load");
|
||||
if (!variant.active()) {
|
||||
element.addLore(ChatColor.YELLOW + "Right-click for details or deletion");
|
||||
}
|
||||
element.onLeftClick(clicked -> controller.switchVariant(
|
||||
window.getViewer(),
|
||||
state.requestId(),
|
||||
workcell.stableId(),
|
||||
variant.pieceKey()));
|
||||
element.onRightClick(clicked -> J.runEntity(
|
||||
window.getViewer(),
|
||||
() -> controller.openDetails(
|
||||
window.getViewer(),
|
||||
state.requestId(),
|
||||
workcell.stableId(),
|
||||
variant.pieceKey(),
|
||||
0),
|
||||
1));
|
||||
window.setElement(position, row, element);
|
||||
}
|
||||
}
|
||||
|
||||
private void addMainActions(
|
||||
UIWindow window,
|
||||
JigsawStudioMenuState state,
|
||||
JigsawStudioMenuState.Workcell workcell,
|
||||
int page
|
||||
) {
|
||||
UIElement create = element("create", Material.NETHER_STAR, ChatColor.GREEN + "New Blank Variant");
|
||||
create.addLore(ChatColor.GRAY + "Create an empty owned variant with this cell's current size.");
|
||||
create.onLeftClick(clicked -> controller.createVariant(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), false));
|
||||
window.setElement(-4, 5, create);
|
||||
|
||||
JigsawStudioMenuState.Variant active = workcell.activeVariant();
|
||||
boolean duplicateAvailable = active != null && active.owned();
|
||||
UIElement duplicate = element(
|
||||
"duplicate",
|
||||
duplicateAvailable ? Material.PAPER : Material.GRAY_DYE,
|
||||
duplicateAvailable
|
||||
? ChatColor.AQUA + "Duplicate This Cell's Variant"
|
||||
: active == null
|
||||
? ChatColor.GRAY + "Duplicate This Cell's Variant"
|
||||
: ChatColor.GRAY + "Duplicate This Cell's Variant");
|
||||
duplicate.addLore(duplicateAvailable
|
||||
? ChatColor.GRAY + "Clone the loaded variant into an owned variant."
|
||||
: active == null
|
||||
? ChatColor.GRAY + "Load a variant before duplicating it."
|
||||
: ChatColor.GRAY + "Adopt the read-only graph before duplicating it.");
|
||||
if (duplicateAvailable) {
|
||||
duplicate.onLeftClick(clicked -> controller.createVariant(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), true));
|
||||
}
|
||||
window.setElement(-3, 5, duplicate);
|
||||
|
||||
UIElement settings = element("settings", Material.CRAFTING_TABLE, ChatColor.GOLD + "Workcell Settings");
|
||||
settings.addLore(ChatColor.GRAY + "Enabled: " + yesNo(workcell.enabled()));
|
||||
settings.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity()));
|
||||
settings.onLeftClick(clicked -> controller.openWorkcellSettings(
|
||||
window.getViewer(), state.requestId(), workcell.stableId()));
|
||||
window.setElement(-2, 5, settings);
|
||||
|
||||
int pageCount = pageCount(workcell.variants().size(), VARIANTS_PER_PAGE);
|
||||
if (page > 0) {
|
||||
UIElement previous = element("previous", Material.ARROW, ChatColor.YELLOW + "Previous Page");
|
||||
previous.onLeftClick(clicked -> controller.refreshMain(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), page - 1));
|
||||
window.setElement(-1, 5, previous);
|
||||
}
|
||||
|
||||
UIElement pageIndicator = element("page", Material.BOOK, ChatColor.WHITE + "Page "
|
||||
+ (page + 1) + "/" + pageCount);
|
||||
pageIndicator.addLore(ChatColor.GRAY + "" + workcell.variants().size() + " variants");
|
||||
window.setElement(0, 5, pageIndicator);
|
||||
|
||||
if (page + 1 < pageCount) {
|
||||
UIElement next = element("next", Material.ARROW, ChatColor.YELLOW + "Next Page");
|
||||
next.onLeftClick(clicked -> controller.refreshMain(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), page + 1));
|
||||
window.setElement(1, 5, next);
|
||||
}
|
||||
|
||||
window.setElement(2, 5, evaluationElement(state.evaluation()));
|
||||
|
||||
UIElement preview = element("preview", Material.ENDER_EYE, ChatColor.LIGHT_PURPLE + "Go to Preview");
|
||||
preview.addLore(ChatColor.GRAY + "Open the deterministic assembly preview.");
|
||||
preview.onLeftClick(clicked -> controller.goToPreview(window.getViewer(), state.requestId()));
|
||||
window.setElement(3, 5, preview);
|
||||
|
||||
UIElement toolbox = element("toolbox", Material.STICK, ChatColor.AQUA + "Toolbox");
|
||||
toolbox.addLore(ChatColor.GRAY + "Take named sticks bound to this Studio session.");
|
||||
toolbox.onLeftClick(clicked -> controller.openToolbox(
|
||||
window.getViewer(), state.requestId(), workcell.stableId(), 0));
|
||||
window.setElement(4, 5, toolbox);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
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.JigsawStudioSession;
|
||||
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkCaptureArea;
|
||||
import art.arcane.iris.core.service.JigsawStudioCapture.LocalPosition;
|
||||
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
|
||||
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static art.arcane.iris.core.service.JigsawStudioCapture.chunkIntersections;
|
||||
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.hydrateChunk;
|
||||
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.restoreConnectorChunk;
|
||||
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.validateMaterialization;
|
||||
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.writeMaterializedChunk;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.chunkKey;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
|
||||
import static art.arcane.iris.core.service.JigsawStudioService.message;
|
||||
|
||||
final class JigsawStudioMaterializer {
|
||||
private static final int HYDRATION_RETRY_TICKS = 2;
|
||||
private static final int MAX_HYDRATION_ATTEMPTS = 40;
|
||||
|
||||
private final JigsawStudioService service;
|
||||
final Set<UUID> materializationsInProgress = new HashSet<>();
|
||||
|
||||
JigsawStudioMaterializer(JigsawStudioService service) {
|
||||
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
|
||||
}
|
||||
|
||||
boolean scheduleMaterialization(MaterializationWork work) {
|
||||
String validationFailure = validateMaterialization(work.target());
|
||||
if (!validationFailure.isEmpty()) {
|
||||
work.studio().generator().getSession().abortVariantSwitch(work.token());
|
||||
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
|
||||
message(work.player(), "Variant '" + work.token().targetVariant().pieceKey()
|
||||
+ "' cannot load: " + validationFailure);
|
||||
return false;
|
||||
}
|
||||
String reservationFailure = beginMaterialization(work);
|
||||
if (!reservationFailure.isEmpty()) {
|
||||
work.studio().generator().getSession().abortVariantSwitch(work.token());
|
||||
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
|
||||
message(work.player(), reservationFailure);
|
||||
return false;
|
||||
}
|
||||
List<ChunkCaptureArea> areas = chunkIntersections(work.workcell().bounds());
|
||||
MaterializationCoordinator coordinator = new MaterializationCoordinator(work, areas);
|
||||
for (ChunkCaptureArea area : areas) {
|
||||
boolean scheduled = J.runRegion(
|
||||
work.world(),
|
||||
area.chunkX(),
|
||||
area.chunkZ(),
|
||||
() -> materializeCandidateChunk(coordinator, area));
|
||||
if (!scheduled) {
|
||||
coordinator.candidateComplete(
|
||||
area,
|
||||
"Iris could not schedule workcell chunk "
|
||||
+ area.chunkX() + "," + area.chunkZ() + " on its owning region.",
|
||||
null);
|
||||
} else {
|
||||
coordinator.markScheduled();
|
||||
}
|
||||
}
|
||||
return coordinator.scheduledAny();
|
||||
}
|
||||
|
||||
private String beginMaterialization(MaterializationWork work) {
|
||||
UUID requestId = work.studio().generator().getRequest().requestId();
|
||||
synchronized (service.saveLifecycleLock) {
|
||||
if (!isCurrentVariantSwitch(work)) {
|
||||
return "The Jigsaw Studio changed before the variant load could start.";
|
||||
}
|
||||
if (service.saveLifecycle.closingRequests.contains(requestId)) {
|
||||
return "This Jigsaw Studio is closing and cannot load another variant.";
|
||||
}
|
||||
if (service.saveLifecycle.savesInProgress.contains(requestId)) {
|
||||
return "Wait for the current Jigsaw Studio save to finish.";
|
||||
}
|
||||
if (service.graphMutations.graphMutationsInProgress.contains(requestId)) {
|
||||
return "Wait for the current Jigsaw Studio graph update to finish.";
|
||||
}
|
||||
if (service.saveLifecycle.exportsInProgress.contains(requestId)) {
|
||||
return "Wait for the current Jigsaw Studio export to finish.";
|
||||
}
|
||||
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
|
||||
return "Finish or close the open vanilla jigsaw-block editor before loading another variant.";
|
||||
}
|
||||
if (!materializationsInProgress.add(requestId)) {
|
||||
return "Another Jigsaw Studio variant load or rollback is already running.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
String beginConnectorRepair(ActiveStudio studio) {
|
||||
UUID requestId = studio.generator().getRequest().requestId();
|
||||
service.tileWatcher.finalizeJigsawTileWatches(requestId);
|
||||
synchronized (service.saveLifecycleLock) {
|
||||
if (!service.isCurrentRequest(studio, requestId)) {
|
||||
return "This Jigsaw Studio session is no longer active.";
|
||||
}
|
||||
if (service.saveLifecycle.closingRequests.contains(requestId)) {
|
||||
return "This Jigsaw Studio is closing and cannot reset connector blocks.";
|
||||
}
|
||||
if (service.saveLifecycle.savesInProgress.contains(requestId)) {
|
||||
return "Wait for the current Jigsaw Studio save to finish.";
|
||||
}
|
||||
if (service.graphMutations.graphMutationsInProgress.contains(requestId)) {
|
||||
return "Wait for the current Jigsaw Studio graph update to finish.";
|
||||
}
|
||||
if (service.saveLifecycle.exportsInProgress.contains(requestId)) {
|
||||
return "Wait for the current Jigsaw Studio export to finish.";
|
||||
}
|
||||
if (studio.generator().getSession().operationInProgress()) {
|
||||
return "Wait for the current workcell operation to finish.";
|
||||
}
|
||||
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
|
||||
return "Finish or close the open vanilla jigsaw-block editor before resetting connectors.";
|
||||
}
|
||||
if (!materializationsInProgress.add(requestId)) {
|
||||
return "Another Jigsaw Studio variant load or repair is already running.";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
boolean scheduleConnectorRepair(
|
||||
Player player,
|
||||
ActiveStudio studio,
|
||||
JigsawStudioBay workcell,
|
||||
JigsawStudioGenerator.RenderedBay rendered,
|
||||
boolean connectorsVisible
|
||||
) {
|
||||
Map<Long, List<JigsawStudioGenerator.RenderedConnector>> connectorsByChunk = new HashMap<>();
|
||||
JigsawStudioBounds bounds = workcell.bounds();
|
||||
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
|
||||
int worldX = bounds.originX() + connector.x();
|
||||
int worldZ = bounds.originZ() + connector.z();
|
||||
connectorsByChunk.computeIfAbsent(
|
||||
chunkKey(worldX >> 4, worldZ >> 4),
|
||||
ignored -> new ArrayList<>()).add(connector);
|
||||
}
|
||||
AtomicInteger remaining = new AtomicInteger(connectorsByChunk.size());
|
||||
AtomicReference<String> failure = new AtomicReference<>("");
|
||||
AtomicReference<Throwable> cause = new AtomicReference<>();
|
||||
AtomicBoolean scheduledAny = new AtomicBoolean(false);
|
||||
Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> renderedBlocks = new HashMap<>();
|
||||
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
|
||||
renderedBlocks.put(new LocalPosition(block.x(), block.y(), block.z()), block);
|
||||
}
|
||||
for (Map.Entry<Long, List<JigsawStudioGenerator.RenderedConnector>> entry
|
||||
: connectorsByChunk.entrySet()) {
|
||||
int chunkX = (int) (entry.getKey() >> 32);
|
||||
int chunkZ = (int) entry.getKey().longValue();
|
||||
boolean scheduled = J.runRegion(studio.world(), chunkX, chunkZ, () -> {
|
||||
try {
|
||||
if (!studio.world().isChunkLoaded(chunkX, chunkZ)) {
|
||||
throw new IOException("connector chunk " + chunkX + "," + chunkZ
|
||||
+ " is not loaded");
|
||||
}
|
||||
restoreConnectorChunk(
|
||||
studio.world(),
|
||||
workcell,
|
||||
entry.getValue(),
|
||||
renderedBlocks,
|
||||
connectorsVisible);
|
||||
} catch (Throwable exception) {
|
||||
failure.compareAndSet("", failureMessage(exception));
|
||||
cause.compareAndSet(null, exception);
|
||||
}
|
||||
if (remaining.decrementAndGet() == 0) {
|
||||
completeConnectorRepair(
|
||||
player,
|
||||
studio,
|
||||
rendered.connectors().size(),
|
||||
failure.get(),
|
||||
cause.get());
|
||||
}
|
||||
});
|
||||
if (scheduled) {
|
||||
scheduledAny.set(true);
|
||||
} else {
|
||||
failure.compareAndSet("", "connector chunk " + chunkX + "," + chunkZ
|
||||
+ " could not be scheduled on its owning region");
|
||||
if (remaining.decrementAndGet() == 0) {
|
||||
completeConnectorRepair(
|
||||
player,
|
||||
studio,
|
||||
rendered.connectors().size(),
|
||||
failure.get(),
|
||||
cause.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!scheduledAny.get()) {
|
||||
finishMaterialization(studio.generator().getRequest().requestId());
|
||||
}
|
||||
return scheduledAny.get();
|
||||
}
|
||||
|
||||
private void completeConnectorRepair(
|
||||
Player player,
|
||||
ActiveStudio studio,
|
||||
int connectorCount,
|
||||
String failure,
|
||||
Throwable cause
|
||||
) {
|
||||
UUID requestId = studio.generator().getRequest().requestId();
|
||||
boolean scheduled = J.runEntity(player, () -> {
|
||||
finishMaterialization(requestId);
|
||||
if (cause != null) {
|
||||
IrisLogging.reportError(cause);
|
||||
}
|
||||
if (!failure.isEmpty()) {
|
||||
message(player, "Connector reset was incomplete: " + failure + ". Retry after visiting the workcell.");
|
||||
return;
|
||||
}
|
||||
message(player, "Restored " + connectorCount
|
||||
+ " connector block(s) from the last saved iteration.");
|
||||
});
|
||||
if (!scheduled) {
|
||||
finishMaterialization(requestId);
|
||||
if (cause != null) {
|
||||
IrisLogging.reportError(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void finishMaterialization(UUID requestId) {
|
||||
synchronized (service.saveLifecycleLock) {
|
||||
materializationsInProgress.remove(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private void materializeCandidateChunk(
|
||||
MaterializationCoordinator coordinator,
|
||||
ChunkCaptureArea area
|
||||
) {
|
||||
MaterializationWork work = coordinator.work();
|
||||
if (!isCurrentVariantSwitch(work)) {
|
||||
coordinator.candidateComplete(
|
||||
area,
|
||||
"Jigsaw Studio changed before the variant could finish loading.",
|
||||
null);
|
||||
return;
|
||||
}
|
||||
if (!work.world().isChunkLoaded(area.chunkX(), area.chunkZ())) {
|
||||
coordinator.candidateComplete(
|
||||
area,
|
||||
"Workcell chunk " + area.chunkX() + "," + area.chunkZ()
|
||||
+ " is not loaded. Visit the whole workcell and try again.",
|
||||
null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
coordinator.markCandidateTouched(area);
|
||||
writeMaterializedChunk(
|
||||
work.world(),
|
||||
work.workcell(),
|
||||
work.target(),
|
||||
area,
|
||||
work.targetConnectorsVisible());
|
||||
coordinator.candidateComplete(area, "", null);
|
||||
} catch (Throwable exception) {
|
||||
coordinator.candidateComplete(
|
||||
area,
|
||||
"Variant materialization failed in chunk "
|
||||
+ area.chunkX() + "," + area.chunkZ() + ": " + failureMessage(exception),
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void materializeRollbackChunk(
|
||||
MaterializationCoordinator coordinator,
|
||||
ChunkCaptureArea area
|
||||
) {
|
||||
MaterializationWork work = coordinator.work();
|
||||
if (!isRollbackVariantSwitch(work)) {
|
||||
coordinator.rollbackComplete(
|
||||
area,
|
||||
"Jigsaw Studio changed before the previous variant could be restored.",
|
||||
null);
|
||||
return;
|
||||
}
|
||||
if (!work.world().isChunkLoaded(area.chunkX(), area.chunkZ())) {
|
||||
coordinator.rollbackComplete(
|
||||
area,
|
||||
"Rollback chunk " + area.chunkX() + "," + area.chunkZ() + " is not loaded.",
|
||||
null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
writeMaterializedChunk(
|
||||
work.world(),
|
||||
work.workcell(),
|
||||
work.previous(),
|
||||
area,
|
||||
work.previousConnectorsVisible());
|
||||
coordinator.rollbackComplete(area, "", null);
|
||||
} catch (Throwable exception) {
|
||||
coordinator.rollbackComplete(
|
||||
area,
|
||||
"Rollback failed in chunk " + area.chunkX() + "," + area.chunkZ()
|
||||
+ ": " + failureMessage(exception),
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCurrentVariantSwitch(MaterializationWork work) {
|
||||
return service.isCurrentRequest(
|
||||
work.studio(),
|
||||
work.studio().generator().getRequest().requestId())
|
||||
&& work.studio().generator().getSession().isVariantSwitchCurrent(work.token());
|
||||
}
|
||||
|
||||
private boolean isRollbackVariantSwitch(MaterializationWork work) {
|
||||
return service.studios.get(work.studio().worldId()) == work.studio()
|
||||
&& service.protection.materializationInProgress(work.studio());
|
||||
}
|
||||
|
||||
void scheduleHydration(
|
||||
ActiveStudio studio,
|
||||
World world,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
int attempt
|
||||
) {
|
||||
if (!service.enabled || service.studios.get(world.getUID()) != studio) {
|
||||
return;
|
||||
}
|
||||
long key = chunkKey(chunkX, chunkZ);
|
||||
if (!studio.hydrationsInProgress().add(key)) {
|
||||
return;
|
||||
}
|
||||
boolean scheduled = J.runRegion(
|
||||
world,
|
||||
chunkX,
|
||||
chunkZ,
|
||||
() -> runHydrationAttempt(studio, world, chunkX, chunkZ, attempt, key),
|
||||
HYDRATION_RETRY_TICKS);
|
||||
if (!scheduled) {
|
||||
studio.hydrationsInProgress().remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private void runHydrationAttempt(
|
||||
ActiveStudio studio,
|
||||
World world,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
int attempt,
|
||||
long key
|
||||
) {
|
||||
studio.hydrationsInProgress().remove(key);
|
||||
if (!service.enabled || service.studios.get(world.getUID()) != studio) {
|
||||
return;
|
||||
}
|
||||
if (!world.isChunkLoaded(chunkX, chunkZ)) {
|
||||
if (attempt < MAX_HYDRATION_ATTEMPTS) {
|
||||
scheduleHydration(studio, world, chunkX, chunkZ, attempt + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hydrateChunk(studio, world, chunkX, chunkZ)) {
|
||||
scheduleHydration(studio, world, chunkX, chunkZ, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private final class MaterializationCoordinator {
|
||||
private final MaterializationWork work;
|
||||
private final List<ChunkCaptureArea> areas;
|
||||
private final Set<Long> candidateCompleted = new HashSet<>();
|
||||
private final Set<Long> candidateTouched = new HashSet<>();
|
||||
private final Set<Long> rollbackCompleted = new HashSet<>();
|
||||
private int candidateRemaining;
|
||||
private int rollbackRemaining;
|
||||
private String candidateFailure = "";
|
||||
private String rollbackFailure = "";
|
||||
private boolean rollbackStarted;
|
||||
private boolean finished;
|
||||
private boolean leaseReleased;
|
||||
private boolean scheduledAny;
|
||||
|
||||
private MaterializationCoordinator(
|
||||
MaterializationWork work,
|
||||
List<ChunkCaptureArea> areas
|
||||
) {
|
||||
this.work = Objects.requireNonNull(work, "Jigsaw Studio materialization work");
|
||||
this.areas = List.copyOf(areas);
|
||||
candidateRemaining = this.areas.size();
|
||||
}
|
||||
|
||||
private MaterializationWork work() {
|
||||
return work;
|
||||
}
|
||||
|
||||
private synchronized void markScheduled() {
|
||||
scheduledAny = true;
|
||||
}
|
||||
|
||||
private synchronized boolean scheduledAny() {
|
||||
return scheduledAny;
|
||||
}
|
||||
|
||||
private synchronized void markCandidateTouched(ChunkCaptureArea area) {
|
||||
candidateTouched.add(chunkKey(area.chunkX(), area.chunkZ()));
|
||||
}
|
||||
|
||||
private void candidateComplete(
|
||||
ChunkCaptureArea area,
|
||||
String failure,
|
||||
Throwable exception
|
||||
) {
|
||||
boolean finishSuccess = false;
|
||||
boolean startRollback = false;
|
||||
synchronized (this) {
|
||||
if (finished || rollbackStarted
|
||||
|| !candidateCompleted.add(chunkKey(area.chunkX(), area.chunkZ()))) {
|
||||
return;
|
||||
}
|
||||
if (failure != null && !failure.isBlank() && candidateFailure.isEmpty()) {
|
||||
candidateFailure = failure;
|
||||
}
|
||||
candidateRemaining--;
|
||||
if (candidateRemaining == 0) {
|
||||
if (candidateFailure.isEmpty()) {
|
||||
finishSuccess = true;
|
||||
} else {
|
||||
rollbackStarted = true;
|
||||
startRollback = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (exception != null) {
|
||||
IrisLogging.reportError(exception);
|
||||
}
|
||||
if (finishSuccess) {
|
||||
finishSuccess();
|
||||
} else if (startRollback) {
|
||||
scheduleRollback();
|
||||
}
|
||||
}
|
||||
|
||||
private void finishSuccess() {
|
||||
JigsawStudioSession session = work.studio().generator().getSession();
|
||||
if (!isCurrentVariantSwitch(work) || !session.completeVariantSwitch(work.token())) {
|
||||
beginLateRollback("Jigsaw Studio changed before the loaded variant could be activated.");
|
||||
return;
|
||||
}
|
||||
if (work.connectorVisibilityChange()) {
|
||||
session.setConnectorsVisible(work.workcell().stableId(), work.targetConnectorsVisible());
|
||||
}
|
||||
synchronized (this) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
}
|
||||
try {
|
||||
work.studio().generator().invalidateRender(work.workcell().stableId());
|
||||
work.studio().replacePopulation(work.workcell(), work.target(), "", true);
|
||||
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
|
||||
message(work.player(), work.connectorVisibilityChange()
|
||||
? "Connector blocks are now "
|
||||
+ (work.targetConnectorsVisible() ? "visible." : "hidden.")
|
||||
: "Loaded variant '" + work.token().targetVariant().pieceKey()
|
||||
+ "' into " + work.workcell().stableId() + ".");
|
||||
} finally {
|
||||
releaseLease();
|
||||
}
|
||||
}
|
||||
|
||||
private void beginLateRollback(String failure) {
|
||||
synchronized (this) {
|
||||
if (finished || rollbackStarted) {
|
||||
return;
|
||||
}
|
||||
candidateFailure = failure;
|
||||
rollbackStarted = true;
|
||||
}
|
||||
scheduleRollback();
|
||||
}
|
||||
|
||||
private void scheduleRollback() {
|
||||
List<ChunkCaptureArea> rollbackAreas = new ArrayList<>();
|
||||
synchronized (this) {
|
||||
for (ChunkCaptureArea area : areas) {
|
||||
if (candidateTouched.contains(chunkKey(area.chunkX(), area.chunkZ()))) {
|
||||
rollbackAreas.add(area);
|
||||
}
|
||||
}
|
||||
rollbackRemaining = rollbackAreas.size();
|
||||
if (rollbackRemaining == 0) {
|
||||
finished = true;
|
||||
}
|
||||
}
|
||||
if (rollbackAreas.isEmpty()) {
|
||||
finishRollback();
|
||||
return;
|
||||
}
|
||||
for (ChunkCaptureArea area : rollbackAreas) {
|
||||
boolean scheduled = J.runRegion(
|
||||
work.world(),
|
||||
area.chunkX(),
|
||||
area.chunkZ(),
|
||||
() -> materializeRollbackChunk(this, area));
|
||||
if (!scheduled) {
|
||||
rollbackComplete(
|
||||
area,
|
||||
"Iris could not schedule rollback for workcell chunk "
|
||||
+ area.chunkX() + "," + area.chunkZ() + ".",
|
||||
null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void rollbackComplete(
|
||||
ChunkCaptureArea area,
|
||||
String failure,
|
||||
Throwable exception
|
||||
) {
|
||||
boolean finalizeRollback = false;
|
||||
synchronized (this) {
|
||||
if (finished || !rollbackStarted
|
||||
|| !rollbackCompleted.add(chunkKey(area.chunkX(), area.chunkZ()))) {
|
||||
return;
|
||||
}
|
||||
if (failure != null && !failure.isBlank() && rollbackFailure.isEmpty()) {
|
||||
rollbackFailure = failure;
|
||||
}
|
||||
rollbackRemaining--;
|
||||
if (rollbackRemaining == 0) {
|
||||
finished = true;
|
||||
finalizeRollback = true;
|
||||
}
|
||||
}
|
||||
if (exception != null) {
|
||||
IrisLogging.reportError(exception);
|
||||
}
|
||||
if (finalizeRollback) {
|
||||
finishRollback();
|
||||
}
|
||||
}
|
||||
|
||||
private void finishRollback() {
|
||||
JigsawStudioSession session = work.studio().generator().getSession();
|
||||
boolean released = session.abortVariantSwitch(work.token());
|
||||
try {
|
||||
work.studio().generator().invalidateRender(work.workcell().stableId());
|
||||
if (rollbackFailure.isEmpty() && released) {
|
||||
work.studio().replacePopulation(work.workcell(), work.previous(), "", true);
|
||||
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
|
||||
message(work.player(), candidateFailure + " The previous variant was restored.");
|
||||
return;
|
||||
}
|
||||
String failure = rollbackFailure.isEmpty()
|
||||
? "The previous variant could not be restored because the Studio session changed."
|
||||
: rollbackFailure;
|
||||
work.studio().replacePopulation(work.workcell(), work.previous(), failure, false);
|
||||
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
|
||||
message(work.player(), candidateFailure + " Automatic rollback also failed: " + failure
|
||||
+ " Do not save this workcell until it is reopened.");
|
||||
} finally {
|
||||
releaseLease();
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseLease() {
|
||||
synchronized (this) {
|
||||
if (leaseReleased) {
|
||||
return;
|
||||
}
|
||||
leaseReleased = true;
|
||||
}
|
||||
finishMaterialization(work.studio().generator().getRequest().requestId());
|
||||
}
|
||||
}
|
||||
|
||||
record MaterializationWork(
|
||||
ActiveStudio studio,
|
||||
World world,
|
||||
Player player,
|
||||
JigsawStudioBay workcell,
|
||||
JigsawStudioSession.VariantSwitchToken token,
|
||||
JigsawStudioGenerator.RenderedBay previous,
|
||||
JigsawStudioGenerator.RenderedBay target,
|
||||
boolean previousConnectorsVisible,
|
||||
boolean targetConnectorsVisible,
|
||||
boolean connectorVisibilityChange
|
||||
) {
|
||||
MaterializationWork {
|
||||
Objects.requireNonNull(studio, "Jigsaw Studio materialization studio");
|
||||
Objects.requireNonNull(world, "Jigsaw Studio materialization world");
|
||||
Objects.requireNonNull(player, "Jigsaw Studio materialization player");
|
||||
Objects.requireNonNull(workcell, "Jigsaw Studio materialization workcell");
|
||||
Objects.requireNonNull(token, "Jigsaw Studio materialization token");
|
||||
Objects.requireNonNull(previous, "Jigsaw Studio previous rendered variant");
|
||||
Objects.requireNonNull(target, "Jigsaw Studio target rendered variant");
|
||||
}
|
||||
}
|
||||
|
||||
public record VariantReloadRequest(
|
||||
String workcellId,
|
||||
JigsawStudioGenerator.RenderedBay previous
|
||||
) {
|
||||
public VariantReloadRequest {
|
||||
workcellId = workcellId == null ? "" : workcellId.trim();
|
||||
if (workcellId.isEmpty()) {
|
||||
throw new IllegalArgumentException("Jigsaw Studio reload workcell ID cannot be blank");
|
||||
}
|
||||
Objects.requireNonNull(previous, "Jigsaw Studio previous rendered variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface JigsawStudioMenuActions {
|
||||
Optional<JigsawStudioMenuState> menuState(Player player);
|
||||
|
||||
boolean selectWorkcell(Player player, String workcellId);
|
||||
|
||||
boolean teleportToWorkcell(Player player, String workcellId);
|
||||
|
||||
boolean setConnectorBlocksVisible(Player player, String workcellId, boolean visible);
|
||||
|
||||
boolean resetConnectorBlocks(Player player, String workcellId);
|
||||
|
||||
boolean undoAutosave(Player player);
|
||||
|
||||
boolean switchVariant(Player player, String workcellId, String pieceKey, boolean discardDirty);
|
||||
|
||||
boolean createVariant(Player player, String workcellId, boolean duplicateActive);
|
||||
|
||||
boolean setWorkcellEnabled(Player player, String workcellId, boolean enabled);
|
||||
|
||||
boolean updateWorkcellDimensions(
|
||||
Player player,
|
||||
String workcellId,
|
||||
JigsawStudioCellDimensions dimensions
|
||||
);
|
||||
|
||||
boolean updateWorkcellDisplayName(Player player, String workcellId, String displayName);
|
||||
|
||||
boolean setRequireCaps(Player player, boolean requireCaps);
|
||||
|
||||
boolean duplicateActiveFamily(Player player, String themeKey);
|
||||
|
||||
boolean updateThemeSetWeight(Player player, String themeKey, int weight);
|
||||
|
||||
boolean flushAutosave(Player player, String workcellId);
|
||||
|
||||
boolean goToPreview(Player player);
|
||||
|
||||
boolean toggleVariantRotatable(Player player, String workcellId);
|
||||
|
||||
boolean expandVariantToCell(Player player, String workcellId);
|
||||
|
||||
boolean resizeVariant(
|
||||
Player player,
|
||||
String workcellId,
|
||||
String pieceKey,
|
||||
JigsawStudioCellDimensions dimensions
|
||||
);
|
||||
|
||||
boolean updateVariantDisplayName(
|
||||
Player player,
|
||||
String workcellId,
|
||||
String pieceKey,
|
||||
String displayName
|
||||
);
|
||||
|
||||
boolean updateVariantThemes(
|
||||
Player player,
|
||||
String workcellId,
|
||||
String pieceKey,
|
||||
List<String> themes
|
||||
);
|
||||
|
||||
boolean updateVariantRules(
|
||||
Player player,
|
||||
String workcellId,
|
||||
String pieceKey,
|
||||
JigsawStudioPieceRules rules
|
||||
);
|
||||
|
||||
boolean adjustVariantWeight(
|
||||
Player player,
|
||||
String workcellId,
|
||||
String pieceKey,
|
||||
String poolKey,
|
||||
int entryIndex,
|
||||
int delta
|
||||
);
|
||||
|
||||
boolean adjustVariantChance(
|
||||
Player player,
|
||||
String workcellId,
|
||||
String pieceKey,
|
||||
String poolKey,
|
||||
int entryIndex,
|
||||
int deltaPercentagePoints
|
||||
);
|
||||
|
||||
boolean unlinkVariantMembership(
|
||||
Player player,
|
||||
String workcellId,
|
||||
String pieceKey,
|
||||
String poolKey,
|
||||
int entryIndex
|
||||
);
|
||||
|
||||
boolean deleteVariant(Player player, String workcellId, String pieceKey);
|
||||
|
||||
boolean deleteProject(Player player);
|
||||
|
||||
boolean giveTool(Player player, JigsawStudioToolPayload payload);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,477 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules;
|
||||
import art.arcane.volmlib.util.data.MaterialBlock;
|
||||
import art.arcane.volmlib.util.inventorygui.UIElement;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Material;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuController.PLACEMENT_RULE_SHIFT_STEP;
|
||||
import static art.arcane.iris.core.service.JigsawStudioMenuController.RULE_SHIFT_STEP;
|
||||
|
||||
final class JigsawStudioMenuFormat {
|
||||
private JigsawStudioMenuFormat() {
|
||||
}
|
||||
|
||||
static boolean hasMembership(
|
||||
JigsawStudioMenuState state,
|
||||
String workcellId,
|
||||
JigsawStudioMenuState.Membership expected
|
||||
) {
|
||||
JigsawStudioMenuState.Variant active = activeVariant(state, workcellId);
|
||||
if (active == null) {
|
||||
return false;
|
||||
}
|
||||
for (JigsawStudioMenuState.Membership membership : active.memberships()) {
|
||||
if (membership.poolKey().equals(expected.poolKey())
|
||||
&& membership.entryIndex() == expected.entryIndex()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static JigsawStudioMenuState.Variant activeVariant(
|
||||
JigsawStudioMenuState state,
|
||||
String workcellId
|
||||
) {
|
||||
JigsawStudioMenuState.Workcell workcell = state.workcell(workcellId);
|
||||
return workcell == null ? null : workcell.activeVariant();
|
||||
}
|
||||
|
||||
static JigsawStudioMenuState.Variant variant(
|
||||
JigsawStudioMenuState.Workcell workcell,
|
||||
String pieceKey
|
||||
) {
|
||||
if (workcell == null || pieceKey == null) {
|
||||
return null;
|
||||
}
|
||||
for (JigsawStudioMenuState.Variant variant : workcell.variants()) {
|
||||
if (variant.pieceKey().equals(pieceKey)) {
|
||||
return variant;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String nextThemeSetKey(List<JigsawStudioMenuState.ThemeSet> themeSets) {
|
||||
List<JigsawStudioMenuState.ThemeSet> values = List.copyOf(Objects.requireNonNull(
|
||||
themeSets,
|
||||
"Jigsaw Studio theme sets"));
|
||||
int candidate = 1;
|
||||
while (candidate > 0) {
|
||||
String key = "variant-" + candidate;
|
||||
boolean present = false;
|
||||
for (JigsawStudioMenuState.ThemeSet themeSet : values) {
|
||||
if (Objects.requireNonNull(themeSet, "Jigsaw Studio theme set").key().equals(key)) {
|
||||
present = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!present) {
|
||||
return key;
|
||||
}
|
||||
candidate = Math.incrementExact(candidate);
|
||||
}
|
||||
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");
|
||||
}
|
||||
try {
|
||||
int adjusted = Math.addExact(value, delta);
|
||||
return adjusted < 1 ? Optional.empty() : Optional.of(adjusted);
|
||||
} catch (ArithmeticException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
static Optional<JigsawStudioPieceRules> adjustedRules(
|
||||
JigsawStudioPieceRules rules,
|
||||
RuleField field,
|
||||
int delta
|
||||
) {
|
||||
JigsawStudioPieceRules current = Objects.requireNonNull(rules, "Jigsaw Studio piece rules");
|
||||
RuleField target = Objects.requireNonNull(field, "Jigsaw Studio piece rule field");
|
||||
if (delta == 0) {
|
||||
throw new IllegalArgumentException("Jigsaw Studio piece rule delta cannot be zero");
|
||||
}
|
||||
try {
|
||||
int minimumDepth = current.minimumDepth();
|
||||
int maximumDepth = current.maximumDepth();
|
||||
int minimumPlacements = current.minimumPlacements();
|
||||
int maximumPlacements = current.maximumPlacements();
|
||||
switch (target) {
|
||||
case MINIMUM_DEPTH -> minimumDepth = Math.addExact(minimumDepth, delta);
|
||||
case MAXIMUM_DEPTH -> maximumDepth = Math.addExact(maximumDepth, delta);
|
||||
case MINIMUM_PLACEMENTS -> minimumPlacements = Math.addExact(minimumPlacements, delta);
|
||||
case MAXIMUM_PLACEMENTS -> {
|
||||
Optional<Integer> adjustedMaximum = adjustedMaximumPlacements(maximumPlacements, delta);
|
||||
if (adjustedMaximum.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
maximumPlacements = adjustedMaximum.get();
|
||||
}
|
||||
}
|
||||
return Optional.of(new JigsawStudioPieceRules(
|
||||
minimumDepth,
|
||||
maximumDepth,
|
||||
minimumPlacements,
|
||||
maximumPlacements,
|
||||
current.terminal()));
|
||||
} catch (ArithmeticException | IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
static JigsawStudioPieceRules withTerminal(JigsawStudioPieceRules rules, boolean terminal) {
|
||||
JigsawStudioPieceRules current = Objects.requireNonNull(rules, "Jigsaw Studio piece rules");
|
||||
return new JigsawStudioPieceRules(
|
||||
current.minimumDepth(),
|
||||
current.maximumDepth(),
|
||||
current.minimumPlacements(),
|
||||
current.maximumPlacements(),
|
||||
terminal);
|
||||
}
|
||||
|
||||
private static Optional<Integer> adjustedMaximumPlacements(int value, int delta) {
|
||||
if (value == 0) {
|
||||
if (delta > 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int adjusted = Math.addExact(513, delta);
|
||||
return adjusted < 1 ? Optional.empty() : Optional.of(adjusted);
|
||||
}
|
||||
int adjusted = Math.addExact(value, delta);
|
||||
if (adjusted > 512) {
|
||||
return Optional.of(0);
|
||||
}
|
||||
return adjusted < 1 ? Optional.empty() : Optional.of(adjusted);
|
||||
}
|
||||
|
||||
static Optional<JigsawStudioCellDimensions> adjustedDimensions(
|
||||
JigsawStudioCellDimensions dimensions,
|
||||
DimensionAxis axis,
|
||||
int delta
|
||||
) {
|
||||
JigsawStudioCellDimensions current = Objects.requireNonNull(
|
||||
dimensions,
|
||||
"Jigsaw Studio workcell dimensions");
|
||||
DimensionAxis target = Objects.requireNonNull(axis, "Jigsaw Studio workcell dimension axis");
|
||||
if (delta == 0) {
|
||||
throw new IllegalArgumentException("Jigsaw Studio workcell dimension delta cannot be zero");
|
||||
}
|
||||
try {
|
||||
int width = target == DimensionAxis.WIDTH
|
||||
? Math.addExact(current.width(), delta)
|
||||
: current.width();
|
||||
int height = target == DimensionAxis.HEIGHT
|
||||
? Math.addExact(current.height(), delta)
|
||||
: current.height();
|
||||
int depth = target == DimensionAxis.DEPTH
|
||||
? Math.addExact(current.depth(), delta)
|
||||
: current.depth();
|
||||
return Optional.of(new JigsawStudioCellDimensions(width, height, depth));
|
||||
} catch (ArithmeticException | IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
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 int pageCount(int itemCount, int pageSize) {
|
||||
if (itemCount < 0 || pageSize < 1) {
|
||||
throw new IllegalArgumentException("Jigsaw Studio menu page bounds are invalid");
|
||||
}
|
||||
return Math.max(1, (itemCount + pageSize - 1) / pageSize);
|
||||
}
|
||||
|
||||
static int clampPage(int requestedPage, int itemCount, int pageSize) {
|
||||
int maximum = pageCount(itemCount, pageSize) - 1;
|
||||
return Math.max(0, Math.min(requestedPage, maximum));
|
||||
}
|
||||
|
||||
static <T> List<T> page(List<T> items, int requestedPage, int pageSize) {
|
||||
List<T> values = List.copyOf(Objects.requireNonNull(items, "Jigsaw Studio menu page items"));
|
||||
int page = clampPage(requestedPage, values.size(), pageSize);
|
||||
int start = page * pageSize;
|
||||
int end = Math.min(values.size(), start + pageSize);
|
||||
return values.subList(start, end);
|
||||
}
|
||||
|
||||
static UIElement evaluationElement(JigsawStudioMenuState.Evaluation evaluation) {
|
||||
UIElement element = element(
|
||||
"evaluation",
|
||||
evaluationMaterial(evaluation.state()),
|
||||
evaluationColor(evaluation.state()) + evaluationName(evaluation.state()));
|
||||
element.addLore(ChatColor.GRAY + "Evaluation updates automatically.");
|
||||
element.addLore(ChatColor.GRAY + "Seed: " + evaluation.seed());
|
||||
if (evaluation.generation() > 0L) {
|
||||
element.addLore(ChatColor.GRAY + "Generation: " + evaluation.generation());
|
||||
}
|
||||
if (!evaluation.selectedTheme().isEmpty()) {
|
||||
element.addLore(ChatColor.GRAY + "Theme: " + safe(evaluation.selectedTheme()));
|
||||
}
|
||||
element.addLore(ChatColor.GRAY + "Pieces: " + evaluation.pieceCount());
|
||||
if (!evaluation.detail().isEmpty()) {
|
||||
element.addLore(ChatColor.GRAY + safe(evaluation.detail()));
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
static Material evaluationMaterial(JigsawStudioEvaluationState state) {
|
||||
return switch (state) {
|
||||
case PENDING -> Material.CLOCK;
|
||||
case VALID -> Material.EMERALD;
|
||||
case WARNING -> Material.YELLOW_DYE;
|
||||
case INVALID -> Material.RED_DYE;
|
||||
case STALE -> Material.GRAY_DYE;
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatColor evaluationColor(JigsawStudioEvaluationState state) {
|
||||
return switch (state) {
|
||||
case PENDING -> ChatColor.AQUA;
|
||||
case VALID -> ChatColor.GREEN;
|
||||
case WARNING -> ChatColor.YELLOW;
|
||||
case INVALID -> ChatColor.RED;
|
||||
case STALE -> ChatColor.GRAY;
|
||||
};
|
||||
}
|
||||
|
||||
private static String evaluationName(JigsawStudioEvaluationState state) {
|
||||
return switch (state) {
|
||||
case PENDING -> "Evaluation Pending";
|
||||
case VALID -> "Ready to Assemble";
|
||||
case WARNING -> "Ready with Warnings";
|
||||
case INVALID -> "Assembly Blocked";
|
||||
case STALE -> "Evaluation Stale";
|
||||
};
|
||||
}
|
||||
|
||||
static Material workcellMaterial(JigsawStudioMenuState.Workcell workcell, boolean selected) {
|
||||
if (!workcell.enabled()) {
|
||||
return Material.GRAY_WOOL;
|
||||
}
|
||||
if (workcell.loading() || workcell.saving()) {
|
||||
return Material.YELLOW_WOOL;
|
||||
}
|
||||
if (workcell.dirty()) {
|
||||
return Material.ORANGE_WOOL;
|
||||
}
|
||||
return selected ? Material.LIME_WOOL : Material.LIGHT_GRAY_WOOL;
|
||||
}
|
||||
|
||||
static Material variantMaterial(JigsawStudioMenuState.Variant variant) {
|
||||
if (variant.active()) {
|
||||
return Material.JIGSAW;
|
||||
}
|
||||
return variant.owned() ? Material.PAPER : Material.GRAY_DYE;
|
||||
}
|
||||
|
||||
static String workcellStatus(JigsawStudioMenuState.Workcell workcell) {
|
||||
if (workcell.loading()) {
|
||||
return ChatColor.YELLOW + "Loading variant";
|
||||
}
|
||||
if (workcell.saving()) {
|
||||
return ChatColor.YELLOW + "Autosave in progress";
|
||||
}
|
||||
if (workcell.dirty()) {
|
||||
return ChatColor.GOLD + "Autosave pending";
|
||||
}
|
||||
return ChatColor.GREEN + "Autosaved";
|
||||
}
|
||||
|
||||
static String dimensions(JigsawStudioCellDimensions dimensions) {
|
||||
return dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth();
|
||||
}
|
||||
|
||||
static String themes(List<String> themes) {
|
||||
return themes.isEmpty() ? "None" : safe(String.join(", ", themes));
|
||||
}
|
||||
|
||||
static String maximumPlacements(int maximum) {
|
||||
return maximum == 0 ? "Unlimited" : Integer.toString(maximum);
|
||||
}
|
||||
|
||||
static String chance(double chance) {
|
||||
return String.format(Locale.ROOT, "%.1f%%", chance * 100D);
|
||||
}
|
||||
|
||||
static String yesNo(boolean value) {
|
||||
return value ? "Yes" : "No";
|
||||
}
|
||||
|
||||
static String compatibilityName(JigsawStudioCompatibilityTarget target) {
|
||||
return switch (target) {
|
||||
case IRIS_EXTENDED -> "Iris Extended";
|
||||
case VANILLA_PORTABLE -> "Vanilla Portable";
|
||||
};
|
||||
}
|
||||
|
||||
static UIElement element(String id, Material material, String name) {
|
||||
return new UIElement(id)
|
||||
.setMaterial(new MaterialBlock(material))
|
||||
.setName(name);
|
||||
}
|
||||
|
||||
static String title(String structureKey) {
|
||||
String value = "Iris Jigsaw: " + safe(displayKey(structureKey));
|
||||
return value.length() > 32 ? value.substring(0, 32) : value;
|
||||
}
|
||||
|
||||
static String displayKey(String resourceKey) {
|
||||
int separator = resourceKey.lastIndexOf('/');
|
||||
return separator < 0 ? resourceKey : resourceKey.substring(separator + 1);
|
||||
}
|
||||
|
||||
static String safe(String value) {
|
||||
return BoardSVC.untrustedBoardValue(value);
|
||||
}
|
||||
|
||||
enum RuleField {
|
||||
MINIMUM_DEPTH("Minimum Depth", Material.LIGHT_BLUE_DYE, RULE_SHIFT_STEP) {
|
||||
@Override
|
||||
int value(JigsawStudioPieceRules rules) {
|
||||
return rules.minimumDepth();
|
||||
}
|
||||
},
|
||||
MAXIMUM_DEPTH("Maximum Depth", Material.BLUE_DYE, RULE_SHIFT_STEP) {
|
||||
@Override
|
||||
int value(JigsawStudioPieceRules rules) {
|
||||
return rules.maximumDepth();
|
||||
}
|
||||
},
|
||||
MINIMUM_PLACEMENTS("Minimum Placements", Material.TARGET, PLACEMENT_RULE_SHIFT_STEP) {
|
||||
@Override
|
||||
int value(JigsawStudioPieceRules rules) {
|
||||
return rules.minimumPlacements();
|
||||
}
|
||||
},
|
||||
MAXIMUM_PLACEMENTS("Maximum Placements", Material.GREEN_DYE, PLACEMENT_RULE_SHIFT_STEP) {
|
||||
@Override
|
||||
int value(JigsawStudioPieceRules rules) {
|
||||
return rules.maximumPlacements();
|
||||
}
|
||||
|
||||
@Override
|
||||
String displayValue(int value) {
|
||||
return maximumPlacements(value);
|
||||
}
|
||||
};
|
||||
|
||||
private final String displayName;
|
||||
private final Material material;
|
||||
private final int shiftStep;
|
||||
|
||||
RuleField(String displayName, Material material, int shiftStep) {
|
||||
this.displayName = displayName;
|
||||
this.material = material;
|
||||
this.shiftStep = shiftStep;
|
||||
}
|
||||
|
||||
String displayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
Material material() {
|
||||
return material;
|
||||
}
|
||||
|
||||
int shiftStep() {
|
||||
return shiftStep;
|
||||
}
|
||||
|
||||
String displayValue(int value) {
|
||||
return Integer.toString(value);
|
||||
}
|
||||
|
||||
abstract int value(JigsawStudioPieceRules rules);
|
||||
}
|
||||
|
||||
enum DimensionAxis {
|
||||
WIDTH("Width") {
|
||||
@Override
|
||||
int value(JigsawStudioCellDimensions dimensions) {
|
||||
return dimensions.width();
|
||||
}
|
||||
},
|
||||
HEIGHT("Height") {
|
||||
@Override
|
||||
int value(JigsawStudioCellDimensions dimensions) {
|
||||
return dimensions.height();
|
||||
}
|
||||
},
|
||||
DEPTH("Depth") {
|
||||
@Override
|
||||
int value(JigsawStudioCellDimensions dimensions) {
|
||||
return dimensions.depth();
|
||||
}
|
||||
};
|
||||
|
||||
private final String displayName;
|
||||
|
||||
DimensionAxis(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
String displayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
abstract int value(JigsawStudioCellDimensions dimensions);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 520 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user