(removed ai file from when i generated docs)
This commit is contained in:
Brian Neumann-Fopiano
2026-08-10 15:47:57 -04:00
parent ebfe278b3b
commit 998a5c9f5f
256 changed files with 49776 additions and 1221 deletions
+2
View File
@@ -62,3 +62,5 @@ __pycache__/
# Doc authoring scratch
.docs-wip/
AGENTS.md
-77
View File
@@ -1,77 +0,0 @@
# Iris Agent Guide
Iris is a world generation engine for Minecraft servers and mod loaders. It generates terrain, biomes, caves, structures, objects, and entities from editable JSON packs, with an in-game studio authoring workflow. The same engine runs as a Bukkit-family plugin and as a Fabric, Forge, or NeoForge server mod. Read this file before making any change; the workspace-level `../AGENTS.md` also applies when working inside the VolmitSoftware workspace.
## Documentation Policy (mandatory)
- `docs/` is the authoritative reference for every feature of this plugin/mod. Files are flat (no subfolders) and numbered `NN - Title.md`, ordered for someone new to Iris; API docs always keep the highest numbers.
- ANY change that alters a feature, command, permission, setting, pack JSON contract, studio/editor workflow, pregen behavior, structure/object system, integration, localization, client HUD/protocol, or public API surface MUST update the matching numbered doc in the same workstream. A behavior change with stale docs is an incomplete change — do not finish work without the doc update.
- Docs state actual runtime behavior, not intended behavior. If a change fixes a documented quirk, update or remove that quirk entry. If a change introduces surprising behavior, document it plainly.
- Docs are purely factual reference material: no marketing language, no emojis, no filler. Each file opens with a 14 sentence summary.
- Cross-references use exact filenames (for example `see "04 - Commands & Permissions.md"`). When adding or renumbering files, fix every cross-reference.
- Hosted external docs are not authority; this `docs/` tree is.
- Maintainer-only checklists use high numbers before the API series and are titled `Maintainer — …`.
## Doc Index
| File | Covers |
|------|--------|
| `00 - Overview.md` | What Iris is, feature map, doc index, project layout |
| `01 - Installation & Platforms.md` | Plugin/mod install, data dirs, first boot, platforms, native worldgen matrix |
| `02 - Getting Started.md` | First world, teleport, basic pregen, first studio |
| `03 - Configuration.md` | `settings.json` keys, defaults, hotload |
| `04 - Commands & Permissions.md` | Full `/iris` tree, Bukkit vs modded, permissions |
| `05 - Concepts & Pack Layout.md` | Pack roots, keys, folders, snippets, world snapshot vs studio |
| `06 - Worlds & Lifecycle.md` | create/load/unload/remove, main world, Folia, pack copy |
| `07 - Pregeneration.md` | pregen ops, cache, mantle, HUD |
| `08 - Localization.md` | locales, overrides, client lang |
| `09 - PlaceholderAPI.md` | `%iris_…%` keys and migration |
| `10 - Studio & VSCode Schemas.md` | Studio workflow, schemas, hotload |
| `11 - Dimensions.md` | Dimension JSON, modes, height, imports |
| `12 - Regions.md` | Regions and region-level content |
| `13 - Biomes.md` | Biome JSON, layers, custom biomes, spawns |
| `14 - Generators & Noise.md` | Generators, styles, expressions, images |
| `15 - Caves & Carving.md` | Cave profiles, field modules, carving |
| `16 - Surfaces, Decorators & Deposits.md` | Decorators, deposits, palettes |
| `17 - Trees, Fungi, Coral, Crystals, Formations, Ruins.md` | Procedural decoration systems |
| `18 - Structures Overview.md` | Objects vs jigsaw vs native structures |
| `19 - Objects.md` | Creating and importing `.iob` objects |
| `20 - Object Placement.md` | Placing objects in biomes and regions |
| `21 - Jigsaw Structures.md` | Iris multi-piece structures |
| `22 - Native Structures & Datapacks.md` | Vanilla/datapack structures on Iris |
| `23 - Loot, Entities, Spawners, Markers.md` | Pack entities and loot |
| `24 - Pack Mods & Snippets.md` | Injectors/replacers and snippets |
| `25 - Pack Management.md` | Download, validate, cleanup, package, update-world |
| `26 - Example - Minimal Dimension.md` | Minimal pack walkthrough |
| `27 - Example - Configuring Overworld.md` | Editing the shipping overworld |
| `28 - Integrations.md` | WorldEdit, Multiverse, Mythic, item plugins, tree feller |
| `29 - Client HUD & Protocol.md` | Client mod HUD and protocol |
| `30 - Platform Differences.md` | Bukkit vs Fabric/Forge/NeoForge matrix |
| `31 - Operator Runbooks & Smoke Tests.md` | Manual verification |
| `32 - Determinism & Goldenhash.md` | Cross-platform parity gate |
| `33 - Performance Tuning.md` | Threads, mantle, SIMD, pregen caps |
| `85 - Maintainer - MC Version Bump.md` | Version bump procedure |
| `86 - Maintainer - Release Checklist.md` | Release steps |
| `87 - Maintainer - Release Readiness.md` | Living readiness tracker |
| `90 - API - Getting Started.md` | Bukkit public API setup |
| `91 - API - Terrain.md` | Terrain query service |
| `92 - API - World Events.md` | Engine and pregen events |
| `93 - API - Tree Feller.md` | Tree feller service |
| `94 - API - Modded.md` | Modded public API |
Docs `00``33` serve operators and pack authors in reading order; `85``87` are maintainer; `90``94` serve plugin and mod developers.
## Build and Platforms
- Java 25 required. Independent Gradle build from `Iris/`: `./gradlew build`, `./gradlew test`.
- Artifacts: Bukkit-family plugin jar; Fabric, Forge, and NeoForge mod jars under `dist/` when built.
- Modules: `core` (engine), `spi` (platform SPI), `adapters/bukkit/plugin` (plugin + Bukkit API), `adapters/modded-common` + loader adapters, `probe` (offline tooling).
- Default pack downloads at first boot from the IrisDimensions overworld release; packs live under the platform data directory `packs/<key>/`.
## Content Model (brief)
- **Pack** — directory of JSON and `.iob` resources under `packs/<key>/` with at least `dimensions/*.json`.
- **Dimension** — root config for a world type (height, modes, regions, imports).
- **Region / Biome / Generator** — spatial and terrain authoring units.
- **Object / Structure** — placed content (`.iob`, Iris jigsaw, native/datapack structures).
- **Studio** — transient authoring world with live pack hotload and VSCode schemas.
@@ -0,0 +1,289 @@
package art.arcane.iris.core.nms.v26_2_R1;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import net.minecraft.core.Holder;
import net.minecraft.core.Vec3i;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
final class DatapackStructureStateFilter {
private DatapackStructureStateFilter() {
}
static Selection filter(
List<Holder<StructureSet>> structureSets,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources
) {
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
for (Holder<StructureSet> holder : structureSets) {
String key = structureSetKey(holder);
if (key != null) {
holdersByKey.putIfAbsent(key, holder);
}
}
Map<Holder<StructureSet>, Holder<StructureSet>> scopedByIdentity = new IdentityHashMap<>();
Set<Holder<StructureSet>> visiting = Collections.newSetFromMap(new IdentityHashMap<>());
List<Holder<StructureSet>> filteredSets = new ArrayList<>(structureSets.size());
int retainedManagedSets = 0;
int excludedManagedSets = 0;
for (Holder<StructureSet> holder : structureSets) {
String setKey = structureSetKey(holder);
boolean managedSet = setKey != null && scopeIndex.isManagedStructureSet(setKey);
Holder<StructureSet> scopedHolder = scopeHolder(
holder,
scopeIndex,
declaredSources,
holdersByKey,
scopedByIdentity,
visiting);
if (scopedHolder == null) {
if (managedSet) {
excludedManagedSets++;
}
continue;
}
if (managedSet) {
retainedManagedSets++;
}
filteredSets.add(scopedHolder);
}
return new Selection(
List.copyOf(filteredSets),
retainedManagedSets,
excludedManagedSets);
}
static String structureSetKey(Holder<StructureSet> holder) {
Optional<ResourceKey<StructureSet>> key = holder.unwrapKey();
if (key.isPresent()) {
return key.get().identifier().toString();
}
if (holder.value().placement()
instanceof ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement) {
return keyedPlacement.key.identifier().toString();
}
return null;
}
private static List<StructureSet.StructureSelectionEntry> filterEntries(
List<StructureSet.StructureSelectionEntry> entries,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources
) {
List<StructureSet.StructureSelectionEntry> filtered = new ArrayList<>(entries.size());
for (StructureSet.StructureSelectionEntry entry : entries) {
String structureKey = entry.structure().unwrapKey()
.map(key -> key.identifier().toString())
.orElse(null);
if (structureKey == null || scopeIndex.allowsStructure(structureKey, declaredSources)) {
filtered.add(entry);
}
}
return filtered;
}
private static Holder<StructureSet> scopeHolder(
Holder<StructureSet> holder,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources,
Map<String, Holder<StructureSet>> holdersByKey,
Map<Holder<StructureSet>, Holder<StructureSet>> scopedByIdentity,
Set<Holder<StructureSet>> visiting
) {
if (scopedByIdentity.containsKey(holder)) {
return scopedByIdentity.get(holder);
}
if (!visiting.add(holder)) {
return null;
}
try {
String setKey = structureSetKey(holder);
if (setKey != null && scopeIndex.isManagedStructureSet(setKey)
&& !scopeIndex.allowsStructureSet(setKey, declaredSources)) {
scopedByIdentity.put(holder, null);
return null;
}
StructureSet originalSet = holder.value();
List<StructureSet.StructureSelectionEntry> entries = filterEntries(
originalSet.structures(), scopeIndex, declaredSources);
if (entries.isEmpty()) {
scopedByIdentity.put(holder, null);
return null;
}
StructurePlacement placement = scopePlacement(
originalSet.placement(),
scopeIndex,
declaredSources,
holdersByKey,
scopedByIdentity,
visiting);
if (entries.size() == originalSet.structures().size()
&& placement == originalSet.placement()) {
scopedByIdentity.put(holder, holder);
return holder;
}
Holder<StructureSet> scoped = Holder.direct(new StructureSet(entries, placement));
scopedByIdentity.put(holder, scoped);
return scoped;
} finally {
visiting.remove(holder);
}
}
static Optional<StructurePlacement.ExclusionZone> exclusionZone(StructurePlacement placement) {
Object value = declaredFieldValue(StructurePlacement.class, placement, Optional.class);
if (value instanceof Optional<?> optional && (optional.isEmpty()
|| optional.get() instanceof StructurePlacement.ExclusionZone)) {
@SuppressWarnings("unchecked")
Optional<StructurePlacement.ExclusionZone> resolved =
(Optional<StructurePlacement.ExclusionZone>) optional;
return resolved;
}
throw new IllegalStateException("Could not read structure placement exclusion zone from "
+ placement.getClass().getName());
}
private static StructurePlacement scopePlacement(
StructurePlacement placement,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources,
Map<String, Holder<StructureSet>> holdersByKey,
Map<Holder<StructureSet>, Holder<StructureSet>> scopedByIdentity,
Set<Holder<StructureSet>> visiting
) {
Optional<StructurePlacement.ExclusionZone> currentZone = exclusionZone(placement);
if (currentZone.isEmpty()) {
return placement;
}
Holder<StructureSet> target = currentZone.get().otherSet();
String targetKey = structureSetKey(target);
if (targetKey != null) {
target = holdersByKey.getOrDefault(targetKey, target);
}
Holder<StructureSet> scopedTarget = scopeHolder(
target,
scopeIndex,
declaredSources,
holdersByKey,
scopedByIdentity,
visiting);
Optional<StructurePlacement.ExclusionZone> scopedZone = Optional.empty();
if (scopedTarget != null) {
scopedZone = Optional.of(new StructurePlacement.ExclusionZone(
scopedTarget,
currentZone.get().chunkCount()));
}
if (scopedTarget == currentZone.get().otherSet()) {
return placement;
}
return copyPlacement(placement, scopedZone);
}
private static StructurePlacement copyPlacement(
StructurePlacement placement,
Optional<StructurePlacement.ExclusionZone> exclusionZone
) {
Vec3i locateOffset = (Vec3i) declaredFieldValue(
StructurePlacement.class, placement, Vec3i.class);
StructurePlacement.FrequencyReductionMethod frequencyReductionMethod =
(StructurePlacement.FrequencyReductionMethod) declaredFieldValue(
StructurePlacement.class,
placement,
StructurePlacement.FrequencyReductionMethod.class);
float frequency = (float) declaredFieldValue(
StructurePlacement.class, placement, float.class);
int salt = (int) declaredFieldValue(
StructurePlacement.class, placement, int.class);
if (placement instanceof ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement) {
return new ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement(
keyedPlacement.key,
locateOffset,
frequencyReductionMethod,
frequency,
salt,
exclusionZone,
keyedPlacement.spacing(),
keyedPlacement.separation(),
keyedPlacement.spreadType());
}
if (placement instanceof RandomSpreadStructurePlacement randomSpread) {
return new RandomSpreadStructurePlacement(
locateOffset,
frequencyReductionMethod,
frequency,
salt,
exclusionZone,
randomSpread.spacing(),
randomSpread.separation(),
randomSpread.spreadType());
}
if (placement instanceof ConcentricRingsStructurePlacement rings) {
return new ConcentricRingsStructurePlacement(
locateOffset,
frequencyReductionMethod,
frequency,
salt,
exclusionZone,
rings.distance(),
rings.spread(),
rings.count(),
rings.preferredBiomes());
}
throw new IllegalStateException("Unsupported structure placement with an exclusion zone: "
+ placement.getClass().getName());
}
private static Object declaredFieldValue(
Class<?> declaringType,
Object target,
Class<?> fieldType
) {
Field match = null;
for (Field field : declaringType.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers()) || field.getType() != fieldType) {
continue;
}
if (match != null) {
throw new IllegalStateException("Ambiguous " + fieldType.getName() + " field on "
+ declaringType.getName());
}
match = field;
}
if (match == null) {
throw new IllegalStateException("Missing " + fieldType.getName() + " field on "
+ declaringType.getName());
}
try {
match.setAccessible(true);
return match.get(target);
} catch (IllegalAccessException e) {
throw new IllegalStateException("Could not read " + fieldType.getName() + " field on "
+ declaringType.getName(), e);
}
}
record Selection(
List<Holder<StructureSet>> structureSets,
int retainedManagedSets,
int excludedManagedSets
) {
}
}
@@ -7,6 +7,8 @@ import art.arcane.iris.engine.framework.IrisStructureLocator;
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.IrisEngine;
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisMaterialPalette;
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
@@ -25,6 +27,7 @@ import art.arcane.iris.nativegen.NativeStructureVerticalPlacer;
import art.arcane.iris.nativegen.NativeStructureVanillaLocator;
import art.arcane.iris.nativegen.NativeStructureVolumeIndex;
import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.data.IrisCustomData;
import art.arcane.iris.util.common.reflect.WrappedField;
@@ -43,6 +46,7 @@ import net.minecraft.core.SectionPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ChunkMap;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.util.random.Weighted;
@@ -91,6 +95,8 @@ import javax.annotation.Nullable;
import java.lang.ref.WeakReference;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
@@ -98,23 +104,32 @@ 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.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.IntBinaryOperator;
public class IrisChunkGenerator extends CustomChunkGenerator {
private static final WrappedField<ChunkGenerator, BiomeSource> BIOME_SOURCE;
private static final WrappedReturningMethod<Heightmap, Object> SET_HEIGHT;
private static final Runnable NO_OP = () -> {
};
private final ChunkGenerator delegate;
private final Engine engine;
private final CustomBiomeSource customBiomeSource;
private final ServerLevel runtimeLevel;
private final @Nullable BukkitChunkGenerator platformGenerator;
private final int runtimeMinY;
private final int runtimeHeight;
private final int runtimeSeaLevel;
private final ConcurrentHashMap<SpawnTableKey, WeightedList<MobSpawnSettings.SpawnerData>> mergedSpawnTables = new ConcurrentHashMap<>();
private final ImportedFeatureStage importedFeatures;
private final AtomicReference<StudioStructureState> retainedStudioStructureState = new AtomicReference<>();
private volatile ReachableStructureCache reachableStructureCache;
private volatile StructureStepCache structureStepCache;
@@ -129,6 +144,10 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
this.customBiomeSource = customBiomeSource;
this.importedFeatures = new ImportedFeatureStage(engine);
ServerLevel level = ((CraftWorld) world).getHandle();
this.runtimeLevel = level;
this.platformGenerator = world.getGenerator() instanceof BukkitChunkGenerator bukkitGenerator
? bukkitGenerator
: null;
this.runtimeMinY = level.getMinY();
this.runtimeHeight = level.getHeight();
this.runtimeSeaLevel = runtimeMinY + engine.getDimension().getFluidHeight();
@@ -154,6 +173,12 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
@Override
public @Nullable Pair<BlockPos, Holder<Structure>> findNearestMapStructure(ServerLevel level, HolderSet<Structure> holders, BlockPos pos, int radius, boolean findUnexplored) {
if (platformGenerator != null && !platformGenerator.shouldGenerateStructures()) {
return null;
}
if (level != runtimeLevel || level.getChunkSource().getGenerator() != this) {
return null;
}
try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_structure_locate");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
HolderSet<Structure> reachable = filterReachableStructures(level, holders);
@@ -325,7 +350,15 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
@Override
public void createStructures(RegistryAccess registryAccess, ChunkGeneratorStructureState structureState, StructureManager structureManager, ChunkAccess access, StructureTemplateManager templateManager, ResourceKey<Level> levelKey) {
try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_structures");
if (platformGenerator != null && !platformGenerator.shouldGenerateStructures()) {
return;
}
if (runtimeLevel.getChunkSource().getGenerator() != this
|| runtimeLevel.getChunkSource().getGeneratorState() != structureState) {
return;
}
try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_create_structures");
GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_structures");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
Map<Structure, StructureStart> previousStarts = new HashMap<>(access.getAllStarts());
super.createStructures(registryAccess, structureState, structureManager, access, templateManager, levelKey);
@@ -409,9 +442,192 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
return delegate.createState(holderlookup, randomstate, i, conf);
}
void retainStudioStructureState(
ServerLevel level,
ChunkMap chunkMap,
ChunkGeneratorStructureState structureState
) {
requireCurrentStructureOwner(level, chunkMap);
StudioStructureState retained = new StudioStructureState(
level,
chunkMap,
Objects.requireNonNull(structureState, "Studio native structure state"));
if (!retainedStudioStructureState.compareAndSet(null, retained)) {
throw new IllegalStateException("Studio native structure state is already retained.");
}
}
StudioStructureState retainedStudioStructureState(ServerLevel level, ChunkMap chunkMap) {
StudioStructureState retained = retainedStudioStructureState.get();
if (retained == null) {
return null;
}
requireCurrentStructureOwner(level, chunkMap);
if (retained.level() != level || retained.chunkMap() != chunkMap) {
throw new IllegalStateException("Retained Studio native structure state belongs to another world runtime.");
}
if (level.getChunkSource().getGeneratorState() != retained.structureState()) {
throw new IllegalStateException("Studio native structure state is no longer current.");
}
return retained;
}
void claimStudioStructureState(StudioStructureState retained) {
if (!retainedStudioStructureState.compareAndSet(retained, null)) {
throw new IllegalStateException("Studio native structure state changed before activation began.");
}
}
void abandonStudioStructureState() {
retainedStudioStructureState.set(null);
}
CompletableFuture<Void> initializeAndPublishStructureState(
ChunkGeneratorStructureState structureState,
StructureStatePublisher publisher
) {
return startStructureStateBootstrap(
structureState,
NO_OP,
() -> publishStructureState(publisher));
}
CompletableFuture<Void> activateStudioStructureState(StudioStructureState retained) {
Objects.requireNonNull(retained, "Retained Studio native structure state");
return startStructureStateBootstrap(
retained.structureState(),
() -> {
StudioStructureState current = retainedStudioStructureState(
retained.level(), retained.chunkMap());
if (current != retained) {
throw new IllegalStateException("Studio native structure state changed before activation.");
}
claimStudioStructureState(retained);
},
NO_OP);
}
private CompletableFuture<Void> startStructureStateBootstrap(
ChunkGeneratorStructureState structureState,
Runnable claim,
Runnable activation
) {
if (!(engine instanceof IrisEngine irisEngine)) {
throw new IllegalStateException("Native structure bootstrap requires an IrisEngine runtime.");
}
AtomicReference<CompletableFuture<Void>> registeredCompletion = new AtomicReference<>();
CompletableFuture<Void> completion = irisEngine.startNativeStructureBootstrap(
claim,
() -> {
CompletableFuture<Void> rings = initializeStructureState(structureState);
registeredCompletion.set(rings);
return rings;
},
() -> {
CompletableFuture<Void> rings = Objects.requireNonNull(
registeredCompletion.get(),
"Registered native structure ring completion");
if (rings.isCompletedExceptionally()) {
throw new IllegalStateException(
"Minecraft native structure ring bootstrap failed before activation.");
}
activation.run();
});
completion.whenComplete((ignored, failure) -> {
if (failure != null) {
Throwable cause = failure instanceof CompletionException && failure.getCause() != null
? failure.getCause()
: failure;
IrisLogging.reportError("Native structure ring bootstrap failed for world '"
+ runtimeLevel.getWorld().getName() + "'.", cause);
}
});
return completion;
}
private CompletableFuture<Void> initializeStructureState(ChunkGeneratorStructureState structureState) {
Map<?, ?> ringPositions = structureRingPositions(structureState);
structureState.ensureStructuresGenerated();
return structureRingCompletion(ringPositions);
}
private Map<?, ?> structureRingPositions(ChunkGeneratorStructureState structureState) {
try {
Field field = structureRingPositionsField();
field.setAccessible(true);
Object value = field.get(structureState);
if (!(value instanceof Map<?, ?> ringPositions)) {
throw new IllegalStateException("Minecraft native structure ring state is unavailable.");
}
return ringPositions;
} catch (IllegalAccessException e) {
throw new IllegalStateException("Could not bind Minecraft native structure ring completions.", e);
}
}
private CompletableFuture<Void> structureRingCompletion(Map<?, ?> ringPositions) {
List<CompletableFuture<?>> futures = new ArrayList<>(ringPositions.size());
for (Object candidate : ringPositions.values()) {
if (candidate instanceof CompletableFuture<?> future) {
futures.add(future);
} else {
throw new IllegalStateException(
"Minecraft native structure ring completion is not a future.");
}
}
CompletableFuture<?>[] completions = futures.toArray(new CompletableFuture<?>[0]);
return CompletableFuture.allOf(completions);
}
private Field structureRingPositionsField() {
List<Field> candidates = new ArrayList<>(1);
for (Field field : ChunkGeneratorStructureState.class.getDeclaredFields()) {
if (!Map.class.isAssignableFrom(field.getType())) {
continue;
}
Type genericType = field.getGenericType();
if (!(genericType instanceof ParameterizedType parameterizedType)) {
continue;
}
Type[] arguments = parameterizedType.getActualTypeArguments();
if (arguments.length == 2
&& arguments[1].getTypeName().contains(CompletableFuture.class.getName())) {
candidates.add(field);
}
}
if (candidates.size() != 1) {
throw new IllegalStateException("Expected one Minecraft native structure ring-future map, found "
+ candidates.size() + ".");
}
return candidates.getFirst();
}
private void requireCurrentStructureOwner(ServerLevel level, ChunkMap chunkMap) {
if (runtimeLevel != level
|| level.getChunkSource().chunkMap != chunkMap
|| level.getChunkSource().getGenerator() != this) {
throw new IllegalStateException("Iris native structure state no longer belongs to the active world runtime.");
}
}
private void publishStructureState(StructureStatePublisher publisher) {
try {
publisher.publish();
} catch (IllegalAccessException e) {
throw new IllegalStateException("Could not publish Minecraft native structure state.", e);
}
}
@Override
public void createReferences(WorldGenLevel generatoraccessseed, StructureManager structuremanager, ChunkAccess ichunkaccess) {
try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_references");
if (platformGenerator != null && !platformGenerator.shouldGenerateStructures()) {
return;
}
if (runtimeLevel.getChunkSource().getGenerator() != this) {
return;
}
try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_create_references");
GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_references");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
NativeStructureReferenceRepair.createReferences(
engine, generatoraccessseed, structuremanager, ichunkaccess);
@@ -420,7 +636,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
@Override
public CompletableFuture<ChunkAccess> createBiomes(RandomState randomstate, Blender blender, StructureManager structuremanager, ChunkAccess ichunkaccess) {
try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_biomes");
try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_create_biomes");
GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_biomes");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
ichunkaccess.fillBiomesFromNoise(customBiomeSource::getVisibleNoiseBiome, randomstate.sampler());
return CompletableFuture.completedFuture(ichunkaccess);
@@ -439,17 +656,52 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
@Override
public CompletableFuture<ChunkAccess> fillFromNoise(Blender blender, RandomState randomstate, StructureManager structuremanager, ChunkAccess ichunkaccess) {
return delegate.fillFromNoise(blender, randomstate, structuremanager, ichunkaccess)
.thenApply(filled -> {
try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_worldgen_heightmaps");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
primeWorldgenHeightmaps(filled);
return filled;
} catch (GenerationSessionException e) {
throw new IllegalStateException(
"Iris worldgen heightmap priming could not acquire its engine runtime.", e);
}
});
BukkitChunkGenerator.GenerationStagePermit stage = requireNoiseGenerationStage(
ichunkaccess.getPos(),
"bukkit_nms_chunk_pipeline");
GenerationSessionLease lease;
try {
lease = requireGenerationLease("bukkit_nms_chunk_pipeline");
} catch (RuntimeException | Error failure) {
stage.close();
throw failure;
}
try {
CompletableFuture<ChunkAccess> pipeline = delegate
.fillFromNoise(blender, randomstate, structuremanager, ichunkaccess)
.thenApply(filled -> {
try (IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
primeWorldgenHeightmaps(filled);
return filled;
}
});
CompletableFuture<ChunkAccess> completion = new CompletableFuture<>();
pipeline.whenComplete((filled, failure) -> {
boolean cancelled = isCancellationFailure(failure);
lease.close();
stage.close();
if (failure == null) {
completion.complete(filled);
} else if (cancelled) {
completion.cancel(false);
} else {
completion.completeExceptionally(failure);
}
});
return completion;
} catch (RuntimeException | Error failure) {
lease.close();
stage.close();
throw failure;
}
}
private static boolean isCancellationFailure(Throwable failure) {
Throwable current = failure;
while (current instanceof CompletionException && current.getCause() != null) {
current = current.getCause();
}
return current instanceof CancellationException;
}
private void primeWorldgenHeightmaps(ChunkAccess chunkAccess) {
@@ -502,11 +754,12 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
@Override
public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla) {
// Bind-time equivalent for Bukkit: the table is built on the first decorated chunk, which is where a
// feature-order cycle is reported once and degraded to features-off.
importedFeatures.prepare(generatoraccessseed);
try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_biome_decoration");
try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_biome_decoration");
GenerationSessionLease lease = requireGenerationLease("bukkit_nms_biome_decoration");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
// Bind-time equivalent for Bukkit: the table is built on the first decorated chunk, which is where a
// feature-order cycle is reported once and degraded to features-off.
importedFeatures.prepare(generatoraccessseed);
addVanillaDecorations(generatoraccessseed, ichunkaccess, structuremanager);
placeVanillaStructures(generatoraccessseed, ichunkaccess, structuremanager);
// Vanilla's placed-feature pass, on THIS thread. The delegate is still called with
@@ -833,6 +1086,25 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
}
}
private BukkitChunkGenerator.GenerationStagePermit requireGenerationStage(String operation) {
return platformGenerator == null
? BukkitChunkGenerator.GenerationStagePermit.noop()
: platformGenerator.acquireGenerationStage(operation);
}
private BukkitChunkGenerator.GenerationStagePermit requireNoiseGenerationStage(
ChunkPos chunkPos,
String operation
) {
return platformGenerator == null
? BukkitChunkGenerator.GenerationStagePermit.noop()
: platformGenerator.acquireNoiseGenerationStage(
engine,
chunkPos.x(),
chunkPos.z(),
operation);
}
@Override
public Optional<Identifier> getTypeNameForDataFixer() {
return delegate.getTypeNameForDataFixer();
@@ -894,6 +1166,18 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
private record StructureStepCache(Registry<Structure> registry, List<List<Structure>> structures) {
}
record StudioStructureState(
ServerLevel level,
ChunkMap chunkMap,
ChunkGeneratorStructureState structureState
) {
}
@FunctionalInterface
interface StructureStatePublisher {
void publish() throws IllegalAccessException;
}
private record NativePlacement(StructureStart start, IrisNativeStructureDecision decision) {
}
@@ -4,6 +4,8 @@ import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO;
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkHolderManager;
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.NewChunkHolder;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import art.arcane.iris.core.nms.DatapackStructureScopeResult;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.nms.INMSBinding;
import art.arcane.iris.core.nms.MinecraftVersion;
@@ -17,6 +19,7 @@ import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.NativeStructureVolume;
import art.arcane.iris.nativegen.NativeStructureVolumeIndex;
import art.arcane.iris.engine.object.IrisDimensionRuntimeContract;
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.nativegen.NativeStructureFactory;
import art.arcane.iris.nativegen.NativeStructureGenerationException;
@@ -95,6 +98,7 @@ import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.Property;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.chunk.LevelChunkSection;
import net.minecraft.world.level.chunk.ProtoChunk;
@@ -156,6 +160,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@@ -313,8 +318,14 @@ public class NMSBinding implements INMSBinding {
return;
}
var level = ((CraftWorld) pos.getWorld()).getHandle();
var blockPos = new BlockPos(pos.getBlockX(), pos.getBlockY(), pos.getBlockZ());
ServerLevel level = ((CraftWorld) pos.getWorld()).getHandle();
BlockPos blockPos = new BlockPos(pos.getBlockX(), pos.getBlockY(), pos.getBlockZ());
int chunkX = pos.getBlockX() >> 4;
int chunkZ = pos.getBlockZ() >> 4;
if (J.isOwnedByCurrentRegion(pos.getWorld(), chunkX, chunkZ)) {
merge(level, blockPos, tag);
return;
}
if (!J.runAt(pos, () -> merge(level, blockPos, tag))) {
IrisLogging.warn("[NMS] Failed to schedule tile deserialize at " + blockPos + " in world " + pos.getWorld().getName());
}
@@ -1201,6 +1212,121 @@ public class NMSBinding implements INMSBinding {
retargetStructureCheck(level, irisGenerator);
}
@Override
public DatapackStructureScopeResult scopeDatapackStructures(
World world,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources
) throws NoSuchFieldException, IllegalAccessException {
ServerLevel level = ((CraftWorld) world).getHandle();
ChunkMap chunkMap = level.getChunkSource().chunkMap;
ChunkGeneratorStructureState currentState = level.getChunkSource().getGeneratorState();
net.minecraft.world.level.chunk.ChunkGenerator generator = level.getChunkSource().getGenerator();
ChunkGeneratorStructureState scopedState = createStructureState(level, generator, currentState);
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
scopedState.possibleStructureSets(), scopeIndex, declaredSources);
Field possibleSetsField = getField(scopedState.getClass(), List.class);
possibleSetsField.setAccessible(true);
possibleSetsField.set(scopedState, selection.structureSets());
Field stateField = getField(chunkMap.getClass(), ChunkGeneratorStructureState.class);
stateField.setAccessible(true);
BukkitChunkGenerator platformGenerator = world.getGenerator() instanceof BukkitChunkGenerator bukkitGenerator
? bukkitGenerator
: null;
boolean studioBootstrap = platformGenerator != null
&& platformGenerator.isStudioEntryBootstrapActive();
boolean jigsawStudio = platformGenerator != null
&& platformGenerator.isJigsawStudioActive();
if (jigsawStudio) {
requireIrisGenerator(generator);
if (currentState.possibleStructureSets().isEmpty()) {
currentState.ensureStructuresGenerated();
} else {
ChunkGeneratorStructureState bootstrapState = createStructureState(level, generator, currentState);
Field bootstrapSetsField = getField(bootstrapState.getClass(), List.class);
bootstrapSetsField.setAccessible(true);
bootstrapSetsField.set(bootstrapState, List.of());
bootstrapState.ensureStructuresGenerated();
stateField.set(chunkMap, bootstrapState);
}
} else if (studioBootstrap) {
IrisChunkGenerator irisGenerator = requireIrisGenerator(generator);
irisGenerator.retainStudioStructureState(level, chunkMap, scopedState);
try {
stateField.set(chunkMap, scopedState);
} catch (IllegalAccessException | RuntimeException | Error failure) {
irisGenerator.abandonStudioStructureState();
throw failure;
}
} else {
initializeAndPublishStructureState(
generator,
scopedState,
() -> stateField.set(chunkMap, scopedState));
}
return new DatapackStructureScopeResult(
selection.retainedManagedSets(),
selection.excludedManagedSets());
}
@Override
public void completeStudioStructureBootstrap(World world) throws NoSuchFieldException, IllegalAccessException {
ServerLevel level = ((CraftWorld) world).getHandle();
ChunkMap chunkMap = level.getChunkSource().chunkMap;
IrisChunkGenerator generator = requireIrisGenerator(level.getChunkSource().getGenerator());
IrisChunkGenerator.StudioStructureState retained =
generator.retainedStudioStructureState(level, chunkMap);
if (retained == null) {
return;
}
generator.activateStudioStructureState(retained);
}
@Override
public void abandonStudioStructureBootstrap(World world) {
ServerLevel level = ((CraftWorld) world).getHandle();
net.minecraft.world.level.chunk.ChunkGenerator generator = level.getChunkSource().getGenerator();
if (generator instanceof IrisChunkGenerator irisGenerator) {
irisGenerator.abandonStudioStructureState();
}
}
private ChunkGeneratorStructureState createStructureState(
ServerLevel level,
net.minecraft.world.level.chunk.ChunkGenerator generator,
ChunkGeneratorStructureState currentState
) {
return generator.createState(
level.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET),
currentState.randomState(),
currentState.getLevelSeed(),
currentState.conf);
}
private void initializeAndPublishStructureState(
net.minecraft.world.level.chunk.ChunkGenerator generator,
ChunkGeneratorStructureState structureState,
IrisChunkGenerator.StructureStatePublisher publisher
) throws IllegalAccessException {
if (generator instanceof IrisChunkGenerator irisGenerator) {
irisGenerator.initializeAndPublishStructureState(structureState, publisher);
return;
}
structureState.ensureStructuresGenerated();
publisher.publish();
}
private IrisChunkGenerator requireIrisGenerator(
net.minecraft.world.level.chunk.ChunkGenerator generator
) {
if (generator instanceof IrisChunkGenerator irisGenerator) {
return irisGenerator;
}
throw new IllegalStateException("Studio native structure state is not owned by the active Iris generator.");
}
private void validateDimensionContract(Engine engine, World world, ServerLevel level) {
DimensionType actualType = level.dimensionType();
String actualTypeKey = level.dimensionTypeRegistration().unwrapKey()
@@ -5,9 +5,21 @@ import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.LockSupport;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class IrisChunkGeneratorFailureContractTest {
@@ -20,9 +32,17 @@ public class IrisChunkGeneratorFailureContractTest {
int reachabilityStart = source.indexOf("private Set<String> reachableStructureKeys");
int reachabilityEnd = source.indexOf("protected MapCodec", reachabilityStart);
String reachability = source.substring(reachabilityStart, reachabilityEnd);
int bootstrapGate = locate.indexOf("!platformGenerator.shouldGenerateStructures()");
int generatorIdentity = locate.indexOf("level.getChunkSource().getGenerator() != this");
int lease = locate.indexOf("requireGenerationLease(\"bukkit_nms_structure_locate\")");
int nativePrediction = locate.indexOf("NativeStructureVanillaLocator.predict(");
assertTrue(locate.contains("reached its safety limit"));
assertTrue(locate.contains("unregistered structure holder"));
assertTrue(bootstrapGate >= 0);
assertTrue(generatorIdentity > bootstrapGate);
assertTrue(lease > generatorIdentity);
assertTrue(nativePrediction > lease);
assertFalse(locate.contains("catch (Throwable"));
assertFalse(locate.contains("IrisLogging.reportError"));
assertFalse(reachability.contains("catch (Throwable"));
@@ -78,13 +98,101 @@ public class IrisChunkGeneratorFailureContractTest {
int referencesEnd = source.indexOf("public CompletableFuture<ChunkAccess> createBiomes", referencesStart);
String references = source.substring(referencesStart, referencesEnd);
assertTrue(references.contains("requireGenerationLease(\"bukkit_nms_create_references\")"));
int nativeStructureGate = references.indexOf("!platformGenerator.shouldGenerateStructures()");
int generatorIdentity = references.indexOf("runtimeLevel.getChunkSource().getGenerator() != this");
int stage = references.indexOf("requireGenerationStage(\"bukkit_nms_create_references\")");
int lease = references.indexOf("requireGenerationLease(\"bukkit_nms_create_references\")");
int repair = references.indexOf("NativeStructureReferenceRepair.createReferences(");
assertTrue(nativeStructureGate >= 0);
assertTrue(generatorIdentity > nativeStructureGate);
assertTrue(stage > generatorIdentity);
assertTrue(lease > stage);
assertTrue(repair > lease);
assertTrue(references.contains("IrisContext.open(engine, lease.sessionId(), null)"));
assertTrue(references.contains("NativeStructureReferenceRepair.createReferences("));
assertFalse(references.contains("delegate.createReferences("));
assertFalse(references.contains("catch ("));
}
@Test
public void structureGenerationAcquiresBootstrapGateBeforeUsingPublishedState() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
int generationStart = source.indexOf("public void createStructures(");
int generationEnd = source.indexOf("private void adjustGeneratedStructures", generationStart);
String generation = source.substring(generationStart, generationEnd);
int gate = generation.indexOf("!platformGenerator.shouldGenerateStructures()");
int currentGenerator = generation.indexOf(
"runtimeLevel.getChunkSource().getGenerator() != this");
int stateIdentity = generation.indexOf(
"runtimeLevel.getChunkSource().getGeneratorState() != structureState");
int stage = generation.indexOf(
"requireGenerationStage(\"bukkit_nms_create_structures\")");
int lease = generation.indexOf(
"requireGenerationLease(\"bukkit_nms_create_structures\")");
int generationCall = generation.indexOf(
"super.createStructures(registryAccess, structureState");
assertTrue(gate >= 0);
assertTrue(currentGenerator > gate);
assertTrue(stateIdentity > currentGenerator);
assertTrue(stage > stateIdentity);
assertTrue(lease > stage);
assertTrue(generationCall > lease);
assertTrue(generation.contains("structureState,\n structureManager"));
assertFalse(generation.contains("resolvePublishedStructureState"));
}
@Test
public void nativeRingBootstrapTracksTheExactMinecraftFutures() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
int initializeStart = source.indexOf("private CompletableFuture<Void> initializeStructureState(");
int initializeEnd = source.indexOf("private Map<?, ?> structureRingPositions", initializeStart);
String initialize = source.substring(initializeStart, initializeEnd);
int completionStart = source.indexOf(
"private CompletableFuture<Void> structureRingCompletion", initializeEnd);
int completionEnd = source.indexOf("private void requireCurrentStructureOwner", completionStart);
String completion = source.substring(completionStart, completionEnd);
assertBefore(initialize,
"structureRingPositions(structureState)",
"structureState.ensureStructuresGenerated()");
assertBefore(initialize,
"structureState.ensureStructuresGenerated()",
"structureRingCompletion(ringPositions)");
assertFalse(initialize.contains("catch ("));
assertTrue(completion.contains("for (Object candidate : ringPositions.values())"));
assertTrue(completion.contains("CompletableFuture.allOf(completions)"));
assertTrue(completion.contains("throw new IllegalStateException("));
assertFalse(completion.contains("join()"));
assertFalse(completion.contains("get()"));
}
@Test
public void standardActivationClaimsTheExactPublishedStateBeforeInitialization() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
int activationStart = source.indexOf("CompletableFuture<Void> activateStudioStructureState(");
int activationEnd = source.indexOf(
"private CompletableFuture<Void> startStructureStateBootstrap", activationStart);
String activation = source.substring(activationStart, activationEnd);
int bootstrapStart = activationEnd;
int bootstrapEnd = source.indexOf(
"private CompletableFuture<Void> initializeStructureState", bootstrapStart);
String bootstrap = source.substring(bootstrapStart, bootstrapEnd);
assertTrue(activation.contains("retained.structureState()"));
assertBefore(activation,
"retainedStudioStructureState(",
"claimStudioStructureState(retained)");
assertBefore(bootstrap,
"claim,",
"initializeStructureState(");
assertBefore(bootstrap,
"initializeStructureState(",
"activation.run()");
assertFalse(activation.contains("publishStructureState("));
assertFalse(source.contains("ChunkGeneratorStructureState fullState"));
}
@Test
public void terrainWritesPrimeTheWorldgenHeightmapsForEveryChunk() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
@@ -107,10 +215,171 @@ public class IrisChunkGeneratorFailureContractTest {
assertTrue(placement.contains("WorldgenTerrainHeightmaps.primeStructurePlacement("));
assertTrue(placement.indexOf("WorldgenTerrainHeightmaps.primeStructurePlacement(")
< placement.indexOf("prepareSurfaceStructures"));
assertTrue(source.contains("engine.acquireGenerationLease(\"bukkit_nms_worldgen_heightmaps\")"));
assertTrue(fill.contains("requireGenerationLease(\"bukkit_nms_chunk_pipeline\")"));
assertTrue(source.contains("int minY = chunk.getMinY() + 1;"));
}
@Test
public void fillFromNoiseLeaseSpansTheDelegateAndHeightmapPipeline() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
int fillStart = source.indexOf("public CompletableFuture<ChunkAccess> fillFromNoise");
int fillEnd = source.indexOf("private static boolean isCancellationFailure", fillStart);
String fill = source.substring(fillStart, fillEnd);
int completionStart = fill.indexOf("pipeline.whenComplete(");
int completionEnd = fill.indexOf(" return completion;", completionStart);
assertTrue(completionStart >= 0);
assertTrue(completionEnd > completionStart);
String completion = fill.substring(completionStart, completionEnd);
assertBefore(fill,
"BukkitChunkGenerator.GenerationStagePermit stage = requireNoiseGenerationStage(",
"GenerationSessionLease lease");
assertBefore(fill,
"requireNoiseGenerationStage(",
"\"bukkit_nms_chunk_pipeline\")");
assertBefore(fill,
"lease = requireGenerationLease(\"bukkit_nms_chunk_pipeline\")",
".fillFromNoise(blender, randomstate, structuremanager, ichunkaccess)");
assertTrue(fill.contains("IrisContext.open(engine, lease.sessionId(), null)"));
assertBefore(fill, "primeWorldgenHeightmaps(filled)", "pipeline.whenComplete(");
assertTrue(fill.contains("CompletableFuture<ChunkAccess> completion = new CompletableFuture<>()"));
assertBefore(completion, "boolean cancelled = isCancellationFailure(failure);", "lease.close();");
assertBefore(completion, "lease.close();", "stage.close();");
assertBefore(completion, "stage.close();", "completion.complete(filled)");
assertBefore(completion, "stage.close();", "completion.cancel(false)");
assertBefore(completion, "stage.close();", "completion.completeExceptionally(failure)");
assertTrue(completion.contains("else if (cancelled)"));
assertFalse(completion.contains("pipeline.isCancelled()"));
assertFalse(completion.contains("finally"));
assertTrue(fill.contains("catch (RuntimeException | Error failure)"));
assertTrue(fill.contains("lease.close();\n stage.close();\n throw failure;"));
assertTrue(fill.contains("return completion;"));
assertFalse(fill.contains("return pipeline;"));
assertFalse(fill.contains("pipeline.cancel("));
assertFalse(fill.contains("bukkit_nms_worldgen_heightmaps"));
}
@Test
public void releasedNoiseAdmissionLetsSynchronousDependentWaitForQueuedExclusive() throws Exception {
Semaphore admission = new Semaphore(1, true);
admission.acquire();
CountDownLatch exclusiveEntered = new CountDownLatch(1);
CountDownLatch releaseExclusive = new CountDownLatch(1);
AtomicBoolean dependentFinished = new AtomicBoolean(false);
ExecutorService executor = Executors.newSingleThreadExecutor();
boolean stageReleased = false;
try {
Future<?> exclusive = executor.submit(() -> {
boolean acquired = false;
try {
admission.acquire();
acquired = true;
exclusiveEntered.countDown();
assertTrue(releaseExclusive.await(2, TimeUnit.SECONDS));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
} finally {
if (acquired) {
admission.release();
}
}
});
awaitQueueLength(admission, 1);
CompletableFuture<Void> outward = new CompletableFuture<>();
outward.thenRun(() -> {
try {
assertTrue(exclusiveEntered.await(2, TimeUnit.SECONDS));
dependentFinished.set(true);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
});
admission.release();
stageReleased = true;
assertTrue(outward.complete(null));
assertTrue(dependentFinished.get());
releaseExclusive.countDown();
exclusive.get(2, TimeUnit.SECONDS);
assertEquals(1, admission.availablePermits());
} finally {
if (!stageReleased) {
admission.release();
}
releaseExclusive.countDown();
executor.shutdownNow();
}
}
@Test
public void transformedDelegateCancellationIsDetectedThroughItsCompletionFailure() throws IOException {
CompletableFuture<Void> delegate = new CompletableFuture<>();
CompletableFuture<Void> transformed = delegate.thenApply(value -> value);
assertTrue(delegate.cancel(false));
assertFalse(transformed.isCancelled());
CompletionException failure = assertThrows(CompletionException.class, transformed::join);
assertTrue(failure.getCause() instanceof CancellationException);
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
String cancellation = method(
source,
"private static boolean isCancellationFailure",
"private void primeWorldgenHeightmaps");
assertTrue(cancellation.contains("current instanceof CompletionException"));
assertTrue(cancellation.contains("current instanceof CancellationException"));
}
@Test
public void noiseAdmissionCanPrepareStudioBeforeAcquiringTheGenerationLease() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
String fill = method(
source,
"public CompletableFuture<ChunkAccess> fillFromNoise",
"private static boolean isCancellationFailure");
String admission = method(
source,
"private BukkitChunkGenerator.GenerationStagePermit requireNoiseGenerationStage",
"public Optional<Identifier> getTypeNameForDataFixer");
assertBefore(fill,
"requireNoiseGenerationStage(",
"requireGenerationLease(\"bukkit_nms_chunk_pipeline\")");
assertTrue(admission.contains("platformGenerator.acquireNoiseGenerationStage("));
assertBefore(admission, "engine,", "chunkPos.x(),");
assertBefore(admission, "chunkPos.x(),", "chunkPos.z(),");
assertFalse(fill.contains("requireGenerationStage(\"bukkit_nms_chunk_pipeline\")"));
}
@Test
public void everyTopLevelMoonriseStageEntersTheFairGateBeforeItsGenerationLease() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
String structures = method(source, "public void createStructures(", "private void adjustGeneratedStructures");
String references = method(source, "public void createReferences(", "public CompletableFuture<ChunkAccess> createBiomes");
String biomes = method(source, "public CompletableFuture<ChunkAccess> createBiomes", "public void buildSurface");
String noise = method(source, "public CompletableFuture<ChunkAccess> fillFromNoise", "private static boolean isCancellationFailure");
String decoration = method(source,
"public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla)",
"public BiomeGenerationSettings getBiomeGenerationSettings");
assertStageBeforeLease(structures, "bukkit_nms_create_structures");
assertStageBeforeLease(references, "bukkit_nms_create_references");
assertStageBeforeLease(biomes, "bukkit_nms_create_biomes");
assertBefore(noise,
"requireNoiseGenerationStage(",
"requireGenerationLease(\"bukkit_nms_chunk_pipeline\")");
assertStageBeforeLease(decoration, "bukkit_nms_biome_decoration");
assertBefore(decoration,
"requireGenerationLease(\"bukkit_nms_biome_decoration\")",
"importedFeatures.prepare(generatoraccessseed)");
assertEquals(4, occurrences(source, "requireGenerationStage(\"bukkit_nms_"));
assertEquals(2, occurrences(source, "requireNoiseGenerationStage("));
assertFalse(source.contains("GenerationSessionManager"));
}
@Test
public void worldgenHeightmapPrimingLivesInTheSharedNativegenSources() throws IOException {
Path nativegen = Path.of(System.getProperty("iris.nativeStructurePostProcessorSource")).getParent();
@@ -174,6 +443,37 @@ public class IrisChunkGeneratorFailureContractTest {
return count;
}
private static void awaitQueueLength(Semaphore semaphore, int expected) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
while (semaphore.getQueueLength() < expected && System.nanoTime() < deadline) {
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
}
assertTrue("Expected at least " + expected + " queued semaphore threads",
semaphore.getQueueLength() >= expected);
}
private static String method(String source, String startToken, String endToken) {
int start = source.indexOf(startToken);
int end = source.indexOf(endToken, start);
assertTrue("Missing source contract token: " + startToken, start >= 0);
assertTrue("Missing source contract token: " + endToken, end > start);
return source.substring(start, end);
}
private static void assertStageBeforeLease(String source, String operation) {
assertBefore(source,
"requireGenerationStage(\"" + operation + "\")",
"requireGenerationLease(\"" + operation + "\")");
}
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);
}
@Test
public void vanillaChunkGenerationMobsUseTheVisibleBiomesVanillaDerivative() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
@@ -0,0 +1,334 @@
package art.arcane.iris.core.nms.v26_2_R1;
import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import com.mojang.datafixers.util.Either;
import net.minecraft.SharedConstants;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderOwner;
import net.minecraft.core.Vec3i;
import net.minecraft.core.component.DataComponentMap;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureSet;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadType;
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
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 java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class NMSBindingDatapackStructureScopeTest {
private static final String SOURCE = "https://example.test/managed.zip";
@BeforeClass
public static void bootstrapMinecraft() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void managedSetContainingVanillaStructureIsAbsentOutsideDeclaringDimension() {
Holder<Structure> vanillaStructure = structureHolder("minecraft:pillager_outpost");
Holder<StructureSet> managedSet = structureSetHolder(
"managed:illager_barracks", vanillaStructure);
DatapackStructureScopeIndex index = index(
List.of(),
List.of("managed:illager_barracks"));
DatapackStructureStateFilter.Selection vanilla = DatapackStructureStateFilter.filter(
List.of(managedSet), index, Set.of());
DatapackStructureStateFilter.Selection declaring = DatapackStructureStateFilter.filter(
List.of(managedSet), index, index.declaredSources(List.of(SOURCE)));
assertEquals(0, vanilla.structureSets().size());
assertEquals(1, vanilla.excludedManagedSets());
assertEquals(1, declaring.structureSets().size());
assertSame(managedSet, declaring.structureSets().getFirst());
}
@Test
public void unmanagedSetRetainsOnlyDefinitionsAllowedInTheWorld() {
Holder<Structure> vanillaStructure = structureHolder("minecraft:village_plains");
Holder<Structure> managedStructure = structureHolder("managed:tavern");
Holder<StructureSet> vanillaSet = structureSetHolder(
"minecraft:villages", vanillaStructure, managedStructure);
DatapackStructureScopeIndex index = index(
List.of("managed:tavern"),
List.of());
DatapackStructureStateFilter.Selection vanilla = DatapackStructureStateFilter.filter(
List.of(vanillaSet), index, Set.of());
DatapackStructureStateFilter.Selection declaring = DatapackStructureStateFilter.filter(
List.of(vanillaSet), index, index.declaredSources(List.of(SOURCE)));
assertEquals(1, vanilla.structureSets().size());
assertEquals(1, vanilla.structureSets().getFirst().value().structures().size());
assertSame(vanillaStructure,
vanilla.structureSets().getFirst().value().structures().getFirst().structure());
assertSame(vanillaSet.value().placement(),
vanilla.structureSets().getFirst().value().placement());
assertSame(vanillaSet, declaring.structureSets().getFirst());
}
@Test
public void setWithNoAllowedDefinitionsIsRemoved() {
Holder<StructureSet> unmanagedSet = structureSetHolder(
"minecraft:custom", structureHolder("managed:only"));
DatapackStructureScopeIndex index = index(List.of("managed:only"), List.of());
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
List.of(unmanagedSet), index, Set.of());
assertEquals(0, selection.structureSets().size());
}
@Test
public void spigotDirectHolderRetainsItsStructureSetKey() {
ResourceKey<StructureSet> key = ResourceKey.create(
Registries.STRUCTURE_SET,
Identifier.parse("minecraft:villages"));
ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement placement =
new ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement(
key,
Vec3i.ZERO,
StructurePlacement.FrequencyReductionMethod.DEFAULT,
1.0F,
10387312,
Optional.empty(),
34,
8,
RandomSpreadType.LINEAR);
StructureSet structureSet = new StructureSet(
List.of(new StructureSet.StructureSelectionEntry(
structureHolder("minecraft:village_plains"), 1)),
placement);
assertEquals("minecraft:villages",
DatapackStructureStateFilter.structureSetKey(Holder.direct(structureSet)));
}
@Test
public void excludedManagedSetCannotSuppressAnAllowedSetThroughExclusionZone() {
Holder<StructureSet> managedSet = structureSetHolder(
"managed:blocked",
structureHolder("managed:blocked"));
RandomSpreadStructurePlacement originalPlacement = new RandomSpreadStructurePlacement(
Vec3i.ZERO,
StructurePlacement.FrequencyReductionMethod.DEFAULT,
1.0F,
4567,
Optional.of(new StructurePlacement.ExclusionZone(managedSet, 1)),
32,
8,
RandomSpreadType.LINEAR);
Holder<StructureSet> vanillaSet = structureSetHolder(
"minecraft:allowed",
originalPlacement,
structureHolder("minecraft:village_plains"));
DatapackStructureScopeIndex index = index(
List.of("managed:blocked"),
List.of("managed:blocked"));
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
List.of(vanillaSet, managedSet), index, Set.of());
assertEquals(1, selection.structureSets().size());
StructurePlacement scopedPlacement = selection.structureSets().getFirst().value().placement();
assertEquals(0, DatapackStructureStateFilter.exclusionZone(scopedPlacement).stream().count());
}
@Test
public void standardPublishesOneDeferredStateWhileJigsawPublishesInitializedEmptyState() throws IOException {
Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource"));
String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java"));
int methodStart = source.indexOf("public DatapackStructureScopeResult scopeDatapackStructures(");
int methodEnd = source.indexOf("\n @Override\n public void completeStudioStructureBootstrap", methodStart);
assertTrue(methodStart >= 0);
assertTrue(methodEnd > methodStart);
String method = source.substring(methodStart, methodEnd);
int filteredState = method.indexOf("possibleSetsField.set(scopedState, selection.structureSets());");
int jigsawMode = method.indexOf(
"boolean jigsawStudio = platformGenerator != null");
int jigsawOnly = method.indexOf("if (jigsawStudio)");
int emptyCreation = method.indexOf("ChunkGeneratorStructureState bootstrapState = createStructureState(");
int emptyFiltering = method.indexOf("bootstrapSetsField.set(bootstrapState, List.of());");
int emptyInitialization = method.indexOf("bootstrapState.ensureStructuresGenerated();");
int emptyPublication = method.indexOf("stateField.set(chunkMap, bootstrapState);");
int standardOnly = method.indexOf("else if (studioBootstrap)");
int retention = method.indexOf("irisGenerator.retainStudioStructureState(");
int standardPublication = method.indexOf("stateField.set(chunkMap, scopedState);");
int immediateInitialization = method.indexOf("initializeAndPublishStructureState(");
assertTrue(filteredState >= 0);
assertTrue(jigsawMode > filteredState);
assertTrue(jigsawOnly > jigsawMode);
assertTrue(emptyCreation > jigsawOnly);
assertTrue(emptyFiltering > emptyCreation);
assertTrue(emptyInitialization > emptyFiltering);
assertTrue(emptyPublication > emptyInitialization);
assertTrue(standardOnly > emptyPublication);
assertTrue(retention > standardOnly);
assertTrue(standardPublication > retention);
assertTrue(immediateInitialization > standardPublication);
assertFalse(method.contains("scopedState.ensureStructuresGenerated();"));
assertFalse(method.contains("if (studioBootstrap && platformGenerator.isJigsawStudioActive())"));
}
@Test
public void standardCompletionActivatesTheAlreadyPublishedStateWithoutReplacingIt() throws IOException {
Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource"));
String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java"));
int methodStart = source.indexOf("public void completeStudioStructureBootstrap(World world)");
int methodEnd = source.indexOf("\n @Override\n public void abandonStudioStructureBootstrap", methodStart);
assertTrue(methodStart >= 0);
assertTrue(methodEnd > methodStart);
String method = source.substring(methodStart, methodEnd);
int retained = method.indexOf("generator.retainedStudioStructureState(level, chunkMap)");
int activation = method.indexOf("generator.activateStudioStructureState(retained);");
assertTrue(retained >= 0);
assertTrue(activation > retained);
assertFalse(method.contains("stateField.set("));
assertFalse(method.contains("retained.fullState()"));
}
private static DatapackStructureScopeIndex index(
List<String> structureKeys,
List<String> structureSetKeys
) {
return DatapackStructureScopeIndex.create(List.of(
new DatapackIngestService.StructureScopeResources(
SOURCE,
structureKeys,
structureSetKeys)));
}
private static Holder<Structure> structureHolder(String key) {
return new KeyedHolder<>(ResourceKey.create(Registries.STRUCTURE, Identifier.parse(key)), null);
}
private static Holder<StructureSet> structureSetHolder(
String key,
Holder<Structure>... structures
) {
return structureSetHolder(
key,
new RandomSpreadStructurePlacement(32, 8, RandomSpreadType.LINEAR, 12345),
structures);
}
private static Holder<StructureSet> structureSetHolder(
String key,
StructurePlacement placement,
Holder<Structure>... structures
) {
List<StructureSet.StructureSelectionEntry> entries = Stream.of(structures)
.map(structure -> new StructureSet.StructureSelectionEntry(structure, 1))
.toList();
StructureSet value = new StructureSet(entries, placement);
return new KeyedHolder<>(
ResourceKey.create(Registries.STRUCTURE_SET, Identifier.parse(key)),
value);
}
private static final class KeyedHolder<T> implements Holder<T> {
private final ResourceKey<T> key;
private final T value;
private KeyedHolder(ResourceKey<T> key, T value) {
this.key = key;
this.value = value;
}
@Override
public T value() {
return value;
}
@Override
public boolean isBound() {
return true;
}
@Override
public boolean areComponentsBound() {
return true;
}
@Override
public boolean is(Identifier identifier) {
return key.identifier().equals(identifier);
}
@Override
public boolean is(ResourceKey<T> candidate) {
return key.equals(candidate);
}
@Override
public boolean is(Predicate<ResourceKey<T>> predicate) {
return predicate.test(key);
}
@Override
public boolean is(TagKey<T> tag) {
return false;
}
@Override
public boolean is(Holder<T> holder) {
return holder == this;
}
@Override
public Stream<TagKey<T>> tags() {
return Stream.empty();
}
@Override
public DataComponentMap components() {
return DataComponentMap.EMPTY;
}
@Override
public Either<ResourceKey<T>, T> unwrap() {
return Either.left(key);
}
@Override
public Optional<ResourceKey<T>> unwrapKey() {
return Optional.of(key);
}
@Override
public Kind kind() {
return Kind.REFERENCE;
}
@Override
public boolean canSerializeIn(HolderOwner<T> owner) {
return true;
}
}
}
@@ -0,0 +1,30 @@
package art.arcane.iris.core.nms.v26_2_R1;
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 NMSBindingTileDeserializeContractTest {
@Test
public void ownedRegionTileDataMergesBeforeScheduledFallback() throws IOException {
Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource"));
String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java"));
int methodStart = source.indexOf("public void deserializeTile(");
int methodEnd = source.indexOf("\n private void merge(", methodStart);
String method = source.substring(methodStart, methodEnd);
int ownershipCheck = method.indexOf("J.isOwnedByCurrentRegion(");
int synchronousMerge = method.indexOf("merge(level, blockPos, tag);", ownershipCheck);
int synchronousReturn = method.indexOf("return;", synchronousMerge);
int scheduledFallback = method.indexOf("J.runAt(pos, () -> merge(level, blockPos, tag))");
assertTrue(ownershipCheck >= 0);
assertTrue(synchronousMerge > ownershipCheck);
assertTrue(synchronousReturn > synchronousMerge);
assertTrue(scheduledFallback > synchronousReturn);
}
}
@@ -0,0 +1,294 @@
/*
* 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.structure;
import art.arcane.iris.core.structure.authoring.StructureCapability;
import art.arcane.iris.core.structure.authoring.StructureLoss;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement;
import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement;
import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class VillageImporterListPoolElementRuntimeContractTest {
@BeforeClass
public static void bootstrapMinecraft() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void pillagerOutpostTowerCompositeProducesOnePhysicalTowerChoice() throws Exception {
StructurePoolElement tower = StructurePoolElement.legacy("minecraft:pillager_outpost/watchtower")
.apply(StructureTemplatePool.Projection.RIGID);
StructurePoolElement overgrown = StructurePoolElement.legacy("minecraft:pillager_outpost/watchtower_overgrown")
.apply(StructureTemplatePool.Projection.RIGID);
ListPoolElement towers = new ListPoolElement(
List.of(tower, overgrown),
StructureTemplatePool.Projection.RIGID
);
VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(towers);
Map<String, Object> entry = VillageImporter.piecePoolEntry(
"qa/pillager_outpost/piece/minecraft/pillager_outpost/watchtower",
1
);
StructureLoss loss = VillageImporter.listElementFallbackLoss(
resolution,
"minecraft:pillager_outpost/towers"
);
Map<String, Object> basePlatePiece = VillageImporter.pieceJson(
"qa/pillager_outpost/piece/minecraft/pillager_outpost/base_plate",
List.of(Map.of("name", "minecraft:bottom")),
0
);
Map<String, Object> watchtowerPiece = VillageImporter.pieceJson(
"qa/pillager_outpost/piece/minecraft/pillager_outpost/watchtower",
List.of(),
1155
);
VillageImporter.PoolMemberNormalization connectorScaffold = VillageImporter.normalizePoolMember(
"minecraft:pillager_outpost/features",
"qa/pillager_outpost/pool/minecraft/pillager_outpost/features",
false,
2,
"minecraft:empty",
"minecraft:pillager_outpost/feature_plate",
"qa/pillager_outpost/piece/minecraft/pillager_outpost/feature_plate",
1,
0,
List.of(Map.of("name", "minecraft:bottom")));
assertSame(tower, resolution.physicalElement());
assertEquals("minecraft:pillager_outpost/watchtower", resolution.templateLocation());
assertEquals(1, resolution.omittedElements());
assertEquals("qa/pillager_outpost/piece/minecraft/pillager_outpost/watchtower", entry.get("piece"));
assertEquals(1, entry.get("weight"));
assertEquals(StructureCapability.LIST_ELEMENTS, loss.capability());
assertEquals("list_pool_overlays_not_imported", loss.code());
assertEquals(false, basePlatePiece.get("collidable"));
assertFalse(watchtowerPiece.containsKey("collidable"));
assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, connectorScaffold.disposition());
assertTrue(connectorScaffold.losses().isEmpty());
}
@Test
public void actualWaystoneLikeAllAirMemberNormalizesToEmpty() throws Exception {
StructurePoolElement waystone = StructurePoolElement
.legacy("dungeons_and_taverns:waystones/waystone_scaffold")
.apply(StructureTemplatePool.Projection.RIGID);
VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(waystone);
VillageImporter.PoolMemberNormalization normalization = VillageImporter.normalizePoolMember(
"dungeons_and_taverns:waystones",
"qa/pool/dungeons_and_taverns/waystones",
false,
1,
"dungeons_and_taverns:waystones",
resolution.templateLocation(),
"qa/piece/dungeons_and_taverns/waystones/waystone_scaffold",
2,
0,
List.of());
assertSame(waystone, resolution.physicalElement());
assertEquals("dungeons_and_taverns:waystones/waystone_scaffold", resolution.templateLocation());
assertEquals(VillageImporter.PoolMemberDisposition.EMPTY, normalization.disposition());
assertEquals(true, normalization.poolEntry().get("empty"));
assertEquals(2, normalization.poolEntry().get("weight"));
assertEquals("connectorless_all_air_member_normalized_empty",
normalization.losses().getFirst().code());
}
@Test
public void actualNonAirOrphanMemberIsOmitted() throws Exception {
StructurePoolElement orphan = StructurePoolElement
.legacy("test:orphan_house")
.apply(StructureTemplatePool.Projection.RIGID);
VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(orphan);
VillageImporter.PoolMemberNormalization normalization = VillageImporter.normalizePoolMember(
"test:orphan_pool",
"qa/pool/test/orphan_pool",
false,
1,
null,
resolution.templateLocation(),
"qa/piece/test/orphan_house",
4,
37,
List.of());
assertSame(orphan, resolution.physicalElement());
assertEquals("test:orphan_house", resolution.templateLocation());
assertEquals(VillageImporter.PoolMemberDisposition.OMITTED, normalization.disposition());
assertTrue(normalization.poolEntry().isEmpty());
assertEquals("connectorless_non_air_member_omitted",
normalization.losses().getFirst().code());
assertTrue(normalization.losses().getFirst().detail().contains("37 non-air block(s)"));
assertTrue(normalization.losses().getFirst().detail().contains("no source fallback"));
assertTrue(normalization.losses().getFirst().detail().contains("selection weights"));
assertTrue(normalization.losses().getFirst().detail().contains("RNG consumption"));
}
@Test
public void actualConnectorlessStartMemberRemainsPhysical() throws Exception {
StructurePoolElement start = StructurePoolElement
.legacy("test:air_start")
.apply(StructureTemplatePool.Projection.RIGID);
VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(start);
VillageImporter.PoolMemberNormalization normalization = VillageImporter.normalizePoolMember(
"test:start",
"qa/pool/test/start",
true,
2,
"test:fallback",
resolution.templateLocation(),
"qa/piece/test/air_start",
6,
0,
List.of());
assertSame(start, resolution.physicalElement());
assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, normalization.disposition());
assertEquals("qa/piece/test/air_start", normalization.poolEntry().get("piece"));
assertTrue(normalization.losses().isEmpty());
}
@Test
public void actualMixedPoolAllAirMemberIsOmittedWithoutBecomingEmpty() throws Exception {
StructurePoolElement inert = StructurePoolElement
.legacy("test:mixed_air_inert")
.apply(StructureTemplatePool.Projection.RIGID);
VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(inert);
VillageImporter.PoolMemberNormalization normalization = VillageImporter.normalizePoolMember(
"test:mixed",
"qa/pool/test/mixed",
false,
4,
"test:mixed",
resolution.templateLocation(),
"qa/piece/test/mixed_air_inert",
9,
0,
List.of());
assertSame(inert, resolution.physicalElement());
assertEquals(VillageImporter.PoolMemberDisposition.OMITTED, normalization.disposition());
assertTrue(normalization.poolEntry().isEmpty());
assertEquals("connectorless_all_air_mixed_member_omitted",
normalization.losses().getFirst().code());
assertTrue(normalization.losses().getFirst().detail().contains("selection weights"));
assertTrue(normalization.losses().getFirst().detail().contains("RNG consumption"));
}
@Test
public void actualMixedDistinctFallbackRetainsAllConnectorlessMembers() throws Exception {
StructurePoolElement allAirElement = StructurePoolElement
.legacy("test:mixed_all_air_primary")
.apply(StructureTemplatePool.Projection.RIGID);
StructurePoolElement nonAirElement = StructurePoolElement
.legacy("test:mixed_non_air_primary")
.apply(StructureTemplatePool.Projection.RIGID);
VillageImporter.PoolElementResolution allAirResolution = VillageImporter.resolvePoolElement(allAirElement);
VillageImporter.PoolElementResolution nonAirResolution = VillageImporter.resolvePoolElement(nonAirElement);
VillageImporter.PoolMemberNormalization allAir = VillageImporter.normalizePoolMember(
"test:mixed",
"qa/pool/test/mixed",
false,
4,
"test:fallback",
allAirResolution.templateLocation(),
"qa/piece/test/mixed_all_air_primary",
9,
0,
List.of());
VillageImporter.PoolMemberNormalization nonAir = VillageImporter.normalizePoolMember(
"test:mixed",
"qa/pool/test/mixed",
false,
4,
"test:fallback",
nonAirResolution.templateLocation(),
"qa/piece/test/mixed_non_air_primary",
12,
41,
List.of());
assertSame(allAirElement, allAirResolution.physicalElement());
assertSame(nonAirElement, nonAirResolution.physicalElement());
assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, allAir.disposition());
assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, nonAir.disposition());
assertEquals("qa/piece/test/mixed_all_air_primary", allAir.poolEntry().get("piece"));
assertEquals("qa/piece/test/mixed_non_air_primary", nonAir.poolEntry().get("piece"));
assertTrue(allAir.losses().isEmpty());
assertTrue(nonAir.losses().isEmpty());
}
@Test
public void actualSingletonDistinctFallbackRetainsAllAirAndNonAirMembers() throws Exception {
StructurePoolElement allAirElement = StructurePoolElement
.legacy("test:all_air_primary")
.apply(StructureTemplatePool.Projection.RIGID);
StructurePoolElement nonAirElement = StructurePoolElement
.legacy("test:non_air_primary")
.apply(StructureTemplatePool.Projection.RIGID);
VillageImporter.PoolElementResolution allAirResolution = VillageImporter.resolvePoolElement(allAirElement);
VillageImporter.PoolElementResolution nonAirResolution = VillageImporter.resolvePoolElement(nonAirElement);
VillageImporter.PoolMemberNormalization allAir = VillageImporter.normalizePoolMember(
"test:primary",
"qa/pool/test/primary",
false,
1,
"test:fallback",
allAirResolution.templateLocation(),
"qa/piece/test/all_air_primary",
5,
0,
List.of());
VillageImporter.PoolMemberNormalization nonAir = VillageImporter.normalizePoolMember(
"test:primary",
"qa/pool/test/primary",
false,
1,
"test:fallback",
nonAirResolution.templateLocation(),
"qa/piece/test/non_air_primary",
7,
29,
List.of());
assertSame(allAirElement, allAirResolution.physicalElement());
assertSame(nonAirElement, nonAirResolution.physicalElement());
assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, allAir.disposition());
assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, nonAir.disposition());
assertEquals("qa/piece/test/all_air_primary", allAir.poolEntry().get("piece"));
assertEquals("qa/piece/test/non_air_primary", nonAir.poolEntry().get("piece"));
assertTrue(allAir.losses().isEmpty());
assertTrue(nonAir.losses().isEmpty());
}
}
@@ -37,14 +37,11 @@ import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
@@ -341,40 +338,31 @@ public final class PendingWorldDeleteQueue implements WorldDeletionQueue {
LinkedHashMap<String, String> remaining
) {
try {
QueueEntry entry = QueueEntry.parse(worldName, levelRoot.getName());
List<DeleteTarget> targets = entry.targets(levelRoot);
if (targets.stream().anyMatch(PendingWorldDeleteQueue::isLoaded)) {
EntryDeletionResult result = attemptEntry(
levelRoot,
worldName,
PendingWorldDeleteQueue::isLoaded
);
if (result.loaded()) {
Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is currently loaded.");
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
return;
}
boolean foundAny = false;
boolean deletedAll = true;
for (DeleteTarget target : targets) {
Path worldFolder = target.path();
if (!Files.exists(worldFolder, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
if (Files.isSymbolicLink(worldFolder) || !Files.isDirectory(worldFolder, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Queued world target is not a safe directory: " + worldFolder);
}
foundAny = true;
try {
deleteTree(worldFolder);
Iris.info("Deleted queued world folder \"" + worldFolder.getFileName() + "\".");
} catch (IOException failure) {
deletedAll = false;
Iris.reportError("Failed to delete queued world folder \"" + worldFolder + "\".", failure);
}
for (Path deleted : result.deleted()) {
Iris.info("Deleted queued world folder \"" + deleted.getFileName() + "\".");
}
if (!foundAny) {
for (DeletionFailure deletionFailure : result.failures()) {
Iris.reportError(
"Failed to delete queued world folder \"" + deletionFailure.path() + "\".",
deletionFailure.failure()
);
}
if (!result.foundAny()) {
Iris.info("Queued world deletion skipped for \"" + worldName + "\" (folder missing).");
return;
}
if (!deletedAll) {
if (result.retainQueueEntry()) {
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
}
} catch (Throwable failure) {
@@ -383,12 +371,51 @@ public final class PendingWorldDeleteQueue implements WorldDeletionQueue {
}
}
private static boolean isLoaded(DeleteTarget target) {
if (target.key() != null && WorldIdentity.resolve(target.key()).isPresent()) {
static EntryDeletionResult attemptEntry(
File levelRoot,
String worldName,
LoadedTargetCheck loadedTargetCheck
) throws IOException {
QueueEntry entry = QueueEntry.parse(worldName, levelRoot.getName());
List<DeleteTarget> targets = entry.targets(levelRoot);
if (targets.stream().anyMatch(target -> loadedTargetCheck.isLoaded(target.key(), target.path()))) {
return new EntryDeletionResult(true, false, List.of(), List.of());
}
boolean foundAny = false;
ArrayList<Path> deleted = new ArrayList<>(targets.size());
ArrayList<DeletionFailure> failures = new ArrayList<>(targets.size());
for (DeleteTarget target : targets) {
Path worldFolder = target.path();
if (!Files.exists(worldFolder, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
if (Files.isSymbolicLink(worldFolder) || !Files.isDirectory(worldFolder, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Queued world target is not a safe directory: " + worldFolder);
}
foundAny = true;
try {
SnapshotDirectoryTreeDeleter.delete(worldFolder);
deleted.add(worldFolder);
} catch (IOException failure) {
failures.add(new DeletionFailure(worldFolder, failure));
}
}
return new EntryDeletionResult(
false,
foundAny,
List.copyOf(deleted),
List.copyOf(failures)
);
}
private static boolean isLoaded(@Nullable NamespacedKey key, Path path) {
if (key != null && WorldIdentity.resolve(key).isPresent()) {
return true;
}
Path targetPath = target.path().toAbsolutePath().normalize();
Path targetPath = path.toAbsolutePath().normalize();
for (World world : Bukkit.getWorlds()) {
if (world.getWorldFolder().toPath().toAbsolutePath().normalize().equals(targetPath)) {
return true;
@@ -397,25 +424,6 @@ public final class PendingWorldDeleteQueue implements WorldDeletionQueue {
return false;
}
private static void deleteTree(Path target) throws IOException {
Files.walkFileTree(target, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException {
if (failure != null) {
throw failure;
}
Files.delete(directory);
return FileVisitResult.CONTINUE;
}
});
}
private enum QueueEntryType {
EXACT,
LOGICAL,
@@ -479,4 +487,23 @@ public final class PendingWorldDeleteQueue implements WorldDeletionQueue {
private record DeleteTarget(@Nullable NamespacedKey key, Path path) {
}
@FunctionalInterface
interface LoadedTargetCheck {
boolean isLoaded(@Nullable NamespacedKey key, Path path);
}
record EntryDeletionResult(
boolean loaded,
boolean foundAny,
List<Path> deleted,
List<DeletionFailure> failures
) {
boolean retainQueueEntry() {
return loaded || !failures.isEmpty();
}
}
record DeletionFailure(Path path, IOException failure) {
}
}
@@ -98,6 +98,7 @@ public class CommandIris implements DirectorExecutor {
private CommandPregen pregen;
private CommandObject object;
private CommandStructure structure;
private CommandJigsaw jigsaw;
private CommandWhat what;
private CommandEdit edit;
private CommandDeveloper developer;
@@ -182,8 +182,7 @@ public class CommandObject implements DirectorExecutor {
private static final Set<Material> skipBlocks = Set.of(Materials.GRASS, Material.SNOW, Material.VINE, Material.TORCH, Material.DEAD_BUSH,
Material.POPPY, Material.DANDELION);
public static IObjectPlacer createPlacer(World world, Map<Block, BlockData> futureBlockChanges) {
public static IObjectPlacer createPlacer(World world, Map<Block, BlockData> futureBlockChanges, Engine targetEngine) {
return new IObjectPlacer() {
@Override
public int getHighest(int x, int z, IrisData data) {
@@ -263,7 +262,7 @@ public class CommandObject implements DirectorExecutor {
@Override
public Engine getEngine() {
return null;
return targetEngine;
}
};
}
@@ -565,7 +564,7 @@ public class CommandObject implements DirectorExecutor {
// Block writes must run on the thread owning the target chunk; the undo log stays global.
final IrisObject placed = o;
if (!J.runAt(block, () -> {
placed.place(block.getBlockX(), block.getBlockY() + (int) placed.getCenter().getY(), block.getBlockZ(), createPlacer(block.getWorld(), futureChanges), placement, new RNG(), null);
placed.place(block.getBlockX(), block.getBlockY() + (int) placed.getCenter().getY(), block.getBlockZ(), createPlacer(block.getWorld(), futureChanges, null), placement, new RNG(), null);
J.runGlobal(() -> Iris.service(ObjectSVC.class).addChanges(futureChanges));
if (!edit) {
@@ -30,6 +30,7 @@ import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.framework.StructureAssembler;
import art.arcane.iris.engine.framework.StructureReachability;
import art.arcane.iris.engine.framework.structure.StructureAssemblyResult;
import art.arcane.iris.engine.object.IObjectPlacer;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
@@ -61,6 +62,7 @@ import org.bukkit.generator.structure.Structure;
import java.io.File;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -99,14 +101,15 @@ public class CommandStructure implements DirectorExecutor {
BulkStructureImporter.Report jigsaws = BulkStructureImporter.importAllVanilla(data, StructureImporter.Mode.OVERWRITE, true, sender());
BulkStructureImporter.Report templates = BulkStructureImporter.importAllTemplates(data, StructureImporter.Mode.OVERWRITE, sender());
BulkStructureImporter.Report groups = BulkStructureImporter.importTemplateGroups(data, StructureImporter.Mode.OVERWRITE, sender());
StructureCaptureImporter.Report captured = StructureCaptureImporter.importAllStructures(data, StructureImporter.Mode.OVERWRITE, sender());
StructureCaptureImporter.Report captured = StructureCaptureImporter.importStructures(
data, StructureImporter.Mode.OVERWRITE, sender(), jigsaws.captureCandidates());
int imported = jigsaws.imported() + templates.imported() + groups.imported() + captured.imported();
int failed = jigsaws.failed() + templates.failed() + groups.failed() + captured.failed();
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STRUCTURE_IMPORT_COMPLETE_STRUCTURES_OBJECTS_WRITTEN_FAILED, MessageArgument.untrusted("imported", imported), MessageArgument.untrusted("failed", failed)));
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STRUCTURE_REFERENCE_THEM_FROM_BIOME_REGION_DIMENSION_STRUCTURES_LIST_RUN_IRIS, MessageArgument.untrusted("value", dimension.getLoadKey())));
}
@Director(name = "capture", description = "Capture code-generated structures that have no NBT template (swamp huts, igloos, etc.) into editable Iris objects by generating each one in a throwaway scratch world and reading back its blocks. Skips structures that already import as a structure, structures wider/taller than the capture cap (strongholds, mansions, monuments stay vanilla), and anything that will not generate in a flat overworld. Each captured structure becomes a single-piece Iris structure you can place from a biome/region/dimension 'structures' list. Runs automatically as the last pass of /iris structure import.", descriptionKey = "iris.director.commandstructure.director.capture_code_generated_structures_that_have_no_nbt_template_swamp_huts_igloos", aliases = {"cap"}, origin = DirectorOrigin.BOTH)
@Director(name = "capture", description = "Capture live registered structures into editable Iris objects by generating each one in a throwaway scratch world and reading back its blocks. This standalone command overwrites its owned outputs; structures wider/taller than the capture cap and anything that will not generate in a flat overworld are skipped. Each captured structure becomes a single-piece Iris structure you can place from a biome/region/dimension 'structures' list. /iris structure import runs a restricted final capture pass only for non-jigsaw structures that have no loadable NBT template.", descriptionKey = "iris.director.commandstructure.director.capture_code_generated_structures_that_have_no_nbt_template_swamp_huts_igloos", aliases = {"cap"}, origin = DirectorOrigin.BOTH)
public void capture(
@Param(description = "The dimension whose pack to capture into", descriptionKey = "iris.director.commandstructure.param.dimension_whose_pack_capture_into", aliases = "dim")
IrisDimension dimension
@@ -305,8 +308,9 @@ public class CommandStructure implements DirectorExecutor {
}
StructureAssembler assembler = StructureAssembler.forData(
data, s, new IrisPosition(0, 64, 0));
KList<PlacedStructurePiece> pieces = assembler.assemble(new RNG(1234));
if (pieces == null || pieces.isEmpty()) {
StructureAssemblyResult assembly = assembler.assemble(new RNG(1234));
List<PlacedStructurePiece> pieces = assembly.pieces();
if (!assembly.hasOutput()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STRUCTURE_STRUCTURE_ASSEMBLED_0_PIECES_CHECK_STARTPOOL, MessageArgument.untrusted("structure", structure), MessageArgument.untrusted("value", s.getStartPool())));
return;
}
@@ -344,13 +348,17 @@ public class CommandStructure implements DirectorExecutor {
StructureAssembler assembler = StructureAssembler.forData(
data, s, new IrisPosition(loc.getBlockX(), loc.getBlockY(), loc.getBlockZ()));
RNG rng = new RNG((long) loc.getBlockX() * 341873128712L + loc.getBlockZ());
KList<PlacedStructurePiece> pieces = assembler.assemble(rng);
if (pieces == null || pieces.isEmpty()) {
StructureAssemblyResult assembly = assembler.assemble(rng);
List<PlacedStructurePiece> pieces = assembly.pieces();
if (!assembly.hasOutput()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STRUCTURE_STRUCTURE_ASSEMBLED_0_PIECES, MessageArgument.untrusted("structure", structure)));
return;
}
Map<Block, BlockData> future = new HashMap<>();
IObjectPlacer placer = CommandObject.createPlacer(player().getWorld(), future);
World targetWorld = player().getWorld();
PlatformChunkGenerator targetGenerator = IrisToolbelt.access(targetWorld);
Engine targetEngine = targetGenerator == null ? null : targetGenerator.getEngine();
IObjectPlacer placer = CommandObject.createPlacer(targetWorld, future, targetEngine);
for (PlacedStructurePiece p : pieces) {
IrisObjectPlacement config = new IrisObjectPlacement();
config.setMode(ObjectPlaceMode.STRUCTURE_PIECE);
@@ -67,6 +67,7 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
private static final String ROOT_PERMISSION = "iris.all";
private final transient AtomicCache<DirectorRuntimeEngine> directorCache = new AtomicCache<>();
private final transient ThreadLocal<CommandSender> dispatchSenders = new ThreadLocal<>();
@Override
public void onEnable() {
@@ -120,11 +121,18 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
}
private void dispatchDirector(DirectorExecutionMode mode, Runnable runnable) {
if (mode == DirectorExecutionMode.SYNC) {
J.s(runnable);
} else {
if (mode != DirectorExecutionMode.SYNC) {
runnable.run();
return;
}
CommandSender sender = dispatchSenders.get();
if (sender instanceof Player player) {
if (!J.runEntity(player, runnable)) {
throw new IllegalStateException("Failed to schedule player command on its entity thread");
}
return;
}
J.s(runnable);
}
@Override
@@ -241,11 +249,14 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
}
private DirectorExecutionResult runDirector(CommandSender sender, String label, String[] args) {
dispatchSenders.set(sender);
try {
return getDirector().execute(new DirectorInvocation(new BukkitDirectorSender(sender), label, Arrays.asList(args)));
} catch (Throwable e) {
Iris.warn("Director command execution failed: " + e.getClass().getSimpleName() + " " + e.getMessage());
return DirectorExecutionResult.notHandled();
} finally {
dispatchSenders.remove();
}
}
@@ -267,7 +278,8 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
}
if (sender instanceof Player player) {
J.s(() -> playSounds(player, Sound.BLOCK_AMETHYST_CLUSTER_BREAK, 0.77f, 0.25f, Sound.BLOCK_BEACON_DEACTIVATE, 0.2f, 0.45f));
J.runEntity(player, () -> playSounds(player, Sound.BLOCK_AMETHYST_CLUSTER_BREAK, 0.77f, 0.25f,
Sound.BLOCK_BEACON_DEACTIVATE, 0.2f, 0.45f));
}
}
@@ -277,7 +289,8 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
}
if (sender instanceof Player player) {
J.s(() -> playSounds(player, Sound.BLOCK_AMETHYST_CLUSTER_BREAK, 0.77f, 1.65f, Sound.BLOCK_RESPAWN_ANCHOR_CHARGE, 0.125f, 2.99f));
J.runEntity(player, () -> playSounds(player, Sound.BLOCK_AMETHYST_CLUSTER_BREAK, 0.77f, 1.65f,
Sound.BLOCK_RESPAWN_ANCHOR_CHARGE, 0.125f, 2.99f));
}
}
@@ -0,0 +1,91 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import art.arcane.iris.core.nms.DatapackStructureScopeResult;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.plugin.IrisService;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.world.WorldInitEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import java.io.IOException;
import java.util.Set;
public final class DatapackStructureScopeSVC implements IrisService {
private DatapackStructureScopeIndex scopeIndex = DatapackStructureScopeIndex.create(null);
@Override
public void onEnable() {
try {
scopeIndex = DatapackStructureScopeIndex.create(
DatapackIngestService.installedStructureScopeResources());
} catch (IOException e) {
throw new IllegalStateException(
"Iris could not establish ownership for installed datapack structure sets", e);
}
if (scopeIndex.isEmpty()) {
return;
}
for (World world : Bukkit.getWorlds()) {
applyScope(world);
}
}
@Override
public void onDisable() {
for (World world : Bukkit.getWorlds()) {
INMS.get().abandonStudioStructureBootstrap(world);
}
scopeIndex = DatapackStructureScopeIndex.create(null);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onWorldInit(WorldInitEvent event) {
boolean studioEntryBootstrap = event.getWorld().getGenerator()
instanceof BukkitChunkGenerator generator
&& generator.isStudioEntryBootstrapActive();
if (shouldApplyScope(scopeIndex.isEmpty(), studioEntryBootstrap)) {
applyScope(event.getWorld());
}
}
@EventHandler(priority = EventPriority.LOWEST)
public void onWorldUnload(WorldUnloadEvent event) {
INMS.get().abandonStudioStructureBootstrap(event.getWorld());
}
static boolean shouldApplyScope(boolean scopeIndexEmpty, boolean studioEntryBootstrap) {
return !scopeIndexEmpty || studioEntryBootstrap;
}
private void applyScope(World world) {
Set<String> declaredSources = declaredSources(world);
try {
DatapackStructureScopeResult result = INMS.get().scopeDatapackStructures(
world, scopeIndex, declaredSources);
IrisLogging.info("Scoped Iris-managed datapack structure sets for world '"
+ world.getName() + "': " + result.retainedManagedSets() + " retained, "
+ result.excludedManagedSets() + " excluded.");
} catch (Throwable error) {
throw new IllegalStateException("Could not scope Iris-managed datapack structure sets for world '"
+ world.getName() + "'", error);
}
}
private Set<String> declaredSources(World world) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator == null) {
return Set.of();
}
return scopeIndex.declaredSources(
generator.getTarget().getDimension().getDatapackImports());
}
}
@@ -2,6 +2,7 @@ package art.arcane.iris.core.service;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.gui.PregeneratorJob;
import art.arcane.iris.core.lifecycle.WorldUnloadBoundaryRegistry;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.pregenerator.MantleHeapPressure;
import art.arcane.iris.core.tools.IrisToolbelt;
@@ -36,6 +37,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.RejectedExecutionException;
@@ -157,7 +159,10 @@ public final class IrisEngineSVC implements IrisService {
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onWorldUnload(WorldUnloadEvent event) {
remove(event.getWorld());
World world = event.getWorld();
CompletionStage<Boolean> unloadBoundary = WorldUnloadBoundaryRegistry.claim(
WorldIdentity.serialize(world));
remove(world, unloadBoundary);
}
@EventHandler
@@ -230,7 +235,7 @@ public final class IrisEngineSVC implements IrisService {
}
}
private void remove(World world) {
private void remove(World world, CompletionStage<Boolean> unloadBoundary) {
if (world == null) {
return;
}
@@ -247,10 +252,53 @@ public final class IrisEngineSVC implements IrisService {
}
if (closing != null) {
phases.closing(world);
startClose(registered, closing);
deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary);
}
}
private void deferCloseUntilWorldUnload(
World world,
Registered registered,
ClosingGenerator closing,
CompletionStage<Boolean> unloadBoundary
) {
if (unloadBoundary == null) {
J.sfut(() -> startClose(registered, closing), 1)
.whenComplete((ignored, failure) -> {
if (failure != null) {
Throwable cause = failure.getCause() == null ? failure : failure.getCause();
reportFailure("Failed to defer generator close for " + registered.name(), cause);
completeClose(closing, cause);
}
});
return;
}
unloadBoundary.whenComplete((unloaded, failure) -> {
if (failure == null && Boolean.TRUE.equals(unloaded)) {
startClose(registered, closing);
return;
}
abandonClose(world, closing);
});
}
private void abandonClose(World world, ClosingGenerator closing) {
synchronized (registrationLock) {
closingGenerators.remove(closing);
}
closing.completion().complete(null);
J.sfut(() -> {
if (isCurrentWorld(world)) {
add(world);
}
}, 1).whenComplete((ignored, failure) -> {
if (failure != null) {
Throwable cause = failure.getCause() == null ? failure : failure.getCause();
reportFailure("Failed to restore generator maintenance for " + world.getName(), cause);
}
});
}
private ClosingGenerator reserveClose(Registered registered) {
ClosingGenerator closing = new ClosingGenerator(
registered.registrationIdentity(),
@@ -173,4 +173,37 @@ public class PendingWorldDeleteQueueTest {
levelRoot.toPath().resolve("dimensions/iris/alpha_the_end").toAbsolutePath()
), family);
}
@Test
public void failedSafeDeletionSignalsQueueRetentionAndSucceedsOnRetry() throws IOException {
File levelRoot = temporaryFolder.newFolder("retry-world");
Path quarantine = levelRoot.toPath().resolve("dimensions/iris").resolve(QUARANTINE_NAME);
Files.createDirectories(quarantine);
Path external = temporaryFolder.newFolder("retry-external").toPath();
Path link = Files.createSymbolicLink(quarantine.resolve("unsafe-link"), external);
PendingWorldDeleteQueue.EntryDeletionResult first = PendingWorldDeleteQueue.attemptEntry(
levelRoot,
QUARANTINE_NAME,
(key, path) -> false
);
assertTrue(first.retainQueueEntry());
assertEquals(1, first.failures().size());
assertTrue(Files.isSymbolicLink(link));
assertTrue(Files.exists(external));
Files.delete(link);
Files.writeString(quarantine.resolve("safe.dat"), "safe");
PendingWorldDeleteQueue.EntryDeletionResult retry = PendingWorldDeleteQueue.attemptEntry(
levelRoot,
QUARANTINE_NAME,
(key, path) -> false
);
assertFalse(retry.retainQueueEntry());
assertTrue(retry.failures().isEmpty());
assertFalse(Files.exists(quarantine));
}
}
@@ -0,0 +1,589 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.core.runtime.StudioOpenCoordinator;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariantCatalog;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioWorkcellSpec;
import art.arcane.iris.core.structure.authoring.StructureBackend;
import art.arcane.iris.core.structure.authoring.StructureCapability;
import art.arcane.iris.core.structure.authoring.StructureHash;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionInputKind;
import art.arcane.iris.core.structure.export.VanillaJigsawExportFormat;
import art.arcane.iris.core.tools.IrisCreator;
import art.arcane.iris.core.service.JigsawStudioService;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.volmlib.util.director.DirectorOrigin;
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.NamespacedKey;
import org.bukkit.entity.Player;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class CommandJigsawContractTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void commandIrisRegistersJigsawTree() throws Exception {
Field field = CommandIris.class.getDeclaredField("jigsaw");
assertEquals(CommandJigsaw.class, field.getType());
assertNotNull(CommandJigsaw.class.getDeclaredField("piece"));
assertNotNull(CommandJigsaw.class.getDeclaredField("pool"));
assertNotNull(CommandJigsaw.class.getDeclaredField("connector"));
assertNotNull(CommandJigsaw.class.getDeclaredField("variant"));
assertNotNull(CommandJigsaw.class.getDeclaredField("workcell"));
assertNotNull(CommandJigsaw.class.getDeclaredField("rules"));
assertNotNull(CommandJigsaw.class.getDeclaredField("preview"));
assertNotNull(CommandJigsaw.class.getDeclaredField("adopt"));
}
@Test
public void exposesStudioLifecycleAndAuthoringCommands() throws Exception {
assertCommand("create", IrisDimension.class, String.class, String.class, String.class,
int.class, int.class, int.class, long.class);
assertCommand("convert", IrisDimension.class, String.class, String.class, long.class);
assertCommand("open", IrisDimension.class, String.class, long.class);
assertCommand("close", boolean.class);
assertCommand("delete", boolean.class);
assertCommand("status");
assertCommand("menu");
assertCommand("select");
assertCommand("bounds", int.class, int.class, int.class);
assertCommand("save", String.class);
assertCommand("gotoBay", String.class);
assertCommand("particles", boolean.class);
assertCommand("export", String.class, String.class, String.class, boolean.class);
}
@Test
public void jigsawStudioUsesItsDedicatedOpenLifecycle() {
assertEquals(
StudioOpenCoordinator.StudioOpenKind.JIGSAW,
CommandJigsaw.STUDIO_OPEN_KIND);
assertFalse(CommandJigsaw.STUDIO_OPEN_KIND.openWorkspace());
assertFalse(CommandJigsaw.STUDIO_OPEN_KIND.teleportThroughStandardEntry());
assertEquals(
IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY,
CommandJigsaw.STUDIO_OPEN_KIND.datapackPreparation());
}
@Test
public void committedActivationStartsInitialEvaluationBeforePlayerBinding() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java"));
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(
"convert", IrisDimension.class, String.class, String.class, long.class);
Director command = convert.getAnnotation(Director.class);
Parameter[] parameters = convert.getParameters();
assertEquals(List.of("import", "import-vanilla"), List.of(command.aliases()));
assertParameter(parameters[2], "auto", null);
assertParameter(parameters[3], "1337", null);
NamespacedKey source = CommandJigsaw.parseRegisteredStructureKey("minecraft:village_plains");
assertEquals("minecraft_village_plains", CommandJigsaw.resolveConversionTarget(source, "auto"));
assertEquals("villages/plains", CommandJigsaw.resolveConversionTarget(source, "iris:villages/plains"));
assertThrows(IllegalArgumentException.class,
() -> CommandJigsaw.resolveConversionTarget(source, "custom:village"));
}
@Test
public void adoptionCommandsExposeTwoStepPlanContract() throws Exception {
Method inspect = CommandJigsaw.CommandJigsawAdopt.class.getDeclaredMethod(
"inspect", IrisDimension.class, String.class, String.class, String.class);
Method apply = CommandJigsaw.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,
apply.getParameters()[0].getAnnotation(Param.class).customHandler());
CommandJigsaw.JigsawAdoptionStrategyHandler strategyHandler =
new CommandJigsaw.JigsawAdoptionStrategyHandler();
assertEquals(List.of("auto", "in-place", "clone"), strategyHandler.getPossibilities());
assertEquals("in-place", strategyHandler.parse("claim", false));
assertEquals("clone", strategyHandler.parse("copy", false));
assertThrows(DirectorParsingException.class, () -> strategyHandler.parse("overwrite", false));
}
@Test
public void adoptionInputKindUsesOnlyOwnershipProvenance() throws Exception {
Path root = temporaryFolder.newFolder("adoption-provenance").toPath();
assertEquals(IrisStructureAdoptionInputKind.UNOWNED_IRIS,
CommandJigsaw.adoptionInputKind(root, "unowned"));
writeManifest(root, "datapack-created", StructureSource.Kind.DATAPACK,
StructureOwnershipManifest.Provenance.created());
assertEquals(IrisStructureAdoptionInputKind.UNOWNED_IRIS,
CommandJigsaw.adoptionInputKind(root, "datapack-created"));
writeManifest(root, "managed-provenance", StructureSource.Kind.IRIS, managedProvenance());
assertEquals(IrisStructureAdoptionInputKind.MANAGED_DATAPACK,
CommandJigsaw.adoptionInputKind(root, "managed-provenance"));
}
@Test
public void legacyGraphWritersShareTheServiceMutationContract() throws Exception {
Method mutation = CommandJigsaw.class.getDeclaredMethod(
"runGraphMutation",
Player.class,
CommandJigsaw.ActiveContext.class,
JigsawStudioService.CommandGraphMutation.class);
assertEquals(boolean.class, mutation.getReturnType());
assertNotNull(JigsawStudioService.CommandGraphMutation.class.getDeclaredMethod("run"));
assertNotNull(JigsawStudioService.CommandGraphMutationResult.class.getDeclaredConstructor(
JigsawStudioLayout.class,
String.class,
String.class,
String.class));
}
@Test
public void exposesAutosaveDynamicEvaluationAndSelectedWorkcellResize() throws Exception {
Method save = CommandJigsaw.class.getDeclaredMethod("save", String.class);
Method status = CommandJigsaw.class.getDeclaredMethod("status");
Method bounds = CommandJigsaw.class.getDeclaredMethod(
"bounds", int.class, int.class, int.class);
assertEquals("Flush the automatic save for a workcell now",
save.getAnnotation(Director.class).description());
assertEquals("Show active Jigsaw Studio and dynamic evaluation state",
status.getAnnotation(Director.class).description());
assertEquals("Set the selected Studio workcell capacity",
bounds.getAnnotation(Director.class).description());
assertThrows(NoSuchMethodException.class,
() -> CommandJigsaw.class.getDeclaredMethod("validate"));
}
@Test
public void createDefaultsToPlanarIrisWithCompleteCellAndSeedDefaults() throws Exception {
Method create = CommandJigsaw.class.getDeclaredMethod(
"create",
IrisDimension.class,
String.class,
String.class,
String.class,
int.class,
int.class,
int.class,
long.class);
Parameter[] parameters = create.getParameters();
assertParameter(parameters[2], "planar", CommandJigsaw.JigsawModeHandler.class);
assertParameter(parameters[3], "iris", CommandJigsaw.JigsawCompatibilityHandler.class);
assertParameter(parameters[4], "16", null);
assertParameter(parameters[5], "16", null);
assertParameter(parameters[6], "16", null);
assertParameter(parameters[7], "1337", null);
}
@Test
public void structureKeysExplainTheirResourceAndOpenSupportsEditingAliases() throws Exception {
Method create = CommandJigsaw.class.getDeclaredMethod(
"create",
IrisDimension.class,
String.class,
String.class,
String.class,
int.class,
int.class,
int.class,
long.class);
Method open = CommandJigsaw.class.getDeclaredMethod(
"open", IrisDimension.class, String.class, long.class);
Param createKey = create.getParameters()[1].getAnnotation(Param.class);
Param openKey = open.getParameters()[1].getAnnotation(Param.class);
Director openCommand = open.getAnnotation(Director.class);
assertEquals("key", createKey.name());
assertEquals(List.of("structure", "name"), List.of(createKey.aliases()));
assertEquals("New key written as structures/<key>.json", createKey.description());
assertEquals("key", openKey.name());
assertEquals(List.of("structure", "name"), List.of(openKey.aliases()));
assertEquals("Existing key loaded from structures/<key>.json", openKey.description());
assertEquals(List.of("edit", "reopen"), List.of(openCommand.aliases()));
}
@Test
public void createChoiceHandlersExposeCanonicalCompletionsAndRetainAliases() throws Exception {
CommandJigsaw.JigsawModeHandler modeHandler = new CommandJigsaw.JigsawModeHandler();
CommandJigsaw.JigsawCompatibilityHandler compatibilityHandler =
new CommandJigsaw.JigsawCompatibilityHandler();
assertEquals(List.of("planar", "spatial"), modeHandler.getPossibilities());
assertEquals("planar", modeHandler.parse("2d", false));
assertEquals("spatial", modeHandler.parse("3d", false));
assertEquals(List.of("iris", "vanilla"), compatibilityHandler.getPossibilities());
assertEquals("iris", compatibilityHandler.parse("extended", false));
assertEquals("vanilla", compatibilityHandler.parse("portable", false));
assertThrows(DirectorParsingException.class, () -> modeHandler.parse("volume", false));
assertThrows(DirectorParsingException.class, () -> compatibilityHandler.parse("mixed", false));
}
@Test
public void exposesNestedPieceVariantAndPreviewCommands() throws Exception {
assertNotNull(CommandJigsaw.CommandJigsawPool.class
.getDeclaredMethod("create", String.class, String.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawConnector.class
.getDeclaredMethod("channel", String.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
.getDeclaredMethod("create", String.class, String.class, int.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
.getDeclaredMethod("add", String.class, String.class, int.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
.getDeclaredMethod("remove", String.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
.getDeclaredMethod("rotatable", boolean.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPiece.class
.getDeclaredMethod("expand")
.getAnnotation(Director.class));
assertEquals("Resize the selected piece object exactly to workcell capacity",
CommandJigsaw.CommandJigsawPiece.class
.getDeclaredMethod("expand")
.getAnnotation(Director.class)
.description());
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
.getDeclaredMethod("weight", String.class, int.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
.getDeclaredMethod("resize", int.class, int.class, int.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
.getDeclaredMethod("label", String.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
.getDeclaredMethod("labelReset")
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawVariant.class
.getDeclaredMethod("duplicate")
.getAnnotation(Director.class));
Method duplicateFamily = CommandJigsaw.CommandJigsawVariant.class
.getDeclaredMethod("duplicateFamily", String.class);
assertNotNull(duplicateFamily.getAnnotation(Director.class));
assertParameter(duplicateFamily.getParameters()[0], "next", null);
assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class
.getDeclaredMethod("capacity", int.class, int.class, int.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class
.getDeclaredMethod("label", String.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class
.getDeclaredMethod("labelReset")
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawRules.class
.getDeclaredMethod("limits", int.class, int.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawRules.class
.getDeclaredMethod("fallback", String.class, String.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.CommandJigsawPreview.class
.getDeclaredMethod("assemble", long.class)
.getAnnotation(Director.class));
assertNotNull(CommandJigsaw.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"));
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();
IrisJigsawPiece eastEnd = new IrisJigsawPiece();
eastEnd.getConnectors().add(new IrisJigsawConnector().setDirection(IrisDirection.EAST_POSITIVE_X));
IrisObject exactObject = new IrisObject(7, 5, 13);
CommandJigsaw.PieceWorkcellResolution exact = CommandJigsaw.resolvePieceWorkcell(
layout,
eastEnd,
exactObject);
CommandJigsaw.PieceWorkcellResolution oversized = CommandJigsaw.resolvePieceWorkcell(
layout,
eastEnd,
new IrisObject(8, 5, 13));
assertEquals(JigsawPlanarArchetype.END.stableId(), exact.workcell().stableId());
assertEquals(new IrisPosition(13, 5, 7), exact.requiredDimensions());
assertEquals(new JigsawStudioCellDimensions(13, 5, 7), exact.workcell().capacity());
assertTrue(exact.fits());
assertTrue(exactObject.getD() > exact.workcell().capacity().depth());
assertFalse(oversized.fits());
}
@Test
public void pieceAddKeepsRawObjectAxesForSpatialWorkcells() {
JigsawStudioCellDimensions capacity = new JigsawStudioCellDimensions(7, 5, 13);
JigsawStudioLayout layout = JigsawStudioLayout.create(
JigsawStudioMode.SPATIAL_JIGSAW,
capacity,
JigsawStudioVariantCatalog.empty());
IrisJigsawPiece spatialPiece = new IrisJigsawPiece();
spatialPiece.getConnectors().add(
new IrisJigsawConnector().setDirection(IrisDirection.EAST_POSITIVE_X));
CommandJigsaw.PieceWorkcellResolution resolution = CommandJigsaw.resolvePieceWorkcell(
layout,
spatialPiece,
new IrisObject(7, 5, 13));
assertEquals(JigsawStudioLayout.SPATIAL_WORKCELL_ID, resolution.workcell().stableId());
assertEquals(new IrisPosition(7, 5, 13), resolution.requiredDimensions());
assertEquals(capacity, resolution.workcell().capacity());
assertTrue(resolution.fits());
}
@Test
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
};
for (Class<?> commandType : commandTypes) {
for (Method method : commandType.getDeclaredMethods()) {
Director director = method.getAnnotation(Director.class);
if (director != null) {
assertEquals(method.toString(), DirectorOrigin.PLAYER, director.origin());
}
}
}
}
@Test
public void exportOutputIsOneSafeChildArtifact() {
Path root = Path.of("build", "jigsaw-exports").toAbsolutePath().normalize();
assertEquals(root.resolve("village.zip"), CommandJigsaw.resolveExportDestination(
root, "village", VanillaJigsawExportFormat.ZIP));
assertEquals(root.resolve("village"), CommandJigsaw.resolveExportDestination(
root, "village", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination(
root, "", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination(
root, ".", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination(
root, "../all-exports", VanillaJigsawExportFormat.DIRECTORY));
assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination(
root, "nested/export", VanillaJigsawExportFormat.DIRECTORY));
}
@Test
public void exportLeaseRejectsDuplicatePlayerAndDestination() {
UUID firstPlayer = UUID.randomUUID();
UUID secondPlayer = UUID.randomUUID();
Path firstDestination = Path.of("build", "jigsaw-exports", "first.zip");
Path secondDestination = Path.of("build", "jigsaw-exports", "second.zip");
assertEquals(true, CommandJigsaw.beginExport(firstPlayer, firstDestination));
try {
assertEquals(false, CommandJigsaw.beginExport(firstPlayer, secondDestination));
assertEquals(false, CommandJigsaw.beginExport(secondPlayer, firstDestination));
} finally {
CommandJigsaw.finishExport(firstPlayer, firstDestination);
}
assertEquals(true, CommandJigsaw.beginExport(secondPlayer, firstDestination));
CommandJigsaw.finishExport(secondPlayer, firstDestination);
}
@Test
public void exportStartFailuresHavePreciseOperatorMessages() {
assertEquals("The active Jigsaw Studio is no longer available.",
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.NOT_ACTIVE));
assertEquals("Only the Jigsaw Studio owner can export this project.",
CommandJigsaw.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));
assertEquals("The active Jigsaw Studio is closing and cannot be exported.",
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.CLOSING));
assertEquals("Wait for the current Jigsaw Studio save to finish before exporting.",
CommandJigsaw.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));
assertEquals("A Jigsaw Studio export is already in progress.",
CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.IN_PROGRESS));
assertThrows(IllegalArgumentException.class,
() -> CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.STARTED));
}
@Test
public void exportLeaseReleaseActionRunsExactlyOnce() {
AtomicInteger releases = new AtomicInteger();
CommandJigsaw.ExportLease lease = new CommandJigsaw.ExportLease(releases::incrementAndGet);
lease.release();
lease.release();
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"));
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));
}
private static JigsawStudioLayout nonuniformPlanarLayout() {
List<JigsawStudioWorkcellSpec> workcells = List.of(
workcell(JigsawPlanarArchetype.BLANK, 3, 1, 3),
workcell(JigsawPlanarArchetype.END, 13, 5, 7),
workcell(JigsawPlanarArchetype.STRAIGHT, 5, 2, 11),
workcell(JigsawPlanarArchetype.CORNER, 9, 3, 6),
workcell(JigsawPlanarArchetype.TEE, 12, 4, 8),
workcell(JigsawPlanarArchetype.CROSS, 10, 6, 10));
return JigsawStudioLayout.createPlanar(
new JigsawStudioCellDimensions(3, 1, 3),
workcells,
JigsawStudioVariantCatalog.empty());
}
private static JigsawStudioWorkcellSpec workcell(
JigsawPlanarArchetype archetype,
int width,
int height,
int depth
) {
return new JigsawStudioWorkcellSpec(
archetype,
"",
new JigsawStudioCellDimensions(width, height, depth),
true);
}
private static void assertParameter(Parameter parameter, String defaultValue, Class<?> customHandler) {
Param annotation = parameter.getAnnotation(Param.class);
assertNotNull(annotation);
assertEquals(defaultValue, annotation.defaultValue());
if (customHandler != null) {
assertEquals(customHandler, annotation.customHandler());
}
}
private static void writeManifest(
Path root,
String structure,
StructureSource.Kind sourceKind,
StructureOwnershipManifest.Provenance provenance
) throws Exception {
StructureKey structureKey = new StructureKey("iris", structure);
StructureOwnershipManifest manifest = new StructureOwnershipManifest(
StructureOwnershipManifest.CURRENT_SCHEMA_VERSION,
structureKey,
StructureSource.of(sourceKind, new StructureKey("iris", "source/" + structure)),
StructureBackend.IRIS_ASSEMBLY,
List.of(StructureCapability.BLOCKS, StructureCapability.CONNECTORS),
List.of(),
Map.of("structures/" + structure + ".json", StructureHash.sha256(
structure.getBytes(StandardCharsets.UTF_8))),
provenance);
Path manifestPath = new StructureTransactionWriter(root).ownershipManifestPath(structureKey);
Files.createDirectories(manifestPath.getParent());
Files.write(manifestPath, manifest.toJson());
}
private static StructureOwnershipManifest.Provenance managedProvenance() {
String path = "structures/source.json";
String hash = StructureHash.sha256("source".getBytes(StandardCharsets.UTF_8));
return new StructureOwnershipManifest.Provenance(
StructureOwnershipManifest.Origin.MANAGED_DATAPACK,
UUID.randomUUID().toString(),
StructureHash.sha256("plan".getBytes(StandardCharsets.UTF_8)),
StructureHash.sha256("closure".getBytes(StandardCharsets.UTF_8)),
1L,
Map.of(path, hash),
Map.of(path, path),
StructureOwnershipManifest.RollbackDisposition.NONE);
}
}
@@ -238,6 +238,38 @@ public class IrisStructureLocateCommandContractTest {
assertFalse(source.contains("at[2] + 8"));
}
@Test
public void bulkStructureImportRestrictsCaptureWithoutChangingStandaloneCapture() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.commandStructureSource")));
int importStart = source.indexOf("public void importAll(");
int importEnd = source.indexOf("@Director(name = \"capture\"", importStart);
int captureStart = source.indexOf("public void capture(", importEnd);
int captureEnd = source.indexOf("@Director(description = \"Verify", captureStart);
assertTrue(importStart >= 0);
assertTrue(importEnd > importStart);
assertTrue(captureStart > importEnd);
assertTrue(captureEnd > captureStart);
String importMethod = source.substring(importStart, importEnd);
String captureMethod = source.substring(captureStart, captureEnd);
assertTrue(importMethod.contains("StructureCaptureImporter.importStructures("));
assertTrue(importMethod.contains("jigsaws.captureCandidates()"));
assertFalse(importMethod.contains("StructureCaptureImporter.importAllStructures("));
assertTrue(captureMethod.contains("StructureCaptureImporter.importAllStructures("));
}
@Test
public void structurePlaceSeparatesPackContentFromTargetWorldEngine() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.commandStructureSource")));
int methodStart = source.indexOf("public void place(");
assertTrue(methodStart >= 0);
String method = source.substring(methodStart);
assertTrue(method.contains("IrisData data = dimension.getLoader()"));
assertTrue(method.contains("PlatformChunkGenerator targetGenerator = IrisToolbelt.access(targetWorld)"));
assertTrue(method.contains("CommandObject.createPlacer(targetWorld, future, targetEngine)"));
assertFalse(method.contains("data.getEngine()"));
}
@Test
public void structureVerifyPartitionsPolicyBeforeNativeReachability() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.commandStructureSource")));
@@ -20,6 +20,12 @@ public class BukkitEngineLifecycleContractTest {
assertTrue(closeAsync.contains("operation.whenComplete("));
assertFalse(closeAsync.contains("!existing.isDone()"));
String exclusiveFuture = method(source, "public CompletableFuture<Void> withExclusiveControlFuture(Runnable r)");
assertTrue(exclusiveFuture.contains("J.a(() -> completeExclusiveControlFuture(loadLock, r, future))"));
String exclusiveCompletion = method(source, "static void completeExclusiveControlFuture(");
assertBefore(exclusiveCompletion, "activeGate.releaseExclusive();", "outward.complete(null);");
assertBefore(exclusiveCompletion, "activeGate.releaseExclusive();", "outward.completeExceptionally(failure);");
String baseHeight = method(source, "public int getBaseHeight(");
assertTrue(baseHeight.contains("currentEngine.acquireGenerationLease(\"bukkit_base_height\")"));
assertTrue(baseHeight.contains("IrisContext.open(currentEngine, lease.sessionId(), null)"));
@@ -0,0 +1,41 @@
package art.arcane.iris.core.service;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.world.WorldInitEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import org.junit.Test;
import java.lang.reflect.Method;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public class DatapackStructureScopeSVCTest {
@Test
public void structureScopeRunsAfterIrisGeneratorInjectionAndBeforeSpawnChunks() throws NoSuchMethodException {
Method handler = DatapackStructureScopeSVC.class.getMethod("onWorldInit", WorldInitEvent.class);
EventHandler annotation = handler.getAnnotation(EventHandler.class);
assertNotNull(annotation);
assertEquals(EventPriority.HIGHEST, annotation.priority());
}
@Test
public void unloadAbandonsRetainedStudioStateBeforeEngineTeardown() throws NoSuchMethodException {
Method handler = DatapackStructureScopeSVC.class.getMethod("onWorldUnload", WorldUnloadEvent.class);
EventHandler annotation = handler.getAnnotation(EventHandler.class);
assertNotNull(annotation);
assertEquals(EventPriority.LOWEST, annotation.priority());
}
@Test
public void emptyScopeStillAppliesToJigsawStudioBootstrap() {
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(true, true));
assertFalse(DatapackStructureScopeSVC.shouldApplyScope(true, false));
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(false, false));
}
}
@@ -75,9 +75,11 @@ public class IrisApiWiringContractTest {
assertBefore(add, "catch (RejectedExecutionException exception)", "phases.ready(world)");
assertBefore(add, "registered = true;", "phases.ready(world)");
String remove = method(source, "private void remove(World world)");
String remove = method(source,
"private void remove(World world, CompletionStage<Boolean> unloadBoundary)");
assertBefore(remove, "registered = worlds.remove(world)", "phases.closing(world)");
assertBefore(remove, "phases.closing(world)", "startClose(registered, closing)");
assertBefore(remove, "phases.closing(world)",
"deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary)");
}
@Test
@@ -49,13 +49,14 @@ public class IrisEngineLifecycleContractTest {
public void registrationRetryWaitsAsynchronouslyForClose() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.engineSvcSource")));
String add = method(source, "private void add(World world)");
String remove = method(source, "private void remove(World world)");
String remove = method(source, "private void remove(World world, CompletionStage<Boolean> unloadBoundary)");
String completeClose = method(source, "private void completeClose(");
String retry = method(source, "private void retryRegistrationAfterClose(");
String invokeClose = method(source, "private CompletableFuture<Void> invokeGeneratorClose()");
assertBefore(add, "findClosingGenerator(registrationIdentity)", "new Registered(");
assertBefore(remove, "reserveClose(registered)", "startClose(registered, closing)");
assertBefore(remove, "registered.close()", "reserveClose(registered)");
assertBefore(remove, "reserveClose(registered)", "deferCloseUntilWorldUnload(");
assertBefore(completeClose, "if (failure == null)", "closingGenerators.remove(closing)");
assertBefore(completeClose, "closingGenerators.remove(closing)", "closing.completion().complete(null)");
assertBefore(completeClose, "} else {", "closing.completion().completeExceptionally(failure)");
@@ -68,6 +69,24 @@ public class IrisEngineLifecycleContractTest {
assertTrue(invokeClose.contains("future.whenComplete("));
}
@Test
public void worldUnloadDefersGeneratorCloseUntilTheRawBoundaryCompletesTrue() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.engineSvcSource")));
String handler = method(source, "public void onWorldUnload(WorldUnloadEvent event)");
String remove = method(source, "private void remove(World world, CompletionStage<Boolean> unloadBoundary)");
String defer = method(source, "private void deferCloseUntilWorldUnload(");
assertBefore(handler, "WorldUnloadBoundaryRegistry.claim(", "remove(world, unloadBoundary)");
assertBefore(remove, "registered.close()", "deferCloseUntilWorldUnload(");
assertFalse(remove.contains("startClose(registered, closing)"));
assertTrue(defer.contains("J.sfut(() -> startClose(registered, closing), 1)"));
assertTrue(defer.contains("unloadBoundary.whenComplete("));
String managedBoundary = defer.substring(defer.indexOf("unloadBoundary.whenComplete("));
assertBefore(managedBoundary, "failure == null && Boolean.TRUE.equals(unloaded)",
"startClose(registered, closing)");
assertBefore(managedBoundary, "startClose(registered, closing)", "abandonClose(world, closing)");
}
private static void assertMonitorUnloadHandler(Method method) {
EventHandler eventHandler = method.getAnnotation(EventHandler.class);
assertNotNull(eventHandler);
+488
View File
@@ -0,0 +1,488 @@
[12:40:12] [Test worker/WARN]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0
[12:40:12] [Test worker/WARN]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
[12:40:12] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:definitely_not_a_real_block
[12:40:12] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:minecraft:definitely_not_a_real_block
[12:40:12] [Test worker/INFO]: [STDERR]: [Iris/WARN] Block 'minecraft:oak_log' rejected state 'not_a_property=x'; using its default state
[12:40:12] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test'
[12:40:12] [Test worker/WARN]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial16265972129816775503/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial16265972129816775503/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"}
[12:40:12] [Test worker/ERROR]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot11106486503678824408/iris-dimensions.json is corrupt; quarantining it and continuing boot
java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot11106486503678824408/iris-dimensions.json could not be read; refusing to discard persistent worlds
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:150)
at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:56)
at art.arcane.iris.modded.ModdedDimensionRegistryStore.loadForStartup(ModdedDimensionRegistryStore.java:69)
at art.arcane.iris.modded.ModdedDimensionRegistryStoreTest.startupLoadQuarantinesACorruptRegistryInsteadOfFailingBoot(ModdedDimensionRegistryStoreTest.java:99)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Caused by: art.arcane.volmlib.util.json.JSONException: A JSONObject text must end with '}' at 34 [character 35 line 1]
at art.arcane.volmlib.util.json.JSONTokener.syntaxError(JSONTokener.java:414)
at art.arcane.volmlib.util.json.JSONObject.<init>(JSONObject.java:145)
at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:345)
at art.arcane.volmlib.util.json.JSONArray.<init>(JSONArray.java:111)
at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:348)
at art.arcane.volmlib.util.json.JSONObject.<init>(JSONObject.java:159)
at art.arcane.volmlib.util.json.JSONObject.<init>(JSONObject.java:260)
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117)
... 45 more
[12:40:12] [Test worker/ERROR]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost
[12:40:12] [Test worker/ERROR]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot11106486503678824408/iris-dimensions.json.broken-1786380012686
[12:40:12] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[12:40:12] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService
java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[12:40:12] [Test worker/ERROR]: Iris disabled all services with 2 failure(s)
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Suppressed: java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54)
... 42 more
[12:40:12] [Test worker/ERROR]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[12:40:12] [Test worker/ERROR]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: enable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Suppressed: java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32)
... 42 more
[12:40:12] [Test worker/ERROR]: [worldcheck] server stop request failed
java.lang.IllegalStateException: stop request failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238)
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:144)
at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:235)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[12:40:12] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed
java.lang.IllegalStateException: shutdown wait failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264)
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:159)
at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:262)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[12:40:12] [Test worker/ERROR]: [worldcheck] check failed
java.lang.IllegalStateException: check failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224)
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:139)
at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:222)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[12:40:12] [Test worker/INFO]: Iris registered custom content provider 'iris_discovery_success'
[12:40:12] [Test worker/WARN]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider)
java.lang.RuntimeException: provider init failed
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
[12:40:12] [Test worker/INFO]: Iris object undo service ready (bounded to 32 paste(s) per player)
[12:40:12] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered
[12:40:12] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22 -22
View File
@@ -1,12 +1,12 @@
[02Aug2026 01:34:53.070] [Test worker/DEBUG] [io.netty.util.internal.logging.InternalLoggerFactory/]: Using SLF4J as the default logging framework
[02Aug2026 01:34:53.071] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.level: simple
[02Aug2026 01:34:53.071] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.targetRecords: 4
[02Aug2026 01:34:54.789] [Test worker/WARN] [Iris/]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0
[02Aug2026 01:34:54.795] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
[02Aug2026 01:34:54.825] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test'
[02Aug2026 01:34:54.832] [Test worker/WARN] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial6681980702089307791/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial6681980702089307791/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"}
[02Aug2026 01:34:54.844] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot3401444738373117988/iris-dimensions.json is corrupt; quarantining it and continuing boot
java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot3401444738373117988/iris-dimensions.json could not be read; refusing to discard persistent worlds
[10Aug2026 12:40:42.241] [Test worker/DEBUG] [io.netty.util.internal.logging.InternalLoggerFactory/]: Using SLF4J as the default logging framework
[10Aug2026 12:40:42.242] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.level: simple
[10Aug2026 12:40:42.242] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.targetRecords: 4
[10Aug2026 12:40:44.506] [Test worker/WARN] [Iris/]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0
[10Aug2026 12:40:44.507] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
[10Aug2026 12:40:44.546] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test'
[10Aug2026 12:40:44.555] [Test worker/WARN] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial8292600765329941987/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial8292600765329941987/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"}
[10Aug2026 12:40:44.572] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot9980172071037313072/iris-dimensions.json is corrupt; quarantining it and continuing boot
java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot9980172071037313072/iris-dimensions.json could not be read; refusing to discard persistent worlds
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:150) ~[main/:?]
at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:56) ~[main/:?]
at art.arcane.iris.modded.ModdedDimensionRegistryStore.loadForStartup(ModdedDimensionRegistryStore.java:69) ~[main/:?]
@@ -63,9 +63,9 @@ Caused by: art.arcane.volmlib.util.json.JSONException: A JSONObject text must en
at art.arcane.volmlib.util.json.JSONObject.<init>(JSONObject.java:260) ~[shared-local-SNAPSHOT.jar:?]
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117) ~[main/:?]
... 45 more
[02Aug2026 01:34:54.851] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost
[02Aug2026 01:34:54.851] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot3401444738373117988/iris-dimensions.json.broken-1785648894851
[02Aug2026 01:34:54.894] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[10Aug2026 12:40:44.580] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost
[10Aug2026 12:40:44.581] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot9980172071037313072/iris-dimensions.json.broken-1786380044580
[10Aug2026 12:40:44.633] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -110,7 +110,7 @@ java.lang.RuntimeException: second disable failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.896] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService
[10Aug2026 12:40:44.635] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService
java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -155,7 +155,7 @@ java.lang.RuntimeException: first disable failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.899] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s)
[10Aug2026 12:40:44.638] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s)
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -203,7 +203,7 @@ java.lang.RuntimeException: second disable failed
Suppressed: java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?]
... 42 more
[02Aug2026 01:34:54.902] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[10Aug2026 12:40:44.642] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -248,7 +248,7 @@ java.lang.RuntimeException: cleanup failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.905] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[10Aug2026 12:40:44.644] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: enable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -296,7 +296,7 @@ java.lang.RuntimeException: enable failed
Suppressed: java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
... 42 more
[02Aug2026 01:34:54.939] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed
[10Aug2026 12:40:44.683] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed
java.lang.IllegalStateException: stop request failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238) ~[test/:?]
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:144) ~[main/:?]
@@ -343,7 +343,7 @@ java.lang.IllegalStateException: stop request failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.942] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed
[10Aug2026 12:40:44.685] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed
java.lang.IllegalStateException: shutdown wait failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264) ~[test/:?]
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:159) ~[main/:?]
@@ -390,7 +390,7 @@ java.lang.IllegalStateException: shutdown wait failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.946] [Test worker/ERROR] [Iris/]: [worldcheck] check failed
[10Aug2026 12:40:44.690] [Test worker/ERROR] [Iris/]: [worldcheck] check failed
java.lang.IllegalStateException: check failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224) ~[test/:?]
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:139) ~[main/:?]
@@ -437,8 +437,8 @@ java.lang.IllegalStateException: check failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.952] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success'
[02Aug2026 01:34:54.953] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider)
[10Aug2026 12:40:44.704] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success'
[10Aug2026 12:40:44.705] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider)
java.lang.RuntimeException: provider init failed
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -483,4 +483,4 @@ java.lang.RuntimeException: provider init failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.966] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player)
[10Aug2026 12:40:44.720] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player)
+19 -19
View File
@@ -1,9 +1,9 @@
[02Aug2026 01:34:54.789] [Test worker/WARN] [Iris/]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0
[02Aug2026 01:34:54.795] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
[02Aug2026 01:34:54.825] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test'
[02Aug2026 01:34:54.832] [Test worker/WARN] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial6681980702089307791/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial6681980702089307791/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"}
[02Aug2026 01:34:54.844] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot3401444738373117988/iris-dimensions.json is corrupt; quarantining it and continuing boot
java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot3401444738373117988/iris-dimensions.json could not be read; refusing to discard persistent worlds
[10Aug2026 12:40:44.506] [Test worker/WARN] [Iris/]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0
[10Aug2026 12:40:44.507] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
[10Aug2026 12:40:44.546] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test'
[10Aug2026 12:40:44.555] [Test worker/WARN] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial8292600765329941987/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial8292600765329941987/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"}
[10Aug2026 12:40:44.572] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot9980172071037313072/iris-dimensions.json is corrupt; quarantining it and continuing boot
java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot9980172071037313072/iris-dimensions.json could not be read; refusing to discard persistent worlds
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:150) ~[main/:?]
at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:56) ~[main/:?]
at art.arcane.iris.modded.ModdedDimensionRegistryStore.loadForStartup(ModdedDimensionRegistryStore.java:69) ~[main/:?]
@@ -60,9 +60,9 @@ Caused by: art.arcane.volmlib.util.json.JSONException: A JSONObject text must en
at art.arcane.volmlib.util.json.JSONObject.<init>(JSONObject.java:260) ~[shared-local-SNAPSHOT.jar:?]
at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117) ~[main/:?]
... 45 more
[02Aug2026 01:34:54.851] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost
[02Aug2026 01:34:54.851] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot3401444738373117988/iris-dimensions.json.broken-1785648894851
[02Aug2026 01:34:54.894] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[10Aug2026 12:40:44.580] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost
[10Aug2026 12:40:44.581] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot9980172071037313072/iris-dimensions.json.broken-1786380044580
[10Aug2026 12:40:44.633] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -107,7 +107,7 @@ java.lang.RuntimeException: second disable failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.896] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService
[10Aug2026 12:40:44.635] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService
java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -152,7 +152,7 @@ java.lang.RuntimeException: first disable failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.899] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s)
[10Aug2026 12:40:44.638] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s)
java.lang.RuntimeException: second disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -200,7 +200,7 @@ java.lang.RuntimeException: second disable failed
Suppressed: java.lang.RuntimeException: first disable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?]
... 42 more
[02Aug2026 01:34:54.902] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[10Aug2026 12:40:44.642] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -245,7 +245,7 @@ java.lang.RuntimeException: cleanup failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.905] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
[10Aug2026 12:40:44.644] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService
java.lang.RuntimeException: enable failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -293,7 +293,7 @@ java.lang.RuntimeException: enable failed
Suppressed: java.lang.RuntimeException: cleanup failed
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
... 42 more
[02Aug2026 01:34:54.939] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed
[10Aug2026 12:40:44.683] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed
java.lang.IllegalStateException: stop request failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238) ~[test/:?]
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:144) ~[main/:?]
@@ -340,7 +340,7 @@ java.lang.IllegalStateException: stop request failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.942] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed
[10Aug2026 12:40:44.685] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed
java.lang.IllegalStateException: shutdown wait failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264) ~[test/:?]
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:159) ~[main/:?]
@@ -387,7 +387,7 @@ java.lang.IllegalStateException: shutdown wait failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.946] [Test worker/ERROR] [Iris/]: [worldcheck] check failed
[10Aug2026 12:40:44.690] [Test worker/ERROR] [Iris/]: [worldcheck] check failed
java.lang.IllegalStateException: check failed
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224) ~[test/:?]
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:139) ~[main/:?]
@@ -434,8 +434,8 @@ java.lang.IllegalStateException: check failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.952] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success'
[02Aug2026 01:34:54.953] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider)
[10Aug2026 12:40:44.704] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success'
[10Aug2026 12:40:44.705] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider)
java.lang.RuntimeException: provider init failed
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?]
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
@@ -480,4 +480,4 @@ java.lang.RuntimeException: provider init failed
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?]
[02Aug2026 01:34:54.966] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player)
[10Aug2026 12:40:44.720] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player)
@@ -141,7 +141,7 @@ public final class ModdedParityProbe {
.maxHeight(dimension.getMaxHeight())
.build();
EngineTarget target = new EngineTarget(world, dimension, data);
Engine engine = new IrisEngine(target, false);
Engine engine = new IrisEngine(target, IrisEngine.InitializationMode.RUNTIME);
settle();
int minY = dimension.getMinHeight();
@@ -122,7 +122,9 @@ public final class ModdedWorldEngines {
.maxHeight(dimension.getMaxHeight())
.platformWorld(new ModdedPlatformWorld(level))
.build();
Engine engine = new IrisEngine(new EngineTarget(world, dimension, data), false);
Engine engine = new IrisEngine(
new EngineTarget(world, dimension, data),
IrisEngine.InitializationMode.RUNTIME);
if (engine.isClosed() || engine.getComplex() == null) {
IllegalStateException failure = new IllegalStateException("Iris engine for "
+ level.dimension().identifier() + " did not initialize a ready biome complex");
@@ -23,11 +23,11 @@ import art.arcane.iris.core.structure.StructureIndexService;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.framework.StructureAssembler;
import art.arcane.iris.engine.framework.structure.StructureAssemblyResult;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.object.ObjectPlaceMode;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
@@ -44,6 +44,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.Predicate;
@@ -148,8 +149,9 @@ public final class ModdedStructureCommands {
}
StructureAssembler assembler = StructureAssembler.forData(
data, structure, new IrisPosition(0, 64, 0));
KList<PlacedStructurePiece> pieces = assembler.assemble(new RNG(1234));
if (pieces == null || pieces.isEmpty()) {
StructureAssemblyResult assembly = assembler.assemble(new RNG(1234));
List<PlacedStructurePiece> pieces = assembly.pieces();
if (!assembly.hasOutput()) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STRUCTURE_COMMANDS_STRUCTURE_ASSEMBLED_0_PIECES_CHECK_STARTPOOL, MessageArgument.untrusted("key", key), MessageArgument.untrusted("value", structure.getStartPool())));
return 0;
}
@@ -191,8 +193,9 @@ public final class ModdedStructureCommands {
StructureAssembler assembler = StructureAssembler.forData(
data, structure, new IrisPosition(originX, originY, originZ));
RNG rng = new RNG((long) originX * 341873128712L + originZ);
KList<PlacedStructurePiece> pieces = assembler.assemble(rng);
if (pieces == null || pieces.isEmpty()) {
StructureAssemblyResult assembly = assembler.assemble(rng);
List<PlacedStructurePiece> pieces = assembly.pieces();
if (!assembly.hasOutput()) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STRUCTURE_COMMANDS_STRUCTURE_ASSEMBLED_0_PIECES, MessageArgument.untrusted("key", key)));
return 0;
}
@@ -189,30 +189,37 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
}
private void runTilePass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) {
int minHeight = engine.getWorld().minHeight();
int baseX = chunkX << 4;
int baseZ = chunkZ << 4;
chunk.iterate(TileWrapper.class, (Integer x, Integer yf, Integer z, TileWrapper v) -> {
int y = yf + minHeight;
if (y < level.getMinY() || y >= level.getMaxY()) {
return;
}
applyTile(level, baseX + (x & 15), y, baseZ + (z & 15), v.getData());
});
int minHeight = engine.getWorld().minHeight();
materializeDeferredSlice(chunk, TileWrapper.class, () ->
chunk.iterate(TileWrapper.class, (Integer x, Integer yf, Integer z, TileWrapper v) -> {
int y = yf + minHeight;
if (y < level.getMinY() || y >= level.getMaxY()) {
return;
}
applyTile(level, baseX + (x & 15), y, baseZ + (z & 15), v.getData());
}));
}
private void runCustomPass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) {
int minHeight = engine.getWorld().minHeight();
int baseX = chunkX << 4;
int baseZ = chunkZ << 4;
chunk.iterate(Identifier.class, (Integer x, Integer yf, Integer z, Identifier identifier) -> {
int y = yf + minHeight;
if (y < level.getMinY() || y >= level.getMaxY()) {
return;
}
BlockPos position = new BlockPos(baseX + (x & 15), y, baseZ + (z & 15));
ModdedCustomContentRegistry.processBlockPlacement(engine, level, position, identifier.toString());
});
int minHeight = engine.getWorld().minHeight();
materializeDeferredSlice(chunk, Identifier.class, () ->
chunk.iterate(Identifier.class, (Integer x, Integer yf, Integer z, Identifier identifier) -> {
int y = yf + minHeight;
if (y < level.getMinY() || y >= level.getMaxY()) {
return;
}
BlockPos position = new BlockPos(baseX + (x & 15), y, baseZ + (z & 15));
ModdedCustomContentRegistry.processBlockPlacement(engine, level, position, identifier.toString());
}));
}
static void materializeDeferredSlice(MantleChunk<Matter> chunk, Class<?> sliceType, Runnable materializer) {
materializer.run();
chunk.deleteSlices(sliceType);
}
private void applyTile(ServerLevel level, int x, int y, int z, TileData tile) {
@@ -1,8 +1,19 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.util.project.matter.TileWrapper;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter;
import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
import art.arcane.volmlib.util.matter.Matter;
import org.junit.Test;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class ModdedChunkUpdateServiceTest {
@@ -20,4 +31,67 @@ public class ModdedChunkUpdateServiceTest {
public void skipsLevelsWithoutPlayersOrForcedChunks() {
assertFalse(ModdedChunkUpdateService.hasUpdateTargets(false, false));
}
@Test
public void deferredSliceIsDeletedAfterMaterialization() {
RecordingMantleChunk chunk = new RecordingMantleChunk();
ModdedChunkUpdateService.materializeDeferredSlice(
chunk,
TileWrapper.class,
() -> chunk.record("materialize")
);
assertEquals(List.of("materialize", "delete:" + TileWrapper.class.getName()),
chunk.operations());
}
@Test
public void failedMaterializationRetainsDeferredSlice() {
RecordingMantleChunk chunk = new RecordingMantleChunk();
assertThrows(IllegalStateException.class, () ->
ModdedChunkUpdateService.materializeDeferredSlice(
chunk,
TileWrapper.class,
() -> {
chunk.record("materialize");
throw new IllegalStateException("materialization failure");
}
));
assertEquals(List.of("materialize"), chunk.operations());
}
private static final class RecordingMantleChunk extends MantleChunk<Matter> {
private final ArrayList<String> operations = new ArrayList<>();
private RecordingMantleChunk() {
super(1, 0, 0, emptyAdapter(), MantleHooks.NONE);
}
@Override
public void deleteSlices(Class<?> type) {
operations.add("delete:" + type.getName());
}
private void record(String operation) {
operations.add(operation);
}
private List<String> operations() {
return List.copyOf(operations);
}
}
@SuppressWarnings("unchecked")
private static MantleDataAdapter<Matter> emptyAdapter() {
return (MantleDataAdapter<Matter>) Proxy.newProxyInstance(
MantleDataAdapter.class.getClassLoader(),
new Class<?>[]{MantleDataAdapter.class},
(proxy, method, arguments) -> {
throw new UnsupportedOperationException(method.getName());
}
);
}
}
@@ -416,7 +416,7 @@ public class NativeStructureReferenceRepairTest {
private IrisData data;
private TestEngine() {
super(null, false);
super(null, InitializationMode.RUNTIME);
}
@Override
+7
View File
@@ -60,6 +60,12 @@ 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/JigsawStudioMarkerParser.java
art/arcane/iris/core/service/JigsawStudioMenuController.java
art/arcane/iris/core/service/JigsawStudioDisabledWorkcellRenderer.java
art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java
art/arcane/iris/core/service/JigsawStudioService.java
art/arcane/iris/core/service/JigsawStudioToolCodec.java
art/arcane/iris/core/service/ObjectSVC.java
art/arcane/iris/core/service/ObjectStudioSaveService.java
art/arcane/iris/core/service/StudioSVC.java
@@ -123,6 +129,7 @@ art/arcane/iris/engine/platform/BukkitChunkGenerator.java
art/arcane/iris/engine/platform/DummyBiomeProvider.java
art/arcane/iris/engine/platform/DummyChunkGenerator.java
art/arcane/iris/engine/platform/EngineBukkitOps.java
art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java
art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java
art/arcane/iris/platform/bukkit/BukkitBiome.java
art/arcane/iris/platform/bukkit/BukkitBlockResolution.java
@@ -1,7 +1,9 @@
package art.arcane.iris.core;
import art.arcane.iris.BuildConstants;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.PackDirectoryResolver;
@@ -12,15 +14,20 @@ import art.arcane.volmlib.util.collection.KSet;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -30,18 +37,105 @@ import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.stream.Stream;
public final class IrisDatapackCompiler {
private static final int INPUT_FINGERPRINT_SCHEMA = 1;
private static final int WORLD_PACK_SCAN_DEPTH = 8;
private static final List<String> INPUT_DIRECTORIES = List.of("dimensions", "biomes", "snippet");
private IrisDatapackCompiler() {
}
public static List<File> collectPackRoots(Path dataDirectory, Path serverRoot) throws IOException {
return collectPackRoots(dataDirectory, serverRoot, true);
}
public static List<File> collectCompilerInputRoots(Path dataDirectory, Path serverRoot) throws IOException {
return collectPackRoots(dataDirectory, serverRoot, false);
}
private static List<File> collectPackRoots(
Path dataDirectory,
Path serverRoot,
boolean validateWholePack
) throws IOException {
LinkedHashMap<Path, File> roots = new LinkedHashMap<>();
collectInstalledPackRoots(dataDirectory.resolve("packs"), roots);
collectWorldPackRoots(serverRoot.resolve("dimensions"), roots);
collectInstalledPackRoots(dataDirectory.resolve("packs"), roots, validateWholePack);
collectWorldPackRoots(serverRoot.resolve("dimensions"), roots, validateWholePack);
return new ArrayList<>(roots.values());
}
public static String computeInputFingerprint(
List<File> packRoots,
IDataFixer fixer,
boolean adjustVanillaHeight
) throws IOException {
Objects.requireNonNull(fixer, "fixer");
return computeInputFingerprint(
packRoots,
adjustVanillaHeight,
compilerIdentity(fixer));
}
static String computeInputFingerprint(
List<File> packRoots,
boolean adjustVanillaHeight,
String compilerIdentity
) throws IOException {
Objects.requireNonNull(packRoots, "packRoots");
Objects.requireNonNull(compilerIdentity, "compilerIdentity");
MessageDigest digest = sha256();
updateDigestString(digest, "iris-datapack-compiler-input");
updateDigestInt(digest, INPUT_FINGERPRINT_SCHEMA);
updateDigestString(digest, compilerIdentity);
digest.update((byte) (adjustVanillaHeight ? 1 : 0));
updateDigestInt(digest, packRoots.size());
for (int index = 0; index < packRoots.size(); index++) {
File packRoot = Objects.requireNonNull(packRoots.get(index), "pack root");
Path normalizedRoot = packRoot.toPath().toAbsolutePath().normalize();
if (!Files.isDirectory(normalizedRoot)) {
throw new IOException("Iris datapack compiler input root is missing or unsafe: " + normalizedRoot);
}
Path realRoot = normalizedRoot.toRealPath();
updateDigestInt(digest, index);
updateDigestString(digest, normalizedRoot.toString());
updateDigestString(digest, realRoot.toString());
boolean active = hasDimensions(normalizedRoot);
digest.update((byte) (active ? 1 : 0));
if (!active) {
continue;
}
List<CompilerInputEntry> entries = collectCompilerInputEntries(normalizedRoot);
updateDigestInt(digest, entries.size());
byte[] buffer = new byte[8192];
for (CompilerInputEntry entry : entries) {
updateDigestString(digest, entry.relativePath());
updateDigestLong(digest, Files.size(entry.source()));
try (InputStream input = Files.newInputStream(entry.source())) {
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
}
}
}
}
}
return HexFormat.of().formatHex(digest.digest());
}
public static String compilerIdentity(IDataFixer fixer) {
IDataFixer requiredFixer = Objects.requireNonNull(fixer, "fixer");
return String.join(
"|",
Integer.toString(INPUT_FINGERPRINT_SCHEMA),
BuildConstants.COMMIT,
BuildConstants.MINECRAFT_VERSION,
requiredFixer.getClass().getName(),
Integer.toString(DataVersion.minSupportedPackFormat()),
Integer.toString(DataVersion.getLatest().getPackFormat()));
}
public static CompilationResult compile(
List<File> packRoots,
KList<File> datapackRoots,
@@ -102,14 +196,22 @@ public final class IrisDatapackCompiler {
return new CompilationResult(packCount, dimensionCount, countBiomes(biomes));
}
private static void collectInstalledPackRoots(Path packsRoot, Map<Path, File> roots) throws IOException {
private static void collectInstalledPackRoots(
Path packsRoot,
Map<Path, File> roots,
boolean validateWholePack
) throws IOException {
List<File> candidates = PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(packsRoot.toFile());
for (File candidate : candidates) {
addPackRoot(candidate.toPath(), roots);
addPackRoot(candidate.toPath(), roots, validateWholePack);
}
}
private static void collectWorldPackRoots(Path dimensionsRoot, Map<Path, File> roots) throws IOException {
private static void collectWorldPackRoots(
Path dimensionsRoot,
Map<Path, File> roots,
boolean validateWholePack
) throws IOException {
if (Files.isSymbolicLink(dimensionsRoot)
|| !Files.isDirectory(dimensionsRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
@@ -133,11 +235,15 @@ public final class IrisDatapackCompiler {
});
candidates.sort(Comparator.comparing(Path::toString));
for (Path candidate : candidates) {
addPackRoot(candidate, roots);
addPackRoot(candidate, roots, validateWholePack);
}
}
private static void addPackRoot(Path root, Map<Path, File> roots) throws IOException {
private static void addPackRoot(
Path root,
Map<Path, File> roots,
boolean validateWholePack
) throws IOException {
if (!hasDimensions(root)) {
return;
}
@@ -145,8 +251,13 @@ public final class IrisDatapackCompiler {
if (!Files.isDirectory(normalized)) {
return;
}
PackDirectoryResolver.requireSafePackTree(normalized.toFile());
Path identity = normalized.toRealPath();
if (!Files.isDirectory(identity, LinkOption.NOFOLLOW_LINKS)) {
return;
}
if (validateWholePack) {
PackDirectoryResolver.requireSafePackTree(normalized.toFile());
}
roots.putIfAbsent(identity, normalized.toFile());
}
@@ -165,6 +276,77 @@ public final class IrisDatapackCompiler {
}
}
private static List<CompilerInputEntry> collectCompilerInputEntries(Path packRoot) throws IOException {
List<CompilerInputEntry> entries = new ArrayList<>();
for (String directoryName : INPUT_DIRECTORIES) {
Path directory = packRoot.resolve(directoryName);
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
if (Files.isSymbolicLink(directory)
|| !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Iris datapack compiler input is missing or unsafe: " + directory);
}
Files.walkFileTree(directory, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path child, BasicFileAttributes attributes) throws IOException {
if (attributes.isSymbolicLink() || Files.isSymbolicLink(child)) {
throw new IOException("Iris datapack compiler input contains a symbolic link: " + child);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
throw new IOException("Iris datapack compiler input contains a symbolic link: " + file);
}
if (!attributes.isRegularFile()) {
throw new IOException("Iris datapack compiler input contains an unsupported entry: " + file);
}
if (file.getFileName().toString().endsWith(".json")) {
String relativePath = packRoot.relativize(file).toString().replace(File.separatorChar, '/');
entries.add(new CompilerInputEntry(file, relativePath));
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
throw new IOException("Unable to inspect Iris datapack compiler input: " + file, failure);
}
});
}
entries.sort(Comparator.comparing(CompilerInputEntry::relativePath));
return entries;
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 not available", exception);
}
}
private static void updateDigestString(MessageDigest digest, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
updateDigestInt(digest, bytes.length);
digest.update(bytes);
}
private static void updateDigestInt(MessageDigest digest, int value) {
for (int shift = Integer.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) {
digest.update((byte) (value >>> shift));
}
}
private static void updateDigestLong(MessageDigest digest, long value) {
for (int shift = Long.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) {
digest.update((byte) (value >>> shift));
}
}
private static void resetOutputRoots(Collection<File> datapackRoots) throws IOException {
for (File datapackRoot : datapackRoots) {
Path root = datapackRoot.toPath().toAbsolutePath().normalize();
@@ -204,6 +386,9 @@ public final class IrisDatapackCompiler {
public record CompilationResult(int packCount, int dimensionCount, int biomeCount) {
}
private record CompilerInputEntry(Path source, String relativePath) {
}
public static final class DimensionHeight {
private final IDataFixer fixer;
private final AtomicIntegerArray[] dimensions = new AtomicIntegerArray[3];
@@ -21,6 +21,7 @@ package art.arcane.iris.core;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.datapack.DatapackIngestService.ReapplyOutcome;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
@@ -73,6 +74,7 @@ import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.stream.Stream;
import art.arcane.iris.core.localization.BukkitRuntimeMessages;
@@ -81,8 +83,18 @@ import art.arcane.volmlib.util.localization.MessageArgument;
public class ServerConfigurator {
private static final Object DATAPACK_INSTALL_LOCK = new Object();
private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace";
private static final String COMPILER_INPUT_FINGERPRINT_CACHE = "datapack-compiler-input-fingerprint";
private static volatile boolean loadedDatapackRuntimeReady;
private static volatile String loadedDatapackCompilerInputFingerprint = "";
private static volatile long loadedDatapackRuntimeGeneration;
private static volatile boolean loadedDatapackRestartRequired;
public static void configure() {
synchronized (DATAPACK_INSTALL_LOCK) {
invalidateLoadedDatapackRuntime();
loadedDatapackCompilerInputFingerprint = "";
loadedDatapackRestartRequired = false;
}
IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration();
if (s.isConfigureSpigotTimeoutTime()) {
J.attempt(ServerConfigurator::increaseKeepAliveSpigot);
@@ -93,15 +105,91 @@ public class ServerConfigurator {
}
if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) {
loadedDatapackRuntimeReady = !IrisSettings.get().getGeneral().adjustVanillaHeight
&& pinLoadedDatapackCompilerInputs(
DefaultPackBootstrapProvisioner.compilerInputFingerprintThisStartup());
IrisLogging.info("Paper loaded the Iris datapack during bootstrap; skipping the legacy startup install.");
} else {
DatapackInstallResult result = installDataPacks(true);
loadedDatapackRuntimeReady = result.succeeded()
&& !result.restartRequired()
&& pinLoadedDatapackCompilerInputs();
if (result.restartRequired() && IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall()) {
restart();
}
}
}
public static boolean isLoadedDatapackRuntimeReady(IrisDimension dimension) {
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "Iris dimension");
if (!loadedDatapackRuntimeReady
|| loadedDatapackRestartRequired
|| !BukkitPlatform.hasPlugin()
|| !BukkitPlatform.plugin().isEnabled()) {
return false;
}
try {
if (!INMS.get().supportsIrisWorldGeneration()
|| INMS.get().missingDimensionTypes(requiredDimension.getDimensionTypeKey())) {
return false;
}
String currentFingerprint = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer());
return reusableRuntimeFingerprint(
loadedDatapackCompilerInputFingerprint,
currentFingerprint);
} catch (IOException | RuntimeException exception) {
IrisLogging.reportError("Unable to verify loaded Iris datapack compiler inputs.", exception);
return false;
}
}
public static LoadedDatapackRuntimeInvalidation invalidateLoadedDatapackRuntime() {
synchronized (DATAPACK_INSTALL_LOCK) {
boolean wasReady = loadedDatapackRuntimeReady;
String fingerprint = loadedDatapackCompilerInputFingerprint;
loadedDatapackRuntimeReady = false;
loadedDatapackRuntimeGeneration++;
return new LoadedDatapackRuntimeInvalidation(
loadedDatapackRuntimeGeneration,
wasReady,
fingerprint);
}
}
public static void requireDatapackRestart() {
synchronized (DATAPACK_INSTALL_LOCK) {
invalidateLoadedDatapackRuntime();
loadedDatapackRestartRequired = true;
}
}
public static void restoreLoadedDatapackRuntimeIfUnchanged(
LoadedDatapackRuntimeInvalidation invalidation
) {
if (invalidation == null
|| !invalidation.wasReady()
|| invalidation.fingerprint().isBlank()) {
return;
}
String currentFingerprint;
try {
currentFingerprint = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer());
} catch (IOException | RuntimeException exception) {
IrisLogging.reportError("Unable to restore loaded Iris datapack runtime readiness.", exception);
return;
}
synchronized (DATAPACK_INSTALL_LOCK) {
if (loadedDatapackRuntimeGeneration != invalidation.generation()
|| loadedDatapackRuntimeReady
|| loadedDatapackRestartRequired
|| !reusableRuntimeFingerprint(invalidation.fingerprint(), currentFingerprint)) {
return;
}
loadedDatapackCompilerInputFingerprint = currentFingerprint;
loadedDatapackRuntimeReady = true;
}
}
private static void increaseKeepAliveSpigot() throws IOException, InvalidConfigurationException {
File spigotConfig = new File("spigot.yml");
FileConfiguration f = new YamlConfiguration();
@@ -156,6 +244,26 @@ public class ServerConfigurator {
}
private static DatapackInstallResult installDataPacksLocked(IDataFixer fixer, boolean fullInstall) {
if (fixer == null) {
IrisLogging.error("Unable to install datapacks, fixer is null!");
return DatapackInstallResult.failedResult();
}
KList<File> datapacksFolders = getDatapacksFolder();
ReapplyOutcome reapply = DatapackIngestService.reapplyFromStaging(datapacksFolders);
if (!reapply.succeeded()) {
return DatapackInstallResult.failedResult();
}
return compileDataPacksLocked(fixer, fullInstall, reapply);
}
private static DatapackInstallResult compileDataPacksLocked(
IDataFixer fixer,
boolean fullInstall,
ReapplyOutcome reapply
) {
if (!Objects.requireNonNull(reapply, "External datapack reapply outcome").succeeded()) {
return DatapackInstallResult.failedResult();
}
if (fixer == null) {
IrisLogging.error("Unable to install datapacks, fixer is null!");
return DatapackInstallResult.failedResult();
@@ -165,18 +273,12 @@ public class ServerConfigurator {
} else {
IrisLogging.debug("Checking Data Packs...");
}
KList<File> datapacksFolders = getDatapacksFolder();
if (!DatapackIngestService.reapplyFromStaging(datapacksFolders)) {
IrisLogging.error("Unable to compile Iris datapacks while external datapack recovery is incomplete.");
return DatapackInstallResult.failedResult();
}
List<File> packRoots;
try (Stream<IrisData> stream = allPacks()) {
packRoots = stream
.map(IrisData::getDataFolder)
.map(File::getAbsoluteFile)
.distinct()
.toList();
try {
packRoots = collectCompilerPackRoots();
} catch (IOException exception) {
IrisLogging.reportError("Unable to resolve Iris datapack compiler roots.", exception);
return DatapackInstallResult.failedResult();
}
KList<File> liveRoots = getIrisDatapackRoots();
@@ -237,7 +339,8 @@ public class ServerConfigurator {
IrisLogging.debug("Data Packs Setup!");
}
boolean restartRequired = fullInstall && verifyDataPacksPost();
boolean verifiedRestartRequired = fullInstall && verifyDataPacksPost();
boolean restartRequired = fullInstall && (reapply.changed() || verifiedRestartRequired);
return restartRequired
? DatapackInstallResult.restartRequiredResult()
: DatapackInstallResult.readyResult();
@@ -264,33 +367,167 @@ public class ServerConfigurator {
}
public static DatapackInstallResult installDataPacksIfChanged(boolean fullInstall) {
return installDataPacksIfChanged(fullInstall, null);
}
public static DatapackInstallResult installDataPacksIfChanged(
boolean fullInstall,
BiConsumer<String, Long> timingConsumer
) {
synchronized (DATAPACK_INSTALL_LOCK) {
File packsDir = IrisPlatforms.get().dataFolder("packs");
File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint");
FingerprintCache cached = readFingerprintCache(cacheFile.toPath());
PackFingerprint fingerprint;
try {
fingerprint = resolvePackFingerprint(packsDir, cached.metadata(), cached.content());
} catch (RuntimeException exception) {
IrisLogging.reportError("Unable to fingerprint Iris packs safely", exception);
long totalStart = System.nanoTime();
File cacheFile = new File(
IrisPlatforms.get().dataFolder("cache"),
COMPILER_INPUT_FINGERPRINT_CACHE);
String cached = readCompilerInputFingerprintCache(cacheFile.toPath());
long recoveryStart = System.nanoTime();
ReapplyOutcome reapply = DatapackIngestService.reapplyFromStaging(getDatapacksFolder());
reportTiming(timingConsumer, "datapack_external_recovery", recoveryStart);
if (!reapply.succeeded()) {
reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart);
return DatapackInstallResult.failedResult();
}
String current = fingerprint.content();
if (!current.isEmpty() && current.equals(cached.content())) {
if (!fingerprint.metadata().equals(cached.metadata())) {
writeFingerprintCache(cacheFile.toPath(), fingerprint);
}
if (fullInstall && loadedDatapackRestartRequired) {
reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart);
return DatapackInstallResult.restartRequiredResult();
}
String current;
long fingerprintStart = System.nanoTime();
try {
current = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer());
} catch (IOException | RuntimeException exception) {
reportTiming(timingConsumer, "datapack_compiler_input_fingerprint", fingerprintStart);
reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart);
IrisLogging.reportError("Unable to fingerprint Iris datapack compiler inputs safely", exception);
return DatapackInstallResult.failedResult();
}
reportTiming(timingConsumer, "datapack_compiler_input_fingerprint", fingerprintStart);
boolean loadedCompilerInputsChanged = !loadedDatapackCompilerInputFingerprint.isBlank()
&& !reusableRuntimeFingerprint(
loadedDatapackCompilerInputFingerprint,
current);
if (!current.isEmpty() && current.equals(cached)) {
IrisLogging.debug("Data packs unchanged, skipping install.");
return DatapackInstallResult.unchangedResult();
DatapackInstallResult result = fullInstall && loadedCompilerInputsChanged
? DatapackInstallResult.restartRequiredResult()
: resultForUnchangedFingerprint(fullInstall, reapply);
if (result.restartRequired()) {
requireDatapackRestart();
}
reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart);
return result;
}
DatapackInstallResult result = installDataPacksLocked(resolveDataFixer(), fullInstall);
if (result.succeeded()) {
writeFingerprintCache(cacheFile.toPath(), fingerprint);
long compileStart = System.nanoTime();
DatapackInstallResult result = compileDataPacksLocked(
resolveDataFixer(),
fullInstall,
reapply);
if (fullInstall && loadedCompilerInputsChanged && result.succeeded()) {
result = DatapackInstallResult.restartRequiredResult();
}
if (result.restartRequired()) {
requireDatapackRestart();
}
reportTiming(timingConsumer, "datapack_compile_publish", compileStart);
if (result.succeeded() && !result.restartRequired()) {
writeCompilerInputFingerprintCache(cacheFile.toPath(), current);
}
reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart);
return result;
}
}
private static List<File> collectCompilerPackRoots() throws IOException {
return IrisDatapackCompiler.collectPackRoots(
IrisPlatforms.get().dataFolder().toPath(),
IrisWorldStorage.levelRoot().toPath());
}
private static String computeCurrentDatapackCompilerInputFingerprint(IDataFixer fixer) throws IOException {
return IrisDatapackCompiler.computeInputFingerprint(
IrisDatapackCompiler.collectCompilerInputRoots(
IrisPlatforms.get().dataFolder().toPath(),
IrisWorldStorage.levelRoot().toPath()),
Objects.requireNonNull(fixer, "Datapack fixer"),
IrisSettings.get().getGeneral().adjustVanillaHeight);
}
private static boolean pinLoadedDatapackCompilerInputs() {
return pinLoadedDatapackCompilerInputs(null);
}
private static boolean pinLoadedDatapackCompilerInputs(String expectedFingerprint) {
if (loadedDatapackRestartRequired) {
return false;
}
try {
String fingerprint = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer());
if (fingerprint.isBlank()
|| expectedFingerprint != null
&& !reusableRuntimeFingerprint(expectedFingerprint, fingerprint)) {
return false;
}
loadedDatapackCompilerInputFingerprint = fingerprint;
File cacheFile = new File(
IrisPlatforms.get().dataFolder("cache"),
COMPILER_INPUT_FINGERPRINT_CACHE);
writeCompilerInputFingerprintCache(cacheFile.toPath(), fingerprint);
return true;
} catch (IOException | RuntimeException exception) {
loadedDatapackCompilerInputFingerprint = "";
IrisLogging.reportError("Unable to pin loaded Iris datapack compiler inputs.", exception);
return false;
}
}
static boolean reusableRuntimeFingerprint(String loadedFingerprint, String currentFingerprint) {
return loadedFingerprint != null
&& !loadedFingerprint.isBlank()
&& loadedFingerprint.equals(currentFingerprint);
}
private static void reportTiming(
BiConsumer<String, Long> timingConsumer,
String phase,
long startedAtNanos
) {
if (timingConsumer == null) {
return;
}
try {
timingConsumer.accept(phase, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos));
} catch (Throwable exception) {
IrisLogging.reportError("Datapack timing consumer failed during phase \"" + phase + "\".", exception);
}
}
static DatapackInstallResult resultForUnchangedFingerprint(
boolean fullInstall,
ReapplyOutcome reapply
) {
if (!Objects.requireNonNull(reapply, "External datapack reapply outcome").succeeded()) {
return DatapackInstallResult.failedResult();
}
if (!reapply.changed()) {
return DatapackInstallResult.unchangedResult();
}
return fullInstall
? DatapackInstallResult.restartRequiredResult()
: DatapackInstallResult.readyResult();
}
static PackFingerprint resolvePostRecoveryPackFingerprint(
File packsDir,
String cachedMetadata,
String cachedContent,
ReapplyOutcome reapply
) {
if (Objects.requireNonNull(reapply, "External datapack reapply outcome").changed()) {
return resolvePackFingerprint(packsDir, "", "");
}
return resolvePackFingerprint(packsDir, cachedMetadata, cachedContent);
}
static PackFingerprint resolvePackFingerprint(File packsDir, String cachedMetadata, String cachedContent) {
String metadata = computePackMetadataDigest(packsDir);
if (!metadata.isEmpty()
@@ -392,6 +629,17 @@ public class ServerConfigurator {
}
}
private static String readCompilerInputFingerprintCache(Path cacheFile) {
if (!Files.isRegularFile(cacheFile)) {
return "";
}
try {
return Files.readString(cacheFile, StandardCharsets.UTF_8).trim();
} catch (IOException exception) {
return "";
}
}
private static void writeFingerprintCache(Path cacheFile, PackFingerprint fingerprint) {
try {
writeFingerprintAtomic(cacheFile, fingerprint.content() + "\n" + fingerprint.metadata());
@@ -400,6 +648,15 @@ public class ServerConfigurator {
}
}
private static void writeCompilerInputFingerprintCache(Path cacheFile, String fingerprint) {
try {
writeFingerprintAtomic(cacheFile, fingerprint);
} catch (IOException exception) {
IrisLogging.warn("Failed to write datapack compiler-input fingerprint cache: "
+ exception.getMessage());
}
}
private static void writeFingerprintAtomic(Path target, String fingerprint) throws IOException {
Path absoluteTarget = target.toAbsolutePath().normalize();
Path parent = absoluteTarget.getParent();
@@ -571,6 +828,7 @@ public class ServerConfigurator {
}
public static void restart(String reason) {
requireDatapackRestart();
LifecycleOperationCoordinator.get().quiesceForRestart(() -> J.s(() -> {
IrisLogging.warn(reason + " Restarting server to restore a safe lifecycle boundary.");
J.s(() -> {
@@ -659,4 +917,14 @@ public class ServerConfigurator {
return key == null ? null : key.toString();
}
public record LoadedDatapackRuntimeInvalidation(
long generation,
boolean wasReady,
String fingerprint
) {
public LoadedDatapackRuntimeInvalidation {
fingerprint = Objects.requireNonNullElse(fingerprint, "");
}
}
}
@@ -0,0 +1,91 @@
package art.arcane.iris.core;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class SnapshotDirectoryTreeDeleter {
private SnapshotDirectoryTreeDeleter() {
}
public static void delete(Path target) throws IOException {
Path root = Objects.requireNonNull(target, "target").toAbsolutePath().normalize();
if (root.getParent() == null) {
throw new IOException("Refusing to delete a filesystem root: " + root);
}
if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) {
return;
}
requireDirectory(root);
deleteDirectory(root);
}
private static void deleteDirectory(Path directory) throws IOException {
requireDirectory(directory);
List<SnapshotEntry> entries = snapshot(directory);
for (SnapshotEntry entry : entries) {
BasicFileAttributes current = requireSafeEntry(entry.path());
if (entry.directory() != current.isDirectory()
|| !sameFile(entry.fileKey(), current.fileKey())) {
throw new IOException("Directory entry changed during deletion: " + entry.path());
}
if (entry.directory()) {
deleteDirectory(entry.path());
} else {
Files.delete(entry.path());
}
}
Files.delete(directory);
}
private static List<SnapshotEntry> snapshot(Path directory) throws IOException {
ArrayList<SnapshotEntry> entries = new ArrayList<>();
try (DirectoryStream<Path> children = Files.newDirectoryStream(directory)) {
for (Path child : children) {
Path normalized = child.toAbsolutePath().normalize();
if (!Objects.equals(normalized.getParent(), directory)) {
throw new IOException("Directory entry escapes its parent: " + child);
}
BasicFileAttributes attributes = requireSafeEntry(normalized);
entries.add(new SnapshotEntry(normalized, attributes.isDirectory(), attributes.fileKey()));
}
}
return List.copyOf(entries);
}
private static BasicFileAttributes requireDirectory(Path directory) throws IOException {
BasicFileAttributes attributes = requireSafeEntry(directory);
if (!attributes.isDirectory()) {
throw new IOException("Deletion target is not a directory: " + directory);
}
return attributes;
}
private static BasicFileAttributes requireSafeEntry(Path entry) throws IOException {
BasicFileAttributes attributes = Files.readAttributes(
entry,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS
);
if (attributes.isSymbolicLink()) {
throw new IOException("Deletion target contains a symbolic link: " + entry);
}
if (!attributes.isDirectory() && !attributes.isRegularFile()) {
throw new IOException("Deletion target contains an unsafe filesystem entry: " + entry);
}
return attributes;
}
private static boolean sameFile(Object expected, Object actual) {
return expected == null || actual == null || expected.equals(actual);
}
private record SnapshotEntry(Path path, boolean directory, Object fileKey) {
}
}
@@ -99,6 +99,7 @@ public final class DatapackIngestService {
private static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json";
private static final int OWNERSHIP_SCHEMA = 1;
private static final int TRANSACTION_SCHEMA = 2;
private static final int STRUCTURE_IMPORT_FORMAT_REVISION = 3;
private static final int MAX_REDIRECTS = 5;
private static final int MAX_ARCHIVE_ENTRIES = 100_000;
private static final int MAX_CACHE_FILES = 32;
@@ -157,12 +158,25 @@ public final class DatapackIngestService {
}
public static Report ingest(VolmitSender sender, KList<String> urls, boolean restart) {
ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation =
ServerConfigurator.invalidateLoadedDatapackRuntime();
Report report;
TRANSACTION_LOCK.lock();
try {
return ingestLocked(sender, urls, restart);
report = ingestLocked(sender, urls, restart);
} finally {
TRANSACTION_LOCK.unlock();
}
if (!report.changed() && report.getFailed().isEmpty()) {
ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation);
} else if (report.changed()) {
if (restart) {
ServerConfigurator.restart();
} else {
ServerConfigurator.requireDatapackRestart();
}
}
return report;
}
private static Report ingestLocked(VolmitSender sender, KList<String> urls, boolean restart) {
@@ -266,11 +280,9 @@ public final class DatapackIngestService {
if (report.changed()) {
message(sender, C.YELLOW + "New datapack structures were installed. A server restart is required for them to register and generate.");
message(sender, C.GRAY + "After the restart they generate natively - no import needed. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import <dimension>, or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures.");
message(sender, C.GRAY + "After the restart they generate natively only in Iris dimensions that declare their source URL - no import needed. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import <dimension>, or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures.");
message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; deny non-minecraft datapack and mod structures explicitly with importedStructures.disabled.");
if (restart) {
ServerConfigurator.restart();
} else {
if (!restart) {
message(sender, C.GRAY + "Run with restart=true to restart now, or restart manually. After restart, run /iris structure list <dimension> to see the new keys.");
}
}
@@ -278,23 +290,40 @@ public final class DatapackIngestService {
return report;
}
public static boolean reapplyFromStaging(KList<File> worldFolders) {
public static ReapplyOutcome reapplyFromStaging(KList<File> worldFolders) {
ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation =
ServerConfigurator.invalidateLoadedDatapackRuntime();
ReapplyOutcome outcome;
TRANSACTION_LOCK.lock();
try {
return reapplyFromStagingLocked(worldFolders);
outcome = reapplyFromStagingLocked(worldFolders);
} finally {
TRANSACTION_LOCK.unlock();
}
if (outcome.succeeded() && !outcome.changed()) {
ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation);
} else if (outcome.changed()) {
ServerConfigurator.requireDatapackRestart();
}
return outcome;
}
private static boolean reapplyFromStagingLocked(KList<File> worldFolders) {
private static ReapplyOutcome reapplyFromStagingLocked(KList<File> worldFolders) {
File root = IrisPlatforms.get().dataFolder("datapacks");
if (!recoverBeforeReapply(root, worldFolders)) {
return false;
ReapplyOutcome recovery = recoverBeforeReapplyOutcome(root, worldFolders);
if (!recovery.succeeded()) {
return reportReapplyFailure(recovery);
}
File stagingDir = IrisPlatforms.get().dataFolderNoCreate("datapacks", "staging");
return reapplyStagingRoot(
root, stagingDir, worldFolders, resolveStripOverrides());
ReapplyOutcome repair = reapplyStagingRootOutcome(
root,
stagingDir,
worldFolders,
resolveStripOverrides());
if (!repair.succeeded()) {
return reportReapplyFailure(repair);
}
return ReapplyOutcome.success(recovery.recovered(), repair.repaired());
}
static boolean reapplyStagingRoot(
@@ -302,23 +331,36 @@ public final class DatapackIngestService {
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
return reportReapplyFailure(reapplyStagingRootOutcome(
root,
stagingDir,
worldFolders,
stripOverrides)).succeeded();
}
static ReapplyOutcome reapplyStagingRootOutcome(
File root,
File stagingDir,
KList<File> worldFolders,
boolean stripOverrides
) {
Manifest manifest = readManifest(root);
if (stagingDir == null
|| !Files.exists(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
if (manifest.entries.isEmpty()) {
return true;
return ReapplyOutcome.success(false, false);
}
IrisLogging.error("Managed datapack staging is missing at "
+ (stagingDir == null ? new File(root, "staging").getPath() : stagingDir.getPath()));
return false;
File missing = stagingDir == null ? new File(root, "staging") : stagingDir;
return ReapplyOutcome.failed(new IOException(
"Managed datapack staging is missing at " + missing.getPath()));
}
if (Files.isSymbolicLink(stagingDir.toPath())
|| !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
IrisLogging.error("Managed datapack staging is not a safe directory at " + stagingDir.getPath());
return false;
return ReapplyOutcome.failed(new IOException(
"Managed datapack staging is not a safe directory at " + stagingDir.getPath()));
}
return reapplyStagedDirectories(
return reapplyStagedDirectoriesOutcome(
root, stagingDir, worldFolders, stripOverrides, manifest);
}
@@ -330,14 +372,19 @@ public final class DatapackIngestService {
) {
if (Files.isSymbolicLink(stagingDir.toPath())
|| !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) {
IrisLogging.error("Managed datapack staging is not a safe directory at " + stagingDir.getPath());
return false;
return reportReapplyFailure(ReapplyOutcome.failed(new IOException(
"Managed datapack staging is not a safe directory at "
+ stagingDir.getPath()))).succeeded();
}
return reapplyStagedDirectories(
root, stagingDir, worldFolders, stripOverrides, readManifest(root));
return reportReapplyFailure(reapplyStagedDirectoriesOutcome(
root,
stagingDir,
worldFolders,
stripOverrides,
readManifest(root))).succeeded();
}
private static boolean reapplyStagedDirectories(
private static ReapplyOutcome reapplyStagedDirectoriesOutcome(
File root,
File stagingDir,
KList<File> worldFolders,
@@ -346,37 +393,57 @@ public final class DatapackIngestService {
) {
File[] staged = stagingDir.listFiles(File::isDirectory);
if (staged == null) {
IrisLogging.error("Unable to enumerate managed datapack staging at " + stagingDir.getPath());
return false;
return ReapplyOutcome.failed(new IOException(
"Unable to enumerate managed datapack staging at " + stagingDir.getPath()));
}
boolean successful = true;
boolean repaired = false;
IOException failure = null;
for (Entry entry : manifest.entries) {
File stagedDir = new File(stagingDir, entry.id);
if (isRecordedUnchangedInstall(stagedDir, worldFolders, entry, stripOverrides)) {
continue;
}
if (!isUsableStaging(stagedDir, entry)) {
IrisLogging.error("Managed datapack staging is unusable for '" + entry.id
+ "' at " + stagedDir.getPath());
forgetInstallMetadata(entry);
successful = false;
failure = appendFailure(failure, new IOException(
"Managed datapack staging is unusable for '" + entry.id
+ "' at " + stagedDir.getPath()));
continue;
}
try {
InstallResult result = install(stagedDir, worldFolders, entry, stripOverrides);
if (result.changed()) {
repaired = true;
IrisLogging.warn("Repaired installed datapack '" + entry.id
+ "' from Iris staging before datapack compilation.");
}
recordInstallMetadata(stagedDir, worldFolders, entry);
} catch (IOException e) {
IrisLogging.reportError(e);
forgetInstallMetadata(entry);
successful = false;
failure = appendFailure(failure, e);
}
}
writeManifest(root, manifest);
return successful;
return failure == null
? ReapplyOutcome.success(false, repaired)
: ReapplyOutcome.failed(failure);
}
private static IOException appendFailure(IOException current, IOException additional) {
if (current == null) {
return additional;
}
current.addSuppressed(additional);
return current;
}
private static ReapplyOutcome reportReapplyFailure(ReapplyOutcome outcome) {
if (!outcome.succeeded()) {
IrisLogging.reportError(
"External datapack recovery or staging repair failed.",
outcome.failure().orElseThrow());
}
return outcome;
}
private static boolean isRecordedUnchangedInstall(
@@ -501,22 +568,30 @@ public final class DatapackIngestService {
}
static boolean recoverBeforeReapply(File root, List<File> worldFolders) {
return reportReapplyFailure(recoverBeforeReapplyOutcome(root, worldFolders)).succeeded();
}
private static ReapplyOutcome recoverBeforeReapplyOutcome(File root, List<File> worldFolders) {
try {
recoverTransactions(root, worldFolders);
return ReapplyOutcome.success(recoverTransactions(root, worldFolders), false);
} catch (IOException e) {
IrisLogging.reportError("Datapack staging reapply blocked by incomplete transaction recovery.", e);
return false;
return ReapplyOutcome.failed(e);
}
return true;
}
public static boolean remove(VolmitSender sender, String id) {
ServerConfigurator.invalidateLoadedDatapackRuntime();
boolean removed;
TRANSACTION_LOCK.lock();
try {
return removeLocked(sender, id);
removed = removeLocked(sender, id);
} finally {
TRANSACTION_LOCK.unlock();
}
if (removed) {
ServerConfigurator.requireDatapackRestart();
}
return removed;
}
private static boolean removeLocked(VolmitSender sender, String id) {
@@ -737,6 +812,7 @@ public final class DatapackIngestService {
List<PreparedEditableImport> prepared = new ArrayList<>();
Set<String> targetIdSet = new TreeSet<>(entry.importedBundles.keySet());
targetIdSet.addAll(entry.importedTargets.keySet());
targetIdSet.addAll(entry.importAttempts.keySet());
List<String> targetIds = new ArrayList<>(targetIdSet);
targetIds.sort(String::compareTo);
try {
@@ -792,7 +868,7 @@ public final class DatapackIngestService {
if (ownedSource.isEmpty() || !sourceClaimsContain(bundle.getValue(), ownedSource.get())) {
continue;
}
removals.add(new StructureTransactionWriter.OwnedRemoval(
removals.add(StructureTransactionWriter.OwnedRemoval.managedDatapack(
targetKey,
ownedSource.get().kind(),
ownedSource.get().key()
@@ -826,6 +902,7 @@ public final class DatapackIngestService {
}
if (candidateRetained) {
candidate.importedTargets.remove(targetId);
candidate.importAttempts.remove(targetId);
candidate.structuresImported = false;
}
}
@@ -963,6 +1040,56 @@ public final class DatapackIngestService {
}
}
public static List<StructureScopeResources> installedStructureScopeResources() throws IOException {
TRANSACTION_LOCK.lock();
try {
File root = IrisPlatforms.get().dataFolder("datapacks");
Manifest manifest = readManifest(root);
KList<File> datapackFolders = ServerConfigurator.getDatapacksFolder();
List<StructureScopeResources> resources = new ArrayList<>();
for (Entry entry : manifest.entries) {
boolean found = false;
for (File datapackFolder : datapackFolders) {
File installedDirectory = new File(datapackFolder, entry.id);
if (!Files.exists(installedDirectory.toPath(), LinkOption.NOFOLLOW_LINKS)) {
continue;
}
resources.add(scanInstalledStructureScope(installedDirectory, entry));
found = true;
}
if (!found) {
throw new IOException("Missing installed Iris-managed datapack '" + entry.id + "'");
}
}
return List.copyOf(resources);
} finally {
TRANSACTION_LOCK.unlock();
}
}
static StructureScopeResources scanInstalledStructureScope(File directory, Entry entry) throws IOException {
Path path = directory.toPath();
if (Files.isSymbolicLink(path)
|| !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Invalid installed Iris-managed datapack directory " + directory.getPath());
}
validatePackMetadata(directory);
rejectSymbolicLinks(directory);
Ownership ownership = readOwnership(directory);
if (!ownershipSourceMatches(ownership, entry)) {
throw new IOException("Installed datapack ownership mismatch at " + directory.getPath());
}
if (!Objects.equals(ownership.contentHash, directoryHash(directory))) {
throw new IOException("Installed Iris-managed datapack is modified or corrupt at "
+ directory.getPath());
}
PackResources resources = scanPackResources(directory);
return new StructureScopeResources(
entry.url,
resources.structureKeys(),
resources.structureSetKeys());
}
private static void ingestSingle(
VolmitSender sender,
String url,
@@ -2258,10 +2385,11 @@ public final class DatapackIngestService {
private static PackResources scanPackResources(File root) throws IOException {
TreeSet<String> structureKeys = new TreeSet<>();
TreeSet<String> structureSetKeys = new TreeSet<>();
TreeSet<String> templateKeys = new TreeSet<>();
Path dataRoot = new File(root, "data").toPath();
if (!Files.isDirectory(dataRoot, LinkOption.NOFOLLOW_LINKS)) {
return new PackResources(new ArrayList<>(), new ArrayList<>());
return new PackResources(new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
}
try (Stream<Path> paths = Files.walk(dataRoot)) {
for (Path path : paths.filter(file -> Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)).toList()) {
@@ -2273,11 +2401,16 @@ public final class DatapackIngestService {
String normalized = relative.subpath(1, relative.getNameCount()).toString().replace(File.separatorChar, '/');
addResourceKey(structureKeys, namespace, normalized, "worldgen/structure/", ".json");
addResourceKey(structureKeys, namespace, normalized, "worldgen/structures/", ".json");
addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_set/", ".json");
addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_sets/", ".json");
addResourceKey(templateKeys, namespace, normalized, "structure/", ".nbt");
addResourceKey(templateKeys, namespace, normalized, "structures/", ".nbt");
}
}
return new PackResources(new ArrayList<>(structureKeys), new ArrayList<>(templateKeys));
return new PackResources(
new ArrayList<>(structureKeys),
new ArrayList<>(structureSetKeys),
new ArrayList<>(templateKeys));
}
private static void addResourceKey(Set<String> keys, String namespace, String path, String prefix, String suffix) {
@@ -2362,13 +2495,13 @@ public final class DatapackIngestService {
Set<String> configured = configuredImports(data);
String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString();
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url) && entry.importedBundles.containsKey(targetId)) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
cleanupTargets++;
}
}
if (!cleanupRemovedImports(data, targetId, configured, manifest.entries, manifestEntriesByUrl)) {
for (Entry entry : manifest.entries) {
if (!configured.contains(entry.url) && entry.importedBundles.containsKey(targetId)) {
if (!configured.contains(entry.url) && hasImportState(entry, targetId)) {
failedUrls.add(entry.url);
}
}
@@ -2380,7 +2513,7 @@ public final class DatapackIngestService {
Set<String> pendingUrls = new HashSet<>();
for (String url : configured) {
Entry entry = entriesByUrl.get(url);
if (entry != null && !importRevision(entry).equals(entry.importedTargets.get(targetId))) {
if (entry != null && importPending(entry, targetId)) {
pendingUrls.add(url);
}
}
@@ -2411,20 +2544,13 @@ public final class DatapackIngestService {
}
attemptedPacks++;
try {
BulkStructureImporter.Report report = BulkStructureImporter.importDatapackStructures(
BulkStructureImporter.Report report = BulkStructureImporter.importManagedDatapackStructures(
data,
StructureImporter.Mode.OVERWRITE,
BukkitPlatform.console(),
structureKeys,
templateKeys
);
if (report.failed() > 0) {
IrisLogging.error("Datapack structure import for pack '%s' reported %d failure(s); the manifest remains pending for retry.",
data.getDataFolder().getPath(), report.failed());
failedUrls.addAll(pendingUrls);
reconcileFailedImportInventories(root, manifest, data, targetId, pendingUrls, entriesByUrl);
continue;
}
Set<String> successfulPendingUrls = new HashSet<>(pendingUrls);
Set<String> incompleteUrls = new HashSet<>();
for (String pendingUrl : pendingUrls) {
@@ -2434,13 +2560,25 @@ public final class DatapackIngestService {
successfulPendingUrls.remove(pendingUrl);
incompleteUrls.add(pendingUrl);
failedUrls.add(pendingUrl);
IrisLogging.error("Datapack structure import for '%s' did not prove every requested bundle in pack '%s'; the source remains pending for retry.",
pendingUrl, data.getDataFolder().getPath());
}
}
if (report.failed() > 0 && incompleteUrls.isEmpty()) {
successfulPendingUrls.clear();
incompleteUrls.addAll(pendingUrls);
failedUrls.addAll(pendingUrls);
}
if (!incompleteUrls.isEmpty()) {
reconcileFailedImportInventories(
boolean reconciled = reconcileFailedImportInventories(
root, manifest, data, targetId, incompleteUrls, entriesByUrl);
if (report.retryRequired() || !reconciled) {
IrisLogging.error("Datapack structure import for pack '%s' reported %d incomplete source(s) and remains pending because a retryable runtime failure occurred.",
data.getDataFolder().getPath(), incompleteUrls.size());
} else if (recordDeterministicImportAttempts(
root, manifest, targetId, incompleteUrls, entriesByUrl)) {
IrisLogging.warn("Datapack structure import for pack '"
+ data.getDataFolder().getPath() + "' left " + incompleteUrls.size()
+ " source(s) incomplete after deterministic validation failures. Iris will retain the partial editable imports without retrying until the datapack source, importer format, or target pack changes.");
}
}
Map<String, String> sharedBundles = desiredBundles(configured, entriesByUrl);
boolean packCompleted = false;
@@ -2457,7 +2595,7 @@ public final class DatapackIngestService {
continue;
}
entry.importedBundles.put(targetId, desired);
entry.importedTargets.put(targetId, importRevision(entry));
recordSuccessfulImport(entry, targetId);
completedUrls.add(pendingUrl);
packCompleted = true;
}
@@ -2492,8 +2630,29 @@ public final class DatapackIngestService {
+ " pack(s). Reference the imported keys from a 'structures' placement to position them manually.");
}
private static String importRevision(Entry entry) {
return safe(entry.versionId) + ":" + safe(entry.sha1);
static String importRevision(Entry entry) {
return importRevision(entry, STRUCTURE_IMPORT_FORMAT_REVISION);
}
static String importRevision(Entry entry, int importerFormatRevision) {
return "v" + importerFormatRevision + ":" + safe(entry.versionId) + ":" + safe(entry.sha1);
}
static boolean importPending(Entry entry, String targetId) {
String revision = importRevision(entry);
return !revision.equals(entry.importedTargets.get(targetId))
&& !revision.equals(entry.importAttempts.get(targetId));
}
static void recordDeterministicImportAttempt(Entry entry, String targetId) {
entry.importedTargets.remove(targetId);
entry.importAttempts.put(targetId, importRevision(entry));
entry.structuresImported = false;
}
static void recordSuccessfulImport(Entry entry, String targetId) {
entry.importedTargets.put(targetId, importRevision(entry));
entry.importAttempts.remove(targetId);
}
static void prepareImportRecoveryInventory(Entry entry, String targetId) {
@@ -2502,10 +2661,11 @@ public final class DatapackIngestService {
recovery.putAll(entry.importedBundles.getOrDefault(targetId, Map.of()));
entry.importedBundles.put(targetId, recovery);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
}
private static void reconcileFailedImportInventories(
private static boolean reconcileFailedImportInventories(
File root,
Manifest manifest,
IrisData data,
@@ -2513,11 +2673,13 @@ public final class DatapackIngestService {
Set<String> pendingUrls,
Map<String, Entry> entriesByUrl
) {
boolean reconciled = true;
for (String pendingUrl : pendingUrls) {
Entry entry = entriesByUrl.get(pendingUrl);
try {
reconcileFailedImportInventory(data, entry, targetId);
} catch (IOException | RuntimeException e) {
reconciled = false;
IrisLogging.reportError("Could not reconcile partial editable structure imports for '"
+ pendingUrl + "' in pack '" + data.getDataFolder().getPath()
+ "'; the conservative recovery inventory remains pending.", e);
@@ -2526,9 +2688,33 @@ public final class DatapackIngestService {
try {
writeManifestChecked(root, manifest);
} catch (IOException e) {
reconciled = false;
IrisLogging.reportError("Could not persist reconciled partial editable structure imports for pack '"
+ data.getDataFolder().getPath() + "'; the earlier recovery inventory remains durable.", e);
}
return reconciled;
}
private static boolean recordDeterministicImportAttempts(
File root,
Manifest manifest,
String targetId,
Set<String> incompleteUrls,
Map<String, Entry> entriesByUrl
) {
for (String incompleteUrl : incompleteUrls) {
recordDeterministicImportAttempt(entriesByUrl.get(incompleteUrl), targetId);
}
try {
writeManifestChecked(root, manifest);
return true;
} catch (IOException e) {
for (String incompleteUrl : incompleteUrls) {
entriesByUrl.get(incompleteUrl).importAttempts.remove(targetId);
}
IrisLogging.reportError("Could not persist deterministic editable structure import attempts; the sources remain pending for retry.", e);
return false;
}
}
private static void reconcileFailedImportInventory(
@@ -2557,6 +2743,7 @@ public final class DatapackIngestService {
entry.importedBundles.put(targetId, reconciled);
}
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
}
@@ -2574,36 +2761,47 @@ public final class DatapackIngestService {
continue;
}
Map<String, String> inventory = entry.importedBundles.get(targetId);
if (inventory == null) {
if (inventory == null && !hasImportState(entry, targetId)) {
continue;
}
Map<String, String> resolvedInventory = inventory == null ? Map.of() : inventory;
for (Entry retainedEntry : entries) {
if (configured.contains(retainedEntry.url)) {
retainedEntry.importedTargets.remove(targetId);
retainedEntry.importAttempts.remove(targetId);
retainedEntry.structuresImported = false;
}
}
Map<String, String> removable = new TreeMap<>(inventory);
Map<String, String> removable = new TreeMap<>(resolvedInventory);
removable.keySet().removeAll(retainedBundles.keySet());
Map<String, String> remaining = cleanupImportedBundles(data, removable);
if (!remaining.isEmpty()) {
Map<String, String> retained = new TreeMap<>();
for (Map.Entry<String, String> bundle : inventory.entrySet()) {
for (Map.Entry<String, String> bundle : resolvedInventory.entrySet()) {
if (retainedBundles.containsKey(bundle.getKey()) || remaining.containsKey(bundle.getKey())) {
retained.put(bundle.getKey(), bundle.getValue());
}
}
entry.importedBundles.put(targetId, retained);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
entry.structuresImported = false;
successful = false;
continue;
}
entry.importedBundles.remove(targetId);
entry.importedTargets.remove(targetId);
entry.importAttempts.remove(targetId);
}
return successful;
}
private static boolean hasImportState(Entry entry, String targetId) {
return entry.importedBundles.containsKey(targetId)
|| entry.importedTargets.containsKey(targetId)
|| entry.importAttempts.containsKey(targetId);
}
private static Map<String, String> cleanupImportedBundles(IrisData data, Map<String, String> inventory) {
Map<String, String> remaining = new TreeMap<>();
StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath());
@@ -2614,7 +2812,11 @@ public final class DatapackIngestService {
sourceKey = StructureKey.parse(bundle.getValue());
StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft")
? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK;
removed |= writer.removeOwned(StructureKey.parse(bundle.getKey()), sourceKind, sourceKey);
removed |= writer.removeManagedDatapackOwned(
StructureKey.parse(bundle.getKey()),
sourceKind,
sourceKey
);
} catch (IOException | RuntimeException e) {
remaining.put(bundle.getKey(), bundle.getValue());
IrisLogging.reportError("Preserving imported structure bundle '" + bundle.getKey()
@@ -2940,6 +3142,8 @@ public final class DatapackIngestService {
resolved.installMetadata, Map::of));
copy.importedTargets = new HashMap<>(Objects.requireNonNullElseGet(
resolved.importedTargets, Map::of));
copy.importAttempts = new HashMap<>(Objects.requireNonNullElseGet(
resolved.importAttempts, Map::of));
copy.importedBundles = new HashMap<>();
if (resolved.importedBundles != null) {
for (Map.Entry<String, Map<String, String>> bundle : resolved.importedBundles.entrySet()) {
@@ -3049,6 +3253,7 @@ public final class DatapackIngestService {
entry.stagingMetadata = entry.stagingMetadata == null ? "" : entry.stagingMetadata.trim();
entry.installMetadata = normalizeImportedTargets(entry.installMetadata);
entry.importedTargets = normalizeImportedTargets(entry.importedTargets);
entry.importAttempts = normalizeImportedTargets(entry.importAttempts);
entry.importedBundles = normalizeImportedBundles(entry.importedBundles);
if (!urls.add(entry.url) || !ids.add(entry.id)) {
IrisLogging.warn("Ignoring duplicate datapack manifest entry for id '" + entry.id + "' and url " + entry.url);
@@ -3361,13 +3566,12 @@ public final class DatapackIngestService {
return path.toRealPath().toString();
}
static void recoverTransactions(File root, List<File> worldFolders) throws IOException {
recoverStagingScratch(new File(root, "staging"));
static boolean recoverTransactions(File root, List<File> worldFolders) throws IOException {
boolean changed = recoverStagingScratch(new File(root, "staging"));
File transactionDirectory = new File(root, TRANSACTION_DIRECTORY);
Path transactionPath = transactionDirectory.toPath();
if (!Files.exists(transactionPath, LinkOption.NOFOLLOW_LINKS)) {
recoverInstallScratch(root, worldFolders);
return;
return recoverInstallScratch(root, worldFolders) | changed;
}
verifyDirectoryContainerIfPresent(transactionDirectory, "datapack transaction");
Manifest committedManifest = readCommittedManifest(root);
@@ -3390,29 +3594,32 @@ public final class DatapackIngestService {
}
for (Path transactionRoot : transactionRoots) {
if (isHarmlessRecoveryArtifact(transactionRoot)) {
Files.deleteIfExists(transactionRoot);
changed |= Files.deleteIfExists(transactionRoot);
continue;
}
recoverTransaction(root, worldFolders, committedManifest, transactionPath, transactionRoot);
changed = true;
}
transactionDirectory.delete();
recoverInstallScratch(root, worldFolders);
changed |= transactionDirectory.delete();
return recoverInstallScratch(root, worldFolders) | changed;
}
private static void recoverInstallScratch(File root, List<File> worldFolders) throws IOException {
private static boolean recoverInstallScratch(File root, List<File> worldFolders) throws IOException {
Set<Path> scratchRoots = new TreeSet<>();
scratchRoots.add(installScratchRoot(new File(root, "staging")).toPath().toAbsolutePath().normalize());
for (File worldFolder : worldFolders) {
scratchRoots.add(installScratchRoot(worldFolder).toPath().toAbsolutePath().normalize());
}
boolean changed = false;
for (Path scratchRoot : scratchRoots) {
recoverInstallScratchRoot(scratchRoot);
changed |= recoverInstallScratchRoot(scratchRoot);
}
return changed;
}
private static void recoverInstallScratchRoot(Path scratchRoot) throws IOException {
private static boolean recoverInstallScratchRoot(Path scratchRoot) throws IOException {
if (!Files.exists(scratchRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
return false;
}
verifyDirectoryContainerIfPresent(scratchRoot.toFile(), "datapack install scratch");
List<Path> children;
@@ -3431,9 +3638,10 @@ public final class DatapackIngestService {
List<StagingScratch> pending = new ArrayList<>();
List<StagingScratch> backups = new ArrayList<>();
boolean changed = false;
for (Path child : children) {
if (isHarmlessRecoveryArtifact(child)) {
Files.deleteIfExists(child);
changed |= Files.deleteIfExists(child);
continue;
}
StagingScratch scratch = parseInstallScratch(scratchRoot, child);
@@ -3456,8 +3664,10 @@ public final class DatapackIngestService {
}
for (StagingScratch scratch : pending) {
deleteInstallScratch(scratch.path().toFile(), "orphan datapack install pending directory");
changed = true;
}
scratchRoot.toFile().delete();
changed |= scratchRoot.toFile().delete();
return changed;
}
private static StagingScratch parseInstallScratch(Path scratchRoot, Path child) throws IOException {
@@ -3486,10 +3696,10 @@ public final class DatapackIngestService {
return new StagingScratch(kind, id, normalized);
}
private static void recoverStagingScratch(File stagingDirectory) throws IOException {
private static boolean recoverStagingScratch(File stagingDirectory) throws IOException {
Path stagingRoot = stagingDirectory.toPath().toAbsolutePath().normalize();
if (!Files.exists(stagingRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
return false;
}
verifyDirectoryContainerIfPresent(stagingDirectory, "datapack staging");
List<Path> children;
@@ -3536,6 +3746,7 @@ public final class DatapackIngestService {
}
}
boolean changed = false;
for (List<StagingScratch> matches : backups.values()) {
StagingScratch backup = matches.getFirst();
Path target = stagingRoot.resolve(backup.id()).normalize();
@@ -3544,11 +3755,14 @@ public final class DatapackIngestService {
} else {
moveNew(backup.path(), target);
}
changed = true;
}
for (StagingScratch scratch : pending) {
deleteVerifiedDirectory(scratch.path().toFile());
changed = true;
}
forceDirectoryIfSupported(stagingRoot);
return changed;
}
private static StagingScratch parseStagingScratch(Path stagingRoot, Path child) throws IOException {
@@ -3825,6 +4039,7 @@ public final class DatapackIngestService {
throw new IOException("Datapack transaction conflicts with the committed editable pack owner");
}
addExistingPackRoots(roots, committed.importedTargets.keySet());
addExistingPackRoots(roots, committed.importAttempts.keySet());
addExistingPackRoots(roots, committed.importedBundles.keySet());
return roots;
}
@@ -4125,6 +4340,7 @@ public final class DatapackIngestService {
),
commit
);
IrisData.invalidateLoadedStructureResources(packRoot.toFile());
} catch (IOException | RuntimeException e) {
IOException participantFailure = e instanceof IOException ioFailure
? ioFailure : new IOException("Failed resolving editable structure participant", e);
@@ -4524,6 +4740,7 @@ public final class DatapackIngestService {
&& copyList(current.structureKeys).equals(copyList(expected.structureKeys))
&& copyList(current.templateKeys).equals(copyList(expected.templateKeys))
&& Objects.equals(current.importedTargets, expected.importedTargets)
&& Objects.equals(current.importAttempts, expected.importAttempts)
&& Objects.equals(current.importedBundles, expected.importedBundles);
}
@@ -4610,6 +4827,7 @@ public final class DatapackIngestService {
public List<String> templateKeys = new ArrayList<>();
public Map<String, String> installMetadata = new HashMap<>();
public Map<String, String> importedTargets = new HashMap<>();
public Map<String, String> importAttempts = new HashMap<>();
public Map<String, Map<String, String>> importedBundles = new HashMap<>();
}
@@ -4656,10 +4874,83 @@ public final class DatapackIngestService {
static record InstallResult(boolean changed) {
}
public record ReapplyOutcome(
ReapplyStatus status,
Optional<Throwable> failure
) {
public ReapplyOutcome {
status = Objects.requireNonNull(status, "External datapack reapply status");
failure = Objects.requireNonNull(failure, "External datapack reapply failure");
if (status == ReapplyStatus.FAILED && failure.isEmpty()) {
throw new IllegalArgumentException("Failed external datapack reapply requires a cause");
}
if (status != ReapplyStatus.FAILED && failure.isPresent()) {
throw new IllegalArgumentException("Successful external datapack reapply cannot carry a cause");
}
}
public static ReapplyOutcome success(boolean recovered, boolean repaired) {
ReapplyStatus status;
if (recovered && repaired) {
status = ReapplyStatus.RECOVERED_AND_REPAIRED;
} else if (recovered) {
status = ReapplyStatus.RECOVERED;
} else if (repaired) {
status = ReapplyStatus.REPAIRED;
} else {
status = ReapplyStatus.UNCHANGED;
}
return new ReapplyOutcome(status, Optional.empty());
}
public static ReapplyOutcome failed(Throwable failure) {
return new ReapplyOutcome(
ReapplyStatus.FAILED,
Optional.of(Objects.requireNonNull(failure, "External datapack reapply failure cause")));
}
public boolean succeeded() {
return status != ReapplyStatus.FAILED;
}
public boolean changed() {
return recovered() || repaired();
}
public boolean recovered() {
return status == ReapplyStatus.RECOVERED
|| status == ReapplyStatus.RECOVERED_AND_REPAIRED;
}
public boolean repaired() {
return status == ReapplyStatus.REPAIRED
|| status == ReapplyStatus.RECOVERED_AND_REPAIRED;
}
}
public enum ReapplyStatus {
UNCHANGED,
RECOVERED,
REPAIRED,
RECOVERED_AND_REPAIRED,
FAILED
}
record InstallExecution(InstallResult result, DatapackCoordinator coordinator) {
}
private record PackResources(List<String> structureKeys, List<String> templateKeys) {
private record PackResources(
List<String> structureKeys,
List<String> structureSetKeys,
List<String> templateKeys
) {
}
public record StructureScopeResources(
String source,
List<String> structureKeys,
List<String> structureSetKeys
) {
}
private static final class EditableImportRemoval {
@@ -0,0 +1,131 @@
package art.arcane.iris.core.datapack;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
public final class DatapackStructureScopeIndex {
private final Map<String, Set<String>> sourcesByStructure;
private final Map<String, Set<String>> sourcesByStructureSet;
private DatapackStructureScopeIndex(
Map<String, Set<String>> sourcesByStructure,
Map<String, Set<String>> sourcesByStructureSet
) {
this.sourcesByStructure = sourcesByStructure;
this.sourcesByStructureSet = sourcesByStructureSet;
}
public static DatapackStructureScopeIndex create(
List<DatapackIngestService.StructureScopeResources> resources
) {
Map<String, Set<String>> mutableSourcesByStructure = new HashMap<>();
Map<String, Set<String>> mutableSourcesBySet = new HashMap<>();
if (resources != null) {
for (DatapackIngestService.StructureScopeResources resource : resources) {
if (resource == null) {
continue;
}
String source = normalizeSource(resource.source());
if (source.isEmpty()) {
continue;
}
addOwnership(mutableSourcesByStructure, resource.structureKeys(), source);
addOwnership(mutableSourcesBySet, resource.structureSetKeys(), source);
}
}
return new DatapackStructureScopeIndex(
immutableOwnership(mutableSourcesByStructure),
immutableOwnership(mutableSourcesBySet));
}
public Set<String> declaredSources(Iterable<String> datapackImports) {
if (datapackImports == null) {
return Set.of();
}
Set<String> declared = new HashSet<>();
for (String source : datapackImports) {
String normalized = normalizeSource(source);
if (!normalized.isEmpty()) {
declared.add(normalized);
}
}
return declared.isEmpty() ? Set.of() : Set.copyOf(declared);
}
public boolean allowsStructureSet(String structureSetKey, Set<String> declaredSources) {
return allows(sourcesByStructureSet, structureSetKey, declaredSources);
}
public boolean allowsStructure(String structureKey, Set<String> declaredSources) {
return allows(sourcesByStructure, structureKey, declaredSources);
}
public boolean isManagedStructureSet(String structureSetKey) {
return sourcesByStructureSet.containsKey(normalizeKey(structureSetKey));
}
public boolean isManagedStructure(String structureKey) {
return sourcesByStructure.containsKey(normalizeKey(structureKey));
}
public int managedStructureCount() {
return sourcesByStructure.size();
}
public int managedStructureSetCount() {
return sourcesByStructureSet.size();
}
public boolean isEmpty() {
return sourcesByStructure.isEmpty() && sourcesByStructureSet.isEmpty();
}
private static void addOwnership(
Map<String, Set<String>> ownership,
List<String> keys,
String source
) {
if (keys == null) {
return;
}
for (String key : keys) {
String normalizedKey = normalizeKey(key);
if (!normalizedKey.isEmpty()) {
ownership.computeIfAbsent(normalizedKey, ignored -> new HashSet<>()).add(source);
}
}
}
private static Map<String, Set<String>> immutableOwnership(Map<String, Set<String>> ownership) {
Map<String, Set<String>> immutable = new HashMap<>(ownership.size());
for (Map.Entry<String, Set<String>> entry : ownership.entrySet()) {
immutable.put(entry.getKey(), Set.copyOf(entry.getValue()));
}
return Map.copyOf(immutable);
}
private static boolean allows(
Map<String, Set<String>> ownership,
String key,
Set<String> declaredSources
) {
Set<String> owners = ownership.get(normalizeKey(key));
if (owners == null) {
return true;
}
return declaredSources != null && declaredSources.containsAll(owners);
}
private static String normalizeSource(String source) {
return source == null ? "" : source.trim();
}
private static String normalizeKey(String key) {
return key == null ? "" : key.trim().toLowerCase(Locale.ROOT);
}
}
@@ -3,6 +3,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.IrisWorlds;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import art.arcane.iris.core.WorldRemovalPathPolicy;
import art.arcane.iris.core.link.MultiverseCoreLink;
import art.arcane.iris.core.runtime.WorldDeletionQueue;
@@ -22,13 +23,10 @@ import org.bukkit.entity.Player;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -1062,7 +1060,7 @@ public final class IrisWorldRemovalService {
private DeleteDisposition deleteQuarantine(Path quarantine) {
try {
deleteTree(quarantine);
SnapshotDirectoryTreeDeleter.delete(quarantine);
return new DeleteDisposition(false, quarantine);
} catch (Throwable deletionFailure) {
IrisLogging.reportError(
@@ -1073,25 +1071,6 @@ public final class IrisWorldRemovalService {
}
}
private static void deleteTree(Path target) throws IOException {
Files.walkFileTree(target, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException {
if (failure != null) {
throw failure;
}
Files.delete(directory);
return FileVisitResult.CONTINUE;
}
});
}
private static <T> CompletableFuture<T> onGlobal(Supplier<T> supplier) {
CompletableFuture<T> result = new CompletableFuture<>();
boolean scheduled = J.runGlobal(() -> {
@@ -118,6 +118,13 @@ public final class WorldLifecycleService {
worldName,
backend.backendName());
WorldUnloadBoundaryRegistry.Boundary rawBoundary;
try {
rawBoundary = WorldUnloadBoundaryRegistry.begin(worldIdentity);
} catch (Throwable e) {
return CompletableFuture.failedFuture(e);
}
CompletableFuture<Boolean> unloadFuture;
try {
unloadFuture = backend.unloadAsync(requiredWorld, save);
@@ -127,6 +134,8 @@ public final class WorldLifecycleService {
} catch (Throwable e) {
unloadFuture = CompletableFuture.failedFuture(e);
}
unloadFuture.whenComplete((unloaded, throwable) ->
WorldUnloadBoundaryRegistry.complete(rawBoundary, unloaded, throwable));
CompletableFuture<Boolean> guardedFuture = guardUnloadCompletion(worldName, unloadFuture);
return guardedFuture.whenComplete((unloaded, throwable) -> {
@@ -0,0 +1,41 @@
package art.arcane.iris.core.lifecycle;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
public final class WorldUnloadBoundaryRegistry {
private static final ConcurrentHashMap<String, Boundary> ACTIVE = new ConcurrentHashMap<>();
private WorldUnloadBoundaryRegistry() {
}
static Boundary begin(String worldIdentity) {
String requiredIdentity = Objects.requireNonNull(worldIdentity, "world identity");
Boundary boundary = new Boundary(requiredIdentity, new CompletableFuture<>());
Boundary existing = ACTIVE.putIfAbsent(requiredIdentity, boundary);
if (existing != null) {
throw new IllegalStateException("World unload is already active for " + requiredIdentity + ".");
}
return boundary;
}
public static CompletionStage<Boolean> claim(String worldIdentity) {
Boundary boundary = ACTIVE.remove(Objects.requireNonNull(worldIdentity, "world identity"));
return boundary == null ? null : boundary.completion();
}
static void complete(Boundary boundary, Boolean unloaded, Throwable failure) {
Objects.requireNonNull(boundary, "world unload boundary");
ACTIVE.remove(boundary.worldIdentity(), boundary);
if (failure == null) {
boundary.completion().complete(Boolean.TRUE.equals(unloaded));
return;
}
boundary.completion().completeExceptionally(WorldLifecycleSupport.unwrap(failure));
}
record Boundary(String worldIdentity, CompletableFuture<Boolean> completion) {
}
}
@@ -153,6 +153,22 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
return Optional.ofNullable(dataLoaders.get(dataFolder));
}
public static boolean invalidateLoadedStructureResources(File dataFolder) {
Path requested = dataFolderIdentity(Objects.requireNonNull(
dataFolder,
"Iris data folder to invalidate"));
boolean invalidated = false;
for (Map.Entry<File, IrisData> entry : dataLoaders.entrySet()) {
Path loaded = dataFolderIdentity(entry.getKey());
if (!loaded.equals(requested)) {
continue;
}
entry.getValue().invalidateStructureResources();
invalidated = true;
}
return invalidated;
}
public static void dereference() {
dataLoaders.values().forEach(IrisData::cleanupEngine);
}
@@ -528,6 +544,17 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
loader.clearList();
}
private static Path dataFolderIdentity(File dataFolder) {
Path normalized = dataFolder.toPath().toAbsolutePath().normalize();
try {
return Files.exists(normalized) ? normalized.toRealPath() : normalized;
} catch (IOException exception) {
IrisLogging.debug("Unable to resolve Iris data folder identity for "
+ normalized + "; using its normalized path: " + exception.getMessage());
return normalized;
}
}
public Set<Class<?>> resolveSnippets() {
var result = new HashSet<Class<?>>();
var processed = new HashSet<Class<?>>();
@@ -0,0 +1,7 @@
package art.arcane.iris.core.nms;
public record DatapackStructureScopeResult(
int retainedManagedSets,
int excludedManagedSets
) {
}
@@ -18,6 +18,7 @@
package art.arcane.iris.core.nms;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import art.arcane.iris.core.lifecycle.WorldLifecycleCaller;
import art.arcane.iris.core.lifecycle.WorldLifecycleRequest;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
@@ -53,6 +54,7 @@ import org.bukkit.inventory.ItemStack;
import java.awt.Color;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
public interface INMSBinding {
@@ -226,6 +228,16 @@ public interface INMSBinding {
void inject(long seed, Engine engine, World world) throws NoSuchFieldException, IllegalAccessException;
DatapackStructureScopeResult scopeDatapackStructures(
World world,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources
) throws NoSuchFieldException, IllegalAccessException;
void completeStudioStructureBootstrap(World world) throws NoSuchFieldException, IllegalAccessException;
void abandonStudioStructureBootstrap(World world);
Vector3d getBoundingbox(org.bukkit.entity.EntityType entity);
String getEntitySpawnCategory(String key);
@@ -18,6 +18,8 @@
package art.arcane.iris.core.nms.v1X;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import art.arcane.iris.core.nms.DatapackStructureScopeResult;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.nms.INMSBinding;
import art.arcane.iris.core.nms.container.BiomeColor;
@@ -45,6 +47,7 @@ import org.bukkit.inventory.ItemStack;
import java.awt.Color;
import java.util.List;
import java.util.Set;
import java.util.stream.StreamSupport;
public class NMSBinding1X implements INMSBinding {
@@ -102,6 +105,23 @@ public class NMSBinding1X implements INMSBinding {
+ "general.disableNMS=true cannot create or initialize an Iris world");
}
@Override
public DatapackStructureScopeResult scopeDatapackStructures(
World world,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources
) {
throw new IllegalStateException("Iris-managed datapack structure isolation requires the supported NMS binding");
}
@Override
public void completeStudioStructureBootstrap(World world) {
}
@Override
public void abandonStudioStructureBootstrap(World world) {
}
public Vector3d getBoundingbox() {
return null;
}
@@ -33,6 +33,7 @@ import java.util.Properties;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
@@ -41,11 +42,13 @@ import java.util.zip.ZipInputStream;
public final class DefaultPackBootstrapProvisioner {
private static final URI DEFAULT_SOURCE = URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip");
private static final String WORLD_DATAPACK_DIRECTORY = "iris";
private static final int MARKER_SCHEMA = 2;
private static final int MARKER_SCHEMA = 3;
private static final int MAX_ARCHIVE_ENTRIES = 100_000;
private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L;
private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L;
private static final AtomicBoolean PROVISIONED_THIS_STARTUP = new AtomicBoolean(false);
private static final AtomicReference<String> PROVISIONED_COMPILER_INPUT_FINGERPRINT =
new AtomicReference<>("");
private DefaultPackBootstrapProvisioner() {
}
@@ -102,6 +105,12 @@ public final class DefaultPackBootstrapProvisioner {
if (!Integer.toString(MARKER_SCHEMA).equals(marker.getProperty("schema"))) {
return false;
}
IDataFixer fixer = DataVersion.getLatest().get();
if (fixer == null
|| !IrisDatapackCompiler.compilerIdentity(fixer)
.equals(marker.getProperty("compilerIdentity"))) {
return false;
}
return directoryFingerprint(packRoot).equals(marker.getProperty("defaultPackFingerprint"))
&& directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
&& datapackRoot.toString().equals(marker.getProperty("datapackPath"))
@@ -118,7 +127,13 @@ public final class DefaultPackBootstrapProvisioner {
return PROVISIONED_THIS_STARTUP.get();
}
public static String compilerInputFingerprintThisStartup() {
return PROVISIONED_COMPILER_INPUT_FINGERPRINT.get();
}
static ProvisionResult provision(Path dataDirectory, Consumer<String> feedback, ProvisionOptions options) throws IOException {
PROVISIONED_THIS_STARTUP.set(false);
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set("");
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
Path packsRoot = normalizedData.resolve("packs");
Path packRoot = packsRoot.resolve("overworld");
@@ -174,20 +189,22 @@ public final class DefaultPackBootstrapProvisioner {
if (packRoots.isEmpty()) {
throw new IOException("No Iris pack roots were available for bootstrap datapack compilation");
}
IDataFixer fixer = DataVersion.getLatest().get();
if (fixer == null) {
throw new IOException("Latest Iris datapack fixer is unavailable during bootstrap");
}
String compilerIdentity = IrisDatapackCompiler.compilerIdentity(fixer);
String aggregateFingerprint = packRootsFingerprint(packRoots);
boolean rebuildDatapack = replacePack
|| !existingDatapack
|| !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint"))
|| !compilerIdentity.equals(previousMarker.getProperty("compilerIdentity"))
|| !datapackRoot.toString().equals(previousMarker.getProperty("datapackPath"))
|| !directoryFingerprint(datapackRoot).equals(previousMarker.getProperty("datapackFingerprint"));
if (rebuildDatapack) {
compileContainer = datapacksRoot.resolve("." + WORLD_DATAPACK_DIRECTORY + "-stage-" + UUID.randomUUID());
Files.createDirectories(compileContainer);
KList<File> outputFolders = new KList<File>().qadd(compileContainer.toFile());
IDataFixer fixer = DataVersion.getLatest().get();
if (fixer == null) {
throw new IOException("Latest Iris datapack fixer is unavailable during bootstrap");
}
IrisDatapackCompiler.compile(packRoots, outputFolders, fixer, false);
if (!isDatapackRoot(compileContainer)) {
throw new IOException("Canonical Iris datapack compiler produced incomplete output at " + compileContainer);
@@ -202,9 +219,14 @@ public final class DefaultPackBootstrapProvisioner {
throw new IOException("Bootstrap datapack output is incomplete at " + datapackRoot);
}
String finalPackFingerprint = directoryFingerprint(packRoot);
String finalAggregateFingerprint = packRootsFingerprint(
IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot())
);
List<File> finalPackRoots = IrisDatapackCompiler.collectPackRoots(
normalizedData,
options.levelRoot());
String finalAggregateFingerprint = packRootsFingerprint(finalPackRoots);
String finalCompilerInputFingerprint = IrisDatapackCompiler.computeInputFingerprint(
finalPackRoots,
fixer,
false);
String finalDatapackFingerprint = directoryFingerprint(datapackRoot);
Properties marker = new Properties();
marker.setProperty("schema", Integer.toString(MARKER_SCHEMA));
@@ -213,10 +235,12 @@ public final class DefaultPackBootstrapProvisioner {
marker.setProperty("managedDefault", Boolean.toString(managedDefault));
marker.setProperty("defaultPackFingerprint", finalPackFingerprint);
marker.setProperty("aggregateFingerprint", finalAggregateFingerprint);
marker.setProperty("compilerIdentity", compilerIdentity);
marker.setProperty("datapackFingerprint", finalDatapackFingerprint);
marker.setProperty("datapackPath", datapackRoot.toString());
marker.setProperty("completedAt", Long.toString(options.clock().millis()));
storePropertiesAtomic(markerFile, marker);
PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(finalCompilerInputFingerprint);
PROVISIONED_THIS_STARTUP.set(true);
deleteQuietly(packBackup, feedback);
deleteQuietly(datapackBackup, feedback);
@@ -21,6 +21,7 @@ package art.arcane.iris.core.pack;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.framework.StructureAssembler;
import art.arcane.iris.engine.framework.structure.IrisObjectFrameReader;
import art.arcane.iris.engine.framework.structure.StructureAssemblyResult;
import art.arcane.iris.engine.framework.structure.StructureGraphCompilation;
import art.arcane.iris.engine.framework.structure.StructureGraphCompiler;
import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic;
@@ -31,7 +32,6 @@ import art.arcane.iris.engine.object.IrisJigsawPool;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
@@ -131,13 +131,13 @@ final class StructureGraphPackValidator {
try {
StructureAssembler assembler = StructureAssembler.forCompilation(
compilation, new IrisPosition(0, GEOMETRY_SAMPLE_ORIGIN_Y, 0));
KList<PlacedStructurePiece> pieces = assembler.assemble(new RNG(seed));
if (pieces == null) {
failures.add("seed " + seed + " returned no complete assembly");
} else if (pieces.isEmpty()) {
StructureAssemblyResult result = assembler.assemble(new RNG(seed));
if (result.status().isFailure()) {
failures.add("seed " + seed + " returned " + result.status() + ": " + result.detail());
} else if (!result.hasOutput()) {
outputForEverySample = false;
} else {
sampledVerticalEnvelopes.add(sampleVerticalEnvelope(seed, pieces));
sampledVerticalEnvelopes.add(sampleVerticalEnvelope(seed, result.pieces()));
}
} catch (RuntimeException e) {
failures.add("seed " + seed + " threw " + e.getClass().getSimpleName()
@@ -150,7 +150,7 @@ final class StructureGraphPackValidator {
private static SampledVerticalEnvelope sampleVerticalEnvelope(
long seed,
KList<PlacedStructurePiece> pieces
List<PlacedStructurePiece> pieces
) {
int minimumY = Integer.MAX_VALUE;
int maximumY = Integer.MIN_VALUE;
@@ -187,8 +187,8 @@ final class StructureGraphPackValidator {
continue;
}
failures.add("seed " + sample.seed() + " placed " + sample.outcome().pieceKeys().size()
+ " piece(s), left " + sample.outcome().unresolvedConnectorCount()
+ " unresolved, cap=" + sample.outcome().pieceCapReached());
+ " piece(s), status=" + sample.outcome().status()
+ ": " + sample.outcome().detail());
if (failures.size() == 3) {
break;
}
@@ -7,6 +7,7 @@ import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
@@ -51,8 +52,16 @@ public final class StructurePackageClosure {
}
public static StructurePackageClosure collect(File sourceRoot, Collection<String> rootStructures) {
return collect(sourceRoot, rootStructures, null);
}
public static StructurePackageClosure collect(
File sourceRoot,
Collection<String> rootStructures,
Limits limits
) {
Path normalizedRoot = sourceRoot.toPath().toAbsolutePath().normalize();
MutableClosure closure = new MutableClosure();
MutableClosure closure = new MutableClosure(limits);
if (!Files.isDirectory(normalizedRoot)) {
closure.errors.add("Structure package source is not a directory: " + normalizedRoot);
return new StructurePackageClosure(normalizedRoot, closure);
@@ -127,7 +136,7 @@ public final class StructurePackageClosure {
continue;
}
JSONObject structure = readJson(sourceRoot, STRUCTURES, structureKey, closure.errors);
JSONObject structure = readJson(sourceRoot, STRUCTURES, structureKey, closure.errors, closure.limits);
if (structure == null) {
continue;
}
@@ -148,7 +157,7 @@ public final class StructurePackageClosure {
continue;
}
JSONObject pool = readJson(sourceRoot, POOLS, poolKey, closure.errors);
JSONObject pool = readJson(sourceRoot, POOLS, poolKey, closure.errors, closure.limits);
if (pool == null) {
continue;
}
@@ -184,7 +193,7 @@ public final class StructurePackageClosure {
continue;
}
JSONObject piece = readJson(sourceRoot, PIECES, pieceKey, closure.errors);
JSONObject piece = readJson(sourceRoot, PIECES, pieceKey, closure.errors, closure.limits);
if (piece == null) {
continue;
}
@@ -223,19 +232,38 @@ public final class StructurePackageClosure {
}
}
private static JSONObject readJson(Path sourceRoot, String folder, String key, List<String> errors) {
private static JSONObject readJson(
Path sourceRoot,
String folder,
String key,
List<String> errors,
Limits limits
) {
Path file = resolveExisting(sourceRoot, folder, key, ".json", errors);
if (file == null) {
return null;
}
try {
return new JSONObject(Files.readString(file, StandardCharsets.UTF_8));
String content = limits == null
? Files.readString(file, StandardCharsets.UTF_8)
: new String(readBounded(file, limits.maxJsonBytes()), StandardCharsets.UTF_8);
return new JSONObject(content);
} catch (IOException | RuntimeException e) {
errors.add("Invalid " + folder + " resource '" + key + "': " + describe(e));
return null;
}
}
private static byte[] readBounded(Path file, int maximumBytes) throws IOException {
try (InputStream input = Files.newInputStream(file)) {
byte[] content = input.readNBytes(maximumBytes + 1);
if (content.length > maximumBytes) {
throw new IOException("JSON resource exceeds " + maximumBytes + " bytes");
}
return content;
}
}
private static Path resolveExisting(Path sourceRoot, String folder, String key, String extension,
List<String> errors) {
Path file = resolveResource(sourceRoot, folder, key, extension, errors);
@@ -481,8 +509,16 @@ public final class StructurePackageClosure {
if (!isEmpty) {
return false;
}
if (entry.has("piece")) {
errors.add("Empty piece entry " + index + " in jigsaw pool '" + poolKey + "' cannot define field 'piece'.");
if (!entry.has("piece")) {
return true;
}
Object pieceValue = entry.opt("piece");
if (!(pieceValue instanceof String piece)) {
errors.add("Empty piece entry " + index + " in jigsaw pool '" + poolKey
+ "' requires string field 'piece' when defined.");
} else if (!piece.isBlank()) {
errors.add("Empty piece entry " + index + " in jigsaw pool '" + poolKey
+ "' cannot define non-empty field 'piece'.");
}
return true;
}
@@ -504,15 +540,66 @@ public final class StructurePackageClosure {
return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message;
}
public record Limits(int maxResources, int maxJsonBytes) {
public Limits {
if (maxResources < 1 || maxResources > 100_000) {
throw new IllegalArgumentException("Structure closure resource limit must be between 1 and 100000");
}
if (maxJsonBytes < 1 || maxJsonBytes > 64 * 1024 * 1024) {
throw new IllegalArgumentException("Structure closure JSON limit must be between 1 and 67108864 bytes");
}
}
}
private static final class MutableClosure {
private final Set<String> structures = new LinkedHashSet<>();
private final Set<String> pools = new LinkedHashSet<>();
private final Set<String> pieces = new LinkedHashSet<>();
private final Set<String> objects = new LinkedHashSet<>();
private final Set<String> loot = new LinkedHashSet<>();
private final Limits limits;
private final Set<String> structures;
private final Set<String> pools;
private final Set<String> pieces;
private final Set<String> objects;
private final Set<String> loot;
private final List<String> errors = new ArrayList<>();
private final Deque<String> structureQueue = new ArrayDeque<>();
private final Deque<String> poolQueue = new ArrayDeque<>();
private final Deque<String> pieceQueue = new ArrayDeque<>();
private int resources;
private boolean resourceLimitReported;
private MutableClosure(Limits limits) {
this.limits = limits;
structures = new BudgetedSet(this);
pools = new BudgetedSet(this);
pieces = new BudgetedSet(this);
objects = new BudgetedSet(this);
loot = new BudgetedSet(this);
}
private boolean reserveResource() {
if (limits == null || resources < limits.maxResources()) {
resources++;
return true;
}
if (!resourceLimitReported) {
errors.add("Structure closure exceeds " + limits.maxResources() + " resources.");
resourceLimitReported = true;
}
return false;
}
}
private static final class BudgetedSet extends LinkedHashSet<String> {
private final MutableClosure closure;
private BudgetedSet(MutableClosure closure) {
this.closure = closure;
}
@Override
public boolean add(String value) {
if (contains(value) || !closure.reserveResource()) {
return false;
}
return super.add(value);
}
}
}
@@ -74,20 +74,30 @@ public class IrisProject {
}
public void open(VolmitSender sender) throws IrisException {
open(sender, 1337, (w) ->
open(sender, 1337, StudioOpenCoordinator.StudioOpenKind.STANDARD, (w) ->
{
});
}
public CompletableFuture<StudioOpenCoordinator.StudioOpenResult> open(VolmitSender sender, long seed, Consumer<World> onDone) throws IrisException {
public CompletableFuture<StudioOpenCoordinator.StudioOpenResult> open(
VolmitSender sender,
long seed,
StudioOpenCoordinator.StudioOpenKind openKind,
Consumer<World> onDone
) throws IrisException {
if (isOpen()) {
return close().thenCompose(ignored -> openInternal(sender, seed, onDone));
return close().thenCompose(ignored -> openInternal(sender, seed, openKind, onDone));
}
return openInternal(sender, seed, onDone);
return openInternal(sender, seed, openKind, onDone);
}
private CompletableFuture<StudioOpenCoordinator.StudioOpenResult> openInternal(VolmitSender sender, long seed, Consumer<World> onDone) {
private CompletableFuture<StudioOpenCoordinator.StudioOpenResult> openInternal(
VolmitSender sender,
long seed,
StudioOpenCoordinator.StudioOpenKind openKind,
Consumer<World> onDone
) {
AtomicReference<String> stage = new AtomicReference<>("Queued");
AtomicReference<Double> progress = new AtomicReference<>(0.01D);
AtomicBoolean complete = new AtomicBoolean(false);
@@ -97,6 +107,7 @@ public class IrisProject {
this,
sender,
seed,
openKind,
update -> {
if (update.stage() != null && !update.stage().isBlank()) {
stage.set(update.stage());
@@ -5,6 +5,7 @@ import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.link.MultiverseCoreLink;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
@@ -13,12 +14,15 @@ import art.arcane.iris.core.project.IrisProject;
import art.arcane.iris.core.project.IrisCodeWorkspace;
import art.arcane.iris.core.tools.IrisCreator;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.exceptions.IrisException;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
@@ -33,9 +37,13 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -46,9 +54,14 @@ import java.util.function.Supplier;
public final class StudioOpenCoordinator {
private static final long STUDIO_CLOSE_TIMEOUT_SECONDS = 120L;
private static final long STUDIO_ENTRY_LOAD_TIMEOUT_SECONDS = 30L;
private static final long STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS = 30L;
private static final long STUDIO_ENTRY_CLEANUP_BOUNDARY_SECONDS = 120L;
private static volatile StudioOpenCoordinator instance;
private final EntryLoadRegistry entryLoads;
private StudioOpenCoordinator() {
entryLoads = new EntryLoadRegistry();
}
public static StudioOpenCoordinator get() {
@@ -93,10 +106,11 @@ public final class StudioOpenCoordinator {
private void executeOpen(StudioOpenRequest request, CompletableFuture<StudioOpenResult> future) {
World world = null;
PlatformChunkGenerator provider = null;
CompletableFuture<Void> entryLoadFuture = null;
try {
long openStart = System.currentTimeMillis();
long openStart = System.nanoTime();
long t = openStart;
IrisLogging.debug("[Studio timing] ===== studio open START: " + request.worldName() + " =====");
entryLoads.rejectNewOpen();
updateStage(request, "resolve_dimension", 0.04D);
if (IrisToolbelt.getDimension(request.dimensionKey()) == null) {
throw new IrisException("Dimension cannot be found for id " + request.dimensionKey() + ".");
@@ -104,7 +118,7 @@ public final class StudioOpenCoordinator {
updateStage(request, "prepare_world_pack", 0.10D);
cleanupStaleTransientWorlds(request.worldName());
t = logStudioPhase("resolveDimension + cleanupStaleWorlds", t, openStart);
t = logStudioPhase(request, "resolve_dimension_and_cleanup", t, openStart);
updateStage(request, "install_datapacks", 0.18D);
IrisCreator creator = IrisToolbelt.createWorld()
@@ -113,9 +127,11 @@ public final class StudioOpenCoordinator {
.studio(true)
.name(request.worldName())
.dimension(request.dimensionKey())
.studioProgressConsumer((progress, stage) -> updateStage(request, mapCreatorStage(stage), progress));
.datapackPreparation(request.openKind().datapackPreparation())
.studioProgressConsumer((progress, stage) -> updateStage(request, mapCreatorStage(stage), progress))
.studioTimingConsumer((phase, duration) -> logMeasuredStudioPhase(request, phase, duration));
world = creator.create();
t = logStudioPhase("createWorld (datapacks + bukkit world + engine setup)", t, openStart);
t = logStudioPhase(request, "create_world_total", t, openStart);
provider = IrisToolbelt.access(world);
if (provider == null) {
throw new IllegalStateException("Studio runtime provider is unavailable for world \"" + request.worldName() + "\".");
@@ -128,28 +144,36 @@ public final class StudioOpenCoordinator {
if (rulesApplied != null) {
rulesApplied.get(15L, TimeUnit.SECONDS);
}
t = logStudioPhase("applyStudioWorldRules", t, openStart);
t = logStudioPhase(request, "apply_world_rules", t, openStart);
updateStage(request, "prepare_generator", 0.78D);
WorldRuntimeControlService.get().prepareGenerator(world);
t = logStudioPhase("prepareGenerator", t, openStart);
if (request.openKind().prepareGeneratorState()) {
WorldRuntimeControlService.get().prepareGenerator(world);
}
t = logStudioPhase(request, "prepare_generator", t, openStart);
Location entryAnchor = WorldRuntimeControlService.get().resolveEntryAnchor(world);
if (entryAnchor == null) {
throw new IllegalStateException("Studio entry anchor could not be resolved.");
}
t = logStudioPhase("resolveEntryAnchor", t, openStart);
t = logStudioPhase(request, "resolve_entry_anchor", t, openStart);
updateStage(request, "load_entry_chunk", 0.80D);
int entryChunkX = entryAnchor.getBlockX() >> 4;
int entryChunkZ = entryAnchor.getBlockZ() >> 4;
try {
loadEntryChunk(world, entryChunkX, entryChunkZ).get(30L, TimeUnit.SECONDS);
entryLoadFuture = loadEntryChunk(world, entryChunkX, entryChunkZ);
entryLoads.register(request.worldName(), entryLoadFuture);
entryLoadFuture.get(STUDIO_ENTRY_LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException e) {
throw new IllegalStateException("Studio entry chunk did not load in time at "
+ entryChunkX + "," + entryChunkZ + " — chunk system may be stalled.");
+ entryChunkX + "," + entryChunkZ + " — chunk system may be stalled.", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Studio entry chunk load was interrupted at "
+ entryChunkX + "," + entryChunkZ + ".", e);
}
t = logStudioPhase("loadEntryChunk (generate spawn chunk to FULL)", t, openStart);
t = logStudioPhase(request, "load_entry_chunk", t, openStart);
updateStage(request, "resolve_safe_entry", 0.84D);
Location safeEntry;
@@ -162,9 +186,11 @@ public final class StudioOpenCoordinator {
if (safeEntry == null) {
throw new IllegalStateException("Studio entry point could not be resolved for world \"" + request.worldName() + "\".");
}
t = logStudioPhase("resolveSafeEntry (generates/loads spawn chunk to FULL)", t, openStart);
t = logStudioPhase(request, "resolve_safe_entry", t, openStart);
if (request.playerName() != null && !request.playerName().isBlank()) {
if (request.openKind().teleportThroughStandardEntry()
&& request.playerName() != null
&& !request.playerName().isBlank()) {
updateStage(request, "teleport_player", 0.96D);
Player player = resolvePlayer(request.playerName());
if (player == null) {
@@ -180,75 +206,297 @@ public final class StudioOpenCoordinator {
if (!Boolean.TRUE.equals(teleported)) {
throw new IllegalStateException("Studio teleport did not complete successfully.");
}
t = logStudioPhase("teleportPlayer", t, openStart);
t = logStudioPhase(request, "teleport_standard_entry", t, openStart);
}
endStudioEntryBootstrap(world, provider);
updateStage(request, "finalize_open", 1.00D);
if (request.project() != null) {
request.project().setActiveProvider(provider);
}
if (request.openWorkspace() && request.project() != null) {
if (request.openKind().openWorkspace() && request.project() != null) {
new IrisCodeWorkspace(request.project()).openVSCode(request.sender());
}
if (request.onDone() != null) {
request.onDone().accept(world);
}
t = logStudioPhase("finalize + openVSCode", t, openStart);
t = logStudioPhase(request, "finalize_open", t, openStart);
IrisLogging.info("Studio open: " + world.getName() + " ready in " + (System.currentTimeMillis() - openStart) + "ms");
IrisLogging.info("Studio open: " + world.getName() + " ready in "
+ elapsedMillis(openStart) + "ms");
entryLoads.release(request.worldName(), entryLoadFuture);
future.complete(new StudioOpenResult(world, safeEntry));
} catch (Throwable e) {
abandonStudioEntryBootstrap(world, e);
IrisLogging.reportError("Studio open failed for world \"" + request.worldName() + "\".", e);
if (!request.retainOnFailure()) {
try {
updateStage(request, "cleanup", 1.00D);
StudioCloseResult cleanupResult = closeWorldCoordinated(
updateStage(request, "cleanup", 1.00D);
if (requiresDeferredEntryCleanup(entryLoadFuture)) {
deferFailedOpenCleanup(
entryLoadFuture,
provider,
request.worldName(),
world,
true,
request.project()
).get(45L, TimeUnit.SECONDS);
if (cleanupResult.failureCause() != null) {
throw cleanupResult.failureCause();
request.project());
} else {
try {
CompletableFuture<Void> cleanup = cleanupFailedOpen(
provider,
request.worldName(),
world,
request.project());
entryLoads.releaseAfterSuccessfulCompletion(
request.worldName(),
entryLoadFuture,
cleanup);
cleanup.get(45L, TimeUnit.SECONDS);
} catch (Throwable cleanupError) {
IrisLogging.reportError("Studio cleanup failed for world \""
+ request.worldName() + "\".", unwrapFailure(cleanupError));
}
} catch (Throwable cleanupError) {
IrisLogging.reportError("Studio cleanup failed for world \"" + request.worldName() + "\".", cleanupError);
}
}
future.completeExceptionally(e);
}
}
private long logStudioPhase(String phase, long t, long openStart) {
long now = System.currentTimeMillis();
IrisLogging.debug("[Studio timing] " + phase + " = " + (now - t) + "ms (cumulative " + (now - openStart) + "ms)");
private long logStudioPhase(StudioOpenRequest request, String phase, long t, long openStart) {
long now = System.nanoTime();
IrisLogging.info("[Studio timing] world=%s kind=%s phase=%s duration=%dms cumulative=%dms",
request.worldName(),
request.openKind().name().toLowerCase(Locale.ROOT),
phase,
TimeUnit.NANOSECONDS.toMillis(now - t),
TimeUnit.NANOSECONDS.toMillis(now - openStart));
return now;
}
private long elapsedMillis(long startedAtNanos) {
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos);
}
private void logMeasuredStudioPhase(StudioOpenRequest request, String phase, long duration) {
IrisLogging.info("[Studio timing] world=%s kind=%s phase=%s duration=%dms",
request.worldName(),
request.openKind().name().toLowerCase(Locale.ROOT),
phase,
duration);
}
private CompletableFuture<Void> loadEntryChunk(World world, int chunkX, int chunkZ) {
// A freshly created studio world has no ticking region at the entry
// chunk. On Folia getChunkAtAsync only works from the owning region
// thread, and RegionScheduler.execute never fires for a chunk no region
// owns yet which is why resolveSafeEntry (a region task) would stall
// and time out. A plugin chunk ticket force-loads the chunk and creates
// its ticking region; we then confirm via a region task that the region
// is live before resolving the safe entry / teleporting into it.
if (!J.isFolia()) {
return loadEntryChunkAsync(world, chunkX, chunkZ);
}
return scheduleEntryChunkRetention(world, chunkX, chunkZ);
}
private CompletableFuture<Void> loadEntryChunkAsync(World world, int chunkX, int chunkZ) {
CompletableFuture<Chunk> requested;
try {
requested = WorldRuntimeControlService.get().requestChunkAsync(
world,
chunkX,
chunkZ,
true,
true);
} catch (Throwable throwable) {
return CompletableFuture.failedFuture(throwable);
}
if (requested == null) {
return CompletableFuture.failedFuture(new IllegalStateException(
"Entry-chunk async request did not return a future at " + chunkX + "," + chunkZ + "."));
}
return requested.thenCompose(chunk -> {
if (chunk == null) {
return CompletableFuture.failedFuture(new IllegalStateException(
"Entry-chunk async request returned no chunk at " + chunkX + "," + chunkZ + "."));
}
return scheduleEntryChunkRetention(world, chunkX, chunkZ);
});
}
private CompletableFuture<Void> scheduleEntryChunkRetention(World world, int chunkX, int chunkZ) {
CompletableFuture<Void> loaded = new CompletableFuture<>();
J.s(() -> {
try {
world.addPluginChunkTicket(chunkX, chunkZ, art.arcane.iris.platform.bukkit.BukkitPlatform.plugin());
} catch (Throwable t) {
loaded.completeExceptionally(t);
try {
J.s(() -> retainAndConfirmEntryChunk(world, chunkX, chunkZ)
.whenComplete((ignored, throwable) -> complete(loaded, throwable)));
} catch (Throwable throwable) {
loaded.completeExceptionally(throwable);
}
return loaded;
}
private CompletableFuture<Void> retainAndConfirmEntryChunk(World world, int chunkX, int chunkZ) {
CompletableFuture<Void> confirmed = new CompletableFuture<>();
try {
world.addPluginChunkTicket(
chunkX,
chunkZ,
BukkitPlatform.plugin());
} catch (Throwable throwable) {
confirmed.completeExceptionally(throwable);
return confirmed;
}
if (!J.runRegion(world, chunkX, chunkZ, () -> confirmed.complete(null))) {
confirmed.completeExceptionally(new IllegalStateException(
"Failed to confirm entry-chunk region at " + chunkX + "," + chunkZ + "."));
}
return confirmed;
}
private void complete(CompletableFuture<Void> target, Throwable throwable) {
if (throwable == null) {
target.complete(null);
return;
}
target.completeExceptionally(throwable);
}
private void endStudioEntryBootstrap(World world, PlatformChunkGenerator provider) {
if (!(provider instanceof BukkitChunkGenerator bukkitGenerator)) {
throw new IllegalStateException("Studio runtime provider cannot finish its entry bootstrap.");
}
AtomicBoolean activationClaim = new AtomicBoolean(true);
CompletableFuture<Void> activation = J.sfut(() -> {
if (!activationClaim.compareAndSet(true, false)) {
INMS.get().abandonStudioStructureBootstrap(world);
return;
}
if (!J.runRegion(world, chunkX, chunkZ, () -> loaded.complete(null))) {
loaded.completeExceptionally(new IllegalStateException(
"Failed to confirm entry-chunk region at " + chunkX + "," + chunkZ + "."));
try {
INMS.get().completeStudioStructureBootstrap(world);
bukkitGenerator.endStudioEntryBootstrap();
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(
"Studio native structure state could not be activated after entry bootstrap.", e);
}
});
return loaded;
try {
activation.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
activationClaim.compareAndSet(true, false);
Thread.currentThread().interrupt();
throw new IllegalStateException("Studio native structure activation was interrupted.", e);
} catch (ExecutionException e) {
activationClaim.compareAndSet(true, false);
throw new IllegalStateException("Studio native structure activation did not complete.",
unwrapFailure(e));
} catch (TimeoutException e) {
if (!activationClaim.compareAndSet(true, false)) {
try {
activation.get(5L, TimeUnit.SECONDS);
return;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"Studio native structure activation was interrupted.", interrupted);
} catch (ExecutionException | TimeoutException settlementFailure) {
throw new IllegalStateException(
"Studio native structure activation did not settle after claiming completion.",
unwrapFailure(settlementFailure));
}
}
throw new IllegalStateException("Studio native structure activation did not complete.", e);
}
}
private void abandonStudioEntryBootstrap(World world, Throwable failure) {
if (world == null) {
return;
}
if (J.isPrimaryThread()) {
try {
INMS.get().abandonStudioStructureBootstrap(world);
} catch (Throwable abandonmentFailure) {
failure.addSuppressed(abandonmentFailure);
}
return;
}
CompletableFuture<Void> abandonment = J.sfut(
() -> INMS.get().abandonStudioStructureBootstrap(world));
try {
abandonment.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
failure.addSuppressed(new IllegalStateException(
"Studio native structure abandonment was interrupted.", e));
} catch (ExecutionException | TimeoutException e) {
failure.addSuppressed(new IllegalStateException(
"Studio native structure abandonment did not complete.", unwrapFailure(e)));
}
}
private void deferFailedOpenCleanup(
CompletableFuture<Void> entryLoadFuture,
PlatformChunkGenerator provider,
String worldName,
World world,
IrisProject project
) {
CompletableFuture<Void> cleanup = deferCleanupUntilEntrySettlement(
entryLoadFuture,
() -> cleanupFailedOpen(provider, worldName, world, project));
entryLoads.releaseAfterSuccessfulCompletion(worldName, entryLoadFuture, cleanup);
cleanup.whenComplete((ignored, cleanupFailure) -> {
if (cleanupFailure != null) {
IrisLogging.reportError("Deferred Studio cleanup failed for world \""
+ worldName + "\".", unwrapFailure(cleanupFailure));
}
});
observeDeferredEntryCleanupBoundary(entryLoadFuture, worldName);
}
static CompletableFuture<Void> deferCleanupUntilEntrySettlement(
CompletableFuture<?> entryLoadFuture,
Supplier<CompletableFuture<Void>> cleanup
) {
Objects.requireNonNull(entryLoadFuture, "Studio entry-load future");
Objects.requireNonNull(cleanup, "Studio cleanup");
return entryLoadFuture.handle((ignored, entryFailure) -> null)
.thenCompose(ignored -> invokePhase(cleanup));
}
static boolean requiresDeferredEntryCleanup(CompletableFuture<?> entryLoadFuture) {
return entryLoadFuture != null && !entryLoadFuture.isDone();
}
private CompletableFuture<Void> cleanupFailedOpen(
PlatformChunkGenerator provider,
String worldName,
World world,
IrisProject project
) {
return closeWorldCoordinated(provider, worldName, world, true, project)
.thenCompose(result -> result.failureCause() == null
? CompletableFuture.completedFuture(null)
: CompletableFuture.failedFuture(result.failureCause()));
}
private void observeDeferredEntryCleanupBoundary(
CompletableFuture<?> entryLoadFuture,
String worldName
) {
CompletableFuture.delayedExecutor(
STUDIO_ENTRY_CLEANUP_BOUNDARY_SECONDS,
TimeUnit.SECONDS).execute(() -> {
if (entryLoadFuture.isDone()) {
return;
}
TimeoutException timeout = new TimeoutException(
"Studio entry generation remained active for "
+ STUDIO_ENTRY_CLEANUP_BOUNDARY_SECONDS
+ " seconds after the open timeout for \"" + worldName + "\".");
boolean queued = queueStartupCleanup(worldName, timeout);
String recovery = queued
? " The transient world is queued for deletion at the next clean startup."
: " The transient world could not be queued for startup deletion.";
IrisLogging.reportError(
"Studio world \"" + worldName
+ "\" remains loaded because its entry generation is still active;"
+ " Iris did not unload or close its generator."
+ recovery,
timeout);
});
}
private CompletableFuture<StudioCloseResult> closeWorldCoordinated(
@@ -539,6 +787,11 @@ public final class StudioOpenCoordinator {
}
for (String staleWorldName : staleWorldNames) {
if (entryLoads.isFenced(staleWorldName)) {
IrisLogging.warn("Skipping stale Studio cleanup for \"" + staleWorldName
+ "\" because its open or deferred cleanup is still active.");
continue;
}
try {
StudioCloseResult cleanupResult = closeWorldCoordinated(
null,
@@ -659,12 +912,23 @@ public final class StudioOpenCoordinator {
long seed,
String worldName,
String playerName,
boolean openWorkspace,
StudioOpenKind openKind,
boolean retainOnFailure,
Consumer<StudioOpenProgress> progressConsumer,
Consumer<World> onDone
) {
public static StudioOpenRequest studioProject(IrisProject project, VolmitSender sender, long seed, Consumer<StudioOpenProgress> progressConsumer, Consumer<World> onDone) {
public StudioOpenRequest {
openKind = Objects.requireNonNull(openKind, "Studio open kind");
}
public static StudioOpenRequest studioProject(
IrisProject project,
VolmitSender sender,
long seed,
StudioOpenKind openKind,
Consumer<StudioOpenProgress> progressConsumer,
Consumer<World> onDone
) {
String playerName = sender != null && sender.isPlayer() && sender.player() != null ? sender.player().getName() : null;
return new StudioOpenRequest(
project.getName(),
@@ -673,7 +937,7 @@ public final class StudioOpenCoordinator {
seed,
"iris-" + UUID.randomUUID(),
playerName,
true,
openKind,
false,
progressConsumer,
onDone
@@ -681,6 +945,49 @@ public final class StudioOpenCoordinator {
}
}
public enum StudioOpenKind {
STANDARD(
true,
true,
IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY),
JIGSAW(
false,
false,
IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY);
private final boolean teleportThroughStandardEntry;
private final boolean openWorkspace;
private final IrisCreator.DatapackPreparation datapackPreparation;
StudioOpenKind(
boolean teleportThroughStandardEntry,
boolean openWorkspace,
IrisCreator.DatapackPreparation datapackPreparation
) {
this.teleportThroughStandardEntry = teleportThroughStandardEntry;
this.openWorkspace = openWorkspace;
this.datapackPreparation = Objects.requireNonNull(
datapackPreparation,
"Studio datapack preparation");
}
public boolean teleportThroughStandardEntry() {
return teleportThroughStandardEntry;
}
public boolean openWorkspace() {
return openWorkspace;
}
public boolean prepareGeneratorState() {
return this == STANDARD;
}
public IrisCreator.DatapackPreparation datapackPreparation() {
return datapackPreparation;
}
}
public record StudioOpenProgress(double progress, String stage) {
}
@@ -698,4 +1005,64 @@ public final class StudioOpenCoordinator {
return failureCause == null;
}
}
static final class EntryLoadRegistry {
private final ConcurrentHashMap<String, CompletableFuture<?>> entryLoads;
EntryLoadRegistry() {
entryLoads = new ConcurrentHashMap<>();
}
void register(String worldName, CompletableFuture<?> entryLoadFuture) {
Objects.requireNonNull(worldName, "Studio world name");
Objects.requireNonNull(entryLoadFuture, "Studio entry-load future");
CompletableFuture<?> existing = entryLoads.putIfAbsent(worldName, entryLoadFuture);
if (existing != null) {
throw new IllegalStateException("Studio open or deferred cleanup is already active for \""
+ worldName + "\".");
}
}
void release(
String worldName,
CompletableFuture<?> entryLoadFuture
) {
if (worldName == null || entryLoadFuture == null) {
return;
}
entryLoads.remove(worldName, entryLoadFuture);
}
void releaseAfterSuccessfulCompletion(
String worldName,
CompletableFuture<?> entryLoadFuture,
CompletableFuture<?> completion
) {
Objects.requireNonNull(completion, "Studio lifecycle completion");
completion.whenComplete((ignored, failure) -> {
if (failure == null) {
release(worldName, entryLoadFuture);
}
});
}
void rejectNewOpen() {
ArrayList<String> activeWorlds = new ArrayList<>();
for (Map.Entry<String, CompletableFuture<?>> entry : entryLoads.entrySet()) {
activeWorlds.add(entry.getKey());
}
if (activeWorlds.isEmpty()) {
return;
}
Collections.sort(activeWorlds);
throw new IllegalStateException("A previous Studio open or deferred cleanup is still active for "
+ String.join(", ", activeWorlds)
+ ". Wait for it to settle before opening another Studio; a reported cleanup failure"
+ " requires the queued clean restart.");
}
boolean isFenced(String worldName) {
return entryLoads.containsKey(worldName);
}
}
}
@@ -3,6 +3,7 @@ package art.arcane.iris.core.runtime;
import org.bukkit.Chunk;
import org.bukkit.World;
import java.lang.reflect.Method;
import java.util.OptionalLong;
import java.util.concurrent.CompletableFuture;
@@ -18,4 +19,39 @@ interface WorldRuntimeControlBackend {
void syncTime(World world);
CompletableFuture<Chunk> requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate);
default CompletableFuture<Chunk> requestChunkAsync(
World world,
int chunkX,
int chunkZ,
boolean generate,
boolean urgent
) {
if (!urgent) {
return requestChunkAsync(world, chunkX, chunkZ, generate);
}
if (world == null) {
return CompletableFuture.failedFuture(new IllegalStateException("World is null."));
}
try {
Method method = World.class.getMethod(
"getChunkAtAsync",
int.class,
int.class,
boolean.class,
boolean.class);
Object result = method.invoke(world, chunkX, chunkZ, generate, true);
if (result instanceof CompletableFuture<?> future) {
@SuppressWarnings("unchecked")
CompletableFuture<Chunk> chunkFuture = (CompletableFuture<Chunk>) future;
return chunkFuture;
}
return CompletableFuture.failedFuture(
new IllegalStateException("Paper World#getChunkAtAsync returned a non-future result."));
} catch (NoSuchMethodException exception) {
return requestChunkAsync(world, chunkX, chunkZ, generate);
} catch (Throwable exception) {
return CompletableFuture.failedFuture(exception);
}
}
}
@@ -198,6 +198,16 @@ public final class WorldRuntimeControlService {
return backend.requestChunkAsync(world, chunkX, chunkZ, generate);
}
public CompletableFuture<Chunk> requestChunkAsync(
World world,
int chunkX,
int chunkZ,
boolean generate,
boolean urgent
) {
return backend.requestChunkAsync(world, chunkX, chunkZ, generate, urgent);
}
public void prepareGenerator(World world) {
if (world == null) {
return;
@@ -0,0 +1,78 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import java.util.Objects;
public enum JigsawPlanarArchetype {
BLANK("workcell/blank", JigsawPlanarTopology.BLANK),
END("workcell/end", JigsawPlanarTopology.NORTH_END),
STRAIGHT("workcell/straight", JigsawPlanarTopology.NORTH_SOUTH_STRAIGHT),
CORNER("workcell/corner", JigsawPlanarTopology.NORTH_EAST_CORNER),
TEE("workcell/tee", JigsawPlanarTopology.NORTH_EAST_WEST_TEE),
CROSS("workcell/cross", JigsawPlanarTopology.CROSS);
private final String stableId;
private final JigsawPlanarTopology canonicalTopology;
JigsawPlanarArchetype(String stableId, JigsawPlanarTopology canonicalTopology) {
this.stableId = stableId;
this.canonicalTopology = canonicalTopology;
}
public static JigsawPlanarArchetype fromTopology(JigsawPlanarTopology topology) {
JigsawPlanarTopology source = Objects.requireNonNull(topology, "Planar topology");
return switch (source.kind()) {
case BLANK -> BLANK;
case END -> END;
case STRAIGHT -> STRAIGHT;
case CORNER -> CORNER;
case TEE -> TEE;
case CROSS -> CROSS;
};
}
public static JigsawPlanarArchetype fromModel(IrisJigsawWorkcellArchetype archetype) {
return valueOf(Objects.requireNonNull(archetype, "Planar workcell archetype").name());
}
public String stableId() {
return stableId;
}
public JigsawPlanarTopology canonicalTopology() {
return canonicalTopology;
}
public IrisJigsawWorkcellArchetype modelArchetype() {
return IrisJigsawWorkcellArchetype.valueOf(name());
}
public String displayName() {
return switch (this) {
case BLANK -> "Blank";
case END -> "End Cap";
case STRAIGHT -> "Hallway";
case CORNER -> "L Junction";
case TEE -> "T Junction";
case CROSS -> "Cross Junction";
};
}
public int sourceToCanonicalQuarterTurns(JigsawPlanarTopology sourceTopology) {
JigsawPlanarTopology source = Objects.requireNonNull(sourceTopology, "Source planar topology");
if (fromTopology(source) != this) {
throw new IllegalArgumentException("Topology " + source + " does not belong to archetype " + this);
}
for (int quarterTurns = 0; quarterTurns < 4; quarterTurns++) {
if (source.rotateClockwise(quarterTurns) == canonicalTopology) {
return quarterTurns;
}
}
throw new IllegalStateException("No canonical rotation exists for topology " + source);
}
public int canonicalToSourceQuarterTurns(JigsawPlanarTopology sourceTopology) {
return Math.floorMod(-sourceToCanonicalQuarterTurns(sourceTopology), 4);
}
}
@@ -0,0 +1,38 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.engine.object.IrisDirection;
public enum JigsawPlanarDirection {
NORTH(1),
EAST(2),
SOUTH(4),
WEST(8);
private final int bit;
JigsawPlanarDirection(int bit) {
this.bit = bit;
}
public int bit() {
return bit;
}
public IrisDirection irisDirection() {
return switch (this) {
case NORTH -> IrisDirection.NORTH_NEGATIVE_Z;
case EAST -> IrisDirection.EAST_POSITIVE_X;
case SOUTH -> IrisDirection.SOUTH_POSITIVE_Z;
case WEST -> IrisDirection.WEST_NEGATIVE_X;
};
}
public JigsawPlanarDirection rotateClockwise(int quarterTurns) {
int normalizedTurns = Math.floorMod(quarterTurns, values().length);
return values()[(ordinal() + normalizedTurns) % values().length];
}
public JigsawPlanarDirection opposite() {
return rotateClockwise(2);
}
}
@@ -0,0 +1,100 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
public enum JigsawPlanarTopology {
BLANK(0),
NORTH_END(1),
EAST_END(2),
NORTH_EAST_CORNER(3),
SOUTH_END(4),
NORTH_SOUTH_STRAIGHT(5),
EAST_SOUTH_CORNER(6),
NORTH_EAST_SOUTH_TEE(7),
WEST_END(8),
NORTH_WEST_CORNER(9),
EAST_WEST_STRAIGHT(10),
NORTH_EAST_WEST_TEE(11),
SOUTH_WEST_CORNER(12),
NORTH_SOUTH_WEST_TEE(13),
EAST_SOUTH_WEST_TEE(14),
CROSS(15);
private static final JigsawPlanarTopology[] BY_MASK = buildMaskIndex();
private final int mask;
private final JigsawPlanarTopologyKind kind;
private final Set<JigsawPlanarDirection> directions;
JigsawPlanarTopology(int mask) {
this.mask = mask;
this.kind = resolveKind(mask);
this.directions = Collections.unmodifiableSet(resolveDirections(mask));
}
public static JigsawPlanarTopology fromMask(int mask) {
if (mask < 0 || mask >= BY_MASK.length) {
throw new IllegalArgumentException("Planar topology mask must be between 0 and 15");
}
return BY_MASK[mask];
}
public int mask() {
return mask;
}
public JigsawPlanarTopologyKind kind() {
return kind;
}
public Set<JigsawPlanarDirection> directions() {
return directions;
}
public boolean connects(JigsawPlanarDirection direction) {
return directions.contains(direction);
}
public JigsawPlanarTopology rotateClockwise(int quarterTurns) {
int normalizedTurns = Math.floorMod(quarterTurns, 4);
if (normalizedTurns == 0 || mask == 0 || mask == 15) {
return this;
}
int rotatedMask = ((mask << normalizedTurns) | (mask >>> (4 - normalizedTurns))) & 15;
return fromMask(rotatedMask);
}
private static JigsawPlanarTopology[] buildMaskIndex() {
JigsawPlanarTopology[] index = new JigsawPlanarTopology[16];
for (JigsawPlanarTopology topology : values()) {
index[topology.mask] = topology;
}
return index;
}
private static EnumSet<JigsawPlanarDirection> resolveDirections(int mask) {
EnumSet<JigsawPlanarDirection> result = EnumSet.noneOf(JigsawPlanarDirection.class);
for (JigsawPlanarDirection direction : JigsawPlanarDirection.values()) {
if ((mask & direction.bit()) != 0) {
result.add(direction);
}
}
return result;
}
private static JigsawPlanarTopologyKind resolveKind(int mask) {
int connectionCount = Integer.bitCount(mask);
return switch (connectionCount) {
case 0 -> JigsawPlanarTopologyKind.BLANK;
case 1 -> JigsawPlanarTopologyKind.END;
case 2 -> mask == 5 || mask == 10
? JigsawPlanarTopologyKind.STRAIGHT
: JigsawPlanarTopologyKind.CORNER;
case 3 -> JigsawPlanarTopologyKind.TEE;
case 4 -> JigsawPlanarTopologyKind.CROSS;
default -> throw new IllegalArgumentException("Invalid planar topology mask " + mask);
};
}
}
@@ -0,0 +1,10 @@
package art.arcane.iris.core.runtime.jigsaw;
public enum JigsawPlanarTopologyKind {
BLANK,
END,
STRAIGHT,
CORNER,
TEE,
CROSS
}
@@ -0,0 +1,409 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.loader.IrisData;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
public final class JigsawStudioActivation {
private static final Map<String, ActiveRequest> ACTIVE = new ConcurrentHashMap<>();
private static final AtomicReference<UUID> OPENING_OWNER = new AtomicReference<>();
private static final AtomicReference<StagedState> STAGED = new AtomicReference<>();
private JigsawStudioActivation() {
}
public static Request activate(
String packKey,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions,
IrisData source
) {
JigsawStudioLayout layout = JigsawStudioLayout.create(
mode,
cellDimensions,
JigsawStudioVariantCatalog.empty());
return activate(
packKey,
structureKey,
mode,
compatibilityTarget,
cellDimensions,
source,
layout,
null
);
}
public static Request activate(
String packKey,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions,
IrisData source,
JigsawStudioLayout initialLayout
) {
return activate(
packKey,
structureKey,
mode,
compatibilityTarget,
cellDimensions,
source,
initialLayout,
null
);
}
public static synchronized Request activate(
String packKey,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions,
IrisData source,
JigsawStudioLayout initialLayout,
UUID ownerId
) {
if (STAGED.get() != null) {
throw new IllegalStateException("A Jigsaw Studio replacement is already staged");
}
if (ownerId != null && !ownerId.equals(OPENING_OWNER.get())) {
throw new IllegalStateException("Jigsaw Studio activation does not own the active opening lease");
}
UUID activeOwnerId = activeOwnerId();
if (ownerId != null && activeOwnerId != null && !ownerId.equals(activeOwnerId)) {
throw new IllegalStateException("Jigsaw Studio is owned by another player session");
}
ActiveRequest activeRequest = createActiveRequest(
packKey,
structureKey,
mode,
compatibilityTarget,
cellDimensions,
source,
initialLayout,
ownerId);
ACTIVE.put(normalize(packKey), activeRequest);
return activeRequest.request();
}
public static synchronized StagedActivation stage(
String packKey,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions,
IrisData source,
JigsawStudioLayout initialLayout,
UUID ownerId,
UUID preservedRequestId
) {
UUID openingOwner = OPENING_OWNER.get();
UUID activeOwner = activeOwnerId();
UUID requestedOwner = Objects.requireNonNull(ownerId, "Jigsaw Studio staged owner ID");
if (!requestedOwner.equals(openingOwner)) {
throw new IllegalStateException("Jigsaw Studio staging does not own the active opening lease");
}
if (activeOwner != null && !requestedOwner.equals(activeOwner)) {
throw new IllegalStateException("Jigsaw Studio is owned by another player session");
}
if (STAGED.get() != null) {
throw new IllegalStateException("A Jigsaw Studio replacement is already staged");
}
ActiveRequest preserved = preservedRequestId == null ? null : active(preservedRequestId);
if (preservedRequestId != null && preserved == null) {
throw new IllegalStateException("The Jigsaw Studio being replaced is no longer active");
}
if (activeOwner != null && preserved == null) {
throw new IllegalStateException("The active Jigsaw Studio must be preserved during replacement");
}
if (preserved != null && preserved.request().ownerId() != null
&& !requestedOwner.equals(preserved.request().ownerId())) {
throw new IllegalStateException("The Jigsaw Studio being replaced belongs to another player session");
}
ActiveRequest candidate = createActiveRequest(
packKey,
structureKey,
mode,
compatibilityTarget,
cellDimensions,
source,
initialLayout,
requestedOwner);
StagedActivation staged = new StagedActivation(
UUID.randomUUID(), candidate.request(), candidate.session());
STAGED.set(new StagedState(staged, candidate, preserved, false));
return staged;
}
public static synchronized boolean beginStagedGeneration(StagedActivation expected) {
StagedState state = stagedState(expected);
if (state == null || state.generationVisible()) {
return false;
}
STAGED.set(new StagedState(state.staged(), state.candidate(), state.preserved(), true));
return true;
}
public static synchronized boolean commit(StagedActivation expected) {
StagedState state = stagedState(expected);
if (state == null || !state.generationVisible()) {
return false;
}
if (state.preserved() != null) {
ACTIVE.remove(normalize(state.preserved().request().packKey()), state.preserved());
}
ACTIVE.put(normalize(state.candidate().request().packKey()), state.candidate());
STAGED.set(null);
return true;
}
public static synchronized boolean rollback(StagedActivation expected) {
StagedState state = stagedState(expected);
if (state == null) {
return false;
}
STAGED.set(null);
return true;
}
public static Request getGeneratorRequest(String packKey) {
StagedState state = STAGED.get();
if (state != null && state.generationVisible()
&& normalize(state.candidate().request().packKey()).equals(normalize(packKey))) {
return state.candidate().request();
}
return getRequest(packKey);
}
public static JigsawStudioSession getGeneratorSession(String packKey) {
StagedState state = STAGED.get();
if (state != null && state.generationVisible()
&& normalize(state.candidate().request().packKey()).equals(normalize(packKey))) {
return state.candidate().session();
}
return getSession(packKey);
}
public static boolean tryBeginOpen(UUID ownerId) {
UUID requestedOwner = Objects.requireNonNull(ownerId, "Jigsaw Studio opening owner ID");
UUID activeOwnerId = activeOwnerId();
if (activeOwnerId != null && !requestedOwner.equals(activeOwnerId)) {
return false;
}
return OPENING_OWNER.compareAndSet(null, requestedOwner);
}
public static void finishOpen(UUID ownerId) {
if (ownerId != null) {
OPENING_OWNER.compareAndSet(ownerId, null);
}
}
public static UUID openingOwnerId() {
return OPENING_OWNER.get();
}
public static UUID activeOwnerId() {
for (ActiveRequest activeRequest : ACTIVE.values()) {
UUID ownerId = activeRequest.request().ownerId();
if (ownerId != null) {
return ownerId;
}
}
return null;
}
public static void deactivate(String packKey) {
if (packKey != null) {
ACTIVE.remove(normalize(packKey));
StagedState state = STAGED.get();
if (state != null && (normalize(state.candidate().request().packKey()).equals(normalize(packKey))
|| state.preserved() != null && normalize(state.preserved().request().packKey())
.equals(normalize(packKey)))) {
STAGED.compareAndSet(state, null);
}
}
}
public static boolean deactivate(String packKey, UUID requestId) {
if (packKey == null || requestId == null) {
return false;
}
StagedState staged = STAGED.get();
if (staged != null && staged.preserved() != null
&& requestId.equals(staged.preserved().request().requestId())
&& normalize(packKey).equals(normalize(staged.preserved().request().packKey()))) {
return false;
}
AtomicBoolean removed = new AtomicBoolean(false);
ACTIVE.computeIfPresent(normalize(packKey), (key, activeRequest) -> {
if (!requestId.equals(activeRequest.request().requestId())) {
return activeRequest;
}
removed.set(true);
return null;
});
return removed.get();
}
public static boolean isActive(String packKey) {
return getRequest(packKey) != null;
}
public static Request getRequest(String packKey) {
ActiveRequest activeRequest = active(packKey);
return activeRequest == null ? null : activeRequest.request();
}
public static JigsawStudioSession getSession(String packKey) {
ActiveRequest activeRequest = active(packKey);
return activeRequest == null ? null : activeRequest.session();
}
public static JigsawStudioLayout getLayout(String packKey) {
JigsawStudioSession session = getSession(packKey);
return session == null ? null : session.layout();
}
public static Request getRequest(UUID requestId) {
ActiveRequest activeRequest = active(requestId);
return activeRequest == null ? null : activeRequest.request();
}
public static JigsawStudioSession getSession(UUID requestId) {
ActiveRequest activeRequest = active(requestId);
return activeRequest == null ? null : activeRequest.session();
}
private static ActiveRequest active(String packKey) {
if (packKey == null) {
return null;
}
return ACTIVE.get(normalize(packKey));
}
private static ActiveRequest active(UUID requestId) {
if (requestId == null) {
return null;
}
for (ActiveRequest activeRequest : ACTIVE.values()) {
if (requestId.equals(activeRequest.request().requestId())) {
return activeRequest;
}
}
return null;
}
private static ActiveRequest createActiveRequest(
String packKey,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions,
IrisData source,
JigsawStudioLayout initialLayout,
UUID ownerId
) {
Request request = new Request(
UUID.randomUUID(),
packKey,
structureKey,
mode,
compatibilityTarget,
cellDimensions,
source,
ownerId);
JigsawStudioLayout layout = Objects.requireNonNull(initialLayout, "Initial Jigsaw Studio layout");
if (layout.mode() != mode) {
throw new IllegalArgumentException("Initial Jigsaw Studio layout mode does not match the request");
}
if (!layout.cellDimensions().equals(cellDimensions)) {
throw new IllegalArgumentException("Initial Jigsaw Studio layout cell dimensions do not match the request");
}
JigsawStudioSession session = new JigsawStudioSession(
request.requestId(),
request.packKey(),
request.structureKey(),
layout);
return new ActiveRequest(request, session);
}
private static StagedState stagedState(StagedActivation expected) {
StagedActivation staged = Objects.requireNonNull(expected, "Staged Jigsaw Studio activation");
StagedState state = STAGED.get();
return state != null && state.staged().stageId().equals(staged.stageId()) ? state : null;
}
private static String normalize(String key) {
return key.trim().toLowerCase(Locale.ROOT);
}
private record ActiveRequest(Request request, JigsawStudioSession session) {
}
private record StagedState(
StagedActivation staged,
ActiveRequest candidate,
ActiveRequest preserved,
boolean generationVisible
) {
}
public record StagedActivation(
UUID stageId,
Request request,
JigsawStudioSession session
) {
public StagedActivation {
Objects.requireNonNull(stageId, "Jigsaw Studio stage ID");
Objects.requireNonNull(request, "Jigsaw Studio staged request");
Objects.requireNonNull(session, "Jigsaw Studio staged session");
if (!request.requestId().equals(session.sessionId())) {
throw new IllegalArgumentException("Staged Jigsaw Studio request and session IDs do not match");
}
}
}
public record Request(
UUID requestId,
String packKey,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions,
IrisData source,
UUID ownerId
) {
public Request {
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio request ID");
packKey = requireKey(packKey, "pack");
structureKey = requireKey(structureKey, "structure");
mode = Objects.requireNonNull(mode, "Jigsaw Studio mode");
compatibilityTarget = Objects.requireNonNull(
compatibilityTarget,
"Jigsaw Studio compatibility target"
);
cellDimensions = Objects.requireNonNull(cellDimensions, "Jigsaw Studio cell dimensions");
source = Objects.requireNonNull(source, "Jigsaw Studio source data");
}
private static String requireKey(String value, String name) {
Objects.requireNonNull(value, "Jigsaw Studio " + name + " key");
String normalized = value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio " + name + " key cannot be blank");
}
return normalized;
}
}
}
@@ -0,0 +1,31 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import java.io.IOException;
import java.util.Objects;
public final class JigsawStudioAuthoringAccess {
private JigsawStudioAuthoringAccess() {
}
public static boolean isEditable(StructureOwnershipManifest manifest) {
StructureOwnershipManifest ownership = Objects.requireNonNull(
manifest,
"Jigsaw Studio ownership manifest");
return ownership.provenance().origin() != StructureOwnershipManifest.Origin.MANAGED_DATAPACK;
}
public static StructureOwnershipManifest requireEditable(
StructureOwnershipManifest manifest
) throws IOException {
StructureOwnershipManifest ownership = Objects.requireNonNull(
manifest,
"Jigsaw Studio ownership manifest");
if (!isEditable(ownership)) {
throw new IOException("This graph is read-only because it is managed by datapack ingest; "
+ "adopt or clone it before making authoring changes.");
}
return ownership;
}
}
@@ -0,0 +1,62 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Objects;
import java.util.Optional;
public record JigsawStudioBay(
String stableId,
JigsawStudioBayKind kind,
Optional<JigsawStudioWorkcellSpec> workcellSpec,
String authorDisplayName,
JigsawStudioBounds bounds
) {
public JigsawStudioBay {
stableId = requireStableId(stableId);
kind = Objects.requireNonNull(kind, "Jigsaw Studio bay kind");
workcellSpec = Objects.requireNonNull(workcellSpec, "Jigsaw Studio workcell specification");
authorDisplayName = authorDisplayName == null ? "" : authorDisplayName.trim();
bounds = Objects.requireNonNull(bounds, "Jigsaw Studio bay bounds");
if (kind == JigsawStudioBayKind.PLANAR_WORKCELL && workcellSpec.isEmpty()) {
throw new IllegalArgumentException("Planar Jigsaw Studio workcells require an archetype");
}
if (kind == JigsawStudioBayKind.SPATIAL_WORKCELL && workcellSpec.isPresent()) {
throw new IllegalArgumentException("Spatial Jigsaw Studio workcells cannot declare an archetype");
}
if (workcellSpec.isPresent() && !workcellSpec.get().dimensions().equals(bounds.dimensions())) {
throw new IllegalArgumentException("Jigsaw Studio workcell specification dimensions do not match bounds");
}
}
public Optional<JigsawPlanarArchetype> archetype() {
return workcellSpec.map(JigsawStudioWorkcellSpec::archetype);
}
public boolean enabled() {
return workcellSpec.map(JigsawStudioWorkcellSpec::enabled).orElse(true);
}
public String canonicalDisplayName() {
return archetype().map(JigsawPlanarArchetype::displayName).orElse("Spatial");
}
public String displayName() {
return authorDisplayName.isEmpty() ? canonicalDisplayName() : authorDisplayName;
}
public JigsawStudioCellDimensions capacity() {
return bounds.dimensions();
}
public Optional<JigsawPlanarTopology> topology() {
return archetype().map(JigsawPlanarArchetype::canonicalTopology);
}
private static String requireStableId(String value) {
Objects.requireNonNull(value, "Jigsaw Studio bay stable ID");
String normalized = value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio bay stable ID cannot be blank");
}
return normalized;
}
}
@@ -0,0 +1,6 @@
package art.arcane.iris.core.runtime.jigsaw;
public enum JigsawStudioBayKind {
PLANAR_WORKCELL,
SPATIAL_WORKCELL
}
@@ -0,0 +1,37 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Objects;
public record JigsawStudioBounds(
int originX,
int originY,
int originZ,
JigsawStudioCellDimensions dimensions
) {
public JigsawStudioBounds {
dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio bounds dimensions");
}
public int maxX() {
return originX + dimensions.width() - 1;
}
public int maxY() {
return originY + dimensions.height() - 1;
}
public int maxZ() {
return originZ + dimensions.depth() - 1;
}
public boolean contains(int worldX, int worldY, int worldZ) {
return worldX >= originX && worldX <= maxX()
&& worldY >= originY && worldY <= maxY()
&& worldZ >= originZ && worldZ <= maxZ();
}
public boolean intersectsHorizontal(int minX, int minZ, int maxX, int maxZ) {
return this.maxX() >= minX && originX <= maxX
&& this.maxZ() >= minZ && originZ <= maxZ;
}
}
@@ -0,0 +1,30 @@
package art.arcane.iris.core.runtime.jigsaw;
public record JigsawStudioCellDimensions(int width, int height, int depth) {
public static final int MAX_HORIZONTAL_AXIS = 128;
public static final int MAX_HEIGHT = 192;
public static final long MAX_VOLUME = 2_097_152L;
public JigsawStudioCellDimensions {
if (width < 1 || height < 1 || depth < 1) {
throw new IllegalArgumentException("Jigsaw Studio cell dimensions must be positive");
}
if (width > MAX_HORIZONTAL_AXIS || depth > MAX_HORIZONTAL_AXIS) {
throw new IllegalArgumentException("Jigsaw Studio cell width and depth cannot exceed "
+ MAX_HORIZONTAL_AXIS + " blocks");
}
if (height > MAX_HEIGHT) {
throw new IllegalArgumentException("Jigsaw Studio cell height cannot exceed "
+ MAX_HEIGHT + " blocks");
}
long volume = (long) width * height * depth;
if (volume > MAX_VOLUME) {
throw new IllegalArgumentException("Jigsaw Studio cell volume cannot exceed "
+ MAX_VOLUME + " blocks");
}
}
public long volume() {
return (long) width * height * depth;
}
}
@@ -0,0 +1,6 @@
package art.arcane.iris.core.runtime.jigsaw;
public enum JigsawStudioCompatibilityTarget {
IRIS_EXTENDED,
VANILLA_PORTABLE
}
@@ -0,0 +1,4 @@
package art.arcane.iris.core.runtime.jigsaw;
public record JigsawStudioControlPosition(int worldX, int worldY, int worldZ) {
}
@@ -0,0 +1,381 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawMode;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisJigsawPieceEntry;
import art.arcane.iris.engine.object.IrisJigsawPool;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisStructure;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
public final class JigsawStudioGraphMapper {
private JigsawStudioGraphMapper() {
}
public static JigsawStudioLayout map(IrisData data, IrisStructure structure) {
Objects.requireNonNull(data, "Jigsaw Studio graph mapping requires pack data");
Objects.requireNonNull(structure, "Jigsaw Studio graph mapping requires a structure");
JigsawStudioMode mode = structure.resolvedMode() == IrisJigsawMode.PLANAR_JIGSAW
? JigsawStudioMode.PLANAR_JIGSAW
: JigsawStudioMode.SPATIAL_JIGSAW;
JigsawStudioVariantCatalog catalog = catalog(data, structure, mode);
IrisPosition configuredCell = structure.getCellSize();
JigsawStudioCellDimensions dimensions = configuredCell == null
? new JigsawStudioCellDimensions(16, 16, 16)
: new JigsawStudioCellDimensions(
Math.max(1, configuredCell.getX()),
Math.max(1, configuredCell.getY()),
Math.max(1, configuredCell.getZ()));
if (mode == JigsawStudioMode.SPATIAL_JIGSAW) {
dimensions = expandSpatialDimensions(data, catalog, dimensions);
return JigsawStudioLayout.createSpatial(
dimensions,
catalog,
structure.getSpatialWorkcellDisplayName());
}
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> resolved =
PlanarJigsawWorkcellResolver.resolve(structure);
List<JigsawStudioWorkcellSpec> workcells = new ArrayList<>(resolved.size());
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = resolved.get(archetype.modelArchetype());
workcells.add(new JigsawStudioWorkcellSpec(
archetype,
workcell.displayName(),
new JigsawStudioCellDimensions(
workcell.width(),
workcell.height(),
workcell.depth()),
workcell.enabled()));
}
return JigsawStudioLayout.createPlanar(dimensions, workcells, catalog);
}
public static JigsawStudioVariantCatalog catalog(
IrisData data,
IrisStructure structure,
JigsawStudioMode mode
) {
IrisData source = Objects.requireNonNull(data, "Jigsaw Studio catalog pack data");
IrisStructure root = Objects.requireNonNull(structure, "Jigsaw Studio catalog structure");
JigsawStudioMode activeMode = Objects.requireNonNull(mode, "Jigsaw Studio catalog mode");
OwnedResources owned = ownedResources(source, root);
Map<String, MutableVariant> variants = new LinkedHashMap<>();
Set<String> visitedPools = new LinkedHashSet<>();
Set<String> queuedPools = new HashSet<>();
ArrayDeque<String> pools = new ArrayDeque<>();
enqueuePool(root.getStartPool(), pools, queuedPools);
traversePools(source, activeMode, owned, pools, queuedPools, visitedPools, variants);
for (String poolKey : owned.poolKeys()) {
enqueuePool(poolKey, pools, queuedPools);
}
traversePools(source, activeMode, owned, pools, queuedPools, visitedPools, variants);
for (String pieceKey : owned.pieceKeys()) {
addOwnedVariant(source, activeMode, owned, pieceKey, variants);
}
List<JigsawStudioVariant> built = new ArrayList<>(variants.size());
for (MutableVariant variant : variants.values()) {
built.add(variant.build(source));
}
return new JigsawStudioVariantCatalog(built, owned.editable());
}
public static JigsawPlanarTopology topologyOf(IrisJigsawPiece piece) {
Objects.requireNonNull(piece, "Planar topology requires a jigsaw piece");
int mask = 0;
if (piece.getConnectors() != null) {
for (IrisJigsawConnector connector : piece.getConnectors()) {
if (connector == null || connector.getDirection() == null) {
continue;
}
mask |= directionBit(connector.getDirection());
}
}
return JigsawPlanarTopology.fromMask(mask);
}
static JigsawStudioCellDimensions expandSpatialDimensions(
IrisData data,
JigsawStudioVariantCatalog catalog,
JigsawStudioCellDimensions configured
) {
if (data.getObjectLoader() == null) {
return configured;
}
int width = configured.width();
int height = configured.height();
int depth = configured.depth();
for (JigsawStudioVariant variant : catalog.spatialVariants()) {
IrisObject object = data.getObjectLoader().load(variant.objectKey());
if (object == null) {
continue;
}
int objectWidth = object.getW();
int objectDepth = object.getD();
if (variant.rotatable()) {
int horizontalSpan = Math.max(objectWidth, objectDepth);
objectWidth = horizontalSpan;
objectDepth = horizontalSpan;
}
width = Math.max(width, objectWidth);
height = Math.max(height, object.getH());
depth = Math.max(depth, objectDepth);
}
return new JigsawStudioCellDimensions(width, height, depth);
}
private static void traversePools(
IrisData data,
JigsawStudioMode mode,
OwnedResources owned,
ArrayDeque<String> pools,
Set<String> queuedPools,
Set<String> visitedPools,
Map<String, MutableVariant> variants
) {
while (!pools.isEmpty()) {
String poolKey = pools.removeFirst();
if (!visitedPools.add(poolKey)) {
continue;
}
IrisJigsawPool pool = data.getJigsawPoolLoader().load(poolKey);
if (pool == null) {
continue;
}
enqueuePool(pool.getFallback(), pools, queuedPools);
if (pool.getPieces() == null) {
continue;
}
for (int entryIndex = 0; entryIndex < pool.getPieces().size(); entryIndex++) {
IrisJigsawPieceEntry entry = pool.getPieces().get(entryIndex);
if (entry == null || entry.isEmpty() || entry.getPiece() == null || entry.getPiece().isBlank()) {
continue;
}
String pieceKey = entry.getPiece();
IrisJigsawPiece piece = data.getJigsawPieceLoader().load(pieceKey);
if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) {
continue;
}
MutableVariant variant = variants.computeIfAbsent(
pieceKey,
key -> new MutableVariant(
key,
piece,
mode,
owned.owns(key, piece.getObject())));
variant.addMembership(new JigsawStudioPoolMembership(
poolKey,
entryIndex,
entry.getWeight(),
entry.getChance()));
enqueueConnectorPools(piece, pools, queuedPools);
}
}
}
private static void addOwnedVariant(
IrisData data,
JigsawStudioMode mode,
OwnedResources owned,
String pieceKey,
Map<String, MutableVariant> variants
) {
if (variants.containsKey(pieceKey)) {
return;
}
IrisJigsawPiece piece = data.getJigsawPieceLoader().load(pieceKey);
if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) {
return;
}
variants.put(pieceKey, new MutableVariant(pieceKey, piece, mode, owned.owns(pieceKey, piece.getObject())));
}
private static OwnedResources ownedResources(IrisData data, IrisStructure structure) {
String structureKey = structure.getLoadKey();
File dataFolder = data.getDataFolder();
if (structureKey == null || structureKey.isBlank() || dataFolder == null) {
return OwnedResources.empty();
}
try {
Path root = dataFolder.toPath().toAbsolutePath().normalize();
StructureTransactionWriter writer = new StructureTransactionWriter(root);
Path manifestPath = writer.ownershipManifestPath(StructureKey.parse(structureKey, "iris"));
if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
return OwnedResources.empty();
}
StructureOwnershipManifest manifest = StructureOwnershipManifest.fromJson(Files.readAllBytes(manifestPath));
Set<String> pieceKeys = new TreeSet<>();
Set<String> poolKeys = new TreeSet<>();
Set<String> objectKeys = new TreeSet<>();
for (String path : manifest.resourceHashes().keySet()) {
addOwnedKey(path, "jigsaw-pieces/", ".json", pieceKeys);
addOwnedKey(path, "jigsaw-pools/", ".json", poolKeys);
addOwnedKey(path, "objects/", ".iob", objectKeys);
}
boolean editable = JigsawStudioAuthoringAccess.isEditable(manifest);
return new OwnedResources(pieceKeys, poolKeys, objectKeys, editable);
} catch (Exception exception) {
throw new IllegalStateException(
"Failed to read Jigsaw Studio ownership for structure '" + structureKey + "'",
exception);
}
}
private static void addOwnedKey(String path, String prefix, String suffix, Set<String> keys) {
if (path.startsWith(prefix) && path.endsWith(suffix) && path.length() > prefix.length() + suffix.length()) {
keys.add(path.substring(prefix.length(), path.length() - suffix.length()));
}
}
private static int directionBit(IrisDirection direction) {
return switch (direction) {
case NORTH_NEGATIVE_Z -> JigsawPlanarDirection.NORTH.bit();
case EAST_POSITIVE_X -> JigsawPlanarDirection.EAST.bit();
case SOUTH_POSITIVE_Z -> JigsawPlanarDirection.SOUTH.bit();
case WEST_NEGATIVE_X -> JigsawPlanarDirection.WEST.bit();
case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> 0;
};
}
private static void enqueueConnectorPools(
IrisJigsawPiece piece,
ArrayDeque<String> pools,
Set<String> queuedPools
) {
if (piece.getConnectors() == null) {
return;
}
for (IrisJigsawConnector connector : piece.getConnectors()) {
if (connector != null) {
enqueuePool(connector.getPool(), pools, queuedPools);
}
}
}
private static void enqueuePool(String poolKey, ArrayDeque<String> pools, Set<String> queuedPools) {
if (poolKey == null || poolKey.isBlank() || !queuedPools.add(poolKey)) {
return;
}
pools.addLast(poolKey);
}
private static final class MutableVariant {
private final String pieceKey;
private final IrisJigsawPiece piece;
private final JigsawStudioMode mode;
private final boolean owned;
private final List<JigsawStudioPoolMembership> memberships = new ArrayList<>();
private MutableVariant(
String pieceKey,
IrisJigsawPiece piece,
JigsawStudioMode mode,
boolean owned
) {
this.pieceKey = pieceKey;
this.piece = piece;
this.mode = mode;
this.owned = owned;
}
private void addMembership(JigsawStudioPoolMembership membership) {
memberships.add(membership);
}
private JigsawStudioVariant build(IrisData data) {
Optional<JigsawStudioCellDimensions> dimensions = objectDimensions(data, piece, mode);
return new JigsawStudioVariant(
pieceKey,
piece.getObject(),
piece.getDisplayName(),
dimensions,
mode,
mode == JigsawStudioMode.PLANAR_JIGSAW
? Optional.of(topologyOf(piece))
: Optional.empty(),
piece.isRotatable(),
owned,
piece.getThemes() == null ? List.of() : piece.getThemes(),
JigsawStudioPieceRules.from(piece.resolvedRules()),
memberships);
}
private static Optional<JigsawStudioCellDimensions> objectDimensions(
IrisData data,
IrisJigsawPiece piece,
JigsawStudioMode mode
) {
if (data.getObjectLoader() == null) {
return Optional.empty();
}
IrisObject object = data.getObjectLoader().load(piece.getObject());
if (object == null) {
return Optional.empty();
}
JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions(
object.getW(),
object.getH(),
object.getD());
if (mode == JigsawStudioMode.SPATIAL_JIGSAW) {
return Optional.of(sourceDimensions);
}
JigsawPlanarTopology topology = topologyOf(piece);
int quarterTurns = JigsawPlanarArchetype.fromTopology(topology)
.sourceToCanonicalQuarterTurns(topology);
return Math.floorMod(quarterTurns, 2) == 0
? Optional.of(sourceDimensions)
: Optional.of(new JigsawStudioCellDimensions(
sourceDimensions.depth(),
sourceDimensions.height(),
sourceDimensions.width()));
}
}
private record OwnedResources(
Set<String> pieceKeys,
Set<String> poolKeys,
Set<String> objectKeys,
boolean editable
) {
private OwnedResources {
pieceKeys = Collections.unmodifiableSet(new LinkedHashSet<>(pieceKeys));
poolKeys = Collections.unmodifiableSet(new LinkedHashSet<>(poolKeys));
objectKeys = Collections.unmodifiableSet(new LinkedHashSet<>(objectKeys));
}
private boolean owns(String pieceKey, String objectKey) {
return editable && pieceKeys.contains(pieceKey) && objectKeys.contains(objectKey);
}
private static OwnedResources empty() {
return new OwnedResources(Set.of(), Set.of(), Set.of(), false);
}
}
}
@@ -0,0 +1,298 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public final class JigsawStudioLayout {
public static final int FLOOR_Y = 64;
public static final int PLANAR_COLUMNS = 3;
public static final int PLANAR_GAP = 2;
public static final int MAX_VARIANTS = 512;
public static final String SPATIAL_WORKCELL_ID = "workcell/spatial";
private static final int FIRST_ORIGIN = 16;
private static final JigsawStudioControlPosition CONTROL_POSITION =
new JigsawStudioControlPosition(8, FLOOR_Y + 1, 8);
private final JigsawStudioMode mode;
private final JigsawStudioCellDimensions cellDimensions;
private final int columns;
private final int gap;
private final JigsawStudioVariantCatalog variantCatalog;
private final List<JigsawStudioBay> bays;
private final Map<String, JigsawStudioBay> byStableId;
private JigsawStudioLayout(
JigsawStudioMode mode,
JigsawStudioCellDimensions cellDimensions,
int columns,
int gap,
JigsawStudioVariantCatalog variantCatalog,
List<JigsawStudioBay> bays
) {
this.mode = mode;
this.cellDimensions = cellDimensions;
this.columns = columns;
this.gap = gap;
this.variantCatalog = variantCatalog;
this.bays = Collections.unmodifiableList(new ArrayList<>(bays));
Map<String, JigsawStudioBay> index = new LinkedHashMap<>();
for (JigsawStudioBay bay : bays) {
JigsawStudioBay previous = index.putIfAbsent(bay.stableId(), bay);
if (previous != null) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio workcell stable ID " + bay.stableId());
}
}
this.byStableId = Collections.unmodifiableMap(index);
}
public static JigsawStudioLayout create(
JigsawStudioMode mode,
JigsawStudioCellDimensions cellDimensions,
JigsawStudioVariantCatalog variantCatalog
) {
JigsawStudioMode activeMode = Objects.requireNonNull(mode, "Jigsaw Studio layout mode");
JigsawStudioCellDimensions dimensions = Objects.requireNonNull(
cellDimensions,
"Jigsaw Studio layout cell dimensions");
JigsawStudioVariantCatalog catalog = Objects.requireNonNull(
variantCatalog,
"Jigsaw Studio variant catalog");
validateCatalogMode(activeMode, catalog);
if (activeMode == JigsawStudioMode.PLANAR_JIGSAW) {
return createPlanar(dimensions, uniformSpecs(dimensions), catalog);
}
return createSpatial(dimensions, catalog, "");
}
public static JigsawStudioLayout createSpatial(
JigsawStudioCellDimensions cellDimensions,
JigsawStudioVariantCatalog variantCatalog,
String displayName
) {
JigsawStudioCellDimensions dimensions = Objects.requireNonNull(
cellDimensions,
"Spatial Jigsaw Studio cell dimensions");
JigsawStudioVariantCatalog catalog = Objects.requireNonNull(
variantCatalog,
"Spatial Jigsaw Studio variant catalog");
validateCatalogMode(JigsawStudioMode.SPATIAL_JIGSAW, catalog);
String resolvedDisplayName = displayName == null ? "" : displayName.trim();
List<JigsawStudioBay> workcells = new ArrayList<>();
workcells.add(new JigsawStudioBay(
SPATIAL_WORKCELL_ID,
JigsawStudioBayKind.SPATIAL_WORKCELL,
Optional.empty(),
resolvedDisplayName,
new JigsawStudioBounds(FIRST_ORIGIN, FLOOR_Y + 1, FIRST_ORIGIN, dimensions)));
return new JigsawStudioLayout(
JigsawStudioMode.SPATIAL_JIGSAW,
dimensions,
1,
PLANAR_GAP,
catalog,
workcells);
}
public static JigsawStudioLayout createPlanar(
JigsawStudioCellDimensions defaultDimensions,
List<JigsawStudioWorkcellSpec> workcellSpecs,
JigsawStudioVariantCatalog variantCatalog
) {
JigsawStudioCellDimensions defaults = Objects.requireNonNull(
defaultDimensions,
"Jigsaw Studio default cell dimensions");
JigsawStudioVariantCatalog catalog = Objects.requireNonNull(
variantCatalog,
"Jigsaw Studio variant catalog");
validateCatalogMode(JigsawStudioMode.PLANAR_JIGSAW, catalog);
Map<JigsawPlanarArchetype, JigsawStudioWorkcellSpec> specs = indexSpecs(workcellSpecs);
int[] columnWidths = new int[PLANAR_COLUMNS];
int[] rowDepths = new int[2];
JigsawPlanarArchetype[] archetypes = JigsawPlanarArchetype.values();
for (int index = 0; index < archetypes.length; index++) {
JigsawStudioCellDimensions dimensions = specs.get(archetypes[index]).dimensions();
int column = index % PLANAR_COLUMNS;
int row = index / PLANAR_COLUMNS;
columnWidths[column] = Math.max(columnWidths[column], dimensions.width());
rowDepths[row] = Math.max(rowDepths[row], dimensions.depth());
}
List<JigsawStudioBay> workcells = new ArrayList<>(archetypes.length);
for (int index = 0; index < archetypes.length; index++) {
JigsawPlanarArchetype archetype = archetypes[index];
JigsawStudioWorkcellSpec spec = specs.get(archetype);
workcells.add(new JigsawStudioBay(
archetype.stableId(),
JigsawStudioBayKind.PLANAR_WORKCELL,
Optional.of(spec),
spec.displayName(),
planarBounds(index, spec.dimensions(), columnWidths, rowDepths)));
}
return new JigsawStudioLayout(
JigsawStudioMode.PLANAR_JIGSAW,
defaults,
PLANAR_COLUMNS,
PLANAR_GAP,
catalog,
workcells);
}
public JigsawStudioMode mode() {
return mode;
}
public JigsawStudioCellDimensions cellDimensions() {
return cellDimensions;
}
public int columns() {
return columns;
}
public int gap() {
return gap;
}
public JigsawStudioVariantCatalog variantCatalog() {
return variantCatalog;
}
public List<JigsawStudioBay> bays() {
return bays;
}
public JigsawStudioBay get(String stableId) {
return stableId == null ? null : byStableId.get(stableId);
}
public JigsawStudioBay findAt(int worldX, int worldY, int worldZ) {
for (JigsawStudioBay bay : bays) {
if (bay.bounds().contains(worldX, worldY, worldZ)) {
return bay;
}
}
return null;
}
public List<JigsawStudioVariant> variants(JigsawStudioBay workcell) {
JigsawStudioBay activeWorkcell = requireWorkcell(workcell);
if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) {
return variantCatalog.spatialVariants();
}
return variantCatalog.variants(activeWorkcell.archetype().orElseThrow());
}
public Optional<JigsawStudioVariant> defaultVariant(JigsawStudioBay workcell) {
List<JigsawStudioVariant> variants = variants(workcell);
return variants.isEmpty() ? Optional.empty() : Optional.of(variants.getFirst());
}
public boolean accepts(JigsawStudioBay workcell, JigsawStudioVariant variant) {
JigsawStudioBay activeWorkcell = requireWorkcell(workcell);
JigsawStudioVariant activeVariant = Objects.requireNonNull(variant, "Jigsaw Studio variant");
if (variantCatalog.find(activeVariant.pieceKey()).filter(activeVariant::equals).isEmpty()) {
return false;
}
if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) {
return activeVariant.mode() == JigsawStudioMode.SPATIAL_JIGSAW;
}
return activeVariant.archetype().filter(activeWorkcell.archetype().orElseThrow()::equals).isPresent();
}
public JigsawStudioControlPosition controlPosition() {
return CONTROL_POSITION;
}
public int extentX() {
int maximum = CONTROL_POSITION.worldX();
for (JigsawStudioBay bay : bays) {
maximum = Math.max(maximum, bay.bounds().maxX() + PLANAR_GAP);
}
return maximum;
}
public int extentZ() {
int maximum = CONTROL_POSITION.worldZ();
for (JigsawStudioBay bay : bays) {
maximum = Math.max(maximum, bay.bounds().maxZ() + PLANAR_GAP);
}
return maximum;
}
private JigsawStudioBay requireWorkcell(JigsawStudioBay workcell) {
JigsawStudioBay activeWorkcell = Objects.requireNonNull(workcell, "Jigsaw Studio workcell");
if (byStableId.get(activeWorkcell.stableId()) != activeWorkcell) {
throw new IllegalArgumentException("Workcell does not belong to this Jigsaw Studio layout");
}
return activeWorkcell;
}
private static void validateCatalogMode(JigsawStudioMode mode, JigsawStudioVariantCatalog catalog) {
for (JigsawStudioVariant variant : catalog.variants()) {
if (variant.mode() != mode) {
throw new IllegalArgumentException("Jigsaw Studio variant mode does not match the layout mode");
}
}
}
private static List<JigsawStudioWorkcellSpec> uniformSpecs(JigsawStudioCellDimensions dimensions) {
List<JigsawStudioWorkcellSpec> specs = new ArrayList<>(JigsawPlanarArchetype.values().length);
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
specs.add(new JigsawStudioWorkcellSpec(archetype, "", dimensions, true));
}
return specs;
}
private static Map<JigsawPlanarArchetype, JigsawStudioWorkcellSpec> indexSpecs(
List<JigsawStudioWorkcellSpec> workcellSpecs
) {
List<JigsawStudioWorkcellSpec> source = List.copyOf(Objects.requireNonNull(
workcellSpecs,
"Jigsaw Studio workcell specifications"));
Map<JigsawPlanarArchetype, JigsawStudioWorkcellSpec> specs =
new EnumMap<>(JigsawPlanarArchetype.class);
for (JigsawStudioWorkcellSpec spec : source) {
JigsawStudioWorkcellSpec active = Objects.requireNonNull(
spec,
"Jigsaw Studio workcell specification");
if (specs.putIfAbsent(active.archetype(), active) != null) {
throw new IllegalArgumentException(
"Duplicate Jigsaw Studio workcell specification " + active.archetype());
}
}
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
if (!specs.containsKey(archetype)) {
throw new IllegalArgumentException("Missing Jigsaw Studio workcell specification " + archetype);
}
}
return Collections.unmodifiableMap(specs);
}
private static JigsawStudioBounds planarBounds(
int index,
JigsawStudioCellDimensions dimensions,
int[] columnWidths,
int[] rowDepths
) {
int column = index % PLANAR_COLUMNS;
int row = index / PLANAR_COLUMNS;
int originX = FIRST_ORIGIN;
for (int current = 0; current < column; current++) {
originX = Math.addExact(originX, Math.addExact(columnWidths[current], PLANAR_GAP));
}
int originZ = FIRST_ORIGIN;
for (int current = 0; current < row; current++) {
originZ = Math.addExact(originZ, Math.addExact(rowDepths[current], PLANAR_GAP));
}
return new JigsawStudioBounds(originX, FLOOR_Y + 1, originZ, dimensions);
}
}
@@ -0,0 +1,35 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Pattern;
public final class JigsawStudioMarkerKeyCodec {
private static final String STUDIO_NAMESPACE = "iris:";
private static final Pattern INTERNAL_PATH = Pattern.compile("[a-z0-9._-]+(?:/[a-z0-9._-]+)*");
private JigsawStudioMarkerKeyCodec() {
}
public static String encodePool(String internalPoolKey) {
return STUDIO_NAMESPACE + requireInternalPath(internalPoolKey, "pool");
}
public static String decodePool(String markerPoolKey) {
String markerKey = Objects.requireNonNull(markerPoolKey, "Jigsaw Studio marker pool key").trim();
if (!markerKey.toLowerCase(Locale.ROOT).startsWith(STUDIO_NAMESPACE)) {
throw new IllegalArgumentException(
"Jigsaw Studio marker pools must use iris:<owned-pool-key>, not '" + markerKey + "'");
}
return requireInternalPath(markerKey.substring(STUDIO_NAMESPACE.length()), "pool");
}
public static String requireInternalPath(String value, String kind) {
String path = Objects.requireNonNull(value, "Jigsaw Studio " + kind + " key").trim();
if (!INTERNAL_PATH.matcher(path).matches()) {
throw new IllegalArgumentException("Jigsaw Studio " + kind
+ " keys must use lowercase [a-z0-9._-/] resource-path characters");
}
return path;
}
}
@@ -0,0 +1,6 @@
package art.arcane.iris.core.runtime.jigsaw;
public enum JigsawStudioMode {
PLANAR_JIGSAW,
SPATIAL_JIGSAW
}
@@ -0,0 +1,34 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.engine.object.IrisJigsawPieceRules;
import java.util.Objects;
public record JigsawStudioPieceRules(
int minimumDepth,
int maximumDepth,
int minimumPlacements,
int maximumPlacements,
boolean terminal
) {
public JigsawStudioPieceRules {
if (minimumDepth < 0 || maximumDepth < minimumDepth || maximumDepth > 30) {
throw new IllegalArgumentException("Jigsaw Studio piece depth rules are invalid");
}
if (minimumPlacements < 0 || maximumPlacements < 0
|| minimumPlacements > 512 || maximumPlacements > 512
|| maximumPlacements != 0 && minimumPlacements > maximumPlacements) {
throw new IllegalArgumentException("Jigsaw Studio piece placement-count rules are invalid");
}
}
public static JigsawStudioPieceRules from(IrisJigsawPieceRules rules) {
IrisJigsawPieceRules source = Objects.requireNonNull(rules, "Jigsaw Studio piece rules");
return new JigsawStudioPieceRules(
source.getMinimumDepth(),
source.getMaximumDepth(),
source.getMinimumPlacements(),
source.getMaximumPlacements(),
source.isTerminal());
}
}
@@ -0,0 +1,442 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureHash;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.authoring.StructureWriteOptions;
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.Map;
import java.util.Objects;
public final class JigsawStudioPoolEditor {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private JigsawStudioPoolEditor() {
}
public static WeightUpdate updateWeight(
Path packRoot,
String structureKey,
String poolKey,
String pieceKey,
int weight
) throws IOException {
if (weight < 1) {
throw new IllegalArgumentException("Jigsaw variant weight must be positive");
}
PoolUpdate update = updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateWeight(content, pieceKey, weight, poolPath));
return new WeightUpdate(
update.changed(),
update.changedEntries(),
update.poolPath(),
update.writeResult());
}
public static WeightUpdate updateWeightAtIndex(
Path packRoot,
String structureKey,
String poolKey,
int entryIndex,
String expectedPieceKey,
int weight
) throws IOException {
if (weight < 1) {
throw new IllegalArgumentException("Jigsaw variant weight must be positive");
}
if (entryIndex < 0) {
throw new IllegalArgumentException("Jigsaw pool entry index cannot be negative");
}
PoolUpdate update = updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateWeightAtIndex(
content,
entryIndex,
expectedPieceKey,
weight,
poolPath));
return new WeightUpdate(
update.changed(),
update.changedEntries(),
update.poolPath(),
update.writeResult());
}
public static ChanceUpdate updateChanceAtIndex(
Path packRoot,
String structureKey,
String poolKey,
int entryIndex,
String expectedPieceKey,
double chance
) throws IOException {
if (!Double.isFinite(chance) || chance < 0D || chance > 1D) {
throw new IllegalArgumentException("Jigsaw variant chance must be finite and within 0 and 1");
}
if (entryIndex < 0) {
throw new IllegalArgumentException("Jigsaw pool entry index cannot be negative");
}
PoolUpdate update = updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateChanceAtIndex(
content,
entryIndex,
expectedPieceKey,
chance,
poolPath));
return new ChanceUpdate(
update.changed(),
update.changedEntries(),
update.poolPath(),
update.writeResult());
}
public static PoolUpdate addPiece(
Path packRoot,
String structureKey,
String poolKey,
String pieceKey,
int weight
) throws IOException {
if (weight < 1) {
throw new IllegalArgumentException("Jigsaw piece weight must be positive");
}
return updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateAdd(content, pieceKey, weight, poolPath));
}
public static PoolUpdate removePiece(
Path packRoot,
String structureKey,
String poolKey,
String pieceKey
) throws IOException {
return updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateRemove(content, pieceKey, poolPath));
}
public static PoolUpdate removeEntry(
Path packRoot,
String structureKey,
String poolKey,
int entryIndex,
String expectedPieceKey
) throws IOException {
if (entryIndex < 0) {
throw new IllegalArgumentException("Jigsaw pool entry index cannot be negative");
}
return updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateRemoveAtIndex(
content,
entryIndex,
expectedPieceKey,
poolPath));
}
public static PoolUpdate updateFallback(
Path packRoot,
String structureKey,
String poolKey,
String fallbackPoolKey
) throws IOException {
String fallback = fallbackPoolKey == null ? "" : fallbackPoolKey.trim();
return updateOwnedPool(
packRoot,
structureKey,
poolKey,
(content, poolPath) -> mutateFallback(content, fallback, poolPath));
}
private static PoolUpdate updateOwnedPool(
Path packRoot,
String structureKey,
String poolKey,
PoolContentEditor editor
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
StructureKey ownershipKey = StructureKey.parse(structureKey, "iris");
StructureTransactionWriter writer = new StructureTransactionWriter(root);
Path manifestPath = writer.ownershipManifestPath(ownershipKey);
if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("This graph is read-only because it is not Studio-owned; create a new Jigsaw Studio project before editing pools");
}
byte[] manifestContent = Files.readAllBytes(manifestPath);
String expectedManifestHash = StructureHash.sha256(manifestContent);
StructureOwnershipManifest manifest = JigsawStudioAuthoringAccess.requireEditable(
StructureOwnershipManifest.fromJson(manifestContent));
String normalizedPool = JigsawStudioProjectCreator.Options.requireResourceKey(poolKey);
String targetResource = "jigsaw-pools/" + normalizedPool + ".json";
Path targetPath = root.resolve(targetResource).normalize();
if (!manifest.resourceHashes().containsKey(targetResource)) {
return new PoolUpdate(false, 0, targetPath, null);
}
StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(manifest.structure())
.source(manifest.source())
.backend(manifest.backend())
.capabilities(manifest.capabilities())
.losses(manifest.losses());
int changedEntries = 0;
for (Map.Entry<String, String> resource : manifest.resourceHashes().entrySet()) {
Path resourcePath = resolveOwnedResource(root, resource.getKey());
byte[] content = Files.readAllBytes(resourcePath);
if (resource.getKey().equals(targetResource)) {
PoolMutation mutation = editor.edit(content, resourcePath);
changedEntries = mutation.changedEntries();
content = mutation.content();
}
bundle.resource(resource.getKey(), content);
}
if (changedEntries == 0) {
return new PoolUpdate(false, 0, targetPath, null);
}
StructureResourceBundle updatedBundle = bundle.build();
StructureResourceBundleGraphCompiler.requireViable(updatedBundle);
StructureWriteResult writeResult = writer.write(
updatedBundle,
StructureWriteOptions.overwriteExpected(expectedManifestHash));
if (!writeResult.successful()) {
String conflict = writeResult.conflicts().isEmpty()
? writeResult.status().name()
: writeResult.conflicts().getFirst().relativePath() + ": "
+ writeResult.conflicts().getFirst().reason();
throw new IOException("Atomic graph update was rejected: " + conflict);
}
return new PoolUpdate(true, changedEntries, targetPath, writeResult);
}
private static Path resolveOwnedResource(Path root, String relativePath) throws IOException {
StructureResourceBundle.validateRelativePath(relativePath);
Path resource = root.resolve(relativePath).normalize();
if (!resource.startsWith(root) || !Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Owned graph resource is missing or unsafe: " + relativePath);
}
return resource;
}
private static PoolMutation mutateWeight(
byte[] content,
String pieceKey,
int weight,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
JsonArray pieces = root.getAsJsonArray("pieces");
int changedEntries = 0;
for (JsonElement element : pieces) {
if (!isPiece(element, pieceKey)) {
continue;
}
element.getAsJsonObject().addProperty("weight", weight);
changedEntries++;
}
return mutation(root, changedEntries);
}
private static PoolMutation mutateWeightAtIndex(
byte[] content,
int entryIndex,
String expectedPieceKey,
int weight,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
JsonArray pieces = root.getAsJsonArray("pieces");
JsonObject entry = requirePieceEntry(pieces, entryIndex, expectedPieceKey, poolPath);
int currentWeight = entry.has("weight") ? entry.get("weight").getAsInt() : 1;
if (currentWeight == weight) {
return mutation(root, 0);
}
entry.addProperty("weight", weight);
return mutation(root, 1);
}
private static PoolMutation mutateChanceAtIndex(
byte[] content,
int entryIndex,
String expectedPieceKey,
double chance,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
JsonArray pieces = root.getAsJsonArray("pieces");
JsonObject entry = requirePieceEntry(pieces, entryIndex, expectedPieceKey, poolPath);
double currentChance = entry.has("chance") ? entry.get("chance").getAsDouble() : 1D;
if (Double.compare(currentChance, chance) == 0) {
return mutation(root, 0);
}
entry.addProperty("chance", chance);
return mutation(root, 1);
}
private static PoolMutation mutateAdd(
byte[] content,
String pieceKey,
int weight,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
JsonArray pieces = root.getAsJsonArray("pieces");
for (JsonElement element : pieces) {
if (isPiece(element, pieceKey)) {
return mutation(root, 0);
}
}
JsonObject entry = new JsonObject();
entry.addProperty("piece", pieceKey);
entry.addProperty("weight", weight);
pieces.add(entry);
return mutation(root, 1);
}
private static PoolMutation mutateRemove(
byte[] content,
String pieceKey,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
JsonArray pieces = root.getAsJsonArray("pieces");
int changedEntries = 0;
for (int index = pieces.size() - 1; index >= 0; index--) {
if (isPiece(pieces.get(index), pieceKey)) {
pieces.remove(index);
changedEntries++;
}
}
return mutation(root, changedEntries);
}
private static PoolMutation mutateRemoveAtIndex(
byte[] content,
int entryIndex,
String expectedPieceKey,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
JsonArray pieces = root.getAsJsonArray("pieces");
requirePieceEntry(pieces, entryIndex, expectedPieceKey, poolPath);
pieces.remove(entryIndex);
return mutation(root, 1);
}
private static PoolMutation mutateFallback(
byte[] content,
String fallbackPoolKey,
Path poolPath
) throws IOException {
JsonObject root = parsePool(content, poolPath);
String existing = root.has("fallback") && !root.get("fallback").isJsonNull()
? root.get("fallback").getAsString().trim()
: "";
if (existing.equals(fallbackPoolKey)) {
return mutation(root, 0);
}
root.addProperty("fallback", fallbackPoolKey);
return mutation(root, 1);
}
private static JsonObject parsePool(byte[] content, Path poolPath) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw pool is not a JSON object: " + poolPath);
}
JsonObject root = parsed.getAsJsonObject();
if (!root.has("pieces") || !root.get("pieces").isJsonArray()) {
throw new IOException("Jigsaw pool does not declare a pieces array: " + poolPath);
}
return root;
}
private static boolean isPiece(JsonElement element, String pieceKey) {
return element.isJsonObject()
&& element.getAsJsonObject().has("piece")
&& pieceKey.equals(element.getAsJsonObject().get("piece").getAsString());
}
private static JsonObject requirePieceEntry(
JsonArray pieces,
int entryIndex,
String expectedPieceKey,
Path poolPath
) throws IOException {
if (entryIndex >= pieces.size()) {
throw new IOException("Jigsaw pool entry " + entryIndex + " no longer exists in " + poolPath);
}
JsonElement element = pieces.get(entryIndex);
if (!isPiece(element, expectedPieceKey)) {
throw new IOException("Jigsaw pool entry " + entryIndex + " changed before the update in "
+ poolPath);
}
return element.getAsJsonObject();
}
private static PoolMutation mutation(JsonObject root, int changedEntries) {
return new PoolMutation(
(GSON.toJson(root) + "\n").getBytes(StandardCharsets.UTF_8),
changedEntries);
}
public record WeightUpdate(
boolean changed,
int changedEntries,
Path poolPath,
StructureWriteResult writeResult
) {
}
public record ChanceUpdate(
boolean changed,
int changedEntries,
Path poolPath,
StructureWriteResult writeResult
) {
}
public record PoolUpdate(
boolean changed,
int changedEntries,
Path poolPath,
StructureWriteResult writeResult
) {
}
@FunctionalInterface
private interface PoolContentEditor {
PoolMutation edit(byte[] content, Path poolPath) throws IOException;
}
private record PoolMutation(byte[] content, int changedEntries) {
}
}
@@ -0,0 +1,22 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Objects;
public record JigsawStudioPoolMembership(String poolKey, int entryIndex, int weight, double chance) {
public JigsawStudioPoolMembership {
Objects.requireNonNull(poolKey, "Jigsaw Studio pool key");
poolKey = poolKey.trim();
if (poolKey.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio pool key cannot be blank");
}
if (entryIndex < 0) {
throw new IllegalArgumentException("Jigsaw Studio pool entry index cannot be negative");
}
if (weight < 1) {
throw new IllegalArgumentException("Jigsaw Studio pool membership weight must be positive");
}
if (!Double.isFinite(chance) || chance < 0D || chance > 1D) {
throw new IllegalArgumentException("Jigsaw Studio pool membership chance must be within 0 and 1");
}
}
}
@@ -0,0 +1,227 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.structure.authoring.StructureBackend;
import art.arcane.iris.core.structure.authoring.StructureCapability;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.authoring.StructureWriteMode;
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy;
import art.arcane.iris.engine.object.IrisJigsawCompatibility;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawMode;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisJigsawPieceEntry;
import art.arcane.iris.engine.object.IrisJigsawPieceRules;
import art.arcane.iris.engine.object.IrisJigsawPool;
import art.arcane.iris.engine.object.IrisJigsawThemeSet;
import art.arcane.iris.engine.object.IrisJigsawWorkcell;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.object.JigsawJoint;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Objects;
public final class JigsawStudioProjectCreator {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private JigsawStudioProjectCreator() {
}
public static StructureWriteResult create(Path packRoot, Options options) throws IOException {
Options activeOptions = Objects.requireNonNull(options, "Jigsaw Studio project options");
StructureResourceBundle bundle = bundle(activeOptions);
StructureResourceBundleGraphCompiler.requireViable(bundle);
return new StructureTransactionWriter(packRoot).write(bundle, StructureWriteMode.ADD_ONLY);
}
static StructureResourceBundle bundle(Options options) throws IOException {
String resourceKey = options.structureKey();
StructureKey ownershipKey = new StructureKey("iris", resourceKey);
IrisStructure structure = new IrisStructure()
.setStartPool(resourceKey + "/start")
.setMaxDepth(7)
.setMaxSizeChunks(8)
.setMode(toModelMode(options.mode()))
.setCompatibility(toModelCompatibility(options.compatibilityTarget()))
.setBranchFailurePolicy(toBranchFailurePolicy(options.compatibilityTarget()))
.setCellSize(new IrisPosition(
options.cellDimensions().width(),
options.cellDimensions().height(),
options.cellDimensions().depth()));
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
structure.getThemeSets().add(new IrisJigsawThemeSet("variant-1", 1));
}
StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(ownershipKey)
.source(StructureSource.of(StructureSource.Kind.IRIS, ownershipKey))
.backend(StructureBackend.IRIS_ASSEMBLY)
.capability(StructureCapability.BLOCKS)
.capability(StructureCapability.CONNECTORS)
.capability(StructureCapability.IRIS_PLACEMENT);
IrisJigsawPool pool = new IrisJigsawPool();
if (options.mode() == JigsawStudioMode.PLANAR_JIGSAW) {
addPlanarDefaults(bundle, structure, pool, options);
} else {
addSpatialDefault(bundle, pool, options);
}
bundle.textResource("jigsaw-pools/" + resourceKey + "/start.json", GSON.toJson(pool) + "\n");
bundle.textResource("structures/" + resourceKey + ".json", GSON.toJson(structure) + "\n");
return bundle.build();
}
static byte[] serialize(IrisObject object) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
object.write(output);
return output.toByteArray();
}
private static IrisJigsawMode toModelMode(JigsawStudioMode mode) {
return mode == JigsawStudioMode.PLANAR_JIGSAW
? IrisJigsawMode.PLANAR_JIGSAW
: IrisJigsawMode.SPATIAL_JIGSAW;
}
private static IrisJigsawCompatibility toModelCompatibility(
JigsawStudioCompatibilityTarget compatibilityTarget
) {
return compatibilityTarget == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE
? IrisJigsawCompatibility.VANILLA_PORTABLE
: IrisJigsawCompatibility.IRIS_EXTENDED;
}
private static IrisJigsawBranchFailurePolicy toBranchFailurePolicy(
JigsawStudioCompatibilityTarget compatibilityTarget
) {
return compatibilityTarget == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE
? IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH
: IrisJigsawBranchFailurePolicy.FAIL_ASSEMBLY;
}
private static void addPlanarDefaults(
StructureResourceBundle.Builder bundle,
IrisStructure structure,
IrisJigsawPool pool,
Options options
) throws IOException {
JigsawStudioCellDimensions dimensions = options.cellDimensions();
IrisPosition size = new IrisPosition(dimensions.width(), dimensions.height(), dimensions.depth());
String piecePoolKey = options.structureKey() + "/pieces";
String capPoolKey = options.structureKey() + "/caps";
IrisJigsawPool piecePool = new IrisJigsawPool().setFallback(capPoolKey);
IrisJigsawPool capPool = new IrisJigsawPool();
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
String key = options.structureKey() + "/" + archetype.name().toLowerCase(Locale.ROOT);
IrisJigsawPiece piece = planarPiece(key, piecePoolKey, size, archetype);
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
piece.getThemes().add("variant-1");
}
if (archetype == JigsawPlanarArchetype.CROSS) {
pool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
}
if (archetype != JigsawPlanarArchetype.BLANK) {
piecePool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
}
if (archetype == JigsawPlanarArchetype.END) {
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
piece.setRules(new IrisJigsawPieceRules().setTerminal(true));
}
capPool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
}
structure.getPlanarWorkcells().add(new IrisJigsawWorkcell(
"",
archetype.modelArchetype(),
dimensions.width(),
dimensions.height(),
dimensions.depth(),
true));
bundle.resource("objects/" + key + ".iob", serialize(new IrisObject(
dimensions.width(),
dimensions.height(),
dimensions.depth())));
bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n");
}
capPool.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true));
bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n");
bundle.textResource("jigsaw-pools/" + capPoolKey + ".json", GSON.toJson(capPool) + "\n");
}
private static void addSpatialDefault(
StructureResourceBundle.Builder bundle,
IrisJigsawPool pool,
Options options
) throws IOException {
String key = options.structureKey() + "/start";
JigsawStudioCellDimensions dimensions = options.cellDimensions();
IrisJigsawPiece piece = new IrisJigsawPiece().setObject(key).setRotatable(true);
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
piece.getThemes().add("variant-1");
}
pool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
bundle.resource("objects/" + key + ".iob", serialize(new IrisObject(
dimensions.width(),
dimensions.height(),
dimensions.depth())));
bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n");
}
private static IrisJigsawPiece planarPiece(
String objectKey,
String poolKey,
IrisPosition dimensions,
JigsawPlanarArchetype archetype
) {
IrisJigsawPiece piece = new IrisJigsawPiece().setObject(objectKey).setRotatable(true);
for (JigsawPlanarDirection planarDirection : archetype.canonicalTopology().directions()) {
IrisDirection direction = planarDirection.irisDirection();
piece.getConnectors().add(new IrisJigsawConnector()
.setPosition(IrisJigsawConnector.canonicalPlanarPosition(dimensions, direction))
.setDirection(direction)
.setTop(IrisDirection.UP_POSITIVE_Y)
.setPool(poolKey)
.setName("iris:planar")
.setTargetName("iris:planar")
.setJoint(JigsawJoint.ALIGNED)
.setFinalState("minecraft:structure_void"));
}
return piece;
}
public record Options(
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioCellDimensions cellDimensions
) {
public Options {
structureKey = requireResourceKey(structureKey);
mode = Objects.requireNonNull(mode, "Jigsaw Studio mode");
compatibilityTarget = Objects.requireNonNull(
compatibilityTarget,
"Jigsaw Studio compatibility target");
cellDimensions = Objects.requireNonNull(cellDimensions, "Jigsaw Studio cell dimensions");
if (mode == JigsawStudioMode.PLANAR_JIGSAW
&& (cellDimensions.width() < 3 || cellDimensions.depth() < 3)) {
throw new IllegalArgumentException(
"Planar Jigsaw Studio cells require width and depth of at least 3 blocks");
}
}
static String requireResourceKey(String value) {
String key = JigsawStudioMarkerKeyCodec.requireInternalPath(value, "resource");
StructureResourceBundle.validateRelativePath("structures/" + key + ".json");
new StructureKey("iris", key);
return key;
}
}
}
@@ -0,0 +1,435 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.structure.authoring.StructureHash;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.core.structure.authoring.StructureSource;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
import java.util.stream.Stream;
public final class JigsawStudioProjectDeletionService {
private static final long MAX_JSON_BYTES = 16L * 1024L * 1024L;
private static final int MAX_SCANNED_FILES = 100_000;
private JigsawStudioProjectDeletionService() {
}
public static DeletionPlan inspect(Path packRoot, String structureKey) throws IOException {
Path root = canonicalRoot(packRoot);
StructureKey key = StructureKey.parse(structureKey, "iris");
ManifestSnapshot snapshot = loadSnapshot(root, key);
List<ReverseReference> blockers = scanReverseReferences(root, snapshot);
return new DeletionPlan(
UUID.randomUUID(),
root,
key,
snapshot.manifest().source(),
snapshot.manifestHash(),
snapshot.sourceHash(),
snapshot.manifest().resourceHashes(),
blockers);
}
public static ProjectDeletionResult delete(DeletionPlan plan) throws IOException {
DeletionPlan expected = Objects.requireNonNull(plan, "Jigsaw Studio project deletion plan");
if (!expected.deletable()) {
throw new IOException("Jigsaw Studio project deletion is blocked by "
+ expected.blockers().size() + " reverse references");
}
Path root = canonicalRoot(expected.packRoot());
StructureTransactionWriter writer = new StructureTransactionWriter(root);
StructureTransactionWriter.OwnedRemoval request = new StructureTransactionWriter.OwnedRemoval(
expected.structureKey(),
expected.expectedSource().kind(),
expected.expectedSource().key(),
Optional.empty(),
Optional.of(expected.expectedManifestHash()));
try (StructureTransactionWriter.PreparedRemoval removal = writer.prepareOwnedRemovals(
List.of(request),
() -> validateDeletion(root, expected))) {
if (!removal.changed()) {
throw new IOException("Jigsaw Studio project no longer exists");
}
removal.markCommitted();
removal.finishCommit();
}
return new ProjectDeletionResult(
expected.planId(),
expected.structureKey(),
expected.expectedResourceHashes().size(),
true);
}
private static void validateDeletion(Path root, DeletionPlan expected) throws IOException {
ManifestSnapshot current = loadSnapshot(root, expected.structureKey());
if (!expected.expectedManifestHash().equals(current.manifestHash())) {
throw new IOException("Jigsaw Studio project changed after deletion was inspected");
}
if (!expected.expectedSource().equals(current.manifest().source())
|| !expected.expectedSourceHash().equals(current.sourceHash())) {
throw new IOException("Jigsaw Studio project source changed after deletion was inspected");
}
if (!expected.expectedResourceHashes().equals(current.manifest().resourceHashes())) {
throw new IOException("Jigsaw Studio project resources changed after deletion was inspected");
}
List<ReverseReference> currentBlockers = scanReverseReferences(root, current);
if (!currentBlockers.isEmpty()) {
throw new IOException("Jigsaw Studio project gained " + currentBlockers.size()
+ " reverse references after deletion was inspected");
}
}
private static Path canonicalRoot(Path packRoot) throws IOException {
Path normalized = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
Path resolved;
try {
resolved = normalized.toRealPath();
} catch (IOException exception) {
throw new IOException("Cannot resolve Jigsaw Studio pack root " + normalized, exception);
}
if (!Files.isDirectory(resolved, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Jigsaw Studio pack root is not a directory: " + resolved);
}
return resolved;
}
private static ManifestSnapshot loadSnapshot(Path root, StructureKey key) throws IOException {
StructureTransactionWriter writer = new StructureTransactionWriter(root);
Path manifestPath = writer.ownershipManifestPath(key);
if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("This graph is read-only because it is not Studio-owned; only Studio-owned projects can be deleted");
}
byte[] manifestContent = readBoundedJson(manifestPath, "Jigsaw Studio ownership manifest");
StructureOwnershipManifest manifest;
try {
manifest = JigsawStudioAuthoringAccess.requireEditable(
StructureOwnershipManifest.fromJson(manifestContent));
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid Jigsaw Studio ownership manifest at " + manifestPath, exception);
}
if (!manifest.structure().equals(key)) {
throw new IOException("Jigsaw Studio ownership manifest belongs to " + manifest.structure());
}
String structureResource = "structures/" + manifest.structure().path() + ".json";
String structureHash = manifest.resourceHashes().get(structureResource);
if (structureHash == null) {
throw new IOException("Jigsaw Studio ownership manifest does not include " + structureResource);
}
for (Map.Entry<String, String> resource : manifest.resourceHashes().entrySet()) {
Path resourcePath = resolveOwnedResource(root, resource.getKey());
String actualHash;
try (InputStream input = Files.newInputStream(resourcePath)) {
actualHash = StructureHash.sha256(input);
}
if (!resource.getValue().equals(actualHash)) {
throw new IOException("Owned graph resource changed outside Studio: " + resource.getKey());
}
}
String sourceHash = manifest.source().contentHash().isEmpty()
? structureHash
: manifest.source().contentHash();
return new ManifestSnapshot(
manifest,
StructureHash.sha256(manifestContent),
sourceHash,
manifestPath);
}
private static Path resolveOwnedResource(Path root, String relativePath) throws IOException {
StructureResourceBundle.validateRelativePath(relativePath);
Path resource = root.resolve(relativePath).normalize();
if (!resource.startsWith(root)
|| !Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Owned graph resource is missing or unsafe: " + relativePath);
}
return resource;
}
private static List<ReverseReference> scanReverseReferences(
Path root,
ManifestSnapshot snapshot
) throws IOException {
Map<String, Set<String>> targetsByKey = referenceTargets(snapshot.manifest());
Set<String> ownedPaths = snapshot.manifest().resourceHashes().keySet();
Set<ReverseReference> references = new LinkedHashSet<>();
int scannedFiles = 0;
try (Stream<Path> paths = Files.walk(root)) {
Iterator<Path> iterator = paths.iterator();
while (iterator.hasNext()) {
Path path = iterator.next();
Path relative = root.relativize(path);
String relativePath = relative.toString().replace(path.getFileSystem().getSeparator(), "/");
if (relativePath.isEmpty() || relativePath.startsWith(".iris/")) {
continue;
}
if (Files.isSymbolicLink(path)) {
if (relativePath.endsWith(".json") || Files.isDirectory(path)) {
throw new IOException("Cannot prove deletion safety through symbolic pack path "
+ relativePath);
}
continue;
}
if (!relativePath.endsWith(".json") || ownedPaths.contains(relativePath)) {
continue;
}
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
scannedFiles++;
if (scannedFiles > MAX_SCANNED_FILES) {
throw new IOException("Jigsaw Studio project deletion scan exceeds "
+ MAX_SCANNED_FILES + " JSON resources");
}
JsonElement json = parseBoundedJson(path, relativePath);
collectReferences(json, "$", relativePath, targetsByKey, references);
}
}
scanOwnershipManifests(root, snapshot, references);
List<ReverseReference> ordered = new ArrayList<>(references);
ordered.sort(Comparator.comparing(ReverseReference::ownerPath)
.thenComparing(ReverseReference::location)
.thenComparing(ReverseReference::targetResourcePath));
return List.copyOf(ordered);
}
private static Map<String, Set<String>> referenceTargets(StructureOwnershipManifest manifest) {
Map<String, Set<String>> targets = new LinkedHashMap<>();
addReferenceTarget(targets, manifest.structure().path(),
"structures/" + manifest.structure().path() + ".json");
addReferenceTarget(targets, manifest.structure().value(),
"structures/" + manifest.structure().path() + ".json");
for (String relativePath : manifest.resourceHashes().keySet()) {
addResourceReferenceTarget(targets, relativePath, "jigsaw-pools/", ".json");
addResourceReferenceTarget(targets, relativePath, "jigsaw-pieces/", ".json");
addResourceReferenceTarget(targets, relativePath, "objects/", ".iob");
}
return targets;
}
private static void addResourceReferenceTarget(
Map<String, Set<String>> targets,
String relativePath,
String prefix,
String suffix
) {
if (!relativePath.startsWith(prefix) || !relativePath.endsWith(suffix)) {
return;
}
String key = relativePath.substring(prefix.length(), relativePath.length() - suffix.length());
addReferenceTarget(targets, key, relativePath);
addReferenceTarget(targets, "iris:" + key, relativePath);
}
private static void addReferenceTarget(
Map<String, Set<String>> targets,
String key,
String relativePath
) {
targets.computeIfAbsent(key, ignored -> new LinkedHashSet<>()).add(relativePath);
}
private static void collectReferences(
JsonElement element,
String location,
String ownerPath,
Map<String, Set<String>> targetsByKey,
Set<ReverseReference> references
) {
if (element == null || element.isJsonNull()) {
return;
}
if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) {
Set<String> targets = targetsByKey.get(element.getAsString());
if (targets != null) {
for (String target : targets) {
references.add(new ReverseReference(ownerPath, location, target));
}
}
return;
}
if (element.isJsonArray()) {
JsonArray array = element.getAsJsonArray();
for (int index = 0; index < array.size(); index++) {
collectReferences(
array.get(index),
location + "[" + index + "]",
ownerPath,
targetsByKey,
references);
}
return;
}
if (element.isJsonObject()) {
JsonObject object = element.getAsJsonObject();
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
collectReferences(
entry.getValue(),
location + "." + entry.getKey(),
ownerPath,
targetsByKey,
references);
}
}
}
private static void scanOwnershipManifests(
Path root,
ManifestSnapshot target,
Set<ReverseReference> references
) throws IOException {
Path manifestRoot = root.resolve(".iris/structure-manifests");
if (!Files.isDirectory(manifestRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try (Stream<Path> paths = Files.list(manifestRoot)) {
Iterator<Path> iterator = paths.iterator();
while (iterator.hasNext()) {
Path path = iterator.next();
if (Files.isSameFile(path, target.manifestPath())
|| !path.getFileName().toString().endsWith(".json")) {
continue;
}
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Cannot prove deletion safety through ownership manifest " + path);
}
StructureOwnershipManifest manifest;
try {
manifest = StructureOwnershipManifest.fromJson(
readBoundedJson(path, "Structure ownership manifest"));
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid structure ownership manifest at " + path, exception);
}
for (String relativePath : target.manifest().resourceHashes().keySet()) {
if (manifest.resourceHashes().containsKey(relativePath)) {
references.add(new ReverseReference(
root.relativize(path).toString().replace(path.getFileSystem().getSeparator(), "/"),
"$.resourceHashes",
relativePath));
}
}
}
}
}
private static JsonElement parseBoundedJson(Path path, String relativePath) throws IOException {
byte[] content = readBoundedJson(path, "JSON resource");
try {
return JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
} catch (RuntimeException exception) {
throw new IOException("Cannot prove deletion safety through invalid JSON resource "
+ relativePath, exception);
}
}
private static byte[] readBoundedJson(Path path, String kind) throws IOException {
long size = Files.size(path);
if (size > MAX_JSON_BYTES) {
throw new IOException(kind + " exceeds " + MAX_JSON_BYTES + " bytes: " + path);
}
return Files.readAllBytes(path);
}
public record DeletionPlan(
UUID planId,
Path packRoot,
StructureKey structureKey,
StructureSource expectedSource,
String expectedManifestHash,
String expectedSourceHash,
Map<String, String> expectedResourceHashes,
List<ReverseReference> blockers
) {
public DeletionPlan {
Objects.requireNonNull(planId, "Jigsaw Studio project deletion plan ID");
packRoot = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
Objects.requireNonNull(structureKey, "Jigsaw Studio project deletion structure key");
Objects.requireNonNull(expectedSource, "Jigsaw Studio project deletion source");
if (!StructureHash.isSha256(expectedManifestHash)) {
throw new IllegalArgumentException("Expected Jigsaw Studio ownership manifest hash must be SHA-256");
}
if (!StructureHash.isSha256(expectedSourceHash)) {
throw new IllegalArgumentException("Expected Jigsaw Studio source hash must be SHA-256");
}
Objects.requireNonNull(expectedResourceHashes, "Jigsaw Studio project deletion resources");
expectedResourceHashes = Collections.unmodifiableMap(new TreeMap<>(expectedResourceHashes));
blockers = List.copyOf(Objects.requireNonNull(
blockers,
"Jigsaw Studio project deletion blockers"));
}
public boolean deletable() {
return blockers.isEmpty();
}
}
public record ReverseReference(
String ownerPath,
String location,
String targetResourcePath
) {
public ReverseReference {
ownerPath = requireNonBlank(ownerPath, "reverse-reference owner path");
location = requireNonBlank(location, "reverse-reference location");
targetResourcePath = StructureResourceBundle.validateRelativePath(targetResourcePath);
}
}
public record ProjectDeletionResult(
UUID planId,
StructureKey structureKey,
int removedResourceCount,
boolean manifestRemoved
) {
public ProjectDeletionResult {
Objects.requireNonNull(planId, "Jigsaw Studio project deletion plan ID");
Objects.requireNonNull(structureKey, "Deleted Jigsaw Studio structure key");
if (removedResourceCount < 0) {
throw new IllegalArgumentException("Removed Jigsaw Studio resource count cannot be negative");
}
}
}
private static String requireNonBlank(String value, String name) {
Objects.requireNonNull(value, name);
String normalized = value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException(name + " cannot be blank");
}
return normalized;
}
private record ManifestSnapshot(
StructureOwnershipManifest manifest,
String manifestHash,
String sourceHash,
Path manifestPath
) {
}
}
@@ -0,0 +1,666 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
public final class JigsawStudioSession {
private final UUID sessionId;
private final String packKey;
private final String structureKey;
private final Map<String, MutableWorkcellState> workcells = new LinkedHashMap<>();
private JigsawStudioLayout layout;
private String selectedBayId;
private long nextLoadGeneration;
private long nextMutationGeneration;
private long nextOperationGeneration;
private long revision;
public JigsawStudioSession(String packKey, String structureKey, JigsawStudioLayout layout) {
this(UUID.randomUUID(), packKey, structureKey, layout);
}
public JigsawStudioSession(
UUID sessionId,
String packKey,
String structureKey,
JigsawStudioLayout layout
) {
this.sessionId = Objects.requireNonNull(sessionId, "Jigsaw Studio session ID");
this.packKey = requireKey(packKey, "pack");
this.structureKey = requireKey(structureKey, "structure");
this.layout = Objects.requireNonNull(layout, "Jigsaw Studio session layout");
initializeWorkcells(layout);
}
public synchronized UUID sessionId() {
return sessionId;
}
public synchronized String packKey() {
return packKey;
}
public synchronized String structureKey() {
return structureKey;
}
public synchronized JigsawStudioLayout layout() {
return layout;
}
public synchronized Optional<String> selectedBayId() {
return Optional.ofNullable(selectedBayId);
}
public synchronized boolean selectBay(String stableId) {
Objects.requireNonNull(stableId, "Jigsaw Studio selected workcell ID");
if (layout.get(stableId) == null) {
return false;
}
selectedBayId = stableId;
return true;
}
public synchronized void clearSelection() {
selectedBayId = null;
}
public synchronized Optional<JigsawStudioVariant> activeVariant(String workcellId) {
MutableWorkcellState state = workcells.get(workcellId);
if (state == null || state.activeVariantKey.isEmpty()) {
return Optional.empty();
}
return layout.variantCatalog().find(state.activeVariantKey);
}
public synchronized WorkcellSnapshot workcellSnapshot(String workcellId) {
MutableWorkcellState state = requireWorkcellState(workcellId);
return state.snapshot(workcellId);
}
public synchronized boolean replaceLayout(JigsawStudioLayout replacement) {
JigsawStudioLayout nextLayout = Objects.requireNonNull(replacement, "Replacement Jigsaw Studio layout");
if (layout.mode() != nextLayout.mode()) {
throw new IllegalArgumentException("Replacement Jigsaw Studio layout mode does not match the session");
}
if (operationInProgress()) {
throw new IllegalStateException("Jigsaw Studio layout cannot change during a save or variant switch");
}
if (layout == nextLayout) {
return false;
}
Map<String, MutableWorkcellState> updated = new LinkedHashMap<>();
for (JigsawStudioBay workcell : nextLayout.bays()) {
MutableWorkcellState previous = workcells.get(workcell.stableId());
String activeVariantKey = retainedVariantKey(nextLayout, workcell, previous);
if (previous != null && previous.activeVariantKey.equals(activeVariantKey)) {
updated.put(workcell.stableId(), previous.copy());
continue;
}
updated.put(workcell.stableId(), new MutableWorkcellState(
activeVariantKey,
nextLoadGeneration(),
nextMutationGeneration(),
false));
}
layout = nextLayout;
workcells.clear();
workcells.putAll(updated);
if (selectedBayId != null && layout.get(selectedBayId) == null) {
selectedBayId = null;
}
revision++;
return true;
}
public synchronized boolean replaceLayoutAndRebind(
JigsawStudioLayout replacement,
Map<String, String> activeVariantsByWorkcell
) {
JigsawStudioLayout nextLayout = Objects.requireNonNull(replacement, "Replacement Jigsaw Studio layout");
Map<String, String> bindings = Map.copyOf(Objects.requireNonNull(
activeVariantsByWorkcell,
"Replacement Jigsaw Studio active variants"));
if (layout.mode() != nextLayout.mode()) {
throw new IllegalArgumentException("Replacement Jigsaw Studio layout mode does not match the session");
}
if (operationInProgress()) {
throw new IllegalStateException("Jigsaw Studio layout cannot change during a save or variant switch");
}
for (Map.Entry<String, String> binding : bindings.entrySet()) {
JigsawStudioBay workcell = nextLayout.get(binding.getKey());
JigsawStudioVariant variant = nextLayout.variantCatalog().find(binding.getValue())
.orElseThrow(() -> new IllegalArgumentException(
"Unknown replacement Jigsaw Studio variant " + binding.getValue()));
if (workcell == null || !nextLayout.accepts(workcell, variant)) {
throw new IllegalArgumentException("Replacement Jigsaw Studio variant " + binding.getValue()
+ " does not belong to workcell " + binding.getKey());
}
}
Map<String, MutableWorkcellState> updated = new LinkedHashMap<>();
boolean changed = layout != nextLayout;
for (JigsawStudioBay workcell : nextLayout.bays()) {
MutableWorkcellState previous = workcells.get(workcell.stableId());
String targetVariantKey = bindings.get(workcell.stableId());
if (targetVariantKey == null) {
targetVariantKey = retainedVariantKey(nextLayout, workcell, previous);
}
if (previous != null && previous.activeVariantKey.equals(targetVariantKey)) {
updated.put(workcell.stableId(), previous.copy());
continue;
}
changed = true;
updated.put(workcell.stableId(), new MutableWorkcellState(
targetVariantKey,
nextLoadGeneration(),
nextMutationGeneration(),
false));
}
layout = nextLayout;
workcells.clear();
workcells.putAll(updated);
if (selectedBayId != null && layout.get(selectedBayId) == null) {
selectedBayId = null;
}
if (changed) {
revision++;
}
return changed;
}
public synchronized SwitchStart beginVariantSwitch(
String workcellId,
String targetPieceKey,
boolean discardDirty
) {
return beginVariantTransition(workcellId, targetPieceKey, discardDirty, false);
}
public synchronized SwitchStart beginVariantReload(String workcellId) {
MutableWorkcellState state = workcells.get(workcellId);
if (state == null) {
return SwitchStart.failure(SwitchStatus.UNKNOWN_WORKCELL);
}
if (state.activeVariantKey.isEmpty()) {
return SwitchStart.failure(SwitchStatus.UNKNOWN_VARIANT);
}
return beginVariantTransition(workcellId, state.activeVariantKey, false, true);
}
private SwitchStart beginVariantTransition(
String workcellId,
String targetPieceKey,
boolean discardDirty,
boolean allowActive
) {
MutableWorkcellState state = workcells.get(workcellId);
if (state == null) {
return SwitchStart.failure(SwitchStatus.UNKNOWN_WORKCELL);
}
Optional<JigsawStudioVariant> target = layout.variantCatalog().find(targetPieceKey);
if (target.isEmpty()) {
return SwitchStart.failure(SwitchStatus.UNKNOWN_VARIANT);
}
JigsawStudioBay workcell = layout.get(workcellId);
if (!layout.accepts(workcell, target.get())) {
return SwitchStart.failure(SwitchStatus.WRONG_WORKCELL);
}
if (state.switchInProgress) {
return SwitchStart.failure(SwitchStatus.SWITCH_IN_PROGRESS);
}
if (state.saveInProgress) {
return SwitchStart.failure(SwitchStatus.SAVE_IN_PROGRESS);
}
if (state.activeVariantKey.equals(targetPieceKey) && !allowActive) {
return SwitchStart.failure(SwitchStatus.ALREADY_ACTIVE);
}
if (state.dirty && !discardDirty) {
return SwitchStart.failure(SwitchStatus.DIRTY);
}
JigsawStudioVariant previous = state.activeVariantKey.isEmpty()
? null
: layout.variantCatalog().find(state.activeVariantKey).orElse(null);
long switchGeneration = nextOperationGeneration();
state.switchInProgress = true;
state.switchGeneration = switchGeneration;
VariantSwitchToken token = new VariantSwitchToken(
sessionId,
workcellId,
Optional.ofNullable(previous),
target.get(),
state.loadGeneration,
state.mutationGeneration,
switchGeneration,
discardDirty);
revision++;
return SwitchStart.started(token);
}
public synchronized boolean completeVariantSwitch(VariantSwitchToken expected) {
VariantSwitchToken token = Objects.requireNonNull(expected, "Jigsaw Studio variant switch token");
MutableWorkcellState state = workcells.get(token.workcellId());
if (!validSwitchToken(state, token)) {
return false;
}
state.activeVariantKey = token.targetVariant().pieceKey();
state.loadGeneration = nextLoadGeneration();
state.mutationGeneration = nextMutationGeneration();
state.dirty = false;
state.switchInProgress = false;
state.switchGeneration = 0L;
revision++;
return true;
}
public synchronized boolean isVariantSwitchCurrent(VariantSwitchToken expected) {
VariantSwitchToken token = Objects.requireNonNull(expected, "Jigsaw Studio variant switch token");
return validSwitchToken(workcells.get(token.workcellId()), token);
}
public synchronized boolean abortVariantSwitch(VariantSwitchToken expected) {
VariantSwitchToken token = Objects.requireNonNull(expected, "Jigsaw Studio variant switch token");
MutableWorkcellState state = workcells.get(token.workcellId());
if (!validSwitchToken(state, token)) {
return false;
}
state.switchInProgress = false;
state.switchGeneration = 0L;
revision++;
return true;
}
public synchronized DirtyMark markWorkcellDirty(String workcellId) {
MutableWorkcellState state = workcells.get(workcellId);
if (state == null) {
return DirtyMark.failure(DirtyStatus.UNKNOWN_WORKCELL);
}
if (state.activeVariantKey.isEmpty()) {
return DirtyMark.failure(DirtyStatus.NO_ACTIVE_VARIANT);
}
if (state.switchInProgress) {
return DirtyMark.failure(DirtyStatus.SWITCH_IN_PROGRESS);
}
state.mutationGeneration = nextMutationGeneration();
boolean newlyDirty = !state.dirty;
state.dirty = true;
revision++;
return DirtyMark.marked(new DirtyIdentity(
sessionId,
workcellId,
state.activeVariantKey,
state.loadGeneration,
state.mutationGeneration), newlyDirty);
}
public synchronized boolean isDirtyCurrent(DirtyIdentity expected) {
DirtyIdentity identity = Objects.requireNonNull(expected, "Jigsaw Studio dirty identity");
MutableWorkcellState state = workcells.get(identity.workcellId());
return sessionId.equals(identity.sessionId())
&& state != null
&& state.dirty
&& state.activeVariantKey.equals(identity.variantKey())
&& state.loadGeneration == identity.loadGeneration()
&& state.mutationGeneration == identity.mutationGeneration();
}
public synchronized SaveStart beginSave(String workcellId) {
MutableWorkcellState state = workcells.get(workcellId);
if (state == null) {
return SaveStart.failure(SaveStatus.UNKNOWN_WORKCELL);
}
if (state.activeVariantKey.isEmpty()) {
return SaveStart.failure(SaveStatus.NO_ACTIVE_VARIANT);
}
if (state.switchInProgress) {
return SaveStart.failure(SaveStatus.SWITCH_IN_PROGRESS);
}
if (state.saveInProgress) {
return SaveStart.failure(SaveStatus.SAVE_IN_PROGRESS);
}
long saveGeneration = nextOperationGeneration();
state.saveInProgress = true;
state.saveGeneration = saveGeneration;
SaveIdentity identity = new SaveIdentity(
sessionId,
workcellId,
state.activeVariantKey,
state.loadGeneration,
state.mutationGeneration,
saveGeneration);
revision++;
return SaveStart.started(identity);
}
public synchronized boolean markWorkcellSaved(SaveIdentity expected) {
SaveIdentity identity = Objects.requireNonNull(expected, "Jigsaw Studio save identity");
MutableWorkcellState state = workcells.get(identity.workcellId());
if (!validSaveReservation(state, identity)) {
return false;
}
boolean unchanged = state.activeVariantKey.equals(identity.variantKey())
&& state.loadGeneration == identity.loadGeneration()
&& state.mutationGeneration == identity.mutationGeneration();
state.saveInProgress = false;
state.saveGeneration = 0L;
if (unchanged) {
state.dirty = false;
}
revision++;
return unchanged;
}
public synchronized boolean isSaveCurrent(SaveIdentity expected) {
SaveIdentity identity = Objects.requireNonNull(expected, "Jigsaw Studio save identity");
MutableWorkcellState state = workcells.get(identity.workcellId());
return validSaveReservation(state, identity)
&& state.activeVariantKey.equals(identity.variantKey())
&& state.loadGeneration == identity.loadGeneration()
&& state.mutationGeneration == identity.mutationGeneration();
}
public synchronized boolean abortSave(SaveIdentity expected) {
SaveIdentity identity = Objects.requireNonNull(expected, "Jigsaw Studio save identity");
MutableWorkcellState state = workcells.get(identity.workcellId());
if (!validSaveReservation(state, identity)) {
return false;
}
state.saveInProgress = false;
state.saveGeneration = 0L;
revision++;
return true;
}
public synchronized boolean isDirty() {
for (MutableWorkcellState state : workcells.values()) {
if (state.dirty) {
return true;
}
}
return false;
}
public synchronized List<String> dirtyWorkcellIds() {
List<String> dirty = new ArrayList<>();
for (Map.Entry<String, MutableWorkcellState> entry : workcells.entrySet()) {
if (entry.getValue().dirty) {
dirty.add(entry.getKey());
}
}
return List.copyOf(dirty);
}
public synchronized boolean operationInProgress() {
for (MutableWorkcellState state : workcells.values()) {
if (state.saveInProgress || state.switchInProgress) {
return true;
}
}
return false;
}
public synchronized long revision() {
return revision;
}
private void initializeWorkcells(JigsawStudioLayout initialLayout) {
for (JigsawStudioBay workcell : initialLayout.bays()) {
String variantKey = initialLayout.defaultVariant(workcell)
.map(JigsawStudioVariant::pieceKey)
.orElse("");
workcells.put(workcell.stableId(), new MutableWorkcellState(
variantKey,
nextLoadGeneration(),
nextMutationGeneration(),
false));
}
}
private String retainedVariantKey(
JigsawStudioLayout nextLayout,
JigsawStudioBay workcell,
MutableWorkcellState previous
) {
if (previous != null && previous.activeVariantKey.isEmpty()) {
return "";
}
if (previous != null) {
Optional<JigsawStudioVariant> retained = nextLayout.variantCatalog().find(previous.activeVariantKey);
if (retained.isPresent() && nextLayout.accepts(workcell, retained.get())) {
return previous.activeVariantKey;
}
}
return nextLayout.defaultVariant(workcell).map(JigsawStudioVariant::pieceKey).orElse("");
}
private boolean validSwitchToken(MutableWorkcellState state, VariantSwitchToken token) {
if (!sessionId.equals(token.sessionId()) || state == null || !state.switchInProgress) {
return false;
}
String previousKey = token.previousVariant().map(JigsawStudioVariant::pieceKey).orElse("");
return state.switchGeneration == token.switchGeneration()
&& state.activeVariantKey.equals(previousKey)
&& state.loadGeneration == token.loadGeneration()
&& state.mutationGeneration == token.mutationGeneration();
}
private boolean validSaveReservation(MutableWorkcellState state, SaveIdentity identity) {
return sessionId.equals(identity.sessionId())
&& state != null
&& state.saveInProgress
&& state.saveGeneration == identity.saveGeneration();
}
private MutableWorkcellState requireWorkcellState(String workcellId) {
MutableWorkcellState state = workcells.get(Objects.requireNonNull(workcellId, "Jigsaw Studio workcell ID"));
if (state == null) {
throw new IllegalArgumentException("Unknown Jigsaw Studio workcell " + workcellId);
}
return state;
}
private long nextLoadGeneration() {
return nextLoadGeneration = Math.incrementExact(nextLoadGeneration);
}
private long nextMutationGeneration() {
return nextMutationGeneration = Math.incrementExact(nextMutationGeneration);
}
private long nextOperationGeneration() {
return nextOperationGeneration = Math.incrementExact(nextOperationGeneration);
}
private static String requireKey(String value, String name) {
Objects.requireNonNull(value, "Jigsaw Studio " + name + " key");
String normalized = value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio " + name + " key cannot be blank");
}
return normalized;
}
public enum SwitchStatus {
STARTED,
UNKNOWN_WORKCELL,
UNKNOWN_VARIANT,
WRONG_WORKCELL,
ALREADY_ACTIVE,
DIRTY,
SAVE_IN_PROGRESS,
SWITCH_IN_PROGRESS
}
public enum SaveStatus {
STARTED,
UNKNOWN_WORKCELL,
NO_ACTIVE_VARIANT,
SWITCH_IN_PROGRESS,
SAVE_IN_PROGRESS
}
public enum DirtyStatus {
MARKED,
UNKNOWN_WORKCELL,
NO_ACTIVE_VARIANT,
SWITCH_IN_PROGRESS
}
public record WorkcellSnapshot(
String workcellId,
String activeVariantKey,
long loadGeneration,
long mutationGeneration,
boolean dirty,
boolean saveInProgress,
boolean switchInProgress
) {
}
public record DirtyIdentity(
UUID sessionId,
String workcellId,
String variantKey,
long loadGeneration,
long mutationGeneration
) {
public DirtyIdentity {
Objects.requireNonNull(sessionId, "Jigsaw Studio dirty session ID");
Objects.requireNonNull(workcellId, "Jigsaw Studio dirty workcell ID");
Objects.requireNonNull(variantKey, "Jigsaw Studio dirty variant key");
}
}
public record DirtyMark(
DirtyStatus status,
Optional<DirtyIdentity> identity,
boolean newlyDirty
) {
public DirtyMark {
status = Objects.requireNonNull(status, "Jigsaw Studio dirty status");
identity = Objects.requireNonNull(identity, "Jigsaw Studio dirty identity");
if (status == DirtyStatus.MARKED && identity.isEmpty()) {
throw new IllegalArgumentException("A marked Jigsaw Studio workcell requires a dirty identity");
}
if (status != DirtyStatus.MARKED && (identity.isPresent() || newlyDirty)) {
throw new IllegalArgumentException("A rejected Jigsaw Studio dirty mark cannot carry an identity");
}
}
private static DirtyMark marked(DirtyIdentity identity, boolean newlyDirty) {
return new DirtyMark(DirtyStatus.MARKED, Optional.of(identity), newlyDirty);
}
private static DirtyMark failure(DirtyStatus status) {
return new DirtyMark(status, Optional.empty(), false);
}
}
public record VariantSwitchToken(
UUID sessionId,
String workcellId,
Optional<JigsawStudioVariant> previousVariant,
JigsawStudioVariant targetVariant,
long loadGeneration,
long mutationGeneration,
long switchGeneration,
boolean discardDirty
) {
public VariantSwitchToken {
Objects.requireNonNull(sessionId, "Jigsaw Studio switch session ID");
Objects.requireNonNull(workcellId, "Jigsaw Studio switch workcell ID");
previousVariant = Objects.requireNonNull(previousVariant, "Jigsaw Studio previous variant");
targetVariant = Objects.requireNonNull(targetVariant, "Jigsaw Studio target variant");
}
}
public record SwitchStart(SwitchStatus status, Optional<VariantSwitchToken> token) {
public SwitchStart {
status = Objects.requireNonNull(status, "Jigsaw Studio switch status");
token = Objects.requireNonNull(token, "Jigsaw Studio switch token");
}
private static SwitchStart started(VariantSwitchToken token) {
return new SwitchStart(SwitchStatus.STARTED, Optional.of(token));
}
private static SwitchStart failure(SwitchStatus status) {
return new SwitchStart(status, Optional.empty());
}
}
public record SaveIdentity(
UUID sessionId,
String workcellId,
String variantKey,
long loadGeneration,
long mutationGeneration,
long saveGeneration
) {
public SaveIdentity {
Objects.requireNonNull(sessionId, "Jigsaw Studio save session ID");
Objects.requireNonNull(workcellId, "Jigsaw Studio save workcell ID");
Objects.requireNonNull(variantKey, "Jigsaw Studio save variant key");
}
}
public record SaveStart(SaveStatus status, Optional<SaveIdentity> identity) {
public SaveStart {
status = Objects.requireNonNull(status, "Jigsaw Studio save status");
identity = Objects.requireNonNull(identity, "Jigsaw Studio save identity");
}
private static SaveStart started(SaveIdentity identity) {
return new SaveStart(SaveStatus.STARTED, Optional.of(identity));
}
private static SaveStart failure(SaveStatus status) {
return new SaveStart(status, Optional.empty());
}
}
private static final class MutableWorkcellState {
private String activeVariantKey;
private long loadGeneration;
private long mutationGeneration;
private boolean dirty;
private boolean saveInProgress;
private long saveGeneration;
private boolean switchInProgress;
private long switchGeneration;
private MutableWorkcellState(
String activeVariantKey,
long loadGeneration,
long mutationGeneration,
boolean dirty
) {
this.activeVariantKey = activeVariantKey;
this.loadGeneration = loadGeneration;
this.mutationGeneration = mutationGeneration;
this.dirty = dirty;
}
private MutableWorkcellState copy() {
return new MutableWorkcellState(activeVariantKey, loadGeneration, mutationGeneration, dirty);
}
private WorkcellSnapshot snapshot(String workcellId) {
return new WorkcellSnapshot(
workcellId,
activeVariantKey,
loadGeneration,
mutationGeneration,
dirty,
saveInProgress,
switchInProgress);
}
}
}
@@ -0,0 +1,465 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureHash;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.authoring.StructureWriteOptions;
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver;
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
import art.arcane.iris.engine.object.IrisJigsawMode;
import art.arcane.iris.engine.object.IrisJigsawThemeSet;
import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.util.common.math.IrisBlockVector;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public final class JigsawStudioStructureEditor {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private JigsawStudioStructureEditor() {
}
public static StructureWriteResult updateCellSize(
Path packRoot,
String structureKey,
JigsawStudioCellDimensions dimensions
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
ManifestSnapshot snapshot = loadManifest(root, structureKey);
requireOwnedObjectsFit(root, snapshot.manifest(), dimensions);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateCellSize(content, dimensions, structurePath));
}
public static StructureWriteResult updateLimits(
Path packRoot,
String structureKey,
int maxDepth,
int maxSizeChunks
) throws IOException {
if (maxDepth < 1 || maxDepth > 30) {
throw new IllegalArgumentException("Jigsaw max depth must be between 1 and 30");
}
if (maxSizeChunks < 1 || maxSizeChunks > 32) {
throw new IllegalArgumentException("Jigsaw maximum size must be between 1 and 32 chunks");
}
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
ManifestSnapshot snapshot = loadManifest(root, structureKey);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateLimits(
content, maxDepth, maxSizeChunks, structurePath));
}
public static StructureWriteResult updateThemeSets(
Path packRoot,
String structureKey,
List<IrisJigsawThemeSet> themeSets
) throws IOException {
List<IrisJigsawThemeSet> normalizedThemeSets = normalizeThemeSets(themeSets);
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
ManifestSnapshot snapshot = loadManifest(root, structureKey);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateThemeSets(
content,
normalizedThemeSets,
structurePath));
}
public static StructureWriteResult updateRequireCaps(
Path packRoot,
String structureKey,
boolean requireCaps
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
ManifestSnapshot snapshot = loadManifest(root, structureKey);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateRequireCaps(
content,
requireCaps,
structurePath));
}
public static StructureWriteResult updateWorkcellEnabled(
Path packRoot,
String structureKey,
JigsawPlanarArchetype archetype,
boolean enabled
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
JigsawPlanarArchetype target = Objects.requireNonNull(archetype, "Planar Jigsaw Studio archetype");
ManifestSnapshot snapshot = loadManifest(root, structureKey);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateWorkcell(
content,
target,
null,
enabled,
null,
structurePath));
}
public static StructureWriteResult updateWorkcellDimensions(
Path packRoot,
String structureKey,
JigsawPlanarArchetype archetype,
JigsawStudioCellDimensions dimensions
) throws IOException {
return JigsawStudioGraphEditor.updatePlanarWorkcellCapacity(
packRoot,
structureKey,
archetype,
dimensions).writeResult();
}
public static StructureWriteResult updateWorkcellDisplayName(
Path packRoot,
String structureKey,
JigsawPlanarArchetype archetype,
String displayName
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
JigsawPlanarArchetype target = Objects.requireNonNull(archetype, "Planar Jigsaw Studio archetype");
String normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName);
ManifestSnapshot snapshot = loadManifest(root, structureKey);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateWorkcell(
content,
target,
null,
null,
normalizedName,
structurePath));
}
public static StructureWriteResult updateSpatialWorkcellDisplayName(
Path packRoot,
String structureKey,
String displayName
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root")
.toAbsolutePath().normalize();
String normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName);
ManifestSnapshot snapshot = loadManifest(root, structureKey);
return updateOwnedStructure(
root,
structureKey,
snapshot,
(content, structurePath) -> updateSpatialWorkcellDisplayName(
content,
normalizedName,
structurePath));
}
private static ManifestSnapshot loadManifest(Path root, String structureKey) throws IOException {
StructureKey ownershipKey = StructureKey.parse(structureKey, "iris");
StructureTransactionWriter writer = new StructureTransactionWriter(root);
Path manifestPath = writer.ownershipManifestPath(ownershipKey);
if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("This graph is read-only because it is not Studio-owned; create a new Jigsaw Studio project before editing rules");
}
byte[] manifestContent = Files.readAllBytes(manifestPath);
return new ManifestSnapshot(
JigsawStudioAuthoringAccess.requireEditable(
StructureOwnershipManifest.fromJson(manifestContent)),
StructureHash.sha256(manifestContent));
}
private static StructureWriteResult updateOwnedStructure(
Path root,
String structureKey,
ManifestSnapshot snapshot,
StructureContentEditor editor
) throws IOException {
StructureTransactionWriter writer = new StructureTransactionWriter(root);
StructureOwnershipManifest manifest = snapshot.manifest();
String normalizedStructure = JigsawStudioProjectCreator.Options.requireResourceKey(structureKey);
String targetResource = "structures/" + normalizedStructure + ".json";
if (!manifest.resourceHashes().containsKey(targetResource)) {
throw new IOException("The owned graph manifest does not include " + targetResource);
}
StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(manifest.structure())
.source(manifest.source())
.backend(manifest.backend())
.capabilities(manifest.capabilities())
.losses(manifest.losses());
for (Map.Entry<String, String> resource : manifest.resourceHashes().entrySet()) {
Path resourcePath = resolveOwnedResource(root, resource.getKey());
byte[] content = Files.readAllBytes(resourcePath);
if (resource.getKey().equals(targetResource)) {
content = editor.edit(content, resourcePath);
}
bundle.resource(resource.getKey(), content);
}
StructureResourceBundle updatedBundle = bundle.build();
StructureResourceBundleGraphCompiler.requireViable(updatedBundle);
StructureWriteResult result = writer.write(
updatedBundle,
StructureWriteOptions.overwriteExpected(snapshot.expectedManifestHash()));
if (!result.successful()) {
String conflict = result.conflicts().isEmpty()
? result.status().name()
: result.conflicts().getFirst().relativePath() + ": "
+ result.conflicts().getFirst().reason();
throw new IOException("Atomic graph structure update was rejected: " + conflict);
}
return result;
}
private static Path resolveOwnedResource(Path root, String relativePath) throws IOException {
StructureResourceBundle.validateRelativePath(relativePath);
Path resource = root.resolve(relativePath).normalize();
if (!resource.startsWith(root) || !Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Owned graph resource is missing or unsafe: " + relativePath);
}
return resource;
}
private static void requireOwnedObjectsFit(
Path root,
StructureOwnershipManifest manifest,
JigsawStudioCellDimensions dimensions
) throws IOException {
for (String relativePath : manifest.resourceHashes().keySet()) {
if (!relativePath.startsWith("objects/") || !relativePath.endsWith(".iob")) {
continue;
}
Path objectPath = resolveOwnedResource(root, relativePath);
IrisBlockVector size = IrisObject.sampleSize(objectPath.toFile());
if (size.getBlockX() > dimensions.width()
|| size.getBlockY() > dimensions.height()
|| size.getBlockZ() > dimensions.depth()) {
throw new IOException("Owned object '" + relativePath + "' is "
+ size.getBlockX() + "x" + size.getBlockY() + "x" + size.getBlockZ()
+ " and does not fit the requested cell bounds");
}
}
}
private static byte[] updateCellSize(
byte[] content,
JigsawStudioCellDimensions dimensions,
Path structurePath
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw structure is not a JSON object: " + structurePath);
}
JsonObject cellSize = new JsonObject();
JsonObject structure = parsed.getAsJsonObject();
if (structure.has("mode")
&& "PLANAR_JIGSAW".equals(structure.get("mode").getAsString())
&& dimensions.width() != dimensions.depth()) {
throw new IOException("Planar Jigsaw Studio cells require equal width and depth");
}
cellSize.addProperty("x", dimensions.width());
cellSize.addProperty("y", dimensions.height());
cellSize.addProperty("z", dimensions.depth());
structure.add("cellSize", cellSize);
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
}
private static byte[] updateLimits(
byte[] content,
int maxDepth,
int maxSizeChunks,
Path structurePath
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw structure is not a JSON object: " + structurePath);
}
JsonObject structure = parsed.getAsJsonObject();
structure.addProperty("maxDepth", maxDepth);
structure.addProperty("maxSizeChunks", maxSizeChunks);
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
}
private static List<IrisJigsawThemeSet> normalizeThemeSets(List<IrisJigsawThemeSet> themeSets) {
Objects.requireNonNull(themeSets, "Jigsaw Studio theme sets");
List<IrisJigsawThemeSet> normalized = new ArrayList<>(themeSets.size());
Set<String> keys = new LinkedHashSet<>();
for (IrisJigsawThemeSet themeSet : themeSets) {
IrisJigsawThemeSet source = Objects.requireNonNull(themeSet, "Jigsaw Studio theme set");
String key = source.getKey() == null ? "" : source.getKey().trim();
if (key.isEmpty() || !key.equals(source.getKey())) {
throw new IllegalArgumentException(
"Jigsaw Studio theme keys must be non-blank and whitespace-normalized");
}
if (!keys.add(key)) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio theme key '" + key + "'");
}
if (source.getWeight() < 1) {
throw new IllegalArgumentException("Jigsaw Studio theme weights must be positive");
}
normalized.add(new IrisJigsawThemeSet(key, source.getWeight()));
}
return List.copyOf(normalized);
}
private static byte[] updateThemeSets(
byte[] content,
List<IrisJigsawThemeSet> themeSets,
Path structurePath
) throws IOException {
JsonObject structure = parseStructure(content, structurePath);
JsonArray values = new JsonArray();
for (IrisJigsawThemeSet themeSet : themeSets) {
JsonObject value = new JsonObject();
value.addProperty("key", themeSet.getKey());
value.addProperty("weight", themeSet.getWeight());
values.add(value);
}
structure.add("themeSets", values);
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
}
private static byte[] updateRequireCaps(
byte[] content,
boolean requireCaps,
Path structurePath
) throws IOException {
JsonObject structure = parseStructure(content, structurePath);
structure.addProperty("requireCaps", requireCaps);
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
}
private static byte[] updateSpatialWorkcellDisplayName(
byte[] content,
String displayName,
Path structurePath
) throws IOException {
JsonObject structure = parseStructure(content, structurePath);
IrisStructure model;
try {
model = GSON.fromJson(structure, IrisStructure.class);
} catch (RuntimeException exception) {
throw new IOException("Jigsaw structure is not valid JSON: " + structurePath, exception);
}
if (model == null || model.resolvedMode() != IrisJigsawMode.SPATIAL_JIGSAW) {
throw new IOException("Spatial workcell labels require a spatial Jigsaw Studio structure");
}
if (displayName.isEmpty()) {
structure.remove("spatialWorkcellDisplayName");
} else {
structure.addProperty("spatialWorkcellDisplayName", displayName);
}
return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8);
}
private static JsonObject parseStructure(byte[] content, Path structurePath) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw structure is not a JSON object: " + structurePath);
}
return parsed.getAsJsonObject();
}
static byte[] updateWorkcell(
byte[] content,
JigsawPlanarArchetype archetype,
JigsawStudioCellDimensions dimensions,
Boolean enabled,
String displayName,
Path structurePath
) throws IOException {
JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8));
if (!parsed.isJsonObject()) {
throw new IOException("Jigsaw structure is not a JSON object: " + structurePath);
}
JsonObject structureJson = parsed.getAsJsonObject();
IrisStructure structure;
try {
structure = GSON.fromJson(structureJson, IrisStructure.class);
} catch (RuntimeException exception) {
throw new IOException("Jigsaw structure is not valid JSON: " + structurePath, exception);
}
if (structure == null || structure.resolvedMode() != IrisJigsawMode.PLANAR_JIGSAW) {
throw new IOException("Workcell settings require a planar Jigsaw Studio structure");
}
Map<IrisJigsawWorkcellArchetype, PlanarJigsawWorkcellResolver.ResolvedWorkcell> resolved;
try {
resolved = PlanarJigsawWorkcellResolver.resolve(structure);
} catch (IllegalArgumentException exception) {
throw new IOException("Planar workcell configuration is invalid: " + exception.getMessage(), exception);
}
JsonArray workcells = new JsonArray();
for (JigsawPlanarArchetype current : JigsawPlanarArchetype.values()) {
PlanarJigsawWorkcellResolver.ResolvedWorkcell source = resolved.get(current.modelArchetype());
JsonObject workcell = new JsonObject();
workcell.addProperty("archetype", current.name());
String resolvedDisplayName = current == archetype && displayName != null
? displayName : source.displayName();
if (!resolvedDisplayName.isEmpty()) {
workcell.addProperty("displayName", resolvedDisplayName);
}
workcell.addProperty("width", current == archetype && dimensions != null
? dimensions.width() : source.width());
workcell.addProperty("height", current == archetype && dimensions != null
? dimensions.height() : source.height());
workcell.addProperty("depth", current == archetype && dimensions != null
? dimensions.depth() : source.depth());
workcell.addProperty("enabled", current == archetype && enabled != null
? enabled : source.enabled());
workcells.add(workcell);
}
structureJson.add("planarWorkcells", workcells);
return (GSON.toJson(structureJson) + "\n").getBytes(StandardCharsets.UTF_8);
}
@FunctionalInterface
private interface StructureContentEditor {
byte[] edit(byte[] content, Path structurePath) throws IOException;
}
private record ManifestSnapshot(
StructureOwnershipManifest manifest,
String expectedManifestHash
) {
}
}
@@ -0,0 +1,45 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Objects;
public enum JigsawStudioToolAction {
OPEN_MENU("Open Control Menu", false),
SELECT_WORKCELL("Select Workcell", false),
TOGGLE_WORKCELL("Toggle Workcell", false),
LOAD_VARIANT("Load Variant", false),
CREATE_VARIANT("New Blank Variant", false),
DUPLICATE_VARIANT("Duplicate This Cell's Variant", false),
DUPLICATE_FAMILY("Duplicate All Enabled Cells as Family", false),
PREVIEW_GRAPH("Go to Preview", false),
FLUSH_AUTOSAVE("Flush Autosave", false),
TOGGLE_ROTATION("Toggle Rotation", false),
EXPAND_TO_CELL("Resize Variant to Capacity", false),
RESIZE_VARIANT("Resize This Variant", false),
RESIZE_WORKCELL("Resize Workcell Capacity", false),
RENAME_VARIANT("Rename This Variant", false),
RENAME_WORKCELL("Rename This Workcell", false),
ADJUST_VARIANT_WEIGHT("Adjust Variant Weight", false),
ADJUST_VARIANT_CHANCE("Adjust Variant Chance", false),
SET_THEME("Set Theme", false),
SET_PIECE_RULES("Set Piece Rules", false),
TOGGLE_REQUIRE_CAPS("Toggle Required Caps", false),
UNLINK_MEMBERSHIP("Unlink Pool Entry", true),
DELETE_VARIANT("Delete Variant", true),
DELETE_PROJECT("Delete Project", true);
private final String displayName;
private final boolean destructive;
JigsawStudioToolAction(String displayName, boolean destructive) {
this.displayName = Objects.requireNonNull(displayName, "Jigsaw Studio tool display name");
this.destructive = destructive;
}
public String displayName() {
return displayName;
}
public boolean destructive() {
return destructive;
}
}
@@ -0,0 +1,111 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Objects;
import java.util.UUID;
public record JigsawStudioToolPayload(
int schemaVersion,
JigsawStudioToolAction action,
UUID requestId,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex,
int amount
) {
public static final int CURRENT_SCHEMA_VERSION = 2;
public static final int NO_ENTRY_INDEX = -1;
private static final int MAX_FIELD_LENGTH = 512;
public JigsawStudioToolPayload {
if (schemaVersion < 1) {
throw new IllegalArgumentException("Jigsaw Studio tool schema version must be positive");
}
action = Objects.requireNonNull(action, "Jigsaw Studio tool action");
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio tool request ID");
workcellId = normalize(workcellId, "workcell ID");
pieceKey = normalize(pieceKey, "piece key");
poolKey = normalize(poolKey, "pool key");
if (entryIndex < NO_ENTRY_INDEX) {
throw new IllegalArgumentException("Jigsaw Studio tool entry index cannot be lower than -1");
}
}
public static JigsawStudioToolPayload request(
JigsawStudioToolAction action,
UUID requestId
) {
return new JigsawStudioToolPayload(
CURRENT_SCHEMA_VERSION,
action,
requestId,
"",
"",
"",
NO_ENTRY_INDEX,
0);
}
public static JigsawStudioToolPayload workcell(
JigsawStudioToolAction action,
UUID requestId,
String workcellId
) {
return new JigsawStudioToolPayload(
CURRENT_SCHEMA_VERSION,
action,
requestId,
workcellId,
"",
"",
NO_ENTRY_INDEX,
0);
}
public static JigsawStudioToolPayload variant(
JigsawStudioToolAction action,
UUID requestId,
String workcellId,
String pieceKey
) {
return new JigsawStudioToolPayload(
CURRENT_SCHEMA_VERSION,
action,
requestId,
workcellId,
pieceKey,
"",
NO_ENTRY_INDEX,
0);
}
public static JigsawStudioToolPayload membership(
JigsawStudioToolAction action,
UUID requestId,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex,
int amount
) {
return new JigsawStudioToolPayload(
CURRENT_SCHEMA_VERSION,
action,
requestId,
workcellId,
pieceKey,
poolKey,
entryIndex,
amount);
}
private static String normalize(String value, String fieldName) {
String normalized = value == null ? "" : value.trim();
if (normalized.length() > MAX_FIELD_LENGTH) {
throw new IllegalArgumentException("Jigsaw Studio tool " + fieldName
+ " cannot exceed " + MAX_FIELD_LENGTH + " characters");
}
return normalized;
}
}
@@ -0,0 +1,112 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
public final class JigsawStudioTripleSneakTracker {
public static final long DEFAULT_WINDOW_NANOS = 1_500_000_000L;
private static final int REQUIRED_SNEAKS = 3;
private final long windowNanos;
private final Map<UUID, GestureState> gestures = new HashMap<>();
public JigsawStudioTripleSneakTracker() {
this(DEFAULT_WINDOW_NANOS);
}
public JigsawStudioTripleSneakTracker(long windowNanos) {
if (windowNanos <= 0L) {
throw new IllegalArgumentException("Jigsaw Studio triple-sneak window must be positive");
}
this.windowNanos = windowNanos;
}
public synchronized Progress recordSneak(
UUID playerId,
UUID worldId,
UUID requestId,
long nowNanos
) {
UUID player = Objects.requireNonNull(playerId, "Jigsaw Studio gesture player ID");
UUID world = Objects.requireNonNull(worldId, "Jigsaw Studio gesture world ID");
UUID request = Objects.requireNonNull(requestId, "Jigsaw Studio gesture request ID");
GestureState previous = gestures.get(player);
if (previous == null
|| !previous.worldId().equals(world)
|| !previous.requestId().equals(request)
|| expired(previous, nowNanos)) {
gestures.put(player, new GestureState(world, request, nowNanos, nowNanos, 1));
return Progress.FIRST;
}
int count = previous.count() + 1;
if (count >= REQUIRED_SNEAKS) {
gestures.remove(player);
return Progress.TRIGGERED;
}
gestures.put(player, new GestureState(
world,
request,
previous.startedAtNanos(),
nowNanos,
count));
return Progress.SECOND;
}
public synchronized void clearPlayer(UUID playerId) {
if (playerId != null) {
gestures.remove(playerId);
}
}
public synchronized int clearRequest(UUID requestId) {
if (requestId == null) {
return 0;
}
int removed = 0;
Iterator<GestureState> states = gestures.values().iterator();
while (states.hasNext()) {
GestureState state = states.next();
if (state.requestId().equals(requestId)) {
states.remove();
removed++;
}
}
return removed;
}
public synchronized void clearAll() {
gestures.clear();
}
public synchronized int trackedPlayers() {
return gestures.size();
}
private boolean expired(GestureState state, long nowNanos) {
long elapsedSinceStart = nowNanos - state.startedAtNanos();
long elapsedSinceLast = nowNanos - state.lastSneakAtNanos();
return elapsedSinceStart < 0L
|| elapsedSinceLast < 0L
|| elapsedSinceStart > windowNanos;
}
public enum Progress {
FIRST,
SECOND,
TRIGGERED
}
private record GestureState(
UUID worldId,
UUID requestId,
long startedAtNanos,
long lastSneakAtNanos,
int count
) {
}
}
@@ -0,0 +1,141 @@
package art.arcane.iris.core.runtime.jigsaw;
import art.arcane.iris.engine.object.IrisPosition;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public record JigsawStudioVariant(
String pieceKey,
String objectKey,
String displayName,
Optional<JigsawStudioCellDimensions> dimensions,
JigsawStudioMode mode,
Optional<JigsawPlanarTopology> sourceTopology,
boolean rotatable,
boolean owned,
List<String> themes,
JigsawStudioPieceRules rules,
List<JigsawStudioPoolMembership> memberships
) {
public JigsawStudioVariant {
pieceKey = requireKey(pieceKey, "piece");
objectKey = requireKey(objectKey, "object");
displayName = displayName == null ? "" : displayName.trim();
dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio variant dimensions");
mode = Objects.requireNonNull(mode, "Jigsaw Studio variant mode");
sourceTopology = Objects.requireNonNull(sourceTopology, "Jigsaw Studio variant topology");
themes = List.copyOf(Objects.requireNonNull(themes, "Jigsaw Studio variant themes"));
rules = Objects.requireNonNull(rules, "Jigsaw Studio variant rules");
memberships = List.copyOf(Objects.requireNonNull(memberships, "Jigsaw Studio pool memberships"));
if (mode == JigsawStudioMode.PLANAR_JIGSAW && sourceTopology.isEmpty()) {
throw new IllegalArgumentException("Planar Jigsaw Studio variants require a topology");
}
if (mode == JigsawStudioMode.SPATIAL_JIGSAW && sourceTopology.isPresent()) {
throw new IllegalArgumentException("Spatial Jigsaw Studio variants cannot declare a planar topology");
}
}
public String resolvedDisplayName() {
if (!displayName.isEmpty()) {
return displayName;
}
int separator = Math.max(pieceKey.lastIndexOf('/'), pieceKey.lastIndexOf(':'));
return separator < 0 ? pieceKey : pieceKey.substring(separator + 1);
}
public Optional<JigsawPlanarArchetype> archetype() {
return sourceTopology.map(JigsawPlanarArchetype::fromTopology);
}
public int sourceToCanonicalQuarterTurns() {
JigsawPlanarTopology topology = sourceTopology.orElse(null);
return topology == null ? 0 : JigsawPlanarArchetype.fromTopology(topology)
.sourceToCanonicalQuarterTurns(topology);
}
public int canonicalToSourceQuarterTurns() {
JigsawPlanarTopology topology = sourceTopology.orElse(null);
return topology == null ? 0 : JigsawPlanarArchetype.fromTopology(topology)
.canonicalToSourceQuarterTurns(topology);
}
public IrisPosition sourceToCanonicalPosition(
IrisPosition sourcePosition,
JigsawStudioCellDimensions sourceDimensions
) {
return rotatePosition(
sourcePosition,
sourceDimensions,
sourceToCanonicalQuarterTurns());
}
public IrisPosition canonicalToSourcePosition(
IrisPosition canonicalPosition,
JigsawStudioCellDimensions sourceDimensions
) {
JigsawStudioCellDimensions canonicalDimensions = canonicalDimensions(sourceDimensions);
return rotatePosition(
canonicalPosition,
canonicalDimensions,
canonicalToSourceQuarterTurns());
}
public JigsawStudioCellDimensions canonicalDimensions(JigsawStudioCellDimensions sourceDimensions) {
JigsawStudioCellDimensions dimensions = Objects.requireNonNull(
sourceDimensions,
"Jigsaw Studio source dimensions");
return Math.floorMod(sourceToCanonicalQuarterTurns(), 2) == 0
? dimensions
: new JigsawStudioCellDimensions(
dimensions.depth(),
dimensions.height(),
dimensions.width());
}
public boolean assigned() {
return !memberships.isEmpty();
}
private static IrisPosition rotatePosition(
IrisPosition position,
JigsawStudioCellDimensions dimensions,
int quarterTurns
) {
IrisPosition source = Objects.requireNonNull(position, "Jigsaw Studio variant position");
JigsawStudioCellDimensions bounds = Objects.requireNonNull(
dimensions,
"Jigsaw Studio variant position bounds");
if (source.getX() < 0 || source.getX() >= bounds.width()
|| source.getY() < 0 || source.getY() >= bounds.height()
|| source.getZ() < 0 || source.getZ() >= bounds.depth()) {
throw new IllegalArgumentException("Jigsaw Studio variant position is outside its object bounds");
}
return switch (Math.floorMod(quarterTurns, 4)) {
case 0 -> new IrisPosition(source.getX(), source.getY(), source.getZ());
case 1 -> new IrisPosition(
bounds.depth() - 1 - source.getZ(),
source.getY(),
source.getX());
case 2 -> new IrisPosition(
bounds.width() - 1 - source.getX(),
source.getY(),
bounds.depth() - 1 - source.getZ());
case 3 -> new IrisPosition(
source.getZ(),
source.getY(),
bounds.width() - 1 - source.getX());
default -> throw new IllegalStateException("Unreachable Jigsaw Studio rotation");
};
}
private static String requireKey(String value, String name) {
Objects.requireNonNull(value, "Jigsaw Studio " + name + " key");
String normalized = value.trim();
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio " + name + " key cannot be blank");
}
return normalized;
}
}
@@ -0,0 +1,95 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public final class JigsawStudioVariantCatalog {
private final List<JigsawStudioVariant> variants;
private final Map<String, JigsawStudioVariant> byPieceKey;
private final Map<JigsawPlanarArchetype, List<JigsawStudioVariant>> byArchetype;
private final List<JigsawStudioVariant> spatialVariants;
private final boolean editableGraph;
public JigsawStudioVariantCatalog(List<JigsawStudioVariant> variants) {
this(variants, true);
}
public JigsawStudioVariantCatalog(
List<JigsawStudioVariant> variants,
boolean editableGraph
) {
Objects.requireNonNull(variants, "Jigsaw Studio variants");
if (variants.size() > JigsawStudioLayout.MAX_VARIANTS) {
throw new IllegalArgumentException("Jigsaw Studio catalogs cannot exceed "
+ JigsawStudioLayout.MAX_VARIANTS + " variants");
}
List<JigsawStudioVariant> copied = List.copyOf(variants);
Map<String, JigsawStudioVariant> pieceIndex = new LinkedHashMap<>();
Map<JigsawPlanarArchetype, List<JigsawStudioVariant>> archetypeIndex =
new EnumMap<>(JigsawPlanarArchetype.class);
List<JigsawStudioVariant> spatial = new ArrayList<>();
for (JigsawStudioVariant variant : copied) {
JigsawStudioVariant activeVariant = Objects.requireNonNull(variant, "Jigsaw Studio variant");
JigsawStudioVariant previous = pieceIndex.putIfAbsent(activeVariant.pieceKey(), activeVariant);
if (previous != null) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio variant piece key "
+ activeVariant.pieceKey());
}
Optional<JigsawPlanarArchetype> archetype = activeVariant.archetype();
if (archetype.isPresent()) {
archetypeIndex.computeIfAbsent(archetype.get(), key -> new ArrayList<>()).add(activeVariant);
} else {
spatial.add(activeVariant);
}
}
Map<JigsawPlanarArchetype, List<JigsawStudioVariant>> immutableArchetypes =
new EnumMap<>(JigsawPlanarArchetype.class);
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
immutableArchetypes.put(
archetype,
List.copyOf(archetypeIndex.getOrDefault(archetype, List.of())));
}
this.variants = copied;
this.byPieceKey = Collections.unmodifiableMap(pieceIndex);
this.byArchetype = Collections.unmodifiableMap(immutableArchetypes);
this.spatialVariants = List.copyOf(spatial);
this.editableGraph = editableGraph;
}
public static JigsawStudioVariantCatalog empty() {
return new JigsawStudioVariantCatalog(List.of());
}
public List<JigsawStudioVariant> variants() {
return variants;
}
public Optional<JigsawStudioVariant> find(String pieceKey) {
if (pieceKey == null) {
return Optional.empty();
}
return Optional.ofNullable(byPieceKey.get(pieceKey));
}
public List<JigsawStudioVariant> variants(JigsawPlanarArchetype archetype) {
return byArchetype.get(Objects.requireNonNull(archetype, "Planar archetype"));
}
public List<JigsawStudioVariant> spatialVariants() {
return spatialVariants;
}
public boolean editableGraph() {
return editableGraph;
}
public int size() {
return variants.size();
}
}
@@ -0,0 +1,20 @@
package art.arcane.iris.core.runtime.jigsaw;
import java.util.Objects;
public record JigsawStudioWorkcellSpec(
JigsawPlanarArchetype archetype,
String displayName,
JigsawStudioCellDimensions dimensions,
boolean enabled
) {
public JigsawStudioWorkcellSpec {
archetype = Objects.requireNonNull(archetype, "Jigsaw Studio workcell archetype");
displayName = displayName == null ? "" : displayName.trim();
dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio workcell dimensions");
}
public String resolvedDisplayName() {
return displayName.isEmpty() ? archetype.displayName() : displayName;
}
}
@@ -36,13 +36,13 @@ import art.arcane.iris.util.common.plugin.IrisService;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.matter.MatterCavern;
import art.arcane.volmlib.util.localization.MessageArgument;
import lombok.Data;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
@@ -57,9 +57,15 @@ import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
public class BoardSVC implements IrisService, BoardProvider {
private static final String SEPARATOR = "&7&m-------------------";
private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]");
private final Map<Player, PlayerBoard> boards = new ConcurrentHashMap<>();
private final Map<UUID, JigsawStudioBoardContext> jigsawContexts = new ConcurrentHashMap<>();
private final Map<UUID, UUID> yieldedWorlds = new ConcurrentHashMap<>();
private final Set<UUID> hiddenPlayers = ConcurrentHashMap.newKeySet();
private volatile BoardSettings settings;
private volatile boolean boardEnabled;
@@ -111,28 +117,32 @@ public class BoardSVC implements IrisService, BoardProvider {
board.cancel();
}
boards.clear();
jigsawContexts.clear();
yieldedWorlds.clear();
hiddenPlayers.clear();
settings = null;
}
@EventHandler
@EventHandler(priority = EventPriority.HIGHEST)
public void on(PlayerChangedWorldEvent e) {
J.runEntity(e.getPlayer(), () -> updatePlayer(e.getPlayer()));
}
@EventHandler
@EventHandler(priority = EventPriority.HIGHEST)
public void on(PlayerJoinEvent e) {
J.runEntity(e.getPlayer(), () -> updatePlayer(e.getPlayer()));
}
@EventHandler
@EventHandler(priority = EventPriority.HIGHEST)
public void on(PlayerQuitEvent e) {
remove(e.getPlayer());
jigsawContexts.remove(e.getPlayer().getUniqueId());
yieldedWorlds.remove(e.getPlayer().getUniqueId());
clearPlayerPreference(e.getPlayer().getUniqueId());
}
public void updatePlayer(Player p) {
if (!boardEnabled || settings == null) {
if (p == null || !boardEnabled || settings == null) {
return;
}
@@ -141,12 +151,72 @@ public class BoardSVC implements IrisService, BoardProvider {
return;
}
if (isEligibleWorld(p)) {
boards.computeIfAbsent(p, PlayerBoard::new);
UUID playerId = p.getUniqueId();
UUID worldId = p.getWorld().getUID();
UUID yieldedWorld = yieldedWorlds.get(playerId);
if (yieldedWorld != null) {
if (yieldedWorld.equals(worldId)) {
remove(p);
return;
}
yieldedWorlds.remove(playerId, yieldedWorld);
}
if (!isEligibleWorld(p)) {
jigsawContexts.remove(playerId);
remove(p);
return;
}
remove(p);
PlayerBoard playerBoard = boards.computeIfAbsent(p, PlayerBoard::new);
JigsawStudioBoardContext jigsawContext = currentJigsawContext(p);
if (jigsawContext != null) {
playerBoard.showJigsaw(jigsawContext);
} else {
playerBoard.showOrdinary();
}
}
public void applyJigsawContext(Player player, JigsawStudioBoardContext context) {
Objects.requireNonNull(player, "Jigsaw Studio board player");
Objects.requireNonNull(context, "Jigsaw Studio board context");
if (!J.isOwnedByCurrentRegion(player)) {
J.runEntity(player, () -> applyJigsawContext(player, context));
return;
}
if (!player.isOnline()) {
return;
}
jigsawContexts.put(player.getUniqueId(), context);
if (context.worldId().equals(player.getWorld().getUID())) {
updatePlayer(player);
}
}
public void clearJigsawContext(Player player) {
Objects.requireNonNull(player, "Jigsaw Studio board player");
if (!J.isOwnedByCurrentRegion(player)) {
J.runEntity(player, () -> clearJigsawContext(player));
return;
}
jigsawContexts.remove(player.getUniqueId());
updatePlayer(player);
}
public void refreshOrdinaryContext(Player player) {
Objects.requireNonNull(player, "Studio board player");
if (!J.isOwnedByCurrentRegion(player)) {
J.runEntity(player, () -> refreshOrdinaryContext(player));
return;
}
PlayerBoard previousBoard = boards.get(player);
boolean alreadyOrdinary = previousBoard != null && previousBoard.isOrdinary();
jigsawContexts.remove(player.getUniqueId());
updatePlayer(player);
PlayerBoard playerBoard = boards.get(player);
if (alreadyOrdinary && playerBoard == previousBoard) {
playerBoard.refreshOrdinary();
}
}
private void remove(Player player) {
@@ -168,6 +238,9 @@ public class BoardSVC implements IrisService, BoardProvider {
public boolean toggle(Player player) {
Objects.requireNonNull(player, "player");
boolean visible = togglePlayerBoard(player.getUniqueId());
if (visible) {
yieldedWorlds.remove(player.getUniqueId());
}
updatePlayer(player);
return visible;
}
@@ -202,6 +275,53 @@ public class BoardSVC implements IrisService, BoardProvider {
&& generator.getEngine() != null;
}
static List<String> jigsawLines(JigsawStudioBoardContext context) {
Objects.requireNonNull(context, "Jigsaw Studio board context");
List<String> lines = new ArrayList<>(11);
lines.add(SEPARATOR);
lines.add("&dJigsaw Studio");
lines.add("&bStructure&7: " + untrustedBoardValue(context.structureKey()));
if (!context.insideWorkcell()) {
lines.add("&bMode&7: " + context.modeDisplayName());
lines.add(SEPARATOR);
lines.add("&eWalk into a workcell");
if (!context.controlHint().isEmpty()) {
lines.add("&7" + untrustedBoardValue(context.controlHint()));
}
lines.add(SEPARATOR);
return List.copyOf(lines);
}
lines.add("&bWorkcell&7: " + untrustedBoardValue(context.workcellName()));
if (!context.workcellRole().isEmpty() && !context.workcellRole().equals(context.workcellName())) {
lines.add("&bRole&7: " + untrustedBoardValue(context.workcellRole()));
}
lines.add("&bVariant&7: " + untrustedBoardValue(
context.variantName().isEmpty() ? "None" : context.variantName()));
lines.add("&bState&7: " + context.state().displayName());
lines.add(SEPARATOR);
if (!context.controlHint().isEmpty()) {
lines.add("&e" + untrustedBoardValue(context.controlHint()));
}
lines.add(SEPARATOR);
return List.copyOf(lines);
}
static boolean shouldRenderJigsaw(
JigsawStudioBoardContext previous,
JigsawStudioBoardContext next
) {
return !Objects.equals(previous, next);
}
static String untrustedBoardValue(String value) {
String normalized = value == null ? "" : value.replace('\n', ' ').replace('\r', ' ');
return LEGACY_COLOR.matcher(normalized).replaceAll("")
.replace("&", "")
.replace("<", "")
.replace(">", "");
}
boolean isPlayerBoardEnabled(UUID playerId) {
return playerId != null && !hiddenPlayers.contains(playerId);
}
@@ -222,62 +342,120 @@ public class BoardSVC implements IrisService, BoardProvider {
}
}
static Scoreboard selectScoreboardToRestore(Scoreboard active, Scoreboard iris, Scoreboard previous) {
return Objects.equals(active, iris) ? previous : active;
private JigsawStudioBoardContext currentJigsawContext(Player player) {
JigsawStudioBoardContext context = jigsawContexts.get(player.getUniqueId());
if (context == null || !context.worldId().equals(player.getWorld().getUID())) {
return null;
}
return context;
}
@Data
public class PlayerBoard {
private final Player player;
private final Board board;
private final Scoreboard previousScoreboard;
private final Scoreboard irisScoreboard;
private volatile List<String> lines;
private volatile JigsawStudioBoardContext jigsawContext;
private volatile BoardView view;
private volatile boolean cancelled;
private volatile boolean ordinaryTickScheduled;
public PlayerBoard(Player player) {
this.player = player;
Scoreboard previous = null;
Scoreboard assigned = null;
try {
previous = player.getScoreboard();
if (Bukkit.getScoreboardManager() != null
&& Objects.equals(previous, Bukkit.getScoreboardManager().getMainScoreboard())) {
player.setScoreboard(Bukkit.getScoreboardManager().getNewScoreboard());
}
assigned = player.getScoreboard();
} catch (Throwable e) {
IrisLogging.reportError("Failed to prepare the Studio scoreboard for " + player.getName() + ".", e);
}
this.previousScoreboard = previous;
this.irisScoreboard = assigned;
this.board = new Board(player, settings);
this.lines = new ArrayList<>();
this.lines = List.of();
this.jigsawContext = null;
this.view = BoardView.NONE;
this.cancelled = false;
schedule(0);
this.ordinaryTickScheduled = false;
}
private void schedule(int delayTicks) {
if (cancelled || !boardEnabled || !player.isOnline()) {
private void showOrdinary() {
if (cancelled) {
return;
}
J.runEntity(player, this::tick, delayTicks);
if (!board.ownsScoreboardAssignment()) {
yieldBoard(player, this);
return;
}
boolean switched = view != BoardView.ORDINARY;
view = BoardView.ORDINARY;
jigsawContext = null;
if (switched) {
updateOrdinary();
board.update();
}
scheduleOrdinaryTick();
}
private void tick() {
if (cancelled || !boardEnabled || !player.isOnline()) {
private void refreshOrdinary() {
if (cancelled || view != BoardView.ORDINARY || !board.ownsScoreboardAssignment()) {
return;
}
updateOrdinary();
board.update();
}
private boolean isOrdinary() {
return !cancelled && view == BoardView.ORDINARY;
}
private void showJigsaw(JigsawStudioBoardContext context) {
if (cancelled) {
return;
}
if (!board.ownsScoreboardAssignment()) {
yieldBoard(player, this);
return;
}
if (view == BoardView.JIGSAW && !shouldRenderJigsaw(jigsawContext, context)) {
return;
}
view = BoardView.JIGSAW;
jigsawContext = context;
lines = jigsawLines(context);
board.update();
}
private void scheduleOrdinaryTick() {
if (ordinaryTickScheduled || cancelled || view != BoardView.ORDINARY
|| !boardEnabled || !player.isOnline()) {
return;
}
ordinaryTickScheduled = true;
boolean scheduled = J.runEntity(
player,
() -> {
ordinaryTickScheduled = false;
ordinaryTick();
},
20,
() -> ordinaryTickScheduled = false);
if (!scheduled) {
ordinaryTickScheduled = false;
}
}
private void ordinaryTick() {
if (cancelled || view != BoardView.ORDINARY || !boardEnabled || !player.isOnline()) {
return;
}
if (!isEligibleWorld(player)) {
boards.remove(player, this);
cancel();
return;
}
update();
JigsawStudioBoardContext context = currentJigsawContext(player);
if (context != null) {
showJigsaw(context);
return;
}
if (!board.ownsScoreboardAssignment()) {
yieldBoard(player, this);
return;
}
updateOrdinary();
board.update();
schedule(20);
scheduleOrdinaryTick();
}
public void cancel() {
@@ -293,30 +471,14 @@ public class BoardSVC implements IrisService, BoardProvider {
}
private void removeNow() {
Scoreboard activeScoreboard = null;
try {
activeScoreboard = player.getScoreboard();
board.remove();
if (!player.isOnline()) {
return;
}
Scoreboard restore = selectScoreboardToRestore(
activeScoreboard,
irisScoreboard,
previousScoreboard);
if (restore != null && !Objects.equals(player.getScoreboard(), restore)) {
player.setScoreboard(restore);
}
} catch (Throwable e) {
IrisLogging.reportError("Failed to remove the Studio scoreboard for " + player.getName() + ".", e);
if (activeScoreboard != null && player.isOnline()) {
player.setScoreboard(activeScoreboard);
}
}
}
public void update() {
private void updateOrdinary() {
World world = player.getWorld();
Location loc = player.getLocation();
@@ -362,4 +524,16 @@ public class BoardSVC implements IrisService, BoardProvider {
this.lines = lines;
}
}
private void yieldBoard(Player player, PlayerBoard playerBoard) {
yieldedWorlds.put(player.getUniqueId(), player.getWorld().getUID());
boards.remove(player, playerBoard);
playerBoard.cancel();
}
private enum BoardView {
NONE,
ORDINARY,
JIGSAW
}
}
@@ -0,0 +1,56 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import java.util.Objects;
import java.util.UUID;
public record JigsawStudioBoardContext(
UUID worldId,
UUID requestId,
String structureKey,
JigsawStudioMode mode,
String workcellRole,
String workcellName,
String variantName,
JigsawStudioBoardState state,
String controlHint
) {
public JigsawStudioBoardContext {
worldId = Objects.requireNonNull(worldId, "Jigsaw Studio board world ID");
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio board request ID");
structureKey = requireText(structureKey, "structure key");
mode = Objects.requireNonNull(mode, "Jigsaw Studio board mode");
workcellRole = optionalText(workcellRole);
workcellName = optionalText(workcellName);
variantName = optionalText(variantName);
state = Objects.requireNonNull(state, "Jigsaw Studio board state");
controlHint = optionalText(controlHint);
if (workcellName.isEmpty() && (!workcellRole.isEmpty() || !variantName.isEmpty())) {
throw new IllegalArgumentException("Jigsaw Studio board variants require a workcell");
}
}
public boolean insideWorkcell() {
return !workcellName.isEmpty();
}
public String modeDisplayName() {
return switch (mode) {
case PLANAR_JIGSAW -> "Planar";
case SPATIAL_JIGSAW -> "Spatial";
};
}
private static String requireText(String value, String name) {
String normalized = optionalText(value);
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio board " + name + " cannot be blank");
}
return normalized;
}
private static String optionalText(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,21 @@
package art.arcane.iris.core.service;
public enum JigsawStudioBoardState {
LOADING("Loading"),
SAVED("Saved"),
UNSAVED("Unsaved"),
SAVING("Saving"),
DISABLED("Disabled"),
INVALID("Invalid"),
READ_ONLY("Read-only");
private final String displayName;
JigsawStudioBoardState(String displayName) {
this.displayName = displayName;
}
public String displayName() {
return displayName;
}
}
@@ -0,0 +1,272 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.BlockDisplay;
import org.bukkit.entity.Display;
import org.bukkit.util.Transformation;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
public final class JigsawStudioDisabledWorkcellRenderer {
private static final String ENTITY_TAG = "iris_jigsaw_disabled_workcell";
private final Map<UUID, RequestDisplays> requests = new HashMap<>();
public void reconcile(World world, UUID requestId, JigsawStudioLayout layout) {
World activeWorld = Objects.requireNonNull(world, "Jigsaw Studio display world");
UUID activeRequestId = Objects.requireNonNull(requestId, "Jigsaw Studio display request ID");
Map<String, Descriptor> desired = descriptors(Objects.requireNonNull(
layout,
"Jigsaw Studio display layout"));
List<BlockDisplay> removals = new ArrayList<>();
long generation;
synchronized (this) {
RequestDisplays state = requests.computeIfAbsent(
activeRequestId,
ignored -> new RequestDisplays(activeWorld.getUID()));
if (!state.worldId.equals(activeWorld.getUID())) {
removals.addAll(state.entities.values());
state = new RequestDisplays(activeWorld.getUID());
requests.put(activeRequestId, state);
}
generation = Math.incrementExact(state.generation);
state.generation = generation;
state.desired.clear();
state.desired.putAll(desired);
for (Map.Entry<String, BlockDisplay> entry : new ArrayList<>(state.entities.entrySet())) {
Descriptor descriptor = desired.get(entry.getKey());
if (descriptor == null || !descriptor.equals(state.rendered.get(entry.getKey()))) {
state.entities.remove(entry.getKey());
state.rendered.remove(entry.getKey());
removals.add(entry.getValue());
}
}
}
remove(removals);
for (Descriptor descriptor : desired.values()) {
scheduleSpawn(activeWorld, activeRequestId, generation, descriptor);
}
}
public void unloadChunk(UUID requestId, int chunkX, int chunkZ) {
if (requestId == null) {
return;
}
List<BlockDisplay> removals;
synchronized (this) {
RequestDisplays state = requests.get(requestId);
if (state == null) {
return;
}
removals = detachChunkDisplays(state.entities, state.rendered, chunkX, chunkZ);
}
remove(removals);
}
public void removeRequest(UUID requestId) {
if (requestId == null) {
return;
}
RequestDisplays removed;
synchronized (this) {
removed = requests.remove(requestId);
}
if (removed != null) {
remove(new ArrayList<>(removed.entities.values()));
}
}
public void removeAll() {
List<BlockDisplay> removals = new ArrayList<>();
synchronized (this) {
for (RequestDisplays state : requests.values()) {
removals.addAll(state.entities.values());
}
requests.clear();
}
remove(removals);
}
static Map<String, Descriptor> descriptors(JigsawStudioLayout layout) {
Map<String, Descriptor> descriptors = new LinkedHashMap<>();
for (JigsawStudioBay bay : layout.bays()) {
if (bay.enabled()) {
continue;
}
JigsawStudioBounds bounds = bay.bounds();
descriptors.put(bay.stableId(), new Descriptor(
bay.stableId(),
bounds.originX(),
bounds.originY(),
bounds.originZ(),
bounds.dimensions().width(),
bounds.dimensions().height(),
bounds.dimensions().depth()));
}
return Map.copyOf(descriptors);
}
synchronized int activeDisplayCount(UUID requestId) {
RequestDisplays state = requests.get(requestId);
return state == null ? 0 : state.entities.size();
}
static List<BlockDisplay> detachChunkDisplays(
Map<String, BlockDisplay> entities,
Map<String, Descriptor> rendered,
int chunkX,
int chunkZ
) {
List<BlockDisplay> removals = new ArrayList<>();
for (Map.Entry<String, BlockDisplay> entry : new ArrayList<>(entities.entrySet())) {
Descriptor descriptor = rendered.get(entry.getKey());
if (descriptor == null
|| descriptor.originX() >> 4 != chunkX
|| descriptor.originZ() >> 4 != chunkZ) {
continue;
}
entities.remove(entry.getKey());
rendered.remove(entry.getKey());
removals.add(entry.getValue());
}
return List.copyOf(removals);
}
private void scheduleSpawn(
World world,
UUID requestId,
long generation,
Descriptor descriptor
) {
synchronized (this) {
RequestDisplays state = requests.get(requestId);
if (state == null
|| state.generation != generation
|| state.entities.containsKey(descriptor.workcellId())
|| !descriptor.equals(state.desired.get(descriptor.workcellId()))) {
return;
}
}
J.runRegion(
world,
descriptor.originX() >> 4,
descriptor.originZ() >> 4,
() -> spawn(world, requestId, generation, descriptor));
}
private void spawn(
World world,
UUID requestId,
long generation,
Descriptor descriptor
) {
if (!world.isChunkLoaded(descriptor.originX() >> 4, descriptor.originZ() >> 4)) {
return;
}
synchronized (this) {
RequestDisplays state = requests.get(requestId);
if (state == null
|| state.generation != generation
|| state.entities.containsKey(descriptor.workcellId())
|| !descriptor.equals(state.desired.get(descriptor.workcellId()))) {
return;
}
}
BlockDisplay display = world.spawn(
new Location(world, descriptor.originX(), descriptor.originY(), descriptor.originZ()),
BlockDisplay.class,
entity -> configure(entity, descriptor));
boolean retained;
synchronized (this) {
RequestDisplays state = requests.get(requestId);
retained = state != null
&& state.generation == generation
&& !state.entities.containsKey(descriptor.workcellId())
&& descriptor.equals(state.desired.get(descriptor.workcellId()));
if (retained) {
state.entities.put(descriptor.workcellId(), display);
state.rendered.put(descriptor.workcellId(), descriptor);
}
}
if (!retained) {
remove(display);
}
}
private static void configure(BlockDisplay display, Descriptor descriptor) {
display.setBlock(Material.RED_STAINED_GLASS.createBlockData());
display.setTransformation(new Transformation(
new Vector3f(),
new Quaternionf(),
new Vector3f(descriptor.width(), descriptor.height(), descriptor.depth()),
new Quaternionf()));
display.setBrightness(new Display.Brightness(15, 15));
display.setDisplayWidth(Math.max(descriptor.width(), descriptor.depth()));
display.setDisplayHeight(descriptor.height());
display.setViewRange(128.0F);
display.setShadowRadius(0.0F);
display.setShadowStrength(0.0F);
display.setInterpolationDuration(0);
display.setTeleportDuration(0);
display.setPersistent(false);
display.setInvulnerable(true);
display.setGravity(false);
display.setSilent(true);
display.addScoreboardTag(ENTITY_TAG);
}
private static void remove(List<BlockDisplay> displays) {
for (BlockDisplay display : displays) {
remove(display);
}
}
private static void remove(BlockDisplay display) {
if (display != null) {
J.runEntity(display, display::remove);
}
}
record Descriptor(
String workcellId,
int originX,
int originY,
int originZ,
int width,
int height,
int depth
) {
Descriptor {
workcellId = Objects.requireNonNull(workcellId, "Jigsaw Studio display workcell ID");
if (width < 1 || height < 1 || depth < 1) {
throw new IllegalArgumentException("Jigsaw Studio display dimensions must be positive");
}
}
}
private static final class RequestDisplays {
private final UUID worldId;
private final Map<String, Descriptor> desired = new HashMap<>();
private final Map<String, Descriptor> rendered = new HashMap<>();
private final Map<String, BlockDisplay> entities = new HashMap<>();
private long generation;
private RequestDisplays(UUID worldId) {
this.worldId = worldId;
}
}
}
@@ -0,0 +1,9 @@
package art.arcane.iris.core.service;
public enum JigsawStudioEvaluationState {
PENDING,
VALID,
WARNING,
INVALID,
STALE
}
@@ -0,0 +1,50 @@
package art.arcane.iris.core.service;
import java.util.Objects;
import java.util.UUID;
public record JigsawStudioGraphEvaluation(
UUID requestId,
long generation,
long seed,
JigsawStudioEvaluationState state,
String selectedTheme,
int pieceCount,
String detail,
JigsawStudioPreviewRenderer.PreviewBounds previewBounds
) {
public JigsawStudioGraphEvaluation {
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio evaluation request ID");
if (generation < 1L) {
throw new IllegalArgumentException("Jigsaw Studio evaluation generation must be positive");
}
state = Objects.requireNonNull(state, "Jigsaw Studio evaluation state");
selectedTheme = normalize(selectedTheme);
if (pieceCount < 0) {
throw new IllegalArgumentException("Jigsaw Studio evaluation piece count cannot be negative");
}
detail = normalize(detail);
previewBounds = Objects.requireNonNull(previewBounds, "Jigsaw Studio evaluation preview bounds");
}
public JigsawStudioGraphEvaluation stale(String reason) {
return new JigsawStudioGraphEvaluation(
requestId,
generation,
seed,
JigsawStudioEvaluationState.STALE,
selectedTheme,
pieceCount,
reason,
previewBounds);
}
public boolean successful() {
return state == JigsawStudioEvaluationState.VALID
|| state == JigsawStudioEvaluationState.WARNING;
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,105 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMarkerKeyCodec;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.JigsawJoint;
import org.bukkit.block.Orientation;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
final class JigsawStudioMarkerParser {
private JigsawStudioMarkerParser() {
}
static IrisJigsawConnector parse(
Map<String, Object> nbt,
Orientation orientation,
int x,
int y,
int z
) {
Map<String, Object> properties = Objects.requireNonNull(nbt, "Jigsaw marker NBT");
Directions directions = directions(orientation);
String jointName = requiredString(properties, "joint").toUpperCase(Locale.ROOT);
JigsawJoint joint;
try {
joint = JigsawJoint.valueOf(jointName);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("Unsupported jigsaw joint '" + jointName + "'", exception);
}
return new IrisJigsawConnector()
.setPosition(new IrisPosition(x, y, z))
.setDirection(directions.front())
.setTop(directions.top())
.setPool(JigsawStudioMarkerKeyCodec.decodePool(requiredString(properties, "pool")))
.setName(requiredString(properties, "name"))
.setTargetName(requiredString(properties, "target"))
.setChannel(optionalString(properties, "channel"))
.setJoint(joint)
.setFinalState(requiredString(properties, "final_state"))
.setSelectionPriority(optionalInt(properties, "selection_priority"))
.setPlacementPriority(optionalInt(properties, "placement_priority"));
}
static Directions directions(Orientation orientation) {
return switch (Objects.requireNonNull(orientation, "Jigsaw orientation")) {
case DOWN_EAST -> new Directions(IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.EAST_POSITIVE_X);
case DOWN_NORTH -> new Directions(IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.NORTH_NEGATIVE_Z);
case DOWN_SOUTH -> new Directions(IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.SOUTH_POSITIVE_Z);
case DOWN_WEST -> new Directions(IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.WEST_NEGATIVE_X);
case UP_EAST -> new Directions(IrisDirection.UP_POSITIVE_Y, IrisDirection.EAST_POSITIVE_X);
case UP_NORTH -> new Directions(IrisDirection.UP_POSITIVE_Y, IrisDirection.NORTH_NEGATIVE_Z);
case UP_SOUTH -> new Directions(IrisDirection.UP_POSITIVE_Y, IrisDirection.SOUTH_POSITIVE_Z);
case UP_WEST -> new Directions(IrisDirection.UP_POSITIVE_Y, IrisDirection.WEST_NEGATIVE_X);
case WEST_UP -> new Directions(IrisDirection.WEST_NEGATIVE_X, IrisDirection.UP_POSITIVE_Y);
case EAST_UP -> new Directions(IrisDirection.EAST_POSITIVE_X, IrisDirection.UP_POSITIVE_Y);
case NORTH_UP -> new Directions(IrisDirection.NORTH_NEGATIVE_Z, IrisDirection.UP_POSITIVE_Y);
case SOUTH_UP -> new Directions(IrisDirection.SOUTH_POSITIVE_Z, IrisDirection.UP_POSITIVE_Y);
};
}
private static String requiredString(Map<String, Object> properties, String key) {
Object value = properties.get(key);
if (!(value instanceof String stringValue) || stringValue.isBlank()) {
throw new IllegalArgumentException("Jigsaw marker requires non-empty string NBT '" + key + "'");
}
return stringValue.trim();
}
private static String optionalString(Map<String, Object> properties, String key) {
Object value = properties.get(key);
if (value == null) {
return "";
}
if (!(value instanceof String stringValue)) {
throw new IllegalArgumentException("Jigsaw marker NBT '" + key + "' must be a string");
}
return stringValue.trim();
}
private static int optionalInt(Map<String, Object> properties, String key) {
Object value = properties.get(key);
if (value == null) {
return 0;
}
if (!(value instanceof Number number)) {
throw new IllegalArgumentException("Jigsaw marker NBT '" + key + "' must be an integer");
}
long longValue = number.longValue();
if (longValue < Integer.MIN_VALUE || longValue > Integer.MAX_VALUE) {
throw new IllegalArgumentException("Jigsaw marker NBT '" + key + "' is outside the integer range");
}
return (int) longValue;
}
record Directions(IrisDirection front, IrisDirection top) {
Directions {
Objects.requireNonNull(front, "Jigsaw front direction");
Objects.requireNonNull(top, "Jigsaw top direction");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,289 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public record JigsawStudioMenuState(
UUID worldId,
UUID requestId,
String structureKey,
JigsawStudioMode mode,
JigsawStudioCompatibilityTarget compatibilityTarget,
boolean requireCaps,
List<ThemeSet> themeSets,
String selectedWorkcellId,
Evaluation evaluation,
List<Workcell> workcells
) {
public JigsawStudioMenuState {
worldId = Objects.requireNonNull(worldId, "Jigsaw Studio menu world ID");
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio menu request ID");
structureKey = requireText(structureKey, "structure key");
mode = Objects.requireNonNull(mode, "Jigsaw Studio menu mode");
compatibilityTarget = Objects.requireNonNull(
compatibilityTarget,
"Jigsaw Studio menu compatibility target");
themeSets = List.copyOf(Objects.requireNonNull(themeSets, "Jigsaw Studio menu theme sets"));
selectedWorkcellId = optionalText(selectedWorkcellId);
evaluation = Objects.requireNonNull(evaluation, "Jigsaw Studio menu evaluation");
workcells = List.copyOf(Objects.requireNonNull(workcells, "Jigsaw Studio menu workcells"));
Set<String> workcellIds = new HashSet<>();
for (Workcell workcell : workcells) {
Workcell resolved = Objects.requireNonNull(workcell, "Jigsaw Studio menu workcell");
if (!workcellIds.add(resolved.stableId())) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio menu workcell " + resolved.stableId());
}
}
if (!selectedWorkcellId.isEmpty() && !workcellIds.contains(selectedWorkcellId)) {
throw new IllegalArgumentException("Selected Jigsaw Studio menu workcell is not present");
}
Set<String> themeKeys = new HashSet<>();
for (ThemeSet themeSet : themeSets) {
ThemeSet resolved = Objects.requireNonNull(themeSet, "Jigsaw Studio menu theme set");
if (!themeKeys.add(resolved.key())) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio menu theme " + resolved.key());
}
}
}
public Workcell selectedWorkcell() {
if (selectedWorkcellId.isEmpty()) {
return null;
}
return workcell(selectedWorkcellId);
}
public boolean irisExtended() {
return compatibilityTarget == JigsawStudioCompatibilityTarget.IRIS_EXTENDED;
}
public Workcell workcell(String stableId) {
if (stableId == null) {
return null;
}
for (Workcell workcell : workcells) {
if (workcell.stableId().equals(stableId)) {
return workcell;
}
}
return null;
}
public ThemeSet themeSet(String key) {
if (key == null) {
return null;
}
for (ThemeSet themeSet : themeSets) {
if (themeSet.key().equals(key)) {
return themeSet;
}
}
return null;
}
public record ThemeSet(String key, int weight) {
public ThemeSet {
key = requireText(key, "theme key");
if (weight < 1) {
throw new IllegalArgumentException("Jigsaw Studio menu theme weight must be positive");
}
}
}
public record Evaluation(
JigsawStudioEvaluationState state,
long generation,
long seed,
String selectedTheme,
int pieceCount,
String detail
) {
public Evaluation {
state = Objects.requireNonNull(state, "Jigsaw Studio evaluation state");
if (generation < 0L) {
throw new IllegalArgumentException("Jigsaw Studio evaluation generation cannot be negative");
}
selectedTheme = optionalText(selectedTheme);
if (pieceCount < 0) {
throw new IllegalArgumentException("Jigsaw Studio evaluation piece count cannot be negative");
}
detail = optionalText(detail);
}
public static Evaluation pending() {
return new Evaluation(
JigsawStudioEvaluationState.PENDING,
0L,
1337L,
"",
0,
"Iris evaluates the graph automatically as authoring state changes.");
}
public static Evaluation from(JigsawStudioGraphEvaluation evaluation) {
JigsawStudioGraphEvaluation source = Objects.requireNonNull(
evaluation,
"Jigsaw Studio graph evaluation");
return new Evaluation(
source.state(),
source.generation(),
source.seed(),
source.selectedTheme(),
source.pieceCount(),
source.detail());
}
}
public record Workcell(
String stableId,
String canonicalName,
String displayName,
JigsawStudioCellDimensions capacity,
boolean enabled,
String activeVariantKey,
boolean dirty,
boolean saving,
boolean loading,
List<Variant> variants
) {
public Workcell {
stableId = requireText(stableId, "workcell ID");
canonicalName = requireText(canonicalName, "workcell canonical name");
displayName = requireText(displayName, "workcell display name");
capacity = Objects.requireNonNull(capacity, "Jigsaw Studio menu workcell capacity");
activeVariantKey = optionalText(activeVariantKey);
variants = List.copyOf(Objects.requireNonNull(variants, "Jigsaw Studio menu variants"));
Set<String> variantKeys = new HashSet<>();
int activeVariants = 0;
for (Variant variant : variants) {
Variant resolved = Objects.requireNonNull(variant, "Jigsaw Studio menu variant");
if (!variantKeys.add(resolved.pieceKey())) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio menu variant " + resolved.pieceKey());
}
if (resolved.active()) {
activeVariants++;
if (!resolved.pieceKey().equals(activeVariantKey)) {
throw new IllegalArgumentException("Active Jigsaw Studio menu variant key does not match");
}
}
}
if ((activeVariantKey.isEmpty() && activeVariants != 0)
|| (!activeVariantKey.isEmpty() && activeVariants != 1)) {
throw new IllegalArgumentException("Jigsaw Studio menu workcell active variant is inconsistent");
}
}
public Variant activeVariant() {
if (activeVariantKey.isEmpty()) {
return null;
}
for (Variant variant : variants) {
if (variant.pieceKey().equals(activeVariantKey)) {
return variant;
}
}
return null;
}
public boolean busy() {
return saving || loading;
}
}
public record Variant(
String pieceKey,
String displayName,
Optional<JigsawStudioCellDimensions> dimensions,
boolean active,
boolean owned,
boolean rotatable,
boolean rotationEditable,
boolean resizableToCapacity,
List<String> themes,
JigsawStudioPieceRules rules,
List<Membership> memberships
) {
public Variant {
pieceKey = requireText(pieceKey, "variant piece key");
displayName = requireText(displayName, "variant display name");
dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio menu variant dimensions");
List<String> resolvedThemes = new ArrayList<>();
Set<String> uniqueThemes = new HashSet<>();
for (String theme : Objects.requireNonNull(themes, "Jigsaw Studio menu variant themes")) {
String resolvedTheme = requireText(theme, "variant theme");
if (!uniqueThemes.add(resolvedTheme)) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio menu variant theme "
+ resolvedTheme);
}
resolvedThemes.add(resolvedTheme);
}
themes = List.copyOf(resolvedThemes);
rules = Objects.requireNonNull(rules, "Jigsaw Studio menu variant rules");
memberships = List.copyOf(Objects.requireNonNull(
memberships,
"Jigsaw Studio menu variant memberships"));
if (rotationEditable && !owned) {
throw new IllegalArgumentException("Read-only Jigsaw Studio variants cannot edit rotation");
}
if (resizableToCapacity && (!owned || !active)) {
throw new IllegalArgumentException(
"Only the active owned Jigsaw Studio variant can resize to its workcell capacity");
}
Set<MembershipIdentity> identities = new HashSet<>();
for (Membership membership : memberships) {
Membership resolved = Objects.requireNonNull(
membership,
"Jigsaw Studio menu variant membership");
MembershipIdentity identity = new MembershipIdentity(resolved.poolKey(), resolved.entryIndex());
if (!identities.add(identity)) {
throw new IllegalArgumentException("Duplicate Jigsaw Studio menu membership "
+ resolved.poolKey() + "[" + resolved.entryIndex() + "]");
}
}
}
}
public record Membership(String poolKey, int entryIndex, int weight, double chance) {
public Membership {
poolKey = requireText(poolKey, "membership pool key");
if (entryIndex < 0) {
throw new IllegalArgumentException("Jigsaw Studio menu membership index cannot be negative");
}
if (weight < 1) {
throw new IllegalArgumentException("Jigsaw Studio menu membership weight must be positive");
}
if (!Double.isFinite(chance) || chance < 0D || chance > 1D) {
throw new IllegalArgumentException("Jigsaw Studio menu membership chance must be within 0 and 1");
}
}
}
private record MembershipIdentity(String poolKey, int entryIndex) {
}
private static String requireText(String value, String name) {
String normalized = optionalText(value);
if (normalized.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio menu " + name + " cannot be blank");
}
return normalized;
}
private static String optionalText(String value) {
return value == null ? "" : value.trim();
}
}
@@ -0,0 +1,455 @@
package art.arcane.iris.core.service;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisObjectRotation;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.data.B;
import art.arcane.iris.util.common.math.IrisBlockVector;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
public final class JigsawStudioPreviewRenderer {
private static final int MAX_BLOCKS = 250_000;
private static final String AIR = "minecraft:air";
private static final String STRUCTURE_VOID = "minecraft:structure_void";
private final Map<UUID, RenderState> requests = new HashMap<>();
public static PreviewPlan plan(List<PlacedStructurePiece> pieces) throws IOException {
List<PlacedStructurePiece> source = List.copyOf(Objects.requireNonNull(
pieces,
"Jigsaw Studio preview pieces"));
if (source.isEmpty()) {
return PreviewPlan.empty();
}
Map<BlockPosition, String> blocks = new LinkedHashMap<>();
int minimumX = Integer.MAX_VALUE;
int minimumY = Integer.MAX_VALUE;
int minimumZ = Integer.MAX_VALUE;
int maximumX = Integer.MIN_VALUE;
int maximumY = Integer.MIN_VALUE;
int maximumZ = Integer.MIN_VALUE;
for (PlacedStructurePiece piece : source) {
if (piece == null || piece.getObject() == null || piece.getPiece() == null
|| piece.getRotation() == null) {
throw new IOException("Jigsaw Studio preview contains an incomplete placed piece");
}
minimumX = Math.min(minimumX, piece.getMinX());
minimumY = Math.min(minimumY, piece.getMinY());
minimumZ = Math.min(minimumZ, piece.getMinZ());
maximumX = Math.max(maximumX, piece.getMaxX());
maximumY = Math.max(maximumY, piece.getMaxY());
maximumZ = Math.max(maximumZ, piece.getMaxZ());
appendObject(blocks, piece);
appendFinalStates(blocks, piece);
if (blocks.size() > MAX_BLOCKS) {
throw new IOException("The seed-1337 preview exceeds the Studio render limit of "
+ MAX_BLOCKS + " explicit blocks");
}
}
return new PreviewPlan(
Map.copyOf(blocks),
new PreviewBounds(minimumX, minimumY, minimumZ, maximumX, maximumY, maximumZ));
}
public void render(
World world,
UUID requestId,
long generation,
PreviewPlan plan,
Consumer<RenderResult> completion
) {
World activeWorld = Objects.requireNonNull(world, "Jigsaw Studio preview world");
UUID activeRequestId = Objects.requireNonNull(requestId, "Jigsaw Studio preview request ID");
PreviewPlan activePlan = Objects.requireNonNull(plan, "Jigsaw Studio preview plan");
Consumer<RenderResult> callback = Objects.requireNonNull(completion, "Jigsaw Studio preview callback");
Map<BlockPosition, String> previous;
Set<BlockPosition> uncertain;
Map<Long, List<BlockUpdate>> updates;
synchronized (this) {
RenderState old = requests.get(activeRequestId);
previous = old == null || !old.worldId().equals(activeWorld.getUID())
? Map.of()
: old.blocks();
uncertain = old == null || !old.worldId().equals(activeWorld.getUID())
? Set.of()
: Set.copyOf(old.pending());
updates = updates(previous, activePlan.blocks(), uncertain);
Set<BlockPosition> pending = new HashSet<>();
for (List<BlockUpdate> chunkUpdates : updates.values()) {
for (BlockUpdate update : chunkUpdates) {
pending.add(update.position());
}
}
requests.put(activeRequestId, new RenderState(
activeWorld.getUID(), generation, activePlan.blocks(), activePlan.bounds(), pending));
}
if (updates.isEmpty()) {
callback.accept(new RenderResult(true, 0, ""));
return;
}
AtomicInteger remaining = new AtomicInteger(updates.size());
AtomicBoolean failed = new AtomicBoolean();
for (Map.Entry<Long, List<BlockUpdate>> chunk : updates.entrySet()) {
int chunkX = chunkX(chunk.getKey());
int chunkZ = chunkZ(chunk.getKey());
boolean scheduled = J.runRegion(
activeWorld,
chunkX,
chunkZ,
() -> applyChunk(
activeWorld,
activeRequestId,
generation,
chunk.getValue(),
remaining,
failed,
callback));
if (!scheduled) {
failed.set(true);
if (remaining.decrementAndGet() == 0) {
callback.accept(new RenderResult(
false,
activePlan.blocks().size(),
"One or more preview chunks could not be scheduled"));
}
}
}
}
public synchronized PreviewBounds bounds(UUID requestId) {
RenderState state = requests.get(requestId);
return state == null ? null : state.bounds();
}
public synchronized boolean contains(UUID requestId, int x, int y, int z) {
RenderState state = requests.get(requestId);
return state != null && state.bounds().contains(x, y, z);
}
public void removeRequest(UUID requestId) {
if (requestId == null) {
return;
}
RenderState removed;
synchronized (this) {
removed = requests.remove(requestId);
}
if (removed != null) {
World world = Bukkit.getWorld(removed.worldId());
if (world != null) {
clear(world, removalPositions(removed));
}
}
}
void forgetRequest(UUID requestId) {
if (requestId == null) {
return;
}
synchronized (this) {
requests.remove(requestId);
}
}
public void removeAll() {
Map<UUID, RenderState> removed;
synchronized (this) {
removed = Map.copyOf(requests);
requests.clear();
}
for (RenderState state : removed.values()) {
World world = Bukkit.getWorld(state.worldId());
if (world != null) {
clear(world, removalPositions(state));
}
}
}
private static void appendObject(
Map<BlockPosition, String> blocks,
PlacedStructurePiece piece
) throws IOException {
IrisObject object = piece.getObject();
IrisObjectRotation rotation = piece.getRotation();
for (Map.Entry<IrisBlockVector, PlatformBlockState> entry : object.getBlocks()) {
IrisBlockVector rotated = rotation.rotate(entry.getKey());
PlatformBlockState state = rotation.rotate(entry.getValue(), 0, 0, 0);
putState(
blocks,
new BlockPosition(
piece.getX() + rotated.getBlockX(),
piece.getY() + rotated.getBlockY(),
piece.getZ() + rotated.getBlockZ()),
state,
"object block");
}
}
private static void appendFinalStates(
Map<BlockPosition, String> blocks,
PlacedStructurePiece piece
) throws IOException {
IrisObject object = piece.getObject();
IrisObjectRotation rotation = piece.getRotation();
if (piece.getPiece().getConnectors() == null) {
return;
}
for (IrisJigsawConnector connector : piece.getPiece().getConnectors()) {
if (connector == null || connector.getPosition() == null) {
throw new IOException("Jigsaw Studio preview contains an incomplete connector");
}
IrisBlockVector signed = new IrisBlockVector(
connector.getPosition().getX() - object.getCenter().getBlockX(),
connector.getPosition().getY() - object.getCenter().getBlockY(),
connector.getPosition().getZ() - object.getCenter().getBlockZ());
IrisBlockVector rotated = rotation.rotate(signed);
PlatformBlockState source = B.getStateOrNull(connector.getFinalState(), false);
PlatformBlockState state = source == null ? null : rotation.rotate(source, 0, 0, 0);
putState(
blocks,
new BlockPosition(
piece.getX() + rotated.getBlockX(),
piece.getY() + rotated.getBlockY(),
piece.getZ() + rotated.getBlockZ()),
state,
"connector final state");
}
}
private static void putState(
Map<BlockPosition, String> blocks,
BlockPosition position,
PlatformBlockState state,
String source
) throws IOException {
if (state == null || state.key() == null || state.key().isBlank()) {
throw new IOException("Jigsaw Studio preview could not resolve a " + source);
}
String key = state.key();
if (AIR.equals(key) || STRUCTURE_VOID.equals(key)) {
blocks.remove(position);
return;
}
blocks.put(position, key);
}
private void applyChunk(
World world,
UUID requestId,
long generation,
List<BlockUpdate> updates,
AtomicInteger remaining,
AtomicBoolean failed,
Consumer<RenderResult> completion
) {
int changed = 0;
List<BlockPosition> applied = new ArrayList<>(updates.size());
try {
if (!isCurrent(world.getUID(), requestId, generation)) {
return;
}
for (BlockUpdate update : updates) {
Block block = world.getBlockAt(update.position().x(), update.position().y(), update.position().z());
BlockData data = Bukkit.createBlockData(update.stateKey());
block.setBlockData(data, false);
applied.add(update.position());
changed++;
}
} catch (RuntimeException exception) {
failed.set(true);
IrisLogging.reportError(exception);
} finally {
synchronized (this) {
RenderState active = requests.get(requestId);
if (active != null
&& active.worldId().equals(world.getUID())
&& active.generation() == generation) {
active.pending().removeAll(applied);
}
}
if (remaining.decrementAndGet() == 0) {
RenderState state;
boolean pendingEmpty;
synchronized (this) {
state = requests.get(requestId);
pendingEmpty = state != null && state.pending().isEmpty();
}
boolean current = state != null
&& state.worldId().equals(world.getUID())
&& state.generation() == generation;
completion.accept(new RenderResult(
current && pendingEmpty && !failed.get(),
current ? state.blocks().size() : changed,
failed.get() || current && !pendingEmpty
? "One or more preview blocks could not be rendered"
: ""));
}
}
}
private synchronized boolean isCurrent(UUID worldId, UUID requestId, long generation) {
RenderState state = requests.get(requestId);
return state != null && state.worldId().equals(worldId) && state.generation() == generation;
}
private static Map<Long, List<BlockUpdate>> updates(
Map<BlockPosition, String> previous,
Map<BlockPosition, String> next,
Set<BlockPosition> uncertain
) {
Set<BlockPosition> positions = new HashSet<>(previous.keySet());
positions.addAll(next.keySet());
positions.addAll(uncertain);
Map<Long, List<BlockUpdate>> updates = new HashMap<>();
for (BlockPosition position : positions) {
String nextState = next.getOrDefault(position, AIR);
if (!requiresUpdate(position, previous, next, uncertain)) {
continue;
}
updates.computeIfAbsent(
chunkKey(position.x() >> 4, position.z() >> 4),
ignored -> new ArrayList<>()).add(new BlockUpdate(position, nextState));
}
return updates;
}
static boolean requiresUpdate(
BlockPosition position,
Map<BlockPosition, String> previous,
Map<BlockPosition, String> next,
Set<BlockPosition> uncertain
) {
return uncertain.contains(position)
|| !next.getOrDefault(position, AIR).equals(previous.get(position));
}
private static void clear(World world, Set<BlockPosition> positions) {
Map<Long, List<BlockUpdate>> updates = new HashMap<>();
for (BlockPosition position : positions) {
updates.computeIfAbsent(
chunkKey(position.x() >> 4, position.z() >> 4),
ignored -> new ArrayList<>()).add(new BlockUpdate(position, AIR));
}
for (Map.Entry<Long, List<BlockUpdate>> chunk : updates.entrySet()) {
J.runRegion(world, chunkX(chunk.getKey()), chunkZ(chunk.getKey()), () -> {
for (BlockUpdate update : chunk.getValue()) {
world.getBlockAt(
update.position().x(),
update.position().y(),
update.position().z())
.setType(Material.AIR, false);
}
});
}
}
private static Set<BlockPosition> removalPositions(RenderState state) {
Set<BlockPosition> positions = new HashSet<>(state.blocks().keySet());
positions.addAll(state.pending());
return Set.copyOf(positions);
}
private static long chunkKey(int chunkX, int chunkZ) {
return ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL);
}
private static int chunkX(long chunkKey) {
return (int) (chunkKey >> 32);
}
private static int chunkZ(long chunkKey) {
return (int) chunkKey;
}
public record PreviewPlan(Map<BlockPosition, String> blocks, PreviewBounds bounds) {
public PreviewPlan {
blocks = Map.copyOf(Objects.requireNonNull(blocks, "Jigsaw Studio preview blocks"));
bounds = Objects.requireNonNull(bounds, "Jigsaw Studio preview bounds");
}
static PreviewPlan empty() {
return new PreviewPlan(Map.of(), PreviewBounds.empty());
}
}
public record PreviewBounds(
int minimumX,
int minimumY,
int minimumZ,
int maximumX,
int maximumY,
int maximumZ
) {
static PreviewBounds empty() {
return new PreviewBounds(0, 0, 0, -1, -1, -1);
}
public boolean isEmpty() {
return maximumX < minimumX || maximumY < minimumY || maximumZ < minimumZ;
}
public int centerX() {
return isEmpty() ? 0 : minimumX + (maximumX - minimumX) / 2;
}
public int centerZ() {
return isEmpty() ? 0 : minimumZ + (maximumZ - minimumZ) / 2;
}
public boolean contains(int x, int y, int z) {
return !isEmpty()
&& x >= minimumX && x <= maximumX
&& y >= minimumY && y <= maximumY
&& z >= minimumZ && z <= maximumZ;
}
}
public record BlockPosition(int x, int y, int z) {
}
public record RenderResult(boolean successful, int blockCount, String failure) {
public RenderResult {
failure = failure == null ? "" : failure;
}
}
private record RenderState(
UUID worldId,
long generation,
Map<BlockPosition, String> blocks,
PreviewBounds bounds,
Set<BlockPosition> pending
) {
private RenderState {
Objects.requireNonNull(worldId, "Jigsaw Studio preview world ID");
blocks = Map.copyOf(Objects.requireNonNull(blocks, "Jigsaw Studio preview state blocks"));
bounds = Objects.requireNonNull(bounds, "Jigsaw Studio preview state bounds");
pending = Objects.requireNonNull(pending, "Jigsaw Studio preview pending blocks");
}
}
private record BlockUpdate(BlockPosition position, String stateKey) {
}
}
@@ -0,0 +1,185 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.pack.StructurePackageClosure;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioAuthoringAccess;
import art.arcane.iris.core.structure.authoring.StructureCapability;
import art.arcane.iris.core.structure.authoring.StructureHash;
import art.arcane.iris.core.structure.authoring.StructureKey;
import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest;
import art.arcane.iris.core.structure.authoring.StructureResourceBundle;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.volmlib.util.collection.KList;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
final class JigsawStudioResourceBundleAssembler {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private JigsawStudioResourceBundleAssembler() {
}
static Assembly assemble(
Path packRoot,
String structureKey,
String pieceKey,
byte[] objectContent,
List<IrisJigsawConnector> connectors,
boolean hasBlockEntities
) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root").toAbsolutePath().normalize();
String rootStructure = requireResourceKey(structureKey, "structure");
String editedPiece = requireResourceKey(pieceKey, "piece");
StructureKey ownershipKey = new StructureKey("iris", rootStructure);
StructureTransactionWriter writer = new StructureTransactionWriter(root);
Path manifestPath = writer.ownershipManifestPath(ownershipKey);
if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Jigsaw Studio cannot save '" + rootStructure
+ "' because it is not Studio-owned. Existing unowned graphs are read-only; create a new Jigsaw Studio project to author in-game.");
}
byte[] manifestContent = Files.readAllBytes(manifestPath);
StructureOwnershipManifest manifest;
try {
manifest = StructureOwnershipManifest.fromJson(manifestContent);
} catch (RuntimeException exception) {
throw new IOException("Jigsaw Studio cannot read the ownership manifest for '" + rootStructure + "'", exception);
}
JigsawStudioAuthoringAccess.requireEditable(manifest);
if (!manifest.structure().equals(ownershipKey)) {
throw new IOException("Jigsaw Studio ownership manifest belongs to " + manifest.structure()
+ ", not " + ownershipKey);
}
StructurePackageClosure closure = StructurePackageClosure.collect(root.toFile(), List.of(rootStructure));
if (!closure.isValid()) {
throw new IOException("Jigsaw Studio cannot save an invalid structure graph: "
+ String.join("; ", closure.errors()));
}
Set<String> reachablePaths = resourcePaths(closure);
for (String relativePath : reachablePaths) {
if (!manifest.resourceHashes().containsKey(relativePath)) {
throw new IOException("Jigsaw Studio ownership conflict: reachable resource '" + relativePath
+ "' is not owned by structure '" + rootStructure + "'.");
}
}
String piecePath = "jigsaw-pieces/" + editedPiece + ".json";
if (!manifest.resourceHashes().containsKey(piecePath)) {
throw new IOException("Jigsaw Studio ownership conflict: piece '" + editedPiece
+ "' is not owned by structure '" + rootStructure + "'.");
}
Path absolutePiecePath = resolveOwnedResource(root, piecePath);
IrisJigsawPiece piece;
JsonObject pieceJson;
try {
JsonElement parsed = GSON.fromJson(
Files.readString(absolutePiecePath, StandardCharsets.UTF_8),
JsonElement.class);
if (parsed == null || !parsed.isJsonObject()) {
throw new IllegalArgumentException("Jigsaw piece is not a JSON object");
}
pieceJson = parsed.getAsJsonObject();
piece = GSON.fromJson(pieceJson, IrisJigsawPiece.class);
} catch (RuntimeException exception) {
throw new IOException("Jigsaw Studio cannot parse piece '" + editedPiece + "'", exception);
}
if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) {
throw new IOException("Jigsaw Studio piece '" + editedPiece + "' does not declare an object");
}
String objectKey = requireResourceKey(piece.getObject(), "object");
String objectPath = "objects/" + objectKey + ".iob";
if (!manifest.resourceHashes().containsKey(objectPath)) {
throw new IOException("Jigsaw Studio ownership conflict: object '" + objectKey
+ "' is not owned by structure '" + rootStructure + "'.");
}
piece.setConnectors(new KList<>(List.copyOf(connectors)));
pieceJson.add("connectors", GSON.toJsonTree(piece.getConnectors()));
StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(ownershipKey)
.source(manifest.source())
.backend(manifest.backend())
.capabilities(manifest.capabilities())
.losses(manifest.losses())
.capability(StructureCapability.CONNECTORS);
if (hasBlockEntities) {
bundle.capability(StructureCapability.BLOCK_ENTITIES);
}
for (String relativePath : manifest.resourceHashes().keySet()) {
if (relativePath.equals(piecePath)) {
bundle.textResource(relativePath, GSON.toJson(pieceJson) + "\n");
} else if (relativePath.equals(objectPath)) {
bundle.resource(relativePath, objectContent);
} else {
Path resource = resolveOwnedResource(root, relativePath);
bundle.resource(relativePath, Files.readAllBytes(resource));
}
}
return new Assembly(bundle.build(), objectKey, piece, StructureHash.sha256(manifestContent));
}
private static Set<String> resourcePaths(StructurePackageClosure closure) {
Set<String> resources = new LinkedHashSet<>();
addPaths(resources, "structures", closure.structures(), ".json");
addPaths(resources, "jigsaw-pools", closure.pools(), ".json");
addPaths(resources, "jigsaw-pieces", closure.pieces(), ".json");
addPaths(resources, "objects", closure.objects(), ".iob");
addPaths(resources, "loot", closure.loot(), ".json");
return resources;
}
private static void addPaths(Set<String> resources, String folder, Set<String> keys, String extension) {
for (String key : keys) {
resources.add(folder + "/" + key + extension);
}
}
private static Path resolveOwnedResource(Path root, String relativePath) throws IOException {
StructureResourceBundle.validateRelativePath(relativePath);
Path resource = root.resolve(relativePath).normalize();
if (!resource.startsWith(root)) {
throw new IOException("Jigsaw Studio resource escapes its pack root: " + relativePath);
}
if (!Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Jigsaw Studio owned resource is missing or not a regular file: " + relativePath);
}
Path realRoot = root.toRealPath();
Path realResource = resource.toRealPath();
if (!realResource.startsWith(realRoot)) {
throw new IOException("Jigsaw Studio owned resource escapes through a symbolic link: " + relativePath);
}
return resource;
}
private static String requireResourceKey(String key, String kind) {
String normalized = Objects.requireNonNull(key, "Jigsaw Studio " + kind + " key").trim();
StructureResourceBundle.validateRelativePath(normalized);
return normalized;
}
record Assembly(
StructureResourceBundle bundle,
String objectKey,
IrisJigsawPiece piece,
String expectedManifestHash
) {
Assembly {
Objects.requireNonNull(bundle, "Jigsaw Studio resource bundle");
Objects.requireNonNull(objectKey, "Jigsaw Studio object key");
Objects.requireNonNull(piece, "Jigsaw Studio piece");
Objects.requireNonNull(expectedManifestHash, "Jigsaw Studio expected manifest hash");
}
}
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More