Object fixes / PS

This commit is contained in:
Brian Neumann-Fopiano
2026-08-20 11:49:39 -04:00
parent 5cacf6d435
commit 8eaafa3c69
12 changed files with 401 additions and 132 deletions
@@ -1222,8 +1222,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return generatorResolver.resolveDefaultBiomeProvider(worldName, id, () -> super.getDefaultBiomeProvider(worldName, id));
}
@Nullable
@Override
public ChunkGenerator getDefaultWorldGenerator(String worldName, String id) {
public ChunkGenerator getDefaultWorldGenerator(@NotNull String worldName, @Nullable String id) {
return generatorResolver.resolveDefaultWorldGenerator(worldName, id);
}
@@ -62,6 +62,7 @@ public final class IrisWorldGeneratorResolver {
private static final int VALIDATION_STABILITY_ATTEMPTS = 2;
private static final Object SNAPSHOT_VALIDATION_LOCK = new Object();
private static final String IRIS_DIMENSION_NAMESPACE = "iris";
private static final String PLOT_SQUARED_DISCOVERY_WORLD = "CheckingPlotSquaredGenerator";
private final VolmitPlugin plugin;
@@ -340,7 +341,12 @@ public final class IrisWorldGeneratorResolver {
return fallback.get();
}
public ChunkGenerator resolveDefaultWorldGenerator(String worldName, String id) {
@Nullable
public ChunkGenerator resolveDefaultWorldGenerator(String worldName, @Nullable String id) {
if (isPlotSquaredGeneratorDiscoveryProbe(worldName, id)) {
Iris.debug("Ignoring PlotSquared generator discovery probe");
return null;
}
if (isGeneratorDiscoveryProbe(worldName, id)) {
Iris.debug("Generator discovery probe for loaded world " + worldName);
return new IrisProbeChunkGenerator(worldName);
@@ -370,6 +376,10 @@ public final class IrisWorldGeneratorResolver {
}
}
private static boolean isPlotSquaredGeneratorDiscoveryProbe(String worldName, String id) {
return PLOT_SQUARED_DISCOVERY_WORLD.equals(worldName) && id != null && id.isEmpty();
}
/**
* Multiverse-Core probes every enabled plugin by asking for a generator with an empty dimension
* id and the name of a world that is already loaded. Bukkit never creates a world that is
@@ -42,7 +42,6 @@ import art.arcane.iris.platform.bukkit.BukkitBlockState;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.volmlib.util.data.Cuboid;
import art.arcane.iris.util.common.data.IrisCustomData;
import art.arcane.iris.util.common.data.registry.Materials;
import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.iris.util.common.director.specialhandlers.NullableDimensionHandler;
import art.arcane.iris.util.common.plugin.VolmitSender;
@@ -89,6 +88,23 @@ import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
import art.arcane.iris.core.localization.RuntimeUiMessages;
@Director(name = "object", aliases = "o", origin = DirectorOrigin.PLAYER, description = "Iris object manipulation", descriptionKey = "iris.director.commandobject.director.iris_object_manipulation")
public class CommandObject implements DirectorExecutor {
static final Set<Material> PASTE_TRANSPARENT_BLOCKS = Set.of(
Material.AIR,
Material.CAVE_AIR,
Material.VOID_AIR,
Material.SHORT_GRASS,
Material.SNOW,
Material.VINE,
Material.TORCH,
Material.DEAD_BUSH,
Material.POPPY,
Material.DANDELION
);
static boolean isPasteTarget(Material material) {
return !PASTE_TRANSPARENT_BLOCKS.contains(material);
}
@Director(description = "Open an object studio world (grid of every object; dimension optional, defaults to all packs)", descriptionKey = "iris.director.commandobject.director.open_object_studio_world_grid_every_object_dimension_optional_defaults_all_packs", sync = true)
public void studio(
@Param(defaultValue = "null", description = "Optional dimension whose object pack to lay out; omit to aggregate objects from every pack", descriptionKey = "iris.director.commandobject.param.optional_dimension_whose_object_pack_lay_out_omit_aggregate_objects_from_every", aliases = "dim", customHandler = NullableDimensionHandler.class)
@@ -179,9 +195,6 @@ public class CommandObject implements DirectorExecutor {
}
}
private static final Set<Material> skipBlocks = Set.of(Materials.GRASS, Material.SNOW, Material.VINE, Material.TORCH, Material.DEAD_BUSH,
Material.POPPY, Material.DANDELION);
public static IObjectPlacer createPlacer(World world, Map<Block, BlockData> futureBlockChanges, Engine targetEngine) {
return new IObjectPlacer() {
@Override
@@ -545,15 +558,20 @@ public class CommandObject implements DirectorExecutor {
scale = maxScale;
}
sender().playSound(Sound.BLOCK_ENCHANTMENT_TABLE_USE, 1f, 1.5f);
IrisObjectPlacement placement = new IrisObjectPlacement();
placement.setRotation(IrisObjectRotation.of(0, rotate, 0));
VolmitSender commandSender = sender();
Player player = player();
ItemStack wand = player.getInventory().getItemInMainHand();
Location block = player.getTargetBlock(skipBlocks, 256).getLocation().clone().add(0, 1, 0);
Block targetBlock = player.getTargetBlock(PASTE_TRANSPARENT_BLOCKS, 256);
if (!isPasteTarget(targetBlock.getType())) {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_PLEASE_LOOK_AT_ANY_BLOCK_NOT_AT_SKY));
return;
}
Location block = targetBlock.getLocation().clone().add(0, 1, 0);
commandSender.playSound(Sound.BLOCK_ENCHANTMENT_TABLE_USE, 1f, 1.5f);
Map<Block, BlockData> futureChanges = new HashMap<>();
@@ -573,9 +591,11 @@ public class CommandObject implements DirectorExecutor {
}
onPlayerThread(player, () -> {
Vector center = new Vector(placed.getCenter().getX(), placed.getCenter().getY(), placed.getCenter().getZ());
ItemStack newWand = WandSVC.createWand(block.clone().subtract(center).add(placed.getW() - 1,
placed.getH() + center.getY() - 1, placed.getD() - 1), block.clone().subtract(center.clone().setY(0)));
ObjectPasteBounds bounds = ObjectPasteBounds.resolve(placed, placement.getRotation(), block.getBlockX(),
block.getBlockY() + placed.getCenter().getBlockY(), block.getBlockZ());
Location minimum = new Location(block.getWorld(), bounds.minX(), bounds.minY(), bounds.minZ());
Location maximum = new Location(block.getWorld(), bounds.maxX(), bounds.maxY(), bounds.maxZ());
ItemStack newWand = WandSVC.createWand(maximum, minimum);
if (WandSVC.isWand(wand)) {
player.getInventory().setItemInMainHand(newWand);
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_OBJECT_UPDATED_WAND_OBJECTS_IOB, MessageArgument.untrusted("value", placed.getLoadKey())));
@@ -0,0 +1,57 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisObjectRotation;
import art.arcane.iris.util.common.math.IrisBlockVector;
import java.util.Objects;
record ObjectPasteBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
static ObjectPasteBounds resolve(
IrisObject object,
IrisObjectRotation rotation,
int anchorX,
int anchorY,
int anchorZ
) {
IrisObject placedObject = Objects.requireNonNull(object, "object");
IrisObjectRotation placementRotation = Objects.requireNonNull(rotation, "rotation");
int[] xOffsets = {
-placedObject.getCenter().getBlockX(),
placedObject.getW() - 1 - placedObject.getCenter().getBlockX()
};
int[] yOffsets = {
-placedObject.getCenter().getBlockY(),
placedObject.getH() - 1 - placedObject.getCenter().getBlockY()
};
int[] zOffsets = {
-placedObject.getCenter().getBlockZ(),
placedObject.getD() - 1 - placedObject.getCenter().getBlockZ()
};
int minimumX = Integer.MAX_VALUE;
int minimumY = Integer.MAX_VALUE;
int minimumZ = Integer.MAX_VALUE;
int maximumX = Integer.MIN_VALUE;
int maximumY = Integer.MIN_VALUE;
int maximumZ = Integer.MIN_VALUE;
for (int xOffset : xOffsets) {
for (int yOffset : yOffsets) {
for (int zOffset : zOffsets) {
IrisBlockVector transformed = placementRotation.rotate(new IrisBlockVector(xOffset, yOffset, zOffset));
int worldX = anchorX + (int) Math.round(transformed.getX());
int worldY = anchorY + (int) Math.round(transformed.getY());
int worldZ = anchorZ + (int) Math.round(transformed.getZ());
minimumX = Math.min(minimumX, worldX);
minimumY = Math.min(minimumY, worldY);
minimumZ = Math.min(minimumZ, worldZ);
maximumX = Math.max(maximumX, worldX);
maximumY = Math.max(maximumY, worldY);
maximumZ = Math.max(maximumZ, worldZ);
}
}
}
return new ObjectPasteBounds(minimumX, minimumY, minimumZ, maximumX, maximumY, maximumZ);
}
}
@@ -26,6 +26,7 @@ import java.util.Random;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
@@ -231,7 +232,8 @@ public class IrisWorldGeneratorResolverTest {
int resolverEnd = source.indexOf("private ChunkGenerator resolveFrozenWorldGenerator(", resolverStart);
String resolver = source.substring(resolverStart, resolverEnd);
int probe = resolver.indexOf("isGeneratorDiscoveryProbe(worldName, id)");
int plotSquaredProbe = resolver.indexOf("isPlotSquaredGeneratorDiscoveryProbe(worldName, id)");
int probe = resolver.indexOf("isGeneratorDiscoveryProbe(worldName, id)", plotSquaredProbe);
int readiness = resolver.indexOf("IrisStartupValidation.requireWorldCreationReady()");
int duplicateGuard = resolver.indexOf("requireWorldKeyAvailable(worldName, worldKey)");
int ownership = resolver.indexOf("requireOwnedWorld(worldName, levelRoot, worldKey)");
@@ -241,7 +243,8 @@ public class IrisWorldGeneratorResolverTest {
int shutdown = resolver.indexOf("Bukkit.shutdown()", report);
int rethrow = resolver.indexOf("throw failure", shutdown);
assertTrue(probe >= 0);
assertTrue(plotSquaredProbe >= 0);
assertTrue(probe > plotSquaredProbe);
assertTrue(readiness > probe);
assertTrue(duplicateGuard > readiness);
assertTrue(ownership > duplicateGuard);
@@ -256,6 +259,44 @@ public class IrisWorldGeneratorResolverTest {
assertEquals(shutdownScope, resolverStart + shutdown, source.lastIndexOf("Bukkit.shutdown()"));
}
@Test
public void plotSquaredDiscoveryProbeIsIgnoredQuietly() {
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
bukkit.when(() -> Bukkit.getWorld("CheckingPlotSquaredGenerator")).thenReturn(null);
ChunkGenerator probe = new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "");
assertNull("Iris cannot be used as a PlotSquared base generator", probe);
bukkit.verify(Bukkit::shutdown, never());
}
}
@Test
public void plotSquaredSentinelWithDimensionIdUsesNormalOwnershipChecks() throws Exception {
File worldContainer = temporaryFolder.newFolder("plotsquared-non-probe");
File levelRoot = new File(worldContainer, "world");
assertTrue(levelRoot.mkdirs());
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
Server server = mock(Server.class);
when(server.getLevelDirectory()).thenReturn(levelRoot.toPath());
bukkit.when(Bukkit::getServer).thenReturn(server);
bukkit.when(Bukkit::getWorldContainer).thenReturn(worldContainer);
bukkit.when(Bukkit::getWorlds).thenReturn(List.of());
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> new IrisWorldGeneratorResolver(null)
.resolveDefaultWorldGenerator("CheckingPlotSquaredGenerator", "overworld"));
assertTrue(failure.getMessage(), failure.getMessage().contains("CheckingPlotSquaredGenerator"));
bukkit.verify(Bukkit::shutdown, never());
}
}
@Test
public void discoveryProbeOfLoadedWorldReturnsInertGeneratorQuietly() {
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
@@ -0,0 +1,28 @@
package art.arcane.iris.core.commands;
import org.bukkit.Material;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class CommandObjectPasteRaycastTest {
@Test
public void rejectsEveryAirVariantAndConfiguredFoliageAsPasteTargets() {
assertFalse(CommandObject.isPasteTarget(Material.AIR));
assertFalse(CommandObject.isPasteTarget(Material.CAVE_AIR));
assertFalse(CommandObject.isPasteTarget(Material.VOID_AIR));
assertFalse(CommandObject.isPasteTarget(Material.SHORT_GRASS));
assertFalse(CommandObject.isPasteTarget(Material.SNOW));
assertFalse(CommandObject.isPasteTarget(Material.VINE));
assertFalse(CommandObject.isPasteTarget(Material.TORCH));
assertFalse(CommandObject.isPasteTarget(Material.DEAD_BUSH));
assertFalse(CommandObject.isPasteTarget(Material.POPPY));
assertFalse(CommandObject.isPasteTarget(Material.DANDELION));
}
@Test
public void acceptsSolidBlocksAsPasteTargets() {
assertTrue(CommandObject.isPasteTarget(Material.STONE));
}
}
@@ -0,0 +1,53 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisObjectRotation;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class ObjectPasteBoundsTest {
private static final int ANCHOR_X = 100;
private static final int ANCHOR_Y = 65;
private static final int ANCHOR_Z = -30;
@Test
public void preservesPasteBoundsWithoutRotation() {
ObjectPasteBounds bounds = resolve(0);
assertEquals(new ObjectPasteBounds(98, 64, -31, 101, 66, -30), bounds);
}
@Test
public void followsQuarterTurnPasteFootprint() {
ObjectPasteBounds bounds = resolve(90);
assertEquals(new ObjectPasteBounds(99, 64, -31, 100, 66, -28), bounds);
}
@Test
public void followsHalfTurnPasteOffset() {
ObjectPasteBounds bounds = resolve(180);
assertEquals(new ObjectPasteBounds(99, 64, -30, 102, 66, -29), bounds);
}
@Test
public void followsThreeQuarterTurnPasteOffset() {
ObjectPasteBounds bounds = resolve(270);
assertEquals(new ObjectPasteBounds(100, 64, -32, 101, 66, -29), bounds);
}
@Test
public void enclosesRoundedArbitraryAnglePasteFootprint() {
ObjectPasteBounds bounds = resolve(45);
assertEquals(new ObjectPasteBounds(98, 64, -31, 101, 66, -29), bounds);
}
private ObjectPasteBounds resolve(int rotation) {
IrisObject object = new IrisObject(4, 3, 2);
return ObjectPasteBounds.resolve(object, IrisObjectRotation.of(0, rotation, 0), ANCHOR_X, ANCHOR_Y, ANCHOR_Z);
}
}
@@ -38,13 +38,21 @@ public final class ModdedWorkspaceGenerator {
}
public static File writeWorkspace(IrisData data, File folder) throws IOException {
JSONObject workspaceConfig = buildWorkspace(data);
return writeWorkspace(data, folder, false);
}
public static File writeWorkspace(IrisData data, File folder, boolean immediateSchemas) throws IOException {
JSONObject workspaceConfig = buildWorkspace(data, immediateSchemas);
File workspace = new File(folder, folder.getName() + ".code-workspace");
IO.writeAll(workspace, workspaceConfig.toString(4));
return workspace;
}
public static JSONObject buildWorkspace(IrisData data) {
return buildWorkspace(data, false);
}
private static JSONObject buildWorkspace(IrisData data, boolean immediateSchemas) {
JSONObject ws = new JSONObject();
JSONArray folders = new JSONArray();
JSONObject folder = new JSONObject();
@@ -74,17 +82,17 @@ public final class ModdedWorkspaceGenerator {
json.put("editor.suggest.insertMode", "replace");
settings.put("[json]", json);
settings.put("json.maxItemsComputed", 30000);
settings.put("json.schemas", buildSchemas(data));
settings.put("json.schemas", buildSchemas(data, immediateSchemas));
ws.put("settings", settings);
return ws;
}
private static JSONArray buildSchemas(IrisData data) {
private static JSONArray buildSchemas(IrisData data, boolean immediateSchemas) {
JSONArray schemas = new JSONArray();
for (ResourceLoader<?> loader : data.getLoaders().v()) {
if (loader.supportsSchemas()) {
schemas.put(loader.buildSchema());
schemas.put(immediateSchemas ? loader.buildSchemaImmediately() : loader.buildSchema());
}
}
@@ -102,14 +110,21 @@ public final class ModdedWorkspaceGenerator {
entry.put("url", "./.iris/schema/snippet/" + snipType + "-schema.json");
schemas.put(entry);
File schemaFile = new File(data.getDataFolder(), ".iris/schema/snippet/" + snipType + "-schema.json");
J.attemptAsync(() -> {
try {
IO.writeAll(schemaFile, new SchemaBuilder(snippetClass, data).construct().toString(4));
} catch (Throwable e) {
IrisLogging.reportError(e);
}
});
if (immediateSchemas) {
IO.writeAll(schemaFile, new SchemaBuilder(snippetClass, data).construct().toString(4));
} else {
J.attemptAsync(() -> {
try {
IO.writeAll(schemaFile, new SchemaBuilder(snippetClass, data).construct().toString(4));
} catch (Throwable e) {
IrisLogging.reportError(e);
}
});
}
} catch (Throwable e) {
if (immediateSchemas) {
throw new IllegalStateException("Could not write snippet schema for " + snippetClass.getName(), e);
}
IrisLogging.reportError(e);
}
}
@@ -285,8 +285,8 @@ public final class ModdedStudioCommands {
}
File workspace;
try {
workspace = ModdedWorkspaceGenerator.writeWorkspace(IrisData.get(folder), folder);
} catch (IOException e) {
workspace = ModdedWorkspaceGenerator.writeWorkspace(IrisData.get(folder), folder, open);
} catch (Throwable e) {
LOGGER.error("Iris workspace write failed for {}", folder, e);
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_FAILED_WRITE_WORKSPACE, MessageArgument.untrusted("value", folder.getAbsolutePath()), MessageArgument.untrusted("value2", String.valueOf(e.getMessage()))));
return 0;
@@ -393,6 +393,15 @@ public final class ModdedStudioCommands {
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_HAS_NO_DIMENSIONS_JSON, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))));
return;
}
try {
ModdedWorkspaceGenerator.writeWorkspace(data, packFolder, true);
} catch (Throwable workspaceError) {
LOGGER.error("Iris workspace write failed for {}", packFolder, workspaceError);
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_FAILED_WRITE_WORKSPACE,
MessageArgument.untrusted("value", packFolder.getAbsolutePath()),
MessageArgument.untrusted("value2", String.valueOf(workspaceError.getMessage())))));
}
server.execute(() -> {
if (owner.equals(CONSOLE_OWNER)) {
injectConsole(source, server, dimensionId, pack, seed);