mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +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:
+2
-1
@@ -32,6 +32,7 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
@@ -218,7 +219,7 @@ public class NativeStructureVolumeIndexTest {
|
||||
if (cleared) {
|
||||
break;
|
||||
}
|
||||
Thread.sleep(1);
|
||||
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10L));
|
||||
}
|
||||
assertTrue("Origin eviction did not complete before the query cache lock", cleared);
|
||||
resolver.release();
|
||||
|
||||
+5
@@ -6,6 +6,7 @@ import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.testsupport.PlatformLeakGuard;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
@@ -16,6 +17,7 @@ import net.minecraft.world.level.block.RotatedPillarBlock;
|
||||
import net.minecraft.world.level.block.LiquidBlock;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
@@ -30,6 +32,9 @@ import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public final class NativeTransitionColumnTest {
|
||||
@Rule
|
||||
public final PlatformLeakGuard leakGuard = PlatformLeakGuard.clean();
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
|
||||
@@ -22,6 +22,7 @@ dependencies {
|
||||
testImplementation(libs.paper.api)
|
||||
testImplementation(libs.bstats)
|
||||
testImplementation(project(':core'))
|
||||
testImplementation(testFixtures(project(':core')))
|
||||
testImplementation(volmLibCoordinate) {
|
||||
transitive = false
|
||||
}
|
||||
@@ -59,12 +60,4 @@ tasks.named('jar', Jar).configure {
|
||||
}
|
||||
|
||||
tasks.named('test').configure {
|
||||
systemProperty('iris.pregeneratorJobSource', rootProject.file('core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java').absolutePath)
|
||||
systemProperty('iris.bukkitEnginePlatformHooksSource', file('src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java').absolutePath)
|
||||
systemProperty('iris.engineSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java').absolutePath)
|
||||
systemProperty('iris.terrainSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java').absolutePath)
|
||||
systemProperty('iris.apiEventSvcSource', file('src/main/java/art/arcane/iris/core/service/IrisApiEventSVC.java').absolutePath)
|
||||
systemProperty('iris.worldInfoFactorySource', file('src/main/java/art/arcane/iris/core/service/terrain/IrisWorldInfoFactory.java').absolutePath)
|
||||
systemProperty('iris.startupSource', file('src/main/java/art/arcane/iris/Iris.java').absolutePath)
|
||||
systemProperty('iris.readmeSource', rootProject.file('README.md').absolutePath)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import art.arcane.iris.core.SettingsHotloadWatch;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService;
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService.StartupValidationOutcome;
|
||||
import art.arcane.iris.core.lifecycle.IrisRuntimeStatics;
|
||||
import art.arcane.iris.core.lifecycle.ManagedWorldLoader;
|
||||
import art.arcane.iris.core.lifecycle.MissingWorldStorageLog;
|
||||
import art.arcane.iris.core.lifecycle.PaperLibBootstrap;
|
||||
@@ -87,6 +88,7 @@ import art.arcane.iris.engine.framework.PreservationRegistry;
|
||||
import art.arcane.iris.engine.framework.TreeBlockMaterial;
|
||||
import art.arcane.iris.engine.object.IrisCompat;
|
||||
import art.arcane.iris.core.safeguard.IrisSafeguard;
|
||||
import art.arcane.iris.core.safeguard.RuntimeLockNotice;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
@@ -147,6 +149,7 @@ import java.lang.reflect.Modifier;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -159,6 +162,7 @@ import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@SuppressWarnings("CanBeFinal")
|
||||
public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
@@ -215,16 +219,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
private volatile ServerShutdownBoundary serverShutdownBoundary;
|
||||
|
||||
public static VolmitSender getSender() {
|
||||
if (sender == null) {
|
||||
sender = new VolmitSender(Bukkit.getConsoleSender());
|
||||
sender.setTag(instance.getTag());
|
||||
VolmitSender current = sender;
|
||||
if (current == null) {
|
||||
Iris plugin = instance;
|
||||
current = new VolmitSender(Bukkit.getConsoleSender());
|
||||
current.setTag(plugin == null ? IrisSafeguard.mode().tag("") : plugin.getTag());
|
||||
sender = current;
|
||||
}
|
||||
return sender;
|
||||
return current;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T> T service(Class<T> c) {
|
||||
return (T) instance.services.get(c);
|
||||
Iris plugin = instance;
|
||||
if (plugin == null || plugin.services == null) {
|
||||
throw new IllegalStateException("Iris is disabled; " + c.getSimpleName() + " is unavailable");
|
||||
}
|
||||
return (T) plugin.services.get(c);
|
||||
}
|
||||
|
||||
public static void callEvent(Event e) {
|
||||
@@ -263,7 +274,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
|
||||
public static File getTemp() {
|
||||
return instance.getDataFolder("cache", "temp");
|
||||
Iris plugin = instance;
|
||||
if (plugin == null) {
|
||||
throw new IllegalStateException("Iris is disabled; the temp folder is unavailable");
|
||||
}
|
||||
return plugin.getDataFolder("cache", "temp");
|
||||
}
|
||||
|
||||
public static void msg(String string) {
|
||||
@@ -305,10 +320,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
return;
|
||||
}
|
||||
|
||||
StackWalker.StackFrame frame = null;
|
||||
StackWalker.StackFrame frame;
|
||||
try {
|
||||
frame = DEBUG_STACK_WALKER.walk(stream -> stream.skip(1).findFirst().orElse(null));
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable unavailable) {
|
||||
frame = null;
|
||||
}
|
||||
|
||||
if (frame == null) {
|
||||
@@ -444,7 +460,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
try {
|
||||
IrisSettings currentSettings = IrisSettings.settings != null ? IrisSettings.settings : IrisSettings.get();
|
||||
debug = currentSettings != null && currentSettings.getGeneral().isDebug();
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable unreadable) {
|
||||
debug = false;
|
||||
}
|
||||
}
|
||||
@@ -541,11 +557,11 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
Iris::msg,
|
||||
Iris::reportError,
|
||||
(event) -> Iris.callEvent((org.bukkit.event.Event) event),
|
||||
() -> Iris.instance.getDataFolder(),
|
||||
(path) -> Iris.instance.getDataFile(path),
|
||||
() -> Iris.instance.getJarFile(),
|
||||
() -> Iris.instance.getIrisVersion(),
|
||||
() -> Iris.instance.getMCVersion()));
|
||||
this::getDataFolder,
|
||||
this::getDataFile,
|
||||
this::getJarFile,
|
||||
this::getIrisVersion,
|
||||
this::getMCVersion));
|
||||
SlimJar.load();
|
||||
}
|
||||
|
||||
@@ -612,6 +628,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
|
||||
return false;
|
||||
}
|
||||
EnableTimings timings = new EnableTimings();
|
||||
alreadyDrained.set(false);
|
||||
postStopFinisherStarted.set(false);
|
||||
serverStopTeardownDeferred.set(false);
|
||||
@@ -625,12 +642,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
MultiBurst.burst.reopen();
|
||||
MultiBurst.ioBurst.reopen();
|
||||
IrisLanguage.initialize();
|
||||
debugDump = BukkitDebugDump.create(this);
|
||||
debugDump = BukkitDebugDump.create(this, new BukkitDebugDump.Options(
|
||||
() -> true,
|
||||
() -> IrisSafeguard::debugReport));
|
||||
languageSwitcher = BukkitLanguageSwitcher.register(this, IrisLanguage.selections(),
|
||||
new BukkitLanguageSwitcher.Options("iris", "iris.all",
|
||||
DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions()));
|
||||
PaperLibBootstrap.install();
|
||||
SimdSupport.install();
|
||||
timings.mark("bootstrap");
|
||||
services = new KMap<>();
|
||||
BukkitPlatform.hostHud(new HudActionBar(this), new HudBossBarLane());
|
||||
// Explicit, ordered service list: the previous reflective jar scan gave hash-ordered
|
||||
@@ -669,9 +689,13 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
IrisServices.register(PreservationRegistry.class, services.get(PreservationSVC.class));
|
||||
compat = IrisCompat.configured(getDataFile("compat.json"));
|
||||
IrisServices.register(IrisCompat.class, compat);
|
||||
timings.mark("services");
|
||||
ServerConfigurator.configure();
|
||||
timings.mark("serverConfig");
|
||||
StartupValidationOutcome datapackValidation = DatapackIngestService.validateOnStartup();
|
||||
timings.mark("datapacks");
|
||||
IrisSafeguard.execute();
|
||||
timings.mark("safeguard");
|
||||
getSender().setTag(getTag());
|
||||
// A cosmetic banner must never abort the bootstrap.
|
||||
J.attempt(this::splash);
|
||||
@@ -680,6 +704,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
// Paper's bootstrap runs before any plugin logger exists, so orphan-storage warnings raised there
|
||||
// never reach logs/latest.log. Replay them once now that the platform log is up.
|
||||
MissingWorldStorageLog.replayToPlatformLog();
|
||||
timings.mark("splash");
|
||||
tickets = new ChunkTickets();
|
||||
linkMultiverseCore = new MultiverseCoreLink();
|
||||
IrisServices.register(MultiverseCoreLink.class, linkMultiverseCore);
|
||||
@@ -692,18 +717,21 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
IrisServices.register(ManagedWorldLoader.class, (ManagedWorldLoader) this::loadManagedWorld);
|
||||
SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("iris.json"));
|
||||
settingsHotloadWatch = watch;
|
||||
// Stale-temp cleanup must complete before services enable: StudioSVC.onEnable downloads
|
||||
// packs through cache/temp on an async thread, and a concurrent delete of that folder
|
||||
// truncated pack imports mid-copy (partial packs/<key> without dimensions/).
|
||||
IO.delete(getTemp());
|
||||
StudioSVC.gateDownloadsOnStaleTempCleanup(MultiBurst.ioBurst.completeValueAsync(() -> {
|
||||
IO.delete(getTemp());
|
||||
return null;
|
||||
}));
|
||||
timings.mark("tempSweep");
|
||||
// One throwing service must not abort the bootstrap: the steps after this loop
|
||||
// (listeners, shutdown hook, replacement journals) are the safety-critical ones.
|
||||
// Only services that actually enabled get listeners and a later onDisable.
|
||||
enabledServices.clear();
|
||||
for (IrisService service : orderedServices) {
|
||||
long serviceStartedAt = System.nanoTime();
|
||||
try {
|
||||
service.onEnable();
|
||||
enabledServices.add(service);
|
||||
timings.markService(service.getClass().getSimpleName(), serviceStartedAt);
|
||||
} catch (Throwable e) {
|
||||
// A service failure is NOT a datapack validation failure: the admission gate
|
||||
// must never lock every login over a broken cosmetic service. Log loudly,
|
||||
@@ -724,10 +752,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
Iris.reportError("Failed to register listener for " + service.getClass().getSimpleName() + ".", e);
|
||||
}
|
||||
}
|
||||
timings.mark("serviceEnable");
|
||||
if (datapackValidation == StartupValidationOutcome.READY) {
|
||||
IrisServices.get(ExternalDataSVC.class).setContentChangeListener(generatorResolver::requestExternalContentRefresh);
|
||||
generatorResolver.validateAllPacks();
|
||||
}
|
||||
timings.mark("packValidation");
|
||||
addShutdownHook();
|
||||
pendingWorldReplacements.processPendingStartupReplacements();
|
||||
pendingWorldDeletes.processPendingStartupWorldDeletes();
|
||||
@@ -755,9 +785,46 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
// match a slice type; the block-state slice is deliberately never retainable (regenerable, huge).
|
||||
IrisToolbelt.retainMantleDataForSlice(TreeBlockMaterial.class.getCanonicalName());
|
||||
});
|
||||
timings.mark("startupTasks");
|
||||
timings.report();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static final class EnableTimings {
|
||||
private static final int REPORTED_SERVICES = 3;
|
||||
|
||||
private final long startedAt = System.nanoTime();
|
||||
private final StringBuilder phases = new StringBuilder();
|
||||
private final Map<String, Long> serviceMillis = new LinkedHashMap<>();
|
||||
private long lastMark = startedAt;
|
||||
|
||||
private void mark(String phase) {
|
||||
long now = System.nanoTime();
|
||||
if (!phases.isEmpty()) {
|
||||
phases.append(' ');
|
||||
}
|
||||
phases.append(phase).append('=').append(TimeUnit.NANOSECONDS.toMillis(now - lastMark)).append("ms");
|
||||
lastMark = now;
|
||||
}
|
||||
|
||||
private void markService(String service, long serviceStartedAt) {
|
||||
serviceMillis.put(service, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - serviceStartedAt));
|
||||
}
|
||||
|
||||
private void report() {
|
||||
IrisLogging.notice("Enabled in %dms (%s)",
|
||||
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt), phases);
|
||||
String slowest = serviceMillis.entrySet().stream()
|
||||
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
|
||||
.limit(REPORTED_SERVICES)
|
||||
.map(entry -> entry.getKey() + "=" + entry.getValue() + "ms")
|
||||
.collect(Collectors.joining(" "));
|
||||
if (!slowest.isBlank()) {
|
||||
IrisLogging.notice("Slowest services: %s", slowest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void reconcileStartupWorlds() {
|
||||
generatorResolver.startupWorldsReady().whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
@@ -866,6 +933,25 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
.orElse("Iris startup validation requires a restart.");
|
||||
startupBoundaryRestart.set(true);
|
||||
ServerConfigurator.restartAtStartupBoundary(restartReason);
|
||||
return;
|
||||
}
|
||||
reportLockedRuntime();
|
||||
}
|
||||
|
||||
private static void reportLockedRuntime() {
|
||||
String denial = IrisStartupValidation.denialReason().orElse(null);
|
||||
if (denial == null) {
|
||||
return;
|
||||
}
|
||||
boolean managedStorage;
|
||||
try {
|
||||
managedStorage = IrisWorldStorage.hasManagedWorldStorage(IrisWorldStorage.levelRoot());
|
||||
} catch (Throwable unavailable) {
|
||||
Iris.reportError("Could not inspect Iris world storage while reporting the locked runtime.", unavailable);
|
||||
managedStorage = false;
|
||||
}
|
||||
for (String line : RuntimeLockNotice.compose(denial, managedStorage)) {
|
||||
Iris.error(line);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1027,10 +1113,19 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
Iris.reportError("Failed to close Iris I/O workers.", failure);
|
||||
}
|
||||
clearQueues();
|
||||
IrisServices.clear();
|
||||
IrisRuntimeStatics.reset();
|
||||
releaseStatics();
|
||||
}
|
||||
}
|
||||
|
||||
private static void releaseStatics() {
|
||||
linkMultiverseCore = null;
|
||||
compat = null;
|
||||
tickets = null;
|
||||
sender = null;
|
||||
instance = null;
|
||||
}
|
||||
|
||||
private void quiesceRuntimeForServerShutdown(String reason) {
|
||||
serverStopTeardownDeferred.set(true);
|
||||
INMS.get().deferPluginClassLoaderClose();
|
||||
@@ -1173,6 +1268,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
failure.printStackTrace(SHUTDOWN_ERRORS);
|
||||
}
|
||||
}
|
||||
BukkitPlatform.releaseHost();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1367,7 +1463,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
|
||||
public int getIrisVersion() {
|
||||
String input = Iris.instance.getDescription().getVersion();
|
||||
String input = getDescription().getVersion();
|
||||
int hyphenIndex = input.indexOf('-');
|
||||
if (hyphenIndex != -1) {
|
||||
String result = input.substring(0, hyphenIndex);
|
||||
|
||||
+20
-5
@@ -30,6 +30,7 @@ import art.arcane.iris.core.pack.PackValidationCache;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.pack.PackValidationResult;
|
||||
import art.arcane.iris.core.pack.PackValidator;
|
||||
import art.arcane.iris.core.runtime.RuntimeInjection;
|
||||
import art.arcane.iris.core.service.ExternalDataSVC;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
@@ -486,17 +487,20 @@ public final class IrisWorldGeneratorResolver {
|
||||
Iris.debug("Generator discovery probe for loaded world " + worldName);
|
||||
return IrisFailClosedChunkGenerator.discoveryProbe(worldName);
|
||||
}
|
||||
Optional<String> startupDenial = IrisStartupValidation.denialReason();
|
||||
if (startupDenial.isPresent()) {
|
||||
Iris.warn("Keeping configured Iris world '" + worldName
|
||||
+ "' generation-locked: " + startupDenial.get());
|
||||
return IrisFailClosedChunkGenerator.startupLock(worldName, startupDenial.get());
|
||||
ChunkGenerator startupLock = startupLockedGenerator(worldName);
|
||||
if (startupLock != null) {
|
||||
return startupLock;
|
||||
}
|
||||
ChunkGenerator stagedGenerator = WorldLifecycleStaging.consumeGenerator(worldName);
|
||||
if (stagedGenerator != null) {
|
||||
Iris.debug("Using staged runtime generator for " + worldName);
|
||||
return stagedGenerator;
|
||||
}
|
||||
RuntimeInjection.installIfDeferred();
|
||||
startupLock = startupLockedGenerator(worldName);
|
||||
if (startupLock != null) {
|
||||
return startupLock;
|
||||
}
|
||||
Iris.debug("Default World Generator Called for " + worldName + " using ID: " + id);
|
||||
if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
|
||||
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
|
||||
@@ -516,6 +520,17 @@ public final class IrisWorldGeneratorResolver {
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static ChunkGenerator startupLockedGenerator(String worldName) {
|
||||
Optional<String> startupDenial = IrisStartupValidation.denialReason();
|
||||
if (startupDenial.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Iris.warn("Keeping configured Iris world '" + worldName
|
||||
+ "' generation-locked: " + startupDenial.get());
|
||||
return IrisFailClosedChunkGenerator.startupLock(worldName, startupDenial.get());
|
||||
}
|
||||
|
||||
private static boolean isPlotSquaredGeneratorDiscoveryProbe(String worldName, String id) {
|
||||
return PLOT_SQUARED_DISCOVERY_WORLD.equals(worldName) && id != null && id.isEmpty();
|
||||
}
|
||||
|
||||
+2
-1
@@ -1104,7 +1104,8 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
Iris.instance.getServer().getAllowEnd()
|
||||
);
|
||||
} catch (Throwable failure) {
|
||||
Iris.debug("Could not capture vanilla-slot availability yet: " + detail(failure));
|
||||
Iris.reportError("Could not capture vanilla-slot availability at startup;"
|
||||
+ " world replacement staging will resolve it on demand.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-1411
File diff suppressed because it is too large
Load Diff
+424
@@ -0,0 +1,424 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.structure.authoring.StructureKey;
|
||||
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
|
||||
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
|
||||
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionDiagnostic;
|
||||
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionInputKind;
|
||||
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionPlan;
|
||||
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionRequest;
|
||||
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionResult;
|
||||
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionService;
|
||||
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionStrategy;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.util.common.director.DirectorExecutor;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.director.DirectorOrigin;
|
||||
import art.arcane.volmlib.util.director.DirectorParameterHandler;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Director(name = "adopt", description = "Inspect and apply safe ownership adoption plans",
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public class CommandJigsawAdopt implements DirectorExecutor {
|
||||
private static final Map<UUID, AdoptionPlanBinding> ADOPTION_PLANS = new ConcurrentHashMap<>();
|
||||
private static final Map<Path, IrisStructureAdoptionService> ADOPTION_SERVICES = new ConcurrentHashMap<>();
|
||||
|
||||
@Director(description = "Inspect an Iris graph for safe Studio ownership adoption", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void inspect(
|
||||
@Param(description = "Pack dimension") IrisDimension dimension,
|
||||
@Param(description = "Existing Iris structure key") String source,
|
||||
@Param(description = "New owned Iris key, or auto", defaultValue = "auto") String target,
|
||||
@Param(description = "auto, in-place, or clone", defaultValue = "auto",
|
||||
customHandler = JigsawAdoptionStrategyHandler.class) String strategy
|
||||
) {
|
||||
VolmitSender commandSender = sender();
|
||||
IrisData data = CommandJigsaw.requireData(dimension, commandSender);
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
String sourceKey;
|
||||
Optional<StructureKey> targetKey;
|
||||
IrisStructureAdoptionStrategy adoptionStrategy;
|
||||
Path root;
|
||||
try {
|
||||
sourceKey = parseAdoptionSource(source);
|
||||
targetKey = parseAdoptionTarget(target);
|
||||
adoptionStrategy = parseAdoptionStrategy(strategy);
|
||||
root = canonicalAdoptionRoot(data.getDataFolder().toPath());
|
||||
} catch (IOException | IllegalArgumentException exception) {
|
||||
CommandJigsaw.sendError(commandSender, "Could not inspect adoption source: " + exception.getMessage());
|
||||
return;
|
||||
}
|
||||
Player targetPlayer = player();
|
||||
UUID ownerId = targetPlayer.getUniqueId();
|
||||
commandSender.sendMessage(C.GRAY + "Inspecting Jigsaw adoption source '" + sourceKey
|
||||
+ "' asynchronously...");
|
||||
try {
|
||||
J.a(() -> runAdoptionInspect(
|
||||
targetPlayer,
|
||||
commandSender,
|
||||
ownerId,
|
||||
dimension,
|
||||
data,
|
||||
root,
|
||||
sourceKey,
|
||||
targetKey,
|
||||
adoptionStrategy));
|
||||
} catch (RuntimeException exception) {
|
||||
Iris.reportError("Failed to schedule Jigsaw adoption inspection for '"
|
||||
+ sourceKey + "'.", exception);
|
||||
CommandJigsaw.sendError(commandSender, "Jigsaw adoption inspection could not be scheduled: "
|
||||
+ exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Director(description = "Apply one inspected adoption plan without overwriting", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void apply(
|
||||
@Param(description = "Plan UUID returned by adopt inspect",
|
||||
customHandler = JigsawAdoptionPlanHandler.class) String planId
|
||||
) {
|
||||
VolmitSender commandSender = sender();
|
||||
UUID parsedPlanId;
|
||||
try {
|
||||
parsedPlanId = UUID.fromString(planId);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
CommandJigsaw.sendError(commandSender, "Adoption plan ID must be a UUID.");
|
||||
return;
|
||||
}
|
||||
if (CommandJigsaw.studioIsActiveOrOpening()) {
|
||||
CommandJigsaw.sendError(commandSender,
|
||||
"Close the active or opening Jigsaw Studio before applying an adoption plan.");
|
||||
return;
|
||||
}
|
||||
pruneAdoptionPlanBindings();
|
||||
AdoptionPlanBinding binding = ADOPTION_PLANS.get(parsedPlanId);
|
||||
UUID playerId = player().getUniqueId();
|
||||
if (binding == null || !binding.ownerId().equals(playerId)) {
|
||||
CommandJigsaw.sendError(commandSender, "No active adoption plan '" + parsedPlanId
|
||||
+ "' belongs to your player session. Run adopt inspect again.");
|
||||
return;
|
||||
}
|
||||
if (binding.service().plan(parsedPlanId).isEmpty()) {
|
||||
ADOPTION_PLANS.remove(parsedPlanId, binding);
|
||||
CommandJigsaw.sendError(commandSender, "Adoption plan '" + parsedPlanId
|
||||
+ "' expired or was already consumed. Run adopt inspect again.");
|
||||
return;
|
||||
}
|
||||
if (!ADOPTION_PLANS.remove(parsedPlanId, binding)) {
|
||||
CommandJigsaw.sendError(commandSender, "Adoption plan '" + parsedPlanId + "' is already being applied.");
|
||||
return;
|
||||
}
|
||||
Player targetPlayer = player();
|
||||
commandSender.sendMessage(C.GRAY + "Applying adoption plan '" + parsedPlanId
|
||||
+ "' asynchronously without overwrite...");
|
||||
try {
|
||||
J.a(() -> runAdoptionApply(targetPlayer, commandSender, parsedPlanId, binding));
|
||||
} catch (RuntimeException exception) {
|
||||
ADOPTION_PLANS.putIfAbsent(parsedPlanId, binding);
|
||||
Iris.reportError("Failed to schedule Jigsaw adoption plan '" + parsedPlanId + "'.", exception);
|
||||
CommandJigsaw.sendError(commandSender, "Jigsaw adoption could not be scheduled: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void pruneAdoptionPlanBindings() {
|
||||
for (Map.Entry<UUID, AdoptionPlanBinding> entry : ADOPTION_PLANS.entrySet()) {
|
||||
AdoptionPlanBinding binding = entry.getValue();
|
||||
if (binding.service().plan(entry.getKey()).isEmpty()) {
|
||||
ADOPTION_PLANS.remove(entry.getKey(), binding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void runAdoptionApply(
|
||||
Player targetPlayer,
|
||||
VolmitSender commandSender,
|
||||
UUID planId,
|
||||
AdoptionPlanBinding binding
|
||||
) {
|
||||
if (CommandJigsaw.studioIsActiveOrOpening()) {
|
||||
ADOPTION_PLANS.putIfAbsent(planId, binding);
|
||||
J.runEntity(targetPlayer, () -> CommandJigsaw.sendError(commandSender,
|
||||
"Close the active or opening Jigsaw Studio before applying an adoption plan."));
|
||||
return;
|
||||
}
|
||||
IrisStructureAdoptionResult result;
|
||||
Throwable failure = null;
|
||||
try {
|
||||
result = binding.service().apply(binding.plan());
|
||||
} catch (Throwable throwable) {
|
||||
result = null;
|
||||
failure = throwable;
|
||||
Iris.reportError("Failed to apply Jigsaw adoption plan '" + planId + "'.", throwable);
|
||||
}
|
||||
IrisStructureAdoptionResult completedResult = result;
|
||||
Throwable completedFailure = failure;
|
||||
J.runEntity(targetPlayer, () -> finishAdoptionApply(
|
||||
targetPlayer,
|
||||
commandSender,
|
||||
binding,
|
||||
completedResult,
|
||||
completedFailure));
|
||||
}
|
||||
|
||||
private static void runAdoptionInspect(
|
||||
Player targetPlayer,
|
||||
VolmitSender commandSender,
|
||||
UUID ownerId,
|
||||
IrisDimension dimension,
|
||||
IrisData data,
|
||||
Path root,
|
||||
String sourceKey,
|
||||
Optional<StructureKey> targetKey,
|
||||
IrisStructureAdoptionStrategy strategy
|
||||
) {
|
||||
IrisStructureAdoptionService service = null;
|
||||
IrisStructureAdoptionPlan plan = null;
|
||||
Throwable failure = null;
|
||||
try {
|
||||
IrisStructureAdoptionInputKind inputKind = adoptionInputKind(root, sourceKey);
|
||||
service = ADOPTION_SERVICES.computeIfAbsent(root, IrisStructureAdoptionService::new);
|
||||
plan = service.inspect(new IrisStructureAdoptionRequest(
|
||||
sourceKey,
|
||||
targetKey,
|
||||
strategy,
|
||||
inputKind));
|
||||
} catch (Throwable throwable) {
|
||||
failure = throwable;
|
||||
Iris.reportError("Failed to inspect Jigsaw adoption source '" + sourceKey + "'.", throwable);
|
||||
}
|
||||
IrisStructureAdoptionService completedService = service;
|
||||
IrisStructureAdoptionPlan completedPlan = plan;
|
||||
Throwable completedFailure = failure;
|
||||
J.runEntity(targetPlayer, () -> finishAdoptionInspect(
|
||||
commandSender,
|
||||
ownerId,
|
||||
dimension,
|
||||
data,
|
||||
completedService,
|
||||
completedPlan,
|
||||
completedFailure));
|
||||
}
|
||||
|
||||
private static void finishAdoptionInspect(
|
||||
VolmitSender commandSender,
|
||||
UUID ownerId,
|
||||
IrisDimension dimension,
|
||||
IrisData data,
|
||||
IrisStructureAdoptionService service,
|
||||
IrisStructureAdoptionPlan plan,
|
||||
Throwable failure
|
||||
) {
|
||||
if (failure != null || service == null || plan == null) {
|
||||
CommandJigsaw.sendError(commandSender, "Jigsaw adoption inspection failed: "
|
||||
+ (failure == null ? "no result" : failure.getMessage()));
|
||||
return;
|
||||
}
|
||||
pruneAdoptionPlanBindings();
|
||||
if (service.plan(plan.planId()).isPresent()) {
|
||||
ADOPTION_PLANS.put(plan.planId(), new AdoptionPlanBinding(
|
||||
ownerId, dimension, data, service, plan));
|
||||
}
|
||||
reportAdoptionPlan(commandSender, plan);
|
||||
}
|
||||
|
||||
private static void finishAdoptionApply(
|
||||
Player targetPlayer,
|
||||
VolmitSender commandSender,
|
||||
AdoptionPlanBinding binding,
|
||||
IrisStructureAdoptionResult result,
|
||||
Throwable failure
|
||||
) {
|
||||
if (failure != null || result == null) {
|
||||
CommandJigsaw.sendError(commandSender, "Jigsaw adoption failed: "
|
||||
+ (failure == null ? "no result" : failure.getMessage()));
|
||||
return;
|
||||
}
|
||||
reportAdoptionResult(commandSender, result);
|
||||
if (!result.successful()) {
|
||||
return;
|
||||
}
|
||||
StructureKey target = result.receipt().orElseThrow().targetStructure();
|
||||
binding.data().invalidateStructureResources();
|
||||
CommandJigsawOpen.openProject(targetPlayer, commandSender, binding.dimension(), target.path(), CommandJigsaw.DEFAULT_STUDIO_SEED);
|
||||
}
|
||||
|
||||
private static void reportAdoptionPlan(VolmitSender commandSender, IrisStructureAdoptionPlan plan) {
|
||||
C statusColor = plan.canApply() ? C.GREEN : C.RED;
|
||||
commandSender.sendMessage(statusColor + "Adoption plan " + plan.planId() + " -> "
|
||||
+ plan.disposition() + " for " + plan.targetStructure().value() + ".");
|
||||
commandSender.sendMessage(C.GRAY + "Resources=" + plan.resourceCount()
|
||||
+ ", bytes=" + plan.totalSourceBytes()
|
||||
+ ", errors=" + plan.errorCount()
|
||||
+ ", warnings=" + plan.warningCount() + ".");
|
||||
for (IrisStructureAdoptionDiagnostic diagnostic : plan.diagnostics()) {
|
||||
commandSender.sendMessage(adoptionColor(diagnostic).toString() + diagnostic.summary());
|
||||
}
|
||||
if (plan.canApply()) {
|
||||
commandSender.sendMessage(C.AQUA + "Apply with /iris jigsaw adopt apply " + plan.planId());
|
||||
}
|
||||
}
|
||||
|
||||
private static void reportAdoptionResult(VolmitSender commandSender, IrisStructureAdoptionResult result) {
|
||||
commandSender.sendMessage((result.successful() ? C.GREEN : C.RED)
|
||||
+ "Adoption plan " + result.planId() + ": " + result.status() + ".");
|
||||
for (IrisStructureAdoptionDiagnostic diagnostic : result.diagnostics()) {
|
||||
commandSender.sendMessage(adoptionColor(diagnostic).toString() + diagnostic.summary());
|
||||
}
|
||||
}
|
||||
|
||||
private static C adoptionColor(IrisStructureAdoptionDiagnostic diagnostic) {
|
||||
return switch (diagnostic.severity()) {
|
||||
case ERROR -> C.RED;
|
||||
case WARNING -> C.YELLOW;
|
||||
case INFO -> C.GRAY;
|
||||
};
|
||||
}
|
||||
|
||||
static String parseAdoptionSource(String value) {
|
||||
String normalized = Objects.requireNonNull(value, "Adoption source").trim()
|
||||
.toLowerCase(Locale.ROOT);
|
||||
StructureKey source = StructureKey.parse(normalized, "iris");
|
||||
if (!"iris".equals(source.namespace())) {
|
||||
throw new IllegalArgumentException("Adoption sources must use the iris namespace.");
|
||||
}
|
||||
return source.path();
|
||||
}
|
||||
|
||||
static Optional<StructureKey> parseAdoptionTarget(String value) {
|
||||
String normalized = Objects.requireNonNull(value, "Adoption target").trim()
|
||||
.toLowerCase(Locale.ROOT);
|
||||
if (normalized.isEmpty() || "auto".equals(normalized)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
StructureKey target = StructureKey.parse(normalized, "iris");
|
||||
if (!"iris".equals(target.namespace())) {
|
||||
throw new IllegalArgumentException("Adoption targets must use the iris namespace.");
|
||||
}
|
||||
return Optional.of(target);
|
||||
}
|
||||
|
||||
private static IrisStructureAdoptionStrategy parseAdoptionStrategy(String value) {
|
||||
return switch (Objects.requireNonNull(value, "Adoption strategy").trim().toLowerCase(Locale.ROOT)) {
|
||||
case "auto" -> IrisStructureAdoptionStrategy.AUTO;
|
||||
case "in-place", "in_place", "inplace", "claim" -> IrisStructureAdoptionStrategy.IN_PLACE;
|
||||
case "clone", "copy" -> IrisStructureAdoptionStrategy.CLONE;
|
||||
default -> throw new IllegalArgumentException("Adoption strategy must be auto, in-place, or clone.");
|
||||
};
|
||||
}
|
||||
|
||||
static IrisStructureAdoptionInputKind adoptionInputKind(Path packRoot, String source) throws IOException {
|
||||
Path root = canonicalAdoptionRoot(packRoot);
|
||||
StructureKey sourceKey = new StructureKey("iris", parseAdoptionSource(source));
|
||||
Path manifestPath = new StructureTransactionWriter(root).ownershipManifestPath(sourceKey);
|
||||
if (!Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return IrisStructureAdoptionInputKind.UNOWNED_IRIS;
|
||||
}
|
||||
if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Ownership manifest is not a regular file: " + manifestPath);
|
||||
}
|
||||
StructureOwnershipManifest manifest;
|
||||
try {
|
||||
manifest = StructureOwnershipManifest.fromJson(Files.readAllBytes(manifestPath));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IOException("Invalid ownership manifest at " + manifestPath, exception);
|
||||
}
|
||||
if (!manifest.structure().equals(sourceKey)) {
|
||||
throw new IOException("Ownership manifest at " + manifestPath
|
||||
+ " belongs to " + manifest.structure().value());
|
||||
}
|
||||
return manifest.provenance().origin() == StructureOwnershipManifest.Origin.MANAGED_DATAPACK
|
||||
? IrisStructureAdoptionInputKind.MANAGED_DATAPACK
|
||||
: IrisStructureAdoptionInputKind.UNOWNED_IRIS;
|
||||
}
|
||||
|
||||
private static Path canonicalAdoptionRoot(Path packRoot) throws IOException {
|
||||
Path normalized = Objects.requireNonNull(packRoot, "Adoption pack root").toAbsolutePath().normalize();
|
||||
return Files.exists(normalized, LinkOption.NOFOLLOW_LINKS) ? normalized.toRealPath() : normalized;
|
||||
}
|
||||
|
||||
private record AdoptionPlanBinding(
|
||||
UUID ownerId,
|
||||
IrisDimension dimension,
|
||||
IrisData data,
|
||||
IrisStructureAdoptionService service,
|
||||
IrisStructureAdoptionPlan plan
|
||||
) {
|
||||
}
|
||||
|
||||
public static final class JigsawAdoptionStrategyHandler implements DirectorParameterHandler<String> {
|
||||
@Override
|
||||
public KList<String> getPossibilities() {
|
||||
return new KList<>("auto", "in-place", "clone");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String parse(String input, boolean force) throws DirectorParsingException {
|
||||
try {
|
||||
return switch (parseAdoptionStrategy(input)) {
|
||||
case AUTO -> "auto";
|
||||
case IN_PLACE -> "in-place";
|
||||
case CLONE -> "clone";
|
||||
};
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new DirectorParsingException(exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> type) {
|
||||
return type == String.class;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class JigsawAdoptionPlanHandler implements DirectorParameterHandler<String> {
|
||||
@Override
|
||||
public KList<String> getPossibilities() {
|
||||
return new KList<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String parse(String input, boolean force) throws DirectorParsingException {
|
||||
try {
|
||||
return UUID.fromString(input).toString();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new DirectorParsingException("Adoption plan ID must be a UUID.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> type) {
|
||||
return type == String.class;
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphEditor;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
|
||||
import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
import art.arcane.iris.util.common.director.DirectorExecutor;
|
||||
import art.arcane.volmlib.util.director.DirectorOrigin;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.type.Jigsaw;
|
||||
|
||||
@Director(name = "connector", description = "Inspect and configure targeted jigsaw connectors",
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public class CommandJigsawConnector implements DirectorExecutor {
|
||||
@Director(description = "Set the Iris-only channel on the targeted jigsaw marker", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void channel(
|
||||
@Param(description = "Exact channel, or none to clear") String channel
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
Block target = player().getTargetBlockExact(8);
|
||||
if (target == null || !(target.getBlockData() instanceof Jigsaw)) {
|
||||
CommandJigsaw.sendError(this, "Look directly at a jigsaw marker within eight blocks.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioBay bay = context.session().layout().findAt(
|
||||
target.getX(), target.getY(), target.getZ());
|
||||
JigsawStudioVariant activeVariant = bay == null
|
||||
? null
|
||||
: context.session().activeVariant(bay.stableId()).orElse(null);
|
||||
if (activeVariant == null) {
|
||||
CommandJigsaw.sendError(this, "The targeted marker must be inside a workcell with an active variant.");
|
||||
return;
|
||||
}
|
||||
if (!activeVariant.owned()) {
|
||||
CommandJigsaw.sendError(this, "The active variant is read-only. Adopt it into this project before editing it.");
|
||||
return;
|
||||
}
|
||||
IrisObject object = context.request().source().getObjectLoader().load(activeVariant.objectKey());
|
||||
if (object == null) {
|
||||
CommandJigsaw.sendError(this, "The active variant object '" + activeVariant.objectKey() + "' is missing.");
|
||||
return;
|
||||
}
|
||||
IrisPosition canonicalPosition = new IrisPosition(
|
||||
target.getX() - bay.bounds().originX(),
|
||||
target.getY() - bay.bounds().originY(),
|
||||
target.getZ() - bay.bounds().originZ());
|
||||
IrisPosition position;
|
||||
try {
|
||||
position = activeVariant.canonicalToSourcePosition(
|
||||
canonicalPosition,
|
||||
new JigsawStudioCellDimensions(object.getW(), object.getH(), object.getD()));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
CommandJigsaw.sendError(this, "Connector channel update failed: " + exception.getMessage());
|
||||
return;
|
||||
}
|
||||
String normalized = channel == null || channel.isBlank() || "none".equalsIgnoreCase(channel)
|
||||
? "none"
|
||||
: channel.trim();
|
||||
CommandJigsaw.runGraphMutation(player(), context, () -> {
|
||||
JigsawStudioGraphEditor.updateConnectorChannel(
|
||||
context.request().source().getDataFolder().toPath(),
|
||||
context.request().structureKey(),
|
||||
activeVariant.pieceKey(),
|
||||
position,
|
||||
channel);
|
||||
return CommandJigsaw.mappedMutationResult(
|
||||
context,
|
||||
"",
|
||||
"",
|
||||
"Persisted connector channel '" + normalized + "' on piece '"
|
||||
+ activeVariant.pieceKey() + "' at " + position.getX() + "," + position.getY()
|
||||
+ "," + position.getZ() + ".");
|
||||
});
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.service.JigsawStudioService;
|
||||
import art.arcane.iris.core.structure.export.VanillaJigsawDatapackExporter;
|
||||
import art.arcane.iris.core.structure.export.VanillaJigsawExportDiagnostic;
|
||||
import art.arcane.iris.core.structure.export.VanillaJigsawExportFormat;
|
||||
import art.arcane.iris.core.structure.export.VanillaJigsawExportRequest;
|
||||
import art.arcane.iris.core.structure.export.VanillaJigsawExportResult;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
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.regex.Pattern;
|
||||
|
||||
final class CommandJigsawExports {
|
||||
private static final Pattern EXPORT_OUTPUT_NAME = Pattern.compile("[a-zA-Z0-9_-][a-zA-Z0-9._-]{0,127}");
|
||||
private static final Set<UUID> EXPORTING_PLAYERS = ConcurrentHashMap.newKeySet();
|
||||
private static final Set<Path> EXPORT_DESTINATIONS = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private CommandJigsawExports() {
|
||||
}
|
||||
|
||||
static String exportStartError(JigsawStudioService.ExportStart exportStart) {
|
||||
Objects.requireNonNull(exportStart, "Jigsaw Studio export start status");
|
||||
return switch (exportStart) {
|
||||
case NOT_ACTIVE -> "The active Jigsaw Studio is no longer available.";
|
||||
case NOT_OWNER -> "Only the Jigsaw Studio owner can export this project.";
|
||||
case DIRTY -> "Wait for the pending autosave or discard the edits before exporting the on-disk graph.";
|
||||
case CLOSING -> "The active Jigsaw Studio is closing and cannot be exported.";
|
||||
case SAVE_IN_PROGRESS -> "Wait for the current Jigsaw Studio save to finish before exporting.";
|
||||
case OPERATION_IN_PROGRESS -> "Wait for the current Jigsaw Studio operation to finish before exporting.";
|
||||
case IN_PROGRESS -> "A Jigsaw Studio export is already in progress.";
|
||||
case STARTED -> throw new IllegalArgumentException("STARTED is not an export failure");
|
||||
};
|
||||
}
|
||||
|
||||
static boolean beginExport(UUID playerId, Path destination) {
|
||||
Objects.requireNonNull(playerId);
|
||||
Path normalizedDestination = Objects.requireNonNull(destination).toAbsolutePath().normalize();
|
||||
if (!EXPORTING_PLAYERS.add(playerId)) {
|
||||
return false;
|
||||
}
|
||||
if (EXPORT_DESTINATIONS.add(normalizedDestination)) {
|
||||
return true;
|
||||
}
|
||||
EXPORTING_PLAYERS.remove(playerId);
|
||||
return false;
|
||||
}
|
||||
|
||||
static void finishExport(UUID playerId, Path destination) {
|
||||
EXPORTING_PLAYERS.remove(Objects.requireNonNull(playerId));
|
||||
EXPORT_DESTINATIONS.remove(Objects.requireNonNull(destination).toAbsolutePath().normalize());
|
||||
}
|
||||
|
||||
static void runExport(ExportOperation operation) {
|
||||
VanillaJigsawExportResult result = null;
|
||||
Throwable failure = null;
|
||||
try {
|
||||
result = new VanillaJigsawDatapackExporter().export(operation.request());
|
||||
} catch (Throwable throwable) {
|
||||
failure = throwable;
|
||||
Iris.reportError("Vanilla Jigsaw export failed for '" + operation.structureKey() + "'.", throwable);
|
||||
} finally {
|
||||
operation.lease().release();
|
||||
}
|
||||
VanillaJigsawExportResult completedResult = result;
|
||||
Throwable completedFailure = failure;
|
||||
Runnable response = () -> reportExport(operation, completedResult, completedFailure);
|
||||
J.runEntity(operation.player(), response);
|
||||
}
|
||||
|
||||
private static void reportExport(
|
||||
ExportOperation operation,
|
||||
VanillaJigsawExportResult result,
|
||||
Throwable failure
|
||||
) {
|
||||
if (failure != null || result == null) {
|
||||
operation.sender().sendMessage(C.RED + "Background vanilla export for '"
|
||||
+ operation.structureKey() + "' failed: "
|
||||
+ (failure == null ? "no result" : failure.getMessage()));
|
||||
return;
|
||||
}
|
||||
for (VanillaJigsawExportDiagnostic diagnostic : result.diagnostics()) {
|
||||
C color = diagnostic.isBlocking() ? C.RED : C.YELLOW;
|
||||
operation.sender().sendMessage(color.toString() + diagnostic.code() + ": " + diagnostic.message());
|
||||
}
|
||||
CommandJigsaw.ActiveContext current = CommandJigsaw.active(operation.player());
|
||||
boolean sameSession = current != null
|
||||
&& current.request().requestId().equals(operation.requestId());
|
||||
String contextLabel = sameSession ? "Vanilla export" : "Background vanilla export for '"
|
||||
+ operation.structureKey() + "'";
|
||||
operation.sender().sendMessage((result.isSuccess() ? C.GREEN : C.RED) + contextLabel + " "
|
||||
+ result.status() + ": " + result.output());
|
||||
}
|
||||
|
||||
static VanillaJigsawExportFormat parseFormat(String value) {
|
||||
return switch (value.trim().toLowerCase(Locale.ROOT)) {
|
||||
case "zip" -> VanillaJigsawExportFormat.ZIP;
|
||||
case "directory", "dir", "folder" -> VanillaJigsawExportFormat.DIRECTORY;
|
||||
default -> throw new IllegalArgumentException("Export format must be directory or zip.");
|
||||
};
|
||||
}
|
||||
|
||||
static Path resolveExportDestination(
|
||||
Path exportRoot,
|
||||
String output,
|
||||
VanillaJigsawExportFormat format
|
||||
) {
|
||||
Path root = Objects.requireNonNull(exportRoot, "Jigsaw export root").toAbsolutePath().normalize();
|
||||
String requested = Objects.requireNonNull(output, "Jigsaw export output name");
|
||||
String name = requested.trim();
|
||||
if (!name.equals(requested) || !EXPORT_OUTPUT_NAME.matcher(name).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"Export output must be one file name using letters, numbers, '.', '_', or '-'.");
|
||||
}
|
||||
String fileName = format == VanillaJigsawExportFormat.ZIP && !name.endsWith(".zip")
|
||||
? name + ".zip" : name;
|
||||
Path destination = root.resolve(fileName).normalize();
|
||||
if (destination.equals(root) || !root.equals(destination.getParent())) {
|
||||
throw new IllegalArgumentException("Export output must be one artifact inside the Studio exports folder.");
|
||||
}
|
||||
return destination;
|
||||
}
|
||||
|
||||
record ExportOperation(
|
||||
Player player,
|
||||
VolmitSender sender,
|
||||
UUID requestId,
|
||||
String structureKey,
|
||||
VanillaJigsawExportRequest request,
|
||||
ExportLease lease
|
||||
) {
|
||||
}
|
||||
|
||||
static final class ExportLease {
|
||||
private final Runnable release;
|
||||
private final AtomicBoolean released;
|
||||
|
||||
ExportLease(Runnable release) {
|
||||
this.release = Objects.requireNonNull(release, "Jigsaw export lease release action");
|
||||
this.released = new AtomicBoolean();
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (released.compareAndSet(false, true)) {
|
||||
release.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.BrokenPackException;
|
||||
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
|
||||
import art.arcane.iris.core.runtime.StudioOpenCoordinator;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
|
||||
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;
|
||||
import art.arcane.iris.core.service.StudioSVC;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisStructure;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletionException;
|
||||
|
||||
final class CommandJigsawOpen {
|
||||
private CommandJigsawOpen() {
|
||||
}
|
||||
|
||||
static void openProject(
|
||||
Player targetPlayer,
|
||||
VolmitSender commandSender,
|
||||
IrisDimension dimension,
|
||||
String structure,
|
||||
long seed
|
||||
) {
|
||||
UUID playerId = targetPlayer.getUniqueId();
|
||||
UUID ownerId = JigsawStudioActivation.activeOwnerId();
|
||||
if (ownerId != null && !ownerId.equals(playerId)) {
|
||||
CommandJigsaw.sendError(commandSender, "Jigsaw Studio is already owned by another player session.");
|
||||
return;
|
||||
}
|
||||
UUID openingOwner = JigsawStudioActivation.openingOwnerId();
|
||||
if (openingOwner != null) {
|
||||
CommandJigsaw.sendError(commandSender, openingOwner.equals(playerId)
|
||||
? "Your Jigsaw Studio is already opening."
|
||||
: "Jigsaw Studio is already opening for another player.");
|
||||
return;
|
||||
}
|
||||
CommandJigsaw.ActiveContext previous = CommandJigsaw.active(targetPlayer);
|
||||
if (ownerId != null && previous == null) {
|
||||
CommandJigsaw.sendError(commandSender, "Your active Jigsaw Studio binding is unavailable. Close it before opening another project.");
|
||||
return;
|
||||
}
|
||||
CommandJigsaw.ActiveContext dirty = CommandJigsaw.findDirtyContext();
|
||||
if (dirty != null) {
|
||||
CommandJigsaw.sendError(commandSender,
|
||||
"The current Jigsaw Studio is waiting for autosave. Wait for it to finish or close with discard=true.");
|
||||
return;
|
||||
}
|
||||
IrisData data = CommandJigsaw.requireData(dimension, commandSender);
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
IrisStructure irisStructure = data.load(IrisStructure.class, structure, false);
|
||||
if (irisStructure == null) {
|
||||
CommandJigsaw.sendError(commandSender, "No Iris jigsaw structure '" + structure + "' exists in this pack.");
|
||||
return;
|
||||
}
|
||||
|
||||
JigsawStudioLayout layout;
|
||||
try {
|
||||
layout = JigsawStudioGraphMapper.map(data, irisStructure);
|
||||
} catch (RuntimeException exception) {
|
||||
Iris.reportError("Failed to map Jigsaw Studio graph '" + structure + "'.", exception);
|
||||
CommandJigsaw.sendError(commandSender, "Could not map this jigsaw graph: " + exception.getMessage());
|
||||
return;
|
||||
}
|
||||
String packKey = dimension.getLoadKey();
|
||||
if (!JigsawStudioActivation.tryBeginOpen(playerId)) {
|
||||
CommandJigsaw.sendError(commandSender,
|
||||
"Jigsaw Studio began opening for another player. Try again after it finishes.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioService studioService = JigsawStudioService.get();
|
||||
UUID closingRequestId = previous == null ? null : previous.request().requestId();
|
||||
if (closingRequestId != null) {
|
||||
JigsawStudioService.CloseStart closeStart = studioService.tryBeginClose(
|
||||
closingRequestId,
|
||||
playerId,
|
||||
false);
|
||||
if (closeStart != JigsawStudioService.CloseStart.STARTED) {
|
||||
JigsawStudioActivation.finishOpen(playerId);
|
||||
CommandJigsaw.sendError(commandSender, closeStart == JigsawStudioService.CloseStart.SAVE_IN_PROGRESS
|
||||
? "The current Jigsaw Studio is saving. Wait for it to finish before opening another project."
|
||||
: closeStart == JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS
|
||||
? "The current Jigsaw Studio is loading a variant. Wait for it to finish before opening another project."
|
||||
: closeStart == JigsawStudioService.CloseStart.DIRTY
|
||||
? "The current Jigsaw Studio is waiting for autosave. Wait for it to finish before opening another project."
|
||||
: "The current Jigsaw Studio could not be reserved for replacement.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
JigsawStudioActivation.StagedActivation staged;
|
||||
try {
|
||||
staged = JigsawStudioActivation.stage(
|
||||
packKey,
|
||||
structure,
|
||||
layout.mode(),
|
||||
CommandJigsaw.compatibilityOf(irisStructure),
|
||||
layout.cellDimensions(),
|
||||
data,
|
||||
layout,
|
||||
playerId,
|
||||
closingRequestId);
|
||||
} catch (RuntimeException exception) {
|
||||
JigsawStudioActivation.finishOpen(playerId);
|
||||
studioService.cancelClose(closingRequestId);
|
||||
Iris.reportError("Failed to stage Jigsaw Studio for '" + structure + "'.", exception);
|
||||
CommandJigsaw.sendError(commandSender, "Jigsaw Studio staging failed: " + exception.getMessage());
|
||||
return;
|
||||
}
|
||||
commandSender.sendMessage(C.GREEN + "Opening transient Jigsaw Studio for '" + structure + "' with "
|
||||
+ layout.bays().size() + " workcells.");
|
||||
|
||||
try {
|
||||
Iris.service(StudioSVC.class).openTracked(
|
||||
commandSender,
|
||||
seed,
|
||||
packKey,
|
||||
CommandJigsaw.STUDIO_OPEN_KIND,
|
||||
() -> beginStagedOpen(staged),
|
||||
world -> finishOpen(targetPlayer, world, staged, studioService))
|
||||
.whenComplete((result, throwable) -> J.s(() -> finishOpenAttempt(
|
||||
commandSender,
|
||||
playerId,
|
||||
structure,
|
||||
closingRequestId,
|
||||
studioService,
|
||||
staged,
|
||||
result,
|
||||
throwable)));
|
||||
} catch (Throwable exception) {
|
||||
JigsawStudioActivation.finishOpen(playerId);
|
||||
studioService.cancelClose(closingRequestId);
|
||||
JigsawStudioActivation.rollback(staged);
|
||||
Iris.reportError("Failed to open Jigsaw Studio for '" + structure + "'.", exception);
|
||||
CommandJigsaw.sendError(commandSender, "Jigsaw Studio open failed: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void beginStagedOpen(JigsawStudioActivation.StagedActivation staged) {
|
||||
if (!JigsawStudioActivation.beginStagedGeneration(staged)) {
|
||||
throw new IllegalStateException("The staged Jigsaw Studio activation is no longer current");
|
||||
}
|
||||
}
|
||||
|
||||
private static void finishOpenAttempt(
|
||||
VolmitSender commandSender,
|
||||
UUID playerId,
|
||||
String structureKey,
|
||||
UUID closingRequestId,
|
||||
JigsawStudioService studioService,
|
||||
JigsawStudioActivation.StagedActivation staged,
|
||||
StudioOpenCoordinator.StudioOpenResult result,
|
||||
Throwable throwable
|
||||
) {
|
||||
JigsawStudioActivation.finishOpen(playerId);
|
||||
if (throwable == null && result != null) {
|
||||
studioService.cancelClose(closingRequestId);
|
||||
return;
|
||||
}
|
||||
studioService.cancelClose(closingRequestId);
|
||||
JigsawStudioActivation.rollback(staged);
|
||||
Throwable failure = unwrapCompletionFailure(throwable == null
|
||||
? new IllegalStateException("Studio open completed without a result")
|
||||
: throwable);
|
||||
if (isExpectedOpenDenial(failure)) {
|
||||
Iris.warn("Jigsaw Studio open for '%s' was denied: %s",
|
||||
structureKey, failure.getMessage());
|
||||
return;
|
||||
}
|
||||
Iris.reportError("Failed to open Jigsaw Studio for '" + structureKey + "'.", failure);
|
||||
CommandJigsaw.sendError(commandSender, "Jigsaw Studio open failed: " + failure.getMessage());
|
||||
}
|
||||
|
||||
private static void finishOpen(
|
||||
Player targetPlayer,
|
||||
World world,
|
||||
JigsawStudioActivation.StagedActivation staged,
|
||||
JigsawStudioService studioService
|
||||
) {
|
||||
if (world == null) {
|
||||
throw new IllegalStateException("Jigsaw Studio opened without a world");
|
||||
}
|
||||
JigsawStudioActivation.Request request = staged.request();
|
||||
JigsawStudioSession session = staged.session();
|
||||
try {
|
||||
WorldRuntimeControlService.get().applyObjectStudioWorldRules(world);
|
||||
} catch (Throwable exception) {
|
||||
Iris.reportError("Failed to apply Jigsaw Studio world rules for '" + world.getName() + "'.", exception);
|
||||
}
|
||||
JigsawStudioBay destination = initialBay(session.layout());
|
||||
Location target = destination == null
|
||||
? new Location(world, 0.5D, 66D, 0.5D)
|
||||
: new Location(
|
||||
world,
|
||||
destination.bounds().originX() + destination.bounds().dimensions().width() / 2.0D,
|
||||
destination.bounds().maxY() + 2.0D,
|
||||
destination.bounds().originZ() + destination.bounds().dimensions().depth() / 2.0D);
|
||||
if (destination != null) {
|
||||
session.selectBay(destination.stableId());
|
||||
}
|
||||
if (!JigsawStudioActivation.commit(staged)) {
|
||||
throw new IllegalStateException("The staged Jigsaw Studio activation could not be committed");
|
||||
}
|
||||
studioService.activationCommitted(world, request.requestId());
|
||||
CommandJigsaw.PLAYER_PACKS.put(targetPlayer.getUniqueId(), new CommandJigsaw.SessionBinding(
|
||||
request.packKey(), request.requestId()));
|
||||
try {
|
||||
J.runEntity(targetPlayer, () -> BukkitPlatform.teleportAsync(targetPlayer, target)
|
||||
.thenRun(() -> J.runEntity(targetPlayer, () -> targetPlayer.setGameMode(GameMode.CREATIVE))));
|
||||
} catch (RuntimeException exception) {
|
||||
Iris.reportError("Failed to move the player to the committed Jigsaw Studio workcell.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static JigsawStudioBay initialBay(JigsawStudioLayout layout) {
|
||||
for (JigsawStudioBay bay : layout.bays()) {
|
||||
if (layout.defaultVariant(bay).isPresent()) {
|
||||
return bay;
|
||||
}
|
||||
}
|
||||
return layout.bays().isEmpty() ? null : layout.bays().getFirst();
|
||||
}
|
||||
|
||||
private static Throwable unwrapCompletionFailure(Throwable failure) {
|
||||
Throwable current = failure;
|
||||
while (current instanceof CompletionException && current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
static boolean isExpectedOpenDenial(Throwable failure) {
|
||||
return unwrapCompletionFailure(failure) instanceof BrokenPackException;
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarTopology;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphEditor;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
|
||||
import art.arcane.iris.core.service.JigsawStudioService;
|
||||
import art.arcane.iris.engine.object.IrisJigsawPiece;
|
||||
import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
import art.arcane.iris.util.common.director.DirectorExecutor;
|
||||
import art.arcane.volmlib.util.director.DirectorOrigin;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import org.bukkit.Location;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Director(name = "piece", description = "Manage contextual Studio pieces", origin = DirectorOrigin.PLAYER)
|
||||
public class CommandJigsawPiece implements DirectorExecutor {
|
||||
@Director(description = "Create an owned piece from the selected Studio topology", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void create(
|
||||
@Param(description = "Owned pool resource key") String poolKey,
|
||||
@Param(description = "New owned piece and object key") String pieceKey,
|
||||
@Param(description = "Positive selection weight", defaultValue = "1") int weight
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
JigsawPlanarTopology topology = null;
|
||||
JigsawStudioBay targetWorkcell = context.session().layout().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID);
|
||||
if (context.session().layout().mode() == JigsawStudioMode.PLANAR_JIGSAW) {
|
||||
Location location = player().getLocation();
|
||||
JigsawStudioBay contextual = context.session().layout().findAt(
|
||||
location.getBlockX(), location.getBlockY(), location.getBlockZ());
|
||||
if (contextual == null) {
|
||||
contextual = CommandJigsaw.selectedBay(context);
|
||||
} else {
|
||||
context.session().selectBay(contextual.stableId());
|
||||
}
|
||||
if (contextual == null || contextual.topology().isEmpty()) {
|
||||
CommandJigsaw.sendError(this, "Stand in or select a planar topology workcell before creating a variant.");
|
||||
return;
|
||||
}
|
||||
topology = contextual.topology().get();
|
||||
targetWorkcell = contextual;
|
||||
}
|
||||
if (targetWorkcell == null) {
|
||||
CommandJigsaw.sendError(this, "The selected Jigsaw Studio workcell is no longer available.");
|
||||
return;
|
||||
}
|
||||
JigsawPlanarTopology selectedTopology = topology;
|
||||
String activationWorkcellId = targetWorkcell.stableId();
|
||||
JigsawStudioCellDimensions dimensions = targetWorkcell.capacity();
|
||||
String topologyText = selectedTopology == null ? "blank spatial" : selectedTopology.name();
|
||||
CommandJigsaw.runGraphMutation(player(), context, () -> {
|
||||
JigsawStudioGraphEditor.createPiece(
|
||||
context.request().source().getDataFolder().toPath(),
|
||||
context.request().structureKey(),
|
||||
poolKey,
|
||||
pieceKey,
|
||||
weight,
|
||||
dimensions,
|
||||
selectedTopology);
|
||||
return CommandJigsaw.mappedMutationResult(
|
||||
context,
|
||||
activationWorkcellId,
|
||||
pieceKey,
|
||||
"Created owned " + topologyText + " piece '" + pieceKey
|
||||
+ "' and added it to pool '" + poolKey + "' atomically.");
|
||||
});
|
||||
}
|
||||
|
||||
@Director(description = "Add an existing jigsaw piece to an owned pool transactionally", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void add(
|
||||
@Param(description = "Owned pool resource key") String poolKey,
|
||||
@Param(description = "Existing jigsaw piece key") String pieceKey,
|
||||
@Param(description = "Positive selection weight", defaultValue = "1") int weight
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
IrisJigsawPiece piece = context.request().source().getJigsawPieceLoader().load(pieceKey);
|
||||
if (piece == null) {
|
||||
CommandJigsaw.sendError(this, "No jigsaw piece '" + pieceKey + "' exists in this pack.");
|
||||
return;
|
||||
}
|
||||
if (piece.getObject() == null || piece.getObject().isBlank()) {
|
||||
CommandJigsaw.sendError(this, "Jigsaw piece '" + pieceKey + "' does not declare an object.");
|
||||
return;
|
||||
}
|
||||
IrisObject object = context.request().source().getObjectLoader().load(piece.getObject());
|
||||
if (object == null) {
|
||||
CommandJigsaw.sendError(this, "Jigsaw piece '" + pieceKey + "' has no loadable object.");
|
||||
return;
|
||||
}
|
||||
CommandJigsaw.PieceWorkcellResolution resolution;
|
||||
try {
|
||||
resolution = CommandJigsaw.resolvePieceWorkcell(context.session().layout(), piece, object);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
CommandJigsaw.sendError(this, "Jigsaw piece '" + pieceKey + "' cannot be assigned to a Studio workcell: "
|
||||
+ exception.getMessage());
|
||||
return;
|
||||
}
|
||||
if (!resolution.fits()) {
|
||||
IrisPosition required = resolution.requiredDimensions();
|
||||
JigsawStudioCellDimensions capacity = resolution.workcell().capacity();
|
||||
CommandJigsaw.sendError(this, "Jigsaw piece '" + pieceKey + "' requires " + required.getX() + "x"
|
||||
+ required.getY() + "x" + required.getZ() + " in " + resolution.workcell().displayName()
|
||||
+ ", but that workcell capacity is " + capacity.width() + "x" + capacity.height()
|
||||
+ "x" + capacity.depth() + ".");
|
||||
return;
|
||||
}
|
||||
String objectKey = piece.getObject();
|
||||
CommandJigsaw.runGraphMutation(player(), context, () -> {
|
||||
if (!JigsawStudioGraphEditor.ownsPiece(
|
||||
context.request().source().getDataFolder().toPath(),
|
||||
context.request().structureKey(),
|
||||
pieceKey,
|
||||
objectKey)) {
|
||||
throw new IOException("Piece add accepts only piece/object resources already owned by this project; "
|
||||
+ "use piece create for a new owned piece");
|
||||
}
|
||||
JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.addPiece(
|
||||
context.request().source().getDataFolder().toPath(),
|
||||
context.request().structureKey(),
|
||||
poolKey,
|
||||
pieceKey,
|
||||
weight);
|
||||
if (!update.changed()) {
|
||||
throw new IOException("The owned pool is missing or already references '" + pieceKey + "'");
|
||||
}
|
||||
JigsawStudioLayout layout = CommandJigsaw.loadPersistedLayout(context);
|
||||
String activationWorkcellId = CommandJigsaw.workcellIdForVariant(layout, pieceKey);
|
||||
return new JigsawStudioService.CommandGraphMutationResult(
|
||||
layout,
|
||||
activationWorkcellId,
|
||||
pieceKey,
|
||||
"Added '" + pieceKey + "' to pool '" + poolKey + "' in one graph transaction.");
|
||||
});
|
||||
}
|
||||
|
||||
@Director(description = "Remove the selected piece from an owned pool transactionally", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void remove(
|
||||
@Param(description = "Owned pool resource key") String poolKey
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioVariant selected = CommandJigsaw.selectedVariant(context);
|
||||
if (selected == null) {
|
||||
CommandJigsaw.sendError(this, "Select a workcell with an active variant before removing its pool entry.");
|
||||
return;
|
||||
}
|
||||
String pieceKey = selected.pieceKey();
|
||||
CommandJigsaw.runGraphMutation(player(), context, () -> {
|
||||
JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.removePiece(
|
||||
context.request().source().getDataFolder().toPath(),
|
||||
context.request().structureKey(),
|
||||
poolKey,
|
||||
pieceKey);
|
||||
if (!update.changed()) {
|
||||
throw new IOException("The owned pool does not reference selected piece '" + pieceKey + "'");
|
||||
}
|
||||
return CommandJigsaw.mappedMutationResult(
|
||||
context,
|
||||
"",
|
||||
"",
|
||||
"Removed '" + pieceKey + "' from pool '" + poolKey + "' in one graph transaction.");
|
||||
});
|
||||
}
|
||||
|
||||
@Director(description = "Allow or forbid cardinal rotation of the selected piece", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void rotatable(
|
||||
@Param(description = "Whether the selected piece may rotate") boolean rotatable
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioVariant selected = CommandJigsaw.selectedVariant(context);
|
||||
if (selected == null) {
|
||||
CommandJigsaw.sendError(this, "Select a workcell with an active variant before changing rotation.");
|
||||
return;
|
||||
}
|
||||
if (!rotatable
|
||||
&& context.request().compatibilityTarget()
|
||||
== JigsawStudioCompatibilityTarget.VANILLA_PORTABLE) {
|
||||
CommandJigsaw.sendError(this, "Vanilla-portable pieces must remain rotatable.");
|
||||
return;
|
||||
}
|
||||
String pieceKey = selected.pieceKey();
|
||||
CommandJigsaw.runGraphMutation(player(), context, () -> {
|
||||
JigsawStudioGraphEditor.updateRotatable(
|
||||
context.request().source().getDataFolder().toPath(),
|
||||
context.request().structureKey(),
|
||||
pieceKey,
|
||||
rotatable);
|
||||
return CommandJigsaw.mappedMutationResult(
|
||||
context,
|
||||
"",
|
||||
"",
|
||||
"Persisted piece '" + pieceKey + "' rotatable=" + rotatable + ".");
|
||||
});
|
||||
}
|
||||
|
||||
@Director(description = "Resize the selected piece object exactly to workcell capacity", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void expand() {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioBay workcell = CommandJigsaw.selectedBay(context);
|
||||
if (workcell == null) {
|
||||
CommandJigsaw.sendError(this, "Select a workcell before expanding its active variant.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioService.get().expandVariantToCell(player(), workcell.stableId());
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphEditor;
|
||||
import art.arcane.iris.util.common.director.DirectorExecutor;
|
||||
import art.arcane.volmlib.util.director.DirectorOrigin;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
|
||||
@Director(name = "pool", description = "Manage owned jigsaw pools", origin = DirectorOrigin.PLAYER)
|
||||
public class CommandJigsawPool implements DirectorExecutor {
|
||||
@Director(description = "Create an empty owned jigsaw pool", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void create(
|
||||
@Param(description = "New owned pool resource key") String poolKey,
|
||||
@Param(description = "Owned fallback pool key, or none", defaultValue = "none")
|
||||
String fallbackPoolKey
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
String fallback = "none".equalsIgnoreCase(fallbackPoolKey) ? "" : fallbackPoolKey;
|
||||
CommandJigsaw.runGraphMutation(player(), context, () -> {
|
||||
JigsawStudioGraphEditor.createPool(
|
||||
context.request().source().getDataFolder().toPath(),
|
||||
context.request().structureKey(),
|
||||
poolKey,
|
||||
fallback);
|
||||
return CommandJigsaw.mappedMutationResult(
|
||||
context,
|
||||
"",
|
||||
"",
|
||||
"Created owned pool '" + poolKey + "' with fallback '"
|
||||
+ (fallback.isBlank() ? "none" : fallback) + "'.");
|
||||
});
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.core.service.JigsawStudioService;
|
||||
import art.arcane.iris.util.common.director.DirectorExecutor;
|
||||
import art.arcane.volmlib.util.director.DirectorOrigin;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
|
||||
@Director(name = "preview", description = "Assemble a read-only jigsaw preview",
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public class CommandJigsawPreview implements DirectorExecutor {
|
||||
@Director(name = "goto", aliases = "teleport", description = "Teleport to the live seed-1337 preview",
|
||||
sync = true, origin = DirectorOrigin.PLAYER)
|
||||
public void gotoPreview() {
|
||||
JigsawStudioService.get().goToPreview(player());
|
||||
}
|
||||
|
||||
@Director(description = "Assemble a deterministic preview at your location", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void assemble(
|
||||
@Param(description = "Assembly seed", defaultValue = "1337") long seed
|
||||
) {
|
||||
JigsawStudioService.get().previewStudio(player(), seed);
|
||||
}
|
||||
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioStructureEditor;
|
||||
import art.arcane.iris.util.common.director.DirectorExecutor;
|
||||
import art.arcane.volmlib.util.director.DirectorOrigin;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
|
||||
@Director(name = "rules", aliases = "rule", description = "Manage structure-wide jigsaw rules",
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public class CommandJigsawRules implements DirectorExecutor {
|
||||
@Director(description = "Set maximum expansion depth and horizontal radius", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void limits(
|
||||
@Param(description = "Maximum expansion depth") int maxDepth,
|
||||
@Param(description = "Maximum radius in chunks") int maxSizeChunks
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
if (context.request().compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE
|
||||
&& (maxDepth > 20 || maxSizeChunks > 8)) {
|
||||
CommandJigsaw.sendError(this, "Vanilla-portable rules require maxDepth <= 20 and maxSizeChunks <= 8.");
|
||||
return;
|
||||
}
|
||||
CommandJigsaw.runGraphMutation(player(), context, () -> {
|
||||
JigsawStudioStructureEditor.updateLimits(
|
||||
context.request().source().getDataFolder().toPath(),
|
||||
context.request().structureKey(),
|
||||
maxDepth,
|
||||
maxSizeChunks);
|
||||
return CommandJigsaw.mappedMutationResult(
|
||||
context,
|
||||
"",
|
||||
"",
|
||||
"Persisted jigsaw limits: maxDepth=" + maxDepth
|
||||
+ ", maxSizeChunks=" + maxSizeChunks + ".");
|
||||
});
|
||||
}
|
||||
|
||||
@Director(description = "Set or clear one owned pool's direct fallback", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void fallback(
|
||||
@Param(description = "Owned pool resource key") String poolKey,
|
||||
@Param(description = "Owned fallback pool key, or none") String fallbackPoolKey
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
String fallback = "none".equalsIgnoreCase(fallbackPoolKey) ? "" : fallbackPoolKey;
|
||||
CommandJigsaw.runGraphMutation(player(), context, () -> {
|
||||
JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.updateFallback(
|
||||
context.request().source().getDataFolder().toPath(),
|
||||
context.request().structureKey(),
|
||||
poolKey,
|
||||
fallback);
|
||||
if (!update.changed()) {
|
||||
return CommandJigsaw.mappedMutationResult(context, "", "", "Pool fallback is unchanged.");
|
||||
}
|
||||
return CommandJigsaw.mappedMutationResult(
|
||||
context,
|
||||
"",
|
||||
"",
|
||||
"Persisted pool '" + poolKey + "' fallback as '"
|
||||
+ (fallback.isBlank() ? "none" : fallback) + "'.");
|
||||
});
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
|
||||
import art.arcane.iris.core.service.JigsawStudioService;
|
||||
import art.arcane.iris.util.common.director.DirectorExecutor;
|
||||
import art.arcane.volmlib.util.director.DirectorOrigin;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Director(name = "variant", description = "Manage selected jigsaw variants", origin = DirectorOrigin.PLAYER)
|
||||
public class CommandJigsawVariant implements DirectorExecutor {
|
||||
@Director(description = "Set the selected piece weight in a pool", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void weight(
|
||||
@Param(description = "Pool resource key") String poolKey,
|
||||
@Param(description = "Positive selection weight") int weight
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioVariant selected = CommandJigsaw.selectedVariant(context);
|
||||
if (selected == null) {
|
||||
CommandJigsaw.sendError(this, "Select a workcell with an active variant before changing its weight.");
|
||||
return;
|
||||
}
|
||||
String pieceKey = selected.pieceKey();
|
||||
CommandJigsaw.runGraphMutation(player(), context, () -> {
|
||||
JigsawStudioPoolEditor.WeightUpdate update = JigsawStudioPoolEditor.updateWeight(
|
||||
context.request().source().getDataFolder().toPath(),
|
||||
context.request().structureKey(),
|
||||
poolKey,
|
||||
pieceKey,
|
||||
weight);
|
||||
if (!update.changed()) {
|
||||
throw new IOException("Pool '" + poolKey + "' does not reference selected piece '"
|
||||
+ pieceKey + "'");
|
||||
}
|
||||
return CommandJigsaw.mappedMutationResult(
|
||||
context,
|
||||
"",
|
||||
"",
|
||||
"Updated " + update.changedEntries() + " pool entry weight atomically in "
|
||||
+ update.poolPath() + ".");
|
||||
});
|
||||
}
|
||||
|
||||
@Director(description = "Resize only the active variant object within workcell capacity", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void resize(
|
||||
@Param(description = "Variant width") int width,
|
||||
@Param(description = "Variant height") int height,
|
||||
@Param(description = "Variant depth") int depth
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioBay selectedWorkcell = CommandJigsaw.selectedBay(context);
|
||||
JigsawStudioVariant selectedVariant = CommandJigsaw.selectedVariant(context);
|
||||
if (selectedWorkcell == null || selectedVariant == null) {
|
||||
CommandJigsaw.sendError(this, "Select a workcell with an active variant before resizing it.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioCellDimensions dimensions;
|
||||
try {
|
||||
dimensions = new JigsawStudioCellDimensions(width, height, depth);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
CommandJigsaw.sendError(this, exception.getMessage());
|
||||
return;
|
||||
}
|
||||
JigsawStudioService.get().resizeVariant(
|
||||
player(),
|
||||
selectedWorkcell.stableId(),
|
||||
selectedVariant.pieceKey(),
|
||||
dimensions);
|
||||
}
|
||||
|
||||
@Director(description = "Set the active variant's author-facing label", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void label(
|
||||
@Param(description = "Display label; quote labels containing spaces") String displayName
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioBay selectedWorkcell = CommandJigsaw.selectedBay(context);
|
||||
JigsawStudioVariant selectedVariant = CommandJigsaw.selectedVariant(context);
|
||||
if (selectedWorkcell == null || selectedVariant == null) {
|
||||
CommandJigsaw.sendError(this, "Select a workcell with an active variant before renaming it.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioService.get().updateVariantDisplayName(
|
||||
player(),
|
||||
selectedWorkcell.stableId(),
|
||||
selectedVariant.pieceKey(),
|
||||
displayName);
|
||||
}
|
||||
|
||||
@Director(name = "label-reset", aliases = "reset-label",
|
||||
description = "Reset the active variant to its resource-key label", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void labelReset() {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioBay selectedWorkcell = CommandJigsaw.selectedBay(context);
|
||||
JigsawStudioVariant selectedVariant = CommandJigsaw.selectedVariant(context);
|
||||
if (selectedWorkcell == null || selectedVariant == null) {
|
||||
CommandJigsaw.sendError(this, "Select a workcell with an active variant before resetting its label.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioService.get().updateVariantDisplayName(
|
||||
player(),
|
||||
selectedWorkcell.stableId(),
|
||||
selectedVariant.pieceKey(),
|
||||
"");
|
||||
}
|
||||
|
||||
@Director(description = "Duplicate the active variant in only this workcell", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void duplicate() {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioBay selected = CommandJigsaw.selectedBay(context);
|
||||
if (selected == null || CommandJigsaw.selectedVariant(context) == null) {
|
||||
CommandJigsaw.sendError(this, "Select a workcell with an active variant before duplicating it.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioService.get().createVariant(player(), selected.stableId(), true);
|
||||
}
|
||||
|
||||
@Director(name = "duplicate-family", aliases = "family",
|
||||
description = "Duplicate every enabled workcell's active variant as one coherent family",
|
||||
sync = true, origin = DirectorOrigin.PLAYER)
|
||||
public void duplicateFamily(
|
||||
@Param(description = "Family key or next", defaultValue = "next") String themeKey
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
String selectedTheme = themeKey;
|
||||
if ("next".equalsIgnoreCase(themeKey)) {
|
||||
try {
|
||||
selectedTheme = CommandJigsaw.nextThemeKey(context);
|
||||
} catch (IOException | ArithmeticException exception) {
|
||||
CommandJigsaw.sendError(this, exception.getMessage());
|
||||
return;
|
||||
}
|
||||
}
|
||||
JigsawStudioService.get().duplicateActiveFamily(player(), selectedTheme);
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
|
||||
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
|
||||
import art.arcane.iris.core.service.JigsawStudioService;
|
||||
import art.arcane.iris.util.common.director.DirectorExecutor;
|
||||
import art.arcane.volmlib.util.director.DirectorOrigin;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
|
||||
@Director(name = "workcell", aliases = "cell", description = "Manage the selected Studio workcell",
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public class CommandJigsawWorkcell implements DirectorExecutor {
|
||||
@Director(description = "Set capacity without resizing any variant object", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void capacity(
|
||||
@Param(description = "Capacity width") int width,
|
||||
@Param(description = "Capacity height") int height,
|
||||
@Param(description = "Capacity depth") int depth
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioBay selected = CommandJigsaw.selectedBay(context);
|
||||
if (selected == null) {
|
||||
CommandJigsaw.sendError(this, "Select a workcell before changing its capacity.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioCellDimensions dimensions;
|
||||
try {
|
||||
dimensions = new JigsawStudioCellDimensions(width, height, depth);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
CommandJigsaw.sendError(this, exception.getMessage());
|
||||
return;
|
||||
}
|
||||
JigsawStudioService.get().updateWorkcellDimensions(
|
||||
player(),
|
||||
selected.stableId(),
|
||||
dimensions);
|
||||
}
|
||||
|
||||
@Director(description = "Set the selected workcell's author-facing label", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void label(
|
||||
@Param(description = "Display label; quote labels containing spaces") String displayName
|
||||
) {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioBay selected = CommandJigsaw.selectedBay(context);
|
||||
if (selected == null) {
|
||||
CommandJigsaw.sendError(this, "Select a workcell before renaming it.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioService.get().updateWorkcellDisplayName(
|
||||
player(),
|
||||
selected.stableId(),
|
||||
displayName);
|
||||
}
|
||||
|
||||
@Director(name = "label-reset", aliases = "reset-label",
|
||||
description = "Reset the selected workcell to its canonical solver label", sync = true,
|
||||
origin = DirectorOrigin.PLAYER)
|
||||
public void labelReset() {
|
||||
CommandJigsaw.ActiveContext context = CommandJigsaw.active(player());
|
||||
if (context == null) {
|
||||
CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioBay selected = CommandJigsaw.selectedBay(context);
|
||||
if (selected == null) {
|
||||
CommandJigsaw.sendError(this, "Select a workcell before resetting its label.");
|
||||
return;
|
||||
}
|
||||
JigsawStudioService.get().updateWorkcellDisplayName(player(), selected.stableId(), "");
|
||||
}
|
||||
}
|
||||
@@ -75,8 +75,8 @@ public class WandSVC implements IrisService {
|
||||
private static final int MS_PER_TICK = Integer.parseInt(System.getProperty("iris.ms_per_tick", "30"));
|
||||
private static final int PLAYER_RESCAN_INTERVAL_TICKS = 100;
|
||||
|
||||
private static ItemStack dust;
|
||||
private static ItemStack wand;
|
||||
private static volatile ItemStack dust;
|
||||
private static volatile ItemStack wand;
|
||||
|
||||
private final Map<UUID, Player> activePlayers = new ConcurrentHashMap<>();
|
||||
private final AtomicBoolean playerRescanScheduled = new AtomicBoolean(false);
|
||||
@@ -374,12 +374,14 @@ public class WandSVC implements IrisService {
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
wand = createWand();
|
||||
dust = createDust();
|
||||
enabled = true;
|
||||
activePlayers.clear();
|
||||
ticksUntilPlayerRescan = 0;
|
||||
taskId = J.ar(this::tickAll, 1);
|
||||
J.s(() -> {
|
||||
wand = createWand();
|
||||
dust = createDust();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -389,6 +391,8 @@ public class WandSVC implements IrisService {
|
||||
J.car(taskId);
|
||||
taskId = -1;
|
||||
}
|
||||
wand = null;
|
||||
dust = null;
|
||||
activePlayers.clear();
|
||||
playerRescanScheduled.set(false);
|
||||
}
|
||||
@@ -607,7 +611,8 @@ public class WandSVC implements IrisService {
|
||||
return false;
|
||||
}
|
||||
Byte marker = is.getItemMeta().getPersistentDataContainer().get(dustKey(), PersistentDataType.BYTE);
|
||||
return (marker != null && marker == (byte) 1) || is.isSimilar(dust);
|
||||
ItemStack template = dust;
|
||||
return (marker != null && marker == (byte) 1) || (template != null && is.isSimilar(template));
|
||||
}
|
||||
|
||||
private static NamespacedKey wandKey() {
|
||||
|
||||
@@ -3,14 +3,10 @@ package art.arcane.iris;
|
||||
import art.arcane.iris.spi.LogLevel;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* Core states a severity on every message it logs and the modded adapters honour it. Bukkit must preserve
|
||||
@@ -33,58 +29,4 @@ public class IrisDiagnosticLogLevelTest {
|
||||
public void lifecycleNoticesReachThePluginLoggerAtInfo() {
|
||||
assertEquals(Level.INFO, Iris.diagnosticLevel(LogLevel.NOTICE));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void adapterSideWarningsCarryTheSameSeverityAsCoreWarnings() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")).replace("\r\n", "\n");
|
||||
|
||||
String warn = method(source, "public static void warn(String format, Object... objs)");
|
||||
assertTrue(warn, warn.contains("diagnostic(Level.WARNING"));
|
||||
assertFalse(warn, warn.contains("msg("));
|
||||
|
||||
String error = method(source, "public static void error(String format, Object... objs)");
|
||||
assertTrue(error, error.contains("diagnostic(Level.SEVERE"));
|
||||
assertFalse(error, error.contains("msg("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theCoreLogBridgeRoutesDiagnosticsToThePluginLogger() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")).replace("\r\n", "\n");
|
||||
String bridge = method(source, "private static void bridgeLog(LogLevel level, String message)");
|
||||
|
||||
assertTrue(bridge, bridge.contains("diagnosticLevel(target)"));
|
||||
assertTrue(bridge, bridge.contains("diagnostic(diagnostic, message)"));
|
||||
String diagnostic = method(source, "private static void diagnostic(Level level, String message)");
|
||||
assertTrue(diagnostic, diagnostic.contains("ComponentLog.log("));
|
||||
assertTrue(diagnostic, diagnostic.contains("ComponentText.literal(line)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void informationalMessagesUseTheSharedComponentLogger() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")).replace("\r\n", "\n");
|
||||
String message = method(source, "public static void msg(String string)");
|
||||
|
||||
assertTrue(message, message.contains("ComponentLog.logMarkup("));
|
||||
assertTrue(message, message.contains("logPrefix(plugin)"));
|
||||
assertFalse(message, message.contains("getSender().sendMessage"));
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
assertTrue("method not found: " + signature, start >= 0);
|
||||
int open = source.indexOf('{', start);
|
||||
int depth = 0;
|
||||
for (int i = open; i < source.length(); i++) {
|
||||
char c = source.charAt(i);
|
||||
if (c == '{') {
|
||||
depth++;
|
||||
} else if (c == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return source.substring(start, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new AssertionError("unterminated method: " + signature);
|
||||
}
|
||||
}
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisStartupOrderingTest {
|
||||
@Test
|
||||
public void admissionGateIsRegisteredBeforeStartupValidationBegins() throws Exception {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
|
||||
String onEnable = section(source, "public void onEnable()", "public void onDisable()");
|
||||
|
||||
assertOrdered(onEnable,
|
||||
"IrisStartupValidation.begin();",
|
||||
"registerEvents(new IrisStartupAdmissionListener(), this);",
|
||||
"enable();");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void externalDatapacksValidateBeforeDimensionPacks() throws Exception {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
|
||||
String enable = section(source, "private boolean enable()", "public void addShutdownHook()");
|
||||
|
||||
assertOrdered(enable,
|
||||
"DatapackIngestService.validateOnStartup();",
|
||||
"generatorResolver.validateAllPacks();");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void externalProvidersEnableBeforePackValidation() throws Exception {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
|
||||
String enable = section(source, "private boolean enable()", "public void addShutdownHook()");
|
||||
|
||||
assertOrdered(enable,
|
||||
"service.onEnable();",
|
||||
"setContentChangeListener(generatorResolver::requestExternalContentRefresh)",
|
||||
"generatorResolver.validateAllPacks();");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restartRequiredStartupTerminatesAfterPluginInitialization() throws Exception {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
|
||||
String onEnable = section(source, "public void onEnable()", "public void onDisable()");
|
||||
|
||||
assertOrdered(onEnable,
|
||||
"BukkitGuiHost.install();",
|
||||
"super.onEnable();",
|
||||
"IrisStartupValidation.isRestartRequired()",
|
||||
"ServerConfigurator.restartAtStartupBoundary(restartReason);");
|
||||
}
|
||||
|
||||
private static String section(String source, String startMarker, String endMarker) {
|
||||
int start = source.indexOf(startMarker);
|
||||
int end = source.indexOf(endMarker, start);
|
||||
assertTrue("Missing source section starting with " + startMarker, start >= 0);
|
||||
assertTrue("Missing source section ending with " + endMarker, end > start);
|
||||
return source.substring(start, end);
|
||||
}
|
||||
|
||||
private static void assertOrdered(String source, String... markers) {
|
||||
int previous = -1;
|
||||
for (String marker : markers) {
|
||||
int current = source.indexOf(marker);
|
||||
assertTrue("Missing source marker " + marker, current >= 0);
|
||||
assertTrue("Source marker is out of order: " + marker, current > previous);
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
}
|
||||
-98
@@ -140,23 +140,6 @@ public class IrisWorldGeneratorResolverTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupValidationPublishesFingerprintBoundExactRootResults() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java")).replace("\r\n", "\n");
|
||||
int validateAll = source.indexOf("public void validateAllPacks()");
|
||||
int snapshot = source.indexOf("ServerConfigurator.computePackContentSnapshot(packsRoot)", validateAll);
|
||||
int perPackFingerprint = source.indexOf("contentSnapshot.packContents()", snapshot);
|
||||
int exactRootPublish = source.indexOf(
|
||||
"PackValidationRegistry.publish(packDirectory.toPath(), result, packFingerprint)",
|
||||
perPackFingerprint);
|
||||
|
||||
assertTrue(validateAll >= 0);
|
||||
assertTrue(snapshot > validateAll);
|
||||
assertTrue(perPackFingerprint > snapshot);
|
||||
assertTrue(exactRootPublish > perPackFingerprint);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void paperStartupAliasResolvesToCanonicalRuntimeKey() throws Exception {
|
||||
File levelRoot = ownedLevelRoot("startup-alias", "moon");
|
||||
@@ -251,87 +234,6 @@ public class IrisWorldGeneratorResolverTest {
|
||||
return levelRoot;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configuredWorldResolutionUsesOnlyActiveImmutableGenerationPack() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java")).replace("\r\n", "\n");
|
||||
int resolverStart = source.indexOf("private ChunkGenerator resolveFrozenWorldGenerator(");
|
||||
int resolverEnd = source.indexOf("private record FreshValidation", resolverStart);
|
||||
String resolver = source.substring(resolverStart, resolverEnd);
|
||||
|
||||
int dimensionRoot = resolver.indexOf("IrisWorldStorage.requireFrozenDimensionRoot(");
|
||||
int currentPlatformRoot = resolver.indexOf("WorldCreatorCompat.persistentDimensionRoot(worldKey)");
|
||||
int layoutRefusal = resolver.indexOf(
|
||||
"Frozen Iris world storage does not match the current platform layout",
|
||||
currentPlatformRoot
|
||||
);
|
||||
int history = resolver.indexOf("requireGenerationHistory(dimensionRoot, id, worldSeed)");
|
||||
int snapshotRoot = resolver.indexOf("requireActivePack(history)", history);
|
||||
int validation = resolver.indexOf("requireSnapshotLoadable(snapshotRoot)");
|
||||
int exactLoad = resolver.indexOf("requireHistoricalDimension(history, snapshotRoot, id)");
|
||||
int canonicalIdentity = resolver.indexOf(".platformIdentity(worldKey.toString())");
|
||||
int historicalSeed = resolver.indexOf(".seed(history.activeEpoch().worldSeed())");
|
||||
int resolvedStorage = resolver.indexOf(".worldFolder(dimensionRoot)");
|
||||
|
||||
assertTrue(dimensionRoot >= 0);
|
||||
assertTrue(currentPlatformRoot > dimensionRoot);
|
||||
assertTrue(layoutRefusal > currentPlatformRoot);
|
||||
assertTrue(history > layoutRefusal);
|
||||
assertTrue(snapshotRoot > history);
|
||||
assertTrue(validation > snapshotRoot);
|
||||
assertTrue(exactLoad > validation);
|
||||
assertTrue(canonicalIdentity > exactLoad);
|
||||
assertTrue(historicalSeed > canonicalIdentity);
|
||||
assertTrue(resolvedStorage > historicalSeed);
|
||||
assertFalse(resolver.contains("loadDimension("));
|
||||
assertFalse(resolver.contains("loadAnyDimension("));
|
||||
assertFalse(resolver.contains("replaceIntoWorld("));
|
||||
assertFalse(resolver.contains("installIntoWorld("));
|
||||
assertFalse(resolver.contains(".seed(1337)"));
|
||||
assertTrue(resolver.contains("GenerationHistory.openIfPresent(root, worldSeed)"));
|
||||
assertTrue(resolver.contains("GenerationHistory.adoptLegacyPack("));
|
||||
assertTrue(resolver.contains("GenerationRegistryContractFactory.captureRequiredDefinitions("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ownedWorldSnapshotFailureStopsStartupAndRethrows() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java")).replace("\r\n", "\n");
|
||||
int resolverStart = source.indexOf("public ChunkGenerator resolveDefaultWorldGenerator(");
|
||||
int resolverEnd = source.indexOf("private ChunkGenerator resolveFrozenWorldGenerator(", resolverStart);
|
||||
String resolver = source.substring(resolverStart, resolverEnd);
|
||||
|
||||
int plotSquaredProbe = resolver.indexOf("isPlotSquaredGeneratorDiscoveryProbe(worldName, id)");
|
||||
int probe = resolver.indexOf("isGeneratorDiscoveryProbe(worldName, id)", plotSquaredProbe);
|
||||
int denial = resolver.indexOf("IrisStartupValidation.denialReason()", probe);
|
||||
int failClosed = resolver.indexOf("IrisFailClosedChunkGenerator.startupLock(", denial);
|
||||
int staged = resolver.indexOf("WorldLifecycleStaging.consumeGenerator(worldName)", failClosed);
|
||||
int duplicateGuard = resolver.indexOf("requireWorldKeyAvailable(worldName, worldKey)");
|
||||
int ownership = resolver.indexOf("requireOwnedWorld(worldName, levelRoot, worldKey)");
|
||||
int frozen = resolver.indexOf("return resolveFrozenWorldGenerator(", ownership);
|
||||
int failureCapture = resolver.indexOf("catch (RuntimeException failure)", frozen);
|
||||
int report = resolver.indexOf("Iris.reportError(", failureCapture);
|
||||
int shutdown = resolver.indexOf("Bukkit.shutdown()", report);
|
||||
int rethrow = resolver.indexOf("throw failure", shutdown);
|
||||
|
||||
assertTrue(plotSquaredProbe >= 0);
|
||||
assertTrue(probe > plotSquaredProbe);
|
||||
assertTrue(denial > probe);
|
||||
assertTrue(failClosed > denial);
|
||||
assertTrue(staged > failClosed);
|
||||
assertTrue(duplicateGuard > staged);
|
||||
assertTrue(ownership > duplicateGuard);
|
||||
assertTrue(frozen > ownership);
|
||||
assertTrue(failureCapture > frozen);
|
||||
assertTrue(report > failureCapture);
|
||||
assertTrue(shutdown > report);
|
||||
assertTrue(rethrow > shutdown);
|
||||
|
||||
String shutdownScope = "the fail-fast shutdown must stay scoped to the frozen snapshot of an owned world";
|
||||
assertEquals(shutdownScope, resolverStart + shutdown, source.indexOf("Bukkit.shutdown()"));
|
||||
assertEquals(shutdownScope, resolverStart + shutdown, source.lastIndexOf("Bukkit.shutdown()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void plotSquaredDiscoveryProbeIsIgnoredQuietly() {
|
||||
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class CommandDeveloperGenerationHistoryTest {
|
||||
@Test
|
||||
public void updateWorldStagesThePackWithTheLiveWorldSeed() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java"
|
||||
)).replace("\r\n", "\n");
|
||||
int updateStart = source.indexOf("public void updateWorld(");
|
||||
int updateEnd = source.indexOf("@Director(description = \"Test\"", updateStart);
|
||||
String update = source.substring(updateStart, updateEnd);
|
||||
|
||||
assertTrue(update.contains(
|
||||
"replaceIntoWorld(sender(), pack, folder, world.getSeed())"
|
||||
));
|
||||
assertFalse(update.contains("iris/pack"));
|
||||
}
|
||||
}
|
||||
-24
@@ -7,8 +7,6 @@ import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
@@ -73,26 +71,4 @@ public class CommandIrisCreateOverwriteContractTest {
|
||||
assertEquals(Long.valueOf(Long.MAX_VALUE), handler.parse(Long.toString(Long.MAX_VALUE), false));
|
||||
assertThrows(DirectorParsingException.class, () -> handler.parse("9223372036854775808", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createAndReplaceUseLocalizedStyledMissingDimensionFeedback() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/commands/CommandIris.java"
|
||||
)).replace("\r\n", "\n");
|
||||
|
||||
assertEquals(2, occurrences(source, "COMMAND_IRIS_DIMENSION_NOT_FOUND"));
|
||||
assertEquals(2, occurrences(source, "COMMAND_IRIS_INSTALL_PACK_AND_RESTART"));
|
||||
assertFalse(source.contains("sendMessage(\"Could not find dimension"));
|
||||
assertFalse(source.contains("sendMessage(\"Install its pack with"));
|
||||
}
|
||||
|
||||
private static int occurrences(String value, String match) {
|
||||
int count = 0;
|
||||
int offset = 0;
|
||||
while ((offset = value.indexOf(match, offset)) >= 0) {
|
||||
count++;
|
||||
offset += match.length();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
-17
@@ -5,13 +5,10 @@ import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
@@ -47,20 +44,6 @@ public class CommandIrisDownloadContractTest {
|
||||
assertThrows(Exception.class, () -> handler.parse("custom", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void commandDelegatesAcceptedDownloadsWithoutRawPreamble() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/commands/CommandIris.java"
|
||||
)).replace("\r\n", "\n");
|
||||
String download = method(source, "public void download(");
|
||||
|
||||
assertTrue(download.contains("downloadBuiltIn(sender(), builtInPack)"));
|
||||
assertTrue(download.contains("downloadUrl(sender(), directLink)"));
|
||||
assertFalse(download.contains("Downloading built-in Iris pack"));
|
||||
assertFalse(download.contains("Downloading Iris pack from"));
|
||||
assertFalse(download.contains("sendMessage(directLink"));
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
assertTrue(start >= 0);
|
||||
|
||||
+62
-110
@@ -108,28 +108,13 @@ public class CommandJigsawContractTest {
|
||||
BrokenPackException failure = new BrokenPackException(
|
||||
"overworld", List.of("Biome 'broken' has no resolvable regions."));
|
||||
|
||||
assertTrue(CommandJigsaw.isExpectedOpenDenial(failure));
|
||||
assertTrue(CommandJigsaw.isExpectedOpenDenial(
|
||||
assertTrue(CommandJigsawOpen.isExpectedOpenDenial(failure));
|
||||
assertTrue(CommandJigsawOpen.isExpectedOpenDenial(
|
||||
new CompletionException(failure)));
|
||||
assertFalse(CommandJigsaw.isExpectedOpenDenial(
|
||||
assertFalse(CommandJigsawOpen.isExpectedOpenDenial(
|
||||
new IllegalStateException("Unexpected Studio failure")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void committedActivationStartsInitialEvaluationBeforePlayerBinding() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java")).replace("\r\n", "\n");
|
||||
int commit = source.indexOf("JigsawStudioActivation.commit(staged)");
|
||||
int evaluation = source.indexOf(
|
||||
"studioService.activationCommitted(world, request.requestId())",
|
||||
commit);
|
||||
int binding = source.indexOf("PLAYER_PACKS.put", evaluation);
|
||||
|
||||
assertTrue(commit >= 0);
|
||||
assertTrue(evaluation > commit);
|
||||
assertTrue(binding > evaluation);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void convertIsAddOnlyWorkflowWithAliasesAndDefaults() throws Exception {
|
||||
Method convert = CommandJigsaw.class.getDeclaredMethod(
|
||||
@@ -153,20 +138,20 @@ public class CommandJigsawContractTest {
|
||||
|
||||
@Test
|
||||
public void adoptionCommandsExposeTwoStepPlanContract() throws Exception {
|
||||
Method inspect = CommandJigsaw.CommandJigsawAdopt.class.getDeclaredMethod(
|
||||
Method inspect = CommandJigsawAdopt.class.getDeclaredMethod(
|
||||
"inspect", IrisDimension.class, String.class, String.class, String.class);
|
||||
Method apply = CommandJigsaw.CommandJigsawAdopt.class.getDeclaredMethod("apply", String.class);
|
||||
Method apply = CommandJigsawAdopt.class.getDeclaredMethod("apply", String.class);
|
||||
Parameter[] inspectParameters = inspect.getParameters();
|
||||
|
||||
assertNotNull(inspect.getAnnotation(Director.class));
|
||||
assertNotNull(apply.getAnnotation(Director.class));
|
||||
assertParameter(inspectParameters[2], "auto", null);
|
||||
assertParameter(inspectParameters[3], "auto", CommandJigsaw.JigsawAdoptionStrategyHandler.class);
|
||||
assertEquals(CommandJigsaw.JigsawAdoptionPlanHandler.class,
|
||||
assertParameter(inspectParameters[3], "auto", CommandJigsawAdopt.JigsawAdoptionStrategyHandler.class);
|
||||
assertEquals(CommandJigsawAdopt.JigsawAdoptionPlanHandler.class,
|
||||
apply.getParameters()[0].getAnnotation(Param.class).customHandler());
|
||||
|
||||
CommandJigsaw.JigsawAdoptionStrategyHandler strategyHandler =
|
||||
new CommandJigsaw.JigsawAdoptionStrategyHandler();
|
||||
CommandJigsawAdopt.JigsawAdoptionStrategyHandler strategyHandler =
|
||||
new CommandJigsawAdopt.JigsawAdoptionStrategyHandler();
|
||||
assertEquals(List.of("auto", "in-place", "clone"), strategyHandler.getPossibilities());
|
||||
assertEquals("in-place", strategyHandler.parse("claim", false));
|
||||
assertEquals("clone", strategyHandler.parse("copy", false));
|
||||
@@ -178,14 +163,14 @@ public class CommandJigsawContractTest {
|
||||
Path root = temporaryFolder.newFolder("adoption-provenance").toPath();
|
||||
|
||||
assertEquals(IrisStructureAdoptionInputKind.UNOWNED_IRIS,
|
||||
CommandJigsaw.adoptionInputKind(root, "unowned"));
|
||||
CommandJigsawAdopt.adoptionInputKind(root, "unowned"));
|
||||
writeManifest(root, "datapack-created", StructureSource.Kind.DATAPACK,
|
||||
StructureOwnershipManifest.Provenance.created());
|
||||
assertEquals(IrisStructureAdoptionInputKind.UNOWNED_IRIS,
|
||||
CommandJigsaw.adoptionInputKind(root, "datapack-created"));
|
||||
CommandJigsawAdopt.adoptionInputKind(root, "datapack-created"));
|
||||
writeManifest(root, "managed-provenance", StructureSource.Kind.IRIS, managedProvenance());
|
||||
assertEquals(IrisStructureAdoptionInputKind.MANAGED_DATAPACK,
|
||||
CommandJigsaw.adoptionInputKind(root, "managed-provenance"));
|
||||
CommandJigsawAdopt.adoptionInputKind(root, "managed-provenance"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -290,88 +275,74 @@ public class CommandJigsawContractTest {
|
||||
|
||||
@Test
|
||||
public void exposesNestedPieceVariantAndPreviewCommands() throws Exception {
|
||||
assertNotNull(CommandJigsaw.CommandJigsawPool.class
|
||||
assertNotNull(CommandJigsawPool.class
|
||||
.getDeclaredMethod("create", String.class, String.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawConnector.class
|
||||
assertNotNull(CommandJigsawConnector.class
|
||||
.getDeclaredMethod("channel", String.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
|
||||
assertNotNull(CommandJigsawPiece.class
|
||||
.getDeclaredMethod("create", String.class, String.class, int.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
|
||||
assertNotNull(CommandJigsawPiece.class
|
||||
.getDeclaredMethod("add", String.class, String.class, int.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
|
||||
assertNotNull(CommandJigsawPiece.class
|
||||
.getDeclaredMethod("remove", String.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
|
||||
assertNotNull(CommandJigsawPiece.class
|
||||
.getDeclaredMethod("rotatable", boolean.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
|
||||
assertNotNull(CommandJigsawPiece.class
|
||||
.getDeclaredMethod("expand")
|
||||
.getAnnotation(Director.class));
|
||||
assertEquals("Resize the selected piece object exactly to workcell capacity",
|
||||
CommandJigsaw.CommandJigsawPiece.class
|
||||
CommandJigsawPiece.class
|
||||
.getDeclaredMethod("expand")
|
||||
.getAnnotation(Director.class)
|
||||
.description());
|
||||
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
|
||||
assertNotNull(CommandJigsawVariant.class
|
||||
.getDeclaredMethod("weight", String.class, int.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
|
||||
assertNotNull(CommandJigsawVariant.class
|
||||
.getDeclaredMethod("resize", int.class, int.class, int.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
|
||||
assertNotNull(CommandJigsawVariant.class
|
||||
.getDeclaredMethod("label", String.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
|
||||
assertNotNull(CommandJigsawVariant.class
|
||||
.getDeclaredMethod("labelReset")
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
|
||||
assertNotNull(CommandJigsawVariant.class
|
||||
.getDeclaredMethod("duplicate")
|
||||
.getAnnotation(Director.class));
|
||||
Method duplicateFamily = CommandJigsaw.CommandJigsawVariant.class
|
||||
Method duplicateFamily = CommandJigsawVariant.class
|
||||
.getDeclaredMethod("duplicateFamily", String.class);
|
||||
assertNotNull(duplicateFamily.getAnnotation(Director.class));
|
||||
assertParameter(duplicateFamily.getParameters()[0], "next", null);
|
||||
assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class
|
||||
assertNotNull(CommandJigsawWorkcell.class
|
||||
.getDeclaredMethod("capacity", int.class, int.class, int.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class
|
||||
assertNotNull(CommandJigsawWorkcell.class
|
||||
.getDeclaredMethod("label", String.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class
|
||||
assertNotNull(CommandJigsawWorkcell.class
|
||||
.getDeclaredMethod("labelReset")
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawRules.class
|
||||
assertNotNull(CommandJigsawRules.class
|
||||
.getDeclaredMethod("limits", int.class, int.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawRules.class
|
||||
assertNotNull(CommandJigsawRules.class
|
||||
.getDeclaredMethod("fallback", String.class, String.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawPreview.class
|
||||
assertNotNull(CommandJigsawPreview.class
|
||||
.getDeclaredMethod("assemble", long.class)
|
||||
.getAnnotation(Director.class));
|
||||
assertNotNull(CommandJigsaw.CommandJigsawPreview.class
|
||||
assertNotNull(CommandJigsawPreview.class
|
||||
.getDeclaredMethod("gotoPreview")
|
||||
.getAnnotation(Director.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pieceCreateUsesResolvedWorkcellCapacityInsteadOfLayoutDefault() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java")).replace("\r\n", "\n");
|
||||
int pieceCommands = source.indexOf("public static class CommandJigsawPiece");
|
||||
int create = source.indexOf("public void create(", pieceCommands);
|
||||
int add = source.indexOf("public void add(", create);
|
||||
String createSource = source.substring(create, add);
|
||||
|
||||
assertTrue(createSource.contains("targetWorkcell = contextual;"));
|
||||
assertTrue(createSource.contains("JigsawStudioCellDimensions dimensions = targetWorkcell.capacity();"));
|
||||
assertFalse(createSource.contains("layout().cellDimensions()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pieceAddUsesCanonicalAxesForRotatedRectangularPlanarWorkcells() {
|
||||
JigsawStudioLayout layout = nonuniformPlanarLayout();
|
||||
@@ -422,14 +393,14 @@ public class CommandJigsawContractTest {
|
||||
public void everyExecutableCommandRejectsNonPlayerOrigins() {
|
||||
Class<?>[] commandTypes = {
|
||||
CommandJigsaw.class,
|
||||
CommandJigsaw.CommandJigsawConnector.class,
|
||||
CommandJigsaw.CommandJigsawPool.class,
|
||||
CommandJigsaw.CommandJigsawPiece.class,
|
||||
CommandJigsaw.CommandJigsawVariant.class,
|
||||
CommandJigsaw.CommandJigsawWorkcell.class,
|
||||
CommandJigsaw.CommandJigsawRules.class,
|
||||
CommandJigsaw.CommandJigsawPreview.class,
|
||||
CommandJigsaw.CommandJigsawAdopt.class
|
||||
CommandJigsawConnector.class,
|
||||
CommandJigsawPool.class,
|
||||
CommandJigsawPiece.class,
|
||||
CommandJigsawVariant.class,
|
||||
CommandJigsawWorkcell.class,
|
||||
CommandJigsawRules.class,
|
||||
CommandJigsawPreview.class,
|
||||
CommandJigsawAdopt.class
|
||||
};
|
||||
|
||||
for (Class<?> commandType : commandTypes) {
|
||||
@@ -446,17 +417,17 @@ public class CommandJigsawContractTest {
|
||||
public void exportOutputIsOneSafeChildArtifact() {
|
||||
Path root = Path.of("build", "jigsaw-exports").toAbsolutePath().normalize();
|
||||
|
||||
assertEquals(root.resolve("village.zip"), CommandJigsaw.resolveExportDestination(
|
||||
assertEquals(root.resolve("village.zip"), CommandJigsawExports.resolveExportDestination(
|
||||
root, "village", VanillaJigsawExportFormat.ZIP));
|
||||
assertEquals(root.resolve("village"), CommandJigsaw.resolveExportDestination(
|
||||
assertEquals(root.resolve("village"), CommandJigsawExports.resolveExportDestination(
|
||||
root, "village", VanillaJigsawExportFormat.DIRECTORY));
|
||||
assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination(
|
||||
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
|
||||
root, "", VanillaJigsawExportFormat.DIRECTORY));
|
||||
assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination(
|
||||
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
|
||||
root, ".", VanillaJigsawExportFormat.DIRECTORY));
|
||||
assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination(
|
||||
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
|
||||
root, "../all-exports", VanillaJigsawExportFormat.DIRECTORY));
|
||||
assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination(
|
||||
assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination(
|
||||
root, "nested/export", VanillaJigsawExportFormat.DIRECTORY));
|
||||
}
|
||||
|
||||
@@ -467,42 +438,42 @@ public class CommandJigsawContractTest {
|
||||
Path firstDestination = Path.of("build", "jigsaw-exports", "first.zip");
|
||||
Path secondDestination = Path.of("build", "jigsaw-exports", "second.zip");
|
||||
|
||||
assertEquals(true, CommandJigsaw.beginExport(firstPlayer, firstDestination));
|
||||
assertEquals(true, CommandJigsawExports.beginExport(firstPlayer, firstDestination));
|
||||
try {
|
||||
assertEquals(false, CommandJigsaw.beginExport(firstPlayer, secondDestination));
|
||||
assertEquals(false, CommandJigsaw.beginExport(secondPlayer, firstDestination));
|
||||
assertEquals(false, CommandJigsawExports.beginExport(firstPlayer, secondDestination));
|
||||
assertEquals(false, CommandJigsawExports.beginExport(secondPlayer, firstDestination));
|
||||
} finally {
|
||||
CommandJigsaw.finishExport(firstPlayer, firstDestination);
|
||||
CommandJigsawExports.finishExport(firstPlayer, firstDestination);
|
||||
}
|
||||
|
||||
assertEquals(true, CommandJigsaw.beginExport(secondPlayer, firstDestination));
|
||||
CommandJigsaw.finishExport(secondPlayer, firstDestination);
|
||||
assertEquals(true, CommandJigsawExports.beginExport(secondPlayer, firstDestination));
|
||||
CommandJigsawExports.finishExport(secondPlayer, firstDestination);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exportStartFailuresHavePreciseOperatorMessages() {
|
||||
assertEquals("The active Jigsaw Studio is no longer available.",
|
||||
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.NOT_ACTIVE));
|
||||
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.NOT_ACTIVE));
|
||||
assertEquals("Only the Jigsaw Studio owner can export this project.",
|
||||
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.NOT_OWNER));
|
||||
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.NOT_OWNER));
|
||||
assertEquals("Wait for the pending autosave or discard the edits before exporting the on-disk graph.",
|
||||
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.DIRTY));
|
||||
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.DIRTY));
|
||||
assertEquals("The active Jigsaw Studio is closing and cannot be exported.",
|
||||
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.CLOSING));
|
||||
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.CLOSING));
|
||||
assertEquals("Wait for the current Jigsaw Studio save to finish before exporting.",
|
||||
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.SAVE_IN_PROGRESS));
|
||||
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.SAVE_IN_PROGRESS));
|
||||
assertEquals("Wait for the current Jigsaw Studio operation to finish before exporting.",
|
||||
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.OPERATION_IN_PROGRESS));
|
||||
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.OPERATION_IN_PROGRESS));
|
||||
assertEquals("A Jigsaw Studio export is already in progress.",
|
||||
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.IN_PROGRESS));
|
||||
CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.IN_PROGRESS));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.STARTED));
|
||||
() -> CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.STARTED));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exportLeaseReleaseActionRunsExactlyOnce() {
|
||||
AtomicInteger releases = new AtomicInteger();
|
||||
CommandJigsaw.ExportLease lease = new CommandJigsaw.ExportLease(releases::incrementAndGet);
|
||||
CommandJigsawExports.ExportLease lease = new CommandJigsawExports.ExportLease(releases::incrementAndGet);
|
||||
|
||||
lease.release();
|
||||
lease.release();
|
||||
@@ -510,25 +481,6 @@ public class CommandJigsawContractTest {
|
||||
assertEquals(1, releases.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exportSourceAcquiresStudioLeaseBeforeStaticLeaseAndReleasesBothPaths() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java")).replace("\r\n", "\n");
|
||||
int serviceLease = source.indexOf("studioService.tryBeginExport(requestId, playerId)");
|
||||
int staticLease = source.indexOf("beginExport(playerId, destination)", serviceLease);
|
||||
int dispatch = source.indexOf("J.a(() -> runExport(operation))", staticLease);
|
||||
int schedulingRelease = source.indexOf("operation.lease().release()", dispatch);
|
||||
int exporter = source.indexOf("new VanillaJigsawDatapackExporter().export(operation.request())", dispatch);
|
||||
int completionRelease = source.indexOf("operation.lease().release()", exporter);
|
||||
|
||||
assertTrue(serviceLease >= 0);
|
||||
assertTrue(staticLease > serviceLease);
|
||||
assertTrue(dispatch > staticLease);
|
||||
assertTrue(schedulingRelease > dispatch);
|
||||
assertTrue(exporter > schedulingRelease);
|
||||
assertTrue(completionRelease > exporter);
|
||||
}
|
||||
|
||||
private static void assertCommand(String name, Class<?>... parameterTypes) throws Exception {
|
||||
Method method = CommandJigsaw.class.getDeclaredMethod(name, parameterTypes);
|
||||
assertNotNull(method.getAnnotation(Director.class));
|
||||
|
||||
+4
-20
@@ -7,10 +7,8 @@ import art.arcane.iris.core.pack.PackValidationResult;
|
||||
import art.arcane.volmlib.util.director.compat.DirectorAnnotationCompatibility;
|
||||
import art.arcane.volmlib.util.director.runtime.DirectorNodeDescriptor;
|
||||
import art.arcane.volmlib.util.director.runtime.DirectorParameterDescriptor;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import art.arcane.iris.testsupport.PlatformLeakGuard;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
@@ -20,22 +18,8 @@ import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CommandPackCompatTest {
|
||||
private IrisPlatform previousPlatform;
|
||||
|
||||
/** The persisted Minecraft version is only used when no platform is bound, so pin that here. */
|
||||
@Before
|
||||
public void unbindPlatform() {
|
||||
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
|
||||
@After
|
||||
public void rebindPlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
if (previousPlatform != null) {
|
||||
IrisPlatforms.bind(previousPlatform);
|
||||
}
|
||||
}
|
||||
@Rule
|
||||
public final PlatformLeakGuard leakGuard = PlatformLeakGuard.clean();
|
||||
|
||||
private static CompatFinding block(CompatAction action, String subjectKey) {
|
||||
return new CompatFinding(
|
||||
|
||||
-16
@@ -7,8 +7,6 @@ import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.World;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
@@ -212,20 +210,6 @@ public class IrisPapiExpansionTest {
|
||||
assertTrue(expansion.persist());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theReadmeDocumentsEveryPublishedKeyAndEveryRetiredOne() throws Exception {
|
||||
String readme = Files.readString(Path.of(System.getProperty("iris.readmeSource")));
|
||||
|
||||
for (String key : PUBLISHED_KEYS) {
|
||||
assertTrue("README must document %iris_" + key + "%", readme.contains("%iris_" + key + "%"));
|
||||
}
|
||||
|
||||
for (String retired : RETIRED_KEYS) {
|
||||
assertTrue("README must carry the migration row for %iris_" + retired + "%",
|
||||
readme.contains("%iris_" + retired + "%"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theTerrainServiceIsTheOnlyDoorIntoIris() {
|
||||
IrisTerrainService service = new FakeIrisTerrainService();
|
||||
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
package art.arcane.iris.core.link;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisPapiLifecycleTest {
|
||||
private static final Path PLUGIN_SOURCE = Path.of("src/main/java/art/arcane/iris/Iris.java");
|
||||
private static final String SETUP = "private void setupPapi() {";
|
||||
private static final String TEARDOWN = "private void teardownPapi() {";
|
||||
|
||||
private static String source() throws Exception {
|
||||
// The assertions below match against literals written with \n, so a CRLF checkout has to
|
||||
// be folded first or every multi-line expectation misses.
|
||||
return Files.readString(PLUGIN_SOURCE).replace("\r\n", "\n");
|
||||
}
|
||||
|
||||
private static String body(String declaration) throws Exception {
|
||||
String source = source();
|
||||
int start = source.indexOf(declaration);
|
||||
|
||||
assertTrue("Iris.java must declare " + declaration, start >= 0);
|
||||
|
||||
int open = source.indexOf('{', start);
|
||||
int depth = 0;
|
||||
|
||||
for (int index = open; index < source.length(); index++) {
|
||||
char character = source.charAt(index);
|
||||
|
||||
if (character == '{') {
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character != '}') {
|
||||
continue;
|
||||
}
|
||||
|
||||
depth--;
|
||||
|
||||
if (depth == 0) {
|
||||
return source.substring(open + 1, index);
|
||||
}
|
||||
}
|
||||
|
||||
throw new AssertionError(declaration + " is not brace balanced");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registrationIsGatedOnPlaceholderApiBeingEnabled() throws Exception {
|
||||
assertTrue("setupPapi must bail out when PlaceholderAPI is absent",
|
||||
body(SETUP).contains("if (!PlaceholderRegistration.isPlaceholderApiEnabled()) {"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theRegistrationTheListenerAndTheStateAreAllRetained() throws Exception {
|
||||
String source = source();
|
||||
|
||||
assertTrue(source.contains("private volatile PlaceholderRegistration papiRegistration;"));
|
||||
assertTrue(source.contains("private volatile IrisPapiListener papiListener;"));
|
||||
assertTrue(source.contains("private volatile IrisPapiState papiState;"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void teardownUnregistersTheExpansionTheListenerAndClearsTheState() throws Exception {
|
||||
String teardown = body(TEARDOWN);
|
||||
|
||||
assertTrue("the retained registration must be unregistered inside teardownPapi",
|
||||
teardown.contains("registration.unregister();"));
|
||||
assertTrue("the retained listener must be detached inside teardownPapi",
|
||||
teardown.contains("HandlerList.unregisterAll(listener);"));
|
||||
assertTrue("the retained state must drop every held world reference inside teardownPapi",
|
||||
teardown.contains("state.clear();"));
|
||||
assertTrue("teardownPapi must drop the retained listener", teardown.contains("papiListener = null;"));
|
||||
assertTrue("teardownPapi must drop the retained registration", teardown.contains("papiRegistration = null;"));
|
||||
assertTrue("teardownPapi must drop the retained state", teardown.contains("papiState = null;"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bothDisablePathsTearThePlaceholderSurfaceDown() throws Exception {
|
||||
String disable = body("public void onDisable() {");
|
||||
String preUnload = body("public void onPreUnload(ReloadAware.PreUnloadReason reason) {");
|
||||
int disableTeardown = disable.indexOf("teardownPapi();");
|
||||
int preUnloadTeardown = preUnload.indexOf("teardownPapi();");
|
||||
|
||||
assertTrue("onDisable must tear the expansion down before branching into runtime shutdown",
|
||||
disableTeardown >= 0 && disableTeardown < disable.indexOf("IrisToolbelt.isServerStopping()"));
|
||||
assertTrue("the BileTools pre-unload hook must tear the expansion down before any shutdown branch",
|
||||
preUnloadTeardown >= 0 && preUnloadTeardown < preUnload.indexOf("IrisToolbelt.isServerStopping()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aFailedListenerAttachDoesNotLeaveTheExpansionRegistered() throws Exception {
|
||||
String setup = body(SETUP);
|
||||
int attach = setup.indexOf("registerEvents(listener, this)");
|
||||
|
||||
assertTrue("the listener must be attached inside setupPapi", attach >= 0);
|
||||
|
||||
int rescue = setup.indexOf("} catch (Throwable failure) {", attach);
|
||||
|
||||
assertTrue("the attach must be guarded inside setupPapi", rescue > attach);
|
||||
|
||||
int rollback = setup.indexOf("registration.unregister();", rescue);
|
||||
|
||||
assertTrue("a failed attach must roll the registration back inside setupPapi", rollback > rescue);
|
||||
|
||||
int bail = setup.indexOf("return;", rollback);
|
||||
|
||||
assertTrue("a failed attach must leave setupPapi", bail > rollback);
|
||||
|
||||
int retained = setup.indexOf("papiRegistration = registration;");
|
||||
|
||||
assertTrue("setupPapi must retain the registration", retained > 0);
|
||||
assertTrue("the registration may only be retained after a successful attach", retained > bail);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theExpansionIsNeverConstructedFromAPluginStatic() throws Exception {
|
||||
String setup = body(SETUP);
|
||||
|
||||
assertFalse("the plugin class must not construct the expansion: that forces PlaceholderExpansion to load during enable and crashes a server without PlaceholderAPI",
|
||||
setup.contains("new IrisPapiExpansion("));
|
||||
assertTrue("the expansion takes its state as a constructor argument, built inside the installer",
|
||||
installerSource().contains("new IrisPapiExpansion(state, logger)"));
|
||||
assertFalse("the hand rolled PlaceholderAPI presence check must be gone",
|
||||
source().contains("isPluginEnabled(\"PlaceholderAPI\")"));
|
||||
}
|
||||
|
||||
private static String installerSource() throws Exception {
|
||||
return java.nio.file.Files.readString(java.nio.file.Path.of(
|
||||
"src/main/java/art/arcane/iris/core/link/IrisPapiInstaller.java")).replace("\r\n", "\n");
|
||||
}
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
package art.arcane.iris.core.runtime;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class StudioPlayerModeContractTest {
|
||||
@Test
|
||||
public void autoStartedStudioUsesSpectatorMode() throws IOException {
|
||||
String plugin = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")).replace("\r\n", "\n");
|
||||
String commands = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/commands/CommandStudio.java")).replace("\r\n", "\n");
|
||||
|
||||
assertFalse(plugin.contains("GameMode.CREATIVE"));
|
||||
assertFalse(commands.contains("GameMode.CREATIVE"));
|
||||
assertTrue(plugin.contains("GameMode.SPECTATOR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tpStudioUsesThePreparedCoordinatorEntry() throws IOException {
|
||||
String commands = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/commands/CommandStudio.java")).replace("\r\n", "\n");
|
||||
int methodStart = commands.indexOf("public void tpstudio()");
|
||||
int methodEnd = commands.indexOf("\n @Director", methodStart);
|
||||
String method = commands.substring(methodStart, methodEnd);
|
||||
|
||||
assertTrue(method.contains("StudioSVC studioService = Iris.service(StudioSVC.class)"));
|
||||
assertTrue(method.contains("studioService.teleportToActiveProject(player)"));
|
||||
assertFalse(method.contains("setGameMode("));
|
||||
assertFalse(method.contains("getActiveProject()"));
|
||||
assertFalse(method.contains("BukkitPlatform.teleportAsync"));
|
||||
assertFalse(method.contains("BukkitWorldBinding.spawnLocation"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void editingStudiosRemainCreative() throws IOException {
|
||||
String objectCommands = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/commands/CommandObject.java")).replace("\r\n", "\n");
|
||||
String jigsawCommands = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java")).replace("\r\n", "\n");
|
||||
|
||||
assertTrue(objectCommands.contains("GameMode.CREATIVE"));
|
||||
assertTrue(objectCommands.contains("StudioOpenCoordinator.StudioOpenKind.OBJECT"));
|
||||
assertTrue(jigsawCommands.contains("GameMode.CREATIVE"));
|
||||
assertFalse(objectCommands.contains("GameMode.SPECTATOR"));
|
||||
assertFalse(jigsawCommands.contains("GameMode.SPECTATOR"));
|
||||
}
|
||||
}
|
||||
-195
@@ -1,195 +0,0 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisApiWiringContractTest {
|
||||
@Test
|
||||
public void theTerrainServiceRegistersOnBothRegistriesAndReleasesBoth() throws IOException {
|
||||
String source = source("iris.terrainSvcSource");
|
||||
String onEnable = method(source, "public void onEnable()");
|
||||
String onDisable = method(source, "public void onDisable()");
|
||||
|
||||
assertTrue(onEnable.contains("Bukkit.getServicesManager().register("));
|
||||
assertTrue(onEnable.contains("IrisTerrainService.class"));
|
||||
assertTrue(onEnable.contains("IrisServices.register(IrisTerrainService.class, this)"));
|
||||
assertTrue(onDisable.contains("Bukkit.getServicesManager().unregister(IrisTerrainService.class, this)"));
|
||||
assertTrue(onDisable.contains("IrisServices.remove(IrisTerrainService.class)"));
|
||||
assertBefore(onDisable, "serviceEnabled.set(false)", "Bukkit.getServicesManager().unregister(");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theTerrainServiceNeverForcesEngineInitialisationOrLoadsTheMantle() throws IOException {
|
||||
String source = source("iris.terrainSvcSource");
|
||||
|
||||
assertFalse("isIrisWorld must not touch the generator", source.contains("IrisToolbelt"));
|
||||
assertFalse("isIrisWorld must not touch the generator", source.contains(".touch("));
|
||||
assertFalse("terrain queries must not load mantle chunks", source.contains("getMantle()"));
|
||||
assertFalse("terrain queries must not load mantle chunks", source.contains("getObjectsAt("));
|
||||
assertFalse("terrain queries must not load mantle chunks", source.contains("getPOIsAt("));
|
||||
assertFalse("terrain queries must not load mantle chunks", source.contains("getCaveOrMantleBiome("));
|
||||
assertFalse("terrain queries must never block", source.contains(".join()"));
|
||||
assertFalse("terrain queries must never block", source.contains("synchronized"));
|
||||
assertFalse("terrain queries must never block", source.contains("J.sfut("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sampleColumnsBoundsTheQueryBeforeItWalksAndTreatsAThrowingSinkAsARefusal() throws IOException {
|
||||
String sampleColumns = method(source("iris.terrainSvcSource"),
|
||||
"public boolean sampleColumns(World world, IrisColumnQuery query, IrisColumnSink sink)");
|
||||
|
||||
assertBefore(sampleColumns, "IrisSampleLimits.withinLimits(", "IrisColumnWalk.walk(");
|
||||
assertBefore(sampleColumns, "IrisColumnWalk.walk(", "catch (Throwable error)");
|
||||
assertTrue(sampleColumns.contains("reportSinkFault(world, error)"));
|
||||
assertTrue(sampleColumns.contains("return false;"));
|
||||
assertTrue("the walk must abort when the engine closes underneath it",
|
||||
sampleColumns.contains("engine.isClosed()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void thePregeneratorDispatchesPhasesFromItsExistingTickAndNeverBlocksOnTheSink() throws IOException {
|
||||
String source = source("iris.pregeneratorJobSource");
|
||||
String dispatch = method(source, "private void dispatchApiPhases(List<PregenApiPhase> phases)");
|
||||
|
||||
assertTrue(dispatch.contains("IrisServices.getOrNull(PregenApiSink.class)"));
|
||||
assertTrue(dispatch.contains("catch (Throwable error)"));
|
||||
assertBefore(dispatch, "sink.pregen(phase, progress)", "catch (Throwable error)");
|
||||
assertFalse(dispatch.contains(".join()"));
|
||||
|
||||
assertTrue(method(source, "public void onTick(").contains("dispatchApiPhases(apiPhases.onTick(paused()))"));
|
||||
assertTrue(method(source, "public void onSaving()").contains("dispatchApiPhases(apiPhases.onSaving())"));
|
||||
assertTrue(method(source, "public void onClose()").contains("dispatchApiPhases(apiPhases.onClose(reachedTotal()))"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void worldPhasesAreFiredOutsideTheRegistrationLock() throws IOException {
|
||||
String source = source("iris.engineSvcSource");
|
||||
|
||||
String add = method(source, "private void add(World world)");
|
||||
assertBefore(add, "catch (RejectedExecutionException exception)", "phases.ready(world)");
|
||||
assertBefore(add, "registered = true;", "phases.ready(world)");
|
||||
|
||||
String remove = method(source,
|
||||
"private boolean remove(World world, CompletionStage<Boolean> unloadBoundary)");
|
||||
assertBefore(remove, "registered = worlds.remove(world)", "phases.closing(world)");
|
||||
assertBefore(remove, "phases.closing(world)",
|
||||
"deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary)");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyRegisteredWorldIsAnnouncedClosingWhenTheServiceShutsDown() throws IOException {
|
||||
String onDisable = method(source("iris.engineSvcSource"), "public void onDisable()");
|
||||
|
||||
assertTrue("service shutdown must announce ENGINE_CLOSING for every registered world",
|
||||
onDisable.contains("phases.closing(teardown.world())"));
|
||||
assertBefore(onDisable, "worlds.clear()", "phases.closing(teardown.world())");
|
||||
assertBefore(onDisable, "phases.closing(teardown.world())", "shutdownAndDrain(activeService)");
|
||||
assertBefore(onDisable, "phases.closing(teardown.world())",
|
||||
"startClose(teardown.registered(), teardown.closing())");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aReplacedEngineIsAnnouncedClosingBeforeTheRetryReRegistersTheWorld() throws IOException {
|
||||
String add = method(source("iris.engineSvcSource"), "private void add(World world)");
|
||||
|
||||
assertBefore(add, "catch (RejectedExecutionException exception)", "phases.closing(world)");
|
||||
assertBefore(add, "phases.closing(world)", "retryRegistrationAfterClose(world, retryAfter)");
|
||||
assertBefore(add, "phases.closing(world)", "startClose(replaced, replacementClose)");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aWorldPhaseIsNeverConditionalOnAnotherServicesRegistration() throws IOException {
|
||||
String source = source("iris.apiEventSvcSource");
|
||||
String fire = method(source, "public static void fireWorldPhase(World world, IrisWorldPhase phase)");
|
||||
|
||||
assertFalse("world lifecycle must not resolve a swappable service to build its payload",
|
||||
fire.contains("IrisServices"));
|
||||
assertFalse("world lifecycle must not resolve a swappable service to build its payload",
|
||||
fire.contains("IrisTerrainService"));
|
||||
assertTrue(fire.contains("deliver(new IrisWorldEngineEvent(world, phase, describe(world, phase)))"));
|
||||
|
||||
String describe = method(source, "private static IrisWorldInfo describe(World world, IrisWorldPhase phase)");
|
||||
assertTrue(describe.contains("IrisWorldInfoFactory.forWorld(world)"));
|
||||
assertTrue("an undescribable world must be reported, not swallowed",
|
||||
describe.contains("IrisLogging.reportError("));
|
||||
assertTrue("an undescribable world must still deliver the phase", describe.contains("return null;"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aWorldPhaseRaisedFromAServerThreadIsDeliveredBeforeThatThreadMovesOn() throws IOException {
|
||||
String deliver = method(source("iris.apiEventSvcSource"), "private static void deliver(Event event)");
|
||||
|
||||
assertBefore(deliver, "Bukkit.isPrimaryThread()", "Bukkit.getPluginManager().callEvent(event)");
|
||||
assertBefore(deliver, "Bukkit.getPluginManager().callEvent(event)", "Iris.callEvent(event)");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theWorldInfoFactoryNeverForcesEngineInitialisationOrLoadsTheMantle() throws IOException {
|
||||
String source = source("iris.worldInfoFactorySource");
|
||||
|
||||
assertFalse("the factory must not touch the generator", source.contains("IrisToolbelt"));
|
||||
assertFalse("the factory must not touch the generator", source.contains(".touch("));
|
||||
assertFalse("the factory must not load mantle chunks", source.contains("getMantle()"));
|
||||
assertFalse("the factory must never block", source.contains(".join()"));
|
||||
assertFalse("the factory must never block", source.contains("synchronized"));
|
||||
assertTrue("the factory must refuse a closed engine", source.contains("engine.isClosed()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theHotloadHookStillFiresTheLegacyEventAlongsideTheApiEvent() throws IOException {
|
||||
String hook = method(source("iris.bukkitEnginePlatformHooksSource"), "public void fireHotloadEvent(Engine engine)");
|
||||
|
||||
assertBefore(hook, "new IrisEngineHotloadEvent(engine)",
|
||||
"IrisApiEventSVC.fireWorldPhase(BukkitWorldBinding.world(engine.getWorld()), IrisWorldPhase.ENGINE_HOTLOADED)");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void theEventServiceNeverLetsAThirdPartyFailureEscapeALifecyclePath() throws IOException {
|
||||
String fire = method(source("iris.apiEventSvcSource"),
|
||||
"public static void fireWorldPhase(World world, IrisWorldPhase phase)");
|
||||
|
||||
assertTrue(fire.contains("catch (Throwable error)"));
|
||||
assertTrue(fire.contains("IrisLogging.reportError("));
|
||||
assertFalse(fire.contains("printStackTrace"));
|
||||
}
|
||||
|
||||
private static String source(String property) throws IOException {
|
||||
String path = System.getProperty(property);
|
||||
assertTrue("missing source property " + property, path != null && !path.isBlank());
|
||||
return Files.readString(Path.of(path));
|
||||
}
|
||||
|
||||
private static void assertBefore(String source, String first, String second) {
|
||||
int firstIndex = source.indexOf(first);
|
||||
int secondIndex = source.indexOf(second);
|
||||
assertTrue("Missing source contract token: " + first, firstIndex >= 0);
|
||||
assertTrue("Missing source contract token: " + second, secondIndex >= 0);
|
||||
assertTrue(first + " must occur before " + second, firstIndex < secondIndex);
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
assertTrue("Missing source contract signature: " + signature, start >= 0);
|
||||
int openBrace = source.indexOf('{', start);
|
||||
assertTrue("Missing source contract method body: " + signature, openBrace >= 0);
|
||||
int depth = 0;
|
||||
for (int index = openBrace; index < source.length(); index++) {
|
||||
char current = source.charAt(index);
|
||||
if (current == '{') {
|
||||
depth++;
|
||||
} else if (current == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return source.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unclosed source contract method: " + signature);
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,7 @@ dependencies {
|
||||
implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}")
|
||||
testImplementation('junit:junit:4.13.2')
|
||||
testImplementation('org.mockito:mockito-core:5.23.0')
|
||||
testImplementation(testFixtures('art.arcane:core'))
|
||||
testImplementation(libs.spigot) {
|
||||
transitive = false
|
||||
}
|
||||
@@ -207,7 +208,6 @@ tasks.named('compileJava', JavaCompile).configure {
|
||||
}
|
||||
|
||||
tasks.named('test').configure {
|
||||
systemProperty('iris.moddedCommonSources', file('../modded-common/src/main/java').absolutePath)
|
||||
}
|
||||
|
||||
loom {
|
||||
|
||||
@@ -158,6 +158,7 @@ dependencies {
|
||||
implementation minecraft.dependency("net.minecraftforge:forge:${forgeVersion}")
|
||||
testImplementation('junit:junit:4.13.2')
|
||||
testImplementation('org.mockito:mockito-core:5.23.0')
|
||||
testImplementation(testFixtures('art.arcane:core'))
|
||||
testImplementation(libs.spigot) {
|
||||
transitive = false
|
||||
}
|
||||
@@ -240,7 +241,6 @@ tasks.named('compileJava', JavaCompile).configure {
|
||||
}
|
||||
|
||||
tasks.named('test').configure {
|
||||
systemProperty('iris.moddedCommonSources', file('../modded-common/src/main/java').absolutePath)
|
||||
}
|
||||
|
||||
processResources {
|
||||
|
||||
+3
-9
@@ -73,15 +73,9 @@ public class IrisModLanguageAssetsTest {
|
||||
|
||||
private Set<String> resourceFiles() throws Exception {
|
||||
URL resource = IrisModLanguageAssetsTest.class.getClassLoader().getResource(ROOT);
|
||||
Path directory;
|
||||
if (resource != null && "file".equals(resource.getProtocol())) {
|
||||
directory = Path.of(resource.toURI());
|
||||
} else {
|
||||
String sources = System.getProperty("iris.moddedCommonSources");
|
||||
assertNotNull("Missing mod language resource directory and source root", sources);
|
||||
directory = Path.of(sources).getParent()
|
||||
.resolve("resources").resolve(ROOT);
|
||||
}
|
||||
assertNotNull("Missing mod language resource directory: " + ROOT, resource);
|
||||
assertEquals("Mod language resources must resolve to a directory on disk", "file", resource.getProtocol());
|
||||
Path directory = Path.of(resource.toURI());
|
||||
try (Stream<Path> paths = Files.list(directory)) {
|
||||
return paths
|
||||
.filter(Files::isRegularFile)
|
||||
|
||||
-21
@@ -9,13 +9,9 @@ import net.minecraft.world.level.biome.MobSpawnSettings;
|
||||
import org.junit.Test;
|
||||
import org.junit.BeforeClass;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisModdedChunkGeneratorSpawnTest {
|
||||
@BeforeClass
|
||||
@@ -48,21 +44,4 @@ public class IrisModdedChunkGeneratorSpawnTest {
|
||||
assertEquals(explicitCow, entries.get(2).value());
|
||||
assertEquals(3, entries.get(2).weight());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void vanillaChunkGenerationMobsUseTheVisibleBiomesVanillaDerivative() throws IOException {
|
||||
Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/IrisModdedChunkGenerator.java");
|
||||
String source = Files.readString(sourcePath);
|
||||
int spawnStart = source.indexOf("public void spawnOriginalMobs");
|
||||
int spawnEnd = source.indexOf("@Override", spawnStart + 1);
|
||||
String spawn = source.substring(spawnStart, spawnEnd);
|
||||
|
||||
assertTrue(spawn.contains("spawnTables.initializeVanillaSpawnBiomes(registry)"));
|
||||
assertTrue(spawn.contains("structureBiomeSource.getVisibleSurfaceBiome("));
|
||||
assertTrue(spawn.contains("spawnTables.vanillaSpawnBiome(visibleBiome.value())"));
|
||||
assertTrue(spawn.contains("NaturalSpawner.spawnMobsForChunkGeneration("));
|
||||
assertTrue(spawn.contains("new LegacyRandomSource(RandomSupport.generateUniqueSeed())"));
|
||||
assertTrue(spawn.contains("random.setDecorationSeed(region.getSeed()"));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-153
@@ -14,9 +14,6 @@ import net.minecraft.server.Bootstrap;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
@@ -64,16 +61,10 @@ public class IrisModdedStructureParityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void strongholdRingSearchSamplesOneQuartColumnPerChunk() throws IOException {
|
||||
public void strongholdRingSearchSamplesOneQuartColumnPerChunk() {
|
||||
assertEquals(4, IrisModdedBiomeSource.horizontalBiomeSearchQuartStep(0, 112));
|
||||
assertEquals(1, IrisModdedBiomeSource.horizontalBiomeSearchQuartStep(1, 112));
|
||||
assertEquals(1, IrisModdedBiomeSource.horizontalBiomeSearchQuartStep(0, 111));
|
||||
String source = Files.readString(Path.of(
|
||||
System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/IrisModdedBiomeSource.java"));
|
||||
assertTrue(source.contains("x, y, z, searchRadius, quartStep, allowed, random, false, sampler"));
|
||||
assertTrue(source.contains(
|
||||
"return super.findBiomeHorizontal(x, y, z, searchRadius, allowed, random, sampler)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -130,126 +121,12 @@ public class IrisModdedStructureParityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void visibleBiomeResolutionSelectsStackOwnershipBeforeHostCaves() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/IrisModdedBiomeSource.java"));
|
||||
int resolutionStart = source.indexOf("private BiomeResolution resolveBiomeResolution(");
|
||||
int resolutionEnd = source.indexOf("private Holder<Biome> resolveRequiredStructureBiome(", resolutionStart);
|
||||
|
||||
assertTrue(resolutionStart >= 0);
|
||||
assertTrue(resolutionEnd > resolutionStart);
|
||||
String resolution = source.substring(resolutionStart, resolutionEnd);
|
||||
|
||||
assertTrue(resolution.contains("resolveDimensionStackLayer("));
|
||||
assertTrue(resolution.contains("!stackLayer.terrainContext().isSelfReferencing()"));
|
||||
assertTrue(resolution.indexOf("resolveDimensionStackLayer(")
|
||||
< resolution.indexOf("boolean underground"));
|
||||
assertTrue(resolution.contains("includeDimensionStack"));
|
||||
assertTrue(resolution.contains(
|
||||
"return resolveBiomeResolution(engine, quartX, quartY, quartZ, false)"));
|
||||
assertTrue(source.contains("resolution.packName(), resolution.dimension(), customBiome.getId()"));
|
||||
int helperStart = source.indexOf("private static IrisBiome resolveSurfaceStructureBiome(");
|
||||
int helperEnd = source.indexOf("private static Set<String> registeredBiomeKeys(", helperStart);
|
||||
String helper = source.substring(helperStart, helperEnd);
|
||||
assertTrue(helper.contains("engine.getComplex().getTrueBiomeStream().get(blockX, blockZ)"));
|
||||
assertFalse(helper.contains("getDimensionStackContext()"));
|
||||
assertFalse(helper.contains("getSurfaceBiome("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stackBiomesAreVisibleButExcludedFromStructureReachability() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/IrisModdedBiomeSource.java"));
|
||||
|
||||
assertTrue(source.contains("return biomeKeySets().structureRequired()"));
|
||||
assertTrue(source.contains("generatedBiomeKeys.addAll(keys.visibleRequired())"));
|
||||
assertTrue(source.contains("collectStructureBiomeKeys("));
|
||||
assertTrue(source.contains("dimension.getReachableBiomes(() -> data)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void biomeLocatorSearchesTheVisibleStack() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/IrisModdedBiomeSource.java"));
|
||||
int start = source.indexOf("public Pair<BlockPos, Holder<Biome>> findClosestBiome3d(");
|
||||
int end = source.indexOf("private Holder<Biome> getNoiseBiome(", start);
|
||||
String method = source.substring(start, end);
|
||||
|
||||
assertTrue(method.contains("modded_locate_visible_biome"));
|
||||
assertTrue(method.contains("return super.findClosestBiome3d("));
|
||||
assertTrue(method.contains("collectVisibleBiomeHolders()"));
|
||||
assertTrue(method.contains("QuartPos.toBlock(quartX), QuartPos.toBlock(quartZ)"));
|
||||
assertTrue(method.contains("resolveVisibleBiome("));
|
||||
assertFalse(method.contains("Holder<Biome> structureBiome = getNoiseBiome("));
|
||||
assertTrue(method.indexOf("tryAcquireGenerationLease(engine, \"modded_locate_visible_biome\")")
|
||||
< method.indexOf("engine.getDimensionStackContext()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void surfaceSpawnBiomeUsesTheVisibleQuartCell() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/IrisModdedBiomeSource.java"));
|
||||
int start = source.indexOf("Holder<Biome> getVisibleSurfaceBiome(");
|
||||
int end = source.indexOf("boolean isStructureReachable(", start);
|
||||
String method = source.substring(start, end);
|
||||
|
||||
assertTrue(method.contains("QuartPos.fromBlock(blockX)"));
|
||||
assertTrue(method.contains("QuartPos.toBlock(quartX)"));
|
||||
assertTrue(method.contains("QuartPos.fromBlock(internalY + engine.getMinHeight())"));
|
||||
assertTrue(method.contains("stackContext.getLayout(sampleX, sampleZ).surfaceLayer()"));
|
||||
assertTrue(method.contains("visibleBiomeCache"));
|
||||
assertTrue(method.contains("isBiomeCacheable(engine, sampleX, sampleZ)"));
|
||||
assertTrue(method.indexOf("boolean cacheable = isBiomeCacheable(engine, sampleX, sampleZ)")
|
||||
< method.indexOf("Engine.hostHeight(engine, sampleX, sampleZ, true)"));
|
||||
assertCacheGuarded(method, "visibleBiomeCache");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void temporaryNaturalBiomeAnswersAreNotMemoized() throws IOException {
|
||||
public void temporaryNaturalBiomeAnswersAreNotMemoized() {
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.answersFromNaturalTerrain(12, -8)).thenReturn(true);
|
||||
assertFalse(IrisModdedBiomeSource.isBiomeCacheable(engine, 12, -8));
|
||||
when(engine.answersFromNaturalTerrain(12, -8)).thenReturn(false);
|
||||
assertTrue(IrisModdedBiomeSource.isBiomeCacheable(engine, 12, -8));
|
||||
|
||||
String source = Files.readString(Path.of(
|
||||
System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/IrisModdedBiomeSource.java"));
|
||||
int structureStart = source.indexOf("private Holder<Biome> getNoiseBiome(Engine engine");
|
||||
int structureEnd = source.indexOf("Holder<Biome> getVisibleNoiseBiome(", structureStart);
|
||||
String structure = source.substring(structureStart, structureEnd);
|
||||
assertTrue(structure.contains("QuartPos.toBlock(quartX)"));
|
||||
assertTrue(structure.contains("QuartPos.toBlock(quartZ)"));
|
||||
assertCacheGuarded(structure, "structureBiomeCache");
|
||||
|
||||
int visibleStart = source.indexOf(
|
||||
"private Holder<Biome> getVisibleNoiseBiomeWithActiveGenerationLease(");
|
||||
int visibleEnd = source.indexOf("Holder<Biome> getVisibleSurfaceBiome(", visibleStart);
|
||||
String visible = source.substring(visibleStart, visibleEnd);
|
||||
assertTrue(visible.contains("QuartPos.toBlock(quartX)"));
|
||||
assertTrue(visible.contains("QuartPos.toBlock(quartZ)"));
|
||||
assertCacheGuarded(visible, "visibleBiomeCache");
|
||||
|
||||
int predicateStart = source.indexOf("static boolean isBiomeCacheable(");
|
||||
int predicateEnd = source.indexOf("static boolean isMonumentSurfaceBiomeQuery(", predicateStart);
|
||||
String predicate = source.substring(predicateStart, predicateEnd);
|
||||
assertTrue(predicate.contains("return !engine.answersFromNaturalTerrain(blockX, blockZ)"));
|
||||
assertFalse(predicate.contains("getDimensionStackContext()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void importedFeaturesCollectOnlyHostOwnedStackCells() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/ModdedImportedFeatureStage.java"));
|
||||
|
||||
assertTrue(source.contains("stackContext.getLayerAt("));
|
||||
assertTrue(source.contains("layer.terrainContext().isSelfReferencing()"));
|
||||
assertTrue(source.contains("section.getBiomes().get(quartX, quartY, quartZ)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -281,17 +158,6 @@ public class IrisModdedStructureParityTest {
|
||||
assertTrue(recursiveKeys.contains("layers:sky/aurora"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void biomeWriterDerivativeFallbackMatchesCanonicalRecursiveKeys() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/ModdedBiomeWriter.java"));
|
||||
|
||||
assertTrue(source.contains(
|
||||
"key.equalsIgnoreCase(dimension.getCustomBiomeKey(custom.getId()))"));
|
||||
assertFalse(source.contains("String dimensionLoadKey = key.substring(0, colon)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void structureStateRejectsBiomesOutsideThePackContract() {
|
||||
Set<String> generated = Set.of("minecraft:deep_ocean", "minecraft:dark_forest");
|
||||
@@ -410,21 +276,4 @@ public class IrisModdedStructureParityTest {
|
||||
public void initialEntitySpawnsUseThePaperCompletionMarker() {
|
||||
assertSame(MantleFlag.INITIAL_SPAWNED_MARKER, ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG);
|
||||
}
|
||||
|
||||
private static void assertCacheGuarded(String method, String cacheName) {
|
||||
int predicate = method.indexOf("boolean cacheable = isBiomeCacheable(");
|
||||
int cache = method.indexOf("BiomeHolderTable cache = " + cacheName, predicate);
|
||||
int read = method.indexOf("cache.get(", predicate);
|
||||
int readGuard = method.lastIndexOf("if (cacheable)", read);
|
||||
int write = method.indexOf("cache.put(", read);
|
||||
int writeGuard = method.lastIndexOf("if (cacheable)", write);
|
||||
|
||||
assertTrue(predicate >= 0);
|
||||
assertTrue(cache >= 0);
|
||||
assertTrue(read > predicate);
|
||||
assertTrue(readGuard >= predicate);
|
||||
assertTrue(write > read);
|
||||
assertTrue(writeGuard > read);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedDynamicLevelLifecycleContractTest {
|
||||
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
|
||||
|
||||
@Test
|
||||
public void sharedManagerPublishesDynamicLoadAndUnloadAroundRegistration() throws IOException {
|
||||
String manager = commonSource("ModdedDimensionManager.java");
|
||||
String loader = commonSource("ModdedLoader.java");
|
||||
String injection = method(manager, "private static Handle inject(");
|
||||
String removal = method(manager, "public static boolean remove(");
|
||||
String injectionRollback = method(manager, "private static void rollbackInjection(");
|
||||
String removalRollback = method(manager, "private static void rollbackRemoval(");
|
||||
|
||||
assertTrue(loader.contains("void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level);"));
|
||||
assertTrue(loader.contains("void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level);"));
|
||||
assertBefore(injection, "serverAccess.putLevelIfAbsent(server, key, level);",
|
||||
"fireDynamicLevelLoad(server, level);");
|
||||
assertBefore(removal, "fireDynamicLevelUnload(server, level);",
|
||||
"serverAccess.removeLevel(server, key);");
|
||||
assertTrue(injectionRollback.contains("fireDynamicLevelUnload(server, level);"));
|
||||
assertTrue(removalRollback.contains("fireDynamicLevelLoad(server, level);"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyLoaderUsesItsNativeLifecycleBus() throws IOException {
|
||||
String fabric = loaderSource("fabric", "art/arcane/iris/fabric/FabricModdedLoader.java");
|
||||
String forge = loaderSource("forge", "art/arcane/iris/forge/ForgeModdedLoader.java");
|
||||
String neoForge = loaderSource("neoforge", "art/arcane/iris/neoforge/NeoForgeModdedLoader.java");
|
||||
|
||||
assertTrue(fabric.contains("ServerLevelEvents.LOAD.invoker().onLevelLoad(server, level);"));
|
||||
assertTrue(fabric.contains("ServerLevelEvents.UNLOAD.invoker().onLevelUnload(server, level);"));
|
||||
assertTrue(forge.contains("LevelEvent.Load.BUS.post(new LevelEvent.Load(level));"));
|
||||
assertTrue(forge.contains("LevelEvent.Unload.BUS.post(new LevelEvent.Unload(level));"));
|
||||
assertTrue(neoForge.contains("NeoForge.EVENT_BUS.post(new LevelEvent.Load(level));"));
|
||||
assertTrue(neoForge.contains("NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level));"));
|
||||
}
|
||||
|
||||
private static String commonSource(String file) throws IOException {
|
||||
return Files.readString(commonRoot().resolve("art/arcane/iris/modded").resolve(file))
|
||||
.replace("\r\n", "\n");
|
||||
}
|
||||
|
||||
private static String loaderSource(String loader, String relative) throws IOException {
|
||||
Path adaptersRoot = commonRoot().getParent().getParent().getParent().getParent();
|
||||
return Files.readString(adaptersRoot.resolve(loader).resolve("src/main/java").resolve(relative))
|
||||
.replace("\r\n", "\n");
|
||||
}
|
||||
|
||||
private static Path commonRoot() {
|
||||
String root = System.getProperty(SOURCE_ROOT_PROPERTY);
|
||||
if (root == null || root.isBlank()) {
|
||||
throw new IllegalStateException("Missing test property " + SOURCE_ROOT_PROPERTY);
|
||||
}
|
||||
return Path.of(root);
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
if (start < 0) {
|
||||
throw new IllegalArgumentException("Missing source method " + signature);
|
||||
}
|
||||
int open = source.indexOf('{', start);
|
||||
int depth = 0;
|
||||
for (int index = open; index < source.length(); index++) {
|
||||
char current = source.charAt(index);
|
||||
if (current == '{') {
|
||||
depth++;
|
||||
} else if (current == '}' && --depth == 0) {
|
||||
return source.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unclosed source method " + signature);
|
||||
}
|
||||
|
||||
private static void assertBefore(String source, String first, String second) {
|
||||
int firstIndex = source.indexOf(first);
|
||||
int secondIndex = source.indexOf(second);
|
||||
assertTrue("Missing source token " + first, firstIndex >= 0);
|
||||
assertTrue("Missing source token " + second, secondIndex >= 0);
|
||||
assertTrue(first + " must precede " + second, firstIndex < secondIndex);
|
||||
}
|
||||
}
|
||||
-64
@@ -7,12 +7,8 @@ import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
@@ -24,8 +20,6 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedGenerationBootstrapBindingTest {
|
||||
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
|
||||
|
||||
@Test
|
||||
public void publishedLevelRequiresCurrentGeneratorIdentity() {
|
||||
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class, CALLS_REAL_METHODS);
|
||||
@@ -89,62 +83,4 @@ public class ModdedGenerationBootstrapBindingTest {
|
||||
assertSame(dynamicLevel, generator.resolveBoundLevel(server, List.of(dynamicLevel)));
|
||||
verify(server, never()).getLevel(Level.OVERWORLD);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void firstGenerationStagesProvideAuthoritativeLevelContext() throws IOException {
|
||||
String source = source("IrisModdedChunkGenerator.java");
|
||||
String structures = method(source, "public void createStructures(");
|
||||
String references = method(source, "public void createReferences(");
|
||||
assertTrue(structures.contains("Engine current = engine(levelKey);"));
|
||||
assertTrue(references.contains("Engine current = engine(level.getLevel());"));
|
||||
|
||||
String publishedBinding = method(source, "private Engine engine(ResourceKey<Level> levelKey)");
|
||||
assertTrue(publishedBinding.contains("requirePublishedLevel(ModdedEngineBootstrap.currentServer(), levelKey)"));
|
||||
assertTrue(publishedBinding.contains("return bindGenerationLevel(level);"));
|
||||
assertFalse(publishedBinding.contains("ModdedServerLevels.level("));
|
||||
|
||||
String fullBinding = method(source, "private Engine bindGenerationLevel(ServerLevel level)");
|
||||
assertTrue(fullBinding.contains("bindLevel(level);"));
|
||||
assertTrue(fullBinding.contains("Engine bound = readyEngine();"));
|
||||
|
||||
String genericBinding = method(source, "Engine engine()");
|
||||
assertTrue(genericBinding.contains("return bindGenerationLevel(level);"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void constructorMixinIsNotPartOfGenerationBootstrap() throws IOException {
|
||||
String sourceRoot = System.getProperty(SOURCE_ROOT_PROPERTY);
|
||||
assertTrue(sourceRoot != null && !sourceRoot.isBlank());
|
||||
Path root = Path.of(sourceRoot);
|
||||
assertFalse(Files.exists(root.resolve(Path.of("art", "arcane", "iris", "modded", "mixin",
|
||||
"ServerLevelBindingMixin.java"))));
|
||||
String mixinConfig = Files.readString(root.getParent().resolve("resources/irisworldgen.entity.mixins.json"));
|
||||
assertFalse(mixinConfig.contains("ServerLevelBindingMixin"));
|
||||
}
|
||||
|
||||
private String source(String fileName) throws IOException {
|
||||
String sourceRoot = System.getProperty(SOURCE_ROOT_PROPERTY);
|
||||
assertTrue(sourceRoot != null && !sourceRoot.isBlank());
|
||||
return Files.readString(Path.of(sourceRoot, "art", "arcane", "iris", "modded", fileName));
|
||||
}
|
||||
|
||||
private String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
assertTrue(start >= 0);
|
||||
int body = source.indexOf('{', start);
|
||||
assertTrue(body >= 0);
|
||||
int depth = 0;
|
||||
for (int index = body; index < source.length(); index++) {
|
||||
char token = source.charAt(index);
|
||||
if (token == '{') {
|
||||
depth++;
|
||||
} else if (token == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return source.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unclosed source contract method: " + signature);
|
||||
}
|
||||
}
|
||||
|
||||
-203
@@ -1,203 +0,0 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedGenerationHistoryRoutingContractTest {
|
||||
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
|
||||
|
||||
@Test
|
||||
public void startupPublishesOnlyAnImmutableHistoryBoundRuntime() throws IOException {
|
||||
String source = source("ModdedWorldEngines.java");
|
||||
String create = method(source, "private static Engine create(");
|
||||
String createHistoryEngine = method(source, "private static Engine createHistoryEngine(");
|
||||
String buildEngine = method(source, "private static IrisEngine buildEngine(");
|
||||
|
||||
assertTrue(create.contains("ModdedGenerationHistoryStorage.openOrAdopt("));
|
||||
assertFalse(create.contains("resolvePack("));
|
||||
assertTrue(createHistoryEngine.contains("candidate.attachGenerationHistory("));
|
||||
assertTrue(createHistoryEngine.contains("IrisBoundarySignatureSampler.INSTANCE"));
|
||||
assertTrue(createHistoryEngine.contains("getGenerationTransitionWidthBlocks()"));
|
||||
assertTrue(createHistoryEngine.contains("router.preloadActiveRuntimes();"));
|
||||
assertBefore(createHistoryEngine, "history.prepareCurrentGenerator(",
|
||||
"opened = ModdedGenerationHistoryStorage.resolveActive(history)");
|
||||
assertBefore(createHistoryEngine, "opened = ModdedGenerationHistoryStorage.resolveActive(history)",
|
||||
"IrisEngine candidate = buildEngine(level, seed, opened)");
|
||||
String promoted = method(createHistoryEngine,
|
||||
"if (history.activeActivation().activationId() != openedActivationId)");
|
||||
assertBefore(promoted, "close(candidate);", "ModdedGenerationHistoryStorage.resolveActive(history)");
|
||||
assertBefore(promoted, "ModdedGenerationHistoryStorage.resolveActive(history)",
|
||||
"candidate = buildEngine(level, seed, promoted)");
|
||||
assertTrue(createHistoryEngine.contains("GenerationHistoryRuntimeRouter.attach("));
|
||||
assertTrue(buildEngine.contains("active.packRoot().toFile()"));
|
||||
assertTrue(buildEngine.contains("history.paths().activationMantleRoot(activation.activationId())"));
|
||||
assertTrue(buildEngine.contains("epoch.kernelVersion()"));
|
||||
assertTrue(buildEngine.contains("transitionPlan"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onlyExplicitTransientStudioEnginesBypassHistory() throws IOException {
|
||||
String manager = source("ModdedDimensionManager.java");
|
||||
String transientCreate = method(manager, "public static Handle createTransientStudio(");
|
||||
String persistentCreate = method(manager, "public static Handle createPersistent(");
|
||||
String engines = source("ModdedWorldEngines.java");
|
||||
String studio = method(engines, "private static IrisEngine createTransientStudioEngine(");
|
||||
String generator = source("IrisModdedChunkGenerator.java");
|
||||
String bypass = method(generator, "boolean allowsGenerationHistoryBypass(");
|
||||
String route = method(generator, "private GenerationHistoryRuntimeRouter.RuntimeRoute openHistoryRoute(");
|
||||
|
||||
assertTrue(transientCreate.contains("ModdedGenerationMode.TRANSIENT_STUDIO"));
|
||||
assertTrue(persistentCreate.contains("ModdedGenerationMode.PERSISTENT_CREATE"));
|
||||
assertTrue(studio.contains("IrisData.openRuntime(packDir)"));
|
||||
assertTrue(studio.contains("IrisEngine.InitializationMode.STUDIO"));
|
||||
assertFalse(studio.contains("ModdedGenerationHistoryStorage"));
|
||||
assertFalse(studio.contains("attachGenerationHistory("));
|
||||
assertTrue(bypass.contains("generationMode != ModdedGenerationMode.TRANSIENT_STUDIO"));
|
||||
assertTrue(bypass.contains("irisEngine.isStudio()"));
|
||||
assertTrue(bypass.contains("Only an explicitly transient Iris Studio engine"));
|
||||
assertBefore(route, "allowsGenerationHistoryBypass(current)", "requireHistoryRouter(current, operation)");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyCoordinateBearingChunkStageOpensItsRoutedRuntime() throws IOException {
|
||||
String source = source("IrisModdedChunkGenerator.java");
|
||||
|
||||
assertRuntimeRoute(source, "public Pair<BlockPos, Holder<Structure>> findNearestMapStructure(");
|
||||
assertCoordinateRoute(source, "public WeightedList<MobSpawnSettings.SpawnerData> getMobsAt(");
|
||||
assertRuntimeRoute(source, "public CompletableFuture<ChunkAccess> createBiomes(");
|
||||
assertRuntimeRoute(source, "public void applyCarvers(");
|
||||
assertRuntimeRoute(source, "public void buildSurface(");
|
||||
assertRuntimeRoute(source, "public void applyBiomeDecoration(");
|
||||
assertRuntimeRoute(source, "public void createStructures(");
|
||||
assertRuntimeRoute(source, "public void createReferences(");
|
||||
assertRuntimeRoute(source, "public void spawnOriginalMobs(");
|
||||
assertCoordinateRoute(source, "public int getBaseHeight(");
|
||||
assertCoordinateRoute(source, "public NoiseColumn getBaseColumn(");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void asyncTerrainKeepsTheRouteUntilDurableSemanticClaim() throws IOException {
|
||||
String source = source("IrisModdedChunkGenerator.java");
|
||||
String fill = method(source, "public CompletableFuture<ChunkAccess> fillFromNoise(");
|
||||
String terrain = method(source, "private ChunkAccess generateTerrain(");
|
||||
String completion = method(source, "private static CompletableFuture<ChunkAccess> closeRouteOnCompletion(");
|
||||
|
||||
assertBefore(fill, "openHistoryRoute(", "CompletableFuture.supplyAsync(");
|
||||
assertTrue(fill.contains("generateTerrain(chunk, generationEngine, pos, air, route)"));
|
||||
assertTrue(fill.contains("return closeRouteOnCompletion(pipeline, route);"));
|
||||
assertTrue(terrain.contains("openHistoryRuntimeScope(route)"));
|
||||
assertBefore(terrain, "generationEngine.generate(", "writeBlocks(chunk, blocks, dimMinY, height)");
|
||||
assertBefore(terrain, "writeBlocks(chunk, blocks, dimMinY, height)", "route.claimGeneratedSemantics(");
|
||||
assertTrue(terrain.contains("chunk.getBlockState(position.set(x, dimMinY + y, z))"));
|
||||
assertTrue(terrain.contains("state.isAir() || state.liquid()"));
|
||||
assertBefore(terrain, "route.claimGeneratedSemantics(", "ModdedNativeTerrainReceipts.persist(chunk, route)");
|
||||
assertBefore(terrain, "ModdedNativeTerrainReceipts.persist(chunk, route)", "return chunk;");
|
||||
assertTrue(completion.contains("pipeline.whenComplete("));
|
||||
assertTrue(completion.contains("route.close();"));
|
||||
assertBefore(completion, "pipeline.whenComplete(", "route.detachThread()");
|
||||
assertBefore(completion, "route.detachThread()", "return completion;");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void biomeQueriesRouteEachCoordinateAndKeepRuntimeSpecificCaches() throws IOException {
|
||||
String source = source("IrisModdedBiomeSource.java");
|
||||
String structure = method(source, "public Holder<Biome> getNoiseBiome(");
|
||||
String visible = method(source, "Holder<Biome> getVisibleNoiseBiome(");
|
||||
String batch = method(source, "public Set<Holder<Biome>> getBiomesWithin(");
|
||||
String resolution = method(source, "private Holder<Biome> resolveVisibleBiome(");
|
||||
|
||||
assertTrue(structure.contains("openHistoryCoordinateScope("));
|
||||
assertTrue(visible.contains("openHistoryCoordinateScope("));
|
||||
assertTrue(batch.contains("quartX << 2, quartZ << 2"));
|
||||
assertTrue(source.contains("int runtimeIdentity = engine.getCacheID();"));
|
||||
assertTrue(source.contains("cache.get(runtimeIdentity, key)"));
|
||||
assertTrue(source.contains("cache.put(runtimeIdentity, key, resolved)"));
|
||||
assertBefore(resolution, "historicalPhysicalBiomeKeyAt(", "ModdedWorldgenIds.biomeRef(engine");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pendingNativeStagesPreserveSavedStartsAndGuardNewFeatureFootprints() throws IOException {
|
||||
String generator = source("IrisModdedChunkGenerator.java");
|
||||
String discrete = method(generator, "private boolean allowsRoutedDiscreteGeneration(");
|
||||
String decoration = method(generator, "public void applyBiomeDecoration(");
|
||||
String structures = method(generator, "public void createStructures(");
|
||||
String mobs = method(generator, "public void spawnOriginalMobs(");
|
||||
String nativeStage = source("ModdedNativeStructureStage.java");
|
||||
String adjust = method(nativeStage, "void adjustGeneratedStructures(");
|
||||
String place = method(nativeStage, "void placeVanillaStructures(");
|
||||
String savedStart = method(nativeStage, "private boolean isHistoricalStructureStart(");
|
||||
|
||||
assertTrue(discrete.contains("chunk.getPersistedStatus().isOrAfter(stage)"));
|
||||
assertFalse(discrete.contains("router.history().activeActivation().activationId()"));
|
||||
assertFalse(discrete.contains("return true;"));
|
||||
assertTrue(discrete.contains("allowsNewGenerationChunk("));
|
||||
assertTrue(discrete.contains("NativeGenerationWriteGuard.allowsPendingStage(current, chunk, stage)"));
|
||||
assertTrue(decoration.contains("ChunkStatus.FEATURES"));
|
||||
assertTrue(structures.contains("ChunkStatus.STRUCTURE_STARTS"));
|
||||
assertTrue(mobs.contains("ChunkStatus.SPAWN"));
|
||||
assertBefore(decoration, "chunk.getPersistedStatus().isOrAfter(ChunkStatus.FEATURES)",
|
||||
"nativeStructures.placeVanillaStructures(");
|
||||
assertBefore(decoration, "nativeStructures.placeVanillaStructures(", "allowsRoutedDiscreteGeneration(");
|
||||
assertBefore(decoration, "allowsRoutedDiscreteGeneration(", "importedFeatures.run(");
|
||||
assertBefore(decoration, "NativeGenerationWriteGuard.allowsDecoration(", "importedFeatures.run(");
|
||||
assertBefore(structures, "allowsRoutedDiscreteGeneration(", "super.createStructures(");
|
||||
assertBefore(mobs, "allowsRoutedDiscreteGeneration(", "NaturalSpawner.spawnMobsForChunkGeneration(");
|
||||
assertBefore(adjust, "allowsNewGenerationFootprint(", "recordWorldCheckStructureShift(");
|
||||
assertBefore(place, "!isHistoricalStructureStart(current, world, start)", "allowsNewGenerationFootprint(");
|
||||
assertTrue(savedStart.contains("world.getChunk(origin.x(), origin.z(), ChunkStatus.EMPTY, false)"));
|
||||
assertTrue(savedStart.contains("ModdedNativeTerrainReceipts.structureActivation(source)"));
|
||||
assertBefore(structures, "super.createStructures(", "ModdedNativeTerrainReceipts.persistStructureActivation(");
|
||||
}
|
||||
|
||||
private static void assertRuntimeRoute(String source, String signature) {
|
||||
String body = method(source, signature);
|
||||
assertTrue(signature + " must open a generation-history route", body.contains("openHistoryRoute("));
|
||||
assertTrue(signature + " must open its routed runtime", body.contains("openHistoryRuntimeScope(route)"));
|
||||
}
|
||||
|
||||
private static void assertCoordinateRoute(String source, String signature) {
|
||||
String body = method(source, signature);
|
||||
assertTrue(signature + " must route its coordinate", body.contains("openHistoryCoordinateScope("));
|
||||
}
|
||||
|
||||
private static void assertBefore(String source, String first, String second) {
|
||||
int firstIndex = source.indexOf(first);
|
||||
int secondIndex = source.indexOf(second);
|
||||
assertTrue("Missing source contract token: " + first, firstIndex >= 0);
|
||||
assertTrue("Missing source contract token: " + second, secondIndex >= 0);
|
||||
assertTrue(first + " must occur before " + second, firstIndex < secondIndex);
|
||||
}
|
||||
|
||||
private static String source(String fileName) throws IOException {
|
||||
String sourceRoot = System.getProperty(SOURCE_ROOT_PROPERTY);
|
||||
assertTrue("Missing system property " + SOURCE_ROOT_PROPERTY,
|
||||
sourceRoot != null && !sourceRoot.isBlank());
|
||||
return Files.readString(Path.of(sourceRoot, "art", "arcane", "iris", "modded", fileName));
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
assertTrue("Missing source contract signature: " + signature, start >= 0);
|
||||
int openBrace = source.indexOf('{', start);
|
||||
assertTrue("Missing source contract method body: " + signature, openBrace >= 0);
|
||||
int depth = 0;
|
||||
for (int index = openBrace; index < source.length(); index++) {
|
||||
char current = source.charAt(index);
|
||||
if (current == '{') {
|
||||
depth++;
|
||||
} else if (current == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return source.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unclosed source contract method: " + signature);
|
||||
}
|
||||
}
|
||||
-244
@@ -1,244 +0,0 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedGenerationLeaseContractTest {
|
||||
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
|
||||
|
||||
@Test
|
||||
public void biomeRuntimeReadsAreGenerationLeased() throws IOException {
|
||||
String source = source("art/arcane/iris/modded/IrisModdedBiomeSource.java");
|
||||
|
||||
assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_structure_biome\")"));
|
||||
assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_visible_biome\")"));
|
||||
assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_biomes_within\")"));
|
||||
assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_structure_reachability\")"));
|
||||
assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_structure_state_biome\")"));
|
||||
assertTrue(source.contains("tryAcquireGenerationLease(engine, \"modded_structure_biome_keys\")"));
|
||||
assertTrue(source.contains("engine == null || engine.isClosed()"));
|
||||
assertTrue(source.contains("catch (GenerationSessionException e)"));
|
||||
assertTrue(source.contains("e.isExpectedTeardown()"));
|
||||
assertTrue(source.contains("Iris structure biome key lookup was rejected during an engine transition"));
|
||||
assertTrue(source.contains("int runtimeIdentity = engine.getCacheID();"));
|
||||
assertTrue(source.contains("cache.get(runtimeIdentity, key)"));
|
||||
assertTrue(source.contains("cache.put(runtimeIdentity, key, resolved)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void terrainAndHeightQueriesShareGenerationSessions() throws IOException {
|
||||
String source = source("art/arcane/iris/modded/IrisModdedChunkGenerator.java");
|
||||
|
||||
assertTrue(source.contains("generationEngine.acquireGenerationLease(\"modded_chunk_pipeline\")"));
|
||||
assertTrue(source.contains("current.acquireGenerationLease(\"modded_base_height\")"));
|
||||
assertTrue(source.contains("current.acquireGenerationLease(\"modded_base_column\")"));
|
||||
assertTrue(source.contains("current.acquireGenerationLease(\"modded_configured_biome_keys\")"));
|
||||
assertTrue(source.contains("generationEngine.isClosing() || e.isExpectedTeardown()"));
|
||||
String terrain = method(source, "private ChunkAccess generateTerrain(");
|
||||
int sessionFailure = terrain.indexOf("catch (GenerationSessionException e)");
|
||||
int generalFailure = terrain.indexOf("catch (Throwable e)", sessionFailure);
|
||||
String sessionHandler = terrain.substring(sessionFailure, generalFailure);
|
||||
assertTrue(sessionHandler.contains("throw new IllegalStateException"));
|
||||
assertFalse(sessionHandler.contains("return chunk;"));
|
||||
|
||||
String references = method(source, "public void createReferences(");
|
||||
assertTrue(references.contains("requireGenerationLease(current, \"modded_create_references\")"));
|
||||
assertTrue(references.contains("IrisContext.open(current, lease.sessionId(), null)"));
|
||||
assertTrue(references.contains("NativeStructureReferenceRepair.createReferences("));
|
||||
assertFalse(references.contains("super.createReferences(level, structureManager, chunk);"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preservationDereferencesSharedPackData() throws IOException {
|
||||
String source = source("art/arcane/iris/modded/service/ModdedPreservationService.java");
|
||||
int dereference = source.indexOf("public void dereference()");
|
||||
int irisData = source.indexOf("IrisData.dereference();", dereference);
|
||||
int trackedResources = source.indexOf("threads.removeIf", dereference);
|
||||
|
||||
assertTrue(dereference >= 0);
|
||||
assertTrue(irisData > dereference);
|
||||
assertTrue(trackedResources > irisData);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hotloadShutdownOnlyTargetsItsOwnWorld() throws IOException {
|
||||
String source = source("art/arcane/iris/modded/service/ModdedStudioHotloadService.java");
|
||||
int shutdown = source.indexOf("public void shutdownPregenerator(Engine engine)");
|
||||
int nextMethod = source.indexOf("private boolean throttled", shutdown);
|
||||
String method = source.substring(shutdown, nextMethod);
|
||||
|
||||
assertTrue(method.contains("ModdedPregenJob.shutdownForWorld(world.identity());"));
|
||||
assertFalse(method.contains("PregeneratorJob.shutdownInstance();"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void maintenanceOwnsOneGenerationSessionUntilWorkCompletes() throws IOException {
|
||||
String source = source("art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java");
|
||||
String maintain = method(source, "private void maintain(Engine engine, long scheduledAt)");
|
||||
|
||||
assertTrue(maintain.contains("engine.acquireGenerationLease(\"modded_engine_maintenance\")"));
|
||||
assertTrue(maintain.contains("IrisContext.open(engine, lease.sessionId(), null)"));
|
||||
assertTrue(maintain.contains("exception.isExpectedTeardown()"));
|
||||
assertTrue(source.contains("active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void packDownloadAdmissionPrecedesAsyncDispatch() throws IOException {
|
||||
String source = source("art/arcane/iris/modded/command/IrisModdedCommands.java");
|
||||
String download = method(source, "static int download(CommandSourceStack source, String rawRequest)");
|
||||
int admission = download.indexOf("LifecycleOperationCoordinator.get().acquire(");
|
||||
int executionTracking = download.indexOf("new PackDownloadExecution(");
|
||||
int dispatch = download.indexOf("scheduler.asyncIfRunning(execution, execution::cancel)");
|
||||
|
||||
assertTrue(admission >= 0);
|
||||
assertTrue(executionTracking > admission);
|
||||
assertTrue(dispatch > admission);
|
||||
assertTrue(download.contains("execution.cancel();"));
|
||||
|
||||
String execution = method(source, "private static void executeDownload(");
|
||||
assertTrue(execution.contains("PackDownloader.DownloadCancellation cancellation"));
|
||||
assertTrue(execution.contains("catch (PackDownloader.PackDownloadCancelledException error)"));
|
||||
assertTrue(execution.contains("dispatchDownloadFeedback(source,"));
|
||||
assertFalse(execution.contains("scheduler.global("));
|
||||
assertFalse(execution.contains("lease.close();"));
|
||||
|
||||
String feedback = method(source, "private static void dispatchDownloadFeedback(");
|
||||
assertTrue(feedback.contains("source.getServer().execute(feedback);"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void packDownloadsDrainBeforeModdedSchedulerShutdown() throws IOException {
|
||||
String commands = source("art/arcane/iris/modded/command/IrisModdedCommands.java");
|
||||
String shutdownDownloads = method(commands, "public static void shutdownDownloads()");
|
||||
assertTrue(shutdownDownloads.contains("downloadAdmissionOpen = false;"));
|
||||
assertTrue(shutdownDownloads.contains("execution.cancel();"));
|
||||
assertTrue(shutdownDownloads.contains("execution.await("));
|
||||
|
||||
String bootstrap = source("art/arcane/iris/modded/ModdedEngineBootstrap.java");
|
||||
String stop = method(bootstrap, "public static void stop()");
|
||||
int downloads = stop.indexOf("IrisModdedCommands::shutdownDownloads");
|
||||
int scheduler = stop.indexOf("scheduler::shutdown");
|
||||
assertTrue(downloads >= 0);
|
||||
assertTrue(scheduler > downloads);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void moddedSchedulerRejectsAndCancelsAbandonedDownloadSubmissions() throws IOException {
|
||||
String scheduler = source("art/arcane/iris/modded/ModdedScheduler.java");
|
||||
String dispatch = method(scheduler, "public boolean asyncIfRunning(Runnable task, Runnable rejection)");
|
||||
assertTrue(dispatch.contains("RejectionAwareTask"));
|
||||
assertTrue(dispatch.contains("Objects.requireNonNull(rejection"));
|
||||
|
||||
String shutdown = method(scheduler, "public void shutdown()");
|
||||
assertTrue(shutdown.contains("asyncExecutor.shutdownNow()"));
|
||||
assertTrue(shutdown.contains("rejectAbandonedTasks(abandonedTasks);"));
|
||||
|
||||
String reset = method(scheduler, "public void reset()");
|
||||
assertTrue(reset.contains("asyncExecutor.getQueue().drainTo(abandonedTasks);"));
|
||||
assertTrue(reset.contains("rejectAbandonedTasks(abandonedTasks);"));
|
||||
|
||||
String rejection = method(scheduler, "private static void rejectAbandonedTasks(");
|
||||
assertTrue(rejection.contains("rejectionAwareTask.reject();"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void blockingPregenShutdownDefersItsFinalSaveToTheServerThread() throws IOException {
|
||||
String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java");
|
||||
String shutdown = method(jobSource, "private static boolean shutdownAndSave(");
|
||||
assertTrue(shutdown.contains("deferFinalSaveToServerThread()"));
|
||||
assertTrue(shutdown.contains("completeDeferredFinalSave()"));
|
||||
assertTrue(shutdown.contains("cancelDeferredFinalSave()"));
|
||||
|
||||
String methodSource = source("art/arcane/iris/modded/command/ModdedPregenMethod.java");
|
||||
String close = method(methodSource, "public void close()");
|
||||
assertTrue(close.contains("deferFinalSaveIfRequested()"));
|
||||
|
||||
String await = method(methodSource, "private void awaitFinalSave(");
|
||||
assertTrue(await.contains("FINAL_SAVE_POLL_MILLIS"));
|
||||
assertTrue(await.contains("deferFinalSaveIfRequested()"));
|
||||
|
||||
String complete = method(methodSource, "void completeDeferredFinalSave()");
|
||||
int cancel = complete.indexOf("cancelQueuedFinalSave();");
|
||||
int directSave = complete.indexOf("saveLevelOnServerThread();");
|
||||
assertTrue(cancel >= 0);
|
||||
assertTrue(directSave > cancel);
|
||||
|
||||
String queuedSave = method(methodSource, "private void executeFinalSave(");
|
||||
assertTrue(queuedSave.contains("if (!request.claim())"));
|
||||
String cancelRequest = method(methodSource, "private void cancelQueuedFinalSave()");
|
||||
assertTrue(cancelRequest.contains("request.cancel();"));
|
||||
String pending = method(methodSource, "boolean hasPendingFinalSave()");
|
||||
assertTrue(pending.contains("queuedFinalSave.get() != null"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void firstPregenChunkFailureRetainsItsFullCause() throws IOException {
|
||||
String methodSource = source("art/arcane/iris/modded/command/ModdedPregenMethod.java");
|
||||
String logFailure = method(methodSource, "private void logChunkFailure(");
|
||||
|
||||
assertTrue(methodSource.contains("AtomicBoolean failureDetailLogged"));
|
||||
assertTrue(logFailure.contains("unwrap(failure)"));
|
||||
assertTrue(logFailure.contains("failureDetailLogged.compareAndSet(false, true)"));
|
||||
assertTrue(logFailure.contains("x, z, cause);"));
|
||||
assertTrue(logFailure.contains("cause.toString()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pregenReportsOnlyCleanupCallbacksFromCoveredTargets() throws IOException {
|
||||
String source = source("art/arcane/iris/modded/command/ModdedPregenMethod.java");
|
||||
String cleanup = method(source, "private void cleanupMantleChunksCoveredBy(");
|
||||
|
||||
assertTrue(source.contains("cleanupMantleChunksCoveredBy(x, z, listener);"));
|
||||
assertFalse(source.contains("cleanupMantleChunk(x, z);"));
|
||||
assertTrue(cleanup.contains("cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned)"));
|
||||
assertFalse(cleanup.contains("forceCleanupChunk"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidPregenBoundsFailBeforeRuntimeMutation() throws IOException {
|
||||
String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java");
|
||||
String start = method(jobSource, "public static boolean start(");
|
||||
int taskConstruction = start.indexOf("PregenTask task = PregenTask.builder()");
|
||||
int profileMutation = start.indexOf("PregenPerformanceProfile.apply(engine)");
|
||||
|
||||
assertTrue(taskConstruction >= 0);
|
||||
assertTrue(profileMutation > taskConstruction);
|
||||
|
||||
String commandSource = source("art/arcane/iris/modded/command/ModdedPregenCommands.java");
|
||||
String command = method(commandSource, "static int pregenStart(");
|
||||
assertTrue(command.contains("catch (IllegalArgumentException failure)"));
|
||||
assertTrue(command.contains("IrisModdedCommands.fail(source, failure.getMessage())"));
|
||||
}
|
||||
|
||||
private static String source(String relativePath) throws IOException {
|
||||
String root = System.getProperty(SOURCE_ROOT_PROPERTY);
|
||||
assertTrue("Missing system property " + SOURCE_ROOT_PROPERTY, root != null && !root.isBlank());
|
||||
return Files.readString(Path.of(root, relativePath));
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
assertTrue("Missing source contract signature: " + signature, start >= 0);
|
||||
int openBrace = source.indexOf('{', start);
|
||||
assertTrue("Missing source contract method body: " + signature, openBrace >= 0);
|
||||
int depth = 0;
|
||||
for (int index = openBrace; index < source.length(); index++) {
|
||||
char current = source.charAt(index);
|
||||
if (current == '{') {
|
||||
depth++;
|
||||
} else if (current == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return source.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unclosed source contract method: " + signature);
|
||||
}
|
||||
}
|
||||
-58
@@ -2,32 +2,9 @@ package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* The level router is a switch statement, which the compiler does not check for exhaustiveness. A level added
|
||||
* to {@link art.arcane.iris.spi.LogLevel} with no arm here would compile and then drop every message at that
|
||||
* level without a trace, so the fallback arm is what keeps a new level visible on the modded loaders.
|
||||
*/
|
||||
public class ModdedIrisLogLevelCoverageTest {
|
||||
@Test
|
||||
public void everyLogLevelReachesTheLoggerIncludingOnesAddedLater() throws IOException {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/ModdedIrisLog.java"));
|
||||
int router = source.indexOf("public static void log(LogLevel level, String message)");
|
||||
assertTrue("log(LogLevel, String) not found", router >= 0);
|
||||
|
||||
String body = source.substring(router, source.indexOf("public static void debug(", router));
|
||||
assertTrue(body, body.contains("default -> info(message);"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void slf4jStyleArgumentsAndTrailingThrowableArePreserved() {
|
||||
RuntimeException failure = new RuntimeException("broken");
|
||||
@@ -37,39 +14,4 @@ public class ModdedIrisLogLevelCoverageTest {
|
||||
assertEquals("chunk 4,9 failed", rendered.message());
|
||||
assertEquals(failure, rendered.error());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formattedDebugThrowableUsesTheVisibleDebugRouteWhenEnabled() throws IOException {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/ModdedIrisLog.java"));
|
||||
int start = source.indexOf("public static void debug(String format, Object... arguments)");
|
||||
int end = source.indexOf("public static void info(String message)", start);
|
||||
assertTrue("formatted debug overload not found", start >= 0);
|
||||
assertTrue("formatted debug overload boundary not found", end > start);
|
||||
String body = source.substring(start, end);
|
||||
|
||||
assertTrue(body, body.contains("if (!debugEnabled())"));
|
||||
assertTrue(body, body.contains("LOGGER.info(\"[Iris/DEBUG] \" + clean(rendered.message()), rendered.error())"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void moddedProductionUsesTheIrisLogFrontDoor() throws IOException {
|
||||
Path root = Path.of(System.getProperty("iris.moddedCommonSources"));
|
||||
try (Stream<Path> files = Files.walk(root)) {
|
||||
List<Path> bypasses = files
|
||||
.filter(path -> path.toString().endsWith(".java"))
|
||||
.filter(path -> !path.getFileName().toString().equals("ModdedIrisLog.java"))
|
||||
.filter(path -> {
|
||||
try {
|
||||
String source = Files.readString(path);
|
||||
return source.contains("LoggerFactory.getLogger")
|
||||
|| source.contains("private static final Logger LOGGER");
|
||||
} catch (IOException unreadable) {
|
||||
throw new IllegalStateException(unreadable);
|
||||
}
|
||||
})
|
||||
.toList();
|
||||
assertTrue(bypasses.toString(), bypasses.isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-23
@@ -2,10 +2,6 @@ package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
@@ -48,25 +44,6 @@ public class ModdedLevelDataAttachmentTest {
|
||||
assertNull(failure.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void initializesLevelDataBeforeBindingAndPublication() throws IOException {
|
||||
String source = Files.readString(sourcePath("ModdedDimensionManager.java"));
|
||||
int construction = source.indexOf("ServerLevel level = new ServerLevel(");
|
||||
int initialization = source.indexOf("serverAccess.initializeLevelData(server, level);", construction);
|
||||
int binding = source.indexOf("generator.bindLevel(level);", initialization);
|
||||
int publication = source.indexOf("serverAccess.putLevelIfAbsent(server, key, level);", binding);
|
||||
|
||||
assertTrue(construction >= 0);
|
||||
assertTrue(initialization > construction);
|
||||
assertTrue(binding > initialization);
|
||||
assertTrue(publication > binding);
|
||||
}
|
||||
|
||||
private static Path sourcePath(String fileName) {
|
||||
return Path.of(System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded", fileName);
|
||||
}
|
||||
|
||||
public static final class AttachedLevelData {
|
||||
private RootData rootData;
|
||||
private DimensionKey dimensionKey;
|
||||
|
||||
-436
@@ -1,436 +0,0 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedLifecycleFailureContractTest {
|
||||
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
|
||||
|
||||
@Test
|
||||
public void existingDimensionStagesWithoutReplacingTheLiveEngine() throws IOException {
|
||||
String managerSource = source("ModdedDimensionManager.java");
|
||||
String create = method(managerSource, "private static Handle create(");
|
||||
int existingStart = requiredIndex(create, "if (existing != null");
|
||||
int presentStart = requiredIndex(create, "if (serverAccess.hasLevel(server, key))", existingStart + 1);
|
||||
int injectionStart = requiredIndex(create, "try {", presentStart);
|
||||
|
||||
assertFalse(create.substring(existingStart, presentStart).contains("repointAndBind("));
|
||||
assertFalse(create.substring(presentStart, injectionStart).contains("repointAndBind("));
|
||||
assertBefore(create, "ModdedGenerationHistoryStorage.createOrStage(", "if (existing != null");
|
||||
|
||||
String generatorSource = source("IrisModdedChunkGenerator.java");
|
||||
String repointAndBind = method(generatorSource, "void repointAndBind(");
|
||||
assertBefore(repointAndBind, "ModdedWorldEngines.prepareReplacement(",
|
||||
"ModdedWorldEngines.installReplacement(");
|
||||
assertBefore(repointAndBind, "ModdedWorldEngines.installReplacement(", "this.activePack = pack;");
|
||||
String failure = catchBlock(repointAndBind);
|
||||
assertTrue(failure.contains("ModdedWorldEngines.closeUnregistered(replacement);"));
|
||||
assertTrue(failure.contains("addSuppressed("));
|
||||
|
||||
String worldEnginesSource = source("ModdedWorldEngines.java");
|
||||
String installReplacement = method(worldEnginesSource, "static void installReplacement(");
|
||||
assertTrue(installReplacement.contains("ENGINES.compute("));
|
||||
assertBefore(installReplacement, "close(current);", "return activeReplacement;");
|
||||
assertFalse(installReplacement.contains("catch ("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistentPackUpdatesUseAnExplicitRestartBoundPath() throws IOException {
|
||||
String managerSource = source("ModdedDimensionManager.java");
|
||||
String createPersistent = method(managerSource, "public static Handle createPersistent(");
|
||||
String update = method(managerSource, "public static UpdateResult stagePersistentUpdate(");
|
||||
|
||||
assertBefore(createPersistent, "if (previous != null)", "ModdedDimensionRegistryStore.put(");
|
||||
assertTrue(createPersistent.contains("Use the Iris world update command"));
|
||||
assertTrue(update.contains("ModdedGenerationHistoryStorage.createOrStage("));
|
||||
assertTrue(update.contains("active.history().pendingActivation()"));
|
||||
assertTrue(update.contains("restartRequired()"));
|
||||
assertFalse(update.contains("inject("));
|
||||
assertFalse(update.contains("repointAndBind("));
|
||||
assertBefore(update, "ModdedDimensionRegistryStore.put(server, updated);",
|
||||
"ModdedGenerationHistoryStorage.createOrStage(");
|
||||
assertTrue(update.contains("ModdedDimensionRegistryStore.put(server, previous);"));
|
||||
|
||||
String commandsSource = source("command/ModdedWorldCommands.java");
|
||||
assertTrue(commandsSource.contains("root.then(updateTree());"));
|
||||
String command = method(commandsSource, "private static int update(");
|
||||
assertTrue(command.contains("ModdedDimensionManager.stagePersistentUpdate("));
|
||||
assertTrue(command.contains("Restart the server to activate it"));
|
||||
assertTrue(command.contains("running world remains on its current pack"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void injectionBindsBeforeRegistrationAndRollsBackEveryFailure() throws IOException {
|
||||
String source = source("ModdedDimensionManager.java");
|
||||
String injection = method(source, "private static Handle inject(");
|
||||
|
||||
assertBefore(injection, "generator.bindLevel(level);", "serverAccess.putLevelIfAbsent(server, key, level);");
|
||||
assertBefore(injection, "serverAccess.putLevelIfAbsent(server, key, level);",
|
||||
"server.getPlayerList().addWorldborderListener(level);");
|
||||
assertFalse(injection.contains("serverAccess.putLevel(server, key, previous);"));
|
||||
String failure = catchBlock(injection);
|
||||
assertBefore(failure, "rollbackInjection(", "throw ");
|
||||
|
||||
String rollback = method(source, "void rollbackInjection(");
|
||||
assertBefore(rollback, "serverAccess.hasLevel(server, key)", "serverAccess.removeLevel(server, key);");
|
||||
assertTrue(rollback.contains("generator.unbindEngine("));
|
||||
assertTrue(rollback.contains("level.close();"));
|
||||
assertTrue(rollback.contains("addSuppressed("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistentReinjectionQuarantinesBrokenEntriesAndContinues() throws IOException {
|
||||
String source = source("ModdedStartup.java");
|
||||
String reinjection = method(source, "private static void reinjectPersistentDimensions(");
|
||||
String failure = catchBlock(reinjection);
|
||||
|
||||
assertTrue(failure.contains("ModdedIrisLog.error("));
|
||||
assertFalse(failure.contains("e.toString()"));
|
||||
assertFalse(failure.contains("throw new IllegalStateException("));
|
||||
assertTrue(reinjection.contains("injected++;"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistentReinjectionWaitsForPlayerServicesAndServerStarted() throws IOException {
|
||||
String bootstrapSource = source("ModdedEngineBootstrap.java");
|
||||
String start = method(bootstrapSource, "public static void start(");
|
||||
String serverAboutToStart = method(bootstrapSource, "public static void serverAboutToStart(");
|
||||
String serverStarted = method(bootstrapSource, "public static void serverStarted(");
|
||||
|
||||
assertFalse(start.contains("ModdedStartup.runOnce(server);"));
|
||||
assertFalse(serverAboutToStart.contains("ModdedStartup.runOnce(server);"));
|
||||
assertEquals(1, occurrences(serverStarted, "ModdedStartup.runOnce(server);"));
|
||||
assertBefore(serverStarted, "bindWorldGenerators(server);", "ModdedStartup.runOnce(server);");
|
||||
assertBefore(serverStarted, "ModdedStartup.runOnce(server);", "reconcileSpawn(server);");
|
||||
|
||||
String startupSource = source("ModdedStartup.java");
|
||||
String runOnce = method(startupSource, "public static void runOnce(");
|
||||
assertBefore(runOnce, "server.getPlayerList() == null", "STARTED.compareAndSet(false, true)");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dynamicLevelMutationsInvalidateLoaderTickCaches() throws IOException {
|
||||
String levelsSource = source("ModdedServerLevels.java");
|
||||
String put = method(levelsSource, "public ServerLevel putLevel(");
|
||||
String putIfAbsent = method(levelsSource, "public ServerLevel putLevelIfAbsent(");
|
||||
String remove = method(levelsSource, "public ServerLevel removeLevel(");
|
||||
|
||||
assertBefore(put, "server.levels.put(key, level);", "levelCacheInvalidator.accept(server);");
|
||||
assertTrue(put.contains("if (previous != level)"));
|
||||
assertBefore(putIfAbsent, "server.levels.putIfAbsent(key, level);", "levelCacheInvalidator.accept(server);");
|
||||
assertTrue(putIfAbsent.contains("if (previous == null)"));
|
||||
assertBefore(remove, "server.levels.remove(key);", "levelCacheInvalidator.accept(server);");
|
||||
assertTrue(remove.contains("if (removed != null)"));
|
||||
|
||||
String loaderSource = source("ModdedLoader.java");
|
||||
assertTrue(loaderSource.contains("void invalidateLevelCache(MinecraftServer server);"));
|
||||
String bootstrapSource = source("ModdedEngineBootstrap.java");
|
||||
assertTrue(bootstrapSource.contains("new ModdedServerLevels(boundLoader::invalidateLevelCache)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistentRestoreOpensHistoryBeforeConsultingTheGlobalPack() throws IOException {
|
||||
String source = source("ModdedGenerationHistoryStorage.java");
|
||||
String restore = method(source, "static GenerationHistory restoreOrAdopt(");
|
||||
|
||||
assertBefore(restore, "GenerationHistory.openIfPresent(", "sourceSupplier\").get();");
|
||||
assertTrue(restore.contains("if (existing.isPresent())"));
|
||||
assertBefore(restore, "requireRegistryDefinitions(existing.get()", "return existing.get();");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void productionPackResolutionNeverUsesTheParityProbeAsData() throws IOException {
|
||||
String source = source("ModdedWorldEngines.java");
|
||||
String resolvePack = method(source, "static File resolvePack(");
|
||||
|
||||
assertFalse(source.contains("System.getProperty(\"iris.parity\")"));
|
||||
assertFalse(source.contains("parityPack"));
|
||||
assertFalse(source.contains("falling back to parity pack"));
|
||||
assertTrue(resolvePack.contains("throw new IllegalStateException("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void engineBootstrapResetsStartupExactlyOnceDuringStop() throws IOException {
|
||||
String source = source("ModdedEngineBootstrap.java");
|
||||
String stop = method(source, "public static void stop(");
|
||||
|
||||
assertEquals(1, occurrences(stop, "ModdedStartup::reset"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void engineBootstrapAttemptsEveryShutdownStageAndAggregatesFailures() throws IOException {
|
||||
String source = source("ModdedEngineBootstrap.java");
|
||||
String stop = method(source, "public static void stop(");
|
||||
|
||||
assertBefore(stop, "\"services\"", "\"world engines\"");
|
||||
assertBefore(stop, "\"world engines\"", "\"dimension manager\"");
|
||||
assertBefore(stop, "\"server state\"", "if (failure != null)");
|
||||
assertFalse(stop.contains("throw"));
|
||||
assertTrue(stop.contains("ModdedIrisLog.error(\"Iris modded shutdown completed with failures\", failure);"));
|
||||
|
||||
String runStage = method(source, "private static Throwable runStopStage(");
|
||||
assertTrue(runStage.contains("catch (Throwable stageFailure)"));
|
||||
assertTrue(runStage.contains("failure.addSuppressed(stageFailure);"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void worldShutdownRemovesOnlySuccessfullyClosedMappings() throws IOException {
|
||||
String source = source("ModdedWorldEngines.java");
|
||||
String shutdown = method(source, "public static void shutdown()");
|
||||
|
||||
assertTrue(shutdown.contains("new ArrayList<>(ENGINES.entrySet())"));
|
||||
assertFalse(shutdown.contains("ENGINES.clear()"));
|
||||
// Bound generators must go through unbindEngine (latch unloading, then strict evict) so a
|
||||
// late chunk-system drain cannot rebuild an engine nothing closes again.
|
||||
assertTrue(shutdown.contains("generator.unbindEngine(level);"));
|
||||
assertBefore(shutdown, "close(engine);", "ENGINES.remove(level, engine)");
|
||||
assertTrue(shutdown.contains("ENGINES.containsKey(level)"));
|
||||
assertTrue(shutdown.contains("failure.addSuppressed(e);"));
|
||||
assertTrue(shutdown.contains("failed mappings were retained"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void engineEvictionRetainsTheMappingUntilCloseSucceeds() throws IOException {
|
||||
String source = source("ModdedWorldEngines.java");
|
||||
String eviction = method(source, "static void evictOrThrow(");
|
||||
|
||||
assertTrue(eviction.contains("ENGINES.computeIfPresent("));
|
||||
assertFalse(eviction.contains("ENGINES.remove("));
|
||||
assertBefore(eviction, "close(current);", "return null;");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatorUnbindRetainsOwnershipWhenEvictionFails() throws IOException {
|
||||
String source = source("IrisModdedChunkGenerator.java");
|
||||
String unbind = method(source, "synchronized void unbindEngine(ServerLevel level)");
|
||||
|
||||
assertFalse(unbind.contains("finally"));
|
||||
assertBefore(unbind, "unloading = true;", "ModdedWorldEngines.evictOrThrow(level);");
|
||||
assertBefore(unbind, "ModdedWorldEngines.evictOrThrow(level);", "clearEngineBinding();");
|
||||
String binding = method(source, "private Engine bindEngine(ServerLevel level)");
|
||||
assertTrue(binding.contains("requireBindingAllowed();"));
|
||||
assertTrue(binding.contains("requireCompletedShutdown(cached);"));
|
||||
String bindLevel = method(source, "synchronized void bindLevel(ServerLevel level)");
|
||||
assertBefore(bindLevel, "requireCompletedShutdown(engine);", "unloading = false;");
|
||||
String boundEngine = method(source, "public Engine engineIfBound()");
|
||||
assertTrue(boundEngine.contains("unloading || current == null || current.isClosing() || current.isClosed()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loaderUnloadSurfacesCloseFailureAndDynamicRemovalUsesStrictEviction() throws IOException {
|
||||
String bootstrapSource = source("ModdedEngineBootstrap.java");
|
||||
String unload = method(bootstrapSource, "public static void levelUnloaded(ServerLevel level)");
|
||||
String failure = catchBlock(unload);
|
||||
assertTrue(failure.contains("ModdedIrisLog.error("));
|
||||
assertTrue(failure.contains("throw "));
|
||||
|
||||
String managerSource = source("ModdedDimensionManager.java");
|
||||
String remove = method(managerSource, "public static boolean remove(MinecraftServer server, String dimensionId, boolean wipeStorage)");
|
||||
assertTrue(remove.contains("ModdedWorldEngines.evictOrThrow(level);"));
|
||||
assertFalse(remove.contains("ModdedWorldEngines.evict(level);"));
|
||||
assertTrue(remove.contains("unloadEventStarted = true;"));
|
||||
assertTrue(remove.contains("rollbackRemoval(server, serverAccess, key, level, generator, unloadEventStarted, e);"));
|
||||
|
||||
String rollback = method(managerSource, "private static void rollbackRemoval(");
|
||||
assertTrue(rollback.contains("serverAccess.hasLevel(server, key)"));
|
||||
assertTrue(rollback.contains("generator.bindLevel(level);"));
|
||||
assertTrue(rollback.contains("failure.addSuppressed(rollbackFailure);"));
|
||||
assertTrue(rollback.contains("ModdedIrisLog.error("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void engineBootstrapRollsBackEveryPublishedBindingBeforeRethrowing() throws IOException {
|
||||
String source = source("ModdedEngineBootstrap.java");
|
||||
String bind = method(source, "public static ModdedPlatform bind(");
|
||||
String failure = catchBlock(bind);
|
||||
|
||||
assertBefore(bind, "createdServices.enableAll();", "runtime = new BoundRuntime(");
|
||||
assertTrue(bind.contains("rollback.add(() -> GuiHost.suppressDesktop("));
|
||||
assertTrue(bind.contains("rollback.add(() -> restorePlatform("));
|
||||
assertTrue(bind.contains("rollback.add(() -> ModdedDimensionManager.restoreAccess("));
|
||||
assertTrue(bind.contains("rollback.add(() -> IrisObjectRotation.restorePlatformRotator("));
|
||||
assertTrue(bind.contains("rollback.add(() -> BlockDataMergeSupport.restorePlatformMerger("));
|
||||
assertTrue(bind.contains("rollback.add(() -> TileData.restorePlatformReader("));
|
||||
assertTrue(bind.contains("rollback.add(() -> TileData.restorePlatformFactory("));
|
||||
assertTrue(bind.contains("rollback.add(() -> GuiHost.set("));
|
||||
assertTrue(bind.contains("rollback.add(() -> DecoratorPlatformHooks.restore("));
|
||||
assertTrue(bind.contains("bindService(PreservationRegistry.class"));
|
||||
assertTrue(bind.contains("bindService(EngineEffectsProvider.class"));
|
||||
assertTrue(bind.contains("bindService(EnginePlatformHooks.class"));
|
||||
assertTrue(bind.contains("bindService(EngineWorldManagerProvider.class"));
|
||||
assertTrue(bind.contains("rollback.add(customContentDiscovery::rollback);"));
|
||||
assertBefore(failure, "createdServices.rollback(failure);", "rollback.restore(failure);");
|
||||
assertBefore(failure, "rollback.restore(failure);", "throw ");
|
||||
}
|
||||
|
||||
private static void assertBefore(String source, String first, String second) {
|
||||
int firstIndex = requiredIndex(source, first);
|
||||
int secondIndex = requiredIndex(source, second);
|
||||
assertTrue(first + " must occur before " + second, firstIndex < secondIndex);
|
||||
}
|
||||
|
||||
private static int requiredIndex(String source, String needle) {
|
||||
return requiredIndex(source, needle, 0);
|
||||
}
|
||||
|
||||
private static int requiredIndex(String source, String needle, int start) {
|
||||
int index = source.indexOf(needle, start);
|
||||
assertTrue("Missing source contract token: " + needle, index >= 0);
|
||||
return index;
|
||||
}
|
||||
|
||||
private static String source(String fileName) throws IOException {
|
||||
String sourceRoot = System.getProperty(SOURCE_ROOT_PROPERTY);
|
||||
assertTrue("Missing system property " + SOURCE_ROOT_PROPERTY,
|
||||
sourceRoot != null && !sourceRoot.isBlank());
|
||||
Path sourcePath = Path.of(sourceRoot, "art", "arcane", "iris", "modded", fileName);
|
||||
return Files.readString(sourcePath);
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = requiredIndex(source, signature);
|
||||
int openBrace = openingBrace(source, start);
|
||||
return source.substring(start, matchingBrace(source, openBrace) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the block of the shallowest catch in the method, i.e. the one attached to the
|
||||
* method's outermost try. Textual first-match would return a nested catch instead, and a
|
||||
* nested catch handles a different failure than the one these contracts pin.
|
||||
*/
|
||||
private static String catchBlock(String source) {
|
||||
int bodyStart = openingBrace(source, 0);
|
||||
int depth = 0;
|
||||
int bestDepth = Integer.MAX_VALUE;
|
||||
int catchIndex = -1;
|
||||
int i = bodyStart;
|
||||
while (i < source.length()) {
|
||||
int skipped = skipNonCode(source, i);
|
||||
if (skipped > i) {
|
||||
i = skipped;
|
||||
continue;
|
||||
}
|
||||
char current = source.charAt(i);
|
||||
if (current == '{') {
|
||||
depth++;
|
||||
} else if (current == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
break;
|
||||
}
|
||||
} else if (current == 'c' && depth < bestDepth && source.startsWith("catch (", i)) {
|
||||
bestDepth = depth;
|
||||
catchIndex = i;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
assertTrue("Missing source contract token: catch (", catchIndex >= 0);
|
||||
return blockAt(source, openingBrace(source, catchIndex));
|
||||
}
|
||||
|
||||
private static String blockAt(String source, int openBrace) {
|
||||
return source.substring(openBrace, matchingBrace(source, openBrace) + 1);
|
||||
}
|
||||
|
||||
private static int openingBrace(String source, int start) {
|
||||
int i = start;
|
||||
while (i < source.length()) {
|
||||
int skipped = skipNonCode(source, i);
|
||||
if (skipped > i) {
|
||||
i = skipped;
|
||||
continue;
|
||||
}
|
||||
if (source.charAt(i) == '{') {
|
||||
return i;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
throw new IllegalArgumentException("No source block after offset " + start);
|
||||
}
|
||||
|
||||
private static int matchingBrace(String source, int openBrace) {
|
||||
int depth = 0;
|
||||
int i = openBrace;
|
||||
while (i < source.length()) {
|
||||
int skipped = skipNonCode(source, i);
|
||||
if (skipped > i) {
|
||||
i = skipped;
|
||||
continue;
|
||||
}
|
||||
char current = source.charAt(i);
|
||||
if (current == '{') {
|
||||
depth++;
|
||||
} else if (current == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
throw new IllegalArgumentException("Unclosed source block");
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances past a comment, string, char or text block starting at the index, otherwise returns
|
||||
* the index unchanged. Comments must be skipped: an apostrophe in prose ("generator's") would
|
||||
* otherwise open a char literal and swallow the rest of the file.
|
||||
*/
|
||||
private static int skipNonCode(String source, int index) {
|
||||
char current = source.charAt(index);
|
||||
if (current == '/' && index + 1 < source.length()) {
|
||||
char next = source.charAt(index + 1);
|
||||
if (next == '/') {
|
||||
int end = source.indexOf('\n', index);
|
||||
return end < 0 ? source.length() : end + 1;
|
||||
}
|
||||
if (next == '*') {
|
||||
int end = source.indexOf("*/", index + 2);
|
||||
return end < 0 ? source.length() : end + 2;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
if (current == '"' && source.startsWith("\"\"\"", index)) {
|
||||
int end = source.indexOf("\"\"\"", index + 3);
|
||||
return end < 0 ? source.length() : end + 3;
|
||||
}
|
||||
if (current == '"' || current == '\'') {
|
||||
return endOfLiteral(source, index, current);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
private static int endOfLiteral(String source, int index, char terminator) {
|
||||
for (int i = index + 1; i < source.length(); i++) {
|
||||
char current = source.charAt(i);
|
||||
if (current == '\\') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (current == terminator) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return source.length();
|
||||
}
|
||||
|
||||
private static int occurrences(String source, String needle) {
|
||||
int count = 0;
|
||||
int offset = 0;
|
||||
while (true) {
|
||||
int index = source.indexOf(needle, offset);
|
||||
if (index < 0) {
|
||||
return count;
|
||||
}
|
||||
count++;
|
||||
offset = index + needle.length();
|
||||
}
|
||||
}
|
||||
}
|
||||
-37
@@ -5,13 +5,9 @@ import art.arcane.iris.engine.object.IrisEntitySpawn;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedSpawnerRarityParityTest {
|
||||
private static IrisEntitySpawn spawn(int rarity) {
|
||||
@@ -39,37 +35,4 @@ public class ModdedSpawnerRarityParityTest {
|
||||
assertEquals(1, IRare.get(spawn(-5)));
|
||||
assertEquals(3, IRare.get(spawn(3)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void perPositionSpawnLoopsDoNotRerollEntryRarity() throws Exception {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/ModdedWorldManager.java"));
|
||||
|
||||
for (String method : List.of("private int spawnEntry(", "private int spawnEntryAt(")) {
|
||||
String body = methodBody(source, method);
|
||||
assertFalse(method + " must not re-roll rarity per position; rarityPick already weighted the pool",
|
||||
body.contains("getRarity()"));
|
||||
assertTrue(method + " must keep the min/max spawn-count roll",
|
||||
body.contains("LootResolver.inclusive("));
|
||||
}
|
||||
}
|
||||
|
||||
private static String methodBody(String source, String declaration) {
|
||||
int start = source.indexOf(declaration);
|
||||
assertTrue("ModdedWorldManager must declare " + declaration, start >= 0);
|
||||
int open = source.indexOf('{', start);
|
||||
int depth = 0;
|
||||
for (int index = open; index < source.length(); index++) {
|
||||
char character = source.charAt(index);
|
||||
if (character == '{') {
|
||||
depth++;
|
||||
} else if (character == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return source.substring(open + 1, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new AssertionError(declaration + " is not brace balanced");
|
||||
}
|
||||
}
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedStructurePlacementFailureContractTest {
|
||||
@Test
|
||||
public void structurePlacementPropagatesRuntimeFailuresWithContext() throws IOException {
|
||||
Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources"),
|
||||
"art/arcane/iris/modded/ModdedStructureHooks.java");
|
||||
String source = Files.readString(sourcePath);
|
||||
int methodStart = source.indexOf("public int[] placeStructure(");
|
||||
int methodEnd = source.indexOf("\n @Override", methodStart + 1);
|
||||
String method = source.substring(methodStart, methodEnd);
|
||||
int catchStart = method.indexOf("catch (RuntimeException error)");
|
||||
|
||||
assertTrue(catchStart >= 0);
|
||||
String failurePath = method.substring(catchStart);
|
||||
assertTrue(failurePath.contains("throw NativeStructureGenerationException.failure("));
|
||||
assertTrue(failurePath.contains("\"capture placement\", structureKey, chunkX, chunkZ, error"));
|
||||
assertFalse(failurePath.contains("return null;"));
|
||||
assertFalse(failurePath.contains("catch (Throwable"));
|
||||
}
|
||||
}
|
||||
+4
-11
@@ -4,8 +4,6 @@ import org.junit.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -64,14 +62,9 @@ public class ModdedStructureTemplateCacheTest {
|
||||
config = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
assertTrue(config.contains("\"StructureTemplatePaletteConcurrencyMixin\""));
|
||||
|
||||
Path source = Path.of(System.getProperty("iris.moddedCommonSources"))
|
||||
.resolve("art/arcane/iris/modded/mixin/StructureTemplatePaletteConcurrencyMixin.java");
|
||||
String mixin = Files.readString(source);
|
||||
assertTrue(mixin.contains("@Mixin(StructureTemplate.Palette.class)"));
|
||||
assertTrue(mixin.contains("@Shadow\n @Final\n @Mutable"));
|
||||
assertTrue(mixin.contains("private Map<Block, List<StructureTemplate.StructureBlockInfo>> cache;"));
|
||||
assertTrue(mixin.contains("@Inject(method = \"<init>(Ljava/util/List;)V\", at = @At(\"RETURN\"))"));
|
||||
assertTrue(mixin.contains("cache = new ConcurrentHashMap<>();"));
|
||||
assertNotNull(Class.forName(
|
||||
"art.arcane.iris.modded.mixin.StructureTemplatePaletteConcurrencyMixin",
|
||||
false,
|
||||
ModdedStructureTemplateCacheTest.class.getClassLoader()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,6 @@ import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@@ -26,32 +23,6 @@ public class ModdedWorldCheckTest {
|
||||
assertTrue(thread.isDaemon());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void poiAuditRunsInASecondServerTaskAfterVillageGeneration() throws IOException {
|
||||
Path sourceRoot = Path.of(System.getProperty("iris.moddedCommonSources"));
|
||||
String source = Files.readString(sourceRoot.resolve("art/arcane/iris/modded/ModdedWorldCheck.java"));
|
||||
String auditSource = Files.readString(
|
||||
sourceRoot.resolve("art/arcane/iris/modded/WorldCheckStructureAudit.java"));
|
||||
int preparationSubmit = source.indexOf(
|
||||
"WorldCheckPreparation preparation = serverRef.submit(() -> run(serverRef))");
|
||||
int completionSubmit = source.indexOf(
|
||||
"exitCode = serverRef.submit(() -> runAndRequestStop(", preparationSubmit);
|
||||
int completionMethod = source.indexOf("private static boolean completeWorldCheck");
|
||||
int deferredAudit = source.indexOf(
|
||||
"PoiAudit poi = WorldCheckStructureAudit.auditStructurePois", completionMethod);
|
||||
int structureMethod = auditSource.indexOf("private static StructureCheckResult checkNativeStructure");
|
||||
int structureMethodEnd = auditSource.indexOf("private static StructureStart resolveStructureStart",
|
||||
structureMethod);
|
||||
String structureSource = auditSource.substring(structureMethod, structureMethodEnd);
|
||||
|
||||
assertTrue(preparationSubmit >= 0);
|
||||
assertTrue(completionSubmit > preparationSubmit);
|
||||
assertTrue(deferredAudit > completionMethod);
|
||||
assertFalse(structureSource.contains("auditStructurePois"));
|
||||
assertFalse(source.contains("prepareDeferredAudits"));
|
||||
assertFalse(auditSource.contains("prepareDeferredAudits"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validStructureStartIsGenerationEvidence() {
|
||||
assertTrue(WorldCheckPredicates.hasNativeStructureEvidence(true, 0));
|
||||
|
||||
-90
@@ -6,11 +6,6 @@ import net.minecraft.server.Bootstrap;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
@@ -22,25 +17,6 @@ public class NativeStructureFailureContractTest {
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void structureLocateDoesNotCatchAndFallThroughToAnotherImplementation() throws IOException {
|
||||
String source = moddedSource("IrisModdedChunkGenerator.java");
|
||||
int locateStart = source.indexOf("public Pair<BlockPos, Holder<Structure>> findNearestMapStructure");
|
||||
int locateEnd = source.indexOf("public boolean isNativeStructureReachable", locateStart);
|
||||
String locate = source.substring(locateStart, locateEnd);
|
||||
String stage = moddedSource("ModdedNativeStructureStage.java");
|
||||
int filterStart = stage.indexOf("HolderSet<Structure> filterReachableNativeStructures");
|
||||
int filterEnd = stage.indexOf("void adjustGeneratedStructures", filterStart);
|
||||
String filter = stage.substring(filterStart, filterEnd);
|
||||
|
||||
assertTrue(locate.contains("Engine current = engine();"));
|
||||
assertFalse(locate.contains("catch (Throwable"));
|
||||
assertFalse(locate.contains("return null;\n } catch"));
|
||||
assertTrue(filter.contains("unregistered structure holder"));
|
||||
assertFalse(filter.contains("catch (Throwable"));
|
||||
assertFalse(filter.contains("failed closed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void globalStructureDisableRefusesGeneratorBinding() {
|
||||
IrisModdedChunkGenerator.requireGlobalStructureGeneration(true, "overworld:overworld");
|
||||
@@ -56,66 +32,6 @@ public class NativeStructureFailureContractTest {
|
||||
assertTrue(error.getMessage().contains("importedStructures.disabledExact"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void structureVegetationCleanupPrecedesTerrainPreparationAndPlacement() throws IOException {
|
||||
String source = moddedSource("ModdedNativeStructureStage.java");
|
||||
int placementStart = source.indexOf("void placeVanillaStructures");
|
||||
int placementEnd = source.indexOf("private static String nativeStructureBatchContext", placementStart);
|
||||
String placement = source.substring(placementStart, placementEnd);
|
||||
|
||||
assertTrue(placement.contains("\"terrain integration\""));
|
||||
assertTrue(placement.contains("\"terrain preparation\""));
|
||||
assertTrue(placement.contains("\"foundation repair\""));
|
||||
assertFalse(placement.contains("\"terrain carving\""));
|
||||
assertTrue(placement.contains("visitExistingPois(chunk"));
|
||||
assertTrue(placement.contains("level.updatePOIOnBlockStateChange("));
|
||||
assertTrue(placement.contains("Blocks.AIR.defaultBlockState(), state"));
|
||||
assertTrue(placement.indexOf("visitExistingPois(chunk")
|
||||
< placement.indexOf("WorldgenTerrainHeightmaps.primeStructurePlacement("));
|
||||
assertTrue(placement.contains("prepareSurfaceStructures"));
|
||||
assertTrue(placement.contains("clearIntersectingVegetation"));
|
||||
assertTrue(placement.indexOf("clearIntersectingVegetation")
|
||||
< placement.indexOf("prepareSurfaceStructures"));
|
||||
assertTrue(placement.indexOf("prepareSurfaceStructures")
|
||||
< placement.indexOf("for (NativePlacementGroup group"));
|
||||
assertTrue(placement.indexOf("repairVacuumFoundations")
|
||||
> placement.indexOf("for (NativePlacementGroup group"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void structurePlacementPrimesNeighbourWorldgenHeightmapsBeforeTerrainPreparation() throws IOException {
|
||||
String source = moddedSource("ModdedNativeStructureStage.java");
|
||||
int placementStart = source.indexOf("void placeVanillaStructures");
|
||||
int placementEnd = source.indexOf("private static String nativeStructureBatchContext", placementStart);
|
||||
String placement = source.substring(placementStart, placementEnd);
|
||||
|
||||
assertTrue(source.contains("import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps;"));
|
||||
assertTrue(placement.contains("WorldgenTerrainHeightmaps.primeStructurePlacement("));
|
||||
assertTrue(placement.contains("\"heightmap priming\""));
|
||||
assertTrue(placement.contains("heightmapStarts.add(start);"));
|
||||
assertTrue(placement.indexOf("WorldgenTerrainHeightmaps.primeStructurePlacement(")
|
||||
< placement.indexOf("prepareSurfaceStructures"));
|
||||
assertTrue(source.contains("Engine.hostHeight(generationEngine, x, z, false) + runtimeMinY + 1"));
|
||||
assertTrue(source.contains("Engine.hostHeight(generationEngine, x, z, true) + runtimeMinY + 1"));
|
||||
assertTrue(placement.contains("current.getDimensionStackContext() != null"));
|
||||
assertTrue(source.contains("int minY = chunk.getMinY() + 1;"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeStructurePostProcessingUsesBoundedWorldgenAccess() throws IOException {
|
||||
String source = moddedSource("ModdedNativeStructureStage.java");
|
||||
int placementStart = source.indexOf("void placeVanillaStructures");
|
||||
int placementEnd = source.indexOf("private List<List<Structure>> structuresByStep", placementStart);
|
||||
String placement = source.substring(placementStart, placementEnd);
|
||||
|
||||
assertTrue(placement.contains("ModdedNativeStructureWorldgenAccess.create("));
|
||||
assertTrue(placement.contains("nativeStructureProtection(current, staticObjects)"));
|
||||
assertTrue(placement.contains("Engine.hostHeight(current, x, z, true)"));
|
||||
assertTrue(placement.contains("placeVanillaStructure(boundedWorld, structureManager"));
|
||||
assertFalse(placement.contains("NativeStructurePostProcessor.place(world,"));
|
||||
assertTrue(placement.contains("world.setCurrentlyGenerating(null);"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void structureFailurePreservesPhaseIdentityChunkAndCause() {
|
||||
IllegalArgumentException cause = new IllegalArgumentException("broken placement");
|
||||
@@ -128,10 +44,4 @@ public class NativeStructureFailureContractTest {
|
||||
assertTrue(error.getMessage().contains("12,-8"));
|
||||
assertTrue(error.getMessage().contains("aborted"));
|
||||
}
|
||||
|
||||
private static String moddedSource(String fileName) throws IOException {
|
||||
Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources"),
|
||||
"art", "arcane", "iris", "modded", fileName);
|
||||
return Files.readString(sourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
-183
@@ -7,9 +7,6 @@ import com.mojang.datafixers.util.Pair;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -48,30 +45,6 @@ public class IrisModdedStructureCommandTest {
|
||||
assertEquals(1, nativeReferences.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void gotoStructureSupportsIrisAndNativeRegistryTargets() throws IOException {
|
||||
String source = source("ModdedLocateCommands.java");
|
||||
String suggestions = source("ModdedCommandSuggestions.java");
|
||||
|
||||
assertTrue(source.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, key)"));
|
||||
assertTrue(source.contains("registry.get(identifier)"));
|
||||
assertTrue(source.contains("getPlacementsForStructure(holder)"));
|
||||
assertTrue(source.contains("generator.findNearestMapStructure("));
|
||||
assertTrue(source.contains("NATIVE_STRUCTURE_LOCATE_RADIUS = 100"));
|
||||
assertTrue(source.contains("HolderSet.direct(target.holder())"));
|
||||
assertFalse(source.contains("NativeStructureLocateCapability"));
|
||||
assertTrue(source.contains("boolean teleported = player.teleportTo("));
|
||||
assertTrue(suggestions.contains("combineStructureKeys(unregisteredIrisKeys, nativeKeys)"));
|
||||
assertTrue(source.contains("irisGenerator.isNativeStructureReachable(holder)"));
|
||||
assertTrue(source.contains("LocateStatus.SEARCH_LIMIT_REACHED"));
|
||||
assertTrue(source.contains("IRIS_MODDED_COMMANDS_UNABLE_LOCATE_IRIS_PLACED_STRUCTURE_DENSITY_SEARCH_SAFETY_LIMIT_WAS"));
|
||||
assertTrue(source.contains("int targetX = result.originX()"));
|
||||
assertTrue(source.contains("int targetY = result.baseY() + 2"));
|
||||
assertTrue(source.contains("int targetZ = result.originZ()"));
|
||||
assertFalse(source.contains("at[0] + 8"));
|
||||
assertFalse(source.contains("at[2] + 8"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registeredStructureEligibilityMatchesGotoAndSuggestions() {
|
||||
IrisNativeStructureDecision nativeDecision = decision(NativeStructureGenerationStatus.GENERATE_NATIVE);
|
||||
@@ -175,162 +148,6 @@ public class IrisModdedStructureCommandTest {
|
||||
.contains("non-positive density or a Y band outside"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unregisteredCommandUsesGotoEligibilityAndPrintsToConsole() throws IOException {
|
||||
String command = source("ModdedUnregisteredStructures.java");
|
||||
String tree = source("ModdedCommandTree.java");
|
||||
|
||||
assertTrue(tree.contains("Commands.literal(\"unregistered\")"));
|
||||
assertTrue(tree.contains("ModdedUnregisteredStructures.print(context.getSource())"));
|
||||
assertTrue(command.contains("ModdedCommandSuggestions.isEligibleRegisteredStructure("));
|
||||
assertTrue(command.contains("ModdedLocateCommands.registeredStructureUnavailableMessage("));
|
||||
assertTrue(command.contains("engine.getData().getStructureLoader().getPossibleKeys()"));
|
||||
assertTrue(command.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, key)"));
|
||||
assertTrue(command.contains("ModdedIrisLog.info(\"[Iris goto unregistered] [{}] {} - {}\""));
|
||||
assertTrue(command.contains("UNREGISTERED(\"unregistered\")"));
|
||||
assertFalse(command.contains("DatapackIngestService"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void structureSuggestionsUseActiveLevelAvailability() throws IOException {
|
||||
String suggestions = source("ModdedCommandSuggestions.java");
|
||||
int methodStart = suggestions.indexOf("static CompletableFuture<Suggestions> suggestStructureKeys(");
|
||||
int methodEnd = suggestions.indexOf("static boolean isEligibleRegisteredStructure(", methodStart);
|
||||
String method = suggestions.substring(methodStart, methodEnd);
|
||||
|
||||
assertTrue(method.contains("ServerLevel level = source.getLevel()"));
|
||||
assertTrue(method.contains("Engine engine = IrisModdedCommands.engineFor(level)"));
|
||||
assertFalse(method.contains("IrisStructureLocator.locatableKeys(engine)"));
|
||||
assertTrue(method.contains("IrisStructureLocator.locatableEditableKeys(engine)"));
|
||||
assertTrue(method.contains("StructureReachability.reachableKeys(engine)"));
|
||||
assertTrue(method.contains("NativeStructureGenerationPolicy.resolve(engine, key, false)"));
|
||||
assertTrue(method.contains("IrisStructureLocator.hasLocatableNativePlacement(engine, key)"));
|
||||
assertTrue(method.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, key)"));
|
||||
assertTrue(method.contains("reachableNativeKeys.contains(normalizeKey(key))"));
|
||||
assertTrue(method.contains("nativeGenerationEnabled"));
|
||||
assertTrue(method.contains("eligibleUnregisteredEditableKeys("));
|
||||
assertTrue(method.contains("IrisStructureLocator.hasNativePlacement(engine, candidate)"));
|
||||
assertFalse(method.contains("NativeStructureGenerationKeys.active(level)"));
|
||||
assertFalse(method.contains("getPlacementsForStructure"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatorLocateUsesEveryIrisPlacedNativeStructure() throws IOException {
|
||||
String source = moddedSource("ModdedNativeStructureStage.java");
|
||||
int methodStart = source.indexOf("Pair<BlockPos, Holder<Structure>> findNearestIrisStructure(");
|
||||
int methodEnd = source.indexOf("HolderSet<Structure> filterReachableNativeStructures(", methodStart);
|
||||
String method = source.substring(methodStart, methodEnd);
|
||||
int registryLookup = method.indexOf("level.registryAccess().lookupOrThrow(Registries.STRUCTURE)");
|
||||
int placedCheck = method.indexOf(
|
||||
"if (!IrisStructureLocator.hasNativePlacement(current, structureId))");
|
||||
int irisLocate = method.indexOf("NativeStructureLocatePersistence.search(", placedCheck);
|
||||
int nearestSelection = method.indexOf(
|
||||
"NativeStructureLocateResults.nearest(pos, predicted, nativeLocated)", irisLocate);
|
||||
int selectedVerification = method.indexOf("bestSearch.search().verify(bestResult)", nearestSelection);
|
||||
int selectedReference = method.indexOf(
|
||||
"selectedSearch.search().reference(selectedStart)", selectedVerification);
|
||||
|
||||
assertTrue(registryLookup >= 0);
|
||||
assertTrue(placedCheck > registryLookup);
|
||||
assertTrue(irisLocate > placedCheck);
|
||||
assertTrue(nearestSelection > irisLocate);
|
||||
assertTrue(selectedVerification > nearestSelection);
|
||||
assertTrue(selectedReference > selectedVerification);
|
||||
assertTrue(method.contains("NativeStructureLocatePersistence.probe("));
|
||||
assertTrue(method.contains("findUnexplored"));
|
||||
assertTrue(method.contains("LocateStatus.SEARCH_LIMIT_REACHED"));
|
||||
assertTrue(method.contains("verified.ownership().locatorY()"));
|
||||
assertTrue(method.contains("selectedSearch.search().reference(selectedStart)"));
|
||||
assertTrue(method.contains("NativeStructureLocateResults.selectAndReference("));
|
||||
assertFalse(method.contains("NativeStructureLocateCapability"));
|
||||
assertTrue(source.contains("structureBiomeSource.isStructureReachable(holder)"));
|
||||
assertFalse(source.contains("isPaperUnavailable"));
|
||||
String generator = moddedSource("IrisModdedChunkGenerator.java");
|
||||
assertTrue(generator.contains("NativeStructureVanillaLocator.predict("));
|
||||
assertFalse(generator.contains("super.findNearestMapStructure(level, reachable"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void commandResolvesNativePolicyBeforeAnyVanillaAliasLookup() throws IOException {
|
||||
String source = source("ModdedLocateCommands.java");
|
||||
int methodStart = source.indexOf("static int gotoStructure(");
|
||||
int methodEnd = source.indexOf("private static void locateIrisStructure(", methodStart);
|
||||
String method = source.substring(methodStart, methodEnd);
|
||||
int nativeResolution = method.indexOf("resolveNativeStructure(source, level, engine, key)");
|
||||
int editableIrisLookup = method.indexOf(
|
||||
"IrisStructureLocator.hasLocatableEditablePlacement(engine, key)", nativeResolution);
|
||||
int policyResolution = method.indexOf(
|
||||
"NativeStructureGenerationPolicy.resolve(engine, target.key(), false)", editableIrisLookup);
|
||||
int eligibilityCheck = method.indexOf(
|
||||
"ModdedCommandSuggestions.isEligibleRegisteredStructure(", policyResolution);
|
||||
int replacementLocate = method.indexOf(
|
||||
"locateIrisStructure(source, level, engine, player, target.key())", eligibilityCheck);
|
||||
int nativeLocate = method.indexOf("runNativeStructureLocate(source, level, player, target)",
|
||||
replacementLocate);
|
||||
|
||||
assertTrue(nativeResolution >= 0);
|
||||
assertTrue(editableIrisLookup > nativeResolution);
|
||||
assertTrue(policyResolution > editableIrisLookup);
|
||||
assertTrue(eligibilityCheck > policyResolution);
|
||||
assertTrue(replacementLocate > eligibilityCheck);
|
||||
assertTrue(nativeLocate > replacementLocate);
|
||||
assertTrue(method.contains("IrisStructureLocator.hasLocatableNativePlacement(engine, target.key())"));
|
||||
assertTrue(method.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, target.key())"));
|
||||
assertTrue(method.contains("!IrisStructureLocator.hasNativePlacement(engine, key)"));
|
||||
assertTrue(method.contains("StructureReachability.isReachable(engine, target.key())"));
|
||||
assertTrue(method.contains("getWorldGenSettings().options().generateStructures()"));
|
||||
assertFalse(method.contains("IrisStructureLocator.isPlaced(engine, key)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifyResolvesRegisteredNativeBeforeGenericIrisAliases() throws IOException {
|
||||
String source = source("ModdedLocateCommands.java");
|
||||
int methodStart = source.indexOf("private static int verifyStructure(");
|
||||
int methodEnd = source.indexOf("private static Optional<NativeStructureTarget> resolveNativeStructure(",
|
||||
methodStart);
|
||||
String method = source.substring(methodStart, methodEnd);
|
||||
int nativeResolution = method.indexOf("resolveNativeStructure(source, level, engine, key)");
|
||||
int genericIrisLookup = method.indexOf("IrisStructureLocator.isPlaced(engine, key)", nativeResolution);
|
||||
|
||||
assertTrue(nativeResolution >= 0);
|
||||
assertTrue(genericIrisLookup > nativeResolution);
|
||||
assertTrue(method.contains("NativeStructureAvailability.IRIS_SUPPRESSED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void structureVerificationNoLongerClaimsNativeGenerationIsMissing() throws IOException {
|
||||
String source = source("ModdedStructureCommands.java");
|
||||
|
||||
assertTrue(source.contains("verifyTree(\"verify\")"));
|
||||
assertTrue(source.contains("verifyTree(\"locateall\")"));
|
||||
assertTrue(source.contains("IrisModdedCommands.verifyStructures("));
|
||||
assertFalse(source.contains("Vanilla structure locate is meaningless here"));
|
||||
}
|
||||
|
||||
private String source(String fileName) throws IOException {
|
||||
Path source = commonSourceRoot().resolve("art/arcane/iris/modded/command/")
|
||||
.resolve(fileName)
|
||||
.normalize();
|
||||
return Files.readString(source);
|
||||
}
|
||||
|
||||
private String moddedSource(String fileName) throws IOException {
|
||||
Path source = commonSourceRoot().resolve("art/arcane/iris/modded/")
|
||||
.resolve(fileName)
|
||||
.normalize();
|
||||
return Files.readString(source);
|
||||
}
|
||||
|
||||
private Path commonSourceRoot() {
|
||||
String configuredRoot = System.getProperty("iris.moddedCommonSources");
|
||||
if (configuredRoot != null && !configuredRoot.isBlank()) {
|
||||
return Path.of(configuredRoot);
|
||||
}
|
||||
return Path.of(System.getProperty("user.dir"))
|
||||
.resolve("../modded-common/src/main/java")
|
||||
.normalize();
|
||||
}
|
||||
|
||||
private IrisNativeStructureDecision decision(NativeStructureGenerationStatus status) {
|
||||
return new IrisNativeStructureDecision(status, 0, null, false, null, null);
|
||||
}
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class ModdedRiverLocateContractTest {
|
||||
@Test
|
||||
public void riverLookupMergesRecordedAndEligibleActiveFeatures() throws IOException {
|
||||
Path sourcePath = Path.of(System.getProperty("iris.moddedCommonSources"))
|
||||
.resolve("art/arcane/iris/modded/command/ModdedLocateCommands.java");
|
||||
String source = Files.readString(sourcePath);
|
||||
int methodStart = source.indexOf("static int gotoRiver(");
|
||||
int methodEnd = source.indexOf("static int gotoStructure(", methodStart);
|
||||
String method = source.substring(methodStart, methodEnd);
|
||||
|
||||
assertTrue(method.contains("GenerationSemanticQueries.nearestRiver("));
|
||||
assertTrue(method.contains("runtime == null"));
|
||||
assertTrue(method.contains("feature.y() + 2"));
|
||||
assertFalse(method.contains("runtime.nearestFeature("));
|
||||
assertFalse(method.contains("feature.y() + engine.getDimension().getMinHeight()"));
|
||||
}
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class ModdedStructureHistoryContractTest {
|
||||
@Test
|
||||
public void retainedStructureNamesReachTheSemanticSearchOnBothAdmissionBranches() throws IOException {
|
||||
Path root = Path.of(System.getProperty("iris.moddedCommonSources"));
|
||||
String source = Files.readString(root.resolve("art/arcane/iris/modded/command/ModdedLocateCommands.java"));
|
||||
int start = source.indexOf("static int gotoStructure(");
|
||||
int end = source.indexOf("static String registeredStructureUnavailableMessage(", start);
|
||||
String admission = source.substring(start, end);
|
||||
String retainedCheck = "GenerationFindCatalog.hasRetainedStructurePlacement(engine, key)";
|
||||
int firstCheck = admission.indexOf(retainedCheck);
|
||||
assertTrue(firstCheck >= 0);
|
||||
assertTrue(admission.indexOf(retainedCheck, firstCheck + retainedCheck.length()) >= 0);
|
||||
|
||||
int locateStart = source.indexOf("private static void locateIrisStructure(");
|
||||
int locateEnd = source.indexOf("private static void runNativeStructureLocate(", locateStart);
|
||||
String locate = source.substring(locateStart, locateEnd);
|
||||
assertTrue(locate.contains("GenerationSemanticQueries.nearestStructure("));
|
||||
assertFalse(locate.contains("IrisStructureLocator.locate("));
|
||||
|
||||
String suggestions = Files.readString(root.resolve("art/arcane/iris/modded/command/ModdedCommandSuggestions.java"));
|
||||
assertTrue(suggestions.contains("GenerationFindCatalog.retainedStructureKeys(engine)"));
|
||||
}
|
||||
}
|
||||
-89
@@ -1,89 +0,0 @@
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedStudioTeleportContractTest {
|
||||
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
|
||||
|
||||
@Test
|
||||
public void studioOpenSerializesReplacementAndWaitsForTheNativePath() throws IOException {
|
||||
String source = source("command/ModdedStudioCommands.java");
|
||||
String open = method(source, "private static int open(");
|
||||
String execute = method(source, "private static void executeStudioOpen(");
|
||||
|
||||
assertTrue(open.contains("TRANSITIONS.submit("));
|
||||
assertFalse(open.contains("orTimeout("));
|
||||
assertFalse(open.contains("deadlineNanos"));
|
||||
assertBefore(execute, "replaceExistingStudio(", "ModdedDimensionManager.createTransientStudio(");
|
||||
assertBefore(execute, "ModdedDimensionManager.createTransientStudio(", "ModdedDimensionManager.teleportAsync(");
|
||||
assertFalse(execute.contains("deadlineNanos"));
|
||||
assertFalse(source.contains("player.teleportTo(studio"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void studioTeleportAndVisionShareWarmFutureSemantics() throws IOException {
|
||||
String studio = source("command/ModdedStudioCommands.java");
|
||||
String vision = source("command/ModdedVisionOverlay.java");
|
||||
String dimensions = source("ModdedDimensionManager.java");
|
||||
|
||||
assertTrue(studio.contains("private static CompletableFuture<Void> teleportToStudio("));
|
||||
assertTrue(studio.contains("ModdedDimensionManager.teleportAsync("));
|
||||
assertTrue(dimensions.contains("TELEPORT_WARM_RADIUS = 0"));
|
||||
assertTrue(dimensions.contains("addTicketAndLoadWithRadius(\n"
|
||||
+ " TELEPORT_WARM_TICKET,\n"
|
||||
+ " chunkPos,\n"
|
||||
+ " TELEPORT_WARM_RADIUS)"));
|
||||
assertTrue(dimensions.contains("removeTicketWithRadius(\n"
|
||||
+ " TELEPORT_WARM_TICKET,\n"
|
||||
+ " chunkPos,\n"
|
||||
+ " TELEPORT_WARM_RADIUS)"));
|
||||
assertTrue(vision.contains("Math.floor(worldX)"));
|
||||
assertTrue(vision.contains("Math.floor(worldZ)"));
|
||||
assertTrue(vision.contains("if (opener != null)"));
|
||||
assertTrue(vision.contains("ModdedDimensionManager.teleportAsync("));
|
||||
assertFalse(vision.contains("level.getHeight("));
|
||||
assertFalse(vision.contains("player.teleportTo("));
|
||||
}
|
||||
|
||||
private static String source(String relative) throws IOException {
|
||||
String root = System.getProperty(SOURCE_ROOT_PROPERTY);
|
||||
if (root == null || root.isBlank()) {
|
||||
throw new IllegalStateException("Missing test property " + SOURCE_ROOT_PROPERTY);
|
||||
}
|
||||
return Files.readString(Path.of(root).resolve("art/arcane/iris/modded").resolve(relative))
|
||||
.replace("\r\n", "\n");
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
if (start < 0) {
|
||||
throw new IllegalArgumentException("Missing source method " + signature);
|
||||
}
|
||||
int open = source.indexOf('{', start);
|
||||
int depth = 0;
|
||||
for (int index = open; index < source.length(); index++) {
|
||||
char current = source.charAt(index);
|
||||
if (current == '{') {
|
||||
depth++;
|
||||
} else if (current == '}' && --depth == 0) {
|
||||
return source.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unclosed source method " + signature);
|
||||
}
|
||||
|
||||
private static void assertBefore(String source, String first, String second) {
|
||||
int firstIndex = source.indexOf(first);
|
||||
int secondIndex = source.indexOf(second);
|
||||
assertTrue("Missing source token " + first, firstIndex >= 0);
|
||||
assertTrue("Missing source token " + second, secondIndex >= 0);
|
||||
assertTrue(first + " must precede " + second, firstIndex < secondIndex);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -2,6 +2,7 @@ package art.arcane.iris.modded.localization;
|
||||
|
||||
import art.arcane.iris.core.localization.IrisLanguage;
|
||||
import art.arcane.iris.core.localization.IrisMessages;
|
||||
import art.arcane.iris.testsupport.ProjectPaths;
|
||||
import art.arcane.volmlib.util.localization.MessageKey;
|
||||
import art.arcane.volmlib.util.localization.VolmitLocales;
|
||||
import com.google.gson.JsonObject;
|
||||
@@ -27,7 +28,7 @@ public class ModdedMessageContributorTest {
|
||||
|
||||
@Test
|
||||
public void completeLoaderCatalogStillMatchesEveryDownloadableLocale() throws Exception {
|
||||
Path sourceRoot = Path.of("../../core/src/main/resources/languages");
|
||||
Path sourceRoot = ProjectPaths.repositoryFile("core/src/main/resources/languages");
|
||||
for (String locale : VolmitLocales.nonEnglish()) {
|
||||
JsonObject document = JsonParser.parseString(Files.readString(sourceRoot.resolve(locale + ".json")))
|
||||
.getAsJsonObject();
|
||||
|
||||
@@ -132,6 +132,7 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
|
||||
dependencies {
|
||||
testImplementation('junit:junit:4.13.2')
|
||||
testImplementation('org.mockito:mockito-core:5.23.0')
|
||||
testImplementation(testFixtures('art.arcane:core'))
|
||||
testImplementation(libs.spigot) {
|
||||
transitive = false
|
||||
}
|
||||
@@ -222,7 +223,6 @@ tasks.named('compileJava', JavaCompile).configure {
|
||||
|
||||
tasks.named('test').configure {
|
||||
useJUnitPlatform()
|
||||
systemProperty('iris.moddedCommonSources', file('../modded-common/src/main/java').absolutePath)
|
||||
}
|
||||
|
||||
processResources {
|
||||
|
||||
+1
-1
@@ -164,6 +164,7 @@ nmsBindings.each { key, value ->
|
||||
testImplementation('junit:junit:4.13.2')
|
||||
testImplementation('org.mockito:mockito-core:5.23.0')
|
||||
testImplementation(project(':core'))
|
||||
testImplementation(testFixtures(project(':core')))
|
||||
testCompileOnly(volmLibCoordinate) {
|
||||
transitive = false
|
||||
}
|
||||
@@ -599,7 +600,6 @@ List<String> requiredBukkitArtifactEntries = [
|
||||
'art/arcane/iris/engine/platform/PlatformChunkGenerator.class',
|
||||
'art/arcane/iris/core/lifecycle/WorldLifecycleStaging.class',
|
||||
'art/arcane/iris/util/simd/VectorSimdKernels.class',
|
||||
'art/arcane/iris/util/simd/VectorNoiseKernels2D.class',
|
||||
'art/arcane/iris/util/project/agent/Agent.class',
|
||||
'art/arcane/iris/util/common/misc/getHardware.class',
|
||||
'art/arcane/volmlib/util/io/JarScanner.class',
|
||||
|
||||
@@ -35,6 +35,16 @@ public final class GenerationRevisionScope {
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPregen.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawAdopt.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawConnector.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawExports.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawOpen.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPiece.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPool.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPreview.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawRules.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawVariant.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawWorkcell.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java",
|
||||
"adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/PaperCommandRegistrar.java",
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user