From a4e22006e0df093da2d9075c3282c726c5ac1b12 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 8 Sep 2026 05:07:40 -0400 Subject: [PATCH] Split jigsaw command tree - Move each jigsaw subgroup executor into its own top-level command class - Move the adoption plan store, handlers and helpers into CommandJigsawAdopt - Move the vanilla export lease and reporting helpers into CommandJigsawExports - Move the studio open lifecycle into CommandJigsawOpen - Point the jigsaw contract test at the relocated command types - Exclude the new jigsaw command sources from the generation revision scope --- .../iris/core/commands/CommandJigsaw.java | 1429 +---------------- .../core/commands/CommandJigsawAdopt.java | 424 +++++ .../core/commands/CommandJigsawConnector.java | 84 + .../core/commands/CommandJigsawExports.java | 159 ++ .../iris/core/commands/CommandJigsawOpen.java | 251 +++ .../core/commands/CommandJigsawPiece.java | 238 +++ .../iris/core/commands/CommandJigsawPool.java | 38 + .../core/commands/CommandJigsawPreview.java | 26 + .../core/commands/CommandJigsawRules.java | 74 + .../core/commands/CommandJigsawVariant.java | 169 ++ .../core/commands/CommandJigsawWorkcell.java | 81 + .../commands/CommandJigsawContractTest.java | 124 +- .../buildtools/GenerationRevisionScope.java | 10 + 13 files changed, 1634 insertions(+), 1473 deletions(-) create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawAdopt.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawConnector.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawExports.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawOpen.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPiece.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPool.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPreview.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawRules.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawVariant.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawWorkcell.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java index 6c0d302c6..be5ee3411 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java @@ -2,23 +2,17 @@ package art.arcane.iris.core.commands; import art.arcane.iris.Iris; import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.core.pack.BrokenPackException; -import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.core.runtime.StudioOpenCoordinator; import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype; -import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarTopology; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphMapper; -import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphEditor; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; -import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioProjectCreator; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession; -import art.arcane.iris.core.runtime.jigsaw.JigsawStudioStructureEditor; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant; import art.arcane.iris.core.service.JigsawStudioGraphEvaluation; import art.arcane.iris.core.service.JigsawStudioService; @@ -26,21 +20,9 @@ import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.structure.StructureImporter; import art.arcane.iris.core.structure.VillageImporter; import art.arcane.iris.core.structure.authoring.StructureKey; -import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; -import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; import art.arcane.iris.core.structure.authoring.StructureWriteResult; -import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionDiagnostic; -import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionInputKind; -import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionPlan; -import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionRequest; -import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionResult; -import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionService; -import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionStrategy; -import art.arcane.iris.core.structure.export.VanillaJigsawDatapackExporter; -import art.arcane.iris.core.structure.export.VanillaJigsawExportDiagnostic; import art.arcane.iris.core.structure.export.VanillaJigsawExportFormat; import art.arcane.iris.core.structure.export.VanillaJigsawExportRequest; -import art.arcane.iris.core.structure.export.VanillaJigsawExportResult; import art.arcane.iris.core.structure.export.VanillaJigsawExportSource; import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver; import art.arcane.iris.engine.object.IrisDimension; @@ -51,7 +33,6 @@ import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisStructure; -import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.util.common.director.DirectorExecutor; import art.arcane.iris.util.common.director.specialhandlers.IrisStructureHandler; import art.arcane.iris.util.common.format.C; @@ -63,18 +44,12 @@ import art.arcane.volmlib.util.director.DirectorParameterHandler; import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; -import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.NamespacedKey; -import org.bukkit.World; -import org.bukkit.block.Block; -import org.bukkit.block.data.type.Jigsaw; import org.bukkit.entity.Player; import java.io.File; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.LinkOption; import java.nio.file.Path; import java.util.HashSet; import java.util.Locale; @@ -83,24 +58,15 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.regex.Pattern; @Director(name = "jigsaw", aliases = {"jig", "jgs"}, description = "Iris Jigsaw Studio tools", origin = DirectorOrigin.PLAYER) public class CommandJigsaw implements DirectorExecutor { - private static final long DEFAULT_STUDIO_SEED = 1337L; + static final long DEFAULT_STUDIO_SEED = 1337L; static final StudioOpenCoordinator.StudioOpenKind STUDIO_OPEN_KIND = StudioOpenCoordinator.StudioOpenKind.JIGSAW; - private static final Pattern EXPORT_OUTPUT_NAME = Pattern.compile("[a-zA-Z0-9_-][a-zA-Z0-9._-]{0,127}"); - private static final Map PLAYER_PACKS = new ConcurrentHashMap<>(); - private static final Map ADOPTION_PLANS = new ConcurrentHashMap<>(); - private static final Map ADOPTION_SERVICES = new ConcurrentHashMap<>(); - private static final Set EXPORTING_PLAYERS = ConcurrentHashMap.newKeySet(); - private static final Set EXPORT_DESTINATIONS = ConcurrentHashMap.newKeySet(); - + static final Map PLAYER_PACKS = new ConcurrentHashMap<>(); private CommandJigsawPiece piece; private CommandJigsawPool pool; private CommandJigsawConnector connector; @@ -212,7 +178,7 @@ public class CommandJigsaw implements DirectorExecutor { data.invalidateStructureResources(); VolmitSender commandSender = sender(); commandSender.sendMessage(C.GREEN + result.message()); - openProject(player(), commandSender, dimension, targetKey, seed); + CommandJigsawOpen.openProject(player(), commandSender, dimension, targetKey, seed); } @Director(description = "Open an existing Iris jigsaw graph in transient Studio", @@ -224,129 +190,7 @@ public class CommandJigsaw implements DirectorExecutor { customHandler = IrisStructureHandler.class) String structure, @Param(description = "Studio world seed", defaultValue = "1337") long seed ) { - openProject(player(), sender(), dimension, structure, seed); - } - - private static void openProject( - Player targetPlayer, - VolmitSender commandSender, - IrisDimension dimension, - String structure, - long seed - ) { - UUID playerId = targetPlayer.getUniqueId(); - UUID ownerId = JigsawStudioActivation.activeOwnerId(); - if (ownerId != null && !ownerId.equals(playerId)) { - sendError(commandSender, "Jigsaw Studio is already owned by another player session."); - return; - } - UUID openingOwner = JigsawStudioActivation.openingOwnerId(); - if (openingOwner != null) { - sendError(commandSender, openingOwner.equals(playerId) - ? "Your Jigsaw Studio is already opening." - : "Jigsaw Studio is already opening for another player."); - return; - } - ActiveContext previous = active(targetPlayer); - if (ownerId != null && previous == null) { - sendError(commandSender, "Your active Jigsaw Studio binding is unavailable. Close it before opening another project."); - return; - } - ActiveContext dirty = findDirtyContext(); - if (dirty != null) { - sendError(commandSender, - "The current Jigsaw Studio is waiting for autosave. Wait for it to finish or close with discard=true."); - return; - } - IrisData data = requireData(dimension, commandSender); - if (data == null) { - return; - } - IrisStructure irisStructure = data.load(IrisStructure.class, structure, false); - if (irisStructure == null) { - sendError(commandSender, "No Iris jigsaw structure '" + structure + "' exists in this pack."); - return; - } - - JigsawStudioLayout layout; - try { - layout = JigsawStudioGraphMapper.map(data, irisStructure); - } catch (RuntimeException exception) { - Iris.reportError("Failed to map Jigsaw Studio graph '" + structure + "'.", exception); - sendError(commandSender, "Could not map this jigsaw graph: " + exception.getMessage()); - return; - } - String packKey = dimension.getLoadKey(); - if (!JigsawStudioActivation.tryBeginOpen(playerId)) { - sendError(commandSender, - "Jigsaw Studio began opening for another player. Try again after it finishes."); - return; - } - JigsawStudioService studioService = JigsawStudioService.get(); - UUID closingRequestId = previous == null ? null : previous.request().requestId(); - if (closingRequestId != null) { - JigsawStudioService.CloseStart closeStart = studioService.tryBeginClose( - closingRequestId, - playerId, - false); - if (closeStart != JigsawStudioService.CloseStart.STARTED) { - JigsawStudioActivation.finishOpen(playerId); - sendError(commandSender, closeStart == JigsawStudioService.CloseStart.SAVE_IN_PROGRESS - ? "The current Jigsaw Studio is saving. Wait for it to finish before opening another project." - : closeStart == JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS - ? "The current Jigsaw Studio is loading a variant. Wait for it to finish before opening another project." - : closeStart == JigsawStudioService.CloseStart.DIRTY - ? "The current Jigsaw Studio is waiting for autosave. Wait for it to finish before opening another project." - : "The current Jigsaw Studio could not be reserved for replacement."); - return; - } - } - JigsawStudioActivation.StagedActivation staged; - try { - staged = JigsawStudioActivation.stage( - packKey, - structure, - layout.mode(), - compatibilityOf(irisStructure), - layout.cellDimensions(), - data, - layout, - playerId, - closingRequestId); - } catch (RuntimeException exception) { - JigsawStudioActivation.finishOpen(playerId); - studioService.cancelClose(closingRequestId); - Iris.reportError("Failed to stage Jigsaw Studio for '" + structure + "'.", exception); - sendError(commandSender, "Jigsaw Studio staging failed: " + exception.getMessage()); - return; - } - commandSender.sendMessage(C.GREEN + "Opening transient Jigsaw Studio for '" + structure + "' with " - + layout.bays().size() + " workcells."); - - try { - Iris.service(StudioSVC.class).openTracked( - commandSender, - seed, - packKey, - STUDIO_OPEN_KIND, - () -> beginStagedOpen(staged), - world -> finishOpen(targetPlayer, world, staged, studioService)) - .whenComplete((result, throwable) -> J.s(() -> finishOpenAttempt( - commandSender, - playerId, - structure, - closingRequestId, - studioService, - staged, - result, - throwable))); - } catch (Throwable exception) { - JigsawStudioActivation.finishOpen(playerId); - studioService.cancelClose(closingRequestId); - JigsawStudioActivation.rollback(staged); - Iris.reportError("Failed to open Jigsaw Studio for '" + structure + "'.", exception); - sendError(commandSender, "Jigsaw Studio open failed: " + exception.getMessage()); - } + CommandJigsawOpen.openProject(player(), sender(), dimension, structure, seed); } @Director(description = "Close the active Jigsaw Studio", sync = true, @@ -578,7 +422,7 @@ public class CommandJigsaw implements DirectorExecutor { } VanillaJigsawExportFormat exportFormat; try { - exportFormat = parseFormat(format); + exportFormat = CommandJigsawExports.parseFormat(format); } catch (IllegalArgumentException exception) { sendError(exception.getMessage()); return; @@ -587,7 +431,7 @@ public class CommandJigsaw implements DirectorExecutor { .toPath().toAbsolutePath().normalize(); Path destination; try { - destination = resolveExportDestination(exportRoot, output, exportFormat); + destination = CommandJigsawExports.resolveExportDestination(exportRoot, output, exportFormat); } catch (IllegalArgumentException exception) { sendError(exception.getMessage()); return; @@ -612,19 +456,19 @@ public class CommandJigsaw implements DirectorExecutor { JigsawStudioService studioService = JigsawStudioService.get(); JigsawStudioService.ExportStart exportStart = studioService.tryBeginExport(requestId, playerId); if (exportStart != JigsawStudioService.ExportStart.STARTED) { - sendError(exportStartError(exportStart)); + sendError(CommandJigsawExports.exportStartError(exportStart)); return; } - if (!beginExport(playerId, destination)) { + if (!CommandJigsawExports.beginExport(playerId, destination)) { studioService.finishExport(requestId); sendError("A vanilla Jigsaw export for this player or output is already in progress."); return; } - ExportLease lease = new ExportLease(() -> { - finishExport(playerId, destination); + CommandJigsawExports.ExportLease lease = new CommandJigsawExports.ExportLease(() -> { + CommandJigsawExports.finishExport(playerId, destination); studioService.finishExport(requestId); }); - ExportOperation operation = new ExportOperation( + CommandJigsawExports.ExportOperation operation = new CommandJigsawExports.ExportOperation( targetPlayer, sender(), requestId, @@ -634,7 +478,7 @@ public class CommandJigsaw implements DirectorExecutor { sender().sendMessage(C.GRAY + "Exporting '" + operation.structureKey() + "' in the background to " + destination + "..."); try { - J.a(() -> runExport(operation)); + J.a(() -> CommandJigsawExports.runExport(operation)); } catch (RuntimeException exception) { operation.lease().release(); Iris.reportError("Failed to schedule vanilla Jigsaw export for '" @@ -643,238 +487,11 @@ public class CommandJigsaw implements DirectorExecutor { } } - static String exportStartError(JigsawStudioService.ExportStart exportStart) { - Objects.requireNonNull(exportStart, "Jigsaw Studio export start status"); - return switch (exportStart) { - case NOT_ACTIVE -> "The active Jigsaw Studio is no longer available."; - case NOT_OWNER -> "Only the Jigsaw Studio owner can export this project."; - case DIRTY -> "Wait for the pending autosave or discard the edits before exporting the on-disk graph."; - case CLOSING -> "The active Jigsaw Studio is closing and cannot be exported."; - case SAVE_IN_PROGRESS -> "Wait for the current Jigsaw Studio save to finish before exporting."; - case OPERATION_IN_PROGRESS -> "Wait for the current Jigsaw Studio operation to finish before exporting."; - case IN_PROGRESS -> "A Jigsaw Studio export is already in progress."; - case STARTED -> throw new IllegalArgumentException("STARTED is not an export failure"); - }; - } - - static boolean beginExport(UUID playerId, Path destination) { - Objects.requireNonNull(playerId); - Path normalizedDestination = Objects.requireNonNull(destination).toAbsolutePath().normalize(); - if (!EXPORTING_PLAYERS.add(playerId)) { - return false; - } - if (EXPORT_DESTINATIONS.add(normalizedDestination)) { - return true; - } - EXPORTING_PLAYERS.remove(playerId); - return false; - } - - static void finishExport(UUID playerId, Path destination) { - EXPORTING_PLAYERS.remove(Objects.requireNonNull(playerId)); - EXPORT_DESTINATIONS.remove(Objects.requireNonNull(destination).toAbsolutePath().normalize()); - } - - private static void pruneAdoptionPlanBindings() { - for (Map.Entry entry : ADOPTION_PLANS.entrySet()) { - AdoptionPlanBinding binding = entry.getValue(); - if (binding.service().plan(entry.getKey()).isEmpty()) { - ADOPTION_PLANS.remove(entry.getKey(), binding); - } - } - } - - private static boolean studioIsActiveOrOpening() { + static boolean studioIsActiveOrOpening() { return JigsawStudioActivation.activeOwnerId() != null || JigsawStudioActivation.openingOwnerId() != null; } - private static void runAdoptionApply( - Player targetPlayer, - VolmitSender commandSender, - UUID planId, - AdoptionPlanBinding binding - ) { - if (studioIsActiveOrOpening()) { - ADOPTION_PLANS.putIfAbsent(planId, binding); - J.runEntity(targetPlayer, () -> sendError(commandSender, - "Close the active or opening Jigsaw Studio before applying an adoption plan.")); - return; - } - IrisStructureAdoptionResult result; - Throwable failure = null; - try { - result = binding.service().apply(binding.plan()); - } catch (Throwable throwable) { - result = null; - failure = throwable; - Iris.reportError("Failed to apply Jigsaw adoption plan '" + planId + "'.", throwable); - } - IrisStructureAdoptionResult completedResult = result; - Throwable completedFailure = failure; - J.runEntity(targetPlayer, () -> finishAdoptionApply( - targetPlayer, - commandSender, - binding, - completedResult, - completedFailure)); - } - - private static void runAdoptionInspect( - Player targetPlayer, - VolmitSender commandSender, - UUID ownerId, - IrisDimension dimension, - IrisData data, - Path root, - String sourceKey, - Optional targetKey, - IrisStructureAdoptionStrategy strategy - ) { - IrisStructureAdoptionService service = null; - IrisStructureAdoptionPlan plan = null; - Throwable failure = null; - try { - IrisStructureAdoptionInputKind inputKind = adoptionInputKind(root, sourceKey); - service = ADOPTION_SERVICES.computeIfAbsent(root, IrisStructureAdoptionService::new); - plan = service.inspect(new IrisStructureAdoptionRequest( - sourceKey, - targetKey, - strategy, - inputKind)); - } catch (Throwable throwable) { - failure = throwable; - Iris.reportError("Failed to inspect Jigsaw adoption source '" + sourceKey + "'.", throwable); - } - IrisStructureAdoptionService completedService = service; - IrisStructureAdoptionPlan completedPlan = plan; - Throwable completedFailure = failure; - J.runEntity(targetPlayer, () -> finishAdoptionInspect( - commandSender, - ownerId, - dimension, - data, - completedService, - completedPlan, - completedFailure)); - } - - private static void finishAdoptionInspect( - VolmitSender commandSender, - UUID ownerId, - IrisDimension dimension, - IrisData data, - IrisStructureAdoptionService service, - IrisStructureAdoptionPlan plan, - Throwable failure - ) { - if (failure != null || service == null || plan == null) { - sendError(commandSender, "Jigsaw adoption inspection failed: " - + (failure == null ? "no result" : failure.getMessage())); - return; - } - pruneAdoptionPlanBindings(); - if (service.plan(plan.planId()).isPresent()) { - ADOPTION_PLANS.put(plan.planId(), new AdoptionPlanBinding( - ownerId, dimension, data, service, plan)); - } - reportAdoptionPlan(commandSender, plan); - } - - private static void finishAdoptionApply( - Player targetPlayer, - VolmitSender commandSender, - AdoptionPlanBinding binding, - IrisStructureAdoptionResult result, - Throwable failure - ) { - if (failure != null || result == null) { - sendError(commandSender, "Jigsaw adoption failed: " - + (failure == null ? "no result" : failure.getMessage())); - return; - } - reportAdoptionResult(commandSender, result); - if (!result.successful()) { - return; - } - StructureKey target = result.receipt().orElseThrow().targetStructure(); - binding.data().invalidateStructureResources(); - openProject(targetPlayer, commandSender, binding.dimension(), target.path(), DEFAULT_STUDIO_SEED); - } - - private static void reportAdoptionPlan(VolmitSender commandSender, IrisStructureAdoptionPlan plan) { - C statusColor = plan.canApply() ? C.GREEN : C.RED; - commandSender.sendMessage(statusColor + "Adoption plan " + plan.planId() + " -> " - + plan.disposition() + " for " + plan.targetStructure().value() + "."); - commandSender.sendMessage(C.GRAY + "Resources=" + plan.resourceCount() - + ", bytes=" + plan.totalSourceBytes() - + ", errors=" + plan.errorCount() - + ", warnings=" + plan.warningCount() + "."); - for (IrisStructureAdoptionDiagnostic diagnostic : plan.diagnostics()) { - commandSender.sendMessage(adoptionColor(diagnostic).toString() + diagnostic.summary()); - } - if (plan.canApply()) { - commandSender.sendMessage(C.AQUA + "Apply with /iris jigsaw adopt apply " + plan.planId()); - } - } - - private static void reportAdoptionResult(VolmitSender commandSender, IrisStructureAdoptionResult result) { - commandSender.sendMessage((result.successful() ? C.GREEN : C.RED) - + "Adoption plan " + result.planId() + ": " + result.status() + "."); - for (IrisStructureAdoptionDiagnostic diagnostic : result.diagnostics()) { - commandSender.sendMessage(adoptionColor(diagnostic).toString() + diagnostic.summary()); - } - } - - private static C adoptionColor(IrisStructureAdoptionDiagnostic diagnostic) { - return switch (diagnostic.severity()) { - case ERROR -> C.RED; - case WARNING -> C.YELLOW; - case INFO -> C.GRAY; - }; - } - - private static void runExport(ExportOperation operation) { - VanillaJigsawExportResult result = null; - Throwable failure = null; - try { - result = new VanillaJigsawDatapackExporter().export(operation.request()); - } catch (Throwable throwable) { - failure = throwable; - Iris.reportError("Vanilla Jigsaw export failed for '" + operation.structureKey() + "'.", throwable); - } finally { - operation.lease().release(); - } - VanillaJigsawExportResult completedResult = result; - Throwable completedFailure = failure; - Runnable response = () -> reportExport(operation, completedResult, completedFailure); - J.runEntity(operation.player(), response); - } - - private static void reportExport( - ExportOperation operation, - VanillaJigsawExportResult result, - Throwable failure - ) { - if (failure != null || result == null) { - operation.sender().sendMessage(C.RED + "Background vanilla export for '" - + operation.structureKey() + "' failed: " - + (failure == null ? "no result" : failure.getMessage())); - return; - } - for (VanillaJigsawExportDiagnostic diagnostic : result.diagnostics()) { - C color = diagnostic.isBlocking() ? C.RED : C.YELLOW; - operation.sender().sendMessage(color.toString() + diagnostic.code() + ": " + diagnostic.message()); - } - ActiveContext current = active(operation.player()); - boolean sameSession = current != null - && current.request().requestId().equals(operation.requestId()); - String contextLabel = sameSession ? "Vanilla export" : "Background vanilla export for '" - + operation.structureKey() + "'"; - operation.sender().sendMessage((result.isSuccess() ? C.GREEN : C.RED) + contextLabel + " " - + result.status() + ": " + result.output()); - } - static ActiveContext active(Player player) { if (player == null) { return null; @@ -902,7 +519,7 @@ public class CommandJigsaw implements DirectorExecutor { return new ActiveContext(binding.packKey(), request, session); } - private static ActiveContext findDirtyContext() { + static ActiveContext findDirtyContext() { for (SessionBinding binding : PLAYER_PACKS.values()) { JigsawStudioActivation.Request request = JigsawStudioActivation.getRequest(binding.packKey()); JigsawStudioSession session = JigsawStudioActivation.getSession(binding.packKey()); @@ -927,7 +544,7 @@ public class CommandJigsaw implements DirectorExecutor { return requireData(dimension, sender()); } - private static IrisData requireData(IrisDimension dimension, VolmitSender commandSender) { + static IrisData requireData(IrisDimension dimension, VolmitSender commandSender) { IrisData data = dimension == null ? null : dimension.getLoader(); if (data == null) { sendError(commandSender, "Could not resolve the requested Iris pack dimension."); @@ -935,108 +552,11 @@ public class CommandJigsaw implements DirectorExecutor { return data; } - private static void beginStagedOpen(JigsawStudioActivation.StagedActivation staged) { - if (!JigsawStudioActivation.beginStagedGeneration(staged)) { - throw new IllegalStateException("The staged Jigsaw Studio activation is no longer current"); - } - } - - private static void finishOpenAttempt( - VolmitSender commandSender, - UUID playerId, - String structureKey, - UUID closingRequestId, - JigsawStudioService studioService, - JigsawStudioActivation.StagedActivation staged, - StudioOpenCoordinator.StudioOpenResult result, - Throwable throwable - ) { - JigsawStudioActivation.finishOpen(playerId); - if (throwable == null && result != null) { - studioService.cancelClose(closingRequestId); - return; - } - studioService.cancelClose(closingRequestId); - JigsawStudioActivation.rollback(staged); - Throwable failure = unwrapCompletionFailure(throwable == null - ? new IllegalStateException("Studio open completed without a result") - : throwable); - if (isExpectedOpenDenial(failure)) { - Iris.warn("Jigsaw Studio open for '%s' was denied: %s", - structureKey, failure.getMessage()); - return; - } - Iris.reportError("Failed to open Jigsaw Studio for '" + structureKey + "'.", failure); - sendError(commandSender, "Jigsaw Studio open failed: " + failure.getMessage()); - } - - private static void finishOpen( - Player targetPlayer, - World world, - JigsawStudioActivation.StagedActivation staged, - JigsawStudioService studioService - ) { - if (world == null) { - throw new IllegalStateException("Jigsaw Studio opened without a world"); - } - JigsawStudioActivation.Request request = staged.request(); - JigsawStudioSession session = staged.session(); - try { - WorldRuntimeControlService.get().applyObjectStudioWorldRules(world); - } catch (Throwable exception) { - Iris.reportError("Failed to apply Jigsaw Studio world rules for '" + world.getName() + "'.", exception); - } - JigsawStudioBay destination = initialBay(session.layout()); - Location target = destination == null - ? new Location(world, 0.5D, 66D, 0.5D) - : new Location( - world, - destination.bounds().originX() + destination.bounds().dimensions().width() / 2.0D, - destination.bounds().maxY() + 2.0D, - destination.bounds().originZ() + destination.bounds().dimensions().depth() / 2.0D); - if (destination != null) { - session.selectBay(destination.stableId()); - } - if (!JigsawStudioActivation.commit(staged)) { - throw new IllegalStateException("The staged Jigsaw Studio activation could not be committed"); - } - studioService.activationCommitted(world, request.requestId()); - PLAYER_PACKS.put(targetPlayer.getUniqueId(), new SessionBinding( - request.packKey(), request.requestId())); - try { - J.runEntity(targetPlayer, () -> BukkitPlatform.teleportAsync(targetPlayer, target) - .thenRun(() -> J.runEntity(targetPlayer, () -> targetPlayer.setGameMode(GameMode.CREATIVE)))); - } catch (RuntimeException exception) { - Iris.reportError("Failed to move the player to the committed Jigsaw Studio workcell.", exception); - } - } - - private static JigsawStudioBay initialBay(JigsawStudioLayout layout) { - for (JigsawStudioBay bay : layout.bays()) { - if (layout.defaultVariant(bay).isPresent()) { - return bay; - } - } - return layout.bays().isEmpty() ? null : layout.bays().getFirst(); - } - - private static Throwable unwrapCompletionFailure(Throwable failure) { - Throwable current = failure; - while (current instanceof CompletionException && current.getCause() != null) { - current = current.getCause(); - } - return current; - } - - static boolean isExpectedOpenDenial(Throwable failure) { - return unwrapCompletionFailure(failure) instanceof BrokenPackException; - } - private void sendError(String message) { sender().sendMessage(C.RED + message); } - private static void sendError(VolmitSender commandSender, String message) { + static void sendError(VolmitSender commandSender, String message) { commandSender.sendMessage(C.RED + message); } @@ -1056,7 +576,7 @@ public class CommandJigsaw implements DirectorExecutor { }; } - private static JigsawStudioCompatibilityTarget compatibilityOf(IrisStructure structure) { + static JigsawStudioCompatibilityTarget compatibilityOf(IrisStructure structure) { return structure.resolvedCompatibility() == IrisJigsawCompatibility.VANILLA_PORTABLE ? JigsawStudioCompatibilityTarget.VANILLA_PORTABLE : JigsawStudioCompatibilityTarget.IRIS_EXTENDED; @@ -1098,97 +618,6 @@ public class CommandJigsaw implements DirectorExecutor { + ". This Studio converter accepts registered jigsaw structures only."; } - static String parseAdoptionSource(String value) { - String normalized = Objects.requireNonNull(value, "Adoption source").trim() - .toLowerCase(Locale.ROOT); - StructureKey source = StructureKey.parse(normalized, "iris"); - if (!"iris".equals(source.namespace())) { - throw new IllegalArgumentException("Adoption sources must use the iris namespace."); - } - return source.path(); - } - - static Optional parseAdoptionTarget(String value) { - String normalized = Objects.requireNonNull(value, "Adoption target").trim() - .toLowerCase(Locale.ROOT); - if (normalized.isEmpty() || "auto".equals(normalized)) { - return Optional.empty(); - } - StructureKey target = StructureKey.parse(normalized, "iris"); - if (!"iris".equals(target.namespace())) { - throw new IllegalArgumentException("Adoption targets must use the iris namespace."); - } - return Optional.of(target); - } - - private static IrisStructureAdoptionStrategy parseAdoptionStrategy(String value) { - return switch (Objects.requireNonNull(value, "Adoption strategy").trim().toLowerCase(Locale.ROOT)) { - case "auto" -> IrisStructureAdoptionStrategy.AUTO; - case "in-place", "in_place", "inplace", "claim" -> IrisStructureAdoptionStrategy.IN_PLACE; - case "clone", "copy" -> IrisStructureAdoptionStrategy.CLONE; - default -> throw new IllegalArgumentException("Adoption strategy must be auto, in-place, or clone."); - }; - } - - static IrisStructureAdoptionInputKind adoptionInputKind(Path packRoot, String source) throws IOException { - Path root = canonicalAdoptionRoot(packRoot); - StructureKey sourceKey = new StructureKey("iris", parseAdoptionSource(source)); - Path manifestPath = new StructureTransactionWriter(root).ownershipManifestPath(sourceKey); - if (!Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) { - return IrisStructureAdoptionInputKind.UNOWNED_IRIS; - } - if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) { - throw new IOException("Ownership manifest is not a regular file: " + manifestPath); - } - StructureOwnershipManifest manifest; - try { - manifest = StructureOwnershipManifest.fromJson(Files.readAllBytes(manifestPath)); - } catch (RuntimeException exception) { - throw new IOException("Invalid ownership manifest at " + manifestPath, exception); - } - if (!manifest.structure().equals(sourceKey)) { - throw new IOException("Ownership manifest at " + manifestPath - + " belongs to " + manifest.structure().value()); - } - return manifest.provenance().origin() == StructureOwnershipManifest.Origin.MANAGED_DATAPACK - ? IrisStructureAdoptionInputKind.MANAGED_DATAPACK - : IrisStructureAdoptionInputKind.UNOWNED_IRIS; - } - - private static Path canonicalAdoptionRoot(Path packRoot) throws IOException { - Path normalized = Objects.requireNonNull(packRoot, "Adoption pack root").toAbsolutePath().normalize(); - return Files.exists(normalized, LinkOption.NOFOLLOW_LINKS) ? normalized.toRealPath() : normalized; - } - - private static VanillaJigsawExportFormat parseFormat(String value) { - return switch (value.trim().toLowerCase(Locale.ROOT)) { - case "zip" -> VanillaJigsawExportFormat.ZIP; - case "directory", "dir", "folder" -> VanillaJigsawExportFormat.DIRECTORY; - default -> throw new IllegalArgumentException("Export format must be directory or zip."); - }; - } - - static Path resolveExportDestination( - Path exportRoot, - String output, - VanillaJigsawExportFormat format - ) { - Path root = Objects.requireNonNull(exportRoot, "Jigsaw export root").toAbsolutePath().normalize(); - String requested = Objects.requireNonNull(output, "Jigsaw export output name"); - String name = requested.trim(); - if (!name.equals(requested) || !EXPORT_OUTPUT_NAME.matcher(name).matches()) { - throw new IllegalArgumentException( - "Export output must be one file name using letters, numbers, '.', '_', or '-'."); - } - String fileName = format == VanillaJigsawExportFormat.ZIP && !name.endsWith(".zip") - ? name + ".zip" : name; - Path destination = root.resolve(fileName).normalize(); - if (destination.equals(root) || !root.equals(destination.getParent())) { - throw new IllegalArgumentException("Export output must be one artifact inside the Studio exports folder."); - } - return destination; - } - static JigsawStudioBay selectedBay(ActiveContext context) { String selected = context.session().selectedBayId().orElse(null); return selected == null ? null : context.session().layout().get(selected); @@ -1328,42 +757,7 @@ public class CommandJigsaw implements DirectorExecutor { } } - private record SessionBinding(String packKey, UUID requestId) { - } - - private record ExportOperation( - Player player, - VolmitSender sender, - UUID requestId, - String structureKey, - VanillaJigsawExportRequest request, - ExportLease lease - ) { - } - - static final class ExportLease { - private final Runnable release; - private final AtomicBoolean released; - - ExportLease(Runnable release) { - this.release = Objects.requireNonNull(release, "Jigsaw export lease release action"); - this.released = new AtomicBoolean(); - } - - void release() { - if (released.compareAndSet(false, true)) { - release.run(); - } - } - } - - private record AdoptionPlanBinding( - UUID ownerId, - IrisDimension dimension, - IrisData data, - IrisStructureAdoptionService service, - IrisStructureAdoptionPlan plan - ) { + record SessionBinding(String packKey, UUID requestId) { } public static final class JigsawModeHandler implements DirectorParameterHandler { @@ -1419,791 +813,4 @@ public class CommandJigsaw implements DirectorExecutor { } } - public static final class JigsawAdoptionStrategyHandler implements DirectorParameterHandler { - @Override - public KList getPossibilities() { - return new KList<>("auto", "in-place", "clone"); - } - - @Override - public String toString(String value) { - return value == null ? "" : value; - } - - @Override - public String parse(String input, boolean force) throws DirectorParsingException { - try { - return switch (parseAdoptionStrategy(input)) { - case AUTO -> "auto"; - case IN_PLACE -> "in-place"; - case CLONE -> "clone"; - }; - } catch (IllegalArgumentException exception) { - throw new DirectorParsingException(exception.getMessage()); - } - } - - @Override - public boolean supports(Class type) { - return type == String.class; - } - } - - public static final class JigsawAdoptionPlanHandler implements DirectorParameterHandler { - @Override - public KList getPossibilities() { - return new KList<>(); - } - - @Override - public String toString(String value) { - return value == null ? "" : value; - } - - @Override - public String parse(String input, boolean force) throws DirectorParsingException { - try { - return UUID.fromString(input).toString(); - } catch (IllegalArgumentException exception) { - throw new DirectorParsingException("Adoption plan ID must be a UUID."); - } - } - - @Override - public boolean supports(Class type) { - return type == String.class; - } - } - - @Director(name = "adopt", description = "Inspect and apply safe ownership adoption plans", - origin = DirectorOrigin.PLAYER) - public static class CommandJigsawAdopt implements DirectorExecutor { - @Director(description = "Inspect an Iris graph for safe Studio ownership adoption", sync = true, - origin = DirectorOrigin.PLAYER) - public void inspect( - @Param(description = "Pack dimension") IrisDimension dimension, - @Param(description = "Existing Iris structure key") String source, - @Param(description = "New owned Iris key, or auto", defaultValue = "auto") String target, - @Param(description = "auto, in-place, or clone", defaultValue = "auto", - customHandler = JigsawAdoptionStrategyHandler.class) String strategy - ) { - VolmitSender commandSender = sender(); - IrisData data = requireData(dimension, commandSender); - if (data == null) { - return; - } - String sourceKey; - Optional targetKey; - IrisStructureAdoptionStrategy adoptionStrategy; - Path root; - try { - sourceKey = parseAdoptionSource(source); - targetKey = parseAdoptionTarget(target); - adoptionStrategy = parseAdoptionStrategy(strategy); - root = canonicalAdoptionRoot(data.getDataFolder().toPath()); - } catch (IOException | IllegalArgumentException exception) { - sendError(commandSender, "Could not inspect adoption source: " + exception.getMessage()); - return; - } - Player targetPlayer = player(); - UUID ownerId = targetPlayer.getUniqueId(); - commandSender.sendMessage(C.GRAY + "Inspecting Jigsaw adoption source '" + sourceKey - + "' asynchronously..."); - try { - J.a(() -> runAdoptionInspect( - targetPlayer, - commandSender, - ownerId, - dimension, - data, - root, - sourceKey, - targetKey, - adoptionStrategy)); - } catch (RuntimeException exception) { - Iris.reportError("Failed to schedule Jigsaw adoption inspection for '" - + sourceKey + "'.", exception); - sendError(commandSender, "Jigsaw adoption inspection could not be scheduled: " - + exception.getMessage()); - } - } - - @Director(description = "Apply one inspected adoption plan without overwriting", sync = true, - origin = DirectorOrigin.PLAYER) - public void apply( - @Param(description = "Plan UUID returned by adopt inspect", - customHandler = JigsawAdoptionPlanHandler.class) String planId - ) { - VolmitSender commandSender = sender(); - UUID parsedPlanId; - try { - parsedPlanId = UUID.fromString(planId); - } catch (IllegalArgumentException exception) { - sendError(commandSender, "Adoption plan ID must be a UUID."); - return; - } - if (studioIsActiveOrOpening()) { - sendError(commandSender, - "Close the active or opening Jigsaw Studio before applying an adoption plan."); - return; - } - pruneAdoptionPlanBindings(); - AdoptionPlanBinding binding = ADOPTION_PLANS.get(parsedPlanId); - UUID playerId = player().getUniqueId(); - if (binding == null || !binding.ownerId().equals(playerId)) { - sendError(commandSender, "No active adoption plan '" + parsedPlanId - + "' belongs to your player session. Run adopt inspect again."); - return; - } - if (binding.service().plan(parsedPlanId).isEmpty()) { - ADOPTION_PLANS.remove(parsedPlanId, binding); - sendError(commandSender, "Adoption plan '" + parsedPlanId - + "' expired or was already consumed. Run adopt inspect again."); - return; - } - if (!ADOPTION_PLANS.remove(parsedPlanId, binding)) { - sendError(commandSender, "Adoption plan '" + parsedPlanId + "' is already being applied."); - return; - } - Player targetPlayer = player(); - commandSender.sendMessage(C.GRAY + "Applying adoption plan '" + parsedPlanId - + "' asynchronously without overwrite..."); - try { - J.a(() -> runAdoptionApply(targetPlayer, commandSender, parsedPlanId, binding)); - } catch (RuntimeException exception) { - ADOPTION_PLANS.putIfAbsent(parsedPlanId, binding); - Iris.reportError("Failed to schedule Jigsaw adoption plan '" + parsedPlanId + "'.", exception); - sendError(commandSender, "Jigsaw adoption could not be scheduled: " + exception.getMessage()); - } - } - } - - @Director(name = "connector", description = "Inspect and configure targeted jigsaw connectors", - origin = DirectorOrigin.PLAYER) - public static class CommandJigsawConnector implements DirectorExecutor { - @Director(description = "Set the Iris-only channel on the targeted jigsaw marker", sync = true, - origin = DirectorOrigin.PLAYER) - public void channel( - @Param(description = "Exact channel, or none to clear") String channel - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - Block target = player().getTargetBlockExact(8); - if (target == null || !(target.getBlockData() instanceof Jigsaw)) { - sendError(this, "Look directly at a jigsaw marker within eight blocks."); - return; - } - JigsawStudioBay bay = context.session().layout().findAt( - target.getX(), target.getY(), target.getZ()); - JigsawStudioVariant activeVariant = bay == null - ? null - : context.session().activeVariant(bay.stableId()).orElse(null); - if (activeVariant == null) { - sendError(this, "The targeted marker must be inside a workcell with an active variant."); - return; - } - if (!activeVariant.owned()) { - sendError(this, "The active variant is read-only. Adopt it into this project before editing it."); - return; - } - IrisObject object = context.request().source().getObjectLoader().load(activeVariant.objectKey()); - if (object == null) { - sendError(this, "The active variant object '" + activeVariant.objectKey() + "' is missing."); - return; - } - IrisPosition canonicalPosition = new IrisPosition( - target.getX() - bay.bounds().originX(), - target.getY() - bay.bounds().originY(), - target.getZ() - bay.bounds().originZ()); - IrisPosition position; - try { - position = activeVariant.canonicalToSourcePosition( - canonicalPosition, - new JigsawStudioCellDimensions(object.getW(), object.getH(), object.getD())); - } catch (IllegalArgumentException exception) { - sendError(this, "Connector channel update failed: " + exception.getMessage()); - return; - } - String normalized = channel == null || channel.isBlank() || "none".equalsIgnoreCase(channel) - ? "none" - : channel.trim(); - runGraphMutation(player(), context, () -> { - JigsawStudioGraphEditor.updateConnectorChannel( - context.request().source().getDataFolder().toPath(), - context.request().structureKey(), - activeVariant.pieceKey(), - position, - channel); - return mappedMutationResult( - context, - "", - "", - "Persisted connector channel '" + normalized + "' on piece '" - + activeVariant.pieceKey() + "' at " + position.getX() + "," + position.getY() - + "," + position.getZ() + "."); - }); - } - } - - @Director(name = "pool", description = "Manage owned jigsaw pools", origin = DirectorOrigin.PLAYER) - public static class CommandJigsawPool implements DirectorExecutor { - @Director(description = "Create an empty owned jigsaw pool", sync = true, - origin = DirectorOrigin.PLAYER) - public void create( - @Param(description = "New owned pool resource key") String poolKey, - @Param(description = "Owned fallback pool key, or none", defaultValue = "none") - String fallbackPoolKey - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - String fallback = "none".equalsIgnoreCase(fallbackPoolKey) ? "" : fallbackPoolKey; - runGraphMutation(player(), context, () -> { - JigsawStudioGraphEditor.createPool( - context.request().source().getDataFolder().toPath(), - context.request().structureKey(), - poolKey, - fallback); - return mappedMutationResult( - context, - "", - "", - "Created owned pool '" + poolKey + "' with fallback '" - + (fallback.isBlank() ? "none" : fallback) + "'."); - }); - } - } - - @Director(name = "piece", description = "Manage contextual Studio pieces", origin = DirectorOrigin.PLAYER) - public static class CommandJigsawPiece implements DirectorExecutor { - @Director(description = "Create an owned piece from the selected Studio topology", sync = true, - origin = DirectorOrigin.PLAYER) - public void create( - @Param(description = "Owned pool resource key") String poolKey, - @Param(description = "New owned piece and object key") String pieceKey, - @Param(description = "Positive selection weight", defaultValue = "1") int weight - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - JigsawPlanarTopology topology = null; - JigsawStudioBay targetWorkcell = context.session().layout().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID); - if (context.session().layout().mode() == JigsawStudioMode.PLANAR_JIGSAW) { - Location location = player().getLocation(); - JigsawStudioBay contextual = context.session().layout().findAt( - location.getBlockX(), location.getBlockY(), location.getBlockZ()); - if (contextual == null) { - contextual = selectedBay(context); - } else { - context.session().selectBay(contextual.stableId()); - } - if (contextual == null || contextual.topology().isEmpty()) { - sendError(this, "Stand in or select a planar topology workcell before creating a variant."); - return; - } - topology = contextual.topology().get(); - targetWorkcell = contextual; - } - if (targetWorkcell == null) { - sendError(this, "The selected Jigsaw Studio workcell is no longer available."); - return; - } - JigsawPlanarTopology selectedTopology = topology; - String activationWorkcellId = targetWorkcell.stableId(); - JigsawStudioCellDimensions dimensions = targetWorkcell.capacity(); - String topologyText = selectedTopology == null ? "blank spatial" : selectedTopology.name(); - runGraphMutation(player(), context, () -> { - JigsawStudioGraphEditor.createPiece( - context.request().source().getDataFolder().toPath(), - context.request().structureKey(), - poolKey, - pieceKey, - weight, - dimensions, - selectedTopology); - return mappedMutationResult( - context, - activationWorkcellId, - pieceKey, - "Created owned " + topologyText + " piece '" + pieceKey - + "' and added it to pool '" + poolKey + "' atomically."); - }); - } - - @Director(description = "Add an existing jigsaw piece to an owned pool transactionally", sync = true, - origin = DirectorOrigin.PLAYER) - public void add( - @Param(description = "Owned pool resource key") String poolKey, - @Param(description = "Existing jigsaw piece key") String pieceKey, - @Param(description = "Positive selection weight", defaultValue = "1") int weight - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - IrisJigsawPiece piece = context.request().source().getJigsawPieceLoader().load(pieceKey); - if (piece == null) { - sendError(this, "No jigsaw piece '" + pieceKey + "' exists in this pack."); - return; - } - if (piece.getObject() == null || piece.getObject().isBlank()) { - sendError(this, "Jigsaw piece '" + pieceKey + "' does not declare an object."); - return; - } - IrisObject object = context.request().source().getObjectLoader().load(piece.getObject()); - if (object == null) { - sendError(this, "Jigsaw piece '" + pieceKey + "' has no loadable object."); - return; - } - PieceWorkcellResolution resolution; - try { - resolution = resolvePieceWorkcell(context.session().layout(), piece, object); - } catch (IllegalArgumentException exception) { - sendError(this, "Jigsaw piece '" + pieceKey + "' cannot be assigned to a Studio workcell: " - + exception.getMessage()); - return; - } - if (!resolution.fits()) { - IrisPosition required = resolution.requiredDimensions(); - JigsawStudioCellDimensions capacity = resolution.workcell().capacity(); - sendError(this, "Jigsaw piece '" + pieceKey + "' requires " + required.getX() + "x" - + required.getY() + "x" + required.getZ() + " in " + resolution.workcell().displayName() - + ", but that workcell capacity is " + capacity.width() + "x" + capacity.height() - + "x" + capacity.depth() + "."); - return; - } - String objectKey = piece.getObject(); - runGraphMutation(player(), context, () -> { - if (!JigsawStudioGraphEditor.ownsPiece( - context.request().source().getDataFolder().toPath(), - context.request().structureKey(), - pieceKey, - objectKey)) { - throw new IOException("Piece add accepts only piece/object resources already owned by this project; " - + "use piece create for a new owned piece"); - } - JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.addPiece( - context.request().source().getDataFolder().toPath(), - context.request().structureKey(), - poolKey, - pieceKey, - weight); - if (!update.changed()) { - throw new IOException("The owned pool is missing or already references '" + pieceKey + "'"); - } - JigsawStudioLayout layout = loadPersistedLayout(context); - String activationWorkcellId = workcellIdForVariant(layout, pieceKey); - return new JigsawStudioService.CommandGraphMutationResult( - layout, - activationWorkcellId, - pieceKey, - "Added '" + pieceKey + "' to pool '" + poolKey + "' in one graph transaction."); - }); - } - - @Director(description = "Remove the selected piece from an owned pool transactionally", sync = true, - origin = DirectorOrigin.PLAYER) - public void remove( - @Param(description = "Owned pool resource key") String poolKey - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - JigsawStudioVariant selected = selectedVariant(context); - if (selected == null) { - sendError(this, "Select a workcell with an active variant before removing its pool entry."); - return; - } - String pieceKey = selected.pieceKey(); - runGraphMutation(player(), context, () -> { - JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.removePiece( - context.request().source().getDataFolder().toPath(), - context.request().structureKey(), - poolKey, - pieceKey); - if (!update.changed()) { - throw new IOException("The owned pool does not reference selected piece '" + pieceKey + "'"); - } - return mappedMutationResult( - context, - "", - "", - "Removed '" + pieceKey + "' from pool '" + poolKey + "' in one graph transaction."); - }); - } - - @Director(description = "Allow or forbid cardinal rotation of the selected piece", sync = true, - origin = DirectorOrigin.PLAYER) - public void rotatable( - @Param(description = "Whether the selected piece may rotate") boolean rotatable - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - JigsawStudioVariant selected = selectedVariant(context); - if (selected == null) { - sendError(this, "Select a workcell with an active variant before changing rotation."); - return; - } - if (!rotatable - && context.request().compatibilityTarget() - == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE) { - sendError(this, "Vanilla-portable pieces must remain rotatable."); - return; - } - String pieceKey = selected.pieceKey(); - runGraphMutation(player(), context, () -> { - JigsawStudioGraphEditor.updateRotatable( - context.request().source().getDataFolder().toPath(), - context.request().structureKey(), - pieceKey, - rotatable); - return mappedMutationResult( - context, - "", - "", - "Persisted piece '" + pieceKey + "' rotatable=" + rotatable + "."); - }); - } - - @Director(description = "Resize the selected piece object exactly to workcell capacity", sync = true, - origin = DirectorOrigin.PLAYER) - public void expand() { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - JigsawStudioBay workcell = selectedBay(context); - if (workcell == null) { - sendError(this, "Select a workcell before expanding its active variant."); - return; - } - JigsawStudioService.get().expandVariantToCell(player(), workcell.stableId()); - } - } - - @Director(name = "workcell", aliases = "cell", description = "Manage the selected Studio workcell", - origin = DirectorOrigin.PLAYER) - public static class CommandJigsawWorkcell implements DirectorExecutor { - @Director(description = "Set capacity without resizing any variant object", sync = true, - origin = DirectorOrigin.PLAYER) - public void capacity( - @Param(description = "Capacity width") int width, - @Param(description = "Capacity height") int height, - @Param(description = "Capacity depth") int depth - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - JigsawStudioBay selected = selectedBay(context); - if (selected == null) { - sendError(this, "Select a workcell before changing its capacity."); - return; - } - JigsawStudioCellDimensions dimensions; - try { - dimensions = new JigsawStudioCellDimensions(width, height, depth); - } catch (IllegalArgumentException exception) { - sendError(this, exception.getMessage()); - return; - } - JigsawStudioService.get().updateWorkcellDimensions( - player(), - selected.stableId(), - dimensions); - } - - @Director(description = "Set the selected workcell's author-facing label", sync = true, - origin = DirectorOrigin.PLAYER) - public void label( - @Param(description = "Display label; quote labels containing spaces") String displayName - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - JigsawStudioBay selected = selectedBay(context); - if (selected == null) { - sendError(this, "Select a workcell before renaming it."); - return; - } - JigsawStudioService.get().updateWorkcellDisplayName( - player(), - selected.stableId(), - displayName); - } - - @Director(name = "label-reset", aliases = "reset-label", - description = "Reset the selected workcell to its canonical solver label", sync = true, - origin = DirectorOrigin.PLAYER) - public void labelReset() { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - JigsawStudioBay selected = selectedBay(context); - if (selected == null) { - sendError(this, "Select a workcell before resetting its label."); - return; - } - JigsawStudioService.get().updateWorkcellDisplayName(player(), selected.stableId(), ""); - } - } - - @Director(name = "variant", description = "Manage selected jigsaw variants", origin = DirectorOrigin.PLAYER) - public static class CommandJigsawVariant implements DirectorExecutor { - @Director(description = "Set the selected piece weight in a pool", sync = true, - origin = DirectorOrigin.PLAYER) - public void weight( - @Param(description = "Pool resource key") String poolKey, - @Param(description = "Positive selection weight") int weight - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - JigsawStudioVariant selected = selectedVariant(context); - if (selected == null) { - sendError(this, "Select a workcell with an active variant before changing its weight."); - return; - } - String pieceKey = selected.pieceKey(); - runGraphMutation(player(), context, () -> { - JigsawStudioPoolEditor.WeightUpdate update = JigsawStudioPoolEditor.updateWeight( - context.request().source().getDataFolder().toPath(), - context.request().structureKey(), - poolKey, - pieceKey, - weight); - if (!update.changed()) { - throw new IOException("Pool '" + poolKey + "' does not reference selected piece '" - + pieceKey + "'"); - } - return mappedMutationResult( - context, - "", - "", - "Updated " + update.changedEntries() + " pool entry weight atomically in " - + update.poolPath() + "."); - }); - } - - @Director(description = "Resize only the active variant object within workcell capacity", sync = true, - origin = DirectorOrigin.PLAYER) - public void resize( - @Param(description = "Variant width") int width, - @Param(description = "Variant height") int height, - @Param(description = "Variant depth") int depth - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - JigsawStudioBay selectedWorkcell = selectedBay(context); - JigsawStudioVariant selectedVariant = selectedVariant(context); - if (selectedWorkcell == null || selectedVariant == null) { - sendError(this, "Select a workcell with an active variant before resizing it."); - return; - } - JigsawStudioCellDimensions dimensions; - try { - dimensions = new JigsawStudioCellDimensions(width, height, depth); - } catch (IllegalArgumentException exception) { - sendError(this, exception.getMessage()); - return; - } - JigsawStudioService.get().resizeVariant( - player(), - selectedWorkcell.stableId(), - selectedVariant.pieceKey(), - dimensions); - } - - @Director(description = "Set the active variant's author-facing label", sync = true, - origin = DirectorOrigin.PLAYER) - public void label( - @Param(description = "Display label; quote labels containing spaces") String displayName - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - JigsawStudioBay selectedWorkcell = selectedBay(context); - JigsawStudioVariant selectedVariant = selectedVariant(context); - if (selectedWorkcell == null || selectedVariant == null) { - sendError(this, "Select a workcell with an active variant before renaming it."); - return; - } - JigsawStudioService.get().updateVariantDisplayName( - player(), - selectedWorkcell.stableId(), - selectedVariant.pieceKey(), - displayName); - } - - @Director(name = "label-reset", aliases = "reset-label", - description = "Reset the active variant to its resource-key label", sync = true, - origin = DirectorOrigin.PLAYER) - public void labelReset() { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - JigsawStudioBay selectedWorkcell = selectedBay(context); - JigsawStudioVariant selectedVariant = selectedVariant(context); - if (selectedWorkcell == null || selectedVariant == null) { - sendError(this, "Select a workcell with an active variant before resetting its label."); - return; - } - JigsawStudioService.get().updateVariantDisplayName( - player(), - selectedWorkcell.stableId(), - selectedVariant.pieceKey(), - ""); - } - - @Director(description = "Duplicate the active variant in only this workcell", sync = true, - origin = DirectorOrigin.PLAYER) - public void duplicate() { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - JigsawStudioBay selected = selectedBay(context); - if (selected == null || selectedVariant(context) == null) { - sendError(this, "Select a workcell with an active variant before duplicating it."); - return; - } - JigsawStudioService.get().createVariant(player(), selected.stableId(), true); - } - - @Director(name = "duplicate-family", aliases = "family", - description = "Duplicate every enabled workcell's active variant as one coherent family", - sync = true, origin = DirectorOrigin.PLAYER) - public void duplicateFamily( - @Param(description = "Family key or next", defaultValue = "next") String themeKey - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - String selectedTheme = themeKey; - if ("next".equalsIgnoreCase(themeKey)) { - try { - selectedTheme = nextThemeKey(context); - } catch (IOException | ArithmeticException exception) { - sendError(this, exception.getMessage()); - return; - } - } - JigsawStudioService.get().duplicateActiveFamily(player(), selectedTheme); - } - } - - @Director(name = "rules", aliases = "rule", description = "Manage structure-wide jigsaw rules", - origin = DirectorOrigin.PLAYER) - public static class CommandJigsawRules implements DirectorExecutor { - @Director(description = "Set maximum expansion depth and horizontal radius", sync = true, - origin = DirectorOrigin.PLAYER) - public void limits( - @Param(description = "Maximum expansion depth") int maxDepth, - @Param(description = "Maximum radius in chunks") int maxSizeChunks - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - if (context.request().compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE - && (maxDepth > 20 || maxSizeChunks > 8)) { - sendError(this, "Vanilla-portable rules require maxDepth <= 20 and maxSizeChunks <= 8."); - return; - } - runGraphMutation(player(), context, () -> { - JigsawStudioStructureEditor.updateLimits( - context.request().source().getDataFolder().toPath(), - context.request().structureKey(), - maxDepth, - maxSizeChunks); - return mappedMutationResult( - context, - "", - "", - "Persisted jigsaw limits: maxDepth=" + maxDepth - + ", maxSizeChunks=" + maxSizeChunks + "."); - }); - } - - @Director(description = "Set or clear one owned pool's direct fallback", sync = true, - origin = DirectorOrigin.PLAYER) - public void fallback( - @Param(description = "Owned pool resource key") String poolKey, - @Param(description = "Owned fallback pool key, or none") String fallbackPoolKey - ) { - ActiveContext context = active(player()); - if (context == null) { - sendError(this, "Open a Jigsaw Studio project first."); - return; - } - String fallback = "none".equalsIgnoreCase(fallbackPoolKey) ? "" : fallbackPoolKey; - runGraphMutation(player(), context, () -> { - JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.updateFallback( - context.request().source().getDataFolder().toPath(), - context.request().structureKey(), - poolKey, - fallback); - if (!update.changed()) { - return mappedMutationResult(context, "", "", "Pool fallback is unchanged."); - } - return mappedMutationResult( - context, - "", - "", - "Persisted pool '" + poolKey + "' fallback as '" - + (fallback.isBlank() ? "none" : fallback) + "'."); - }); - } - } - - @Director(name = "preview", description = "Assemble a read-only jigsaw preview", - origin = DirectorOrigin.PLAYER) - public static class CommandJigsawPreview implements DirectorExecutor { - @Director(name = "goto", aliases = "teleport", description = "Teleport to the live seed-1337 preview", - sync = true, origin = DirectorOrigin.PLAYER) - public void gotoPreview() { - JigsawStudioService.get().goToPreview(player()); - } - - @Director(description = "Assemble a deterministic preview at your location", sync = true, - origin = DirectorOrigin.PLAYER) - public void assemble( - @Param(description = "Assembly seed", defaultValue = "1337") long seed - ) { - JigsawStudioService.get().previewStudio(player(), seed); - } - - } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawAdopt.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawAdopt.java new file mode 100644 index 000000000..61babbe2e --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawAdopt.java @@ -0,0 +1,424 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionDiagnostic; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionInputKind; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionPlan; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionRequest; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionResult; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionService; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionStrategy; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.director.DirectorOrigin; +import art.arcane.volmlib.util.director.DirectorParameterHandler; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; +import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; +import org.bukkit.entity.Player; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Director(name = "adopt", description = "Inspect and apply safe ownership adoption plans", + origin = DirectorOrigin.PLAYER) +public class CommandJigsawAdopt implements DirectorExecutor { + private static final Map ADOPTION_PLANS = new ConcurrentHashMap<>(); + private static final Map ADOPTION_SERVICES = new ConcurrentHashMap<>(); + + @Director(description = "Inspect an Iris graph for safe Studio ownership adoption", sync = true, + origin = DirectorOrigin.PLAYER) + public void inspect( + @Param(description = "Pack dimension") IrisDimension dimension, + @Param(description = "Existing Iris structure key") String source, + @Param(description = "New owned Iris key, or auto", defaultValue = "auto") String target, + @Param(description = "auto, in-place, or clone", defaultValue = "auto", + customHandler = JigsawAdoptionStrategyHandler.class) String strategy + ) { + VolmitSender commandSender = sender(); + IrisData data = CommandJigsaw.requireData(dimension, commandSender); + if (data == null) { + return; + } + String sourceKey; + Optional targetKey; + IrisStructureAdoptionStrategy adoptionStrategy; + Path root; + try { + sourceKey = parseAdoptionSource(source); + targetKey = parseAdoptionTarget(target); + adoptionStrategy = parseAdoptionStrategy(strategy); + root = canonicalAdoptionRoot(data.getDataFolder().toPath()); + } catch (IOException | IllegalArgumentException exception) { + CommandJigsaw.sendError(commandSender, "Could not inspect adoption source: " + exception.getMessage()); + return; + } + Player targetPlayer = player(); + UUID ownerId = targetPlayer.getUniqueId(); + commandSender.sendMessage(C.GRAY + "Inspecting Jigsaw adoption source '" + sourceKey + + "' asynchronously..."); + try { + J.a(() -> runAdoptionInspect( + targetPlayer, + commandSender, + ownerId, + dimension, + data, + root, + sourceKey, + targetKey, + adoptionStrategy)); + } catch (RuntimeException exception) { + Iris.reportError("Failed to schedule Jigsaw adoption inspection for '" + + sourceKey + "'.", exception); + CommandJigsaw.sendError(commandSender, "Jigsaw adoption inspection could not be scheduled: " + + exception.getMessage()); + } + } + + @Director(description = "Apply one inspected adoption plan without overwriting", sync = true, + origin = DirectorOrigin.PLAYER) + public void apply( + @Param(description = "Plan UUID returned by adopt inspect", + customHandler = JigsawAdoptionPlanHandler.class) String planId + ) { + VolmitSender commandSender = sender(); + UUID parsedPlanId; + try { + parsedPlanId = UUID.fromString(planId); + } catch (IllegalArgumentException exception) { + CommandJigsaw.sendError(commandSender, "Adoption plan ID must be a UUID."); + return; + } + if (CommandJigsaw.studioIsActiveOrOpening()) { + CommandJigsaw.sendError(commandSender, + "Close the active or opening Jigsaw Studio before applying an adoption plan."); + return; + } + pruneAdoptionPlanBindings(); + AdoptionPlanBinding binding = ADOPTION_PLANS.get(parsedPlanId); + UUID playerId = player().getUniqueId(); + if (binding == null || !binding.ownerId().equals(playerId)) { + CommandJigsaw.sendError(commandSender, "No active adoption plan '" + parsedPlanId + + "' belongs to your player session. Run adopt inspect again."); + return; + } + if (binding.service().plan(parsedPlanId).isEmpty()) { + ADOPTION_PLANS.remove(parsedPlanId, binding); + CommandJigsaw.sendError(commandSender, "Adoption plan '" + parsedPlanId + + "' expired or was already consumed. Run adopt inspect again."); + return; + } + if (!ADOPTION_PLANS.remove(parsedPlanId, binding)) { + CommandJigsaw.sendError(commandSender, "Adoption plan '" + parsedPlanId + "' is already being applied."); + return; + } + Player targetPlayer = player(); + commandSender.sendMessage(C.GRAY + "Applying adoption plan '" + parsedPlanId + + "' asynchronously without overwrite..."); + try { + J.a(() -> runAdoptionApply(targetPlayer, commandSender, parsedPlanId, binding)); + } catch (RuntimeException exception) { + ADOPTION_PLANS.putIfAbsent(parsedPlanId, binding); + Iris.reportError("Failed to schedule Jigsaw adoption plan '" + parsedPlanId + "'.", exception); + CommandJigsaw.sendError(commandSender, "Jigsaw adoption could not be scheduled: " + exception.getMessage()); + } + } + + private static void pruneAdoptionPlanBindings() { + for (Map.Entry entry : ADOPTION_PLANS.entrySet()) { + AdoptionPlanBinding binding = entry.getValue(); + if (binding.service().plan(entry.getKey()).isEmpty()) { + ADOPTION_PLANS.remove(entry.getKey(), binding); + } + } + } + + private static void runAdoptionApply( + Player targetPlayer, + VolmitSender commandSender, + UUID planId, + AdoptionPlanBinding binding + ) { + if (CommandJigsaw.studioIsActiveOrOpening()) { + ADOPTION_PLANS.putIfAbsent(planId, binding); + J.runEntity(targetPlayer, () -> CommandJigsaw.sendError(commandSender, + "Close the active or opening Jigsaw Studio before applying an adoption plan.")); + return; + } + IrisStructureAdoptionResult result; + Throwable failure = null; + try { + result = binding.service().apply(binding.plan()); + } catch (Throwable throwable) { + result = null; + failure = throwable; + Iris.reportError("Failed to apply Jigsaw adoption plan '" + planId + "'.", throwable); + } + IrisStructureAdoptionResult completedResult = result; + Throwable completedFailure = failure; + J.runEntity(targetPlayer, () -> finishAdoptionApply( + targetPlayer, + commandSender, + binding, + completedResult, + completedFailure)); + } + + private static void runAdoptionInspect( + Player targetPlayer, + VolmitSender commandSender, + UUID ownerId, + IrisDimension dimension, + IrisData data, + Path root, + String sourceKey, + Optional targetKey, + IrisStructureAdoptionStrategy strategy + ) { + IrisStructureAdoptionService service = null; + IrisStructureAdoptionPlan plan = null; + Throwable failure = null; + try { + IrisStructureAdoptionInputKind inputKind = adoptionInputKind(root, sourceKey); + service = ADOPTION_SERVICES.computeIfAbsent(root, IrisStructureAdoptionService::new); + plan = service.inspect(new IrisStructureAdoptionRequest( + sourceKey, + targetKey, + strategy, + inputKind)); + } catch (Throwable throwable) { + failure = throwable; + Iris.reportError("Failed to inspect Jigsaw adoption source '" + sourceKey + "'.", throwable); + } + IrisStructureAdoptionService completedService = service; + IrisStructureAdoptionPlan completedPlan = plan; + Throwable completedFailure = failure; + J.runEntity(targetPlayer, () -> finishAdoptionInspect( + commandSender, + ownerId, + dimension, + data, + completedService, + completedPlan, + completedFailure)); + } + + private static void finishAdoptionInspect( + VolmitSender commandSender, + UUID ownerId, + IrisDimension dimension, + IrisData data, + IrisStructureAdoptionService service, + IrisStructureAdoptionPlan plan, + Throwable failure + ) { + if (failure != null || service == null || plan == null) { + CommandJigsaw.sendError(commandSender, "Jigsaw adoption inspection failed: " + + (failure == null ? "no result" : failure.getMessage())); + return; + } + pruneAdoptionPlanBindings(); + if (service.plan(plan.planId()).isPresent()) { + ADOPTION_PLANS.put(plan.planId(), new AdoptionPlanBinding( + ownerId, dimension, data, service, plan)); + } + reportAdoptionPlan(commandSender, plan); + } + + private static void finishAdoptionApply( + Player targetPlayer, + VolmitSender commandSender, + AdoptionPlanBinding binding, + IrisStructureAdoptionResult result, + Throwable failure + ) { + if (failure != null || result == null) { + CommandJigsaw.sendError(commandSender, "Jigsaw adoption failed: " + + (failure == null ? "no result" : failure.getMessage())); + return; + } + reportAdoptionResult(commandSender, result); + if (!result.successful()) { + return; + } + StructureKey target = result.receipt().orElseThrow().targetStructure(); + binding.data().invalidateStructureResources(); + CommandJigsawOpen.openProject(targetPlayer, commandSender, binding.dimension(), target.path(), CommandJigsaw.DEFAULT_STUDIO_SEED); + } + + private static void reportAdoptionPlan(VolmitSender commandSender, IrisStructureAdoptionPlan plan) { + C statusColor = plan.canApply() ? C.GREEN : C.RED; + commandSender.sendMessage(statusColor + "Adoption plan " + plan.planId() + " -> " + + plan.disposition() + " for " + plan.targetStructure().value() + "."); + commandSender.sendMessage(C.GRAY + "Resources=" + plan.resourceCount() + + ", bytes=" + plan.totalSourceBytes() + + ", errors=" + plan.errorCount() + + ", warnings=" + plan.warningCount() + "."); + for (IrisStructureAdoptionDiagnostic diagnostic : plan.diagnostics()) { + commandSender.sendMessage(adoptionColor(diagnostic).toString() + diagnostic.summary()); + } + if (plan.canApply()) { + commandSender.sendMessage(C.AQUA + "Apply with /iris jigsaw adopt apply " + plan.planId()); + } + } + + private static void reportAdoptionResult(VolmitSender commandSender, IrisStructureAdoptionResult result) { + commandSender.sendMessage((result.successful() ? C.GREEN : C.RED) + + "Adoption plan " + result.planId() + ": " + result.status() + "."); + for (IrisStructureAdoptionDiagnostic diagnostic : result.diagnostics()) { + commandSender.sendMessage(adoptionColor(diagnostic).toString() + diagnostic.summary()); + } + } + + private static C adoptionColor(IrisStructureAdoptionDiagnostic diagnostic) { + return switch (diagnostic.severity()) { + case ERROR -> C.RED; + case WARNING -> C.YELLOW; + case INFO -> C.GRAY; + }; + } + + static String parseAdoptionSource(String value) { + String normalized = Objects.requireNonNull(value, "Adoption source").trim() + .toLowerCase(Locale.ROOT); + StructureKey source = StructureKey.parse(normalized, "iris"); + if (!"iris".equals(source.namespace())) { + throw new IllegalArgumentException("Adoption sources must use the iris namespace."); + } + return source.path(); + } + + static Optional parseAdoptionTarget(String value) { + String normalized = Objects.requireNonNull(value, "Adoption target").trim() + .toLowerCase(Locale.ROOT); + if (normalized.isEmpty() || "auto".equals(normalized)) { + return Optional.empty(); + } + StructureKey target = StructureKey.parse(normalized, "iris"); + if (!"iris".equals(target.namespace())) { + throw new IllegalArgumentException("Adoption targets must use the iris namespace."); + } + return Optional.of(target); + } + + private static IrisStructureAdoptionStrategy parseAdoptionStrategy(String value) { + return switch (Objects.requireNonNull(value, "Adoption strategy").trim().toLowerCase(Locale.ROOT)) { + case "auto" -> IrisStructureAdoptionStrategy.AUTO; + case "in-place", "in_place", "inplace", "claim" -> IrisStructureAdoptionStrategy.IN_PLACE; + case "clone", "copy" -> IrisStructureAdoptionStrategy.CLONE; + default -> throw new IllegalArgumentException("Adoption strategy must be auto, in-place, or clone."); + }; + } + + static IrisStructureAdoptionInputKind adoptionInputKind(Path packRoot, String source) throws IOException { + Path root = canonicalAdoptionRoot(packRoot); + StructureKey sourceKey = new StructureKey("iris", parseAdoptionSource(source)); + Path manifestPath = new StructureTransactionWriter(root).ownershipManifestPath(sourceKey); + if (!Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + return IrisStructureAdoptionInputKind.UNOWNED_IRIS; + } + if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Ownership manifest is not a regular file: " + manifestPath); + } + StructureOwnershipManifest manifest; + try { + manifest = StructureOwnershipManifest.fromJson(Files.readAllBytes(manifestPath)); + } catch (RuntimeException exception) { + throw new IOException("Invalid ownership manifest at " + manifestPath, exception); + } + if (!manifest.structure().equals(sourceKey)) { + throw new IOException("Ownership manifest at " + manifestPath + + " belongs to " + manifest.structure().value()); + } + return manifest.provenance().origin() == StructureOwnershipManifest.Origin.MANAGED_DATAPACK + ? IrisStructureAdoptionInputKind.MANAGED_DATAPACK + : IrisStructureAdoptionInputKind.UNOWNED_IRIS; + } + + private static Path canonicalAdoptionRoot(Path packRoot) throws IOException { + Path normalized = Objects.requireNonNull(packRoot, "Adoption pack root").toAbsolutePath().normalize(); + return Files.exists(normalized, LinkOption.NOFOLLOW_LINKS) ? normalized.toRealPath() : normalized; + } + + private record AdoptionPlanBinding( + UUID ownerId, + IrisDimension dimension, + IrisData data, + IrisStructureAdoptionService service, + IrisStructureAdoptionPlan plan + ) { + } + + public static final class JigsawAdoptionStrategyHandler implements DirectorParameterHandler { + @Override + public KList getPossibilities() { + return new KList<>("auto", "in-place", "clone"); + } + + @Override + public String toString(String value) { + return value == null ? "" : value; + } + + @Override + public String parse(String input, boolean force) throws DirectorParsingException { + try { + return switch (parseAdoptionStrategy(input)) { + case AUTO -> "auto"; + case IN_PLACE -> "in-place"; + case CLONE -> "clone"; + }; + } catch (IllegalArgumentException exception) { + throw new DirectorParsingException(exception.getMessage()); + } + } + + @Override + public boolean supports(Class type) { + return type == String.class; + } + } + + public static final class JigsawAdoptionPlanHandler implements DirectorParameterHandler { + @Override + public KList getPossibilities() { + return new KList<>(); + } + + @Override + public String toString(String value) { + return value == null ? "" : value; + } + + @Override + public String parse(String input, boolean force) throws DirectorParsingException { + try { + return UUID.fromString(input).toString(); + } catch (IllegalArgumentException exception) { + throw new DirectorParsingException("Adoption plan ID must be a UUID."); + } + } + + @Override + public boolean supports(Class type) { + return type == String.class; + } + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawConnector.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawConnector.java new file mode 100644 index 000000000..d466fdd0a --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawConnector.java @@ -0,0 +1,84 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphEditor; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.volmlib.util.director.DirectorOrigin; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; +import org.bukkit.block.Block; +import org.bukkit.block.data.type.Jigsaw; + +@Director(name = "connector", description = "Inspect and configure targeted jigsaw connectors", + origin = DirectorOrigin.PLAYER) +public class CommandJigsawConnector implements DirectorExecutor { + @Director(description = "Set the Iris-only channel on the targeted jigsaw marker", sync = true, + origin = DirectorOrigin.PLAYER) + public void channel( + @Param(description = "Exact channel, or none to clear") String channel + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + Block target = player().getTargetBlockExact(8); + if (target == null || !(target.getBlockData() instanceof Jigsaw)) { + CommandJigsaw.sendError(this, "Look directly at a jigsaw marker within eight blocks."); + return; + } + JigsawStudioBay bay = context.session().layout().findAt( + target.getX(), target.getY(), target.getZ()); + JigsawStudioVariant activeVariant = bay == null + ? null + : context.session().activeVariant(bay.stableId()).orElse(null); + if (activeVariant == null) { + CommandJigsaw.sendError(this, "The targeted marker must be inside a workcell with an active variant."); + return; + } + if (!activeVariant.owned()) { + CommandJigsaw.sendError(this, "The active variant is read-only. Adopt it into this project before editing it."); + return; + } + IrisObject object = context.request().source().getObjectLoader().load(activeVariant.objectKey()); + if (object == null) { + CommandJigsaw.sendError(this, "The active variant object '" + activeVariant.objectKey() + "' is missing."); + return; + } + IrisPosition canonicalPosition = new IrisPosition( + target.getX() - bay.bounds().originX(), + target.getY() - bay.bounds().originY(), + target.getZ() - bay.bounds().originZ()); + IrisPosition position; + try { + position = activeVariant.canonicalToSourcePosition( + canonicalPosition, + new JigsawStudioCellDimensions(object.getW(), object.getH(), object.getD())); + } catch (IllegalArgumentException exception) { + CommandJigsaw.sendError(this, "Connector channel update failed: " + exception.getMessage()); + return; + } + String normalized = channel == null || channel.isBlank() || "none".equalsIgnoreCase(channel) + ? "none" + : channel.trim(); + CommandJigsaw.runGraphMutation(player(), context, () -> { + JigsawStudioGraphEditor.updateConnectorChannel( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + activeVariant.pieceKey(), + position, + channel); + return CommandJigsaw.mappedMutationResult( + context, + "", + "", + "Persisted connector channel '" + normalized + "' on piece '" + + activeVariant.pieceKey() + "' at " + position.getX() + "," + position.getY() + + "," + position.getZ() + "."); + }); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawExports.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawExports.java new file mode 100644 index 000000000..cd5239509 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawExports.java @@ -0,0 +1,159 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.service.JigsawStudioService; +import art.arcane.iris.core.structure.export.VanillaJigsawDatapackExporter; +import art.arcane.iris.core.structure.export.VanillaJigsawExportDiagnostic; +import art.arcane.iris.core.structure.export.VanillaJigsawExportFormat; +import art.arcane.iris.core.structure.export.VanillaJigsawExportRequest; +import art.arcane.iris.core.structure.export.VanillaJigsawExportResult; +import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.entity.Player; + +import java.nio.file.Path; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; + +final class CommandJigsawExports { + private static final Pattern EXPORT_OUTPUT_NAME = Pattern.compile("[a-zA-Z0-9_-][a-zA-Z0-9._-]{0,127}"); + private static final Set EXPORTING_PLAYERS = ConcurrentHashMap.newKeySet(); + private static final Set EXPORT_DESTINATIONS = ConcurrentHashMap.newKeySet(); + + private CommandJigsawExports() { + } + + static String exportStartError(JigsawStudioService.ExportStart exportStart) { + Objects.requireNonNull(exportStart, "Jigsaw Studio export start status"); + return switch (exportStart) { + case NOT_ACTIVE -> "The active Jigsaw Studio is no longer available."; + case NOT_OWNER -> "Only the Jigsaw Studio owner can export this project."; + case DIRTY -> "Wait for the pending autosave or discard the edits before exporting the on-disk graph."; + case CLOSING -> "The active Jigsaw Studio is closing and cannot be exported."; + case SAVE_IN_PROGRESS -> "Wait for the current Jigsaw Studio save to finish before exporting."; + case OPERATION_IN_PROGRESS -> "Wait for the current Jigsaw Studio operation to finish before exporting."; + case IN_PROGRESS -> "A Jigsaw Studio export is already in progress."; + case STARTED -> throw new IllegalArgumentException("STARTED is not an export failure"); + }; + } + + static boolean beginExport(UUID playerId, Path destination) { + Objects.requireNonNull(playerId); + Path normalizedDestination = Objects.requireNonNull(destination).toAbsolutePath().normalize(); + if (!EXPORTING_PLAYERS.add(playerId)) { + return false; + } + if (EXPORT_DESTINATIONS.add(normalizedDestination)) { + return true; + } + EXPORTING_PLAYERS.remove(playerId); + return false; + } + + static void finishExport(UUID playerId, Path destination) { + EXPORTING_PLAYERS.remove(Objects.requireNonNull(playerId)); + EXPORT_DESTINATIONS.remove(Objects.requireNonNull(destination).toAbsolutePath().normalize()); + } + + static void runExport(ExportOperation operation) { + VanillaJigsawExportResult result = null; + Throwable failure = null; + try { + result = new VanillaJigsawDatapackExporter().export(operation.request()); + } catch (Throwable throwable) { + failure = throwable; + Iris.reportError("Vanilla Jigsaw export failed for '" + operation.structureKey() + "'.", throwable); + } finally { + operation.lease().release(); + } + VanillaJigsawExportResult completedResult = result; + Throwable completedFailure = failure; + Runnable response = () -> reportExport(operation, completedResult, completedFailure); + J.runEntity(operation.player(), response); + } + + private static void reportExport( + ExportOperation operation, + VanillaJigsawExportResult result, + Throwable failure + ) { + if (failure != null || result == null) { + operation.sender().sendMessage(C.RED + "Background vanilla export for '" + + operation.structureKey() + "' failed: " + + (failure == null ? "no result" : failure.getMessage())); + return; + } + for (VanillaJigsawExportDiagnostic diagnostic : result.diagnostics()) { + C color = diagnostic.isBlocking() ? C.RED : C.YELLOW; + operation.sender().sendMessage(color.toString() + diagnostic.code() + ": " + diagnostic.message()); + } + CommandJigsaw.ActiveContext current = CommandJigsaw.active(operation.player()); + boolean sameSession = current != null + && current.request().requestId().equals(operation.requestId()); + String contextLabel = sameSession ? "Vanilla export" : "Background vanilla export for '" + + operation.structureKey() + "'"; + operation.sender().sendMessage((result.isSuccess() ? C.GREEN : C.RED) + contextLabel + " " + + result.status() + ": " + result.output()); + } + + static VanillaJigsawExportFormat parseFormat(String value) { + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "zip" -> VanillaJigsawExportFormat.ZIP; + case "directory", "dir", "folder" -> VanillaJigsawExportFormat.DIRECTORY; + default -> throw new IllegalArgumentException("Export format must be directory or zip."); + }; + } + + static Path resolveExportDestination( + Path exportRoot, + String output, + VanillaJigsawExportFormat format + ) { + Path root = Objects.requireNonNull(exportRoot, "Jigsaw export root").toAbsolutePath().normalize(); + String requested = Objects.requireNonNull(output, "Jigsaw export output name"); + String name = requested.trim(); + if (!name.equals(requested) || !EXPORT_OUTPUT_NAME.matcher(name).matches()) { + throw new IllegalArgumentException( + "Export output must be one file name using letters, numbers, '.', '_', or '-'."); + } + String fileName = format == VanillaJigsawExportFormat.ZIP && !name.endsWith(".zip") + ? name + ".zip" : name; + Path destination = root.resolve(fileName).normalize(); + if (destination.equals(root) || !root.equals(destination.getParent())) { + throw new IllegalArgumentException("Export output must be one artifact inside the Studio exports folder."); + } + return destination; + } + + record ExportOperation( + Player player, + VolmitSender sender, + UUID requestId, + String structureKey, + VanillaJigsawExportRequest request, + ExportLease lease + ) { + } + + static final class ExportLease { + private final Runnable release; + private final AtomicBoolean released; + + ExportLease(Runnable release) { + this.release = Objects.requireNonNull(release, "Jigsaw export lease release action"); + this.released = new AtomicBoolean(); + } + + void release() { + if (released.compareAndSet(false, true)) { + release.run(); + } + } + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawOpen.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawOpen.java new file mode 100644 index 000000000..da1575ba2 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawOpen.java @@ -0,0 +1,251 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.BrokenPackException; +import art.arcane.iris.core.runtime.WorldRuntimeControlService; +import art.arcane.iris.core.runtime.StudioOpenCoordinator; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphMapper; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession; +import art.arcane.iris.core.service.JigsawStudioService; +import art.arcane.iris.core.service.StudioSVC; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; + +import java.util.UUID; +import java.util.concurrent.CompletionException; + +final class CommandJigsawOpen { + private CommandJigsawOpen() { + } + + static void openProject( + Player targetPlayer, + VolmitSender commandSender, + IrisDimension dimension, + String structure, + long seed + ) { + UUID playerId = targetPlayer.getUniqueId(); + UUID ownerId = JigsawStudioActivation.activeOwnerId(); + if (ownerId != null && !ownerId.equals(playerId)) { + CommandJigsaw.sendError(commandSender, "Jigsaw Studio is already owned by another player session."); + return; + } + UUID openingOwner = JigsawStudioActivation.openingOwnerId(); + if (openingOwner != null) { + CommandJigsaw.sendError(commandSender, openingOwner.equals(playerId) + ? "Your Jigsaw Studio is already opening." + : "Jigsaw Studio is already opening for another player."); + return; + } + CommandJigsaw.ActiveContext previous = CommandJigsaw.active(targetPlayer); + if (ownerId != null && previous == null) { + CommandJigsaw.sendError(commandSender, "Your active Jigsaw Studio binding is unavailable. Close it before opening another project."); + return; + } + CommandJigsaw.ActiveContext dirty = CommandJigsaw.findDirtyContext(); + if (dirty != null) { + CommandJigsaw.sendError(commandSender, + "The current Jigsaw Studio is waiting for autosave. Wait for it to finish or close with discard=true."); + return; + } + IrisData data = CommandJigsaw.requireData(dimension, commandSender); + if (data == null) { + return; + } + IrisStructure irisStructure = data.load(IrisStructure.class, structure, false); + if (irisStructure == null) { + CommandJigsaw.sendError(commandSender, "No Iris jigsaw structure '" + structure + "' exists in this pack."); + return; + } + + JigsawStudioLayout layout; + try { + layout = JigsawStudioGraphMapper.map(data, irisStructure); + } catch (RuntimeException exception) { + Iris.reportError("Failed to map Jigsaw Studio graph '" + structure + "'.", exception); + CommandJigsaw.sendError(commandSender, "Could not map this jigsaw graph: " + exception.getMessage()); + return; + } + String packKey = dimension.getLoadKey(); + if (!JigsawStudioActivation.tryBeginOpen(playerId)) { + CommandJigsaw.sendError(commandSender, + "Jigsaw Studio began opening for another player. Try again after it finishes."); + return; + } + JigsawStudioService studioService = JigsawStudioService.get(); + UUID closingRequestId = previous == null ? null : previous.request().requestId(); + if (closingRequestId != null) { + JigsawStudioService.CloseStart closeStart = studioService.tryBeginClose( + closingRequestId, + playerId, + false); + if (closeStart != JigsawStudioService.CloseStart.STARTED) { + JigsawStudioActivation.finishOpen(playerId); + CommandJigsaw.sendError(commandSender, closeStart == JigsawStudioService.CloseStart.SAVE_IN_PROGRESS + ? "The current Jigsaw Studio is saving. Wait for it to finish before opening another project." + : closeStart == JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS + ? "The current Jigsaw Studio is loading a variant. Wait for it to finish before opening another project." + : closeStart == JigsawStudioService.CloseStart.DIRTY + ? "The current Jigsaw Studio is waiting for autosave. Wait for it to finish before opening another project." + : "The current Jigsaw Studio could not be reserved for replacement."); + return; + } + } + JigsawStudioActivation.StagedActivation staged; + try { + staged = JigsawStudioActivation.stage( + packKey, + structure, + layout.mode(), + CommandJigsaw.compatibilityOf(irisStructure), + layout.cellDimensions(), + data, + layout, + playerId, + closingRequestId); + } catch (RuntimeException exception) { + JigsawStudioActivation.finishOpen(playerId); + studioService.cancelClose(closingRequestId); + Iris.reportError("Failed to stage Jigsaw Studio for '" + structure + "'.", exception); + CommandJigsaw.sendError(commandSender, "Jigsaw Studio staging failed: " + exception.getMessage()); + return; + } + commandSender.sendMessage(C.GREEN + "Opening transient Jigsaw Studio for '" + structure + "' with " + + layout.bays().size() + " workcells."); + + try { + Iris.service(StudioSVC.class).openTracked( + commandSender, + seed, + packKey, + CommandJigsaw.STUDIO_OPEN_KIND, + () -> beginStagedOpen(staged), + world -> finishOpen(targetPlayer, world, staged, studioService)) + .whenComplete((result, throwable) -> J.s(() -> finishOpenAttempt( + commandSender, + playerId, + structure, + closingRequestId, + studioService, + staged, + result, + throwable))); + } catch (Throwable exception) { + JigsawStudioActivation.finishOpen(playerId); + studioService.cancelClose(closingRequestId); + JigsawStudioActivation.rollback(staged); + Iris.reportError("Failed to open Jigsaw Studio for '" + structure + "'.", exception); + CommandJigsaw.sendError(commandSender, "Jigsaw Studio open failed: " + exception.getMessage()); + } + } + + private static void beginStagedOpen(JigsawStudioActivation.StagedActivation staged) { + if (!JigsawStudioActivation.beginStagedGeneration(staged)) { + throw new IllegalStateException("The staged Jigsaw Studio activation is no longer current"); + } + } + + private static void finishOpenAttempt( + VolmitSender commandSender, + UUID playerId, + String structureKey, + UUID closingRequestId, + JigsawStudioService studioService, + JigsawStudioActivation.StagedActivation staged, + StudioOpenCoordinator.StudioOpenResult result, + Throwable throwable + ) { + JigsawStudioActivation.finishOpen(playerId); + if (throwable == null && result != null) { + studioService.cancelClose(closingRequestId); + return; + } + studioService.cancelClose(closingRequestId); + JigsawStudioActivation.rollback(staged); + Throwable failure = unwrapCompletionFailure(throwable == null + ? new IllegalStateException("Studio open completed without a result") + : throwable); + if (isExpectedOpenDenial(failure)) { + Iris.warn("Jigsaw Studio open for '%s' was denied: %s", + structureKey, failure.getMessage()); + return; + } + Iris.reportError("Failed to open Jigsaw Studio for '" + structureKey + "'.", failure); + CommandJigsaw.sendError(commandSender, "Jigsaw Studio open failed: " + failure.getMessage()); + } + + private static void finishOpen( + Player targetPlayer, + World world, + JigsawStudioActivation.StagedActivation staged, + JigsawStudioService studioService + ) { + if (world == null) { + throw new IllegalStateException("Jigsaw Studio opened without a world"); + } + JigsawStudioActivation.Request request = staged.request(); + JigsawStudioSession session = staged.session(); + try { + WorldRuntimeControlService.get().applyObjectStudioWorldRules(world); + } catch (Throwable exception) { + Iris.reportError("Failed to apply Jigsaw Studio world rules for '" + world.getName() + "'.", exception); + } + JigsawStudioBay destination = initialBay(session.layout()); + Location target = destination == null + ? new Location(world, 0.5D, 66D, 0.5D) + : new Location( + world, + destination.bounds().originX() + destination.bounds().dimensions().width() / 2.0D, + destination.bounds().maxY() + 2.0D, + destination.bounds().originZ() + destination.bounds().dimensions().depth() / 2.0D); + if (destination != null) { + session.selectBay(destination.stableId()); + } + if (!JigsawStudioActivation.commit(staged)) { + throw new IllegalStateException("The staged Jigsaw Studio activation could not be committed"); + } + studioService.activationCommitted(world, request.requestId()); + CommandJigsaw.PLAYER_PACKS.put(targetPlayer.getUniqueId(), new CommandJigsaw.SessionBinding( + request.packKey(), request.requestId())); + try { + J.runEntity(targetPlayer, () -> BukkitPlatform.teleportAsync(targetPlayer, target) + .thenRun(() -> J.runEntity(targetPlayer, () -> targetPlayer.setGameMode(GameMode.CREATIVE)))); + } catch (RuntimeException exception) { + Iris.reportError("Failed to move the player to the committed Jigsaw Studio workcell.", exception); + } + } + + private static JigsawStudioBay initialBay(JigsawStudioLayout layout) { + for (JigsawStudioBay bay : layout.bays()) { + if (layout.defaultVariant(bay).isPresent()) { + return bay; + } + } + return layout.bays().isEmpty() ? null : layout.bays().getFirst(); + } + + private static Throwable unwrapCompletionFailure(Throwable failure) { + Throwable current = failure; + while (current instanceof CompletionException && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + static boolean isExpectedOpenDenial(Throwable failure) { + return unwrapCompletionFailure(failure) instanceof BrokenPackException; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPiece.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPiece.java new file mode 100644 index 000000000..374c24ea6 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPiece.java @@ -0,0 +1,238 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarTopology; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphEditor; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant; +import art.arcane.iris.core.service.JigsawStudioService; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.volmlib.util.director.DirectorOrigin; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; +import org.bukkit.Location; + +import java.io.IOException; + +@Director(name = "piece", description = "Manage contextual Studio pieces", origin = DirectorOrigin.PLAYER) +public class CommandJigsawPiece implements DirectorExecutor { + @Director(description = "Create an owned piece from the selected Studio topology", sync = true, + origin = DirectorOrigin.PLAYER) + public void create( + @Param(description = "Owned pool resource key") String poolKey, + @Param(description = "New owned piece and object key") String pieceKey, + @Param(description = "Positive selection weight", defaultValue = "1") int weight + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawPlanarTopology topology = null; + JigsawStudioBay targetWorkcell = context.session().layout().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID); + if (context.session().layout().mode() == JigsawStudioMode.PLANAR_JIGSAW) { + Location location = player().getLocation(); + JigsawStudioBay contextual = context.session().layout().findAt( + location.getBlockX(), location.getBlockY(), location.getBlockZ()); + if (contextual == null) { + contextual = CommandJigsaw.selectedBay(context); + } else { + context.session().selectBay(contextual.stableId()); + } + if (contextual == null || contextual.topology().isEmpty()) { + CommandJigsaw.sendError(this, "Stand in or select a planar topology workcell before creating a variant."); + return; + } + topology = contextual.topology().get(); + targetWorkcell = contextual; + } + if (targetWorkcell == null) { + CommandJigsaw.sendError(this, "The selected Jigsaw Studio workcell is no longer available."); + return; + } + JigsawPlanarTopology selectedTopology = topology; + String activationWorkcellId = targetWorkcell.stableId(); + JigsawStudioCellDimensions dimensions = targetWorkcell.capacity(); + String topologyText = selectedTopology == null ? "blank spatial" : selectedTopology.name(); + CommandJigsaw.runGraphMutation(player(), context, () -> { + JigsawStudioGraphEditor.createPiece( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + poolKey, + pieceKey, + weight, + dimensions, + selectedTopology); + return CommandJigsaw.mappedMutationResult( + context, + activationWorkcellId, + pieceKey, + "Created owned " + topologyText + " piece '" + pieceKey + + "' and added it to pool '" + poolKey + "' atomically."); + }); + } + + @Director(description = "Add an existing jigsaw piece to an owned pool transactionally", sync = true, + origin = DirectorOrigin.PLAYER) + public void add( + @Param(description = "Owned pool resource key") String poolKey, + @Param(description = "Existing jigsaw piece key") String pieceKey, + @Param(description = "Positive selection weight", defaultValue = "1") int weight + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + IrisJigsawPiece piece = context.request().source().getJigsawPieceLoader().load(pieceKey); + if (piece == null) { + CommandJigsaw.sendError(this, "No jigsaw piece '" + pieceKey + "' exists in this pack."); + return; + } + if (piece.getObject() == null || piece.getObject().isBlank()) { + CommandJigsaw.sendError(this, "Jigsaw piece '" + pieceKey + "' does not declare an object."); + return; + } + IrisObject object = context.request().source().getObjectLoader().load(piece.getObject()); + if (object == null) { + CommandJigsaw.sendError(this, "Jigsaw piece '" + pieceKey + "' has no loadable object."); + return; + } + CommandJigsaw.PieceWorkcellResolution resolution; + try { + resolution = CommandJigsaw.resolvePieceWorkcell(context.session().layout(), piece, object); + } catch (IllegalArgumentException exception) { + CommandJigsaw.sendError(this, "Jigsaw piece '" + pieceKey + "' cannot be assigned to a Studio workcell: " + + exception.getMessage()); + return; + } + if (!resolution.fits()) { + IrisPosition required = resolution.requiredDimensions(); + JigsawStudioCellDimensions capacity = resolution.workcell().capacity(); + CommandJigsaw.sendError(this, "Jigsaw piece '" + pieceKey + "' requires " + required.getX() + "x" + + required.getY() + "x" + required.getZ() + " in " + resolution.workcell().displayName() + + ", but that workcell capacity is " + capacity.width() + "x" + capacity.height() + + "x" + capacity.depth() + "."); + return; + } + String objectKey = piece.getObject(); + CommandJigsaw.runGraphMutation(player(), context, () -> { + if (!JigsawStudioGraphEditor.ownsPiece( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + pieceKey, + objectKey)) { + throw new IOException("Piece add accepts only piece/object resources already owned by this project; " + + "use piece create for a new owned piece"); + } + JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.addPiece( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + poolKey, + pieceKey, + weight); + if (!update.changed()) { + throw new IOException("The owned pool is missing or already references '" + pieceKey + "'"); + } + JigsawStudioLayout layout = CommandJigsaw.loadPersistedLayout(context); + String activationWorkcellId = CommandJigsaw.workcellIdForVariant(layout, pieceKey); + return new JigsawStudioService.CommandGraphMutationResult( + layout, + activationWorkcellId, + pieceKey, + "Added '" + pieceKey + "' to pool '" + poolKey + "' in one graph transaction."); + }); + } + + @Director(description = "Remove the selected piece from an owned pool transactionally", sync = true, + origin = DirectorOrigin.PLAYER) + public void remove( + @Param(description = "Owned pool resource key") String poolKey + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioVariant selected = CommandJigsaw.selectedVariant(context); + if (selected == null) { + CommandJigsaw.sendError(this, "Select a workcell with an active variant before removing its pool entry."); + return; + } + String pieceKey = selected.pieceKey(); + CommandJigsaw.runGraphMutation(player(), context, () -> { + JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.removePiece( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + poolKey, + pieceKey); + if (!update.changed()) { + throw new IOException("The owned pool does not reference selected piece '" + pieceKey + "'"); + } + return CommandJigsaw.mappedMutationResult( + context, + "", + "", + "Removed '" + pieceKey + "' from pool '" + poolKey + "' in one graph transaction."); + }); + } + + @Director(description = "Allow or forbid cardinal rotation of the selected piece", sync = true, + origin = DirectorOrigin.PLAYER) + public void rotatable( + @Param(description = "Whether the selected piece may rotate") boolean rotatable + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioVariant selected = CommandJigsaw.selectedVariant(context); + if (selected == null) { + CommandJigsaw.sendError(this, "Select a workcell with an active variant before changing rotation."); + return; + } + if (!rotatable + && context.request().compatibilityTarget() + == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE) { + CommandJigsaw.sendError(this, "Vanilla-portable pieces must remain rotatable."); + return; + } + String pieceKey = selected.pieceKey(); + CommandJigsaw.runGraphMutation(player(), context, () -> { + JigsawStudioGraphEditor.updateRotatable( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + pieceKey, + rotatable); + return CommandJigsaw.mappedMutationResult( + context, + "", + "", + "Persisted piece '" + pieceKey + "' rotatable=" + rotatable + "."); + }); + } + + @Director(description = "Resize the selected piece object exactly to workcell capacity", sync = true, + origin = DirectorOrigin.PLAYER) + public void expand() { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay workcell = CommandJigsaw.selectedBay(context); + if (workcell == null) { + CommandJigsaw.sendError(this, "Select a workcell before expanding its active variant."); + return; + } + JigsawStudioService.get().expandVariantToCell(player(), workcell.stableId()); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPool.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPool.java new file mode 100644 index 000000000..9953188c3 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPool.java @@ -0,0 +1,38 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphEditor; +import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.volmlib.util.director.DirectorOrigin; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; + +@Director(name = "pool", description = "Manage owned jigsaw pools", origin = DirectorOrigin.PLAYER) +public class CommandJigsawPool implements DirectorExecutor { + @Director(description = "Create an empty owned jigsaw pool", sync = true, + origin = DirectorOrigin.PLAYER) + public void create( + @Param(description = "New owned pool resource key") String poolKey, + @Param(description = "Owned fallback pool key, or none", defaultValue = "none") + String fallbackPoolKey + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + String fallback = "none".equalsIgnoreCase(fallbackPoolKey) ? "" : fallbackPoolKey; + CommandJigsaw.runGraphMutation(player(), context, () -> { + JigsawStudioGraphEditor.createPool( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + poolKey, + fallback); + return CommandJigsaw.mappedMutationResult( + context, + "", + "", + "Created owned pool '" + poolKey + "' with fallback '" + + (fallback.isBlank() ? "none" : fallback) + "'."); + }); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPreview.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPreview.java new file mode 100644 index 000000000..173d99e1a --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPreview.java @@ -0,0 +1,26 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.core.service.JigsawStudioService; +import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.volmlib.util.director.DirectorOrigin; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; + +@Director(name = "preview", description = "Assemble a read-only jigsaw preview", + origin = DirectorOrigin.PLAYER) +public class CommandJigsawPreview implements DirectorExecutor { + @Director(name = "goto", aliases = "teleport", description = "Teleport to the live seed-1337 preview", + sync = true, origin = DirectorOrigin.PLAYER) + public void gotoPreview() { + JigsawStudioService.get().goToPreview(player()); + } + + @Director(description = "Assemble a deterministic preview at your location", sync = true, + origin = DirectorOrigin.PLAYER) + public void assemble( + @Param(description = "Assembly seed", defaultValue = "1337") long seed + ) { + JigsawStudioService.get().previewStudio(player(), seed); + } + +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawRules.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawRules.java new file mode 100644 index 000000000..d8aa173e2 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawRules.java @@ -0,0 +1,74 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioStructureEditor; +import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.volmlib.util.director.DirectorOrigin; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; + +@Director(name = "rules", aliases = "rule", description = "Manage structure-wide jigsaw rules", + origin = DirectorOrigin.PLAYER) +public class CommandJigsawRules implements DirectorExecutor { + @Director(description = "Set maximum expansion depth and horizontal radius", sync = true, + origin = DirectorOrigin.PLAYER) + public void limits( + @Param(description = "Maximum expansion depth") int maxDepth, + @Param(description = "Maximum radius in chunks") int maxSizeChunks + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + if (context.request().compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE + && (maxDepth > 20 || maxSizeChunks > 8)) { + CommandJigsaw.sendError(this, "Vanilla-portable rules require maxDepth <= 20 and maxSizeChunks <= 8."); + return; + } + CommandJigsaw.runGraphMutation(player(), context, () -> { + JigsawStudioStructureEditor.updateLimits( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + maxDepth, + maxSizeChunks); + return CommandJigsaw.mappedMutationResult( + context, + "", + "", + "Persisted jigsaw limits: maxDepth=" + maxDepth + + ", maxSizeChunks=" + maxSizeChunks + "."); + }); + } + + @Director(description = "Set or clear one owned pool's direct fallback", sync = true, + origin = DirectorOrigin.PLAYER) + public void fallback( + @Param(description = "Owned pool resource key") String poolKey, + @Param(description = "Owned fallback pool key, or none") String fallbackPoolKey + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + String fallback = "none".equalsIgnoreCase(fallbackPoolKey) ? "" : fallbackPoolKey; + CommandJigsaw.runGraphMutation(player(), context, () -> { + JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.updateFallback( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + poolKey, + fallback); + if (!update.changed()) { + return CommandJigsaw.mappedMutationResult(context, "", "", "Pool fallback is unchanged."); + } + return CommandJigsaw.mappedMutationResult( + context, + "", + "", + "Persisted pool '" + poolKey + "' fallback as '" + + (fallback.isBlank() ? "none" : fallback) + "'."); + }); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawVariant.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawVariant.java new file mode 100644 index 000000000..1933cf389 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawVariant.java @@ -0,0 +1,169 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant; +import art.arcane.iris.core.service.JigsawStudioService; +import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.volmlib.util.director.DirectorOrigin; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; + +import java.io.IOException; + +@Director(name = "variant", description = "Manage selected jigsaw variants", origin = DirectorOrigin.PLAYER) +public class CommandJigsawVariant implements DirectorExecutor { + @Director(description = "Set the selected piece weight in a pool", sync = true, + origin = DirectorOrigin.PLAYER) + public void weight( + @Param(description = "Pool resource key") String poolKey, + @Param(description = "Positive selection weight") int weight + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioVariant selected = CommandJigsaw.selectedVariant(context); + if (selected == null) { + CommandJigsaw.sendError(this, "Select a workcell with an active variant before changing its weight."); + return; + } + String pieceKey = selected.pieceKey(); + CommandJigsaw.runGraphMutation(player(), context, () -> { + JigsawStudioPoolEditor.WeightUpdate update = JigsawStudioPoolEditor.updateWeight( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + poolKey, + pieceKey, + weight); + if (!update.changed()) { + throw new IOException("Pool '" + poolKey + "' does not reference selected piece '" + + pieceKey + "'"); + } + return CommandJigsaw.mappedMutationResult( + context, + "", + "", + "Updated " + update.changedEntries() + " pool entry weight atomically in " + + update.poolPath() + "."); + }); + } + + @Director(description = "Resize only the active variant object within workcell capacity", sync = true, + origin = DirectorOrigin.PLAYER) + public void resize( + @Param(description = "Variant width") int width, + @Param(description = "Variant height") int height, + @Param(description = "Variant depth") int depth + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selectedWorkcell = CommandJigsaw.selectedBay(context); + JigsawStudioVariant selectedVariant = CommandJigsaw.selectedVariant(context); + if (selectedWorkcell == null || selectedVariant == null) { + CommandJigsaw.sendError(this, "Select a workcell with an active variant before resizing it."); + return; + } + JigsawStudioCellDimensions dimensions; + try { + dimensions = new JigsawStudioCellDimensions(width, height, depth); + } catch (IllegalArgumentException exception) { + CommandJigsaw.sendError(this, exception.getMessage()); + return; + } + JigsawStudioService.get().resizeVariant( + player(), + selectedWorkcell.stableId(), + selectedVariant.pieceKey(), + dimensions); + } + + @Director(description = "Set the active variant's author-facing label", sync = true, + origin = DirectorOrigin.PLAYER) + public void label( + @Param(description = "Display label; quote labels containing spaces") String displayName + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selectedWorkcell = CommandJigsaw.selectedBay(context); + JigsawStudioVariant selectedVariant = CommandJigsaw.selectedVariant(context); + if (selectedWorkcell == null || selectedVariant == null) { + CommandJigsaw.sendError(this, "Select a workcell with an active variant before renaming it."); + return; + } + JigsawStudioService.get().updateVariantDisplayName( + player(), + selectedWorkcell.stableId(), + selectedVariant.pieceKey(), + displayName); + } + + @Director(name = "label-reset", aliases = "reset-label", + description = "Reset the active variant to its resource-key label", sync = true, + origin = DirectorOrigin.PLAYER) + public void labelReset() { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selectedWorkcell = CommandJigsaw.selectedBay(context); + JigsawStudioVariant selectedVariant = CommandJigsaw.selectedVariant(context); + if (selectedWorkcell == null || selectedVariant == null) { + CommandJigsaw.sendError(this, "Select a workcell with an active variant before resetting its label."); + return; + } + JigsawStudioService.get().updateVariantDisplayName( + player(), + selectedWorkcell.stableId(), + selectedVariant.pieceKey(), + ""); + } + + @Director(description = "Duplicate the active variant in only this workcell", sync = true, + origin = DirectorOrigin.PLAYER) + public void duplicate() { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selected = CommandJigsaw.selectedBay(context); + if (selected == null || CommandJigsaw.selectedVariant(context) == null) { + CommandJigsaw.sendError(this, "Select a workcell with an active variant before duplicating it."); + return; + } + JigsawStudioService.get().createVariant(player(), selected.stableId(), true); + } + + @Director(name = "duplicate-family", aliases = "family", + description = "Duplicate every enabled workcell's active variant as one coherent family", + sync = true, origin = DirectorOrigin.PLAYER) + public void duplicateFamily( + @Param(description = "Family key or next", defaultValue = "next") String themeKey + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + String selectedTheme = themeKey; + if ("next".equalsIgnoreCase(themeKey)) { + try { + selectedTheme = CommandJigsaw.nextThemeKey(context); + } catch (IOException | ArithmeticException exception) { + CommandJigsaw.sendError(this, exception.getMessage()); + return; + } + } + JigsawStudioService.get().duplicateActiveFamily(player(), selectedTheme); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawWorkcell.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawWorkcell.java new file mode 100644 index 000000000..8c0b99b81 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawWorkcell.java @@ -0,0 +1,81 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.service.JigsawStudioService; +import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.volmlib.util.director.DirectorOrigin; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; + +@Director(name = "workcell", aliases = "cell", description = "Manage the selected Studio workcell", + origin = DirectorOrigin.PLAYER) +public class CommandJigsawWorkcell implements DirectorExecutor { + @Director(description = "Set capacity without resizing any variant object", sync = true, + origin = DirectorOrigin.PLAYER) + public void capacity( + @Param(description = "Capacity width") int width, + @Param(description = "Capacity height") int height, + @Param(description = "Capacity depth") int depth + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selected = CommandJigsaw.selectedBay(context); + if (selected == null) { + CommandJigsaw.sendError(this, "Select a workcell before changing its capacity."); + return; + } + JigsawStudioCellDimensions dimensions; + try { + dimensions = new JigsawStudioCellDimensions(width, height, depth); + } catch (IllegalArgumentException exception) { + CommandJigsaw.sendError(this, exception.getMessage()); + return; + } + JigsawStudioService.get().updateWorkcellDimensions( + player(), + selected.stableId(), + dimensions); + } + + @Director(description = "Set the selected workcell's author-facing label", sync = true, + origin = DirectorOrigin.PLAYER) + public void label( + @Param(description = "Display label; quote labels containing spaces") String displayName + ) { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selected = CommandJigsaw.selectedBay(context); + if (selected == null) { + CommandJigsaw.sendError(this, "Select a workcell before renaming it."); + return; + } + JigsawStudioService.get().updateWorkcellDisplayName( + player(), + selected.stableId(), + displayName); + } + + @Director(name = "label-reset", aliases = "reset-label", + description = "Reset the selected workcell to its canonical solver label", sync = true, + origin = DirectorOrigin.PLAYER) + public void labelReset() { + CommandJigsaw.ActiveContext context = CommandJigsaw.active(player()); + if (context == null) { + CommandJigsaw.sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selected = CommandJigsaw.selectedBay(context); + if (selected == null) { + CommandJigsaw.sendError(this, "Select a workcell before resetting its label."); + return; + } + JigsawStudioService.get().updateWorkcellDisplayName(player(), selected.stableId(), ""); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandJigsawContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandJigsawContractTest.java index 58e5a00b3..e35ef220a 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandJigsawContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandJigsawContractTest.java @@ -108,10 +108,10 @@ public class CommandJigsawContractTest { BrokenPackException failure = new BrokenPackException( "overworld", List.of("Biome 'broken' has no resolvable regions.")); - assertTrue(CommandJigsaw.isExpectedOpenDenial(failure)); - assertTrue(CommandJigsaw.isExpectedOpenDenial( + assertTrue(CommandJigsawOpen.isExpectedOpenDenial(failure)); + assertTrue(CommandJigsawOpen.isExpectedOpenDenial( new CompletionException(failure))); - assertFalse(CommandJigsaw.isExpectedOpenDenial( + assertFalse(CommandJigsawOpen.isExpectedOpenDenial( new IllegalStateException("Unexpected Studio failure"))); } @@ -138,20 +138,20 @@ public class CommandJigsawContractTest { @Test public void adoptionCommandsExposeTwoStepPlanContract() throws Exception { - Method inspect = CommandJigsaw.CommandJigsawAdopt.class.getDeclaredMethod( + Method inspect = CommandJigsawAdopt.class.getDeclaredMethod( "inspect", IrisDimension.class, String.class, String.class, String.class); - Method apply = CommandJigsaw.CommandJigsawAdopt.class.getDeclaredMethod("apply", String.class); + Method apply = CommandJigsawAdopt.class.getDeclaredMethod("apply", String.class); Parameter[] inspectParameters = inspect.getParameters(); assertNotNull(inspect.getAnnotation(Director.class)); assertNotNull(apply.getAnnotation(Director.class)); assertParameter(inspectParameters[2], "auto", null); - assertParameter(inspectParameters[3], "auto", CommandJigsaw.JigsawAdoptionStrategyHandler.class); - assertEquals(CommandJigsaw.JigsawAdoptionPlanHandler.class, + assertParameter(inspectParameters[3], "auto", CommandJigsawAdopt.JigsawAdoptionStrategyHandler.class); + assertEquals(CommandJigsawAdopt.JigsawAdoptionPlanHandler.class, apply.getParameters()[0].getAnnotation(Param.class).customHandler()); - CommandJigsaw.JigsawAdoptionStrategyHandler strategyHandler = - new CommandJigsaw.JigsawAdoptionStrategyHandler(); + CommandJigsawAdopt.JigsawAdoptionStrategyHandler strategyHandler = + new CommandJigsawAdopt.JigsawAdoptionStrategyHandler(); assertEquals(List.of("auto", "in-place", "clone"), strategyHandler.getPossibilities()); assertEquals("in-place", strategyHandler.parse("claim", false)); assertEquals("clone", strategyHandler.parse("copy", false)); @@ -163,14 +163,14 @@ public class CommandJigsawContractTest { Path root = temporaryFolder.newFolder("adoption-provenance").toPath(); assertEquals(IrisStructureAdoptionInputKind.UNOWNED_IRIS, - CommandJigsaw.adoptionInputKind(root, "unowned")); + CommandJigsawAdopt.adoptionInputKind(root, "unowned")); writeManifest(root, "datapack-created", StructureSource.Kind.DATAPACK, StructureOwnershipManifest.Provenance.created()); assertEquals(IrisStructureAdoptionInputKind.UNOWNED_IRIS, - CommandJigsaw.adoptionInputKind(root, "datapack-created")); + CommandJigsawAdopt.adoptionInputKind(root, "datapack-created")); writeManifest(root, "managed-provenance", StructureSource.Kind.IRIS, managedProvenance()); assertEquals(IrisStructureAdoptionInputKind.MANAGED_DATAPACK, - CommandJigsaw.adoptionInputKind(root, "managed-provenance")); + CommandJigsawAdopt.adoptionInputKind(root, "managed-provenance")); } @Test @@ -275,70 +275,70 @@ public class CommandJigsawContractTest { @Test public void exposesNestedPieceVariantAndPreviewCommands() throws Exception { - assertNotNull(CommandJigsaw.CommandJigsawPool.class + assertNotNull(CommandJigsawPool.class .getDeclaredMethod("create", String.class, String.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawConnector.class + assertNotNull(CommandJigsawConnector.class .getDeclaredMethod("channel", String.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawPiece.class + assertNotNull(CommandJigsawPiece.class .getDeclaredMethod("create", String.class, String.class, int.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawPiece.class + assertNotNull(CommandJigsawPiece.class .getDeclaredMethod("add", String.class, String.class, int.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawPiece.class + assertNotNull(CommandJigsawPiece.class .getDeclaredMethod("remove", String.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawPiece.class + assertNotNull(CommandJigsawPiece.class .getDeclaredMethod("rotatable", boolean.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawPiece.class + assertNotNull(CommandJigsawPiece.class .getDeclaredMethod("expand") .getAnnotation(Director.class)); assertEquals("Resize the selected piece object exactly to workcell capacity", - CommandJigsaw.CommandJigsawPiece.class + CommandJigsawPiece.class .getDeclaredMethod("expand") .getAnnotation(Director.class) .description()); - assertNotNull(CommandJigsaw.CommandJigsawVariant.class + assertNotNull(CommandJigsawVariant.class .getDeclaredMethod("weight", String.class, int.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawVariant.class + assertNotNull(CommandJigsawVariant.class .getDeclaredMethod("resize", int.class, int.class, int.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawVariant.class + assertNotNull(CommandJigsawVariant.class .getDeclaredMethod("label", String.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawVariant.class + assertNotNull(CommandJigsawVariant.class .getDeclaredMethod("labelReset") .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawVariant.class + assertNotNull(CommandJigsawVariant.class .getDeclaredMethod("duplicate") .getAnnotation(Director.class)); - Method duplicateFamily = CommandJigsaw.CommandJigsawVariant.class + Method duplicateFamily = CommandJigsawVariant.class .getDeclaredMethod("duplicateFamily", String.class); assertNotNull(duplicateFamily.getAnnotation(Director.class)); assertParameter(duplicateFamily.getParameters()[0], "next", null); - assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class + assertNotNull(CommandJigsawWorkcell.class .getDeclaredMethod("capacity", int.class, int.class, int.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class + assertNotNull(CommandJigsawWorkcell.class .getDeclaredMethod("label", String.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class + assertNotNull(CommandJigsawWorkcell.class .getDeclaredMethod("labelReset") .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawRules.class + assertNotNull(CommandJigsawRules.class .getDeclaredMethod("limits", int.class, int.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawRules.class + assertNotNull(CommandJigsawRules.class .getDeclaredMethod("fallback", String.class, String.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawPreview.class + assertNotNull(CommandJigsawPreview.class .getDeclaredMethod("assemble", long.class) .getAnnotation(Director.class)); - assertNotNull(CommandJigsaw.CommandJigsawPreview.class + assertNotNull(CommandJigsawPreview.class .getDeclaredMethod("gotoPreview") .getAnnotation(Director.class)); } @@ -393,14 +393,14 @@ public class CommandJigsawContractTest { public void everyExecutableCommandRejectsNonPlayerOrigins() { Class[] commandTypes = { CommandJigsaw.class, - CommandJigsaw.CommandJigsawConnector.class, - CommandJigsaw.CommandJigsawPool.class, - CommandJigsaw.CommandJigsawPiece.class, - CommandJigsaw.CommandJigsawVariant.class, - CommandJigsaw.CommandJigsawWorkcell.class, - CommandJigsaw.CommandJigsawRules.class, - CommandJigsaw.CommandJigsawPreview.class, - CommandJigsaw.CommandJigsawAdopt.class + CommandJigsawConnector.class, + CommandJigsawPool.class, + CommandJigsawPiece.class, + CommandJigsawVariant.class, + CommandJigsawWorkcell.class, + CommandJigsawRules.class, + CommandJigsawPreview.class, + CommandJigsawAdopt.class }; for (Class commandType : commandTypes) { @@ -417,17 +417,17 @@ public class CommandJigsawContractTest { public void exportOutputIsOneSafeChildArtifact() { Path root = Path.of("build", "jigsaw-exports").toAbsolutePath().normalize(); - assertEquals(root.resolve("village.zip"), CommandJigsaw.resolveExportDestination( + assertEquals(root.resolve("village.zip"), CommandJigsawExports.resolveExportDestination( root, "village", VanillaJigsawExportFormat.ZIP)); - assertEquals(root.resolve("village"), CommandJigsaw.resolveExportDestination( + assertEquals(root.resolve("village"), CommandJigsawExports.resolveExportDestination( root, "village", VanillaJigsawExportFormat.DIRECTORY)); - assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination( + assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination( root, "", VanillaJigsawExportFormat.DIRECTORY)); - assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination( + assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination( root, ".", VanillaJigsawExportFormat.DIRECTORY)); - assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination( + assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination( root, "../all-exports", VanillaJigsawExportFormat.DIRECTORY)); - assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination( + assertThrows(IllegalArgumentException.class, () -> CommandJigsawExports.resolveExportDestination( root, "nested/export", VanillaJigsawExportFormat.DIRECTORY)); } @@ -438,42 +438,42 @@ public class CommandJigsawContractTest { Path firstDestination = Path.of("build", "jigsaw-exports", "first.zip"); Path secondDestination = Path.of("build", "jigsaw-exports", "second.zip"); - assertEquals(true, CommandJigsaw.beginExport(firstPlayer, firstDestination)); + assertEquals(true, CommandJigsawExports.beginExport(firstPlayer, firstDestination)); try { - assertEquals(false, CommandJigsaw.beginExport(firstPlayer, secondDestination)); - assertEquals(false, CommandJigsaw.beginExport(secondPlayer, firstDestination)); + assertEquals(false, CommandJigsawExports.beginExport(firstPlayer, secondDestination)); + assertEquals(false, CommandJigsawExports.beginExport(secondPlayer, firstDestination)); } finally { - CommandJigsaw.finishExport(firstPlayer, firstDestination); + CommandJigsawExports.finishExport(firstPlayer, firstDestination); } - assertEquals(true, CommandJigsaw.beginExport(secondPlayer, firstDestination)); - CommandJigsaw.finishExport(secondPlayer, firstDestination); + assertEquals(true, CommandJigsawExports.beginExport(secondPlayer, firstDestination)); + CommandJigsawExports.finishExport(secondPlayer, firstDestination); } @Test public void exportStartFailuresHavePreciseOperatorMessages() { assertEquals("The active Jigsaw Studio is no longer available.", - CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.NOT_ACTIVE)); + CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.NOT_ACTIVE)); assertEquals("Only the Jigsaw Studio owner can export this project.", - CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.NOT_OWNER)); + CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.NOT_OWNER)); assertEquals("Wait for the pending autosave or discard the edits before exporting the on-disk graph.", - CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.DIRTY)); + CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.DIRTY)); assertEquals("The active Jigsaw Studio is closing and cannot be exported.", - CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.CLOSING)); + CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.CLOSING)); assertEquals("Wait for the current Jigsaw Studio save to finish before exporting.", - CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.SAVE_IN_PROGRESS)); + CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.SAVE_IN_PROGRESS)); assertEquals("Wait for the current Jigsaw Studio operation to finish before exporting.", - CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.OPERATION_IN_PROGRESS)); + CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.OPERATION_IN_PROGRESS)); assertEquals("A Jigsaw Studio export is already in progress.", - CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.IN_PROGRESS)); + CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.IN_PROGRESS)); assertThrows(IllegalArgumentException.class, - () -> CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.STARTED)); + () -> CommandJigsawExports.exportStartError(JigsawStudioService.ExportStart.STARTED)); } @Test public void exportLeaseReleaseActionRunsExactlyOnce() { AtomicInteger releases = new AtomicInteger(); - CommandJigsaw.ExportLease lease = new CommandJigsaw.ExportLease(releases::incrementAndGet); + CommandJigsawExports.ExportLease lease = new CommandJigsawExports.ExportLease(releases::incrementAndGet); lease.release(); lease.release(); diff --git a/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationRevisionScope.java b/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationRevisionScope.java index cac3eb581..34370e81a 100644 --- a/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationRevisionScope.java +++ b/buildSrc/src/main/java/art/arcane/iris/buildtools/GenerationRevisionScope.java @@ -35,6 +35,16 @@ public final class GenerationRevisionScope { "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPregen.java", "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java", "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java", + "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawAdopt.java", + "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawConnector.java", + "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawExports.java", + "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawOpen.java", + "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPiece.java", + "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPool.java", + "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawPreview.java", + "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawRules.java", + "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawVariant.java", + "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsawWorkcell.java", "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java", "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java", "adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/PaperCommandRegistrar.java",