mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-07-12 18:04:01 +00:00
dd
This commit is contained in:
@@ -26,65 +26,6 @@ jobs:
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
- name: Run verification gates
|
||||
run: ./gradlew :core:check :adapters:bukkit:plugin:test :spi:build :probe:deserializationProbe -PuseLocalVolmLib=false --console=plain --stacktrace
|
||||
|
||||
build-artifacts:
|
||||
name: Build ${{ matrix.platform }}
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: bukkit
|
||||
task: buildBukkit
|
||||
- platform: fabric
|
||||
task: buildFabric
|
||||
- platform: forge
|
||||
task: buildForge
|
||||
- platform: neoforge
|
||||
task: buildNeoforge
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
- name: Build ${{ matrix.platform }} artifact
|
||||
run: ./gradlew ${{ matrix.task }} -PuseLocalVolmLib=false --console=plain --stacktrace
|
||||
- name: Upload ${{ matrix.platform }} jar
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: iris-${{ matrix.platform }}
|
||||
path: dist/*.jar
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
release-bundle:
|
||||
name: Release bundle (buildAll)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [verify, build-artifacts]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
- name: Build all platforms
|
||||
run: ./gradlew buildAll --no-parallel -PuseLocalVolmLib=false --console=plain --stacktrace
|
||||
- name: Upload release bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: iris-release-bundle
|
||||
path: dist/*.jar
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
run: ./gradlew :core:check :adapters:bukkit:plugin:test :spi:build :probe:test :probe:run :probe:deserializationProbe -PuseLocalVolmLib=false --console=plain --stacktrace
|
||||
- name: Run modded shared tests
|
||||
run: ./gradlew -p adapters/fabric test -PuseLocalVolmLib=false --console=plain --stacktrace
|
||||
|
||||
+14
@@ -12,6 +12,7 @@ collection/
|
||||
|
||||
out/
|
||||
tools/simd-bench/out/
|
||||
tools/simd-bench/simd-bench.jar
|
||||
|
||||
DataPackExamples/
|
||||
|
||||
@@ -25,4 +26,17 @@ adapters/*/run/
|
||||
|
||||
plans/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
local.properties
|
||||
*.jks
|
||||
*.p12
|
||||
*.pfx
|
||||
*.pem
|
||||
*.key
|
||||
*.hprof
|
||||
credentials.json
|
||||
service-account*.json
|
||||
|
||||
docs/
|
||||
|
||||
@@ -73,9 +73,16 @@ Mod (positional arguments):
|
||||
/iris create myworld overworld 1337
|
||||
```
|
||||
|
||||
`/iris pregen start` pregenerates around spawn; players with the client mod see the native HUD,
|
||||
everyone else gets a boss bar (modded) or console/status output. `/iris pregen status` reports
|
||||
progress on the plugin.
|
||||
Pregeneration requires a radius in blocks. On the plugin, optional arguments are keyed; on modded
|
||||
servers they are positional and composable:
|
||||
|
||||
```
|
||||
/iris pregen start 352 world=myworld center=0,0 gui=false
|
||||
/iris pregen start 352 irisworldgen:myworld at 0 0 sync
|
||||
```
|
||||
|
||||
Players with the client mod see the native HUD; everyone else gets a boss bar (modded) or
|
||||
console/status output. `/iris pregen status` reports progress on the plugin.
|
||||
|
||||
## Studio and VSCode workspace
|
||||
|
||||
@@ -104,7 +111,7 @@ files hotloads the changes and regenerates the schemas.
|
||||
Requirements: JDK 25 (set `JAVA_HOME` to it). The Gradle wrapper handles everything else.
|
||||
|
||||
```
|
||||
./gradlew buildAll
|
||||
./gradlew buildAllToOut
|
||||
```
|
||||
|
||||
builds every platform artifact into `dist/`:
|
||||
|
||||
+40
-3
@@ -25,6 +25,9 @@ import org.bukkit.craftbukkit.CraftWorld;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -40,6 +43,7 @@ public class CustomBiomeSource extends BiomeSource {
|
||||
private final Registry<Biome> biomeRegistry;
|
||||
private final AtomicCache<RegistryAccess> registryAccess = new AtomicCache<>();
|
||||
private final KMap<String, Holder<Biome>> customBiomes;
|
||||
private final Map<Biome, Holder<Biome>> vanillaSpawnBiomes;
|
||||
private final Holder<Biome> fallbackBiome;
|
||||
private final ConcurrentHashMap<Long, Holder<Biome>> noiseBiomeCache = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<Long, Holder<Biome>> structureBiomeCache = new ConcurrentHashMap<>();
|
||||
@@ -51,6 +55,7 @@ public class CustomBiomeSource extends BiomeSource {
|
||||
this.biomeRegistry = ((RegistryAccess) getFor(RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer())).lookup(Registries.BIOME).orElse(null);
|
||||
this.fallbackBiome = resolveFallbackBiome(this.biomeRegistry, this.biomeCustomRegistry);
|
||||
this.customBiomes = fillCustomBiomes(this.biomeCustomRegistry, engine, this.fallbackBiome);
|
||||
this.vanillaSpawnBiomes = fillVanillaSpawnBiomes(this.biomeCustomRegistry, this.biomeRegistry, engine);
|
||||
}
|
||||
|
||||
private static List<Holder<Biome>> getAllBiomes(Registry<Biome> customRegistry, Registry<Biome> registry, Engine engine, Holder<Biome> fallback) {
|
||||
@@ -163,6 +168,38 @@ public class CustomBiomeSource extends BiomeSource {
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<Biome, Holder<Biome>> fillVanillaSpawnBiomes(Registry<Biome> customRegistry, Registry<Biome> registry, Engine engine) {
|
||||
IdentityHashMap<Biome, Holder<Biome>> spawnBiomes = new IdentityHashMap<>();
|
||||
if (customRegistry == null || registry == null) {
|
||||
return Collections.unmodifiableMap(spawnBiomes);
|
||||
}
|
||||
|
||||
for (IrisBiome irisBiome : engine.getAllBiomes()) {
|
||||
if (!irisBiome.isCustom()) {
|
||||
continue;
|
||||
}
|
||||
Holder<Biome> vanillaHolder = NMSBinding.biomeToBiomeBase(registry, irisBiome.getVanillaDerivative());
|
||||
if (vanillaHolder == null) {
|
||||
continue;
|
||||
}
|
||||
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
|
||||
Holder<Biome> customHolder = resolveCustomBiomeHolder(customRegistry, engine, customBiome.getId());
|
||||
if (customHolder != null) {
|
||||
spawnBiomes.putIfAbsent(customHolder.value(), vanillaHolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Collections.unmodifiableMap(spawnBiomes);
|
||||
}
|
||||
|
||||
Holder<Biome> getVanillaSpawnBiome(Holder<Biome> biome) {
|
||||
if (biome == null) {
|
||||
return null;
|
||||
}
|
||||
return vanillaSpawnBiomes.get(biome.value());
|
||||
}
|
||||
|
||||
private RegistryAccess registry() {
|
||||
return registryAccess.aquire(() -> (RegistryAccess) getFor(RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer()));
|
||||
}
|
||||
@@ -242,8 +279,8 @@ public class CustomBiomeSource extends BiomeSource {
|
||||
}
|
||||
|
||||
org.bukkit.block.Biome vanillaBiome = resolution.underground
|
||||
? resolution.irisBiome.getGroundBiome(resolution.rng, resolution.blockX, resolution.blockY, resolution.blockZ)
|
||||
: resolution.irisBiome.getSkyBiome(resolution.rng, resolution.blockX, resolution.blockY, resolution.blockZ);
|
||||
? resolution.irisBiome.getGroundBiome(resolution.rng, engine, resolution.blockX, resolution.blockY, resolution.blockZ)
|
||||
: resolution.irisBiome.getSkyBiome(resolution.rng, engine, resolution.blockX, resolution.blockY, resolution.blockZ);
|
||||
Holder<Biome> holder = NMSBinding.biomeToBiomeBase(biomeRegistry, vanillaBiome);
|
||||
if (holder != null) {
|
||||
return holder;
|
||||
@@ -253,7 +290,7 @@ public class CustomBiomeSource extends BiomeSource {
|
||||
}
|
||||
|
||||
private Holder<Biome> resolveCustomHolder(BiomeResolution resolution) {
|
||||
IrisBiomeCustom customBiome = resolution.irisBiome.getCustomBiome(resolution.rng, resolution.blockX, resolution.blockY, resolution.blockZ);
|
||||
IrisBiomeCustom customBiome = resolution.irisBiome.getCustomBiome(resolution.rng, engine, resolution.blockX, resolution.blockY, resolution.blockZ);
|
||||
if (customBiome != null) {
|
||||
Holder<Biome> holder = customBiomes.get(customBiome.getId());
|
||||
if (holder != null) {
|
||||
|
||||
+48
-1
@@ -25,6 +25,7 @@ import net.minecraft.world.level.levelgen.WorldgenRandom;
|
||||
import net.minecraft.world.level.levelgen.XoroshiroRandomSource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -40,7 +41,9 @@ import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.WorldGenRegion;
|
||||
import net.minecraft.util.random.Weighted;
|
||||
import net.minecraft.util.random.WeightedList;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
@@ -68,6 +71,7 @@ import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
private static final WrappedField<ChunkGenerator, BiomeSource> BIOME_SOURCE;
|
||||
@@ -75,6 +79,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
private final ChunkGenerator delegate;
|
||||
private final Engine engine;
|
||||
private final CustomBiomeSource customBiomeSource;
|
||||
private final ConcurrentHashMap<SpawnTableKey, WeightedList<MobSpawnSettings.SpawnerData>> mergedSpawnTables = new ConcurrentHashMap<>();
|
||||
private volatile Set<String> reachableStructureKeysCache;
|
||||
|
||||
public IrisChunkGenerator(ChunkGenerator delegate, long seed, Engine engine, World world) {
|
||||
@@ -225,7 +230,28 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
|
||||
@Override
|
||||
public WeightedList<MobSpawnSettings.SpawnerData> getMobsAt(Holder<Biome> holder, StructureManager structuremanager, MobCategory enumcreaturetype, BlockPos blockposition) {
|
||||
return delegate.getMobsAt(holder, structuremanager, enumcreaturetype, blockposition);
|
||||
Holder<Biome> vanillaSpawnBiome = customBiomeSource.getVanillaSpawnBiome(holder);
|
||||
if (vanillaSpawnBiome == null) {
|
||||
return delegate.getMobsAt(holder, structuremanager, enumcreaturetype, blockposition);
|
||||
}
|
||||
|
||||
WeightedList<MobSpawnSettings.SpawnerData> vanillaSpawns = vanillaSpawnBiome.value().getMobSettings().getMobs(enumcreaturetype);
|
||||
WeightedList<MobSpawnSettings.SpawnerData> resolvedSpawns = delegate.getMobsAt(
|
||||
vanillaSpawnBiome, structuremanager, enumcreaturetype, blockposition);
|
||||
if (resolvedSpawns != vanillaSpawns) {
|
||||
return resolvedSpawns;
|
||||
}
|
||||
|
||||
WeightedList<MobSpawnSettings.SpawnerData> explicitSpawns = holder.value().getMobSettings().getMobs(enumcreaturetype);
|
||||
if (explicitSpawns.isEmpty()) {
|
||||
return vanillaSpawns;
|
||||
}
|
||||
if (vanillaSpawns.isEmpty()) {
|
||||
return explicitSpawns;
|
||||
}
|
||||
|
||||
SpawnTableKey key = new SpawnTableKey(holder.value(), enumcreaturetype);
|
||||
return mergedSpawnTables.computeIfAbsent(key, ignored -> mergeSpawnTables(vanillaSpawns, explicitSpawns));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -352,6 +378,24 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
delegate.spawnOriginalMobs(regionlimitedworldaccess);
|
||||
}
|
||||
|
||||
private static WeightedList<MobSpawnSettings.SpawnerData> mergeSpawnTables(
|
||||
WeightedList<MobSpawnSettings.SpawnerData> vanillaSpawns,
|
||||
WeightedList<MobSpawnSettings.SpawnerData> explicitSpawns) {
|
||||
List<Weighted<MobSpawnSettings.SpawnerData>> entries = new ArrayList<>(
|
||||
vanillaSpawns.unwrap().size() + explicitSpawns.unwrap().size());
|
||||
Set<EntityType<?>> explicitTypes = new HashSet<>();
|
||||
for (Weighted<MobSpawnSettings.SpawnerData> entry : explicitSpawns.unwrap()) {
|
||||
explicitTypes.add(entry.value().type());
|
||||
}
|
||||
for (Weighted<MobSpawnSettings.SpawnerData> entry : vanillaSpawns.unwrap()) {
|
||||
if (!explicitTypes.contains(entry.value().type())) {
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
entries.addAll(explicitSpawns.unwrap());
|
||||
return WeightedList.of(entries);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSpawnHeight(LevelHeightAccessor levelheightaccessor) {
|
||||
return delegate.getSpawnHeight(levelheightaccessor);
|
||||
@@ -428,4 +472,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private record SpawnTableKey(Biome biome, MobCategory category) {
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -52,6 +52,7 @@ import net.minecraft.core.IdMapper;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.nbt.ByteTag;
|
||||
import net.minecraft.nbt.CollectionTag;
|
||||
@@ -212,6 +213,16 @@ public class NMSBinding implements INMSBinding {
|
||||
return !CraftBlockState.class.equals(CraftBlockStates.getBlockStateType(material));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getEntitySpawnCategory(String key) {
|
||||
Identifier identifier = Identifier.parse(key);
|
||||
if (!BuiltInRegistries.ENTITY_TYPE.containsKey(identifier)) {
|
||||
throw new IllegalArgumentException("Unknown native entity type: " + key);
|
||||
}
|
||||
EntityType<?> type = BuiltInRegistries.ENTITY_TYPE.getValue(identifier);
|
||||
return type.getCategory().getSerializedName().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasTile(Location l) {
|
||||
return ((CraftWorld) l.getWorld()).getHandle().getBlockEntity(new BlockPos(l.getBlockX(), l.getBlockY(), l.getBlockZ())) != null;
|
||||
|
||||
@@ -1221,7 +1221,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
|
||||
if (!ff.exists()) {
|
||||
ff.mkdirs();
|
||||
service(StudioSVC.class).installIntoWorld(getSender(), dim.getLoadKey(), w.worldFolder());
|
||||
dim = service(StudioSVC.class).installIntoWorld(getSender(), dim, w.worldFolder());
|
||||
if (dim == null) {
|
||||
throw new IllegalStateException("Failed to install dimension pack for " + id);
|
||||
}
|
||||
}
|
||||
|
||||
return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey());
|
||||
@@ -1243,9 +1246,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
for (String reason : result.getBlockingErrors()) {
|
||||
Iris.error(" - " + reason);
|
||||
}
|
||||
} else if (!result.getWarnings().isEmpty() || !result.getRemovedUnusedFiles().isEmpty()) {
|
||||
} else if (!result.getWarnings().isEmpty()) {
|
||||
Iris.info("Pack '" + result.getPackName() + "' validated ("
|
||||
+ result.getRemovedUnusedFiles().size() + " unused file(s) quarantined to .iris-trash/, "
|
||||
+ result.getWarnings().size() + " warning(s)).");
|
||||
for (String warning : result.getWarnings()) {
|
||||
Iris.warn(" [" + result.getPackName() + "] " + warning);
|
||||
|
||||
+1
-1
@@ -218,7 +218,7 @@ public class CommandDeveloper implements DirectorExecutor {
|
||||
Iris.service(StudioSVC.class).downloadSearch(sender(), pack.getLoadKey(), true);
|
||||
}
|
||||
|
||||
Iris.service(StudioSVC.class).installIntoWorld(sender(), pack.getLoadKey(), folder);
|
||||
Iris.service(StudioSVC.class).installIntoWorld(sender(), pack, folder);
|
||||
}
|
||||
|
||||
@Director(description = "Test")
|
||||
|
||||
@@ -135,7 +135,7 @@ public class CommandIris implements DirectorExecutor {
|
||||
try {
|
||||
worldCreation = true;
|
||||
IrisToolbelt.createWorld()
|
||||
.dimension(dimension.getLoadKey())
|
||||
.dimension(resolvedType)
|
||||
.name(name)
|
||||
.seed(seed)
|
||||
.sender(sender())
|
||||
@@ -199,7 +199,7 @@ public class CommandIris implements DirectorExecutor {
|
||||
sender().sendMessage(C.YELLOW + "Preparing world files and bukkit.yml for next startup...");
|
||||
|
||||
File worldFolder = new File(Bukkit.getWorldContainer(), name);
|
||||
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension.getLoadKey(), worldFolder);
|
||||
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension, worldFolder);
|
||||
if (installed == null) {
|
||||
sender().sendMessage(C.RED + "Failed to stage world files for dimension \"" + dimension.getLoadKey() + "\".");
|
||||
return false;
|
||||
@@ -587,6 +587,7 @@ public class CommandIris implements DirectorExecutor {
|
||||
IrisData data = IrisData.get(pack);
|
||||
for (String key : data.getDimensionLoader().getPossibleKeys()) {
|
||||
options.add(key);
|
||||
options.add(pack.getName() + ":" + key);
|
||||
}
|
||||
} catch (Throwable ex) {
|
||||
Iris.warn("Failed to read dimension keys from pack %s: %s%s",
|
||||
|
||||
+120
-24
@@ -19,6 +19,8 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.core.pack.PackResourceCleanup;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.pack.PackValidationResult;
|
||||
import art.arcane.iris.core.pack.PackValidator;
|
||||
@@ -29,6 +31,7 @@ import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
|
||||
@Director(name = "pack", aliases = {"pk"}, description = "Pack validation and maintenance")
|
||||
public class CommandPack implements DirectorExecutor {
|
||||
@@ -61,36 +64,115 @@ public class CommandPack implements DirectorExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
File target = new File(packsRoot, pack);
|
||||
if (!target.isDirectory()) {
|
||||
File target = PackDirectoryResolver.resolveExisting(packsRoot, pack);
|
||||
if (target == null) {
|
||||
s.sendMessage(C.RED + "Pack '" + pack + "' not found under packs/.");
|
||||
return;
|
||||
}
|
||||
runValidate(s, target);
|
||||
}
|
||||
|
||||
@Director(description = "Restore most recent trashed files for a pack", aliases = {"r"})
|
||||
public void restore(
|
||||
@Param(description = "The pack folder name to restore")
|
||||
String pack
|
||||
@Director(description = "Preview or apply unused-resource cleanup", aliases = {"c"})
|
||||
public void cleanup(
|
||||
@Param(description = "The pack folder name to clean")
|
||||
String pack,
|
||||
@Param(description = "preview or apply", defaultValue = "preview")
|
||||
String mode
|
||||
) {
|
||||
VolmitSender s = sender();
|
||||
if (pack == null || pack.isBlank()) {
|
||||
s.sendMessage(C.RED + "You must specify a pack name.");
|
||||
File packFolder = findPack(s, pack);
|
||||
if (packFolder == null) {
|
||||
return;
|
||||
}
|
||||
File packFolder = new File(Iris.instance.getDataFolder("packs"), pack);
|
||||
if (!packFolder.isDirectory()) {
|
||||
s.sendMessage(C.RED + "Pack '" + pack + "' not found under packs/.");
|
||||
if ("apply".equalsIgnoreCase(mode)) {
|
||||
PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(packFolder);
|
||||
if (!result.success()) {
|
||||
s.sendMessage(C.RED + result.error());
|
||||
reportPaths(s, result.quarantinedPaths(), "still quarantined");
|
||||
return;
|
||||
}
|
||||
if (!result.changed()) {
|
||||
s.sendMessage(C.GREEN + "No cleanup candidates found for pack '" + pack + "'.");
|
||||
return;
|
||||
}
|
||||
s.sendMessage(C.GREEN + "Quarantined " + result.quarantinedPaths().size()
|
||||
+ " cleanup candidate(s) under " + result.quarantinePath() + ".");
|
||||
reportPaths(s, result.quarantinedPaths(), "quarantined");
|
||||
return;
|
||||
}
|
||||
int restored = PackValidator.restoreTrash(packFolder);
|
||||
if (restored == 0) {
|
||||
if (!"preview".equalsIgnoreCase(mode)) {
|
||||
s.sendMessage(C.RED + "Cleanup mode must be preview or apply.");
|
||||
return;
|
||||
}
|
||||
PackResourceCleanup.Preview preview = PackResourceCleanup.preview(packFolder);
|
||||
if (!preview.success()) {
|
||||
s.sendMessage(C.RED + preview.error());
|
||||
return;
|
||||
}
|
||||
if (!preview.hasCandidates()) {
|
||||
s.sendMessage(C.GREEN + "No cleanup candidates found for pack '" + pack + "'.");
|
||||
return;
|
||||
}
|
||||
s.sendMessage(C.YELLOW + "Cleanup preview for pack '" + pack + "': "
|
||||
+ preview.candidatePaths().size() + " candidate(s). No files were changed.");
|
||||
reportPaths(s, preview.candidatePaths(), "candidate");
|
||||
s.sendMessage(C.GRAY + "Run /iris pack cleanup " + pack + " mode=apply to quarantine these candidates after a fresh scan.");
|
||||
}
|
||||
|
||||
@Director(description = "Preview or apply restoration of the latest quarantine", aliases = {"r"})
|
||||
public void restore(
|
||||
@Param(description = "The pack folder name to restore")
|
||||
String pack,
|
||||
@Param(description = "preview or apply", defaultValue = "preview")
|
||||
String mode
|
||||
) {
|
||||
VolmitSender s = sender();
|
||||
File packFolder = findPack(s, pack);
|
||||
if (packFolder == null) {
|
||||
return;
|
||||
}
|
||||
if ("apply".equalsIgnoreCase(mode)) {
|
||||
PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(packFolder);
|
||||
if (!result.conflicts().isEmpty()) {
|
||||
s.sendMessage(C.RED + "Restore refused because " + result.conflicts().size() + " destination(s) already exist.");
|
||||
reportPaths(s, result.conflicts(), "conflict");
|
||||
return;
|
||||
}
|
||||
if (!result.success()) {
|
||||
s.sendMessage(C.RED + result.error());
|
||||
return;
|
||||
}
|
||||
if (!result.changed()) {
|
||||
s.sendMessage(C.YELLOW + "Nothing to restore for pack '" + pack + "'.");
|
||||
return;
|
||||
}
|
||||
s.sendMessage(C.GREEN + "Restored " + result.restoredPaths().size()
|
||||
+ " file(s) from " + result.dumpPath() + ".");
|
||||
reportPaths(s, result.restoredPaths(), "restored");
|
||||
return;
|
||||
}
|
||||
if (!"preview".equalsIgnoreCase(mode)) {
|
||||
s.sendMessage(C.RED + "Restore mode must be preview or apply.");
|
||||
return;
|
||||
}
|
||||
PackResourceCleanup.RestorePreview preview = PackResourceCleanup.previewRestore(packFolder);
|
||||
if (!preview.success()) {
|
||||
s.sendMessage(C.RED + preview.error());
|
||||
return;
|
||||
}
|
||||
if (!preview.hasFiles()) {
|
||||
s.sendMessage(C.YELLOW + "Nothing to restore for pack '" + pack + "'.");
|
||||
return;
|
||||
}
|
||||
s.sendMessage(C.GREEN + "Restored " + restored + " file(s) from the most recent trash dump for pack '" + pack + "'.");
|
||||
s.sendMessage(C.GRAY + "Re-run /iris pack validate " + pack + " to re-check.");
|
||||
s.sendMessage(C.YELLOW + "Restore preview for " + preview.dumpPath() + ": "
|
||||
+ preview.filePaths().size() + " file(s). No files were changed.");
|
||||
reportPaths(s, preview.filePaths(), "file");
|
||||
if (!preview.conflicts().isEmpty()) {
|
||||
s.sendMessage(C.RED + "Restore is blocked by " + preview.conflicts().size() + " existing destination(s).");
|
||||
reportPaths(s, preview.conflicts(), "conflict");
|
||||
return;
|
||||
}
|
||||
s.sendMessage(C.GRAY + "Run /iris pack restore " + pack + " mode=apply to restore after a fresh conflict check.");
|
||||
}
|
||||
|
||||
@Director(description = "Show cached validation status for a pack", aliases = {"s"})
|
||||
@@ -108,8 +190,7 @@ public class CommandPack implements DirectorExecutor {
|
||||
String tag = result.isLoadable() ? (C.GREEN + "OK") : (C.RED + "BROKEN");
|
||||
s.sendMessage(tag + C.RESET + " " + name
|
||||
+ C.GRAY + " (blocking=" + result.getBlockingErrors().size()
|
||||
+ ", warnings=" + result.getWarnings().size()
|
||||
+ ", trashed=" + result.getRemovedUnusedFiles().size() + ")");
|
||||
+ ", warnings=" + result.getWarnings().size() + ")");
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -137,8 +218,7 @@ public class CommandPack implements DirectorExecutor {
|
||||
private void reportResult(VolmitSender s, PackValidationResult result) {
|
||||
if (result.isLoadable()) {
|
||||
s.sendMessage(C.GREEN + "Pack '" + result.getPackName() + "' is loadable."
|
||||
+ C.GRAY + " (warnings=" + result.getWarnings().size()
|
||||
+ ", trashed=" + result.getRemovedUnusedFiles().size() + ")");
|
||||
+ C.GRAY + " (warnings=" + result.getWarnings().size() + ")");
|
||||
} else {
|
||||
s.sendMessage(C.RED + "Pack '" + result.getPackName() + "' is BROKEN:");
|
||||
for (String reason : result.getBlockingErrors()) {
|
||||
@@ -152,12 +232,28 @@ public class CommandPack implements DirectorExecutor {
|
||||
if (result.getWarnings().size() > wMax) {
|
||||
s.sendMessage(C.GRAY + " ... and " + (result.getWarnings().size() - wMax) + " more warning(s).");
|
||||
}
|
||||
int tMax = Math.min(10, result.getRemovedUnusedFiles().size());
|
||||
for (int i = 0; i < tMax; i++) {
|
||||
s.sendMessage(C.GRAY + " ~ trashed " + result.getRemovedUnusedFiles().get(i));
|
||||
}
|
||||
|
||||
private File findPack(VolmitSender sender, String pack) {
|
||||
if (pack == null || pack.isBlank()) {
|
||||
sender.sendMessage(C.RED + "You must specify a pack name.");
|
||||
return null;
|
||||
}
|
||||
if (result.getRemovedUnusedFiles().size() > tMax) {
|
||||
s.sendMessage(C.GRAY + " ... and " + (result.getRemovedUnusedFiles().size() - tMax) + " more trashed file(s).");
|
||||
File packFolder = PackDirectoryResolver.resolveExisting(Iris.instance.getDataFolder("packs"), pack);
|
||||
if (packFolder == null) {
|
||||
sender.sendMessage(C.RED + "Pack '" + pack + "' not found under packs/.");
|
||||
return null;
|
||||
}
|
||||
return packFolder;
|
||||
}
|
||||
|
||||
private void reportPaths(VolmitSender sender, List<String> paths, String label) {
|
||||
int max = Math.min(10, paths.size());
|
||||
for (int i = 0; i < max; i++) {
|
||||
sender.sendMessage(C.GRAY + " - " + label + ": " + paths.get(i));
|
||||
}
|
||||
if (paths.size() > max) {
|
||||
sender.sendMessage(C.GRAY + " ... and " + (paths.size() - max) + " more.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -85,7 +85,9 @@ public class CommandPregen implements DirectorExecutor {
|
||||
@Director(description = "Stop the active pregeneration task", aliases = "x")
|
||||
public void stop() {
|
||||
if (PregeneratorJob.shutdownInstance()) {
|
||||
Iris.info( C.BLUE + "Finishing up mca region...");
|
||||
String message = C.BLUE + "Pregen stop requested; finishing active work before cancellation.";
|
||||
sender().sendMessage(message);
|
||||
Iris.info(message);
|
||||
} else {
|
||||
sender().sendMessage(C.YELLOW + "No active pregeneration tasks to stop");
|
||||
}
|
||||
|
||||
@@ -55,6 +55,11 @@ sourceSets {
|
||||
srcDir '../modded-common/src/main/resources'
|
||||
}
|
||||
}
|
||||
test {
|
||||
java {
|
||||
srcDir '../modded-common/src/test/java'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
@@ -102,6 +107,7 @@ configurations.create('jij') {
|
||||
dependencies {
|
||||
minecraft("com.mojang:minecraft:${minecraftVersion}")
|
||||
implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}")
|
||||
testImplementation('junit:junit:4.13.2')
|
||||
List<Object> fabricApi = [
|
||||
libs.fabricApi.base,
|
||||
libs.fabricApi.registrySync,
|
||||
@@ -199,6 +205,10 @@ tasks.named('shadowJar', ShadowJar).configure {
|
||||
exclude('META-INF/*.RSA')
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm')
|
||||
relocate('io.sentry', 'art.arcane.iris.shadow.sentry')
|
||||
relocate('com.sun.jna', 'art.arcane.iris.shadow.jna')
|
||||
relocate('oshi', 'art.arcane.iris.shadow.oshi')
|
||||
relocate('net.jpountz', 'art.arcane.iris.shadow.jpountz')
|
||||
exclude('oshi.properties')
|
||||
from(project.configurations.named('jij')) {
|
||||
into('META-INF/jars')
|
||||
|
||||
@@ -227,6 +227,7 @@ tasks.named('shadowJar', ShadowJar).configure {
|
||||
exclude('org/apache/commons/lang/enum/**')
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm')
|
||||
relocate('io.sentry', 'art.arcane.iris.shadow.sentry')
|
||||
relocate('com.sun.jna', 'art.arcane.iris.shadow.jna')
|
||||
relocate('oshi', 'art.arcane.iris.shadow.oshi')
|
||||
relocate('net.jpountz', 'art.arcane.iris.shadow.jpountz')
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
final class InitialSpawnQueue {
|
||||
private static final long DEFAULT_MAX_AGE_NANOS = TimeUnit.MINUTES.toNanos(5);
|
||||
|
||||
private final int capacity;
|
||||
private final long maxAgeNanos;
|
||||
private final LongSupplier nanoTime;
|
||||
private final ArrayDeque<Long> queue;
|
||||
private final Map<Long, Long> pending;
|
||||
private final Set<Long> queued;
|
||||
private boolean closed;
|
||||
|
||||
InitialSpawnQueue(int capacity) {
|
||||
this(capacity, DEFAULT_MAX_AGE_NANOS, System::nanoTime);
|
||||
}
|
||||
|
||||
InitialSpawnQueue(int capacity, long maxAgeNanos, LongSupplier nanoTime) {
|
||||
if (capacity < 1) {
|
||||
throw new IllegalArgumentException("capacity must be positive");
|
||||
}
|
||||
if (maxAgeNanos < 1L) {
|
||||
throw new IllegalArgumentException("maxAgeNanos must be positive");
|
||||
}
|
||||
this.capacity = capacity;
|
||||
this.maxAgeNanos = maxAgeNanos;
|
||||
this.nanoTime = nanoTime;
|
||||
this.queue = new ArrayDeque<>(Math.min(capacity, 256));
|
||||
this.pending = new HashMap<>();
|
||||
this.queued = new HashSet<>();
|
||||
}
|
||||
|
||||
synchronized boolean offer(long key) {
|
||||
if (closed || pending.containsKey(key)) {
|
||||
return false;
|
||||
}
|
||||
if (pending.size() >= capacity) {
|
||||
expire(nanoTime.getAsLong());
|
||||
}
|
||||
if (pending.size() >= capacity) {
|
||||
return false;
|
||||
}
|
||||
pending.put(key, nanoTime.getAsLong());
|
||||
queued.add(key);
|
||||
queue.addLast(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
synchronized int batchSize(int limit) {
|
||||
if (closed || limit <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(limit, queue.size());
|
||||
}
|
||||
|
||||
synchronized Long poll() {
|
||||
long now = nanoTime.getAsLong();
|
||||
Long key;
|
||||
while ((key = queue.pollFirst()) != null) {
|
||||
queued.remove(key);
|
||||
Long offeredAt = pending.get(key);
|
||||
if (offeredAt == null) {
|
||||
continue;
|
||||
}
|
||||
if (expired(offeredAt, now)) {
|
||||
pending.remove(key);
|
||||
continue;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
synchronized void retry(long key) {
|
||||
Long offeredAt = pending.get(key);
|
||||
if (closed || offeredAt == null || expired(offeredAt, nanoTime.getAsLong())) {
|
||||
pending.remove(key);
|
||||
queued.remove(key);
|
||||
return;
|
||||
}
|
||||
if (!queued.add(key)) {
|
||||
return;
|
||||
}
|
||||
queue.addLast(key);
|
||||
}
|
||||
|
||||
synchronized void complete(long key) {
|
||||
pending.remove(key);
|
||||
if (queued.remove(key)) {
|
||||
queue.removeFirstOccurrence(key);
|
||||
}
|
||||
}
|
||||
|
||||
synchronized boolean isEmpty() {
|
||||
return queue.isEmpty();
|
||||
}
|
||||
|
||||
synchronized int size() {
|
||||
return pending.size();
|
||||
}
|
||||
|
||||
synchronized void clear() {
|
||||
queue.clear();
|
||||
pending.clear();
|
||||
queued.clear();
|
||||
}
|
||||
|
||||
synchronized void close() {
|
||||
closed = true;
|
||||
clear();
|
||||
}
|
||||
|
||||
private void expire(long now) {
|
||||
Set<Long> expired = new HashSet<>();
|
||||
for (Map.Entry<Long, Long> entry : pending.entrySet()) {
|
||||
if (expired(entry.getValue(), now)) {
|
||||
expired.add(entry.getKey());
|
||||
}
|
||||
}
|
||||
if (expired.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Long key : expired) {
|
||||
pending.remove(key);
|
||||
queued.remove(key);
|
||||
}
|
||||
queue.removeIf(expired::contains);
|
||||
}
|
||||
|
||||
private boolean expired(long offeredAt, long now) {
|
||||
return now - offeredAt >= maxAgeNanos;
|
||||
}
|
||||
}
|
||||
+90
@@ -20,6 +20,8 @@ package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionException;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBiome;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
@@ -38,6 +40,8 @@ import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.WorldGenRegion;
|
||||
import net.minecraft.util.random.WeightedList;
|
||||
import net.minecraft.world.entity.MobCategory;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
@@ -49,6 +53,7 @@ import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.biome.BiomeResolver;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.biome.Climate;
|
||||
import net.minecraft.world.level.biome.MobSpawnSettings;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
@@ -65,6 +70,7 @@ import org.slf4j.LoggerFactory;
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -136,8 +142,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
private final String defaultPack;
|
||||
private final String defaultDimensionKey;
|
||||
private final ConcurrentHashMap<String, Holder<Biome>> biomeHolders = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<Biome, Holder<Biome>> vanillaSpawnBiomes = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<SpawnTableKey, WeightedList<MobSpawnSettings.SpawnerData>> mergedSpawnTables = new ConcurrentHashMap<>();
|
||||
private final Set<String> missingBiomeWarnings = ConcurrentHashMap.newKeySet();
|
||||
private final AtomicBoolean announced = new AtomicBoolean(false);
|
||||
private volatile boolean vanillaSpawnBiomesInitialized;
|
||||
private volatile Engine engine;
|
||||
private volatile String activePack;
|
||||
private volatile String activeDimensionKey;
|
||||
@@ -166,6 +175,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
this.announced.set(false);
|
||||
this.biomeHolders.clear();
|
||||
this.missingBiomeWarnings.clear();
|
||||
resetVanillaSpawnBiomes();
|
||||
}
|
||||
|
||||
public synchronized void unbindEngine() {
|
||||
@@ -177,6 +187,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
this.announced.set(false);
|
||||
this.biomeHolders.clear();
|
||||
this.missingBiomeWarnings.clear();
|
||||
resetVanillaSpawnBiomes();
|
||||
}
|
||||
|
||||
public synchronized void resetToDefault() {
|
||||
@@ -259,6 +270,82 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
public void onHotload() {
|
||||
biomeHolders.clear();
|
||||
missingBiomeWarnings.clear();
|
||||
resetVanillaSpawnBiomes();
|
||||
}
|
||||
|
||||
@Override
|
||||
public WeightedList<MobSpawnSettings.SpawnerData> getMobsAt(
|
||||
Holder<Biome> biome, StructureManager structureManager, MobCategory category, BlockPos pos) {
|
||||
WeightedList<MobSpawnSettings.SpawnerData> explicitSpawns = biome.value().getMobSettings().getMobs(category);
|
||||
WeightedList<MobSpawnSettings.SpawnerData> resolvedSpawns = super.getMobsAt(
|
||||
biome, structureManager, category, pos);
|
||||
if (resolvedSpawns != explicitSpawns) {
|
||||
return resolvedSpawns;
|
||||
}
|
||||
|
||||
Registry<Biome> registry = structureManager.registryAccess().lookupOrThrow(Registries.BIOME);
|
||||
initializeVanillaSpawnBiomes(registry);
|
||||
Holder<Biome> vanillaSpawnBiome = vanillaSpawnBiomes.get(biome.value());
|
||||
if (vanillaSpawnBiome == null) {
|
||||
return explicitSpawns;
|
||||
}
|
||||
|
||||
WeightedList<MobSpawnSettings.SpawnerData> vanillaSpawns = vanillaSpawnBiome.value().getMobSettings().getMobs(category);
|
||||
if (explicitSpawns.isEmpty()) {
|
||||
return vanillaSpawns;
|
||||
}
|
||||
if (vanillaSpawns.isEmpty()) {
|
||||
return explicitSpawns;
|
||||
}
|
||||
|
||||
SpawnTableKey key = new SpawnTableKey(biome.value(), category);
|
||||
return mergedSpawnTables.computeIfAbsent(key, ignored -> NativeSpawnTableMerger.merge(vanillaSpawns, explicitSpawns));
|
||||
}
|
||||
|
||||
private synchronized void initializeVanillaSpawnBiomes(Registry<Biome> registry) {
|
||||
if (vanillaSpawnBiomesInitialized) {
|
||||
return;
|
||||
}
|
||||
Engine current = engineOrNull();
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String namespace = current.getDimension().getLoadKey().toLowerCase(Locale.ROOT);
|
||||
for (IrisBiome irisBiome : current.getDimension().getAllBiomes(current)) {
|
||||
if (irisBiome == null || !irisBiome.isCustom()) {
|
||||
continue;
|
||||
}
|
||||
Holder<Biome> vanillaHolder = resolveBiomeHolder(registry, irisBiome.getVanillaDerivativeKey());
|
||||
if (vanillaHolder == null) {
|
||||
continue;
|
||||
}
|
||||
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
|
||||
Holder<Biome> customHolder = resolveBiomeHolder(registry, namespace + ":" + customBiome.getId());
|
||||
if (customHolder != null) {
|
||||
vanillaSpawnBiomes.putIfAbsent(customHolder.value(), vanillaHolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
vanillaSpawnBiomesInitialized = true;
|
||||
}
|
||||
|
||||
private Holder<Biome> resolveBiomeHolder(Registry<Biome> registry, String key) {
|
||||
if (key == null || key.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Identifier identifier = Identifier.tryParse(key);
|
||||
if (identifier == null) {
|
||||
return null;
|
||||
}
|
||||
Optional<Holder.Reference<Biome>> reference = registry.get(identifier);
|
||||
return reference.<Holder<Biome>>map((Holder.Reference<Biome> value) -> value).orElse(null);
|
||||
}
|
||||
|
||||
private synchronized void resetVanillaSpawnBiomes() {
|
||||
vanillaSpawnBiomes.clear();
|
||||
mergedSpawnTables.clear();
|
||||
vanillaSpawnBiomesInitialized = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -491,4 +578,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
public void addDebugScreenInfo(List<String> info, RandomState randomState, BlockPos pos) {
|
||||
info.add("Iris dimension: " + dimensionKey);
|
||||
}
|
||||
|
||||
private record SpawnTableKey(Biome biome, MobCategory category) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ package art.arcane.iris.modded;
|
||||
import art.arcane.iris.spi.PlatformEntityType;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class ModdedEntityType implements PlatformEntityType {
|
||||
@@ -51,6 +52,11 @@ public final class ModdedEntityType implements PlatformEntityType {
|
||||
return namespace;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String spawnCategory() {
|
||||
return type.getCategory().getSerializedName().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object nativeHandle() {
|
||||
return type;
|
||||
|
||||
@@ -88,8 +88,8 @@ public final class ModdedStartup {
|
||||
for (String reason : result.getBlockingErrors()) {
|
||||
LOGGER.error(" - {}", reason);
|
||||
}
|
||||
} else if (!result.getWarnings().isEmpty() || !result.getRemovedUnusedFiles().isEmpty()) {
|
||||
LOGGER.info("Iris pack '{}' validated ({} unused file(s) quarantined to .iris-trash/, {} warning(s)).", result.getPackName(), result.getRemovedUnusedFiles().size(), result.getWarnings().size());
|
||||
} else if (!result.getWarnings().isEmpty()) {
|
||||
LOGGER.info("Iris pack '{}' validated ({} warning(s)).", result.getPackName(), result.getWarnings().size());
|
||||
for (String warning : result.getWarnings()) {
|
||||
LOGGER.warn(" [{}] {}", result.getPackName(), warning);
|
||||
}
|
||||
|
||||
@@ -40,49 +40,85 @@ import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class ModdedWorldCheck {
|
||||
private static final int EXIT_PASS = 0;
|
||||
private static final int EXIT_FAILURE = 1;
|
||||
private static final long SERVER_WAIT_TIMEOUT_MILLIS = 600000L;
|
||||
private static final long SERVER_WAIT_INTERVAL_MILLIS = 250L;
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final ProcessExit PROCESS_EXIT = Runtime.getRuntime()::exit;
|
||||
|
||||
private ModdedWorldCheck() {
|
||||
}
|
||||
|
||||
public static void schedule() {
|
||||
Thread thread = new Thread(ModdedWorldCheck::waitAndRun, "Iris World Check");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
coordinatorThread(() -> waitAndRun(PROCESS_EXIT)).start();
|
||||
}
|
||||
|
||||
private static void waitAndRun() {
|
||||
static Thread coordinatorThread(Runnable coordinator) {
|
||||
Thread thread = new Thread(coordinator, "Iris World Check");
|
||||
thread.setDaemon(false);
|
||||
return thread;
|
||||
}
|
||||
|
||||
private static void waitAndRun(ProcessExit processExit) {
|
||||
long start = System.currentTimeMillis();
|
||||
MinecraftServer server = null;
|
||||
while (System.currentTimeMillis() - start < 600000L) {
|
||||
MinecraftServer candidate = ModdedEngineBootstrap.currentServer();
|
||||
if (candidate != null && candidate.isReady()) {
|
||||
server = candidate;
|
||||
break;
|
||||
boolean ready = false;
|
||||
int exitCode = EXIT_FAILURE;
|
||||
try {
|
||||
while (System.currentTimeMillis() - start < SERVER_WAIT_TIMEOUT_MILLIS) {
|
||||
MinecraftServer candidate = ModdedEngineBootstrap.currentServer();
|
||||
if (candidate != null) {
|
||||
server = candidate;
|
||||
if (candidate.isReady()) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Thread.sleep(SERVER_WAIT_INTERVAL_MILLIS);
|
||||
}
|
||||
try {
|
||||
Thread.sleep(250L);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
|
||||
if (!ready) {
|
||||
LOGGER.error("[worldcheck] server did not become ready within 10 minutes");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (server == null) {
|
||||
LOGGER.error("[worldcheck] server did not become ready within 10 minutes");
|
||||
return;
|
||||
}
|
||||
|
||||
MinecraftServer serverRef = server;
|
||||
AtomicBoolean pass = new AtomicBoolean(false);
|
||||
try {
|
||||
MinecraftServer serverRef = server;
|
||||
AtomicBoolean pass = new AtomicBoolean(false);
|
||||
serverRef.submit(() -> pass.set(run(serverRef))).join();
|
||||
exitCode = pass.get() ? EXIT_PASS : EXIT_FAILURE;
|
||||
} catch (InterruptedException e) {
|
||||
LOGGER.error("[worldcheck] coordinator interrupted", e);
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Throwable e) {
|
||||
LOGGER.error("[worldcheck] check failed", e);
|
||||
} finally {
|
||||
int resultCode = exitCode;
|
||||
LOGGER.info("[worldcheck] shutting down dev server (result={})", resultCode == EXIT_PASS ? "PASS" : "FAIL");
|
||||
MinecraftServer serverRef = server;
|
||||
stopAndExit(serverRef == null ? null : () -> serverRef.halt(true), resultCode, processExit);
|
||||
}
|
||||
}
|
||||
|
||||
LOGGER.info("[worldcheck] shutting down dev server (result={})", pass.get() ? "PASS" : "FAIL");
|
||||
serverRef.halt(false);
|
||||
static void stopAndExit(Runnable serverStop, int requestedExitCode, ProcessExit processExit) {
|
||||
int exitCode = requestedExitCode;
|
||||
boolean interrupted = Thread.interrupted();
|
||||
if (interrupted) {
|
||||
exitCode = EXIT_FAILURE;
|
||||
}
|
||||
try {
|
||||
if (serverStop != null) {
|
||||
serverStop.run();
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
exitCode = EXIT_FAILURE;
|
||||
LOGGER.error("[worldcheck] server shutdown failed", e);
|
||||
} finally {
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
processExit.exit(exitCode);
|
||||
}
|
||||
|
||||
private static boolean run(MinecraftServer server) {
|
||||
@@ -186,4 +222,9 @@ public final class ModdedWorldCheck {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface ProcessExit {
|
||||
void exit(int status);
|
||||
}
|
||||
}
|
||||
|
||||
+138
-29
@@ -57,88 +57,167 @@ import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Queue;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class ModdedWorldManager implements EngineWorldManager {
|
||||
private static final ConcurrentHashMap<Engine, Queue<Long>> INITIAL_QUEUES = new ConcurrentHashMap<>();
|
||||
private static final int MAX_INITIAL_QUEUE = 8192;
|
||||
private static final int MAX_INITIAL_DRAIN_PER_TICK = 8;
|
||||
private static final int MAX_INITIAL_RECOVERY_PER_PASS = 128;
|
||||
private static final int MANTLE_WARMUP_QUEUE_CAPACITY = 256;
|
||||
private static final long INITIAL_RECOVERY_INTERVAL_MS = 1_000L;
|
||||
private static final long COUNT_INTERVAL_MS = 3_000L;
|
||||
private static final int ENTITY_SCAN_RADIUS = 64;
|
||||
private static final int PLAYER_CHUNK_RADIUS = 4;
|
||||
private static final int MIN_TICK_INTERVAL_MS = 1_000;
|
||||
|
||||
private final Engine engine;
|
||||
private final InitialSpawnQueue initialSpawnQueue;
|
||||
private final Set<Long> mantleWarmups;
|
||||
private final ThreadPoolExecutor mantleWarmupExecutor;
|
||||
private long lastAmbientAt;
|
||||
private long lastCountAt;
|
||||
private long lastInitialRecoveryAt;
|
||||
private volatile boolean closed;
|
||||
private volatile int cachedEntityCount;
|
||||
private volatile int cachedConsideredChunks;
|
||||
private volatile double cachedSaturation;
|
||||
|
||||
public ModdedWorldManager(Engine engine) {
|
||||
this.engine = engine;
|
||||
INITIAL_QUEUES.putIfAbsent(engine, new ConcurrentLinkedQueue<>());
|
||||
this.initialSpawnQueue = new InitialSpawnQueue(MAX_INITIAL_QUEUE);
|
||||
this.mantleWarmups = ConcurrentHashMap.newKeySet();
|
||||
BlockingQueue<Runnable> warmupQueue = new ArrayBlockingQueue<>(MANTLE_WARMUP_QUEUE_CAPACITY);
|
||||
this.mantleWarmupExecutor = new ThreadPoolExecutor(
|
||||
1,
|
||||
1,
|
||||
30L,
|
||||
TimeUnit.SECONDS,
|
||||
warmupQueue,
|
||||
runnable -> {
|
||||
Thread thread = new Thread(runnable, "Iris Initial Spawn Mantle Warmup");
|
||||
thread.setDaemon(true);
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
return thread;
|
||||
},
|
||||
new ThreadPoolExecutor.AbortPolicy());
|
||||
this.mantleWarmupExecutor.allowCoreThreadTimeOut(true);
|
||||
}
|
||||
|
||||
public static void enqueueGenerated(Engine engine, int chunkX, int chunkZ) {
|
||||
if (engine == null) {
|
||||
if (engine == null || engine.isClosed()) {
|
||||
return;
|
||||
}
|
||||
Queue<Long> queue = INITIAL_QUEUES.get(engine);
|
||||
if (queue == null || queue.size() >= MAX_INITIAL_QUEUE) {
|
||||
return;
|
||||
EngineWorldManager worldManager = engine.getWorldManager();
|
||||
if (worldManager instanceof ModdedWorldManager moddedWorldManager) {
|
||||
moddedWorldManager.initialSpawnQueue.offer(pack(chunkX, chunkZ));
|
||||
}
|
||||
queue.add(pack(chunkX, chunkZ));
|
||||
}
|
||||
|
||||
public void serverTick(ServerLevel level) {
|
||||
if (engine.isClosed() || engine.getMantle().getMantle().isClosed()) {
|
||||
if (closed || engine.isClosed() || engine.getMantle().getMantle().isClosed()) {
|
||||
return;
|
||||
}
|
||||
if (isPregenActive()) {
|
||||
return;
|
||||
}
|
||||
recoverLoadedInitialSpawns(level);
|
||||
drainInitialSpawns(level);
|
||||
ambientTick(level);
|
||||
}
|
||||
|
||||
private void drainInitialSpawns(ServerLevel level) {
|
||||
Queue<Long> queue = INITIAL_QUEUES.get(engine);
|
||||
if (queue == null || queue.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
private void recoverLoadedInitialSpawns(ServerLevel level) {
|
||||
if (!markerSystemEnabled() && !ambientSystemEnabled()) {
|
||||
queue.clear();
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastInitialRecoveryAt < INITIAL_RECOVERY_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastInitialRecoveryAt = now;
|
||||
|
||||
int budget = MAX_INITIAL_DRAIN_PER_TICK;
|
||||
while (budget-- > 0) {
|
||||
Long key = queue.poll();
|
||||
if (key == null) {
|
||||
return;
|
||||
Set<Long> candidates = new HashSet<>();
|
||||
for (ServerPlayer player : level.players()) {
|
||||
int centerX = player.blockPosition().getX() >> 4;
|
||||
int centerZ = player.blockPosition().getZ() >> 4;
|
||||
for (int dx = -1; dx <= 1; dx++) {
|
||||
for (int dz = -1; dz <= 1; dz++) {
|
||||
candidates.add(pack(centerX + dx, centerZ + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.addAll(level.getForceLoadedChunks());
|
||||
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
int recovered = 0;
|
||||
for (long key : candidates) {
|
||||
int chunkX = unpackX(key);
|
||||
int chunkZ = unpackZ(key);
|
||||
if (level.getChunkSource().getChunkNow(chunkX, chunkZ) == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
initialSpawnChunk(level, chunkX, chunkZ);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
if (mantle.isChunkLoaded(chunkX, chunkZ) && mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED)) {
|
||||
continue;
|
||||
}
|
||||
if (initialSpawnQueue.offer(key) && ++recovered >= MAX_INITIAL_RECOVERY_PER_PASS) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void initialSpawnChunk(ServerLevel level, int chunkX, int chunkZ) {
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
if (!mantle.isChunkLoaded(chunkX, chunkZ) || mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED)) {
|
||||
private void drainInitialSpawns(ServerLevel level) {
|
||||
if (initialSpawnQueue.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!markerSystemEnabled() && !ambientSystemEnabled()) {
|
||||
initialSpawnQueue.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
int budget = initialSpawnQueue.batchSize(MAX_INITIAL_DRAIN_PER_TICK);
|
||||
while (budget-- > 0) {
|
||||
Long key = initialSpawnQueue.poll();
|
||||
if (key == null) {
|
||||
return;
|
||||
}
|
||||
int chunkX = unpackX(key);
|
||||
int chunkZ = unpackZ(key);
|
||||
boolean retry = false;
|
||||
try {
|
||||
if (level.getChunkSource().getChunkNow(chunkX, chunkZ) == null) {
|
||||
retry = true;
|
||||
continue;
|
||||
}
|
||||
if (!initialSpawnChunk(level, chunkX, chunkZ)) {
|
||||
retry = true;
|
||||
warmupMantleChunkAsync(key, chunkX, chunkZ);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
retry = true;
|
||||
} finally {
|
||||
if (retry) {
|
||||
initialSpawnQueue.retry(key);
|
||||
} else {
|
||||
initialSpawnQueue.complete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean initialSpawnChunk(ServerLevel level, int chunkX, int chunkZ) {
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
if (!mantle.isChunkLoaded(chunkX, chunkZ)) {
|
||||
return false;
|
||||
}
|
||||
if (mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
MantleChunk<Matter> chunk = mantle.getChunk(chunkX, chunkZ).use();
|
||||
try {
|
||||
@@ -153,6 +232,33 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
} finally {
|
||||
chunk.release();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void warmupMantleChunkAsync(long key, int chunkX, int chunkZ) {
|
||||
if (closed || !mantleWarmups.add(key)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
mantleWarmupExecutor.execute(() -> warmupMantleChunk(key, chunkX, chunkZ));
|
||||
} catch (RejectedExecutionException e) {
|
||||
mantleWarmups.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private void warmupMantleChunk(long key, int chunkX, int chunkZ) {
|
||||
try {
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
if (!closed && !engine.isClosed() && !mantle.isClosed() && !mantle.isChunkLoaded(chunkX, chunkZ)) {
|
||||
mantle.getChunk(chunkX, chunkZ);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
if (!closed && !engine.isClosed()) {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
} finally {
|
||||
mantleWarmups.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private void ambientTick(ServerLevel level) {
|
||||
@@ -575,7 +681,10 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
INITIAL_QUEUES.remove(engine);
|
||||
closed = true;
|
||||
initialSpawnQueue.close();
|
||||
mantleWarmupExecutor.shutdownNow();
|
||||
mantleWarmups.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import net.minecraft.util.random.Weighted;
|
||||
import net.minecraft.util.random.WeightedList;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.level.biome.MobSpawnSettings;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
final class NativeSpawnTableMerger {
|
||||
private NativeSpawnTableMerger() {
|
||||
}
|
||||
|
||||
static WeightedList<MobSpawnSettings.SpawnerData> merge(
|
||||
WeightedList<MobSpawnSettings.SpawnerData> vanillaSpawns,
|
||||
WeightedList<MobSpawnSettings.SpawnerData> explicitSpawns) {
|
||||
List<Weighted<MobSpawnSettings.SpawnerData>> entries = new ArrayList<>(
|
||||
vanillaSpawns.unwrap().size() + explicitSpawns.unwrap().size());
|
||||
Set<EntityType<?>> explicitTypes = new HashSet<>();
|
||||
for (Weighted<MobSpawnSettings.SpawnerData> entry : explicitSpawns.unwrap()) {
|
||||
explicitTypes.add(entry.value().type());
|
||||
}
|
||||
for (Weighted<MobSpawnSettings.SpawnerData> entry : vanillaSpawns.unwrap()) {
|
||||
if (!explicitTypes.contains(entry.value().type())) {
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
entries.addAll(explicitSpawns.unwrap());
|
||||
return WeightedList.of(entries);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -131,7 +131,8 @@ final class ModdedCommandHelp {
|
||||
SECTIONS.put("s", SECTIONS.get("studio"));
|
||||
SECTIONS.put("pack", List.of(
|
||||
Entry.command("validate", "[pack]", "Validate a pack or every pack", "v"),
|
||||
Entry.command("restore", "<pack>", "Restore the latest trashed files for a pack", "r"),
|
||||
Entry.command("cleanup", "<pack> [apply]", "Preview or quarantine unused-resource candidates", "c"),
|
||||
Entry.command("restore", "<pack> [apply]", "Preview or restore the latest quarantine", "r"),
|
||||
Entry.command("status", "[pack]", "Show cached validation status", "s")
|
||||
));
|
||||
SECTIONS.put("pk", SECTIONS.get("pack"));
|
||||
|
||||
+2
-2
@@ -63,7 +63,7 @@ public final class ModdedGoldenHash {
|
||||
File goldenDir = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("golden").toFile();
|
||||
GoldenHashEngine.Request request = new GoldenHashEngine.Request(
|
||||
engine.getWorld().name(),
|
||||
level.getSeed(),
|
||||
engine.getSeedManager().getSeed(),
|
||||
ModdedEngineBootstrap.loader().minecraftVersion(),
|
||||
engine.getMinHeight(),
|
||||
engine.getMaxHeight(),
|
||||
@@ -88,7 +88,7 @@ public final class ModdedGoldenHash {
|
||||
int chunks = (boundedRadius * 2 + 1) * (boundedRadius * 2 + 1);
|
||||
scan.ok("GoldenHash started: " + chunks + " chunk(s) around 0,0 in buffers (world untouched), threads=" + Math.max(1, threads) + " mode=" + mode);
|
||||
LOGGER.info("goldenhash start: dim={} seed={} radius={} threads={} mode={} file={}",
|
||||
engine.getDimension().getLoadKey(), level.getSeed(), boundedRadius, Math.max(1, threads), mode, scan.hashEngine.getGoldenFile().getName());
|
||||
engine.getDimension().getLoadKey(), engine.getSeedManager().getSeed(), boundedRadius, Math.max(1, threads), mode, scan.hashEngine.getGoldenFile().getName());
|
||||
Thread thread = new Thread(() -> {
|
||||
try {
|
||||
scan.hashEngine.run();
|
||||
|
||||
+134
-21
@@ -18,6 +18,8 @@
|
||||
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.core.pack.PackResourceCleanup;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.pack.PackValidationResult;
|
||||
import art.arcane.iris.core.pack.PackValidator;
|
||||
@@ -58,12 +60,27 @@ public final class ModdedPackCommands {
|
||||
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> validate(context.getSource(), StringArgumentType.getString(context, "pack")))));
|
||||
|
||||
root.then(Commands.literal("cleanup")
|
||||
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> cleanup(context.getSource(), StringArgumentType.getString(context, "pack"), false))
|
||||
.then(Commands.literal("apply")
|
||||
.executes((CommandContext<CommandSourceStack> context) -> cleanup(context.getSource(), StringArgumentType.getString(context, "pack"), true)))));
|
||||
root.then(Commands.literal("c")
|
||||
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> cleanup(context.getSource(), StringArgumentType.getString(context, "pack"), false))
|
||||
.then(Commands.literal("apply")
|
||||
.executes((CommandContext<CommandSourceStack> context) -> cleanup(context.getSource(), StringArgumentType.getString(context, "pack"), true)))));
|
||||
|
||||
root.then(Commands.literal("restore")
|
||||
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack")))));
|
||||
.executes((CommandContext<CommandSourceStack> context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"), false))
|
||||
.then(Commands.literal("apply")
|
||||
.executes((CommandContext<CommandSourceStack> context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"), true)))));
|
||||
root.then(Commands.literal("r")
|
||||
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
|
||||
.executes((CommandContext<CommandSourceStack> context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack")))));
|
||||
.executes((CommandContext<CommandSourceStack> context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"), false))
|
||||
.then(Commands.literal("apply")
|
||||
.executes((CommandContext<CommandSourceStack> context) -> restore(context.getSource(), StringArgumentType.getString(context, "pack"), true)))));
|
||||
|
||||
root.then(Commands.literal("status")
|
||||
.executes((CommandContext<CommandSourceStack> context) -> status(context.getSource(), null))
|
||||
@@ -99,8 +116,8 @@ public final class ModdedPackCommands {
|
||||
targets.add(dir);
|
||||
}
|
||||
} else {
|
||||
File target = new File(packsRoot, pack);
|
||||
if (!target.isDirectory()) {
|
||||
File target = PackDirectoryResolver.resolveExisting(packsRoot, pack);
|
||||
if (target == null) {
|
||||
IrisModdedCommands.fail(source, "Pack '" + pack + "' not found under " + packsRoot.getAbsolutePath());
|
||||
return 0;
|
||||
}
|
||||
@@ -133,19 +150,45 @@ public final class ModdedPackCommands {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int restore(CommandSourceStack source, String pack) {
|
||||
File packFolder = new File(packsRoot(), pack);
|
||||
if (!packFolder.isDirectory()) {
|
||||
private static int cleanup(CommandSourceStack source, String pack, boolean apply) {
|
||||
File packFolder = PackDirectoryResolver.resolveExisting(packsRoot(), pack);
|
||||
if (packFolder == null) {
|
||||
IrisModdedCommands.fail(source, "Pack '" + pack + "' not found under " + packsRoot().getAbsolutePath());
|
||||
return 0;
|
||||
}
|
||||
int restored = PackValidator.restoreTrash(packFolder);
|
||||
if (restored == 0) {
|
||||
IrisModdedCommands.fail(source, "Nothing to restore for pack '" + pack + "'.");
|
||||
MinecraftServer server = source.getServer();
|
||||
Thread thread = new Thread(() -> {
|
||||
if (apply) {
|
||||
PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(packFolder);
|
||||
server.execute(() -> reportCleanupApply(source, pack, result));
|
||||
} else {
|
||||
PackResourceCleanup.Preview result = PackResourceCleanup.preview(packFolder);
|
||||
server.execute(() -> reportCleanupPreview(source, pack, result));
|
||||
}
|
||||
}, "Iris Pack Cleanup");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int restore(CommandSourceStack source, String pack, boolean apply) {
|
||||
File packFolder = PackDirectoryResolver.resolveExisting(packsRoot(), pack);
|
||||
if (packFolder == null) {
|
||||
IrisModdedCommands.fail(source, "Pack '" + pack + "' not found under " + packsRoot().getAbsolutePath());
|
||||
return 0;
|
||||
}
|
||||
IrisModdedCommands.ok(source, "Restored " + restored + " file(s) from the most recent trash dump for pack '" + pack + "'.");
|
||||
IrisModdedCommands.ok(source, "Re-run /iris pack validate " + pack + " to re-check.");
|
||||
MinecraftServer server = source.getServer();
|
||||
Thread thread = new Thread(() -> {
|
||||
if (apply) {
|
||||
PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(packFolder);
|
||||
server.execute(() -> reportRestoreApply(source, pack, result));
|
||||
} else {
|
||||
PackResourceCleanup.RestorePreview result = PackResourceCleanup.previewRestore(packFolder);
|
||||
server.execute(() -> reportRestorePreview(source, pack, result));
|
||||
}
|
||||
}, "Iris Pack Restore");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -161,8 +204,7 @@ public final class ModdedPackCommands {
|
||||
String tag = result.isLoadable() ? "OK" : "BROKEN";
|
||||
IrisModdedCommands.ok(source, tag + " " + entry.getKey()
|
||||
+ " (blocking=" + result.getBlockingErrors().size()
|
||||
+ ", warnings=" + result.getWarnings().size()
|
||||
+ ", trashed=" + result.getRemovedUnusedFiles().size() + ")");
|
||||
+ ", warnings=" + result.getWarnings().size() + ")");
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -178,8 +220,7 @@ public final class ModdedPackCommands {
|
||||
private static void report(CommandSourceStack source, PackValidationResult result) {
|
||||
if (result.isLoadable()) {
|
||||
IrisModdedCommands.ok(source, "Pack '" + result.getPackName() + "' is loadable."
|
||||
+ " (warnings=" + result.getWarnings().size()
|
||||
+ ", trashed=" + result.getRemovedUnusedFiles().size() + ")");
|
||||
+ " (warnings=" + result.getWarnings().size() + ")");
|
||||
} else {
|
||||
IrisModdedCommands.fail(source, "Pack '" + result.getPackName() + "' is BROKEN:");
|
||||
for (String reason : result.getBlockingErrors()) {
|
||||
@@ -193,12 +234,84 @@ public final class ModdedPackCommands {
|
||||
if (result.getWarnings().size() > warningMax) {
|
||||
IrisModdedCommands.ok(source, " ... and " + (result.getWarnings().size() - warningMax) + " more warning(s).");
|
||||
}
|
||||
int trashMax = Math.min(10, result.getRemovedUnusedFiles().size());
|
||||
for (int i = 0; i < trashMax; i++) {
|
||||
IrisModdedCommands.ok(source, " ~ trashed " + result.getRemovedUnusedFiles().get(i));
|
||||
}
|
||||
|
||||
private static void reportCleanupPreview(CommandSourceStack source, String pack, PackResourceCleanup.Preview result) {
|
||||
if (!result.success()) {
|
||||
IrisModdedCommands.fail(source, result.error());
|
||||
return;
|
||||
}
|
||||
if (result.getRemovedUnusedFiles().size() > trashMax) {
|
||||
IrisModdedCommands.ok(source, " ... and " + (result.getRemovedUnusedFiles().size() - trashMax) + " more trashed file(s).");
|
||||
if (!result.hasCandidates()) {
|
||||
IrisModdedCommands.ok(source, "No cleanup candidates found for pack '" + pack + "'.");
|
||||
return;
|
||||
}
|
||||
IrisModdedCommands.ok(source, "Cleanup preview for pack '" + pack + "': "
|
||||
+ result.candidatePaths().size() + " candidate(s). No files were changed.");
|
||||
reportPaths(source, result.candidatePaths(), "candidate");
|
||||
IrisModdedCommands.ok(source, "Run /iris pack cleanup " + pack + " apply to quarantine after a fresh scan.");
|
||||
}
|
||||
|
||||
private static void reportCleanupApply(CommandSourceStack source, String pack, PackResourceCleanup.ApplyResult result) {
|
||||
if (!result.success()) {
|
||||
IrisModdedCommands.fail(source, result.error());
|
||||
reportPaths(source, result.quarantinedPaths(), "still quarantined");
|
||||
return;
|
||||
}
|
||||
if (!result.changed()) {
|
||||
IrisModdedCommands.ok(source, "No cleanup candidates found for pack '" + pack + "'.");
|
||||
return;
|
||||
}
|
||||
IrisModdedCommands.ok(source, "Quarantined " + result.quarantinedPaths().size()
|
||||
+ " cleanup candidate(s) under " + result.quarantinePath() + ".");
|
||||
reportPaths(source, result.quarantinedPaths(), "quarantined");
|
||||
}
|
||||
|
||||
private static void reportRestorePreview(CommandSourceStack source, String pack, PackResourceCleanup.RestorePreview result) {
|
||||
if (!result.success()) {
|
||||
IrisModdedCommands.fail(source, result.error());
|
||||
return;
|
||||
}
|
||||
if (!result.hasFiles()) {
|
||||
IrisModdedCommands.ok(source, "Nothing to restore for pack '" + pack + "'.");
|
||||
return;
|
||||
}
|
||||
IrisModdedCommands.ok(source, "Restore preview for " + result.dumpPath() + ": "
|
||||
+ result.filePaths().size() + " file(s). No files were changed.");
|
||||
reportPaths(source, result.filePaths(), "file");
|
||||
if (!result.conflicts().isEmpty()) {
|
||||
IrisModdedCommands.fail(source, "Restore is blocked by " + result.conflicts().size() + " existing destination(s).");
|
||||
reportPaths(source, result.conflicts(), "conflict");
|
||||
return;
|
||||
}
|
||||
IrisModdedCommands.ok(source, "Run /iris pack restore " + pack + " apply to restore after a fresh conflict check.");
|
||||
}
|
||||
|
||||
private static void reportRestoreApply(CommandSourceStack source, String pack, PackResourceCleanup.RestoreResult result) {
|
||||
if (!result.conflicts().isEmpty()) {
|
||||
IrisModdedCommands.fail(source, "Restore refused because " + result.conflicts().size() + " destination(s) already exist.");
|
||||
reportPaths(source, result.conflicts(), "conflict");
|
||||
return;
|
||||
}
|
||||
if (!result.success()) {
|
||||
IrisModdedCommands.fail(source, result.error());
|
||||
return;
|
||||
}
|
||||
if (!result.changed()) {
|
||||
IrisModdedCommands.ok(source, "Nothing to restore for pack '" + pack + "'.");
|
||||
return;
|
||||
}
|
||||
IrisModdedCommands.ok(source, "Restored " + result.restoredPaths().size()
|
||||
+ " file(s) from " + result.dumpPath() + ".");
|
||||
reportPaths(source, result.restoredPaths(), "restored");
|
||||
}
|
||||
|
||||
private static void reportPaths(CommandSourceStack source, List<String> paths, String label) {
|
||||
int max = Math.min(10, paths.size());
|
||||
for (int i = 0; i < max; i++) {
|
||||
IrisModdedCommands.ok(source, " - " + label + ": " + paths.get(i));
|
||||
}
|
||||
if (paths.size() > max) {
|
||||
IrisModdedCommands.ok(source, " ... and " + (paths.size() - max) + " more.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -77,7 +77,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
this.maxInFlight = Math.max(8, pregen.getModdedPregenInFlight());
|
||||
this.minInFlight = Math.max(4, Math.min(16, maxInFlight / 4));
|
||||
this.semaphore = new Semaphore(maxInFlight, true);
|
||||
this.adaptiveLimit = new AtomicInteger(maxInFlight);
|
||||
this.adaptiveLimit = new AtomicInteger(sync ? 1 : maxInFlight);
|
||||
this.timeoutSeconds = Math.max(120, pregen.getChunkLoadTimeoutSeconds());
|
||||
this.backpressure = new PregenMantleBackpressure(
|
||||
this::getMantle,
|
||||
@@ -179,6 +179,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
CompletableFuture<?> loadFuture = CompletableFuture
|
||||
.supplyAsync(() -> level.getChunkSource().addTicketAndLoadWithRadius(PREGEN_TICKET, pos, 0), level.getServer())
|
||||
.thenCompose((CompletableFuture<?> inner) -> inner);
|
||||
markSubmitted();
|
||||
try {
|
||||
Object result = loadFuture.get(timeoutSeconds, TimeUnit.SECONDS);
|
||||
if (result instanceof ChunkResult<?> chunkResult && !chunkResult.isSuccess()) {
|
||||
@@ -196,6 +197,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, e.toString());
|
||||
listener.onChunkFailed(x, z);
|
||||
} finally {
|
||||
markFinished();
|
||||
level.getServer().execute(() -> level.getChunkSource().removeTicketWithRadius(PREGEN_TICKET, pos, 0));
|
||||
}
|
||||
}
|
||||
@@ -255,6 +257,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
|
||||
private void markFinished() {
|
||||
inFlight.decrementAndGet();
|
||||
if (sync) {
|
||||
return;
|
||||
}
|
||||
synchronized (permitMonitor) {
|
||||
permitMonitor.notifyAll();
|
||||
}
|
||||
|
||||
+13
-1
@@ -44,6 +44,7 @@ import net.minecraft.nbt.NbtUtils;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
@@ -108,17 +109,22 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
|
||||
if (engine == null || engine.isClosed() || engine.getMantle().getMantle().isClosed()) {
|
||||
continue;
|
||||
}
|
||||
if (level.players().isEmpty() || isPregenActive(engine)) {
|
||||
if (!hasUpdateTargets(!level.players().isEmpty(), !level.getForceLoadedChunks().isEmpty()) || isPregenActive(engine)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
updateNearPlayers(engine, level);
|
||||
updateForcedChunks(engine, level);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static boolean hasUpdateTargets(boolean hasPlayers, boolean hasForcedChunks) {
|
||||
return hasPlayers || hasForcedChunks;
|
||||
}
|
||||
|
||||
private boolean isPregenActive(Engine engine) {
|
||||
PregeneratorJob job = PregeneratorJob.getInstance();
|
||||
return job != null && job.targetsWorldName(engine.getWorld().name());
|
||||
@@ -136,6 +142,12 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateForcedChunks(Engine engine, ServerLevel level) {
|
||||
for (long chunkKey : level.getForceLoadedChunks()) {
|
||||
updateChunk(engine, level, ChunkPos.getX(chunkKey), ChunkPos.getZ(chunkKey));
|
||||
}
|
||||
}
|
||||
|
||||
private void updateChunk(Engine engine, ServerLevel level, int chunkX, int chunkZ) {
|
||||
for (int x = -1; x <= 1; x++) {
|
||||
for (int z = -1; z <= 1; z++) {
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class InitialSpawnQueueTest {
|
||||
@Test
|
||||
public void rejectsDuplicatesAndEnforcesCapacityWhileInFlight() {
|
||||
InitialSpawnQueue queue = new InitialSpawnQueue(2);
|
||||
|
||||
assertTrue(queue.offer(1L));
|
||||
assertFalse(queue.offer(1L));
|
||||
assertTrue(queue.offer(2L));
|
||||
assertEquals(Long.valueOf(1L), queue.poll());
|
||||
assertFalse(queue.offer(3L));
|
||||
queue.complete(1L);
|
||||
assertTrue(queue.offer(3L));
|
||||
assertEquals(2, queue.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retryRotatesWithoutBlockingReadyEntries() {
|
||||
InitialSpawnQueue queue = new InitialSpawnQueue(4);
|
||||
queue.offer(1L);
|
||||
queue.offer(2L);
|
||||
queue.offer(3L);
|
||||
|
||||
int budget = queue.batchSize(8);
|
||||
Long unavailable = queue.poll();
|
||||
queue.retry(unavailable);
|
||||
Long firstReady = queue.poll();
|
||||
queue.complete(firstReady);
|
||||
Long secondReady = queue.poll();
|
||||
queue.complete(secondReady);
|
||||
|
||||
assertEquals(3, budget);
|
||||
assertEquals(Long.valueOf(1L), unavailable);
|
||||
assertEquals(Long.valueOf(2L), firstReady);
|
||||
assertEquals(Long.valueOf(3L), secondReady);
|
||||
assertEquals(1, queue.batchSize(8));
|
||||
assertEquals(Long.valueOf(1L), queue.poll());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retryIsDeduplicated() {
|
||||
InitialSpawnQueue queue = new InitialSpawnQueue(2);
|
||||
queue.offer(7L);
|
||||
Long key = queue.poll();
|
||||
|
||||
queue.retry(key);
|
||||
queue.retry(key);
|
||||
|
||||
assertEquals(1, queue.batchSize(8));
|
||||
assertEquals(Long.valueOf(7L), queue.poll());
|
||||
assertNull(queue.poll());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closeClearsAndRejectsFurtherWork() {
|
||||
InitialSpawnQueue queue = new InitialSpawnQueue(2);
|
||||
queue.offer(1L);
|
||||
|
||||
queue.close();
|
||||
|
||||
assertTrue(queue.isEmpty());
|
||||
assertEquals(0, queue.size());
|
||||
assertFalse(queue.offer(2L));
|
||||
assertEquals(0, queue.batchSize(8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clearAllowsLaterWork() {
|
||||
InitialSpawnQueue queue = new InitialSpawnQueue(2);
|
||||
queue.offer(1L);
|
||||
|
||||
queue.clear();
|
||||
|
||||
assertTrue(queue.offer(2L));
|
||||
assertEquals(Long.valueOf(2L), queue.poll());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expiredEntriesReleaseCapacity() {
|
||||
AtomicLong now = new AtomicLong();
|
||||
InitialSpawnQueue queue = new InitialSpawnQueue(2, 100L, now::get);
|
||||
queue.offer(1L);
|
||||
queue.offer(2L);
|
||||
|
||||
now.set(100L);
|
||||
|
||||
assertTrue(queue.offer(3L));
|
||||
assertEquals(1, queue.size());
|
||||
assertEquals(Long.valueOf(3L), queue.poll());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expiredInFlightEntryCannotRetry() {
|
||||
AtomicLong now = new AtomicLong();
|
||||
InitialSpawnQueue queue = new InitialSpawnQueue(2, 100L, now::get);
|
||||
queue.offer(1L);
|
||||
Long inFlight = queue.poll();
|
||||
|
||||
now.set(100L);
|
||||
queue.retry(inFlight);
|
||||
|
||||
assertEquals(0, queue.size());
|
||||
assertNull(queue.poll());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentOffersRemainDeduplicatedAndBounded() throws Exception {
|
||||
InitialSpawnQueue queue = new InitialSpawnQueue(256);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(8);
|
||||
List<Future<?>> futures = new ArrayList<>();
|
||||
for (int thread = 0; thread < 8; thread++) {
|
||||
futures.add(executor.submit(() -> {
|
||||
for (long key = 0L; key < 512L; key++) {
|
||||
queue.offer(key);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for (Future<?> future : futures) {
|
||||
future.get();
|
||||
}
|
||||
executor.shutdown();
|
||||
|
||||
assertTrue(executor.awaitTermination(10L, TimeUnit.SECONDS));
|
||||
assertEquals(256, queue.size());
|
||||
assertEquals(256, queue.batchSize(512));
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.util.random.Weighted;
|
||||
import net.minecraft.util.random.WeightedList;
|
||||
import net.minecraft.world.entity.EntityTypes;
|
||||
import net.minecraft.world.level.biome.MobSpawnSettings;
|
||||
import org.junit.Test;
|
||||
import org.junit.BeforeClass;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class IrisModdedChunkGeneratorSpawnTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitSpawnsReplaceMatchingVanillaTypesAndPreserveOthers() {
|
||||
MobSpawnSettings.SpawnerData zombie = new MobSpawnSettings.SpawnerData(EntityTypes.ZOMBIE, 1, 4);
|
||||
MobSpawnSettings.SpawnerData vanillaSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, 1, 1);
|
||||
MobSpawnSettings.SpawnerData explicitSlime = new MobSpawnSettings.SpawnerData(EntityTypes.SLIME, 2, 5);
|
||||
MobSpawnSettings.SpawnerData explicitCow = new MobSpawnSettings.SpawnerData(EntityTypes.COW, 2, 4);
|
||||
WeightedList<MobSpawnSettings.SpawnerData> vanilla = WeightedList.of(List.of(
|
||||
new Weighted<>(zombie, 100),
|
||||
new Weighted<>(vanillaSlime, 1)));
|
||||
WeightedList<MobSpawnSettings.SpawnerData> explicit = WeightedList.of(List.of(
|
||||
new Weighted<>(explicitSlime, 7),
|
||||
new Weighted<>(explicitCow, 3)));
|
||||
|
||||
WeightedList<MobSpawnSettings.SpawnerData> merged = NativeSpawnTableMerger.merge(vanilla, explicit);
|
||||
List<Weighted<MobSpawnSettings.SpawnerData>> entries = merged.unwrap();
|
||||
|
||||
assertEquals(3, entries.size());
|
||||
assertEquals(EntityTypes.ZOMBIE, entries.get(0).value().type());
|
||||
assertEquals(100, entries.get(0).weight());
|
||||
assertEquals(explicitSlime, entries.get(1).value());
|
||||
assertEquals(7, entries.get(1).weight());
|
||||
assertEquals(explicitCow, entries.get(2).value());
|
||||
assertEquals(3, entries.get(2).weight());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedWorldCheckTest {
|
||||
@Test
|
||||
public void coordinatorThreadIsNonDaemon() {
|
||||
Thread thread = ModdedWorldCheck.coordinatorThread(() -> {
|
||||
});
|
||||
|
||||
assertFalse(thread.isDaemon());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void passStopsServerBeforeZeroExit() {
|
||||
List<String> events = new ArrayList<>();
|
||||
|
||||
ModdedWorldCheck.stopAndExit(
|
||||
() -> events.add("stop"),
|
||||
0,
|
||||
status -> events.add("exit:" + status)
|
||||
);
|
||||
|
||||
assertEquals(List.of("stop", "exit:0"), events);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failureStopsServerBeforeNonzeroExit() {
|
||||
List<String> events = new ArrayList<>();
|
||||
|
||||
ModdedWorldCheck.stopAndExit(
|
||||
() -> events.add("stop"),
|
||||
1,
|
||||
status -> events.add("exit:" + status)
|
||||
);
|
||||
|
||||
assertEquals(List.of("stop", "exit:1"), events);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shutdownFailureForcesNonzeroExit() {
|
||||
AtomicInteger status = new AtomicInteger(-1);
|
||||
|
||||
ModdedWorldCheck.stopAndExit(
|
||||
() -> {
|
||||
throw new IllegalStateException("shutdown failed");
|
||||
},
|
||||
0,
|
||||
status::set
|
||||
);
|
||||
|
||||
assertEquals(1, status.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void interruptionIsClearedForShutdownAndRestoredBeforeExit() {
|
||||
AtomicBoolean interruptedDuringStop = new AtomicBoolean(true);
|
||||
AtomicBoolean interruptedDuringExit = new AtomicBoolean(false);
|
||||
AtomicInteger status = new AtomicInteger(-1);
|
||||
Thread.currentThread().interrupt();
|
||||
try {
|
||||
ModdedWorldCheck.stopAndExit(
|
||||
() -> interruptedDuringStop.set(Thread.currentThread().isInterrupted()),
|
||||
0,
|
||||
exitStatus -> {
|
||||
status.set(exitStatus);
|
||||
interruptedDuringExit.set(Thread.currentThread().isInterrupted());
|
||||
}
|
||||
);
|
||||
|
||||
assertFalse(interruptedDuringStop.get());
|
||||
assertTrue(interruptedDuringExit.get());
|
||||
assertEquals(1, status.get());
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package art.arcane.iris.modded.service;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedChunkUpdateServiceTest {
|
||||
@Test
|
||||
public void scansWhenPlayersArePresent() {
|
||||
assertTrue(ModdedChunkUpdateService.hasUpdateTargets(true, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scansHeadlessForceLoadedChunks() {
|
||||
assertTrue(ModdedChunkUpdateService.hasUpdateTargets(false, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void skipsLevelsWithoutPlayersOrForcedChunks() {
|
||||
assertFalse(ModdedChunkUpdateService.hasUpdateTargets(false, false));
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,7 @@ tasks.named('shadowJar', ShadowJar).configure {
|
||||
exclude('META-INF/*.RSA')
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm')
|
||||
relocate('io.sentry', 'art.arcane.iris.shadow.sentry')
|
||||
relocate('com.sun.jna', 'art.arcane.iris.shadow.jna')
|
||||
relocate('oshi', 'art.arcane.iris.shadow.oshi')
|
||||
relocate('net.jpountz', 'art.arcane.iris.shadow.jpountz')
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
./gradlew buildAll --no-parallel -PuseLocalVolmLib=false "$@"
|
||||
./gradlew buildAllToOut --no-parallel -PuseLocalVolmLib=false "$@"
|
||||
|
||||
+62
-125
@@ -71,36 +71,14 @@ String bukkitArtifactName = irisArtifactName('CraftBukkit', minecraftVersion)
|
||||
String fabricArtifactName = irisArtifactName('Fabric', "${minecraftVersion}+${loaderDisplayVersion(fabricLoaderVersion)}")
|
||||
String forgeArtifactName = irisArtifactName('Forge', "${minecraftVersion}+${loaderDisplayVersion(forgeVersion)}")
|
||||
String neoForgeArtifactName = irisArtifactName('NeoForge', "${minecraftVersion}+${loaderDisplayVersion(neoForgeVersion)}")
|
||||
extensions.extraProperties.set('bukkitArtifactName', bukkitArtifactName)
|
||||
extensions.extraProperties.set('fabricArtifactName', fabricArtifactName)
|
||||
extensions.extraProperties.set('forgeArtifactName', forgeArtifactName)
|
||||
extensions.extraProperties.set('neoForgeArtifactName', neoForgeArtifactName)
|
||||
|
||||
apply plugin: ApiGenerator
|
||||
|
||||
// ADD YOURSELF AS A NEW LINE IF YOU WANT YOUR OWN BUILD TASK GENERATED
|
||||
// ======================== WINDOWS =============================
|
||||
registerCustomOutputTask('Cyberpwn', 'C://Users/cyberpwn/Documents/development/server/plugins')
|
||||
registerCustomOutputTask('Psycho', 'C://Dan/MinecraftDevelopment/Server/plugins')
|
||||
registerCustomOutputTask('ArcaneArts', 'C://Users/arcane/Documents/development/server/plugins')
|
||||
registerCustomOutputTask('Coco', 'D://mcsm/plugins')
|
||||
registerCustomOutputTask('Strange', 'D://Servers/1.17 Test Server/plugins')
|
||||
registerCustomOutputTask('Vatuu', 'D://Minecraft/Servers/1.19.4/plugins')
|
||||
registerCustomOutputTask('CrazyDev22', 'C://Users/Julian/Desktop/server/plugins')
|
||||
registerCustomOutputTask('PixelFury', 'C://Users/repix/workplace/Iris/1.21.3 - Development-Public-v3/plugins')
|
||||
registerCustomOutputTask('PixelFuryDev', 'C://Users/repix/workplace/Iris/1.21 - Development-v3/plugins')
|
||||
// ========================== UNIX ==============================
|
||||
registerCustomOutputTaskUnix('CyberpwnLT', '/Users/danielmills/development/server/plugins')
|
||||
registerCustomOutputTaskUnix(
|
||||
'PsychoLT',
|
||||
'/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/plugin-consumers/dropins/plugins',
|
||||
'/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/fabric-mod-consumers/dropins/mods',
|
||||
'/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/forge-mod-consumers/dropins/mods',
|
||||
'/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/neoforge-mod-consumers/dropins/mods'
|
||||
)
|
||||
registerCustomOutputTaskUnix('PixelMac', '/Users/test/Desktop/mcserver/plugins')
|
||||
registerCustomOutputTaskUnix('CrazyDev22LT', '/home/julian/Desktop/server/plugins')
|
||||
// ==============================================================
|
||||
String consumerLocation = providers.gradleProperty('location')
|
||||
.getOrElse('/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers')
|
||||
String bukkitConsumerPath = "${consumerLocation}/plugin-consumers/dropins/plugins"
|
||||
String fabricConsumerPath = "${consumerLocation}/fabric-mod-consumers/dropins/mods"
|
||||
String forgeConsumerPath = "${consumerLocation}/forge-mod-consumers/dropins/mods"
|
||||
String neoForgeConsumerPath = "${consumerLocation}/neoforge-mod-consumers/dropins/mods"
|
||||
|
||||
def nmsBindings = [
|
||||
v26_2_R1: '26.2.build.25-alpha',
|
||||
@@ -272,14 +250,14 @@ tasks.named('neoforgeJar').configure {
|
||||
mustRunAfter(tasks.named('forgeJar'))
|
||||
}
|
||||
|
||||
tasks.register('buildAll') {
|
||||
tasks.register('buildAllToOut') {
|
||||
group = 'iris'
|
||||
dependsOn('buildBukkit', 'buildFabric', 'buildForge', 'buildNeoforge', ':spi:jar')
|
||||
doLast {
|
||||
File dist = layout.projectDirectory.dir('dist').asFile
|
||||
File[] jars = dist.listFiles((java.io.FileFilter) { File f -> f.name.endsWith('.jar') })
|
||||
println('')
|
||||
println('=== Iris buildAll -> dist/ ===')
|
||||
println('=== Iris buildAllToOut -> dist/ ===')
|
||||
if (jars != null) {
|
||||
jars.sort { it.name }.each { File f ->
|
||||
println(String.format(' %-60s %8d KB', f.name, (long) (f.length() / 1024)))
|
||||
@@ -288,6 +266,60 @@ tasks.register('buildAll') {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('buildAll') {
|
||||
group = 'iris'
|
||||
outputs.upToDateWhen { false }
|
||||
dependsOn('iris', 'fabricJar', 'forgeJar', 'neoforgeJar')
|
||||
doLast {
|
||||
delete(
|
||||
file("${bukkitConsumerPath}/Iris-Fabric.jar"),
|
||||
file("${bukkitConsumerPath}/Iris-Forge.jar"),
|
||||
file("${bukkitConsumerPath}/Iris-Neoforge.jar")
|
||||
)
|
||||
copy {
|
||||
from(layout.buildDirectory.file(bukkitArtifactName))
|
||||
into(bukkitConsumerPath)
|
||||
rename { String ignored -> 'Iris.jar' }
|
||||
}
|
||||
delete(
|
||||
file("${fabricConsumerPath}/Iris.jar"),
|
||||
file("${fabricConsumerPath}/Iris-Forge.jar"),
|
||||
file("${fabricConsumerPath}/Iris-Neoforge.jar")
|
||||
)
|
||||
copy {
|
||||
from(layout.projectDirectory.file("adapters/fabric/build/libs/${fabricArtifactName}"))
|
||||
into(fabricConsumerPath)
|
||||
rename { String ignored -> 'Iris-Fabric.jar' }
|
||||
}
|
||||
delete(
|
||||
file("${forgeConsumerPath}/Iris.jar"),
|
||||
file("${forgeConsumerPath}/Iris-Fabric.jar"),
|
||||
file("${forgeConsumerPath}/Iris-Neoforge.jar")
|
||||
)
|
||||
copy {
|
||||
from(layout.projectDirectory.file("adapters/forge/build/libs/${forgeArtifactName}"))
|
||||
into(forgeConsumerPath)
|
||||
rename { String ignored -> 'Iris-Forge.jar' }
|
||||
}
|
||||
delete(
|
||||
file("${neoForgeConsumerPath}/Iris.jar"),
|
||||
file("${neoForgeConsumerPath}/Iris-Fabric.jar"),
|
||||
file("${neoForgeConsumerPath}/Iris-Forge.jar")
|
||||
)
|
||||
copy {
|
||||
from(layout.projectDirectory.file("adapters/neoforge/build/libs/${neoForgeArtifactName}"))
|
||||
into(neoForgeConsumerPath)
|
||||
rename { String ignored -> 'Iris-Neoforge.jar' }
|
||||
}
|
||||
println('')
|
||||
println("=== Iris buildAll -> ${consumerLocation} ===")
|
||||
println(" ${bukkitConsumerPath}/Iris.jar")
|
||||
println(" ${fabricConsumerPath}/Iris-Fabric.jar")
|
||||
println(" ${forgeConsumerPath}/Iris-Forge.jar")
|
||||
println(" ${neoForgeConsumerPath}/Iris-Neoforge.jar")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('irisDev', Copy) {
|
||||
group = 'iris'
|
||||
from(project(':core').layout.buildDirectory.files('libs/core-javadoc.jar', 'libs/core-sources.jar'))
|
||||
@@ -409,98 +441,3 @@ if (!JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_25)) {
|
||||
System.err.println()
|
||||
System.exit(69)
|
||||
}
|
||||
|
||||
void registerCustomOutputTask(String name, String path) {
|
||||
if (!System.getProperty('os.name').toLowerCase().contains('windows')) {
|
||||
return
|
||||
}
|
||||
|
||||
createCustomOutputTasks(name, path)
|
||||
}
|
||||
|
||||
void registerCustomOutputTask(String name, String path, String fabricPath, String forgePath, String neoForgePath) {
|
||||
if (!System.getProperty('os.name').toLowerCase().contains('windows')) {
|
||||
return
|
||||
}
|
||||
|
||||
createCustomOutputTasks(name, path, fabricPath, forgePath, neoForgePath)
|
||||
}
|
||||
|
||||
void registerCustomOutputTaskUnix(String name, String path) {
|
||||
if (System.getProperty('os.name').toLowerCase().contains('windows')) {
|
||||
return
|
||||
}
|
||||
|
||||
createCustomOutputTasks(name, path)
|
||||
}
|
||||
|
||||
void registerCustomOutputTaskUnix(String name, String path, String fabricPath, String forgePath, String neoForgePath) {
|
||||
if (System.getProperty('os.name').toLowerCase().contains('windows')) {
|
||||
return
|
||||
}
|
||||
|
||||
createCustomOutputTasks(name, path, fabricPath, forgePath, neoForgePath)
|
||||
}
|
||||
|
||||
void createCustomOutputTasks(String name, String path) {
|
||||
createBukkitOutputTask(name, path)
|
||||
|
||||
tasks.register("buildAll${name}") {
|
||||
group = 'development'
|
||||
dependsOn("build${name}")
|
||||
}
|
||||
}
|
||||
|
||||
void createCustomOutputTasks(String name, String path, String fabricPath, String forgePath, String neoForgePath) {
|
||||
createBukkitOutputTask(name, path)
|
||||
createPlatformOutputTask(name, fabricPath, 'Fabric', 'buildFabric', "adapters/fabric/build/libs/${String.valueOf(project.ext.fabricArtifactName)}")
|
||||
createPlatformOutputTask(name, forgePath, 'Forge', 'buildForge', "adapters/forge/build/libs/${String.valueOf(project.ext.forgeArtifactName)}")
|
||||
createPlatformOutputTask(name, neoForgePath, 'Neoforge', 'buildNeoforge', "adapters/neoforge/build/libs/${String.valueOf(project.ext.neoForgeArtifactName)}")
|
||||
|
||||
tasks.register("buildAll${name}") {
|
||||
group = 'development'
|
||||
dependsOn(
|
||||
"build${name}",
|
||||
"build${name}Fabric",
|
||||
"build${name}Forge",
|
||||
"build${name}Neoforge"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
void createBukkitOutputTask(String name, String path) {
|
||||
tasks.register("build${name}", Copy) {
|
||||
group = 'development'
|
||||
outputs.upToDateWhen { false }
|
||||
dependsOn('iris')
|
||||
from(layout.buildDirectory.file((String) project.ext.bukkitArtifactName))
|
||||
into(file(path))
|
||||
rename { String ignored -> 'Iris.jar' }
|
||||
doFirst {
|
||||
delete(
|
||||
file("${path}/Iris-Fabric.jar"),
|
||||
file("${path}/Iris-Forge.jar"),
|
||||
file("${path}/Iris-Neoforge.jar")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void createPlatformOutputTask(String name, String path, String platform, String buildTask, String sourcePath) {
|
||||
tasks.register("build${name}${platform}", Copy) {
|
||||
group = 'development'
|
||||
outputs.upToDateWhen { false }
|
||||
dependsOn(buildTask)
|
||||
from(layout.projectDirectory.file(sourcePath))
|
||||
into(file(path))
|
||||
rename { String ignored -> "Iris-${platform}.jar" }
|
||||
doFirst {
|
||||
delete(
|
||||
file("${path}/Iris.jar"),
|
||||
file("${path}/Iris-Fabric.jar"),
|
||||
file("${path}/Iris-Forge.jar"),
|
||||
file("${path}/Iris-Neoforge.jar")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ public class ServerConfigurator {
|
||||
}
|
||||
DimensionHeight height = new DimensionHeight(fixer);
|
||||
KList<File> folders = getDatapacksFolder();
|
||||
IrisDimension.clearGeneratedBiomeTags(folders);
|
||||
DatapackIngestService.reapplyFromStaging(folders);
|
||||
java.util.concurrent.ConcurrentMap<String, KSet<String>> biomes = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
|
||||
@@ -205,7 +205,9 @@ public interface INMSBinding {
|
||||
void inject(long seed, Engine engine, World world) throws NoSuchFieldException, IllegalAccessException;
|
||||
|
||||
Vector3d getBoundingbox(org.bukkit.entity.EntityType entity);
|
||||
|
||||
|
||||
String getEntitySpawnCategory(String key);
|
||||
|
||||
Entity spawnEntity(Location location, EntityType type, CreatureSpawnEvent.SpawnReason reason);
|
||||
|
||||
Color getBiomeColor(Location location, BiomeColor type);
|
||||
|
||||
@@ -12,6 +12,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
|
||||
Dimension.OVERWORLD, """
|
||||
{
|
||||
"ambient_light": 0.0,
|
||||
"default_clock": "minecraft:overworld",
|
||||
"has_ender_dragon_fight": false,
|
||||
"attributes": {
|
||||
"minecraft:audio/ambient_sounds": {
|
||||
@@ -43,6 +44,7 @@ public class DataFixerV1217 extends DataFixerV1213 {
|
||||
Dimension.NETHER, """
|
||||
{
|
||||
"ambient_light": 0.1,
|
||||
"has_fixed_time": true,
|
||||
"has_ender_dragon_fight": false,
|
||||
"attributes": {
|
||||
"minecraft:gameplay/sky_light_level": 4.0,
|
||||
@@ -59,6 +61,8 @@ public class DataFixerV1217 extends DataFixerV1213 {
|
||||
Dimension.END, """
|
||||
{
|
||||
"ambient_light": 0.25,
|
||||
"default_clock": "minecraft:the_end",
|
||||
"has_fixed_time": true,
|
||||
"has_ender_dragon_fight": true,
|
||||
"attributes": {
|
||||
"minecraft:audio/ambient_sounds": {
|
||||
|
||||
@@ -248,6 +248,11 @@ public class NMSBinding1X implements INMSBinding {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getEntitySpawnCategory(String key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MCAPaletteAccess createPalette() {
|
||||
IrisLogging.error("Cannot use the global data palette! Iris is incapable of using MCA generation on this version of minecraft!");
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public final class PackDirectoryResolver {
|
||||
private PackDirectoryResolver() {
|
||||
}
|
||||
|
||||
public static File resolveExisting(File packsRoot, String packName) {
|
||||
if (packsRoot == null || packName == null || packName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Path root = packsRoot.toPath().toAbsolutePath().normalize();
|
||||
Path candidate = root.resolve(packName).normalize();
|
||||
if (!root.equals(candidate.getParent())) {
|
||||
return null;
|
||||
}
|
||||
if (Files.isSymbolicLink(candidate) || !Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return null;
|
||||
}
|
||||
return candidate.toFile();
|
||||
}
|
||||
}
|
||||
@@ -30,13 +30,18 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class PackDownloader {
|
||||
private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+");
|
||||
private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*");
|
||||
private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}");
|
||||
|
||||
private PackDownloader() {
|
||||
}
|
||||
|
||||
public static String download(File packsFolder, String repo, String branch, boolean forceOverwrite, boolean directUrl, Consumer<String> feedback) throws IOException {
|
||||
String url = directUrl ? branch : "https://codeload.github.com/" + repo + "/zip/refs/heads/" + branch;
|
||||
public static String download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, Consumer<String> feedback) throws IOException {
|
||||
String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref);
|
||||
feedback.accept("Downloading " + url + " "); //The extra space stops a bug in adventure API from repeating the last letter of the URL
|
||||
File zip = WebCache.getNonCachedFile("pack-" + repo, url);
|
||||
File temp = WebCache.getTemp();
|
||||
@@ -136,6 +141,57 @@ public final class PackDownloader {
|
||||
return key;
|
||||
}
|
||||
|
||||
static String resolveGithubArchiveUrl(String repo, String ref) {
|
||||
if (repo == null || !GITHUB_REPOSITORY.matcher(repo).matches()) {
|
||||
throw new IllegalArgumentException("Invalid GitHub repository '" + repo + "'");
|
||||
}
|
||||
String[] repositoryParts = repo.split("/", -1);
|
||||
if (repositoryParts[0].equals(".") || repositoryParts[0].equals("..")
|
||||
|| repositoryParts[1].equals(".") || repositoryParts[1].equals("..")) {
|
||||
throw new IllegalArgumentException("Invalid GitHub repository '" + repo + "'");
|
||||
}
|
||||
if (ref == null || ref.isBlank()) {
|
||||
throw new IllegalArgumentException("GitHub reference cannot be empty");
|
||||
}
|
||||
if (COMMIT_SHA.matcher(ref).matches()) {
|
||||
return "https://github.com/" + repo + "/archive/" + ref + ".zip";
|
||||
}
|
||||
if (ref.startsWith("refs/") && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) {
|
||||
throw new IllegalArgumentException("Unsupported GitHub reference '" + ref + "'");
|
||||
}
|
||||
|
||||
String qualifiedRef;
|
||||
if (ref.startsWith("refs/heads/") || ref.startsWith("refs/tags/")) {
|
||||
qualifiedRef = ref;
|
||||
} else {
|
||||
qualifiedRef = "refs/heads/" + ref;
|
||||
}
|
||||
validateGithubRef(qualifiedRef);
|
||||
return "https://codeload.github.com/" + repo + "/zip/" + qualifiedRef;
|
||||
}
|
||||
|
||||
private static void validateGithubRef(String qualifiedRef) {
|
||||
String refPath = qualifiedRef.startsWith("refs/heads/")
|
||||
? qualifiedRef.substring("refs/heads/".length())
|
||||
: qualifiedRef.substring("refs/tags/".length());
|
||||
if (!GITHUB_REF.matcher(refPath).matches()
|
||||
|| refPath.contains("..")
|
||||
|| refPath.contains("//")
|
||||
|| refPath.contains("@{")
|
||||
|| refPath.endsWith("/")
|
||||
|| refPath.endsWith(".")
|
||||
|| refPath.endsWith(".lock")) {
|
||||
throw new IllegalArgumentException("Invalid GitHub reference '" + qualifiedRef + "'");
|
||||
}
|
||||
|
||||
String[] segments = refPath.split("/");
|
||||
for (String segment : segments) {
|
||||
if (segment.startsWith(".") || segment.endsWith(".lock")) {
|
||||
throw new IllegalArgumentException("Invalid GitHub reference '" + qualifiedRef + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateDownloaded(File packEntry, Consumer<String> feedback) {
|
||||
try {
|
||||
PackValidationResult result = PackValidator.validate(packEntry);
|
||||
@@ -146,9 +202,8 @@ public final class PackDownloader {
|
||||
for (String reason : result.getBlockingErrors()) {
|
||||
feedback.accept(" - " + reason);
|
||||
}
|
||||
} else if (!result.getWarnings().isEmpty() || !result.getRemovedUnusedFiles().isEmpty()) {
|
||||
} else if (!result.getWarnings().isEmpty()) {
|
||||
feedback.accept("Pack '" + result.getPackName() + "' validated ("
|
||||
+ result.getRemovedUnusedFiles().size() + " unused file(s) quarantined to .iris-trash/, "
|
||||
+ result.getWarnings().size() + " warning(s)).");
|
||||
} else {
|
||||
feedback.accept("Pack '" + result.getPackName() + "' validated.");
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class PackResourceCleanup {
|
||||
private static final String TRASH_ROOT = ".iris-trash";
|
||||
private static final List<String> MANAGED_RESOURCE_FOLDERS = List.of(
|
||||
"biomes",
|
||||
"regions",
|
||||
"entities",
|
||||
"spawners",
|
||||
"loot",
|
||||
"generators",
|
||||
"expressions",
|
||||
"markers",
|
||||
"blocks",
|
||||
"mods"
|
||||
);
|
||||
private static final List<String> CORPUS_EXCLUSIONS = List.of(
|
||||
TRASH_ROOT,
|
||||
"datapack-imports",
|
||||
"externaldatapacks",
|
||||
"internaldatapacks",
|
||||
"datapacks",
|
||||
"cache",
|
||||
"objects",
|
||||
".iris"
|
||||
);
|
||||
private static final DateTimeFormatter TRASH_STAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS");
|
||||
private static final Map<Path, Object> PACK_LOCKS = new ConcurrentHashMap<>();
|
||||
|
||||
private PackResourceCleanup() {
|
||||
}
|
||||
|
||||
public static Preview preview(File packFolder) {
|
||||
Path lockPath = lockPath(packFolder);
|
||||
if (lockPath == null) {
|
||||
return new Preview(List.of(), "Pack folder is required.");
|
||||
}
|
||||
synchronized (packLock(lockPath)) {
|
||||
try {
|
||||
Path packRoot = requirePackRoot(packFolder);
|
||||
CleanupScan scan = scanCleanup(packRoot);
|
||||
return new Preview(scan.paths(), null);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return new Preview(List.of(), errorMessage("Unable to scan pack resources", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static ApplyResult apply(File packFolder) {
|
||||
Path lockPath = lockPath(packFolder);
|
||||
if (lockPath == null) {
|
||||
return new ApplyResult(null, List.of(), "Pack folder is required.");
|
||||
}
|
||||
synchronized (packLock(lockPath)) {
|
||||
try {
|
||||
Path packRoot = requirePackRoot(packFolder);
|
||||
CleanupScan scan = scanCleanup(packRoot);
|
||||
if (scan.paths().isEmpty()) {
|
||||
return new ApplyResult(null, List.of(), null);
|
||||
}
|
||||
return quarantine(packRoot, scan.paths());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return new ApplyResult(null, List.of(), errorMessage("Unable to quarantine pack resources", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static RestorePreview previewRestore(File packFolder) {
|
||||
Path lockPath = lockPath(packFolder);
|
||||
if (lockPath == null) {
|
||||
return new RestorePreview(null, List.of(), List.of(), "Pack folder is required.");
|
||||
}
|
||||
synchronized (packLock(lockPath)) {
|
||||
try {
|
||||
Path packRoot = requirePackRoot(packFolder);
|
||||
RestoreScan scan = scanRestore(packRoot);
|
||||
return new RestorePreview(scan.dumpPath(), scan.paths(), scan.conflicts(), null);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return new RestorePreview(null, List.of(), List.of(), errorMessage("Unable to scan quarantined resources", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static RestoreResult restoreLatest(File packFolder) {
|
||||
Path lockPath = lockPath(packFolder);
|
||||
if (lockPath == null) {
|
||||
return new RestoreResult(null, List.of(), List.of(), "Pack folder is required.");
|
||||
}
|
||||
synchronized (packLock(lockPath)) {
|
||||
try {
|
||||
Path packRoot = requirePackRoot(packFolder);
|
||||
RestoreScan scan = scanRestore(packRoot);
|
||||
if (scan.dump() == null) {
|
||||
return new RestoreResult(null, List.of(), List.of(), null);
|
||||
}
|
||||
if (!scan.conflicts().isEmpty()) {
|
||||
return new RestoreResult(scan.dumpPath(), List.of(), scan.conflicts(), null);
|
||||
}
|
||||
return restore(packRoot, scan);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return new RestoreResult(null, List.of(), List.of(), errorMessage("Unable to restore quarantined resources", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ApplyResult quarantine(Path packRoot, List<String> paths) throws IOException {
|
||||
Path dump = createUniqueDump(packRoot);
|
||||
List<Transfer> moved = new ArrayList<>(paths.size());
|
||||
try {
|
||||
for (String path : paths) {
|
||||
Path source = resolveContained(packRoot, path);
|
||||
requireRegularFile(source, "Cleanup candidate");
|
||||
Path destination = resolveContained(dump, path);
|
||||
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new FileAlreadyExistsException(destination.toString());
|
||||
}
|
||||
Files.createDirectories(destination.getParent());
|
||||
Files.move(source, destination);
|
||||
moved.add(new Transfer(path, source, destination));
|
||||
}
|
||||
return new ApplyResult(relativePath(packRoot, dump), paths, null);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
List<String> remaining = rollbackMoves(moved);
|
||||
deleteEmptyDirectories(dump);
|
||||
deleteIfEmpty(dump.getParent());
|
||||
String rollbackState = remaining.isEmpty()
|
||||
? "Quarantine failed and was rolled back"
|
||||
: "Quarantine failed and rollback left " + remaining.size() + " file(s) quarantined";
|
||||
IrisLogging.reportError(rollbackState + " for pack " + packRoot.getFileName(), e);
|
||||
return new ApplyResult(relativePath(packRoot, dump), remaining, errorMessage(rollbackState, e));
|
||||
}
|
||||
}
|
||||
|
||||
private static RestoreResult restore(Path packRoot, RestoreScan scan) throws IOException {
|
||||
List<Path> copiedDestinations = new ArrayList<>(scan.transfers().size());
|
||||
List<Path> createdDirectories = new ArrayList<>();
|
||||
try {
|
||||
for (Transfer transfer : scan.transfers()) {
|
||||
createDirectories(packRoot, transfer.destination().getParent(), createdDirectories);
|
||||
Files.copy(transfer.source(), transfer.destination(), StandardCopyOption.COPY_ATTRIBUTES);
|
||||
copiedDestinations.add(transfer.destination());
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
List<String> rollbackRemainders = rollbackCopies(packRoot, copiedDestinations, createdDirectories);
|
||||
String rollbackState = rollbackRemainders.isEmpty()
|
||||
? "Restore copy failed and was rolled back"
|
||||
: "Restore copy failed and rollback left " + rollbackRemainders.size() + " destination(s)";
|
||||
IrisLogging.reportError(rollbackState + " for pack " + packRoot.getFileName(), e);
|
||||
return new RestoreResult(scan.dumpPath(), rollbackRemainders, List.of(), errorMessage(rollbackState, e));
|
||||
}
|
||||
|
||||
List<Transfer> removedSources = new ArrayList<>(scan.transfers().size());
|
||||
for (Transfer transfer : scan.transfers()) {
|
||||
try {
|
||||
Files.delete(transfer.source());
|
||||
removedSources.add(transfer);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return rollbackSourceRemoval(packRoot, scan, copiedDestinations, createdDirectories, removedSources, e);
|
||||
}
|
||||
}
|
||||
deleteEmptyDirectories(scan.dump());
|
||||
deleteIfEmpty(scan.dump().getParent());
|
||||
return new RestoreResult(scan.dumpPath(), scan.paths(), List.of(), null);
|
||||
}
|
||||
|
||||
private static CleanupScan scanCleanup(Path packRoot) throws IOException {
|
||||
List<Path> corpusFiles = listTree(packRoot);
|
||||
StringBuilder corpus = new StringBuilder(1 << 16);
|
||||
for (Path path : corpusFiles) {
|
||||
if (!isScannableJson(packRoot, path)) {
|
||||
continue;
|
||||
}
|
||||
requireRegularFile(path, "Corpus file");
|
||||
corpus.append(Files.readString(path, StandardCharsets.UTF_8)).append('\n');
|
||||
}
|
||||
if (corpus.isEmpty()) {
|
||||
return new CleanupScan(List.of());
|
||||
}
|
||||
String corpusText = corpus.toString();
|
||||
|
||||
List<String> candidates = new ArrayList<>();
|
||||
for (String folderName : MANAGED_RESOURCE_FOLDERS) {
|
||||
Path resourceFolder = resolveContained(packRoot, folderName);
|
||||
if (!Files.exists(resourceFolder, LinkOption.NOFOLLOW_LINKS)) {
|
||||
continue;
|
||||
}
|
||||
requireDirectory(resourceFolder, "Managed resource folder");
|
||||
for (Path resource : listTree(resourceFolder)) {
|
||||
if (!isJsonFile(resource)) {
|
||||
continue;
|
||||
}
|
||||
requireRegularFile(resource, "Managed resource");
|
||||
String key = stripJsonExtension(relativePath(resourceFolder, resource));
|
||||
if (!key.isBlank() && !isReferenced(corpusText, key)) {
|
||||
candidates.add(relativePath(packRoot, resource));
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort(String::compareTo);
|
||||
return new CleanupScan(List.copyOf(candidates));
|
||||
}
|
||||
|
||||
private static RestoreScan scanRestore(Path packRoot) throws IOException {
|
||||
Path trashRoot = resolveContained(packRoot, TRASH_ROOT);
|
||||
if (!Files.exists(trashRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return RestoreScan.empty();
|
||||
}
|
||||
requireDirectory(trashRoot, "Quarantine root");
|
||||
List<Path> dumps;
|
||||
try (Stream<Path> stream = Files.list(trashRoot)) {
|
||||
dumps = stream.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList();
|
||||
} catch (UncheckedIOException e) {
|
||||
throw e.getCause();
|
||||
}
|
||||
List<Path> directories = new ArrayList<>();
|
||||
for (Path dump : dumps) {
|
||||
if (Files.isSymbolicLink(dump)) {
|
||||
throw new IOException("Quarantine entry is a symbolic link: " + dump);
|
||||
}
|
||||
if (Files.isDirectory(dump, LinkOption.NOFOLLOW_LINKS)) {
|
||||
directories.add(dump);
|
||||
}
|
||||
}
|
||||
if (directories.isEmpty()) {
|
||||
return RestoreScan.empty();
|
||||
}
|
||||
|
||||
Path latest = directories.get(directories.size() - 1);
|
||||
String dumpPath = relativePath(packRoot, latest);
|
||||
List<Transfer> transfers = new ArrayList<>();
|
||||
List<String> conflicts = new ArrayList<>();
|
||||
for (Path source : listTree(latest)) {
|
||||
if (Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS)) {
|
||||
continue;
|
||||
}
|
||||
requireRegularFile(source, "Quarantined resource");
|
||||
String relative = relativePath(latest, source);
|
||||
Path destination = resolveContained(packRoot, relative);
|
||||
if (destination.startsWith(trashRoot)) {
|
||||
throw new IOException("Quarantined resource resolves inside the quarantine root: " + relative);
|
||||
}
|
||||
if (hasDestinationConflict(packRoot, destination)) {
|
||||
conflicts.add(relative);
|
||||
}
|
||||
transfers.add(new Transfer(relative, source, destination));
|
||||
}
|
||||
transfers.sort(Comparator.comparing(Transfer::path));
|
||||
conflicts.sort(String::compareTo);
|
||||
List<String> paths = transfers.stream().map(Transfer::path).toList();
|
||||
return new RestoreScan(latest, dumpPath, List.copyOf(transfers), List.copyOf(paths), List.copyOf(conflicts));
|
||||
}
|
||||
|
||||
private static List<Path> listTree(Path root) throws IOException {
|
||||
requireDirectory(root, "Scan root");
|
||||
try (Stream<Path> stream = Files.walk(root)) {
|
||||
List<Path> paths = stream.sorted(Comparator.comparing(path -> relativePath(root, path))).toList();
|
||||
for (Path path : paths) {
|
||||
if (!path.equals(root) && Files.isSymbolicLink(path)) {
|
||||
throw new IOException("Symbolic links are not supported during pack cleanup: " + path);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
} catch (UncheckedIOException e) {
|
||||
throw e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private static Path createUniqueDump(Path packRoot) throws IOException {
|
||||
Path trashRoot = resolveContained(packRoot, TRASH_ROOT);
|
||||
if (Files.exists(trashRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
requireDirectory(trashRoot, "Quarantine root");
|
||||
} else {
|
||||
Files.createDirectory(trashRoot);
|
||||
}
|
||||
String baseName = LocalDateTime.now().format(TRASH_STAMP);
|
||||
for (int attempt = 0; attempt < 10_000; attempt++) {
|
||||
String name = attempt == 0 ? baseName : baseName + '-' + String.format(Locale.ROOT, "%04d", attempt);
|
||||
Path dump = resolveContained(trashRoot, name);
|
||||
try {
|
||||
return Files.createDirectory(dump);
|
||||
} catch (FileAlreadyExistsException ignored) {
|
||||
}
|
||||
}
|
||||
throw new IOException("Unable to allocate a unique quarantine directory.");
|
||||
}
|
||||
|
||||
private static List<String> rollbackMoves(List<Transfer> moved) {
|
||||
for (int i = moved.size() - 1; i >= 0; i--) {
|
||||
Transfer transfer = moved.get(i);
|
||||
try {
|
||||
if (!Files.exists(transfer.source(), LinkOption.NOFOLLOW_LINKS)
|
||||
&& Files.exists(transfer.destination(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.move(transfer.destination(), transfer.source());
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
IrisLogging.reportError("Failed to roll back quarantined resource '" + transfer.path() + "'", e);
|
||||
}
|
||||
}
|
||||
List<String> remaining = new ArrayList<>();
|
||||
for (Transfer transfer : moved) {
|
||||
if (Files.exists(transfer.destination(), LinkOption.NOFOLLOW_LINKS)) {
|
||||
remaining.add(transfer.path());
|
||||
}
|
||||
}
|
||||
remaining.sort(String::compareTo);
|
||||
return List.copyOf(remaining);
|
||||
}
|
||||
|
||||
private static RestoreResult rollbackSourceRemoval(Path packRoot,
|
||||
RestoreScan scan,
|
||||
List<Path> copiedDestinations,
|
||||
List<Path> createdDirectories,
|
||||
List<Transfer> removedSources,
|
||||
Throwable failure) {
|
||||
List<String> sourceRollbackFailures = new ArrayList<>();
|
||||
for (int i = removedSources.size() - 1; i >= 0; i--) {
|
||||
Transfer transfer = removedSources.get(i);
|
||||
try {
|
||||
Files.copy(transfer.destination(), transfer.source(), StandardCopyOption.COPY_ATTRIBUTES);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
sourceRollbackFailures.add(transfer.path());
|
||||
IrisLogging.reportError("Failed to restore quarantined source '" + transfer.path() + "' during rollback", e);
|
||||
}
|
||||
}
|
||||
List<Path> removableDestinations = new ArrayList<>();
|
||||
for (Path destination : copiedDestinations) {
|
||||
String path = relativePath(packRoot, destination);
|
||||
Path source = scan.dump().resolve(path).normalize();
|
||||
if (Files.exists(source, LinkOption.NOFOLLOW_LINKS)) {
|
||||
removableDestinations.add(destination);
|
||||
}
|
||||
}
|
||||
List<String> destinationRemainders = rollbackCopies(packRoot, removableDestinations, createdDirectories);
|
||||
List<String> remainders = new ArrayList<>(sourceRollbackFailures.size() + destinationRemainders.size());
|
||||
remainders.addAll(sourceRollbackFailures);
|
||||
for (String destinationRemainder : destinationRemainders) {
|
||||
if (!remainders.contains(destinationRemainder)) {
|
||||
remainders.add(destinationRemainder);
|
||||
}
|
||||
}
|
||||
remainders.sort(String::compareTo);
|
||||
String rollbackState = remainders.isEmpty()
|
||||
? "Restore source removal failed and was rolled back"
|
||||
: "Restore source removal failed and rollback left " + remainders.size() + " destination(s)";
|
||||
IrisLogging.reportError(rollbackState + " for pack " + packRoot.getFileName(), failure);
|
||||
return new RestoreResult(scan.dumpPath(), remainders, List.of(), errorMessage(rollbackState, failure));
|
||||
}
|
||||
|
||||
private static List<String> rollbackCopies(Path packRoot,
|
||||
List<Path> destinations,
|
||||
List<Path> createdDirectories) {
|
||||
for (int i = destinations.size() - 1; i >= 0; i--) {
|
||||
try {
|
||||
Files.deleteIfExists(destinations.get(i));
|
||||
} catch (IOException | RuntimeException e) {
|
||||
IrisLogging.reportError("Failed to roll back restored resource '" + relativePath(packRoot, destinations.get(i)) + "'", e);
|
||||
}
|
||||
}
|
||||
for (int i = createdDirectories.size() - 1; i >= 0; i--) {
|
||||
deleteIfEmpty(createdDirectories.get(i));
|
||||
}
|
||||
List<String> remaining = new ArrayList<>();
|
||||
for (Path destination : destinations) {
|
||||
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
|
||||
remaining.add(relativePath(packRoot, destination));
|
||||
}
|
||||
}
|
||||
remaining.sort(String::compareTo);
|
||||
return List.copyOf(remaining);
|
||||
}
|
||||
|
||||
private static void createDirectories(Path packRoot, Path target, List<Path> createdDirectories) throws IOException {
|
||||
Path contained = requireContained(packRoot, target);
|
||||
List<Path> missing = new ArrayList<>();
|
||||
Path current = contained;
|
||||
while (!current.equals(packRoot) && !Files.exists(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
missing.add(current);
|
||||
current = current.getParent();
|
||||
}
|
||||
if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(current)) {
|
||||
throw new IOException("Restore parent is not a safe directory: " + current);
|
||||
}
|
||||
for (int i = missing.size() - 1; i >= 0; i--) {
|
||||
Path directory = missing.get(i);
|
||||
Files.createDirectory(directory);
|
||||
createdDirectories.add(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasDestinationConflict(Path packRoot, Path destination) throws IOException {
|
||||
if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return true;
|
||||
}
|
||||
Path parent = destination.getParent();
|
||||
while (parent != null && !parent.equals(packRoot)) {
|
||||
if (Files.exists(parent, LinkOption.NOFOLLOW_LINKS)
|
||||
&& (!Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(parent))) {
|
||||
return true;
|
||||
}
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return parent == null;
|
||||
}
|
||||
|
||||
private static void deleteEmptyDirectories(Path root) {
|
||||
if (root == null || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.walk(root)) {
|
||||
List<Path> directories = stream.filter(path -> Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS))
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.toList();
|
||||
for (Path directory : directories) {
|
||||
deleteIfEmpty(directory);
|
||||
}
|
||||
} catch (IOException | RuntimeException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteIfEmpty(Path directory) {
|
||||
if (directory == null || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(directory)) {
|
||||
if (stream.findAny().isEmpty()) {
|
||||
Files.deleteIfExists(directory);
|
||||
}
|
||||
} catch (IOException | RuntimeException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isScannableJson(Path packRoot, Path path) {
|
||||
if (!isJsonFile(path)) {
|
||||
return false;
|
||||
}
|
||||
Path relative = packRoot.relativize(path);
|
||||
for (Path segment : relative) {
|
||||
if (CORPUS_EXCLUSIONS.contains(segment.toString())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isJsonFile(Path path) {
|
||||
return path.getFileName() != null && path.getFileName().toString().endsWith(".json")
|
||||
&& Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS);
|
||||
}
|
||||
|
||||
private static boolean isReferenced(String corpus, String key) {
|
||||
if (corpus.contains("\"" + key + "\"")) {
|
||||
return true;
|
||||
}
|
||||
int slash = key.indexOf('/');
|
||||
if (slash <= 0) {
|
||||
return false;
|
||||
}
|
||||
String tail = key.substring(slash + 1);
|
||||
return !tail.isBlank() && corpus.contains("\"" + tail + "\"");
|
||||
}
|
||||
|
||||
private static String stripJsonExtension(String path) {
|
||||
return path.substring(0, path.length() - ".json".length());
|
||||
}
|
||||
|
||||
private static Path requirePackRoot(File packFolder) throws IOException {
|
||||
if (packFolder == null) {
|
||||
throw new IOException("Pack folder is required.");
|
||||
}
|
||||
Path root = packFolder.toPath().toAbsolutePath().normalize();
|
||||
requireDirectory(root, "Pack folder");
|
||||
return root;
|
||||
}
|
||||
|
||||
private static void requireDirectory(Path path, String label) throws IOException {
|
||||
if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException(label + " is not a safe directory: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireRegularFile(Path path, String label) throws IOException {
|
||||
if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException(label + " is not a safe regular file: " + path);
|
||||
}
|
||||
}
|
||||
|
||||
private static Path resolveContained(Path root, String relative) throws IOException {
|
||||
return requireContained(root, root.resolve(relative).normalize());
|
||||
}
|
||||
|
||||
private static Path requireContained(Path root, Path path) throws IOException {
|
||||
Path normalizedRoot = root.toAbsolutePath().normalize();
|
||||
Path normalizedPath = path.toAbsolutePath().normalize();
|
||||
if (!normalizedPath.startsWith(normalizedRoot)) {
|
||||
throw new IOException("Path escapes pack boundary: " + path);
|
||||
}
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
private static String relativePath(Path root, Path path) {
|
||||
return root.relativize(path).toString().replace(File.separatorChar, '/');
|
||||
}
|
||||
|
||||
private static Path lockPath(File packFolder) {
|
||||
return packFolder == null ? null : packFolder.toPath().toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private static Object packLock(Path path) {
|
||||
return PACK_LOCKS.computeIfAbsent(path, ignored -> new Object());
|
||||
}
|
||||
|
||||
private static String errorMessage(String prefix, Throwable error) {
|
||||
String message = error.getMessage();
|
||||
return prefix + (message == null || message.isBlank() ? "." : ": " + message);
|
||||
}
|
||||
|
||||
public record Preview(List<String> candidatePaths, String error) {
|
||||
public Preview {
|
||||
candidatePaths = candidatePaths == null ? List.of() : List.copyOf(candidatePaths);
|
||||
}
|
||||
|
||||
public boolean success() {
|
||||
return error == null;
|
||||
}
|
||||
|
||||
public boolean hasCandidates() {
|
||||
return !candidatePaths.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public record ApplyResult(String quarantinePath, List<String> quarantinedPaths, String error) {
|
||||
public ApplyResult {
|
||||
quarantinedPaths = quarantinedPaths == null ? List.of() : List.copyOf(quarantinedPaths);
|
||||
}
|
||||
|
||||
public boolean success() {
|
||||
return error == null;
|
||||
}
|
||||
|
||||
public boolean changed() {
|
||||
return !quarantinedPaths.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public record RestorePreview(String dumpPath, List<String> filePaths, List<String> conflicts, String error) {
|
||||
public RestorePreview {
|
||||
filePaths = filePaths == null ? List.of() : List.copyOf(filePaths);
|
||||
conflicts = conflicts == null ? List.of() : List.copyOf(conflicts);
|
||||
}
|
||||
|
||||
public boolean success() {
|
||||
return error == null;
|
||||
}
|
||||
|
||||
public boolean hasFiles() {
|
||||
return !filePaths.isEmpty();
|
||||
}
|
||||
|
||||
public boolean canRestore() {
|
||||
return success() && hasFiles() && conflicts.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasConflicts() {
|
||||
return !conflicts.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public record RestoreResult(String dumpPath, List<String> restoredPaths, List<String> conflicts, String error) {
|
||||
public RestoreResult {
|
||||
restoredPaths = restoredPaths == null ? List.of() : List.copyOf(restoredPaths);
|
||||
conflicts = conflicts == null ? List.of() : List.copyOf(conflicts);
|
||||
}
|
||||
|
||||
public boolean success() {
|
||||
return error == null && conflicts.isEmpty();
|
||||
}
|
||||
|
||||
public boolean changed() {
|
||||
return !restoredPaths.isEmpty();
|
||||
}
|
||||
|
||||
public boolean hasConflicts() {
|
||||
return !conflicts.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
private record CleanupScan(List<String> paths) {
|
||||
}
|
||||
|
||||
private record RestoreScan(Path dump, String dumpPath, List<Transfer> transfers, List<String> paths,
|
||||
List<String> conflicts) {
|
||||
private static RestoreScan empty() {
|
||||
return new RestoreScan(null, null, List.of(), List.of(), List.of());
|
||||
}
|
||||
}
|
||||
|
||||
private record Transfer(String path, Path source, Path destination) {
|
||||
}
|
||||
}
|
||||
@@ -26,18 +26,15 @@ public final class PackValidationResult {
|
||||
private final String packName;
|
||||
private final List<String> blockingErrors;
|
||||
private final List<String> warnings;
|
||||
private final List<String> removedUnusedFiles;
|
||||
private final long validatedAtMillis;
|
||||
|
||||
public PackValidationResult(String packName,
|
||||
List<String> blockingErrors,
|
||||
List<String> warnings,
|
||||
List<String> removedUnusedFiles,
|
||||
long validatedAtMillis) {
|
||||
this.packName = packName;
|
||||
this.blockingErrors = blockingErrors == null ? new ArrayList<>() : new ArrayList<>(blockingErrors);
|
||||
this.warnings = warnings == null ? new ArrayList<>() : new ArrayList<>(warnings);
|
||||
this.removedUnusedFiles = removedUnusedFiles == null ? new ArrayList<>() : new ArrayList<>(removedUnusedFiles);
|
||||
this.validatedAtMillis = validatedAtMillis;
|
||||
}
|
||||
|
||||
@@ -57,10 +54,6 @@ public final class PackValidationResult {
|
||||
return Collections.unmodifiableList(warnings);
|
||||
}
|
||||
|
||||
public List<String> getRemovedUnusedFiles() {
|
||||
return Collections.unmodifiableList(removedUnusedFiles);
|
||||
}
|
||||
|
||||
public long getValidatedAtMillis() {
|
||||
return validatedAtMillis;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustomSpawnType;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformEntityType;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
@@ -28,17 +30,16 @@ import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class PackValidator {
|
||||
@@ -50,19 +51,9 @@ public final class PackValidator {
|
||||
private static final String CACHE_FOLDER = "cache";
|
||||
private static final String OBJECTS_FOLDER = "objects";
|
||||
private static final String DIMENSIONS_FOLDER = "dimensions";
|
||||
private static final List<String> MANAGED_RESOURCE_FOLDERS = List.of(
|
||||
"biomes",
|
||||
"regions",
|
||||
"entities",
|
||||
"spawners",
|
||||
"loot",
|
||||
"generators",
|
||||
"expressions",
|
||||
"markers",
|
||||
"blocks",
|
||||
"mods"
|
||||
);
|
||||
private static final DateTimeFormatter TRASH_STAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
|
||||
private static final List<String> STRUCTURE_HOST_FOLDERS = List.of(DIMENSIONS_FOLDER, "regions", "biomes");
|
||||
private static final List<String> UNSUPPORTED_STRUCTURE_TRANSFORM_FIELDS = List.of("rotation", "translate", "scale");
|
||||
private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+");
|
||||
|
||||
private PackValidator() {
|
||||
}
|
||||
@@ -71,39 +62,376 @@ public final class PackValidator {
|
||||
String packName = packFolder == null ? "<unknown>" : packFolder.getName();
|
||||
List<String> blockingErrors = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
List<String> removedUnusedFiles = new ArrayList<>();
|
||||
long validatedAt = System.currentTimeMillis();
|
||||
|
||||
if (packFolder == null || !packFolder.isDirectory()) {
|
||||
blockingErrors.add("Pack folder does not exist or is not a directory.");
|
||||
return new PackValidationResult(packName, blockingErrors, warnings, removedUnusedFiles, validatedAt);
|
||||
return new PackValidationResult(packName, blockingErrors, warnings, validatedAt);
|
||||
}
|
||||
|
||||
File dimensionsFolder = new File(packFolder, DIMENSIONS_FOLDER);
|
||||
if (!dimensionsFolder.isDirectory()) {
|
||||
blockingErrors.add("Missing dimensions/ folder.");
|
||||
return new PackValidationResult(packName, blockingErrors, warnings, removedUnusedFiles, validatedAt);
|
||||
return new PackValidationResult(packName, blockingErrors, warnings, validatedAt);
|
||||
}
|
||||
|
||||
File[] dimensionFiles = dimensionsFolder.listFiles(f -> f.isFile() && f.getName().endsWith(".json"));
|
||||
if (dimensionFiles == null || dimensionFiles.length == 0) {
|
||||
blockingErrors.add("No dimension JSON files under dimensions/.");
|
||||
return new PackValidationResult(packName, blockingErrors, warnings, removedUnusedFiles, validatedAt);
|
||||
return new PackValidationResult(packName, blockingErrors, warnings, validatedAt);
|
||||
}
|
||||
|
||||
validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings);
|
||||
|
||||
try {
|
||||
String packTextCorpus = buildPackTextCorpus(packFolder);
|
||||
runUnusedResourceGc(packFolder, packTextCorpus, removedUnusedFiles, warnings);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("PackValidator GC pass failed for pack '" + packName + "'", e);
|
||||
warnings.add("Unused-resource GC pass failed: " + e.getMessage());
|
||||
}
|
||||
blockingErrors.addAll(validateUnsupportedStructureTransforms(packFolder));
|
||||
blockingErrors.addAll(validateSpawnerEntityReferences(
|
||||
new File(packFolder, "spawners"), new File(packFolder, "entities")));
|
||||
blockingErrors.addAll(validateCustomBiomeSpawns(
|
||||
new File(packFolder, "biomes"), PackValidator::resolveEntitySpawnCategory));
|
||||
|
||||
runContentKeyValidation(packFolder, warnings);
|
||||
|
||||
return new PackValidationResult(packName, blockingErrors, warnings, removedUnusedFiles, validatedAt);
|
||||
return new PackValidationResult(packName, blockingErrors, warnings, validatedAt);
|
||||
}
|
||||
|
||||
static List<String> validateUnsupportedStructureTransforms(File packFolder) {
|
||||
List<String> blockingErrors = new ArrayList<>();
|
||||
if (packFolder == null || !packFolder.isDirectory()) {
|
||||
return blockingErrors;
|
||||
}
|
||||
|
||||
for (String folderName : STRUCTURE_HOST_FOLDERS) {
|
||||
File resourceFolder = new File(packFolder, folderName);
|
||||
if (!resourceFolder.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
List<File> resourceFiles = listJsonRecursive(resourceFolder);
|
||||
resourceFiles.sort(Comparator.comparing(File::getPath));
|
||||
String resourceType = structureHostType(folderName);
|
||||
for (File resourceFile : resourceFiles) {
|
||||
JSONObject resource;
|
||||
try {
|
||||
resource = new JSONObject(Files.readString(resourceFile.toPath(), StandardCharsets.UTF_8));
|
||||
} catch (Throwable ignored) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JSONArray placements = resource.optJSONArray("structures");
|
||||
if (placements == null) {
|
||||
continue;
|
||||
}
|
||||
String resourceKey = deriveKey(resourceFolder, resourceFile);
|
||||
for (int placementIndex = 0; placementIndex < placements.length(); placementIndex++) {
|
||||
JSONObject placement = placements.optJSONObject(placementIndex);
|
||||
if (placement == null) {
|
||||
continue;
|
||||
}
|
||||
for (String field : UNSUPPORTED_STRUCTURE_TRANSFORM_FIELDS) {
|
||||
if (placement.has(field)) {
|
||||
blockingErrors.add(resourceType + " '" + resourceKey + "' structures[" + placementIndex
|
||||
+ "] declares unsupported field '" + field
|
||||
+ "'. Structure placement transforms are not supported; remove the field.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return blockingErrors;
|
||||
}
|
||||
|
||||
private static String structureHostType(String folderName) {
|
||||
return switch (folderName) {
|
||||
case "dimensions" -> "Dimension";
|
||||
case "regions" -> "Region";
|
||||
case "biomes" -> "Biome";
|
||||
default -> "Resource";
|
||||
};
|
||||
}
|
||||
|
||||
static List<String> validateSpawnerEntityReferences(File spawnersFolder, File entitiesFolder) {
|
||||
List<String> blockingErrors = new ArrayList<>();
|
||||
if (spawnersFolder == null || !spawnersFolder.isDirectory()) {
|
||||
return blockingErrors;
|
||||
}
|
||||
|
||||
Path entityRoot = entitiesFolder.toPath().toAbsolutePath().normalize();
|
||||
Set<Path> validEntityFiles = new HashSet<>();
|
||||
Map<Path, String> invalidEntityFiles = new HashMap<>();
|
||||
List<File> spawnerFiles = listJsonRecursive(spawnersFolder);
|
||||
spawnerFiles.sort(Comparator.comparing(File::getPath));
|
||||
for (File spawnerFile : spawnerFiles) {
|
||||
String spawnerKey = deriveKey(spawnersFolder, spawnerFile);
|
||||
JSONObject spawner;
|
||||
try {
|
||||
spawner = new JSONObject(Files.readString(spawnerFile.toPath(), StandardCharsets.UTF_8));
|
||||
} catch (Throwable e) {
|
||||
blockingErrors.add("Spawner '" + spawnerKey + "' has invalid JSON: " + e.getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
validateSpawnerSpawnEntries(spawnerKey, spawner, "spawns", entityRoot,
|
||||
validEntityFiles, invalidEntityFiles, blockingErrors);
|
||||
validateSpawnerSpawnEntries(spawnerKey, spawner, "initialSpawns", entityRoot,
|
||||
validEntityFiles, invalidEntityFiles, blockingErrors);
|
||||
}
|
||||
return blockingErrors;
|
||||
}
|
||||
|
||||
private static void validateSpawnerSpawnEntries(String spawnerKey,
|
||||
JSONObject spawner,
|
||||
String field,
|
||||
Path entityRoot,
|
||||
Set<Path> validEntityFiles,
|
||||
Map<Path, String> invalidEntityFiles,
|
||||
List<String> blockingErrors) {
|
||||
if (!spawner.has(field)) {
|
||||
return;
|
||||
}
|
||||
JSONArray entries = spawner.optJSONArray(field);
|
||||
if (entries == null) {
|
||||
blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " must be an array.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (int index = 0; index < entries.length(); index++) {
|
||||
JSONObject entry = entries.optJSONObject(index);
|
||||
if (entry == null) {
|
||||
blockingErrors.add("Spawner '" + spawnerKey + "' " + field
|
||||
+ " has a non-object entry at index " + index + ".");
|
||||
continue;
|
||||
}
|
||||
if (!entry.has("entity") || entry.isNull("entity")) {
|
||||
blockingErrors.add("Spawner '" + spawnerKey + "' " + field
|
||||
+ " has an entry without an entity reference at index " + index + ".");
|
||||
continue;
|
||||
}
|
||||
|
||||
Object rawEntity = entry.get("entity");
|
||||
if (!(rawEntity instanceof String entityKey)) {
|
||||
blockingErrors.add("Spawner '" + spawnerKey + "' " + field
|
||||
+ " entity reference at index " + index + " must be a string.");
|
||||
continue;
|
||||
}
|
||||
if (entityKey.isBlank()) {
|
||||
blockingErrors.add("Spawner '" + spawnerKey + "' " + field
|
||||
+ " has a blank entity reference at index " + index + ".");
|
||||
continue;
|
||||
}
|
||||
|
||||
Path entityFile;
|
||||
try {
|
||||
if (entityKey.indexOf('\\') >= 0) {
|
||||
throw new IllegalArgumentException("backslash path separators are not portable");
|
||||
}
|
||||
entityFile = entityRoot.resolve(entityKey + ".json").normalize();
|
||||
} catch (RuntimeException e) {
|
||||
blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index
|
||||
+ " has invalid entity reference '" + entityKey + "': " + e.getMessage());
|
||||
continue;
|
||||
}
|
||||
if (!entityFile.startsWith(entityRoot)) {
|
||||
blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index
|
||||
+ " has unsafe entity reference '" + entityKey + "'.");
|
||||
continue;
|
||||
}
|
||||
if (!Files.isRegularFile(entityFile)) {
|
||||
blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index
|
||||
+ " references missing entity '" + entityKey + "'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
String invalidJson = invalidEntityFiles.get(entityFile);
|
||||
if (invalidJson != null) {
|
||||
blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index
|
||||
+ " references malformed entity '" + entityKey + "': " + invalidJson);
|
||||
continue;
|
||||
}
|
||||
if (validEntityFiles.contains(entityFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
new JSONObject(Files.readString(entityFile, StandardCharsets.UTF_8));
|
||||
validEntityFiles.add(entityFile);
|
||||
} catch (Throwable e) {
|
||||
String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||
invalidEntityFiles.put(entityFile, message);
|
||||
blockingErrors.add("Spawner '" + spawnerKey + "' " + field + " entry at index " + index
|
||||
+ " references malformed entity '" + entityKey + "': " + message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> validateCustomBiomeSpawns(File biomesFolder, Function<String, SpawnCategoryResolution> categoryResolver) {
|
||||
List<String> blockingErrors = new ArrayList<>();
|
||||
if (biomesFolder == null || !biomesFolder.isDirectory()) {
|
||||
return blockingErrors;
|
||||
}
|
||||
|
||||
List<File> biomeFiles = listJsonRecursive(biomesFolder);
|
||||
biomeFiles.sort(Comparator.comparing(File::getPath));
|
||||
for (File biomeFile : biomeFiles) {
|
||||
String biomeKey = deriveKey(biomesFolder, biomeFile);
|
||||
JSONObject biome;
|
||||
try {
|
||||
biome = new JSONObject(Files.readString(biomeFile.toPath(), StandardCharsets.UTF_8));
|
||||
} catch (Throwable e) {
|
||||
blockingErrors.add("Biome '" + biomeKey + "' has invalid JSON: " + e.getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
JSONArray derivatives = biome.optJSONArray("customDerivitives");
|
||||
if (derivatives == null) {
|
||||
if (biome.has("customDerivitives") && !biome.isNull("customDerivitives")) {
|
||||
blockingErrors.add("Biome '" + biomeKey + "' customDerivitives must be an array.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (int derivativeIndex = 0; derivativeIndex < derivatives.length(); derivativeIndex++) {
|
||||
JSONObject derivative = derivatives.optJSONObject(derivativeIndex);
|
||||
if (derivative == null) {
|
||||
blockingErrors.add("Biome '" + biomeKey + "' has a non-object custom derivative at index " + derivativeIndex + ".");
|
||||
continue;
|
||||
}
|
||||
validateCustomBiomeDerivativeTags(biomeKey, derivative, derivativeIndex, blockingErrors);
|
||||
validateCustomBiomeDerivativeSpawns(
|
||||
biomeKey, derivative, derivativeIndex, categoryResolver, blockingErrors);
|
||||
}
|
||||
}
|
||||
return blockingErrors;
|
||||
}
|
||||
|
||||
private static void validateCustomBiomeDerivativeTags(String biomeKey,
|
||||
JSONObject derivative,
|
||||
int derivativeIndex,
|
||||
List<String> blockingErrors) {
|
||||
if (!derivative.has("tags") || derivative.isNull("tags")) {
|
||||
return;
|
||||
}
|
||||
String derivativeId = derivative.optString("id", "#" + derivativeIndex);
|
||||
JSONArray tags = derivative.optJSONArray("tags");
|
||||
if (tags == null) {
|
||||
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
|
||||
+ "' tags must be an array.");
|
||||
return;
|
||||
}
|
||||
for (int tagIndex = 0; tagIndex < tags.length(); tagIndex++) {
|
||||
Object rawTag = tags.opt(tagIndex);
|
||||
if (!(rawTag instanceof String tag)) {
|
||||
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
|
||||
+ "' has a non-string tag at index " + tagIndex + ".");
|
||||
continue;
|
||||
}
|
||||
String normalized = tag.trim().toLowerCase(Locale.ROOT);
|
||||
if (normalized.indexOf(':') < 0) {
|
||||
normalized = "minecraft:" + normalized;
|
||||
}
|
||||
if (!isSafeResourceKey(normalized)) {
|
||||
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
|
||||
+ "' has invalid tag '" + tag + "'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSafeResourceKey(String key) {
|
||||
if (!RESOURCE_KEY_PATTERN.matcher(key).matches()) {
|
||||
return false;
|
||||
}
|
||||
int separator = key.indexOf(':');
|
||||
String[] segments = key.substring(separator + 1).split("/");
|
||||
for (String segment : segments) {
|
||||
if (segment.equals("..")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void validateCustomBiomeDerivativeSpawns(String biomeKey,
|
||||
JSONObject derivative,
|
||||
int derivativeIndex,
|
||||
Function<String, SpawnCategoryResolution> categoryResolver,
|
||||
List<String> blockingErrors) {
|
||||
JSONArray spawns = derivative.optJSONArray("spawns");
|
||||
if (spawns == null) {
|
||||
if (derivative.has("spawns") && !derivative.isNull("spawns")) {
|
||||
String derivativeId = derivative.optString("id", "#" + derivativeIndex);
|
||||
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
|
||||
+ "' spawns must be an array.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
String derivativeId = derivative.optString("id", "#" + derivativeIndex);
|
||||
for (int spawnIndex = 0; spawnIndex < spawns.length(); spawnIndex++) {
|
||||
JSONObject spawn = spawns.optJSONObject(spawnIndex);
|
||||
if (spawn == null) {
|
||||
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
|
||||
+ "' has a non-object spawn at index " + spawnIndex + ".");
|
||||
continue;
|
||||
}
|
||||
|
||||
String type = spawn.optString("type", "").trim().toLowerCase(Locale.ROOT);
|
||||
if (type.isEmpty()) {
|
||||
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
|
||||
+ "' has a spawn without an entity type at index " + spawnIndex + ".");
|
||||
continue;
|
||||
}
|
||||
String typeKey = type.indexOf(':') >= 0 ? type : "minecraft:" + type;
|
||||
SpawnCategoryResolution resolution;
|
||||
try {
|
||||
resolution = categoryResolver == null ? null : categoryResolver.apply(typeKey);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("PackValidator failed to resolve spawn category for '" + typeKey + "'", e);
|
||||
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
|
||||
+ "' spawn category lookup failed for '" + typeKey + "': " + e.getMessage());
|
||||
continue;
|
||||
}
|
||||
if (resolution != null && !resolution.entityKnown()) {
|
||||
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
|
||||
+ "' spawn references unknown entity type '" + typeKey + "'.");
|
||||
continue;
|
||||
}
|
||||
String expectedGroup = resolution == null ? null : resolution.category();
|
||||
String group = spawn.optString("group", "").trim();
|
||||
if (group.isEmpty()) {
|
||||
if (expectedGroup != null && !expectedGroup.isBlank()
|
||||
&& !IrisBiomeCustomSpawnType.MISC.name().equalsIgnoreCase(expectedGroup)) {
|
||||
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
|
||||
+ "' spawn '" + typeKey + "' must declare group '"
|
||||
+ expectedGroup.toUpperCase(Locale.ROOT) + "'.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
IrisBiomeCustomSpawnType configuredGroup;
|
||||
try {
|
||||
configuredGroup = IrisBiomeCustomSpawnType.valueOf(group);
|
||||
} catch (IllegalArgumentException e) {
|
||||
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
|
||||
+ "' spawn '" + typeKey + "' declares unknown group '" + group + "'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expectedGroup != null && !expectedGroup.isBlank()
|
||||
&& !configuredGroup.name().equalsIgnoreCase(expectedGroup)) {
|
||||
blockingErrors.add("Biome '" + biomeKey + "' custom derivative '" + derivativeId
|
||||
+ "' spawn '" + typeKey + "' declares group '" + configuredGroup.name()
|
||||
+ "' but the live entity registry requires '" + expectedGroup.toUpperCase(Locale.ROOT) + "'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static SpawnCategoryResolution resolveEntitySpawnCategory(String typeKey) {
|
||||
if (!IrisPlatforms.isBound()) {
|
||||
return null;
|
||||
}
|
||||
PlatformRegistries registries = IrisPlatforms.get().registries();
|
||||
if (registries == null) {
|
||||
return null;
|
||||
}
|
||||
PlatformEntityType entityType = registries.entity(typeKey);
|
||||
return entityType == null
|
||||
? SpawnCategoryResolution.unknown()
|
||||
: SpawnCategoryResolution.known(entityType.spawnCategory());
|
||||
}
|
||||
|
||||
private static void runContentKeyValidation(File packFolder, List<String> warnings) {
|
||||
@@ -221,6 +549,16 @@ public final class PackValidator {
|
||||
private record ReferencedContentKeys(Set<String> blocks, Set<String> items, Set<String> entities) {
|
||||
}
|
||||
|
||||
record SpawnCategoryResolution(boolean entityKnown, String category) {
|
||||
static SpawnCategoryResolution unknown() {
|
||||
return new SpawnCategoryResolution(false, null);
|
||||
}
|
||||
|
||||
static SpawnCategoryResolution known(String category) {
|
||||
return new SpawnCategoryResolution(true, category);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateDimensions(File packFolder, File[] dimensionFiles, List<String> blockingErrors, List<String> warnings) {
|
||||
File regionsFolder = new File(packFolder, "regions");
|
||||
File biomesFolder = new File(packFolder, "biomes");
|
||||
@@ -299,24 +637,6 @@ public final class PackValidator {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static String buildPackTextCorpus(File packFolder) {
|
||||
StringBuilder sb = new StringBuilder(1 << 16);
|
||||
try (Stream<Path> stream = Files.walk(packFolder.toPath())) {
|
||||
stream.filter(Files::isRegularFile)
|
||||
.filter(PackValidator::isScannableJsonPath)
|
||||
.forEach(p -> {
|
||||
try {
|
||||
sb.append(Files.readString(p, StandardCharsets.UTF_8));
|
||||
sb.append('\n');
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
});
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("PackValidator failed to walk pack folder for corpus scan", e);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static boolean isScannableJsonPath(Path path) {
|
||||
String name = path.getFileName().toString();
|
||||
if (!name.endsWith(".json")) {
|
||||
@@ -350,66 +670,6 @@ public final class PackValidator {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void runUnusedResourceGc(File packFolder, String corpus, List<String> removedUnusedFiles, List<String> warnings) {
|
||||
if (corpus == null || corpus.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
File trashRoot = new File(packFolder, TRASH_ROOT + File.separator + LocalDateTime.now().format(TRASH_STAMP));
|
||||
Set<File> scheduledForTrash = new LinkedHashSet<>();
|
||||
|
||||
for (String folderName : MANAGED_RESOURCE_FOLDERS) {
|
||||
File resourceFolder = new File(packFolder, folderName);
|
||||
if (!resourceFolder.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<File> files = listJsonRecursive(resourceFolder);
|
||||
for (File resourceFile : files) {
|
||||
String key = deriveKey(resourceFolder, resourceFile);
|
||||
if (key == null || key.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (isReferenced(corpus, key)) {
|
||||
continue;
|
||||
}
|
||||
scheduledForTrash.add(resourceFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduledForTrash.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (File file : scheduledForTrash) {
|
||||
try {
|
||||
Path src = file.toPath();
|
||||
Path relative = packFolder.toPath().relativize(src);
|
||||
Path dest = trashRoot.toPath().resolve(relative);
|
||||
Files.createDirectories(dest.getParent());
|
||||
Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING);
|
||||
removedUnusedFiles.add(relative.toString().replace(File.separatorChar, '/'));
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("PackValidator failed to move unused file " + file.getPath() + " to trash", e);
|
||||
warnings.add("Failed to quarantine unused file " + file.getName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isReferenced(String corpus, String key) {
|
||||
String needleQuoted = "\"" + key + "\"";
|
||||
if (corpus.contains(needleQuoted)) {
|
||||
return true;
|
||||
}
|
||||
int slash = key.indexOf('/');
|
||||
if (slash > 0) {
|
||||
String tail = key.substring(slash + 1);
|
||||
if (!tail.isBlank() && corpus.contains("\"" + tail + "\"")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<File> listJsonRecursive(File root) {
|
||||
List<File> out = new ArrayList<>();
|
||||
try (Stream<Path> stream = Files.walk(root.toPath())) {
|
||||
@@ -435,49 +695,6 @@ public final class PackValidator {
|
||||
return dot <= 0 ? name : name.substring(0, dot);
|
||||
}
|
||||
|
||||
public static int restoreTrash(File packFolder) {
|
||||
if (packFolder == null || !packFolder.isDirectory()) {
|
||||
return 0;
|
||||
}
|
||||
File trashRoot = new File(packFolder, TRASH_ROOT);
|
||||
if (!trashRoot.isDirectory()) {
|
||||
return 0;
|
||||
}
|
||||
File[] dumps = trashRoot.listFiles(File::isDirectory);
|
||||
if (dumps == null || dumps.length == 0) {
|
||||
return 0;
|
||||
}
|
||||
Arrays.sort(dumps, Comparator.comparing(File::getName));
|
||||
File latestDump = dumps[dumps.length - 1];
|
||||
int restored = 0;
|
||||
try (Stream<Path> stream = Files.walk(latestDump.toPath())) {
|
||||
List<Path> files = stream.filter(Files::isRegularFile).toList();
|
||||
for (Path src : files) {
|
||||
Path relative = latestDump.toPath().relativize(src);
|
||||
Path dest = packFolder.toPath().resolve(relative);
|
||||
Files.createDirectories(dest.getParent());
|
||||
Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING);
|
||||
restored++;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("PackValidator failed to restore trash for pack " + packFolder.getName(), e);
|
||||
}
|
||||
deleteFolderQuiet(latestDump);
|
||||
return restored;
|
||||
}
|
||||
|
||||
private static void deleteFolderQuiet(File folder) {
|
||||
if (folder == null || !folder.exists()) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.walk(folder.toPath())) {
|
||||
stream.sorted(Comparator.reverseOrder())
|
||||
.map(Path::toFile)
|
||||
.forEach(File::delete);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public static Set<String> listReferencedKeysFromCorpus(String corpus) {
|
||||
Set<String> keys = new HashSet<>();
|
||||
if (corpus == null) {
|
||||
|
||||
@@ -26,7 +26,6 @@ import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.tools.IrisPackBenchmarking;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KSet;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.volmlib.util.format.Form;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.math.M;
|
||||
@@ -217,16 +216,21 @@ public class IrisPregenerator {
|
||||
} finally {
|
||||
shutdown();
|
||||
}
|
||||
if (completed) {
|
||||
if (completed && allVisitsComplete()) {
|
||||
logSuccessfulCompletion(p);
|
||||
}
|
||||
if (benchmarking == null) {
|
||||
IrisLogging.info(C.IRIS + "Pregen stopped.");
|
||||
} else {
|
||||
logIncompleteCompletion(p);
|
||||
}
|
||||
if (benchmarking != null) {
|
||||
benchmarking.finishedBenchmark(chunksPerSecondHistory);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean allVisitsComplete() {
|
||||
long total = totalChunks.get();
|
||||
return total > 0 && generated.get() + failed.get() >= total;
|
||||
}
|
||||
|
||||
private void logSuccessfulCompletion(PrecisionStopwatch stopwatch) {
|
||||
long failedCount = failed.get();
|
||||
if (failedCount > 0) {
|
||||
@@ -238,6 +242,19 @@ public class IrisPregenerator {
|
||||
+ " duration=" + Form.duration((long) stopwatch.getMilliseconds()));
|
||||
}
|
||||
|
||||
private void logIncompleteCompletion(PrecisionStopwatch stopwatch) {
|
||||
long generatedCount = generated.get();
|
||||
long failedCount = failed.get();
|
||||
long total = totalChunks.get();
|
||||
long remaining = Math.max(0L, total - generatedCount - failedCount);
|
||||
String status = shutdown.get() ? "Pregen cancelled" : "Pregen stopped before completion";
|
||||
IrisLogging.info(status + ": generated=" + Form.f(generatedCount)
|
||||
+ " total=" + Form.f(total)
|
||||
+ " failed=" + Form.f(failedCount)
|
||||
+ " remaining=" + Form.f(remaining)
|
||||
+ " duration=" + Form.duration((long) stopwatch.getMilliseconds()));
|
||||
}
|
||||
|
||||
private void checkRegions() {
|
||||
task.iterateRegions(this::checkRegion);
|
||||
}
|
||||
|
||||
@@ -646,7 +646,7 @@ public class IrisProject {
|
||||
biomes.forEach((i) -> i.getGenerators().forEach((j) -> generators.add(j.getCachedGenerator(() -> dm))));
|
||||
biomes.forEach((r) -> r.getLoot().getTables().forEach((i) -> loot.add(dm.getLootLoader().load(i))));
|
||||
biomes.forEach((r) -> r.getEntitySpawners().forEach((sp) -> spawners.add(dm.getSpawnerLoader().load(sp))));
|
||||
spawners.forEach((i) -> i.getSpawns().forEach((j) -> entities.add(dm.getEntityLoader().load(j.getEntity()))));
|
||||
collectSpawnerEntityKeys(spawners).forEach((i) -> entities.add(dm.getEntityLoader().load(i)));
|
||||
KMap<String, String> renameObjects = new KMap<>();
|
||||
String a;
|
||||
StringBuilder b = new StringBuilder();
|
||||
@@ -769,6 +769,15 @@ public class IrisProject {
|
||||
return null;
|
||||
}
|
||||
|
||||
static KSet<String> collectSpawnerEntityKeys(KSet<IrisSpawner> spawners) {
|
||||
KSet<String> entityKeys = new KSet<>();
|
||||
for (IrisSpawner spawner : spawners) {
|
||||
spawner.getSpawns().forEach((spawn) -> entityKeys.add(spawn.getEntity()));
|
||||
spawner.getInitialSpawns().forEach((spawn) -> entityKeys.add(spawn.getEntity()));
|
||||
}
|
||||
return entityKeys;
|
||||
}
|
||||
|
||||
public void compile(VolmitSender sender) {
|
||||
IrisData data = IrisData.get(getPath());
|
||||
KList<Job> jobs = new KList<>();
|
||||
|
||||
@@ -130,8 +130,17 @@ public class StudioSVC implements IrisService {
|
||||
queueStudioWorldDeletionOnStartup(worldNamesToDelete);
|
||||
}
|
||||
|
||||
public IrisDimension installIntoWorld(VolmitSender sender, String type, File folder) {
|
||||
return installInto(sender, type, new File(folder, "iris/pack"));
|
||||
public IrisDimension installIntoWorld(VolmitSender sender, IrisDimension dimension, File folder) {
|
||||
File target = new File(folder, "iris/pack");
|
||||
File source = dimension.getLoader().getDataFolder();
|
||||
sender.sendMessage("Installing Package: " + source.getName() + ":" + dimension.getLoadKey());
|
||||
try {
|
||||
FileUtils.copyDirectory(source, target);
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError(e);
|
||||
return null;
|
||||
}
|
||||
return IrisData.get(target).getDimensionLoader().load(dimension.getLoadKey());
|
||||
}
|
||||
|
||||
public IrisDimension installInto(VolmitSender sender, String type, File folder) {
|
||||
|
||||
@@ -166,7 +166,11 @@ public class IrisCreator {
|
||||
|
||||
reportStudioProgress(0.16D, "prepare_world_pack");
|
||||
if (!studio() || benchmark) {
|
||||
IrisServices.get(StudioSVC.class).installIntoWorld(sender, d.getLoadKey(), new File(Bukkit.getWorldContainer(), name()));
|
||||
d = IrisServices.get(StudioSVC.class).installIntoWorld(sender, d, new File(Bukkit.getWorldContainer(), name()));
|
||||
if (d == null) {
|
||||
throw new IrisException("Failed to install dimension pack for " + dimension());
|
||||
}
|
||||
dimension = d.getLoadKey();
|
||||
}
|
||||
if (studio()) {
|
||||
IrisRuntimeSchedulerMode runtimeSchedulerMode = IrisRuntimeSchedulerMode.resolve(IrisSettings.get().getPregen());
|
||||
@@ -178,7 +182,7 @@ public class IrisCreator {
|
||||
AtomicDouble pp = new AtomicDouble(0);
|
||||
AtomicBoolean done = new AtomicBoolean(false);
|
||||
WorldCreator wc = new IrisWorldCreator()
|
||||
.dimension(dimension)
|
||||
.dimension(d)
|
||||
.name(name)
|
||||
.seed(seed)
|
||||
.studio(studio)
|
||||
|
||||
@@ -81,23 +81,23 @@ public class IrisToolbelt {
|
||||
return null;
|
||||
}
|
||||
|
||||
String requested = dimension.trim();
|
||||
if (requested.isEmpty()) {
|
||||
PackReference reference = parsePackReference(dimension);
|
||||
if (reference == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
File packsFolder = IrisPlatforms.get().dataFolder("packs");
|
||||
File pack = new File(packsFolder, requested);
|
||||
File pack = new File(packsFolder, reference.pack());
|
||||
if (!pack.exists()) {
|
||||
File found = findCaseInsensitivePack(packsFolder, requested);
|
||||
File found = findCaseInsensitivePack(packsFolder, reference.pack());
|
||||
if (found != null) {
|
||||
pack = found;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pack.exists()) {
|
||||
IrisServices.get(StudioSVC.class).downloadSearch(new VolmitSender(Bukkit.getConsoleSender(), BukkitPlatform.volmitPlugin().getTag()), requested, false);
|
||||
File found = findCaseInsensitivePack(packsFolder, requested);
|
||||
IrisServices.get(StudioSVC.class).downloadSearch(new VolmitSender(Bukkit.getConsoleSender(), BukkitPlatform.volmitPlugin().getTag()), reference.pack(), false);
|
||||
File found = findCaseInsensitivePack(packsFolder, reference.pack());
|
||||
if (found != null) {
|
||||
pack = found;
|
||||
}
|
||||
@@ -108,13 +108,16 @@ public class IrisToolbelt {
|
||||
}
|
||||
|
||||
IrisData data = IrisData.get(pack);
|
||||
IrisDimension resolved = data.getDimensionLoader().load(requested, false);
|
||||
IrisDimension resolved = data.getDimensionLoader().load(reference.dimension(), false);
|
||||
if (resolved != null) {
|
||||
return resolved;
|
||||
}
|
||||
if (reference.explicitDimension()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String packName = pack.getName();
|
||||
if (!packName.equals(requested)) {
|
||||
if (!packName.equals(reference.pack())) {
|
||||
resolved = data.getDimensionLoader().load(packName, false);
|
||||
if (resolved != null) {
|
||||
return resolved;
|
||||
@@ -122,7 +125,7 @@ public class IrisToolbelt {
|
||||
}
|
||||
|
||||
for (String key : data.getDimensionLoader().getPossibleKeys()) {
|
||||
if (!key.equalsIgnoreCase(requested) && !key.equalsIgnoreCase(packName)) {
|
||||
if (!key.equalsIgnoreCase(reference.pack()) && !key.equalsIgnoreCase(packName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -135,6 +138,26 @@ public class IrisToolbelt {
|
||||
return null;
|
||||
}
|
||||
|
||||
static PackReference parsePackReference(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String requested = value.trim();
|
||||
if (requested.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int separator = requested.indexOf(':');
|
||||
if (separator < 0) {
|
||||
return new PackReference(requested, requested, false);
|
||||
}
|
||||
String pack = requested.substring(0, separator).trim();
|
||||
String dimension = requested.substring(separator + 1).trim();
|
||||
if (pack.isEmpty() || dimension.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return new PackReference(pack, dimension, true);
|
||||
}
|
||||
|
||||
private static File findCaseInsensitivePack(File packsFolder, String requested) {
|
||||
File[] children = packsFolder.listFiles();
|
||||
if (children == null) {
|
||||
@@ -503,4 +526,7 @@ public class IrisToolbelt {
|
||||
public static boolean removeWorld(World world) throws IOException {
|
||||
return IrisCreator.removeFromBukkitYml(world.getName());
|
||||
}
|
||||
|
||||
record PackReference(String pack, String dimension, boolean explicitDimension) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ public class IrisWorldCreator {
|
||||
private String name;
|
||||
private boolean studio = false;
|
||||
private String dimensionName = null;
|
||||
private IrisDimension dimension;
|
||||
private long seed = 1337;
|
||||
|
||||
public IrisWorldCreator() {
|
||||
@@ -41,6 +42,13 @@ public class IrisWorldCreator {
|
||||
|
||||
public IrisWorldCreator dimension(String loadKey) {
|
||||
this.dimensionName = loadKey;
|
||||
this.dimension = null;
|
||||
return this;
|
||||
}
|
||||
|
||||
public IrisWorldCreator dimension(IrisDimension dimension) {
|
||||
this.dimension = dimension;
|
||||
this.dimensionName = dimension.getLoadKey();
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -65,7 +73,7 @@ public class IrisWorldCreator {
|
||||
}
|
||||
|
||||
public WorldCreator create() {
|
||||
IrisDimension dim = IrisData.loadAnyDimension(dimensionName, null);
|
||||
IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension;
|
||||
|
||||
IrisWorld w = IrisWorld.builder()
|
||||
.name(name)
|
||||
@@ -87,7 +95,7 @@ public class IrisWorldCreator {
|
||||
}
|
||||
|
||||
private World.Environment findEnvironment() {
|
||||
IrisDimension dim = IrisData.loadAnyDimension(dimensionName, null);
|
||||
IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension;
|
||||
if (dim == null || dim.getEnvironment() == null) {
|
||||
return World.Environment.NORMAL;
|
||||
} else {
|
||||
|
||||
@@ -42,8 +42,10 @@ import art.arcane.iris.util.project.interpolation.IrisInterpolation.NoiseBounds;
|
||||
import art.arcane.iris.util.project.noise.CNG;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import art.arcane.iris.util.project.stream.interpolation.Interpolated;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.io.File;
|
||||
@@ -56,13 +58,14 @@ import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(exclude = "data")
|
||||
@ToString(exclude = "data")
|
||||
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache"})
|
||||
@ToString(exclude = {"data", "gridBoundsCache"})
|
||||
public class IrisComplex implements DataProvider {
|
||||
private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D);
|
||||
private static final int GRID_BOUNDS_CACHE_SIZE = 8192;
|
||||
private static final int HEIGHT_BOUNDS_GRID = 4;
|
||||
private static final ThreadLocal<GridBoundsCache> GRID_BOUNDS_CACHE = ThreadLocal.withInitial(GridBoundsCache::new);
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final transient ThreadLocal<GridBoundsCache> gridBoundsCache = ThreadLocal.withInitial(GridBoundsCache::new);
|
||||
private RNG rng;
|
||||
private double fluidHeight;
|
||||
private IrisData data;
|
||||
@@ -362,7 +365,7 @@ public class IrisComplex implements DataProvider {
|
||||
double fx = (x - gx) / grid;
|
||||
double fz = (z - gz) / grid;
|
||||
|
||||
GridBoundsCache cache = GRID_BOUNDS_CACHE.get();
|
||||
GridBoundsCache cache = gridBoundsCache.get();
|
||||
long b00 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz);
|
||||
long b10 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz);
|
||||
long b01 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz + grid);
|
||||
|
||||
@@ -67,6 +67,8 @@ import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -78,6 +80,8 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -86,6 +90,8 @@ import java.util.stream.Stream;
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
private static final int MAX_FORCED_CHUNK_UPDATES = 128;
|
||||
|
||||
private final Looper looper;
|
||||
private final int id;
|
||||
private final KList<Runnable> updateQueue = new KList<>();
|
||||
@@ -100,11 +106,16 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
private final Set<Long> markerFlagQueue = ConcurrentHashMap.newKeySet();
|
||||
private final Set<Long> discoveredFlagQueue = ConcurrentHashMap.newKeySet();
|
||||
private final Set<Long> markerScanQueue = ConcurrentHashMap.newKeySet();
|
||||
private final Set<Long> chunkUpdateQueue = ConcurrentHashMap.newKeySet();
|
||||
private final AtomicBoolean chunkUpdateScanScheduled = new AtomicBoolean();
|
||||
private final AtomicBoolean chunkDiscoveryScanScheduled = new AtomicBoolean();
|
||||
private int entityCount = 0;
|
||||
private int actuallySpawned = 0;
|
||||
private int cooldown = 0;
|
||||
private List<Entity> precount = new KList<>();
|
||||
private int forcedChunkUpdateCursor = 0;
|
||||
private volatile boolean playersPresent = false;
|
||||
private KSet<Position2> injectBiomes = new KSet<>();
|
||||
private volatile int loadedChunkCount = 0;
|
||||
|
||||
public IrisWorldManager() {
|
||||
super(null);
|
||||
@@ -126,7 +137,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
cl = new ChronoLatch(3000);
|
||||
clw = new ChronoLatch(1000, true);
|
||||
cleanupService = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
var thread = new Thread(runnable, "Iris Mantle Cleanup " + getTarget().getWorld().name());
|
||||
Thread thread = new Thread(runnable, "Iris Mantle Cleanup " + getTarget().getWorld().name());
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
return thread;
|
||||
});
|
||||
@@ -139,18 +150,18 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
}
|
||||
|
||||
if (!getEngine().getWorld().hasRealWorld() && clw.flip()) {
|
||||
getEngine().getWorld().tryGetRealWorld();
|
||||
J.runGlobal(() -> getEngine().getWorld().tryGetRealWorld());
|
||||
}
|
||||
|
||||
if (getEngine().getWorld().hasRealWorld()) {
|
||||
if (getEngine().getWorld().getPlayers().isEmpty()) {
|
||||
return 5000;
|
||||
}
|
||||
|
||||
if (chunkUpdater.flip()) {
|
||||
updateChunks();
|
||||
}
|
||||
|
||||
if (!playersPresent) {
|
||||
return 5000;
|
||||
}
|
||||
|
||||
if (chunkDiscovery.flip()) {
|
||||
discoverChunks();
|
||||
}
|
||||
@@ -163,19 +174,6 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
return 3000;
|
||||
}
|
||||
|
||||
if (precount != null) {
|
||||
entityCount = 0;
|
||||
for (Entity i : precount) {
|
||||
if (i instanceof LivingEntity) {
|
||||
if (!i.isDead()) {
|
||||
entityCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
precount = null;
|
||||
}
|
||||
|
||||
onAsyncTick();
|
||||
}
|
||||
|
||||
@@ -202,23 +200,46 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Player player : getEngine().getWorld().getPlayers()) {
|
||||
if (player == null || !player.isOnline()) {
|
||||
continue;
|
||||
}
|
||||
if (!chunkDiscoveryScanScheduled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
J.runEntity(player, () -> {
|
||||
int centerX = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockX());
|
||||
int centerZ = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockZ());
|
||||
int radius = 1;
|
||||
for (int x = -radius; x <= radius; x++) {
|
||||
for (int z = -radius; z <= radius; z++) {
|
||||
int chunkX = centerX + x;
|
||||
int chunkZ = centerZ + z;
|
||||
raiseDiscoveredChunkFlag(world, chunkX, chunkZ);
|
||||
}
|
||||
boolean scheduled = J.runGlobal(() -> {
|
||||
try {
|
||||
if (getEngine().isClosed() || !world.equals(getEngine().getWorld().realWorld())) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
for (Player player : world.getPlayers()) {
|
||||
if (player == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
J.runEntity(player, () -> {
|
||||
if (!player.isOnline() || !world.equals(player.getWorld())) {
|
||||
return;
|
||||
}
|
||||
|
||||
int centerX = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockX());
|
||||
int centerZ = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockZ());
|
||||
int radius = 1;
|
||||
for (int x = -radius; x <= radius; x++) {
|
||||
for (int z = -radius; z <= radius; z++) {
|
||||
int chunkX = centerX + x;
|
||||
int chunkZ = centerZ + z;
|
||||
raiseDiscoveredChunkFlag(world, chunkX, chunkZ);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
} finally {
|
||||
chunkDiscoveryScanScheduled.set(false);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
chunkDiscoveryScanScheduled.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,24 +282,98 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Player player : getEngine().getWorld().getPlayers()) {
|
||||
if (player == null || !player.isOnline()) {
|
||||
continue;
|
||||
if (!chunkUpdateScanScheduled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean scheduled = J.runGlobal(() -> updateChunksOnGlobal(world));
|
||||
if (!scheduled) {
|
||||
chunkUpdateScanScheduled.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateChunksOnGlobal(World world) {
|
||||
try {
|
||||
if (getEngine().isClosed() || !world.equals(getEngine().getWorld().realWorld())) {
|
||||
return;
|
||||
}
|
||||
|
||||
J.runEntity(player, () -> {
|
||||
int centerX = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockX());
|
||||
int centerZ = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockZ());
|
||||
int radius = 1;
|
||||
List<Player> players = new ArrayList<>(world.getPlayers());
|
||||
playersPresent = !players.isEmpty();
|
||||
loadedChunkCount = world.getLoadedChunks().length;
|
||||
for (Player player : players) {
|
||||
if (player == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int x = -radius; x <= radius; x++) {
|
||||
for (int z = -radius; z <= radius; z++) {
|
||||
int targetX = centerX + x;
|
||||
int targetZ = centerZ + z;
|
||||
J.runRegion(world, targetX, targetZ, () -> updateChunkRegion(world, targetX, targetZ));
|
||||
}
|
||||
J.runEntity(player, () -> schedulePlayerChunkUpdates(world, player));
|
||||
}
|
||||
|
||||
scheduleForcedChunkUpdates(world);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
} finally {
|
||||
chunkUpdateScanScheduled.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void schedulePlayerChunkUpdates(World world, Player player) {
|
||||
if (!player.isOnline() || !world.equals(player.getWorld())) {
|
||||
return;
|
||||
}
|
||||
|
||||
int centerX = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockX());
|
||||
int centerZ = PowerOfTwoCoordinates.blockToChunkFloor(player.getLocation().getBlockZ());
|
||||
int radius = 1;
|
||||
for (int x = -radius; x <= radius; x++) {
|
||||
for (int z = -radius; z <= radius; z++) {
|
||||
scheduleChunkUpdate(world, centerX + x, centerZ + z);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleForcedChunkUpdates(World world) {
|
||||
List<Position2> forcedChunks = new ArrayList<>();
|
||||
for (Chunk chunk : world.getForceLoadedChunks()) {
|
||||
forcedChunks.add(new Position2(chunk.getX(), chunk.getZ()));
|
||||
}
|
||||
forcedChunks.sort(Comparator.comparingInt(Position2::getX).thenComparingInt(Position2::getZ));
|
||||
|
||||
int forcedChunkCount = forcedChunks.size();
|
||||
if (forcedChunkCount == 0) {
|
||||
forcedChunkUpdateCursor = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
int updateCount = Math.min(forcedChunkCount, MAX_FORCED_CHUNK_UPDATES);
|
||||
int start = Math.floorMod(forcedChunkUpdateCursor, forcedChunkCount);
|
||||
for (int i = 0; i < updateCount; i++) {
|
||||
Position2 chunk = forcedChunks.get((start + i) % forcedChunkCount);
|
||||
scheduleChunkUpdate(world, chunk.getX(), chunk.getZ());
|
||||
}
|
||||
forcedChunkUpdateCursor = (start + updateCount) % forcedChunkCount;
|
||||
}
|
||||
|
||||
private void scheduleChunkUpdate(World world, int chunkX, int chunkZ) {
|
||||
long key = Cache.key(chunkX, chunkZ);
|
||||
if (!chunkUpdateQueue.add(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
|
||||
try {
|
||||
updateChunkRegion(world, chunkX, chunkZ);
|
||||
} finally {
|
||||
chunkUpdateQueue.remove(key);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
chunkUpdateQueue.remove(key);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
chunkUpdateQueue.remove(key);
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +502,36 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cl.flip()) {
|
||||
try {
|
||||
World realWorld = getEngine().getWorld().realWorld();
|
||||
if (realWorld == null) {
|
||||
entityCount = 0;
|
||||
} else if (J.isFolia()) {
|
||||
entityCount = getFoliaLivingEntityCount(realWorld);
|
||||
} else {
|
||||
CompletableFuture<Integer> future = new CompletableFuture<>();
|
||||
boolean scheduled = J.runGlobal(() -> {
|
||||
try {
|
||||
int count = 0;
|
||||
for (Entity entity : realWorld.getEntities()) {
|
||||
if (entity instanceof LivingEntity && !entity.isDead()) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
future.complete(count);
|
||||
} catch (Throwable ex) {
|
||||
future.completeExceptionally(ex);
|
||||
}
|
||||
});
|
||||
entityCount = scheduled ? future.get(2, TimeUnit.SECONDS) : 0;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
double epx = getEntitySaturation();
|
||||
if (epx > IrisSettings.get().getWorld().getTargetSpawnEntitiesPerChunk()) {
|
||||
IrisLogging.debug("Can't spawn. The entity per chunk ratio is at " + Form.pc(epx, 2) + " > 100% (total entities " + entityCount + ")");
|
||||
@@ -414,43 +539,20 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cl.flip()) {
|
||||
try {
|
||||
World realWorld = getEngine().getWorld().realWorld();
|
||||
if (realWorld == null) {
|
||||
precount = new KList<>();
|
||||
} else if (J.isFolia()) {
|
||||
precount = getFoliaEntitySnapshot(realWorld);
|
||||
} else {
|
||||
CompletableFuture<List<Entity>> future = new CompletableFuture<>();
|
||||
J.s(() -> {
|
||||
try {
|
||||
future.complete(realWorld.getEntities());
|
||||
} catch (Throwable ex) {
|
||||
future.completeExceptionally(ex);
|
||||
}
|
||||
});
|
||||
precount = future.get(2, TimeUnit.SECONDS);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
int spawnBuffer = RNG.r.i(2, 12);
|
||||
World world = getEngine().getWorld().realWorld();
|
||||
if (world == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Chunk[] cc = getLoadedChunksSnapshot(world);
|
||||
Position2[] cc = getLoadedChunkPositionsSnapshot(world);
|
||||
while (spawnBuffer-- > 0) {
|
||||
if (cc.length == 0) {
|
||||
IrisLogging.debug("Can't spawn. No chunks!");
|
||||
return false;
|
||||
}
|
||||
|
||||
Chunk c = cc[RNG.r.nextInt(cc.length)];
|
||||
Position2 c = cc[RNG.r.nextInt(cc.length)];
|
||||
spawnChunkSafely(world, c.getX(), c.getZ(), false);
|
||||
}
|
||||
|
||||
@@ -475,48 +577,76 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
return job.targetsWorldName(world.getName());
|
||||
}
|
||||
|
||||
private Chunk[] getLoadedChunksSnapshot(World world) {
|
||||
private Position2[] getLoadedChunkPositionsSnapshot(World world) {
|
||||
if (world == null) {
|
||||
return new Chunk[0];
|
||||
return new Position2[0];
|
||||
}
|
||||
|
||||
CompletableFuture<Chunk[]> future = new CompletableFuture<>();
|
||||
J.s(() -> {
|
||||
CompletableFuture<Position2[]> future = new CompletableFuture<>();
|
||||
boolean scheduled = J.runGlobal(() -> {
|
||||
try {
|
||||
future.complete(world.getLoadedChunks());
|
||||
Chunk[] chunks = world.getLoadedChunks();
|
||||
Position2[] positions = new Position2[chunks.length];
|
||||
for (int i = 0; i < chunks.length; i++) {
|
||||
positions[i] = new Position2(chunks[i].getX(), chunks[i].getZ());
|
||||
}
|
||||
loadedChunkCount = positions.length;
|
||||
future.complete(positions);
|
||||
} catch (Throwable e) {
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
return new Position2[0];
|
||||
}
|
||||
|
||||
try {
|
||||
return future.get(2, TimeUnit.SECONDS);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
return new Chunk[0];
|
||||
return new Position2[0];
|
||||
}
|
||||
}
|
||||
|
||||
private List<Entity> getFoliaEntitySnapshot(World world) {
|
||||
Map<String, Entity> snapshot = new ConcurrentHashMap<>();
|
||||
List<Player> players = getEngine().getWorld().getPlayers();
|
||||
if (players == null || players.isEmpty()) {
|
||||
return new KList<>();
|
||||
private int getFoliaLivingEntityCount(World world) {
|
||||
CompletableFuture<List<Player>> playerFuture = new CompletableFuture<>();
|
||||
boolean scheduled = J.runGlobal(() -> {
|
||||
try {
|
||||
playerFuture.complete(new ArrayList<>(world.getPlayers()));
|
||||
} catch (Throwable e) {
|
||||
playerFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<Player> players;
|
||||
try {
|
||||
players = playerFuture.get(2, TimeUnit.SECONDS);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
return 0;
|
||||
}
|
||||
|
||||
Map<String, Entity> candidates = new ConcurrentHashMap<>();
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(players.size());
|
||||
for (Player player : players) {
|
||||
if (player == null || !player.isOnline() || !world.equals(player.getWorld())) {
|
||||
if (player == null) {
|
||||
latch.countDown();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!J.runEntity(player, () -> {
|
||||
try {
|
||||
snapshot.put(player.getUniqueId().toString(), player);
|
||||
if (!player.isOnline() || !world.equals(player.getWorld())) {
|
||||
return;
|
||||
}
|
||||
candidates.put(player.getUniqueId().toString(), player);
|
||||
for (Entity nearby : player.getNearbyEntities(64, 64, 64)) {
|
||||
if (nearby != null && world.equals(nearby.getWorld())) {
|
||||
snapshot.put(nearby.getUniqueId().toString(), nearby);
|
||||
if (nearby != null) {
|
||||
candidates.put(nearby.getUniqueId().toString(), nearby);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -533,9 +663,28 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
KList<Entity> entities = new KList<>();
|
||||
entities.addAll(snapshot.values());
|
||||
return entities;
|
||||
AtomicInteger count = new AtomicInteger();
|
||||
CountDownLatch entityLatch = new CountDownLatch(candidates.size());
|
||||
for (Entity entity : candidates.values()) {
|
||||
if (!J.runEntity(entity, () -> {
|
||||
try {
|
||||
if (entity instanceof LivingEntity && world.equals(entity.getWorld()) && !entity.isDead()) {
|
||||
count.incrementAndGet();
|
||||
}
|
||||
} finally {
|
||||
entityLatch.countDown();
|
||||
}
|
||||
})) {
|
||||
entityLatch.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
entityLatch.await(2, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return count.get();
|
||||
}
|
||||
|
||||
private void spawnChunkSafely(World world, int chunkX, int chunkZ, boolean initial) {
|
||||
@@ -1039,7 +1188,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
|
||||
@Override
|
||||
public int getChunkCount() {
|
||||
return getEngine().getWorld().realWorld().getLoadedChunks().length;
|
||||
return loadedChunkCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1048,7 +1197,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (double) entityCount / (getEngine().getWorld().realWorld().getLoadedChunks().length + 1) * 1.28;
|
||||
return (double) entityCount / (loadedChunkCount + 1) * 1.28;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -55,12 +55,12 @@ public class IrisBiomeActuator extends EngineAssignedActuator<PlatformBiome> {
|
||||
PlatformBiome biome;
|
||||
|
||||
if (ib.isCustom()) {
|
||||
IrisBiomeCustom custom = ib.getCustomBiome(rng, x, 0, z);
|
||||
IrisBiomeCustom custom = ib.getCustomBiome(rng, getEngine(), x + xf, 0, z + zf);
|
||||
String key = getDimension().getLoadKey() + ":" + custom.getId();
|
||||
biome = IrisPlatforms.get().registries().biome(key);
|
||||
matter = BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(key));
|
||||
} else {
|
||||
String skyKey = ib.getSkyBiomeKey(rng, x, 0, z);
|
||||
String skyKey = ib.getSkyBiomeKey(rng, getEngine(), x + xf, 0, z + zf);
|
||||
biome = IrisPlatforms.get().registries().biome(skyKey);
|
||||
matter = BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(skyKey));
|
||||
}
|
||||
|
||||
@@ -42,9 +42,9 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
|
||||
private static final Predicate<PlatformBlockState> PREDICATE_SOLID = (s) -> s != null && !B.isAirOrFluid(s);
|
||||
private final RNG rng;
|
||||
@Getter
|
||||
private final EngineDecorator surfaceDecorator;
|
||||
private final IrisSurfaceDecorator surfaceDecorator;
|
||||
@Getter
|
||||
private final EngineDecorator ceilingDecorator;
|
||||
private final IrisCeilingDecorator ceilingDecorator;
|
||||
@Getter
|
||||
private final EngineDecorator seaSurfaceDecorator;
|
||||
@Getter
|
||||
|
||||
@@ -41,7 +41,13 @@ public class IrisCeilingDecorator extends IrisEngineDecorator {
|
||||
@Override
|
||||
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
|
||||
Hunk<PlatformBlockState> data, IrisBiome biome, int height, int max) {
|
||||
boolean caveSkipFluid = biome.getInferredType() == InferredType.CAVE;
|
||||
decorate(x, z, realX, realX1, realX_1, realZ, realZ1, realZ_1, data, biome, biome.getInferredType(), height, max);
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
|
||||
Hunk<PlatformBlockState> data, IrisBiome biome, InferredType inferredType, int height, int max) {
|
||||
boolean caveSkipFluid = IrisSurfaceDecorator.skipsFluid(inferredType);
|
||||
RNG rng = getRNG(realX, realZ);
|
||||
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
|
||||
|
||||
|
||||
@@ -52,13 +52,19 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
|
||||
@Override
|
||||
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
|
||||
Hunk<PlatformBlockState> data, IrisBiome biome, int height, int max) {
|
||||
decorate(x, z, realX, realX1, realX_1, realZ, realZ1, realZ_1, data, biome, biome.getInferredType(), height, max);
|
||||
}
|
||||
|
||||
@BlockCoordinates
|
||||
public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1,
|
||||
Hunk<PlatformBlockState> data, IrisBiome biome, InferredType inferredType, int height, int max) {
|
||||
int fluidHeight = getDimension().getFluidHeight();
|
||||
if (biome.getInferredType().equals(InferredType.SHORE) && height < fluidHeight) {
|
||||
if (inferredType == InferredType.SHORE && height < fluidHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean underwater = height < fluidHeight && biome.getInferredType() != InferredType.CAVE;
|
||||
boolean caveSkipFluid = biome.getInferredType() == InferredType.CAVE;
|
||||
boolean underwater = isUnderwater(inferredType, height, fluidHeight);
|
||||
boolean caveSkipFluid = skipsFluid(inferredType);
|
||||
RNG rng = getRNG(realX, realZ);
|
||||
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
|
||||
|
||||
@@ -79,4 +85,12 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
|
||||
DecoratorCore.placeSurfaceSingle(decorator, x, z, realX, height, realZ,
|
||||
data, rng, getData(), underwater, caveSkipFluid, getEngine().getMantle());
|
||||
}
|
||||
|
||||
static boolean isUnderwater(InferredType inferredType, int height, int fluidHeight) {
|
||||
return height < fluidHeight && inferredType != InferredType.CAVE;
|
||||
}
|
||||
|
||||
static boolean skipsFluid(InferredType inferredType) {
|
||||
return inferredType == InferredType.CAVE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,11 @@ import art.arcane.iris.engine.object.IrisEngineData;
|
||||
import art.arcane.iris.engine.object.IrisLootMode;
|
||||
import art.arcane.iris.engine.object.IrisLootReference;
|
||||
import art.arcane.iris.engine.object.IrisLootTable;
|
||||
import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.engine.object.IrisObjectPlacement;
|
||||
import art.arcane.iris.engine.object.IrisPosition;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisStructure;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.PlatformBiome;
|
||||
@@ -577,15 +579,24 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat
|
||||
return null;
|
||||
}
|
||||
|
||||
String[] v = objectAt.split("\\Q@\\E");
|
||||
String object = v[0];
|
||||
if (object.isEmpty() || object.equals("null")) {
|
||||
StructurePlacementMarker.Decoded marker = StructurePlacementMarker.decode(objectAt);
|
||||
if (marker == null) {
|
||||
return null;
|
||||
}
|
||||
String object = marker.objectKey();
|
||||
if (object.startsWith("procedural/")) {
|
||||
return null;
|
||||
}
|
||||
int id = Integer.parseInt(v[1]);
|
||||
int id = marker.placementId();
|
||||
|
||||
if (marker.structureAware()) {
|
||||
IrisObject placedObject = getData().getObjectLoader().load(object);
|
||||
IrisStructure structure = IrisData.loadAnyStructure(marker.structureKey(), getData());
|
||||
IrisObjectPlacement placement = placedObject == null || structure == null
|
||||
? null
|
||||
: structure.createLootPlacement(object);
|
||||
return new PlacedObject(placement, placedObject, id, x, z);
|
||||
}
|
||||
|
||||
|
||||
IrisRegion region = getRegion(x, z);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class StructurePlacementMarker {
|
||||
private static final String FAMILY_PREFIX = "@iris-structure:";
|
||||
private static final String VERSION_PREFIX = FAMILY_PREFIX + "v1:";
|
||||
|
||||
private StructurePlacementMarker() {
|
||||
}
|
||||
|
||||
public static String encodeStructure(String objectKey, int placementId, String structureKey) {
|
||||
String normalizedObjectKey = requireKey(objectKey, "objectKey");
|
||||
String normalizedStructureKey = requireKey(structureKey, "structureKey");
|
||||
return VERSION_PREFIX
|
||||
+ encodeKey(normalizedObjectKey)
|
||||
+ ":" + placementId
|
||||
+ ":" + encodeKey(normalizedStructureKey);
|
||||
}
|
||||
|
||||
public static Decoded decode(String marker) {
|
||||
if (marker == null || marker.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (marker.startsWith(FAMILY_PREFIX)) {
|
||||
return decodeStructure(marker);
|
||||
}
|
||||
return decodeLegacy(marker);
|
||||
}
|
||||
|
||||
private static Decoded decodeStructure(String marker) {
|
||||
if (!marker.startsWith(VERSION_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
String[] fields = marker.split(":", -1);
|
||||
if (fields.length != 5 || !fields[0].equals("@iris-structure") || !fields[1].equals("v1")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String objectKey = decodeCanonicalKey(fields[2]);
|
||||
int placementId = Integer.parseInt(fields[3]);
|
||||
String structureKey = decodeCanonicalKey(fields[4]);
|
||||
if (objectKey.isBlank() || structureKey.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return new Decoded(objectKey, placementId, structureKey);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Decoded decodeLegacy(String marker) {
|
||||
int separator = marker.indexOf('@');
|
||||
if (separator <= 0 || separator != marker.lastIndexOf('@') || separator == marker.length() - 1) {
|
||||
return null;
|
||||
}
|
||||
String objectKey = marker.substring(0, separator);
|
||||
if (objectKey.isEmpty() || objectKey.equals("null")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new Decoded(objectKey, Integer.parseInt(marker.substring(separator + 1)), null);
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireKey(String key, String name) {
|
||||
String required = Objects.requireNonNull(key, name);
|
||||
if (required.isBlank()) {
|
||||
throw new IllegalArgumentException(name + " must not be blank");
|
||||
}
|
||||
return required;
|
||||
}
|
||||
|
||||
private static String encodeKey(String key) {
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(key.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String decodeCanonicalKey(String key) {
|
||||
String decoded = new String(Base64.getUrlDecoder().decode(key), StandardCharsets.UTF_8);
|
||||
if (!encodeKey(decoded).equals(key)) {
|
||||
throw new IllegalArgumentException("Non-canonical marker key");
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
public record Decoded(String objectKey, int placementId, String structureKey) {
|
||||
public boolean structureAware() {
|
||||
return structureKey != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,6 @@ public class IrisCaveCarver3D {
|
||||
private static final int ADAPTIVE_DEEP_SURFACE_MARGIN = 12;
|
||||
private static final double ADAPTIVE_LOCAL_RANGE_SCALE = 0.125D;
|
||||
private static final double ADAPTIVE_DEEP_MARGIN_BOOST = 0.015D;
|
||||
private static final ThreadLocal<Scratch> SCRATCH = ThreadLocal.withInitial(Scratch::new);
|
||||
|
||||
private final Engine engine;
|
||||
private final IrisData data;
|
||||
@@ -70,6 +69,7 @@ public class IrisCaveCarver3D {
|
||||
private final boolean hasWarp;
|
||||
private final boolean hasModules;
|
||||
private final int warpResolution;
|
||||
private final ThreadLocal<Scratch> scratchCache = ThreadLocal.withInitial(Scratch::new);
|
||||
|
||||
public IrisCaveCarver3D(Engine engine, IrisCaveProfile profile) {
|
||||
this.engine = engine;
|
||||
@@ -112,7 +112,7 @@ public class IrisCaveCarver3D {
|
||||
}
|
||||
|
||||
public int carve(MantleWriter writer, int chunkX, int chunkZ) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
if (!scratch.fullWeightsInitialized) {
|
||||
Arrays.fill(scratch.fullWeights, 1D);
|
||||
scratch.fullWeightsInitialized = true;
|
||||
@@ -169,7 +169,7 @@ public class IrisCaveCarver3D {
|
||||
) {
|
||||
PrecisionStopwatch applyStopwatch = PrecisionStopwatch.start();
|
||||
try {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
if (columnWeights == null || columnWeights.length < 256) {
|
||||
if (!scratch.fullWeightsInitialized) {
|
||||
Arrays.fill(scratch.fullWeights, 1D);
|
||||
@@ -364,7 +364,7 @@ public class IrisCaveCarver3D {
|
||||
boolean skipExistingCarved
|
||||
) {
|
||||
int carved = 0;
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
double[] passThreshold = scratch.passThreshold;
|
||||
int[] activeColumnIndices = scratch.activeColumnIndices;
|
||||
int[] activeColumnTopY = scratch.activeColumnTopY;
|
||||
@@ -483,7 +483,7 @@ public class IrisCaveCarver3D {
|
||||
boolean skipExistingCarved
|
||||
) {
|
||||
int carved = 0;
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
double[] passThreshold = scratch.passThreshold;
|
||||
int[] activeColumnIndices = scratch.activeColumnIndices;
|
||||
int[] activeColumnTopY = scratch.activeColumnTopY;
|
||||
@@ -639,7 +639,7 @@ public class IrisCaveCarver3D {
|
||||
boolean skipExistingCarved
|
||||
) {
|
||||
int carved = 0;
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
double[] passThreshold = scratch.passThreshold;
|
||||
int[] tileIndices = scratch.tileIndices;
|
||||
int[] tileLocalX = scratch.tileLocalX;
|
||||
@@ -778,7 +778,7 @@ public class IrisCaveCarver3D {
|
||||
boolean skipExistingCarved
|
||||
) {
|
||||
int carved = 0;
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
|
||||
for (int lx = 0; lx < 16; lx++) {
|
||||
int x = x0 + lx;
|
||||
@@ -926,7 +926,7 @@ public class IrisCaveCarver3D {
|
||||
}
|
||||
|
||||
private void classifyDensityPlaneNoWarpModules(int x0, int z0, int y, int[] planeColumnIndices, double[] planeThresholdLimit, int planeCount, boolean[] planeCarve) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
int activeModuleCount = prepareActiveModules(scratch, y);
|
||||
if (activeModuleCount == 0) {
|
||||
classifyDensityPlaneNoWarpNoModules(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve);
|
||||
@@ -964,7 +964,7 @@ public class IrisCaveCarver3D {
|
||||
}
|
||||
|
||||
private void classifyDensityPlaneWarpModules(int x0, int z0, int y, int[] planeColumnIndices, double[] planeThresholdLimit, int planeCount, boolean[] planeCarve) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
int activeModuleCount = prepareActiveModules(scratch, y);
|
||||
if (activeModuleCount == 0) {
|
||||
classifyDensityPlaneWarpOnly(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve);
|
||||
@@ -1003,7 +1003,7 @@ public class IrisCaveCarver3D {
|
||||
int adaptiveSampleStep,
|
||||
double adaptiveThresholdMargin
|
||||
) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
double[] adaptivePlaneDensity = scratch.adaptivePlaneDensity;
|
||||
int axisCells = (16 + adaptiveSampleStep - 1) / adaptiveSampleStep;
|
||||
int axisSamples = axisCells + 1;
|
||||
@@ -1046,7 +1046,7 @@ public class IrisCaveCarver3D {
|
||||
int adaptiveSampleStep,
|
||||
double adaptiveThresholdMargin
|
||||
) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
int activeModuleCount = prepareActiveModules(scratch, y);
|
||||
if (activeModuleCount == 0) {
|
||||
classifyDensityPlaneAdaptiveNoWarpNoModules(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve, adaptiveSampleStep, adaptiveThresholdMargin);
|
||||
@@ -1102,7 +1102,7 @@ public class IrisCaveCarver3D {
|
||||
int adaptiveSampleStep,
|
||||
double adaptiveThresholdMargin
|
||||
) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
double[] adaptivePlaneDensity = scratch.adaptivePlaneDensity;
|
||||
int axisCells = (16 + adaptiveSampleStep - 1) / adaptiveSampleStep;
|
||||
int axisSamples = axisCells + 1;
|
||||
@@ -1145,7 +1145,7 @@ public class IrisCaveCarver3D {
|
||||
int adaptiveSampleStep,
|
||||
double adaptiveThresholdMargin
|
||||
) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
int activeModuleCount = prepareActiveModules(scratch, y);
|
||||
if (activeModuleCount == 0) {
|
||||
classifyDensityPlaneAdaptiveWarpOnly(x0, z0, y, planeColumnIndices, planeThresholdLimit, planeCount, planeCarve, adaptiveSampleStep, adaptiveThresholdMargin);
|
||||
@@ -1211,7 +1211,7 @@ public class IrisCaveCarver3D {
|
||||
int axisCells,
|
||||
int axisSamples
|
||||
) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction;
|
||||
double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity;
|
||||
prepareAdaptivePlaneColumns(
|
||||
@@ -1271,7 +1271,7 @@ public class IrisCaveCarver3D {
|
||||
double[] remainingMin,
|
||||
double[] remainingMax
|
||||
) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction;
|
||||
double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity;
|
||||
prepareAdaptivePlaneColumns(
|
||||
@@ -1348,7 +1348,7 @@ public class IrisCaveCarver3D {
|
||||
int axisCells,
|
||||
int axisSamples
|
||||
) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction;
|
||||
double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity;
|
||||
prepareAdaptivePlaneColumns(
|
||||
@@ -1408,7 +1408,7 @@ public class IrisCaveCarver3D {
|
||||
double[] remainingMin,
|
||||
double[] remainingMax
|
||||
) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction;
|
||||
double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity;
|
||||
prepareAdaptivePlaneColumns(
|
||||
@@ -1485,7 +1485,7 @@ public class IrisCaveCarver3D {
|
||||
double[] remainingMin,
|
||||
double[] remainingMax
|
||||
) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
double[] adaptivePlanePrediction = scratch.adaptivePlanePrediction;
|
||||
double[] adaptivePlaneAmbiguity = scratch.adaptivePlaneAmbiguity;
|
||||
prepareAdaptivePlaneColumns(
|
||||
@@ -1604,7 +1604,7 @@ public class IrisCaveCarver3D {
|
||||
}
|
||||
|
||||
private boolean classifyDensityPointWarpOnly(int x, int y, int z, double thresholdLimit) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
int sx = snapWarp(x);
|
||||
int sy = snapWarp(y);
|
||||
int sz = snapWarp(z);
|
||||
@@ -1640,7 +1640,7 @@ public class IrisCaveCarver3D {
|
||||
return classifyDensityPointWarpOnly(x, y, z, thresholdLimit);
|
||||
}
|
||||
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
int sx = snapWarp(x);
|
||||
int sy = snapWarp(y);
|
||||
int sz = snapWarp(z);
|
||||
@@ -1740,7 +1740,7 @@ public class IrisCaveCarver3D {
|
||||
return true;
|
||||
}
|
||||
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
int sx = snapWarp(x);
|
||||
int sy = snapWarp(y);
|
||||
int sz = snapWarp(z);
|
||||
@@ -1778,7 +1778,7 @@ public class IrisCaveCarver3D {
|
||||
int[] adaptivePlaneSampleBounds,
|
||||
int axisCells
|
||||
) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
prepareAdaptiveGeometry(scratch, adaptiveSampleStep, axisCells, axisCells + 1);
|
||||
int[] adaptiveCellX = scratch.adaptiveCellX;
|
||||
int[] adaptiveCellZ = scratch.adaptiveCellZ;
|
||||
@@ -1822,7 +1822,7 @@ public class IrisCaveCarver3D {
|
||||
double[] adaptivePlanePrediction,
|
||||
double[] adaptivePlaneAmbiguity
|
||||
) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
prepareAdaptiveGeometry(scratch, adaptiveSampleStep, axisCells, axisSamples);
|
||||
int[] adaptiveCellZ = scratch.adaptiveCellZ;
|
||||
int[] adaptiveRow0 = scratch.adaptiveRow0;
|
||||
@@ -1882,7 +1882,7 @@ public class IrisCaveCarver3D {
|
||||
}
|
||||
|
||||
private double sampleDensityNoWarpModules(int x, int y, int z) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
int activeModuleCount = prepareActiveModules(scratch, y);
|
||||
if (activeModuleCount == 0) {
|
||||
return sampleDensityNoWarpNoModules(x, y, z);
|
||||
@@ -1905,7 +1905,7 @@ public class IrisCaveCarver3D {
|
||||
}
|
||||
|
||||
private double sampleDensityWarpOnly(int x, int y, int z) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
int sx = snapWarp(x);
|
||||
int sy = snapWarp(y);
|
||||
int sz = snapWarp(z);
|
||||
@@ -1921,7 +1921,7 @@ public class IrisCaveCarver3D {
|
||||
}
|
||||
|
||||
private double sampleDensityWarpModules(int x, int y, int z) {
|
||||
Scratch scratch = SCRATCH.get();
|
||||
Scratch scratch = scratchCache.get();
|
||||
int activeModuleCount = prepareActiveModules(scratch, y);
|
||||
if (activeModuleCount == 0) {
|
||||
return sampleDensityWarpOnly(x, y, z);
|
||||
|
||||
+33
-3
@@ -23,6 +23,7 @@ import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.framework.PlacedStructurePiece;
|
||||
import art.arcane.iris.engine.framework.StructureAssembler;
|
||||
import art.arcane.iris.engine.framework.StructurePlacementMarker;
|
||||
import art.arcane.iris.engine.framework.StructurePlacementGrid;
|
||||
import art.arcane.iris.engine.mantle.ComponentFlag;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
@@ -37,6 +38,7 @@ import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisStructure;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.project.noise.CNG;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
@@ -331,10 +333,10 @@ public class IrisStructureComponent extends IrisMantleComponent {
|
||||
|
||||
private void placeObject(MantleWriter writer, IrisStructure structure, PlacedStructurePiece p, ObjectPlaceMode mode, int y, RNG rng) {
|
||||
IrisObject object = p.getObject();
|
||||
IrisObjectPlacement config = new IrisObjectPlacement();
|
||||
String objectKey = object.getLoadKey();
|
||||
IrisObjectPlacement config = structure.createLootPlacement(objectKey);
|
||||
config.setMode(mode);
|
||||
config.setRotation(p.getRotation());
|
||||
config.getPlace().add(object.getLoadKey());
|
||||
if (!structure.getEdit().isEmpty()) {
|
||||
config.setEdit(structure.getEdit());
|
||||
}
|
||||
@@ -342,7 +344,35 @@ public class IrisStructureComponent extends IrisMantleComponent {
|
||||
config.setForcePlace(true);
|
||||
}
|
||||
int placeY = (y == -1) ? -1 : y - getEngineMantle().getEngine().getMinHeight();
|
||||
object.place(p.getX(), placeY, p.getZ(), writer, config, rng, null, null, getData());
|
||||
String marker = structurePlacementMarker(structure, p, objectKey);
|
||||
object.place(p.getX(), placeY, p.getZ(), writer, config, rng, (position, state) -> {
|
||||
if (marker != null && shouldWriteStructureMarker(state)) {
|
||||
writer.setData(position.getX(), position.getY(), position.getZ(), marker);
|
||||
}
|
||||
}, null, getData());
|
||||
}
|
||||
|
||||
static boolean shouldWriteStructureMarker(PlatformBlockState state) {
|
||||
return state != null && state.isStorageChest();
|
||||
}
|
||||
|
||||
static int structurePlacementId(String structureKey, String objectKey, int x, int y, int z) {
|
||||
int hash = 17;
|
||||
hash = 31 * hash + structureKey.hashCode();
|
||||
hash = 31 * hash + objectKey.hashCode();
|
||||
hash = 31 * hash + x;
|
||||
hash = 31 * hash + y;
|
||||
hash = 31 * hash + z;
|
||||
return hash & Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
private static String structurePlacementMarker(IrisStructure structure, PlacedStructurePiece piece, String objectKey) {
|
||||
String structureKey = structure.getLoadKey();
|
||||
if (structureKey == null || structureKey.isBlank() || objectKey == null || objectKey.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
int placementId = structurePlacementId(structureKey, objectKey, piece.getX(), piece.getY(), piece.getZ());
|
||||
return StructurePlacementMarker.encodeStructure(objectKey, placementId, structureKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+1
-1
@@ -464,7 +464,7 @@ public class MantleCarvingComponent extends IrisMantleComponent {
|
||||
}
|
||||
|
||||
protected int computeRadius() {
|
||||
return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
private int[] prepareChunkSurfaceHeights(int chunkX, int chunkZ, ChunkContext context, int[] scratch) {
|
||||
|
||||
@@ -168,7 +168,6 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
: resolveCustomBiome(customBiomeCache, customBiome);
|
||||
|
||||
if (biome != null) {
|
||||
biome.setInferredType(InferredType.CAVE);
|
||||
PlatformBlockState data = biome.getWall().get(rng, worldX, yy, worldZ, getData());
|
||||
int columnIndex = PowerOfTwoCoordinates.packLocal16(rx, rz);
|
||||
|
||||
@@ -461,7 +460,6 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
return;
|
||||
}
|
||||
|
||||
biome.setInferredType(InferredType.CAVE);
|
||||
|
||||
KList<PlatformBlockState> floorLayers = biome.generateLayers(getDimension(), worldX, worldZ, rng, 3, zoneFloor, getData(), getComplex());
|
||||
for (int i = 0; i < zoneFloor - 1; i++) {
|
||||
@@ -550,7 +548,6 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
return;
|
||||
}
|
||||
|
||||
biome.setInferredType(InferredType.CAVE);
|
||||
|
||||
KList<PlatformBlockState> blocks = biome.generateLayers(getDimension(), xx, zz, rng, 3, zone.floor, getData(), getComplex());
|
||||
|
||||
@@ -600,9 +597,9 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
|
||||
|
||||
for (IrisDecorator decorator : biome.getDecorators()) {
|
||||
if (decorator.getPartOf().equals(IrisDecorationPart.NONE) && zone.getFloor() > 0 && B.isSolid(output.getRaw(rx, zone.getFloor() - 1, rz))) {
|
||||
decorant.getSurfaceDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, zone.getFloor() - 1, zone.airThickness());
|
||||
decorant.getSurfaceDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, InferredType.CAVE, zone.getFloor() - 1, zone.airThickness());
|
||||
} else if (decorator.getPartOf().equals(IrisDecorationPart.CEILING) && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) {
|
||||
decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, zone.getCeiling(), zone.airThickness());
|
||||
decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, biome, InferredType.CAVE, zone.getCeiling(), zone.airThickness());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -394,10 +394,10 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier<Platf
|
||||
|
||||
private MatterBiomeInject createSkyBiomeMatter(IrisBiome target, int wx, int wz) {
|
||||
if (target.isCustom()) {
|
||||
IrisBiomeCustom custom = target.getCustomBiome(rng, wx, 0, wz);
|
||||
IrisBiomeCustom custom = target.getCustomBiome(rng, getEngine(), wx, 0, wz);
|
||||
return BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(getDimension().getLoadKey() + ":" + custom.getId()));
|
||||
}
|
||||
|
||||
return BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(target.getSkyBiomeKey(rng, wx, 0, wz)));
|
||||
return BiomeInjectMatter.get(IrisPlatforms.get().biomeWriter().biomeIdFor(target.getSkyBiomeKey(rng, getEngine(), wx, 0, wz)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,9 +44,14 @@ import art.arcane.volmlib.util.json.JSONObject;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.iris.util.project.noise.CNG;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.Accessors;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import org.bukkit.NamespacedKey;
|
||||
@@ -61,6 +66,8 @@ import java.util.EnumMap;
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
public class IrisBiome extends IrisRegistrant implements IRare {
|
||||
private static final int BIOME_GENERATOR_CACHE_SIZE = 8;
|
||||
|
||||
private static final class States {
|
||||
private static final PlatformBlockState BARRIER = B.getState("BARRIER");
|
||||
}
|
||||
@@ -76,7 +83,13 @@ public class IrisBiome extends IrisRegistrant implements IRare {
|
||||
private final transient AtomicCache<Color> cacheColorLayerLoad = new AtomicCache<>();
|
||||
private final transient AtomicCache<Color> cacheColorDepositLoad = new AtomicCache<>();
|
||||
private final transient AtomicCache<CNG> childrenCell = new AtomicCache<>();
|
||||
private final transient AtomicCache<CNG> biomeGenerator = new AtomicCache<>();
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final transient ConcurrentLinkedHashMap<Long, CNG> biomeGenerators = new ConcurrentLinkedHashMap.Builder<Long, CNG>()
|
||||
.maximumWeightedCapacity(BIOME_GENERATOR_CACHE_SIZE)
|
||||
.build();
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
private transient volatile SeededBiomeGenerator recentBiomeGenerator;
|
||||
private final transient AtomicCache<Integer> maxHeight = new AtomicCache<>();
|
||||
private final transient AtomicCache<Integer> maxWithObjectHeight = new AtomicCache<>();
|
||||
private final transient AtomicCache<IrisBiome> realCarveBiome = new AtomicCache<>();
|
||||
@@ -469,8 +482,39 @@ public class IrisBiome extends IrisRegistrant implements IRare {
|
||||
}
|
||||
|
||||
public CNG getBiomeGenerator(RNG random) {
|
||||
return biomeGenerator.aquire(() ->
|
||||
biomeStyle.create(random.nextParallelRNG(213949 + 228888 + getRarity() + getName().length()), getLoader()));
|
||||
IrisData loader = getLoader();
|
||||
Engine engine = resolveBiomeGeneratorEngine(loader);
|
||||
return getBiomeGenerator(random, engine);
|
||||
}
|
||||
|
||||
public CNG getBiomeGenerator(RNG random, Engine engine) {
|
||||
IrisData loader = getLoader();
|
||||
long seed = engine == null ? random.getSeed() : engine.getSeedManager().getBiome();
|
||||
SeededBiomeGenerator recent = recentBiomeGenerator;
|
||||
if (recent != null && recent.seed == seed) {
|
||||
return recent.generator;
|
||||
}
|
||||
|
||||
CNG generator = biomeGenerators.computeIfAbsent(seed, ignored -> createBiomeGenerator(seed, loader));
|
||||
recentBiomeGenerator = new SeededBiomeGenerator(seed, generator);
|
||||
return generator;
|
||||
}
|
||||
|
||||
private Engine resolveBiomeGeneratorEngine(IrisData loader) {
|
||||
IrisContext context = IrisContext.get();
|
||||
if (context != null) {
|
||||
Engine contextEngine = context.getEngine();
|
||||
if (!contextEngine.isClosed() && (loader == null || contextEngine.getData() == loader)) {
|
||||
return contextEngine;
|
||||
}
|
||||
}
|
||||
|
||||
return loader == null ? null : loader.getEngine();
|
||||
}
|
||||
|
||||
private CNG createBiomeGenerator(long seed, IrisData loader) {
|
||||
int signature = 213949 + 228888 + getRarity() + getName().length();
|
||||
return biomeStyle.createNoCache(new RNG(seed).nextParallelRNG(signature), loader);
|
||||
}
|
||||
|
||||
public CNG getChildrenGenerator(RNG random, int sig, double scale) {
|
||||
@@ -752,23 +796,31 @@ public class IrisBiome extends IrisRegistrant implements IRare {
|
||||
}
|
||||
|
||||
public Biome getSkyBiome(RNG rng, double x, double y, double z) {
|
||||
return getSkyBiome(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z);
|
||||
}
|
||||
|
||||
public Biome getSkyBiome(RNG rng, Engine engine, double x, double y, double z) {
|
||||
if (biomeSkyScatter.size() == 1) {
|
||||
return getBiomeSkyScatterResolved().get(0);
|
||||
}
|
||||
|
||||
if (biomeSkyScatter.isEmpty()) {
|
||||
return getGroundBiome(rng, x, y, z);
|
||||
return getGroundBiome(rng, engine, x, y, z);
|
||||
}
|
||||
|
||||
return getBiomeSkyScatterResolved().get(getBiomeGenerator(rng).fit(0, biomeSkyScatter.size() - 1, x, y, z));
|
||||
return getBiomeSkyScatterResolved().get(getBiomeGenerator(rng, engine).fit(0, biomeSkyScatter.size() - 1, x, y, z));
|
||||
}
|
||||
|
||||
public IrisBiomeCustom getCustomBiome(RNG rng, double x, double y, double z) {
|
||||
return getCustomBiome(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z);
|
||||
}
|
||||
|
||||
public IrisBiomeCustom getCustomBiome(RNG rng, Engine engine, double x, double y, double z) {
|
||||
if (customDerivitives.size() == 1) {
|
||||
return customDerivitives.get(0);
|
||||
}
|
||||
|
||||
return customDerivitives.get(getBiomeGenerator(rng).fit(0, customDerivitives.size() - 1, x, y, z));
|
||||
return customDerivitives.get(getBiomeGenerator(rng, engine).fit(0, customDerivitives.size() - 1, x, y, z));
|
||||
}
|
||||
|
||||
public KList<IrisBiome> getRealChildren(DataProvider g) {
|
||||
@@ -801,6 +853,10 @@ public class IrisBiome extends IrisRegistrant implements IRare {
|
||||
|
||||
//TODO: Test
|
||||
public Biome getGroundBiome(RNG rng, double x, double y, double z) {
|
||||
return getGroundBiome(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z);
|
||||
}
|
||||
|
||||
public Biome getGroundBiome(RNG rng, Engine engine, double x, double y, double z) {
|
||||
if (biomeScatter.isEmpty()) {
|
||||
return getDerivative();
|
||||
}
|
||||
@@ -809,22 +865,30 @@ public class IrisBiome extends IrisRegistrant implements IRare {
|
||||
return getBiomeScatterResolved().get(0);
|
||||
}
|
||||
|
||||
return getBiomeGenerator(rng).fit(getBiomeScatterResolved(), x, y, z);
|
||||
return getBiomeGenerator(rng, engine).fit(getBiomeScatterResolved(), x, y, z);
|
||||
}
|
||||
|
||||
public String getSkyBiomeKey(RNG rng, double x, double y, double z) {
|
||||
return getSkyBiomeKey(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z);
|
||||
}
|
||||
|
||||
public String getSkyBiomeKey(RNG rng, Engine engine, double x, double y, double z) {
|
||||
if (biomeSkyScatter.size() == 1) {
|
||||
return namespacedBiomeKey(biomeSkyScatter.get(0));
|
||||
}
|
||||
|
||||
if (biomeSkyScatter.isEmpty()) {
|
||||
return getGroundBiomeKey(rng, x, y, z);
|
||||
return getGroundBiomeKey(rng, engine, x, y, z);
|
||||
}
|
||||
|
||||
return namespacedBiomeKey(biomeSkyScatter.get(getBiomeGenerator(rng).fit(0, biomeSkyScatter.size() - 1, x, y, z)));
|
||||
return namespacedBiomeKey(biomeSkyScatter.get(getBiomeGenerator(rng, engine).fit(0, biomeSkyScatter.size() - 1, x, y, z)));
|
||||
}
|
||||
|
||||
public String getGroundBiomeKey(RNG rng, double x, double y, double z) {
|
||||
return getGroundBiomeKey(rng, resolveBiomeGeneratorEngine(getLoader()), x, y, z);
|
||||
}
|
||||
|
||||
public String getGroundBiomeKey(RNG rng, Engine engine, double x, double y, double z) {
|
||||
if (biomeScatter.isEmpty()) {
|
||||
return namespacedBiomeKey(derivative);
|
||||
}
|
||||
@@ -833,7 +897,7 @@ public class IrisBiome extends IrisRegistrant implements IRare {
|
||||
return namespacedBiomeKey(biomeScatter.get(0));
|
||||
}
|
||||
|
||||
return namespacedBiomeKey(biomeScatter.get(getBiomeGenerator(rng).fit(0, biomeScatter.size() - 1, x, y, z)));
|
||||
return namespacedBiomeKey(biomeScatter.get(getBiomeGenerator(rng, engine).fit(0, biomeScatter.size() - 1, x, y, z)));
|
||||
}
|
||||
|
||||
public PlatformBlockState getSurfaceBlock(int x, int z, RNG rng, IrisData idm) {
|
||||
@@ -931,4 +995,14 @@ public class IrisBiome extends IrisRegistrant implements IRare {
|
||||
public void scanForErrors(JSONObject p, VolmitSender sender) {
|
||||
|
||||
}
|
||||
|
||||
private static final class SeededBiomeGenerator {
|
||||
private final long seed;
|
||||
private final CNG generator;
|
||||
|
||||
private SeededBiomeGenerator(long seed, CNG generator) {
|
||||
this.seed = seed;
|
||||
this.generator = generator;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ public class IrisBiomeCustom {
|
||||
@Desc("The biome's mob spawns")
|
||||
private KList<IrisBiomeCustomSpawn> spawns = new KList<>();
|
||||
|
||||
@ArrayType(min = 1, type = String.class)
|
||||
@Desc("Biome tags to opt this custom biome into, such as minecraft:allows_surface_slime_spawns")
|
||||
private KList<String> tags = new KList<>();
|
||||
|
||||
@Desc("The biome's downfall type")
|
||||
private IrisBiomeCustomPrecipType downfallType = IrisBiomeCustomPrecipType.rain;
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ import lombok.experimental.Accessors;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.EntityType;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
@Snippet("custom-biome-spawn")
|
||||
@Accessors(chain = true)
|
||||
@NoArgsConstructor
|
||||
@@ -45,11 +47,12 @@ public class IrisBiomeCustomSpawn {
|
||||
private String type = "minecraft:cow";
|
||||
|
||||
public EntityType getType() {
|
||||
if (type == null) {
|
||||
String typeKey = getTypeKey();
|
||||
if (typeKey == null) {
|
||||
return null;
|
||||
}
|
||||
return typeResolved.aquire(() -> {
|
||||
NamespacedKey namespacedKey = NamespacedKey.fromString(type);
|
||||
NamespacedKey namespacedKey = NamespacedKey.fromString(typeKey);
|
||||
return namespacedKey == null ? null : RegistryUtil.lookup(EntityType.class).get(namespacedKey);
|
||||
});
|
||||
}
|
||||
@@ -58,7 +61,8 @@ public class IrisBiomeCustomSpawn {
|
||||
if (type == null || type.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return type.contains(":") ? type : "minecraft:" + type;
|
||||
String normalized = type.trim().toLowerCase(Locale.ROOT);
|
||||
return normalized.contains(":") ? normalized : "minecraft:" + normalized;
|
||||
}
|
||||
|
||||
@MinNumber(1)
|
||||
|
||||
@@ -31,6 +31,9 @@ public enum IrisBiomeCustomSpawnType {
|
||||
@Desc("Eg bats")
|
||||
AMBIENT,
|
||||
|
||||
@Desc("Axolotls")
|
||||
AXOLOTLS,
|
||||
|
||||
@Desc("Odd spawn group but ok")
|
||||
UNDERGROUND_WATER_CREATURE,
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KSet;
|
||||
import art.arcane.iris.util.common.data.DataProvider;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
|
||||
import art.arcane.volmlib.util.math.Position2;
|
||||
@@ -56,9 +57,17 @@ import org.bukkit.block.Biome;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Accessors(chain = true)
|
||||
@NoArgsConstructor
|
||||
@@ -67,6 +76,8 @@ import java.util.Map;
|
||||
@EqualsAndHashCode(callSuper = false, doNotUseGetters = true)
|
||||
@ToString(doNotUseGetters = true)
|
||||
public class IrisDimension extends IrisRegistrant {
|
||||
private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+");
|
||||
|
||||
private final transient AtomicCache<Position2> parallaxSize = new AtomicCache<>();
|
||||
private final transient AtomicCache<CNG> rockLayerGenerator = new AtomicCache<>();
|
||||
private final transient AtomicCache<CNG> fluidLayerGenerator = new AtomicCache<>();
|
||||
@@ -523,6 +534,7 @@ public class IrisDimension extends IrisRegistrant {
|
||||
output.getParentFile().mkdirs();
|
||||
try {
|
||||
IO.writeAll(output, json);
|
||||
installBiomeTags(datapacks, namespace + ":" + customBiomeId, customBiome.getTags());
|
||||
} catch (IOException e) {
|
||||
IrisLogging.reportError(e);
|
||||
e.printStackTrace();
|
||||
@@ -532,6 +544,97 @@ public class IrisDimension extends IrisRegistrant {
|
||||
}
|
||||
}
|
||||
|
||||
public static void clearGeneratedBiomeTags(KList<File> folders) {
|
||||
for (File datapacks : folders) {
|
||||
File dataFolder = new File(datapacks, "iris/data");
|
||||
File[] namespaces = dataFolder.listFiles(File::isDirectory);
|
||||
if (namespaces == null) {
|
||||
continue;
|
||||
}
|
||||
for (File namespace : namespaces) {
|
||||
IO.delete(new File(namespace, "tags/worldgen/biome"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void installBiomeTags(File datapacks, String biomeKey, KList<String> tags) throws IOException {
|
||||
if (tags == null || tags.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (String rawTag : tags) {
|
||||
String tag = normalizeResourceKey(rawTag);
|
||||
if (tag == null) {
|
||||
IrisLogging.error("Invalid custom biome tag '" + rawTag + "' for " + biomeKey);
|
||||
continue;
|
||||
}
|
||||
int separator = tag.indexOf(':');
|
||||
String namespace = tag.substring(0, separator);
|
||||
String path = tag.substring(separator + 1);
|
||||
Path tagRoot = new File(datapacks, "iris/data/" + namespace + "/tags/worldgen/biome").toPath()
|
||||
.toAbsolutePath().normalize();
|
||||
Path output = tagRoot.resolve(path + ".json").normalize();
|
||||
if (!output.startsWith(tagRoot)) {
|
||||
IrisLogging.error("Unsafe custom biome tag '" + rawTag + "' for " + biomeKey);
|
||||
continue;
|
||||
}
|
||||
writeBiomeTag(output, biomeKey);
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeResourceKey(String key) {
|
||||
if (key == null || key.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = key.trim().toLowerCase(Locale.ROOT);
|
||||
if (normalized.indexOf(':') < 0) {
|
||||
normalized = "minecraft:" + normalized;
|
||||
}
|
||||
return RESOURCE_KEY_PATTERN.matcher(normalized).matches() ? normalized : null;
|
||||
}
|
||||
|
||||
static void writeBiomeTag(Path output, String biomeKey) throws IOException {
|
||||
synchronized (IrisDimension.class) {
|
||||
Set<String> values = new TreeSet<>();
|
||||
if (Files.isRegularFile(output)) {
|
||||
JSONObject existing = new JSONObject(Files.readString(output, StandardCharsets.UTF_8));
|
||||
JSONArray existingValues = existing.optJSONArray("values");
|
||||
if (existingValues != null) {
|
||||
for (int index = 0; index < existingValues.length(); index++) {
|
||||
String value = existingValues.optString(index, null);
|
||||
if (value != null && !value.isBlank()) {
|
||||
values.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
values.add(biomeKey);
|
||||
|
||||
JSONArray outputValues = new JSONArray();
|
||||
for (String value : values) {
|
||||
outputValues.put(value);
|
||||
}
|
||||
JSONObject tag = new JSONObject();
|
||||
tag.put("replace", false);
|
||||
tag.put("values", outputValues);
|
||||
writeAtomic(output, tag.toString(4));
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeAtomic(Path output, String content) throws IOException {
|
||||
Files.createDirectories(output.getParent());
|
||||
Path temporary = Files.createTempFile(output.getParent(), output.getFileName().toString(), ".tmp");
|
||||
try {
|
||||
Files.writeString(temporary, content, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(temporary, output, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(temporary, output, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
public Dimension getBaseDimension() {
|
||||
return switch (environment) {
|
||||
case NETHER -> Dimension.NETHER;
|
||||
|
||||
@@ -71,6 +71,21 @@ public class IrisStructure extends IrisRegistrant {
|
||||
@Desc("If this structure was generated by importing a vanilla or datapack structure, this is that structure's key (provenance). Empty for hand-authored structures.")
|
||||
private String vanillaSource = "";
|
||||
|
||||
public IrisObjectPlacement createLootPlacement(String objectKey) {
|
||||
IrisObjectPlacement placement = new IrisObjectPlacement();
|
||||
placement.getPlace().add(objectKey);
|
||||
placement.setOverrideGlobalLoot(false);
|
||||
if (loot == null) {
|
||||
return placement;
|
||||
}
|
||||
for (String lootTable : loot) {
|
||||
if (lootTable != null && !lootTable.isBlank()) {
|
||||
placement.getLoot().add(new IrisObjectLoot().setName(lootTable).setWeight(1));
|
||||
}
|
||||
}
|
||||
return placement;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFolderName() {
|
||||
return "structures";
|
||||
|
||||
@@ -72,15 +72,6 @@ public class IrisStructurePlacement {
|
||||
@Desc("CONCENTRIC_RINGS only: how many placements share each ring before moving outward.")
|
||||
private int ringSpread = 3;
|
||||
|
||||
@Desc("rotation applied to the placed structure.")
|
||||
private IrisObjectRotation rotation = new IrisObjectRotation();
|
||||
|
||||
@Desc("translation applied to the placed structure.")
|
||||
private IrisObjectTranslate translate = new IrisObjectTranslate();
|
||||
|
||||
@Desc("scale applied to the placed structure.")
|
||||
private IrisObjectScale scale = new IrisObjectScale();
|
||||
|
||||
@Desc("When underground=false this is the minimum surface Y the placement is allowed at (a gate); when underground=true this is the lower bound of the Y band the structure is placed within.")
|
||||
private int minHeight = -2032;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
package art.arcane.iris.platform.bukkit;
|
||||
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.spi.PlatformEntityType;
|
||||
import org.bukkit.entity.EntityType;
|
||||
|
||||
@@ -32,12 +33,14 @@ public final class BukkitEntityType implements PlatformEntityType {
|
||||
private final EntityType type;
|
||||
private final String key;
|
||||
private final String namespace;
|
||||
private final String spawnCategory;
|
||||
|
||||
private BukkitEntityType(EntityType type, String key) {
|
||||
this.type = type;
|
||||
this.key = key;
|
||||
int colon = key.indexOf(':');
|
||||
this.namespace = colon >= 0 ? key.substring(0, colon) : "minecraft";
|
||||
this.spawnCategory = INMS.get().getEntitySpawnCategory(key);
|
||||
}
|
||||
|
||||
public static BukkitEntityType of(EntityType type) {
|
||||
@@ -55,6 +58,11 @@ public final class BukkitEntityType implements PlatformEntityType {
|
||||
return namespace;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String spawnCategory() {
|
||||
return spawnCategory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object nativeHandle() {
|
||||
return type;
|
||||
|
||||
@@ -281,6 +281,41 @@ public class J {
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean runGlobal(Runnable runnable) {
|
||||
if (runnable == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!BUKKIT_PRESENT) {
|
||||
if (!IrisPlatforms.isBound()) {
|
||||
return false;
|
||||
}
|
||||
IrisPlatforms.get().scheduler().global(runnable);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isPluginEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isFolia()) {
|
||||
return FoliaScheduler.runGlobal(BukkitPlatform.plugin(), runnable);
|
||||
}
|
||||
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
runnable.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
Bukkit.getScheduler().runTask(BukkitPlatform.plugin(), runnable);
|
||||
return true;
|
||||
} catch (UnsupportedOperationException e) {
|
||||
FoliaScheduler.forceFoliaThreading(Bukkit.getServer());
|
||||
return FoliaScheduler.runGlobal(BukkitPlatform.plugin(), runnable);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean runRegion(World world, int chunkX, int chunkZ, Runnable runnable, int delayTicks) {
|
||||
if (world == null || runnable == null) {
|
||||
return false;
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package art.arcane.iris.core.nms.datapack.v1217;
|
||||
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer.Dimension;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class DataFixerV1217ClockTest {
|
||||
@Test
|
||||
public void overworldProvidesTheVanillaDefaultClock() {
|
||||
JSONObject json = fixed(Dimension.OVERWORLD);
|
||||
|
||||
assertEquals("minecraft:overworld", json.getString("default_clock"));
|
||||
assertFalse(json.optBoolean("has_fixed_time", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void endProvidesItsFixedVanillaClock() {
|
||||
JSONObject json = fixed(Dimension.END);
|
||||
|
||||
assertEquals("minecraft:the_end", json.getString("default_clock"));
|
||||
assertTrue(json.getBoolean("has_fixed_time"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void netherRemainsFixedWithoutADefaultClock() {
|
||||
JSONObject json = fixed(Dimension.NETHER);
|
||||
|
||||
assertFalse(json.has("default_clock"));
|
||||
assertTrue(json.getBoolean("has_fixed_time"));
|
||||
}
|
||||
|
||||
private JSONObject fixed(Dimension dimension) {
|
||||
DataFixerV1217 fixer = new DataFixerV1217();
|
||||
JSONObject json = fixer.resolve(dimension, null);
|
||||
fixer.fixDimension(dimension, json);
|
||||
return json;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
|
||||
public class PackDirectoryResolverTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void resolvesOnlyExistingDirectChildren() throws Exception {
|
||||
File packs = temporaryFolder.newFolder("packs");
|
||||
File overworld = new File(packs, "overworld");
|
||||
Files.createDirectory(overworld.toPath());
|
||||
|
||||
assertEquals(overworld.getAbsoluteFile(), PackDirectoryResolver.resolveExisting(packs, "overworld"));
|
||||
assertEquals(overworld.getAbsoluteFile(), PackDirectoryResolver.resolveExisting(packs, "./overworld"));
|
||||
assertNull(PackDirectoryResolver.resolveExisting(packs, "missing"));
|
||||
assertNull(PackDirectoryResolver.resolveExisting(packs, ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsTraversalAbsoluteAndNestedPaths() throws Exception {
|
||||
File packs = temporaryFolder.newFolder("pack-root");
|
||||
File outside = temporaryFolder.newFolder("outside");
|
||||
File nested = new File(packs, "nested/pack");
|
||||
Files.createDirectories(nested.toPath());
|
||||
|
||||
assertNull(PackDirectoryResolver.resolveExisting(packs, "../outside"));
|
||||
assertNull(PackDirectoryResolver.resolveExisting(packs, outside.getAbsolutePath()));
|
||||
assertNull(PackDirectoryResolver.resolveExisting(packs, "nested/pack"));
|
||||
assertNull(PackDirectoryResolver.resolveExisting(packs, "."));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymbolicLinkChildren() throws Exception {
|
||||
File packs = temporaryFolder.newFolder("symlink-root");
|
||||
File outside = temporaryFolder.newFolder("symlink-target");
|
||||
Path link = new File(packs, "linked").toPath();
|
||||
try {
|
||||
Files.createSymbolicLink(link, outside.toPath());
|
||||
} catch (IOException | UnsupportedOperationException | SecurityException e) {
|
||||
Assume.assumeNoException(e);
|
||||
}
|
||||
|
||||
assertNull(PackDirectoryResolver.resolveExisting(packs, "linked"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class PackDownloaderTest {
|
||||
@Test
|
||||
public void resolvesBranchReference() {
|
||||
assertEquals(
|
||||
"https://codeload.github.com/IrisDimensions/overworld/zip/refs/heads/feature/release",
|
||||
PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "feature/release")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesQualifiedHeadReference() {
|
||||
assertEquals(
|
||||
"https://codeload.github.com/IrisDimensions/overworld/zip/refs/heads/master",
|
||||
PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/heads/master")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesTagReference() {
|
||||
assertEquals(
|
||||
"https://codeload.github.com/IrisDimensions/overworld/zip/refs/tags/v4.0.0",
|
||||
PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/tags/v4.0.0")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesCommitReference() {
|
||||
assertEquals(
|
||||
"https://github.com/IrisDimensions/overworld/archive/8e32852ee6ecd039fae27a36f701f57cdc02e83f.zip",
|
||||
PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "8e32852ee6ecd039fae27a36f701f57cdc02e83f")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnsafeRepositoryAndReference() {
|
||||
assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld?raw=1", "master"));
|
||||
assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("../overworld", "master"));
|
||||
assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/heads/../master"));
|
||||
assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/pull/123/head"));
|
||||
assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.Assume;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackResourceCleanupTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void previewIsReadOnlyAndReturnsSortedCandidates() throws Exception {
|
||||
File pack = createPack("preview");
|
||||
write(pack, "dimensions/main.json", "{\"biome\":\"nested/used\"}");
|
||||
write(pack, "biomes/z-last.json", "{}");
|
||||
write(pack, "biomes/a-first.json", "{}");
|
||||
write(pack, "biomes/nested/used.json", "{}");
|
||||
|
||||
PackResourceCleanup.Preview preview = PackResourceCleanup.preview(pack);
|
||||
|
||||
assertTrue(preview.success());
|
||||
assertTrue(preview.hasCandidates());
|
||||
assertEquals(List.of("biomes/a-first.json", "biomes/z-last.json"), preview.candidatePaths());
|
||||
assertFalse(new File(pack, ".iris-trash").exists());
|
||||
assertTrue(new File(pack, "biomes/a-first.json").isFile());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyRunsFreshScanBeforeMutating() throws Exception {
|
||||
File pack = createPack("fresh-scan");
|
||||
write(pack, "dimensions/main.json", "{}");
|
||||
write(pack, "biomes/kept.json", "{}");
|
||||
|
||||
PackResourceCleanup.Preview preview = PackResourceCleanup.preview(pack);
|
||||
assertEquals(List.of("biomes/kept.json"), preview.candidatePaths());
|
||||
|
||||
write(pack, "dimensions/main.json", "{\"biome\":\"kept\"}");
|
||||
PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(pack);
|
||||
|
||||
assertTrue(result.success());
|
||||
assertFalse(result.changed());
|
||||
assertNull(result.quarantinePath());
|
||||
assertTrue(new File(pack, "biomes/kept.json").isFile());
|
||||
assertFalse(new File(pack, ".iris-trash").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyCreatesUniqueDumpsAndPreservesRelativePaths() throws Exception {
|
||||
File pack = createPack("unique-dumps");
|
||||
write(pack, "dimensions/main.json", "{}");
|
||||
write(pack, "biomes/nested/first.json", "{\"value\":1}");
|
||||
|
||||
PackResourceCleanup.ApplyResult first = PackResourceCleanup.apply(pack);
|
||||
assertTrue(first.success());
|
||||
assertEquals(List.of("biomes/nested/first.json"), first.quarantinedPaths());
|
||||
assertTrue(new File(pack, first.quarantinePath() + "/biomes/nested/first.json").isFile());
|
||||
|
||||
write(pack, "biomes/nested/second.json", "{\"value\":2}");
|
||||
PackResourceCleanup.ApplyResult second = PackResourceCleanup.apply(pack);
|
||||
|
||||
assertTrue(second.success());
|
||||
assertNotEquals(first.quarantinePath(), second.quarantinePath());
|
||||
assertTrue(new File(pack, first.quarantinePath() + "/biomes/nested/first.json").isFile());
|
||||
assertTrue(new File(pack, second.quarantinePath() + "/biomes/nested/second.json").isFile());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restorePreviewReportsLatestDumpAndAllConflictsWithoutMutation() throws Exception {
|
||||
File pack = createPack("restore-conflict");
|
||||
write(pack, "dimensions/main.json", "{}");
|
||||
write(pack, "biomes/first.json", "{\"value\":1}");
|
||||
PackResourceCleanup.ApplyResult first = PackResourceCleanup.apply(pack);
|
||||
write(pack, "biomes/second.json", "{\"value\":2}");
|
||||
PackResourceCleanup.ApplyResult second = PackResourceCleanup.apply(pack);
|
||||
write(pack, "biomes/second.json", "{\"replacement\":true}");
|
||||
|
||||
PackResourceCleanup.RestorePreview preview = PackResourceCleanup.previewRestore(pack);
|
||||
|
||||
assertTrue(preview.success());
|
||||
assertEquals(second.quarantinePath(), preview.dumpPath());
|
||||
assertEquals(List.of("biomes/second.json"), preview.filePaths());
|
||||
assertEquals(List.of("biomes/second.json"), preview.conflicts());
|
||||
assertTrue(preview.hasConflicts());
|
||||
assertFalse(preview.canRestore());
|
||||
assertTrue(new File(pack, second.quarantinePath() + "/biomes/second.json").isFile());
|
||||
assertTrue(new File(pack, first.quarantinePath() + "/biomes/first.json").isFile());
|
||||
|
||||
PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack);
|
||||
|
||||
assertFalse(result.success());
|
||||
assertEquals(List.of("biomes/second.json"), result.conflicts());
|
||||
assertTrue(new File(pack, second.quarantinePath() + "/biomes/second.json").isFile());
|
||||
assertEquals("{\"replacement\":true}", read(pack, "biomes/second.json"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restoreLatestRestoresOnlyNewestDump() throws Exception {
|
||||
File pack = createPack("restore-latest");
|
||||
write(pack, "dimensions/main.json", "{}");
|
||||
write(pack, "biomes/first.json", "{\"value\":1}");
|
||||
PackResourceCleanup.ApplyResult first = PackResourceCleanup.apply(pack);
|
||||
write(pack, "biomes/second.json", "{\"value\":2}");
|
||||
PackResourceCleanup.ApplyResult second = PackResourceCleanup.apply(pack);
|
||||
|
||||
PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack);
|
||||
|
||||
assertTrue(result.success());
|
||||
assertTrue(result.changed());
|
||||
assertEquals(second.quarantinePath(), result.dumpPath());
|
||||
assertEquals(List.of("biomes/second.json"), result.restoredPaths());
|
||||
assertEquals("{\"value\":2}", read(pack, "biomes/second.json"));
|
||||
assertFalse(new File(pack, "biomes/first.json").exists());
|
||||
assertTrue(new File(pack, first.quarantinePath() + "/biomes/first.json").isFile());
|
||||
assertFalse(new File(pack, second.quarantinePath()).exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restoreCopyFailureRollsBackCreatedDestinations() throws Exception {
|
||||
File pack = createPack("restore-rollback");
|
||||
write(pack, "dimensions/main.json", "{}");
|
||||
write(pack, "biomes/a-readable.json", "{\"value\":1}");
|
||||
write(pack, "biomes/z-unreadable.json", "{\"value\":2}");
|
||||
PackResourceCleanup.ApplyResult applied = PackResourceCleanup.apply(pack);
|
||||
Path unreadable = new File(pack, applied.quarantinePath() + "/biomes/z-unreadable.json").toPath();
|
||||
Assume.assumeTrue(Files.getFileStore(unreadable).supportsFileAttributeView("posix"));
|
||||
Set<PosixFilePermission> originalPermissions = Files.getPosixFilePermissions(unreadable);
|
||||
Files.setPosixFilePermissions(unreadable, Set.of(PosixFilePermission.OWNER_WRITE));
|
||||
try {
|
||||
PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack);
|
||||
|
||||
assertFalse(result.success());
|
||||
assertTrue(result.error().contains("rolled back"));
|
||||
assertFalse(new File(pack, "biomes/a-readable.json").exists());
|
||||
assertFalse(new File(pack, "biomes/z-unreadable.json").exists());
|
||||
assertTrue(new File(pack, applied.quarantinePath() + "/biomes/a-readable.json").isFile());
|
||||
assertTrue(Files.exists(unreadable));
|
||||
} finally {
|
||||
Files.setPosixFilePermissions(unreadable, originalPermissions);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restoreSourceRemovalFailureRollsBackDestinationsAndPreservesDump() throws Exception {
|
||||
File pack = createPack("restore-delete-rollback");
|
||||
write(pack, "dimensions/main.json", "{}");
|
||||
write(pack, "biomes/a.json", "{\"value\":1}");
|
||||
write(pack, "biomes/b.json", "{\"value\":2}");
|
||||
PackResourceCleanup.ApplyResult applied = PackResourceCleanup.apply(pack);
|
||||
Path quarantineBiomes = new File(pack, applied.quarantinePath() + "/biomes").toPath();
|
||||
Assume.assumeTrue(Files.getFileStore(quarantineBiomes).supportsFileAttributeView("posix"));
|
||||
Set<PosixFilePermission> originalPermissions = Files.getPosixFilePermissions(quarantineBiomes);
|
||||
Files.setPosixFilePermissions(quarantineBiomes, Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_EXECUTE));
|
||||
try {
|
||||
PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack);
|
||||
|
||||
assertFalse(result.success());
|
||||
assertTrue(result.error().contains("source removal failed"));
|
||||
assertFalse(new File(pack, "biomes/a.json").exists());
|
||||
assertFalse(new File(pack, "biomes/b.json").exists());
|
||||
assertTrue(new File(pack, applied.quarantinePath() + "/biomes/a.json").isFile());
|
||||
assertTrue(new File(pack, applied.quarantinePath() + "/biomes/b.json").isFile());
|
||||
} finally {
|
||||
Files.setPosixFilePermissions(quarantineBiomes, originalPermissions);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cleanupFailsClosedWhenManagedContentContainsSymbolicLink() throws Exception {
|
||||
File pack = createPack("symlink");
|
||||
write(pack, "dimensions/main.json", "{}");
|
||||
File outside = temporaryFolder.newFile("outside.json");
|
||||
Path link = new File(pack, "biomes/linked.json").toPath();
|
||||
Files.createDirectories(link.getParent());
|
||||
try {
|
||||
Files.createSymbolicLink(link, outside.toPath());
|
||||
} catch (IOException | UnsupportedOperationException | SecurityException e) {
|
||||
Assume.assumeNoException(e);
|
||||
}
|
||||
|
||||
PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(pack);
|
||||
|
||||
assertFalse(result.success());
|
||||
assertFalse(result.changed());
|
||||
assertTrue(Files.isSymbolicLink(link));
|
||||
assertFalse(new File(pack, ".iris-trash").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentAppliesSerializePerPack() throws Exception {
|
||||
File pack = createPack("concurrent");
|
||||
write(pack, "dimensions/main.json", "{}");
|
||||
write(pack, "biomes/unused.json", "{}");
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<PackResourceCleanup.ApplyResult> first = executor.submit(() -> {
|
||||
start.await();
|
||||
return PackResourceCleanup.apply(pack);
|
||||
});
|
||||
Future<PackResourceCleanup.ApplyResult> second = executor.submit(() -> {
|
||||
start.await();
|
||||
return PackResourceCleanup.apply(pack);
|
||||
});
|
||||
start.countDown();
|
||||
|
||||
List<PackResourceCleanup.ApplyResult> results = List.of(first.get(), second.get());
|
||||
assertTrue(results.stream().allMatch(PackResourceCleanup.ApplyResult::success));
|
||||
assertEquals(1L, results.stream().filter(PackResourceCleanup.ApplyResult::changed).count());
|
||||
assertFalse(new File(pack, "biomes/unused.json").exists());
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private File createPack(String name) throws Exception {
|
||||
return temporaryFolder.newFolder(name);
|
||||
}
|
||||
|
||||
private void write(File pack, String relativePath, String content) throws Exception {
|
||||
Path path = new File(pack, relativePath).toPath();
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private String read(File pack, String relativePath) throws Exception {
|
||||
return Files.readString(new File(pack, relativePath).toPath(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackValidatorCustomBiomeSpawnTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void acceptsExplicitMatchingSpawnGroup() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\",\"group\":\"MONSTER\"}]}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(
|
||||
biomes, key -> PackValidator.SpawnCategoryResolution.known("monster"));
|
||||
|
||||
assertTrue(errors.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsBiomeWithoutCustomSpawns() throws Exception {
|
||||
File biomes = createBiomes("{\"name\":\"Plains\"}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(
|
||||
biomes, key -> PackValidator.SpawnCategoryResolution.known("monster"));
|
||||
|
||||
assertTrue(errors.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMissingSpawnGroup() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\"}]}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(
|
||||
biomes, key -> PackValidator.SpawnCategoryResolution.known("monster"));
|
||||
|
||||
assertEquals(1, errors.size());
|
||||
assertTrue(errors.get(0).contains("must declare group 'MONSTER'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsImplicitMiscSpawnGroup() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"effects\",\"spawns\":[{\"type\":\"minecraft:armor_stand\"}]}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(
|
||||
biomes, key -> PackValidator.SpawnCategoryResolution.known("misc"));
|
||||
|
||||
assertTrue(errors.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSpawnGroupThatDisagreesWithRegistry() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\",\"group\":\"MISC\"}]}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(
|
||||
biomes, key -> PackValidator.SpawnCategoryResolution.known("monster"));
|
||||
|
||||
assertEquals(1, errors.size());
|
||||
assertTrue(errors.get(0).contains("live entity registry requires 'MONSTER'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsAxolotlSpawnCategory() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"cave\",\"spawns\":[{\"type\":\"minecraft:axolotl\",\"group\":\"AXOLOTLS\"}]}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(
|
||||
biomes, key -> PackValidator.SpawnCategoryResolution.known("axolotls"));
|
||||
|
||||
assertTrue(errors.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnknownSpawnEntity() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"missing:entity\",\"group\":\"MISC\"}]}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(
|
||||
biomes, key -> PackValidator.SpawnCategoryResolution.unknown());
|
||||
|
||||
assertEquals(1, errors.size());
|
||||
assertTrue(errors.get(0).contains("unknown entity type 'missing:entity'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsCaseNormalizedGroupThatRuntimeWouldNotParse() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\",\"group\":\"monster\"}]}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(
|
||||
biomes, key -> PackValidator.SpawnCategoryResolution.known("monster"));
|
||||
|
||||
assertEquals(1, errors.size());
|
||||
assertTrue(errors.get(0).contains("unknown group 'monster'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsWrongCustomDerivativeContainerType() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":{}}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, null);
|
||||
|
||||
assertEquals(1, errors.size());
|
||||
assertTrue(errors.get(0).contains("customDerivitives must be an array"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsWrongSpawnContainerType() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":{}}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, null);
|
||||
|
||||
assertEquals(1, errors.size());
|
||||
assertTrue(errors.get(0).contains("spawns must be an array"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsNullCustomDerivativeContainerAsAbsent() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":null}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, null);
|
||||
|
||||
assertTrue(errors.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsNullSpawnContainerAsAbsent() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":null}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, null);
|
||||
|
||||
assertTrue(errors.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsNamespacedCustomBiomeTags() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"tags\":[\"minecraft:allows_surface_slime_spawns\"]}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, null);
|
||||
|
||||
assertTrue(errors.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnsafeCustomBiomeTags() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"tags\":[\"minecraft:../outside\"]}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, null);
|
||||
|
||||
assertEquals(1, errors.size());
|
||||
assertTrue(errors.get(0).contains("invalid tag"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void convertsSpawnCategoryResolverFailureIntoBlockingError() throws Exception {
|
||||
File biomes = createBiomes("{\"customDerivitives\":[{\"id\":\"swamp\",\"spawns\":[{\"type\":\"minecraft:slime\",\"group\":\"MONSTER\"}]}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateCustomBiomeSpawns(biomes, key -> {
|
||||
throw new IllegalStateException("registry unavailable");
|
||||
});
|
||||
|
||||
assertEquals(1, errors.size());
|
||||
assertTrue(errors.get(0).contains("spawn category lookup failed"));
|
||||
assertTrue(errors.get(0).contains("registry unavailable"));
|
||||
}
|
||||
|
||||
private File createBiomes(String json) throws Exception {
|
||||
File biomes = temporaryFolder.newFolder("biomes");
|
||||
File biome = new File(biomes, "test.json");
|
||||
Files.writeString(biome.toPath(), json, StandardCharsets.UTF_8);
|
||||
return biomes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackValidatorReadOnlyTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void validationDoesNotMoveOrRewritePackFiles() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "dimensions/main.json", "{\"regions\":[\"region\"]}");
|
||||
write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}");
|
||||
write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}");
|
||||
write(pack, "generators/unused.json", "{\"name\":\"Unused\"}");
|
||||
Map<String, byte[]> before = snapshot(pack.toPath());
|
||||
|
||||
PackValidationResult result = PackValidator.validate(pack);
|
||||
|
||||
assertTrue(result.isLoadable());
|
||||
assertEquals(before.keySet(), snapshot(pack.toPath()).keySet());
|
||||
Map<String, byte[]> after = snapshot(pack.toPath());
|
||||
for (Map.Entry<String, byte[]> entry : before.entrySet()) {
|
||||
assertArrayEquals(entry.getValue(), after.get(entry.getKey()));
|
||||
}
|
||||
assertFalse(new File(pack, ".iris-trash").exists());
|
||||
}
|
||||
|
||||
private void write(File root, String relative, String content) throws Exception {
|
||||
Path path = root.toPath().resolve(relative);
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private Map<String, byte[]> snapshot(Path root) throws Exception {
|
||||
Map<String, byte[]> files = new LinkedHashMap<>();
|
||||
try (Stream<Path> stream = Files.walk(root)) {
|
||||
for (Path path : stream.filter(Files::isRegularFile).sorted().toList()) {
|
||||
files.put(root.relativize(path).toString(), Files.readAllBytes(path));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackValidatorSpawnerEntityTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void acceptsNestedEntitiesReferencedByBothSpawnLists() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "spawners/frozen/passive.json", """
|
||||
{
|
||||
"spawns": [{"entity": "standard/passive/cod"}],
|
||||
"initialSpawns": [{"entity": "standard/passive/camel"}]
|
||||
}
|
||||
""");
|
||||
write(pack, "entities/standard/passive/cod.json", "{\"type\":\"COD\",\"surface\":\"WATER\"}");
|
||||
write(pack, "entities/standard/passive/camel.json", "{\"type\":\"CAMEL\"}");
|
||||
|
||||
List<String> errors = validate(pack);
|
||||
|
||||
assertTrue(errors.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMalformedContainersAndEntries() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "spawners/broken.json", """
|
||||
{
|
||||
"spawns": null,
|
||||
"initialSpawns": [7, {}, {"entity": 9}, {"entity": " "}]
|
||||
}
|
||||
""");
|
||||
|
||||
List<String> errors = validate(pack);
|
||||
|
||||
assertEquals(5, errors.size());
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("spawns must be an array")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("non-object entry")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("without an entity reference")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("must be a string")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("blank entity reference")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMissingAndMalformedReferencedEntities() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "spawners/broken.json", """
|
||||
{
|
||||
"spawns": [
|
||||
{"entity": "standard/hostile/missing"},
|
||||
{"entity": "standard/hostile/broken"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
write(pack, "entities/standard/hostile/broken.json", "not-json");
|
||||
|
||||
List<String> errors = validate(pack);
|
||||
|
||||
assertEquals(2, errors.size());
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("references missing entity 'standard/hostile/missing'")));
|
||||
assertTrue(errors.stream().anyMatch(error -> error.contains("references malformed entity 'standard/hostile/broken'")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMalformedSpawnerJson() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "spawners/broken.json", "not-json");
|
||||
|
||||
List<String> errors = validate(pack);
|
||||
|
||||
assertEquals(1, errors.size());
|
||||
assertTrue(errors.get(0).contains("Spawner 'broken' has invalid JSON"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsEntityReferenceThatEscapesPack() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "spawners/broken.json", "{\"spawns\":[{\"entity\":\"../outside\"}]}");
|
||||
write(pack, "outside.json", "{\"type\":\"COW\"}");
|
||||
|
||||
List<String> errors = validate(pack);
|
||||
|
||||
assertEquals(1, errors.size());
|
||||
assertTrue(errors.get(0).contains("unsafe entity reference '../outside'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ignoresMalformedEntityThatNoSpawnerReferences() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "spawners/passive.json", "{\"spawns\":[{\"entity\":\"standard/passive/cow\"}]}");
|
||||
write(pack, "entities/standard/passive/cow.json", "{\"type\":\"COW\"}");
|
||||
write(pack, "entities/unique/unused.json", "not-json");
|
||||
|
||||
List<String> errors = validate(pack);
|
||||
|
||||
assertTrue(errors.isEmpty());
|
||||
}
|
||||
|
||||
private List<String> validate(File pack) {
|
||||
return PackValidator.validateSpawnerEntityReferences(
|
||||
new File(pack, "spawners"), new File(pack, "entities"));
|
||||
}
|
||||
|
||||
private void write(File root, String relative, String content) throws Exception {
|
||||
Path path = root.toPath().resolve(relative);
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackValidatorStructureTransformTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void reportsUnsupportedTransformsAcrossEveryStructureHost() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "dimensions/main.json", "{\"structures\":[{\"structures\":[\"test\"],\"rotation\":{}}]}");
|
||||
write(pack, "regions/nested/region.json", "{\"structures\":[{\"structures\":[\"test\"],\"translate\":{}}]}");
|
||||
write(pack, "biomes/nested/biome.json", "{\"structures\":[{\"structures\":[\"test\"],\"scale\":{}}]}");
|
||||
|
||||
List<String> errors = PackValidator.validateUnsupportedStructureTransforms(pack);
|
||||
|
||||
assertEquals(List.of(
|
||||
"Dimension 'main' structures[0] declares unsupported field 'rotation'. Structure placement transforms are not supported; remove the field.",
|
||||
"Region 'nested/region' structures[0] declares unsupported field 'translate'. Structure placement transforms are not supported; remove the field.",
|
||||
"Biome 'nested/biome' structures[0] declares unsupported field 'scale'. Structure placement transforms are not supported; remove the field."
|
||||
), errors);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ignoresTransformsOutsideStructurePlacementArrays() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "biomes/biome.json", "{\"objects\":[{\"rotation\":{},\"translate\":{},\"scale\":{}}],\"structures\":[{\"structures\":[\"test\"]}]}");
|
||||
|
||||
assertTrue(PackValidator.validateUnsupportedStructureTransforms(pack).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unsupportedTransformBlocksFullPackValidation() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("pack");
|
||||
write(pack, "dimensions/main.json", "{\"regions\":[\"region\"],\"structures\":[{\"structures\":[\"test\"],\"rotation\":null}]}");
|
||||
write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}");
|
||||
write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}");
|
||||
|
||||
PackValidationResult result = PackValidator.validate(pack);
|
||||
|
||||
assertFalse(result.isLoadable());
|
||||
assertTrue(result.getBlockingErrors().contains(
|
||||
"Dimension 'main' structures[0] declares unsupported field 'rotation'. Structure placement transforms are not supported; remove the field."));
|
||||
}
|
||||
|
||||
private void write(File root, String relative, String content) throws Exception {
|
||||
Path path = root.toPath().resolve(relative);
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import java.io.PrintStream;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisPregeneratorInitTest {
|
||||
@@ -53,6 +55,35 @@ public class IrisPregeneratorInitTest {
|
||||
assertTrue(output.contains("Pregen finished: generated=8 total=9 failed=1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancellationSummaryDoesNotReportPartialRunAsFinished() {
|
||||
IrisSettings previousSettings = IrisSettings.settings;
|
||||
PrintStream previousOut = System.out;
|
||||
IrisSettings.settings = new IrisSettings();
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8));
|
||||
AtomicReference<IrisPregenerator> pregeneratorReference = new AtomicReference<>();
|
||||
CancelAfterFirstChunkMethod method = new CancelAfterFirstChunkMethod(pregeneratorReference);
|
||||
PregenTask task = PregenTask.builder()
|
||||
.center(new Position2(0, 0))
|
||||
.radiusX(1)
|
||||
.radiusZ(1)
|
||||
.build();
|
||||
try {
|
||||
IrisPregenerator pregenerator = new IrisPregenerator(task, method, new NoOpPregenListener());
|
||||
pregeneratorReference.set(pregenerator);
|
||||
|
||||
pregenerator.start();
|
||||
|
||||
String summary = output.toString(StandardCharsets.UTF_8);
|
||||
assertTrue(summary.contains("Pregen cancelled: generated=1 total=9 failed=0 remaining=8"));
|
||||
assertFalse(summary.contains("Pregen finished:"));
|
||||
} finally {
|
||||
System.setOut(previousOut);
|
||||
IrisSettings.settings = previousSettings;
|
||||
}
|
||||
}
|
||||
|
||||
private String runCompletionOnClose(boolean failLast) {
|
||||
IrisSettings previousSettings = IrisSettings.settings;
|
||||
PrintStream previousOut = System.out;
|
||||
@@ -173,6 +204,55 @@ public class IrisPregeneratorInitTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CancelAfterFirstChunkMethod implements PregeneratorMethod {
|
||||
private final AtomicReference<IrisPregenerator> pregeneratorReference;
|
||||
private boolean cancelled;
|
||||
|
||||
private CancelAfterFirstChunkMethod(AtomicReference<IrisPregenerator> pregeneratorReference) {
|
||||
this.pregeneratorReference = pregeneratorReference;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsRegions(int x, int z, PregenListener listener) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMethod(int x, int z) {
|
||||
return "test";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void generateRegion(int x, int z, PregenListener listener) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void generateChunk(int x, int z, PregenListener listener) {
|
||||
listener.onChunkGenerated(x, z);
|
||||
if (!cancelled) {
|
||||
cancelled = true;
|
||||
pregeneratorReference.get().close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mantle getMantle() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NoOpPregenListener implements PregenListener {
|
||||
@Override
|
||||
public void onTick(double chunksPerSecond, double chunksPerMinute, double regionsPerMinute, double percent, long generated, long totalChunks, long chunksRemaining, long eta, long elapsed, String method, boolean cached) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package art.arcane.iris.core.project;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisEntitySpawn;
|
||||
import art.arcane.iris.engine.object.IrisSpawner;
|
||||
import art.arcane.volmlib.util.collection.KSet;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisProjectEntityDependencyTest {
|
||||
@Test
|
||||
public void collectsInitialOnlyEntityAlongsideNormalSpawnEntityForExport() {
|
||||
IrisSpawner spawner = new IrisSpawner();
|
||||
spawner.getSpawns().add(new IrisEntitySpawn().setEntity("standard/passive/cow"));
|
||||
spawner.getInitialSpawns().add(new IrisEntitySpawn().setEntity("beta/sentinel"));
|
||||
KSet<IrisSpawner> spawners = new KSet<>();
|
||||
spawners.add(spawner);
|
||||
|
||||
KSet<String> entityKeys = IrisProject.collectSpawnerEntityKeys(spawners);
|
||||
|
||||
assertEquals(2, entityKeys.size());
|
||||
assertTrue(entityKeys.contains("standard/passive/cow"));
|
||||
assertTrue(entityKeys.contains("beta/sentinel"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void returnsNoEntityDependenciesForEmptySpawnerSet() {
|
||||
KSet<String> entityKeys = IrisProject.collectSpawnerEntityKeys(new KSet<>());
|
||||
|
||||
assertTrue(entityKeys.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
package art.arcane.iris.core.project;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.engine.object.IrisJigsawPiece;
|
||||
import art.arcane.iris.engine.object.IrisStructure;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.iris.engine.object.annotations.Desc;
|
||||
import art.arcane.iris.engine.object.annotations.MaxNumber;
|
||||
import art.arcane.iris.engine.object.annotations.MinNumber;
|
||||
@@ -50,6 +54,10 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class SchemaBuilderParityTest {
|
||||
private static final List<String> POTION_KEYS = List.of("minecraft:speed", "minecraft:slow_falling", "sniffer_mod:mega_boost");
|
||||
@@ -108,6 +116,26 @@ public class SchemaBuilderParityTest {
|
||||
assertEquals(List.of("ALPHA", "BETA"), enumValues(schema.getJSONObject("definitions"), flavorDefinitionKey()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void structurePlacementSchemaOmitsUnsupportedTransforms() {
|
||||
IrisData data = mock(IrisData.class);
|
||||
ResourceLoader<IrisStructure> structureLoader = mock(ResourceLoader.class);
|
||||
ResourceLoader<IrisJigsawPiece> pieceLoader = mock(ResourceLoader.class);
|
||||
when(data.getStructureLoader()).thenReturn(structureLoader);
|
||||
when(data.getJigsawPieceLoader()).thenReturn(pieceLoader);
|
||||
when(structureLoader.getPossibleKeys()).thenReturn(new String[0]);
|
||||
when(pieceLoader.getPossibleKeys()).thenReturn(new String[0]);
|
||||
|
||||
JSONObject properties = new SchemaBuilder(IrisStructurePlacement.class, data)
|
||||
.construct().getJSONObject("properties");
|
||||
|
||||
assertTrue(properties.has("structures"));
|
||||
assertTrue(properties.has("distribution"));
|
||||
assertFalse(properties.has("rotation"));
|
||||
assertFalse(properties.has("translate"));
|
||||
assertFalse(properties.has("scale"));
|
||||
}
|
||||
|
||||
private static String flavorDefinitionKey() {
|
||||
return "enum-" + Flavor.class.getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package art.arcane.iris.core.tools;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisToolbeltPackReferenceTest {
|
||||
@Test
|
||||
public void plainPackUsesMatchingDimensionKey() {
|
||||
IrisToolbelt.PackReference reference = IrisToolbelt.parsePackReference("overworld");
|
||||
|
||||
assertEquals("overworld", reference.pack());
|
||||
assertEquals("overworld", reference.dimension());
|
||||
assertFalse(reference.explicitDimension());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitDimensionKeepsPackAndDimensionSeparate() {
|
||||
IrisToolbelt.PackReference reference = IrisToolbelt.parsePackReference(" custom_pack : dimensions/sky ");
|
||||
|
||||
assertEquals("custom_pack", reference.pack());
|
||||
assertEquals("dimensions/sky", reference.dimension());
|
||||
assertTrue(reference.explicitDimension());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedReferencesAreRejected() {
|
||||
assertNull(IrisToolbelt.parsePackReference(null));
|
||||
assertNull(IrisToolbelt.parsePackReference(""));
|
||||
assertNull(IrisToolbelt.parsePackReference(":"));
|
||||
assertNull(IrisToolbelt.parsePackReference("pack:"));
|
||||
assertNull(IrisToolbelt.parsePackReference(":dimension"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisGenerator;
|
||||
import art.arcane.iris.engine.object.IrisInterpolator;
|
||||
import art.arcane.iris.util.project.interpolation.IrisInterpolation.NoiseBounds;
|
||||
import art.arcane.iris.util.project.interpolation.IrisInterpolation.NoiseBoundsProvider;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.mockito.Answers.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class IrisComplexGridBoundsCacheTest {
|
||||
@Test
|
||||
public void gridBoundsCacheIsIsolatedPerComplex() throws Exception {
|
||||
IrisComplex first = createComplex();
|
||||
IrisComplex second = createComplex();
|
||||
Method cornerBounds = cornerBoundsMethod();
|
||||
|
||||
long firstPacked = invokeCornerBounds(first, cornerBounds, new CountingInterpolator(1.25D, 2.5D), 64, -32);
|
||||
long secondPacked = invokeCornerBounds(second, cornerBounds, new CountingInterpolator(10.25D, 20.5D), 64, -32);
|
||||
|
||||
assertNotEquals(firstPacked, secondPacked);
|
||||
assertEquals(1.25F, unpackLow(firstPacked), 0D);
|
||||
assertEquals(2.5F, unpackHigh(firstPacked), 0D);
|
||||
assertEquals(10.25F, unpackLow(secondPacked), 0D);
|
||||
assertEquals(20.5F, unpackHigh(secondPacked), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void gridBoundsCacheReusesCornersWithinSameComplex() throws Exception {
|
||||
IrisComplex complex = createComplex();
|
||||
Method cornerBounds = cornerBoundsMethod();
|
||||
CountingInterpolator interpolator = new CountingInterpolator(3.5D, 7.25D);
|
||||
|
||||
long firstPacked = invokeCornerBounds(complex, cornerBounds, interpolator, 128, 96);
|
||||
long secondPacked = invokeCornerBounds(complex, cornerBounds, interpolator, 128, 96);
|
||||
|
||||
assertEquals(firstPacked, secondPacked);
|
||||
assertEquals(1, interpolator.getInvocations());
|
||||
}
|
||||
|
||||
private IrisComplex createComplex() throws Exception {
|
||||
IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS);
|
||||
|
||||
Field generatorBounds = IrisComplex.class.getDeclaredField("generatorBounds");
|
||||
generatorBounds.setAccessible(true);
|
||||
generatorBounds.set(complex, new HashMap<>());
|
||||
|
||||
Class<?> cacheClass = Class.forName("art.arcane.iris.engine.IrisComplex$GridBoundsCache");
|
||||
Constructor<?> cacheConstructor = cacheClass.getDeclaredConstructor();
|
||||
cacheConstructor.setAccessible(true);
|
||||
ThreadLocal<Object> cache = ThreadLocal.withInitial(() -> newCache(cacheConstructor));
|
||||
Field gridBoundsCache = IrisComplex.class.getDeclaredField("gridBoundsCache");
|
||||
gridBoundsCache.setAccessible(true);
|
||||
gridBoundsCache.set(complex, cache);
|
||||
return complex;
|
||||
}
|
||||
|
||||
private Object newCache(Constructor<?> constructor) {
|
||||
try {
|
||||
return constructor.newInstance();
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Method cornerBoundsMethod() throws Exception {
|
||||
Class<?> cacheClass = Class.forName("art.arcane.iris.engine.IrisComplex$GridBoundsCache");
|
||||
Method method = IrisComplex.class.getDeclaredMethod(
|
||||
"cornerBounds",
|
||||
cacheClass,
|
||||
Engine.class,
|
||||
IrisInterpolator.class,
|
||||
int.class,
|
||||
Set.class,
|
||||
int.class,
|
||||
int.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
}
|
||||
|
||||
private long invokeCornerBounds(IrisComplex complex, Method method, IrisInterpolator interpolator, int x, int z) throws Exception {
|
||||
Field gridBoundsCache = IrisComplex.class.getDeclaredField("gridBoundsCache");
|
||||
gridBoundsCache.setAccessible(true);
|
||||
ThreadLocal<?> cache = (ThreadLocal<?>) gridBoundsCache.get(complex);
|
||||
return (long) method.invoke(complex, cache.get(), null, interpolator, 0, Set.<IrisGenerator>of(), x, z);
|
||||
}
|
||||
|
||||
private float unpackLow(long packed) {
|
||||
return Float.intBitsToFloat((int) (packed >>> 32));
|
||||
}
|
||||
|
||||
private float unpackHigh(long packed) {
|
||||
return Float.intBitsToFloat((int) packed);
|
||||
}
|
||||
|
||||
private static final class CountingInterpolator extends IrisInterpolator {
|
||||
private final NoiseBounds bounds;
|
||||
private final AtomicInteger invocations = new AtomicInteger();
|
||||
|
||||
private CountingInterpolator(double low, double high) {
|
||||
bounds = new NoiseBounds(low, high);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) {
|
||||
invocations.incrementAndGet();
|
||||
return bounds;
|
||||
}
|
||||
|
||||
private int getInvocations() {
|
||||
return invocations.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package art.arcane.iris.engine.actuator;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineMetrics;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformBiome;
|
||||
import art.arcane.iris.spi.PlatformBiomeWriter;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.iris.util.project.context.ChunkedDataCache;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisBiomeActuatorCoordinateTest {
|
||||
@After
|
||||
public void unbindPlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void derivativeScatterUsesEachWorldColumnCoordinate() {
|
||||
bindPlatform();
|
||||
Engine engine = engine();
|
||||
IrisBiomeActuator actuator = new IrisBiomeActuator(engine);
|
||||
Hunk<PlatformBiome> output = mock(Hunk.class);
|
||||
when(output.getWidth()).thenReturn(2);
|
||||
when(output.getDepth()).thenReturn(2);
|
||||
when(output.getHeight()).thenReturn(1);
|
||||
|
||||
List<String> samples = new ArrayList<>();
|
||||
IrisBiome biome = mock(IrisBiome.class);
|
||||
when(biome.getSkyBiomeKey(any(RNG.class), same(engine), anyDouble(), anyDouble(), anyDouble()))
|
||||
.thenAnswer(invocation -> {
|
||||
double worldX = invocation.getArgument(2);
|
||||
double worldZ = invocation.getArgument(4);
|
||||
samples.add((int) worldX + "," + (int) worldZ);
|
||||
return "minecraft:plains";
|
||||
});
|
||||
|
||||
ChunkedDataCache<IrisBiome> biomeCache = mock(ChunkedDataCache.class);
|
||||
when(biomeCache.get(anyInt(), anyInt())).thenReturn(biome);
|
||||
ChunkContext context = mock(ChunkContext.class);
|
||||
when(context.getBiome()).thenReturn(biomeCache);
|
||||
|
||||
actuator.onActuate(100, -200, output, false, context);
|
||||
|
||||
assertEquals(List.of("100,-200", "100,-199", "101,-200", "101,-199"), samples);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Engine engine() {
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
when(engineMantle.getMantle()).thenReturn(mantle);
|
||||
SeedManager seedManager = mock(SeedManager.class);
|
||||
when(seedManager.getBiome()).thenReturn(1337L);
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getCacheID()).thenReturn(1);
|
||||
when(engine.getSeedManager()).thenReturn(seedManager);
|
||||
when(engine.getMantle()).thenReturn(engineMantle);
|
||||
when(engine.getMetrics()).thenReturn(new EngineMetrics(16));
|
||||
return engine;
|
||||
}
|
||||
|
||||
private void bindPlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
PlatformBiome biome = mock(PlatformBiome.class);
|
||||
PlatformBlockState block = mock(PlatformBlockState.class);
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
when(registries.biome(anyString())).thenReturn(biome);
|
||||
when(registries.block(anyString())).thenReturn(block);
|
||||
PlatformBiomeWriter biomeWriter = mock(PlatformBiomeWriter.class);
|
||||
when(biomeWriter.biomeIdFor(anyString())).thenReturn(1);
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
when(platform.biomeWriter()).thenReturn(biomeWriter);
|
||||
IrisPlatforms.bind(platform);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package art.arcane.iris.engine.decorator;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.object.InferredType;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDecorationPart;
|
||||
import art.arcane.iris.engine.object.IrisDecorator;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class IrisDecoratorCaveContextTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void explicitCaveContextSkipsFluidWithoutMutatingBiome() {
|
||||
Engine engine = mock(Engine.class);
|
||||
SeedManager seedManager = mock(SeedManager.class);
|
||||
doReturn(seedManager).when(engine).getSeedManager();
|
||||
doReturn(mock(IrisData.class)).when(engine).getData();
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
doReturn(63).when(dimension).getFluidHeight();
|
||||
doReturn(dimension).when(engine).getDimension();
|
||||
|
||||
IrisDecorator decorator = mock(IrisDecorator.class);
|
||||
doReturn(true).when(decorator).passesChanceGate(any(), anyDouble(), anyDouble(), any());
|
||||
doReturn(true).when(decorator).isStacking();
|
||||
doReturn(true).when(decorator).isForcePlace();
|
||||
doReturn(1).when(decorator).getHeight(any(), anyDouble(), anyDouble(), any());
|
||||
|
||||
IrisBiome biome = mock(IrisBiome.class);
|
||||
doReturn(InferredType.LAND).when(biome).getInferredType();
|
||||
doReturn(new IrisDecorator[]{decorator}).when(biome).getDecoratorBucket(IrisDecorationPart.NONE);
|
||||
doReturn(new IrisDecorator[]{decorator}).when(biome).getDecoratorBucket(IrisDecorationPart.CEILING);
|
||||
|
||||
PlatformBlockState fluid = mock(PlatformBlockState.class);
|
||||
doReturn(true).when(fluid).isFluid();
|
||||
Hunk<PlatformBlockState> output = mock(Hunk.class);
|
||||
doReturn(128).when(output).getHeight();
|
||||
doReturn(fluid).when(output).get(0, 10, 0);
|
||||
|
||||
IrisSurfaceDecorator surfaceDecorator = new IrisSurfaceDecorator(engine);
|
||||
surfaceDecorator.decorate(0, 0, 0, 0, 0, 0, 0, 0, output, biome, InferredType.CAVE, 10, 10);
|
||||
DecoratorCore.PlaceOpts surfaceOptions = DecoratorCore.SCRATCH_OPTS.get();
|
||||
assertTrue(surfaceOptions.caveSkipFluid);
|
||||
assertFalse(surfaceOptions.underwater);
|
||||
|
||||
IrisCeilingDecorator ceilingDecorator = new IrisCeilingDecorator(engine);
|
||||
ceilingDecorator.decorate(0, 0, 0, 0, 0, 0, 0, 0, output, biome, InferredType.CAVE, 10, 10);
|
||||
DecoratorCore.PlaceOpts ceilingOptions = DecoratorCore.SCRATCH_OPTS.get();
|
||||
assertTrue(ceilingOptions.caveSkipFluid);
|
||||
assertEquals(InferredType.LAND, biome.getInferredType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nullInferenceRemainsSafeForNormalSurfaceContext() {
|
||||
assertTrue(IrisSurfaceDecorator.isUnderwater(null, 10, 63));
|
||||
assertFalse(IrisSurfaceDecorator.skipsFluid(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class StructurePlacementMarkerTest {
|
||||
@Test
|
||||
public void structureMarkerRoundTripsDelimiterSafeKeys() {
|
||||
String objectKey = "pieces/room@east:alpha/樹";
|
||||
String structureKey = "structures/village@night:beta/城";
|
||||
|
||||
String encoded = StructurePlacementMarker.encodeStructure(objectKey, 918273, structureKey);
|
||||
StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode(encoded);
|
||||
|
||||
assertTrue(encoded.startsWith("@iris-structure:v1:"));
|
||||
assertEquals(objectKey, decoded.objectKey());
|
||||
assertEquals(918273, decoded.placementId());
|
||||
assertEquals(structureKey, decoded.structureKey());
|
||||
assertTrue(decoded.structureAware());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyMarkerDecodesWithoutChangingFields() {
|
||||
StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode("objects/oak_tree@42");
|
||||
|
||||
assertEquals("objects/oak_tree", decoded.objectKey());
|
||||
assertEquals(42, decoded.placementId());
|
||||
assertNull(decoded.structureKey());
|
||||
assertFalse(decoded.structureAware());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedAndUnknownMarkersFailSafely() {
|
||||
assertNull(StructurePlacementMarker.decode(null));
|
||||
assertNull(StructurePlacementMarker.decode(""));
|
||||
assertNull(StructurePlacementMarker.decode("objects/oak_tree"));
|
||||
assertNull(StructurePlacementMarker.decode("objects/oak_tree@not-an-id"));
|
||||
assertNull(StructurePlacementMarker.decode("objects/oak_tree@42@extra"));
|
||||
assertNull(StructurePlacementMarker.decode("@iris-structure:v2:b2JqZWN0:42:c3RydWN0dXJl"));
|
||||
assertNull(StructurePlacementMarker.decode("@iris-structure:v1:not%base64:42:c3RydWN0dXJl"));
|
||||
assertNull(StructurePlacementMarker.decode("@iris-structure:v1:b2JqZWN0:42:"));
|
||||
}
|
||||
}
|
||||
+43
@@ -37,9 +37,11 @@ import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
@@ -117,6 +119,43 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
assertEquals(firstCapture.carvedLiquids, secondCapture.carvedLiquids);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void warpCacheDoesNotCrossContaminateCarverInstancesOnSameThread() throws Exception {
|
||||
Engine engine = createEngine(128, 92);
|
||||
IrisCaveProfile firstProfile = createProfile(true, false);
|
||||
firstProfile.setWarpStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).zoomed(0.12D));
|
||||
IrisCaveProfile secondProfile = createProfile(true, false);
|
||||
secondProfile.setWarpStyle(new IrisGeneratorStyle(NoiseStyle.CELLULAR).zoomed(0.12D));
|
||||
IrisCaveProfile controlProfile = createProfile(true, false);
|
||||
controlProfile.setWarpStyle(new IrisGeneratorStyle(NoiseStyle.CELLULAR).zoomed(0.12D));
|
||||
IrisCaveCarver3D firstCarver = new IrisCaveCarver3D(engine, firstProfile);
|
||||
IrisCaveCarver3D secondCarver = new IrisCaveCarver3D(engine, secondProfile);
|
||||
IrisCaveCarver3D controlCarver = new IrisCaveCarver3D(engine, controlProfile);
|
||||
int x = 48;
|
||||
int y = 64;
|
||||
int z = -352;
|
||||
|
||||
double firstDensity = sampleDensity(firstCarver, x, y, z);
|
||||
double secondDensity = sampleDensity(secondCarver, x, y, z);
|
||||
AtomicReference<Double> controlDensity = new AtomicReference<>();
|
||||
AtomicReference<Throwable> controlFailure = new AtomicReference<>();
|
||||
Thread controlThread = new Thread(() -> {
|
||||
try {
|
||||
controlDensity.set(sampleDensity(controlCarver, x, y, z));
|
||||
} catch (Throwable throwable) {
|
||||
controlFailure.set(throwable);
|
||||
}
|
||||
}, "Iris cave warp cache control");
|
||||
controlThread.start();
|
||||
controlThread.join();
|
||||
|
||||
if (controlFailure.get() != null) {
|
||||
throw new AssertionError(controlFailure.get());
|
||||
}
|
||||
assertNotEquals(firstDensity, controlDensity.get(), 0D);
|
||||
assertEquals(controlDensity.get(), secondDensity, 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactPathCarvesChunkEdgesAndRespectsWorldHeightClipping() {
|
||||
Engine engine = createEngine(48, 46);
|
||||
@@ -277,6 +316,10 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
private double sampleDensity(IrisCaveCarver3D carver, int x, int y, int z) throws Exception {
|
||||
return (double) sampleDensityMethod.invoke(carver, x, y, z);
|
||||
}
|
||||
|
||||
private long runNaiveOnce(IrisCaveCarver3D carver, int chunkX, int chunkZ, double[] columnWeights, IrisRange worldYRange, int[] precomputedSurfaceHeights, int worldHeight) throws Exception {
|
||||
WriterCapture capture = createWriterCapture(worldHeight);
|
||||
long start = System.nanoTime();
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package art.arcane.iris.engine.mantle.components;
|
||||
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisStructureComponentMarkerTest {
|
||||
@Test
|
||||
public void markerFilterAcceptsOnlyStorageContainers() {
|
||||
PlatformBlockState storage = mock(PlatformBlockState.class);
|
||||
PlatformBlockState solid = mock(PlatformBlockState.class);
|
||||
when(storage.isStorageChest()).thenReturn(true);
|
||||
when(solid.isStorageChest()).thenReturn(false);
|
||||
|
||||
assertTrue(IrisStructureComponent.shouldWriteStructureMarker(storage));
|
||||
assertFalse(IrisStructureComponent.shouldWriteStructureMarker(solid));
|
||||
assertFalse(IrisStructureComponent.shouldWriteStructureMarker(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void placementIdIsStableAndCoordinateSensitive() {
|
||||
int first = IrisStructureComponent.structurePlacementId("structures/village", "pieces/house", 20, -14, 35);
|
||||
int repeated = IrisStructureComponent.structurePlacementId("structures/village", "pieces/house", 20, -14, 35);
|
||||
int moved = IrisStructureComponent.structurePlacementId("structures/village", "pieces/house", 21, -14, 35);
|
||||
|
||||
assertEquals(first, repeated);
|
||||
assertNotEquals(first, moved);
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package art.arcane.iris.engine.mantle.components;
|
||||
|
||||
import art.arcane.iris.core.nms.container.Pair;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.mantle.MantleComponent;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.mantle.MatterGenerator;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class MantleCarvingComponentBoundaryRadiusTest {
|
||||
@Before
|
||||
public void bindPlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
PlatformBlockState block = mock(PlatformBlockState.class);
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
when(registries.block(anyString())).thenReturn(block);
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
IrisPlatforms.bind(platform);
|
||||
}
|
||||
|
||||
@After
|
||||
public void unbindPlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundaryWallResolutionSchedulesAdjacentCarvingChunks() {
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
MantleCarvingComponent component = new MantleCarvingComponent(engineMantle);
|
||||
|
||||
assertEquals(1, component.getRadius());
|
||||
assertEquals(1, Math.ceilDiv(component.getRadius(), 16));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void matterGenerationRunsCarvingForAllAdjacentChunks() {
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
when(dimension.isUseMantle()).thenReturn(true);
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk);
|
||||
when(chunk.use()).thenReturn(chunk);
|
||||
doAnswer(invocation -> {
|
||||
Runnable task = invocation.getArgument(1);
|
||||
task.run();
|
||||
return null;
|
||||
}).when(chunk).raiseFlagSuspend(any(), any(Runnable.class));
|
||||
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getMantle()).thenReturn(engineMantle);
|
||||
when(engineMantle.getEngine()).thenReturn(engine);
|
||||
|
||||
List<String> generatedChunks = new ArrayList<>();
|
||||
MantleCarvingComponent component = spy(new MantleCarvingComponent(engineMantle));
|
||||
doAnswer(invocation -> {
|
||||
int chunkX = invocation.getArgument(1);
|
||||
int chunkZ = invocation.getArgument(2);
|
||||
generatedChunks.add(chunkX + "," + chunkZ);
|
||||
return null;
|
||||
}).when(component).generateLayer(any(), anyInt(), anyInt(), any());
|
||||
|
||||
TestMatterGenerator generator = new TestMatterGenerator(engine, mantle, component);
|
||||
generator.generateMatter(4, -7, false, mock(ChunkContext.class));
|
||||
|
||||
assertEquals(List.of(
|
||||
"3,-8", "3,-7", "3,-6",
|
||||
"4,-8", "4,-7", "4,-6",
|
||||
"5,-8", "5,-7", "5,-6"
|
||||
), generatedChunks);
|
||||
}
|
||||
|
||||
private static final class TestMatterGenerator implements MatterGenerator {
|
||||
private final Engine engine;
|
||||
private final Mantle<Matter> mantle;
|
||||
private final List<Pair<List<MantleComponent>, Integer>> components;
|
||||
|
||||
private TestMatterGenerator(Engine engine, Mantle<Matter> mantle, MantleComponent component) {
|
||||
this.engine = engine;
|
||||
this.mantle = mantle;
|
||||
this.components = List.of(new Pair<>(List.of(component), 1));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Engine getEngine() {
|
||||
return engine;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mantle<Matter> getMantle() {
|
||||
return mantle;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRadius() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRealRadius() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Pair<List<MantleComponent>, Integer>> getComponents() {
|
||||
return components;
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package art.arcane.iris.engine.modifier;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.InferredType;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionCarvingResolver;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class IrisCarveModifierInferenceIsolationTest {
|
||||
@BeforeClass
|
||||
public static void bindPlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
PlatformBlockState block = mock(PlatformBlockState.class);
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
doReturn(block).when(registries).block(anyString());
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
doReturn(registries).when(platform).registries();
|
||||
IrisPlatforms.bind(platform);
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void unbindPlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void cavePaintingDoesNotMutateSharedBiomeInference() throws Exception {
|
||||
IrisBiome biome = new IrisBiome().setInferredType(InferredType.LAND);
|
||||
biome.getLayers().clear();
|
||||
biome.getCaveCeilingLayers().clear();
|
||||
|
||||
Engine engine = mock(Engine.class);
|
||||
doReturn(mock(IrisData.class)).when(engine).getData();
|
||||
doReturn(mock(IrisDimension.class)).when(engine).getDimension();
|
||||
doReturn(mock(IrisComplex.class)).when(engine).getComplex();
|
||||
doReturn(IrisWorld.builder().minHeight(-256).maxHeight(512).build()).when(engine).getWorld();
|
||||
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
|
||||
Map<String, IrisBiome> customBiomes = new HashMap<>();
|
||||
customBiomes.put("shared", biome);
|
||||
Method paintBoundaryZone = IrisCarveModifier.class.getDeclaredMethod(
|
||||
"paintBoundaryZone",
|
||||
Hunk.class,
|
||||
MatterCavern.class,
|
||||
int.class,
|
||||
int.class,
|
||||
int.class,
|
||||
int.class,
|
||||
int.class,
|
||||
int.class,
|
||||
IrisDimensionCarvingResolver.State.class,
|
||||
Long2ObjectOpenHashMap.class,
|
||||
Map.class
|
||||
);
|
||||
paintBoundaryZone.setAccessible(true);
|
||||
paintBoundaryZone.invoke(
|
||||
modifier,
|
||||
mock(Hunk.class),
|
||||
new MatterCavern(true, "shared", (byte) 0),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
new IrisDimensionCarvingResolver.State(),
|
||||
new Long2ObjectOpenHashMap<IrisBiome>(),
|
||||
customBiomes
|
||||
);
|
||||
|
||||
IrisBiome fallback = new IrisBiome();
|
||||
ProceduralStream<IrisBiome> landStream = mock(ProceduralStream.class);
|
||||
doReturn(fallback).when(landStream).get(anyDouble(), anyDouble());
|
||||
IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS);
|
||||
Field landBiomeStream = IrisComplex.class.getDeclaredField("landBiomeStream");
|
||||
landBiomeStream.setAccessible(true);
|
||||
landBiomeStream.set(complex, landStream);
|
||||
IrisRegion region = mock(IrisRegion.class);
|
||||
doReturn(0D).when(region).getShoreHeight(anyDouble(), anyDouble());
|
||||
Method fixBiomeType = IrisComplex.class.getDeclaredMethod(
|
||||
"fixBiomeType",
|
||||
Double.class,
|
||||
IrisBiome.class,
|
||||
IrisRegion.class,
|
||||
Double.class,
|
||||
Double.class,
|
||||
double.class
|
||||
);
|
||||
fixBiomeType.setAccessible(true);
|
||||
IrisBiome resolved = (IrisBiome) fixBiomeType.invoke(complex, 10D, biome, region, 0D, 0D, 0D);
|
||||
assertSame(biome, resolved);
|
||||
assertEquals(InferredType.LAND, biome.getInferredType());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
|
||||
public class IrisBiomeCustomSpawnTest {
|
||||
@Test
|
||||
public void normalizesNamespacedEntityKey() {
|
||||
IrisBiomeCustomSpawn spawn = new IrisBiomeCustomSpawn().setType(" Minecraft:Slime ");
|
||||
|
||||
assertEquals("minecraft:slime", spawn.getTypeKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void prefixesBareEntityKey() {
|
||||
IrisBiomeCustomSpawn spawn = new IrisBiomeCustomSpawn().setType("Slime");
|
||||
|
||||
assertEquals("minecraft:slime", spawn.getTypeKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void returnsNullForBlankEntityKey() {
|
||||
IrisBiomeCustomSpawn spawn = new IrisBiomeCustomSpawn().setType(" ");
|
||||
|
||||
assertNull(spawn.getTypeKey());
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.util.project.noise.CNG;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisBiomeGeneratorSeedIsolationTest {
|
||||
private static final double SAMPLE_X = 128D;
|
||||
private static final double SAMPLE_Z = -64D;
|
||||
|
||||
@Test
|
||||
public void engineSeedMakesPaperStyleFirstCallOrderIrrelevant() {
|
||||
IrisData data = dataWithActiveEngine(new AtomicReference<>(engine(1337L)));
|
||||
IrisBiome paperFirst = biome(data);
|
||||
IrisBiome moddedFirst = biome(data);
|
||||
|
||||
CNG paperGenerator = paperFirst.getBiomeGenerator(new RNG(8457289L));
|
||||
CNG moddedGenerator = moddedFirst.getBiomeGenerator(new RNG(-991245L));
|
||||
|
||||
assertEquals(paperGenerator.noise(SAMPLE_X, SAMPLE_Z), moddedGenerator.noise(SAMPLE_X, SAMPLE_Z), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void distinctEnginesKeepDistinctBiomeGenerators() {
|
||||
Engine firstEngine = engine(1337L);
|
||||
Engine secondEngine = engine(7331L);
|
||||
IrisBiome biome = biome(dataWithActiveEngine(new AtomicReference<>(secondEngine)));
|
||||
|
||||
CNG first = biome.getBiomeGenerator(new RNG(1L), firstEngine);
|
||||
CNG second = biome.getBiomeGenerator(new RNG(1L), secondEngine);
|
||||
|
||||
assertNotSame(first, second);
|
||||
assertNotEquals(first.noise(SAMPLE_X, SAMPLE_Z), second.noise(SAMPLE_X, SAMPLE_Z), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sameSeedEnginesSharingBiomeReuseBiomeGenerator() {
|
||||
Engine firstEngine = engine(1337L);
|
||||
Engine secondEngine = engine(1337L);
|
||||
IrisBiome biome = biome(dataWithActiveEngine(new AtomicReference<>(secondEngine)));
|
||||
|
||||
CNG first = biome.getBiomeGenerator(new RNG(11L), firstEngine);
|
||||
CNG second = biome.getBiomeGenerator(new RNG(99L), secondEngine);
|
||||
|
||||
assertSame(first, second);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void engineLessToolingUsesSuppliedRngSeed() {
|
||||
IrisBiome biome = biome(null);
|
||||
|
||||
CNG first = biome.getBiomeGenerator(new RNG(11L));
|
||||
CNG sameSeed = biome.getBiomeGenerator(new RNG(11L));
|
||||
CNG second = biome.getBiomeGenerator(new RNG(99L));
|
||||
|
||||
assertSame(first, sameSeed);
|
||||
assertNotSame(first, second);
|
||||
assertNotEquals(first.noise(SAMPLE_X, SAMPLE_Z), second.noise(SAMPLE_X, SAMPLE_Z), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentExactEnginesIgnoreLoaderEngine() throws Exception {
|
||||
Engine firstEngine = engine(1337L);
|
||||
Engine secondEngine = engine(7331L);
|
||||
IrisBiome biome = biome(dataWithActiveEngine(new AtomicReference<>(secondEngine)));
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<CNG> firstFuture = executor.submit(() -> repeatedlyResolve(biome, firstEngine, start));
|
||||
Future<CNG> secondFuture = executor.submit(() -> repeatedlyResolve(biome, secondEngine, start));
|
||||
start.countDown();
|
||||
|
||||
CNG first = firstFuture.get();
|
||||
CNG second = secondFuture.get();
|
||||
assertNotSame(first, second);
|
||||
assertSame(first, biome.getBiomeGenerator(new RNG(99L), firstEngine));
|
||||
assertSame(second, biome.getBiomeGenerator(new RNG(99L), secondEngine));
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void biomeGeneratorCacheEvictsOldSeeds() {
|
||||
IrisBiome biome = biome(null);
|
||||
CNG first = biome.getBiomeGenerator(new RNG(0L));
|
||||
|
||||
for (long seed = 1L; seed <= 8L; seed++) {
|
||||
biome.getBiomeGenerator(new RNG(seed));
|
||||
}
|
||||
|
||||
assertNotSame(first, biome.getBiomeGenerator(new RNG(0L)));
|
||||
}
|
||||
|
||||
private IrisBiome biome(IrisData data) {
|
||||
IrisBiome biome = new IrisBiome().setName("Scatter Test");
|
||||
biome.setLoader(data);
|
||||
return biome;
|
||||
}
|
||||
|
||||
private IrisData dataWithActiveEngine(AtomicReference<Engine> activeEngine) {
|
||||
IrisData data = mock(IrisData.class);
|
||||
when(data.getEngine()).thenAnswer(ignored -> activeEngine.get());
|
||||
return data;
|
||||
}
|
||||
|
||||
private Engine engine(long biomeSeed) {
|
||||
SeedManager seedManager = mock(SeedManager.class);
|
||||
when(seedManager.getBiome()).thenReturn(biomeSeed);
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getSeedManager()).thenReturn(seedManager);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private CNG repeatedlyResolve(IrisBiome biome, Engine engine, CountDownLatch start) throws InterruptedException {
|
||||
start.await();
|
||||
CNG generator = biome.getBiomeGenerator(new RNG(1L), engine);
|
||||
for (int index = 0; index < 100; index++) {
|
||||
assertSame(generator, biome.getBiomeGenerator(new RNG(index + 2L), engine));
|
||||
}
|
||||
return generator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
|
||||
public class IrisDimensionBiomeTagTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void biomeTagWritesAreSortedAndDeduplicated() throws Exception {
|
||||
Path output = temporaryFolder.getRoot().toPath().resolve("allows_surface_slime_spawns.json");
|
||||
|
||||
IrisDimension.writeBiomeTag(output, "overworld:swamp_b");
|
||||
IrisDimension.writeBiomeTag(output, "overworld:swamp_a");
|
||||
IrisDimension.writeBiomeTag(output, "overworld:swamp_b");
|
||||
|
||||
JSONObject tag = new JSONObject(Files.readString(output, StandardCharsets.UTF_8));
|
||||
JSONArray values = tag.getJSONArray("values");
|
||||
assertFalse(tag.getBoolean("replace"));
|
||||
assertEquals(2, values.length());
|
||||
assertEquals("overworld:swamp_a", values.getString(0));
|
||||
assertEquals("overworld:swamp_b", values.getString(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void customBiomeTagUsesTheMinecraftBiomeTagPath() throws Exception {
|
||||
KList<String> tags = new KList<>();
|
||||
tags.add("minecraft:allows_surface_slime_spawns");
|
||||
IrisDimension.installBiomeTags(temporaryFolder.getRoot(), "overworld:swamp", tags);
|
||||
|
||||
Path output = temporaryFolder.getRoot().toPath()
|
||||
.resolve("iris/data/minecraft/tags/worldgen/biome/allows_surface_slime_spawns.json");
|
||||
JSONObject tag = new JSONObject(Files.readString(output, StandardCharsets.UTF_8));
|
||||
|
||||
assertEquals("overworld:swamp", tag.getJSONArray("values").getString(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package art.arcane.iris.engine.object;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisStructureLootPlacementTest {
|
||||
@Test
|
||||
public void createsPiecePlacementWithAuthoredLootOrderAndDefaultWeights() {
|
||||
IrisStructure structure = new IrisStructure();
|
||||
structure.getLoot().add("village_common");
|
||||
structure.getLoot().add("village_rare");
|
||||
|
||||
IrisObjectPlacement placement = structure.createLootPlacement("pieces/village_house");
|
||||
|
||||
assertEquals(1, placement.getPlace().size());
|
||||
assertEquals("pieces/village_house", placement.getPlace().get(0));
|
||||
assertEquals(2, placement.getLoot().size());
|
||||
assertEquals("village_common", placement.getLoot().get(0).getName());
|
||||
assertEquals(1, placement.getLoot().get(0).getWeight());
|
||||
assertTrue(placement.getLoot().get(0).getFilter().isEmpty());
|
||||
assertEquals("village_rare", placement.getLoot().get(1).getName());
|
||||
assertEquals(1, placement.getLoot().get(1).getWeight());
|
||||
assertTrue(placement.getLoot().get(1).getFilter().isEmpty());
|
||||
assertFalse(placement.isOverrideGlobalLoot());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createsEmptyLootPlacementWhenStructureHasNoLoot() {
|
||||
IrisObjectPlacement placement = new IrisStructure().createLootPlacement("pieces/empty_house");
|
||||
|
||||
assertEquals("pieces/empty_house", placement.getPlace().get(0));
|
||||
assertTrue(placement.getLoot().isEmpty());
|
||||
assertFalse(placement.isOverrideGlobalLoot());
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
name: ${name}
|
||||
version: ${version}
|
||||
main: ${main}
|
||||
load: STARTUP
|
||||
authors: [ cyberpwn, NextdoorPsycho ]
|
||||
website: volmit.com
|
||||
description: More than a Dimension!
|
||||
libraries:
|
||||
- org.zeroturnaround:zt-zip:1.14
|
||||
- com.googlecode.concurrentlinkedhashmap:concurrentlinkedhashmap-lru:1.4.2
|
||||
- org.ow2.asm:asm:9.2
|
||||
- com.google.code.gson:gson:2.8.7
|
||||
- it.unimi.dsi:fastutil:8.5.4
|
||||
- com.google.guava:guava:30.1.1-jre
|
||||
- bsf:bsf:2.4.0
|
||||
- rhino:js:1.7R2
|
||||
commands:
|
||||
iris:
|
||||
aliases: [ ir, irs ]
|
||||
api-version: ${apiversion}
|
||||
hotload-dependencies: false
|
||||
@@ -16,6 +16,7 @@ String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||
dependencies {
|
||||
implementation(project(':core'))
|
||||
compileOnly(libs.spigot)
|
||||
testImplementation('junit:junit:4.13.2')
|
||||
compileOnly(volmLibCoordinate) {
|
||||
transitive = false
|
||||
}
|
||||
@@ -44,6 +45,10 @@ tasks.named('run', JavaExec).configure {
|
||||
dependsOn(':core:compileJava')
|
||||
}
|
||||
|
||||
tasks.named('test').configure {
|
||||
dependsOn(':core:compileJava')
|
||||
}
|
||||
|
||||
String probePack = providers.gradleProperty('probePack')
|
||||
.orElse('/Users/brianfopiano/Developer/RemoteGit/[Minecraft Server]/consumers/plugin-consumers/instances/purpur-26.2/plugins/Iris/packs/overworld')
|
||||
.get()
|
||||
|
||||
@@ -18,15 +18,23 @@
|
||||
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class ClassloadProbe {
|
||||
private static final String ALLOWLIST_RESOURCE = "/classload-allowlist.tsv";
|
||||
|
||||
private static final String[] CRITICAL_PREFIXES = {
|
||||
"art.arcane.iris.engine.",
|
||||
"art.arcane.iris.util.",
|
||||
@@ -34,26 +42,6 @@ public final class ClassloadProbe {
|
||||
"art.arcane.iris.spi.",
|
||||
};
|
||||
|
||||
private static final String[] EXEMPT_PREFIXES = {
|
||||
"art.arcane.iris.platform.bukkit.",
|
||||
"art.arcane.iris.engine.platform.",
|
||||
"art.arcane.iris.util.common.plugin.",
|
||||
"art.arcane.iris.util.common.inventorygui.",
|
||||
"art.arcane.iris.util.common.director.",
|
||||
"art.arcane.iris.util.common.data.registry.",
|
||||
};
|
||||
|
||||
private static final String[] EXEMPT_CLASSES = {
|
||||
"art.arcane.iris.util.common.misc.Bindings",
|
||||
"art.arcane.iris.util.common.misc.SlimJar",
|
||||
"art.arcane.iris.util.common.misc.ServerProperties",
|
||||
"art.arcane.iris.util.common.data.IrisCustomData",
|
||||
"art.arcane.iris.engine.IrisWorldManager",
|
||||
"art.arcane.iris.engine.framework.EngineAssignedWorldManager",
|
||||
"art.arcane.iris.engine.object.StudioMode",
|
||||
"art.arcane.iris.engine.framework.placer.WorldObjectPlacer",
|
||||
};
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
art.arcane.iris.spi.IrisPlatforms.bind(new StubPlatform());
|
||||
boolean bukkitPresent = true;
|
||||
@@ -63,39 +51,42 @@ public final class ClassloadProbe {
|
||||
bukkitPresent = false;
|
||||
}
|
||||
System.out.println("[probe] org.bukkit on classpath: " + bukkitPresent);
|
||||
|
||||
Path classesRoot = Path.of(args[0]);
|
||||
List<String> names = new ArrayList<>();
|
||||
try (Stream<Path> walk = Files.walk(classesRoot)) {
|
||||
walk.filter(p -> p.toString().endsWith(".class"))
|
||||
.forEach(p -> {
|
||||
String rel = classesRoot.relativize(p).toString();
|
||||
String name = rel.substring(0, rel.length() - 6).replace('/', '.');
|
||||
if (name.contains("$")) {
|
||||
return;
|
||||
}
|
||||
names.add(name);
|
||||
});
|
||||
if (bukkitPresent) {
|
||||
System.out.println("[probe] RESULT: FAIL - org.bukkit must be absent from the classload probe runtime");
|
||||
System.exit(1);
|
||||
return;
|
||||
}
|
||||
names.sort(String::compareTo);
|
||||
|
||||
TreeMap<String, String> criticalFailures = new TreeMap<>();
|
||||
TreeMap<String, String> otherFailures = new TreeMap<>();
|
||||
TreeMap<String, Allowance> allowlist = loadAllowlist();
|
||||
Path classesRoot = Path.of(args[0]);
|
||||
List<String> names = scanClassNames(classesRoot);
|
||||
|
||||
TreeMap<String, Failure> criticalFailures = new TreeMap<>();
|
||||
TreeMap<String, Failure> otherFailures = new TreeMap<>();
|
||||
int loaded = 0;
|
||||
int nestedTotal = 0;
|
||||
int nestedLoaded = 0;
|
||||
int criticalTotal = 0;
|
||||
for (String name : names) {
|
||||
boolean critical = matchesAny(name, CRITICAL_PREFIXES) && !matchesAny(name, EXEMPT_PREFIXES) && !exactAny(name);
|
||||
boolean nested = name.contains("$");
|
||||
if (nested) {
|
||||
nestedTotal++;
|
||||
}
|
||||
boolean critical = matchesAny(name, CRITICAL_PREFIXES) && !allowlist.containsKey(name);
|
||||
if (critical) {
|
||||
criticalTotal++;
|
||||
}
|
||||
try {
|
||||
Class.forName(name, true, ClassloadProbe.class.getClassLoader());
|
||||
loaded++;
|
||||
if (nested) {
|
||||
nestedLoaded++;
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
if (System.getenv("PROBE_TRACE") != null && name.contains(System.getenv("PROBE_TRACE"))) {
|
||||
failure.printStackTrace(System.out);
|
||||
}
|
||||
String cause = rootCause(failure);
|
||||
Failure cause = Failure.from(failure);
|
||||
if (critical) {
|
||||
criticalFailures.put(name, cause);
|
||||
} else {
|
||||
@@ -105,25 +96,56 @@ public final class ClassloadProbe {
|
||||
}
|
||||
|
||||
System.out.println("[probe] classes scanned: " + names.size() + ", initialized OK: " + loaded);
|
||||
System.out.println("[probe] nested classes scanned: " + nestedTotal + ", initialized OK: " + nestedLoaded
|
||||
+ ", initialization failures: " + (nestedTotal - nestedLoaded));
|
||||
System.out.println("[probe] critical set: " + criticalTotal + ", critical failures: " + criticalFailures.size());
|
||||
criticalFailures.forEach((name, cause) -> System.out.println(" CRITICAL " + name + " -> " + cause));
|
||||
criticalFailures.forEach((name, cause) -> System.out.println(" CRITICAL " + name + " -> " + cause.display()));
|
||||
System.out.println("[probe] non-critical failures: " + otherFailures.size());
|
||||
otherFailures.forEach((name, cause) -> System.out.println(" other " + name + " -> " + cause));
|
||||
Review review = review(otherFailures, allowlist);
|
||||
System.out.println("[probe] reviewed allowances: " + allowlist.size() + ", matched failures: " + review.matched());
|
||||
otherFailures.forEach((name, cause) -> {
|
||||
Allowance allowance = allowlist.get(name);
|
||||
String status = allowance != null && allowance.matches(cause) ? "allowed " + allowance.category() : "UNEXPECTED";
|
||||
System.out.println(" " + status + " " + name + " -> " + cause.display());
|
||||
});
|
||||
System.out.println("[probe] unexpected non-critical failures: " + review.unexpected().size());
|
||||
System.out.println("[probe] stale allowances: " + review.staleAllowances().size());
|
||||
review.staleAllowances().forEach((name, allowance) -> System.out.println(" STALE " + name + " -> " + allowance));
|
||||
|
||||
if (!criticalFailures.isEmpty()) {
|
||||
if (!criticalFailures.isEmpty() || !review.passes()) {
|
||||
System.out.println("[probe] RESULT: FAIL");
|
||||
System.exit(1);
|
||||
}
|
||||
System.out.println("[probe] RESULT: PASS");
|
||||
}
|
||||
|
||||
private static boolean exactAny(String name) {
|
||||
for (String exempt : EXEMPT_CLASSES) {
|
||||
if (name.equals(exempt)) {
|
||||
return true;
|
||||
static Review review(Map<String, Failure> failures, Map<String, Allowance> allowlist) {
|
||||
TreeMap<String, Failure> unexpected = new TreeMap<>();
|
||||
TreeMap<String, Allowance> staleAllowances = new TreeMap<>(allowlist);
|
||||
int matched = 0;
|
||||
for (Map.Entry<String, Failure> entry : failures.entrySet()) {
|
||||
Allowance allowance = allowlist.get(entry.getKey());
|
||||
staleAllowances.remove(entry.getKey());
|
||||
if (allowance == null || !allowance.matches(entry.getValue())) {
|
||||
unexpected.put(entry.getKey(), entry.getValue());
|
||||
} else {
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return new Review(matched, unexpected, staleAllowances);
|
||||
}
|
||||
|
||||
static List<String> scanClassNames(Path classesRoot) throws IOException {
|
||||
List<String> names = new ArrayList<>();
|
||||
try (Stream<Path> walk = Files.walk(classesRoot)) {
|
||||
walk.filter(path -> path.toString().endsWith(".class"))
|
||||
.forEach(path -> {
|
||||
String relative = classesRoot.relativize(path).toString();
|
||||
names.add(relative.substring(0, relative.length() - 6).replace(File.separatorChar, '.'));
|
||||
});
|
||||
}
|
||||
names.sort(String::compareTo);
|
||||
return names;
|
||||
}
|
||||
|
||||
private static boolean matchesAny(String name, String[] prefixes) {
|
||||
@@ -135,11 +157,91 @@ public final class ClassloadProbe {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String rootCause(Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
while (cause.getCause() != null && cause.getCause() != cause) {
|
||||
cause = cause.getCause();
|
||||
private static TreeMap<String, Allowance> loadAllowlist() throws IOException {
|
||||
InputStream resource = ClassloadProbe.class.getResourceAsStream(ALLOWLIST_RESOURCE);
|
||||
if (resource == null) {
|
||||
throw new IOException("Classload allowlist resource not found: " + ALLOWLIST_RESOURCE);
|
||||
}
|
||||
|
||||
TreeMap<String, Allowance> allowlist = new TreeMap<>();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
int lineNumber = 0;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
lineNumber++;
|
||||
if (line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String[] fields = line.split("\\t", -1);
|
||||
if (fields.length != 3 || fields[0].isBlank() || fields[2].isBlank()) {
|
||||
throw new IOException("Invalid classload allowlist entry at line " + lineNumber);
|
||||
}
|
||||
Allowance allowance;
|
||||
try {
|
||||
allowance = new Allowance(AllowanceCategory.valueOf(fields[1]), fields[2]);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("Invalid classload allowlist category at line " + lineNumber + ": " + fields[1], exception);
|
||||
}
|
||||
if (allowlist.put(fields[0], allowance) != null) {
|
||||
throw new IOException("Duplicate classload allowlist class at line " + lineNumber + ": " + fields[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return allowlist;
|
||||
}
|
||||
|
||||
enum AllowanceCategory {
|
||||
BUKKIT_API,
|
||||
PAPERLIB_API,
|
||||
ADVENTURE_API,
|
||||
MYTHICMOBS_API,
|
||||
SERVER_RUNTIME_FILE
|
||||
}
|
||||
|
||||
record Allowance(AllowanceCategory category, String detail) {
|
||||
boolean matches(Failure failure) {
|
||||
return switch (category) {
|
||||
case BUKKIT_API -> detail.startsWith("org.bukkit.") && failure.isMissingClassIn("org.bukkit.");
|
||||
case PAPERLIB_API -> detail.startsWith("io.papermc.lib.") && failure.isMissingClassIn("io.papermc.lib.");
|
||||
case ADVENTURE_API -> detail.startsWith("net.kyori.adventure.") && failure.isMissingClassIn("net.kyori.adventure.");
|
||||
case MYTHICMOBS_API -> detail.startsWith("io.lumine.mythic.") && failure.isMissingClassIn("io.lumine.mythic.");
|
||||
case SERVER_RUNTIME_FILE -> failure.exceptionClass().equals("java.io.FileNotFoundException")
|
||||
&& failure.message().startsWith(detail);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
record Failure(String exceptionClass, String message) {
|
||||
static Failure from(Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
while (cause.getCause() != null && cause.getCause() != cause) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return new Failure(cause.getClass().getName(), String.valueOf(cause.getMessage()));
|
||||
}
|
||||
|
||||
boolean isMissingClassIn(String packagePrefix) {
|
||||
if (exceptionClass.equals("java.lang.ClassNotFoundException")) {
|
||||
return message.startsWith(packagePrefix);
|
||||
}
|
||||
String internalPrefix = packagePrefix.replace('.', '/');
|
||||
if (exceptionClass.equals("java.lang.NoClassDefFoundError")) {
|
||||
return message.startsWith(internalPrefix);
|
||||
}
|
||||
return exceptionClass.equals("java.lang.ExceptionInInitializerError")
|
||||
&& message.contains("NoClassDefFoundError: " + internalPrefix);
|
||||
}
|
||||
|
||||
String display() {
|
||||
int separator = exceptionClass.lastIndexOf('.');
|
||||
String simpleName = separator < 0 ? exceptionClass : exceptionClass.substring(separator + 1);
|
||||
return simpleName + ": " + message;
|
||||
}
|
||||
}
|
||||
|
||||
record Review(int matched, TreeMap<String, Failure> unexpected, TreeMap<String, Allowance> staleAllowances) {
|
||||
boolean passes() {
|
||||
return unexpected.isEmpty() && staleAllowances.isEmpty();
|
||||
}
|
||||
return cause.getClass().getSimpleName() + ": " + cause.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
art.arcane.iris.core.IrisWorlds BUKKIT_API org.bukkit.command.CommandSender
|
||||
art.arcane.iris.core.ServerConfigurator BUKKIT_API org.bukkit.command.CommandSender
|
||||
art.arcane.iris.core.edit.BlockSignal BUKKIT_API org.bukkit.entity.Entity
|
||||
art.arcane.iris.core.edit.BlockSignal$1 BUKKIT_API org.bukkit.entity.Entity
|
||||
art.arcane.iris.core.edit.DustRevealer ADVENTURE_API net.kyori.adventure.text.format.TextColor
|
||||
art.arcane.iris.core.events.IrisEngineEvent BUKKIT_API org.bukkit.event.Event
|
||||
art.arcane.iris.core.events.IrisEngineHotloadEvent BUKKIT_API org.bukkit.event.Event
|
||||
art.arcane.iris.core.events.IrisLootEvent BUKKIT_API org.bukkit.event.Event
|
||||
art.arcane.iris.core.events.IrisLootEvent$1 BUKKIT_API org.bukkit.loot.LootTable
|
||||
art.arcane.iris.core.events.IrisLootEvent$2 BUKKIT_API org.bukkit.inventory.InventoryHolder
|
||||
art.arcane.iris.core.lifecycle.PaperLibBootstrap PAPERLIB_API io.papermc.lib.environments.Environment
|
||||
art.arcane.iris.core.lifecycle.PaperLibBootstrap$ModernPaperEnvironment PAPERLIB_API io.papermc.lib.environments.PaperEnvironment
|
||||
art.arcane.iris.core.link.ExternalDataProvider BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.link.data.CraftEngineDataProvider BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.link.data.EcoItemsDataProvider BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.link.data.ExecutableItemsDataProvider BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.link.data.HMCLeavesDataProvider BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.link.data.ItemAdderDataProvider BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.link.data.KGeneratorsDataProvider BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.link.data.MMOItemsDataProvider BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.link.data.MythicCrucibleDataProvider BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.link.data.MythicMobsDataProvider BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.link.data.MythicMobsDataProvider$IrisBiomeCondition MYTHICMOBS_API io.lumine.mythic.api.skills.conditions.ILocationCondition
|
||||
art.arcane.iris.core.link.data.MythicMobsDataProvider$IrisRegionCondition MYTHICMOBS_API io.lumine.mythic.api.skills.conditions.ILocationCondition
|
||||
art.arcane.iris.core.link.data.NexoDataProvider BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.nms.INMS BUKKIT_API org.bukkit.Bukkit
|
||||
art.arcane.iris.core.project.IrisProject BUKKIT_API org.bukkit.entity.Entity
|
||||
art.arcane.iris.core.runtime.WorldRuntimeControlService BUKKIT_API org.bukkit.entity.Entity
|
||||
art.arcane.iris.core.service.BoardSVC BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.service.BoardSVC$PlayerBoard BUKKIT_API org.bukkit.entity.Entity
|
||||
art.arcane.iris.core.service.ExternalDataSVC BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.service.GlobalCacheSVC BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.service.LogFilterSVC BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.service.ObjectSVC BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.service.ObjectStudioSaveService BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.service.PreservationSVC BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.service.StudioSVC BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.service.TreeSVC BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.core.tools.IrisToolbelt BUKKIT_API org.bukkit.command.CommandSender
|
||||
art.arcane.iris.core.tools.IrisWorldCreator BUKKIT_API org.bukkit.generator.ChunkGenerator
|
||||
art.arcane.iris.core.tools.TreePlausibilizer BUKKIT_API org.bukkit.Keyed
|
||||
art.arcane.iris.engine.IrisWorldManager BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.engine.framework.EngineAssignedWorldManager BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.engine.framework.placer.WorldObjectPlacer BUKKIT_API org.bukkit.event.Event
|
||||
art.arcane.iris.engine.object.IrisDirection$11 BUKKIT_API org.bukkit.block.BlockFace
|
||||
art.arcane.iris.engine.object.IrisEffect$BukkitFx BUKKIT_API org.bukkit.entity.Entity
|
||||
art.arcane.iris.engine.object.IrisEntity$BukkitOps BUKKIT_API org.bukkit.loot.LootTable
|
||||
art.arcane.iris.engine.object.IrisEntity$BukkitOps$1 BUKKIT_API org.bukkit.loot.LootTable
|
||||
art.arcane.iris.engine.object.IrisObjectRotation$1 BUKKIT_API org.bukkit.block.BlockFace
|
||||
art.arcane.iris.engine.object.IrisObjectRotation$Faces BUKKIT_API org.bukkit.block.BlockFace
|
||||
art.arcane.iris.engine.object.LegacyTileData$BannerHandler BUKKIT_API org.bukkit.Keyed
|
||||
art.arcane.iris.engine.object.LegacyTileData$Handlers BUKKIT_API org.bukkit.block.BlockState
|
||||
art.arcane.iris.engine.object.LegacyTileData$SignHandler BUKKIT_API org.bukkit.Keyed
|
||||
art.arcane.iris.engine.object.LegacyTileData$SignTags BUKKIT_API org.bukkit.Tag
|
||||
art.arcane.iris.engine.object.LegacyTileData$SignTags$1 BUKKIT_API org.bukkit.Tag
|
||||
art.arcane.iris.engine.platform.BukkitChunkGenerator BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.engine.platform.BukkitChunkGenerator$2 BUKKIT_API org.bukkit.HeightMap
|
||||
art.arcane.iris.engine.platform.DummyBiomeProvider BUKKIT_API org.bukkit.generator.BiomeProvider
|
||||
art.arcane.iris.engine.platform.DummyChunkGenerator BUKKIT_API org.bukkit.generator.ChunkGenerator
|
||||
art.arcane.iris.engine.platform.EngineBukkitOps BUKKIT_API org.bukkit.entity.Entity
|
||||
art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator BUKKIT_API org.bukkit.Material
|
||||
art.arcane.iris.platform.bukkit.BukkitBlockResolution BUKKIT_API org.bukkit.Material
|
||||
art.arcane.iris.platform.bukkit.BukkitPlatform BUKKIT_API org.bukkit.command.CommandSender
|
||||
art.arcane.iris.util.common.data.IrisCustomData BUKKIT_API org.bukkit.block.data.BlockData
|
||||
art.arcane.iris.util.common.data.registry.Attributes BUKKIT_API org.bukkit.attribute.Attribute
|
||||
art.arcane.iris.util.common.data.registry.Materials BUKKIT_API org.bukkit.Material
|
||||
art.arcane.iris.util.common.data.registry.Particles BUKKIT_API org.bukkit.Particle
|
||||
art.arcane.iris.util.common.director.handlers.VectorHandler BUKKIT_API org.bukkit.util.Vector
|
||||
art.arcane.iris.util.common.format.C$DyeMaps BUKKIT_API org.bukkit.DyeColor
|
||||
art.arcane.iris.util.common.inventorygui.WindowResolution BUKKIT_API org.bukkit.event.inventory.InventoryType
|
||||
art.arcane.iris.util.common.misc.Bindings BUKKIT_API org.bukkit.plugin.Plugin
|
||||
art.arcane.iris.util.common.misc.ServerProperties SERVER_RUNTIME_FILE server.properties
|
||||
art.arcane.iris.util.common.misc.SlimJar BUKKIT_API org.bukkit.plugin.Plugin
|
||||
art.arcane.iris.util.common.plugin.CommandDummy BUKKIT_API org.bukkit.command.CommandSender
|
||||
art.arcane.iris.util.common.plugin.Controller BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.util.common.plugin.IController BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.util.common.plugin.IrisService BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.util.common.plugin.RouterCommand BUKKIT_API org.bukkit.command.Command
|
||||
art.arcane.iris.util.common.plugin.VolmitPlugin BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.util.common.plugin.VolmitSender BUKKIT_API org.bukkit.command.CommandSender
|
||||
art.arcane.iris.util.common.plugin.chunk.ChunkTickets BUKKIT_API org.bukkit.event.Listener
|
||||
art.arcane.iris.util.nbt.common.mca.NBTWorld$Holder BUKKIT_API org.bukkit.Material
|
||||
art.arcane.iris.util.project.sentry.ServerID$Holder BUKKIT_API org.bukkit.Bukkit
|
||||
|
@@ -0,0 +1,204 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.Rule;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class ClassloadProbeTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void scansNamedAndGeneratedNestedClasses() throws Exception {
|
||||
Path classesRoot = temporaryFolder.newFolder("classes").toPath();
|
||||
Path packageRoot = Files.createDirectories(classesRoot.resolve("art/arcane/iris"));
|
||||
Files.createFile(packageRoot.resolve("Outer.class"));
|
||||
Files.createFile(packageRoot.resolve("Outer$Nested.class"));
|
||||
Files.createFile(packageRoot.resolve("Outer$1.class"));
|
||||
Files.createFile(packageRoot.resolve("ignored.txt"));
|
||||
|
||||
List<String> names = ClassloadProbe.scanClassNames(classesRoot);
|
||||
|
||||
assertEquals(List.of(
|
||||
"art.arcane.iris.Outer",
|
||||
"art.arcane.iris.Outer$1",
|
||||
"art.arcane.iris.Outer$Nested"
|
||||
), names);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scansEmptyClassDirectory() throws Exception {
|
||||
Path classesRoot = temporaryFolder.newFolder("empty-classes").toPath();
|
||||
|
||||
assertTrue(ClassloadProbe.scanClassNames(classesRoot).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsMatchingReviewedFailure() {
|
||||
ClassloadProbe.Failure failure = new ClassloadProbe.Failure(
|
||||
"java.lang.ClassNotFoundException",
|
||||
"org.bukkit.event.Listener"
|
||||
);
|
||||
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
|
||||
ClassloadProbe.AllowanceCategory.BUKKIT_API,
|
||||
"org.bukkit.event.Listener"
|
||||
);
|
||||
|
||||
ClassloadProbe.Review review = ClassloadProbe.review(
|
||||
Map.of("art.arcane.iris.Allowed", failure),
|
||||
Map.of("art.arcane.iris.Allowed", allowance)
|
||||
);
|
||||
|
||||
assertTrue(review.passes());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnlistedFailure() {
|
||||
ClassloadProbe.Failure failure = new ClassloadProbe.Failure(
|
||||
"java.lang.ClassNotFoundException",
|
||||
"org.bukkit.event.Listener"
|
||||
);
|
||||
|
||||
ClassloadProbe.Review review = ClassloadProbe.review(
|
||||
Map.of("art.arcane.iris.Unlisted", failure),
|
||||
Map.of()
|
||||
);
|
||||
|
||||
assertFalse(review.passes());
|
||||
assertTrue(review.unexpected().containsKey("art.arcane.iris.Unlisted"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsDifferentMissingClassInReviewedNamespace() {
|
||||
ClassloadProbe.Failure changedFailure = new ClassloadProbe.Failure(
|
||||
"java.lang.ClassNotFoundException",
|
||||
"org.bukkit.Material"
|
||||
);
|
||||
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
|
||||
ClassloadProbe.AllowanceCategory.BUKKIT_API,
|
||||
"org.bukkit.event.Listener"
|
||||
);
|
||||
|
||||
ClassloadProbe.Review review = ClassloadProbe.review(
|
||||
Map.of("art.arcane.iris.Changed", changedFailure),
|
||||
Map.of("art.arcane.iris.Changed", allowance)
|
||||
);
|
||||
|
||||
assertTrue(review.passes());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsChangedFailureNamespace() {
|
||||
ClassloadProbe.Failure changedFailure = new ClassloadProbe.Failure(
|
||||
"java.lang.ClassNotFoundException",
|
||||
"io.papermc.lib.environments.Environment"
|
||||
);
|
||||
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
|
||||
ClassloadProbe.AllowanceCategory.BUKKIT_API,
|
||||
"org.bukkit.event.Listener"
|
||||
);
|
||||
|
||||
ClassloadProbe.Review review = ClassloadProbe.review(
|
||||
Map.of("art.arcane.iris.Changed", changedFailure),
|
||||
Map.of("art.arcane.iris.Changed", allowance)
|
||||
);
|
||||
|
||||
assertFalse(review.passes());
|
||||
assertTrue(review.unexpected().containsKey("art.arcane.iris.Changed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsNonMissingClassFailure() {
|
||||
ClassloadProbe.Failure changedFailure = new ClassloadProbe.Failure(
|
||||
"java.lang.IllegalStateException",
|
||||
"org.bukkit.event.Listener"
|
||||
);
|
||||
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
|
||||
ClassloadProbe.AllowanceCategory.BUKKIT_API,
|
||||
"org.bukkit.event.Listener"
|
||||
);
|
||||
|
||||
assertFalse(allowance.matches(changedFailure));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsBukkitFailureCachedByClassInitialization() {
|
||||
ClassloadProbe.Failure failure = new ClassloadProbe.Failure(
|
||||
"java.lang.ExceptionInInitializerError",
|
||||
"Exception java.lang.NoClassDefFoundError: org/bukkit/Material [in thread \"main\"]"
|
||||
);
|
||||
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
|
||||
ClassloadProbe.AllowanceCategory.BUKKIT_API,
|
||||
"org.bukkit.Material"
|
||||
);
|
||||
|
||||
assertTrue(allowance.matches(failure));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsDifferentBukkitFailureCachedByClassInitialization() {
|
||||
ClassloadProbe.Failure failure = new ClassloadProbe.Failure(
|
||||
"java.lang.ExceptionInInitializerError",
|
||||
"Exception java.lang.NoClassDefFoundError: org/bukkit/World [in thread \"main\"]"
|
||||
);
|
||||
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
|
||||
ClassloadProbe.AllowanceCategory.BUKKIT_API,
|
||||
"org.bukkit.Material"
|
||||
);
|
||||
|
||||
assertTrue(allowance.matches(failure));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsDifferentNamespaceCachedByClassInitialization() {
|
||||
ClassloadProbe.Failure failure = new ClassloadProbe.Failure(
|
||||
"java.lang.ExceptionInInitializerError",
|
||||
"Exception java.lang.NoClassDefFoundError: io/papermc/lib/environments/Environment [in thread \"main\"]"
|
||||
);
|
||||
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
|
||||
ClassloadProbe.AllowanceCategory.BUKKIT_API,
|
||||
"org.bukkit.Material"
|
||||
);
|
||||
|
||||
assertFalse(allowance.matches(failure));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsMythicMobsApiNamespace() {
|
||||
ClassloadProbe.Failure failure = new ClassloadProbe.Failure(
|
||||
"java.lang.ClassNotFoundException",
|
||||
"io.lumine.mythic.api.skills.conditions.ILocationCondition"
|
||||
);
|
||||
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
|
||||
ClassloadProbe.AllowanceCategory.MYTHICMOBS_API,
|
||||
"io.lumine.mythic.api.skills.conditions.ILocationCondition"
|
||||
);
|
||||
|
||||
assertTrue(allowance.matches(failure));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsStaleAllowance() {
|
||||
ClassloadProbe.Allowance allowance = new ClassloadProbe.Allowance(
|
||||
ClassloadProbe.AllowanceCategory.BUKKIT_API,
|
||||
"org.bukkit.event.Listener"
|
||||
);
|
||||
TreeMap<String, ClassloadProbe.Allowance> allowlist = new TreeMap<>();
|
||||
allowlist.put("art.arcane.iris.Stale", allowance);
|
||||
|
||||
ClassloadProbe.Review review = ClassloadProbe.review(Map.of(), allowlist);
|
||||
|
||||
assertFalse(review.passes());
|
||||
assertTrue(review.staleAllowances().containsKey("art.arcane.iris.Stale"));
|
||||
}
|
||||
}
|
||||
@@ -26,5 +26,7 @@ public interface PlatformEntityType {
|
||||
|
||||
String namespace();
|
||||
|
||||
String spawnCategory();
|
||||
|
||||
Object nativeHandle();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ macOS / Linux:
|
||||
./run.sh
|
||||
```
|
||||
|
||||
The run scripts compile the sources and create the ignored `simd-bench.jar` locally when it is
|
||||
missing. Use `build.bat` or `./build.sh` to rebuild it explicitly.
|
||||
|
||||
Or invoke directly (the `--add-modules` flag is required because the Vector API
|
||||
is still an incubator module):
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user