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
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-28 17:14:14 -04:00
1 parent b3579e623a
commit 7d73c4d150
56 files changed
+4869 -537

No files matched your search

@@ -23,6 +23,7 @@ import art.arcane.iris.structure.authoring.StructureKey;
import art.arcane.iris.structure.authoring.StructureWriteResult; import art.arcane.iris.structure.authoring.StructureWriteResult;
import art.arcane.iris.structure.export.VanillaJigsawExportFormat; import art.arcane.iris.structure.export.VanillaJigsawExportFormat;
import art.arcane.iris.structure.export.VanillaJigsawExportRequest; 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.export.VanillaJigsawExportSource;
import art.arcane.iris.structure.graph.PlanarJigsawWorkcellResolver; import art.arcane.iris.structure.graph.PlanarJigsawWorkcellResolver;
import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimension;
@@ -437,13 +438,14 @@ public class CommandJigsaw implements DirectorExecutor {
} }
VanillaJigsawExportRequest request; VanillaJigsawExportRequest request;
try { try {
request = VanillaJigsawExportRequest.builder( VanillaJigsawExportSource source = VanillaJigsawExportSource.forData(
VanillaJigsawExportSource.forData( context.request().source(), context.request().structureKey());
context.request().source(), context.request().structureKey()), destination) request = VanillaJigsawExportRequest.builder(source, destination)
.namespace(namespace) .namespace(namespace)
.resourcePath(context.request().structureKey()) .resourcePath(context.request().structureKey())
.format(exportFormat) .format(exportFormat)
.replaceExisting(replace) .replaceExisting(replace)
.settings(VanillaJigsawExportSettings.runtimeDefaults(source.loadStructure()))
.build(); .build();
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
sendError(exception.getMessage()); sendError(exception.getMessage());
@@ -1,5 +1,8 @@
package art.arcane.iris.structure.export; 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.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@@ -41,6 +44,13 @@ public final class VanillaJigsawExportSettings {
return builder().build(); 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<String> biomes() { public List<String> biomes() {
return biomes; return biomes;
} }
@@ -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.") @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"; 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; 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.") @Description("Signed priority assigned to a child piece attached through this connector. Higher-priority child pieces expand first; equal priorities preserve attachment order.")
@@ -4,7 +4,7 @@ import art.arcane.volmlib.util.documentation.Description;
@Description("The orientation-independent connector shape represented by a planar jigsaw workcell.") @Description("The orientation-independent connector shape represented by a planar jigsaw workcell.")
public enum IrisJigsawWorkcellArchetype { 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), BLANK(0),
@Description("One-connector dead-end pieces; the Studio bay seeds a single north connector and new projects mark these as terminal caps.") @Description("One-connector dead-end pieces; the Studio bay seeds a single north connector and new projects mark these as terminal caps.")
@@ -218,7 +218,7 @@ public final class StructureAssembler {
+ "' references missing connector pool '" + connector.pool + "'"); + "' references missing connector pool '" + connector.pool + "'");
} }
AttachmentResult attachment = attachOne( AttachmentResult attachment = attach(
pending, pending,
placed, placed,
placementCounts, placementCounts,
@@ -229,7 +229,9 @@ public final class StructureAssembler {
depth, depth,
selectedTheme, selectedTheme,
rng); rng);
consume(consumedConnectors, connector.sourcePiece(), connector.sourceConnector()); if (attachment.state() != AttachmentState.TERMINATED) {
consume(consumedConnectors, connector.sourcePiece(), connector.sourceConnector());
}
if (attachment.state() == AttachmentState.FAILED) { if (attachment.state() == AttachmentState.FAILED) {
return StructureAssemblyResult.failed( return StructureAssemblyResult.failed(
StructureAssemblyStatus.FAILED_UNCAPPED, StructureAssemblyStatus.FAILED_UNCAPPED,
@@ -250,6 +252,49 @@ public final class StructureAssembler {
return StructureAssemblyResult.complete(placed, selectedTheme); return StructureAssemblyResult.complete(placed, selectedTheme);
} }
private AttachmentResult attach(
Deque<PieceExpansion> pending,
KList<PlacedStructurePiece> placed,
Map<String, Integer> placementCounts,
Map<PlacedStructurePiece, Set<IrisJigsawConnector>> consumedConnectors,
Map<PlacedStructurePiece, Map<IrisJigsawConnector, OpenConnector>> 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( private AttachmentResult attachOne(
Deque<PieceExpansion> pending, Deque<PieceExpansion> pending,
KList<PlacedStructurePiece> placed, KList<PlacedStructurePiece> placed,
@@ -260,6 +305,7 @@ public final class StructureAssembler {
OpenConnector connector, OpenConnector connector,
int depth, int depth,
String selectedTheme, String selectedTheme,
boolean neighborClosures,
RNG rng RNG rng
) { ) {
if (pool.getPieces() == null) { if (pool.getPieces() == null) {
@@ -281,6 +327,7 @@ public final class StructureAssembler {
selectedTheme, selectedTheme,
false, false,
!fallbackRequired, !fallbackRequired,
neighborClosures,
rng); rng);
if (primary != AttachmentState.NO_MATCH) { if (primary != AttachmentState.NO_MATCH) {
return AttachmentResult.success(primary); return AttachmentResult.success(primary);
@@ -322,6 +369,7 @@ public final class StructureAssembler {
selectedTheme, selectedTheme,
fallbackRequired, fallbackRequired,
!fallbackRequired, !fallbackRequired,
neighborClosures,
rng); rng);
if (fallbackState != AttachmentState.NO_MATCH) { if (fallbackState != AttachmentState.NO_MATCH) {
return AttachmentResult.success(fallbackState); return AttachmentResult.success(fallbackState);
@@ -352,9 +400,14 @@ public final class StructureAssembler {
String selectedTheme, String selectedTheme,
boolean terminalOnly, boolean terminalOnly,
boolean allowEmpty, boolean allowEmpty,
boolean neighborClosures,
RNG rng RNG rng
) { ) {
int ruleDepth = Math.min(connector.pieceDepth, structure.getMaxDepth()); 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( for (IrisJigsawPieceEntry entry : weightedOrder(
pool, pool,
selectedTheme, selectedTheme,
@@ -391,21 +444,16 @@ public final class StructureAssembler {
requireConnector(pieceConnector, pieceName); requireConnector(pieceConnector, pieceName);
} }
List<IrisJigsawConnector> candidateConnectors = orderedConnectors(piece.getConnectors(), null); List<IrisJigsawConnector> candidateConnectors = orderedConnectors(piece.getConnectors(), null);
for (IrisJigsawConnector cb : candidateConnectors) { int[] rotations = piece.isRotatable() ? shuffledRotations(rng) : NO_ROTATION;
if (connector.targetName == null) { for (int yDeg : rotations) {
throw assemblyFailure("open connector from pool '" + connector.pool + "' has no target name"); IrisObjectRotation rot = IrisObjectRotation.of(0, yDeg, 0);
} for (IrisJigsawConnector cb : candidateConnectors) {
if (!Objects.equals(connector.channel, cb.getChannel())) { if (!Objects.equals(connector.channel, cb.getChannel())) {
continue; continue;
} }
if (!cb.getName().equals(connector.targetName)) { if (!cb.getName().equals(connector.targetName)) {
continue; 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);
IrisDirection rotatedFace = rot.rotate(cb.getDirection()); IrisDirection rotatedFace = rot.rotate(cb.getDirection());
if (rotatedFace != needed) { if (rotatedFace != needed) {
continue; continue;
@@ -431,7 +479,8 @@ public final class StructureAssembler {
consumedConnectors, consumedConnectors,
connector, connector,
candidate, candidate,
cb); cb,
neighborClosures);
if (closures == null) { if (closures == null) {
continue; continue;
} }
@@ -504,9 +553,10 @@ public final class StructureAssembler {
Map<PlacedStructurePiece, Set<IrisJigsawConnector>> consumedConnectors, Map<PlacedStructurePiece, Set<IrisJigsawConnector>> consumedConnectors,
OpenConnector attachmentSource, OpenConnector attachmentSource,
PlacedStructurePiece candidatePiece, PlacedStructurePiece candidatePiece,
IrisJigsawConnector attachmentConnector IrisJigsawConnector attachmentConnector,
boolean neighborClosures
) { ) {
if (!structure.isRequireCaps()) { if (!neighborClosures) {
return List.of(); return List.of();
} }
List<ConnectorClosure> closures = new ArrayList<>(); List<ConnectorClosure> closures = new ArrayList<>();
@@ -1025,10 +1075,7 @@ public final class StructureAssembler {
return false; return false;
} }
private int[] rotationCandidates(JigsawJoint joint, RNG rng) { private int[] shuffledRotations(RNG rng) {
if (joint == JigsawJoint.ALIGNED) {
return Y_DEGREES;
}
int[] shuffled = {0, 90, 180, 270}; int[] shuffled = {0, 90, 180, 270};
for (int i = shuffled.length - 1; i > 0; i--) { for (int i = shuffled.length - 1; i > 0; i--) {
int j = rng.i(i + 1); int j = rng.i(i + 1);
@@ -1,12 +1,11 @@
package art.arcane.iris.studio.generation; 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.JigsawStudioActivation;
import art.arcane.iris.studio.jigsaw.JigsawStudioBay; import art.arcane.iris.studio.jigsaw.JigsawStudioBay;
import art.arcane.iris.studio.jigsaw.JigsawStudioBounds; import art.arcane.iris.studio.jigsaw.JigsawStudioBounds;
import art.arcane.iris.studio.jigsaw.JigsawStudioCellDimensions; import art.arcane.iris.studio.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.studio.jigsaw.JigsawStudioControlPosition; 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.JigsawStudioLayout;
import art.arcane.iris.studio.jigsaw.JigsawStudioSession; import art.arcane.iris.studio.jigsaw.JigsawStudioSession;
import art.arcane.iris.studio.jigsaw.JigsawStudioVariant; import art.arcane.iris.studio.jigsaw.JigsawStudioVariant;
@@ -51,9 +50,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
private final NativeBlockState lightFloor; private final NativeBlockState lightFloor;
private final NativeBlockState darkFloor; private final NativeBlockState darkFloor;
private final NativeBlockState frame; private final NativeBlockState frame;
private final NativeBlockState topologyBase; private final FloorPalette floorPalette;
private final NativeBlockState topologyPath;
private final NativeBlockState connectorCap;
private final NativeBlockState invalidMarker; private final NativeBlockState invalidMarker;
private final NativeBlockState controlChest; private final NativeBlockState controlChest;
private final FinalStateRenderer finalStateRenderer; private final FinalStateRenderer finalStateRenderer;
@@ -79,9 +76,13 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
B.getState("minecraft:smooth_stone"), B.getState("minecraft:smooth_stone"),
B.getState("minecraft:polished_deepslate"), B.getState("minecraft:polished_deepslate"),
B.getState("minecraft:white_concrete"), B.getState("minecraft:white_concrete"),
B.getState("minecraft:light_gray_wool"), new FloorPalette(
B.getState("minecraft:red_wool"), B.getState("minecraft:light_gray_wool"),
B.getState("minecraft:sea_lantern"), 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:red_stained_glass"),
B.getState("minecraft:chest[facing=south,type=single,waterlogged=false]"), B.getState("minecraft:chest[facing=south,type=single,waterlogged=false]"),
JigsawStudioGenerator::rotatedFinalState JigsawStudioGenerator::rotatedFinalState
@@ -95,9 +96,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
NativeBlockState lightFloor, NativeBlockState lightFloor,
NativeBlockState darkFloor, NativeBlockState darkFloor,
NativeBlockState frame, NativeBlockState frame,
NativeBlockState topologyBase, FloorPalette floorPalette,
NativeBlockState topologyPath,
NativeBlockState connectorCap,
NativeBlockState invalidMarker, NativeBlockState invalidMarker,
NativeBlockState controlChest, NativeBlockState controlChest,
FinalStateRenderer finalStateRenderer FinalStateRenderer finalStateRenderer
@@ -108,9 +107,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
this.lightFloor = Objects.requireNonNull(lightFloor, "Jigsaw Studio light floor block"); this.lightFloor = Objects.requireNonNull(lightFloor, "Jigsaw Studio light floor block");
this.darkFloor = Objects.requireNonNull(darkFloor, "Jigsaw Studio dark floor block"); this.darkFloor = Objects.requireNonNull(darkFloor, "Jigsaw Studio dark floor block");
this.frame = Objects.requireNonNull(frame, "Jigsaw Studio frame block"); this.frame = Objects.requireNonNull(frame, "Jigsaw Studio frame block");
this.topologyBase = Objects.requireNonNull(topologyBase, "Jigsaw Studio topology base block"); this.floorPalette = Objects.requireNonNull(floorPalette, "Jigsaw Studio floor palette");
this.topologyPath = Objects.requireNonNull(topologyPath, "Jigsaw Studio topology path block");
this.connectorCap = Objects.requireNonNull(connectorCap, "Jigsaw Studio connector cap block");
this.invalidMarker = Objects.requireNonNull(invalidMarker, "Jigsaw Studio invalid marker block"); this.invalidMarker = Objects.requireNonNull(invalidMarker, "Jigsaw Studio invalid marker block");
this.controlChest = Objects.requireNonNull(controlChest, "Jigsaw Studio control chest block"); this.controlChest = Objects.requireNonNull(controlChest, "Jigsaw Studio control chest block");
this.finalStateRenderer = Objects.requireNonNull( this.finalStateRenderer = Objects.requireNonNull(
@@ -165,6 +162,21 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
return renderedBays.computeIfAbsent(key, ignored -> buildRenderedBay(activeWorkcell, variant)); 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) { public void invalidateRender(String workcellId) {
if (workcellId != null) { if (workcellId != null) {
renderedBays.keySet().removeIf(key -> key.workcellId().equals(workcellId)); renderedBays.keySet().removeIf(key -> key.workcellId().equals(workcellId));
@@ -215,10 +227,8 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
continue; continue;
} }
paintCage(terrainChunk, workcell, chunkWorldX, chunkWorldZ); paintCage(terrainChunk, workcell, chunkWorldX, chunkWorldZ);
if (workcell.archetype().isPresent()) {
paintTopologyGlyph(terrainChunk, workcell, chunkWorldX, chunkWorldZ);
}
RenderedBay renderedBay = renderBay(workcell); RenderedBay renderedBay = renderBay(workcell);
paintFloorGlyph(terrainChunk, workcell, glyphFor(renderedBay), chunkWorldX, chunkWorldZ);
if (!renderedBay.valid()) { if (!renderedBay.valid()) {
paintInvalidBay(terrainChunk, workcell, chunkWorldX, chunkWorldZ); paintInvalidBay(terrainChunk, workcell, chunkWorldX, chunkWorldZ);
continue; continue;
@@ -345,85 +355,44 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
} }
} }
private void paintTopologyGlyph( private void paintFloorGlyph(
TerrainChunk terrainChunk, TerrainChunk terrainChunk,
JigsawStudioBay workcell, JigsawStudioBay workcell,
JigsawStudioFloorGlyph glyph,
int chunkWorldX, int chunkWorldX,
int chunkWorldZ int chunkWorldZ
) { ) {
JigsawPlanarTopology topology = workcell.topology().orElseThrow();
JigsawStudioBounds bounds = workcell.bounds(); JigsawStudioBounds bounds = workcell.bounds();
int minimumX = Math.max(bounds.originX(), chunkWorldX); int minimumX = Math.max(bounds.originX(), chunkWorldX);
int maximumX = Math.min(bounds.maxX(), chunkWorldX + 15); int maximumX = Math.min(bounds.maxX(), chunkWorldX + 15);
int minimumZ = Math.max(bounds.originZ(), chunkWorldZ); int minimumZ = Math.max(bounds.originZ(), chunkWorldZ);
int maximumZ = Math.min(bounds.maxZ(), chunkWorldZ + 15); int maximumZ = Math.min(bounds.maxZ(), chunkWorldZ + 15);
if (minimumX > maximumX || minimumZ > maximumZ) { int floorY = bounds.originY() - 1;
return;
}
int glyphY = bounds.originY() - 1;
int centerX = bounds.dimensions().width() / 2;
int centerZ = bounds.dimensions().depth() / 2;
for (int worldX = minimumX; worldX <= maximumX; worldX++) { for (int worldX = minimumX; worldX <= maximumX; worldX++) {
int localX = worldX - bounds.originX();
for (int worldZ = minimumZ; worldZ <= maximumZ; worldZ++) { for (int worldZ = minimumZ; worldZ <= maximumZ; worldZ++) {
int localZ = worldZ - bounds.originZ(); NativeBlockState state = floorState(glyph.cell(
NativeBlockState state = topologyGlyphState(topology, bounds.dimensions(), localX, localZ, worldX - bounds.originX(),
centerX, centerZ); worldZ - bounds.originZ()));
setWorldBlock(terrainChunk, worldX, glyphY, worldZ, state, chunkWorldX, chunkWorldZ); setWorldBlock(terrainChunk, worldX, floorY, worldZ, state, chunkWorldX, chunkWorldZ);
} }
} }
} }
private NativeBlockState topologyGlyphState( private static JigsawStudioFloorGlyph glyphFor(RenderedBay renderedBay) {
JigsawPlanarTopology topology, if (!renderedBay.valid() || renderedBay.connectors().isEmpty()) {
JigsawStudioCellDimensions dimensions, return JigsawStudioFloorGlyph.empty();
int localX,
int localZ,
int centerX,
int centerZ
) {
if (isConnectorCap(topology, dimensions, localX, localZ, centerX, centerZ)) {
return connectorCap;
} }
if (isTopologyPath(topology, localX, localZ, centerX, centerZ)) { List<JigsawStudioFloorGlyph.Marker> markers = new ArrayList<>(renderedBay.connectors().size());
return topologyPath; for (RenderedConnector connector : renderedBay.connectors()) {
markers.add(new JigsawStudioFloorGlyph.Marker(
connector.x(),
connector.z(),
connector.connector().getDirection()));
} }
return topologyBase; return JigsawStudioFloorGlyph.of(
} renderedBay.dimensions().width(),
renderedBay.dimensions().depth(),
static boolean isConnectorCap( markers);
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;
} }
private RenderedBay buildRenderedBay(JigsawStudioBay workcell, JigsawStudioVariant variant) { private RenderedBay buildRenderedBay(JigsawStudioBay workcell, JigsawStudioVariant variant) {
@@ -720,7 +689,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
private JigsawStudioBay requireWorkcell(JigsawStudioBay workcell) { private JigsawStudioBay requireWorkcell(JigsawStudioBay workcell) {
JigsawStudioBay activeWorkcell = Objects.requireNonNull(workcell, "Jigsaw Studio 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"); throw new IllegalArgumentException("Workcell does not belong to this Jigsaw Studio layout");
} }
return activeWorkcell; 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 record RenderedBlock(int x, int y, int z, NativeBlockState state, TileData tileData) {
public RenderedBlock { public RenderedBlock {
state = Objects.requireNonNull(state, "Jigsaw Studio rendered block state"); state = Objects.requireNonNull(state, "Jigsaw Studio rendered block state");
@@ -141,6 +141,10 @@ final class JigsawStudioCapture {
if (hiddenConnector != null) { if (hiddenConnector != null) {
hiddenConnector.setFinalState(blockData.getAsString()); hiddenConnector.setFinalState(blockData.getAsString());
connectors.add(CapturedConnector.from(hiddenConnector)); 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) { if (hiddenConnector == null && blockData instanceof Jigsaw jigsaw) {
KMap<String, Object> nbt = BukkitPlatform.serializeTile(block.getLocation()); KMap<String, Object> nbt = BukkitPlatform.serializeTile(block.getLocation());
@@ -490,7 +494,7 @@ final class JigsawStudioCapture {
}; };
} }
private static LocalPosition forwardPosition( static LocalPosition forwardPosition(
int x, int x,
int y, int y,
int z, int z,
@@ -18,6 +18,7 @@ import art.arcane.iris.structure.object.IrisObject;
import art.arcane.iris.studio.generation.JigsawStudioGenerator; import art.arcane.iris.studio.generation.JigsawStudioGenerator;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.task.J; import art.arcane.iris.world.task.J;
import org.bukkit.Bukkit;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@@ -122,7 +123,7 @@ final class JigsawStudioCaptureScheduler {
} }
coordinator.accept(snapshot); coordinator.accept(snapshot);
} catch (Throwable exception) { } 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); + area.chunkX() + "," + area.chunkZ() + ": " + failureMessage(exception), exception);
} }
} }
@@ -219,7 +220,7 @@ final class JigsawStudioCaptureScheduler {
if (!result.successful()) { if (!result.successful()) {
String failure = writeFailure(result); String failure = writeFailure(result);
coordinator.recordPersistentFailure(failure, result.failure().orElse(null)); coordinator.recordPersistentFailure(failure, result.failure().orElse(null));
message(player, failure); message(coordinator.notifiedPlayer(), failure);
return; return;
} }
source.invalidateStructureResources(); source.invalidateStructureResources();
@@ -234,13 +235,14 @@ final class JigsawStudioCaptureScheduler {
+ "Close and reopen this project before continuing: " + "Close and reopen this project before continuing: "
+ failureMessage(refreshFailure)); + failureMessage(refreshFailure));
} }
service.floorPainter.refresh(studio, bay.stableId());
String cleanup = result.status() == StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED String cleanup = result.status() == StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED
? " The graph committed, but transaction cleanup requires operator attention in the console." ? " The graph committed, but transaction cleanup requires operator attention in the console."
: ""; : "";
String mutationNotice = unchanged ? "" : " Newer edits remain unsaved."; String mutationNotice = unchanged ? "" : " Newer edits remain unsaved.";
message(player, "Saved piece '" + coordinator.saveIdentity().variantKey() + "' and object '" message(player, "Saved piece '" + coordinator.saveIdentity().variantKey() + "' and object '"
+ assembly.objectKey() + "' atomically." + mutationNotice + cleanup); + assembly.objectKey() + "' atomically." + mutationNotice + cleanup);
playSaveSound(player); playSaveSound(coordinator.notifiedPlayer());
if (unchanged) { if (unchanged) {
service.evaluator.scheduleEvaluation(studio); service.evaluator.scheduleEvaluation(studio);
} }
@@ -250,12 +252,19 @@ final class JigsawStudioCaptureScheduler {
} catch (Throwable exception) { } catch (Throwable exception) {
String failure = "Jigsaw Studio save failed: " + failureMessage(exception); String failure = "Jigsaw Studio save failed: " + failureMessage(exception);
coordinator.recordPersistentFailure(failure, exception); coordinator.recordPersistentFailure(failure, exception);
message(player, failure); message(coordinator.notifiedPlayer(), failure);
} finally { } finally {
coordinator.complete(); coordinator.complete();
} }
} }
static Player notificationTarget(Player player, UUID ownerId) {
if (player != null || ownerId == null) {
return player;
}
return Bukkit.getPlayer(ownerId);
}
static Set<Long> requiredChunks( static Set<Long> requiredChunks(
JigsawStudioBay bay, JigsawStudioBay bay,
JigsawStudioGenerator.RenderedBay rendered JigsawStudioGenerator.RenderedBay rendered
@@ -368,6 +377,10 @@ final class JigsawStudioCaptureScheduler {
return work.player(); return work.player();
} }
private Player notifiedPlayer() {
return notificationTarget(work.player(), work.studio().generator().getRequest().ownerId());
}
private UUID requestId() { private UUID requestId() {
return work.requestId(); return work.requestId();
} }
@@ -427,7 +440,7 @@ final class JigsawStudioCaptureScheduler {
} }
recordPersistentFailure(failure, exception); recordPersistentFailure(failure, exception);
complete(); complete();
message(player(), failure); message(notifiedPlayer(), failure);
} }
private void recordPersistentFailure(String failure, Throwable exception) { private void recordPersistentFailure(String failure, Throwable exception) {
@@ -40,58 +40,84 @@ final class JigsawStudioChunkWriter {
private JigsawStudioChunkWriter() { private JigsawStudioChunkWriter() {
} }
static void restoreConnectorChunk( static void restoreChunkPositions(
World world, World world,
JigsawStudioBay workcell, JigsawStudioBay workcell,
List<JigsawStudioGenerator.RenderedConnector> connectors, Set<LocalPosition> positions,
Map<LocalPosition, JigsawStudioGenerator.RenderedConnector> renderedConnectors,
Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> renderedBlocks, Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> renderedBlocks,
boolean connectorsVisible boolean connectorsVisible
) throws IOException { ) throws IOException {
JigsawStudioBounds bounds = workcell.bounds(); JigsawStudioBounds bounds = workcell.bounds();
for (JigsawStudioGenerator.RenderedConnector connector : connectors) { BlockData air = null;
LocalPosition position = new LocalPosition(connector.x(), connector.y(), connector.z()); for (LocalPosition position : positions) {
Block target = world.getBlockAt( Block target = world.getBlockAt(
bounds.originX() + connector.x(), bounds.originX() + position.x(),
bounds.originY() + connector.y(), bounds.originY() + position.y(),
bounds.originZ() + connector.z()); bounds.originZ() + position.z());
if (connectorsVisible) { JigsawStudioGenerator.RenderedConnector connector = renderedConnectors.get(position);
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;
}
JigsawStudioGenerator.RenderedBlock renderedBlock = renderedBlocks.get(position); JigsawStudioGenerator.RenderedBlock renderedBlock = renderedBlocks.get(position);
BlockData restored; if (connector != null) {
if (renderedBlock != null) { restoreConnector(target, connector, renderedBlock, connectorsVisible);
if (renderedBlock.state().isCustom() } else if (renderedBlock != null) {
|| !(renderedBlock.state().nativeHandle() instanceof BlockData blockData)) { restoreRenderedBlock(target, renderedBlock);
throw new IOException("Saved connector block '" + renderedBlock.state().key()
+ "' cannot be restored directly in Bukkit Studio");
}
restored = blockData;
} else { } else {
try { if (air == null) {
restored = Bukkit.createBlockData(connector.connector().getFinalState()); air = Material.AIR.createBlockData();
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid saved connector final state '"
+ connector.connector().getFinalState() + "'", exception);
} }
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)) { private static void restoreConnector(
throw new IOException("Saved connector tile data could not be restored at " Block target,
+ target.getX() + "," + target.getY() + "," + target.getZ()); 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());
} }
} }
@@ -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<JigsawPlanarDirection> SIDE_ORDER = List.of(
JigsawPlanarDirection.NORTH,
JigsawPlanarDirection.EAST,
JigsawPlanarDirection.SOUTH,
JigsawPlanarDirection.WEST);
private JigsawStudioConnectorPlacement() {
}
static Optional<Target> target(
Box box,
Ray ray,
Optional<BlockClick> click,
Set<Cell> 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<Target> 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<LocalPosition, IrisDirection> connectors,
Optional<LocalPosition> selection
) {
boolean occupied = connectors.containsKey(target);
Optional<LocalPosition> 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<LocalPosition, IrisDirection> 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<JigsawPlanarDirection> 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<JigsawPlanarDirection> 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<IrisJigsawConnector> 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<IrisJigsawConnector> add(
List<IrisJigsawConnector> 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<IrisJigsawConnector> updated = copy(connectors);
updated.add(added);
return List.copyOf(updated);
}
static List<IrisJigsawConnector> move(
List<IrisJigsawConnector> 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<IrisJigsawConnector> 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<IrisJigsawConnector> remove(
List<IrisJigsawConnector> connectors,
Frame frame,
LocalPosition at
) {
int index = indexAt(connectors, frame, at);
if (index < 0) {
throw new IllegalArgumentException("No connector exists at " + describe(at));
}
List<IrisJigsawConnector> updated = copy(connectors);
updated.remove(index);
return List.copyOf(updated);
}
static ReseatResult reseat(
List<IrisJigsawConnector> 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<IrisJigsawConnector> 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<IrisJigsawConnector> 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<LocalPosition> changedPositions(
List<IrisJigsawConnector> before,
List<IrisJigsawConnector> after,
Frame frame
) {
Map<LocalPosition, CapturedConnector> previous = displayed(before, frame);
Map<LocalPosition, CapturedConnector> current = displayed(after, frame);
Set<LocalPosition> changed = new HashSet<>();
for (Map.Entry<LocalPosition, CapturedConnector> entry : previous.entrySet()) {
if (!entry.getValue().equals(current.get(entry.getKey()))) {
changed.add(entry.getKey());
}
}
for (Map.Entry<LocalPosition, CapturedConnector> 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<LocalPosition, CapturedConnector> displayed(List<IrisJigsawConnector> connectors, Frame frame) {
Map<LocalPosition, CapturedConnector> displayed = new HashMap<>(connectors.size());
for (IrisJigsawConnector connector : connectors) {
displayed.put(frame.display(connector.getPosition()), CapturedConnector.from(connector));
}
return displayed;
}
private static List<IrisJigsawConnector> copy(List<IrisJigsawConnector> connectors) {
List<IrisJigsawConnector> 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<CellContent> 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<LocalPosition, IrisDirection> connectors, IrisDirection facing) {
int count = 0;
for (IrisDirection direction : connectors.values()) {
if (direction == facing) {
count++;
}
}
return count;
}
private static Optional<JigsawPlanarDirection> 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<String> 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<Target> connectorTarget(Ray ray, Set<Cell> 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<Target> 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<Target> 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<CellContent> at(IrisPosition source);
}
record ReseatResult(List<IrisJigsawConnector> connectors, boolean changed, int removedStrays) {
ReseatResult {
connectors = List.copyOf(connectors);
}
}
}
@@ -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<UUID, Selection> selections = new ConcurrentHashMap<>();
final Map<UUID, PendingClick> 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> feedback(Player player, ActiveStudio studio, boolean holdingTool) {
Optional<Cell> selected = selectedCell(player, studio);
Optional<Hover> hover = Optional.empty();
if (holdingTool) {
RayTraceResult trace = player.rayTraceBlocks(HOVER_REACH);
Optional<BlockClick> 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<Cell> 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<BlockClick> 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<LocalPosition, IrisDirection> connectors = displayedConnectors(rendered);
Set<Cell> cells = new HashSet<>(connectors.size());
for (LocalPosition position : connectors.keySet()) {
cells.add(box.world(position));
}
Optional<Target> 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<LocalPosition, IrisDirection> connectors = displayedConnectors(rendered);
Optional<LocalPosition> 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<IrisJigsawConnector> before = List.copyOf(piece.getConnectors());
LocalPosition target = edit.click().target();
IrisDirection facing = edit.click().facing();
IrisDirection top = edit.click().top();
List<IrisJigsawConnector> 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<LocalPosition, JigsawStudioGenerator.RenderedConnector> connectors = new HashMap<>();
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
connectors.put(new LocalPosition(connector.x(), connector.y(), connector.z()), connector);
}
Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> 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<Long, Set<LocalPosition>> 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<String> failure = new AtomicReference<>("");
AtomicReference<Throwable> cause = new AtomicReference<>();
for (Map.Entry<Long, Set<LocalPosition>> 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<LocalPosition> 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<IrisJigsawConnector> 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> 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<BlockClick> 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> 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<LocalPosition, IrisDirection> displayedConnectors(JigsawStudioGenerator.RenderedBay rendered) {
Map<LocalPosition, IrisDirection> 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<Cell> selected, Optional<Hover> hover) {
}
private record Resolution(Optional<ToolClick> 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<LocalPosition> selection) {
}
private record EditCommit(
boolean changed,
JigsawStudioLayout layout,
Set<LocalPosition> changedPositions,
String summary,
Optional<Selection> selection,
Optional<LocalPosition> removed
) {
private static EditCommit unchanged(String summary) {
return new EditCommit(false, null, Set.of(), summary, Optional.empty(), Optional.empty());
}
}
}
@@ -158,8 +158,11 @@ final class JigsawStudioDetailsMenu {
} }
window.setElement(2, 0, size); window.setElement(2, 0, size);
boolean lastVariant = workcell.variants().size() <= 1; boolean spatial = state.mode() == JigsawStudioMode.SPATIAL_JIGSAW;
boolean deletionAvailable = variant.owned() && !variant.active() && !lastVariant; 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( boolean confirmed = deletionAvailable && controller.isDeleteConfirmed(
window.getViewer().getUniqueId(), window.getViewer().getUniqueId(),
state.requestId(), state.requestId(),
@@ -170,11 +173,11 @@ final class JigsawStudioDetailsMenu {
deletionAvailable ? confirmed ? Material.REDSTONE : Material.LAVA_BUCKET : Material.BARRIER, deletionAvailable ? confirmed ? Material.REDSTONE : Material.LAVA_BUCKET : Material.BARRIER,
deletionAvailable deletionAvailable
? confirmed ? ChatColor.RED + "Confirm Delete Variant" : ChatColor.RED + "Delete Variant" ? confirmed ? ChatColor.RED + "Confirm Delete Variant" : ChatColor.RED + "Delete Variant"
: variant.active() : !variant.owned()
? ChatColor.GRAY + "Loaded Variant Cannot Be Deleted" ? ChatColor.GRAY + "Deletion is Read-only"
: lastVariant : lastVariant
? ChatColor.GRAY + "Last Variant Cannot Be Deleted" ? ChatColor.GRAY + "Last Variant Cannot Be Deleted"
: ChatColor.GRAY + "Deletion is Read-only") : ChatColor.GRAY + "Loaded Variant Cannot Be Deleted")
.setEnchanted(confirmed); .setEnchanted(confirmed);
if (deletionAvailable) { if (deletionAvailable) {
delete.addLore(confirmed delete.addLore(confirmed
@@ -186,10 +189,10 @@ final class JigsawStudioDetailsMenu {
workcell.stableId(), workcell.stableId(),
variant.pieceKey(), variant.pieceKey(),
page)); page));
} else if (variant.active()) { } else if (variant.owned() && lastVariant) {
delete.addLore(ChatColor.GRAY + "Load another variant in this workcell first.");
} else if (lastVariant) {
delete.addLore(ChatColor.GRAY + "Create another variant before deleting this one."); 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); window.setElement(4, 0, delete);
} }
@@ -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<Long, Cell> cells;
private JigsawStudioFloorGlyph(int width, int depth, Map<Long, Cell> 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<Marker> markers) {
List<Marker> sourceMarkers = List.copyOf(Objects.requireNonNull(markers, "Jigsaw Studio floor markers"));
if (width < 1 || depth < 1 || sourceMarkers.isEmpty()) {
return EMPTY;
}
Map<Long, Cell> 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<Long, Cell> 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<Long, Cell> 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");
}
}
}
@@ -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);
}
}
}
@@ -44,13 +44,7 @@ public final class JigsawStudioGraphMapper {
? JigsawStudioMode.PLANAR_JIGSAW ? JigsawStudioMode.PLANAR_JIGSAW
: JigsawStudioMode.SPATIAL_JIGSAW; : JigsawStudioMode.SPATIAL_JIGSAW;
JigsawStudioVariantCatalog catalog = catalog(data, structure, mode); JigsawStudioVariantCatalog catalog = catalog(data, structure, mode);
IrisPosition configuredCell = structure.getCellSize(); JigsawStudioCellDimensions dimensions = configuredCellDimensions(structure);
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()));
if (mode == JigsawStudioMode.SPATIAL_JIGSAW) { if (mode == JigsawStudioMode.SPATIAL_JIGSAW) {
dimensions = expandSpatialDimensions(data, catalog, dimensions); dimensions = expandSpatialDimensions(data, catalog, dimensions);
return JigsawStudioLayout.createSpatial( return JigsawStudioLayout.createSpatial(
@@ -129,26 +123,54 @@ public final class JigsawStudioGraphMapper {
if (data.getObjectLoader() == null) { if (data.getObjectLoader() == null) {
return configured; return configured;
} }
int width = configured.width(); SpatialExtent extent = SpatialExtent.of(configured);
int height = configured.height();
int depth = configured.depth();
for (JigsawStudioVariant variant : catalog.spatialVariants()) { for (JigsawStudioVariant variant : catalog.spatialVariants()) {
IrisObject object = data.getObjectLoader().load(variant.objectKey()); 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; continue;
} }
int objectWidth = object.getW(); IrisObject object = data.getObjectLoader() == null ? null : data.getObjectLoader().load(variant.objectKey());
int objectDepth = object.getD(); if (object != null) {
if (variant.rotatable()) { extent = extent.include(object.getW(), object.getH(), object.getD(), variant.rotatable());
int horizontalSpan = Math.max(objectWidth, objectDepth);
objectWidth = horizontalSpan;
objectDepth = horizontalSpan;
} }
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( private static void traversePools(
@@ -378,4 +400,25 @@ public final class JigsawStudioGraphMapper {
return new OwnedResources(Set.of(), Set.of(), Set.of(), false); 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));
}
}
} }
@@ -199,6 +199,7 @@ final class JigsawStudioGraphMutations {
for (JigsawStudioBay workcell : result.layout().bays()) { for (JigsawStudioBay workcell : result.layout().bays()) {
service.playerContext.refreshWorkcellContext(studio.worldId(), workcell.stableId()); service.playerContext.refreshWorkcellContext(studio.worldId(), workcell.stableId());
} }
service.floorPainter.refreshAll(studio);
service.evaluator.scheduleEvaluation(studio); service.evaluator.scheduleEvaluation(studio);
message(player, result.message()); message(player, result.message());
finishGraphMutation(requestId); finishGraphMutation(requestId);
@@ -273,15 +274,20 @@ final class JigsawStudioGraphMutations {
String failure, String failure,
Throwable cause Throwable cause
) { ) {
boolean scheduled = J.runEntity( Runnable abandon = () -> {
player,
() -> completeLiveRelayout(player, studio, requestId, result, failure, cause));
if (!scheduled) {
service.saveLifecycle.reopenRequiredRequests.add(requestId); service.saveLifecycle.reopenRequiredRequests.add(requestId);
finishGraphMutation(requestId); finishGraphMutation(requestId);
if (cause != null) { if (cause != null) {
IrisLogging.reportError(cause); IrisLogging.reportError(cause);
} }
};
boolean scheduled = J.runEntity(
player,
() -> completeLiveRelayout(player, studio, requestId, result, failure, cause),
0,
abandon);
if (!scheduled) {
abandon.run();
} }
} }
@@ -15,6 +15,7 @@ public final class JigsawStudioLayout {
public static final int PLANAR_GAP = 1; public static final int PLANAR_GAP = 1;
public static final int MAX_VARIANTS = 512; public static final int MAX_VARIANTS = 512;
public static final String SPATIAL_WORKCELL_ID = "workcell/spatial"; 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 int FIRST_ORIGIN = 16;
private static final JigsawStudioControlPosition CONTROL_POSITION = private static final JigsawStudioControlPosition CONTROL_POSITION =
@@ -208,6 +209,18 @@ public final class JigsawStudioLayout {
return null; 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<JigsawStudioVariant> variants(JigsawStudioBay workcell) { public List<JigsawStudioVariant> variants(JigsawStudioBay workcell) {
JigsawStudioBay activeWorkcell = requireWorkcell(workcell); JigsawStudioBay activeWorkcell = requireWorkcell(workcell);
if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) { if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) {
@@ -276,7 +289,7 @@ public final class JigsawStudioLayout {
private JigsawStudioBay requireWorkcell(JigsawStudioBay workcell) { private JigsawStudioBay requireWorkcell(JigsawStudioBay workcell) {
JigsawStudioBay activeWorkcell = Objects.requireNonNull(workcell, "Jigsaw Studio 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"); throw new IllegalArgumentException("Workcell does not belong to this Jigsaw Studio layout");
} }
return activeWorkcell; return activeWorkcell;
@@ -6,6 +6,7 @@ import art.arcane.volmlib.util.inventorygui.UIWindow;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.Material; import org.bukkit.Material;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; 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; import static art.arcane.iris.studio.jigsaw.JigsawStudioMenuFormat.yesNo;
final class JigsawStudioMainMenu { final class JigsawStudioMainMenu {
static final int WORKCELL_ROW_CAPACITY = 6;
static final int WORKCELLS_PER_PAGE = 5;
private final JigsawStudioMenuController controller; private final JigsawStudioMenuController controller;
JigsawStudioMainMenu(JigsawStudioMenuController controller) { JigsawStudioMainMenu(JigsawStudioMenuController controller) {
@@ -103,51 +107,113 @@ final class JigsawStudioMainMenu {
} }
private void addWorkcells(UIWindow window, JigsawStudioMenuState state, String selectedWorkcellId) { private void addWorkcells(UIWindow window, JigsawStudioMenuState state, String selectedWorkcellId) {
int count = state.workcells().size(); List<JigsawStudioMenuState.Workcell> workcells = state.workcells();
int start = count == 1 ? 0 : -(count / 2); int viewedIndex = 0;
for (int index = 0; index < count; index++) { for (int index = 0; index < workcells.size(); index++) {
JigsawStudioMenuState.Workcell workcell = state.workcells().get(index); if (workcells.get(index).stableId().equals(selectedWorkcellId)) {
boolean selected = workcell.stableId().equals(selectedWorkcellId); viewedIndex = index;
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));
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<Integer> indices = new ArrayList<>(Math.min(count, WORKCELL_ROW_CAPACITY));
List<Integer> 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( private void addVariants(
@@ -283,4 +349,11 @@ final class JigsawStudioMainMenu {
window.getViewer(), state.requestId(), workcell.stableId(), 0)); window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.setElement(4, 5, toolbox); window.setElement(4, 5, toolbox);
} }
record WorkcellRow(List<Integer> indices, List<Integer> slots, int previousTarget, int nextTarget) {
WorkcellRow {
indices = List.copyOf(indices);
slots = List.copyOf(slots);
}
}
} }
@@ -6,7 +6,9 @@ import art.arcane.iris.studio.jigsaw.JigsawStudioService.ActiveStudio;
import art.arcane.iris.studio.generation.JigsawStudioGenerator; import art.arcane.iris.studio.generation.JigsawStudioGenerator;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.task.J; import art.arcane.iris.world.task.J;
import org.bukkit.Material;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.io.IOException; 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.JigsawStudioCapture.chunkIntersections;
import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.hydrateChunk; 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.validateMaterialization;
import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.writeMaterializedChunk; import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.writeMaterializedChunk;
import static art.arcane.iris.studio.jigsaw.JigsawStudioService.chunkKey; 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( boolean scheduleConnectorRepair(
Player player, Player player,
ActiveStudio studio, ActiveStudio studio,
@@ -146,39 +193,55 @@ final class JigsawStudioMaterializer {
JigsawStudioGenerator.RenderedBay rendered, JigsawStudioGenerator.RenderedBay rendered,
boolean connectorsVisible boolean connectorsVisible
) { ) {
Map<Long, List<JigsawStudioGenerator.RenderedConnector>> connectorsByChunk = new HashMap<>();
JigsawStudioBounds bounds = workcell.bounds(); JigsawStudioBounds bounds = workcell.bounds();
JigsawStudioBounds variantBounds = new JigsawStudioBounds(
bounds.originX(),
bounds.originY(),
bounds.originZ(),
rendered.dimensions());
Map<LocalPosition, JigsawStudioGenerator.RenderedConnector> renderedConnectors = new HashMap<>();
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) { for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
int worldX = bounds.originX() + connector.x(); renderedConnectors.put(new LocalPosition(connector.x(), connector.y(), connector.z()), connector);
int worldZ = bounds.originZ() + connector.z();
connectorsByChunk.computeIfAbsent(
chunkKey(worldX >> 4, worldZ >> 4),
ignored -> new ArrayList<>()).add(connector);
} }
AtomicInteger remaining = new AtomicInteger(connectorsByChunk.size());
AtomicReference<String> failure = new AtomicReference<>("");
AtomicReference<Throwable> cause = new AtomicReference<>();
AtomicBoolean scheduledAny = new AtomicBoolean(false);
Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> renderedBlocks = new HashMap<>(); Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> renderedBlocks = new HashMap<>();
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) { for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
renderedBlocks.put(new LocalPosition(block.x(), block.y(), block.z()), block); renderedBlocks.put(new LocalPosition(block.x(), block.y(), block.z()), block);
} }
for (Map.Entry<Long, List<JigsawStudioGenerator.RenderedConnector>> entry List<ChunkCaptureArea> areas = chunkIntersections(variantBounds);
: connectorsByChunk.entrySet()) { AtomicInteger remaining = new AtomicInteger(areas.size());
int chunkX = (int) (entry.getKey() >> 32); AtomicInteger cleared = new AtomicInteger();
int chunkZ = (int) entry.getKey().longValue(); AtomicReference<String> failure = new AtomicReference<>("");
AtomicReference<Throwable> 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, () -> { boolean scheduled = J.runRegion(studio.world(), chunkX, chunkZ, () -> {
try { try {
if (!studio.world().isChunkLoaded(chunkX, chunkZ)) { if (!studio.world().isChunkLoaded(chunkX, chunkZ)) {
throw new IOException("connector chunk " + chunkX + "," + chunkZ throw new IOException("connector chunk " + chunkX + "," + chunkZ
+ " is not loaded"); + " is not loaded");
} }
restoreConnectorChunk( Set<LocalPosition> strays = strayMarkerPositions(
studio.world(),
variantBounds,
area,
renderedConnectors.keySet(),
connectorsVisible);
Set<LocalPosition> positions = new HashSet<>(strays);
for (LocalPosition position : renderedConnectors.keySet()) {
if (area.contains(position.x(), position.z())) {
positions.add(position);
}
}
restoreChunkPositions(
studio.world(), studio.world(),
workcell, workcell,
entry.getValue(), positions,
renderedConnectors,
renderedBlocks, renderedBlocks,
connectorsVisible); connectorsVisible);
cleared.addAndGet(strays.size());
} catch (Throwable exception) { } catch (Throwable exception) {
failure.compareAndSet("", failureMessage(exception)); failure.compareAndSet("", failureMessage(exception));
cause.compareAndSet(null, exception); cause.compareAndSet(null, exception);
@@ -188,6 +251,7 @@ final class JigsawStudioMaterializer {
player, player,
studio, studio,
rendered.connectors().size(), rendered.connectors().size(),
cleared.get(),
failure.get(), failure.get(),
cause.get()); cause.get());
} }
@@ -202,6 +266,7 @@ final class JigsawStudioMaterializer {
player, player,
studio, studio,
rendered.connectors().size(), rendered.connectors().size(),
cleared.get(),
failure.get(), failure.get(),
cause.get()); cause.get());
} }
@@ -213,31 +278,61 @@ final class JigsawStudioMaterializer {
return scheduledAny.get(); return scheduledAny.get();
} }
static Set<LocalPosition> strayMarkerPositions(
World world,
JigsawStudioBounds variantBounds,
ChunkCaptureArea area,
Set<LocalPosition> savedConnectors,
boolean connectorsVisible
) {
Set<LocalPosition> 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( private void completeConnectorRepair(
Player player, Player player,
ActiveStudio studio, ActiveStudio studio,
int connectorCount, int connectorCount,
int clearedMarkers,
String failure, String failure,
Throwable cause Throwable cause
) { ) {
UUID requestId = studio.generator().getRequest().requestId(); UUID requestId = studio.generator().getRequest().requestId();
boolean scheduled = J.runEntity(player, () -> { Runnable release = () -> {
finishMaterialization(requestId); finishMaterialization(requestId);
if (cause != null) { if (cause != null) {
IrisLogging.reportError(cause); IrisLogging.reportError(cause);
} }
};
boolean scheduled = J.runEntity(player, () -> {
release.run();
if (!failure.isEmpty()) { if (!failure.isEmpty()) {
message(player, "Connector reset was incomplete: " + failure + ". Retry after visiting the workcell."); message(player, "Connector reset was incomplete: " + failure + ". Retry after visiting the workcell.");
return; return;
} }
message(player, "Restored " + connectorCount 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) { if (!scheduled) {
finishMaterialization(requestId); release.run();
if (cause != null) {
IrisLogging.reportError(cause);
}
} }
} }
@@ -474,6 +569,7 @@ final class JigsawStudioMaterializer {
try { try {
work.studio().generator().invalidateRender(work.workcell().stableId()); work.studio().generator().invalidateRender(work.workcell().stableId());
work.studio().replacePopulation(work.workcell(), work.target(), "", true); 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()); service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), work.connectorVisibilityChange() message(work.player(), work.connectorVisibilityChange()
? "Connector blocks are now " ? "Connector blocks are now "
@@ -564,6 +660,7 @@ final class JigsawStudioMaterializer {
work.studio().generator().invalidateRender(work.workcell().stableId()); work.studio().generator().invalidateRender(work.workcell().stableId());
if (rollbackFailure.isEmpty() && released) { if (rollbackFailure.isEmpty() && released) {
work.studio().replacePopulation(work.workcell(), work.previous(), "", true); 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()); service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), candidateFailure + " The previous variant was restored."); message(work.player(), candidateFailure + " The previous variant was restored.");
return; return;
@@ -572,6 +669,7 @@ final class JigsawStudioMaterializer {
? "The previous variant could not be restored because the Studio session changed." ? "The previous variant could not be restored because the Studio session changed."
: rollbackFailure; : rollbackFailure;
work.studio().replacePopulation(work.workcell(), work.previous(), failure, false); 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()); service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), candidateFailure + " Automatic rollback also failed: " + failure message(work.player(), candidateFailure + " Automatic rollback also failed: " + failure
+ " Do not save this workcell until it is reopened."); + " 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( public record VariantReloadRequest(
String workcellId, String workcellId,
JigsawStudioGenerator.RenderedBay previous JigsawStudioGenerator.RenderedBay previous
@@ -857,7 +857,7 @@ public final class JigsawStudioMenuController {
} }
JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId);
JigsawStudioMenuState.Variant target = variant(workcell, pieceKey); JigsawStudioMenuState.Variant target = variant(workcell, pieceKey);
if (target == null || !target.owned() || target.active()) { if (!current.get().canDeleteVariant(workcell, target)) {
stale(player); stale(player);
return; return;
} }
@@ -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() { public Workcell selectedWorkcell() {
if (selectedWorkcellId.isEmpty()) { if (selectedWorkcellId.isEmpty()) {
return null; return null;
@@ -59,7 +59,7 @@ final class JigsawStudioPlayerContext {
return; return;
} }
JigsawStudioSession session = studio.generator().getSession(); JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().findAt( JigsawStudioBay workcell = session.layout().findColumn(
target.getBlockX(), target.getBlockY(), target.getBlockZ()); target.getBlockX(), target.getBlockY(), target.getBlockZ());
boolean owner = ownerMatches(player, studio); boolean owner = ownerMatches(player, studio);
selectEnteredWorkcell(session, workcell, owner); selectEnteredWorkcell(session, workcell, owner);
@@ -36,13 +36,9 @@ import java.util.Objects;
public final class JigsawStudioProjectCreator { public final class JigsawStudioProjectCreator {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final List<IrisDirection> SPATIAL_CONNECTOR_ORDER = List.of( private static final int DEFAULT_MAX_DEPTH = 7;
IrisDirection.NORTH_NEGATIVE_Z, private static final int SPATIAL_PORTABLE_MAX_DEPTH = 4;
IrisDirection.SOUTH_POSITIVE_Z, private static final int SPATIAL_MAXIMUM_PLACEMENTS = 4;
IrisDirection.EAST_POSITIVE_X,
IrisDirection.WEST_NEGATIVE_X,
IrisDirection.UP_POSITIVE_Y,
IrisDirection.DOWN_NEGATIVE_Y);
private JigsawStudioProjectCreator() { private JigsawStudioProjectCreator() {
} }
@@ -59,7 +55,7 @@ public final class JigsawStudioProjectCreator {
StructureKey ownershipKey = new StructureKey("iris", resourceKey); StructureKey ownershipKey = new StructureKey("iris", resourceKey);
IrisStructure structure = new IrisStructure() IrisStructure structure = new IrisStructure()
.setStartPool(resourceKey + "/start") .setStartPool(resourceKey + "/start")
.setMaxDepth(7) .setMaxDepth(maxDepth(options))
.setMaxSizeChunks(8) .setMaxSizeChunks(8)
.setMode(toModelMode(options.mode())) .setMode(toModelMode(options.mode()))
.setCompatibility(toModelCompatibility(options.compatibilityTarget())) .setCompatibility(toModelCompatibility(options.compatibilityTarget()))
@@ -94,6 +90,13 @@ public final class JigsawStudioProjectCreator {
return output.toByteArray(); 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) { private static IrisJigsawMode toModelMode(JigsawStudioMode mode) {
return mode == JigsawStudioMode.PLANAR_JIGSAW return mode == JigsawStudioMode.PLANAR_JIGSAW
? IrisJigsawMode.PLANAR_JIGSAW ? IrisJigsawMode.PLANAR_JIGSAW
@@ -129,14 +132,11 @@ public final class JigsawStudioProjectCreator {
IrisJigsawPool piecePool = new IrisJigsawPool().setFallback(capPoolKey); IrisJigsawPool piecePool = new IrisJigsawPool().setFallback(capPoolKey);
IrisJigsawPool capPool = new IrisJigsawPool(); IrisJigsawPool capPool = new IrisJigsawPool();
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { 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); IrisJigsawPiece piece = planarPiece(key, piecePoolKey, size, archetype);
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) { if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
piece.getThemes().add("variant-1"); piece.getThemes().add("variant-1");
} }
if (archetype == JigsawPlanarArchetype.CROSS) {
pool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
}
if (archetype != JigsawPlanarArchetype.BLANK) { if (archetype != JigsawPlanarArchetype.BLANK) {
piecePool.getPieces().add(new IrisJigsawPieceEntry(key, 1)); piecePool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
} }
@@ -159,6 +159,8 @@ public final class JigsawStudioProjectCreator {
dimensions.depth()))); dimensions.depth())));
bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n"); 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)); capPool.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true));
bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n"); bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n");
bundle.textResource("jigsaw-pools/" + capPoolKey + ".json", GSON.toJson(capPool) + "\n"); bundle.textResource("jigsaw-pools/" + capPoolKey + ".json", GSON.toJson(capPool) + "\n");
@@ -172,26 +174,25 @@ public final class JigsawStudioProjectCreator {
JigsawStudioCellDimensions dimensions = options.cellDimensions(); JigsawStudioCellDimensions dimensions = options.cellDimensions();
String piecePoolKey = options.structureKey() + "/pieces"; String piecePoolKey = options.structureKey() + "/pieces";
IrisJigsawPool piecePool = new IrisJigsawPool(); IrisJigsawPool piecePool = new IrisJigsawPool();
for (int connectorCount = 0; connectorCount <= SPATIAL_CONNECTOR_ORDER.size(); connectorCount++) { boolean irisExtended = options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED;
String key = options.structureKey() + "/" for (SpatialVerticalClass vertical : SpatialVerticalClass.values()) {
+ (connectorCount == 0 ? "start" : "connectors-" + connectorCount); for (JigsawPlanarArchetype horizontal : JigsawPlanarArchetype.values()) {
IrisJigsawPiece piece = spatialPiece( String key = archetypeKey(options, horizontal) + vertical.keySuffix();
key, IrisJigsawPiece piece = spatialPiece(key, piecePoolKey, dimensions, horizontal, vertical);
piecePoolKey, if (irisExtended) {
dimensions, piece.getThemes().add("variant-1");
connectorCount); piece.setRules(new IrisJigsawPieceRules().setMaximumPlacements(SPATIAL_MAXIMUM_PLACEMENTS));
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) { }
piece.getThemes().add("variant-1"); 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)); piecePool.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true));
bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n"); bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n");
@@ -201,30 +202,40 @@ public final class JigsawStudioProjectCreator {
String objectKey, String objectKey,
String poolKey, String poolKey,
JigsawStudioCellDimensions dimensions, JigsawStudioCellDimensions dimensions,
int connectorCount JigsawPlanarArchetype horizontal,
SpatialVerticalClass vertical
) { ) {
IrisJigsawPiece piece = new IrisJigsawPiece() IrisJigsawPiece piece = new IrisJigsawPiece()
.setDisplayName(connectorCount + (connectorCount == 1 ? " Connector" : " Connectors")) .setDisplayName(horizontal.displayName() + vertical.displaySuffix())
.setObject(objectKey) .setObject(objectKey)
.setRotatable(true) .setRotatable(true);
.setRules(new IrisJigsawPieceRules().setMaximumPlacements(16)); for (JigsawPlanarDirection planarDirection : horizontal.canonicalTopology().directions()) {
for (int index = 0; index < connectorCount; index++) { piece.getConnectors().add(spatialConnector(dimensions, planarDirection.irisDirection(), poolKey));
IrisDirection direction = SPATIAL_CONNECTOR_ORDER.get(index); }
piece.getConnectors().add(new IrisJigsawConnector() for (IrisDirection direction : vertical.directions()) {
.setPosition(spatialConnectorPosition(dimensions, direction)) piece.getConnectors().add(spatialConnector(dimensions, direction, poolKey));
.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"));
} }
return piece; 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( private static IrisPosition spatialConnectorPosition(
JigsawStudioCellDimensions dimensions, JigsawStudioCellDimensions dimensions,
IrisDirection direction 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( private static IrisJigsawPiece planarPiece(
String objectKey, String objectKey,
String poolKey, String poolKey,
@@ -291,4 +306,33 @@ public final class JigsawStudioProjectCreator {
return key; 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<IrisDirection> directions;
SpatialVerticalClass(String keySuffix, String displaySuffix, List<IrisDirection> directions) {
this.keySuffix = keySuffix;
this.displaySuffix = displaySuffix;
this.directions = directions;
}
private String keySuffix() {
return keySuffix;
}
private String displaySuffix() {
return displaySuffix;
}
private List<IrisDirection> directions() {
return directions;
}
}
} }
@@ -128,7 +128,7 @@ final class JigsawStudioProtection {
static boolean blocksMutatingCommand(UUID ownerId, UUID actorId, String commandLine) { static boolean blocksMutatingCommand(UUID ownerId, UUID actorId, String commandLine) {
return !ownerMatches(ownerId, actorId) return !ownerMatches(ownerId, actorId)
&& !isSafeNonOwnerCommand(commandLine); && isMutatingCommand(commandLine);
} }
static boolean blocksNonEditableWorkcellMutation( static boolean blocksNonEditableWorkcellMutation(
@@ -231,17 +231,25 @@ final class JigsawStudioProtection {
} }
boolean isUnauthorizedStudioEdit(Player player, Block block) { boolean isUnauthorizedStudioEdit(Player player, Block block) {
if (player == null || block == null) { String refusal = studioEditRefusal(player, block);
if (refusal.isEmpty()) {
return false; 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()); ActiveStudio studio = service.studios.get(block.getWorld().getUID());
if (studio == null) { if (studio == null) {
return false; return "";
} }
UUID requestId = studio.generator().getRequest().requestId(); UUID requestId = studio.generator().getRequest().requestId();
if (service.previewRenderer.contains(requestId, block.getX(), block.getY(), block.getZ())) { 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 "The seed-1337 Jigsaw preview is read-only and refreshes automatically.";
return true;
} }
JigsawStudioSession session = studio.generator().getSession(); JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay bay = session.layout().findAt(block.getX(), block.getY(), block.getZ()); JigsawStudioBay bay = session.layout().findAt(block.getX(), block.getY(), block.getZ());
@@ -249,24 +257,20 @@ final class JigsawStudioProtection {
? null ? null
: session.activeVariant(bay.stableId()).orElse(null); : session.activeVariant(bay.stableId()).orElse(null);
if (bay != null && (variant == null || !variant.owned())) { 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 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."); : "This Jigsaw Studio variant is read-only. Adopt or clone its graph before editing it.";
return true;
} }
if (service.saveLifecycle.reopenRequiredRequests.contains(requestId)) { if (service.saveLifecycle.reopenRequiredRequests.contains(requestId)) {
message(player, "Close and reopen Jigsaw Studio before editing the resized layout."); return "Close and reopen Jigsaw Studio before editing the resized layout.";
return true;
} }
if (materializationInProgress(studio)) { if (materializationInProgress(studio)) {
message(player, "Wait for the current Jigsaw Studio variant load or rollback to finish."); return "Wait for the current Jigsaw Studio variant load or rollback to finish.";
return true;
} }
if (!blocksStudioEdit(studio.generator().getRequest().ownerId(), player.getUniqueId())) { if (!blocksStudioEdit(studio.generator().getRequest().ownerId(), player.getUniqueId())) {
return false; return "";
} }
message(player, "This Jigsaw Studio is owned by another player session."); return "This Jigsaw Studio is owned by another player session.";
return true;
} }
boolean materializationInProgress(ActiveStudio studio) { boolean materializationInProgress(ActiveStudio studio) {
@@ -105,6 +105,7 @@ final class JigsawStudioProtectionListener implements Listener {
ActiveStudio studio = service.studios.get(event.getWorld().getUID()); ActiveStudio studio = service.studios.get(event.getWorld().getUID());
if (studio != null) { if (studio != null) {
service.registry.markChunkAvailable(studio, event.getChunk().getX(), event.getChunk().getZ()); 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.toolbelt.closeMenu(event.getPlayer());
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId()); service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
service.tripleSneakTracker.clearPlayer(event.getPlayer().getUniqueId()); service.tripleSneakTracker.clearPlayer(event.getPlayer().getUniqueId());
service.connectorTool.clearPlayer(event.getPlayer().getUniqueId());
service.visualization.visualizationLoops.remove(event.getPlayer().getUniqueId()); service.visualization.visualizationLoops.remove(event.getPlayer().getUniqueId());
service.visualization.assemblyPreviews.remove(event.getPlayer().getUniqueId()); service.visualization.assemblyPreviews.remove(event.getPlayer().getUniqueId());
service.playerContext.playerWorkcells.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.tileWatcher.finalizeJigsawTileWatchesForPlayer(playerId);
service.tripleSneakTracker.clearPlayer(playerId); service.tripleSneakTracker.clearPlayer(playerId);
service.toolbelt.toolConfirmations.remove(playerId); service.toolbelt.toolConfirmations.remove(playerId);
service.connectorTool.clearPlayer(playerId);
service.visualization.visualizationLoops.remove(playerId); service.visualization.visualizationLoops.remove(playerId);
service.particlesDisabled.remove(playerId); service.particlesDisabled.remove(playerId);
service.visualization.assemblyPreviews.remove(playerId); service.visualization.assemblyPreviews.remove(playerId);
@@ -170,15 +173,29 @@ final class JigsawStudioProtectionListener implements Listener {
|| (event.getClickedBlock() != null && event.useInteractedBlock() == Event.Result.DENY)) { || (event.getClickedBlock() != null && event.useInteractedBlock() == Event.Result.DENY)) {
return; return;
} }
if (event.getHand() != EquipmentSlot.HAND if (event.getHand() != EquipmentSlot.HAND || event.getAction() == Action.PHYSICAL) {
|| (event.getAction() != Action.RIGHT_CLICK_AIR
&& event.getAction() != Action.RIGHT_CLICK_BLOCK)) {
return; return;
} }
boolean leftClick = event.getAction() == Action.LEFT_CLICK_AIR
|| event.getAction() == Action.LEFT_CLICK_BLOCK;
Optional<JigsawStudioToolPayload> decoded = service.toolbelt.toolCodec.decode(event.getItem()); Optional<JigsawStudioToolPayload> decoded = service.toolbelt.toolCodec.decode(event.getItem());
if (decoded.isEmpty()) { if (decoded.isEmpty()) {
return; 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()); service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
event.setCancelled(true); event.setCancelled(true);
service.toolbelt.useTool(event.getPlayer(), decoded.get(), event.getItem(), event.getPlayer().isSneaking()); 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) @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBlockBreak(BlockBreakEvent event) { 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())) { if (service.protection.isControlChest(event.getBlock())) {
event.setCancelled(true); event.setCancelled(true);
message(event.getPlayer(), "The Jigsaw Studio control chest cannot be removed."); 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) @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onUnauthorizedInteract(PlayerInteractEvent event) { public void onUnauthorizedInteract(PlayerInteractEvent event) {
if (event.getAction() == Action.RIGHT_CLICK_BLOCK if (event.getAction() != Action.RIGHT_CLICK_BLOCK
&& !service.protection.isControlChest(event.getClickedBlock()) || service.protection.isControlChest(event.getClickedBlock())) {
&& service.protection.isUnauthorizedStudioEdit(event.getPlayer(), 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); event.setCancelled(true);
} }
} }
@@ -337,7 +369,8 @@ final class JigsawStudioProtectionListener implements Listener {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedPistonExtend(BlockPistonExtendEvent event) { public void onProtectedPistonExtend(BlockPistonExtendEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID())) 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); event.setCancelled(true);
} }
} }
@@ -129,6 +129,11 @@ final class JigsawStudioResourceBundleAssembler {
return new Assembly(bundle.build(), objectKey, piece, StructureHash.sha256(manifestContent)); 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<String> resourcePaths(StructurePackageClosure closure) { private static Set<String> resourcePaths(StructurePackageClosure closure) {
Set<String> resources = new LinkedHashSet<>(); Set<String> resources = new LinkedHashSet<>();
addPaths(resources, "structures", closure.structures(), ".json"); addPaths(resources, "structures", closure.structures(), ".json");
@@ -286,7 +286,7 @@ final class JigsawStudioSaveLifecycle {
String selected = session.selectedBayId().orElse(null); String selected = session.selectedBayId().orElse(null);
if (selected == null) { if (selected == null) {
Location location = player.getLocation(); Location location = player.getLocation();
JigsawStudioBay underPlayer = session.layout().findAt( JigsawStudioBay underPlayer = session.layout().findColumn(
location.getBlockX(), location.getBlockY(), location.getBlockZ()); location.getBlockX(), location.getBlockY(), location.getBlockZ());
if (underPlayer != null) { if (underPlayer != null) {
selected = underPlayer.stableId(); selected = underPlayer.stableId();
@@ -73,6 +73,8 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuA
final JigsawStudioAutosaveScheduler autosaveScheduler = new JigsawStudioAutosaveScheduler(this); final JigsawStudioAutosaveScheduler autosaveScheduler = new JigsawStudioAutosaveScheduler(this);
final JigsawStudioTileWatcher tileWatcher = new JigsawStudioTileWatcher(this); final JigsawStudioTileWatcher tileWatcher = new JigsawStudioTileWatcher(this);
final JigsawStudioVisualization visualization = new JigsawStudioVisualization(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); final JigsawStudioPlayerContext playerContext = new JigsawStudioPlayerContext(this);
public static JigsawStudioService get() { public static JigsawStudioService get() {
@@ -4,6 +4,7 @@ import java.util.Objects;
public enum JigsawStudioToolAction { public enum JigsawStudioToolAction {
OPEN_MENU("Open Control Menu", false), OPEN_MENU("Open Control Menu", false),
CONNECTOR_TOOL("Connector Tool", false),
SELECT_WORKCELL("Select Workcell", false), SELECT_WORKCELL("Select Workcell", false),
TOGGLE_WORKCELL("Toggle Workcell", false), TOGGLE_WORKCELL("Toggle Workcell", false),
LOAD_VARIANT("Load Variant", false), LOAD_VARIANT("Load Variant", false),
@@ -140,6 +140,13 @@ public final class JigsawStudioToolCodec {
if (payload.amount() != 0) { if (payload.amount() != 0) {
lore.add(ChatColor.GRAY + "Amount: " + payload.amount()); 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() lore.add(payload.action().destructive()
? ChatColor.RED + "Right-click twice to confirm" ? ChatColor.RED + "Right-click twice to confirm"
: ChatColor.YELLOW + "Right-click to use"); : ChatColor.YELLOW + "Right-click to use");
@@ -29,6 +29,7 @@ import static art.arcane.iris.studio.jigsaw.JigsawStudioVariantProperties.canRes
final class JigsawStudioToolbelt { final class JigsawStudioToolbelt {
private static final long TOOL_CONFIRM_NANOS = 10_000_000_000L; 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; private final JigsawStudioService service;
final Map<UUID, DeferredDuplication> deferredDuplications = new ConcurrentHashMap<>(); final Map<UUID, DeferredDuplication> deferredDuplications = new ConcurrentHashMap<>();
@@ -184,13 +185,27 @@ final class JigsawStudioToolbelt {
message(player, "This Jigsaw Studio tool is stale or belongs to another session."); message(player, "This Jigsaw Studio tool is stale or belongs to another session.");
return false; return false;
} }
if (payload.action().destructive() && !confirmTool(player, payload)) { if (payload.action().destructive()) {
message(player, "Right-click the same tool again within 10 seconds to confirm " ToolConfirmationState confirmation = confirmTool(player, payload);
+ payload.action().displayName() + "."); if (confirmation == ToolConfirmationState.ARMED) {
return true; 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()) { return switch (payload.action()) {
case OPEN_MENU -> openControlMenu(player); case OPEN_MENU -> openControlMenu(player);
case CONNECTOR_TOOL -> service.connectorTool.handleClick(player, payload, false, null, null);
case SELECT_WORKCELL -> selectWorkcell(player, payload.workcellId()); case SELECT_WORKCELL -> selectWorkcell(player, payload.workcellId());
case LOAD_VARIANT -> service.variantEditor.switchVariant( case LOAD_VARIANT -> service.variantEditor.switchVariant(
player, player,
@@ -349,19 +364,40 @@ final class JigsawStudioToolbelt {
return state.isPresent() && service.workcellEditor.setRequireCaps(player, !state.get().requireCaps()); 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(); long now = System.nanoTime();
ToolConfirmation previous = toolConfirmations.get(player.getUniqueId()); ToolConfirmation previous = toolConfirmations.get(player.getUniqueId());
if (previous != null ToolConfirmationState state = confirmationState(previous, payload, now);
&& previous.expiresAtNanos() >= now if (state == ToolConfirmationState.CONFIRMED) {
&& previous.payload().equals(payload)) {
toolConfirmations.remove(player.getUniqueId(), previous); 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( return state;
player.getUniqueId(), }
new ToolConfirmation(payload, now + TOOL_CONFIRM_NANOS));
return false; 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( 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 { ToolConfirmation {
Objects.requireNonNull(payload, "Jigsaw Studio tool confirmation payload"); Objects.requireNonNull(payload, "Jigsaw Studio tool confirmation payload");
} }
@@ -105,6 +105,9 @@ final class JigsawStudioToolboxMenu {
"Jigsaw Studio toolbox workcell"); "Jigsaw Studio toolbox workcell");
UUID requestId = menu.requestId(); UUID requestId = menu.requestId();
List<ToolboxTool> tools = new ArrayList<>(); List<ToolboxTool> tools = new ArrayList<>();
tools.add(new ToolboxTool(
"Connector Tool",
JigsawStudioToolPayload.request(JigsawStudioToolAction.CONNECTOR_TOOL, requestId)));
tools.add(new ToolboxTool( tools.add(new ToolboxTool(
"Open Control Menu", "Open Control Menu",
JigsawStudioToolPayload.request(JigsawStudioToolAction.OPEN_MENU, requestId))); JigsawStudioToolPayload.request(JigsawStudioToolAction.OPEN_MENU, requestId)));
@@ -216,6 +219,15 @@ final class JigsawStudioToolboxMenu {
selected.stableId(), selected.stableId(),
active.pieceKey()))); 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()) { if (active.owned()) {
for (JigsawStudioMenuState.Membership membership : active.memberships()) { for (JigsawStudioMenuState.Membership membership : active.memberships()) {
addMembershipTools( addMembershipTools(
@@ -255,7 +267,7 @@ final class JigsawStudioToolboxMenu {
selected.stableId(), selected.stableId(),
variant.pieceKey()))); variant.pieceKey())));
} }
if (variant.owned() && selected.variants().size() > 1) { if (menu.canDeleteVariant(selected, variant)) {
tools.add(new ToolboxTool( tools.add(new ToolboxTool(
"Delete " + variant.displayName(), "Delete " + variant.displayName(),
JigsawStudioToolPayload.variant( JigsawStudioToolPayload.variant(
@@ -174,12 +174,20 @@ final class JigsawStudioVariantProperties {
return false; return false;
} }
JigsawStudioSession session = studio.generator().getSession(); 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); String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) { if (!reservationFailure.isEmpty()) {
message(player, reservationFailure); message(player, reservationFailure);
return false; return false;
} }
boolean rotatable = !variant.rotatable();
return service.graphMutations.scheduleGraphMutation( return service.graphMutations.scheduleGraphMutation(
player, player,
studio, studio,
@@ -268,6 +276,12 @@ final class JigsawStudioVariantProperties {
+ dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth() + "."); + dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth() + ".");
return false; 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) boolean active = session.activeVariant(workcellId)
.map(activeVariant -> activeVariant.pieceKey().equals(pieceKey)) .map(activeVariant -> activeVariant.pieceKey().equals(pieceKey))
.orElse(false); .orElse(false);
@@ -536,4 +550,23 @@ final class JigsawStudioVariantProperties {
|| canonical.height() < target.height() || canonical.height() < target.height()
|| canonical.depth() < target.depth()); || 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);
}
} }
@@ -17,6 +17,7 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; 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 VALID_CONNECTOR_COLOR = Color.LIME;
private static final Color ASSEMBLY_PREVIEW_COLOR = Color.fromRGB(180, 90, 255); 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 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 static final long ASSEMBLY_PREVIEW_MILLIS = 10_000L;
private final JigsawStudioService service; private final JigsawStudioService service;
@@ -124,7 +128,7 @@ final class JigsawStudioVisualization {
JigsawStudioSession session = studio.generator().getSession(); JigsawStudioSession session = studio.generator().getSession();
JigsawStudioLayout layout = session.layout(); JigsawStudioLayout layout = session.layout();
Location playerLocation = player.getLocation(); Location playerLocation = player.getLocation();
JigsawStudioBay focused = layout.findAt( JigsawStudioBay focused = layout.findColumn(
playerLocation.getBlockX(), playerLocation.getBlockX(),
playerLocation.getBlockY(), playerLocation.getBlockY(),
playerLocation.getBlockZ()); playerLocation.getBlockZ());
@@ -134,6 +138,7 @@ final class JigsawStudioVisualization {
drawConnectors(player, playerLocation, studio, focused, budget); drawConnectors(player, playerLocation, studio, focused, budget);
drawBounds(player, playerLocation, focused.bounds(), focusedColor, 1.15F, 1.0D, budget); drawBounds(player, playerLocation, focused.bounds(), focusedColor, 1.15F, 1.0D, budget);
} }
drawConnectorToolFeedback(player, playerLocation, studio, budget);
drawLivePreview(player, playerLocation, studio, budget); drawLivePreview(player, playerLocation, studio, budget);
drawAssemblyPreview(player, playerLocation, budget); drawAssemblyPreview(player, playerLocation, budget);
for (JigsawStudioBay bay : layout.bays()) { 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<JigsawStudioConnectorTool.Feedback> 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( private void drawLivePreview(
Player player, Player player,
Location playerLocation, Location playerLocation,
@@ -114,10 +114,6 @@ final class JigsawStudioWorkcellEditor {
message(player, "Connector blocks cannot reset: " + validationFailure); message(player, "Connector blocks cannot reset: " + validationFailure);
return false; 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); String reservationFailure = service.materializer.beginConnectorRepair(studio);
if (!reservationFailure.isEmpty()) { if (!reservationFailure.isEmpty()) {
message(player, reservationFailure); message(player, reservationFailure);
@@ -125,7 +121,7 @@ final class JigsawStudioWorkcellEditor {
} }
boolean connectorsVisible = session.workcellSnapshot(workcellId).connectorsVisible(); boolean connectorsVisible = session.workcellSnapshot(workcellId).connectorsVisible();
message(player, "Resetting " + rendered.connectors().size() 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( return service.materializer.scheduleConnectorRepair(
player, player,
studio, studio,
@@ -383,6 +379,16 @@ final class JigsawStudioWorkcellEditor {
message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'."); message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'.");
return false; 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; String normalizedName;
try { try {
normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName); normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName);
@@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest {
@ClassRule @ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); 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<Class<?>> SCHEMA_ROOTS = List.of( private static final List<Class<?>> SCHEMA_ROOTS = List.of(
IrisDimension.class, IrisDimension.class,
@@ -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 @Test
public void rejects1211VanillaExportWithDifferentVerticalDistance() throws Exception { public void rejects1211VanillaExportWithDifferentVerticalDistance() throws Exception {
when(IrisPlatforms.get().minecraftVersion()).thenReturn("1.21.1"); when(IrisPlatforms.get().minecraftVersion()).thenReturn("1.21.1");
@@ -38,7 +38,10 @@ import art.arcane.volmlib.util.math.RNG;
import org.junit.ClassRule; import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
@@ -310,6 +313,82 @@ public class StructureAssemblerTest {
assertEquals(west.getMaxX() + 1, start.getMinX()); 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<Integer> 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<PlacedStructurePiece> 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<PlacedStructurePiece> 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 @Test
public void selectionPriorityProcessesHigherConnectorsFirstAndPreservesStableTies() { public void selectionPriorityProcessesHigherConnectorsFirstAndPreservesStableTies() {
Fixture fixture = new Fixture(); Fixture fixture = new Fixture();
@@ -1115,6 +1194,53 @@ public class StructureAssemblerTest {
return fixture.assembler(structure("start", 1), 0, 0, 0).assemble(new RNG(137L)); 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<IrisDirection> worldFacings(PlacedStructurePiece piece) {
Set<IrisDirection> 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) { private static IrisStructure structure(String startPool, int maxDepth) {
return new IrisStructure().setStartPool(startPool).setMaxDepth(maxDepth).setMaxSizeChunks(8); return new IrisStructure().setStartPool(startPool).setMaxDepth(maxDepth).setMaxSizeChunks(8);
} }
@@ -2,8 +2,6 @@ package art.arcane.iris.studio.generation;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.loading.ResourceLoader; 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.JigsawPlanarTopology;
import art.arcane.iris.studio.jigsaw.JigsawStudioActivation; import art.arcane.iris.studio.jigsaw.JigsawStudioActivation;
import art.arcane.iris.studio.jigsaw.JigsawStudioBay; 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.assertFalse;
import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
@@ -168,57 +167,123 @@ public class JigsawStudioGeneratorTest {
} }
@Test @Test
public void paintsCanonicalWoolGlyphsBelowCaptureAndOneControlChest() { @SuppressWarnings("unchecked")
public void paintsActiveVariantConnectorLinesBelowCaptureAndOneControlChest() {
IrisData source = mock(IrisData.class);
ResourceLoader<IrisJigsawPiece> pieceLoader = mock(ResourceLoader.class);
ResourceLoader<IrisObject> objectLoader = mock(ResourceLoader.class);
when(source.getJigsawPieceLoader()).thenReturn(pieceLoader);
when(source.getObjectLoader()).thenReturn(objectLoader);
JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(7, 5, 7); 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( GeneratorFixture fixture = fixture(
source,
JigsawStudioMode.PLANAR_JIGSAW, JigsawStudioMode.PLANAR_JIGSAW,
dimensions, dimensions,
JigsawStudioVariantCatalog.empty()); new JigsawStudioVariantCatalog(List.of(variant)));
JigsawStudioBay corner = fixture.layout().get("workcell/corner"); 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 centerX = dimensions.width() / 2;
int centerZ = dimensions.depth() / 2; int centerZ = dimensions.depth() / 2;
assertSame(fixture.connectorCap(), stateAt( try (MockedStatic<B> ignored = mockStatic(B.class)) {
fixture.generator(), assertSame(fixture.palette().horizontalSocket(), stateAt(
corner.bounds().originX() + centerX, fixture.generator(), corner.bounds().originX() + centerX, floorY, corner.bounds().originZ()));
glyphY, assertSame(fixture.palette().horizontalSocket(), stateAt(
corner.bounds().originZ())); fixture.generator(), corner.bounds().maxX(), floorY, corner.bounds().originZ() + centerZ));
assertSame(fixture.connectorCap(), stateAt( assertSame(fixture.palette().line(), stateAt(
fixture.generator(), fixture.generator(), corner.bounds().originX() + centerX, floorY, corner.bounds().originZ() + 1));
corner.bounds().maxX(), assertSame(fixture.palette().line(), stateAt(
glyphY, fixture.generator(),
corner.bounds().originZ() + centerZ)); corner.bounds().originX() + centerX + 1,
assertSame(fixture.topologyPath(), stateAt( floorY,
fixture.generator(), corner.bounds().originZ() + centerZ));
corner.bounds().originX() + centerX, assertSame(fixture.palette().base(), stateAt(
glyphY, fixture.generator(), corner.bounds().originX(), floorY, corner.bounds().originZ()));
corner.bounds().originZ() + 1)); assertFalse(fixture.palette().base() == stateAt(
assertSame(fixture.topologyPath(), stateAt( fixture.generator(),
fixture.generator(), corner.bounds().originX() + centerX,
corner.bounds().originX() + centerX + 1, corner.bounds().originY(),
glyphY, corner.bounds().originZ() + centerZ));
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));
JigsawStudioBay blank = fixture.layout().get("workcell/blank"); JigsawStudioBay blank = fixture.layout().get("workcell/blank");
assertSame(fixture.topologyBase(), stateAt( assertSame(fixture.palette().base(), stateAt(
fixture.generator(), fixture.generator(),
blank.bounds().originX() + centerX, blank.bounds().originX() + centerX,
blank.bounds().originY() - 1, blank.bounds().originY() - 1,
blank.bounds().originZ() + centerZ)); blank.bounds().originZ() + centerZ));
JigsawStudioControlPosition control = fixture.layout().controlPosition(); JigsawStudioBay tee = fixture.layout().get("workcell/tee");
assertSame(fixture.controlChest(), stateAt( assertSame(fixture.palette().base(), stateAt(
fixture.generator(), control.worldX(), control.worldY(), control.worldZ())); 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<IrisJigsawPiece> pieceLoader = mock(ResourceLoader.class);
ResourceLoader<IrisObject> 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<B> 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 @Test
@@ -247,77 +312,6 @@ public class JigsawStudioGeneratorTest {
workcell.bounds().originZ())); 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<JigsawPlanarArchetype, List<String>> 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<String> 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 @Test
public void clipsLargeGlyphPaintingToOneChunkFootprint() { public void clipsLargeGlyphPaintingToOneChunkFootprint() {
GeneratorFixture fixture = fixture( GeneratorFixture fixture = fixture(
@@ -331,10 +325,7 @@ public class JigsawStudioGeneratorTest {
fixture.generator().paintChunk(chunk, chunkX, chunkZ); fixture.generator().paintChunk(chunk, chunkX, chunkZ);
int glyphBlocks = chunk.countState(fixture.topologyBase()) assertEquals(256, chunk.countState(fixture.palette().base()));
+ chunk.countState(fixture.topologyPath())
+ chunk.countState(fixture.connectorCap());
assertEquals(256, glyphBlocks);
} }
@Test @Test
@@ -467,6 +458,29 @@ public class JigsawStudioGeneratorTest {
assertNotSame(nextGeneration, fixture.generator().renderVariant(workcell, variant, 5L)); 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 @Test
public void mapsAllVanillaJigsawOrientations() { public void mapsAllVanillaJigsawOrientations() {
assertEquals("north_up", JigsawStudioGenerator.orientation( assertEquals("north_up", JigsawStudioGenerator.orientation(
@@ -535,9 +549,13 @@ public class JigsawStudioGeneratorTest {
NativeBlockState lightFloor = mock(NativeBlockState.class); NativeBlockState lightFloor = mock(NativeBlockState.class);
NativeBlockState darkFloor = mock(NativeBlockState.class); NativeBlockState darkFloor = mock(NativeBlockState.class);
NativeBlockState frame = mock(NativeBlockState.class); NativeBlockState frame = mock(NativeBlockState.class);
NativeBlockState topologyBase = mock(NativeBlockState.class); JigsawStudioGenerator.FloorPalette palette = new JigsawStudioGenerator.FloorPalette(
NativeBlockState topologyPath = mock(NativeBlockState.class); mock(NativeBlockState.class),
NativeBlockState connectorCap = mock(NativeBlockState.class); mock(NativeBlockState.class),
mock(NativeBlockState.class),
mock(NativeBlockState.class),
mock(NativeBlockState.class),
mock(NativeBlockState.class));
NativeBlockState invalidMarker = mock(NativeBlockState.class); NativeBlockState invalidMarker = mock(NativeBlockState.class);
NativeBlockState controlChest = mock(NativeBlockState.class); NativeBlockState controlChest = mock(NativeBlockState.class);
JigsawStudioGenerator generator = new JigsawStudioGenerator( JigsawStudioGenerator generator = new JigsawStudioGenerator(
@@ -547,9 +565,7 @@ public class JigsawStudioGeneratorTest {
lightFloor, lightFloor,
darkFloor, darkFloor,
frame, frame,
topologyBase, palette,
topologyPath,
connectorCap,
invalidMarker, invalidMarker,
controlChest, controlChest,
JigsawStudioGeneratorTest::renderFinalState); JigsawStudioGeneratorTest::renderFinalState);
@@ -557,12 +573,22 @@ public class JigsawStudioGeneratorTest {
generator, generator,
layout, layout,
frame, frame,
topologyBase, palette,
topologyPath,
connectorCap,
controlChest); 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) { private static JigsawStudioVariant planarVariant(String key, JigsawPlanarTopology topology) {
return new JigsawStudioVariant( return new JigsawStudioVariant(
key, key,
@@ -596,9 +622,7 @@ public class JigsawStudioGeneratorTest {
JigsawStudioGenerator generator, JigsawStudioGenerator generator,
JigsawStudioLayout layout, JigsawStudioLayout layout,
NativeBlockState frame, NativeBlockState frame,
NativeBlockState topologyBase, JigsawStudioGenerator.FloorPalette palette,
NativeBlockState topologyPath,
NativeBlockState connectorCap,
NativeBlockState controlChest NativeBlockState controlChest
) { ) {
} }
@@ -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<JigsawStudioCellDimensions> PLANAR_DIMENSIONS = List.of(
new JigsawStudioCellDimensions(16, 16, 16),
new JigsawStudioCellDimensions(15, 9, 15));
private static final List<JigsawStudioCellDimensions> 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<PlacedStructurePiece> 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<String, Set<Integer>> attachments = new TreeMap<>();
for (long seed = 0; seed < SEEDS; seed++) {
List<PlacedStructurePiece> 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<Integer> 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<PlacedStructurePiece> 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<String, IrisJigsawPiece> pieces = pieces(bundle);
Map<Integer, String> bySignature = new HashMap<>();
for (Map.Entry<String, IrisJigsawPiece> 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<String> placedKinds = new HashSet<>();
Set<Integer> cornerAttachments = new HashSet<>();
for (long seed = 0; seed < SEEDS; seed++) {
List<PlacedStructurePiece> 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<Integer> 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<PlacedStructurePiece> 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<PlacedStructurePiece> 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<Integer> earlierSpatialAttachments(List<PlacedStructurePiece> pieces, int index) {
PlacedStructurePiece piece = pieces.get(index);
List<Integer> 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<PlacedStructurePiece> 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<String, IrisJigsawPiece> pieces(StructureResourceBundle bundle) {
Map<String, IrisJigsawPiece> 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);
}
}
@@ -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<Bukkit> 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();
}
}
}
@@ -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> 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> 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> 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> 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> 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> 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> 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<LocalPosition, IrisDirection> 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<LocalPosition, IrisDirection> 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<LocalPosition, IrisDirection> 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<LocalPosition, IrisDirection> single = Map.of(socket, IrisDirection.NORTH_NEGATIVE_Z);
Map<LocalPosition, IrisDirection> 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<IrisJigsawConnector> 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<IrisJigsawConnector> 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<IrisJigsawConnector> 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<IrisJigsawConnector> source = List.of(first, second);
List<IrisJigsawConnector> 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<IrisJigsawConnector> 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<IrisJigsawConnector> 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<IrisJigsawConnector> before = List.of(north, east);
List<IrisJigsawConnector> 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<LocalPosition> 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<LocalPosition, IrisDirection> 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");
}
}
@@ -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<LocalPosition> 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));
}
}
@@ -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<J> 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<UUID, Object> studios = (Map<UUID, Object>) 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);
}
}
@@ -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<JigsawPlanarArchetype, List<String>> 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<JigsawPlanarArchetype, List<String>> entry : expected.entrySet()) {
List<JigsawStudioFloorGlyph.Marker> 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<String> 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';
};
}
}
@@ -234,6 +234,60 @@ public class JigsawStudioGraphMapperTest {
assertEquals(new JigsawStudioCellDimensions(24, 32, 24), dimensions); assertEquals(new JigsawStudioCellDimensions(24, 32, 24), dimensions);
} }
@Test
@SuppressWarnings("unchecked")
public void spatialFootprintRejectsChangesThatExceedTheWorkcellVolume() {
IrisData data = mock(IrisData.class);
ResourceLoader<IrisObject> 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 @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public void mapsAuthorLabelsAndCanonicalPerVariantDimensionsIndependently() { public void mapsAuthorLabelsAndCanonicalPerVariantDimensionsIndependently() {
@@ -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));
}
}
@@ -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<Integer> 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());
}
}
@@ -491,7 +491,7 @@ public class JigsawStudioPersistenceEditorsTest {
JigsawStudioGraphEditor.VariantFamilyCreation creation = JigsawStudioGraphEditor.duplicateActiveFamily( JigsawStudioGraphEditor.VariantFamilyCreation creation = JigsawStudioGraphEditor.duplicateActiveFamily(
packRoot, packRoot,
"studio/spatial", "studio/spatial",
Map.of(JigsawStudioLayout.SPATIAL_WORKCELL_ID, "studio/spatial/start"), Map.of(JigsawStudioLayout.SPATIAL_WORKCELL_ID, "studio/spatial/blank"),
"variant-2"); "variant-2");
assertTrue(creation.writeResult().successful()); assertTrue(creation.writeResult().successful());
@@ -499,7 +499,7 @@ public class JigsawStudioPersistenceEditorsTest {
"studio/spatial/variants/spatial/variant-2", "studio/spatial/variants/spatial/variant-2",
creation.pieceKeysByWorkcell().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID)); creation.pieceKeysByWorkcell().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID));
JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/studio/spatial/start.json")); JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/studio/spatial/start.json"));
assertEquals(8, pool.getAsJsonArray("pieces").size()); assertEquals(25, pool.getAsJsonArray("pieces").size());
} }
@Test @Test
@@ -507,10 +507,13 @@ public class JigsawStudioPersistenceEditorsTest {
Path packRoot = createSpatialProject("spatial-theme-row"); Path packRoot = createSpatialProject("spatial-theme-row");
Map<String, String> sources = new LinkedHashMap<>(); Map<String, String> sources = new LinkedHashMap<>();
sources.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID, "studio/spatial/start"); for (String vertical : List.of("", "-up", "-down", "-up-down")) {
for (int connectorCount = 1; connectorCount <= 6; connectorCount++) { for (String horizontal : List.of("blank", "end", "straight", "corner", "tee", "cross")) {
String pieceKey = "studio/spatial/connectors-" + connectorCount; String pieceKey = "studio/spatial/" + horizontal + vertical;
sources.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID + "/" + pieceKey, pieceKey); sources.put(sources.isEmpty()
? JigsawStudioLayout.SPATIAL_WORKCELL_ID
: JigsawStudioLayout.SPATIAL_WORKCELL_ID + "/" + pieceKey, pieceKey);
}
} }
JigsawStudioGraphEditor.VariantFamilyCreation creation = JigsawStudioGraphEditor.duplicateActiveFamily( JigsawStudioGraphEditor.VariantFamilyCreation creation = JigsawStudioGraphEditor.duplicateActiveFamily(
packRoot, packRoot,
@@ -519,13 +522,13 @@ public class JigsawStudioPersistenceEditorsTest {
"variant-2"); "variant-2");
assertTrue(creation.writeResult().successful()); assertTrue(creation.writeResult().successful());
assertEquals(7, creation.pieceKeysByWorkcell().size()); assertEquals(24, creation.pieceKeysByWorkcell().size());
for (String targetPieceKey : creation.pieceKeysByWorkcell().values()) { for (String targetPieceKey : creation.pieceKeysByWorkcell().values()) {
assertTrue(Files.isRegularFile(packRoot.resolve("jigsaw-pieces/" + targetPieceKey + ".json"))); assertTrue(Files.isRegularFile(packRoot.resolve("jigsaw-pieces/" + targetPieceKey + ".json")));
assertTrue(Files.isRegularFile(packRoot.resolve("objects/" + targetPieceKey + ".iob"))); assertTrue(Files.isRegularFile(packRoot.resolve("objects/" + targetPieceKey + ".iob")));
} }
JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/studio/spatial/start.json")); JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/studio/spatial/start.json"));
assertEquals(14, pool.getAsJsonArray("pieces").size()); assertEquals(48, pool.getAsJsonArray("pieces").size());
} }
@Test @Test
@@ -557,7 +560,7 @@ public class JigsawStudioPersistenceEditorsTest {
"END").get("displayName").getAsString()); "END").get("displayName").getAsString());
Path spatialRoot = createSpatialProject("labels-spatial"); 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); byte[] spatialBefore = Files.readAllBytes(spatialObject);
assertTrue(JigsawStudioStructureEditor.updateSpatialWorkcellDisplayName( assertTrue(JigsawStudioStructureEditor.updateSpatialWorkcellDisplayName(
@@ -34,7 +34,7 @@ public class JigsawStudioPersistenceIdentityTest {
private static final String PIECE_POOL_KEY = "studio/identity/pieces"; private static final String PIECE_POOL_KEY = "studio/identity/pieces";
private static final String RECORDED_AT_FIELD = "recordedAtEpochMilli"; private static final String RECORDED_AT_FIELD = "recordedAtEpochMilli";
private static final String SCRIPTED_SESSION_DIGEST = private static final String SCRIPTED_SESSION_DIGEST =
"318d9338a63a44063c85e59a9e74f869473d6e0f15c362536172f7723d960faa"; "42b5d3bed8d74b2ef7a010a58302544d0d425c83faba0dc86efb0e41e69ba627";
@Rule @Rule
public final TemporaryFolder temporaryFolder = new TemporaryFolder(); public final TemporaryFolder temporaryFolder = new TemporaryFolder();
@@ -102,9 +102,15 @@ public class JigsawStudioProjectCreatorTest {
JsonObject startPool = JsonParser.parseString(Files.readString( JsonObject startPool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/settlement/test/start.json"), temporaryDirectory.resolve("jigsaw-pools/settlement/test/start.json"),
StandardCharsets.UTF_8)).getAsJsonObject(); 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) assertEquals("settlement/test/cross", startPool.getAsJsonArray("pieces").get(0)
.getAsJsonObject().get("piece").getAsString()); .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( JsonObject piecePool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/settlement/test/pieces.json"), temporaryDirectory.resolve("jigsaw-pools/settlement/test/pieces.json"),
StandardCharsets.UTF_8)).getAsJsonObject(); StandardCharsets.UTF_8)).getAsJsonObject();
@@ -229,7 +235,7 @@ public class JigsawStudioProjectCreatorTest {
} }
@Test @Test
public void spatialProjectSeedsZeroThroughSixConnectorVariants() throws Exception { public void spatialProjectSeedsEveryYRotationTopologyVariant() throws Exception {
Path temporaryDirectory = temporaryFolder.getRoot().toPath(); Path temporaryDirectory = temporaryFolder.getRoot().toPath();
JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options(
"stronghold/gallery", "stronghold/gallery",
@@ -246,35 +252,48 @@ public class JigsawStudioProjectCreatorTest {
JsonObject piecePool = JsonParser.parseString(Files.readString( JsonObject piecePool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/stronghold/gallery/pieces.json"), temporaryDirectory.resolve("jigsaw-pools/stronghold/gallery/pieces.json"),
StandardCharsets.UTF_8)).getAsJsonObject(); StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(7, startPool.getAsJsonArray("pieces").size()); assertEquals(24, startPool.getAsJsonArray("pieces").size());
assertEquals(7, piecePool.getAsJsonArray("pieces").size()); assertEquals("stronghold/gallery/blank", startPool.getAsJsonArray("pieces").get(0)
assertEquals("stronghold/gallery/connectors-1", piecePool.getAsJsonArray("pieces").get(0)
.getAsJsonObject().get("piece").getAsString()); .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()); .get("empty").getAsBoolean());
for (int connectorCount = 0; connectorCount <= 6; connectorCount++) { List<String> horizontalClasses = List.of("blank", "end", "straight", "corner", "tee", "cross");
String pieceName = connectorCount == 0 ? "start" : "connectors-" + connectorCount; List<String> verticalClasses = List.of("", "-up", "-down", "-up-down");
JsonObject piece = JsonParser.parseString(Files.readString( List<Integer> horizontalCounts = List.of(0, 1, 2, 2, 3, 4);
temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/" for (String vertical : verticalClasses) {
+ pieceName + ".json"), for (int index = 0; index < horizontalClasses.size(); index++) {
StandardCharsets.UTF_8)).getAsJsonObject(); String pieceName = horizontalClasses.get(index) + vertical;
assertEquals(connectorCount, piece.getAsJsonArray("connectors").size()); JsonObject piece = JsonParser.parseString(Files.readString(
assertEquals(connectorCount + (connectorCount == 1 ? " Connector" : " Connectors"), temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/" + pieceName + ".json"),
piece.get("displayName").getAsString()); StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(16, piece.getAsJsonObject("rules").get("maximumPlacements").getAsInt()); int verticalCount = vertical.isEmpty() ? 0 : vertical.equals("-up-down") ? 2 : 1;
assertEquals(new IrisBlockVector(15, 15, 15), IrisObject.sampleSize( assertEquals(pieceName, horizontalCounts.get(index) + verticalCount,
temporaryDirectory.resolve("objects/stronghold/gallery/" piece.getAsJsonArray("connectors").size());
+ pieceName + ".iob").toFile())); 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( JsonObject cornerShaft = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/connectors-6.json"), temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/corner-up-down.json"),
StandardCharsets.UTF_8)).getAsJsonObject(); 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()); .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()); .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()); .getAsJsonObject().getAsJsonObject("position").get("y").getAsInt());
assertEquals("NORTH_NEGATIVE_Z", cornerShaft.getAsJsonArray("connectors").get(3)
.getAsJsonObject().get("top").getAsString());
StructureGraphCompilation compilation = StructureResourceBundleGraphCompiler.compile( StructureGraphCompilation compilation = StructureResourceBundleGraphCompiler.compile(
JigsawStudioProjectCreator.bundle(options)).getFirst(); JigsawStudioProjectCreator.bundle(options)).getFirst();
@@ -283,7 +302,7 @@ public class JigsawStudioProjectCreatorTest {
new IrisPosition(0, 0, 0)).assemble(new RNG(1337L)); new IrisPosition(0, 0, 0)).assemble(new RNG(1337L));
assertEquals(preview.detail(), StructureAssemblyStatus.COMPLETE, preview.status()); assertEquals(preview.detail(), StructureAssemblyStatus.COMPLETE, preview.status());
assertTrue(preview.pieces().size() > 1); assertTrue(preview.pieces().size() > 1);
assertTrue(preview.pieces().size() <= 96); assertTrue(preview.pieces().size() <= 93);
} }
@Test @Test
@@ -411,10 +430,10 @@ public class JigsawStudioProjectCreatorTest {
JsonObject pool = JsonParser.parseString(Files.readString( JsonObject pool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/stronghold/test/start.json"), temporaryDirectory.resolve("jigsaw-pools/stronghold/test/start.json"),
StandardCharsets.UTF_8)).getAsJsonObject(); StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(8, pool.getAsJsonArray("pieces").size()); assertEquals(25, pool.getAsJsonArray("pieces").size());
assertEquals("stronghold/test/hall", pool.getAsJsonArray("pieces").get(7) assertEquals("stronghold/test/hall", pool.getAsJsonArray("pieces").get(24)
.getAsJsonObject().get("piece").getAsString()); .getAsJsonObject().get("piece").getAsString());
assertEquals(3, pool.getAsJsonArray("pieces").get(7) assertEquals(3, pool.getAsJsonArray("pieces").get(24)
.getAsJsonObject().get("weight").getAsInt()); .getAsJsonObject().get("weight").getAsInt());
StructureWriteResult resized = JigsawStudioStructureEditor.updateCellSize( StructureWriteResult resized = JigsawStudioStructureEditor.updateCellSize(
@@ -435,7 +454,7 @@ public class JigsawStudioProjectCreatorTest {
IrisBlockVector hallSize = IrisObject.sampleSize( IrisBlockVector hallSize = IrisObject.sampleSize(
temporaryDirectory.resolve("objects/stronghold/test/hall.iob").toFile()); temporaryDirectory.resolve("objects/stronghold/test/hall.iob").toFile());
IrisBlockVector starterSize = IrisObject.sampleSize( 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(16, 12, 18), hallSize);
assertEquals(new IrisBlockVector(12, 10, 14), starterSize); assertEquals(new IrisBlockVector(12, 10, 14), starterSize);
@@ -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() { private static PlayerInteractEvent probe() {
Player player = mock(Player.class); Player player = mock(Player.class);
World world = mock(World.class); World world = mock(World.class);
@@ -80,20 +80,20 @@ public class JigsawStudioResourceBundleAssemblerTest {
JigsawStudioResourceBundleAssembler.assemble( JigsawStudioResourceBundleAssembler.assemble(
packRoot, packRoot,
"fort", "fort",
"fort/start", "fort/blank",
emptyObject(), emptyObject(),
List.of(connector), List.of(connector),
false); false);
assertEquals(17, assembly.bundle().resources().size()); assertEquals(51, assembly.bundle().resources().size());
assertEquals("fort/start", assembly.objectKey()); assertEquals("fort/blank", assembly.objectKey());
assertEquals(1, assembly.piece().getConnectors().size()); assertEquals(1, assembly.piece().getConnectors().size());
StructureWriteResult result = new StructureTransactionWriter(packRoot) StructureWriteResult result = new StructureTransactionWriter(packRoot)
.write(assembly.bundle(), StructureWriteMode.OVERWRITE); .write(assembly.bundle(), StructureWriteMode.OVERWRITE);
assertTrue(result.successful()); assertTrue(result.successful());
assertTrue(result.committed()); 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( IrisJigsawPiece persisted = new Gson().fromJson(
Files.readString(piecePath, StandardCharsets.UTF_8), IrisJigsawPiece.class); Files.readString(piecePath, StandardCharsets.UTF_8), IrisJigsawPiece.class);
assertEquals(1, persisted.getConnectors().size()); assertEquals(1, persisted.getConnectors().size());
@@ -143,7 +143,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
@Test @Test
public void capturePreservesAbsentDefaultsAndUnknownPieceMetadata() throws Exception { public void capturePreservesAbsentDefaultsAndUnknownPieceMetadata() throws Exception {
Path packRoot = createProject(); Path packRoot = createProject();
String pieceResource = "jigsaw-pieces/fort/start.json"; String pieceResource = "jigsaw-pieces/fort/blank.json";
Path piecePath = packRoot.resolve(pieceResource); Path piecePath = packRoot.resolve(pieceResource);
JsonObject source = JsonParser.parseString( JsonObject source = JsonParser.parseString(
Files.readString(piecePath, StandardCharsets.UTF_8)).getAsJsonObject(); Files.readString(piecePath, StandardCharsets.UTF_8)).getAsJsonObject();
@@ -158,7 +158,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
JigsawStudioResourceBundleAssembler.assemble( JigsawStudioResourceBundleAssembler.assemble(
packRoot, packRoot,
STRUCTURE_KEY, STRUCTURE_KEY,
"fort/start", "fort/blank",
emptyObject(), emptyObject(),
List.copyOf(sourcePiece.getConnectors()), List.copyOf(sourcePiece.getConnectors()),
false); false);
@@ -173,7 +173,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
JigsawStudioResourceBundleAssembler.assemble( JigsawStudioResourceBundleAssembler.assemble(
packRoot, packRoot,
STRUCTURE_KEY, STRUCTURE_KEY,
"fort/start", "fort/blank",
emptyObject(), emptyObject(),
List.of(updated), List.of(updated),
false); false);
@@ -202,7 +202,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
() -> JigsawStudioResourceBundleAssembler.assemble( () -> JigsawStudioResourceBundleAssembler.assemble(
packRoot, packRoot,
STRUCTURE_KEY, STRUCTURE_KEY,
"fort/start", "fort/blank",
emptyObject(), emptyObject(),
List.of(connector()), List.of(connector()),
false)); false));
@@ -213,7 +213,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
@Test @Test
public void refusesManagedDatapackOwnershipWithoutAssemblingAStudioSave() throws Exception { public void refusesManagedDatapackOwnershipWithoutAssemblingAStudioSave() throws Exception {
Path packRoot = createProject(); Path packRoot = createProject();
Path objectPath = packRoot.resolve("objects/fort/start.iob"); Path objectPath = packRoot.resolve("objects/fort/blank.iob");
byte[] before = Files.readAllBytes(objectPath); byte[] before = Files.readAllBytes(objectPath);
markManagedDatapack(packRoot, STRUCTURE_KEY); markManagedDatapack(packRoot, STRUCTURE_KEY);
@@ -221,7 +221,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
() -> JigsawStudioResourceBundleAssembler.assemble( () -> JigsawStudioResourceBundleAssembler.assemble(
packRoot, packRoot,
STRUCTURE_KEY, STRUCTURE_KEY,
"fort/start", "fort/blank",
emptyObject(), emptyObject(),
List.of(connector()), List.of(connector()),
false)); false));
@@ -234,13 +234,13 @@ public class JigsawStudioResourceBundleAssemblerTest {
@Test @Test
public void writerRejectsAResourceModifiedOutsideTheOwnershipTransaction() throws Exception { public void writerRejectsAResourceModifiedOutsideTheOwnershipTransaction() throws Exception {
Path packRoot = createProject(); 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"); Files.writeString(piecePath, Files.readString(piecePath, StandardCharsets.UTF_8) + "\n");
JigsawStudioResourceBundleAssembler.Assembly assembly = JigsawStudioResourceBundleAssembler.Assembly assembly =
JigsawStudioResourceBundleAssembler.assemble( JigsawStudioResourceBundleAssembler.assemble(
packRoot, packRoot,
STRUCTURE_KEY, STRUCTURE_KEY,
"fort/start", "fort/blank",
emptyObject(), emptyObject(),
List.of(connector().setPool("fort/start")), List.of(connector().setPool("fort/start")),
false); false);
@@ -181,11 +181,13 @@ public class JigsawStudioServiceCaptureTest {
when(state.isCustom()).thenReturn(false); when(state.isCustom()).thenReturn(false);
when(state.nativeHandle()).thenReturn(blockData); when(state.nativeHandle()).thenReturn(blockData);
JigsawStudioChunkWriter.restoreConnectorChunk( JigsawStudioCapture.LocalPosition position = new JigsawStudioCapture.LocalPosition(1, 2, 3);
JigsawStudioChunkWriter.restoreChunkPositions(
world, world,
workcell, workcell,
List.of(renderedConnector), Set.of(position),
Map.of(new JigsawStudioCapture.LocalPosition(1, 2, 3), renderedBlock), Map.of(position, renderedConnector),
Map.of(position, renderedBlock),
false); false);
verify(world).getBlockAt(11, 22, 33); 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, "/iris jigsaw status"));
assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/msg owner hello")); assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/msg owner hello"));
assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/execute run say bypass")); 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( assertTrue(JigsawStudioProtection.blocksNonEditableWorkcellMutation(
true, "/fill 0 0 0 1 1 1 stone")); true, "/fill 0 0 0 1 1 1 stone"));
assertTrue(JigsawStudioProtection.blocksNonEditableWorkcellMutation(true, "//paste -a")); assertTrue(JigsawStudioProtection.blocksNonEditableWorkcellMutation(true, "//paste -a"));
@@ -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<J> 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<UUID, Object> studios = (Map<UUID, Object>) 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);
}
}
@@ -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<JigsawStudioToolboxMenu.ToolboxTool> 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<JigsawStudioMenuState.Workcell> 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());
}
}