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
+4762 -430

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.export.VanillaJigsawExportFormat;
import art.arcane.iris.structure.export.VanillaJigsawExportRequest;
import art.arcane.iris.structure.export.VanillaJigsawExportSettings;
import art.arcane.iris.structure.export.VanillaJigsawExportSource;
import art.arcane.iris.structure.graph.PlanarJigsawWorkcellResolver;
import art.arcane.iris.generation.terrain.IrisDimension;
@@ -437,13 +438,14 @@ public class CommandJigsaw implements DirectorExecutor {
}
VanillaJigsawExportRequest request;
try {
request = VanillaJigsawExportRequest.builder(
VanillaJigsawExportSource.forData(
context.request().source(), context.request().structureKey()), destination)
VanillaJigsawExportSource source = VanillaJigsawExportSource.forData(
context.request().source(), context.request().structureKey());
request = VanillaJigsawExportRequest.builder(source, destination)
.namespace(namespace)
.resourcePath(context.request().structureKey())
.format(exportFormat)
.replaceExisting(replace)
.settings(VanillaJigsawExportSettings.runtimeDefaults(source.loadStructure()))
.build();
} catch (IllegalArgumentException exception) {
sendError(exception.getMessage());
@@ -1,5 +1,8 @@
package art.arcane.iris.structure.export;
import art.arcane.iris.pack.datapack.DataVersion;
import art.arcane.iris.structure.placement.IrisStructure;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -41,6 +44,13 @@ public final class VanillaJigsawExportSettings {
return builder().build();
}
public static VanillaJigsawExportSettings runtimeDefaults(IrisStructure structure) {
if (structure == null || DataVersion.getRuntime() != DataVersion.V1_21_1) {
return defaults();
}
return builder().maxDistanceVertical(structure.getMaxSizeChunks() * 16).build();
}
public List<String> 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.")
private String finalState = "minecraft:air";
@Description("Signed priority for selecting this connector within its piece. Higher values are processed before lower values; equal priorities preserve authored order.")
@Description("Signed priority for selecting this connector within its piece. Higher values are processed before lower values; equal priorities preserve authored order. A rotatable candidate piece applies this order within each of its cardinal rotations, which it tries in random order.")
private int selectionPriority = 0;
@Description("Signed priority assigned to a child piece attached through this connector. Higher-priority child pieces expand first; equal priorities preserve attachment order.")
@@ -4,7 +4,7 @@ import art.arcane.volmlib.util.documentation.Description;
@Description("The orientation-independent connector shape represented by a planar jigsaw workcell.")
public enum IrisJigsawWorkcellArchetype {
@Description("Pieces with no horizontal connectors: sealed filler cells that terminate nothing and are excluded from the default piece pool.")
@Description("Pieces with no horizontal connectors. Nothing can attach to them, so they generate only as standalone start pieces; new projects list the default Blank in the start pool and exclude it from the piece pool.")
BLANK(0),
@Description("One-connector dead-end pieces; the Studio bay seeds a single north connector and new projects mark these as terminal caps.")
@@ -218,7 +218,7 @@ public final class StructureAssembler {
+ "' references missing connector pool '" + connector.pool + "'");
}
AttachmentResult attachment = attachOne(
AttachmentResult attachment = attach(
pending,
placed,
placementCounts,
@@ -229,7 +229,9 @@ public final class StructureAssembler {
depth,
selectedTheme,
rng);
if (attachment.state() != AttachmentState.TERMINATED) {
consume(consumedConnectors, connector.sourcePiece(), connector.sourceConnector());
}
if (attachment.state() == AttachmentState.FAILED) {
return StructureAssemblyResult.failed(
StructureAssemblyStatus.FAILED_UNCAPPED,
@@ -250,6 +252,49 @@ public final class StructureAssembler {
return StructureAssemblyResult.complete(placed, selectedTheme);
}
private AttachmentResult attach(
Deque<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(
Deque<PieceExpansion> pending,
KList<PlacedStructurePiece> placed,
@@ -260,6 +305,7 @@ public final class StructureAssembler {
OpenConnector connector,
int depth,
String selectedTheme,
boolean neighborClosures,
RNG rng
) {
if (pool.getPieces() == null) {
@@ -281,6 +327,7 @@ public final class StructureAssembler {
selectedTheme,
false,
!fallbackRequired,
neighborClosures,
rng);
if (primary != AttachmentState.NO_MATCH) {
return AttachmentResult.success(primary);
@@ -322,6 +369,7 @@ public final class StructureAssembler {
selectedTheme,
fallbackRequired,
!fallbackRequired,
neighborClosures,
rng);
if (fallbackState != AttachmentState.NO_MATCH) {
return AttachmentResult.success(fallbackState);
@@ -352,9 +400,14 @@ public final class StructureAssembler {
String selectedTheme,
boolean terminalOnly,
boolean allowEmpty,
boolean neighborClosures,
RNG rng
) {
int ruleDepth = Math.min(connector.pieceDepth, structure.getMaxDepth());
if (connector.targetName == null) {
throw assemblyFailure("open connector from pool '" + connector.pool + "' has no target name");
}
IrisDirection needed = connector.facing.reverse();
for (IrisJigsawPieceEntry entry : weightedOrder(
pool,
selectedTheme,
@@ -391,21 +444,16 @@ public final class StructureAssembler {
requireConnector(pieceConnector, pieceName);
}
List<IrisJigsawConnector> candidateConnectors = orderedConnectors(piece.getConnectors(), null);
int[] rotations = piece.isRotatable() ? shuffledRotations(rng) : NO_ROTATION;
for (int yDeg : rotations) {
IrisObjectRotation rot = IrisObjectRotation.of(0, yDeg, 0);
for (IrisJigsawConnector cb : candidateConnectors) {
if (connector.targetName == null) {
throw assemblyFailure("open connector from pool '" + connector.pool + "' has no target name");
}
if (!Objects.equals(connector.channel, cb.getChannel())) {
continue;
}
if (!cb.getName().equals(connector.targetName)) {
continue;
}
IrisDirection needed = connector.facing.reverse();
int[] rotations = piece.isRotatable() ? rotationCandidates(connector.joint, rng) : NO_ROTATION;
for (int yDeg : rotations) {
IrisObjectRotation rot = IrisObjectRotation.of(0, yDeg, 0);
IrisDirection rotatedFace = rot.rotate(cb.getDirection());
if (rotatedFace != needed) {
continue;
@@ -431,7 +479,8 @@ public final class StructureAssembler {
consumedConnectors,
connector,
candidate,
cb);
cb,
neighborClosures);
if (closures == null) {
continue;
}
@@ -504,9 +553,10 @@ public final class StructureAssembler {
Map<PlacedStructurePiece, Set<IrisJigsawConnector>> consumedConnectors,
OpenConnector attachmentSource,
PlacedStructurePiece candidatePiece,
IrisJigsawConnector attachmentConnector
IrisJigsawConnector attachmentConnector,
boolean neighborClosures
) {
if (!structure.isRequireCaps()) {
if (!neighborClosures) {
return List.of();
}
List<ConnectorClosure> closures = new ArrayList<>();
@@ -1025,10 +1075,7 @@ public final class StructureAssembler {
return false;
}
private int[] rotationCandidates(JigsawJoint joint, RNG rng) {
if (joint == JigsawJoint.ALIGNED) {
return Y_DEGREES;
}
private int[] shuffledRotations(RNG rng) {
int[] shuffled = {0, 90, 180, 270};
for (int i = shuffled.length - 1; i > 0; i--) {
int j = rng.i(i + 1);
@@ -1,12 +1,11 @@
package art.arcane.iris.studio.generation;
import art.arcane.iris.studio.jigsaw.JigsawPlanarDirection;
import art.arcane.iris.studio.jigsaw.JigsawPlanarTopology;
import art.arcane.iris.studio.jigsaw.JigsawStudioActivation;
import art.arcane.iris.studio.jigsaw.JigsawStudioBay;
import art.arcane.iris.studio.jigsaw.JigsawStudioBounds;
import art.arcane.iris.studio.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.studio.jigsaw.JigsawStudioControlPosition;
import art.arcane.iris.studio.jigsaw.JigsawStudioFloorGlyph;
import art.arcane.iris.studio.jigsaw.JigsawStudioLayout;
import art.arcane.iris.studio.jigsaw.JigsawStudioSession;
import art.arcane.iris.studio.jigsaw.JigsawStudioVariant;
@@ -51,9 +50,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
private final NativeBlockState lightFloor;
private final NativeBlockState darkFloor;
private final NativeBlockState frame;
private final NativeBlockState topologyBase;
private final NativeBlockState topologyPath;
private final NativeBlockState connectorCap;
private final FloorPalette floorPalette;
private final NativeBlockState invalidMarker;
private final NativeBlockState controlChest;
private final FinalStateRenderer finalStateRenderer;
@@ -79,9 +76,13 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
B.getState("minecraft:smooth_stone"),
B.getState("minecraft:polished_deepslate"),
B.getState("minecraft:white_concrete"),
new FloorPalette(
B.getState("minecraft:light_gray_wool"),
B.getState("minecraft:red_wool"),
B.getState("minecraft:sea_lantern"),
B.getState("minecraft:verdant_froglight"),
B.getState("minecraft:ochre_froglight"),
B.getState("minecraft:pearlescent_froglight")),
B.getState("minecraft:red_stained_glass"),
B.getState("minecraft:chest[facing=south,type=single,waterlogged=false]"),
JigsawStudioGenerator::rotatedFinalState
@@ -95,9 +96,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
NativeBlockState lightFloor,
NativeBlockState darkFloor,
NativeBlockState frame,
NativeBlockState topologyBase,
NativeBlockState topologyPath,
NativeBlockState connectorCap,
FloorPalette floorPalette,
NativeBlockState invalidMarker,
NativeBlockState controlChest,
FinalStateRenderer finalStateRenderer
@@ -108,9 +107,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
this.lightFloor = Objects.requireNonNull(lightFloor, "Jigsaw Studio light floor block");
this.darkFloor = Objects.requireNonNull(darkFloor, "Jigsaw Studio dark floor block");
this.frame = Objects.requireNonNull(frame, "Jigsaw Studio frame block");
this.topologyBase = Objects.requireNonNull(topologyBase, "Jigsaw Studio topology base block");
this.topologyPath = Objects.requireNonNull(topologyPath, "Jigsaw Studio topology path block");
this.connectorCap = Objects.requireNonNull(connectorCap, "Jigsaw Studio connector cap block");
this.floorPalette = Objects.requireNonNull(floorPalette, "Jigsaw Studio floor palette");
this.invalidMarker = Objects.requireNonNull(invalidMarker, "Jigsaw Studio invalid marker block");
this.controlChest = Objects.requireNonNull(controlChest, "Jigsaw Studio control chest block");
this.finalStateRenderer = Objects.requireNonNull(
@@ -165,6 +162,21 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
return renderedBays.computeIfAbsent(key, ignored -> buildRenderedBay(activeWorkcell, variant));
}
public JigsawStudioFloorGlyph floorGlyph(JigsawStudioBay workcell) {
return glyphFor(renderBay(workcell));
}
public NativeBlockState floorState(JigsawStudioFloorGlyph.Cell cell) {
return switch (Objects.requireNonNull(cell, "Jigsaw Studio floor glyph cell")) {
case BASE -> floorPalette.base();
case LINE -> floorPalette.line();
case HORIZONTAL_SOCKET -> floorPalette.horizontalSocket();
case UP_SOCKET -> floorPalette.upSocket();
case DOWN_SOCKET -> floorPalette.downSocket();
case UP_AND_DOWN_SOCKET -> floorPalette.upAndDownSocket();
};
}
public void invalidateRender(String workcellId) {
if (workcellId != null) {
renderedBays.keySet().removeIf(key -> key.workcellId().equals(workcellId));
@@ -215,10 +227,8 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
continue;
}
paintCage(terrainChunk, workcell, chunkWorldX, chunkWorldZ);
if (workcell.archetype().isPresent()) {
paintTopologyGlyph(terrainChunk, workcell, chunkWorldX, chunkWorldZ);
}
RenderedBay renderedBay = renderBay(workcell);
paintFloorGlyph(terrainChunk, workcell, glyphFor(renderedBay), chunkWorldX, chunkWorldZ);
if (!renderedBay.valid()) {
paintInvalidBay(terrainChunk, workcell, chunkWorldX, chunkWorldZ);
continue;
@@ -345,85 +355,44 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
}
}
private void paintTopologyGlyph(
private void paintFloorGlyph(
TerrainChunk terrainChunk,
JigsawStudioBay workcell,
JigsawStudioFloorGlyph glyph,
int chunkWorldX,
int chunkWorldZ
) {
JigsawPlanarTopology topology = workcell.topology().orElseThrow();
JigsawStudioBounds bounds = workcell.bounds();
int minimumX = Math.max(bounds.originX(), chunkWorldX);
int maximumX = Math.min(bounds.maxX(), chunkWorldX + 15);
int minimumZ = Math.max(bounds.originZ(), chunkWorldZ);
int maximumZ = Math.min(bounds.maxZ(), chunkWorldZ + 15);
if (minimumX > maximumX || minimumZ > maximumZ) {
return;
}
int glyphY = bounds.originY() - 1;
int centerX = bounds.dimensions().width() / 2;
int centerZ = bounds.dimensions().depth() / 2;
int floorY = bounds.originY() - 1;
for (int worldX = minimumX; worldX <= maximumX; worldX++) {
int localX = worldX - bounds.originX();
for (int worldZ = minimumZ; worldZ <= maximumZ; worldZ++) {
int localZ = worldZ - bounds.originZ();
NativeBlockState state = topologyGlyphState(topology, bounds.dimensions(), localX, localZ,
centerX, centerZ);
setWorldBlock(terrainChunk, worldX, glyphY, worldZ, state, chunkWorldX, chunkWorldZ);
NativeBlockState state = floorState(glyph.cell(
worldX - bounds.originX(),
worldZ - bounds.originZ()));
setWorldBlock(terrainChunk, worldX, floorY, worldZ, state, chunkWorldX, chunkWorldZ);
}
}
}
private NativeBlockState topologyGlyphState(
JigsawPlanarTopology topology,
JigsawStudioCellDimensions dimensions,
int localX,
int localZ,
int centerX,
int centerZ
) {
if (isConnectorCap(topology, dimensions, localX, localZ, centerX, centerZ)) {
return connectorCap;
private static JigsawStudioFloorGlyph glyphFor(RenderedBay renderedBay) {
if (!renderedBay.valid() || renderedBay.connectors().isEmpty()) {
return JigsawStudioFloorGlyph.empty();
}
if (isTopologyPath(topology, localX, localZ, centerX, centerZ)) {
return topologyPath;
List<JigsawStudioFloorGlyph.Marker> markers = new ArrayList<>(renderedBay.connectors().size());
for (RenderedConnector connector : renderedBay.connectors()) {
markers.add(new JigsawStudioFloorGlyph.Marker(
connector.x(),
connector.z(),
connector.connector().getDirection()));
}
return topologyBase;
}
static boolean isConnectorCap(
JigsawPlanarTopology topology,
JigsawStudioCellDimensions dimensions,
int localX,
int localZ,
int centerX,
int centerZ
) {
return topology.connects(JigsawPlanarDirection.NORTH) && localX == centerX && localZ == 0
|| topology.connects(JigsawPlanarDirection.EAST)
&& localX == dimensions.width() - 1 && localZ == centerZ
|| topology.connects(JigsawPlanarDirection.SOUTH)
&& localX == centerX && localZ == dimensions.depth() - 1
|| topology.connects(JigsawPlanarDirection.WEST) && localX == 0 && localZ == centerZ;
}
static boolean isTopologyPath(
JigsawPlanarTopology topology,
int localX,
int localZ,
int centerX,
int centerZ
) {
if (topology == JigsawPlanarTopology.BLANK) {
return false;
}
boolean vertical = localX == centerX
&& (topology.connects(JigsawPlanarDirection.NORTH) && localZ <= centerZ
|| topology.connects(JigsawPlanarDirection.SOUTH) && localZ >= centerZ);
boolean horizontal = localZ == centerZ
&& (topology.connects(JigsawPlanarDirection.WEST) && localX <= centerX
|| topology.connects(JigsawPlanarDirection.EAST) && localX >= centerX);
return vertical || horizontal;
return JigsawStudioFloorGlyph.of(
renderedBay.dimensions().width(),
renderedBay.dimensions().depth(),
markers);
}
private RenderedBay buildRenderedBay(JigsawStudioBay workcell, JigsawStudioVariant variant) {
@@ -720,7 +689,7 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
private JigsawStudioBay requireWorkcell(JigsawStudioBay workcell) {
JigsawStudioBay activeWorkcell = Objects.requireNonNull(workcell, "Jigsaw Studio workcell");
if (getLayout().get(activeWorkcell.stableId()) != activeWorkcell) {
if (!activeWorkcell.equals(getLayout().get(activeWorkcell.stableId()))) {
throw new IllegalArgumentException("Workcell does not belong to this Jigsaw Studio layout");
}
return activeWorkcell;
@@ -884,6 +853,24 @@ public final class JigsawStudioGenerator extends EnginedStudioGenerator {
}
}
public record FloorPalette(
NativeBlockState base,
NativeBlockState line,
NativeBlockState horizontalSocket,
NativeBlockState upSocket,
NativeBlockState downSocket,
NativeBlockState upAndDownSocket
) {
public FloorPalette {
Objects.requireNonNull(base, "Jigsaw Studio floor base block");
Objects.requireNonNull(line, "Jigsaw Studio floor line block");
Objects.requireNonNull(horizontalSocket, "Jigsaw Studio horizontal socket block");
Objects.requireNonNull(upSocket, "Jigsaw Studio upward socket block");
Objects.requireNonNull(downSocket, "Jigsaw Studio downward socket block");
Objects.requireNonNull(upAndDownSocket, "Jigsaw Studio vertical socket block");
}
}
public record RenderedBlock(int x, int y, int z, NativeBlockState state, TileData tileData) {
public RenderedBlock {
state = Objects.requireNonNull(state, "Jigsaw Studio rendered block state");
@@ -141,6 +141,10 @@ final class JigsawStudioCapture {
if (hiddenConnector != null) {
hiddenConnector.setFinalState(blockData.getAsString());
connectors.add(CapturedConnector.from(hiddenConnector));
if (isAir(blockData.getMaterial())) {
blocks.add(new CapturedBlock(x, y, z, BukkitBlockState.of(blockData), null));
continue;
}
}
if (hiddenConnector == null && blockData instanceof Jigsaw jigsaw) {
KMap<String, Object> nbt = BukkitPlatform.serializeTile(block.getLocation());
@@ -490,7 +494,7 @@ final class JigsawStudioCapture {
};
}
private static LocalPosition forwardPosition(
static LocalPosition forwardPosition(
int x,
int y,
int z,
@@ -18,6 +18,7 @@ import art.arcane.iris.structure.object.IrisObject;
import art.arcane.iris.studio.generation.JigsawStudioGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.task.J;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.entity.Player;
@@ -122,7 +123,7 @@ final class JigsawStudioCaptureScheduler {
}
coordinator.accept(snapshot);
} catch (Throwable exception) {
coordinator.fail("Jigsaw Studio capture failed in chunk "
coordinator.failPersistent("Jigsaw Studio capture failed in chunk "
+ area.chunkX() + "," + area.chunkZ() + ": " + failureMessage(exception), exception);
}
}
@@ -219,7 +220,7 @@ final class JigsawStudioCaptureScheduler {
if (!result.successful()) {
String failure = writeFailure(result);
coordinator.recordPersistentFailure(failure, result.failure().orElse(null));
message(player, failure);
message(coordinator.notifiedPlayer(), failure);
return;
}
source.invalidateStructureResources();
@@ -234,13 +235,14 @@ final class JigsawStudioCaptureScheduler {
+ "Close and reopen this project before continuing: "
+ failureMessage(refreshFailure));
}
service.floorPainter.refresh(studio, bay.stableId());
String cleanup = result.status() == StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED
? " The graph committed, but transaction cleanup requires operator attention in the console."
: "";
String mutationNotice = unchanged ? "" : " Newer edits remain unsaved.";
message(player, "Saved piece '" + coordinator.saveIdentity().variantKey() + "' and object '"
+ assembly.objectKey() + "' atomically." + mutationNotice + cleanup);
playSaveSound(player);
playSaveSound(coordinator.notifiedPlayer());
if (unchanged) {
service.evaluator.scheduleEvaluation(studio);
}
@@ -250,12 +252,19 @@ final class JigsawStudioCaptureScheduler {
} catch (Throwable exception) {
String failure = "Jigsaw Studio save failed: " + failureMessage(exception);
coordinator.recordPersistentFailure(failure, exception);
message(player, failure);
message(coordinator.notifiedPlayer(), failure);
} finally {
coordinator.complete();
}
}
static Player notificationTarget(Player player, UUID ownerId) {
if (player != null || ownerId == null) {
return player;
}
return Bukkit.getPlayer(ownerId);
}
static Set<Long> requiredChunks(
JigsawStudioBay bay,
JigsawStudioGenerator.RenderedBay rendered
@@ -368,6 +377,10 @@ final class JigsawStudioCaptureScheduler {
return work.player();
}
private Player notifiedPlayer() {
return notificationTarget(work.player(), work.studio().generator().getRequest().ownerId());
}
private UUID requestId() {
return work.requestId();
}
@@ -427,7 +440,7 @@ final class JigsawStudioCaptureScheduler {
}
recordPersistentFailure(failure, exception);
complete();
message(player(), failure);
message(notifiedPlayer(), failure);
}
private void recordPersistentFailure(String failure, Throwable exception) {
@@ -40,20 +40,42 @@ final class JigsawStudioChunkWriter {
private JigsawStudioChunkWriter() {
}
static void restoreConnectorChunk(
static void restoreChunkPositions(
World world,
JigsawStudioBay workcell,
List<JigsawStudioGenerator.RenderedConnector> connectors,
Set<LocalPosition> positions,
Map<LocalPosition, JigsawStudioGenerator.RenderedConnector> renderedConnectors,
Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> renderedBlocks,
boolean connectorsVisible
) throws IOException {
JigsawStudioBounds bounds = workcell.bounds();
for (JigsawStudioGenerator.RenderedConnector connector : connectors) {
LocalPosition position = new LocalPosition(connector.x(), connector.y(), connector.z());
BlockData air = null;
for (LocalPosition position : positions) {
Block target = world.getBlockAt(
bounds.originX() + connector.x(),
bounds.originY() + connector.y(),
bounds.originZ() + connector.z());
bounds.originX() + position.x(),
bounds.originY() + position.y(),
bounds.originZ() + position.z());
JigsawStudioGenerator.RenderedConnector connector = renderedConnectors.get(position);
JigsawStudioGenerator.RenderedBlock renderedBlock = renderedBlocks.get(position);
if (connector != null) {
restoreConnector(target, connector, renderedBlock, connectorsVisible);
} else if (renderedBlock != null) {
restoreRenderedBlock(target, renderedBlock);
} else {
if (air == null) {
air = Material.AIR.createBlockData();
}
target.setBlockData(air, false);
}
}
}
private static void restoreConnector(
Block target,
JigsawStudioGenerator.RenderedConnector connector,
JigsawStudioGenerator.RenderedBlock renderedBlock,
boolean connectorsVisible
) throws IOException {
if (connectorsVisible) {
BlockData marker;
try {
@@ -65,35 +87,39 @@ final class JigsawStudioChunkWriter {
}
target.setBlockData(marker, false);
BukkitPlatform.deserializeTile(markerNbt(connector.connector()), target.getLocation());
continue;
return;
}
JigsawStudioGenerator.RenderedBlock renderedBlock = renderedBlocks.get(position);
BlockData restored;
if (renderedBlock != null) {
if (renderedBlock.state().isCustom()
|| !(renderedBlock.state().nativeHandle() instanceof BlockData blockData)) {
throw new IOException("Saved connector block '" + renderedBlock.state().key()
+ "' cannot be restored directly in Bukkit Studio");
restoreRenderedBlock(target, renderedBlock);
return;
}
restored = blockData;
} else {
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);
if (renderedBlock != null && renderedBlock.tileData() != null) {
}
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.isApplicable(target.getBlockData()) || !tileData.toBukkitTry(target)) {
throw new IOException("Saved connector tile data could not be restored at "
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());
}
}
}
}
static String validateMaterialization(JigsawStudioGenerator.RenderedBay rendered) {
if (!rendered.valid()) {
@@ -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);
boolean lastVariant = workcell.variants().size() <= 1;
boolean deletionAvailable = variant.owned() && !variant.active() && !lastVariant;
boolean spatial = state.mode() == JigsawStudioMode.SPATIAL_JIGSAW;
boolean lastVariant = spatial
? state.workcells().stream().mapToInt(cell -> cell.variants().size()).sum() <= 1
: workcell.variants().size() <= 1;
boolean deletionAvailable = state.canDeleteVariant(workcell, variant);
boolean confirmed = deletionAvailable && controller.isDeleteConfirmed(
window.getViewer().getUniqueId(),
state.requestId(),
@@ -170,11 +173,11 @@ final class JigsawStudioDetailsMenu {
deletionAvailable ? confirmed ? Material.REDSTONE : Material.LAVA_BUCKET : Material.BARRIER,
deletionAvailable
? confirmed ? ChatColor.RED + "Confirm Delete Variant" : ChatColor.RED + "Delete Variant"
: variant.active()
? ChatColor.GRAY + "Loaded Variant Cannot Be Deleted"
: !variant.owned()
? ChatColor.GRAY + "Deletion is Read-only"
: lastVariant
? ChatColor.GRAY + "Last Variant Cannot Be Deleted"
: ChatColor.GRAY + "Deletion is Read-only")
: ChatColor.GRAY + "Loaded Variant Cannot Be Deleted")
.setEnchanted(confirmed);
if (deletionAvailable) {
delete.addLore(confirmed
@@ -186,10 +189,10 @@ final class JigsawStudioDetailsMenu {
workcell.stableId(),
variant.pieceKey(),
page));
} else if (variant.active()) {
delete.addLore(ChatColor.GRAY + "Load another variant in this workcell first.");
} else if (lastVariant) {
} else if (variant.owned() && lastVariant) {
delete.addLore(ChatColor.GRAY + "Create another variant before deleting this one.");
} else if (variant.owned()) {
delete.addLore(ChatColor.GRAY + "Load another variant in this workcell first.");
}
window.setElement(4, 0, delete);
}
@@ -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.SPATIAL_JIGSAW;
JigsawStudioVariantCatalog catalog = catalog(data, structure, mode);
IrisPosition configuredCell = structure.getCellSize();
JigsawStudioCellDimensions dimensions = configuredCell == null
? new JigsawStudioCellDimensions(15, 15, 15)
: new JigsawStudioCellDimensions(
Math.max(1, configuredCell.getX()),
Math.max(1, configuredCell.getY()),
Math.max(1, configuredCell.getZ()));
JigsawStudioCellDimensions dimensions = configuredCellDimensions(structure);
if (mode == JigsawStudioMode.SPATIAL_JIGSAW) {
dimensions = expandSpatialDimensions(data, catalog, dimensions);
return JigsawStudioLayout.createSpatial(
@@ -129,26 +123,54 @@ public final class JigsawStudioGraphMapper {
if (data.getObjectLoader() == null) {
return configured;
}
int width = configured.width();
int height = configured.height();
int depth = configured.depth();
SpatialExtent extent = SpatialExtent.of(configured);
for (JigsawStudioVariant variant : catalog.spatialVariants()) {
IrisObject object = data.getObjectLoader().load(variant.objectKey());
if (object == null) {
if (object != null) {
extent = extent.include(object.getW(), object.getH(), object.getD(), variant.rotatable());
}
}
return new JigsawStudioCellDimensions((int) extent.width(), (int) extent.height(), (int) extent.depth());
}
static JigsawStudioCellDimensions configuredCellDimensions(IrisStructure structure) {
IrisPosition configuredCell = structure.getCellSize();
return configuredCell == null
? new JigsawStudioCellDimensions(15, 15, 15)
: new JigsawStudioCellDimensions(
Math.max(1, configuredCell.getX()),
Math.max(1, configuredCell.getY()),
Math.max(1, configuredCell.getZ()));
}
static String spatialFootprintFailure(
IrisData data,
JigsawStudioCellDimensions configured,
JigsawStudioVariantCatalog catalog,
SpatialChange change
) {
SpatialExtent extent = SpatialExtent.of(configured);
for (JigsawStudioVariant variant : catalog.spatialVariants()) {
if (variant.pieceKey().equals(change.pieceKey())) {
continue;
}
int objectWidth = object.getW();
int objectDepth = object.getD();
if (variant.rotatable()) {
int horizontalSpan = Math.max(objectWidth, objectDepth);
objectWidth = horizontalSpan;
objectDepth = horizontalSpan;
IrisObject object = data.getObjectLoader() == null ? null : data.getObjectLoader().load(variant.objectKey());
if (object != null) {
extent = extent.include(object.getW(), object.getH(), object.getD(), variant.rotatable());
}
width = Math.max(width, objectWidth);
height = Math.max(height, object.getH());
depth = Math.max(depth, objectDepth);
}
return new JigsawStudioCellDimensions(width, height, depth);
JigsawStudioCellDimensions changed = change.dimensions();
extent = extent.include(changed.width(), changed.height(), changed.depth(), change.rotatable());
long volume = extent.width() * extent.height() * extent.depth();
if (extent.width() <= JigsawStudioCellDimensions.MAX_HORIZONTAL_AXIS
&& extent.depth() <= JigsawStudioCellDimensions.MAX_HORIZONTAL_AXIS
&& extent.height() <= JigsawStudioCellDimensions.MAX_HEIGHT
&& volume <= JigsawStudioCellDimensions.MAX_VOLUME) {
return "";
}
return "Spatial workcells would grow to " + extent.width() + "x" + extent.height() + "x" + extent.depth()
+ " (" + volume + " blocks), past the " + JigsawStudioCellDimensions.MAX_VOLUME
+ "-block workcell limit. Make this variant smaller or keep rotation disabled.";
}
private static void traversePools(
@@ -378,4 +400,25 @@ public final class JigsawStudioGraphMapper {
return new OwnedResources(Set.of(), Set.of(), Set.of(), false);
}
}
record SpatialChange(String pieceKey, JigsawStudioCellDimensions dimensions, boolean rotatable) {
SpatialChange {
Objects.requireNonNull(pieceKey, "Spatial change piece key");
Objects.requireNonNull(dimensions, "Spatial change dimensions");
}
}
private record SpatialExtent(long width, long height, long depth) {
static SpatialExtent of(JigsawStudioCellDimensions dimensions) {
return new SpatialExtent(dimensions.width(), dimensions.height(), dimensions.depth());
}
SpatialExtent include(int objectWidth, int objectHeight, int objectDepth, boolean rotatable) {
int horizontalSpan = Math.max(objectWidth, objectDepth);
return new SpatialExtent(
Math.max(width, rotatable ? horizontalSpan : objectWidth),
Math.max(height, objectHeight),
Math.max(depth, rotatable ? horizontalSpan : objectDepth));
}
}
}
@@ -199,6 +199,7 @@ final class JigsawStudioGraphMutations {
for (JigsawStudioBay workcell : result.layout().bays()) {
service.playerContext.refreshWorkcellContext(studio.worldId(), workcell.stableId());
}
service.floorPainter.refreshAll(studio);
service.evaluator.scheduleEvaluation(studio);
message(player, result.message());
finishGraphMutation(requestId);
@@ -273,15 +274,20 @@ final class JigsawStudioGraphMutations {
String failure,
Throwable cause
) {
boolean scheduled = J.runEntity(
player,
() -> completeLiveRelayout(player, studio, requestId, result, failure, cause));
if (!scheduled) {
Runnable abandon = () -> {
service.saveLifecycle.reopenRequiredRequests.add(requestId);
finishGraphMutation(requestId);
if (cause != null) {
IrisLogging.reportError(cause);
}
};
boolean scheduled = J.runEntity(
player,
() -> completeLiveRelayout(player, studio, requestId, result, failure, cause),
0,
abandon);
if (!scheduled) {
abandon.run();
}
}
@@ -15,6 +15,7 @@ public final class JigsawStudioLayout {
public static final int PLANAR_GAP = 1;
public static final int MAX_VARIANTS = 512;
public static final String SPATIAL_WORKCELL_ID = "workcell/spatial";
public static final int COLUMN_HEADROOM = 4;
private static final int FIRST_ORIGIN = 16;
private static final JigsawStudioControlPosition CONTROL_POSITION =
@@ -208,6 +209,18 @@ public final class JigsawStudioLayout {
return null;
}
public JigsawStudioBay findColumn(int worldX, int worldY, int worldZ) {
for (JigsawStudioBay bay : bays) {
JigsawStudioBounds bounds = bay.bounds();
if (worldX >= bounds.originX() && worldX <= bounds.maxX()
&& worldZ >= bounds.originZ() && worldZ <= bounds.maxZ()
&& worldY >= bounds.originY() - 1 && worldY <= bounds.maxY() + COLUMN_HEADROOM) {
return bay;
}
}
return null;
}
public List<JigsawStudioVariant> variants(JigsawStudioBay workcell) {
JigsawStudioBay activeWorkcell = requireWorkcell(workcell);
if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) {
@@ -276,7 +289,7 @@ public final class JigsawStudioLayout {
private JigsawStudioBay requireWorkcell(JigsawStudioBay workcell) {
JigsawStudioBay activeWorkcell = Objects.requireNonNull(workcell, "Jigsaw Studio workcell");
if (byStableId.get(activeWorkcell.stableId()) != activeWorkcell) {
if (!activeWorkcell.equals(byStableId.get(activeWorkcell.stableId()))) {
throw new IllegalArgumentException("Workcell does not belong to this Jigsaw Studio layout");
}
return activeWorkcell;
@@ -6,6 +6,7 @@ import art.arcane.volmlib.util.inventorygui.UIWindow;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -27,6 +28,9 @@ import static art.arcane.iris.studio.jigsaw.JigsawStudioMenuFormat.workcellStatu
import static art.arcane.iris.studio.jigsaw.JigsawStudioMenuFormat.yesNo;
final class JigsawStudioMainMenu {
static final int WORKCELL_ROW_CAPACITY = 6;
static final int WORKCELLS_PER_PAGE = 5;
private final JigsawStudioMenuController controller;
JigsawStudioMainMenu(JigsawStudioMenuController controller) {
@@ -103,11 +107,74 @@ final class JigsawStudioMainMenu {
}
private void addWorkcells(UIWindow window, JigsawStudioMenuState state, String selectedWorkcellId) {
int count = state.workcells().size();
List<JigsawStudioMenuState.Workcell> workcells = state.workcells();
int viewedIndex = 0;
for (int index = 0; index < workcells.size(); index++) {
if (workcells.get(index).stableId().equals(selectedWorkcellId)) {
viewedIndex = index;
}
}
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++) {
JigsawStudioMenuState.Workcell workcell = state.workcells().get(index);
boolean selected = workcell.stableId().equals(selectedWorkcellId);
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),
@@ -146,8 +213,7 @@ final class JigsawStudioMainMenu {
state.requestId(),
workcell.stableId()),
1));
window.setElement(start + index, 0, element);
}
return element;
}
private void addVariants(
@@ -283,4 +349,11 @@ final class JigsawStudioMainMenu {
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.setElement(4, 5, toolbox);
}
record WorkcellRow(List<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.spi.IrisLogging;
import art.arcane.iris.world.task.J;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import java.io.IOException;
@@ -24,7 +26,7 @@ import java.util.concurrent.atomic.AtomicReference;
import static art.arcane.iris.studio.jigsaw.JigsawStudioCapture.chunkIntersections;
import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.hydrateChunk;
import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.restoreConnectorChunk;
import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.restoreChunkPositions;
import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.validateMaterialization;
import static art.arcane.iris.studio.jigsaw.JigsawStudioChunkWriter.writeMaterializedChunk;
import static art.arcane.iris.studio.jigsaw.JigsawStudioService.chunkKey;
@@ -139,6 +141,51 @@ final class JigsawStudioMaterializer {
}
}
ConnectorEditReservation beginConnectorEdit(ActiveStudio studio, String workcellId) {
UUID requestId = studio.generator().getRequest().requestId();
service.tileWatcher.finalizeJigsawTileWatches(requestId);
JigsawStudioSession session = studio.generator().getSession();
synchronized (service.saveLifecycleLock) {
if (!service.isCurrentRequest(studio, requestId)) {
return ConnectorEditReservation.blocked("This Jigsaw Studio session is no longer active.");
}
if (service.saveLifecycle.closingRequests.contains(requestId)) {
return ConnectorEditReservation.blocked("This Jigsaw Studio is closing and cannot edit connectors.");
}
if (service.saveLifecycle.reopenRequiredRequests.contains(requestId)) {
return ConnectorEditReservation.blocked("Close and reopen Jigsaw Studio before editing connectors.");
}
if (session.layout().get(workcellId) == null) {
return ConnectorEditReservation.blocked("The targeted workcell no longer exists.");
}
JigsawStudioSession.WorkcellSnapshot snapshot = session.workcellSnapshot(workcellId);
if (snapshot.dirty() || snapshot.saveInProgress()
|| service.saveLifecycle.savesInProgress.contains(requestId)) {
return ConnectorEditReservation.busy("Saving your latest edits before changing connectors...");
}
if (service.graphMutations.graphMutationsInProgress.contains(requestId)) {
return ConnectorEditReservation.busy("Waiting for the current graph update before changing connectors...");
}
if (service.saveLifecycle.exportsInProgress.contains(requestId)) {
return ConnectorEditReservation.busy("Waiting for the current export before changing connectors...");
}
if (session.operationInProgress()) {
return ConnectorEditReservation.busy("Waiting for the current workcell operation before changing connectors...");
}
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
return ConnectorEditReservation.busy("Waiting for the vanilla jigsaw-block editor to close...");
}
if (!materializationsInProgress.add(requestId)) {
return ConnectorEditReservation.busy("Waiting for the current variant load or connector edit to finish...");
}
return ConnectorEditReservation.started();
}
}
void finishConnectorEdit(UUID requestId) {
finishMaterialization(requestId);
}
boolean scheduleConnectorRepair(
Player player,
ActiveStudio studio,
@@ -146,39 +193,55 @@ final class JigsawStudioMaterializer {
JigsawStudioGenerator.RenderedBay rendered,
boolean connectorsVisible
) {
Map<Long, List<JigsawStudioGenerator.RenderedConnector>> connectorsByChunk = new HashMap<>();
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()) {
int worldX = bounds.originX() + connector.x();
int worldZ = bounds.originZ() + connector.z();
connectorsByChunk.computeIfAbsent(
chunkKey(worldX >> 4, worldZ >> 4),
ignored -> new ArrayList<>()).add(connector);
renderedConnectors.put(new LocalPosition(connector.x(), connector.y(), connector.z()), connector);
}
AtomicInteger remaining = new AtomicInteger(connectorsByChunk.size());
AtomicReference<String> failure = new AtomicReference<>("");
AtomicReference<Throwable> cause = new AtomicReference<>();
AtomicBoolean scheduledAny = new AtomicBoolean(false);
Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> renderedBlocks = new HashMap<>();
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
renderedBlocks.put(new LocalPosition(block.x(), block.y(), block.z()), block);
}
for (Map.Entry<Long, List<JigsawStudioGenerator.RenderedConnector>> entry
: connectorsByChunk.entrySet()) {
int chunkX = (int) (entry.getKey() >> 32);
int chunkZ = (int) entry.getKey().longValue();
List<ChunkCaptureArea> areas = chunkIntersections(variantBounds);
AtomicInteger remaining = new AtomicInteger(areas.size());
AtomicInteger cleared = new AtomicInteger();
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, () -> {
try {
if (!studio.world().isChunkLoaded(chunkX, chunkZ)) {
throw new IOException("connector chunk " + chunkX + "," + chunkZ
+ " 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(),
workcell,
entry.getValue(),
positions,
renderedConnectors,
renderedBlocks,
connectorsVisible);
cleared.addAndGet(strays.size());
} catch (Throwable exception) {
failure.compareAndSet("", failureMessage(exception));
cause.compareAndSet(null, exception);
@@ -188,6 +251,7 @@ final class JigsawStudioMaterializer {
player,
studio,
rendered.connectors().size(),
cleared.get(),
failure.get(),
cause.get());
}
@@ -202,6 +266,7 @@ final class JigsawStudioMaterializer {
player,
studio,
rendered.connectors().size(),
cleared.get(),
failure.get(),
cause.get());
}
@@ -213,31 +278,61 @@ final class JigsawStudioMaterializer {
return scheduledAny.get();
}
static Set<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(
Player player,
ActiveStudio studio,
int connectorCount,
int clearedMarkers,
String failure,
Throwable cause
) {
UUID requestId = studio.generator().getRequest().requestId();
boolean scheduled = J.runEntity(player, () -> {
Runnable release = () -> {
finishMaterialization(requestId);
if (cause != null) {
IrisLogging.reportError(cause);
}
};
boolean scheduled = J.runEntity(player, () -> {
release.run();
if (!failure.isEmpty()) {
message(player, "Connector reset was incomplete: " + failure + ". Retry after visiting the workcell.");
return;
}
message(player, "Restored " + connectorCount
+ " connector block(s) from the last saved iteration.");
});
+ " connector block(s) from the last saved iteration"
+ (clearedMarkers == 0 ? "." : " and cleared " + clearedMarkers + " unsaved marker(s)."));
}, 0, release);
if (!scheduled) {
finishMaterialization(requestId);
if (cause != null) {
IrisLogging.reportError(cause);
}
release.run();
}
}
@@ -474,6 +569,7 @@ final class JigsawStudioMaterializer {
try {
work.studio().generator().invalidateRender(work.workcell().stableId());
work.studio().replacePopulation(work.workcell(), work.target(), "", true);
service.floorPainter.refresh(work.studio(), work.workcell().stableId());
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), work.connectorVisibilityChange()
? "Connector blocks are now "
@@ -564,6 +660,7 @@ final class JigsawStudioMaterializer {
work.studio().generator().invalidateRender(work.workcell().stableId());
if (rollbackFailure.isEmpty() && released) {
work.studio().replacePopulation(work.workcell(), work.previous(), "", true);
service.floorPainter.refresh(work.studio(), work.workcell().stableId());
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), candidateFailure + " The previous variant was restored.");
return;
@@ -572,6 +669,7 @@ final class JigsawStudioMaterializer {
? "The previous variant could not be restored because the Studio session changed."
: rollbackFailure;
work.studio().replacePopulation(work.workcell(), work.previous(), failure, false);
service.floorPainter.refresh(work.studio(), work.workcell().stableId());
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), candidateFailure + " Automatic rollback also failed: " + failure
+ " Do not save this workcell until it is reopened.");
@@ -614,6 +712,31 @@ final class JigsawStudioMaterializer {
}
}
enum ConnectorEditStatus {
STARTED,
BUSY,
BLOCKED
}
record ConnectorEditReservation(ConnectorEditStatus status, String message) {
ConnectorEditReservation {
Objects.requireNonNull(status, "Jigsaw Studio connector edit status");
message = message == null ? "" : message;
}
static ConnectorEditReservation started() {
return new ConnectorEditReservation(ConnectorEditStatus.STARTED, "");
}
static ConnectorEditReservation busy(String message) {
return new ConnectorEditReservation(ConnectorEditStatus.BUSY, message);
}
static ConnectorEditReservation blocked(String message) {
return new ConnectorEditReservation(ConnectorEditStatus.BLOCKED, message);
}
}
public record VariantReloadRequest(
String workcellId,
JigsawStudioGenerator.RenderedBay previous
@@ -857,7 +857,7 @@ public final class JigsawStudioMenuController {
}
JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId);
JigsawStudioMenuState.Variant target = variant(workcell, pieceKey);
if (target == null || !target.owned() || target.active()) {
if (!current.get().canDeleteVariant(workcell, target)) {
stale(player);
return;
}
@@ -54,6 +54,20 @@ public record JigsawStudioMenuState(
}
}
public boolean canDeleteVariant(Workcell workcell, Variant variant) {
if (workcell == null || variant == null || !variant.owned()) {
return false;
}
if (mode == JigsawStudioMode.SPATIAL_JIGSAW) {
int variants = 0;
for (Workcell current : workcells) {
variants += current.variants().size();
}
return variants > 1;
}
return !variant.active() && workcell.variants().size() > 1;
}
public Workcell selectedWorkcell() {
if (selectedWorkcellId.isEmpty()) {
return null;
@@ -59,7 +59,7 @@ final class JigsawStudioPlayerContext {
return;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().findAt(
JigsawStudioBay workcell = session.layout().findColumn(
target.getBlockX(), target.getBlockY(), target.getBlockZ());
boolean owner = ownerMatches(player, studio);
selectEnteredWorkcell(session, workcell, owner);
@@ -36,13 +36,9 @@ import java.util.Objects;
public final class JigsawStudioProjectCreator {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final List<IrisDirection> SPATIAL_CONNECTOR_ORDER = List.of(
IrisDirection.NORTH_NEGATIVE_Z,
IrisDirection.SOUTH_POSITIVE_Z,
IrisDirection.EAST_POSITIVE_X,
IrisDirection.WEST_NEGATIVE_X,
IrisDirection.UP_POSITIVE_Y,
IrisDirection.DOWN_NEGATIVE_Y);
private static final int DEFAULT_MAX_DEPTH = 7;
private static final int SPATIAL_PORTABLE_MAX_DEPTH = 4;
private static final int SPATIAL_MAXIMUM_PLACEMENTS = 4;
private JigsawStudioProjectCreator() {
}
@@ -59,7 +55,7 @@ public final class JigsawStudioProjectCreator {
StructureKey ownershipKey = new StructureKey("iris", resourceKey);
IrisStructure structure = new IrisStructure()
.setStartPool(resourceKey + "/start")
.setMaxDepth(7)
.setMaxDepth(maxDepth(options))
.setMaxSizeChunks(8)
.setMode(toModelMode(options.mode()))
.setCompatibility(toModelCompatibility(options.compatibilityTarget()))
@@ -94,6 +90,13 @@ public final class JigsawStudioProjectCreator {
return output.toByteArray();
}
private static int maxDepth(Options options) {
return options.mode() == JigsawStudioMode.SPATIAL_JIGSAW
&& options.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE
? SPATIAL_PORTABLE_MAX_DEPTH
: DEFAULT_MAX_DEPTH;
}
private static IrisJigsawMode toModelMode(JigsawStudioMode mode) {
return mode == JigsawStudioMode.PLANAR_JIGSAW
? IrisJigsawMode.PLANAR_JIGSAW
@@ -129,14 +132,11 @@ public final class JigsawStudioProjectCreator {
IrisJigsawPool piecePool = new IrisJigsawPool().setFallback(capPoolKey);
IrisJigsawPool capPool = new IrisJigsawPool();
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
String key = options.structureKey() + "/" + archetype.name().toLowerCase(Locale.ROOT);
String key = archetypeKey(options, archetype);
IrisJigsawPiece piece = planarPiece(key, piecePoolKey, size, archetype);
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
piece.getThemes().add("variant-1");
}
if (archetype == JigsawPlanarArchetype.CROSS) {
pool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
}
if (archetype != JigsawPlanarArchetype.BLANK) {
piecePool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
}
@@ -159,6 +159,8 @@ public final class JigsawStudioProjectCreator {
dimensions.depth())));
bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n");
}
pool.getPieces().add(new IrisJigsawPieceEntry(archetypeKey(options, JigsawPlanarArchetype.CROSS), 3));
pool.getPieces().add(new IrisJigsawPieceEntry(archetypeKey(options, JigsawPlanarArchetype.BLANK), 1));
capPool.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true));
bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n");
bundle.textResource("jigsaw-pools/" + capPoolKey + ".json", GSON.toJson(capPool) + "\n");
@@ -172,19 +174,17 @@ public final class JigsawStudioProjectCreator {
JigsawStudioCellDimensions dimensions = options.cellDimensions();
String piecePoolKey = options.structureKey() + "/pieces";
IrisJigsawPool piecePool = new IrisJigsawPool();
for (int connectorCount = 0; connectorCount <= SPATIAL_CONNECTOR_ORDER.size(); connectorCount++) {
String key = options.structureKey() + "/"
+ (connectorCount == 0 ? "start" : "connectors-" + connectorCount);
IrisJigsawPiece piece = spatialPiece(
key,
piecePoolKey,
dimensions,
connectorCount);
if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) {
boolean irisExtended = options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED;
for (SpatialVerticalClass vertical : SpatialVerticalClass.values()) {
for (JigsawPlanarArchetype horizontal : JigsawPlanarArchetype.values()) {
String key = archetypeKey(options, horizontal) + vertical.keySuffix();
IrisJigsawPiece piece = spatialPiece(key, piecePoolKey, dimensions, horizontal, vertical);
if (irisExtended) {
piece.getThemes().add("variant-1");
piece.setRules(new IrisJigsawPieceRules().setMaximumPlacements(SPATIAL_MAXIMUM_PLACEMENTS));
}
pool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
if (connectorCount > 0) {
if (!piece.getConnectors().isEmpty()) {
piecePool.getPieces().add(new IrisJigsawPieceEntry(key, 1));
}
bundle.resource("objects/" + key + ".iob", serialize(new IrisObject(
@@ -193,6 +193,7 @@ public final class JigsawStudioProjectCreator {
dimensions.depth())));
bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n");
}
}
piecePool.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true));
bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n");
}
@@ -201,16 +202,28 @@ public final class JigsawStudioProjectCreator {
String objectKey,
String poolKey,
JigsawStudioCellDimensions dimensions,
int connectorCount
JigsawPlanarArchetype horizontal,
SpatialVerticalClass vertical
) {
IrisJigsawPiece piece = new IrisJigsawPiece()
.setDisplayName(connectorCount + (connectorCount == 1 ? " Connector" : " Connectors"))
.setDisplayName(horizontal.displayName() + vertical.displaySuffix())
.setObject(objectKey)
.setRotatable(true)
.setRules(new IrisJigsawPieceRules().setMaximumPlacements(16));
for (int index = 0; index < connectorCount; index++) {
IrisDirection direction = SPATIAL_CONNECTOR_ORDER.get(index);
piece.getConnectors().add(new IrisJigsawConnector()
.setRotatable(true);
for (JigsawPlanarDirection planarDirection : horizontal.canonicalTopology().directions()) {
piece.getConnectors().add(spatialConnector(dimensions, planarDirection.irisDirection(), poolKey));
}
for (IrisDirection direction : vertical.directions()) {
piece.getConnectors().add(spatialConnector(dimensions, direction, poolKey));
}
return piece;
}
private static IrisJigsawConnector spatialConnector(
JigsawStudioCellDimensions dimensions,
IrisDirection direction,
String poolKey
) {
return new IrisJigsawConnector()
.setPosition(spatialConnectorPosition(dimensions, direction))
.setDirection(direction)
.setTop(direction.isVertical()
@@ -220,9 +233,7 @@ public final class JigsawStudioProjectCreator {
.setName("iris:spatial")
.setTargetName("iris:spatial")
.setJoint(JigsawJoint.ROLLABLE)
.setFinalState("minecraft:structure_void"));
}
return piece;
.setFinalState("minecraft:structure_void");
}
private static IrisPosition spatialConnectorPosition(
@@ -242,6 +253,10 @@ public final class JigsawStudioProjectCreator {
};
}
private static String archetypeKey(Options options, JigsawPlanarArchetype archetype) {
return options.structureKey() + "/" + archetype.name().toLowerCase(Locale.ROOT);
}
private static IrisJigsawPiece planarPiece(
String objectKey,
String poolKey,
@@ -291,4 +306,33 @@ public final class JigsawStudioProjectCreator {
return key;
}
}
private enum SpatialVerticalClass {
NONE("", "", List.of()),
UP("-up", " + Up", List.of(IrisDirection.UP_POSITIVE_Y)),
DOWN("-down", " + Down", List.of(IrisDirection.DOWN_NEGATIVE_Y)),
UP_DOWN("-up-down", " + Up/Down", List.of(IrisDirection.UP_POSITIVE_Y, IrisDirection.DOWN_NEGATIVE_Y));
private final String keySuffix;
private final String displaySuffix;
private final List<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) {
return !ownerMatches(ownerId, actorId)
&& !isSafeNonOwnerCommand(commandLine);
&& isMutatingCommand(commandLine);
}
static boolean blocksNonEditableWorkcellMutation(
@@ -231,17 +231,25 @@ final class JigsawStudioProtection {
}
boolean isUnauthorizedStudioEdit(Player player, Block block) {
if (player == null || block == null) {
String refusal = studioEditRefusal(player, block);
if (refusal.isEmpty()) {
return false;
}
message(player, refusal);
return true;
}
String studioEditRefusal(Player player, Block block) {
if (player == null || block == null) {
return "";
}
ActiveStudio studio = service.studios.get(block.getWorld().getUID());
if (studio == null) {
return false;
return "";
}
UUID requestId = studio.generator().getRequest().requestId();
if (service.previewRenderer.contains(requestId, block.getX(), block.getY(), block.getZ())) {
message(player, "The seed-1337 Jigsaw preview is read-only and refreshes automatically.");
return true;
return "The seed-1337 Jigsaw preview is read-only and refreshes automatically.";
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay bay = session.layout().findAt(block.getX(), block.getY(), block.getZ());
@@ -249,24 +257,20 @@ final class JigsawStudioProtection {
? null
: session.activeVariant(bay.stableId()).orElse(null);
if (bay != null && (variant == null || !variant.owned())) {
message(player, variant == null
return variant == null
? "This empty Jigsaw Studio workcell has no loaded editable variant. Load one before editing it."
: "This Jigsaw Studio variant is read-only. Adopt or clone its graph before editing it.");
return true;
: "This Jigsaw Studio variant is read-only. Adopt or clone its graph before editing it.";
}
if (service.saveLifecycle.reopenRequiredRequests.contains(requestId)) {
message(player, "Close and reopen Jigsaw Studio before editing the resized layout.");
return true;
return "Close and reopen Jigsaw Studio before editing the resized layout.";
}
if (materializationInProgress(studio)) {
message(player, "Wait for the current Jigsaw Studio variant load or rollback to finish.");
return true;
return "Wait for the current Jigsaw Studio variant load or rollback to finish.";
}
if (!blocksStudioEdit(studio.generator().getRequest().ownerId(), player.getUniqueId())) {
return false;
return "";
}
message(player, "This Jigsaw Studio is owned by another player session.");
return true;
return "This Jigsaw Studio is owned by another player session.";
}
boolean materializationInProgress(ActiveStudio studio) {
@@ -105,6 +105,7 @@ final class JigsawStudioProtectionListener implements Listener {
ActiveStudio studio = service.studios.get(event.getWorld().getUID());
if (studio != null) {
service.registry.markChunkAvailable(studio, event.getChunk().getX(), event.getChunk().getZ());
service.floorPainter.refreshChunk(studio, event.getChunk().getX(), event.getChunk().getZ());
}
}
@@ -119,6 +120,7 @@ final class JigsawStudioProtectionListener implements Listener {
service.toolbelt.closeMenu(event.getPlayer());
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
service.tripleSneakTracker.clearPlayer(event.getPlayer().getUniqueId());
service.connectorTool.clearPlayer(event.getPlayer().getUniqueId());
service.visualization.visualizationLoops.remove(event.getPlayer().getUniqueId());
service.visualization.assemblyPreviews.remove(event.getPlayer().getUniqueId());
service.playerContext.playerWorkcells.remove(event.getPlayer().getUniqueId());
@@ -133,6 +135,7 @@ final class JigsawStudioProtectionListener implements Listener {
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(playerId);
service.tripleSneakTracker.clearPlayer(playerId);
service.toolbelt.toolConfirmations.remove(playerId);
service.connectorTool.clearPlayer(playerId);
service.visualization.visualizationLoops.remove(playerId);
service.particlesDisabled.remove(playerId);
service.visualization.assemblyPreviews.remove(playerId);
@@ -170,15 +173,29 @@ final class JigsawStudioProtectionListener implements Listener {
|| (event.getClickedBlock() != null && event.useInteractedBlock() == Event.Result.DENY)) {
return;
}
if (event.getHand() != EquipmentSlot.HAND
|| (event.getAction() != Action.RIGHT_CLICK_AIR
&& event.getAction() != Action.RIGHT_CLICK_BLOCK)) {
if (event.getHand() != EquipmentSlot.HAND || event.getAction() == Action.PHYSICAL) {
return;
}
boolean leftClick = event.getAction() == Action.LEFT_CLICK_AIR
|| event.getAction() == Action.LEFT_CLICK_BLOCK;
Optional<JigsawStudioToolPayload> decoded = service.toolbelt.toolCodec.decode(event.getItem());
if (decoded.isEmpty()) {
return;
}
if (decoded.get().action() == JigsawStudioToolAction.CONNECTOR_TOOL) {
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
event.setCancelled(true);
service.connectorTool.handleClick(
event.getPlayer(),
decoded.get(),
leftClick,
event.getClickedBlock(),
event.getBlockFace());
return;
}
if (leftClick) {
return;
}
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
event.setCancelled(true);
service.toolbelt.useTool(event.getPlayer(), decoded.get(), event.getItem(), event.getPlayer().isSneaking());
@@ -237,6 +254,13 @@ final class JigsawStudioProtectionListener implements Listener {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBlockBreak(BlockBreakEvent event) {
if (service.studios.containsKey(event.getBlock().getWorld().getUID())
&& service.toolbelt.toolCodec.decode(event.getPlayer().getInventory().getItemInMainHand())
.filter(payload -> payload.action() == JigsawStudioToolAction.CONNECTOR_TOOL)
.isPresent()) {
event.setCancelled(true);
return;
}
if (service.protection.isControlChest(event.getBlock())) {
event.setCancelled(true);
message(event.getPlayer(), "The Jigsaw Studio control chest cannot be removed.");
@@ -264,9 +288,17 @@ final class JigsawStudioProtectionListener implements Listener {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onUnauthorizedInteract(PlayerInteractEvent event) {
if (event.getAction() == Action.RIGHT_CLICK_BLOCK
&& !service.protection.isControlChest(event.getClickedBlock())
&& service.protection.isUnauthorizedStudioEdit(event.getPlayer(), event.getClickedBlock())) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK
|| service.protection.isControlChest(event.getClickedBlock())) {
return;
}
if (ProtectionProbe.isProbe(event)) {
if (!service.protection.studioEditRefusal(event.getPlayer(), event.getClickedBlock()).isEmpty()) {
event.setCancelled(true);
}
return;
}
if (service.protection.isUnauthorizedStudioEdit(event.getPlayer(), event.getClickedBlock())) {
event.setCancelled(true);
}
}
@@ -337,7 +369,8 @@ final class JigsawStudioProtectionListener implements Listener {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedPistonExtend(BlockPistonExtendEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID()))
|| service.protection.movesProtectedBlocks(event.getBlocks(), event.getDirection())) {
|| service.protection.movesProtectedBlocks(event.getBlocks(), event.getDirection())
|| service.protection.isImmutableStudioBlock(event.getBlock().getRelative(event.getDirection()))) {
event.setCancelled(true);
}
}
@@ -129,6 +129,11 @@ final class JigsawStudioResourceBundleAssembler {
return new Assembly(bundle.build(), objectKey, piece, StructureHash.sha256(manifestContent));
}
static byte[] ownedResourceContent(Path packRoot, String relativePath) throws IOException {
Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root").toAbsolutePath().normalize();
return Files.readAllBytes(resolveOwnedResource(root, relativePath));
}
private static Set<String> resourcePaths(StructurePackageClosure closure) {
Set<String> resources = new LinkedHashSet<>();
addPaths(resources, "structures", closure.structures(), ".json");
@@ -286,7 +286,7 @@ final class JigsawStudioSaveLifecycle {
String selected = session.selectedBayId().orElse(null);
if (selected == null) {
Location location = player.getLocation();
JigsawStudioBay underPlayer = session.layout().findAt(
JigsawStudioBay underPlayer = session.layout().findColumn(
location.getBlockX(), location.getBlockY(), location.getBlockZ());
if (underPlayer != null) {
selected = underPlayer.stableId();
@@ -73,6 +73,8 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuA
final JigsawStudioAutosaveScheduler autosaveScheduler = new JigsawStudioAutosaveScheduler(this);
final JigsawStudioTileWatcher tileWatcher = new JigsawStudioTileWatcher(this);
final JigsawStudioVisualization visualization = new JigsawStudioVisualization(this);
final JigsawStudioFloorPainter floorPainter = new JigsawStudioFloorPainter(this);
final JigsawStudioConnectorTool connectorTool = new JigsawStudioConnectorTool(this);
final JigsawStudioPlayerContext playerContext = new JigsawStudioPlayerContext(this);
public static JigsawStudioService get() {
@@ -4,6 +4,7 @@ import java.util.Objects;
public enum JigsawStudioToolAction {
OPEN_MENU("Open Control Menu", false),
CONNECTOR_TOOL("Connector Tool", false),
SELECT_WORKCELL("Select Workcell", false),
TOGGLE_WORKCELL("Toggle Workcell", false),
LOAD_VARIANT("Load Variant", false),
@@ -140,6 +140,13 @@ public final class JigsawStudioToolCodec {
if (payload.amount() != 0) {
lore.add(ChatColor.GRAY + "Amount: " + payload.amount());
}
if (payload.action() == JigsawStudioToolAction.CONNECTOR_TOOL) {
lore.add(ChatColor.YELLOW + "Right-click: place, select, or move a connector");
lore.add(ChatColor.YELLOW + "Left-click: remove a connector");
lore.add(ChatColor.GRAY + "Faces out of the clicked face; sneak to face into it");
lore.add(ChatColor.GRAY + "Planar: right-click a side to seat its socket");
return List.copyOf(lore);
}
lore.add(payload.action().destructive()
? ChatColor.RED + "Right-click twice to confirm"
: ChatColor.YELLOW + "Right-click to use");
@@ -29,6 +29,7 @@ import static art.arcane.iris.studio.jigsaw.JigsawStudioVariantProperties.canRes
final class JigsawStudioToolbelt {
private static final long TOOL_CONFIRM_NANOS = 10_000_000_000L;
private static final long TOOL_CONFIRM_MINIMUM_NANOS = 500_000_000L;
private final JigsawStudioService service;
final Map<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.");
return false;
}
if (payload.action().destructive() && !confirmTool(player, payload)) {
if (payload.action().destructive()) {
ToolConfirmationState confirmation = confirmTool(player, payload);
if (confirmation == ToolConfirmationState.ARMED) {
message(player, "Right-click the same tool again within 10 seconds to confirm "
+ payload.action().displayName() + ".");
return true;
}
if (confirmation == ToolConfirmationState.HELD) {
return true;
}
}
if (requiresBoundVariant(payload) && activeOwnedVariant(
player,
studio,
payload.workcellId(),
payload.pieceKey()) == null) {
return false;
}
return switch (payload.action()) {
case OPEN_MENU -> openControlMenu(player);
case CONNECTOR_TOOL -> service.connectorTool.handleClick(player, payload, false, null, null);
case SELECT_WORKCELL -> selectWorkcell(player, payload.workcellId());
case LOAD_VARIANT -> service.variantEditor.switchVariant(
player,
@@ -349,19 +364,40 @@ final class JigsawStudioToolbelt {
return state.isPresent() && service.workcellEditor.setRequireCaps(player, !state.get().requireCaps());
}
private boolean confirmTool(Player player, JigsawStudioToolPayload payload) {
private ToolConfirmationState confirmTool(Player player, JigsawStudioToolPayload payload) {
long now = System.nanoTime();
ToolConfirmation previous = toolConfirmations.get(player.getUniqueId());
if (previous != null
&& previous.expiresAtNanos() >= now
&& previous.payload().equals(payload)) {
ToolConfirmationState state = confirmationState(previous, payload, now);
if (state == ToolConfirmationState.CONFIRMED) {
toolConfirmations.remove(player.getUniqueId(), previous);
return true;
}
} else if (state == ToolConfirmationState.ARMED) {
toolConfirmations.put(
player.getUniqueId(),
new ToolConfirmation(payload, now + TOOL_CONFIRM_NANOS));
return false;
new ToolConfirmation(payload, now, now + TOOL_CONFIRM_NANOS));
}
return state;
}
static ToolConfirmationState confirmationState(
ToolConfirmation previous,
JigsawStudioToolPayload payload,
long nowNanos
) {
if (previous == null
|| previous.expiresAtNanos() < nowNanos
|| !previous.payload().equals(payload)) {
return ToolConfirmationState.ARMED;
}
return nowNanos - previous.armedAtNanos() < TOOL_CONFIRM_MINIMUM_NANOS
? ToolConfirmationState.HELD
: ToolConfirmationState.CONFIRMED;
}
static boolean requiresBoundVariant(JigsawStudioToolPayload payload) {
return !payload.pieceKey().isEmpty() && switch (payload.action()) {
case DUPLICATE_VARIANT, TOGGLE_ROTATION, EXPAND_TO_CELL -> true;
default -> false;
};
}
JigsawStudioVariant activeOwnedVariant(
@@ -677,7 +713,13 @@ final class JigsawStudioToolbelt {
}
}
record ToolConfirmation(JigsawStudioToolPayload payload, long expiresAtNanos) {
enum ToolConfirmationState {
ARMED,
HELD,
CONFIRMED
}
record ToolConfirmation(JigsawStudioToolPayload payload, long armedAtNanos, long expiresAtNanos) {
ToolConfirmation {
Objects.requireNonNull(payload, "Jigsaw Studio tool confirmation payload");
}
@@ -105,6 +105,9 @@ final class JigsawStudioToolboxMenu {
"Jigsaw Studio toolbox workcell");
UUID requestId = menu.requestId();
List<ToolboxTool> tools = new ArrayList<>();
tools.add(new ToolboxTool(
"Connector Tool",
JigsawStudioToolPayload.request(JigsawStudioToolAction.CONNECTOR_TOOL, requestId)));
tools.add(new ToolboxTool(
"Open Control Menu",
JigsawStudioToolPayload.request(JigsawStudioToolAction.OPEN_MENU, requestId)));
@@ -216,6 +219,15 @@ final class JigsawStudioToolboxMenu {
selected.stableId(),
active.pieceKey())));
}
if (menu.mode() == JigsawStudioMode.SPATIAL_JIGSAW && menu.canDeleteVariant(selected, active)) {
tools.add(new ToolboxTool(
"Delete " + active.displayName(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.DELETE_VARIANT,
requestId,
selected.stableId(),
active.pieceKey())));
}
if (active.owned()) {
for (JigsawStudioMenuState.Membership membership : active.memberships()) {
addMembershipTools(
@@ -255,7 +267,7 @@ final class JigsawStudioToolboxMenu {
selected.stableId(),
variant.pieceKey())));
}
if (variant.owned() && selected.variants().size() > 1) {
if (menu.canDeleteVariant(selected, variant)) {
tools.add(new ToolboxTool(
"Delete " + variant.displayName(),
JigsawStudioToolPayload.variant(
@@ -174,12 +174,20 @@ final class JigsawStudioVariantProperties {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
boolean rotatable = !variant.rotatable();
if (variant.dimensions().isPresent()) {
String footprintFailure = spatialFootprintFailure(studio, variant,
new JigsawStudioGraphMapper.SpatialChange(variant.pieceKey(), variant.dimensions().get(), rotatable));
if (!footprintFailure.isEmpty()) {
message(player, footprintFailure);
return false;
}
}
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
boolean rotatable = !variant.rotatable();
return service.graphMutations.scheduleGraphMutation(
player,
studio,
@@ -268,6 +276,12 @@ final class JigsawStudioVariantProperties {
+ dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth() + ".");
return false;
}
String footprintFailure = spatialFootprintFailure(studio, variant,
new JigsawStudioGraphMapper.SpatialChange(variant.pieceKey(), dimensions, variant.rotatable()));
if (!footprintFailure.isEmpty()) {
message(player, footprintFailure);
return false;
}
boolean active = session.activeVariant(workcellId)
.map(activeVariant -> activeVariant.pieceKey().equals(pieceKey))
.orElse(false);
@@ -536,4 +550,23 @@ final class JigsawStudioVariantProperties {
|| canonical.height() < target.height()
|| canonical.depth() < target.depth());
}
private static String spatialFootprintFailure(
ActiveStudio studio,
JigsawStudioVariant variant,
JigsawStudioGraphMapper.SpatialChange change
) {
if (variant.mode() != JigsawStudioMode.SPATIAL_JIGSAW) {
return "";
}
IrisStructure structure = loadStudioStructure(studio);
if (structure == null) {
return "The structure could not be loaded to check the spatial workcell size.";
}
return JigsawStudioGraphMapper.spatialFootprintFailure(
studio.generator().getRequest().source(),
JigsawStudioGraphMapper.configuredCellDimensions(structure),
studio.generator().getSession().layout().variantCatalog(),
change);
}
}
@@ -17,6 +17,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@@ -35,6 +36,9 @@ final class JigsawStudioVisualization {
private static final Color VALID_CONNECTOR_COLOR = Color.LIME;
private static final Color ASSEMBLY_PREVIEW_COLOR = Color.fromRGB(180, 90, 255);
private static final Color LIVE_PREVIEW_WARNING_COLOR = Color.fromRGB(255, 180, 55);
private static final Color TOOL_SELECTED_COLOR = Color.YELLOW;
private static final Color TOOL_PLACE_COLOR = Color.WHITE;
private static final Color TOOL_EXISTING_COLOR = Color.ORANGE;
private static final long ASSEMBLY_PREVIEW_MILLIS = 10_000L;
private final JigsawStudioService service;
@@ -124,7 +128,7 @@ final class JigsawStudioVisualization {
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioLayout layout = session.layout();
Location playerLocation = player.getLocation();
JigsawStudioBay focused = layout.findAt(
JigsawStudioBay focused = layout.findColumn(
playerLocation.getBlockX(),
playerLocation.getBlockY(),
playerLocation.getBlockZ());
@@ -134,6 +138,7 @@ final class JigsawStudioVisualization {
drawConnectors(player, playerLocation, studio, focused, budget);
drawBounds(player, playerLocation, focused.bounds(), focusedColor, 1.15F, 1.0D, budget);
}
drawConnectorToolFeedback(player, playerLocation, studio, budget);
drawLivePreview(player, playerLocation, studio, budget);
drawAssemblyPreview(player, playerLocation, budget);
for (JigsawStudioBay bay : layout.bays()) {
@@ -148,6 +153,57 @@ final class JigsawStudioVisualization {
}
}
private void drawConnectorToolFeedback(
Player player,
Location playerLocation,
ActiveStudio studio,
ParticleBudget budget
) {
UUID requestId = studio.generator().getRequest().requestId();
boolean holdingTool = service.toolbelt.toolCodec.decode(player.getInventory().getItemInMainHand())
.filter(payload -> payload.action() == JigsawStudioToolAction.CONNECTOR_TOOL
&& payload.requestId().equals(requestId))
.isPresent();
Optional<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(
Player player,
Location playerLocation,
@@ -114,10 +114,6 @@ final class JigsawStudioWorkcellEditor {
message(player, "Connector blocks cannot reset: " + validationFailure);
return false;
}
if (rendered.connectors().isEmpty()) {
message(player, "The active variant has no saved connectors to reset.");
return false;
}
String reservationFailure = service.materializer.beginConnectorRepair(studio);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
@@ -125,7 +121,7 @@ final class JigsawStudioWorkcellEditor {
}
boolean connectorsVisible = session.workcellSnapshot(workcellId).connectorsVisible();
message(player, "Resetting " + rendered.connectors().size()
+ " connector block(s) from the last saved iteration...");
+ " connector block(s) from the last saved iteration and clearing unsaved markers...");
return service.materializer.scheduleConnectorRepair(
player,
studio,
@@ -383,6 +379,16 @@ final class JigsawStudioWorkcellEditor {
message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'.");
return false;
}
JigsawStudioVariant spatialVariant = workcell.archetype().isPresent()
? null
: session.activeVariant(workcellId).orElse(null);
if (spatialVariant != null) {
return service.variantProperties.updateVariantDisplayName(
player,
workcellId,
spatialVariant.pieceKey(),
displayName);
}
String normalizedName;
try {
normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName);
@@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final String EXPECTED_SCHEMA_DIGEST = "6935e8d3914e5cde852522151d87fe898f79de3b394826e544c5c5bfbfceba57";
private static final String EXPECTED_SCHEMA_DIGEST = "b8dd16d1a3188e8150a68a9d5b2964aed5094d679c0f941e3703aec675f87e20";
private static final List<Class<?>> SCHEMA_ROOTS = List.of(
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
public void rejects1211VanillaExportWithDifferentVerticalDistance() throws Exception {
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.Test;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -310,6 +313,82 @@ public class StructureAssemblerTest {
assertEquals(west.getMaxX() + 1, start.getMinX());
}
@Test
public void rotatableCandidatesAttachThroughEitherCornerConnectorAcrossSeeds() {
Fixture fixture = new Fixture();
IrisJigsawPiece startPiece = piece("start-object", false,
connector(IrisDirection.NORTH_NEGATIVE_Z, "turns", "door", "door")
.setPosition(new IrisPosition(1, 0, 0))
.setJoint(JigsawJoint.ALIGNED));
IrisJigsawPiece cornerPiece = piece("corner-object", true,
connector(IrisDirection.NORTH_NEGATIVE_Z, "", "door", "door")
.setPosition(new IrisPosition(1, 0, 0))
.setJoint(JigsawJoint.ALIGNED),
connector(IrisDirection.EAST_POSITIVE_X, "", "door", "door")
.setPosition(new IrisPosition(2, 0, 1))
.setJoint(JigsawJoint.ALIGNED));
fixture.pool("start", pool("", "start-piece"));
fixture.pool("turns", pool("", "corner-piece"));
fixture.piece("start-piece", startPiece);
fixture.piece("corner-piece", cornerPiece);
fixture.object("start-object", new IrisObject(3, 1, 3));
fixture.object("corner-object", new IrisObject(3, 1, 3));
StructureAssembler assembler = fixture.assembler(structure("start", 1), 0, 0, 0);
Set<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
public void selectionPriorityProcessesHigherConnectorsFirstAndPreservesStableTies() {
Fixture fixture = new Fixture();
@@ -1115,6 +1194,53 @@ public class StructureAssemblerTest {
return fixture.assembler(structure("start", 1), 0, 0, 0).assemble(new RNG(137L));
}
private static Fixture planarJunctionFixture(String... junctionPieces) {
Fixture fixture = new Fixture();
fixture.pool("start", pool("", "start-piece"));
fixture.pool("north", pool("", "north-elbow"));
fixture.pool("east", pool("", "east-elbow"));
fixture.pool("junction", pool("", junctionPieces));
fixture.piece("start-piece", piece("cell-object", false,
planarConnector(IrisDirection.NORTH_NEGATIVE_Z, "north"),
planarConnector(IrisDirection.EAST_POSITIVE_X, "east")));
fixture.piece("north-elbow", piece("cell-object", false,
planarConnector(IrisDirection.SOUTH_POSITIVE_Z, ""),
planarConnector(IrisDirection.EAST_POSITIVE_X, "junction")));
fixture.piece("east-elbow", piece("cell-object", false,
planarConnector(IrisDirection.WEST_NEGATIVE_X, ""),
planarConnector(IrisDirection.NORTH_NEGATIVE_Z, "")));
fixture.object("cell-object", new IrisObject(3, 1, 3));
return fixture;
}
private static IrisJigsawPiece planarPiece(boolean rotatable, IrisDirection... directions) {
IrisJigsawPiece piece = new IrisJigsawPiece().setObject("cell-object").setRotatable(rotatable);
for (IrisDirection direction : directions) {
piece.getConnectors().add(planarConnector(direction, ""));
}
return piece;
}
private static IrisJigsawConnector planarConnector(IrisDirection direction, String pool) {
return connector(direction, pool, "door", "door")
.setPosition(IrisJigsawConnector.canonicalPlanarPosition(new IrisPosition(3, 1, 3), direction))
.setJoint(JigsawJoint.ALIGNED);
}
private static IrisStructure planarJunctionStructure() {
return structure("start", 3)
.setMode(IrisJigsawMode.PLANAR_JIGSAW)
.setCellSize(new IrisPosition(3, 1, 3));
}
private static Set<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) {
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.ResourceLoader;
import art.arcane.iris.studio.jigsaw.JigsawPlanarArchetype;
import art.arcane.iris.studio.jigsaw.JigsawPlanarDirection;
import art.arcane.iris.studio.jigsaw.JigsawPlanarTopology;
import art.arcane.iris.studio.jigsaw.JigsawStudioActivation;
import art.arcane.iris.studio.jigsaw.JigsawStudioBay;
@@ -52,6 +50,7 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
@@ -168,58 +167,124 @@ public class JigsawStudioGeneratorTest {
}
@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);
IrisJigsawPiece piece = new IrisJigsawPiece()
.setObject("village/corner")
.setConnectors(new KList<>());
piece.getConnectors().add(connector(new IrisPosition(3, 2, 0), IrisDirection.NORTH_NEGATIVE_Z));
piece.getConnectors().add(connector(new IrisPosition(6, 2, 3), IrisDirection.EAST_POSITIVE_X));
when(pieceLoader.load("village/corner", false)).thenReturn(piece);
when(objectLoader.load("village/corner", false)).thenReturn(new IrisObject(7, 5, 7));
JigsawStudioVariant variant = planarVariant("village/corner", JigsawPlanarTopology.NORTH_EAST_CORNER);
GeneratorFixture fixture = fixture(
source,
JigsawStudioMode.PLANAR_JIGSAW,
dimensions,
JigsawStudioVariantCatalog.empty());
new JigsawStudioVariantCatalog(List.of(variant)));
JigsawStudioBay corner = fixture.layout().get("workcell/corner");
int glyphY = corner.bounds().originY() - 1;
int floorY = corner.bounds().originY() - 1;
int centerX = dimensions.width() / 2;
int centerZ = dimensions.depth() / 2;
assertSame(fixture.connectorCap(), stateAt(
fixture.generator(),
corner.bounds().originX() + centerX,
glyphY,
corner.bounds().originZ()));
assertSame(fixture.connectorCap(), stateAt(
fixture.generator(),
corner.bounds().maxX(),
glyphY,
corner.bounds().originZ() + centerZ));
assertSame(fixture.topologyPath(), stateAt(
fixture.generator(),
corner.bounds().originX() + centerX,
glyphY,
corner.bounds().originZ() + 1));
assertSame(fixture.topologyPath(), stateAt(
try (MockedStatic<B> ignored = mockStatic(B.class)) {
assertSame(fixture.palette().horizontalSocket(), stateAt(
fixture.generator(), corner.bounds().originX() + centerX, floorY, corner.bounds().originZ()));
assertSame(fixture.palette().horizontalSocket(), stateAt(
fixture.generator(), corner.bounds().maxX(), floorY, corner.bounds().originZ() + centerZ));
assertSame(fixture.palette().line(), stateAt(
fixture.generator(), corner.bounds().originX() + centerX, floorY, corner.bounds().originZ() + 1));
assertSame(fixture.palette().line(), stateAt(
fixture.generator(),
corner.bounds().originX() + centerX + 1,
glyphY,
floorY,
corner.bounds().originZ() + centerZ));
assertSame(fixture.topologyBase(), stateAt(
fixture.generator(),
corner.bounds().originX(),
glyphY,
corner.bounds().originZ()));
assertFalse(fixture.topologyBase() == stateAt(
assertSame(fixture.palette().base(), stateAt(
fixture.generator(), corner.bounds().originX(), floorY, corner.bounds().originZ()));
assertFalse(fixture.palette().base() == stateAt(
fixture.generator(),
corner.bounds().originX() + centerX,
corner.bounds().originY(),
corner.bounds().originZ() + centerZ));
JigsawStudioBay blank = fixture.layout().get("workcell/blank");
assertSame(fixture.topologyBase(), stateAt(
assertSame(fixture.palette().base(), stateAt(
fixture.generator(),
blank.bounds().originX() + centerX,
blank.bounds().originY() - 1,
blank.bounds().originZ() + centerZ));
JigsawStudioBay tee = fixture.layout().get("workcell/tee");
assertSame(fixture.palette().base(), stateAt(
fixture.generator(),
tee.bounds().originX() + centerX,
tee.bounds().originY() - 1,
tee.bounds().originZ()));
JigsawStudioControlPosition control = fixture.layout().controlPosition();
assertSame(fixture.controlChest(), stateAt(
fixture.generator(), control.worldX(), control.worldY(), control.worldZ()));
}
}
@Test
@SuppressWarnings("unchecked")
public void spatialWorkcellsPaintVerticalSocketsUnderTheirConnectorColumns() {
IrisData source = mock(IrisData.class);
ResourceLoader<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
public void paintsEveryWorkcellAsAWhiteConcreteEdgeCuboid() {
@@ -247,77 +312,6 @@ public class JigsawStudioGeneratorTest {
workcell.bounds().originZ()));
}
@Test
public void glyphMathHandlesAllArchetypesAndSmallEvenAndOddCells() {
int[] sizes = {1, 2, 3, 4, 7, 16};
for (int size : sizes) {
JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(size, 1, size);
int center = size / 2;
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
JigsawPlanarTopology topology = archetype.canonicalTopology();
boolean centerPath = JigsawStudioGenerator.isTopologyPath(
topology, center, center, center, center);
assertEquals(archetype != JigsawPlanarArchetype.BLANK, centerPath);
if (topology.connects(JigsawPlanarDirection.NORTH)) {
assertTrue(JigsawStudioGenerator.isConnectorCap(
topology, dimensions, center, 0, center, center));
}
if (topology.connects(JigsawPlanarDirection.EAST)) {
assertTrue(JigsawStudioGenerator.isConnectorCap(
topology, dimensions, size - 1, center, center, center));
}
}
}
}
@Test
public void connectorCapsOnlyOccupyConnectedFaceCenters() {
int[] sizes = {3, 4, 7, 16};
for (int size : sizes) {
JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(size, 1, size);
int center = size / 2;
for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) {
JigsawPlanarTopology topology = archetype.canonicalTopology();
for (int z = 0; z < size; z++) {
for (int x = 0; x < size; x++) {
boolean expected = topology.connects(JigsawPlanarDirection.NORTH)
&& x == center && z == 0
|| topology.connects(JigsawPlanarDirection.EAST)
&& x == size - 1 && z == center
|| topology.connects(JigsawPlanarDirection.SOUTH)
&& x == center && z == size - 1
|| topology.connects(JigsawPlanarDirection.WEST)
&& x == 0 && z == center;
assertEquals(expected, JigsawStudioGenerator.isConnectorCap(
topology, dimensions, x, z, center, center));
}
}
}
}
}
@Test
public void canonicalFiveByFivePathsMatchEveryArchetype() {
Map<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
public void clipsLargeGlyphPaintingToOneChunkFootprint() {
GeneratorFixture fixture = fixture(
@@ -331,10 +325,7 @@ public class JigsawStudioGeneratorTest {
fixture.generator().paintChunk(chunk, chunkX, chunkZ);
int glyphBlocks = chunk.countState(fixture.topologyBase())
+ chunk.countState(fixture.topologyPath())
+ chunk.countState(fixture.connectorCap());
assertEquals(256, glyphBlocks);
assertEquals(256, chunk.countState(fixture.palette().base()));
}
@Test
@@ -467,6 +458,29 @@ public class JigsawStudioGeneratorTest {
assertNotSame(nextGeneration, fixture.generator().renderVariant(workcell, variant, 5L));
}
@Test
public void workcellsFromAnEquivalentReplacedLayoutStillRender() {
JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(5, 5, 5);
GeneratorFixture fixture = fixture(
JigsawStudioMode.PLANAR_JIGSAW,
dimensions,
JigsawStudioVariantCatalog.empty());
JigsawStudioBay stale = fixture.layout().get("workcell/end");
fixture.generator().getSession().replaceLayout(JigsawStudioLayout.create(
JigsawStudioMode.PLANAR_JIGSAW,
dimensions,
JigsawStudioVariantCatalog.empty()));
assertNotSame(stale, fixture.generator().getLayout().get("workcell/end"));
assertTrue(fixture.generator().renderBay(stale).valid());
fixture.generator().getSession().replaceLayout(JigsawStudioLayout.create(
JigsawStudioMode.PLANAR_JIGSAW,
new JigsawStudioCellDimensions(6, 5, 5),
JigsawStudioVariantCatalog.empty()));
assertThrows(IllegalArgumentException.class, () -> fixture.generator().renderBay(stale));
}
@Test
public void mapsAllVanillaJigsawOrientations() {
assertEquals("north_up", JigsawStudioGenerator.orientation(
@@ -535,9 +549,13 @@ public class JigsawStudioGeneratorTest {
NativeBlockState lightFloor = mock(NativeBlockState.class);
NativeBlockState darkFloor = mock(NativeBlockState.class);
NativeBlockState frame = mock(NativeBlockState.class);
NativeBlockState topologyBase = mock(NativeBlockState.class);
NativeBlockState topologyPath = mock(NativeBlockState.class);
NativeBlockState connectorCap = mock(NativeBlockState.class);
JigsawStudioGenerator.FloorPalette palette = new JigsawStudioGenerator.FloorPalette(
mock(NativeBlockState.class),
mock(NativeBlockState.class),
mock(NativeBlockState.class),
mock(NativeBlockState.class),
mock(NativeBlockState.class),
mock(NativeBlockState.class));
NativeBlockState invalidMarker = mock(NativeBlockState.class);
NativeBlockState controlChest = mock(NativeBlockState.class);
JigsawStudioGenerator generator = new JigsawStudioGenerator(
@@ -547,9 +565,7 @@ public class JigsawStudioGeneratorTest {
lightFloor,
darkFloor,
frame,
topologyBase,
topologyPath,
connectorCap,
palette,
invalidMarker,
controlChest,
JigsawStudioGeneratorTest::renderFinalState);
@@ -557,12 +573,22 @@ public class JigsawStudioGeneratorTest {
generator,
layout,
frame,
topologyBase,
topologyPath,
connectorCap,
palette,
controlChest);
}
private static IrisJigsawConnector connector(IrisPosition position, IrisDirection direction) {
return new IrisJigsawConnector()
.setPosition(position)
.setDirection(direction)
.setTop(IrisDirection.UP_POSITIVE_Y)
.setPool("test/pieces")
.setName("iris:planar")
.setTargetName("iris:planar")
.setJoint(JigsawJoint.ALIGNED)
.setFinalState("minecraft:structure_void");
}
private static JigsawStudioVariant planarVariant(String key, JigsawPlanarTopology topology) {
return new JigsawStudioVariant(
key,
@@ -596,9 +622,7 @@ public class JigsawStudioGeneratorTest {
JigsawStudioGenerator generator,
JigsawStudioLayout layout,
NativeBlockState frame,
NativeBlockState topologyBase,
NativeBlockState topologyPath,
NativeBlockState connectorCap,
JigsawStudioGenerator.FloorPalette palette,
NativeBlockState controlChest
) {
}
@@ -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);
}
@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
@SuppressWarnings("unchecked")
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(
packRoot,
"studio/spatial",
Map.of(JigsawStudioLayout.SPATIAL_WORKCELL_ID, "studio/spatial/start"),
Map.of(JigsawStudioLayout.SPATIAL_WORKCELL_ID, "studio/spatial/blank"),
"variant-2");
assertTrue(creation.writeResult().successful());
@@ -499,7 +499,7 @@ public class JigsawStudioPersistenceEditorsTest {
"studio/spatial/variants/spatial/variant-2",
creation.pieceKeysByWorkcell().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID));
JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/studio/spatial/start.json"));
assertEquals(8, pool.getAsJsonArray("pieces").size());
assertEquals(25, pool.getAsJsonArray("pieces").size());
}
@Test
@@ -507,10 +507,13 @@ public class JigsawStudioPersistenceEditorsTest {
Path packRoot = createSpatialProject("spatial-theme-row");
Map<String, String> sources = new LinkedHashMap<>();
sources.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID, "studio/spatial/start");
for (int connectorCount = 1; connectorCount <= 6; connectorCount++) {
String pieceKey = "studio/spatial/connectors-" + connectorCount;
sources.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID + "/" + pieceKey, pieceKey);
for (String vertical : List.of("", "-up", "-down", "-up-down")) {
for (String horizontal : List.of("blank", "end", "straight", "corner", "tee", "cross")) {
String pieceKey = "studio/spatial/" + horizontal + vertical;
sources.put(sources.isEmpty()
? JigsawStudioLayout.SPATIAL_WORKCELL_ID
: JigsawStudioLayout.SPATIAL_WORKCELL_ID + "/" + pieceKey, pieceKey);
}
}
JigsawStudioGraphEditor.VariantFamilyCreation creation = JigsawStudioGraphEditor.duplicateActiveFamily(
packRoot,
@@ -519,13 +522,13 @@ public class JigsawStudioPersistenceEditorsTest {
"variant-2");
assertTrue(creation.writeResult().successful());
assertEquals(7, creation.pieceKeysByWorkcell().size());
assertEquals(24, creation.pieceKeysByWorkcell().size());
for (String targetPieceKey : creation.pieceKeysByWorkcell().values()) {
assertTrue(Files.isRegularFile(packRoot.resolve("jigsaw-pieces/" + targetPieceKey + ".json")));
assertTrue(Files.isRegularFile(packRoot.resolve("objects/" + targetPieceKey + ".iob")));
}
JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/studio/spatial/start.json"));
assertEquals(14, pool.getAsJsonArray("pieces").size());
assertEquals(48, pool.getAsJsonArray("pieces").size());
}
@Test
@@ -557,7 +560,7 @@ public class JigsawStudioPersistenceEditorsTest {
"END").get("displayName").getAsString());
Path spatialRoot = createSpatialProject("labels-spatial");
Path spatialObject = spatialRoot.resolve("objects/studio/spatial/start.iob");
Path spatialObject = spatialRoot.resolve("objects/studio/spatial/blank.iob");
byte[] spatialBefore = Files.readAllBytes(spatialObject);
assertTrue(JigsawStudioStructureEditor.updateSpatialWorkcellDisplayName(
@@ -34,7 +34,7 @@ public class JigsawStudioPersistenceIdentityTest {
private static final String PIECE_POOL_KEY = "studio/identity/pieces";
private static final String RECORDED_AT_FIELD = "recordedAtEpochMilli";
private static final String SCRIPTED_SESSION_DIGEST =
"318d9338a63a44063c85e59a9e74f869473d6e0f15c362536172f7723d960faa";
"42b5d3bed8d74b2ef7a010a58302544d0d425c83faba0dc86efb0e41e69ba627";
@Rule
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
@@ -102,9 +102,15 @@ public class JigsawStudioProjectCreatorTest {
JsonObject startPool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/settlement/test/start.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(1, startPool.getAsJsonArray("pieces").size());
assertEquals(2, startPool.getAsJsonArray("pieces").size());
assertEquals("settlement/test/cross", startPool.getAsJsonArray("pieces").get(0)
.getAsJsonObject().get("piece").getAsString());
assertEquals(3, startPool.getAsJsonArray("pieces").get(0)
.getAsJsonObject().get("weight").getAsInt());
assertEquals("settlement/test/blank", startPool.getAsJsonArray("pieces").get(1)
.getAsJsonObject().get("piece").getAsString());
assertEquals(1, startPool.getAsJsonArray("pieces").get(1)
.getAsJsonObject().get("weight").getAsInt());
JsonObject piecePool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/settlement/test/pieces.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
@@ -229,7 +235,7 @@ public class JigsawStudioProjectCreatorTest {
}
@Test
public void spatialProjectSeedsZeroThroughSixConnectorVariants() throws Exception {
public void spatialProjectSeedsEveryYRotationTopologyVariant() throws Exception {
Path temporaryDirectory = temporaryFolder.getRoot().toPath();
JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options(
"stronghold/gallery",
@@ -246,35 +252,48 @@ public class JigsawStudioProjectCreatorTest {
JsonObject piecePool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/stronghold/gallery/pieces.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(7, startPool.getAsJsonArray("pieces").size());
assertEquals(7, piecePool.getAsJsonArray("pieces").size());
assertEquals("stronghold/gallery/connectors-1", piecePool.getAsJsonArray("pieces").get(0)
assertEquals(24, startPool.getAsJsonArray("pieces").size());
assertEquals("stronghold/gallery/blank", startPool.getAsJsonArray("pieces").get(0)
.getAsJsonObject().get("piece").getAsString());
assertTrue(piecePool.getAsJsonArray("pieces").get(6).getAsJsonObject()
assertEquals(24, piecePool.getAsJsonArray("pieces").size());
assertEquals("stronghold/gallery/end", piecePool.getAsJsonArray("pieces").get(0)
.getAsJsonObject().get("piece").getAsString());
assertTrue(piecePool.getAsJsonArray("pieces").get(23).getAsJsonObject()
.get("empty").getAsBoolean());
for (int connectorCount = 0; connectorCount <= 6; connectorCount++) {
String pieceName = connectorCount == 0 ? "start" : "connectors-" + connectorCount;
List<String> horizontalClasses = List.of("blank", "end", "straight", "corner", "tee", "cross");
List<String> verticalClasses = List.of("", "-up", "-down", "-up-down");
List<Integer> horizontalCounts = List.of(0, 1, 2, 2, 3, 4);
for (String vertical : verticalClasses) {
for (int index = 0; index < horizontalClasses.size(); index++) {
String pieceName = horizontalClasses.get(index) + vertical;
JsonObject piece = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/"
+ pieceName + ".json"),
temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/" + pieceName + ".json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(connectorCount, piece.getAsJsonArray("connectors").size());
assertEquals(connectorCount + (connectorCount == 1 ? " Connector" : " Connectors"),
piece.get("displayName").getAsString());
assertEquals(16, piece.getAsJsonObject("rules").get("maximumPlacements").getAsInt());
int verticalCount = vertical.isEmpty() ? 0 : vertical.equals("-up-down") ? 2 : 1;
assertEquals(pieceName, horizontalCounts.get(index) + verticalCount,
piece.getAsJsonArray("connectors").size());
assertEquals(4, piece.getAsJsonObject("rules").get("maximumPlacements").getAsInt());
assertEquals(new IrisBlockVector(15, 15, 15), IrisObject.sampleSize(
temporaryDirectory.resolve("objects/stronghold/gallery/"
+ pieceName + ".iob").toFile()));
}
JsonObject six = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/connectors-6.json"),
}
JsonObject cornerShaft = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pieces/stronghold/gallery/corner-up-down.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals("NORTH_NEGATIVE_Z", six.getAsJsonArray("connectors").get(0)
assertEquals("L Junction + Up/Down", cornerShaft.get("displayName").getAsString());
assertEquals("NORTH_NEGATIVE_Z", cornerShaft.getAsJsonArray("connectors").get(0)
.getAsJsonObject().get("direction").getAsString());
assertEquals("DOWN_NEGATIVE_Y", six.getAsJsonArray("connectors").get(5)
assertEquals("EAST_POSITIVE_X", cornerShaft.getAsJsonArray("connectors").get(1)
.getAsJsonObject().get("direction").getAsString());
assertEquals(0, six.getAsJsonArray("connectors").get(5)
assertEquals("UP_POSITIVE_Y", cornerShaft.getAsJsonArray("connectors").get(2)
.getAsJsonObject().get("direction").getAsString());
assertEquals("DOWN_NEGATIVE_Y", cornerShaft.getAsJsonArray("connectors").get(3)
.getAsJsonObject().get("direction").getAsString());
assertEquals(0, cornerShaft.getAsJsonArray("connectors").get(3)
.getAsJsonObject().getAsJsonObject("position").get("y").getAsInt());
assertEquals("NORTH_NEGATIVE_Z", cornerShaft.getAsJsonArray("connectors").get(3)
.getAsJsonObject().get("top").getAsString());
StructureGraphCompilation compilation = StructureResourceBundleGraphCompiler.compile(
JigsawStudioProjectCreator.bundle(options)).getFirst();
@@ -283,7 +302,7 @@ public class JigsawStudioProjectCreatorTest {
new IrisPosition(0, 0, 0)).assemble(new RNG(1337L));
assertEquals(preview.detail(), StructureAssemblyStatus.COMPLETE, preview.status());
assertTrue(preview.pieces().size() > 1);
assertTrue(preview.pieces().size() <= 96);
assertTrue(preview.pieces().size() <= 93);
}
@Test
@@ -411,10 +430,10 @@ public class JigsawStudioProjectCreatorTest {
JsonObject pool = JsonParser.parseString(Files.readString(
temporaryDirectory.resolve("jigsaw-pools/stronghold/test/start.json"),
StandardCharsets.UTF_8)).getAsJsonObject();
assertEquals(8, pool.getAsJsonArray("pieces").size());
assertEquals("stronghold/test/hall", pool.getAsJsonArray("pieces").get(7)
assertEquals(25, pool.getAsJsonArray("pieces").size());
assertEquals("stronghold/test/hall", pool.getAsJsonArray("pieces").get(24)
.getAsJsonObject().get("piece").getAsString());
assertEquals(3, pool.getAsJsonArray("pieces").get(7)
assertEquals(3, pool.getAsJsonArray("pieces").get(24)
.getAsJsonObject().get("weight").getAsInt());
StructureWriteResult resized = JigsawStudioStructureEditor.updateCellSize(
@@ -435,7 +454,7 @@ public class JigsawStudioProjectCreatorTest {
IrisBlockVector hallSize = IrisObject.sampleSize(
temporaryDirectory.resolve("objects/stronghold/test/hall.iob").toFile());
IrisBlockVector starterSize = IrisObject.sampleSize(
temporaryDirectory.resolve("objects/stronghold/test/start.iob").toFile());
temporaryDirectory.resolve("objects/stronghold/test/blank.iob").toFile());
assertEquals(new IrisBlockVector(16, 12, 18), hallSize);
assertEquals(new IrisBlockVector(12, 10, 14), starterSize);
@@ -56,6 +56,21 @@ public class JigsawStudioProtectionProbeTest {
}
}
@Test
public void protectedWorkcellProbesAreDeniedWithoutChatMessages() throws Exception {
JigsawStudioService service = new JigsawStudioService();
JigsawStudioProtection protection = mock(JigsawStudioProtection.class);
setField(service, "protection", protection);
PlayerInteractEvent event = probe();
when(protection.studioEditRefusal(event.getPlayer(), event.getClickedBlock()))
.thenReturn("This Jigsaw Studio variant is read-only.");
service.protectionListener.onUnauthorizedInteract(event);
assertTrue(event.isCancelled());
verify(protection, never()).isUnauthorizedStudioEdit(any(Player.class), any(Block.class));
}
private static PlayerInteractEvent probe() {
Player player = mock(Player.class);
World world = mock(World.class);
@@ -80,20 +80,20 @@ public class JigsawStudioResourceBundleAssemblerTest {
JigsawStudioResourceBundleAssembler.assemble(
packRoot,
"fort",
"fort/start",
"fort/blank",
emptyObject(),
List.of(connector),
false);
assertEquals(17, assembly.bundle().resources().size());
assertEquals("fort/start", assembly.objectKey());
assertEquals(51, assembly.bundle().resources().size());
assertEquals("fort/blank", assembly.objectKey());
assertEquals(1, assembly.piece().getConnectors().size());
StructureWriteResult result = new StructureTransactionWriter(packRoot)
.write(assembly.bundle(), StructureWriteMode.OVERWRITE);
assertTrue(result.successful());
assertTrue(result.committed());
Path piecePath = packRoot.resolve("jigsaw-pieces/fort/start.json");
Path piecePath = packRoot.resolve("jigsaw-pieces/fort/blank.json");
IrisJigsawPiece persisted = new Gson().fromJson(
Files.readString(piecePath, StandardCharsets.UTF_8), IrisJigsawPiece.class);
assertEquals(1, persisted.getConnectors().size());
@@ -143,7 +143,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
@Test
public void capturePreservesAbsentDefaultsAndUnknownPieceMetadata() throws Exception {
Path packRoot = createProject();
String pieceResource = "jigsaw-pieces/fort/start.json";
String pieceResource = "jigsaw-pieces/fort/blank.json";
Path piecePath = packRoot.resolve(pieceResource);
JsonObject source = JsonParser.parseString(
Files.readString(piecePath, StandardCharsets.UTF_8)).getAsJsonObject();
@@ -158,7 +158,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
JigsawStudioResourceBundleAssembler.assemble(
packRoot,
STRUCTURE_KEY,
"fort/start",
"fort/blank",
emptyObject(),
List.copyOf(sourcePiece.getConnectors()),
false);
@@ -173,7 +173,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
JigsawStudioResourceBundleAssembler.assemble(
packRoot,
STRUCTURE_KEY,
"fort/start",
"fort/blank",
emptyObject(),
List.of(updated),
false);
@@ -202,7 +202,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
() -> JigsawStudioResourceBundleAssembler.assemble(
packRoot,
STRUCTURE_KEY,
"fort/start",
"fort/blank",
emptyObject(),
List.of(connector()),
false));
@@ -213,7 +213,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
@Test
public void refusesManagedDatapackOwnershipWithoutAssemblingAStudioSave() throws Exception {
Path packRoot = createProject();
Path objectPath = packRoot.resolve("objects/fort/start.iob");
Path objectPath = packRoot.resolve("objects/fort/blank.iob");
byte[] before = Files.readAllBytes(objectPath);
markManagedDatapack(packRoot, STRUCTURE_KEY);
@@ -221,7 +221,7 @@ public class JigsawStudioResourceBundleAssemblerTest {
() -> JigsawStudioResourceBundleAssembler.assemble(
packRoot,
STRUCTURE_KEY,
"fort/start",
"fort/blank",
emptyObject(),
List.of(connector()),
false));
@@ -234,13 +234,13 @@ public class JigsawStudioResourceBundleAssemblerTest {
@Test
public void writerRejectsAResourceModifiedOutsideTheOwnershipTransaction() throws Exception {
Path packRoot = createProject();
Path piecePath = packRoot.resolve("jigsaw-pieces/fort/start.json");
Path piecePath = packRoot.resolve("jigsaw-pieces/fort/blank.json");
Files.writeString(piecePath, Files.readString(piecePath, StandardCharsets.UTF_8) + "\n");
JigsawStudioResourceBundleAssembler.Assembly assembly =
JigsawStudioResourceBundleAssembler.assemble(
packRoot,
STRUCTURE_KEY,
"fort/start",
"fort/blank",
emptyObject(),
List.of(connector().setPool("fort/start")),
false);
@@ -181,11 +181,13 @@ public class JigsawStudioServiceCaptureTest {
when(state.isCustom()).thenReturn(false);
when(state.nativeHandle()).thenReturn(blockData);
JigsawStudioChunkWriter.restoreConnectorChunk(
JigsawStudioCapture.LocalPosition position = new JigsawStudioCapture.LocalPosition(1, 2, 3);
JigsawStudioChunkWriter.restoreChunkPositions(
world,
workcell,
List.of(renderedConnector),
Map.of(new JigsawStudioCapture.LocalPosition(1, 2, 3), renderedBlock),
Set.of(position),
Map.of(position, renderedConnector),
Map.of(position, renderedBlock),
false);
verify(world).getBlockAt(11, 22, 33);
@@ -293,7 +295,9 @@ public class JigsawStudioServiceCaptureTest {
assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/iris jigsaw status"));
assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/msg owner hello"));
assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/execute run say bypass"));
assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/unknownplugin mutate"));
assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/setblock 0 0 0 stone"));
assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/unknownplugin status"));
assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/spawn"));
assertTrue(JigsawStudioProtection.blocksNonEditableWorkcellMutation(
true, "/fill 0 0 0 1 1 1 stone"));
assertTrue(JigsawStudioProtection.blocksNonEditableWorkcellMutation(true, "//paste -a"));
@@ -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());
}
}