mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
dwa
(removed ai file from when i generated docs)
This commit is contained in:
@@ -62,3 +62,5 @@ __pycache__/
|
||||
|
||||
# Doc authoring scratch
|
||||
.docs-wip/
|
||||
|
||||
AGENTS.md
|
||||
|
||||
@@ -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 1–4 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.
|
||||
+289
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+302
-18
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+128
-2
@@ -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()
|
||||
|
||||
+303
-3
@@ -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")));
|
||||
|
||||
+334
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+294
@@ -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());
|
||||
}
|
||||
}
|
||||
+77
-50
@@ -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;
|
||||
|
||||
+2187
File diff suppressed because it is too large
Load Diff
+3
-4
@@ -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) {
|
||||
|
||||
+15
-7
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+91
@@ -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());
|
||||
}
|
||||
}
|
||||
+51
-3
@@ -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(),
|
||||
|
||||
+33
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+589
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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")));
|
||||
|
||||
+6
@@ -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)"));
|
||||
|
||||
+41
@@ -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));
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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
|
||||
|
||||
+21
-2
@@ -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);
|
||||
|
||||
@@ -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.
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
+8
-5
@@ -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;
|
||||
}
|
||||
|
||||
+24
-17
@@ -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) {
|
||||
|
||||
+74
@@ -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());
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -416,7 +416,7 @@ public class NativeStructureReferenceRepairTest {
|
||||
private IrisData data;
|
||||
|
||||
private TestEngine() {
|
||||
super(null, false);
|
||||
super(null, InitializationMode.RUNTIME);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package art.arcane.iris.core.runtime.jigsaw;
|
||||
|
||||
public enum JigsawStudioCompatibilityTarget {
|
||||
IRIS_EXTENDED,
|
||||
VANILLA_PORTABLE
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package art.arcane.iris.core.runtime.jigsaw;
|
||||
|
||||
public record JigsawStudioControlPosition(int worldX, int worldY, int worldZ) {
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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) {
|
||||
}
|
||||
}
|
||||
+22
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+227
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+435
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+465
@@ -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;
|
||||
}
|
||||
}
|
||||
+112
@@ -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;
|
||||
}
|
||||
}
|
||||
+95
@@ -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;
|
||||
}
|
||||
}
|
||||
+272
@@ -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) {
|
||||
}
|
||||
}
|
||||
+185
@@ -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
Reference in New Issue
Block a user