From 7d73c4d150bd10d8ac98305bee516dcccdaaf423 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 28 Oct 2026 17:14:14 -0400 Subject: [PATCH] Jigsaw junctions and studio tools - Rotatable jigsaw candidates try their rotations in random order - Candidate connectors are tried by priority within each rotation - Planar candidates must agree with every occupied neighbor socket - Planar assembly falls back to source-only matching before it fails - Connectors whose branch placed nothing stay open - Spatial default kit covers all 24 Y-rotation topologies - Planar start pool includes Blank at weight 1 - Vanilla spatial projects omit Iris themes and placement caps - Vanilla spatial projects use max depth 4 - 1.21.1 export derives the vertical distance from the structure - Workcell floors map the active variant's connectors - Floor froglights mark up, down and up-down connectors - Connector Tool places, moves, re-orients and removes spatial connectors - Connector Tool re-seats planar face-center sockets - Main menu pages workcells five at a time - Spatial variants can be deleted while another variant remains - Tool sticks act on the variant they were made for - Destructive tool confirmation needs two separate clicks - Pistons cannot extend into protected cells - Autosave capture failures back off and notify the owner - Autosave plays the save bell - Hidden-connector air final states are stored as air - Reset Connector Blocks clears unsaved markers - Folia logout releases reset and resize reservations - Spatial workcell rename relabels its variant - Workcell teleports land inside the focused workcell - Replacement layouts keep rendering and hydration working - Container preview probes stay out of chat - Non-owners can run commands that are not recognized as mutating - Spatial resize or rotation past the workcell volume limit is refused - Tests cover default kit assembly, connector placement, floor glyphs and menu paging --- .../arcane/iris/command/CommandJigsaw.java | 8 +- .../export/VanillaJigsawExportSettings.java | 10 + .../structure/jigsaw/IrisJigsawConnector.java | 2 +- .../jigsaw/IrisJigsawWorkcellArchetype.java | 2 +- .../placement/StructureAssembler.java | 95 +- .../generation/JigsawStudioGenerator.java | 145 ++- .../studio/jigsaw/JigsawStudioCapture.java | 6 +- .../jigsaw/JigsawStudioCaptureScheduler.java | 23 +- .../jigsaw/JigsawStudioChunkWriter.java | 106 ++- .../JigsawStudioConnectorPlacement.java | 682 ++++++++++++++ .../jigsaw/JigsawStudioConnectorTool.java | 842 ++++++++++++++++++ .../jigsaw/JigsawStudioDetailsMenu.java | 19 +- .../studio/jigsaw/JigsawStudioFloorGlyph.java | 145 +++ .../jigsaw/JigsawStudioFloorPainter.java | 122 +++ .../jigsaw/JigsawStudioGraphMapper.java | 85 +- .../jigsaw/JigsawStudioGraphMutations.java | 14 +- .../studio/jigsaw/JigsawStudioLayout.java | 15 +- .../studio/jigsaw/JigsawStudioMainMenu.java | 159 +++- .../jigsaw/JigsawStudioMaterializer.java | 171 +++- .../jigsaw/JigsawStudioMenuController.java | 2 +- .../studio/jigsaw/JigsawStudioMenuState.java | 14 + .../jigsaw/JigsawStudioPlayerContext.java | 2 +- .../jigsaw/JigsawStudioProjectCreator.java | 140 ++- .../studio/jigsaw/JigsawStudioProtection.java | 34 +- .../JigsawStudioProtectionListener.java | 47 +- .../JigsawStudioResourceBundleAssembler.java | 5 + .../jigsaw/JigsawStudioSaveLifecycle.java | 2 +- .../studio/jigsaw/JigsawStudioService.java | 2 + .../studio/jigsaw/JigsawStudioToolAction.java | 1 + .../studio/jigsaw/JigsawStudioToolCodec.java | 7 + .../studio/jigsaw/JigsawStudioToolbelt.java | 70 +- .../jigsaw/JigsawStudioToolboxMenu.java | 14 +- .../jigsaw/JigsawStudioVariantProperties.java | 35 +- .../jigsaw/JigsawStudioVisualization.java | 58 +- .../jigsaw/JigsawStudioWorkcellEditor.java | 16 +- .../schema/SchemaBuilderIdentityTest.java | 2 +- .../VanillaJigsawDatapackExporterTest.java | 27 + .../placement/StructureAssemblerTest.java | 126 +++ .../generation/JigsawStudioGeneratorTest.java | 286 +++--- .../jigsaw/JigsawDefaultKitAssemblyTest.java | 509 +++++++++++ .../JigsawStudioCaptureFinalStateTest.java | 101 +++ .../JigsawStudioConnectorPlacementTest.java | 383 ++++++++ .../JigsawStudioConnectorRepairTest.java | 40 + .../jigsaw/JigsawStudioEditorRoutingTest.java | 143 +++ .../jigsaw/JigsawStudioFloorGlyphTest.java | 139 +++ .../jigsaw/JigsawStudioGraphMapperTest.java | 54 ++ .../jigsaw/JigsawStudioLayoutColumnTest.java | 27 + .../jigsaw/JigsawStudioMainMenuRowTest.java | 47 + .../JigsawStudioPersistenceEditorsTest.java | 21 +- .../JigsawStudioPersistenceIdentityTest.java | 2 +- .../JigsawStudioProjectCreatorTest.java | 77 +- .../JigsawStudioProtectionProbeTest.java | 15 + ...gsawStudioResourceBundleAssemblerTest.java | 24 +- .../JigsawStudioServiceCaptureTest.java | 12 +- .../jigsaw/JigsawStudioToolbeltTest.java | 172 ++++ .../JigsawStudioVariantDeletionRuleTest.java | 99 ++ 56 files changed, 4869 insertions(+), 537 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorPlacement.java create mode 100644 core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorTool.java create mode 100644 core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioFloorGlyph.java create mode 100644 core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioFloorPainter.java create mode 100644 core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawDefaultKitAssemblyTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioCaptureFinalStateTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorPlacementTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorRepairTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioEditorRoutingTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioFloorGlyphTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioLayoutColumnTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioMainMenuRowTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolbeltTest.java create mode 100644 core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioVariantDeletionRuleTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandJigsaw.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandJigsaw.java index 16714c0bf..d4a849590 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandJigsaw.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandJigsaw.java @@ -23,6 +23,7 @@ import art.arcane.iris.structure.authoring.StructureKey; import art.arcane.iris.structure.authoring.StructureWriteResult; import art.arcane.iris.structure.export.VanillaJigsawExportFormat; import art.arcane.iris.structure.export.VanillaJigsawExportRequest; +import art.arcane.iris.structure.export.VanillaJigsawExportSettings; import art.arcane.iris.structure.export.VanillaJigsawExportSource; import art.arcane.iris.structure.graph.PlanarJigsawWorkcellResolver; import art.arcane.iris.generation.terrain.IrisDimension; @@ -437,13 +438,14 @@ public class CommandJigsaw implements DirectorExecutor { } VanillaJigsawExportRequest request; try { - request = VanillaJigsawExportRequest.builder( - VanillaJigsawExportSource.forData( - context.request().source(), context.request().structureKey()), destination) + VanillaJigsawExportSource source = VanillaJigsawExportSource.forData( + context.request().source(), context.request().structureKey()); + request = VanillaJigsawExportRequest.builder(source, destination) .namespace(namespace) .resourcePath(context.request().structureKey()) .format(exportFormat) .replaceExisting(replace) + .settings(VanillaJigsawExportSettings.runtimeDefaults(source.loadStructure())) .build(); } catch (IllegalArgumentException exception) { sendError(exception.getMessage()); diff --git a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportSettings.java b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportSettings.java index f90cc6c7d..32b2e8e25 100644 --- a/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportSettings.java +++ b/core/src/main/java/art/arcane/iris/structure/export/VanillaJigsawExportSettings.java @@ -1,5 +1,8 @@ package art.arcane.iris.structure.export; +import art.arcane.iris.pack.datapack.DataVersion; +import art.arcane.iris.structure.placement.IrisStructure; + import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -41,6 +44,13 @@ public final class VanillaJigsawExportSettings { return builder().build(); } + public static VanillaJigsawExportSettings runtimeDefaults(IrisStructure structure) { + if (structure == null || DataVersion.getRuntime() != DataVersion.V1_21_1) { + return defaults(); + } + return builder().maxDistanceVertical(structure.getMaxSizeChunks() * 16).build(); + } + public List biomes() { return biomes; } diff --git a/core/src/main/java/art/arcane/iris/structure/jigsaw/IrisJigsawConnector.java b/core/src/main/java/art/arcane/iris/structure/jigsaw/IrisJigsawConnector.java index a2b333211..c6e5a6c57 100644 --- a/core/src/main/java/art/arcane/iris/structure/jigsaw/IrisJigsawConnector.java +++ b/core/src/main/java/art/arcane/iris/structure/jigsaw/IrisJigsawConnector.java @@ -89,7 +89,7 @@ public class IrisJigsawConnector { @Description("Block state placed where this jigsaw marker was authored after assembly. Used when exporting or capturing Mojang-style jigsaw templates.") private String finalState = "minecraft:air"; - @Description("Signed priority for selecting this connector within its piece. Higher values are processed before lower values; equal priorities preserve authored order.") + @Description("Signed priority for selecting this connector within its piece. Higher values are processed before lower values; equal priorities preserve authored order. A rotatable candidate piece applies this order within each of its cardinal rotations, which it tries in random order.") private int selectionPriority = 0; @Description("Signed priority assigned to a child piece attached through this connector. Higher-priority child pieces expand first; equal priorities preserve attachment order.") diff --git a/core/src/main/java/art/arcane/iris/structure/jigsaw/IrisJigsawWorkcellArchetype.java b/core/src/main/java/art/arcane/iris/structure/jigsaw/IrisJigsawWorkcellArchetype.java index f6b4fc8cc..573733541 100644 --- a/core/src/main/java/art/arcane/iris/structure/jigsaw/IrisJigsawWorkcellArchetype.java +++ b/core/src/main/java/art/arcane/iris/structure/jigsaw/IrisJigsawWorkcellArchetype.java @@ -4,7 +4,7 @@ import art.arcane.volmlib.util.documentation.Description; @Description("The orientation-independent connector shape represented by a planar jigsaw workcell.") public enum IrisJigsawWorkcellArchetype { - @Description("Pieces with no horizontal connectors: sealed filler cells that terminate nothing and are excluded from the default piece pool.") + @Description("Pieces with no horizontal connectors. Nothing can attach to them, so they generate only as standalone start pieces; new projects list the default Blank in the start pool and exclude it from the piece pool.") BLANK(0), @Description("One-connector dead-end pieces; the Studio bay seeds a single north connector and new projects mark these as terminal caps.") diff --git a/core/src/main/java/art/arcane/iris/structure/placement/StructureAssembler.java b/core/src/main/java/art/arcane/iris/structure/placement/StructureAssembler.java index 30e28f101..1f908d9f4 100644 --- a/core/src/main/java/art/arcane/iris/structure/placement/StructureAssembler.java +++ b/core/src/main/java/art/arcane/iris/structure/placement/StructureAssembler.java @@ -218,7 +218,7 @@ public final class StructureAssembler { + "' references missing connector pool '" + connector.pool + "'"); } - AttachmentResult attachment = attachOne( + AttachmentResult attachment = attach( pending, placed, placementCounts, @@ -229,7 +229,9 @@ public final class StructureAssembler { depth, selectedTheme, rng); - consume(consumedConnectors, connector.sourcePiece(), connector.sourceConnector()); + if (attachment.state() != AttachmentState.TERMINATED) { + consume(consumedConnectors, connector.sourcePiece(), connector.sourceConnector()); + } if (attachment.state() == AttachmentState.FAILED) { return StructureAssemblyResult.failed( StructureAssemblyStatus.FAILED_UNCAPPED, @@ -250,6 +252,49 @@ public final class StructureAssembler { return StructureAssemblyResult.complete(placed, selectedTheme); } + private AttachmentResult attach( + Deque pending, + KList placed, + Map placementCounts, + Map> consumedConnectors, + Map> openConnectors, + IrisJigsawPool pool, + OpenConnector connector, + int depth, + String selectedTheme, + RNG rng + ) { + boolean planarNeighborClosures = structure.resolvedMode() == IrisJigsawMode.PLANAR_JIGSAW + && !structure.isRequireCaps(); + AttachmentResult agreed = attachOne( + pending, + placed, + placementCounts, + consumedConnectors, + openConnectors, + pool, + connector, + depth, + selectedTheme, + structure.isRequireCaps() || planarNeighborClosures, + rng); + if (agreed.state() != AttachmentState.FAILED || !planarNeighborClosures) { + return agreed; + } + return attachOne( + pending, + placed, + placementCounts, + consumedConnectors, + openConnectors, + pool, + connector, + depth, + selectedTheme, + false, + rng); + } + private AttachmentResult attachOne( Deque pending, KList placed, @@ -260,6 +305,7 @@ public final class StructureAssembler { OpenConnector connector, int depth, String selectedTheme, + boolean neighborClosures, RNG rng ) { if (pool.getPieces() == null) { @@ -281,6 +327,7 @@ public final class StructureAssembler { selectedTheme, false, !fallbackRequired, + neighborClosures, rng); if (primary != AttachmentState.NO_MATCH) { return AttachmentResult.success(primary); @@ -322,6 +369,7 @@ public final class StructureAssembler { selectedTheme, fallbackRequired, !fallbackRequired, + neighborClosures, rng); if (fallbackState != AttachmentState.NO_MATCH) { return AttachmentResult.success(fallbackState); @@ -352,9 +400,14 @@ public final class StructureAssembler { String selectedTheme, boolean terminalOnly, boolean allowEmpty, + boolean neighborClosures, RNG rng ) { int ruleDepth = Math.min(connector.pieceDepth, structure.getMaxDepth()); + if (connector.targetName == null) { + throw assemblyFailure("open connector from pool '" + connector.pool + "' has no target name"); + } + IrisDirection needed = connector.facing.reverse(); for (IrisJigsawPieceEntry entry : weightedOrder( pool, selectedTheme, @@ -391,21 +444,16 @@ public final class StructureAssembler { requireConnector(pieceConnector, pieceName); } List candidateConnectors = orderedConnectors(piece.getConnectors(), null); - for (IrisJigsawConnector cb : candidateConnectors) { - if (connector.targetName == null) { - throw assemblyFailure("open connector from pool '" + connector.pool + "' has no target name"); - } - if (!Objects.equals(connector.channel, cb.getChannel())) { - continue; - } - if (!cb.getName().equals(connector.targetName)) { - continue; - } - - IrisDirection needed = connector.facing.reverse(); - int[] rotations = piece.isRotatable() ? rotationCandidates(connector.joint, rng) : NO_ROTATION; - for (int yDeg : rotations) { - IrisObjectRotation rot = IrisObjectRotation.of(0, yDeg, 0); + int[] rotations = piece.isRotatable() ? shuffledRotations(rng) : NO_ROTATION; + for (int yDeg : rotations) { + IrisObjectRotation rot = IrisObjectRotation.of(0, yDeg, 0); + for (IrisJigsawConnector cb : candidateConnectors) { + if (!Objects.equals(connector.channel, cb.getChannel())) { + continue; + } + if (!cb.getName().equals(connector.targetName)) { + continue; + } IrisDirection rotatedFace = rot.rotate(cb.getDirection()); if (rotatedFace != needed) { continue; @@ -431,7 +479,8 @@ public final class StructureAssembler { consumedConnectors, connector, candidate, - cb); + cb, + neighborClosures); if (closures == null) { continue; } @@ -504,9 +553,10 @@ public final class StructureAssembler { Map> consumedConnectors, OpenConnector attachmentSource, PlacedStructurePiece candidatePiece, - IrisJigsawConnector attachmentConnector + IrisJigsawConnector attachmentConnector, + boolean neighborClosures ) { - if (!structure.isRequireCaps()) { + if (!neighborClosures) { return List.of(); } List closures = new ArrayList<>(); @@ -1025,10 +1075,7 @@ public final class StructureAssembler { return false; } - private int[] rotationCandidates(JigsawJoint joint, RNG rng) { - if (joint == JigsawJoint.ALIGNED) { - return Y_DEGREES; - } + private int[] shuffledRotations(RNG rng) { int[] shuffled = {0, 90, 180, 270}; for (int i = shuffled.length - 1; i > 0; i--) { int j = rng.i(i + 1); diff --git a/core/src/main/java/art/arcane/iris/studio/generation/JigsawStudioGenerator.java b/core/src/main/java/art/arcane/iris/studio/generation/JigsawStudioGenerator.java index cc8fe26bc..7468a3de7 100644 --- a/core/src/main/java/art/arcane/iris/studio/generation/JigsawStudioGenerator.java +++ b/core/src/main/java/art/arcane/iris/studio/generation/JigsawStudioGenerator.java @@ -1,12 +1,11 @@ package art.arcane.iris.studio.generation; -import art.arcane.iris.studio.jigsaw.JigsawPlanarDirection; -import art.arcane.iris.studio.jigsaw.JigsawPlanarTopology; import art.arcane.iris.studio.jigsaw.JigsawStudioActivation; import art.arcane.iris.studio.jigsaw.JigsawStudioBay; import art.arcane.iris.studio.jigsaw.JigsawStudioBounds; import art.arcane.iris.studio.jigsaw.JigsawStudioCellDimensions; import art.arcane.iris.studio.jigsaw.JigsawStudioControlPosition; +import art.arcane.iris.studio.jigsaw.JigsawStudioFloorGlyph; import art.arcane.iris.studio.jigsaw.JigsawStudioLayout; import art.arcane.iris.studio.jigsaw.JigsawStudioSession; import art.arcane.iris.studio.jigsaw.JigsawStudioVariant; @@ -51,9 +50,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator { private final NativeBlockState lightFloor; private final NativeBlockState darkFloor; private final NativeBlockState frame; - private final NativeBlockState topologyBase; - private final NativeBlockState topologyPath; - private final NativeBlockState connectorCap; + private final FloorPalette floorPalette; private final NativeBlockState invalidMarker; private final NativeBlockState controlChest; private final FinalStateRenderer finalStateRenderer; @@ -79,9 +76,13 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator { B.getState("minecraft:smooth_stone"), B.getState("minecraft:polished_deepslate"), B.getState("minecraft:white_concrete"), - B.getState("minecraft:light_gray_wool"), - B.getState("minecraft:red_wool"), - B.getState("minecraft:sea_lantern"), + new FloorPalette( + B.getState("minecraft:light_gray_wool"), + B.getState("minecraft:red_wool"), + B.getState("minecraft:sea_lantern"), + B.getState("minecraft:verdant_froglight"), + B.getState("minecraft:ochre_froglight"), + B.getState("minecraft:pearlescent_froglight")), B.getState("minecraft:red_stained_glass"), B.getState("minecraft:chest[facing=south,type=single,waterlogged=false]"), JigsawStudioGenerator::rotatedFinalState @@ -95,9 +96,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator { NativeBlockState lightFloor, NativeBlockState darkFloor, NativeBlockState frame, - NativeBlockState topologyBase, - NativeBlockState topologyPath, - NativeBlockState connectorCap, + FloorPalette floorPalette, NativeBlockState invalidMarker, NativeBlockState controlChest, FinalStateRenderer finalStateRenderer @@ -108,9 +107,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator { this.lightFloor = Objects.requireNonNull(lightFloor, "Jigsaw Studio light floor block"); this.darkFloor = Objects.requireNonNull(darkFloor, "Jigsaw Studio dark floor block"); this.frame = Objects.requireNonNull(frame, "Jigsaw Studio frame block"); - this.topologyBase = Objects.requireNonNull(topologyBase, "Jigsaw Studio topology base block"); - this.topologyPath = Objects.requireNonNull(topologyPath, "Jigsaw Studio topology path block"); - this.connectorCap = Objects.requireNonNull(connectorCap, "Jigsaw Studio connector cap block"); + this.floorPalette = Objects.requireNonNull(floorPalette, "Jigsaw Studio floor palette"); this.invalidMarker = Objects.requireNonNull(invalidMarker, "Jigsaw Studio invalid marker block"); this.controlChest = Objects.requireNonNull(controlChest, "Jigsaw Studio control chest block"); this.finalStateRenderer = Objects.requireNonNull( @@ -165,6 +162,21 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator { return renderedBays.computeIfAbsent(key, ignored -> buildRenderedBay(activeWorkcell, variant)); } + public JigsawStudioFloorGlyph floorGlyph(JigsawStudioBay workcell) { + return glyphFor(renderBay(workcell)); + } + + public NativeBlockState floorState(JigsawStudioFloorGlyph.Cell cell) { + return switch (Objects.requireNonNull(cell, "Jigsaw Studio floor glyph cell")) { + case BASE -> floorPalette.base(); + case LINE -> floorPalette.line(); + case HORIZONTAL_SOCKET -> floorPalette.horizontalSocket(); + case UP_SOCKET -> floorPalette.upSocket(); + case DOWN_SOCKET -> floorPalette.downSocket(); + case UP_AND_DOWN_SOCKET -> floorPalette.upAndDownSocket(); + }; + } + public void invalidateRender(String workcellId) { if (workcellId != null) { renderedBays.keySet().removeIf(key -> key.workcellId().equals(workcellId)); @@ -215,10 +227,8 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator { continue; } paintCage(terrainChunk, workcell, chunkWorldX, chunkWorldZ); - if (workcell.archetype().isPresent()) { - paintTopologyGlyph(terrainChunk, workcell, chunkWorldX, chunkWorldZ); - } RenderedBay renderedBay = renderBay(workcell); + paintFloorGlyph(terrainChunk, workcell, glyphFor(renderedBay), chunkWorldX, chunkWorldZ); if (!renderedBay.valid()) { paintInvalidBay(terrainChunk, workcell, chunkWorldX, chunkWorldZ); continue; @@ -345,85 +355,44 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator { } } - private void paintTopologyGlyph( + private void paintFloorGlyph( TerrainChunk terrainChunk, JigsawStudioBay workcell, + JigsawStudioFloorGlyph glyph, int chunkWorldX, int chunkWorldZ ) { - JigsawPlanarTopology topology = workcell.topology().orElseThrow(); JigsawStudioBounds bounds = workcell.bounds(); int minimumX = Math.max(bounds.originX(), chunkWorldX); int maximumX = Math.min(bounds.maxX(), chunkWorldX + 15); int minimumZ = Math.max(bounds.originZ(), chunkWorldZ); int maximumZ = Math.min(bounds.maxZ(), chunkWorldZ + 15); - if (minimumX > maximumX || minimumZ > maximumZ) { - return; - } - int glyphY = bounds.originY() - 1; - int centerX = bounds.dimensions().width() / 2; - int centerZ = bounds.dimensions().depth() / 2; + int floorY = bounds.originY() - 1; for (int worldX = minimumX; worldX <= maximumX; worldX++) { - int localX = worldX - bounds.originX(); for (int worldZ = minimumZ; worldZ <= maximumZ; worldZ++) { - int localZ = worldZ - bounds.originZ(); - NativeBlockState state = topologyGlyphState(topology, bounds.dimensions(), localX, localZ, - centerX, centerZ); - setWorldBlock(terrainChunk, worldX, glyphY, worldZ, state, chunkWorldX, chunkWorldZ); + NativeBlockState state = floorState(glyph.cell( + worldX - bounds.originX(), + worldZ - bounds.originZ())); + setWorldBlock(terrainChunk, worldX, floorY, worldZ, state, chunkWorldX, chunkWorldZ); } } } - private NativeBlockState topologyGlyphState( - JigsawPlanarTopology topology, - JigsawStudioCellDimensions dimensions, - int localX, - int localZ, - int centerX, - int centerZ - ) { - if (isConnectorCap(topology, dimensions, localX, localZ, centerX, centerZ)) { - return connectorCap; + private static JigsawStudioFloorGlyph glyphFor(RenderedBay renderedBay) { + if (!renderedBay.valid() || renderedBay.connectors().isEmpty()) { + return JigsawStudioFloorGlyph.empty(); } - if (isTopologyPath(topology, localX, localZ, centerX, centerZ)) { - return topologyPath; + List markers = new ArrayList<>(renderedBay.connectors().size()); + for (RenderedConnector connector : renderedBay.connectors()) { + markers.add(new JigsawStudioFloorGlyph.Marker( + connector.x(), + connector.z(), + connector.connector().getDirection())); } - return topologyBase; - } - - static boolean isConnectorCap( - JigsawPlanarTopology topology, - JigsawStudioCellDimensions dimensions, - int localX, - int localZ, - int centerX, - int centerZ - ) { - return topology.connects(JigsawPlanarDirection.NORTH) && localX == centerX && localZ == 0 - || topology.connects(JigsawPlanarDirection.EAST) - && localX == dimensions.width() - 1 && localZ == centerZ - || topology.connects(JigsawPlanarDirection.SOUTH) - && localX == centerX && localZ == dimensions.depth() - 1 - || topology.connects(JigsawPlanarDirection.WEST) && localX == 0 && localZ == centerZ; - } - - static boolean isTopologyPath( - JigsawPlanarTopology topology, - int localX, - int localZ, - int centerX, - int centerZ - ) { - if (topology == JigsawPlanarTopology.BLANK) { - return false; - } - boolean vertical = localX == centerX - && (topology.connects(JigsawPlanarDirection.NORTH) && localZ <= centerZ - || topology.connects(JigsawPlanarDirection.SOUTH) && localZ >= centerZ); - boolean horizontal = localZ == centerZ - && (topology.connects(JigsawPlanarDirection.WEST) && localX <= centerX - || topology.connects(JigsawPlanarDirection.EAST) && localX >= centerX); - return vertical || horizontal; + return JigsawStudioFloorGlyph.of( + renderedBay.dimensions().width(), + renderedBay.dimensions().depth(), + markers); } private RenderedBay buildRenderedBay(JigsawStudioBay workcell, JigsawStudioVariant variant) { @@ -720,7 +689,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator { private JigsawStudioBay requireWorkcell(JigsawStudioBay workcell) { JigsawStudioBay activeWorkcell = Objects.requireNonNull(workcell, "Jigsaw Studio workcell"); - if (getLayout().get(activeWorkcell.stableId()) != activeWorkcell) { + if (!activeWorkcell.equals(getLayout().get(activeWorkcell.stableId()))) { throw new IllegalArgumentException("Workcell does not belong to this Jigsaw Studio layout"); } return activeWorkcell; @@ -884,6 +853,24 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator { } } + public record FloorPalette( + NativeBlockState base, + NativeBlockState line, + NativeBlockState horizontalSocket, + NativeBlockState upSocket, + NativeBlockState downSocket, + NativeBlockState upAndDownSocket + ) { + public FloorPalette { + Objects.requireNonNull(base, "Jigsaw Studio floor base block"); + Objects.requireNonNull(line, "Jigsaw Studio floor line block"); + Objects.requireNonNull(horizontalSocket, "Jigsaw Studio horizontal socket block"); + Objects.requireNonNull(upSocket, "Jigsaw Studio upward socket block"); + Objects.requireNonNull(downSocket, "Jigsaw Studio downward socket block"); + Objects.requireNonNull(upAndDownSocket, "Jigsaw Studio vertical socket block"); + } + } + public record RenderedBlock(int x, int y, int z, NativeBlockState state, TileData tileData) { public RenderedBlock { state = Objects.requireNonNull(state, "Jigsaw Studio rendered block state"); diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioCapture.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioCapture.java index 0f594de9b..231363262 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioCapture.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioCapture.java @@ -141,6 +141,10 @@ final class JigsawStudioCapture { if (hiddenConnector != null) { hiddenConnector.setFinalState(blockData.getAsString()); connectors.add(CapturedConnector.from(hiddenConnector)); + if (isAir(blockData.getMaterial())) { + blocks.add(new CapturedBlock(x, y, z, BukkitBlockState.of(blockData), null)); + continue; + } } if (hiddenConnector == null && blockData instanceof Jigsaw jigsaw) { KMap nbt = BukkitPlatform.serializeTile(block.getLocation()); @@ -490,7 +494,7 @@ final class JigsawStudioCapture { }; } - private static LocalPosition forwardPosition( + static LocalPosition forwardPosition( int x, int y, int z, diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioCaptureScheduler.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioCaptureScheduler.java index fbed3baba..775c7c588 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioCaptureScheduler.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioCaptureScheduler.java @@ -18,6 +18,7 @@ import art.arcane.iris.structure.object.IrisObject; import art.arcane.iris.studio.generation.JigsawStudioGenerator; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.world.task.J; +import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.entity.Player; @@ -122,7 +123,7 @@ final class JigsawStudioCaptureScheduler { } coordinator.accept(snapshot); } catch (Throwable exception) { - coordinator.fail("Jigsaw Studio capture failed in chunk " + coordinator.failPersistent("Jigsaw Studio capture failed in chunk " + area.chunkX() + "," + area.chunkZ() + ": " + failureMessage(exception), exception); } } @@ -219,7 +220,7 @@ final class JigsawStudioCaptureScheduler { if (!result.successful()) { String failure = writeFailure(result); coordinator.recordPersistentFailure(failure, result.failure().orElse(null)); - message(player, failure); + message(coordinator.notifiedPlayer(), failure); return; } source.invalidateStructureResources(); @@ -234,13 +235,14 @@ final class JigsawStudioCaptureScheduler { + "Close and reopen this project before continuing: " + failureMessage(refreshFailure)); } + service.floorPainter.refresh(studio, bay.stableId()); String cleanup = result.status() == StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED ? " The graph committed, but transaction cleanup requires operator attention in the console." : ""; String mutationNotice = unchanged ? "" : " Newer edits remain unsaved."; message(player, "Saved piece '" + coordinator.saveIdentity().variantKey() + "' and object '" + assembly.objectKey() + "' atomically." + mutationNotice + cleanup); - playSaveSound(player); + playSaveSound(coordinator.notifiedPlayer()); if (unchanged) { service.evaluator.scheduleEvaluation(studio); } @@ -250,12 +252,19 @@ final class JigsawStudioCaptureScheduler { } catch (Throwable exception) { String failure = "Jigsaw Studio save failed: " + failureMessage(exception); coordinator.recordPersistentFailure(failure, exception); - message(player, failure); + message(coordinator.notifiedPlayer(), failure); } finally { coordinator.complete(); } } + static Player notificationTarget(Player player, UUID ownerId) { + if (player != null || ownerId == null) { + return player; + } + return Bukkit.getPlayer(ownerId); + } + static Set requiredChunks( JigsawStudioBay bay, JigsawStudioGenerator.RenderedBay rendered @@ -368,6 +377,10 @@ final class JigsawStudioCaptureScheduler { return work.player(); } + private Player notifiedPlayer() { + return notificationTarget(work.player(), work.studio().generator().getRequest().ownerId()); + } + private UUID requestId() { return work.requestId(); } @@ -427,7 +440,7 @@ final class JigsawStudioCaptureScheduler { } recordPersistentFailure(failure, exception); complete(); - message(player(), failure); + message(notifiedPlayer(), failure); } private void recordPersistentFailure(String failure, Throwable exception) { diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioChunkWriter.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioChunkWriter.java index 2f6fb7dbb..7f9344466 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioChunkWriter.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioChunkWriter.java @@ -40,58 +40,84 @@ final class JigsawStudioChunkWriter { private JigsawStudioChunkWriter() { } - static void restoreConnectorChunk( + static void restoreChunkPositions( World world, JigsawStudioBay workcell, - List connectors, + Set positions, + Map renderedConnectors, Map renderedBlocks, boolean connectorsVisible ) throws IOException { JigsawStudioBounds bounds = workcell.bounds(); - for (JigsawStudioGenerator.RenderedConnector connector : connectors) { - LocalPosition position = new LocalPosition(connector.x(), connector.y(), connector.z()); + BlockData air = null; + for (LocalPosition position : positions) { Block target = world.getBlockAt( - bounds.originX() + connector.x(), - bounds.originY() + connector.y(), - bounds.originZ() + connector.z()); - if (connectorsVisible) { - BlockData marker; - try { - marker = Bukkit.createBlockData( - "minecraft:jigsaw[orientation=" + connector.orientation() + "]"); - } catch (IllegalArgumentException exception) { - throw new IOException("Invalid saved connector orientation '" - + connector.orientation() + "'", exception); - } - target.setBlockData(marker, false); - BukkitPlatform.deserializeTile(markerNbt(connector.connector()), target.getLocation()); - continue; - } + bounds.originX() + position.x(), + bounds.originY() + position.y(), + bounds.originZ() + position.z()); + JigsawStudioGenerator.RenderedConnector connector = renderedConnectors.get(position); JigsawStudioGenerator.RenderedBlock renderedBlock = renderedBlocks.get(position); - BlockData restored; - if (renderedBlock != null) { - if (renderedBlock.state().isCustom() - || !(renderedBlock.state().nativeHandle() instanceof BlockData blockData)) { - throw new IOException("Saved connector block '" + renderedBlock.state().key() - + "' cannot be restored directly in Bukkit Studio"); - } - restored = blockData; + if (connector != null) { + restoreConnector(target, connector, renderedBlock, connectorsVisible); + } else if (renderedBlock != null) { + restoreRenderedBlock(target, renderedBlock); } else { - try { - restored = Bukkit.createBlockData(connector.connector().getFinalState()); - } catch (IllegalArgumentException exception) { - throw new IOException("Invalid saved connector final state '" - + connector.connector().getFinalState() + "'", exception); + if (air == null) { + air = Material.AIR.createBlockData(); } + target.setBlockData(air, false); } - target.setBlockData(restored, false); - if (renderedBlock != null && renderedBlock.tileData() != null) { - TileData tileData = renderedBlock.tileData(); - if (!tileData.isApplicable(target.getBlockData()) || !tileData.toBukkitTry(target)) { - throw new IOException("Saved connector tile data could not be restored at " - + target.getX() + "," + target.getY() + "," + target.getZ()); - } + } + } + + private static void restoreConnector( + Block target, + JigsawStudioGenerator.RenderedConnector connector, + JigsawStudioGenerator.RenderedBlock renderedBlock, + boolean connectorsVisible + ) throws IOException { + if (connectorsVisible) { + BlockData marker; + try { + marker = Bukkit.createBlockData( + "minecraft:jigsaw[orientation=" + connector.orientation() + "]"); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid saved connector orientation '" + + connector.orientation() + "'", exception); } + target.setBlockData(marker, false); + BukkitPlatform.deserializeTile(markerNbt(connector.connector()), target.getLocation()); + return; + } + if (renderedBlock != null) { + restoreRenderedBlock(target, renderedBlock); + return; + } + BlockData restored; + try { + restored = Bukkit.createBlockData(connector.connector().getFinalState()); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid saved connector final state '" + + connector.connector().getFinalState() + "'", exception); + } + target.setBlockData(restored, false); + } + + private static void restoreRenderedBlock( + Block target, + JigsawStudioGenerator.RenderedBlock renderedBlock + ) throws IOException { + if (renderedBlock.state().isCustom() + || !(renderedBlock.state().nativeHandle() instanceof BlockData blockData)) { + throw new IOException("Saved block '" + renderedBlock.state().key() + + "' cannot be restored directly in Bukkit Studio"); + } + target.setBlockData(blockData, false); + TileData tileData = renderedBlock.tileData(); + if (tileData != null + && (!tileData.isApplicable(target.getBlockData()) || !tileData.toBukkitTry(target))) { + throw new IOException("Saved tile data could not be restored at " + + target.getX() + "," + target.getY() + "," + target.getZ()); } } diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorPlacement.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorPlacement.java new file mode 100644 index 000000000..b78476814 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorPlacement.java @@ -0,0 +1,682 @@ +package art.arcane.iris.studio.jigsaw; + +import art.arcane.iris.pack.value.IrisDirection; +import art.arcane.iris.pack.value.IrisPosition; +import art.arcane.iris.structure.jigsaw.IrisJigsawConnector; +import art.arcane.iris.structure.jigsaw.JigsawJoint; +import art.arcane.iris.structure.object.IrisObjectRotation; +import art.arcane.iris.studio.jigsaw.JigsawStudioCapture.CapturedConnector; +import art.arcane.iris.studio.jigsaw.JigsawStudioCapture.LocalPosition; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +final class JigsawStudioConnectorPlacement { + static final double MAX_REACH = 64.0D; + static final String EMPTY_FINAL_STATE = "minecraft:structure_void"; + + private static final double EPSILON = 1.0E-4D; + private static final int MAX_TRAVERSAL_STEPS = 512; + private static final List SIDE_ORDER = List.of( + JigsawPlanarDirection.NORTH, + JigsawPlanarDirection.EAST, + JigsawPlanarDirection.SOUTH, + JigsawPlanarDirection.WEST); + + private JigsawStudioConnectorPlacement() { + } + + static Optional target( + Box box, + Ray ray, + Optional click, + Set connectorCells, + boolean sneaking + ) { + Objects.requireNonNull(box, "Jigsaw connector target box"); + Objects.requireNonNull(ray, "Jigsaw connector target ray"); + Objects.requireNonNull(click, "Jigsaw connector target click"); + Objects.requireNonNull(connectorCells, "Jigsaw connector cells"); + double limit = click.map(value -> entryDistance( + ray, + new Box(value.x(), value.y(), value.z(), value.x(), value.y(), value.z()))).orElse(MAX_REACH); + Optional resolved = connectorTarget(ray, connectorCells, limit + EPSILON); + if (resolved.isEmpty() && click.isPresent()) { + resolved = clickTarget(box, click.get()); + } + if (resolved.isEmpty()) { + resolved = boundaryTarget(box, ray, limit); + } + return resolved.map(target -> sneaking + ? new Target(target.x(), target.y(), target.z(), target.facing().reverse()) + : target); + } + + static Decision decideSpatial( + boolean primary, + LocalPosition target, + IrisDirection facing, + Map connectors, + Optional selection + ) { + boolean occupied = connectors.containsKey(target); + Optional activeSelection = selection.filter(connectors::containsKey); + if (primary) { + if (occupied) { + return Decision.of(Operation.REMOVE, target); + } + return activeSelection.isPresent() + ? Decision.of(Operation.CLEAR_SELECTION, activeSelection.get()) + : Decision.message(Operation.NONE, "No connector here. Right-click to place one."); + } + if (activeSelection.isEmpty()) { + return Decision.of(occupied ? Operation.SELECT : Operation.ADD, target); + } + LocalPosition selected = activeSelection.get(); + if (selected.equals(target)) { + return Decision.of(connectors.get(target) == facing ? Operation.DESELECT : Operation.REORIENT, target); + } + return Decision.of(occupied ? Operation.SELECT : Operation.MOVE, target); + } + + static Decision decidePlanar( + boolean primary, + LocalPosition target, + IrisDirection facing, + JigsawStudioCellDimensions displayDimensions, + JigsawPlanarTopology topology, + Map connectors, + String workcellName + ) { + if (primary) { + IrisDirection connectorFacing = connectors.get(target); + if (connectorFacing == null) { + return Decision.message(Operation.NONE, "No connector here. Right-click a side to seat its socket."); + } + Optional side = planarSide(connectorFacing); + if (side.isPresent() && topology.connects(side.get()) + && countFacing(connectors, connectorFacing) == 1) { + return Decision.message(Operation.REFUSE, workcellName + " needs its " + + sideName(side.get()) + " socket; planar topology is fixed by the workcell."); + } + return Decision.of(Operation.REMOVE, target); + } + if (topology.directions().isEmpty()) { + return Decision.message(Operation.REFUSE, workcellName + " has no connector sockets."); + } + Optional clicked = planarSide(facing); + JigsawPlanarDirection side = clicked.orElseGet(() -> nearestSide(target, displayDimensions, topology)); + if (!topology.connects(side)) { + return Decision.message(Operation.REFUSE, workcellName + " connects " + + describeSides(topology) + " only; its planar topology is fixed."); + } + return new Decision(Operation.RESEAT, target, side, ""); + } + + static int indexAt(List connectors, Frame frame, LocalPosition display) { + for (int index = 0; index < connectors.size(); index++) { + if (frame.display(connectors.get(index).getPosition()).equals(display)) { + return index; + } + } + return -1; + } + + static List add( + List connectors, + Frame frame, + LocalPosition target, + IrisDirection facing, + IrisDirection top, + Identity identity, + CellContents contents + ) { + if (indexAt(connectors, frame, target) >= 0) { + throw new IllegalArgumentException("A connector already occupies " + describe(target)); + } + IrisPosition position = frame.source(target); + IrisJigsawConnector added = new IrisJigsawConnector() + .setPosition(position) + .setDirection(frame.sourceDirection(facing)) + .setTop(frame.sourceDirection(top)) + .setPool(identity.pool()) + .setName(identity.name()) + .setTargetName(identity.targetName()) + .setChannel("") + .setJoint(identity.joint()) + .setFinalState(finalState(position, EMPTY_FINAL_STATE, contents)) + .setSelectionPriority(0) + .setPlacementPriority(0); + List updated = copy(connectors); + updated.add(added); + return List.copyOf(updated); + } + + static List move( + List connectors, + Frame frame, + LocalPosition from, + LocalPosition to, + IrisDirection facing, + IrisDirection top, + CellContents contents + ) { + int index = indexAt(connectors, frame, from); + if (index < 0) { + throw new IllegalArgumentException("No connector exists at " + describe(from)); + } + if (!from.equals(to) && indexAt(connectors, frame, to) >= 0) { + throw new IllegalArgumentException("A connector already occupies " + describe(to)); + } + List updated = copy(connectors); + IrisJigsawConnector moved = updated.get(index); + IrisPosition position = frame.source(to); + moved.setPosition(position) + .setDirection(frame.sourceDirection(facing)) + .setTop(frame.sourceDirection(top)); + if (!from.equals(to)) { + moved.setFinalState(finalState(position, moved.getFinalState(), contents)); + } + return List.copyOf(updated); + } + + static List remove( + List connectors, + Frame frame, + LocalPosition at + ) { + int index = indexAt(connectors, frame, at); + if (index < 0) { + throw new IllegalArgumentException("No connector exists at " + describe(at)); + } + List updated = copy(connectors); + updated.remove(index); + return List.copyOf(updated); + } + + static ReseatResult reseat( + List connectors, + Frame frame, + JigsawPlanarDirection displaySide, + Identity identity, + CellContents contents + ) { + IrisDirection sourceDirection = frame.sourceDirection(displaySide.irisDirection()); + JigsawStudioCellDimensions dimensions = frame.sourceDimensions(); + IrisPosition socket = IrisJigsawConnector.canonicalPlanarPosition( + new IrisPosition(dimensions.width(), dimensions.height(), dimensions.depth()), + sourceDirection); + List updated = copy(connectors); + int seatedIndex = -1; + int strays = 0; + for (int index = 0; index < updated.size(); index++) { + if (updated.get(index).getDirection() != sourceDirection) { + continue; + } + if (seatedIndex < 0) { + seatedIndex = index; + } else { + strays++; + } + } + if (seatedIndex < 0) { + updated.add(new IrisJigsawConnector() + .setPosition(socket) + .setDirection(sourceDirection) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool(identity.pool()) + .setName(identity.name()) + .setTargetName(identity.targetName()) + .setChannel("") + .setJoint(identity.joint()) + .setFinalState(finalState(socket, EMPTY_FINAL_STATE, contents)) + .setSelectionPriority(0) + .setPlacementPriority(0)); + return new ReseatResult(List.copyOf(updated), true, 0); + } + IrisJigsawConnector seated = updated.get(seatedIndex); + boolean alreadySeated = socket.equals(seated.getPosition()) + && seated.getTop() == IrisDirection.UP_POSITIVE_Y; + if (alreadySeated && strays == 0) { + return new ReseatResult(List.copyOf(connectors), false, 0); + } + if (!socket.equals(seated.getPosition())) { + seated.setPosition(socket) + .setFinalState(finalState(socket, seated.getFinalState(), contents)); + } + seated.setTop(IrisDirection.UP_POSITIVE_Y); + List retained = new ArrayList<>(updated.size() - strays); + for (int index = 0; index < updated.size(); index++) { + IrisJigsawConnector connector = updated.get(index); + if (index == seatedIndex || connector.getDirection() != sourceDirection) { + retained.add(connector); + } + } + return new ReseatResult(List.copyOf(retained), true, strays); + } + + static Set changedPositions( + List before, + List after, + Frame frame + ) { + Map previous = displayed(before, frame); + Map current = displayed(after, frame); + Set changed = new HashSet<>(); + for (Map.Entry entry : previous.entrySet()) { + if (!entry.getValue().equals(current.get(entry.getKey()))) { + changed.add(entry.getKey()); + } + } + for (Map.Entry entry : current.entrySet()) { + if (!entry.getValue().equals(previous.get(entry.getKey()))) { + changed.add(entry.getKey()); + } + } + return Set.copyOf(changed); + } + + static String describe(LocalPosition position) { + return position.x() + "," + position.y() + "," + position.z(); + } + + static String sideName(JigsawPlanarDirection side) { + return side.name().toLowerCase(Locale.ROOT); + } + + private static Map displayed(List connectors, Frame frame) { + Map displayed = new HashMap<>(connectors.size()); + for (IrisJigsawConnector connector : connectors) { + displayed.put(frame.display(connector.getPosition()), CapturedConnector.from(connector)); + } + return displayed; + } + + private static List copy(List connectors) { + List copied = new ArrayList<>(connectors.size() + 1); + for (IrisJigsawConnector connector : connectors) { + copied.add(CapturedConnector.from(connector).toConnector()); + } + return copied; + } + + private static String finalState(IrisPosition position, String whenEmpty, CellContents contents) { + Optional content = contents.at(position); + if (content.isEmpty()) { + return whenEmpty; + } + if (content.get().blockEntity()) { + throw new IllegalArgumentException("Connectors cannot replace the block entity at " + + position.getX() + "," + position.getY() + "," + position.getZ() + + "; clear that block first."); + } + return content.get().state(); + } + + private static int countFacing(Map connectors, IrisDirection facing) { + int count = 0; + for (IrisDirection direction : connectors.values()) { + if (direction == facing) { + count++; + } + } + return count; + } + + private static Optional planarSide(IrisDirection direction) { + return switch (direction) { + case NORTH_NEGATIVE_Z -> Optional.of(JigsawPlanarDirection.NORTH); + case EAST_POSITIVE_X -> Optional.of(JigsawPlanarDirection.EAST); + case SOUTH_POSITIVE_Z -> Optional.of(JigsawPlanarDirection.SOUTH); + case WEST_NEGATIVE_X -> Optional.of(JigsawPlanarDirection.WEST); + case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> Optional.empty(); + }; + } + + private static JigsawPlanarDirection nearestSide( + LocalPosition target, + JigsawStudioCellDimensions dimensions, + JigsawPlanarTopology topology + ) { + JigsawPlanarDirection nearest = null; + int nearestDistance = Integer.MAX_VALUE; + for (JigsawPlanarDirection side : SIDE_ORDER) { + if (!topology.connects(side)) { + continue; + } + int distance = switch (side) { + case NORTH -> target.z(); + case EAST -> dimensions.width() - 1 - target.x(); + case SOUTH -> dimensions.depth() - 1 - target.z(); + case WEST -> target.x(); + }; + if (distance < nearestDistance) { + nearest = side; + nearestDistance = distance; + } + } + return Objects.requireNonNull(nearest, "Planar topology has no sides"); + } + + private static String describeSides(JigsawPlanarTopology topology) { + List names = new ArrayList<>(4); + for (JigsawPlanarDirection side : SIDE_ORDER) { + if (topology.connects(side)) { + names.add(sideName(side)); + } + } + if (names.size() == 1) { + return names.getFirst(); + } + if (names.size() == 2) { + return names.get(0) + " and " + names.get(1); + } + return String.join(", ", names.subList(0, names.size() - 1)) + ", and " + names.getLast(); + } + + static double entryDistance(Ray ray, Box box) { + double near = 0.0D; + double far = MAX_REACH; + double[] origin = {ray.originX(), ray.originY(), ray.originZ()}; + double[] direction = {ray.directionX(), ray.directionY(), ray.directionZ()}; + double[] minimum = {box.minX(), box.minY(), box.minZ()}; + double[] maximum = {box.maxX() + 1.0D, box.maxY() + 1.0D, box.maxZ() + 1.0D}; + for (int axis = 0; axis < 3; axis++) { + if (Math.abs(direction[axis]) < 1.0E-12D) { + if (origin[axis] < minimum[axis] || origin[axis] >= maximum[axis]) { + return MAX_REACH; + } + continue; + } + double first = (minimum[axis] - origin[axis]) / direction[axis]; + double second = (maximum[axis] - origin[axis]) / direction[axis]; + near = Math.max(near, Math.min(first, second)); + far = Math.min(far, Math.max(first, second)); + } + return near <= far ? near : MAX_REACH; + } + + private static Optional connectorTarget(Ray ray, Set cells, double limit) { + if (cells.isEmpty()) { + return Optional.empty(); + } + int x = (int) Math.floor(ray.originX()); + int y = (int) Math.floor(ray.originY()); + int z = (int) Math.floor(ray.originZ()); + int stepX = (int) Math.signum(ray.directionX()); + int stepY = (int) Math.signum(ray.directionY()); + int stepZ = (int) Math.signum(ray.directionZ()); + double maxX = boundaryDistance(ray.originX(), ray.directionX(), x); + double maxY = boundaryDistance(ray.originY(), ray.directionY(), y); + double maxZ = boundaryDistance(ray.originZ(), ray.directionZ(), z); + double deltaX = stepX == 0 ? Double.POSITIVE_INFINITY : 1.0D / Math.abs(ray.directionX()); + double deltaY = stepY == 0 ? Double.POSITIVE_INFINITY : 1.0D / Math.abs(ray.directionY()); + double deltaZ = stepZ == 0 ? Double.POSITIVE_INFINITY : 1.0D / Math.abs(ray.directionZ()); + for (int step = 0; step < MAX_TRAVERSAL_STEPS; step++) { + double distance; + IrisDirection entered; + if (maxX < maxY && maxX < maxZ) { + distance = maxX; + x += stepX; + maxX += deltaX; + entered = stepX > 0 ? IrisDirection.WEST_NEGATIVE_X : IrisDirection.EAST_POSITIVE_X; + } else if (maxY < maxZ) { + distance = maxY; + y += stepY; + maxY += deltaY; + entered = stepY > 0 ? IrisDirection.DOWN_NEGATIVE_Y : IrisDirection.UP_POSITIVE_Y; + } else { + distance = maxZ; + z += stepZ; + maxZ += deltaZ; + entered = stepZ > 0 ? IrisDirection.NORTH_NEGATIVE_Z : IrisDirection.SOUTH_POSITIVE_Z; + } + if (distance > limit) { + return Optional.empty(); + } + if (cells.contains(new Cell(x, y, z))) { + return Optional.of(new Target(x, y, z, entered)); + } + } + return Optional.empty(); + } + + private static double boundaryDistance(double origin, double direction, int cell) { + if (direction > 0.0D) { + return (cell + 1 - origin) / direction; + } + if (direction < 0.0D) { + return (origin - cell) / -direction; + } + return Double.POSITIVE_INFINITY; + } + + private static Optional clickTarget(Box box, BlockClick click) { + if (box.contains(click.x(), click.y(), click.z())) { + return Optional.of(new Target(click.x(), click.y(), click.z(), click.face())); + } + int adjacentX = click.x() + click.face().x(); + int adjacentY = click.y() + click.face().y(); + int adjacentZ = click.z() + click.face().z(); + if (box.contains(adjacentX, adjacentY, adjacentZ)) { + return Optional.of(new Target(adjacentX, adjacentY, adjacentZ, click.face().reverse())); + } + return Optional.empty(); + } + + private static Optional boundaryTarget(Box box, Ray ray, double limit) { + double[] origin = {ray.originX(), ray.originY(), ray.originZ()}; + double[] direction = {ray.directionX(), ray.directionY(), ray.directionZ()}; + double[] minimum = {box.minX(), box.minY(), box.minZ()}; + double[] maximum = {box.maxX() + 1.0D, box.maxY() + 1.0D, box.maxZ() + 1.0D}; + double near = Double.NEGATIVE_INFINITY; + double far = Double.POSITIVE_INFINITY; + int nearAxis = -1; + int farAxis = -1; + for (int axis = 0; axis < 3; axis++) { + if (Math.abs(direction[axis]) < 1.0E-12D) { + if (origin[axis] < minimum[axis] || origin[axis] >= maximum[axis]) { + return Optional.empty(); + } + continue; + } + double first = (minimum[axis] - origin[axis]) / direction[axis]; + double second = (maximum[axis] - origin[axis]) / direction[axis]; + double axisNear = Math.min(first, second); + double axisFar = Math.max(first, second); + if (axisNear > near) { + near = axisNear; + nearAxis = axis; + } + if (axisFar < far) { + far = axisFar; + farAxis = axis; + } + } + if (near > far || far < 0.0D) { + return Optional.empty(); + } + boolean inside = near < 0.0D; + double distance = inside ? far - EPSILON : near + EPSILON; + int axis = inside ? farAxis : nearAxis; + if (axis < 0 || !inside && near > limit) { + return Optional.empty(); + } + boolean positive = direction[axis] > 0.0D; + IrisDirection facing = axisDirection(axis, inside == positive); + int x = clamp((int) Math.floor(origin[0] + direction[0] * distance), box.minX(), box.maxX()); + int y = clamp((int) Math.floor(origin[1] + direction[1] * distance), box.minY(), box.maxY()); + int z = clamp((int) Math.floor(origin[2] + direction[2] * distance), box.minZ(), box.maxZ()); + return Optional.of(new Target(x, y, z, facing)); + } + + private static IrisDirection axisDirection(int axis, boolean positive) { + return switch (axis) { + case 0 -> positive ? IrisDirection.EAST_POSITIVE_X : IrisDirection.WEST_NEGATIVE_X; + case 1 -> positive ? IrisDirection.UP_POSITIVE_Y : IrisDirection.DOWN_NEGATIVE_Y; + case 2 -> positive ? IrisDirection.SOUTH_POSITIVE_Z : IrisDirection.NORTH_NEGATIVE_Z; + default -> throw new IllegalStateException("Unreachable Jigsaw connector axis " + axis); + }; + } + + private static int clamp(int value, int minimum, int maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + enum Operation { + SELECT, + DESELECT, + CLEAR_SELECTION, + ADD, + MOVE, + REORIENT, + REMOVE, + RESEAT, + REFUSE, + NONE + } + + record Box(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + static Box of(JigsawStudioBounds bounds, JigsawStudioCellDimensions dimensions) { + return new Box( + bounds.originX(), + bounds.originY(), + bounds.originZ(), + bounds.originX() + dimensions.width() - 1, + bounds.originY() + dimensions.height() - 1, + bounds.originZ() + dimensions.depth() - 1); + } + + boolean contains(int x, int y, int z) { + return x >= minX && x <= maxX && y >= minY && y <= maxY && z >= minZ && z <= maxZ; + } + + LocalPosition local(Target target) { + return new LocalPosition(target.x() - minX, target.y() - minY, target.z() - minZ); + } + + Cell world(LocalPosition position) { + return new Cell(minX + position.x(), minY + position.y(), minZ + position.z()); + } + } + + record Ray(double originX, double originY, double originZ, double directionX, double directionY, double directionZ) { + Ray { + double length = Math.sqrt(directionX * directionX + directionY * directionY + directionZ * directionZ); + if (!(length > 1.0E-9D)) { + throw new IllegalArgumentException("Jigsaw connector ray direction cannot be zero"); + } + directionX /= length; + directionY /= length; + directionZ /= length; + } + } + + record BlockClick(int x, int y, int z, IrisDirection face) { + BlockClick { + Objects.requireNonNull(face, "Jigsaw connector clicked face"); + } + } + + record Cell(int x, int y, int z) { + } + + record Target(int x, int y, int z, IrisDirection facing) { + Target { + Objects.requireNonNull(facing, "Jigsaw connector target facing"); + } + } + + record Decision(Operation operation, LocalPosition position, JigsawPlanarDirection side, String message) { + Decision { + Objects.requireNonNull(operation, "Jigsaw connector decision operation"); + message = message == null ? "" : message; + } + + static Decision of(Operation operation, LocalPosition position) { + return new Decision(operation, position, null, ""); + } + + static Decision message(Operation operation, String message) { + return new Decision(operation, null, null, message); + } + } + + record Frame(JigsawStudioCellDimensions sourceDimensions, int quarterTurns) { + Frame { + Objects.requireNonNull(sourceDimensions, "Jigsaw connector frame dimensions"); + quarterTurns = Math.floorMod(quarterTurns, 4); + } + + JigsawStudioCellDimensions displayDimensions() { + return (quarterTurns & 1) == 0 + ? sourceDimensions + : new JigsawStudioCellDimensions( + sourceDimensions.depth(), + sourceDimensions.height(), + sourceDimensions.width()); + } + + LocalPosition display(IrisPosition source) { + return JigsawStudioCapture.forwardPosition( + source.getX(), + source.getY(), + source.getZ(), + sourceDimensions.width(), + sourceDimensions.depth(), + quarterTurns); + } + + IrisPosition source(LocalPosition display) { + LocalPosition position = JigsawStudioCapture.inversePosition( + display.x(), + display.y(), + display.z(), + sourceDimensions.width(), + sourceDimensions.depth(), + quarterTurns); + return new IrisPosition(position.x(), position.y(), position.z()); + } + + IrisDirection sourceDirection(IrisDirection display) { + return quarterTurns == 0 ? display : IrisObjectRotation.of(0, 90.0D * quarterTurns, 0).rotate(display); + } + + IrisDirection displayDirection(IrisDirection source) { + return quarterTurns == 0 ? source : IrisObjectRotation.of(0, -90.0D * quarterTurns, 0).rotate(source); + } + } + + record Identity(String pool, String name, String targetName, JigsawJoint joint) { + Identity { + Objects.requireNonNull(pool, "Jigsaw connector default pool"); + Objects.requireNonNull(name, "Jigsaw connector default name"); + Objects.requireNonNull(targetName, "Jigsaw connector default target"); + Objects.requireNonNull(joint, "Jigsaw connector default joint"); + } + } + + record CellContent(String state, boolean blockEntity) { + CellContent { + Objects.requireNonNull(state, "Jigsaw connector cell state"); + } + } + + @FunctionalInterface + interface CellContents { + Optional at(IrisPosition source); + } + + record ReseatResult(List connectors, boolean changed, int removedStrays) { + ReseatResult { + connectors = List.copyOf(connectors); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorTool.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorTool.java new file mode 100644 index 000000000..3dce25bf5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorTool.java @@ -0,0 +1,842 @@ +package art.arcane.iris.studio.jigsaw; + +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.value.IrisDirection; +import art.arcane.iris.pack.value.IrisPosition; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.structure.authoring.StructureWriteOptions; +import art.arcane.iris.structure.authoring.StructureWriteResult; +import art.arcane.iris.structure.graph.StructureResourceBundleGraphCompiler; +import art.arcane.iris.structure.jigsaw.IrisJigsawConnector; +import art.arcane.iris.structure.jigsaw.IrisJigsawPiece; +import art.arcane.iris.structure.jigsaw.JigsawJoint; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.studio.generation.JigsawStudioGenerator; +import art.arcane.iris.studio.jigsaw.JigsawStudioCapture.LocalPosition; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.BlockClick; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Box; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Cell; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.CellContent; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Decision; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Frame; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Identity; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Operation; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Ray; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.ReseatResult; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Target; +import art.arcane.iris.studio.jigsaw.JigsawStudioMaterializer.ConnectorEditReservation; +import art.arcane.iris.studio.jigsaw.JigsawStudioService.ActiveStudio; +import art.arcane.iris.world.task.J; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hud.HudPriority; +import art.arcane.volmlib.util.hud.HudSegment; +import art.arcane.volmlib.util.hud.HudSlot; +import art.arcane.volmlib.util.plugin.ComponentMessenger; +import org.bukkit.ChatColor; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; +import org.bukkit.util.RayTraceResult; +import org.bukkit.util.Vector; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static art.arcane.iris.studio.jigsaw.JigsawStudioGraphMutations.loadMappedLayout; +import static art.arcane.iris.studio.jigsaw.JigsawStudioProtection.authorizeOwner; +import static art.arcane.iris.studio.jigsaw.JigsawStudioService.AUTOSAVE_RETRY_TICKS; +import static art.arcane.iris.studio.jigsaw.JigsawStudioService.chunkKey; +import static art.arcane.iris.studio.jigsaw.JigsawStudioService.failureMessage; +import static art.arcane.iris.studio.jigsaw.JigsawStudioService.message; +import static art.arcane.iris.studio.jigsaw.JigsawStudioService.writeFailure; + +final class JigsawStudioConnectorTool { + private static final int MAX_QUEUED_ATTEMPTS = 48; + private static final double HOVER_REACH = 5.0D; + private static final String HUD_PURPOSE = "iris:jigsaw-connector"; + private static final long HUD_MILLIS = 4000L; + + private final JigsawStudioService service; + final Map selections = new ConcurrentHashMap<>(); + final Map pendingClicks = new ConcurrentHashMap<>(); + + JigsawStudioConnectorTool(JigsawStudioService service) { + this.service = Objects.requireNonNull(service, "Jigsaw Studio service"); + } + + boolean handleClick( + Player player, + JigsawStudioToolPayload payload, + boolean primary, + Block clicked, + BlockFace face + ) { + if (player == null || payload == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> handleClick(player, payload, primary, clicked, face)); + } + ActiveStudio studio = service.studios.get(player.getWorld().getUID()); + if (studio == null || !studio.generator().getRequest().requestId().equals(payload.requestId())) { + actionBar(player, ChatColor.RED + "This Connector Tool belongs to a different or closed Jigsaw Studio."); + return false; + } + if (!authorizeOwner(player, studio)) { + return false; + } + if (!primary && clicked != null && clicked.getType() == Material.JIGSAW) { + player.closeInventory(); + } + Resolution resolution = resolve(player, studio, primary, blockClick(clicked, face)); + if (resolution.click().isEmpty()) { + actionBar(player, ChatColor.YELLOW + resolution.failure()); + return false; + } + process(player, studio, resolution.click().get(), 0); + return true; + } + + Optional feedback(Player player, ActiveStudio studio, boolean holdingTool) { + Optional selected = selectedCell(player, studio); + Optional hover = Optional.empty(); + if (holdingTool) { + RayTraceResult trace = player.rayTraceBlocks(HOVER_REACH); + Optional click = trace == null + ? Optional.empty() + : blockClick(trace.getHitBlock(), trace.getHitBlockFace()); + Resolution resolution = resolve(player, studio, false, click); + if (resolution.click().isPresent() && resolution.box() != null) { + ToolClick toolClick = resolution.click().get(); + hover = Optional.of(new Hover( + resolution.box().world(toolClick.target()), + toolClick.facing(), + resolution.existingConnector())); + } + } + if (selected.isEmpty() && hover.isEmpty()) { + return Optional.empty(); + } + return Optional.of(new Feedback(selected, hover)); + } + + void clearPlayer(UUID playerId) { + selections.remove(playerId); + pendingClicks.remove(playerId); + } + + private Optional selectedCell(Player player, ActiveStudio studio) { + Selection selection = selections.get(player.getUniqueId()); + if (selection == null || !selection.requestId().equals(studio.generator().getRequest().requestId())) { + return Optional.empty(); + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell = session.layout().get(selection.workcellId()); + if (workcell == null || session.activeVariant(workcell.stableId()) + .map(JigsawStudioVariant::pieceKey) + .filter(selection.pieceKey()::equals) + .isEmpty()) { + return Optional.empty(); + } + JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(workcell); + if (!rendered.valid() || !displayedConnectors(rendered).containsKey(selection.position())) { + return Optional.empty(); + } + return Optional.of(Box.of(workcell.bounds(), rendered.dimensions()).world(selection.position())); + } + + private Resolution resolve( + Player player, + ActiveStudio studio, + boolean primary, + Optional click + ) { + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioLayout layout = session.layout(); + Location eye = player.getEyeLocation(); + Vector direction = eye.getDirection(); + if (direction.lengthSquared() < 1.0E-9D) { + return Resolution.failed("Look at a workcell to use the Connector Tool."); + } + Ray ray = new Ray(eye.getX(), eye.getY(), eye.getZ(), direction.getX(), direction.getY(), direction.getZ()); + JigsawStudioBay workcell = targetWorkcell(layout, player.getLocation(), ray, click); + if (workcell == null) { + return Resolution.failed("Point the Connector Tool at a workcell."); + } + JigsawStudioVariant variant = session.activeVariant(workcell.stableId()).orElse(null); + if (variant == null) { + return Resolution.failed(workcell.displayName() + " has no loaded variant."); + } + if (!variant.owned()) { + return Resolution.failed("Variant '" + variant.pieceKey() + "' is read-only."); + } + JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(workcell); + if (!rendered.valid()) { + return Resolution.failed(workcell.displayName() + " cannot be edited: " + rendered.failure()); + } + Box box = Box.of(workcell.bounds(), rendered.dimensions()); + Map connectors = displayedConnectors(rendered); + Set cells = new HashSet<>(connectors.size()); + for (LocalPosition position : connectors.keySet()) { + cells.add(box.world(position)); + } + Optional target = JigsawStudioConnectorPlacement.target(box, ray, click, cells, player.isSneaking()); + if (target.isEmpty()) { + return Resolution.failed("Point at a block or wall inside the " + + JigsawStudioObjectResizer.describeDimensions(rendered.dimensions()) + " variant."); + } + LocalPosition position = box.local(target.get()); + IrisDirection top = target.get().facing().isVertical() + ? IrisDirection.getDirection(player.getFacing()) + : IrisDirection.UP_POSITIVE_Y; + ToolClick toolClick = new ToolClick( + studio.generator().getRequest().requestId(), + workcell.stableId(), + variant.pieceKey(), + session.workcellSnapshot(workcell.stableId()).loadGeneration(), + primary, + position, + target.get().facing(), + top); + return new Resolution(Optional.of(toolClick), box, connectors.containsKey(position), ""); + } + + private void process(Player player, ActiveStudio studio, ToolClick click, int attempt) { + if (!service.isCurrentRequest(studio, click.requestId())) { + actionBar(player, ChatColor.RED + "This Jigsaw Studio session is no longer active."); + return; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell = session.layout().get(click.workcellId()); + JigsawStudioVariant variant = workcell == null ? null : session.activeVariant(workcell.stableId()).orElse(null); + if (variant == null + || !variant.pieceKey().equals(click.pieceKey()) + || session.workcellSnapshot(workcell.stableId()).loadGeneration() != click.loadGeneration()) { + actionBar(player, ChatColor.YELLOW + "The workcell changed before the connector edit ran; click again."); + return; + } + JigsawStudioSession.WorkcellSnapshot snapshot = session.workcellSnapshot(workcell.stableId()); + if (snapshot.dirty() || snapshot.saveInProgress() || snapshot.switchInProgress()) { + queue(player, studio, click, attempt, "Saving your latest edits before changing connectors..."); + return; + } + JigsawStudioSaveLifecycle.BayReadiness readiness = studio.population(workcell).readiness(); + if (!readiness.ready()) { + if (!readiness.failure().isEmpty()) { + actionBar(player, ChatColor.RED + workcell.displayName() + " is not editable: " + readiness.failure()); + return; + } + queue(player, studio, click, attempt, "Waiting for " + workcell.displayName() + " to finish loading..."); + return; + } + JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(workcell); + if (!rendered.valid()) { + actionBar(player, ChatColor.RED + workcell.displayName() + " cannot be edited: " + rendered.failure()); + return; + } + Map connectors = displayedConnectors(rendered); + Optional selection = currentSelection(player, click); + Decision decision = workcell.topology().isPresent() + ? JigsawStudioConnectorPlacement.decidePlanar( + click.primary(), + click.target(), + click.facing(), + rendered.dimensions(), + workcell.topology().get(), + connectors, + workcell.displayName()) + : JigsawStudioConnectorPlacement.decideSpatial( + click.primary(), + click.target(), + click.facing(), + connectors, + selection); + switch (decision.operation()) { + case SELECT -> { + selections.put(player.getUniqueId(), new Selection( + click.requestId(), click.workcellId(), click.pieceKey(), decision.position())); + actionBar(player, ChatColor.AQUA + "Selected the connector at " + + JigsawStudioConnectorPlacement.describe(decision.position()) + " facing " + + directionName(connectors.get(decision.position())) + + ChatColor.GRAY + ". Right-click a new spot to move it, the same block to deselect, or left-click to remove it."); + } + case DESELECT, CLEAR_SELECTION -> { + selections.remove(player.getUniqueId()); + actionBar(player, ChatColor.GRAY + "Connector deselected."); + } + case REFUSE -> actionBar(player, ChatColor.RED + decision.message()); + case NONE -> actionBar(player, ChatColor.GRAY + decision.message()); + case ADD, MOVE, REORIENT, REMOVE, RESEAT -> write( + player, + studio, + workcell, + variant, + new Edit(decision, click, selection), + attempt); + } + } + + private void write( + Player player, + ActiveStudio studio, + JigsawStudioBay workcell, + JigsawStudioVariant variant, + Edit edit, + int attempt + ) { + ConnectorEditReservation reservation = service.materializer.beginConnectorEdit(studio, workcell.stableId()); + if (reservation.status() == JigsawStudioMaterializer.ConnectorEditStatus.BUSY) { + queue(player, studio, edit.click(), attempt, reservation.message()); + return; + } + if (reservation.status() == JigsawStudioMaterializer.ConnectorEditStatus.BLOCKED) { + actionBar(player, ChatColor.RED + reservation.message()); + return; + } + UUID requestId = edit.click().requestId(); + J.a(() -> { + EditCommit commit = null; + Throwable failure = null; + try { + commit = commitEdit(studio, workcell, variant, edit); + } catch (Throwable exception) { + failure = exception; + } + EditCommit completed = commit; + Throwable completedFailure = failure; + boolean written = completed != null && completed.changed(); + boolean scheduled = J.runEntity( + player, + () -> completeEdit(player, studio, edit, completed, completedFailure), + 0, + () -> abandonEdit(requestId, written, completedFailure)); + if (!scheduled) { + abandonEdit(requestId, written, completedFailure); + } + }); + } + + private EditCommit commitEdit( + ActiveStudio studio, + JigsawStudioBay workcell, + JigsawStudioVariant variant, + Edit edit + ) throws IOException { + JigsawStudioActivation.Request request = studio.generator().getRequest(); + IrisData source = request.source(); + Path packRoot = source.getDataFolder().toPath(); + IrisJigsawPiece piece = source.getJigsawPieceLoader().load(variant.pieceKey(), false); + if (piece == null || piece.getConnectors() == null || piece.getObject() == null || piece.getObject().isBlank()) { + throw new IOException("Piece '" + variant.pieceKey() + "' is missing or has no object"); + } + IrisObject object = source.getObjectLoader().load(piece.getObject(), false); + if (object == null || object.getW() < 1 || object.getH() < 1 || object.getD() < 1) { + throw new IOException("Object '" + piece.getObject() + "' is missing or has invalid dimensions"); + } + Frame frame = new Frame( + new JigsawStudioCellDimensions(object.getW(), object.getH(), object.getD()), + variant.sourceToCanonicalQuarterTurns()); + JigsawStudioConnectorPlacement.CellContents contents = position -> cellContent(object, position); + List before = List.copyOf(piece.getConnectors()); + LocalPosition target = edit.click().target(); + IrisDirection facing = edit.click().facing(); + IrisDirection top = edit.click().top(); + List after; + String summary; + Selection selection = null; + switch (edit.decision().operation()) { + case ADD -> { + after = JigsawStudioConnectorPlacement.add( + before, frame, target, facing, top, defaultIdentity(studio, variant, before, packRoot), contents); + summary = "Placed a connector at " + JigsawStudioConnectorPlacement.describe(target) + + " facing " + directionName(facing) + "."; + } + case MOVE -> { + LocalPosition from = edit.selection().orElseThrow(); + after = JigsawStudioConnectorPlacement.move(before, frame, from, target, facing, top, contents); + summary = "Moved the connector from " + JigsawStudioConnectorPlacement.describe(from) + + " to " + JigsawStudioConnectorPlacement.describe(target) + + " facing " + directionName(facing) + "."; + selection = new Selection( + edit.click().requestId(), edit.click().workcellId(), edit.click().pieceKey(), target); + } + case REORIENT -> { + after = JigsawStudioConnectorPlacement.move(before, frame, target, target, facing, top, contents); + summary = "Turned the connector at " + JigsawStudioConnectorPlacement.describe(target) + + " to face " + directionName(facing) + "."; + selection = new Selection( + edit.click().requestId(), edit.click().workcellId(), edit.click().pieceKey(), target); + } + case REMOVE -> { + after = JigsawStudioConnectorPlacement.remove(before, frame, target); + summary = "Removed the connector at " + JigsawStudioConnectorPlacement.describe(target) + "."; + } + case RESEAT -> { + JigsawPlanarDirection side = Objects.requireNonNull(edit.decision().side(), "Planar socket side"); + ReseatResult result = JigsawStudioConnectorPlacement.reseat( + before, frame, side, defaultIdentity(studio, variant, before, packRoot), contents); + if (!result.changed()) { + return EditCommit.unchanged("The " + JigsawStudioConnectorPlacement.sideName(side) + + " socket is already seated at its face center."); + } + after = result.connectors(); + summary = "Seated the " + JigsawStudioConnectorPlacement.sideName(side) + " socket at its face center" + + (result.removedStrays() == 0 ? "." : " and removed " + result.removedStrays() + + " stray connector" + (result.removedStrays() == 1 ? "." : "s.")); + } + default -> throw new IllegalStateException("Unsupported connector edit " + edit.decision().operation()); + } + JigsawStudioCapture.requireWorkcellTopology(workcell, after, frame.quarterTurns()); + JigsawStudioResourceBundleAssembler.Assembly assembly = JigsawStudioResourceBundleAssembler.assemble( + packRoot, + request.structureKey(), + variant.pieceKey(), + JigsawStudioResourceBundleAssembler.ownedResourceContent( + packRoot, + "objects/" + piece.getObject() + ".iob"), + after, + false); + StructureResourceBundleGraphCompiler.requireViable(assembly.bundle()); + JigsawStudioHistoryStore historyStore = new JigsawStudioHistoryStore(packRoot, request.structureKey()); + JigsawStudioHistoryStore.Snapshot previous = historyStore.snapshotCurrent(variant.pieceKey()); + if (!previous.matches(assembly.bundle())) { + historyStore.append(previous); + } + StructureWriteResult result; + synchronized (service.saveLifecycleLock) { + result = new StructureTransactionWriter(packRoot) + .write(assembly.bundle(), StructureWriteOptions.overwriteExpected(assembly.expectedManifestHash())); + } + if (!result.successful()) { + throw new IOException(writeFailure(result)); + } + source.invalidateStructureResources(); + return new EditCommit( + true, + loadMappedLayout(studio), + JigsawStudioConnectorPlacement.changedPositions(before, after, frame), + summary, + Optional.ofNullable(selection), + edit.decision().operation() == Operation.REMOVE ? Optional.of(target) : Optional.empty()); + } + + private void completeEdit( + Player player, + ActiveStudio studio, + Edit edit, + EditCommit commit, + Throwable failure + ) { + UUID requestId = edit.click().requestId(); + if (failure != null) { + service.materializer.finishConnectorEdit(requestId); + if (!(failure instanceof IllegalArgumentException)) { + IrisLogging.reportError("Jigsaw Studio connector edit failed in workcell " + + edit.click().workcellId(), failure); + } + message(player, "Connector edit failed: " + failureMessage(failure)); + actionBar(player, ChatColor.RED + "Connector edit failed: " + failureMessage(failure)); + return; + } + if (!commit.changed()) { + service.materializer.finishConnectorEdit(requestId); + actionBar(player, ChatColor.GRAY + commit.summary()); + return; + } + if (!service.isCurrentRequest(studio, requestId)) { + service.materializer.finishConnectorEdit(requestId); + message(player, "The connector edit saved after this Studio session changed; reopen it to continue."); + return; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell; + JigsawStudioGenerator.RenderedBay rendered; + try { + session.replaceLayout(commit.layout()); + for (JigsawStudioBay bay : commit.layout().bays()) { + studio.generator().invalidateRender(bay.stableId()); + } + workcell = session.layout().get(edit.click().workcellId()); + if (workcell == null) { + throw new IOException("workcell '" + edit.click().workcellId() + "' left the layout"); + } + rendered = studio.generator().renderBay(workcell); + if (!rendered.valid()) { + throw new IOException(rendered.failure()); + } + } catch (Throwable exception) { + service.saveLifecycle.reopenRequiredRequests.add(requestId); + service.materializer.finishConnectorEdit(requestId); + IrisLogging.reportError("Jigsaw Studio saved a connector edit but could not refresh workcell " + + edit.click().workcellId(), exception); + message(player, "The connector edit saved, but Studio could not refresh: " + + failureMessage(exception) + ". Reopen this project before editing further."); + return; + } + restoreWorld(player, studio, workcell, rendered, commit, + session.workcellSnapshot(workcell.stableId()).connectorsVisible()); + } + + private void restoreWorld( + Player player, + ActiveStudio studio, + JigsawStudioBay workcell, + JigsawStudioGenerator.RenderedBay rendered, + EditCommit commit, + boolean connectorsVisible + ) { + Map connectors = new HashMap<>(); + for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) { + connectors.put(new LocalPosition(connector.x(), connector.y(), connector.z()), connector); + } + Map blocks = new HashMap<>(); + for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) { + blocks.put(new LocalPosition(block.x(), block.y(), block.z()), block); + } + JigsawStudioBounds bounds = workcell.bounds(); + Map> positionsByChunk = new LinkedHashMap<>(); + for (LocalPosition position : commit.changedPositions()) { + positionsByChunk.computeIfAbsent( + chunkKey((bounds.originX() + position.x()) >> 4, (bounds.originZ() + position.z()) >> 4), + ignored -> new HashSet<>()).add(position); + } + if (positionsByChunk.isEmpty()) { + finishEdit(player, studio, workcell.stableId(), commit, "", null); + return; + } + AtomicInteger remaining = new AtomicInteger(positionsByChunk.size()); + AtomicReference failure = new AtomicReference<>(""); + AtomicReference cause = new AtomicReference<>(); + for (Map.Entry> entry : positionsByChunk.entrySet()) { + int chunkX = (int) (entry.getKey() >> 32); + int chunkZ = (int) entry.getKey().longValue(); + boolean scheduled = J.runRegion(studio.world(), chunkX, chunkZ, () -> { + try { + if (!studio.world().isChunkLoaded(chunkX, chunkZ)) { + throw new IOException("workcell chunk " + chunkX + "," + chunkZ + " is not loaded"); + } + JigsawStudioChunkWriter.restoreChunkPositions( + studio.world(), workcell, entry.getValue(), connectors, blocks, connectorsVisible); + } catch (Throwable exception) { + failure.compareAndSet("", failureMessage(exception)); + cause.compareAndSet(null, exception); + } + if (remaining.decrementAndGet() == 0) { + scheduleFinish(player, studio, workcell.stableId(), commit, failure.get(), cause.get()); + } + }); + if (!scheduled) { + failure.compareAndSet("", "workcell chunk " + chunkX + "," + chunkZ + + " could not be scheduled on its owning region"); + if (remaining.decrementAndGet() == 0) { + scheduleFinish(player, studio, workcell.stableId(), commit, failure.get(), cause.get()); + } + } + } + } + + private void scheduleFinish( + Player player, + ActiveStudio studio, + String workcellId, + EditCommit commit, + String failure, + Throwable cause + ) { + UUID requestId = studio.generator().getRequest().requestId(); + boolean scheduled = J.runEntity( + player, + () -> finishEdit(player, studio, workcellId, commit, failure, cause), + 0, + () -> { + service.materializer.finishConnectorEdit(requestId); + service.floorPainter.refresh(studio, workcellId); + }); + if (!scheduled) { + service.materializer.finishConnectorEdit(requestId); + service.floorPainter.refresh(studio, workcellId); + } + } + + private void finishEdit( + Player player, + ActiveStudio studio, + String workcellId, + EditCommit commit, + String failure, + Throwable cause + ) { + service.materializer.finishConnectorEdit(studio.generator().getRequest().requestId()); + commit.selection().ifPresentOrElse( + selection -> selections.put(player.getUniqueId(), selection), + () -> commit.removed().ifPresent(removed -> selections.computeIfPresent( + player.getUniqueId(), + (ignored, current) -> current.workcellId().equals(workcellId) + && current.position().equals(removed) ? null : current))); + service.floorPainter.refresh(studio, workcellId); + service.playerContext.refreshWorkcellContext(studio.worldId(), workcellId); + service.evaluator.scheduleEvaluation(studio); + if (!failure.isEmpty()) { + if (cause != null) { + IrisLogging.reportError("Jigsaw Studio could not update connector blocks in workcell " + workcellId, cause); + } + message(player, commit.summary() + " The workcell blocks could not update: " + failure + + ". Visit the whole workcell and use Reset Connector Blocks."); + return; + } + actionBar(player, ChatColor.GREEN + commit.summary()); + player.playSound(player.getLocation(), "minecraft:block.note_block.hat", 0.6F, 1.4F); + } + + private void abandonEdit(UUID requestId, boolean written, Throwable failure) { + if (written) { + service.saveLifecycle.reopenRequiredRequests.add(requestId); + } + service.materializer.finishConnectorEdit(requestId); + if (failure != null && !(failure instanceof IllegalArgumentException)) { + IrisLogging.reportError("Jigsaw Studio connector edit failed after its player left", failure); + } + } + + private void queue(Player player, ActiveStudio studio, ToolClick click, int attempt, String reason) { + int nextAttempt = attempt + 1; + if (nextAttempt > MAX_QUEUED_ATTEMPTS) { + pendingClicks.remove(player.getUniqueId()); + actionBar(player, ChatColor.RED + "The connector edit was cancelled: " + reason); + return; + } + PendingClick pending = new PendingClick(click, nextAttempt); + PendingClick previous = pendingClicks.put(player.getUniqueId(), pending); + service.autosaveScheduler.expediteAutosaves(click.requestId()); + if (previous == null || attempt == 0) { + actionBar(player, ChatColor.GRAY + reason); + } + boolean scheduled = J.runEntity( + player, + () -> resumePending(player, studio, pending), + AUTOSAVE_RETRY_TICKS, + () -> pendingClicks.remove(player.getUniqueId(), pending)); + if (!scheduled) { + pendingClicks.remove(player.getUniqueId(), pending); + } + } + + private void resumePending(Player player, ActiveStudio studio, PendingClick pending) { + if (!pendingClicks.remove(player.getUniqueId(), pending)) { + return; + } + process(player, studio, pending.click(), pending.attempt()); + } + + private Optional currentSelection(Player player, ToolClick click) { + Selection selection = selections.get(player.getUniqueId()); + if (selection == null + || !selection.requestId().equals(click.requestId()) + || !selection.workcellId().equals(click.workcellId()) + || !selection.pieceKey().equals(click.pieceKey())) { + return Optional.empty(); + } + return Optional.of(selection.position()); + } + + private Identity defaultIdentity( + ActiveStudio studio, + JigsawStudioVariant variant, + List existing, + Path packRoot + ) { + JigsawStudioActivation.Request request = studio.generator().getRequest(); + boolean planar = variant.mode() == JigsawStudioMode.PLANAR_JIGSAW; + String generatedPool = request.structureKey() + "/pieces"; + String pool; + if (Files.isRegularFile(packRoot.resolve("jigsaw-pools/" + generatedPool + ".json"))) { + pool = generatedPool; + } else if (!existing.isEmpty() && existing.getFirst().getPool() != null + && !existing.getFirst().getPool().isBlank()) { + pool = existing.getFirst().getPool(); + } else if (!variant.memberships().isEmpty()) { + pool = variant.memberships().getFirst().poolKey(); + } else { + throw new IllegalArgumentException("Variant '" + variant.pieceKey() + + "' has no pool for a new connector; add it to a pool first."); + } + return new Identity( + pool, + planar ? "iris:planar" : "iris:spatial", + planar ? "iris:planar" : "iris:spatial", + planar ? JigsawJoint.ALIGNED : JigsawJoint.ROLLABLE); + } + + private static Optional cellContent(IrisObject object, IrisPosition position) { + NativeBlockState state = object.getBlocks().get(object.getSigned(position.getX(), position.getY(), position.getZ())); + if (state == null || state.isAir()) { + return Optional.empty(); + } + boolean blockEntity = object.getStates().get(object.getSigned(position.getX(), position.getY(), position.getZ())) != null; + return Optional.of(new CellContent(state.key(), blockEntity)); + } + + private static JigsawStudioBay targetWorkcell( + JigsawStudioLayout layout, + Location feet, + Ray ray, + Optional click + ) { + if (click.isPresent()) { + BlockClick value = click.get(); + JigsawStudioBay clicked = layout.findAt(value.x(), value.y(), value.z()); + if (clicked != null) { + return clicked; + } + JigsawStudioBay adjacent = layout.findAt( + value.x() + value.face().x(), + value.y() + value.face().y(), + value.z() + value.face().z()); + if (adjacent != null) { + return adjacent; + } + } + JigsawStudioBay standing = layout.findColumn(feet.getBlockX(), feet.getBlockY(), feet.getBlockZ()); + if (standing != null) { + return standing; + } + JigsawStudioBay nearest = null; + double nearestDistance = JigsawStudioConnectorPlacement.MAX_REACH; + for (JigsawStudioBay bay : layout.bays()) { + JigsawStudioBounds bounds = bay.bounds(); + double distance = JigsawStudioConnectorPlacement.entryDistance(ray, Box.of(bounds, bounds.dimensions())); + if (distance < nearestDistance) { + nearest = bay; + nearestDistance = distance; + } + } + return nearest; + } + + private static Optional blockClick(Block clicked, BlockFace face) { + if (clicked == null || face == null) { + return Optional.empty(); + } + return switch (face) { + case NORTH, SOUTH, EAST, WEST, UP, DOWN -> Optional.of(new BlockClick( + clicked.getX(), clicked.getY(), clicked.getZ(), IrisDirection.getDirection(face))); + default -> Optional.empty(); + }; + } + + private static Map displayedConnectors(JigsawStudioGenerator.RenderedBay rendered) { + Map connectors = new LinkedHashMap<>(rendered.connectors().size()); + for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) { + connectors.put(new LocalPosition(connector.x(), connector.y(), connector.z()), + connector.connector().getDirection()); + } + return connectors; + } + + static String directionName(IrisDirection direction) { + if (direction == null) { + return "nowhere"; + } + return switch (direction) { + case UP_POSITIVE_Y -> "up"; + case DOWN_NEGATIVE_Y -> "down"; + case NORTH_NEGATIVE_Z -> "north"; + case SOUTH_POSITIVE_Z -> "south"; + case EAST_POSITIVE_X -> "east"; + case WEST_NEGATIVE_X -> "west"; + }; + } + + private static void actionBar(Player player, String text) { + if (BukkitPlatform.hasHud()) { + BukkitPlatform.hudBar().publish(player, new HudSegment( + HUD_PURPOSE, + HudPriority.INTERACTIVE, + HUD_MILLIS, + List.of(HudSlot.CENTER), + text)); + return; + } + ComponentMessenger.sendActionBarLegacy(player, text); + } + + record Selection(UUID requestId, String workcellId, String pieceKey, LocalPosition position) { + Selection { + Objects.requireNonNull(requestId, "Jigsaw connector selection request"); + Objects.requireNonNull(workcellId, "Jigsaw connector selection workcell"); + Objects.requireNonNull(pieceKey, "Jigsaw connector selection piece"); + Objects.requireNonNull(position, "Jigsaw connector selection position"); + } + } + + record PendingClick(ToolClick click, int attempt) { + PendingClick { + Objects.requireNonNull(click, "Jigsaw connector pending click"); + } + } + + record ToolClick( + UUID requestId, + String workcellId, + String pieceKey, + long loadGeneration, + boolean primary, + LocalPosition target, + IrisDirection facing, + IrisDirection top + ) { + ToolClick { + Objects.requireNonNull(requestId, "Jigsaw connector click request"); + Objects.requireNonNull(workcellId, "Jigsaw connector click workcell"); + Objects.requireNonNull(pieceKey, "Jigsaw connector click piece"); + Objects.requireNonNull(target, "Jigsaw connector click target"); + Objects.requireNonNull(facing, "Jigsaw connector click facing"); + Objects.requireNonNull(top, "Jigsaw connector click top"); + } + } + + record Hover(Cell cell, IrisDirection facing, boolean existingConnector) { + } + + record Feedback(Optional selected, Optional hover) { + } + + private record Resolution(Optional click, Box box, boolean existingConnector, String failure) { + private static Resolution failed(String failure) { + return new Resolution(Optional.empty(), null, false, failure); + } + } + + private record Edit(Decision decision, ToolClick click, Optional selection) { + } + + private record EditCommit( + boolean changed, + JigsawStudioLayout layout, + Set changedPositions, + String summary, + Optional selection, + Optional removed + ) { + private static EditCommit unchanged(String summary) { + return new EditCommit(false, null, Set.of(), summary, Optional.empty(), Optional.empty()); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioDetailsMenu.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioDetailsMenu.java index d1ba75b3b..6e96f566e 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioDetailsMenu.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioDetailsMenu.java @@ -158,8 +158,11 @@ final class JigsawStudioDetailsMenu { } window.setElement(2, 0, size); - boolean lastVariant = workcell.variants().size() <= 1; - boolean deletionAvailable = variant.owned() && !variant.active() && !lastVariant; + boolean spatial = state.mode() == JigsawStudioMode.SPATIAL_JIGSAW; + boolean lastVariant = spatial + ? state.workcells().stream().mapToInt(cell -> cell.variants().size()).sum() <= 1 + : workcell.variants().size() <= 1; + boolean deletionAvailable = state.canDeleteVariant(workcell, variant); boolean confirmed = deletionAvailable && controller.isDeleteConfirmed( window.getViewer().getUniqueId(), state.requestId(), @@ -170,11 +173,11 @@ final class JigsawStudioDetailsMenu { deletionAvailable ? confirmed ? Material.REDSTONE : Material.LAVA_BUCKET : Material.BARRIER, deletionAvailable ? confirmed ? ChatColor.RED + "Confirm Delete Variant" : ChatColor.RED + "Delete Variant" - : variant.active() - ? ChatColor.GRAY + "Loaded Variant Cannot Be Deleted" + : !variant.owned() + ? ChatColor.GRAY + "Deletion is Read-only" : lastVariant ? ChatColor.GRAY + "Last Variant Cannot Be Deleted" - : ChatColor.GRAY + "Deletion is Read-only") + : ChatColor.GRAY + "Loaded Variant Cannot Be Deleted") .setEnchanted(confirmed); if (deletionAvailable) { delete.addLore(confirmed @@ -186,10 +189,10 @@ final class JigsawStudioDetailsMenu { workcell.stableId(), variant.pieceKey(), page)); - } else if (variant.active()) { - delete.addLore(ChatColor.GRAY + "Load another variant in this workcell first."); - } else if (lastVariant) { + } else if (variant.owned() && lastVariant) { delete.addLore(ChatColor.GRAY + "Create another variant before deleting this one."); + } else if (variant.owned()) { + delete.addLore(ChatColor.GRAY + "Load another variant in this workcell first."); } window.setElement(4, 0, delete); } diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioFloorGlyph.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioFloorGlyph.java new file mode 100644 index 000000000..369d3035f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioFloorGlyph.java @@ -0,0 +1,145 @@ +package art.arcane.iris.studio.jigsaw; + +import art.arcane.iris.pack.value.IrisDirection; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class JigsawStudioFloorGlyph { + private static final JigsawStudioFloorGlyph EMPTY = new JigsawStudioFloorGlyph(0, 0, Map.of()); + + private final int width; + private final int depth; + private final Map cells; + + private JigsawStudioFloorGlyph(int width, int depth, Map cells) { + this.width = width; + this.depth = depth; + this.cells = cells; + } + + public static JigsawStudioFloorGlyph empty() { + return EMPTY; + } + + public static JigsawStudioFloorGlyph of(int width, int depth, List markers) { + List sourceMarkers = List.copyOf(Objects.requireNonNull(markers, "Jigsaw Studio floor markers")); + if (width < 1 || depth < 1 || sourceMarkers.isEmpty()) { + return EMPTY; + } + Map cells = new HashMap<>(); + int centerX = width / 2; + int centerZ = depth / 2; + for (Marker marker : sourceMarkers) { + int x = clamp(marker.x(), width); + int z = clamp(marker.z(), depth); + drawLine(cells, centerX, centerZ, x, z); + if (!marker.direction().isVertical()) { + drawExtension(cells, x, z, marker.direction(), width, depth); + } + } + for (Marker marker : sourceMarkers) { + int x = clamp(marker.x(), width); + int z = clamp(marker.z(), depth); + Cell socket = switch (marker.direction()) { + case UP_POSITIVE_Y -> Cell.UP_SOCKET; + case DOWN_NEGATIVE_Y -> Cell.DOWN_SOCKET; + case NORTH_NEGATIVE_Z, SOUTH_POSITIVE_Z, EAST_POSITIVE_X, WEST_NEGATIVE_X -> Cell.HORIZONTAL_SOCKET; + }; + cells.merge(key(x, z), socket, JigsawStudioFloorGlyph::merge); + } + return new JigsawStudioFloorGlyph(width, depth, Map.copyOf(cells)); + } + + public Cell cell(int localX, int localZ) { + if (localX < 0 || localZ < 0 || localX >= width || localZ >= depth) { + return Cell.BASE; + } + return cells.getOrDefault(key(localX, localZ), Cell.BASE); + } + + private static void drawLine(Map cells, int startX, int startZ, int endX, int endZ) { + int deltaX = Math.abs(endX - startX); + int deltaZ = -Math.abs(endZ - startZ); + int stepX = startX < endX ? 1 : -1; + int stepZ = startZ < endZ ? 1 : -1; + int error = deltaX + deltaZ; + int x = startX; + int z = startZ; + while (true) { + cells.merge(key(x, z), Cell.LINE, JigsawStudioFloorGlyph::merge); + if (x == endX && z == endZ) { + return; + } + int doubled = error * 2; + if (doubled >= deltaZ) { + error += deltaZ; + x += stepX; + } + if (doubled <= deltaX) { + error += deltaX; + z += stepZ; + } + } + } + + private static void drawExtension( + Map cells, + int startX, + int startZ, + IrisDirection direction, + int width, + int depth + ) { + int x = startX + direction.x(); + int z = startZ + direction.z(); + while (x >= 0 && z >= 0 && x < width && z < depth) { + cells.merge(key(x, z), Cell.LINE, JigsawStudioFloorGlyph::merge); + x += direction.x(); + z += direction.z(); + } + } + + private static Cell merge(Cell current, Cell next) { + if (current == Cell.UP_SOCKET && next == Cell.DOWN_SOCKET + || current == Cell.DOWN_SOCKET && next == Cell.UP_SOCKET) { + return Cell.UP_AND_DOWN_SOCKET; + } + return next.priority() > current.priority() ? next : current; + } + + private static int clamp(int value, int size) { + return Math.max(0, Math.min(size - 1, value)); + } + + private static long key(int x, int z) { + return ((long) x << 32) ^ (z & 0xffffffffL); + } + + public enum Cell { + BASE(0), + LINE(1), + HORIZONTAL_SOCKET(2), + UP_SOCKET(3), + DOWN_SOCKET(3), + UP_AND_DOWN_SOCKET(4); + + private final int priority; + + Cell(int priority) { + this.priority = priority; + } + + int priority() { + return priority; + } + } + + public record Marker(int x, int z, IrisDirection direction) { + public Marker { + Objects.requireNonNull(direction, "Jigsaw Studio floor marker direction"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioFloorPainter.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioFloorPainter.java new file mode 100644 index 000000000..b9d6d7be5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioFloorPainter.java @@ -0,0 +1,122 @@ +package art.arcane.iris.studio.jigsaw; + +import art.arcane.iris.studio.jigsaw.JigsawStudioCapture.ChunkCaptureArea; +import art.arcane.iris.studio.jigsaw.JigsawStudioService.ActiveStudio; +import art.arcane.iris.studio.generation.JigsawStudioGenerator; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.world.task.J; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; + +import java.util.Objects; + +import static art.arcane.iris.studio.jigsaw.JigsawStudioCapture.chunkIntersections; + +final class JigsawStudioFloorPainter { + private final JigsawStudioService service; + + JigsawStudioFloorPainter(JigsawStudioService service) { + this.service = Objects.requireNonNull(service, "Jigsaw Studio service"); + } + + void refresh(ActiveStudio studio, String workcellId) { + if (studio == null || workcellId == null) { + return; + } + JigsawStudioBay workcell = studio.generator().getLayout().get(workcellId); + if (workcell == null) { + return; + } + for (ChunkCaptureArea area : chunkIntersections(workcell.bounds())) { + schedule(studio, workcellId, area.chunkX(), area.chunkZ()); + } + } + + void refreshAll(ActiveStudio studio) { + if (studio == null) { + return; + } + for (JigsawStudioBay workcell : studio.generator().getLayout().bays()) { + refresh(studio, workcell.stableId()); + } + } + + void refreshChunk(ActiveStudio studio, int chunkX, int chunkZ) { + if (studio == null) { + return; + } + int chunkMinimumX = chunkX << 4; + int chunkMinimumZ = chunkZ << 4; + for (JigsawStudioBay workcell : studio.generator().getLayout().bays()) { + if (workcell.bounds().intersectsHorizontal( + chunkMinimumX, + chunkMinimumZ, + chunkMinimumX + 15, + chunkMinimumZ + 15)) { + schedule(studio, workcell.stableId(), chunkX, chunkZ); + } + } + } + + static int paintFloorChunk( + World world, + JigsawStudioGenerator generator, + JigsawStudioBay workcell, + int chunkX, + int chunkZ + ) { + JigsawStudioBounds bounds = workcell.bounds(); + int minimumX = Math.max(bounds.originX(), chunkX << 4); + int maximumX = Math.min(bounds.maxX(), (chunkX << 4) + 15); + int minimumZ = Math.max(bounds.originZ(), chunkZ << 4); + int maximumZ = Math.min(bounds.maxZ(), (chunkZ << 4) + 15); + if (minimumX > maximumX || minimumZ > maximumZ) { + return 0; + } + JigsawStudioFloorGlyph glyph = generator.floorGlyph(workcell); + int floorY = bounds.originY() - 1; + int changed = 0; + for (int worldX = minimumX; worldX <= maximumX; worldX++) { + for (int worldZ = minimumZ; worldZ <= maximumZ; worldZ++) { + JigsawStudioFloorGlyph.Cell cell = glyph.cell( + worldX - bounds.originX(), + worldZ - bounds.originZ()); + NativeBlockState state = generator.floorState(cell); + if (!(state.nativeHandle() instanceof BlockData expected)) { + throw new IllegalStateException("Jigsaw Studio floor block '" + state.key() + + "' has no Bukkit block-data representation"); + } + Block block = world.getBlockAt(worldX, floorY, worldZ); + if (!block.getBlockData().equals(expected)) { + block.setBlockData(expected, false); + changed++; + } + } + } + return changed; + } + + private void schedule(ActiveStudio studio, String workcellId, int chunkX, int chunkZ) { + J.runRegion(studio.world(), chunkX, chunkZ, () -> paint(studio, workcellId, chunkX, chunkZ)); + } + + private void paint(ActiveStudio studio, String workcellId, int chunkX, int chunkZ) { + if (!service.enabled + || service.studios.get(studio.worldId()) != studio + || !studio.world().isChunkLoaded(chunkX, chunkZ)) { + return; + } + JigsawStudioBay workcell = studio.generator().getLayout().get(workcellId); + if (workcell == null) { + return; + } + try { + paintFloorChunk(studio.world(), studio.generator(), workcell, chunkX, chunkZ); + } catch (Throwable exception) { + IrisLogging.reportError("Jigsaw Studio could not refresh the connector floor of workcell " + + workcellId + " in chunk " + chunkX + "," + chunkZ, exception); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioGraphMapper.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioGraphMapper.java index 0922cde6d..e4bf2dc8f 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioGraphMapper.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioGraphMapper.java @@ -44,13 +44,7 @@ public final class JigsawStudioGraphMapper { ? JigsawStudioMode.PLANAR_JIGSAW : JigsawStudioMode.SPATIAL_JIGSAW; JigsawStudioVariantCatalog catalog = catalog(data, structure, mode); - IrisPosition configuredCell = structure.getCellSize(); - JigsawStudioCellDimensions dimensions = configuredCell == null - ? new JigsawStudioCellDimensions(15, 15, 15) - : new JigsawStudioCellDimensions( - Math.max(1, configuredCell.getX()), - Math.max(1, configuredCell.getY()), - Math.max(1, configuredCell.getZ())); + JigsawStudioCellDimensions dimensions = configuredCellDimensions(structure); if (mode == JigsawStudioMode.SPATIAL_JIGSAW) { dimensions = expandSpatialDimensions(data, catalog, dimensions); return JigsawStudioLayout.createSpatial( @@ -129,26 +123,54 @@ public final class JigsawStudioGraphMapper { if (data.getObjectLoader() == null) { return configured; } - int width = configured.width(); - int height = configured.height(); - int depth = configured.depth(); + SpatialExtent extent = SpatialExtent.of(configured); for (JigsawStudioVariant variant : catalog.spatialVariants()) { IrisObject object = data.getObjectLoader().load(variant.objectKey()); - if (object == null) { + if (object != null) { + extent = extent.include(object.getW(), object.getH(), object.getD(), variant.rotatable()); + } + } + return new JigsawStudioCellDimensions((int) extent.width(), (int) extent.height(), (int) extent.depth()); + } + + static JigsawStudioCellDimensions configuredCellDimensions(IrisStructure structure) { + IrisPosition configuredCell = structure.getCellSize(); + return configuredCell == null + ? new JigsawStudioCellDimensions(15, 15, 15) + : new JigsawStudioCellDimensions( + Math.max(1, configuredCell.getX()), + Math.max(1, configuredCell.getY()), + Math.max(1, configuredCell.getZ())); + } + + static String spatialFootprintFailure( + IrisData data, + JigsawStudioCellDimensions configured, + JigsawStudioVariantCatalog catalog, + SpatialChange change + ) { + SpatialExtent extent = SpatialExtent.of(configured); + for (JigsawStudioVariant variant : catalog.spatialVariants()) { + if (variant.pieceKey().equals(change.pieceKey())) { continue; } - int objectWidth = object.getW(); - int objectDepth = object.getD(); - if (variant.rotatable()) { - int horizontalSpan = Math.max(objectWidth, objectDepth); - objectWidth = horizontalSpan; - objectDepth = horizontalSpan; + IrisObject object = data.getObjectLoader() == null ? null : data.getObjectLoader().load(variant.objectKey()); + if (object != null) { + extent = extent.include(object.getW(), object.getH(), object.getD(), variant.rotatable()); } - width = Math.max(width, objectWidth); - height = Math.max(height, object.getH()); - depth = Math.max(depth, objectDepth); } - return new JigsawStudioCellDimensions(width, height, depth); + JigsawStudioCellDimensions changed = change.dimensions(); + extent = extent.include(changed.width(), changed.height(), changed.depth(), change.rotatable()); + long volume = extent.width() * extent.height() * extent.depth(); + if (extent.width() <= JigsawStudioCellDimensions.MAX_HORIZONTAL_AXIS + && extent.depth() <= JigsawStudioCellDimensions.MAX_HORIZONTAL_AXIS + && extent.height() <= JigsawStudioCellDimensions.MAX_HEIGHT + && volume <= JigsawStudioCellDimensions.MAX_VOLUME) { + return ""; + } + return "Spatial workcells would grow to " + extent.width() + "x" + extent.height() + "x" + extent.depth() + + " (" + volume + " blocks), past the " + JigsawStudioCellDimensions.MAX_VOLUME + + "-block workcell limit. Make this variant smaller or keep rotation disabled."; } private static void traversePools( @@ -378,4 +400,25 @@ public final class JigsawStudioGraphMapper { return new OwnedResources(Set.of(), Set.of(), Set.of(), false); } } + + record SpatialChange(String pieceKey, JigsawStudioCellDimensions dimensions, boolean rotatable) { + SpatialChange { + Objects.requireNonNull(pieceKey, "Spatial change piece key"); + Objects.requireNonNull(dimensions, "Spatial change dimensions"); + } + } + + private record SpatialExtent(long width, long height, long depth) { + static SpatialExtent of(JigsawStudioCellDimensions dimensions) { + return new SpatialExtent(dimensions.width(), dimensions.height(), dimensions.depth()); + } + + SpatialExtent include(int objectWidth, int objectHeight, int objectDepth, boolean rotatable) { + int horizontalSpan = Math.max(objectWidth, objectDepth); + return new SpatialExtent( + Math.max(width, rotatable ? horizontalSpan : objectWidth), + Math.max(height, objectHeight), + Math.max(depth, rotatable ? horizontalSpan : objectDepth)); + } + } } diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioGraphMutations.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioGraphMutations.java index a785c937b..d76a15a7e 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioGraphMutations.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioGraphMutations.java @@ -199,6 +199,7 @@ final class JigsawStudioGraphMutations { for (JigsawStudioBay workcell : result.layout().bays()) { service.playerContext.refreshWorkcellContext(studio.worldId(), workcell.stableId()); } + service.floorPainter.refreshAll(studio); service.evaluator.scheduleEvaluation(studio); message(player, result.message()); finishGraphMutation(requestId); @@ -273,15 +274,20 @@ final class JigsawStudioGraphMutations { String failure, Throwable cause ) { - boolean scheduled = J.runEntity( - player, - () -> completeLiveRelayout(player, studio, requestId, result, failure, cause)); - if (!scheduled) { + Runnable abandon = () -> { service.saveLifecycle.reopenRequiredRequests.add(requestId); finishGraphMutation(requestId); if (cause != null) { IrisLogging.reportError(cause); } + }; + boolean scheduled = J.runEntity( + player, + () -> completeLiveRelayout(player, studio, requestId, result, failure, cause), + 0, + abandon); + if (!scheduled) { + abandon.run(); } } diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioLayout.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioLayout.java index 7ac6ed12c..aa306b2d6 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioLayout.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioLayout.java @@ -15,6 +15,7 @@ public final class JigsawStudioLayout { public static final int PLANAR_GAP = 1; public static final int MAX_VARIANTS = 512; public static final String SPATIAL_WORKCELL_ID = "workcell/spatial"; + public static final int COLUMN_HEADROOM = 4; private static final int FIRST_ORIGIN = 16; private static final JigsawStudioControlPosition CONTROL_POSITION = @@ -208,6 +209,18 @@ public final class JigsawStudioLayout { return null; } + public JigsawStudioBay findColumn(int worldX, int worldY, int worldZ) { + for (JigsawStudioBay bay : bays) { + JigsawStudioBounds bounds = bay.bounds(); + if (worldX >= bounds.originX() && worldX <= bounds.maxX() + && worldZ >= bounds.originZ() && worldZ <= bounds.maxZ() + && worldY >= bounds.originY() - 1 && worldY <= bounds.maxY() + COLUMN_HEADROOM) { + return bay; + } + } + return null; + } + public List variants(JigsawStudioBay workcell) { JigsawStudioBay activeWorkcell = requireWorkcell(workcell); if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) { @@ -276,7 +289,7 @@ public final class JigsawStudioLayout { private JigsawStudioBay requireWorkcell(JigsawStudioBay workcell) { JigsawStudioBay activeWorkcell = Objects.requireNonNull(workcell, "Jigsaw Studio workcell"); - if (byStableId.get(activeWorkcell.stableId()) != activeWorkcell) { + if (!activeWorkcell.equals(byStableId.get(activeWorkcell.stableId()))) { throw new IllegalArgumentException("Workcell does not belong to this Jigsaw Studio layout"); } return activeWorkcell; diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMainMenu.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMainMenu.java index 9383036bd..2f744e620 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMainMenu.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMainMenu.java @@ -6,6 +6,7 @@ import art.arcane.volmlib.util.inventorygui.UIWindow; import org.bukkit.ChatColor; import org.bukkit.Material; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -27,6 +28,9 @@ import static art.arcane.iris.studio.jigsaw.JigsawStudioMenuFormat.workcellStatu import static art.arcane.iris.studio.jigsaw.JigsawStudioMenuFormat.yesNo; final class JigsawStudioMainMenu { + static final int WORKCELL_ROW_CAPACITY = 6; + static final int WORKCELLS_PER_PAGE = 5; + private final JigsawStudioMenuController controller; JigsawStudioMainMenu(JigsawStudioMenuController controller) { @@ -103,51 +107,113 @@ final class JigsawStudioMainMenu { } private void addWorkcells(UIWindow window, JigsawStudioMenuState state, String selectedWorkcellId) { - int count = state.workcells().size(); - int start = count == 1 ? 0 : -(count / 2); - for (int index = 0; index < count; index++) { - JigsawStudioMenuState.Workcell workcell = state.workcells().get(index); - boolean selected = workcell.stableId().equals(selectedWorkcellId); - UIElement element = element( - "workcell-" + index, - workcellMaterial(workcell, selected), - (selected ? ChatColor.AQUA : workcell.enabled() ? ChatColor.WHITE : ChatColor.GRAY) - + safe(workcell.displayName())) - .setEnchanted(selected); - element.addLore(workcell.enabled() - ? ChatColor.GREEN + "Enabled" - : ChatColor.RED + "Disabled for assembly and export"); - if (!workcell.canonicalName().equals(workcell.displayName())) { - element.addLore(ChatColor.GRAY + "Solver role: " + safe(workcell.canonicalName())); + List workcells = state.workcells(); + int viewedIndex = 0; + for (int index = 0; index < workcells.size(); index++) { + if (workcells.get(index).stableId().equals(selectedWorkcellId)) { + viewedIndex = index; } - element.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity())); - element.addLore(ChatColor.GRAY + "Variants: " + workcell.variants().size()); - JigsawStudioMenuState.Variant active = workcell.activeVariant(); - element.addLore(ChatColor.GRAY + "Loaded: " - + (active == null ? "None" : safe(active.displayName()))); - element.addLore(workcellStatus(workcell)); - element.addLore(ChatColor.GRAY + "Connector blocks: " - + (workcell.connectorsVisible() ? "Visible" : "Hidden")); - element.addLore(ChatColor.YELLOW + "Left-click to select and teleport"); - element.addLore(ChatColor.YELLOW + "Right-click for workcell settings"); - if (workcell.dirty() && !workcell.saving()) { - element.addLore(ChatColor.GOLD + "Shift-left: Flush Autosave Now"); - element.onShiftLeftClick(clicked -> controller.flushNow( - window.getViewer(), state.requestId(), workcell.stableId())); - } - element.onLeftClick(clicked -> controller.selectWorkcell( - window.getViewer(), - state.requestId(), - workcell.stableId())); - element.onRightClick(clicked -> J.runEntity( - window.getViewer(), - () -> controller.openWorkcellSettings( - window.getViewer(), - state.requestId(), - workcell.stableId()), - 1)); - window.setElement(start + index, 0, element); } + WorkcellRow row = workcellRow(workcells.size(), viewedIndex); + for (int position = 0; position < row.indices().size(); position++) { + int index = row.indices().get(position); + JigsawStudioMenuState.Workcell workcell = workcells.get(index); + window.setElement(row.slots().get(position), 0, workcellElement( + window, + state, + workcell, + index, + workcell.stableId().equals(selectedWorkcellId))); + } + if (row.previousTarget() >= 0) { + JigsawStudioMenuState.Workcell target = workcells.get(row.previousTarget()); + UIElement previous = element("workcells-previous", Material.ARROW, ChatColor.YELLOW + "Previous Workcells"); + previous.addLore(ChatColor.GRAY + "Workcells " + (row.previousTarget() + 1) + "-" + + (row.previousTarget() + WORKCELLS_PER_PAGE) + " of " + workcells.size()); + previous.onLeftClick(clicked -> controller.refreshMain( + window.getViewer(), state.requestId(), target.stableId(), 0)); + window.setElement(-4, 0, previous); + } + if (row.nextTarget() >= 0) { + JigsawStudioMenuState.Workcell target = workcells.get(row.nextTarget()); + UIElement next = element("workcells-next", Material.ARROW, ChatColor.YELLOW + "Next Workcells"); + next.addLore(ChatColor.GRAY + "Workcells " + (row.nextTarget() + 1) + "-" + + Math.min(workcells.size(), row.nextTarget() + WORKCELLS_PER_PAGE) + + " of " + workcells.size()); + next.onLeftClick(clicked -> controller.refreshMain( + window.getViewer(), state.requestId(), target.stableId(), 0)); + window.setElement(2, 0, next); + } + } + + static WorkcellRow workcellRow(int count, int viewedIndex) { + List indices = new ArrayList<>(Math.min(count, WORKCELL_ROW_CAPACITY)); + List slots = new ArrayList<>(Math.min(count, WORKCELL_ROW_CAPACITY)); + if (count <= WORKCELL_ROW_CAPACITY) { + int start = count == 1 ? 0 : -(count / 2); + for (int index = 0; index < count; index++) { + indices.add(index); + slots.add(start + index); + } + return new WorkcellRow(indices, slots, -1, -1); + } + int page = Math.max(0, Math.min(viewedIndex, count - 1)) / WORKCELLS_PER_PAGE; + int first = page * WORKCELLS_PER_PAGE; + for (int offset = 0; offset < WORKCELLS_PER_PAGE && first + offset < count; offset++) { + indices.add(first + offset); + slots.add(-3 + offset); + } + int previous = page == 0 ? -1 : first - WORKCELLS_PER_PAGE; + int next = first + WORKCELLS_PER_PAGE < count ? first + WORKCELLS_PER_PAGE : -1; + return new WorkcellRow(indices, slots, previous, next); + } + + private UIElement workcellElement( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + int index, + boolean selected + ) { + UIElement element = element( + "workcell-" + index, + workcellMaterial(workcell, selected), + (selected ? ChatColor.AQUA : workcell.enabled() ? ChatColor.WHITE : ChatColor.GRAY) + + safe(workcell.displayName())) + .setEnchanted(selected); + element.addLore(workcell.enabled() + ? ChatColor.GREEN + "Enabled" + : ChatColor.RED + "Disabled for assembly and export"); + if (!workcell.canonicalName().equals(workcell.displayName())) { + element.addLore(ChatColor.GRAY + "Solver role: " + safe(workcell.canonicalName())); + } + element.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity())); + element.addLore(ChatColor.GRAY + "Variants: " + workcell.variants().size()); + JigsawStudioMenuState.Variant active = workcell.activeVariant(); + element.addLore(ChatColor.GRAY + "Loaded: " + + (active == null ? "None" : safe(active.displayName()))); + element.addLore(workcellStatus(workcell)); + element.addLore(ChatColor.GRAY + "Connector blocks: " + + (workcell.connectorsVisible() ? "Visible" : "Hidden")); + element.addLore(ChatColor.YELLOW + "Left-click to select and teleport"); + element.addLore(ChatColor.YELLOW + "Right-click for workcell settings"); + if (workcell.dirty() && !workcell.saving()) { + element.addLore(ChatColor.GOLD + "Shift-left: Flush Autosave Now"); + element.onShiftLeftClick(clicked -> controller.flushNow( + window.getViewer(), state.requestId(), workcell.stableId())); + } + element.onLeftClick(clicked -> controller.selectWorkcell( + window.getViewer(), + state.requestId(), + workcell.stableId())); + element.onRightClick(clicked -> J.runEntity( + window.getViewer(), + () -> controller.openWorkcellSettings( + window.getViewer(), + state.requestId(), + workcell.stableId()), + 1)); + return element; } private void addVariants( @@ -283,4 +349,11 @@ final class JigsawStudioMainMenu { window.getViewer(), state.requestId(), workcell.stableId(), 0)); window.setElement(4, 5, toolbox); } + + record WorkcellRow(List indices, List slots, int previousTarget, int nextTarget) { + WorkcellRow { + indices = List.copyOf(indices); + slots = List.copyOf(slots); + } + } } diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMaterializer.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMaterializer.java index 78660012f..2628f8280 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMaterializer.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMaterializer.java @@ -6,7 +6,9 @@ import art.arcane.iris.studio.jigsaw.JigsawStudioService.ActiveStudio; import art.arcane.iris.studio.generation.JigsawStudioGenerator; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.world.task.J; +import org.bukkit.Material; import org.bukkit.World; +import org.bukkit.block.Block; import org.bukkit.entity.Player; import java.io.IOException; @@ -24,7 +26,7 @@ import java.util.concurrent.atomic.AtomicReference; import static art.arcane.iris.studio.jigsaw.JigsawStudioCapture.chunkIntersections; import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.hydrateChunk; -import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.restoreConnectorChunk; +import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.restoreChunkPositions; import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.validateMaterialization; import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.writeMaterializedChunk; import static art.arcane.iris.studio.jigsaw.JigsawStudioService.chunkKey; @@ -139,6 +141,51 @@ final class JigsawStudioMaterializer { } } + ConnectorEditReservation beginConnectorEdit(ActiveStudio studio, String workcellId) { + UUID requestId = studio.generator().getRequest().requestId(); + service.tileWatcher.finalizeJigsawTileWatches(requestId); + JigsawStudioSession session = studio.generator().getSession(); + synchronized (service.saveLifecycleLock) { + if (!service.isCurrentRequest(studio, requestId)) { + return ConnectorEditReservation.blocked("This Jigsaw Studio session is no longer active."); + } + if (service.saveLifecycle.closingRequests.contains(requestId)) { + return ConnectorEditReservation.blocked("This Jigsaw Studio is closing and cannot edit connectors."); + } + if (service.saveLifecycle.reopenRequiredRequests.contains(requestId)) { + return ConnectorEditReservation.blocked("Close and reopen Jigsaw Studio before editing connectors."); + } + if (session.layout().get(workcellId) == null) { + return ConnectorEditReservation.blocked("The targeted workcell no longer exists."); + } + JigsawStudioSession.WorkcellSnapshot snapshot = session.workcellSnapshot(workcellId); + if (snapshot.dirty() || snapshot.saveInProgress() + || service.saveLifecycle.savesInProgress.contains(requestId)) { + return ConnectorEditReservation.busy("Saving your latest edits before changing connectors..."); + } + if (service.graphMutations.graphMutationsInProgress.contains(requestId)) { + return ConnectorEditReservation.busy("Waiting for the current graph update before changing connectors..."); + } + if (service.saveLifecycle.exportsInProgress.contains(requestId)) { + return ConnectorEditReservation.busy("Waiting for the current export before changing connectors..."); + } + if (session.operationInProgress()) { + return ConnectorEditReservation.busy("Waiting for the current workcell operation before changing connectors..."); + } + if (service.tileWatcher.hasJigsawTileWatch(requestId)) { + return ConnectorEditReservation.busy("Waiting for the vanilla jigsaw-block editor to close..."); + } + if (!materializationsInProgress.add(requestId)) { + return ConnectorEditReservation.busy("Waiting for the current variant load or connector edit to finish..."); + } + return ConnectorEditReservation.started(); + } + } + + void finishConnectorEdit(UUID requestId) { + finishMaterialization(requestId); + } + boolean scheduleConnectorRepair( Player player, ActiveStudio studio, @@ -146,39 +193,55 @@ final class JigsawStudioMaterializer { JigsawStudioGenerator.RenderedBay rendered, boolean connectorsVisible ) { - Map> connectorsByChunk = new HashMap<>(); JigsawStudioBounds bounds = workcell.bounds(); + JigsawStudioBounds variantBounds = new JigsawStudioBounds( + bounds.originX(), + bounds.originY(), + bounds.originZ(), + rendered.dimensions()); + Map renderedConnectors = new HashMap<>(); for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) { - int worldX = bounds.originX() + connector.x(); - int worldZ = bounds.originZ() + connector.z(); - connectorsByChunk.computeIfAbsent( - chunkKey(worldX >> 4, worldZ >> 4), - ignored -> new ArrayList<>()).add(connector); + renderedConnectors.put(new LocalPosition(connector.x(), connector.y(), connector.z()), connector); } - AtomicInteger remaining = new AtomicInteger(connectorsByChunk.size()); - AtomicReference failure = new AtomicReference<>(""); - AtomicReference cause = new AtomicReference<>(); - AtomicBoolean scheduledAny = new AtomicBoolean(false); Map renderedBlocks = new HashMap<>(); for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) { renderedBlocks.put(new LocalPosition(block.x(), block.y(), block.z()), block); } - for (Map.Entry> entry - : connectorsByChunk.entrySet()) { - int chunkX = (int) (entry.getKey() >> 32); - int chunkZ = (int) entry.getKey().longValue(); + List areas = chunkIntersections(variantBounds); + AtomicInteger remaining = new AtomicInteger(areas.size()); + AtomicInteger cleared = new AtomicInteger(); + AtomicReference failure = new AtomicReference<>(""); + AtomicReference cause = new AtomicReference<>(); + AtomicBoolean scheduledAny = new AtomicBoolean(false); + for (ChunkCaptureArea area : areas) { + int chunkX = area.chunkX(); + int chunkZ = area.chunkZ(); boolean scheduled = J.runRegion(studio.world(), chunkX, chunkZ, () -> { try { if (!studio.world().isChunkLoaded(chunkX, chunkZ)) { throw new IOException("connector chunk " + chunkX + "," + chunkZ + " is not loaded"); } - restoreConnectorChunk( + Set strays = strayMarkerPositions( + studio.world(), + variantBounds, + area, + renderedConnectors.keySet(), + connectorsVisible); + Set positions = new HashSet<>(strays); + for (LocalPosition position : renderedConnectors.keySet()) { + if (area.contains(position.x(), position.z())) { + positions.add(position); + } + } + restoreChunkPositions( studio.world(), workcell, - entry.getValue(), + positions, + renderedConnectors, renderedBlocks, connectorsVisible); + cleared.addAndGet(strays.size()); } catch (Throwable exception) { failure.compareAndSet("", failureMessage(exception)); cause.compareAndSet(null, exception); @@ -188,6 +251,7 @@ final class JigsawStudioMaterializer { player, studio, rendered.connectors().size(), + cleared.get(), failure.get(), cause.get()); } @@ -202,6 +266,7 @@ final class JigsawStudioMaterializer { player, studio, rendered.connectors().size(), + cleared.get(), failure.get(), cause.get()); } @@ -213,31 +278,61 @@ final class JigsawStudioMaterializer { return scheduledAny.get(); } + static Set strayMarkerPositions( + World world, + JigsawStudioBounds variantBounds, + ChunkCaptureArea area, + Set savedConnectors, + boolean connectorsVisible + ) { + Set strays = new HashSet<>(); + for (int x = area.minimumX(); x < area.maximumX(); x++) { + for (int y = 0; y < variantBounds.dimensions().height(); y++) { + for (int z = area.minimumZ(); z < area.maximumZ(); z++) { + Block block = world.getBlockAt( + variantBounds.originX() + x, + variantBounds.originY() + y, + variantBounds.originZ() + z); + if (block.getType() != Material.JIGSAW) { + continue; + } + LocalPosition position = new LocalPosition(x, y, z); + if (!connectorsVisible || !savedConnectors.contains(position)) { + strays.add(position); + } + } + } + } + return strays; + } + private void completeConnectorRepair( Player player, ActiveStudio studio, int connectorCount, + int clearedMarkers, String failure, Throwable cause ) { UUID requestId = studio.generator().getRequest().requestId(); - boolean scheduled = J.runEntity(player, () -> { + Runnable release = () -> { finishMaterialization(requestId); if (cause != null) { IrisLogging.reportError(cause); } + }; + boolean scheduled = J.runEntity(player, () -> { + release.run(); if (!failure.isEmpty()) { message(player, "Connector reset was incomplete: " + failure + ". Retry after visiting the workcell."); return; } message(player, "Restored " + connectorCount - + " connector block(s) from the last saved iteration."); - }); + + " connector block(s) from the last saved iteration" + + (clearedMarkers == 0 ? "." : " and cleared " + clearedMarkers + " unsaved marker(s).")); + }, 0, release); if (!scheduled) { - finishMaterialization(requestId); - if (cause != null) { - IrisLogging.reportError(cause); - } + release.run(); } } @@ -474,6 +569,7 @@ final class JigsawStudioMaterializer { try { work.studio().generator().invalidateRender(work.workcell().stableId()); work.studio().replacePopulation(work.workcell(), work.target(), "", true); + service.floorPainter.refresh(work.studio(), work.workcell().stableId()); service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId()); message(work.player(), work.connectorVisibilityChange() ? "Connector blocks are now " @@ -564,6 +660,7 @@ final class JigsawStudioMaterializer { work.studio().generator().invalidateRender(work.workcell().stableId()); if (rollbackFailure.isEmpty() && released) { work.studio().replacePopulation(work.workcell(), work.previous(), "", true); + service.floorPainter.refresh(work.studio(), work.workcell().stableId()); service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId()); message(work.player(), candidateFailure + " The previous variant was restored."); return; @@ -572,6 +669,7 @@ final class JigsawStudioMaterializer { ? "The previous variant could not be restored because the Studio session changed." : rollbackFailure; work.studio().replacePopulation(work.workcell(), work.previous(), failure, false); + service.floorPainter.refresh(work.studio(), work.workcell().stableId()); service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId()); message(work.player(), candidateFailure + " Automatic rollback also failed: " + failure + " Do not save this workcell until it is reopened."); @@ -614,6 +712,31 @@ final class JigsawStudioMaterializer { } } + enum ConnectorEditStatus { + STARTED, + BUSY, + BLOCKED + } + + record ConnectorEditReservation(ConnectorEditStatus status, String message) { + ConnectorEditReservation { + Objects.requireNonNull(status, "Jigsaw Studio connector edit status"); + message = message == null ? "" : message; + } + + static ConnectorEditReservation started() { + return new ConnectorEditReservation(ConnectorEditStatus.STARTED, ""); + } + + static ConnectorEditReservation busy(String message) { + return new ConnectorEditReservation(ConnectorEditStatus.BUSY, message); + } + + static ConnectorEditReservation blocked(String message) { + return new ConnectorEditReservation(ConnectorEditStatus.BLOCKED, message); + } + } + public record VariantReloadRequest( String workcellId, JigsawStudioGenerator.RenderedBay previous diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMenuController.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMenuController.java index 49d69790c..e0066cc73 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMenuController.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMenuController.java @@ -857,7 +857,7 @@ public final class JigsawStudioMenuController { } JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); JigsawStudioMenuState.Variant target = variant(workcell, pieceKey); - if (target == null || !target.owned() || target.active()) { + if (!current.get().canDeleteVariant(workcell, target)) { stale(player); return; } diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMenuState.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMenuState.java index d52ebf934..f0c8c350f 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMenuState.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioMenuState.java @@ -54,6 +54,20 @@ public record JigsawStudioMenuState( } } + public boolean canDeleteVariant(Workcell workcell, Variant variant) { + if (workcell == null || variant == null || !variant.owned()) { + return false; + } + if (mode == JigsawStudioMode.SPATIAL_JIGSAW) { + int variants = 0; + for (Workcell current : workcells) { + variants += current.variants().size(); + } + return variants > 1; + } + return !variant.active() && workcell.variants().size() > 1; + } + public Workcell selectedWorkcell() { if (selectedWorkcellId.isEmpty()) { return null; diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioPlayerContext.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioPlayerContext.java index 13b973786..df3728317 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioPlayerContext.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioPlayerContext.java @@ -59,7 +59,7 @@ final class JigsawStudioPlayerContext { return; } JigsawStudioSession session = studio.generator().getSession(); - JigsawStudioBay workcell = session.layout().findAt( + JigsawStudioBay workcell = session.layout().findColumn( target.getBlockX(), target.getBlockY(), target.getBlockZ()); boolean owner = ownerMatches(player, studio); selectEnteredWorkcell(session, workcell, owner); diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProjectCreator.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProjectCreator.java index 3cf423c08..d85d5a0a6 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProjectCreator.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProjectCreator.java @@ -36,13 +36,9 @@ import java.util.Objects; public final class JigsawStudioProjectCreator { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); - private static final List SPATIAL_CONNECTOR_ORDER = List.of( - IrisDirection.NORTH_NEGATIVE_Z, - IrisDirection.SOUTH_POSITIVE_Z, - IrisDirection.EAST_POSITIVE_X, - IrisDirection.WEST_NEGATIVE_X, - IrisDirection.UP_POSITIVE_Y, - IrisDirection.DOWN_NEGATIVE_Y); + private static final int DEFAULT_MAX_DEPTH = 7; + private static final int SPATIAL_PORTABLE_MAX_DEPTH = 4; + private static final int SPATIAL_MAXIMUM_PLACEMENTS = 4; private JigsawStudioProjectCreator() { } @@ -59,7 +55,7 @@ public final class JigsawStudioProjectCreator { StructureKey ownershipKey = new StructureKey("iris", resourceKey); IrisStructure structure = new IrisStructure() .setStartPool(resourceKey + "/start") - .setMaxDepth(7) + .setMaxDepth(maxDepth(options)) .setMaxSizeChunks(8) .setMode(toModelMode(options.mode())) .setCompatibility(toModelCompatibility(options.compatibilityTarget())) @@ -94,6 +90,13 @@ public final class JigsawStudioProjectCreator { return output.toByteArray(); } + private static int maxDepth(Options options) { + return options.mode() == JigsawStudioMode.SPATIAL_JIGSAW + && options.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE + ? SPATIAL_PORTABLE_MAX_DEPTH + : DEFAULT_MAX_DEPTH; + } + private static IrisJigsawMode toModelMode(JigsawStudioMode mode) { return mode == JigsawStudioMode.PLANAR_JIGSAW ? IrisJigsawMode.PLANAR_JIGSAW @@ -129,14 +132,11 @@ public final class JigsawStudioProjectCreator { IrisJigsawPool piecePool = new IrisJigsawPool().setFallback(capPoolKey); IrisJigsawPool capPool = new IrisJigsawPool(); for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { - String key = options.structureKey() + "/" + archetype.name().toLowerCase(Locale.ROOT); + String key = archetypeKey(options, archetype); IrisJigsawPiece piece = planarPiece(key, piecePoolKey, size, archetype); if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) { piece.getThemes().add("variant-1"); } - if (archetype == JigsawPlanarArchetype.CROSS) { - pool.getPieces().add(new IrisJigsawPieceEntry(key, 1)); - } if (archetype != JigsawPlanarArchetype.BLANK) { piecePool.getPieces().add(new IrisJigsawPieceEntry(key, 1)); } @@ -159,6 +159,8 @@ public final class JigsawStudioProjectCreator { dimensions.depth()))); bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n"); } + pool.getPieces().add(new IrisJigsawPieceEntry(archetypeKey(options, JigsawPlanarArchetype.CROSS), 3)); + pool.getPieces().add(new IrisJigsawPieceEntry(archetypeKey(options, JigsawPlanarArchetype.BLANK), 1)); capPool.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true)); bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n"); bundle.textResource("jigsaw-pools/" + capPoolKey + ".json", GSON.toJson(capPool) + "\n"); @@ -172,26 +174,25 @@ public final class JigsawStudioProjectCreator { JigsawStudioCellDimensions dimensions = options.cellDimensions(); String piecePoolKey = options.structureKey() + "/pieces"; IrisJigsawPool piecePool = new IrisJigsawPool(); - for (int connectorCount = 0; connectorCount <= SPATIAL_CONNECTOR_ORDER.size(); connectorCount++) { - String key = options.structureKey() + "/" - + (connectorCount == 0 ? "start" : "connectors-" + connectorCount); - IrisJigsawPiece piece = spatialPiece( - key, - piecePoolKey, - dimensions, - connectorCount); - if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) { - piece.getThemes().add("variant-1"); + boolean irisExtended = options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED; + for (SpatialVerticalClass vertical : SpatialVerticalClass.values()) { + for (JigsawPlanarArchetype horizontal : JigsawPlanarArchetype.values()) { + String key = archetypeKey(options, horizontal) + vertical.keySuffix(); + IrisJigsawPiece piece = spatialPiece(key, piecePoolKey, dimensions, horizontal, vertical); + if (irisExtended) { + piece.getThemes().add("variant-1"); + piece.setRules(new IrisJigsawPieceRules().setMaximumPlacements(SPATIAL_MAXIMUM_PLACEMENTS)); + } + pool.getPieces().add(new IrisJigsawPieceEntry(key, 1)); + if (!piece.getConnectors().isEmpty()) { + piecePool.getPieces().add(new IrisJigsawPieceEntry(key, 1)); + } + bundle.resource("objects/" + key + ".iob", serialize(new IrisObject( + dimensions.width(), + dimensions.height(), + dimensions.depth()))); + bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n"); } - pool.getPieces().add(new IrisJigsawPieceEntry(key, 1)); - if (connectorCount > 0) { - piecePool.getPieces().add(new IrisJigsawPieceEntry(key, 1)); - } - bundle.resource("objects/" + key + ".iob", serialize(new IrisObject( - dimensions.width(), - dimensions.height(), - dimensions.depth()))); - bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n"); } piecePool.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true)); bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n"); @@ -201,30 +202,40 @@ public final class JigsawStudioProjectCreator { String objectKey, String poolKey, JigsawStudioCellDimensions dimensions, - int connectorCount + JigsawPlanarArchetype horizontal, + SpatialVerticalClass vertical ) { IrisJigsawPiece piece = new IrisJigsawPiece() - .setDisplayName(connectorCount + (connectorCount == 1 ? " Connector" : " Connectors")) + .setDisplayName(horizontal.displayName() + vertical.displaySuffix()) .setObject(objectKey) - .setRotatable(true) - .setRules(new IrisJigsawPieceRules().setMaximumPlacements(16)); - for (int index = 0; index < connectorCount; index++) { - IrisDirection direction = SPATIAL_CONNECTOR_ORDER.get(index); - piece.getConnectors().add(new IrisJigsawConnector() - .setPosition(spatialConnectorPosition(dimensions, direction)) - .setDirection(direction) - .setTop(direction.isVertical() - ? IrisDirection.NORTH_NEGATIVE_Z - : IrisDirection.UP_POSITIVE_Y) - .setPool(poolKey) - .setName("iris:spatial") - .setTargetName("iris:spatial") - .setJoint(JigsawJoint.ROLLABLE) - .setFinalState("minecraft:structure_void")); + .setRotatable(true); + for (JigsawPlanarDirection planarDirection : horizontal.canonicalTopology().directions()) { + piece.getConnectors().add(spatialConnector(dimensions, planarDirection.irisDirection(), poolKey)); + } + for (IrisDirection direction : vertical.directions()) { + piece.getConnectors().add(spatialConnector(dimensions, direction, poolKey)); } return piece; } + private static IrisJigsawConnector spatialConnector( + JigsawStudioCellDimensions dimensions, + IrisDirection direction, + String poolKey + ) { + return new IrisJigsawConnector() + .setPosition(spatialConnectorPosition(dimensions, direction)) + .setDirection(direction) + .setTop(direction.isVertical() + ? IrisDirection.NORTH_NEGATIVE_Z + : IrisDirection.UP_POSITIVE_Y) + .setPool(poolKey) + .setName("iris:spatial") + .setTargetName("iris:spatial") + .setJoint(JigsawJoint.ROLLABLE) + .setFinalState("minecraft:structure_void"); + } + private static IrisPosition spatialConnectorPosition( JigsawStudioCellDimensions dimensions, IrisDirection direction @@ -242,6 +253,10 @@ public final class JigsawStudioProjectCreator { }; } + private static String archetypeKey(Options options, JigsawPlanarArchetype archetype) { + return options.structureKey() + "/" + archetype.name().toLowerCase(Locale.ROOT); + } + private static IrisJigsawPiece planarPiece( String objectKey, String poolKey, @@ -291,4 +306,33 @@ public final class JigsawStudioProjectCreator { return key; } } + + private enum SpatialVerticalClass { + NONE("", "", List.of()), + UP("-up", " + Up", List.of(IrisDirection.UP_POSITIVE_Y)), + DOWN("-down", " + Down", List.of(IrisDirection.DOWN_NEGATIVE_Y)), + UP_DOWN("-up-down", " + Up/Down", List.of(IrisDirection.UP_POSITIVE_Y, IrisDirection.DOWN_NEGATIVE_Y)); + + private final String keySuffix; + private final String displaySuffix; + private final List directions; + + SpatialVerticalClass(String keySuffix, String displaySuffix, List directions) { + this.keySuffix = keySuffix; + this.displaySuffix = displaySuffix; + this.directions = directions; + } + + private String keySuffix() { + return keySuffix; + } + + private String displaySuffix() { + return displaySuffix; + } + + private List directions() { + return directions; + } + } } diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtection.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtection.java index c1217dbb6..8a245f23f 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtection.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtection.java @@ -128,7 +128,7 @@ final class JigsawStudioProtection { static boolean blocksMutatingCommand(UUID ownerId, UUID actorId, String commandLine) { return !ownerMatches(ownerId, actorId) - && !isSafeNonOwnerCommand(commandLine); + && isMutatingCommand(commandLine); } static boolean blocksNonEditableWorkcellMutation( @@ -231,17 +231,25 @@ final class JigsawStudioProtection { } boolean isUnauthorizedStudioEdit(Player player, Block block) { - if (player == null || block == null) { + String refusal = studioEditRefusal(player, block); + if (refusal.isEmpty()) { return false; } + message(player, refusal); + return true; + } + + String studioEditRefusal(Player player, Block block) { + if (player == null || block == null) { + return ""; + } ActiveStudio studio = service.studios.get(block.getWorld().getUID()); if (studio == null) { - return false; + return ""; } UUID requestId = studio.generator().getRequest().requestId(); if (service.previewRenderer.contains(requestId, block.getX(), block.getY(), block.getZ())) { - message(player, "The seed-1337 Jigsaw preview is read-only and refreshes automatically."); - return true; + return "The seed-1337 Jigsaw preview is read-only and refreshes automatically."; } JigsawStudioSession session = studio.generator().getSession(); JigsawStudioBay bay = session.layout().findAt(block.getX(), block.getY(), block.getZ()); @@ -249,24 +257,20 @@ final class JigsawStudioProtection { ? null : session.activeVariant(bay.stableId()).orElse(null); if (bay != null && (variant == null || !variant.owned())) { - message(player, variant == null + return variant == null ? "This empty Jigsaw Studio workcell has no loaded editable variant. Load one before editing it." - : "This Jigsaw Studio variant is read-only. Adopt or clone its graph before editing it."); - return true; + : "This Jigsaw Studio variant is read-only. Adopt or clone its graph before editing it."; } if (service.saveLifecycle.reopenRequiredRequests.contains(requestId)) { - message(player, "Close and reopen Jigsaw Studio before editing the resized layout."); - return true; + return "Close and reopen Jigsaw Studio before editing the resized layout."; } if (materializationInProgress(studio)) { - message(player, "Wait for the current Jigsaw Studio variant load or rollback to finish."); - return true; + return "Wait for the current Jigsaw Studio variant load or rollback to finish."; } if (!blocksStudioEdit(studio.generator().getRequest().ownerId(), player.getUniqueId())) { - return false; + return ""; } - message(player, "This Jigsaw Studio is owned by another player session."); - return true; + return "This Jigsaw Studio is owned by another player session."; } boolean materializationInProgress(ActiveStudio studio) { diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionListener.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionListener.java index 1c59620ca..b8e2c1637 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionListener.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionListener.java @@ -105,6 +105,7 @@ final class JigsawStudioProtectionListener implements Listener { ActiveStudio studio = service.studios.get(event.getWorld().getUID()); if (studio != null) { service.registry.markChunkAvailable(studio, event.getChunk().getX(), event.getChunk().getZ()); + service.floorPainter.refreshChunk(studio, event.getChunk().getX(), event.getChunk().getZ()); } } @@ -119,6 +120,7 @@ final class JigsawStudioProtectionListener implements Listener { service.toolbelt.closeMenu(event.getPlayer()); service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId()); service.tripleSneakTracker.clearPlayer(event.getPlayer().getUniqueId()); + service.connectorTool.clearPlayer(event.getPlayer().getUniqueId()); service.visualization.visualizationLoops.remove(event.getPlayer().getUniqueId()); service.visualization.assemblyPreviews.remove(event.getPlayer().getUniqueId()); service.playerContext.playerWorkcells.remove(event.getPlayer().getUniqueId()); @@ -133,6 +135,7 @@ final class JigsawStudioProtectionListener implements Listener { service.tileWatcher.finalizeJigsawTileWatchesForPlayer(playerId); service.tripleSneakTracker.clearPlayer(playerId); service.toolbelt.toolConfirmations.remove(playerId); + service.connectorTool.clearPlayer(playerId); service.visualization.visualizationLoops.remove(playerId); service.particlesDisabled.remove(playerId); service.visualization.assemblyPreviews.remove(playerId); @@ -170,15 +173,29 @@ final class JigsawStudioProtectionListener implements Listener { || (event.getClickedBlock() != null && event.useInteractedBlock() == Event.Result.DENY)) { return; } - if (event.getHand() != EquipmentSlot.HAND - || (event.getAction() != Action.RIGHT_CLICK_AIR - && event.getAction() != Action.RIGHT_CLICK_BLOCK)) { + if (event.getHand() != EquipmentSlot.HAND || event.getAction() == Action.PHYSICAL) { return; } + boolean leftClick = event.getAction() == Action.LEFT_CLICK_AIR + || event.getAction() == Action.LEFT_CLICK_BLOCK; Optional decoded = service.toolbelt.toolCodec.decode(event.getItem()); if (decoded.isEmpty()) { return; } + if (decoded.get().action() == JigsawStudioToolAction.CONNECTOR_TOOL) { + service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId()); + event.setCancelled(true); + service.connectorTool.handleClick( + event.getPlayer(), + decoded.get(), + leftClick, + event.getClickedBlock(), + event.getBlockFace()); + return; + } + if (leftClick) { + return; + } service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId()); event.setCancelled(true); service.toolbelt.useTool(event.getPlayer(), decoded.get(), event.getItem(), event.getPlayer().isSneaking()); @@ -237,6 +254,13 @@ final class JigsawStudioProtectionListener implements Listener { @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onProtectedBlockBreak(BlockBreakEvent event) { + if (service.studios.containsKey(event.getBlock().getWorld().getUID()) + && service.toolbelt.toolCodec.decode(event.getPlayer().getInventory().getItemInMainHand()) + .filter(payload -> payload.action() == JigsawStudioToolAction.CONNECTOR_TOOL) + .isPresent()) { + event.setCancelled(true); + return; + } if (service.protection.isControlChest(event.getBlock())) { event.setCancelled(true); message(event.getPlayer(), "The Jigsaw Studio control chest cannot be removed."); @@ -264,9 +288,17 @@ final class JigsawStudioProtectionListener implements Listener { @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onUnauthorizedInteract(PlayerInteractEvent event) { - if (event.getAction() == Action.RIGHT_CLICK_BLOCK - && !service.protection.isControlChest(event.getClickedBlock()) - && service.protection.isUnauthorizedStudioEdit(event.getPlayer(), event.getClickedBlock())) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK + || service.protection.isControlChest(event.getClickedBlock())) { + return; + } + if (ProtectionProbe.isProbe(event)) { + if (!service.protection.studioEditRefusal(event.getPlayer(), event.getClickedBlock()).isEmpty()) { + event.setCancelled(true); + } + return; + } + if (service.protection.isUnauthorizedStudioEdit(event.getPlayer(), event.getClickedBlock())) { event.setCancelled(true); } } @@ -337,7 +369,8 @@ final class JigsawStudioProtectionListener implements Listener { @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onProtectedPistonExtend(BlockPistonExtendEvent event) { if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID())) - || service.protection.movesProtectedBlocks(event.getBlocks(), event.getDirection())) { + || service.protection.movesProtectedBlocks(event.getBlocks(), event.getDirection()) + || service.protection.isImmutableStudioBlock(event.getBlock().getRelative(event.getDirection()))) { event.setCancelled(true); } } diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioResourceBundleAssembler.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioResourceBundleAssembler.java index c6e016ae8..a259bf1e0 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioResourceBundleAssembler.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioResourceBundleAssembler.java @@ -129,6 +129,11 @@ final class JigsawStudioResourceBundleAssembler { return new Assembly(bundle.build(), objectKey, piece, StructureHash.sha256(manifestContent)); } + static byte[] ownedResourceContent(Path packRoot, String relativePath) throws IOException { + Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root").toAbsolutePath().normalize(); + return Files.readAllBytes(resolveOwnedResource(root, relativePath)); + } + private static Set resourcePaths(StructurePackageClosure closure) { Set resources = new LinkedHashSet<>(); addPaths(resources, "structures", closure.structures(), ".json"); diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioSaveLifecycle.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioSaveLifecycle.java index 3d37ca6d3..62bf8eaf1 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioSaveLifecycle.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioSaveLifecycle.java @@ -286,7 +286,7 @@ final class JigsawStudioSaveLifecycle { String selected = session.selectedBayId().orElse(null); if (selected == null) { Location location = player.getLocation(); - JigsawStudioBay underPlayer = session.layout().findAt( + JigsawStudioBay underPlayer = session.layout().findColumn( location.getBlockX(), location.getBlockY(), location.getBlockZ()); if (underPlayer != null) { selected = underPlayer.stableId(); diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioService.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioService.java index 220a0a82f..03ada303d 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioService.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioService.java @@ -73,6 +73,8 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuA final JigsawStudioAutosaveScheduler autosaveScheduler = new JigsawStudioAutosaveScheduler(this); final JigsawStudioTileWatcher tileWatcher = new JigsawStudioTileWatcher(this); final JigsawStudioVisualization visualization = new JigsawStudioVisualization(this); + final JigsawStudioFloorPainter floorPainter = new JigsawStudioFloorPainter(this); + final JigsawStudioConnectorTool connectorTool = new JigsawStudioConnectorTool(this); final JigsawStudioPlayerContext playerContext = new JigsawStudioPlayerContext(this); public static JigsawStudioService get() { diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolAction.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolAction.java index 63fba4d0c..16a46b771 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolAction.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolAction.java @@ -4,6 +4,7 @@ import java.util.Objects; public enum JigsawStudioToolAction { OPEN_MENU("Open Control Menu", false), + CONNECTOR_TOOL("Connector Tool", false), SELECT_WORKCELL("Select Workcell", false), TOGGLE_WORKCELL("Toggle Workcell", false), LOAD_VARIANT("Load Variant", false), diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolCodec.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolCodec.java index 5203a587a..545298c31 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolCodec.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolCodec.java @@ -140,6 +140,13 @@ public final class JigsawStudioToolCodec { if (payload.amount() != 0) { lore.add(ChatColor.GRAY + "Amount: " + payload.amount()); } + if (payload.action() == JigsawStudioToolAction.CONNECTOR_TOOL) { + lore.add(ChatColor.YELLOW + "Right-click: place, select, or move a connector"); + lore.add(ChatColor.YELLOW + "Left-click: remove a connector"); + lore.add(ChatColor.GRAY + "Faces out of the clicked face; sneak to face into it"); + lore.add(ChatColor.GRAY + "Planar: right-click a side to seat its socket"); + return List.copyOf(lore); + } lore.add(payload.action().destructive() ? ChatColor.RED + "Right-click twice to confirm" : ChatColor.YELLOW + "Right-click to use"); diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolbelt.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolbelt.java index 51242f6f6..62f239623 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolbelt.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolbelt.java @@ -29,6 +29,7 @@ import static art.arcane.iris.studio.jigsaw.JigsawStudioVariantProperties.canRes final class JigsawStudioToolbelt { private static final long TOOL_CONFIRM_NANOS = 10_000_000_000L; + private static final long TOOL_CONFIRM_MINIMUM_NANOS = 500_000_000L; private final JigsawStudioService service; final Map deferredDuplications = new ConcurrentHashMap<>(); @@ -184,13 +185,27 @@ final class JigsawStudioToolbelt { message(player, "This Jigsaw Studio tool is stale or belongs to another session."); return false; } - if (payload.action().destructive() && !confirmTool(player, payload)) { - message(player, "Right-click the same tool again within 10 seconds to confirm " - + payload.action().displayName() + "."); - return true; + if (payload.action().destructive()) { + ToolConfirmationState confirmation = confirmTool(player, payload); + if (confirmation == ToolConfirmationState.ARMED) { + message(player, "Right-click the same tool again within 10 seconds to confirm " + + payload.action().displayName() + "."); + return true; + } + if (confirmation == ToolConfirmationState.HELD) { + return true; + } + } + if (requiresBoundVariant(payload) && activeOwnedVariant( + player, + studio, + payload.workcellId(), + payload.pieceKey()) == null) { + return false; } return switch (payload.action()) { case OPEN_MENU -> openControlMenu(player); + case CONNECTOR_TOOL -> service.connectorTool.handleClick(player, payload, false, null, null); case SELECT_WORKCELL -> selectWorkcell(player, payload.workcellId()); case LOAD_VARIANT -> service.variantEditor.switchVariant( player, @@ -349,19 +364,40 @@ final class JigsawStudioToolbelt { return state.isPresent() && service.workcellEditor.setRequireCaps(player, !state.get().requireCaps()); } - private boolean confirmTool(Player player, JigsawStudioToolPayload payload) { + private ToolConfirmationState confirmTool(Player player, JigsawStudioToolPayload payload) { long now = System.nanoTime(); ToolConfirmation previous = toolConfirmations.get(player.getUniqueId()); - if (previous != null - && previous.expiresAtNanos() >= now - && previous.payload().equals(payload)) { + ToolConfirmationState state = confirmationState(previous, payload, now); + if (state == ToolConfirmationState.CONFIRMED) { toolConfirmations.remove(player.getUniqueId(), previous); - return true; + } else if (state == ToolConfirmationState.ARMED) { + toolConfirmations.put( + player.getUniqueId(), + new ToolConfirmation(payload, now, now + TOOL_CONFIRM_NANOS)); } - toolConfirmations.put( - player.getUniqueId(), - new ToolConfirmation(payload, now + TOOL_CONFIRM_NANOS)); - return false; + return state; + } + + static ToolConfirmationState confirmationState( + ToolConfirmation previous, + JigsawStudioToolPayload payload, + long nowNanos + ) { + if (previous == null + || previous.expiresAtNanos() < nowNanos + || !previous.payload().equals(payload)) { + return ToolConfirmationState.ARMED; + } + return nowNanos - previous.armedAtNanos() < TOOL_CONFIRM_MINIMUM_NANOS + ? ToolConfirmationState.HELD + : ToolConfirmationState.CONFIRMED; + } + + static boolean requiresBoundVariant(JigsawStudioToolPayload payload) { + return !payload.pieceKey().isEmpty() && switch (payload.action()) { + case DUPLICATE_VARIANT, TOGGLE_ROTATION, EXPAND_TO_CELL -> true; + default -> false; + }; } JigsawStudioVariant activeOwnedVariant( @@ -677,7 +713,13 @@ final class JigsawStudioToolbelt { } } - record ToolConfirmation(JigsawStudioToolPayload payload, long expiresAtNanos) { + enum ToolConfirmationState { + ARMED, + HELD, + CONFIRMED + } + + record ToolConfirmation(JigsawStudioToolPayload payload, long armedAtNanos, long expiresAtNanos) { ToolConfirmation { Objects.requireNonNull(payload, "Jigsaw Studio tool confirmation payload"); } diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolboxMenu.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolboxMenu.java index 72b5fe0aa..061f2adc0 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolboxMenu.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolboxMenu.java @@ -105,6 +105,9 @@ final class JigsawStudioToolboxMenu { "Jigsaw Studio toolbox workcell"); UUID requestId = menu.requestId(); List tools = new ArrayList<>(); + tools.add(new ToolboxTool( + "Connector Tool", + JigsawStudioToolPayload.request(JigsawStudioToolAction.CONNECTOR_TOOL, requestId))); tools.add(new ToolboxTool( "Open Control Menu", JigsawStudioToolPayload.request(JigsawStudioToolAction.OPEN_MENU, requestId))); @@ -216,6 +219,15 @@ final class JigsawStudioToolboxMenu { selected.stableId(), active.pieceKey()))); } + if (menu.mode() == JigsawStudioMode.SPATIAL_JIGSAW && menu.canDeleteVariant(selected, active)) { + tools.add(new ToolboxTool( + "Delete " + active.displayName(), + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.DELETE_VARIANT, + requestId, + selected.stableId(), + active.pieceKey()))); + } if (active.owned()) { for (JigsawStudioMenuState.Membership membership : active.memberships()) { addMembershipTools( @@ -255,7 +267,7 @@ final class JigsawStudioToolboxMenu { selected.stableId(), variant.pieceKey()))); } - if (variant.owned() && selected.variants().size() > 1) { + if (menu.canDeleteVariant(selected, variant)) { tools.add(new ToolboxTool( "Delete " + variant.displayName(), JigsawStudioToolPayload.variant( diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioVariantProperties.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioVariantProperties.java index 6b8f38974..b00e9ece7 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioVariantProperties.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioVariantProperties.java @@ -174,12 +174,20 @@ final class JigsawStudioVariantProperties { return false; } JigsawStudioSession session = studio.generator().getSession(); + boolean rotatable = !variant.rotatable(); + if (variant.dimensions().isPresent()) { + String footprintFailure = spatialFootprintFailure(studio, variant, + new JigsawStudioGraphMapper.SpatialChange(variant.pieceKey(), variant.dimensions().get(), rotatable)); + if (!footprintFailure.isEmpty()) { + message(player, footprintFailure); + return false; + } + } String reservationFailure = service.graphMutations.beginGraphMutation(studio, session); if (!reservationFailure.isEmpty()) { message(player, reservationFailure); return false; } - boolean rotatable = !variant.rotatable(); return service.graphMutations.scheduleGraphMutation( player, studio, @@ -268,6 +276,12 @@ final class JigsawStudioVariantProperties { + dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth() + "."); return false; } + String footprintFailure = spatialFootprintFailure(studio, variant, + new JigsawStudioGraphMapper.SpatialChange(variant.pieceKey(), dimensions, variant.rotatable())); + if (!footprintFailure.isEmpty()) { + message(player, footprintFailure); + return false; + } boolean active = session.activeVariant(workcellId) .map(activeVariant -> activeVariant.pieceKey().equals(pieceKey)) .orElse(false); @@ -536,4 +550,23 @@ final class JigsawStudioVariantProperties { || canonical.height() < target.height() || canonical.depth() < target.depth()); } + + private static String spatialFootprintFailure( + ActiveStudio studio, + JigsawStudioVariant variant, + JigsawStudioGraphMapper.SpatialChange change + ) { + if (variant.mode() != JigsawStudioMode.SPATIAL_JIGSAW) { + return ""; + } + IrisStructure structure = loadStudioStructure(studio); + if (structure == null) { + return "The structure could not be loaded to check the spatial workcell size."; + } + return JigsawStudioGraphMapper.spatialFootprintFailure( + studio.generator().getRequest().source(), + JigsawStudioGraphMapper.configuredCellDimensions(structure), + studio.generator().getSession().layout().variantCatalog(), + change); + } } diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioVisualization.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioVisualization.java index ef0f27f67..ee2734cd0 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioVisualization.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioVisualization.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -35,6 +36,9 @@ final class JigsawStudioVisualization { private static final Color VALID_CONNECTOR_COLOR = Color.LIME; private static final Color ASSEMBLY_PREVIEW_COLOR = Color.fromRGB(180, 90, 255); private static final Color LIVE_PREVIEW_WARNING_COLOR = Color.fromRGB(255, 180, 55); + private static final Color TOOL_SELECTED_COLOR = Color.YELLOW; + private static final Color TOOL_PLACE_COLOR = Color.WHITE; + private static final Color TOOL_EXISTING_COLOR = Color.ORANGE; private static final long ASSEMBLY_PREVIEW_MILLIS = 10_000L; private final JigsawStudioService service; @@ -124,7 +128,7 @@ final class JigsawStudioVisualization { JigsawStudioSession session = studio.generator().getSession(); JigsawStudioLayout layout = session.layout(); Location playerLocation = player.getLocation(); - JigsawStudioBay focused = layout.findAt( + JigsawStudioBay focused = layout.findColumn( playerLocation.getBlockX(), playerLocation.getBlockY(), playerLocation.getBlockZ()); @@ -134,6 +138,7 @@ final class JigsawStudioVisualization { drawConnectors(player, playerLocation, studio, focused, budget); drawBounds(player, playerLocation, focused.bounds(), focusedColor, 1.15F, 1.0D, budget); } + drawConnectorToolFeedback(player, playerLocation, studio, budget); drawLivePreview(player, playerLocation, studio, budget); drawAssemblyPreview(player, playerLocation, budget); for (JigsawStudioBay bay : layout.bays()) { @@ -148,6 +153,57 @@ final class JigsawStudioVisualization { } } + private void drawConnectorToolFeedback( + Player player, + Location playerLocation, + ActiveStudio studio, + ParticleBudget budget + ) { + UUID requestId = studio.generator().getRequest().requestId(); + boolean holdingTool = service.toolbelt.toolCodec.decode(player.getInventory().getItemInMainHand()) + .filter(payload -> payload.action() == JigsawStudioToolAction.CONNECTOR_TOOL + && payload.requestId().equals(requestId)) + .isPresent(); + Optional feedback = + service.connectorTool.feedback(player, studio, holdingTool); + if (feedback.isEmpty()) { + return; + } + feedback.get().selected().ifPresent(cell -> + drawCell(player, playerLocation, cell, TOOL_SELECTED_COLOR, budget)); + feedback.get().hover().ifPresent(hover -> { + Color color = hover.existingConnector() ? TOOL_EXISTING_COLOR : TOOL_PLACE_COLOR; + drawCell(player, playerLocation, hover.cell(), color, budget); + drawConnectorLine( + player, + playerLocation, + hover.cell().x() + 0.5D, + hover.cell().y() + 0.5D, + hover.cell().z() + 0.5D, + hover.facing(), + color, + budget); + }); + } + + private static void drawCell( + Player player, + Location playerLocation, + JigsawStudioConnectorPlacement.Cell cell, + Color color, + ParticleBudget budget + ) { + drawBounds( + player, + playerLocation, + new JigsawStudioPreviewRenderer.PreviewBounds( + cell.x(), cell.y(), cell.z(), cell.x(), cell.y(), cell.z()), + color, + 0.75F, + 0.25D, + budget); + } + private void drawLivePreview( Player player, Location playerLocation, diff --git a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioWorkcellEditor.java b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioWorkcellEditor.java index 1e7023e22..0e78771f9 100644 --- a/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioWorkcellEditor.java +++ b/core/src/main/java/art/arcane/iris/studio/jigsaw/JigsawStudioWorkcellEditor.java @@ -114,10 +114,6 @@ final class JigsawStudioWorkcellEditor { message(player, "Connector blocks cannot reset: " + validationFailure); return false; } - if (rendered.connectors().isEmpty()) { - message(player, "The active variant has no saved connectors to reset."); - return false; - } String reservationFailure = service.materializer.beginConnectorRepair(studio); if (!reservationFailure.isEmpty()) { message(player, reservationFailure); @@ -125,7 +121,7 @@ final class JigsawStudioWorkcellEditor { } boolean connectorsVisible = session.workcellSnapshot(workcellId).connectorsVisible(); message(player, "Resetting " + rendered.connectors().size() - + " connector block(s) from the last saved iteration..."); + + " connector block(s) from the last saved iteration and clearing unsaved markers..."); return service.materializer.scheduleConnectorRepair( player, studio, @@ -383,6 +379,16 @@ final class JigsawStudioWorkcellEditor { message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'."); return false; } + JigsawStudioVariant spatialVariant = workcell.archetype().isPresent() + ? null + : session.activeVariant(workcellId).orElse(null); + if (spatialVariant != null) { + return service.variantProperties.updateVariantDisplayName( + player, + workcellId, + spatialVariant.pieceKey(), + displayName); + } String normalizedName; try { normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName); diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index 0e71c9267..c13ec6de9 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "6935e8d3914e5cde852522151d87fe898f79de3b394826e544c5c5bfbfceba57"; + private static final String EXPECTED_SCHEMA_DIGEST = "b8dd16d1a3188e8150a68a9d5b2964aed5094d679c0f941e3703aec675f87e20"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporterTest.java b/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporterTest.java index f7e096695..166a91251 100644 --- a/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporterTest.java +++ b/core/src/test/java/art/arcane/iris/structure/export/VanillaJigsawDatapackExporterTest.java @@ -167,6 +167,33 @@ public class VanillaJigsawDatapackExporterTest { } } + @Test + public void exports1211WithRuntimeDefaultSettingsDerivedFromTheStructure() throws Exception { + when(IrisPlatforms.get().minecraftVersion()).thenReturn("1.21.1"); + try { + TestGraph graph = connectorGraph(); + VanillaJigsawExportResult staticDefaults = new VanillaJigsawDatapackExporter().export( + request(graph, temporaryFolder.getRoot().toPath().resolve("static-defaults-1211")) + .settings(VanillaJigsawExportSettings.defaults()).build()); + assertTrue(hasCode(staticDefaults, VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DISTANCE)); + + Path output = temporaryFolder.getRoot().toPath().resolve("runtime-defaults-1211"); + VanillaJigsawExportSettings settings = VanillaJigsawExportSettings.runtimeDefaults(graph.structure); + VanillaJigsawExportResult result = new VanillaJigsawDatapackExporter().export( + request(graph, output).namespace("studio").resourcePath("village/test").settings(settings).build()); + + assertEquals(graph.structure.getMaxSizeChunks() * 16, settings.maxDistanceVertical()); + assertTrue(result.diagnostics().toString(), result.isSuccess()); + JsonObject structure = json(output.resolve("data/studio/worldgen/structure/village/test.json")); + assertEquals(graph.structure.getMaxSizeChunks() * 16, + structure.get("max_distance_from_center").getAsInt()); + } finally { + when(IrisPlatforms.get().minecraftVersion()).thenReturn("26.2"); + } + assertEquals(VanillaJigsawExportSettings.defaults().maxDistanceVertical(), + VanillaJigsawExportSettings.runtimeDefaults(connectorGraph().structure).maxDistanceVertical()); + } + @Test public void rejects1211VanillaExportWithDifferentVerticalDistance() throws Exception { when(IrisPlatforms.get().minecraftVersion()).thenReturn("1.21.1"); diff --git a/core/src/test/java/art/arcane/iris/structure/placement/StructureAssemblerTest.java b/core/src/test/java/art/arcane/iris/structure/placement/StructureAssemblerTest.java index 65145fdf3..cbab71707 100644 --- a/core/src/test/java/art/arcane/iris/structure/placement/StructureAssemblerTest.java +++ b/core/src/test/java/art/arcane/iris/structure/placement/StructureAssemblerTest.java @@ -38,7 +38,10 @@ import art.arcane.volmlib.util.math.RNG; import org.junit.ClassRule; import org.junit.Test; +import java.util.EnumSet; +import java.util.HashSet; import java.util.List; +import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -310,6 +313,82 @@ public class StructureAssemblerTest { assertEquals(west.getMaxX() + 1, start.getMinX()); } + @Test + public void rotatableCandidatesAttachThroughEitherCornerConnectorAcrossSeeds() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false, + connector(IrisDirection.NORTH_NEGATIVE_Z, "turns", "door", "door") + .setPosition(new IrisPosition(1, 0, 0)) + .setJoint(JigsawJoint.ALIGNED)); + IrisJigsawPiece cornerPiece = piece("corner-object", true, + connector(IrisDirection.NORTH_NEGATIVE_Z, "", "door", "door") + .setPosition(new IrisPosition(1, 0, 0)) + .setJoint(JigsawJoint.ALIGNED), + connector(IrisDirection.EAST_POSITIVE_X, "", "door", "door") + .setPosition(new IrisPosition(2, 0, 1)) + .setJoint(JigsawJoint.ALIGNED)); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("turns", pool("", "corner-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("corner-piece", cornerPiece); + fixture.object("start-object", new IrisObject(3, 1, 3)); + fixture.object("corner-object", new IrisObject(3, 1, 3)); + StructureAssembler assembler = fixture.assembler(structure("start", 1), 0, 0, 0); + + Set attachedConnectors = new HashSet<>(); + for (long seed = 0; seed < 64; seed++) { + PlacedStructurePiece corner = requirePlacedPiece(completedPieces(assembler, new RNG(seed)), cornerPiece); + for (int index = 0; index < cornerPiece.getConnectors().size(); index++) { + if (corner.getRotation().rotate(cornerPiece.getConnectors().get(index).getDirection()) + == IrisDirection.SOUTH_POSITIVE_Z) { + attachedConnectors.add(index); + } + } + } + + assertEquals(Set.of(0, 1), attachedConnectors); + } + + @Test + public void planarJunctionPlacesAPieceOpenTowardEveryOccupiedNeighbor() { + Fixture fixture = planarJunctionFixture("straight-piece", "corner-piece"); + IrisJigsawPiece corner = planarPiece(true, + IrisDirection.NORTH_NEGATIVE_Z, IrisDirection.EAST_POSITIVE_X); + fixture.piece("straight-piece", planarPiece(true, + IrisDirection.NORTH_NEGATIVE_Z, IrisDirection.SOUTH_POSITIVE_Z)); + fixture.piece("corner-piece", corner); + StructureAssembler assembler = fixture.assembler(planarJunctionStructure(), 0, 64, 0); + + for (long seed = 0; seed < 32; seed++) { + List placed = completedPieces(assembler, new RNG(seed)); + PlacedStructurePiece junction = placed.get(3); + + assertEquals(4, placed.size()); + assertSame("seed " + seed, corner, junction.getPiece()); + assertEquals("seed " + seed, + Set.of(IrisDirection.WEST_NEGATIVE_X, IrisDirection.SOUTH_POSITIVE_Z), + worldFacings(junction)); + } + } + + @Test + public void planarJunctionWithoutAFittingPieceKeepsTheAttachmentOnlyPlacementInsteadOfFailing() { + Fixture fixture = planarJunctionFixture("straight-piece"); + IrisJigsawPiece straight = planarPiece(true, + IrisDirection.NORTH_NEGATIVE_Z, IrisDirection.SOUTH_POSITIVE_Z); + fixture.piece("straight-piece", straight); + StructureAssembler assembler = fixture.assembler(planarJunctionStructure(), 0, 64, 0); + + for (long seed = 0; seed < 8; seed++) { + List placed = completedPieces(assembler, new RNG(seed)); + + assertEquals(4, placed.size()); + assertSame(straight, placed.get(3).getPiece()); + assertEquals(Set.of(IrisDirection.WEST_NEGATIVE_X, IrisDirection.EAST_POSITIVE_X), + worldFacings(placed.get(3))); + } + } + @Test public void selectionPriorityProcessesHigherConnectorsFirstAndPreservesStableTies() { Fixture fixture = new Fixture(); @@ -1115,6 +1194,53 @@ public class StructureAssemblerTest { return fixture.assembler(structure("start", 1), 0, 0, 0).assemble(new RNG(137L)); } + private static Fixture planarJunctionFixture(String... junctionPieces) { + Fixture fixture = new Fixture(); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("north", pool("", "north-elbow")); + fixture.pool("east", pool("", "east-elbow")); + fixture.pool("junction", pool("", junctionPieces)); + fixture.piece("start-piece", piece("cell-object", false, + planarConnector(IrisDirection.NORTH_NEGATIVE_Z, "north"), + planarConnector(IrisDirection.EAST_POSITIVE_X, "east"))); + fixture.piece("north-elbow", piece("cell-object", false, + planarConnector(IrisDirection.SOUTH_POSITIVE_Z, ""), + planarConnector(IrisDirection.EAST_POSITIVE_X, "junction"))); + fixture.piece("east-elbow", piece("cell-object", false, + planarConnector(IrisDirection.WEST_NEGATIVE_X, ""), + planarConnector(IrisDirection.NORTH_NEGATIVE_Z, ""))); + fixture.object("cell-object", new IrisObject(3, 1, 3)); + return fixture; + } + + private static IrisJigsawPiece planarPiece(boolean rotatable, IrisDirection... directions) { + IrisJigsawPiece piece = new IrisJigsawPiece().setObject("cell-object").setRotatable(rotatable); + for (IrisDirection direction : directions) { + piece.getConnectors().add(planarConnector(direction, "")); + } + return piece; + } + + private static IrisJigsawConnector planarConnector(IrisDirection direction, String pool) { + return connector(direction, pool, "door", "door") + .setPosition(IrisJigsawConnector.canonicalPlanarPosition(new IrisPosition(3, 1, 3), direction)) + .setJoint(JigsawJoint.ALIGNED); + } + + private static IrisStructure planarJunctionStructure() { + return structure("start", 3) + .setMode(IrisJigsawMode.PLANAR_JIGSAW) + .setCellSize(new IrisPosition(3, 1, 3)); + } + + private static Set worldFacings(PlacedStructurePiece piece) { + Set facings = EnumSet.noneOf(IrisDirection.class); + for (IrisJigsawConnector connector : piece.getPiece().getConnectors()) { + facings.add(piece.getRotation().rotate(connector.getDirection())); + } + return facings; + } + private static IrisStructure structure(String startPool, int maxDepth) { return new IrisStructure().setStartPool(startPool).setMaxDepth(maxDepth).setMaxSizeChunks(8); } diff --git a/core/src/test/java/art/arcane/iris/studio/generation/JigsawStudioGeneratorTest.java b/core/src/test/java/art/arcane/iris/studio/generation/JigsawStudioGeneratorTest.java index 95cb8b3de..7b594d279 100644 --- a/core/src/test/java/art/arcane/iris/studio/generation/JigsawStudioGeneratorTest.java +++ b/core/src/test/java/art/arcane/iris/studio/generation/JigsawStudioGeneratorTest.java @@ -2,8 +2,6 @@ package art.arcane.iris.studio.generation; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.ResourceLoader; -import art.arcane.iris.studio.jigsaw.JigsawPlanarArchetype; -import art.arcane.iris.studio.jigsaw.JigsawPlanarDirection; import art.arcane.iris.studio.jigsaw.JigsawPlanarTopology; import art.arcane.iris.studio.jigsaw.JigsawStudioActivation; import art.arcane.iris.studio.jigsaw.JigsawStudioBay; @@ -52,6 +50,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -168,57 +167,123 @@ public class JigsawStudioGeneratorTest { } @Test - public void paintsCanonicalWoolGlyphsBelowCaptureAndOneControlChest() { + @SuppressWarnings("unchecked") + public void paintsActiveVariantConnectorLinesBelowCaptureAndOneControlChest() { + IrisData source = mock(IrisData.class); + ResourceLoader pieceLoader = mock(ResourceLoader.class); + ResourceLoader objectLoader = mock(ResourceLoader.class); + when(source.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(source.getObjectLoader()).thenReturn(objectLoader); JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(7, 5, 7); + IrisJigsawPiece piece = new IrisJigsawPiece() + .setObject("village/corner") + .setConnectors(new KList<>()); + piece.getConnectors().add(connector(new IrisPosition(3, 2, 0), IrisDirection.NORTH_NEGATIVE_Z)); + piece.getConnectors().add(connector(new IrisPosition(6, 2, 3), IrisDirection.EAST_POSITIVE_X)); + when(pieceLoader.load("village/corner", false)).thenReturn(piece); + when(objectLoader.load("village/corner", false)).thenReturn(new IrisObject(7, 5, 7)); + JigsawStudioVariant variant = planarVariant("village/corner", JigsawPlanarTopology.NORTH_EAST_CORNER); GeneratorFixture fixture = fixture( + source, JigsawStudioMode.PLANAR_JIGSAW, dimensions, - JigsawStudioVariantCatalog.empty()); + new JigsawStudioVariantCatalog(List.of(variant))); JigsawStudioBay corner = fixture.layout().get("workcell/corner"); - int glyphY = corner.bounds().originY() - 1; + int floorY = corner.bounds().originY() - 1; int centerX = dimensions.width() / 2; int centerZ = dimensions.depth() / 2; - assertSame(fixture.connectorCap(), stateAt( - fixture.generator(), - corner.bounds().originX() + centerX, - glyphY, - corner.bounds().originZ())); - assertSame(fixture.connectorCap(), stateAt( - fixture.generator(), - corner.bounds().maxX(), - glyphY, - corner.bounds().originZ() + centerZ)); - assertSame(fixture.topologyPath(), stateAt( - fixture.generator(), - corner.bounds().originX() + centerX, - glyphY, - corner.bounds().originZ() + 1)); - assertSame(fixture.topologyPath(), stateAt( - fixture.generator(), - corner.bounds().originX() + centerX + 1, - glyphY, - corner.bounds().originZ() + centerZ)); - assertSame(fixture.topologyBase(), stateAt( - fixture.generator(), - corner.bounds().originX(), - glyphY, - corner.bounds().originZ())); - assertFalse(fixture.topologyBase() == stateAt( - fixture.generator(), - corner.bounds().originX() + centerX, - corner.bounds().originY(), - corner.bounds().originZ() + centerZ)); + try (MockedStatic ignored = mockStatic(B.class)) { + assertSame(fixture.palette().horizontalSocket(), stateAt( + fixture.generator(), corner.bounds().originX() + centerX, floorY, corner.bounds().originZ())); + assertSame(fixture.palette().horizontalSocket(), stateAt( + fixture.generator(), corner.bounds().maxX(), floorY, corner.bounds().originZ() + centerZ)); + assertSame(fixture.palette().line(), stateAt( + fixture.generator(), corner.bounds().originX() + centerX, floorY, corner.bounds().originZ() + 1)); + assertSame(fixture.palette().line(), stateAt( + fixture.generator(), + corner.bounds().originX() + centerX + 1, + floorY, + corner.bounds().originZ() + centerZ)); + assertSame(fixture.palette().base(), stateAt( + fixture.generator(), corner.bounds().originX(), floorY, corner.bounds().originZ())); + assertFalse(fixture.palette().base() == stateAt( + fixture.generator(), + corner.bounds().originX() + centerX, + corner.bounds().originY(), + corner.bounds().originZ() + centerZ)); - JigsawStudioBay blank = fixture.layout().get("workcell/blank"); - assertSame(fixture.topologyBase(), stateAt( - fixture.generator(), - blank.bounds().originX() + centerX, - blank.bounds().originY() - 1, - blank.bounds().originZ() + centerZ)); - JigsawStudioControlPosition control = fixture.layout().controlPosition(); - assertSame(fixture.controlChest(), stateAt( - fixture.generator(), control.worldX(), control.worldY(), control.worldZ())); + JigsawStudioBay blank = fixture.layout().get("workcell/blank"); + assertSame(fixture.palette().base(), stateAt( + fixture.generator(), + blank.bounds().originX() + centerX, + blank.bounds().originY() - 1, + blank.bounds().originZ() + centerZ)); + JigsawStudioBay tee = fixture.layout().get("workcell/tee"); + assertSame(fixture.palette().base(), stateAt( + fixture.generator(), + tee.bounds().originX() + centerX, + tee.bounds().originY() - 1, + tee.bounds().originZ())); + JigsawStudioControlPosition control = fixture.layout().controlPosition(); + assertSame(fixture.controlChest(), stateAt( + fixture.generator(), control.worldX(), control.worldY(), control.worldZ())); + } + } + + @Test + @SuppressWarnings("unchecked") + public void spatialWorkcellsPaintVerticalSocketsUnderTheirConnectorColumns() { + IrisData source = mock(IrisData.class); + ResourceLoader pieceLoader = mock(ResourceLoader.class); + ResourceLoader objectLoader = mock(ResourceLoader.class); + when(source.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(source.getObjectLoader()).thenReturn(objectLoader); + IrisJigsawPiece piece = new IrisJigsawPiece() + .setObject("stronghold/shaft") + .setConnectors(new KList<>()); + piece.getConnectors().add(connector(new IrisPosition(2, 4, 2), IrisDirection.UP_POSITIVE_Y) + .setTop(IrisDirection.NORTH_NEGATIVE_Z)); + piece.getConnectors().add(connector(new IrisPosition(2, 0, 2), IrisDirection.DOWN_NEGATIVE_Y) + .setTop(IrisDirection.NORTH_NEGATIVE_Z)); + piece.getConnectors().add(connector(new IrisPosition(0, 1, 4), IrisDirection.DOWN_NEGATIVE_Y) + .setTop(IrisDirection.NORTH_NEGATIVE_Z)); + piece.getConnectors().add(connector(new IrisPosition(2, 2, 0), IrisDirection.NORTH_NEGATIVE_Z)); + when(pieceLoader.load("stronghold/shaft", false)).thenReturn(piece); + when(objectLoader.load("stronghold/shaft", false)).thenReturn(new IrisObject(5, 5, 5)); + JigsawStudioVariant variant = new JigsawStudioVariant( + "stronghold/shaft", + "stronghold/shaft", + "", + Optional.of(new JigsawStudioCellDimensions(5, 5, 5)), + JigsawStudioMode.SPATIAL_JIGSAW, + Optional.empty(), + true, + true, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + GeneratorFixture fixture = fixture( + source, + JigsawStudioMode.SPATIAL_JIGSAW, + new JigsawStudioCellDimensions(5, 5, 5), + new JigsawStudioVariantCatalog(List.of(variant))); + JigsawStudioBay workcell = fixture.layout().bays().getFirst(); + int originX = workcell.bounds().originX(); + int originZ = workcell.bounds().originZ(); + int floorY = workcell.bounds().originY() - 1; + + try (MockedStatic ignored = mockStatic(B.class)) { + assertSame(fixture.palette().upAndDownSocket(), stateAt(fixture.generator(), originX + 2, floorY, originZ + 2)); + assertSame(fixture.palette().downSocket(), stateAt(fixture.generator(), originX, floorY, originZ + 4)); + assertSame(fixture.palette().horizontalSocket(), stateAt(fixture.generator(), originX + 2, floorY, originZ)); + assertSame(fixture.palette().line(), stateAt(fixture.generator(), originX + 2, floorY, originZ + 1)); + assertSame(fixture.palette().line(), stateAt(fixture.generator(), originX + 1, floorY, originZ + 3)); + assertSame(fixture.palette().base(), stateAt(fixture.generator(), originX + 4, floorY, originZ + 4)); + } + assertSame( + fixture.palette().upAndDownSocket(), + fixture.generator().floorState(fixture.generator().floorGlyph(workcell).cell(2, 2))); } @Test @@ -247,77 +312,6 @@ public class JigsawStudioGeneratorTest { workcell.bounds().originZ())); } - @Test - public void glyphMathHandlesAllArchetypesAndSmallEvenAndOddCells() { - int[] sizes = {1, 2, 3, 4, 7, 16}; - for (int size : sizes) { - JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(size, 1, size); - int center = size / 2; - for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { - JigsawPlanarTopology topology = archetype.canonicalTopology(); - boolean centerPath = JigsawStudioGenerator.isTopologyPath( - topology, center, center, center, center); - assertEquals(archetype != JigsawPlanarArchetype.BLANK, centerPath); - if (topology.connects(JigsawPlanarDirection.NORTH)) { - assertTrue(JigsawStudioGenerator.isConnectorCap( - topology, dimensions, center, 0, center, center)); - } - if (topology.connects(JigsawPlanarDirection.EAST)) { - assertTrue(JigsawStudioGenerator.isConnectorCap( - topology, dimensions, size - 1, center, center, center)); - } - } - } - } - - @Test - public void connectorCapsOnlyOccupyConnectedFaceCenters() { - int[] sizes = {3, 4, 7, 16}; - for (int size : sizes) { - JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(size, 1, size); - int center = size / 2; - for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { - JigsawPlanarTopology topology = archetype.canonicalTopology(); - for (int z = 0; z < size; z++) { - for (int x = 0; x < size; x++) { - boolean expected = topology.connects(JigsawPlanarDirection.NORTH) - && x == center && z == 0 - || topology.connects(JigsawPlanarDirection.EAST) - && x == size - 1 && z == center - || topology.connects(JigsawPlanarDirection.SOUTH) - && x == center && z == size - 1 - || topology.connects(JigsawPlanarDirection.WEST) - && x == 0 && z == center; - assertEquals(expected, JigsawStudioGenerator.isConnectorCap( - topology, dimensions, x, z, center, center)); - } - } - } - } - } - - @Test - public void canonicalFiveByFivePathsMatchEveryArchetype() { - Map> expected = Map.of( - JigsawPlanarArchetype.BLANK, List.of(".....", ".....", ".....", ".....", "....."), - JigsawPlanarArchetype.END, List.of("..#..", "..#..", "..#..", ".....", "....."), - JigsawPlanarArchetype.STRAIGHT, List.of("..#..", "..#..", "..#..", "..#..", "..#.."), - JigsawPlanarArchetype.CORNER, List.of("..#..", "..#..", "..###", ".....", "....."), - JigsawPlanarArchetype.TEE, List.of("..#..", "..#..", "#####", ".....", "....."), - JigsawPlanarArchetype.CROSS, List.of("..#..", "..#..", "#####", "..#..", "..#..")); - - for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { - List rows = expected.get(archetype); - for (int z = 0; z < 5; z++) { - for (int x = 0; x < 5; x++) { - boolean path = JigsawStudioGenerator.isTopologyPath( - archetype.canonicalTopology(), x, z, 2, 2); - assertEquals(rows.get(z).charAt(x) == '#', path); - } - } - } - } - @Test public void clipsLargeGlyphPaintingToOneChunkFootprint() { GeneratorFixture fixture = fixture( @@ -331,10 +325,7 @@ public class JigsawStudioGeneratorTest { fixture.generator().paintChunk(chunk, chunkX, chunkZ); - int glyphBlocks = chunk.countState(fixture.topologyBase()) - + chunk.countState(fixture.topologyPath()) - + chunk.countState(fixture.connectorCap()); - assertEquals(256, glyphBlocks); + assertEquals(256, chunk.countState(fixture.palette().base())); } @Test @@ -467,6 +458,29 @@ public class JigsawStudioGeneratorTest { assertNotSame(nextGeneration, fixture.generator().renderVariant(workcell, variant, 5L)); } + @Test + public void workcellsFromAnEquivalentReplacedLayoutStillRender() { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(5, 5, 5); + GeneratorFixture fixture = fixture( + JigsawStudioMode.PLANAR_JIGSAW, + dimensions, + JigsawStudioVariantCatalog.empty()); + JigsawStudioBay stale = fixture.layout().get("workcell/end"); + + fixture.generator().getSession().replaceLayout(JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + dimensions, + JigsawStudioVariantCatalog.empty())); + + assertNotSame(stale, fixture.generator().getLayout().get("workcell/end")); + assertTrue(fixture.generator().renderBay(stale).valid()); + fixture.generator().getSession().replaceLayout(JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + new JigsawStudioCellDimensions(6, 5, 5), + JigsawStudioVariantCatalog.empty())); + assertThrows(IllegalArgumentException.class, () -> fixture.generator().renderBay(stale)); + } + @Test public void mapsAllVanillaJigsawOrientations() { assertEquals("north_up", JigsawStudioGenerator.orientation( @@ -535,9 +549,13 @@ public class JigsawStudioGeneratorTest { NativeBlockState lightFloor = mock(NativeBlockState.class); NativeBlockState darkFloor = mock(NativeBlockState.class); NativeBlockState frame = mock(NativeBlockState.class); - NativeBlockState topologyBase = mock(NativeBlockState.class); - NativeBlockState topologyPath = mock(NativeBlockState.class); - NativeBlockState connectorCap = mock(NativeBlockState.class); + JigsawStudioGenerator.FloorPalette palette = new JigsawStudioGenerator.FloorPalette( + mock(NativeBlockState.class), + mock(NativeBlockState.class), + mock(NativeBlockState.class), + mock(NativeBlockState.class), + mock(NativeBlockState.class), + mock(NativeBlockState.class)); NativeBlockState invalidMarker = mock(NativeBlockState.class); NativeBlockState controlChest = mock(NativeBlockState.class); JigsawStudioGenerator generator = new JigsawStudioGenerator( @@ -547,9 +565,7 @@ public class JigsawStudioGeneratorTest { lightFloor, darkFloor, frame, - topologyBase, - topologyPath, - connectorCap, + palette, invalidMarker, controlChest, JigsawStudioGeneratorTest::renderFinalState); @@ -557,12 +573,22 @@ public class JigsawStudioGeneratorTest { generator, layout, frame, - topologyBase, - topologyPath, - connectorCap, + palette, controlChest); } + private static IrisJigsawConnector connector(IrisPosition position, IrisDirection direction) { + return new IrisJigsawConnector() + .setPosition(position) + .setDirection(direction) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool("test/pieces") + .setName("iris:planar") + .setTargetName("iris:planar") + .setJoint(JigsawJoint.ALIGNED) + .setFinalState("minecraft:structure_void"); + } + private static JigsawStudioVariant planarVariant(String key, JigsawPlanarTopology topology) { return new JigsawStudioVariant( key, @@ -596,9 +622,7 @@ public class JigsawStudioGeneratorTest { JigsawStudioGenerator generator, JigsawStudioLayout layout, NativeBlockState frame, - NativeBlockState topologyBase, - NativeBlockState topologyPath, - NativeBlockState connectorCap, + JigsawStudioGenerator.FloorPalette palette, NativeBlockState controlChest ) { } diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawDefaultKitAssemblyTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawDefaultKitAssemblyTest.java new file mode 100644 index 000000000..f672a17bb --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawDefaultKitAssemblyTest.java @@ -0,0 +1,509 @@ +package art.arcane.iris.studio.jigsaw; + +import art.arcane.iris.pack.value.IrisDirection; +import art.arcane.iris.pack.value.IrisPosition; +import art.arcane.iris.structure.authoring.StructureResourceBundle; +import art.arcane.iris.structure.authoring.StructureWriteResult; +import art.arcane.iris.structure.graph.StructureAssemblyResult; +import art.arcane.iris.structure.graph.StructureAssemblyStatus; +import art.arcane.iris.structure.graph.StructureGraphCompilation; +import art.arcane.iris.structure.graph.StructureResourceBundleGraphCompiler; +import art.arcane.iris.structure.jigsaw.IrisJigsawConnector; +import art.arcane.iris.structure.jigsaw.IrisJigsawPiece; +import art.arcane.iris.structure.jigsaw.IrisJigsawPieceEntry; +import art.arcane.iris.structure.jigsaw.IrisJigsawPool; +import art.arcane.iris.structure.jigsaw.JigsawJoint; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.structure.placement.IrisStructure; +import art.arcane.iris.structure.placement.PlacedStructurePiece; +import art.arcane.iris.structure.placement.StructureAssembler; +import art.arcane.iris.testsupport.DurabilityMode; +import art.arcane.volmlib.util.math.RNG; +import com.google.gson.Gson; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class JigsawDefaultKitAssemblyTest { + @ClassRule + public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + + private static final Gson GSON = new Gson(); + private static final int SEEDS = 160; + private static final int SPATIAL_IRIS_PIECE_BOUND = 23 * 4 + 1; + private static final int SPATIAL_PORTABLE_PIECE_BOUND = 129; + private static final List PLANAR_DIMENSIONS = List.of( + new JigsawStudioCellDimensions(16, 16, 16), + new JigsawStudioCellDimensions(15, 9, 15)); + private static final List SPATIAL_DIMENSIONS = List.of( + new JigsawStudioCellDimensions(15, 15, 15), + new JigsawStudioCellDimensions(16, 12, 16)); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void planarDefaultKitOpensEveryOccupiedNeighborOnTheCellGrid() throws IOException { + for (JigsawStudioCellDimensions dimensions : PLANAR_DIMENSIONS) { + for (JigsawStudioCompatibilityTarget target : JigsawStudioCompatibilityTarget.values()) { + StructureGraphCompilation compilation = compile(JigsawStudioMode.PLANAR_JIGSAW, target, dimensions); + int closedFaces = 0; + for (long seed = 0; seed < SEEDS; seed++) { + List pieces = complete(compilation, seed); + for (PlacedStructurePiece piece : pieces) { + for (IrisJigsawConnector connector : piece.getPiece().getConnectors()) { + IrisDirection facing = piece.getRotation().rotate(connector.getDirection()); + IrisPosition targetCell = offset(planarSocket(piece, facing), facing); + PlacedStructurePiece neighbor = containing(pieces, targetCell); + if (neighbor == null) { + continue; + } + String context = target + " " + dimensions + " seed " + seed + ": " + + piece.getPiece().getObject() + " faces " + facing + " into " + + neighbor.getPiece().getObject(); + assertTrue(context, hasPlanarSocket( + neighbor, + facing.reverse(), + targetCell, + piece.getRotation().rotate(connector.getTop()))); + assertSharedFace(context, piece, neighbor, facing); + closedFaces++; + } + } + } + assertTrue(closedFaces > SEEDS); + } + } + } + + @Test + public void planarDefaultKitTurnsBothWaysThroughCornersAndTees() throws IOException { + StructureGraphCompilation compilation = compile( + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + PLANAR_DIMENSIONS.getFirst()); + Map> attachments = new TreeMap<>(); + for (long seed = 0; seed < SEEDS; seed++) { + List pieces = complete(compilation, seed); + for (int index = 1; index < pieces.size(); index++) { + PlacedStructurePiece piece = pieces.get(index); + int attachment = soleEarlierPlanarAttachment(pieces, index); + if (attachment >= 0) { + attachments.computeIfAbsent(baseName(piece), ignored -> new HashSet<>()).add(attachment); + } + } + } + assertEquals(attachments.toString(), Set.of(0, 1), attachments.get("corner")); + Set teeAttachments = attachments.get("tee"); + assertNotNull(attachments.toString(), teeAttachments); + assertTrue(attachments.toString(), teeAttachments.contains(0)); + assertTrue(attachments.toString(), teeAttachments.contains(1) || teeAttachments.contains(2)); + } + + @Test + public void connectorlessBlankPiecesGenerateAsStandaloneStructures() throws IOException { + for (JigsawStudioMode mode : JigsawStudioMode.values()) { + JigsawStudioCellDimensions dimensions = mode == JigsawStudioMode.PLANAR_JIGSAW + ? PLANAR_DIMENSIONS.getFirst() + : SPATIAL_DIMENSIONS.getFirst(); + StructureGraphCompilation compilation = compile( + mode, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + dimensions); + int standaloneBlanks = 0; + int multiPieceAssemblies = 0; + for (long seed = 0; seed < SEEDS * 8 && (standaloneBlanks == 0 || seed < SEEDS); seed++) { + List pieces = complete(compilation, seed); + if (pieces.size() == 1 && pieces.getFirst().getPiece().getConnectors().isEmpty()) { + assertEquals("blank", baseName(pieces.getFirst())); + standaloneBlanks++; + continue; + } + multiPieceAssemblies++; + for (PlacedStructurePiece piece : pieces) { + assertFalse(mode + " seed " + seed, piece.getPiece().getConnectors().isEmpty()); + } + } + assertTrue(mode + " never generated its connectorless piece", standaloneBlanks > 0); + assertTrue(mode + " generated only standalone pieces", multiPieceAssemblies > standaloneBlanks); + } + + StructureResourceBundle planar = JigsawStudioProjectCreator.bundle(options( + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, + PLANAR_DIMENSIONS.getLast())); + IrisJigsawPool start = pool(planar, "kit/test/start"); + assertEquals(2, start.getPieces().size()); + assertEquals("kit/test/cross", start.getPieces().get(0).getPiece()); + assertEquals(3, start.getPieces().get(0).getWeight()); + assertEquals("kit/test/blank", start.getPieces().get(1).getPiece()); + assertEquals(1, start.getPieces().get(1).getWeight()); + for (IrisJigsawPieceEntry entry : pool(planar, "kit/test/pieces").getPieces()) { + assertFalse("kit/test/blank".equals(entry.getPiece())); + } + } + + @Test + public void spatialDefaultKitCoversEveryYRotationTopologyOnce() throws IOException { + for (JigsawStudioCompatibilityTarget target : JigsawStudioCompatibilityTarget.values()) { + JigsawStudioCellDimensions dimensions = SPATIAL_DIMENSIONS.getLast(); + StructureResourceBundle bundle = JigsawStudioProjectCreator.bundle( + options(JigsawStudioMode.SPATIAL_JIGSAW, target, dimensions)); + Map pieces = pieces(bundle); + Map bySignature = new HashMap<>(); + for (Map.Entry entry : pieces.entrySet()) { + IrisJigsawPiece piece = entry.getValue(); + int horizontal = 0; + int vertical = 0; + for (IrisJigsawConnector connector : piece.getConnectors()) { + IrisDirection direction = connector.getDirection(); + assertEquals(spatialFaceCenter(dimensions, direction), connector.getPosition()); + assertEquals(direction.isVertical() ? IrisDirection.NORTH_NEGATIVE_Z : IrisDirection.UP_POSITIVE_Y, + connector.getTop()); + assertEquals(JigsawJoint.ROLLABLE, connector.getJoint()); + assertEquals("kit/test/pieces", connector.getPool()); + horizontal |= horizontalBit(direction); + vertical |= verticalBit(direction); + } + assertTrue(piece.isRotatable()); + assertEquals(entry.getKey(), piece.getObject()); + assertEquals(expectedSpatialKey(canonicalMask(horizontal), vertical), entry.getKey()); + String previous = bySignature.put(canonicalMask(horizontal) | vertical, entry.getKey()); + assertEquals("duplicate topology " + entry.getKey() + " and " + previous, null, previous); + if (target == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) { + assertEquals(List.of("variant-1"), List.copyOf(piece.getThemes())); + assertEquals(4, piece.resolvedRules().getMaximumPlacements()); + } else { + assertTrue(piece.getThemes().isEmpty()); + assertEquals(0, piece.resolvedRules().getMaximumPlacements()); + } + } + assertEquals(24, pieces.size()); + assertEquals(24, bySignature.size()); + + IrisJigsawPool start = pool(bundle, "kit/test/start"); + assertEquals(24, start.getPieces().size()); + IrisJigsawPool piecePool = pool(bundle, "kit/test/pieces"); + assertEquals(24, piecePool.getPieces().size()); + assertTrue(piecePool.getPieces().getLast().isEmpty()); + for (IrisJigsawPieceEntry entry : piecePool.getPieces()) { + assertFalse("kit/test/blank".equals(entry.getPiece())); + } + } + } + + @Test + public void spatialDefaultKitAttachesEveryPieceThroughAlignedConnectorsAndTurnsBothWays() throws IOException { + for (JigsawStudioCellDimensions dimensions : SPATIAL_DIMENSIONS) { + for (JigsawStudioCompatibilityTarget target : JigsawStudioCompatibilityTarget.values()) { + StructureGraphCompilation compilation = compile(JigsawStudioMode.SPATIAL_JIGSAW, target, dimensions); + int bound = target == JigsawStudioCompatibilityTarget.IRIS_EXTENDED + ? SPATIAL_IRIS_PIECE_BOUND + : SPATIAL_PORTABLE_PIECE_BOUND; + Set placedKinds = new HashSet<>(); + Set cornerAttachments = new HashSet<>(); + for (long seed = 0; seed < SEEDS; seed++) { + List pieces = complete(compilation, seed); + String context = target + " " + dimensions + " seed " + seed; + assertTrue(context + " placed " + pieces.size(), pieces.size() <= bound); + for (int index = 0; index < pieces.size(); index++) { + placedKinds.add(baseName(pieces.get(index))); + if (index == 0) { + continue; + } + List attachments = earlierSpatialAttachments(pieces, index); + assertFalse(context + " left " + baseName(pieces.get(index)) + " unattached", + attachments.isEmpty()); + if (attachments.size() == 1 && baseName(pieces.get(index)).startsWith("corner") + && attachments.getFirst() < 2) { + cornerAttachments.add(attachments.getFirst()); + } + } + } + assertEquals(target + " " + dimensions, Set.of(0, 1), cornerAttachments); + assertFalse(placedKinds.contains("blank")); + assertEquals(target + " " + dimensions + " placed " + placedKinds, 23, placedKinds.size()); + } + } + } + + @Test + public void vanillaPortableSpatialProjectIsCreatedWithAViableGraph() throws IOException { + Path packRoot = temporaryFolder.getRoot().toPath(); + JigsawStudioProjectCreator.Options options = options( + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, + SPATIAL_DIMENSIONS.getFirst()); + + StructureWriteResult result = JigsawStudioProjectCreator.create(packRoot, options); + + assertTrue(result.successful()); + IrisStructure structure = GSON.fromJson(Files.readString( + packRoot.resolve("structures/kit/test.json"), StandardCharsets.UTF_8), IrisStructure.class); + assertEquals(4, structure.getMaxDepth()); + assertTrue(Files.isRegularFile(packRoot.resolve("jigsaw-pieces/kit/test/corner-up-down.json"))); + assertTrue(Files.isRegularFile(packRoot.resolve("objects/kit/test/blank.iob"))); + } + + private static JigsawStudioProjectCreator.Options options( + JigsawStudioMode mode, + JigsawStudioCompatibilityTarget target, + JigsawStudioCellDimensions dimensions + ) { + return new JigsawStudioProjectCreator.Options("kit/test", mode, target, dimensions); + } + + private static StructureGraphCompilation compile( + JigsawStudioMode mode, + JigsawStudioCompatibilityTarget target, + JigsawStudioCellDimensions dimensions + ) throws IOException { + StructureGraphCompilation compilation = StructureResourceBundleGraphCompiler.compile( + JigsawStudioProjectCreator.bundle(options(mode, target, dimensions))).getFirst(); + assertFalse(compilation.getDiagnostics().toString(), compilation.hasErrors()); + return compilation; + } + + private static List complete(StructureGraphCompilation compilation, long seed) { + StructureAssemblyResult result = StructureAssembler.forCompilation( + compilation, + new IrisPosition(0, 64, 0)).assemble(new RNG(seed)); + assertEquals("seed " + seed + ": " + result.detail(), StructureAssemblyStatus.COMPLETE, result.status()); + return result.pieces(); + } + + private static int soleEarlierPlanarAttachment(List pieces, int index) { + PlacedStructurePiece piece = pieces.get(index); + int attachment = -1; + int pairings = 0; + for (int connectorIndex = 0; connectorIndex < piece.getPiece().getConnectors().size(); connectorIndex++) { + IrisJigsawConnector connector = piece.getPiece().getConnectors().get(connectorIndex); + IrisDirection facing = piece.getRotation().rotate(connector.getDirection()); + IrisPosition targetCell = offset(planarSocket(piece, facing), facing); + for (int earlier = 0; earlier < index; earlier++) { + PlacedStructurePiece neighbor = pieces.get(earlier); + if (contains(neighbor, targetCell) && hasPlanarSocket( + neighbor, + facing.reverse(), + targetCell, + piece.getRotation().rotate(connector.getTop()))) { + attachment = connectorIndex; + pairings++; + } + } + } + return pairings == 1 ? attachment : -1; + } + + private static List earlierSpatialAttachments(List pieces, int index) { + PlacedStructurePiece piece = pieces.get(index); + List attachments = new ArrayList<>(); + for (int connectorIndex = 0; connectorIndex < piece.getPiece().getConnectors().size(); connectorIndex++) { + IrisJigsawConnector connector = piece.getPiece().getConnectors().get(connectorIndex); + IrisPosition socket = spatialSocket(piece, connector); + IrisDirection facing = piece.getRotation().rotate(connector.getDirection()); + for (int earlier = 0; earlier < index; earlier++) { + PlacedStructurePiece neighbor = pieces.get(earlier); + for (IrisJigsawConnector other : neighbor.getPiece().getConnectors()) { + IrisDirection otherFacing = neighbor.getRotation().rotate(other.getDirection()); + if (otherFacing == facing.reverse() + && offset(spatialSocket(neighbor, other), otherFacing).equals(socket)) { + attachments.add(connectorIndex); + } + } + } + } + return attachments; + } + + private static boolean hasPlanarSocket( + PlacedStructurePiece piece, + IrisDirection facing, + IrisPosition position, + IrisDirection top + ) { + if (!planarSocket(piece, facing).equals(position)) { + return false; + } + for (IrisJigsawConnector connector : piece.getPiece().getConnectors()) { + if (piece.getRotation().rotate(connector.getDirection()) == facing + && piece.getRotation().rotate(connector.getTop()) == top) { + return true; + } + } + return false; + } + + private static void assertSharedFace( + String context, + PlacedStructurePiece piece, + PlacedStructurePiece neighbor, + IrisDirection facing + ) { + assertEquals(context, piece.getMinY(), neighbor.getMinY()); + assertEquals(context, piece.getMaxY(), neighbor.getMaxY()); + switch (facing) { + case EAST_POSITIVE_X -> assertEquals(context, piece.getMaxX() + 1, neighbor.getMinX()); + case WEST_NEGATIVE_X -> assertEquals(context, piece.getMinX() - 1, neighbor.getMaxX()); + case SOUTH_POSITIVE_Z -> assertEquals(context, piece.getMaxZ() + 1, neighbor.getMinZ()); + case NORTH_NEGATIVE_Z -> assertEquals(context, piece.getMinZ() - 1, neighbor.getMaxZ()); + case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> throw new AssertionError(context + " uses a vertical socket"); + } + if (facing.x() != 0) { + assertEquals(context, piece.getMinZ(), neighbor.getMinZ()); + assertEquals(context, piece.getMaxZ(), neighbor.getMaxZ()); + } else { + assertEquals(context, piece.getMinX(), neighbor.getMinX()); + assertEquals(context, piece.getMaxX(), neighbor.getMaxX()); + } + } + + private static IrisPosition planarSocket(PlacedStructurePiece piece, IrisDirection facing) { + IrisPosition size = new IrisPosition( + piece.getMaxX() - piece.getMinX() + 1, + piece.getMaxY() - piece.getMinY() + 1, + piece.getMaxZ() - piece.getMinZ() + 1); + IrisPosition local = IrisJigsawConnector.canonicalPlanarPosition(size, facing); + return new IrisPosition( + piece.getMinX() + local.getX(), + piece.getMinY() + local.getY(), + piece.getMinZ() + local.getZ()); + } + + private static IrisPosition spatialSocket(PlacedStructurePiece piece, IrisJigsawConnector connector) { + IrisObject object = piece.getObject(); + IrisPosition relative = new IrisPosition( + connector.getPosition().getX() - object.getW() / 2, + connector.getPosition().getY() - object.getH() / 2, + connector.getPosition().getZ() - object.getD() / 2); + IrisPosition rotated = piece.getRotation().rotate(relative); + return new IrisPosition( + piece.getX() + rotated.getX(), + piece.getY() + rotated.getY(), + piece.getZ() + rotated.getZ()); + } + + private static IrisPosition spatialFaceCenter(JigsawStudioCellDimensions dimensions, IrisDirection direction) { + int centerX = dimensions.width() / 2; + int centerY = dimensions.height() / 2; + int centerZ = dimensions.depth() / 2; + return switch (direction) { + case NORTH_NEGATIVE_Z -> new IrisPosition(centerX, centerY, 0); + case SOUTH_POSITIVE_Z -> new IrisPosition(centerX, centerY, dimensions.depth() - 1); + case EAST_POSITIVE_X -> new IrisPosition(dimensions.width() - 1, centerY, centerZ); + case WEST_NEGATIVE_X -> new IrisPosition(0, centerY, centerZ); + case UP_POSITIVE_Y -> new IrisPosition(centerX, dimensions.height() - 1, centerZ); + case DOWN_NEGATIVE_Y -> new IrisPosition(centerX, 0, centerZ); + }; + } + + private static IrisPosition offset(IrisPosition position, IrisDirection direction) { + return new IrisPosition( + position.getX() + direction.x(), + position.getY() + direction.y(), + position.getZ() + direction.z()); + } + + private static PlacedStructurePiece containing(List pieces, IrisPosition position) { + for (PlacedStructurePiece piece : pieces) { + if (contains(piece, position)) { + return piece; + } + } + return null; + } + + private static boolean contains(PlacedStructurePiece piece, IrisPosition position) { + return position.getX() >= piece.getMinX() && position.getX() <= piece.getMaxX() + && position.getY() >= piece.getMinY() && position.getY() <= piece.getMaxY() + && position.getZ() >= piece.getMinZ() && position.getZ() <= piece.getMaxZ(); + } + + private static String baseName(PlacedStructurePiece piece) { + String key = piece.getPiece().getObject(); + return key.substring(key.lastIndexOf('/') + 1); + } + + private static int horizontalBit(IrisDirection direction) { + return switch (direction) { + case NORTH_NEGATIVE_Z -> 1; + case EAST_POSITIVE_X -> 2; + case SOUTH_POSITIVE_Z -> 4; + case WEST_NEGATIVE_X -> 8; + case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> 0; + }; + } + + private static int verticalBit(IrisDirection direction) { + return switch (direction) { + case UP_POSITIVE_Y -> 16; + case DOWN_NEGATIVE_Y -> 32; + case NORTH_NEGATIVE_Z, EAST_POSITIVE_X, SOUTH_POSITIVE_Z, WEST_NEGATIVE_X -> 0; + }; + } + + private static int canonicalMask(int mask) { + int canonical = mask; + for (int quarterTurns = 1; quarterTurns < 4; quarterTurns++) { + canonical = Math.min(canonical, ((mask << quarterTurns) | (mask >>> (4 - quarterTurns))) & 15); + } + return canonical; + } + + private static String expectedSpatialKey(int canonicalHorizontal, int vertical) { + String horizontal = switch (canonicalHorizontal) { + case 0 -> "blank"; + case 1 -> "end"; + case 3 -> "corner"; + case 5 -> "straight"; + case 7 -> "tee"; + case 15 -> "cross"; + default -> throw new AssertionError("Unexpected horizontal topology " + canonicalHorizontal); + }; + String suffix = switch (vertical) { + case 0 -> ""; + case 16 -> "-up"; + case 32 -> "-down"; + case 48 -> "-up-down"; + default -> throw new AssertionError("Unexpected vertical topology " + vertical); + }; + return "kit/test/" + horizontal + suffix; + } + + private static Map pieces(StructureResourceBundle bundle) { + Map pieces = new TreeMap<>(); + for (StructureResourceBundle.Resource resource : bundle.resources().values()) { + String path = resource.relativePath(); + if (path.startsWith("jigsaw-pieces/") && path.endsWith(".json")) { + pieces.put( + path.substring("jigsaw-pieces/".length(), path.length() - ".json".length()), + GSON.fromJson(new String(resource.content(), StandardCharsets.UTF_8), IrisJigsawPiece.class)); + } + } + return pieces; + } + + private static IrisJigsawPool pool(StructureResourceBundle bundle, String key) { + StructureResourceBundle.Resource resource = bundle.resources().get("jigsaw-pools/" + key + ".json"); + assertNotNull(key, resource); + return GSON.fromJson(new String(resource.content(), StandardCharsets.UTF_8), IrisJigsawPool.class); + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioCaptureFinalStateTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioCaptureFinalStateTest.java new file mode 100644 index 000000000..16cb8cbb8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioCaptureFinalStateTest.java @@ -0,0 +1,101 @@ +package art.arcane.iris.studio.jigsaw; + +import art.arcane.iris.pack.value.IrisDirection; +import art.arcane.iris.pack.value.IrisPosition; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.structure.jigsaw.IrisJigsawConnector; +import art.arcane.iris.structure.jigsaw.IrisJigsawPiece; +import art.arcane.iris.structure.jigsaw.JigsawJoint; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KList; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Player; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class JigsawStudioCaptureFinalStateTest { + @Test + public void hiddenConnectorOverDugOutAirStoresExplicitAirLikeVisibleMarkers() throws Throwable { + JigsawStudioBounds bounds = new JigsawStudioBounds(0, 64, 0, new JigsawStudioCellDimensions(1, 1, 1)); + IrisJigsawPiece piece = new IrisJigsawPiece().setConnectors(new KList<>()); + piece.getConnectors().add(new IrisJigsawConnector() + .setPosition(new IrisPosition(0, 0, 0)) + .setDirection(IrisDirection.NORTH_NEGATIVE_Z) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool("fort/pieces") + .setName("iris:door") + .setTargetName("iris:door") + .setJoint(JigsawJoint.ALIGNED) + .setFinalState("minecraft:structure_void")); + BlockData air = mock(BlockData.class); + when(air.getMaterial()).thenReturn(Material.AIR); + when(air.getAsString()).thenReturn("minecraft:air"); + Block block = mock(Block.class); + when(block.getBlockData()).thenReturn(air); + World world = mock(World.class); + when(world.getBlockAt(0, 64, 0)).thenReturn(block); + JigsawStudioCapture.ChunkCaptureArea area = JigsawStudioCapture.chunkIntersections(bounds).getFirst(); + + JigsawStudioCapture.ChunkSnapshot snapshot = JigsawStudioCapture.captureChunkIntersection( + world, bounds, piece, new IrisObject(1, 1, 1), area, 0, false); + JigsawStudioCapture.Capture capture = JigsawStudioCapture.aggregateSnapshots( + bounds, List.of(area), List.of(snapshot)); + + assertEquals("minecraft:air", capture.connectors().getFirst().getFinalState()); + NativeBlockState restoredAir = mock(NativeBlockState.class); + when(restoredAir.key()).thenReturn("minecraft:air"); + IrisObject restored = read(capture.objectContent(), restoredAir); + assertTrue(restored.getBlocks().containsKey(restored.getSigned(0, 0, 0))); + assertEquals("minecraft:air", restored.getBlocks().get(restored.getSigned(0, 0, 0)).key()); + } + + @Test + public void autosaveNotificationsFallBackToTheOnlineOwner() { + Player explicit = mock(Player.class); + Player owner = mock(Player.class); + UUID ownerId = UUID.randomUUID(); + try (MockedStatic server = mockStatic(Bukkit.class)) { + server.when(() -> Bukkit.getPlayer(ownerId)).thenReturn(owner); + + assertSame(explicit, JigsawStudioCaptureScheduler.notificationTarget(explicit, ownerId)); + assertSame(owner, JigsawStudioCaptureScheduler.notificationTarget(null, ownerId)); + assertNull(JigsawStudioCaptureScheduler.notificationTarget(null, null)); + } + } + + private static IrisObject read(byte[] content, NativeBlockState restoredState) throws Throwable { + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(restoredState); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + IrisObject restored = new IrisObject(); + restored.read(new ByteArrayInputStream(content)); + return restored; + } finally { + IrisPlatforms.unbind(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorPlacementTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorPlacementTest.java new file mode 100644 index 000000000..35e4fe292 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorPlacementTest.java @@ -0,0 +1,383 @@ +package art.arcane.iris.studio.jigsaw; + +import art.arcane.iris.pack.value.IrisDirection; +import art.arcane.iris.pack.value.IrisPosition; +import art.arcane.iris.structure.jigsaw.IrisJigsawConnector; +import art.arcane.iris.structure.jigsaw.JigsawJoint; +import art.arcane.iris.studio.jigsaw.JigsawStudioCapture.LocalPosition; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Box; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.BlockClick; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Cell; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.CellContent; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.CellContents; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Decision; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Frame; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Identity; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Operation; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Ray; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.ReseatResult; +import art.arcane.iris.studio.jigsaw.JigsawStudioConnectorPlacement.Target; +import org.junit.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class JigsawStudioConnectorPlacementTest { + private static final Box BOX = new Box(16, 65, 16, 20, 69, 20); + private static final CellContents EMPTY = position -> Optional.empty(); + private static final Identity SPATIAL = new Identity("demo/pieces", "iris:spatial", "iris:spatial", JigsawJoint.ROLLABLE); + + @Test + public void clickedBlockInsideTheVariantBecomesTheConnectorFacingOutOfTheClickedFace() { + Optional target = JigsawStudioConnectorPlacement.target( + BOX, + new Ray(18.5D, 67.5D, 25.0D, 0.0D, 0.0D, -1.0D), + Optional.of(new BlockClick(18, 67, 20, IrisDirection.SOUTH_POSITIVE_Z)), + Set.of(), + false); + + assertEquals(Optional.of(new Target(18, 67, 20, IrisDirection.SOUTH_POSITIVE_Z)), target); + } + + @Test + public void sneakingFacesTheConnectorIntoTheClickedFace() { + Optional target = JigsawStudioConnectorPlacement.target( + BOX, + new Ray(18.5D, 67.5D, 25.0D, 0.0D, 0.0D, -1.0D), + Optional.of(new BlockClick(18, 67, 20, IrisDirection.SOUTH_POSITIVE_Z)), + Set.of(), + true); + + assertEquals(Optional.of(new Target(18, 67, 20, IrisDirection.NORTH_NEGATIVE_Z)), target); + } + + @Test + public void clickingTheStudioFloorPlacesADownConnectorOnTheBottomLayer() { + Optional target = JigsawStudioConnectorPlacement.target( + BOX, + new Ray(18.5D, 70.0D, 18.5D, 0.0D, -1.0D, 0.0D), + Optional.of(new BlockClick(18, 64, 18, IrisDirection.UP_POSITIVE_Y)), + Set.of(), + false); + + assertEquals(Optional.of(new Target(18, 65, 18, IrisDirection.DOWN_NEGATIVE_Y)), target); + } + + @Test + public void pointingAtAirFromInsideUsesTheBoundaryCellWhereTheRayLeaves() { + Optional target = JigsawStudioConnectorPlacement.target( + BOX, + new Ray(18.5D, 67.5D, 18.5D, 0.0D, 0.0D, -1.0D), + Optional.empty(), + Set.of(), + false); + + assertEquals(Optional.of(new Target(18, 67, 16, IrisDirection.NORTH_NEGATIVE_Z)), target); + } + + @Test + public void pointingAtAirFromOutsideUsesTheBoundaryCellWhereTheRayEnters() { + Optional target = JigsawStudioConnectorPlacement.target( + BOX, + new Ray(18.5D, 67.5D, 30.0D, 0.0D, 0.0D, -1.0D), + Optional.empty(), + Set.of(), + false); + + assertEquals(Optional.of(new Target(18, 67, 20, IrisDirection.SOUTH_POSITIVE_Z)), target); + } + + @Test + public void connectorCellsInFrontOfTheClickedBlockWinSoHiddenConnectorsStaySelectable() { + Optional target = JigsawStudioConnectorPlacement.target( + BOX, + new Ray(18.5D, 67.5D, 18.5D, 0.0D, 0.0D, -1.0D), + Optional.of(new BlockClick(18, 67, 16, IrisDirection.SOUTH_POSITIVE_Z)), + Set.of(new Cell(18, 67, 17)), + false); + + assertEquals(Optional.of(new Target(18, 67, 17, IrisDirection.SOUTH_POSITIVE_Z)), target); + } + + @Test + public void raysThatMissTheVariantResolveNothing() { + Optional target = JigsawStudioConnectorPlacement.target( + BOX, + new Ray(30.5D, 67.5D, 30.5D, 0.0D, 0.0D, 1.0D), + Optional.empty(), + Set.of(), + false); + + assertTrue(target.isEmpty()); + } + + @Test + public void spatialRightClicksAddSelectMoveReorientAndDeselect() { + LocalPosition door = new LocalPosition(2, 1, 0); + LocalPosition other = new LocalPosition(4, 1, 2); + LocalPosition empty = new LocalPosition(0, 1, 2); + Map connectors = Map.of( + door, IrisDirection.NORTH_NEGATIVE_Z, + other, IrisDirection.EAST_POSITIVE_X); + + assertEquals(Operation.ADD, spatial(false, empty, IrisDirection.WEST_NEGATIVE_X, connectors, null)); + assertEquals(Operation.SELECT, spatial(false, door, IrisDirection.NORTH_NEGATIVE_Z, connectors, null)); + assertEquals(Operation.MOVE, spatial(false, empty, IrisDirection.WEST_NEGATIVE_X, connectors, door)); + assertEquals(Operation.DESELECT, spatial(false, door, IrisDirection.NORTH_NEGATIVE_Z, connectors, door)); + assertEquals(Operation.REORIENT, spatial(false, door, IrisDirection.UP_POSITIVE_Y, connectors, door)); + assertEquals(Operation.SELECT, spatial(false, other, IrisDirection.EAST_POSITIVE_X, connectors, door)); + assertEquals(Operation.ADD, spatial(false, empty, IrisDirection.WEST_NEGATIVE_X, connectors, empty)); + } + + @Test + public void spatialLeftClicksRemoveOrClearTheSelection() { + LocalPosition door = new LocalPosition(2, 1, 0); + Map connectors = Map.of(door, IrisDirection.NORTH_NEGATIVE_Z); + LocalPosition empty = new LocalPosition(1, 1, 1); + + assertEquals(Operation.REMOVE, spatial(true, door, IrisDirection.NORTH_NEGATIVE_Z, connectors, null)); + assertEquals(Operation.CLEAR_SELECTION, spatial(true, empty, IrisDirection.UP_POSITIVE_Y, connectors, door)); + assertEquals(Operation.NONE, spatial(true, empty, IrisDirection.UP_POSITIVE_Y, connectors, null)); + } + + @Test + public void planarRightClicksReseatOnlyTheWorkcellsTopologySides() { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(5, 5, 5); + Map connectors = Map.of(); + + Decision east = JigsawStudioConnectorPlacement.decidePlanar(false, new LocalPosition(4, 2, 2), + IrisDirection.EAST_POSITIVE_X, dimensions, JigsawPlanarTopology.NORTH_EAST_WEST_TEE, connectors, "T Junction"); + Decision south = JigsawStudioConnectorPlacement.decidePlanar(false, new LocalPosition(2, 2, 4), + IrisDirection.SOUTH_POSITIVE_Z, dimensions, JigsawPlanarTopology.NORTH_EAST_WEST_TEE, connectors, "T Junction"); + Decision floor = JigsawStudioConnectorPlacement.decidePlanar(false, new LocalPosition(0, 0, 3), + IrisDirection.DOWN_NEGATIVE_Y, dimensions, JigsawPlanarTopology.NORTH_EAST_WEST_TEE, connectors, "T Junction"); + + assertEquals(Operation.RESEAT, east.operation()); + assertEquals(JigsawPlanarDirection.EAST, east.side()); + assertEquals(Operation.REFUSE, south.operation()); + assertTrue(south.message().contains("north, east, and west")); + assertEquals(Operation.RESEAT, floor.operation()); + assertEquals(JigsawPlanarDirection.WEST, floor.side()); + } + + @Test + public void planarLeftClicksRefuseTheOnlySocketOfAConnectedSideButRemoveStrays() { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(5, 5, 5); + LocalPosition socket = new LocalPosition(2, 2, 0); + LocalPosition stray = new LocalPosition(1, 2, 0); + Map single = Map.of(socket, IrisDirection.NORTH_NEGATIVE_Z); + Map doubled = Map.of( + socket, IrisDirection.NORTH_NEGATIVE_Z, + stray, IrisDirection.NORTH_NEGATIVE_Z); + + Decision refused = JigsawStudioConnectorPlacement.decidePlanar(true, socket, + IrisDirection.NORTH_NEGATIVE_Z, dimensions, JigsawPlanarTopology.NORTH_END, single, "End Cap"); + Decision removed = JigsawStudioConnectorPlacement.decidePlanar(true, stray, + IrisDirection.NORTH_NEGATIVE_Z, dimensions, JigsawPlanarTopology.NORTH_END, doubled, "End Cap"); + + assertEquals(Operation.REFUSE, refused.operation()); + assertEquals(Operation.REMOVE, removed.operation()); + } + + @Test + public void framesRoundTripPositionsAndDirectionsForEveryQuarterTurn() { + JigsawStudioCellDimensions source = new JigsawStudioCellDimensions(6, 3, 4); + for (int quarterTurns = 0; quarterTurns < 4; quarterTurns++) { + Frame frame = new Frame(source, quarterTurns); + for (int x = 0; x < 6; x++) { + for (int z = 0; z < 4; z++) { + IrisPosition position = new IrisPosition(x, 1, z); + LocalPosition display = frame.display(position); + JigsawStudioCellDimensions dimensions = frame.displayDimensions(); + assertTrue(display.x() >= 0 && display.x() < dimensions.width()); + assertTrue(display.z() >= 0 && display.z() < dimensions.depth()); + assertEquals(position, frame.source(display)); + } + } + for (IrisDirection direction : IrisDirection.values()) { + assertEquals(direction, frame.sourceDirection(frame.displayDirection(direction))); + } + } + } + + @Test + public void addConvertsDisplayPlacementIntoSourceSpaceWithDefaultIdentity() { + Frame frame = new Frame(new JigsawStudioCellDimensions(6, 3, 4), 1); + LocalPosition target = new LocalPosition(0, 1, 2); + + List added = JigsawStudioConnectorPlacement.add( + List.of(), frame, target, IrisDirection.WEST_NEGATIVE_X, IrisDirection.UP_POSITIVE_Y, SPATIAL, EMPTY); + + IrisJigsawConnector connector = added.getFirst(); + assertEquals(target, frame.display(connector.getPosition())); + assertEquals(IrisDirection.WEST_NEGATIVE_X, frame.displayDirection(connector.getDirection())); + assertEquals(IrisDirection.UP_POSITIVE_Y, connector.getTop()); + assertEquals("demo/pieces", connector.getPool()); + assertEquals("iris:spatial", connector.getName()); + assertEquals("iris:spatial", connector.getTargetName()); + assertEquals(JigsawJoint.ROLLABLE, connector.getJoint()); + assertEquals("minecraft:structure_void", connector.getFinalState()); + assertEquals("", connector.getChannel()); + } + + @Test + public void connectorsPlacedOnBuiltBlocksKeepThatBlockAsTheirFinalState() { + Frame frame = new Frame(new JigsawStudioCellDimensions(5, 5, 5), 0); + CellContents wall = position -> Optional.of(new CellContent("minecraft:stone_bricks", false)); + CellContents chest = position -> Optional.of(new CellContent("minecraft:chest[facing=north]", true)); + + List added = JigsawStudioConnectorPlacement.add( + List.of(), frame, new LocalPosition(2, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + IrisDirection.UP_POSITIVE_Y, SPATIAL, wall); + + assertEquals("minecraft:stone_bricks", added.getFirst().getFinalState()); + assertThrows(IllegalArgumentException.class, () -> JigsawStudioConnectorPlacement.add( + List.of(), frame, new LocalPosition(2, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + IrisDirection.UP_POSITIVE_Y, SPATIAL, chest)); + } + + @Test + public void addingOntoAnExistingConnectorIsRejected() { + Frame frame = new Frame(new JigsawStudioCellDimensions(5, 5, 5), 0); + List source = List.of(connector(new IrisPosition(2, 1, 0), IrisDirection.NORTH_NEGATIVE_Z)); + + assertThrows(IllegalArgumentException.class, () -> JigsawStudioConnectorPlacement.add( + source, frame, new LocalPosition(2, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + IrisDirection.UP_POSITIVE_Y, SPATIAL, EMPTY)); + } + + @Test + public void moveKeepsIdentityAndOrderAndOnlyReplacesTheFinalStateOnBuiltBlocks() { + Frame frame = new Frame(new JigsawStudioCellDimensions(5, 5, 5), 0); + IrisJigsawConnector first = connector(new IrisPosition(2, 1, 0), IrisDirection.NORTH_NEGATIVE_Z) + .setChannel("hall").setFinalState("minecraft:air"); + IrisJigsawConnector second = connector(new IrisPosition(4, 1, 2), IrisDirection.EAST_POSITIVE_X); + List source = List.of(first, second); + + List moved = JigsawStudioConnectorPlacement.move( + source, frame, new LocalPosition(2, 1, 0), new LocalPosition(0, 1, 2), + IrisDirection.WEST_NEGATIVE_X, IrisDirection.UP_POSITIVE_Y, EMPTY); + List onWall = JigsawStudioConnectorPlacement.move( + source, frame, new LocalPosition(2, 1, 0), new LocalPosition(0, 1, 2), + IrisDirection.WEST_NEGATIVE_X, IrisDirection.UP_POSITIVE_Y, + position -> Optional.of(new CellContent("minecraft:stone", false))); + + assertEquals(2, moved.size()); + assertEquals(new IrisPosition(0, 1, 2), moved.getFirst().getPosition()); + assertEquals(IrisDirection.WEST_NEGATIVE_X, moved.getFirst().getDirection()); + assertEquals("hall", moved.getFirst().getChannel()); + assertEquals("demo/pieces", moved.getFirst().getPool()); + assertEquals("minecraft:air", moved.getFirst().getFinalState()); + assertEquals("minecraft:stone", onWall.getFirst().getFinalState()); + assertEquals(second.getPosition(), moved.get(1).getPosition()); + assertEquals(new IrisPosition(2, 1, 0), first.getPosition()); + assertThrows(IllegalArgumentException.class, () -> JigsawStudioConnectorPlacement.move( + source, frame, new LocalPosition(2, 1, 0), new LocalPosition(4, 1, 2), + IrisDirection.EAST_POSITIVE_X, IrisDirection.UP_POSITIVE_Y, EMPTY)); + } + + @Test + public void removeDropsOnlyTheConnectorAtThatDisplayPosition() { + Frame frame = new Frame(new JigsawStudioCellDimensions(5, 5, 5), 2); + IrisJigsawConnector north = connector(new IrisPosition(2, 1, 0), IrisDirection.NORTH_NEGATIVE_Z); + IrisJigsawConnector east = connector(new IrisPosition(4, 1, 2), IrisDirection.EAST_POSITIVE_X); + + List remaining = JigsawStudioConnectorPlacement.remove( + List.of(north, east), frame, frame.display(north.getPosition())); + + assertEquals(1, remaining.size()); + assertEquals(east.getPosition(), remaining.getFirst().getPosition()); + } + + @Test + public void reseatSnapsAMovedSocketBackToTheSourceFaceCenterAndDropsStrays() { + JigsawStudioCellDimensions source = new JigsawStudioCellDimensions(4, 4, 6); + Frame frame = new Frame(source, 1); + IrisJigsawConnector moved = connector(new IrisPosition(0, 1, 0), IrisDirection.NORTH_NEGATIVE_Z).setChannel("road"); + IrisJigsawConnector stray = connector(new IrisPosition(3, 1, 0), IrisDirection.NORTH_NEGATIVE_Z); + IrisJigsawConnector south = connector( + IrisJigsawConnector.canonicalPlanarPosition(new IrisPosition(4, 4, 6), IrisDirection.SOUTH_POSITIVE_Z), + IrisDirection.SOUTH_POSITIVE_Z); + JigsawPlanarDirection displaySide = JigsawPlanarDirection.EAST; + assertEquals(IrisDirection.NORTH_NEGATIVE_Z, frame.sourceDirection(displaySide.irisDirection())); + + ReseatResult result = JigsawStudioConnectorPlacement.reseat( + List.of(moved, stray, south), frame, displaySide, SPATIAL, EMPTY); + + assertTrue(result.changed()); + assertEquals(1, result.removedStrays()); + assertEquals(2, result.connectors().size()); + IrisJigsawConnector seated = result.connectors().getFirst(); + assertEquals(IrisJigsawConnector.canonicalPlanarPosition( + new IrisPosition(4, 4, 6), IrisDirection.NORTH_NEGATIVE_Z), seated.getPosition()); + assertEquals("road", seated.getChannel()); + assertEquals(south.getPosition(), result.connectors().get(1).getPosition()); + + ReseatResult unchanged = JigsawStudioConnectorPlacement.reseat( + result.connectors(), frame, displaySide, SPATIAL, EMPTY); + assertFalse(unchanged.changed()); + } + + @Test + public void reseatAddsAMissingSocketWithTheDefaultIdentity() { + Frame frame = new Frame(new JigsawStudioCellDimensions(5, 5, 5), 0); + Identity planar = new Identity("demo/pieces", "iris:planar", "iris:planar", JigsawJoint.ALIGNED); + + ReseatResult result = JigsawStudioConnectorPlacement.reseat( + List.of(), frame, JigsawPlanarDirection.WEST, planar, EMPTY); + + assertTrue(result.changed()); + IrisJigsawConnector added = result.connectors().getFirst(); + assertEquals(new IrisPosition(0, 2, 2), added.getPosition()); + assertEquals(IrisDirection.WEST_NEGATIVE_X, added.getDirection()); + assertEquals(IrisDirection.UP_POSITIVE_Y, added.getTop()); + assertEquals(JigsawJoint.ALIGNED, added.getJoint()); + assertEquals("iris:planar", added.getName()); + } + + @Test + public void changedPositionsListEveryDisplayCellWhoseConnectorDiffers() { + Frame frame = new Frame(new JigsawStudioCellDimensions(5, 5, 5), 0); + IrisJigsawConnector north = connector(new IrisPosition(2, 1, 0), IrisDirection.NORTH_NEGATIVE_Z); + IrisJigsawConnector east = connector(new IrisPosition(4, 1, 2), IrisDirection.EAST_POSITIVE_X); + List before = List.of(north, east); + List after = JigsawStudioConnectorPlacement.move( + before, frame, new LocalPosition(2, 1, 0), new LocalPosition(0, 1, 2), + IrisDirection.WEST_NEGATIVE_X, IrisDirection.UP_POSITIVE_Y, EMPTY); + + Set changed = JigsawStudioConnectorPlacement.changedPositions(before, after, frame); + + assertEquals(Set.of(new LocalPosition(2, 1, 0), new LocalPosition(0, 1, 2)), changed); + } + + private static Operation spatial( + boolean primary, + LocalPosition target, + IrisDirection facing, + Map connectors, + LocalPosition selection + ) { + return JigsawStudioConnectorPlacement.decideSpatial( + primary, target, facing, new LinkedHashMap<>(connectors), Optional.ofNullable(selection)).operation(); + } + + private static IrisJigsawConnector connector(IrisPosition position, IrisDirection direction) { + return new IrisJigsawConnector() + .setPosition(position) + .setDirection(direction) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool("demo/pieces") + .setName("iris:spatial") + .setTargetName("iris:spatial") + .setJoint(JigsawJoint.ROLLABLE) + .setFinalState("minecraft:structure_void"); + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorRepairTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorRepairTest.java new file mode 100644 index 000000000..527b6895d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioConnectorRepairTest.java @@ -0,0 +1,40 @@ +package art.arcane.iris.studio.jigsaw; + +import art.arcane.iris.studio.jigsaw.JigsawStudioCapture.LocalPosition; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.junit.Test; + +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class JigsawStudioConnectorRepairTest { + @Test + public void resetFindsUnsavedMarkersSoTopologyErrorsCanBeRepaired() { + JigsawStudioBounds bounds = new JigsawStudioBounds(0, 65, 0, new JigsawStudioCellDimensions(3, 2, 3)); + World world = mock(World.class); + Block air = mock(Block.class); + when(air.getType()).thenReturn(Material.AIR); + Block saved = mock(Block.class); + when(saved.getType()).thenReturn(Material.JIGSAW); + Block stray = mock(Block.class); + when(stray.getType()).thenReturn(Material.JIGSAW); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(air); + when(world.getBlockAt(1, 65, 0)).thenReturn(saved); + when(world.getBlockAt(1, 66, 1)).thenReturn(stray); + JigsawStudioCapture.ChunkCaptureArea area = JigsawStudioCapture.chunkIntersections(bounds).getFirst(); + Set savedConnectors = Set.of(new LocalPosition(1, 0, 0)); + + assertEquals( + Set.of(new LocalPosition(1, 1, 1)), + JigsawStudioMaterializer.strayMarkerPositions(world, bounds, area, savedConnectors, true)); + assertEquals( + Set.of(new LocalPosition(1, 0, 0), new LocalPosition(1, 1, 1)), + JigsawStudioMaterializer.strayMarkerPositions(world, bounds, area, savedConnectors, false)); + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioEditorRoutingTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioEditorRoutingTest.java new file mode 100644 index 000000000..4674195a2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioEditorRoutingTest.java @@ -0,0 +1,143 @@ +package art.arcane.iris.studio.jigsaw; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.studio.generation.JigsawStudioGenerator; +import art.arcane.iris.world.task.J; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; +import org.bukkit.event.block.BlockPistonExtendEvent; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class JigsawStudioEditorRoutingTest { + private static final UUID REQUEST = UUID.fromString("66666666-6666-6666-6666-666666666666"); + + @Test + public void renamingASpatialWorkcellRenamesTheVariantThatLabelsIt() throws Exception { + JigsawStudioVariant variant = new JigsawStudioVariant( + "demo/cross", + "demo/cross", + "", + Optional.of(new JigsawStudioCellDimensions(15, 15, 15)), + JigsawStudioMode.SPATIAL_JIGSAW, + Optional.empty(), + true, + true, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.SPATIAL_JIGSAW, + new JigsawStudioCellDimensions(15, 15, 15), + new JigsawStudioVariantCatalog(List.of(variant))); + JigsawStudioSession session = new JigsawStudioSession(REQUEST, "overworld", "demo", layout); + JigsawStudioService service = new JigsawStudioService(); + JigsawStudioVariantProperties properties = mock(JigsawStudioVariantProperties.class); + setField(service, "variantProperties", properties); + Player player = register(service, session); + + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.isOwnedByCurrentRegion(any(Player.class))).thenReturn(true); + + service.workcellEditor.updateWorkcellDisplayName(player, JigsawStudioLayout.SPATIAL_WORKCELL_ID, "Great Hall"); + + verify(properties).updateVariantDisplayName( + player, JigsawStudioLayout.SPATIAL_WORKCELL_ID, "demo/cross", "Great Hall"); + } + } + + @Test + public void pistonHeadsCannotExtendIntoProtectedStudioCells() throws Exception { + JigsawStudioService service = new JigsawStudioService(); + JigsawStudioProtection protection = mock(JigsawStudioProtection.class); + setField(service, "protection", protection); + World world = mock(World.class); + when(world.getUID()).thenReturn(UUID.randomUUID()); + Block piston = mock(Block.class); + Block head = mock(Block.class); + when(piston.getWorld()).thenReturn(world); + when(piston.getRelative(BlockFace.EAST)).thenReturn(head); + when(protection.isImmutableStudioBlock(head)).thenReturn(true); + BlockPistonExtendEvent event = new BlockPistonExtendEvent(piston, List.of(), BlockFace.EAST); + + service.protectionListener.onProtectedPistonExtend(event); + + assertTrue(event.isCancelled()); + } + + private static Player register(JigsawStudioService service, JigsawStudioSession session) throws Exception { + UUID worldId = UUID.randomUUID(); + World world = mock(World.class); + when(world.getUID()).thenReturn(worldId); + Player player = mock(Player.class); + when(player.getWorld()).thenReturn(world); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + JigsawStudioActivation.Request request = new JigsawStudioActivation.Request( + REQUEST, + "overworld", + "demo", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(15, 15, 15), + mock(IrisData.class), + null); + JigsawStudioGenerator generator = mock(JigsawStudioGenerator.class); + when(generator.getRequest()).thenReturn(request); + when(generator.getSession()).thenReturn(session); + when(generator.getLayout()).thenAnswer(invocation -> session.layout()); + Class studioType = Class.forName(JigsawStudioService.class.getName() + "$ActiveStudio"); + Constructor constructor = studioType.getDeclaredConstructor( + UUID.class, + World.class, + Engine.class, + JigsawStudioGenerator.class, + ConcurrentHashMap.class, + Set.class, + AtomicLong.class); + constructor.setAccessible(true); + Object studio = constructor.newInstance( + worldId, + world, + mock(Engine.class), + generator, + new ConcurrentHashMap<>(), + ConcurrentHashMap.newKeySet(), + new AtomicLong()); + @SuppressWarnings("unchecked") + Map studios = (Map) getField(service, "studios"); + studios.put(worldId, studio); + return player; + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static Object getField(Object target, String name) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(target); + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioFloorGlyphTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioFloorGlyphTest.java new file mode 100644 index 000000000..f88de369a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioFloorGlyphTest.java @@ -0,0 +1,139 @@ +package art.arcane.iris.studio.jigsaw; + +import art.arcane.iris.pack.value.IrisDirection; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +public class JigsawStudioFloorGlyphTest { + @Test + public void emptyConnectorListPaintsOnlyTheBase() { + JigsawStudioFloorGlyph glyph = JigsawStudioFloorGlyph.of(5, 5, List.of()); + + assertRows(glyph, 5, 5, List.of(".....", ".....", ".....", ".....", ".....")); + } + + @Test + public void canonicalPlanarSocketsDrawStraightLinesFromTheCenter() { + Map> expected = Map.of( + JigsawPlanarArchetype.END, List.of("..o..", "..#..", "..#..", ".....", "....."), + JigsawPlanarArchetype.STRAIGHT, List.of("..o..", "..#..", "..#..", "..#..", "..o.."), + JigsawPlanarArchetype.CORNER, List.of("..o..", "..#..", "..##o", ".....", "....."), + JigsawPlanarArchetype.TEE, List.of("..o..", "..#..", "o###o", ".....", "....."), + JigsawPlanarArchetype.CROSS, List.of("..o..", "..#..", "o###o", "..#..", "..o..")); + + for (Map.Entry> entry : expected.entrySet()) { + List markers = new ArrayList<>(); + for (JigsawPlanarDirection direction : entry.getKey().canonicalTopology().directions()) { + markers.add(canonicalMarker(5, 5, direction.irisDirection())); + } + assertRows(JigsawStudioFloorGlyph.of(5, 5, markers), 5, 5, entry.getValue()); + } + } + + @Test + public void evenFootprintsUseFloorDivisionForTheCenter() { + JigsawStudioFloorGlyph glyph = JigsawStudioFloorGlyph.of(4, 4, List.of( + canonicalMarker(4, 4, IrisDirection.NORTH_NEGATIVE_Z), + canonicalMarker(4, 4, IrisDirection.WEST_NEGATIVE_X))); + + assertRows(glyph, 4, 4, List.of("..o.", "..#.", "o##.", "....")); + } + + @Test + public void verticalConnectorsUseDistinctSocketsAndMergeOnOneColumn() { + JigsawStudioFloorGlyph up = JigsawStudioFloorGlyph.of(5, 5, List.of( + new JigsawStudioFloorGlyph.Marker(2, 2, IrisDirection.UP_POSITIVE_Y))); + JigsawStudioFloorGlyph down = JigsawStudioFloorGlyph.of(5, 5, List.of( + new JigsawStudioFloorGlyph.Marker(2, 2, IrisDirection.DOWN_NEGATIVE_Y))); + JigsawStudioFloorGlyph both = JigsawStudioFloorGlyph.of(5, 5, List.of( + new JigsawStudioFloorGlyph.Marker(2, 2, IrisDirection.UP_POSITIVE_Y), + new JigsawStudioFloorGlyph.Marker(2, 2, IrisDirection.DOWN_NEGATIVE_Y), + canonicalMarker(5, 5, IrisDirection.EAST_POSITIVE_X))); + + assertEquals(JigsawStudioFloorGlyph.Cell.UP_SOCKET, up.cell(2, 2)); + assertEquals(JigsawStudioFloorGlyph.Cell.DOWN_SOCKET, down.cell(2, 2)); + assertRows(both, 5, 5, List.of(".....", ".....", "..B#o", ".....", ".....")); + } + + @Test + public void offCenterVerticalConnectorsGetALineToTheirColumn() { + JigsawStudioFloorGlyph glyph = JigsawStudioFloorGlyph.of(5, 5, List.of( + new JigsawStudioFloorGlyph.Marker(0, 2, IrisDirection.DOWN_NEGATIVE_Y))); + + assertRows(glyph, 5, 5, List.of(".....", ".....", "v##..", ".....", ".....")); + } + + @Test + public void interiorHorizontalConnectorsExtendTheirLineOutOfTheFaceTheyOpen() { + JigsawStudioFloorGlyph glyph = JigsawStudioFloorGlyph.of(5, 5, List.of( + new JigsawStudioFloorGlyph.Marker(3, 2, IrisDirection.SOUTH_POSITIVE_Z))); + + assertRows(glyph, 5, 5, List.of(".....", ".....", "..#o.", "...#.", "...#.")); + } + + @Test + public void diagonalLinesStayConnectedAndInsideTheFootprint() { + JigsawStudioFloorGlyph glyph = JigsawStudioFloorGlyph.of(7, 5, List.of( + new JigsawStudioFloorGlyph.Marker(6, 0, IrisDirection.EAST_POSITIVE_X))); + + assertEquals(JigsawStudioFloorGlyph.Cell.HORIZONTAL_SOCKET, glyph.cell(6, 0)); + assertEquals(JigsawStudioFloorGlyph.Cell.LINE, glyph.cell(3, 2)); + int lineCells = 0; + for (int z = 0; z < 5; z++) { + for (int x = 0; x < 7; x++) { + if (glyph.cell(x, z) == JigsawStudioFloorGlyph.Cell.LINE) { + lineCells++; + } + } + } + assertEquals(3, lineCells); + assertEquals(JigsawStudioFloorGlyph.Cell.BASE, glyph.cell(-1, 0)); + assertEquals(JigsawStudioFloorGlyph.Cell.BASE, glyph.cell(7, 0)); + } + + @Test + public void markersOutsideTheFootprintAreClampedToItsEdge() { + JigsawStudioFloorGlyph glyph = JigsawStudioFloorGlyph.of(3, 3, List.of( + new JigsawStudioFloorGlyph.Marker(9, 1, IrisDirection.EAST_POSITIVE_X))); + + assertRows(glyph, 3, 3, List.of("...", ".#o", "...")); + } + + private static JigsawStudioFloorGlyph.Marker canonicalMarker(int width, int depth, IrisDirection direction) { + int centerX = width / 2; + int centerZ = depth / 2; + return switch (direction) { + case NORTH_NEGATIVE_Z -> new JigsawStudioFloorGlyph.Marker(centerX, 0, direction); + case SOUTH_POSITIVE_Z -> new JigsawStudioFloorGlyph.Marker(centerX, depth - 1, direction); + case EAST_POSITIVE_X -> new JigsawStudioFloorGlyph.Marker(width - 1, centerZ, direction); + case WEST_NEGATIVE_X -> new JigsawStudioFloorGlyph.Marker(0, centerZ, direction); + case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> new JigsawStudioFloorGlyph.Marker(centerX, centerZ, direction); + }; + } + + private static void assertRows(JigsawStudioFloorGlyph glyph, int width, int depth, List rows) { + for (int z = 0; z < depth; z++) { + StringBuilder actual = new StringBuilder(width); + for (int x = 0; x < width; x++) { + actual.append(symbol(glyph.cell(x, z))); + } + assertEquals("row " + z, rows.get(z), actual.toString()); + } + } + + private static char symbol(JigsawStudioFloorGlyph.Cell cell) { + return switch (cell) { + case BASE -> '.'; + case LINE -> '#'; + case HORIZONTAL_SOCKET -> 'o'; + case UP_SOCKET -> '^'; + case DOWN_SOCKET -> 'v'; + case UP_AND_DOWN_SOCKET -> 'B'; + }; + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioGraphMapperTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioGraphMapperTest.java index 45be5cc05..cbb330a43 100644 --- a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioGraphMapperTest.java +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioGraphMapperTest.java @@ -234,6 +234,60 @@ public class JigsawStudioGraphMapperTest { assertEquals(new JigsawStudioCellDimensions(24, 32, 24), dimensions); } + @Test + @SuppressWarnings("unchecked") + public void spatialFootprintRejectsChangesThatExceedTheWorkcellVolume() { + IrisData data = mock(IrisData.class); + ResourceLoader objectLoader = mock(ResourceLoader.class); + when(data.getObjectLoader()).thenReturn(objectLoader); + when(objectLoader.load("stronghold/tower")).thenReturn(new IrisObject(16, 192, 16)); + when(objectLoader.load("stronghold/hall")).thenReturn(new IrisObject(128, 10, 20)); + JigsawStudioVariantCatalog catalog = new JigsawStudioVariantCatalog(List.of( + spatialVariant("stronghold/tower", new JigsawStudioCellDimensions(16, 192, 16), false), + spatialVariant("stronghold/hall", new JigsawStudioCellDimensions(128, 10, 20), false))); + JigsawStudioCellDimensions configured = new JigsawStudioCellDimensions(16, 16, 16); + + assertEquals("", JigsawStudioGraphMapper.spatialFootprintFailure( + data, + configured, + catalog, + new JigsawStudioGraphMapper.SpatialChange("stronghold/hall", new JigsawStudioCellDimensions(128, 10, 20), false))); + assertFalse(JigsawStudioGraphMapper.spatialFootprintFailure( + data, + configured, + catalog, + new JigsawStudioGraphMapper.SpatialChange("stronghold/hall", new JigsawStudioCellDimensions(128, 10, 20), true)).isEmpty()); + assertFalse(JigsawStudioGraphMapper.spatialFootprintFailure( + data, + configured, + catalog, + new JigsawStudioGraphMapper.SpatialChange("stronghold/hall", new JigsawStudioCellDimensions(128, 10, 128), false)).isEmpty()); + assertEquals("", JigsawStudioGraphMapper.spatialFootprintFailure( + data, + configured, + catalog, + new JigsawStudioGraphMapper.SpatialChange("stronghold/tower", new JigsawStudioCellDimensions(16, 64, 16), false))); + } + + private static JigsawStudioVariant spatialVariant( + String key, + JigsawStudioCellDimensions dimensions, + boolean rotatable + ) { + return new JigsawStudioVariant( + key, + key, + "", + Optional.of(dimensions), + JigsawStudioMode.SPATIAL_JIGSAW, + Optional.empty(), + rotatable, + true, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + } + @Test @SuppressWarnings("unchecked") public void mapsAuthorLabelsAndCanonicalPerVariantDimensionsIndependently() { diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioLayoutColumnTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioLayoutColumnTest.java new file mode 100644 index 000000000..83e015979 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioLayoutColumnTest.java @@ -0,0 +1,27 @@ +package art.arcane.iris.studio.jigsaw; + +import org.junit.Test; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +public class JigsawStudioLayoutColumnTest { + @Test + public void columnLookupFocusesTheWorkcellFromItsFloorToTheTeleportHeight() { + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + new JigsawStudioCellDimensions(7, 5, 7), + JigsawStudioVariantCatalog.empty()); + JigsawStudioBay cross = layout.get("workcell/cross"); + JigsawStudioBounds bounds = cross.bounds(); + int x = bounds.originX() + 3; + int z = bounds.originZ() + 3; + + assertSame(cross, layout.findColumn(x, bounds.originY() - 1, z)); + assertSame(cross, layout.findColumn(x, bounds.maxY() + 2, z)); + assertSame(cross, layout.findColumn(x, bounds.maxY() + JigsawStudioLayout.COLUMN_HEADROOM, z)); + assertNull(layout.findColumn(x, bounds.maxY() + JigsawStudioLayout.COLUMN_HEADROOM + 1, z)); + assertNull(layout.findColumn(bounds.originX() - 1, bounds.originY(), z)); + assertNull(layout.findAt(x, bounds.maxY() + 2, z)); + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioMainMenuRowTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioMainMenuRowTest.java new file mode 100644 index 000000000..fb7c60aa9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioMainMenuRowTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.studio.jigsaw; + +import org.junit.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class JigsawStudioMainMenuRowTest { + @Test + public void planarRowKeepsAllSixWorkcellsLeftOfTheRowControls() { + JigsawStudioMainMenu.WorkcellRow row = JigsawStudioMainMenu.workcellRow(6, 4); + + assertEquals(List.of(0, 1, 2, 3, 4, 5), row.indices()); + assertEquals(List.of(-3, -2, -1, 0, 1, 2), row.slots()); + assertEquals(-1, row.previousTarget()); + assertEquals(-1, row.nextTarget()); + } + + @Test + public void largeSpatialRowsPageWithoutHidingOrOverlappingWorkcells() { + for (int count : new int[]{7, 24}) { + Set reachable = new HashSet<>(); + for (int viewed = 0; viewed < count; viewed++) { + JigsawStudioMainMenu.WorkcellRow row = JigsawStudioMainMenu.workcellRow(count, viewed); + assertTrue(row.indices().contains(viewed)); + assertEquals(row.indices().size(), new HashSet<>(row.slots()).size()); + for (int slot : row.slots()) { + assertTrue(slot >= -3 && slot <= 1); + } + reachable.addAll(row.indices()); + if (row.nextTarget() >= 0) { + assertTrue(JigsawStudioMainMenu.workcellRow(count, row.nextTarget()).indices() + .contains(row.nextTarget())); + } + } + assertEquals(count, reachable.size()); + } + JigsawStudioMainMenu.WorkcellRow last = JigsawStudioMainMenu.workcellRow(24, 23); + assertEquals(List.of(20, 21, 22, 23), last.indices()); + assertEquals(15, last.previousTarget()); + assertEquals(-1, last.nextTarget()); + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioPersistenceEditorsTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioPersistenceEditorsTest.java index 7774e6b02..684eaba4b 100644 --- a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioPersistenceEditorsTest.java +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioPersistenceEditorsTest.java @@ -491,7 +491,7 @@ public class JigsawStudioPersistenceEditorsTest { JigsawStudioGraphEditor.VariantFamilyCreation creation = JigsawStudioGraphEditor.duplicateActiveFamily( packRoot, "studio/spatial", - Map.of(JigsawStudioLayout.SPATIAL_WORKCELL_ID, "studio/spatial/start"), + Map.of(JigsawStudioLayout.SPATIAL_WORKCELL_ID, "studio/spatial/blank"), "variant-2"); assertTrue(creation.writeResult().successful()); @@ -499,7 +499,7 @@ public class JigsawStudioPersistenceEditorsTest { "studio/spatial/variants/spatial/variant-2", creation.pieceKeysByWorkcell().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID)); JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/studio/spatial/start.json")); - assertEquals(8, pool.getAsJsonArray("pieces").size()); + assertEquals(25, pool.getAsJsonArray("pieces").size()); } @Test @@ -507,10 +507,13 @@ public class JigsawStudioPersistenceEditorsTest { Path packRoot = createSpatialProject("spatial-theme-row"); Map sources = new LinkedHashMap<>(); - sources.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID, "studio/spatial/start"); - for (int connectorCount = 1; connectorCount <= 6; connectorCount++) { - String pieceKey = "studio/spatial/connectors-" + connectorCount; - sources.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID + "/" + pieceKey, pieceKey); + for (String vertical : List.of("", "-up", "-down", "-up-down")) { + for (String horizontal : List.of("blank", "end", "straight", "corner", "tee", "cross")) { + String pieceKey = "studio/spatial/" + horizontal + vertical; + sources.put(sources.isEmpty() + ? JigsawStudioLayout.SPATIAL_WORKCELL_ID + : JigsawStudioLayout.SPATIAL_WORKCELL_ID + "/" + pieceKey, pieceKey); + } } JigsawStudioGraphEditor.VariantFamilyCreation creation = JigsawStudioGraphEditor.duplicateActiveFamily( packRoot, @@ -519,13 +522,13 @@ public class JigsawStudioPersistenceEditorsTest { "variant-2"); assertTrue(creation.writeResult().successful()); - assertEquals(7, creation.pieceKeysByWorkcell().size()); + assertEquals(24, creation.pieceKeysByWorkcell().size()); for (String targetPieceKey : creation.pieceKeysByWorkcell().values()) { assertTrue(Files.isRegularFile(packRoot.resolve("jigsaw-pieces/" + targetPieceKey + ".json"))); assertTrue(Files.isRegularFile(packRoot.resolve("objects/" + targetPieceKey + ".iob"))); } JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/studio/spatial/start.json")); - assertEquals(14, pool.getAsJsonArray("pieces").size()); + assertEquals(48, pool.getAsJsonArray("pieces").size()); } @Test @@ -557,7 +560,7 @@ public class JigsawStudioPersistenceEditorsTest { "END").get("displayName").getAsString()); Path spatialRoot = createSpatialProject("labels-spatial"); - Path spatialObject = spatialRoot.resolve("objects/studio/spatial/start.iob"); + Path spatialObject = spatialRoot.resolve("objects/studio/spatial/blank.iob"); byte[] spatialBefore = Files.readAllBytes(spatialObject); assertTrue(JigsawStudioStructureEditor.updateSpatialWorkcellDisplayName( diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioPersistenceIdentityTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioPersistenceIdentityTest.java index 81cafa5d5..0c8189f9d 100644 --- a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioPersistenceIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioPersistenceIdentityTest.java @@ -34,7 +34,7 @@ public class JigsawStudioPersistenceIdentityTest { private static final String PIECE_POOL_KEY = "studio/identity/pieces"; private static final String RECORDED_AT_FIELD = "recordedAtEpochMilli"; private static final String SCRIPTED_SESSION_DIGEST = - "318d9338a63a44063c85e59a9e74f869473d6e0f15c362536172f7723d960faa"; + "42b5d3bed8d74b2ef7a010a58302544d0d425c83faba0dc86efb0e41e69ba627"; @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioProjectCreatorTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioProjectCreatorTest.java index 370b2460e..41eacd21d 100644 --- a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioProjectCreatorTest.java +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioProjectCreatorTest.java @@ -102,9 +102,15 @@ public class JigsawStudioProjectCreatorTest { JsonObject startPool = JsonParser.parseString(Files.readString( temporaryDirectory.resolve("jigsaw-pools/settlement/test/start.json"), StandardCharsets.UTF_8)).getAsJsonObject(); - assertEquals(1, startPool.getAsJsonArray("pieces").size()); + assertEquals(2, startPool.getAsJsonArray("pieces").size()); assertEquals("settlement/test/cross", startPool.getAsJsonArray("pieces").get(0) .getAsJsonObject().get("piece").getAsString()); + assertEquals(3, startPool.getAsJsonArray("pieces").get(0) + .getAsJsonObject().get("weight").getAsInt()); + assertEquals("settlement/test/blank", startPool.getAsJsonArray("pieces").get(1) + .getAsJsonObject().get("piece").getAsString()); + assertEquals(1, startPool.getAsJsonArray("pieces").get(1) + .getAsJsonObject().get("weight").getAsInt()); JsonObject piecePool = JsonParser.parseString(Files.readString( temporaryDirectory.resolve("jigsaw-pools/settlement/test/pieces.json"), StandardCharsets.UTF_8)).getAsJsonObject(); @@ -229,7 +235,7 @@ public class JigsawStudioProjectCreatorTest { } @Test - public void spatialProjectSeedsZeroThroughSixConnectorVariants() throws Exception { + public void spatialProjectSeedsEveryYRotationTopologyVariant() throws Exception { Path temporaryDirectory = temporaryFolder.getRoot().toPath(); JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( "stronghold/gallery", @@ -246,35 +252,48 @@ public class JigsawStudioProjectCreatorTest { JsonObject piecePool = JsonParser.parseString(Files.readString( temporaryDirectory.resolve("jigsaw-pools/stronghold/gallery/pieces.json"), StandardCharsets.UTF_8)).getAsJsonObject(); - assertEquals(7, startPool.getAsJsonArray("pieces").size()); - assertEquals(7, piecePool.getAsJsonArray("pieces").size()); - assertEquals("stronghold/gallery/connectors-1", piecePool.getAsJsonArray("pieces").get(0) + assertEquals(24, startPool.getAsJsonArray("pieces").size()); + assertEquals("stronghold/gallery/blank", startPool.getAsJsonArray("pieces").get(0) .getAsJsonObject().get("piece").getAsString()); - assertTrue(piecePool.getAsJsonArray("pieces").get(6).getAsJsonObject() + assertEquals(24, piecePool.getAsJsonArray("pieces").size()); + assertEquals("stronghold/gallery/end", piecePool.getAsJsonArray("pieces").get(0) + .getAsJsonObject().get("piece").getAsString()); + assertTrue(piecePool.getAsJsonArray("pieces").get(23).getAsJsonObject() .get("empty").getAsBoolean()); - for (int connectorCount = 0; connectorCount <= 6; connectorCount++) { - String pieceName = connectorCount == 0 ? "start" : "connectors-" + connectorCount; - JsonObject piece = JsonParser.parseString(Files.readString( - temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/" - + pieceName + ".json"), - StandardCharsets.UTF_8)).getAsJsonObject(); - assertEquals(connectorCount, piece.getAsJsonArray("connectors").size()); - assertEquals(connectorCount + (connectorCount == 1 ? " Connector" : " Connectors"), - piece.get("displayName").getAsString()); - assertEquals(16, piece.getAsJsonObject("rules").get("maximumPlacements").getAsInt()); - assertEquals(new IrisBlockVector(15, 15, 15), IrisObject.sampleSize( - temporaryDirectory.resolve("objects/stronghold/gallery/" - + pieceName + ".iob").toFile())); + List horizontalClasses = List.of("blank", "end", "straight", "corner", "tee", "cross"); + List verticalClasses = List.of("", "-up", "-down", "-up-down"); + List horizontalCounts = List.of(0, 1, 2, 2, 3, 4); + for (String vertical : verticalClasses) { + for (int index = 0; index < horizontalClasses.size(); index++) { + String pieceName = horizontalClasses.get(index) + vertical; + JsonObject piece = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/" + pieceName + ".json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + int verticalCount = vertical.isEmpty() ? 0 : vertical.equals("-up-down") ? 2 : 1; + assertEquals(pieceName, horizontalCounts.get(index) + verticalCount, + piece.getAsJsonArray("connectors").size()); + assertEquals(4, piece.getAsJsonObject("rules").get("maximumPlacements").getAsInt()); + assertEquals(new IrisBlockVector(15, 15, 15), IrisObject.sampleSize( + temporaryDirectory.resolve("objects/stronghold/gallery/" + + pieceName + ".iob").toFile())); + } } - JsonObject six = JsonParser.parseString(Files.readString( - temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/connectors-6.json"), + JsonObject cornerShaft = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/corner-up-down.json"), StandardCharsets.UTF_8)).getAsJsonObject(); - assertEquals("NORTH_NEGATIVE_Z", six.getAsJsonArray("connectors").get(0) + assertEquals("L Junction + Up/Down", cornerShaft.get("displayName").getAsString()); + assertEquals("NORTH_NEGATIVE_Z", cornerShaft.getAsJsonArray("connectors").get(0) .getAsJsonObject().get("direction").getAsString()); - assertEquals("DOWN_NEGATIVE_Y", six.getAsJsonArray("connectors").get(5) + assertEquals("EAST_POSITIVE_X", cornerShaft.getAsJsonArray("connectors").get(1) .getAsJsonObject().get("direction").getAsString()); - assertEquals(0, six.getAsJsonArray("connectors").get(5) + assertEquals("UP_POSITIVE_Y", cornerShaft.getAsJsonArray("connectors").get(2) + .getAsJsonObject().get("direction").getAsString()); + assertEquals("DOWN_NEGATIVE_Y", cornerShaft.getAsJsonArray("connectors").get(3) + .getAsJsonObject().get("direction").getAsString()); + assertEquals(0, cornerShaft.getAsJsonArray("connectors").get(3) .getAsJsonObject().getAsJsonObject("position").get("y").getAsInt()); + assertEquals("NORTH_NEGATIVE_Z", cornerShaft.getAsJsonArray("connectors").get(3) + .getAsJsonObject().get("top").getAsString()); StructureGraphCompilation compilation = StructureResourceBundleGraphCompiler.compile( JigsawStudioProjectCreator.bundle(options)).getFirst(); @@ -283,7 +302,7 @@ public class JigsawStudioProjectCreatorTest { new IrisPosition(0, 0, 0)).assemble(new RNG(1337L)); assertEquals(preview.detail(), StructureAssemblyStatus.COMPLETE, preview.status()); assertTrue(preview.pieces().size() > 1); - assertTrue(preview.pieces().size() <= 96); + assertTrue(preview.pieces().size() <= 93); } @Test @@ -411,10 +430,10 @@ public class JigsawStudioProjectCreatorTest { JsonObject pool = JsonParser.parseString(Files.readString( temporaryDirectory.resolve("jigsaw-pools/stronghold/test/start.json"), StandardCharsets.UTF_8)).getAsJsonObject(); - assertEquals(8, pool.getAsJsonArray("pieces").size()); - assertEquals("stronghold/test/hall", pool.getAsJsonArray("pieces").get(7) + assertEquals(25, pool.getAsJsonArray("pieces").size()); + assertEquals("stronghold/test/hall", pool.getAsJsonArray("pieces").get(24) .getAsJsonObject().get("piece").getAsString()); - assertEquals(3, pool.getAsJsonArray("pieces").get(7) + assertEquals(3, pool.getAsJsonArray("pieces").get(24) .getAsJsonObject().get("weight").getAsInt()); StructureWriteResult resized = JigsawStudioStructureEditor.updateCellSize( @@ -435,7 +454,7 @@ public class JigsawStudioProjectCreatorTest { IrisBlockVector hallSize = IrisObject.sampleSize( temporaryDirectory.resolve("objects/stronghold/test/hall.iob").toFile()); IrisBlockVector starterSize = IrisObject.sampleSize( - temporaryDirectory.resolve("objects/stronghold/test/start.iob").toFile()); + temporaryDirectory.resolve("objects/stronghold/test/blank.iob").toFile()); assertEquals(new IrisBlockVector(16, 12, 18), hallSize); assertEquals(new IrisBlockVector(12, 10, 14), starterSize); diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionProbeTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionProbeTest.java index 5502ac44c..828a77dfa 100644 --- a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionProbeTest.java +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioProtectionProbeTest.java @@ -56,6 +56,21 @@ public class JigsawStudioProtectionProbeTest { } } + @Test + public void protectedWorkcellProbesAreDeniedWithoutChatMessages() throws Exception { + JigsawStudioService service = new JigsawStudioService(); + JigsawStudioProtection protection = mock(JigsawStudioProtection.class); + setField(service, "protection", protection); + PlayerInteractEvent event = probe(); + when(protection.studioEditRefusal(event.getPlayer(), event.getClickedBlock())) + .thenReturn("This Jigsaw Studio variant is read-only."); + + service.protectionListener.onUnauthorizedInteract(event); + + assertTrue(event.isCancelled()); + verify(protection, never()).isUnauthorizedStudioEdit(any(Player.class), any(Block.class)); + } + private static PlayerInteractEvent probe() { Player player = mock(Player.class); World world = mock(World.class); diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioResourceBundleAssemblerTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioResourceBundleAssemblerTest.java index fa549f314..d1588b9e3 100644 --- a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioResourceBundleAssemblerTest.java +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioResourceBundleAssemblerTest.java @@ -80,20 +80,20 @@ public class JigsawStudioResourceBundleAssemblerTest { JigsawStudioResourceBundleAssembler.assemble( packRoot, "fort", - "fort/start", + "fort/blank", emptyObject(), List.of(connector), false); - assertEquals(17, assembly.bundle().resources().size()); - assertEquals("fort/start", assembly.objectKey()); + assertEquals(51, assembly.bundle().resources().size()); + assertEquals("fort/blank", assembly.objectKey()); assertEquals(1, assembly.piece().getConnectors().size()); StructureWriteResult result = new StructureTransactionWriter(packRoot) .write(assembly.bundle(), StructureWriteMode.OVERWRITE); assertTrue(result.successful()); assertTrue(result.committed()); - Path piecePath = packRoot.resolve("jigsaw-pieces/fort/start.json"); + Path piecePath = packRoot.resolve("jigsaw-pieces/fort/blank.json"); IrisJigsawPiece persisted = new Gson().fromJson( Files.readString(piecePath, StandardCharsets.UTF_8), IrisJigsawPiece.class); assertEquals(1, persisted.getConnectors().size()); @@ -143,7 +143,7 @@ public class JigsawStudioResourceBundleAssemblerTest { @Test public void capturePreservesAbsentDefaultsAndUnknownPieceMetadata() throws Exception { Path packRoot = createProject(); - String pieceResource = "jigsaw-pieces/fort/start.json"; + String pieceResource = "jigsaw-pieces/fort/blank.json"; Path piecePath = packRoot.resolve(pieceResource); JsonObject source = JsonParser.parseString( Files.readString(piecePath, StandardCharsets.UTF_8)).getAsJsonObject(); @@ -158,7 +158,7 @@ public class JigsawStudioResourceBundleAssemblerTest { JigsawStudioResourceBundleAssembler.assemble( packRoot, STRUCTURE_KEY, - "fort/start", + "fort/blank", emptyObject(), List.copyOf(sourcePiece.getConnectors()), false); @@ -173,7 +173,7 @@ public class JigsawStudioResourceBundleAssemblerTest { JigsawStudioResourceBundleAssembler.assemble( packRoot, STRUCTURE_KEY, - "fort/start", + "fort/blank", emptyObject(), List.of(updated), false); @@ -202,7 +202,7 @@ public class JigsawStudioResourceBundleAssemblerTest { () -> JigsawStudioResourceBundleAssembler.assemble( packRoot, STRUCTURE_KEY, - "fort/start", + "fort/blank", emptyObject(), List.of(connector()), false)); @@ -213,7 +213,7 @@ public class JigsawStudioResourceBundleAssemblerTest { @Test public void refusesManagedDatapackOwnershipWithoutAssemblingAStudioSave() throws Exception { Path packRoot = createProject(); - Path objectPath = packRoot.resolve("objects/fort/start.iob"); + Path objectPath = packRoot.resolve("objects/fort/blank.iob"); byte[] before = Files.readAllBytes(objectPath); markManagedDatapack(packRoot, STRUCTURE_KEY); @@ -221,7 +221,7 @@ public class JigsawStudioResourceBundleAssemblerTest { () -> JigsawStudioResourceBundleAssembler.assemble( packRoot, STRUCTURE_KEY, - "fort/start", + "fort/blank", emptyObject(), List.of(connector()), false)); @@ -234,13 +234,13 @@ public class JigsawStudioResourceBundleAssemblerTest { @Test public void writerRejectsAResourceModifiedOutsideTheOwnershipTransaction() throws Exception { Path packRoot = createProject(); - Path piecePath = packRoot.resolve("jigsaw-pieces/fort/start.json"); + Path piecePath = packRoot.resolve("jigsaw-pieces/fort/blank.json"); Files.writeString(piecePath, Files.readString(piecePath, StandardCharsets.UTF_8) + "\n"); JigsawStudioResourceBundleAssembler.Assembly assembly = JigsawStudioResourceBundleAssembler.assemble( packRoot, STRUCTURE_KEY, - "fort/start", + "fort/blank", emptyObject(), List.of(connector().setPool("fort/start")), false); diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioServiceCaptureTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioServiceCaptureTest.java index 7da7bcf29..6554d7390 100644 --- a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioServiceCaptureTest.java +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioServiceCaptureTest.java @@ -181,11 +181,13 @@ public class JigsawStudioServiceCaptureTest { when(state.isCustom()).thenReturn(false); when(state.nativeHandle()).thenReturn(blockData); - JigsawStudioChunkWriter.restoreConnectorChunk( + JigsawStudioCapture.LocalPosition position = new JigsawStudioCapture.LocalPosition(1, 2, 3); + JigsawStudioChunkWriter.restoreChunkPositions( world, workcell, - List.of(renderedConnector), - Map.of(new JigsawStudioCapture.LocalPosition(1, 2, 3), renderedBlock), + Set.of(position), + Map.of(position, renderedConnector), + Map.of(position, renderedBlock), false); verify(world).getBlockAt(11, 22, 33); @@ -293,7 +295,9 @@ public class JigsawStudioServiceCaptureTest { assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/iris jigsaw status")); assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/msg owner hello")); assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/execute run say bypass")); - assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/unknownplugin mutate")); + assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/setblock 0 0 0 stone")); + assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/unknownplugin status")); + assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/spawn")); assertTrue(JigsawStudioProtection.blocksNonEditableWorkcellMutation( true, "/fill 0 0 0 1 1 1 stone")); assertTrue(JigsawStudioProtection.blocksNonEditableWorkcellMutation(true, "//paste -a")); diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolbeltTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolbeltTest.java new file mode 100644 index 000000000..f5d822f5e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioToolbeltTest.java @@ -0,0 +1,172 @@ +package art.arcane.iris.studio.jigsaw; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.studio.generation.JigsawStudioGenerator; +import art.arcane.iris.world.task.J; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class JigsawStudioToolbeltTest { + private static final UUID REQUEST = UUID.fromString("55555555-5555-5555-5555-555555555555"); + private static final JigsawStudioToolPayload DELETE = JigsawStudioToolPayload.variant( + JigsawStudioToolAction.DELETE_VARIANT, REQUEST, "workcell/end", "test/end"); + + @Test + public void heldUseRepeatsCannotConfirmADestructiveTool() { + JigsawStudioToolbelt.ToolConfirmation armed = + new JigsawStudioToolbelt.ToolConfirmation(DELETE, 1_000_000_000L, 11_000_000_000L); + + assertEquals(JigsawStudioToolbelt.ToolConfirmationState.ARMED, + JigsawStudioToolbelt.confirmationState(null, DELETE, 1_000_000_000L)); + assertEquals(JigsawStudioToolbelt.ToolConfirmationState.HELD, + JigsawStudioToolbelt.confirmationState(armed, DELETE, 1_200_000_000L)); + assertEquals(JigsawStudioToolbelt.ToolConfirmationState.CONFIRMED, + JigsawStudioToolbelt.confirmationState(armed, DELETE, 1_600_000_000L)); + assertEquals(JigsawStudioToolbelt.ToolConfirmationState.ARMED, + JigsawStudioToolbelt.confirmationState(armed, DELETE, 12_000_000_000L)); + assertEquals(JigsawStudioToolbelt.ToolConfirmationState.ARMED, + JigsawStudioToolbelt.confirmationState( + armed, + JigsawStudioToolPayload.request(JigsawStudioToolAction.DELETE_PROJECT, REQUEST), + 1_600_000_000L)); + } + + @Test + public void variantBoundSticksRequireTheirBoundVariant() { + assertTrue(JigsawStudioToolbelt.requiresBoundVariant(JigsawStudioToolPayload.variant( + JigsawStudioToolAction.TOGGLE_ROTATION, REQUEST, "workcell/end", "test/end"))); + assertTrue(JigsawStudioToolbelt.requiresBoundVariant(JigsawStudioToolPayload.variant( + JigsawStudioToolAction.EXPAND_TO_CELL, REQUEST, "workcell/end", "test/end"))); + assertTrue(JigsawStudioToolbelt.requiresBoundVariant(JigsawStudioToolPayload.variant( + JigsawStudioToolAction.DUPLICATE_VARIANT, REQUEST, "workcell/end", "test/end"))); + assertFalse(JigsawStudioToolbelt.requiresBoundVariant(JigsawStudioToolPayload.workcell( + JigsawStudioToolAction.CREATE_VARIANT, REQUEST, "workcell/end"))); + assertFalse(JigsawStudioToolbelt.requiresBoundVariant(JigsawStudioToolPayload.request( + JigsawStudioToolAction.CONNECTOR_TOOL, REQUEST))); + } + + @Test + public void staleRotationStickDoesNotToggleTheNewlyLoadedVariant() throws Exception { + JigsawStudioVariant first = variant("test/end"); + JigsawStudioVariant second = variant("test/end-b"); + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + new JigsawStudioCellDimensions(16, 16, 16), + new JigsawStudioVariantCatalog(List.of(second, first))); + JigsawStudioSession session = new JigsawStudioSession(REQUEST, "overworld", "test/project", layout); + assertEquals("test/end-b", session.activeVariant("workcell/end").orElseThrow().pieceKey()); + JigsawStudioService service = new JigsawStudioService(); + JigsawStudioVariantProperties properties = mock(JigsawStudioVariantProperties.class); + setField(service, "variantProperties", properties); + Player player = player(service, session); + + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.isOwnedByCurrentRegion(any(Player.class))).thenReturn(true); + + boolean used = service.toolbelt.useTool( + player, + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.TOGGLE_ROTATION, REQUEST, "workcell/end", "test/end"), + null, + false); + + assertFalse(used); + verify(properties, never()).toggleVariantRotatable(any(Player.class), anyString()); + } + } + + private static Player player(JigsawStudioService service, JigsawStudioSession session) throws Exception { + UUID worldId = UUID.randomUUID(); + World world = mock(World.class); + when(world.getUID()).thenReturn(worldId); + Player player = mock(Player.class); + when(player.getWorld()).thenReturn(world); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + JigsawStudioActivation.Request request = new JigsawStudioActivation.Request( + REQUEST, + "overworld", + "test/project", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16), + mock(IrisData.class), + null); + JigsawStudioGenerator generator = mock(JigsawStudioGenerator.class); + when(generator.getRequest()).thenReturn(request); + when(generator.getSession()).thenReturn(session); + when(generator.getLayout()).thenAnswer(invocation -> session.layout()); + Class studioType = Class.forName(JigsawStudioService.class.getName() + "$ActiveStudio"); + Constructor constructor = studioType.getDeclaredConstructor( + UUID.class, + World.class, + Engine.class, + JigsawStudioGenerator.class, + ConcurrentHashMap.class, + Set.class, + AtomicLong.class); + constructor.setAccessible(true); + Object studio = constructor.newInstance( + worldId, + world, + mock(Engine.class), + generator, + new ConcurrentHashMap<>(), + ConcurrentHashMap.newKeySet(), + new AtomicLong()); + @SuppressWarnings("unchecked") + Map studios = (Map) getField(service, "studios"); + studios.put(worldId, studio); + return player; + } + + private static JigsawStudioVariant variant(String pieceKey) { + return new JigsawStudioVariant( + pieceKey, + pieceKey, + "", + Optional.of(new JigsawStudioCellDimensions(16, 16, 16)), + JigsawStudioMode.PLANAR_JIGSAW, + Optional.of(JigsawPlanarTopology.NORTH_END), + true, + true, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static Object getField(Object target, String name) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(target); + } +} diff --git a/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioVariantDeletionRuleTest.java b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioVariantDeletionRuleTest.java new file mode 100644 index 000000000..b9f35c2e1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/studio/jigsaw/JigsawStudioVariantDeletionRuleTest.java @@ -0,0 +1,99 @@ +package art.arcane.iris.studio.jigsaw; + +import org.junit.Test; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class JigsawStudioVariantDeletionRuleTest { + private static final UUID WORLD = UUID.randomUUID(); + private static final UUID REQUEST = UUID.randomUUID(); + + @Test + public void spatialVariantsCanBeDeletedWhileLoadedUnlessTheyAreTheLastOne() { + JigsawStudioMenuState.Variant first = variant("demo/end", true, true); + JigsawStudioMenuState.Variant second = variant("demo/cross", true, true); + JigsawStudioMenuState.Workcell firstCell = workcell("workcell/spatial", first); + JigsawStudioMenuState.Workcell secondCell = workcell("workcell/spatial/demo/cross", second); + JigsawStudioMenuState twoCells = state(JigsawStudioMode.SPATIAL_JIGSAW, List.of(firstCell, secondCell)); + JigsawStudioMenuState oneCell = state(JigsawStudioMode.SPATIAL_JIGSAW, List.of(firstCell)); + + assertTrue(twoCells.canDeleteVariant(firstCell, first)); + assertFalse(oneCell.canDeleteVariant(firstCell, first)); + List tools = JigsawStudioToolboxMenu.toolboxTools(twoCells, firstCell); + assertTrue(tools.stream().anyMatch(tool -> tool.payload().action() == JigsawStudioToolAction.DELETE_VARIANT + && tool.payload().pieceKey().equals("demo/end"))); + } + + @Test + public void planarVariantsStillRequireAnotherLoadedVariantFirst() { + JigsawStudioMenuState.Variant loaded = variant("demo/end", true, true); + JigsawStudioMenuState.Variant spare = variant("demo/end-b", false, true); + JigsawStudioMenuState.Variant readOnly = variant("demo/end-c", false, false); + JigsawStudioMenuState.Workcell cell = new JigsawStudioMenuState.Workcell( + "workcell/end", + "End Cap", + "End Cap", + new JigsawStudioCellDimensions(16, 16, 16), + true, + loaded.pieceKey(), + false, + false, + false, + false, + List.of(loaded, spare, readOnly)); + JigsawStudioMenuState state = state(JigsawStudioMode.PLANAR_JIGSAW, List.of(cell)); + + assertFalse(state.canDeleteVariant(cell, loaded)); + assertTrue(state.canDeleteVariant(cell, spare)); + assertFalse(state.canDeleteVariant(cell, readOnly)); + } + + private static JigsawStudioMenuState state(JigsawStudioMode mode, List workcells) { + return new JigsawStudioMenuState( + WORLD, + REQUEST, + "demo", + mode, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + false, + List.of(new JigsawStudioMenuState.ThemeSet("variant-1", 1)), + workcells.getFirst().stableId(), + JigsawStudioMenuState.Evaluation.pending(), + workcells); + } + + private static JigsawStudioMenuState.Workcell workcell(String stableId, JigsawStudioMenuState.Variant active) { + return new JigsawStudioMenuState.Workcell( + stableId, + "Spatial", + active.displayName(), + new JigsawStudioCellDimensions(15, 15, 15), + true, + active.pieceKey(), + false, + false, + false, + false, + List.of(active)); + } + + private static JigsawStudioMenuState.Variant variant(String pieceKey, boolean active, boolean owned) { + return new JigsawStudioMenuState.Variant( + pieceKey, + pieceKey.substring(pieceKey.lastIndexOf('/') + 1), + Optional.of(new JigsawStudioCellDimensions(15, 15, 15)), + active, + owned, + true, + owned, + active, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + } +}