mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-22 16:17:44 +00:00
Improve generation and lifecycle safety
- Reduce terrain, noise, hydrology, and decorator sampling overhead. - Reduce object transaction allocations while preserving rollback behavior. - Optimize structure placement and cave anchor sampling. - Parallelize Studio cache warmup and protect world entry and hotload recovery. - Validate pack fingerprints and publish pack replacements atomically. - Notify modded operators about outdated packs without synchronizing pack data. - Harden world creation, loading, evacuation, and teleport failure recovery. - Coalesce queued region flushes while preserving chunk save ordering. - Remove obsolete mantle, pack, and cave planning code. - Add regression tests and reusable gameplay fixtures.
This commit is contained in:
1 parent
a2bcd7a302
commit
7f5ce70f49
197 files changed
+7560
-4420
No files matched your search
@@ -18,6 +18,8 @@
|
||||
|
||||
package art.arcane.iris.command;
|
||||
|
||||
import art.arcane.iris.pack.PackFingerprints;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.pack.validation.PackCompatReport;
|
||||
import art.arcane.iris.pack.PackDirectoryResolver;
|
||||
@@ -174,7 +176,6 @@ public class CommandPack implements DirectorExecutor {
|
||||
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
|
||||
return;
|
||||
}
|
||||
PackValidationRegistry.remove(packFolder.getName());
|
||||
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath()))));
|
||||
reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED);
|
||||
return;
|
||||
@@ -230,7 +231,6 @@ public class CommandPack implements DirectorExecutor {
|
||||
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
|
||||
return;
|
||||
}
|
||||
PackValidationRegistry.remove(packFolder.getName());
|
||||
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath()))));
|
||||
reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED);
|
||||
return;
|
||||
@@ -363,47 +363,79 @@ public class CommandPack implements DirectorExecutor {
|
||||
return persisted == null || persisted.isBlank() ? "unknown" : persisted;
|
||||
}
|
||||
|
||||
private PackValidationResult runValidate(VolmitSender s, File packFolder) {
|
||||
PackValidationResult runValidate(VolmitSender s, File packFolder) {
|
||||
PackValidationRegistry.ValidationTicket ticket =
|
||||
PackValidationRegistry.tryBeginValidation(packFolder.toPath());
|
||||
PackValidationResult result;
|
||||
String contentFingerprint = "";
|
||||
String contextFingerprint = "";
|
||||
try {
|
||||
PackValidationResult result = PackValidator.validate(packFolder);
|
||||
PackValidationRegistry.publish(result);
|
||||
reportResult(s, result);
|
||||
return result;
|
||||
if (ticket == null) {
|
||||
throw new IllegalStateException("Pack content is currently being changed.");
|
||||
}
|
||||
contentFingerprint = PackFingerprints.computePackTreeFingerprint(packFolder);
|
||||
contextFingerprint = PackValidationCache.contextFingerprint();
|
||||
result = PackValidator.validate(packFolder);
|
||||
if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packFolder))
|
||||
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
|
||||
throw new IllegalStateException("Pack content or validation context changed during validation.");
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e);
|
||||
String detail = e.getMessage() == null || e.getMessage().isBlank()
|
||||
? e.getClass().getSimpleName()
|
||||
: e.getMessage();
|
||||
PackValidationResult result = new PackValidationResult(
|
||||
result = new PackValidationResult(
|
||||
packFolder.getName(),
|
||||
List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail),
|
||||
List.of(),
|
||||
System.currentTimeMillis());
|
||||
PackValidationRegistry.publish(result);
|
||||
reportResult(s, result);
|
||||
return result;
|
||||
contentFingerprint = "";
|
||||
contextFingerprint = "";
|
||||
}
|
||||
if (PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
|
||||
PackValidationRegistry.publish(result);
|
||||
} else {
|
||||
result = new PackValidationResult(packFolder.getName(),
|
||||
List.of("Pack content changed during validation."), List.of(), System.currentTimeMillis());
|
||||
}
|
||||
reportResult(s, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void persistValidationCache(File packsRoot) {
|
||||
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
|
||||
List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
|
||||
for (File packDirectory : packDirectories) {
|
||||
PackValidationResult result = PackValidationRegistry.get(packDirectory.getName());
|
||||
if (result == null) {
|
||||
// The boot cache only loads when it covers every pack, so a partial write is
|
||||
// pointless - but say so instead of silently skipping the persist forever.
|
||||
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
|
||||
+ "\" has no registered result. Run /iris pack validate all to repopulate it.");
|
||||
try {
|
||||
String contextFingerprint = PackValidationCache.contextFingerprint();
|
||||
PackFingerprints.PackContentSnapshot snapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
|
||||
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
|
||||
List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
|
||||
for (File packDirectory : packDirectories) {
|
||||
PackValidationResult result = PackValidationRegistry.getMatching(
|
||||
packDirectory.toPath(), snapshot.packContents().get(packDirectory.getName()));
|
||||
if (result == null) {
|
||||
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
|
||||
+ "\" has no validation matching its current content. Run /iris pack validate to refresh it.");
|
||||
return;
|
||||
}
|
||||
results.add(result);
|
||||
}
|
||||
if (!snapshot.content().equals(PackFingerprints.computePackContentSnapshot(packsRoot).content())
|
||||
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
|
||||
Iris.warn("Pack validation cache not written because pack content or validation context changed.");
|
||||
return;
|
||||
}
|
||||
results.add(result);
|
||||
}
|
||||
try {
|
||||
for (int index = 0; index < packDirectories.size(); index++) {
|
||||
File packDirectory = packDirectories.get(index);
|
||||
if (PackValidationRegistry.getMatching(packDirectory.toPath(),
|
||||
snapshot.packContents().get(packDirectory.getName())) != results.get(index)) {
|
||||
Iris.warn("Pack validation cache not written because validation changed during persistence.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
PackValidationCache.save(
|
||||
Iris.instance.getDataFile("cache", "pack-validation.json").toPath(),
|
||||
PackValidationCache.contentFingerprint(packsRoot),
|
||||
PackValidationCache.contextFingerprint(),
|
||||
snapshot.content(),
|
||||
contextFingerprint,
|
||||
results);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
Iris.reportError("Could not persist refreshed pack-validation results", e);
|
||||
|
||||
+11
-10
@@ -18,8 +18,9 @@
|
||||
|
||||
package art.arcane.iris.world;
|
||||
|
||||
import art.arcane.iris.pack.PackFingerprints;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.pack.datapack.ServerConfigurator;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.world.lifecycle.WorldLifecycleStaging;
|
||||
@@ -184,12 +185,12 @@ public final class IrisWorldGeneratorResolver {
|
||||
}
|
||||
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
|
||||
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
|
||||
ServerConfigurator.PackContentSnapshot contentSnapshot =
|
||||
new ServerConfigurator.PackContentSnapshot("", Map.of());
|
||||
PackFingerprints.PackContentSnapshot contentSnapshot =
|
||||
new PackFingerprints.PackContentSnapshot("", Map.of());
|
||||
String contextFingerprint = "";
|
||||
Optional<List<PackValidationResult>> cached = Optional.empty();
|
||||
try {
|
||||
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot);
|
||||
contentSnapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
|
||||
contextFingerprint = PackValidationCache.contextFingerprint();
|
||||
if (!externalContentChanged) {
|
||||
cached = PackValidationCache.load(
|
||||
@@ -274,19 +275,19 @@ public final class IrisWorldGeneratorResolver {
|
||||
private static FreshValidation validateStablePacks(
|
||||
File packsRoot,
|
||||
List<File> initialPackDirs,
|
||||
ServerConfigurator.PackContentSnapshot initialSnapshot
|
||||
PackFingerprints.PackContentSnapshot initialSnapshot
|
||||
) {
|
||||
List<File> packDirs = initialPackDirs;
|
||||
ServerConfigurator.PackContentSnapshot before = initialSnapshot;
|
||||
PackFingerprints.PackContentSnapshot before = initialSnapshot;
|
||||
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
|
||||
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
|
||||
List<PackValidationResult> results = validatePacks(packDirs);
|
||||
ServerConfigurator.PackContentSnapshot after;
|
||||
PackFingerprints.PackContentSnapshot after;
|
||||
try {
|
||||
after = ServerConfigurator.computePackContentSnapshot(packsRoot);
|
||||
after = PackFingerprints.computePackContentSnapshot(packsRoot);
|
||||
} catch (RuntimeException exception) {
|
||||
Iris.reportError("Could not verify Iris pack bytes after validation", exception);
|
||||
after = new ServerConfigurator.PackContentSnapshot("", Map.of());
|
||||
after = new PackFingerprints.PackContentSnapshot("", Map.of());
|
||||
}
|
||||
List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
|
||||
List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
|
||||
@@ -923,7 +924,7 @@ public final class IrisWorldGeneratorResolver {
|
||||
|
||||
private record FreshValidation(
|
||||
List<File> packDirs,
|
||||
ServerConfigurator.PackContentSnapshot contentSnapshot,
|
||||
PackFingerprints.PackContentSnapshot contentSnapshot,
|
||||
List<PackValidationResult> results,
|
||||
boolean stable
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"derivative": "minecraft:plains",
|
||||
"vanillaDerivative": "minecraft:plains",
|
||||
"layers": [
|
||||
{
|
||||
"minHeight": 1,
|
||||
"maxHeight": 1,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:lime_wool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"minHeight": 384,
|
||||
"maxHeight": 384,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:green_concrete"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generators": [
|
||||
{
|
||||
"generator": "flat",
|
||||
"min": 96,
|
||||
"max": 96
|
||||
}
|
||||
],
|
||||
"decorators": [
|
||||
{
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:oak_stairs",
|
||||
"data": {
|
||||
"half": "bottom",
|
||||
"facing": "north"
|
||||
}
|
||||
}
|
||||
],
|
||||
"chance": 1,
|
||||
"forcePlace": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "Decorator Fixture",
|
||||
"version": 1,
|
||||
"mode": {
|
||||
"type": "OVERWORLD"
|
||||
},
|
||||
"regions": [
|
||||
"flat"
|
||||
],
|
||||
"focusRegion": "flat",
|
||||
"focus": "flat",
|
||||
"landChance": 1,
|
||||
"environment": "NORMAL",
|
||||
"dimensionHeight": {
|
||||
"min": -64,
|
||||
"max": 320
|
||||
},
|
||||
"logicalHeight": 384,
|
||||
"fluidHeight": 0,
|
||||
"bedrock": false,
|
||||
"rockPalette": {
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:red_concrete"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"interpolator": {
|
||||
"function": "NONE",
|
||||
"horizontalScale": 1
|
||||
},
|
||||
"seed": 310,
|
||||
"composite": [
|
||||
{
|
||||
"seed": 310,
|
||||
"style": {
|
||||
"style": "FLAT"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"landBiomes": [
|
||||
"flat"
|
||||
],
|
||||
"seaBiomes": [
|
||||
"flat"
|
||||
],
|
||||
"shoreBiomes": [
|
||||
"flat"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
name: 'iris-decorators',
|
||||
description: 'Verify authored stair decorations occupy one block and retain their half state.',
|
||||
async run(context) {
|
||||
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'Decorator generation requires an isolated instance')
|
||||
await context.step('create a world with authored stair decorations', async () => {
|
||||
const response = await context.command('/iris create name=decorator_probe type=decorator-fixture seed=78264193',
|
||||
/Successfully created your world|Failed to create/, 180000)
|
||||
context.expect(response.includes('Successfully created your world'), response)
|
||||
})
|
||||
await context.step('client sees single bottom-half stair decorations', async () => {
|
||||
let checked = 0
|
||||
await context.waitUntil(() => {
|
||||
checked = 0
|
||||
const center = context.bot.entity.position.floored()
|
||||
for (let x = -6; x <= 6; x++) {
|
||||
for (let z = -6; z <= 6; z++) {
|
||||
for (let y = -4; y <= 1; y++) {
|
||||
const position = center.offset(x, y, z)
|
||||
if (context.bot.blockAt(position)?.name !== 'lime_wool') continue
|
||||
const decoration = context.bot.blockAt(position.offset(0, 1, 0))
|
||||
const above = context.bot.blockAt(position.offset(0, 2, 0))
|
||||
if (!decoration || !above) return false
|
||||
if (decoration.name !== 'oak_stairs' || decoration.getProperties().half !== 'bottom'
|
||||
|| above.name !== 'air') return false
|
||||
checked++
|
||||
}
|
||||
}
|
||||
}
|
||||
return checked >= 100
|
||||
}, { label: 'At least 100 authored stair columns with empty space above', timeoutMs: 60000, intervalMs: 250 })
|
||||
context.expect(checked >= 100, 'Client verified generated decorator columns', { checked })
|
||||
context.report.decoratorColumns = checked
|
||||
})
|
||||
}
|
||||
}
|
||||
+71
-1
@@ -4,6 +4,13 @@ import art.arcane.volmlib.nativelib.NativeAdapters;
|
||||
import art.arcane.iris.world.runtime.RuntimeInjection;
|
||||
import art.arcane.iris.world.IrisCreator;
|
||||
import art.arcane.iris.world.IrisToolbelt;
|
||||
import art.arcane.iris.world.IrisWorldStorage;
|
||||
import art.arcane.iris.world.WorldCreatorCompat;
|
||||
import art.arcane.iris.world.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.platform.bootstrap.ServerProperties;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.Biome;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
|
||||
@@ -19,6 +26,10 @@ import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
@@ -26,6 +37,7 @@ import java.util.logging.Level;
|
||||
public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution {
|
||||
private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe");
|
||||
private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe");
|
||||
private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe");
|
||||
private static final long WORLD_SEED = 78264193L;
|
||||
private NativeWorldRuntime runtime;
|
||||
|
||||
@@ -45,7 +57,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (arguments.length != 1) {
|
||||
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload");
|
||||
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
@@ -56,6 +68,8 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
case "terrain-create" -> createTerrain(sender);
|
||||
case "terrain-check" -> checkTerrain(sender);
|
||||
case "terrain-unload" -> unloadTerrain(sender);
|
||||
case "lifecycle-status" -> lifecycleStatus(sender);
|
||||
case "lifecycle-entry" -> lifecycleEntry(sender);
|
||||
case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available()
|
||||
+ " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null));
|
||||
default -> throw new IllegalArgumentException("Unknown lifecycle operation");
|
||||
@@ -66,6 +80,61 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
return true;
|
||||
}
|
||||
|
||||
private void lifecycleStatus(CommandSender sender) throws Exception {
|
||||
World world = Bukkit.getWorld(COMMAND_KEY);
|
||||
Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath();
|
||||
Path levelRoot = IrisWorldStorage.levelRoot().toPath();
|
||||
Path registry = levelRoot.resolve("iris/worlds.json");
|
||||
boolean registered = Files.exists(registry)
|
||||
&& Files.readString(registry).contains("\"" + COMMAND_KEY + "\"");
|
||||
String startupName = IrisWorldStorage.configuredWorldName(COMMAND_KEY, levelRoot.getFileName().toString());
|
||||
YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
|
||||
boolean configured = configuration.getConfigurationSection("worlds") != null
|
||||
&& configuration.getConfigurationSection("worlds").contains(startupName);
|
||||
File quarantineRoot = levelRoot.resolve("dimensions/iris").toFile();
|
||||
File[] quarantines = quarantineRoot.listFiles((directory, name) -> name.startsWith(".iris-delete-"));
|
||||
int quarantineCount = quarantines == null ? 0 : quarantines.length;
|
||||
Path queueFile = Bukkit.getPluginManager().getPlugin("Iris").getDataFolder().toPath()
|
||||
.resolve("pending-world-deletes.txt");
|
||||
List<String> queue = Files.exists(queueFile) ? Files.readAllLines(queueFile) : List.of();
|
||||
boolean queued = quarantineCount > 0;
|
||||
if (quarantines != null) {
|
||||
for (File quarantine : quarantines) {
|
||||
queued &= queue.contains(quarantine.getName());
|
||||
}
|
||||
}
|
||||
boolean disk = Files.exists(storage, LinkOption.NOFOLLOW_LINKS);
|
||||
String cleanup = disk ? "present" : quarantineCount == 0 ? "deleted" : queued ? "queued" : "unsafe";
|
||||
boolean idle = true;
|
||||
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
|
||||
for (LifecycleOperationCoordinator.Domain domain : LifecycleOperationCoordinator.Domain.values()) {
|
||||
idle &= coordinator.active(domain).isEmpty();
|
||||
}
|
||||
sender.sendMessage("IRIS_LIFECYCLE loaded=" + (world != null) + " registered=" + registered
|
||||
+ " configured=" + configured + " disk=" + disk + " quarantines=" + quarantineCount
|
||||
+ " cleanup=" + cleanup + " idle=" + idle);
|
||||
}
|
||||
|
||||
private void lifecycleEntry(CommandSender sender) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
throw new IllegalArgumentException("Lifecycle entry requires a player");
|
||||
}
|
||||
World world = Bukkit.getWorld(COMMAND_KEY);
|
||||
if (world == null || player.getWorld() != world || world.getSeed() != WORLD_SEED
|
||||
|| !IrisToolbelt.isIrisWorld(world)) {
|
||||
throw new IllegalStateException("Player did not enter the requested seeded Iris world");
|
||||
}
|
||||
Location feet = player.getLocation();
|
||||
Material floor = world.getBlockAt(feet.getBlockX(), feet.getBlockY() - 1, feet.getBlockZ()).getType();
|
||||
if (floor != Material.LIME_WOOL || !floor.isSolid()
|
||||
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY(), feet.getBlockZ()).isPassable()
|
||||
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY() + 1, feet.getBlockZ()).isPassable()) {
|
||||
throw new IllegalStateException("Lifecycle player entry lacks a safe fixture floor");
|
||||
}
|
||||
sender.sendMessage("IRIS_LIFECYCLE_ENTRY world=" + COMMAND_KEY + " seed=" + world.getSeed()
|
||||
+ " floor=lime_wool safe=true");
|
||||
}
|
||||
|
||||
private void create(CommandSender sender) throws ReflectiveOperationException {
|
||||
if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) {
|
||||
throw new IllegalStateException("Native runtime unavailable or probe world already loaded");
|
||||
@@ -124,6 +193,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
int y = world.getHighestBlockYAt(x, z);
|
||||
if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL
|
||||
|| world.getBlockAt(x, y - 3, z).getType() != Material.GREEN_CONCRETE
|
||||
|| world.getBlockAt(x, world.getMinHeight(), z).getType() != Material.GREEN_CONCRETE
|
||||
|| world.getBiome(x, y, z) != Biome.PLAINS) {
|
||||
throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z
|
||||
+ ": " + world.getBlockAt(x, y, z).getType());
|
||||
|
||||
@@ -11,6 +11,15 @@
|
||||
"block": "minecraft:lime_wool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"minHeight": 384,
|
||||
"maxHeight": 384,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:green_concrete"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generators": [
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"rockPalette": {
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:green_concrete"
|
||||
"block": "minecraft:red_concrete"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"derivative": "minecraft:plains",
|
||||
"vanillaDerivative": "minecraft:plains",
|
||||
"layers": [
|
||||
{
|
||||
"minHeight": 1,
|
||||
"maxHeight": 1,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:lime_wool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"minHeight": 384,
|
||||
"maxHeight": 384,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:green_concrete"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generators": [
|
||||
{
|
||||
"generator": "flat",
|
||||
"min": 64,
|
||||
"max": 96
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "Noise Sources",
|
||||
"version": 1,
|
||||
"mode": {
|
||||
"type": "OVERWORLD"
|
||||
},
|
||||
"regions": [
|
||||
"flat"
|
||||
],
|
||||
"focusRegion": "flat",
|
||||
"focus": "flat",
|
||||
"landChance": 1,
|
||||
"environment": "NORMAL",
|
||||
"dimensionHeight": {
|
||||
"min": -64,
|
||||
"max": 320
|
||||
},
|
||||
"logicalHeight": 384,
|
||||
"fluidHeight": 0,
|
||||
"bedrock": false,
|
||||
"rockPalette": {
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:red_concrete"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"expression": "0.5 + sin(x * 0.15) * 0.25 + cos(y * 0.13) * 0.25"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"interpolator": {
|
||||
"function": "NONE",
|
||||
"horizontalScale": 1
|
||||
},
|
||||
"seed": 310,
|
||||
"composite": [
|
||||
{
|
||||
"seed": 310,
|
||||
"style": {
|
||||
"expression": "terrain"
|
||||
}
|
||||
},
|
||||
{
|
||||
"seed": 311,
|
||||
"style": {
|
||||
"style": "CLOVER_HERMITE",
|
||||
"zoom": 0.1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"landBiomes": [
|
||||
"flat"
|
||||
],
|
||||
"seaBiomes": [
|
||||
"flat"
|
||||
],
|
||||
"shoreBiomes": [
|
||||
"flat"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
name: 'iris-noise',
|
||||
description: 'Verify expression and interpolated noise produce varied, bounded terrain visible to a player.',
|
||||
async run(context) {
|
||||
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'Noise generation requires an isolated instance')
|
||||
await context.step('create a world with expression and Clover noise', async () => {
|
||||
const response = await context.command('/iris create name=noise_probe type=noise-fixture seed=78264193',
|
||||
/Successfully created your world|Failed to create/, 180000)
|
||||
context.expect(response.includes('Successfully created your world'), response)
|
||||
})
|
||||
await context.step('client observes bounded terrain with varying heights', async () => {
|
||||
let columns = 0
|
||||
let minimum = Infinity
|
||||
let maximum = -Infinity
|
||||
await context.waitUntil(() => {
|
||||
columns = 0
|
||||
minimum = Infinity
|
||||
maximum = -Infinity
|
||||
const center = context.bot.entity.position.floored()
|
||||
for (let x = -8; x <= 8; x++) {
|
||||
for (let z = -8; z <= 8; z++) {
|
||||
let surface = null
|
||||
for (let y = 60; y <= 100; y++) {
|
||||
const position = center.offset(x, y - center.y, z)
|
||||
const block = context.bot.blockAt(position)
|
||||
if (!block) return false
|
||||
if (block.name === 'lime_wool') {
|
||||
if (surface !== null || context.bot.blockAt(position.offset(0, 1, 0))?.name !== 'air') return false
|
||||
surface = y
|
||||
}
|
||||
}
|
||||
if (surface === null || surface < 64 || surface > 96) return false
|
||||
columns++
|
||||
minimum = Math.min(minimum, surface)
|
||||
maximum = Math.max(maximum, surface)
|
||||
}
|
||||
}
|
||||
return columns === 289 && maximum - minimum >= 3
|
||||
}, { label: '289 noise columns with bounded, varied surfaces', timeoutMs: 60000, intervalMs: 250 })
|
||||
context.expect(columns === 289 && maximum - minimum >= 3, 'Client verified noise terrain', { columns, minimum, maximum })
|
||||
context.report.noiseTerrain = { columns, minimum, maximum }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"derivative": "minecraft:plains",
|
||||
"vanillaDerivative": "minecraft:plains",
|
||||
"layers": [
|
||||
{
|
||||
"minHeight": 1,
|
||||
"maxHeight": 1,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:lime_wool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"minHeight": 384,
|
||||
"maxHeight": 384,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:stone"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generators": [
|
||||
{
|
||||
"generator": "flat",
|
||||
"min": 64,
|
||||
"max": 64
|
||||
}
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"place": [
|
||||
"object"
|
||||
],
|
||||
"chance": 1,
|
||||
"density": 4,
|
||||
"mode": "CENTER_HEIGHT",
|
||||
"rotation": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "Structure Fixture",
|
||||
"version": 1,
|
||||
"mode": {
|
||||
"type": "OVERWORLD"
|
||||
},
|
||||
"regions": [
|
||||
"flat"
|
||||
],
|
||||
"focusRegion": "flat",
|
||||
"focus": "flat",
|
||||
"landChance": 1,
|
||||
"environment": "NORMAL",
|
||||
"dimensionHeight": {
|
||||
"min": -64,
|
||||
"max": 320
|
||||
},
|
||||
"logicalHeight": 384,
|
||||
"fluidHeight": 0,
|
||||
"bedrock": false,
|
||||
"rockPalette": {
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:stone"
|
||||
}
|
||||
]
|
||||
},
|
||||
"structures": [
|
||||
{
|
||||
"structures": [
|
||||
"surface"
|
||||
],
|
||||
"placementId": "surface",
|
||||
"distribution": "DENSITY",
|
||||
"density": 1,
|
||||
"anchor": "SURFACE",
|
||||
"terrain": {
|
||||
"mode": "PRESERVE"
|
||||
},
|
||||
"stilt": {
|
||||
"maxDepth": 16,
|
||||
"palette": {
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:diamond_block"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"structures": [
|
||||
"buried"
|
||||
],
|
||||
"placementId": "buried",
|
||||
"distribution": "DENSITY",
|
||||
"density": 1,
|
||||
"anchor": "HEIGHT_BAND",
|
||||
"minHeight": 24,
|
||||
"maxHeight": 24,
|
||||
"terrain": {
|
||||
"mode": "BORE"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"interpolator": {
|
||||
"function": "NONE",
|
||||
"horizontalScale": 1
|
||||
},
|
||||
"seed": 310,
|
||||
"composite": [
|
||||
{
|
||||
"seed": 310,
|
||||
"style": {
|
||||
"style": "FLAT"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"object": "buried",
|
||||
"connectors": [],
|
||||
"rotatable": false,
|
||||
"rules": {
|
||||
"terminal": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"object": "surface",
|
||||
"connectors": [],
|
||||
"rotatable": false,
|
||||
"rules": {
|
||||
"terminal": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"pieces": [
|
||||
{
|
||||
"piece": "buried",
|
||||
"weight": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"pieces": [
|
||||
{
|
||||
"piece": "surface",
|
||||
"weight": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"landBiomes": [
|
||||
"flat"
|
||||
],
|
||||
"seaBiomes": [
|
||||
"flat"
|
||||
],
|
||||
"shoreBiomes": [
|
||||
"flat"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"startPool": "buried",
|
||||
"maxDepth": 1,
|
||||
"maxSizeChunks": 1,
|
||||
"placeMode": "STRUCTURE_PIECE"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"startPool": "surface",
|
||||
"maxDepth": 1,
|
||||
"maxSizeChunks": 1,
|
||||
"placeMode": "STRUCTURE_PIECE"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
name: 'iris-structures',
|
||||
description: 'Verify surface foundations, buried rooms, and waterlogged object blocks in generated chunks.',
|
||||
async run(context) {
|
||||
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'Structure generation requires an isolated instance')
|
||||
const world = context.options.command ?? 'structure_probe'
|
||||
context.expect(/^[a-z0-9_]+$/.test(world), 'Fixture world name must be a simple identifier')
|
||||
await context.step('create the structure fixture world', async () => {
|
||||
const response = await context.command(`/iris create name=${world} type=structure-fixture seed=78264193`,
|
||||
/World ready|Failed to create/, 180000)
|
||||
context.expect(response.includes('World ready'), response)
|
||||
})
|
||||
await context.step('observe structures and objects above and below terrain', async () => {
|
||||
await context.waitUntil(() => {
|
||||
if (Math.abs(context.bot.entity.position.x) > 32 || Math.abs(context.bot.entity.position.z) > 32) return false
|
||||
for (let x = -16; x < 32; x += 16) {
|
||||
for (let z = -16; z < 32; z += 16) {
|
||||
for (let y = 16; y <= 80; y += 16) {
|
||||
if (!context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))) return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}, { label: 'Complete structure fixture chunks', timeoutMs: 60000, intervalMs: 100 })
|
||||
const digest = createHash('sha256')
|
||||
const counts = { emerald_block: 0, diamond_block: 0, blue_concrete: 0, gold_block: 0, oak_slab: 0 }
|
||||
for (let x = -16; x < 32; x++) {
|
||||
for (let z = -16; z < 32; z++) {
|
||||
for (let y = 16; y <= 80; y++) {
|
||||
const block = context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))
|
||||
context.expect(block !== null, 'Every sampled structure block must be loaded')
|
||||
digest.update(`${block.stateId}\n`)
|
||||
if (Object.hasOwn(counts, block.name)) counts[block.name]++
|
||||
if (block.name === 'emerald_block') context.expect(y > 64, 'Surface platform must be above terrain')
|
||||
if (block.name === 'blue_concrete') context.expect(y < 40, 'Buried room must remain underground')
|
||||
if (block.name === 'oak_slab') context.expect(block.getProperties().waterlogged === false,
|
||||
'Object slabs placed on dry ground must clear waterlogging')
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [block, count] of Object.entries(counts)) {
|
||||
context.expect(count > 0, `Generated fixture must contain ${block}`, { count })
|
||||
}
|
||||
context.report.structures = { counts, hash: digest.digest('hex') }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
|
||||
export default {
|
||||
name: 'iris-studio-open',
|
||||
description: 'Measure repeated Studio entry and verify visible terrain, spectator mode, and world cleanup.',
|
||||
async run(context) {
|
||||
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'Studio entry requires an isolated instance')
|
||||
const match = /^([a-z0-9_-]+):([1-4])$/.exec(context.options.command ?? 'overworld:2')
|
||||
context.expect(match, 'Expected pack:cycles with one to four cycles')
|
||||
context.report.studioOpen = []
|
||||
for (let cycle = 0; cycle < Number(match[2]); cycle++) {
|
||||
await context.step(`open and close Studio ${cycle + 1}`, async () => {
|
||||
const before = new Set((await context.observe()).worlds.map(world => world.id))
|
||||
const start = Date.now()
|
||||
let world
|
||||
let center
|
||||
await context.command(`/iris studio open ${match[1]} seed=1337`)
|
||||
await context.waitUntil(async () => {
|
||||
const observation = await context.observe()
|
||||
const player = observation.players.find(entry => entry.username === context.bot.username)
|
||||
world = observation.worlds.find(entry => entry.id === player?.world)
|
||||
if (!world || before.has(world.id) || !/iris-[0-9a-f-]{36}$/.test(world.name)) return false
|
||||
center = context.bot.entity.position.floored()
|
||||
return world.loadedChunks > 0 && context.bot.blockAt(center) !== null && context.bot.game.gameMode === 'spectator'
|
||||
}, { label: 'Studio arrival with terrain and spectator mode', timeoutMs: 180000, intervalMs: 100 })
|
||||
const arrivalMs = Date.now() - start
|
||||
context.expect(Number.isInteger(context.bot.game.minY) && Number.isInteger(context.bot.game.height),
|
||||
'Studio dimension must report its vertical range')
|
||||
await context.waitUntil(() => {
|
||||
for (let x = -1; x <= 1; x++) {
|
||||
for (let z = -1; z <= 1; z++) {
|
||||
for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y += 16) {
|
||||
if (context.bot.blockAt(center.offset(x, y - center.y, z)) === null) return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}, { label: 'Complete neighboring Studio columns', timeoutMs: 60000, intervalMs: 100 })
|
||||
const digest = createHash('sha256')
|
||||
let solid = 0
|
||||
for (let x = -1; x <= 1; x++) {
|
||||
for (let z = -1; z <= 1; z++) {
|
||||
for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y++) {
|
||||
const block = context.bot.blockAt(center.offset(x, y - center.y, z))
|
||||
context.expect(block !== null, 'Studio entry column must be available')
|
||||
digest.update(`${block.name}\n`)
|
||||
if (!['air', 'cave_air', 'void_air', 'water', 'lava'].includes(block.name)) solid++
|
||||
}
|
||||
}
|
||||
}
|
||||
context.expect(solid > 0, 'Studio entry must contain solid terrain')
|
||||
context.report.studioOpen.push({ cycle, arrivalMs, world: world.name, x: center.x, z: center.z,
|
||||
terrainHash: digest.digest('hex'), solid })
|
||||
const closed = await context.command('/iris studio close', /^Studio closed\.$|Studio close failed:/, 180000)
|
||||
context.expect(closed === 'Studio closed.', closed)
|
||||
await context.waitUntil(async () => !(await context.observe()).worlds.some(entry => entry.id === world.id),
|
||||
{ label: 'Studio world unload', timeoutMs: 90000, intervalMs: 100 })
|
||||
const transientName = /iris-[0-9a-f-]{36}$/.exec(world.name)[0]
|
||||
const properties = await readFile(path.join(context.server.directory, 'server.properties'), 'utf8')
|
||||
const level = /^level-name=(.+)$/m.exec(properties)?.[1].trim() ?? 'world'
|
||||
const directory = path.join(context.server.directory, level, 'dimensions', 'iris', transientName)
|
||||
await context.waitUntil(async () => {
|
||||
try { await stat(directory); return false } catch (error) {
|
||||
if (error.code === 'ENOENT') return true
|
||||
throw error
|
||||
}
|
||||
}, { label: 'Studio directory cleanup', timeoutMs: 90000, intervalMs: 100 })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const worldName = 'lifecycle_probe'
|
||||
const worldKey = `iris:${worldName}`
|
||||
const storageName = `world_iris_${worldName}`
|
||||
|
||||
export default {
|
||||
name: 'iris-world-lifecycle',
|
||||
description: 'Create, enter, evacuate, reload, and remove an Iris world through the player commands.',
|
||||
async run(context) {
|
||||
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'World lifecycle requires an isolated instance')
|
||||
const wait = (predicate, label, timeoutMs = 180000) => context.waitUntil(predicate, {
|
||||
label, timeoutMs, intervalMs: 250
|
||||
})
|
||||
const playerWorld = async () => {
|
||||
const observation = await context.observe()
|
||||
const player = observation.players.find(entry => entry.username === context.bot.username)
|
||||
return observation.worlds.find(entry => entry.id === player?.world)?.name
|
||||
}
|
||||
const status = async () => {
|
||||
const response = await context.command('/irislifecycle lifecycle-status', /IRIS_LIFECYCLE |NATIVE_FAILED/, 10000)
|
||||
context.expect(!response.includes('NATIVE_FAILED'), response)
|
||||
return response
|
||||
}
|
||||
const settledStatus = async expected => {
|
||||
const response = await status()
|
||||
return response.includes(expected) && response.includes('idle=true')
|
||||
}
|
||||
const assertEvacuated = async label => {
|
||||
let destination
|
||||
await wait(async () => {
|
||||
destination = await playerWorld()
|
||||
return Boolean(destination && destination !== storageName)
|
||||
}, label, 30000)
|
||||
context.expect(destination && destination !== storageName, label, { destination })
|
||||
}
|
||||
const assertSafeEntry = async () => {
|
||||
await wait(async () => (await playerWorld()) === storageName, 'Player entered the lifecycle world')
|
||||
await wait(() => {
|
||||
const feet = context.bot.entity.position.floored()
|
||||
const floor = context.bot.blockAt(feet.offset(0, -1, 0))
|
||||
const body = context.bot.blockAt(feet)
|
||||
const head = context.bot.blockAt(feet.offset(0, 1, 0))
|
||||
return context.bot.entity.onGround && floor?.name === 'lime_wool'
|
||||
&& body?.boundingBox === 'empty' && head?.boundingBox === 'empty'
|
||||
}, 'Client sees solid terrain below unobstructed player entry', 30000)
|
||||
const response = await context.command('/irislifecycle lifecycle-entry', /IRIS_LIFECYCLE_ENTRY|NATIVE_FAILED/, 10000)
|
||||
context.expect(response.includes(`IRIS_LIFECYCLE_ENTRY world=${worldKey} seed=78264193 floor=lime_wool safe=true`), response)
|
||||
}
|
||||
await context.step('confirm disposable world is absent', async () => {
|
||||
const response = await status()
|
||||
context.expect(response.includes('loaded=false registered=false configured=false disk=false quarantines=0 cleanup=deleted idle=true'), response)
|
||||
await context.command('/minecraft:gamemode survival @s')
|
||||
await wait(() => context.bot.game.gameMode === 'survival', 'Survival mode', 10000)
|
||||
})
|
||||
await context.step('create through Iris and automatically enter safe terrain', async () => {
|
||||
await context.command(`/iris create name=${worldName} type=native-terrain seed=78264193`)
|
||||
await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'Created world registration and storage')
|
||||
await assertSafeEntry()
|
||||
})
|
||||
await context.step('unload while the player is inside and verify evacuation', async () => {
|
||||
context.expect((await playerWorld()) === storageName, 'Player must be inside the world before unload')
|
||||
await context.command(`/iris unloadWorld ${worldKey}`)
|
||||
await wait(async () => await settledStatus('loaded=false registered=true configured=true disk=true'), 'World unloaded with storage preserved')
|
||||
await assertEvacuated('Unload evacuated the player to a loaded world')
|
||||
})
|
||||
await context.step('reload the saved world and enter it again', async () => {
|
||||
await context.command(`/iris loadWorld ${worldName}`)
|
||||
await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'World reloaded')
|
||||
await context.command(`/iris tp ${worldKey}`)
|
||||
await assertSafeEntry()
|
||||
})
|
||||
await context.step('remove the occupied world and verify disk cleanup', async () => {
|
||||
await context.command(`/iris remove world=${worldName} delete=true`)
|
||||
let finalStatus
|
||||
await wait(async () => {
|
||||
finalStatus = await status()
|
||||
return finalStatus.includes('loaded=false registered=false configured=false disk=false')
|
||||
&& /cleanup=(deleted|queued)\b/.test(finalStatus) && finalStatus.includes('idle=true')
|
||||
}, 'Removal detached registrations and deleted or queued quarantine')
|
||||
await assertEvacuated('Removal evacuated the player to a loaded world')
|
||||
context.report.worldLifecycle = { world: worldKey, cleanup: /cleanup=(deleted|queued)\b/.exec(finalStatus)[1] }
|
||||
})
|
||||
if (process.env.IRIS_QA_PACK_URL) {
|
||||
await context.step('download and overwrite the fixture pack through Iris', async () => {
|
||||
const response = await context.command(
|
||||
`/iris download link=${process.env.IRIS_QA_PACK_URL} overwrite=true`,
|
||||
/Iris pack 'native-terrain' installed|Iris pack download failed\./,
|
||||
120000
|
||||
)
|
||||
context.expect(response.includes("Iris pack 'native-terrain' installed"), response)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package art.arcane.iris.command;
|
||||
|
||||
import art.arcane.iris.pack.PackFingerprints;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.pack.PackValidationCache;
|
||||
import art.arcane.iris.pack.PackValidationResult;
|
||||
import art.arcane.iris.pack.PackValidator;
|
||||
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
|
||||
import org.junit.After;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public class CommandPackValidationPublicationTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@After
|
||||
public void clearValidation() {
|
||||
PackValidationRegistry.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changedCachedPackCannotBePersistedAsFreshValidation() throws Exception {
|
||||
File packs = temporaryFolder.newFolder("packs");
|
||||
File root = new File(packs, "terrain");
|
||||
Files.createDirectories(root.toPath());
|
||||
File content = new File(root, "dimension.json");
|
||||
Files.writeString(content.toPath(), "original");
|
||||
File cacheFile = new File(temporaryFolder.getRoot(), "validation-cache.json");
|
||||
Iris previousPlugin = Iris.instance;
|
||||
Iris plugin = mock(Iris.class);
|
||||
Iris.instance = plugin;
|
||||
when(plugin.getDataFile("cache", "pack-validation.json")).thenReturn(cacheFile);
|
||||
try (MockedStatic<Iris> iris = mockStatic(Iris.class);
|
||||
MockedStatic<PackValidationCache> cache = mockStatic(PackValidationCache.class, CALLS_REAL_METHODS)) {
|
||||
cache.when(PackValidationCache::contextFingerprint).thenReturn("context");
|
||||
PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
|
||||
PackValidationRegistry.publish(valid);
|
||||
PackValidationRegistry.publish(root.toPath(), valid,
|
||||
PackFingerprints.computePackTreeFingerprint(root), "context");
|
||||
Files.writeString(content.toPath(), "changed");
|
||||
Method persist = CommandPack.class.getDeclaredMethod("persistValidationCache", File.class);
|
||||
persist.setAccessible(true);
|
||||
|
||||
persist.invoke(new CommandPack(), packs);
|
||||
|
||||
assertFalse(cacheFile.exists());
|
||||
Files.writeString(content.toPath(), "original");
|
||||
|
||||
persist.invoke(new CommandPack(), packs);
|
||||
|
||||
assertTrue(cacheFile.exists());
|
||||
assertTrue(PackValidationCache.load(cacheFile.toPath(),
|
||||
PackFingerprints.computePackContentSnapshot(packs).content(), "context",
|
||||
List.of(root.getName())).orElseThrow().getFirst().isLoadable());
|
||||
} finally {
|
||||
Iris.instance = previousPlugin;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void commandPublishesCanonicalRootValidation() throws Exception {
|
||||
File root = temporaryFolder.newFolder("terrain");
|
||||
PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
|
||||
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
|
||||
validator.when(() -> PackValidator.validate(root)).thenReturn(valid);
|
||||
|
||||
assertSame(valid, new CommandPack().runValidate(mock(VolmitSender.class), root));
|
||||
|
||||
assertSame(valid, PackValidationRegistry.get(root.toPath()));
|
||||
assertSame(valid, PackValidationRegistry.get(root.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mutationDuringValidationCannotRepublishStaleResult() throws Exception {
|
||||
File root = temporaryFolder.newFolder("terrain");
|
||||
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
|
||||
try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(root.toPath())) {
|
||||
return new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
|
||||
}
|
||||
});
|
||||
|
||||
assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable());
|
||||
assertNull(PackValidationRegistry.get(root.toPath()));
|
||||
assertNull(PackValidationRegistry.get(root.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void externalEditDuringValidationPublishesBlockedResult() throws Exception {
|
||||
File root = temporaryFolder.newFolder("terrain");
|
||||
File content = new File(root, "dimension.json");
|
||||
Files.writeString(content.toPath(), "original");
|
||||
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
|
||||
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
|
||||
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
|
||||
Files.writeString(content.toPath(), "changed");
|
||||
return new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
|
||||
});
|
||||
|
||||
assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable());
|
||||
assertFalse(PackValidationRegistry.get(root.toPath()).isLoadable());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user