This commit is contained in:
Brian Neumann-Fopiano
2026-07-10 04:05:24 -04:00
parent 6111b5670a
commit 402bcb04fe
112 changed files with 5610 additions and 733 deletions
@@ -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;
}
}
@@ -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);
}
}
@@ -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
@@ -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);
}
}
@@ -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"));
@@ -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();
@@ -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.");
}
}
}
@@ -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();
}
@@ -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++) {
@@ -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));
}
}
@@ -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();
}
}
}
@@ -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));
}
}