Split jigsaw studio service

- Move world registration and shutdown quiescing into JigsawStudioRegistry
- Move save, export and close arbitration into JigsawStudioSaveLifecycle
- Move autosave scheduling, drains and undo into JigsawStudioAutosaveScheduler
- Move variant creation, duplication and deletion into JigsawStudioVariantEditor
- Move variant metadata and resize edits into JigsawStudioVariantProperties
- Move workcell capacity, labels and connector blocks into JigsawStudioWorkcellEditor
- Move graph mutation and live relayout into JigsawStudioGraphMutations
- Move dynamic graph evaluation into JigsawStudioEvaluator
- Move menu state, studio tools and deferred duplication into JigsawStudioToolbelt
- Move the studio world event handlers into JigsawStudioProtectionListener
- Move ownership and mutation guards into JigsawStudioProtection
- Move variant materialization and hydration into JigsawStudioMaterializer
- Move dirty marking into JigsawStudioDirtyTracker
- Move workcell capture scheduling into JigsawStudioCaptureScheduler
- Move chunk capture and connector aggregation into JigsawStudioCapture
- Move materialized and rendered chunk writing into JigsawStudioChunkWriter
- Move jigsaw marker NBT watching into JigsawStudioTileWatcher
- Move particle preview rendering into JigsawStudioVisualization
- Move player workcell context into JigsawStudioPlayerContext
- Point the studio service tests at the relocated collaborators
- Record the split studio sources in the Bukkit purity allowlist
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-08 06:20:54 -04:00
1 parent 8b9d1fb2fb
commit 2451c89cee
26 files changed
+9617 -8574

No files matched your search

+26
View File
@@ -62,11 +62,37 @@ art/arcane/iris/core/service/BoardSVC.java
art/arcane/iris/core/service/EntityRiseSVC.java art/arcane/iris/core/service/EntityRiseSVC.java
art/arcane/iris/core/service/ExternalDataSVC.java art/arcane/iris/core/service/ExternalDataSVC.java
art/arcane/iris/core/service/GlobalCacheSVC.java art/arcane/iris/core/service/GlobalCacheSVC.java
art/arcane/iris/core/service/JigsawStudioAutosaveScheduler.java
art/arcane/iris/core/service/JigsawStudioCapture.java
art/arcane/iris/core/service/JigsawStudioCaptureScheduler.java
art/arcane/iris/core/service/JigsawStudioChunkWriter.java
art/arcane/iris/core/service/JigsawStudioDetailsMenu.java
art/arcane/iris/core/service/JigsawStudioDirtyTracker.java
art/arcane/iris/core/service/JigsawStudioGraphMutations.java
art/arcane/iris/core/service/JigsawStudioMainMenu.java
art/arcane/iris/core/service/JigsawStudioMarkerParser.java art/arcane/iris/core/service/JigsawStudioMarkerParser.java
art/arcane/iris/core/service/JigsawStudioMaterializer.java
art/arcane/iris/core/service/JigsawStudioMenuActions.java
art/arcane/iris/core/service/JigsawStudioMenuController.java art/arcane/iris/core/service/JigsawStudioMenuController.java
art/arcane/iris/core/service/JigsawStudioMenuFormat.java
art/arcane/iris/core/service/JigsawStudioPlayerContext.java
art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java
art/arcane/iris/core/service/JigsawStudioProtection.java
art/arcane/iris/core/service/JigsawStudioProtectionListener.java
art/arcane/iris/core/service/JigsawStudioRegistry.java
art/arcane/iris/core/service/JigsawStudioSaveLifecycle.java
art/arcane/iris/core/service/JigsawStudioService.java art/arcane/iris/core/service/JigsawStudioService.java
art/arcane/iris/core/service/JigsawStudioStructureMenu.java
art/arcane/iris/core/service/JigsawStudioTileWatcher.java
art/arcane/iris/core/service/JigsawStudioToolCodec.java art/arcane/iris/core/service/JigsawStudioToolCodec.java
art/arcane/iris/core/service/JigsawStudioToolbelt.java
art/arcane/iris/core/service/JigsawStudioToolboxMenu.java
art/arcane/iris/core/service/JigsawStudioVariantEditor.java
art/arcane/iris/core/service/JigsawStudioVariantMenu.java
art/arcane/iris/core/service/JigsawStudioVariantProperties.java
art/arcane/iris/core/service/JigsawStudioVisualization.java
art/arcane/iris/core/service/JigsawStudioWorkcellEditor.java
art/arcane/iris/core/service/JigsawStudioWorkcellMenu.java
art/arcane/iris/core/service/MultiverseSVC.java art/arcane/iris/core/service/MultiverseSVC.java
art/arcane/iris/core/service/ObjectSVC.java art/arcane/iris/core/service/ObjectSVC.java
art/arcane/iris/core/service/ObjectStudioSaveService.java art/arcane/iris/core/service/ObjectStudioSaveService.java
@@ -0,0 +1,670 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkCaptureArea;
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkSnapshot;
import art.arcane.iris.core.service.JigsawStudioCaptureScheduler.CaptureCoordinator;
import art.arcane.iris.core.service.JigsawStudioCaptureScheduler.CaptureTarget;
import art.arcane.iris.core.service.JigsawStudioCaptureScheduler.CaptureWork;
import art.arcane.iris.core.service.JigsawStudioMaterializer.VariantReloadRequest;
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.BayReadiness;
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.SaveAttempt;
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.SaveStart;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.core.service.JigsawStudioService.CommandGraphMutationResult;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static art.arcane.iris.core.service.JigsawStudioCapture.captureChunkIntersection;
import static art.arcane.iris.core.service.JigsawStudioCapture.chunkIntersections;
import static art.arcane.iris.core.service.JigsawStudioCaptureScheduler.resolveCaptureTarget;
import static art.arcane.iris.core.service.JigsawStudioGraphMutations.loadMappedLayout;
import static art.arcane.iris.core.service.JigsawStudioProtection.authorizeOwner;
import static art.arcane.iris.core.service.JigsawStudioService.AUTOSAVE_RETRY_TICKS;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
import static art.arcane.iris.core.service.JigsawStudioService.message;
import static art.arcane.iris.core.service.JigsawStudioService.writeFailure;
final class JigsawStudioAutosaveScheduler {
static final int AUTOSAVE_DEBOUNCE_TICKS = 40;
private static final List<Integer> AUTOSAVE_PERSISTENT_RETRY_DELAYS =
List.of(40, 80, 160, 320, 600);
private final JigsawStudioService service;
final Map<AutosaveKey, AutosaveTicket> autosaves = new ConcurrentHashMap<>();
JigsawStudioAutosaveScheduler(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean undoAutosave(Player player) {
if (player == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> undoAutosave(player));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
Map<String, VariantReloadRequest> activeReloads = new HashMap<>();
for (JigsawStudioBay workcell : session.layout().bays()) {
session.activeVariant(workcell.stableId()).ifPresent(variant -> activeReloads.put(
variant.pieceKey(),
new VariantReloadRequest(
workcell.stableId(),
studio.generator().renderBay(workcell))));
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
message(player, "Restoring the previous Jigsaw Studio autosave iteration...");
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
Path packRoot = request.source().getDataFolder().toPath();
JigsawStudioHistoryStore.UndoResult result = new JigsawStudioHistoryStore(
packRoot,
request.structureKey()).undoLatest();
if (!result.available()) {
return new CommandGraphMutationResult(
session.layout(),
"",
"",
"No earlier autosave iteration is available.");
}
if (!result.successful()) {
throw new IOException("Jigsaw Studio undo failed: "
+ writeFailure(result.writeResult()));
}
request.source().invalidateStructureResources();
JigsawStudioLayout restoredLayout = loadMappedLayout(studio);
VariantReloadRequest reload = activeReloads.get(result.pieceKey());
Optional<VariantReloadRequest> activeReload = reload == null
|| restoredLayout.get(reload.workcellId()) == null
? Optional.empty()
: Optional.of(reload);
String warning = result.warning().isEmpty()
? ""
: " History cleanup warning: " + result.warning();
return new CommandGraphMutationResult(
restoredLayout,
"",
"",
Map.of(),
activeReload,
"Restored the previous autosave iteration. "
+ result.remainingIterations() + " earlier iteration(s) remain."
+ warning);
});
}
boolean flushAutosave(Player player, String workcellId) {
if (player == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> flushAutosave(player, workcellId));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String targetId = workcellId == null || workcellId.isBlank()
? session.selectedBayId().orElse("")
: workcellId;
JigsawStudioBay bay = session.layout().get(targetId);
if (bay == null) {
message(player, "Select a Jigsaw Studio workcell before saving now.");
return false;
}
UUID requestId = studio.generator().getRequest().requestId();
service.tileWatcher.finalizeJigsawTileWatches(requestId);
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
message(player, "Wait for the open vanilla jigsaw-block editor to finish its final snapshot.");
return false;
}
SaveAttempt attempt = service.saveLifecycle.startSave(studio, studio.world(), bay, player, true);
if (attempt == SaveAttempt.STARTED) {
return true;
}
if (attempt == SaveAttempt.PERSISTENT_FAILURE) {
return false;
}
AutosaveTicket ticket = autosaves.get(new AutosaveKey(requestId, bay.stableId()));
if (ticket != null) {
expediteAutosave(
ticket,
attempt == SaveAttempt.RETRY
? AUTOSAVE_RETRY_TICKS
: AUTOSAVE_DEBOUNCE_TICKS);
}
return false;
}
void scheduleAutosave(
ActiveStudio studio,
JigsawStudioBay bay,
JigsawStudioSession.DirtyIdentity identity,
int delayTicks
) {
UUID requestId = studio.generator().getRequest().requestId();
AutosaveKey key = new AutosaveKey(requestId, bay.stableId());
AutosaveTicket ticket = new AutosaveTicket(
key,
studio,
identity,
new AtomicBoolean(false),
new AtomicBoolean(false));
autosaves.put(key, ticket);
scheduleAutosave(ticket, delayTicks);
}
void scheduleAutosave(AutosaveTicket ticket, int delayTicks) {
if (autosaves.get(ticket.key()) != ticket
|| !ticket.scheduled().compareAndSet(false, true)) {
return;
}
JigsawStudioBay bay = resolveAutosaveBay(ticket);
if (bay == null) {
ticket.scheduled().set(false);
return;
}
JigsawStudioBounds bounds = bay.bounds();
boolean scheduled = J.runRegion(
ticket.studio().world(),
bounds.originX() >> 4,
bounds.originZ() >> 4,
() -> runAutosave(ticket),
delayTicks);
if (!scheduled) {
ticket.scheduled().set(false);
if (ticket.scheduleFailureLogged().compareAndSet(false, true)) {
IrisLogging.warn("Jigsaw Studio autosave could not schedule workcell %s for request %s; Iris will keep retrying",
ticket.key().workcellId(), ticket.key().requestId());
}
scheduleAutosaveReconciliation(ticket);
}
}
private JigsawStudioBay resolveAutosaveBay(AutosaveTicket ticket) {
if (autosaves.get(ticket.key()) != ticket) {
return null;
}
JigsawStudioBay bay = resolveCurrentAutosaveBay(
ticket.studio().generator().getSession(),
ticket.key().workcellId());
if (bay != null) {
return bay;
}
if (autosaves.remove(ticket.key(), ticket)) {
reportAutosaveFailure(
ticket,
new AutosavePersistentFailure(
"workcell is absent from the current Studio layout; pending ticket retired",
null));
}
return null;
}
static JigsawStudioBay resolveCurrentAutosaveBay(
JigsawStudioSession session,
String workcellId
) {
JigsawStudioSession activeSession = Objects.requireNonNull(
session,
"Jigsaw Studio autosave session");
return activeSession.layout().get(Objects.requireNonNull(
workcellId,
"Jigsaw Studio autosave workcell ID"));
}
boolean retainCurrentAutosaveFailure(
ActiveStudio studio,
String workcellId,
AutosavePersistentFailure failure
) {
AutosaveKey key = new AutosaveKey(
studio.generator().getRequest().requestId(),
workcellId);
AutosaveTicket ticket = autosaves.get(key);
return ticket != null && retainPersistentAutosaveFailure(ticket, failure);
}
AutosaveFailureState autosaveFailureState(
ActiveStudio studio,
JigsawStudioSession.SaveIdentity saveIdentity
) {
AutosaveKey key = new AutosaveKey(
studio.generator().getRequest().requestId(),
saveIdentity.workcellId());
AutosaveTicket ticket = autosaves.get(key);
return ticket != null && matchesSaveIdentity(ticket.identity(), saveIdentity)
? ticket.failureState()
: new AutosaveFailureState();
}
private boolean retainPersistentAutosaveFailure(
AutosaveTicket ticket,
AutosavePersistentFailure failure
) {
if (autosaves.get(ticket.key()) != ticket
|| !service.isCurrentRequest(ticket.studio(), ticket.key().requestId())
|| !ticket.studio().generator().getSession().isDirtyCurrent(ticket.identity())) {
return false;
}
AutosaveFailureDecision decision = reportAutosaveFailure(ticket, failure);
expediteAutosave(ticket, decision.retryTicks());
return true;
}
private static AutosaveFailureDecision reportAutosaveFailure(
AutosaveTicket ticket,
AutosavePersistentFailure failure
) {
JigsawStudioActivation.Request request = ticket.studio().generator().getRequest();
return recordPersistentAutosaveFailure(
ticket.failureState(),
ticket.key().requestId(),
request.structureKey(),
ticket.key().workcellId(),
ticket.identity().variantKey(),
failure.detail(),
failure.cause());
}
private static void reportPersistentSaveFailure(
ActiveStudio studio,
JigsawStudioSession.SaveIdentity saveIdentity,
AutosaveFailureState failureState,
AutosavePersistentFailure failure
) {
JigsawStudioActivation.Request request = studio.generator().getRequest();
recordPersistentAutosaveFailure(
failureState,
request.requestId(),
request.structureKey(),
saveIdentity.workcellId(),
saveIdentity.variantKey(),
failure.detail(),
failure.cause());
}
static AutosaveFailureDecision recordPersistentAutosaveFailure(
AutosaveFailureState state,
UUID requestId,
String structureKey,
String workcellId,
String pieceKey,
String detail,
Throwable cause
) {
AutosaveFailureState activeState = Objects.requireNonNull(
state,
"Jigsaw Studio autosave failure state");
AutosaveFailureDecision decision = activeState.recordPersistentFailure();
if (!decision.logFailure()) {
return decision;
}
String context = autosaveFailureContext(
requestId,
structureKey,
workcellId,
pieceKey,
detail);
if (cause == null) {
IrisLogging.warn("%s", context);
} else {
IrisLogging.reportError(context, cause);
}
return decision;
}
static String autosaveFailureContext(
UUID requestId,
String structureKey,
String workcellId,
String pieceKey,
String detail
) {
return "Jigsaw Studio save failure: request="
+ Objects.requireNonNull(requestId, "Jigsaw Studio autosave request ID")
+ " structure="
+ Objects.requireNonNull(structureKey, "Jigsaw Studio autosave structure key")
+ " workcell="
+ Objects.requireNonNull(workcellId, "Jigsaw Studio autosave workcell ID")
+ " piece="
+ Objects.requireNonNull(pieceKey, "Jigsaw Studio autosave piece key")
+ " failure="
+ Objects.requireNonNull(detail, "Jigsaw Studio autosave failure detail");
}
static int persistentAutosaveRetryTicks(int failureCount) {
int index = Math.max(0, Math.min(
failureCount - 1,
AUTOSAVE_PERSISTENT_RETRY_DELAYS.size() - 1));
return AUTOSAVE_PERSISTENT_RETRY_DELAYS.get(index);
}
private void scheduleAutosaveReconciliation(AutosaveTicket ticket) {
try {
J.s(() -> {
if (autosaves.get(ticket.key()) == ticket
&& !ticket.scheduled().get()) {
scheduleAutosave(ticket, AUTOSAVE_RETRY_TICKS);
}
}, AUTOSAVE_RETRY_TICKS);
} catch (Throwable exception) {
IrisLogging.reportError(exception);
}
}
private void runAutosave(AutosaveTicket ticket) {
if (autosaves.get(ticket.key()) != ticket) {
return;
}
ticket.scheduled().set(false);
ActiveStudio studio = ticket.studio();
JigsawStudioSession session = studio.generator().getSession();
if (!service.isCurrentRequest(studio, ticket.key().requestId())) {
autosaves.remove(ticket.key(), ticket);
return;
}
JigsawStudioBay bay = resolveAutosaveBay(ticket);
if (bay == null) {
return;
}
if (!session.isDirtyCurrent(ticket.identity())) {
autosaves.remove(ticket.key(), ticket);
return;
}
SaveAttempt attempt = service.saveLifecycle.startSave(studio, studio.world(), bay, null, false);
if (attempt == SaveAttempt.STARTED || attempt == SaveAttempt.PERSISTENT_FAILURE) {
return;
}
scheduleAutosave(
ticket,
attempt == SaveAttempt.RETRY
? AUTOSAVE_RETRY_TICKS
: AUTOSAVE_DEBOUNCE_TICKS);
}
void completeAutosaveAttempt(
ActiveStudio studio,
JigsawStudioSession.SaveIdentity saveIdentity,
AutosaveFailureState failureState,
AutosavePersistentFailure persistentFailure
) {
AutosaveKey key = new AutosaveKey(
studio.generator().getRequest().requestId(),
saveIdentity.workcellId());
AutosaveTicket ticket = autosaves.get(key);
if (ticket == null || !matchesSaveIdentity(ticket.identity(), saveIdentity)) {
if (persistentFailure != null) {
reportPersistentSaveFailure(studio, saveIdentity, failureState, persistentFailure);
}
return;
}
if (resolveAutosaveBay(ticket) == null) {
return;
}
if (!studio.generator().getSession().isDirtyCurrent(ticket.identity())) {
autosaves.remove(key, ticket);
return;
}
if (persistentFailure != null) {
retainPersistentAutosaveFailure(ticket, persistentFailure);
return;
}
scheduleAutosave(ticket, AUTOSAVE_RETRY_TICKS);
}
private static boolean matchesSaveIdentity(
JigsawStudioSession.DirtyIdentity dirtyIdentity,
JigsawStudioSession.SaveIdentity saveIdentity
) {
return dirtyIdentity.sessionId().equals(saveIdentity.sessionId())
&& dirtyIdentity.workcellId().equals(saveIdentity.workcellId())
&& dirtyIdentity.variantKey().equals(saveIdentity.variantKey())
&& dirtyIdentity.loadGeneration() == saveIdentity.loadGeneration()
&& dirtyIdentity.mutationGeneration() == saveIdentity.mutationGeneration();
}
void drainAutosavesBeforeDisable() {
for (ActiveStudio studio : List.copyOf(service.studios.values())) {
try {
drainAutosavesBeforeRemoval(studio);
} catch (Throwable exception) {
IrisLogging.reportError(
"Failed to drain Jigsaw Studio autosaves in world "
+ studio.worldId() + " during shutdown.",
exception);
}
}
if (!autosaves.isEmpty()) {
IrisLogging.warn("Jigsaw Studio disabled with %d autosave operation(s) still pending after the final drain attempt",
autosaves.size());
}
}
void drainAutosavesBeforeRemoval(ActiveStudio studio) {
if (studio == null) {
return;
}
UUID requestId = studio.generator().getRequest().requestId();
if (service.saveLifecycle.discardingRequest(requestId)) {
return;
}
if (J.isFolia() || !J.isPrimaryThread()) {
expediteAutosaves(requestId);
return;
}
for (AutosaveTicket ticket : List.copyOf(autosaves.values())) {
if (requestId.equals(ticket.key().requestId())) {
drainAutosaveSynchronously(ticket);
}
}
}
private boolean drainAutosaveSynchronously(AutosaveTicket ticket) {
if (autosaves.get(ticket.key()) != ticket) {
return true;
}
ActiveStudio studio = ticket.studio();
JigsawStudioSession session = studio.generator().getSession();
if (!service.isCurrentRequest(studio, ticket.key().requestId())) {
autosaves.remove(ticket.key(), ticket);
return true;
}
JigsawStudioBay bay = resolveAutosaveBay(ticket);
if (bay == null) {
return false;
}
if (!session.isDirtyCurrent(ticket.identity())) {
autosaves.remove(ticket.key(), ticket);
return true;
}
JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null);
if (activeVariant == null || !activeVariant.owned()) {
return false;
}
BayReadiness readiness = studio.population(bay).readiness();
if (!readiness.ready()) {
return false;
}
CaptureTarget captureTarget;
try {
captureTarget = resolveCaptureTarget(studio, bay, activeVariant);
} catch (IOException exception) {
retainPersistentAutosaveFailure(
ticket,
new AutosavePersistentFailure(
"Jigsaw Studio cannot capture this workcell: " + failureMessage(exception),
exception));
return false;
}
JigsawStudioSession.SaveStart sessionSave = session.beginSave(bay.stableId());
if (sessionSave.status() != JigsawStudioSession.SaveStatus.STARTED) {
return false;
}
JigsawStudioSession.SaveIdentity saveIdentity = sessionSave.identity().orElseThrow();
if (service.saveLifecycle.tryBeginSave(ticket.key().requestId()) != SaveStart.STARTED) {
session.abortSave(saveIdentity);
return false;
}
List<ChunkCaptureArea> areas = chunkIntersections(captureTarget.bounds());
CaptureWork work = new CaptureWork(
studio,
studio.world(),
bay,
captureTarget,
null,
ticket.key().requestId(),
saveIdentity,
ticket.failureState());
CaptureCoordinator coordinator =
service.captureScheduler.coordinator(work, areas);
List<ChunkSnapshot> snapshots = new ArrayList<>(areas.size());
try {
for (ChunkCaptureArea area : areas) {
if (!studio.world().isChunkLoaded(area.chunkX(), area.chunkZ())) {
throw new IOException("Workcell chunk " + area.chunkX() + "," + area.chunkZ()
+ " is not loaded during the final autosave drain");
}
snapshots.add(captureChunkIntersection(
studio.world(),
captureTarget.bounds(),
captureTarget.piece(),
captureTarget.object(),
area,
captureTarget.displayRotationQuarterTurns(),
captureTarget.connectorsVisible()));
}
service.captureScheduler.assembleAndPersist(coordinator, snapshots);
} catch (Throwable exception) {
coordinator.fail("Jigsaw Studio final autosave drain failed: "
+ failureMessage(exception), exception);
}
return !session.isDirtyCurrent(ticket.identity());
}
void expediteAutosaves(UUID requestId) {
if (requestId == null) {
return;
}
for (AutosaveTicket current : List.copyOf(autosaves.values())) {
if (!requestId.equals(current.key().requestId())) {
continue;
}
expediteAutosave(current, 0);
}
}
private void expediteAutosave(AutosaveTicket current, int delayTicks) {
AutosaveTicket expedited = new AutosaveTicket(
current.key(),
current.studio(),
current.identity(),
new AtomicBoolean(false),
current.scheduleFailureLogged(),
current.failureState());
if (autosaves.replace(current.key(), current, expedited)) {
scheduleAutosave(expedited, delayTicks);
}
}
void clearAutosaves(UUID requestId) {
if (requestId != null) {
autosaves.keySet().removeIf(key -> key.requestId().equals(requestId));
}
}
record AutosaveKey(UUID requestId, String workcellId) {
AutosaveKey {
Objects.requireNonNull(requestId, "Jigsaw Studio autosave request ID");
Objects.requireNonNull(workcellId, "Jigsaw Studio autosave workcell ID");
}
}
static final class AutosaveFailureState {
private final AtomicInteger persistentFailures = new AtomicInteger();
private final AtomicBoolean failureLogged = new AtomicBoolean(false);
AutosaveFailureDecision recordPersistentFailure() {
int failureCount = persistentFailures.incrementAndGet();
return new AutosaveFailureDecision(
persistentAutosaveRetryTicks(failureCount),
failureLogged.compareAndSet(false, true));
}
}
record AutosaveFailureDecision(int retryTicks, boolean logFailure) {
}
record AutosavePersistentFailure(String detail, Throwable cause) {
AutosavePersistentFailure {
Objects.requireNonNull(detail, "Jigsaw Studio persistent autosave failure detail");
}
}
private record AutosaveTicket(
AutosaveKey key,
ActiveStudio studio,
JigsawStudioSession.DirtyIdentity identity,
AtomicBoolean scheduled,
AtomicBoolean scheduleFailureLogged,
AutosaveFailureState failureState
) {
private AutosaveTicket(
AutosaveKey key,
ActiveStudio studio,
JigsawStudioSession.DirtyIdentity identity,
AtomicBoolean scheduled,
AtomicBoolean scheduleFailureLogged
) {
this(
key,
studio,
identity,
scheduled,
scheduleFailureLogged,
new AutosaveFailureState());
}
private AutosaveTicket {
Objects.requireNonNull(key, "Jigsaw Studio autosave key");
Objects.requireNonNull(studio, "Jigsaw Studio autosave studio");
Objects.requireNonNull(identity, "Jigsaw Studio autosave dirty identity");
Objects.requireNonNull(scheduled, "Jigsaw Studio autosave schedule state");
Objects.requireNonNull(scheduleFailureLogged, "Jigsaw Studio autosave schedule warning state");
Objects.requireNonNull(failureState, "Jigsaw Studio autosave failure state");
}
}
}
@@ -0,0 +1,697 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarDirection;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarTopology;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisObjectRotation;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.JigsawJoint;
import art.arcane.iris.engine.object.TileData;
import art.arcane.iris.platform.bukkit.BukkitBlockState;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.data.B;
import art.arcane.volmlib.util.collection.KMap;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.Jigsaw;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.restoreCapturedMetadataForDisplay;
import static art.arcane.iris.core.service.JigsawStudioService.chunkKey;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
final class JigsawStudioCapture {
private static final Comparator<IrisJigsawConnector> NEW_CONNECTOR_SOURCE_POSITION_ORDER = Comparator
.comparingInt((IrisJigsawConnector connector) -> connector.getPosition().getX())
.thenComparingInt(connector -> connector.getPosition().getY())
.thenComparingInt(connector -> connector.getPosition().getZ());
private JigsawStudioCapture() {
}
static List<ChunkCaptureArea> chunkIntersections(JigsawStudioBounds bounds) {
JigsawStudioBounds captureBounds = Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds");
List<ChunkCaptureArea> areas = new ArrayList<>();
int minimumChunkX = captureBounds.originX() >> 4;
int maximumChunkX = captureBounds.maxX() >> 4;
int minimumChunkZ = captureBounds.originZ() >> 4;
int maximumChunkZ = captureBounds.maxZ() >> 4;
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
int chunkOriginX = chunkX << 4;
int minimumX = Math.max(0, chunkOriginX - captureBounds.originX());
int maximumX = Math.min(
captureBounds.dimensions().width(),
chunkOriginX + 16 - captureBounds.originX());
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
int chunkOriginZ = chunkZ << 4;
int minimumZ = Math.max(0, chunkOriginZ - captureBounds.originZ());
int maximumZ = Math.min(
captureBounds.dimensions().depth(),
chunkOriginZ + 16 - captureBounds.originZ());
areas.add(new ChunkCaptureArea(
chunkX,
chunkZ,
minimumX,
maximumX,
minimumZ,
maximumZ));
}
}
return List.copyOf(areas);
}
static ChunkSnapshot captureChunkIntersection(
World world,
JigsawStudioBounds bounds,
IrisJigsawPiece sourcePiece,
IrisObject sourceObject,
ChunkCaptureArea area
) throws IOException {
return captureChunkIntersection(world, bounds, sourcePiece, sourceObject, area, 0);
}
static ChunkSnapshot captureChunkIntersection(
World world,
JigsawStudioBounds bounds,
IrisJigsawPiece sourcePiece,
IrisObject sourceObject,
ChunkCaptureArea area,
int displayRotationQuarterTurns
) throws IOException {
return captureChunkIntersection(
world,
bounds,
sourcePiece,
sourceObject,
area,
displayRotationQuarterTurns,
true);
}
static ChunkSnapshot captureChunkIntersection(
World world,
JigsawStudioBounds bounds,
IrisJigsawPiece sourcePiece,
IrisObject sourceObject,
ChunkCaptureArea area,
int displayRotationQuarterTurns,
boolean connectorsVisible
) throws IOException {
World captureWorld = Objects.requireNonNull(world, "Jigsaw Studio capture world");
JigsawStudioBounds captureBounds = Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds");
IrisObject captureSource = Objects.requireNonNull(sourceObject, "Jigsaw Studio source object");
ChunkCaptureArea captureArea = Objects.requireNonNull(area, "Jigsaw Studio chunk capture area");
int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
int sourceWidth = (quarterTurns & 1) == 0
? captureBounds.dimensions().width()
: captureBounds.dimensions().depth();
int sourceDepth = (quarterTurns & 1) == 0
? captureBounds.dimensions().depth()
: captureBounds.dimensions().width();
List<CapturedBlock> blocks = new ArrayList<>();
List<CapturedConnector> connectors = new ArrayList<>();
Map<LocalPosition, IrisJigsawConnector> hiddenConnectors = connectorsVisible
? Map.of()
: displayedSourceConnectors(sourcePiece, captureBounds.dimensions(), quarterTurns);
for (int x = captureArea.minimumX(); x < captureArea.maximumX(); x++) {
for (int y = 0; y < bounds.dimensions().height(); y++) {
for (int z = captureArea.minimumZ(); z < captureArea.maximumZ(); z++) {
Block block = captureWorld.getBlockAt(
captureBounds.originX() + x,
captureBounds.originY() + y,
captureBounds.originZ() + z);
BlockData blockData = block.getBlockData();
IrisJigsawConnector hiddenConnector = hiddenConnectors.get(new LocalPosition(x, y, z));
if (hiddenConnector != null) {
hiddenConnector.setFinalState(blockData.getAsString());
connectors.add(CapturedConnector.from(hiddenConnector));
}
if (hiddenConnector == null && blockData instanceof Jigsaw jigsaw) {
KMap<String, Object> nbt = BukkitPlatform.serializeTile(block.getLocation());
if (nbt == null) {
throw new IOException("Cannot read jigsaw marker NBT at "
+ block.getX() + "," + block.getY() + "," + block.getZ()
+ ". The active NMS binding must support tile serialization.");
}
IrisJigsawConnector connector;
try {
connector = JigsawStudioMarkerParser.parse(nbt, jigsaw.getOrientation(), x, y, z);
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid jigsaw marker at "
+ block.getX() + "," + block.getY() + "," + block.getZ()
+ ": " + failureMessage(exception), exception);
}
restoreCapturedMetadataForDisplay(
connector,
sourcePiece,
captureBounds.dimensions(),
quarterTurns);
BlockData finalData;
try {
finalData = Bukkit.createBlockData(connector.getFinalState());
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid final_state '" + connector.getFinalState()
+ "' at " + block.getX() + "," + block.getY() + "," + block.getZ(), exception);
}
connector.setFinalState(finalData.getAsString());
connectors.add(CapturedConnector.from(connector));
if (finalData.getMaterial() != Material.STRUCTURE_VOID) {
blocks.add(new CapturedBlock(
x,
y,
z,
BukkitBlockState.of(finalData),
null));
}
continue;
}
if (isAir(blockData.getMaterial())) {
LocalPosition sourcePosition = inversePosition(
x,
y,
z,
sourceWidth,
sourceDepth,
quarterTurns);
PlatformBlockState retainedAir = retainedSourceAir(
captureSource,
sourcePosition.x(),
sourcePosition.y(),
sourcePosition.z(),
blockData);
if (retainedAir != null) {
blocks.add(new CapturedBlock(x, y, z, retainedAir, null));
}
continue;
}
if (blockData.getMaterial() == Material.STRUCTURE_VOID) {
continue;
}
blocks.add(new CapturedBlock(
x,
y,
z,
BukkitBlockState.of(blockData),
TileData.getTileState(block, false)));
}
}
}
return new ChunkSnapshot(captureArea, blocks, connectors);
}
private static Map<LocalPosition, IrisJigsawConnector> displayedSourceConnectors(
IrisJigsawPiece sourcePiece,
JigsawStudioCellDimensions displayDimensions,
int displayRotationQuarterTurns
) throws IOException {
IrisJigsawPiece source = Objects.requireNonNull(sourcePiece, "Jigsaw Studio source piece");
if (source.getConnectors() == null) {
throw new IOException("Jigsaw Studio source piece has no connector list");
}
int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
int sourceWidth = (quarterTurns & 1) == 0
? displayDimensions.width()
: displayDimensions.depth();
int sourceDepth = (quarterTurns & 1) == 0
? displayDimensions.depth()
: displayDimensions.width();
IrisObjectRotation rotation = IrisObjectRotation.of(0, -90.0D * quarterTurns, 0);
Map<LocalPosition, IrisJigsawConnector> displayed = new HashMap<>(source.getConnectors().size());
for (IrisJigsawConnector sourceConnector : source.getConnectors()) {
LocalPosition sourcePosition = connectorPosition(sourceConnector, "source piece");
LocalPosition position = forwardPosition(
sourcePosition.x(),
sourcePosition.y(),
sourcePosition.z(),
sourceWidth,
sourceDepth,
quarterTurns);
IrisJigsawConnector connector = CapturedConnector.from(sourceConnector).toConnector()
.setPosition(new IrisPosition(position.x(), position.y(), position.z()))
.setDirection(rotation.rotate(sourceConnector.getDirection()))
.setTop(rotation.rotate(sourceConnector.getTop()));
if (displayed.put(position, connector) != null) {
throw new IOException("Jigsaw Studio source piece has duplicate displayed connector position "
+ connectorLocation(position));
}
}
return Map.copyOf(displayed);
}
static PlatformBlockState retainedSourceAir(
IrisObject sourceObject,
int x,
int y,
int z,
BlockData current
) {
IrisObject source = Objects.requireNonNull(sourceObject, "Jigsaw Studio source object");
BlockData currentState = Objects.requireNonNull(current, "Jigsaw Studio current block state");
if (!isAir(currentState.getMaterial())
|| x < 0
|| y < 0
|| z < 0
|| x >= source.getW()
|| y >= source.getH()
|| z >= source.getD()) {
return null;
}
PlatformBlockState original = source.getBlocks().get(source.getSigned(x, y, z));
return original != null && original.isAir() ? original : null;
}
private static boolean isAir(Material material) {
return material == Material.AIR || material == Material.CAVE_AIR || material == Material.VOID_AIR;
}
static Capture aggregateSnapshots(
JigsawStudioBounds bounds,
List<ChunkCaptureArea> expectedAreas,
List<ChunkSnapshot> snapshots
) throws IOException {
return aggregateSnapshots(bounds, expectedAreas, snapshots, 0);
}
static Capture aggregateSnapshots(
JigsawStudioBounds bounds,
List<ChunkCaptureArea> expectedAreas,
List<ChunkSnapshot> snapshots,
int displayRotationQuarterTurns
) throws IOException {
JigsawStudioBounds captureBounds = Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds");
int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
List<ChunkCaptureArea> requiredAreas = List.copyOf(expectedAreas);
List<ChunkSnapshot> capturedSnapshots = List.copyOf(snapshots);
Map<Long, ChunkCaptureArea> requiredByChunk = new HashMap<>();
for (ChunkCaptureArea area : requiredAreas) {
if (requiredByChunk.put(chunkKey(area.chunkX(), area.chunkZ()), area) != null) {
throw new IOException("Duplicate required bay chunk " + area.chunkX() + "," + area.chunkZ());
}
}
if (capturedSnapshots.size() != requiredByChunk.size()) {
throw new IOException("Expected " + requiredByChunk.size() + " bay chunk snapshot(s), received "
+ capturedSnapshots.size());
}
Map<Long, ChunkSnapshot> capturedByChunk = new HashMap<>();
for (ChunkSnapshot snapshot : capturedSnapshots) {
ChunkCaptureArea area = snapshot.area();
long key = chunkKey(area.chunkX(), area.chunkZ());
ChunkCaptureArea required = requiredByChunk.get(key);
if (!area.equals(required)) {
throw new IOException("Unexpected bay chunk intersection for "
+ area.chunkX() + "," + area.chunkZ());
}
if (capturedByChunk.put(key, snapshot) != null) {
throw new IOException("Duplicate bay chunk snapshot " + area.chunkX() + "," + area.chunkZ());
}
}
if (!capturedByChunk.keySet().containsAll(requiredByChunk.keySet())) {
throw new IOException("One or more required bay chunk snapshots are missing");
}
List<ChunkSnapshot> ordered = new ArrayList<>(capturedSnapshots);
ordered.sort(Comparator
.comparingInt((ChunkSnapshot snapshot) -> snapshot.area().chunkX())
.thenComparingInt(snapshot -> snapshot.area().chunkZ()));
int sourceWidth = (quarterTurns & 1) == 0
? captureBounds.dimensions().width()
: captureBounds.dimensions().depth();
int sourceDepth = (quarterTurns & 1) == 0
? captureBounds.dimensions().depth()
: captureBounds.dimensions().width();
IrisObject object = new IrisObject(
sourceWidth,
captureBounds.dimensions().height(),
sourceDepth);
IrisObjectRotation inverseRotation = IrisObjectRotation.of(0, 90.0D * quarterTurns, 0);
List<IrisJigsawConnector> connectors = new ArrayList<>();
Set<LocalPosition> capturedBlocks = new HashSet<>();
Set<LocalPosition> capturedConnectors = new HashSet<>();
boolean hasBlockEntities = false;
for (ChunkSnapshot snapshot : ordered) {
ChunkCaptureArea area = snapshot.area();
for (CapturedBlock block : snapshot.blocks()) {
if (!area.contains(block.x(), block.z())
|| block.y() < 0
|| block.y() >= captureBounds.dimensions().height()) {
throw new IOException("Captured block falls outside bay chunk "
+ area.chunkX() + "," + area.chunkZ());
}
LocalPosition position = inversePosition(
block.x(), block.y(), block.z(), sourceWidth, sourceDepth, quarterTurns);
if (!capturedBlocks.add(position)) {
throw new IOException("Duplicate captured block at "
+ block.x() + "," + block.y() + "," + block.z());
}
PlatformBlockState sourceState = quarterTurns == 0
? block.state()
: inverseRotation.rotate(block.state(), 0, 0, 0);
if (sourceState == null) {
throw new IOException("Captured block state cannot be inverse-rotated at "
+ block.x() + "," + block.y() + "," + block.z());
}
object.setUnsigned(position.x(), position.y(), position.z(), sourceState);
TileData tileData = block.tileData();
if (tileData != null) {
object.setUnsignedTile(position.x(), position.y(), position.z(), tileData.clone());
hasBlockEntities = true;
}
}
for (CapturedConnector capturedConnector : snapshot.connectors()) {
if (!area.contains(capturedConnector.x(), capturedConnector.z())
|| capturedConnector.y() < 0
|| capturedConnector.y() >= captureBounds.dimensions().height()) {
throw new IOException("Captured connector falls outside bay chunk "
+ area.chunkX() + "," + area.chunkZ());
}
LocalPosition position = inversePosition(
capturedConnector.x(),
capturedConnector.y(),
capturedConnector.z(),
sourceWidth,
sourceDepth,
quarterTurns);
if (!capturedConnectors.add(position)) {
throw new IOException("Duplicate captured connector at "
+ capturedConnector.x() + "," + capturedConnector.y() + ","
+ capturedConnector.z());
}
IrisJigsawConnector connector = capturedConnector.toConnector();
if (quarterTurns != 0) {
connector.setPosition(new IrisPosition(position.x(), position.y(), position.z()));
connector.setDirection(inverseRotation.rotate(connector.getDirection()));
connector.setTop(inverseRotation.rotate(connector.getTop()));
PlatformBlockState finalState = B.getStateOrNull(connector.getFinalState(), false);
if (finalState == null) {
throw new IOException("Captured connector final state cannot be parsed at "
+ capturedConnector.x() + "," + capturedConnector.y() + ","
+ capturedConnector.z());
}
PlatformBlockState sourceFinalState = inverseRotation.rotate(finalState, 0, 0, 0);
if (sourceFinalState == null) {
throw new IOException("Captured connector final state cannot be inverse-rotated at "
+ capturedConnector.x() + "," + capturedConnector.y() + ","
+ capturedConnector.z());
}
connector.setFinalState(sourceFinalState.key());
}
connectors.add(connector);
}
}
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
object.write(output);
return new Capture(output.toByteArray(), connectors, hasBlockEntities);
}
}
static List<IrisJigsawConnector> preserveCapturedConnectorOrder(
IrisJigsawPiece sourcePiece,
List<IrisJigsawConnector> capturedConnectors
) throws IOException {
IrisJigsawPiece source = Objects.requireNonNull(sourcePiece, "Jigsaw Studio source piece");
List<IrisJigsawConnector> sourceConnectors = source.getConnectors();
if (sourceConnectors == null) {
throw new IOException("Jigsaw Studio source piece has no connector list");
}
List<IrisJigsawConnector> captured = new ArrayList<>(Objects.requireNonNull(
capturedConnectors,
"Jigsaw Studio captured connectors"));
Map<LocalPosition, IrisJigsawConnector> capturedByPosition = new HashMap<>(captured.size());
for (IrisJigsawConnector connector : captured) {
LocalPosition position = connectorPosition(connector, "captured");
if (capturedByPosition.put(position, connector) != null) {
throw new IOException("Jigsaw Studio capture has duplicate connector position "
+ connectorLocation(position));
}
}
List<IrisJigsawConnector> ordered = new ArrayList<>(captured.size());
Set<LocalPosition> sourcePositions = new HashSet<>(sourceConnectors.size());
for (IrisJigsawConnector connector : sourceConnectors) {
LocalPosition position = connectorPosition(connector, "source piece");
if (!sourcePositions.add(position)) {
throw new IOException("Jigsaw Studio source piece has duplicate connector position "
+ connectorLocation(position));
}
IrisJigsawConnector matched = capturedByPosition.remove(position);
if (matched != null) {
ordered.add(matched);
}
}
List<IrisJigsawConnector> appended = new ArrayList<>(capturedByPosition.values());
appended.sort(NEW_CONNECTOR_SOURCE_POSITION_ORDER);
ordered.addAll(appended);
return List.copyOf(ordered);
}
private static LocalPosition connectorPosition(IrisJigsawConnector connector, String owner) throws IOException {
if (connector == null || connector.getPosition() == null) {
throw new IOException("Jigsaw Studio " + owner + " contains a connector without a position");
}
IrisPosition position = connector.getPosition();
return new LocalPosition(position.getX(), position.getY(), position.getZ());
}
private static String connectorLocation(LocalPosition position) {
return position.x() + "," + position.y() + "," + position.z();
}
static LocalPosition inversePosition(
int x,
int y,
int z,
int sourceWidth,
int sourceDepth,
int displayRotationQuarterTurns
) {
return switch (Math.floorMod(displayRotationQuarterTurns, 4)) {
case 0 -> new LocalPosition(x, y, z);
case 1 -> new LocalPosition(z, y, sourceDepth - 1 - x);
case 2 -> new LocalPosition(sourceWidth - 1 - x, y, sourceDepth - 1 - z);
case 3 -> new LocalPosition(sourceWidth - 1 - z, y, x);
default -> throw new IllegalStateException("Unreachable Jigsaw Studio inverse rotation");
};
}
private static LocalPosition forwardPosition(
int x,
int y,
int z,
int sourceWidth,
int sourceDepth,
int displayRotationQuarterTurns
) {
return switch (Math.floorMod(displayRotationQuarterTurns, 4)) {
case 0 -> new LocalPosition(x, y, z);
case 1 -> new LocalPosition(sourceDepth - 1 - z, y, x);
case 2 -> new LocalPosition(sourceWidth - 1 - x, y, sourceDepth - 1 - z);
case 3 -> new LocalPosition(z, y, sourceWidth - 1 - x);
default -> throw new IllegalStateException("Unreachable Jigsaw Studio display rotation");
};
}
static void requireWorkcellTopology(
JigsawStudioBay workcell,
List<IrisJigsawConnector> connectors,
int displayRotationQuarterTurns
) throws IOException {
JigsawPlanarArchetype expected = workcell.archetype().orElse(null);
if (expected == null) {
return;
}
int mask = 0;
for (IrisJigsawConnector connector : connectors) {
mask |= switch (connector.getDirection()) {
case NORTH_NEGATIVE_Z -> JigsawPlanarDirection.NORTH.bit();
case EAST_POSITIVE_X -> JigsawPlanarDirection.EAST.bit();
case SOUTH_POSITIVE_Z -> JigsawPlanarDirection.SOUTH.bit();
case WEST_NEGATIVE_X -> JigsawPlanarDirection.WEST.bit();
case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> throw new WorkcellTopologyException(
"Planar workcell '" + workcell.stableId()
+ "' cannot save a vertical connector. Remove it or use Reset Connector Blocks.");
};
}
JigsawPlanarTopology sourceTopology = JigsawPlanarTopology.fromMask(mask);
JigsawPlanarTopology displayedTopology = sourceTopology.rotateClockwise(displayRotationQuarterTurns);
if (displayedTopology != expected.canonicalTopology()) {
throw new WorkcellTopologyException("Workcell '" + workcell.stableId() + "' requires "
+ topologyDescription(expected.canonicalTopology())
+ ", but the edited markers form "
+ topologyDescription(displayedTopology)
+ ". Use Reset Connector Blocks to restore the saved topology, or edit the markers to match the floor glyph.");
}
}
private static String topologyDescription(JigsawPlanarTopology topology) {
int connectorCount = topology.directions().size();
return topology.name().toLowerCase(Locale.ROOT).replace('_', ' ')
+ " (" + connectorCount + " horizontal connector"
+ (connectorCount == 1 ? "" : "s") + ")";
}
static void storeConnectorFinalState(
IrisObject object,
int x,
int y,
int z,
BlockData finalData
) {
Objects.requireNonNull(object, "Jigsaw Studio captured object");
BlockData activeFinalData = Objects.requireNonNull(finalData, "Jigsaw connector final state");
if (activeFinalData.getMaterial() != Material.STRUCTURE_VOID) {
object.setUnsigned(x, y, z, BukkitBlockState.of(activeFinalData));
}
}
record ChunkCaptureArea(
int chunkX,
int chunkZ,
int minimumX,
int maximumX,
int minimumZ,
int maximumZ
) {
ChunkCaptureArea {
if (minimumX < 0 || minimumZ < 0 || maximumX <= minimumX || maximumZ <= minimumZ) {
throw new IllegalArgumentException("Jigsaw Studio chunk capture intersection is invalid");
}
}
boolean contains(int x, int z) {
return x >= minimumX && x < maximumX && z >= minimumZ && z < maximumZ;
}
}
record CapturedBlock(
int x,
int y,
int z,
PlatformBlockState state,
TileData tileData
) {
CapturedBlock {
Objects.requireNonNull(state, "Jigsaw Studio captured block state");
tileData = tileData == null ? null : tileData.clone();
}
public TileData tileData() {
return tileData == null ? null : tileData.clone();
}
}
record CapturedConnector(
int x,
int y,
int z,
IrisDirection direction,
IrisDirection top,
String pool,
String name,
String targetName,
String channel,
JigsawJoint joint,
String finalState,
int selectionPriority,
int placementPriority
) {
CapturedConnector {
Objects.requireNonNull(direction, "Jigsaw Studio captured connector direction");
Objects.requireNonNull(top, "Jigsaw Studio captured connector top");
Objects.requireNonNull(pool, "Jigsaw Studio captured connector pool");
Objects.requireNonNull(name, "Jigsaw Studio captured connector name");
Objects.requireNonNull(targetName, "Jigsaw Studio captured connector target");
channel = channel == null ? "" : channel;
Objects.requireNonNull(joint, "Jigsaw Studio captured connector joint");
Objects.requireNonNull(finalState, "Jigsaw Studio captured connector final state");
}
static CapturedConnector from(IrisJigsawConnector connector) {
IrisJigsawConnector source = Objects.requireNonNull(connector, "Jigsaw Studio captured connector");
IrisPosition position = source.getPosition();
return new CapturedConnector(
position.getX(),
position.getY(),
position.getZ(),
source.getDirection(),
source.getTop(),
source.getPool(),
source.getName(),
source.getTargetName(),
source.getChannel(),
source.getJoint(),
source.getFinalState(),
source.getSelectionPriority(),
source.getPlacementPriority());
}
IrisJigsawConnector toConnector() {
return new IrisJigsawConnector()
.setPosition(new IrisPosition(x, y, z))
.setDirection(direction)
.setTop(top)
.setPool(pool)
.setName(name)
.setTargetName(targetName)
.setChannel(channel)
.setJoint(joint)
.setFinalState(finalState)
.setSelectionPriority(selectionPriority)
.setPlacementPriority(placementPriority);
}
}
record ChunkSnapshot(
ChunkCaptureArea area,
List<CapturedBlock> blocks,
List<CapturedConnector> connectors
) {
ChunkSnapshot {
Objects.requireNonNull(area, "Jigsaw Studio captured chunk area");
blocks = List.copyOf(blocks);
connectors = List.copyOf(connectors);
}
}
record LocalPosition(int x, int y, int z) {
}
record Capture(byte[] objectContent, List<IrisJigsawConnector> connectors, boolean hasBlockEntities) {
Capture {
objectContent = Objects.requireNonNull(objectContent, "Jigsaw Studio object content").clone();
connectors = List.copyOf(connectors);
}
public byte[] objectContent() {
return objectContent.clone();
}
}
static final class WorkcellTopologyException extends IOException {
WorkcellTopologyException(String message) {
super(message);
}
}
}
@@ -0,0 +1,493 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioAutosaveScheduler.AutosaveFailureState;
import art.arcane.iris.core.service.JigsawStudioAutosaveScheduler.AutosavePersistentFailure;
import art.arcane.iris.core.service.JigsawStudioCapture.Capture;
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkCaptureArea;
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkSnapshot;
import art.arcane.iris.core.service.JigsawStudioCapture.WorkcellTopologyException;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.core.structure.authoring.StructureTransactionWriter;
import art.arcane.iris.core.structure.authoring.StructureWriteOptions;
import art.arcane.iris.core.structure.authoring.StructureWriteResult;
import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.World;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
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.concurrent.atomic.AtomicInteger;
import static art.arcane.iris.core.service.JigsawStudioCapture.aggregateSnapshots;
import static art.arcane.iris.core.service.JigsawStudioCapture.captureChunkIntersection;
import static art.arcane.iris.core.service.JigsawStudioCapture.chunkIntersections;
import static art.arcane.iris.core.service.JigsawStudioCapture.preserveCapturedConnectorOrder;
import static art.arcane.iris.core.service.JigsawStudioCapture.requireWorkcellTopology;
import static art.arcane.iris.core.service.JigsawStudioService.chunkKey;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
import static art.arcane.iris.core.service.JigsawStudioService.message;
import static art.arcane.iris.core.service.JigsawStudioService.playSaveSound;
import static art.arcane.iris.core.service.JigsawStudioService.writeFailure;
final class JigsawStudioCaptureScheduler {
private final JigsawStudioService service;
JigsawStudioCaptureScheduler(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean scheduleCapture(
ActiveStudio studio,
World world,
JigsawStudioBay bay,
CaptureTarget captureTarget,
Player player,
UUID requestId,
JigsawStudioSession.SaveIdentity saveIdentity,
AutosaveFailureState autosaveFailureState
) {
List<ChunkCaptureArea> areas = chunkIntersections(captureTarget.bounds());
CaptureWork work = new CaptureWork(
studio,
world,
bay,
captureTarget,
player,
requestId,
saveIdentity,
autosaveFailureState);
CaptureCoordinator coordinator = new CaptureCoordinator(
work,
areas);
for (ChunkCaptureArea area : areas) {
if (coordinator.stopped()) {
return false;
}
boolean scheduled = J.runRegion(
world,
area.chunkX(),
area.chunkZ(),
() -> captureChunk(coordinator, area));
if (!scheduled) {
coordinator.fail("Iris could not schedule bay chunk "
+ area.chunkX() + "," + area.chunkZ() + " on its owning region.", null);
return false;
}
}
return !coordinator.stopped();
}
private void captureChunk(CaptureCoordinator coordinator, ChunkCaptureArea area) {
if (coordinator.stopped()) {
return;
}
ActiveStudio studio = coordinator.studio();
World world = coordinator.world();
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
coordinator.fail("Jigsaw Studio changed before every bay chunk could be captured; save cancelled.", null);
return;
}
if (!world.isChunkLoaded(area.chunkX(), area.chunkZ())) {
coordinator.fail("Bay chunk " + area.chunkX() + "," + area.chunkZ()
+ " is not loaded. Visit the whole bay and try again.", null);
return;
}
try {
ChunkSnapshot snapshot = captureChunkIntersection(
world,
coordinator.captureTarget().bounds(),
coordinator.captureTarget().piece(),
coordinator.captureTarget().object(),
area,
coordinator.captureTarget().displayRotationQuarterTurns(),
coordinator.captureTarget().connectorsVisible());
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
coordinator.fail("Jigsaw Studio changed while bay chunks were being captured; save cancelled.", null);
return;
}
coordinator.accept(snapshot);
} catch (Throwable exception) {
coordinator.fail("Jigsaw Studio capture failed in chunk "
+ area.chunkX() + "," + area.chunkZ() + ": " + failureMessage(exception), exception);
}
}
void assembleAndPersist(CaptureCoordinator coordinator, List<ChunkSnapshot> snapshots) {
if (coordinator.failed()) {
return;
}
ActiveStudio studio = coordinator.studio();
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
coordinator.fail("Jigsaw Studio changed before captured chunks could be assembled; save cancelled.", null);
return;
}
try {
Capture capture = aggregateSnapshots(
coordinator.captureTarget().bounds(),
coordinator.areas(),
snapshots,
coordinator.captureTarget().displayRotationQuarterTurns());
List<IrisJigsawConnector> connectors = preserveCapturedConnectorOrder(
coordinator.captureTarget().piece(),
capture.connectors());
requireWorkcellTopology(
coordinator.bay(),
connectors,
coordinator.captureTarget().displayRotationQuarterTurns());
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
coordinator.fail("Jigsaw Studio changed before the captured bay could be written; save cancelled.", null);
return;
}
message(coordinator.player(), "Captured " + connectors.size()
+ " connector(s) from " + snapshots.size()
+ " chunk(s); validating and writing the owned structure graph...");
persistCapture(coordinator, capture, connectors);
} catch (WorkcellTopologyException exception) {
coordinator.failPersistent(
"Jigsaw Studio cannot autosave this planar workcell: "
+ failureMessage(exception),
null);
} catch (Throwable exception) {
coordinator.failPersistent(
"Jigsaw Studio capture assembly failed: " + failureMessage(exception),
exception);
}
}
private void persistCapture(
CaptureCoordinator coordinator,
Capture capture,
List<IrisJigsawConnector> connectors
) {
ActiveStudio studio = coordinator.studio();
JigsawStudioBay bay = coordinator.bay();
Player player = coordinator.player();
try {
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
message(player, "Jigsaw Studio changed before the captured bay could be written; save cancelled.");
return;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
IrisData source = request.source();
Path packRoot = source.getDataFolder().toPath();
JigsawStudioResourceBundleAssembler.Assembly assembly =
JigsawStudioResourceBundleAssembler.assemble(
packRoot,
request.structureKey(),
coordinator.saveIdentity().variantKey(),
capture.objectContent(),
connectors,
capture.hasBlockEntities());
StructureResourceBundleGraphCompiler.requireViable(assembly.bundle());
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
message(player, "Jigsaw Studio changed during validation; save cancelled before writing.");
return;
}
JigsawStudioHistoryStore historyStore = new JigsawStudioHistoryStore(
packRoot,
request.structureKey());
JigsawStudioHistoryStore.Snapshot previous = historyStore.snapshotCurrent(
coordinator.saveIdentity().variantKey());
if (!previous.matches(assembly.bundle())) {
historyStore.append(previous);
}
StructureWriteResult result;
synchronized (service.saveLifecycleLock) {
if (!service.saveLifecycle.isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) {
message(player, "Jigsaw Studio changed before the validated save entered the writer; save cancelled.");
return;
}
result = new StructureTransactionWriter(packRoot)
.write(assembly.bundle(), StructureWriteOptions.overwriteExpected(
assembly.expectedManifestHash()));
}
if (!result.successful()) {
String failure = writeFailure(result);
coordinator.recordPersistentFailure(failure, result.failure().orElse(null));
message(player, failure);
return;
}
source.invalidateStructureResources();
studio.generator().invalidateRender(bay.stableId(), coordinator.saveIdentity().variantKey());
JigsawStudioSession session = studio.generator().getSession();
boolean unchanged = session.markWorkcellSaved(coordinator.saveIdentity());
try {
service.reloadSessionLayout(studio);
} catch (IOException | RuntimeException refreshFailure) {
IrisLogging.reportError(refreshFailure);
message(player, "The graph saved, but Studio could not refresh its variant catalog. "
+ "Close and reopen this project before continuing: "
+ failureMessage(refreshFailure));
}
String cleanup = result.status() == StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED
? " The graph committed, but transaction cleanup requires operator attention in the console."
: "";
String mutationNotice = unchanged ? "" : " Newer edits remain unsaved.";
message(player, "Saved piece '" + coordinator.saveIdentity().variantKey() + "' and object '"
+ assembly.objectKey() + "' atomically." + mutationNotice + cleanup);
playSaveSound(player);
if (unchanged) {
service.evaluator.scheduleEvaluation(studio);
}
IrisLogging.debug("Jigsaw Studio saved: structure=%s piece=%s object=%s connectors=%d status=%s",
request.structureKey(), coordinator.saveIdentity().variantKey(), assembly.objectKey(),
connectors.size(), result.status());
} catch (Throwable exception) {
String failure = "Jigsaw Studio save failed: " + failureMessage(exception);
coordinator.recordPersistentFailure(failure, exception);
message(player, failure);
} finally {
coordinator.complete();
}
}
static Set<Long> requiredChunks(
JigsawStudioBay bay,
JigsawStudioGenerator.RenderedBay rendered
) {
Set<Long> chunks = new HashSet<>();
if (!rendered.valid()) {
return chunks;
}
JigsawStudioBounds bounds = bay.bounds();
int minimumChunkX = bounds.originX() >> 4;
int maximumChunkX = bounds.maxX() >> 4;
int minimumChunkZ = bounds.originZ() >> 4;
int maximumChunkZ = bounds.maxZ() >> 4;
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
chunks.add(chunkKey(chunkX, chunkZ));
}
}
return Set.copyOf(chunks);
}
static CaptureTarget resolveCaptureTarget(
ActiveStudio studio,
JigsawStudioBay bay,
JigsawStudioVariant variant
)
throws IOException {
IrisData source = studio.generator().getRequest().source();
JigsawStudioLayout currentLayout = studio.generator().getSession().layout();
JigsawStudioBay currentBay = currentLayout.get(bay.stableId());
if (currentBay == null) {
throw new IOException("workcell '" + bay.stableId() + "' is absent from the current Studio layout");
}
JigsawStudioVariant activeVariant = Objects.requireNonNull(variant, "Jigsaw Studio capture variant");
if (!currentLayout.accepts(currentBay, activeVariant)) {
throw new IOException("variant '" + activeVariant.pieceKey()
+ "' does not belong to workcell '" + currentBay.stableId() + "'");
}
IrisJigsawPiece piece = source.getJigsawPieceLoader().load(activeVariant.pieceKey(), false);
if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) {
throw new IOException("piece '" + activeVariant.pieceKey() + "' is missing or has no object");
}
IrisObject object = source.getObjectLoader().load(piece.getObject(), false);
if (object == null || object.getW() < 1 || object.getH() < 1 || object.getD() < 1) {
throw new IOException("object '" + piece.getObject() + "' is missing or has invalid dimensions");
}
JigsawStudioBounds bayBounds = currentBay.bounds();
JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions(
object.getW(), object.getH(), object.getD());
JigsawStudioCellDimensions canonicalDimensions = activeVariant.canonicalDimensions(sourceDimensions);
if (canonicalDimensions.width() > bayBounds.dimensions().width()
|| canonicalDimensions.height() > bayBounds.dimensions().height()
|| canonicalDimensions.depth() > bayBounds.dimensions().depth()) {
throw new IOException("object '" + piece.getObject() + "' is "
+ canonicalDimensions.width() + "x" + canonicalDimensions.height() + "x"
+ canonicalDimensions.depth() + " in its displayed orientation"
+ " but bay '" + currentBay.stableId() + "' is only "
+ bayBounds.dimensions().width() + "x" + bayBounds.dimensions().height() + "x"
+ bayBounds.dimensions().depth());
}
return new CaptureTarget(
new JigsawStudioBounds(
bayBounds.originX(),
bayBounds.originY(),
bayBounds.originZ(),
canonicalDimensions),
piece,
object,
activeVariant.sourceToCanonicalQuarterTurns(),
studio.generator().getSession().workcellSnapshot(currentBay.stableId()).connectorsVisible());
}
CaptureCoordinator coordinator(CaptureWork work, List<ChunkCaptureArea> areas) {
return new CaptureCoordinator(work, areas);
}
final class CaptureCoordinator {
private final CaptureWork work;
private final List<ChunkCaptureArea> areas;
private final Map<Long, ChunkSnapshot> snapshots = new ConcurrentHashMap<>();
private final AtomicInteger remaining;
private final AtomicBoolean failed = new AtomicBoolean(false);
private final AtomicBoolean assemblyStarted = new AtomicBoolean(false);
private final AtomicBoolean operationFinished = new AtomicBoolean(false);
private volatile AutosavePersistentFailure persistentFailure;
CaptureCoordinator(CaptureWork work, List<ChunkCaptureArea> areas) {
this.work = Objects.requireNonNull(work, "Jigsaw Studio capture work");
this.areas = List.copyOf(areas);
this.remaining = new AtomicInteger(this.areas.size());
}
private ActiveStudio studio() {
return work.studio();
}
private World world() {
return work.world();
}
private JigsawStudioBay bay() {
return work.bay();
}
private CaptureTarget captureTarget() {
return work.captureTarget();
}
private Player player() {
return work.player();
}
private UUID requestId() {
return work.requestId();
}
private JigsawStudioSession.SaveIdentity saveIdentity() {
return work.saveIdentity();
}
private List<ChunkCaptureArea> areas() {
return areas;
}
private boolean stopped() {
return failed.get();
}
private boolean failed() {
return failed.get();
}
private void accept(ChunkSnapshot snapshot) {
if (failed.get() || assemblyStarted.get()) {
return;
}
ChunkCaptureArea area = snapshot.area();
long key = chunkKey(area.chunkX(), area.chunkZ());
if (snapshots.putIfAbsent(key, snapshot) != null) {
fail("Jigsaw Studio received duplicate snapshot for bay chunk "
+ area.chunkX() + "," + area.chunkZ() + ".", null);
return;
}
int incomplete = remaining.decrementAndGet();
if (incomplete < 0) {
fail("Jigsaw Studio received more bay chunk snapshots than expected.", null);
return;
}
if (incomplete == 0 && assemblyStarted.compareAndSet(false, true)) {
List<ChunkSnapshot> completed = List.copyOf(snapshots.values());
J.a(() -> assembleAndPersist(this, completed));
}
}
void fail(String failure, Throwable exception) {
if (!failed.compareAndSet(false, true)) {
return;
}
if (exception != null) {
IrisLogging.reportError(exception);
}
complete();
message(player(), failure);
}
private void failPersistent(String failure, Throwable exception) {
if (!failed.compareAndSet(false, true)) {
return;
}
recordPersistentFailure(failure, exception);
complete();
message(player(), failure);
}
private void recordPersistentFailure(String failure, Throwable exception) {
persistentFailure = new AutosavePersistentFailure(failure, exception);
}
private void complete() {
if (operationFinished.compareAndSet(false, true)) {
studio().generator().getSession().abortSave(saveIdentity());
service.saveLifecycle.finishSave(requestId());
service.autosaveScheduler.completeAutosaveAttempt(
studio(),
saveIdentity(),
work.autosaveFailureState(),
persistentFailure);
service.playerContext.refreshWorkcellContext(studio().worldId(), saveIdentity().workcellId());
}
}
}
record CaptureWork(
ActiveStudio studio,
World world,
JigsawStudioBay bay,
CaptureTarget captureTarget,
Player player,
UUID requestId,
JigsawStudioSession.SaveIdentity saveIdentity,
AutosaveFailureState autosaveFailureState
) {
CaptureWork {
Objects.requireNonNull(studio, "Jigsaw Studio capture studio");
Objects.requireNonNull(world, "Jigsaw Studio capture world");
Objects.requireNonNull(bay, "Jigsaw Studio capture bay");
Objects.requireNonNull(captureTarget, "Jigsaw Studio capture target");
Objects.requireNonNull(requestId, "Jigsaw Studio capture request ID");
Objects.requireNonNull(saveIdentity, "Jigsaw Studio capture save identity");
Objects.requireNonNull(autosaveFailureState, "Jigsaw Studio capture autosave failure state");
}
}
record CaptureTarget(
JigsawStudioBounds bounds,
IrisJigsawPiece piece,
IrisObject object,
int displayRotationQuarterTurns,
boolean connectorsVisible
) {
CaptureTarget {
Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds");
Objects.requireNonNull(piece, "Jigsaw Studio capture piece");
Objects.requireNonNull(object, "Jigsaw Studio capture object");
displayRotationQuarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
}
}
}
@@ -0,0 +1,624 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMarkerKeyCodec;
import art.arcane.iris.core.service.JigsawStudioCapture.CapturedConnector;
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkCaptureArea;
import art.arcane.iris.core.service.JigsawStudioCapture.LocalPosition;
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.BayReadiness;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisObjectRotation;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.TileData;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.volmlib.util.collection.KMap;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.type.Jigsaw;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import static art.arcane.iris.core.service.JigsawStudioCapture.inversePosition;
import static art.arcane.iris.core.service.JigsawStudioService.chunkKey;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
final class JigsawStudioChunkWriter {
private JigsawStudioChunkWriter() {
}
static void restoreConnectorChunk(
World world,
JigsawStudioBay workcell,
List<JigsawStudioGenerator.RenderedConnector> connectors,
Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> renderedBlocks,
boolean connectorsVisible
) throws IOException {
JigsawStudioBounds bounds = workcell.bounds();
for (JigsawStudioGenerator.RenderedConnector connector : connectors) {
LocalPosition position = new LocalPosition(connector.x(), connector.y(), connector.z());
Block target = world.getBlockAt(
bounds.originX() + connector.x(),
bounds.originY() + connector.y(),
bounds.originZ() + connector.z());
if (connectorsVisible) {
BlockData marker;
try {
marker = Bukkit.createBlockData(
"minecraft:jigsaw[orientation=" + connector.orientation() + "]");
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid saved connector orientation '"
+ connector.orientation() + "'", exception);
}
target.setBlockData(marker, false);
BukkitPlatform.deserializeTile(markerNbt(connector.connector()), target.getLocation());
continue;
}
JigsawStudioGenerator.RenderedBlock renderedBlock = renderedBlocks.get(position);
BlockData restored;
if (renderedBlock != null) {
if (renderedBlock.state().isCustom()
|| !(renderedBlock.state().nativeHandle() instanceof BlockData blockData)) {
throw new IOException("Saved connector block '" + renderedBlock.state().key()
+ "' cannot be restored directly in Bukkit Studio");
}
restored = blockData;
} else {
try {
restored = Bukkit.createBlockData(connector.connector().getFinalState());
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid saved connector final state '"
+ connector.connector().getFinalState() + "'", exception);
}
}
target.setBlockData(restored, false);
if (renderedBlock != null && renderedBlock.tileData() != null) {
TileData tileData = renderedBlock.tileData();
if (!tileData.isApplicable(target.getBlockData()) || !tileData.toBukkitTry(target)) {
throw new IOException("Saved connector tile data could not be restored at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
}
}
}
static String validateMaterialization(JigsawStudioGenerator.RenderedBay rendered) {
if (!rendered.valid()) {
return rendered.failure();
}
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
PlatformBlockState state = block.state();
if (state.isCustom()) {
return "custom block '" + state.key()
+ "' requires provider-owned placement and cannot be swapped live in Studio";
}
if (!(state.nativeHandle() instanceof BlockData)) {
return "block '" + state.key() + "' has no Bukkit block-data representation";
}
}
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
try {
BlockData data = Bukkit.createBlockData(
"minecraft:jigsaw[orientation=" + connector.orientation() + "]");
if (!(data instanceof Jigsaw)) {
return "connector orientation '" + connector.orientation()
+ "' did not resolve to a jigsaw marker";
}
} catch (IllegalArgumentException exception) {
return "connector orientation '" + connector.orientation() + "' is invalid";
}
}
return "";
}
static void writeMaterializedChunk(
World world,
JigsawStudioBay workcell,
JigsawStudioGenerator.RenderedBay rendered,
ChunkCaptureArea area,
boolean connectorsVisible
) throws IOException {
JigsawStudioBounds bounds = workcell.bounds();
Map<LocalPosition, BlockData> expected = materializedBlockData(rendered, connectorsVisible);
BlockData air = Material.AIR.createBlockData();
for (int x = area.minimumX(); x < area.maximumX(); x++) {
for (int y = 0; y < bounds.dimensions().height(); y++) {
for (int z = area.minimumZ(); z < area.maximumZ(); z++) {
Block target = world.getBlockAt(
bounds.originX() + x,
bounds.originY() + y,
bounds.originZ() + z);
BlockData blockData = expected.getOrDefault(new LocalPosition(x, y, z), air);
target.setBlockData(blockData, false);
}
}
}
applyRenderedBayChunk(world, workcell, rendered, area.chunkX(), area.chunkZ(), connectorsVisible);
verifyMaterializedChunk(world, workcell, rendered, area, expected, connectorsVisible);
}
private static Map<LocalPosition, BlockData> materializedBlockData(
JigsawStudioGenerator.RenderedBay rendered,
boolean connectorsVisible
) throws IOException {
Map<LocalPosition, BlockData> expected = new HashMap<>();
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
if (block.state().isCustom() || !(block.state().nativeHandle() instanceof BlockData blockData)) {
throw new IOException("Rendered block '" + block.state().key()
+ "' cannot be placed directly in Bukkit Studio");
}
expected.put(new LocalPosition(block.x(), block.y(), block.z()), blockData);
}
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
if (!connectorsVisible && expected.containsKey(
new LocalPosition(connector.x(), connector.y(), connector.z()))) {
continue;
}
BlockData marker;
try {
marker = Bukkit.createBlockData(connectorsVisible
? "minecraft:jigsaw[orientation=" + connector.orientation() + "]"
: connector.connector().getFinalState());
} catch (IllegalArgumentException exception) {
throw new IOException("Invalid rendered connector orientation '"
+ connector.orientation() + "'", exception);
}
expected.put(new LocalPosition(connector.x(), connector.y(), connector.z()), marker);
}
return expected;
}
private static void verifyMaterializedChunk(
World world,
JigsawStudioBay workcell,
JigsawStudioGenerator.RenderedBay rendered,
ChunkCaptureArea area,
Map<LocalPosition, BlockData> expected,
boolean connectorsVisible
) throws IOException {
JigsawStudioBounds bounds = workcell.bounds();
BlockData air = Material.AIR.createBlockData();
for (int x = area.minimumX(); x < area.maximumX(); x++) {
for (int y = 0; y < bounds.dimensions().height(); y++) {
for (int z = area.minimumZ(); z < area.maximumZ(); z++) {
Block target = world.getBlockAt(
bounds.originX() + x,
bounds.originY() + y,
bounds.originZ() + z);
BlockData expectedData = expected.getOrDefault(new LocalPosition(x, y, z), air);
if (!target.getBlockData().equals(expectedData)) {
throw new IOException("Workcell block did not materialize at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
}
}
}
verifyRenderedBayChunk(world, workcell, rendered, area.chunkX(), area.chunkZ(), connectorsVisible);
}
static boolean hydrateChunk(
ActiveStudio studio,
World world,
int chunkX,
int chunkZ
) {
long key = chunkKey(chunkX, chunkZ);
boolean verificationRequired = false;
for (JigsawStudioBay bay : studio.generator().getLayout().bays()) {
BayPopulation population = studio.population(bay);
if (!population.needsApplication(key) && !population.needsVerification(key)) {
continue;
}
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(bay);
if (!rendered.valid()) {
population.fail(rendered.failure());
continue;
}
try {
if (population.needsApplication(key)) {
applyRenderedBayChunk(
world,
bay,
rendered,
chunkX,
chunkZ,
studio.generator().getSession().workcellSnapshot(bay.stableId()).connectorsVisible());
population.markApplied(key);
verificationRequired = true;
} else {
verifyRenderedBayChunk(
world,
bay,
rendered,
chunkX,
chunkZ,
studio.generator().getSession().workcellSnapshot(bay.stableId()).connectorsVisible());
population.markHydrated(key);
}
} catch (Throwable exception) {
if (population.fail("chunk " + chunkX + "," + chunkZ
+ " could not hydrate: " + failureMessage(exception))) {
IrisLogging.reportError(exception);
}
}
}
return verificationRequired;
}
static void applyRenderedBayChunk(
World world,
JigsawStudioBay bay,
JigsawStudioGenerator.RenderedBay rendered,
int chunkX,
int chunkZ,
boolean connectorsVisible
) throws IOException {
JigsawStudioBounds bounds = bay.bounds();
Set<LocalPosition> connectorPositions = new HashSet<>(rendered.connectors().size());
if (connectorsVisible) {
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
connectorPositions.add(new LocalPosition(connector.x(), connector.y(), connector.z()));
}
}
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
TileData tileData = block.tileData();
if (tileData == null
|| connectorPositions.contains(new LocalPosition(block.x(), block.y(), block.z()))) {
continue;
}
int worldX = bounds.originX() + block.x();
int worldZ = bounds.originZ() + block.z();
if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) {
continue;
}
Block target = world.getBlockAt(worldX, bounds.originY() + block.y(), worldZ);
if (!tileData.isApplicable(target.getBlockData())) {
throw new IOException("source tile state does not match rendered block at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
if (!tileData.toBukkitTry(target)) {
throw new IOException("source tile state could not hydrate at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
}
if (!connectorsVisible) {
return;
}
for (JigsawStudioGenerator.RenderedConnector renderedConnector : rendered.connectors()) {
int worldX = bounds.originX() + renderedConnector.x();
int worldZ = bounds.originZ() + renderedConnector.z();
if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) {
continue;
}
Block target = world.getBlockAt(
worldX,
bounds.originY() + renderedConnector.y(),
worldZ);
BlockData blockData = target.getBlockData();
if (!(blockData instanceof Jigsaw jigsaw)) {
throw new IOException("expected a jigsaw marker at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
String actualOrientation = jigsaw.getOrientation().name().toLowerCase(Locale.ROOT);
if (!actualOrientation.equals(renderedConnector.orientation())) {
throw new IOException("jigsaw marker orientation at "
+ target.getX() + "," + target.getY() + "," + target.getZ()
+ " is " + actualOrientation + " instead of " + renderedConnector.orientation());
}
KMap<String, Object> expected = markerNbt(renderedConnector.connector());
BukkitPlatform.deserializeTile(expected, target.getLocation());
}
}
static void verifyRenderedBayChunk(
World world,
JigsawStudioBay bay,
JigsawStudioGenerator.RenderedBay rendered,
int chunkX,
int chunkZ,
boolean connectorsVisible
) throws IOException {
JigsawStudioBounds bounds = bay.bounds();
Set<LocalPosition> connectorPositions = new HashSet<>(rendered.connectors().size());
if (connectorsVisible) {
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
connectorPositions.add(new LocalPosition(connector.x(), connector.y(), connector.z()));
}
}
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
TileData tileData = block.tileData();
if (tileData == null
|| connectorPositions.contains(new LocalPosition(block.x(), block.y(), block.z()))) {
continue;
}
int worldX = bounds.originX() + block.x();
int worldZ = bounds.originZ() + block.z();
if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) {
continue;
}
Block target = world.getBlockAt(worldX, bounds.originY() + block.y(), worldZ);
if (!tileData.isApplicable(target.getBlockData())) {
throw new IOException("hydrated tile state no longer matches its block at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
KMap<String, Object> hydrated = BukkitPlatform.serializeTile(target.getLocation());
if (hydrated == null
|| tileData.getProperties() != null
&& !nbtContains(tileData.getProperties(), hydrated)) {
throw new IOException("the active NMS binding did not preserve source tile NBT at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
}
if (!connectorsVisible) {
return;
}
for (JigsawStudioGenerator.RenderedConnector renderedConnector : rendered.connectors()) {
int worldX = bounds.originX() + renderedConnector.x();
int worldZ = bounds.originZ() + renderedConnector.z();
if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) {
continue;
}
Block target = world.getBlockAt(
worldX,
bounds.originY() + renderedConnector.y(),
worldZ);
BlockData blockData = target.getBlockData();
if (!(blockData instanceof Jigsaw jigsaw)
|| !jigsaw.getOrientation().name().toLowerCase(Locale.ROOT)
.equals(renderedConnector.orientation())) {
throw new IOException("jigsaw marker changed before hydration completed at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
KMap<String, Object> expected = markerNbt(renderedConnector.connector());
KMap<String, Object> hydrated = BukkitPlatform.serializeTile(target.getLocation());
if (!markerNbtMatches(expected, hydrated)) {
throw new IOException("the active NMS binding did not preserve jigsaw marker NBT at "
+ target.getX() + "," + target.getY() + "," + target.getZ());
}
}
}
static KMap<String, Object> markerNbt(IrisJigsawConnector connector) {
IrisJigsawConnector activeConnector = Objects.requireNonNull(connector, "Jigsaw Studio connector");
KMap<String, Object> nbt = new KMap<>();
nbt.put("name", studioIdentifier(activeConnector.getName()));
nbt.put("target", studioIdentifier(activeConnector.getTargetName()));
nbt.put("pool", JigsawStudioMarkerKeyCodec.encodePool(activeConnector.getPool()));
nbt.put("final_state", activeConnector.getFinalState());
nbt.put("joint", activeConnector.getJoint().name().toLowerCase(Locale.ROOT));
nbt.put("selection_priority", activeConnector.getSelectionPriority());
nbt.put("placement_priority", activeConnector.getPlacementPriority());
return nbt;
}
private static boolean markerNbtMatches(
Map<String, Object> expected,
Map<String, Object> hydrated
) {
if (hydrated == null) {
return false;
}
for (Map.Entry<String, Object> entry : expected.entrySet()) {
Object actual = hydrated.get(entry.getKey());
if (entry.getValue() instanceof Number expectedNumber) {
if (!(actual instanceof Number actualNumber)
|| expectedNumber.longValue() != actualNumber.longValue()) {
return false;
}
} else if (!Objects.equals(entry.getValue(), actual)) {
return false;
}
}
return true;
}
private static boolean nbtContains(Object expected, Object actual) {
if (expected instanceof Number expectedNumber) {
if (!(actual instanceof Number actualNumber)) {
return false;
}
if (isIntegral(expectedNumber) && isIntegral(actualNumber)) {
return expectedNumber.longValue() == actualNumber.longValue();
}
return Double.compare(expectedNumber.doubleValue(), actualNumber.doubleValue()) == 0;
}
if (expected instanceof Map<?, ?> expectedMap) {
if (!(actual instanceof Map<?, ?> actualMap)) {
return false;
}
for (Map.Entry<?, ?> entry : expectedMap.entrySet()) {
if (!actualMap.containsKey(entry.getKey())
|| !nbtContains(entry.getValue(), actualMap.get(entry.getKey()))) {
return false;
}
}
return true;
}
if (expected instanceof List<?> expectedList) {
if (!(actual instanceof List<?> actualList) || expectedList.size() != actualList.size()) {
return false;
}
for (int index = 0; index < expectedList.size(); index++) {
if (!nbtContains(expectedList.get(index), actualList.get(index))) {
return false;
}
}
return true;
}
return Objects.equals(expected, actual);
}
private static boolean isIntegral(Number number) {
return number instanceof Byte
|| number instanceof Short
|| number instanceof Integer
|| number instanceof Long;
}
static void restoreCapturedMetadata(
IrisJigsawConnector captured,
IrisJigsawPiece sourcePiece
) {
IrisJigsawConnector activeCaptured = Objects.requireNonNull(captured, "Captured jigsaw connector");
IrisJigsawConnector original = connectorAt(sourcePiece, activeCaptured.getPosition());
if (original == null) {
return;
}
activeCaptured.setName(restoreIdentifier(activeCaptured.getName(), original.getName()));
activeCaptured.setTargetName(restoreIdentifier(
activeCaptured.getTargetName(),
original.getTargetName()));
activeCaptured.setChannel(original.getChannel() == null ? "" : original.getChannel());
}
static void restoreCapturedMetadataForDisplay(
IrisJigsawConnector captured,
IrisJigsawPiece sourcePiece,
JigsawStudioCellDimensions displayDimensions,
int displayRotationQuarterTurns
) {
if (Math.floorMod(displayRotationQuarterTurns, 4) == 0) {
restoreCapturedMetadata(captured, sourcePiece);
return;
}
int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4);
int sourceWidth = (quarterTurns & 1) == 0
? displayDimensions.width()
: displayDimensions.depth();
int sourceDepth = (quarterTurns & 1) == 0
? displayDimensions.depth()
: displayDimensions.width();
LocalPosition position = inversePosition(
captured.getPosition().getX(),
captured.getPosition().getY(),
captured.getPosition().getZ(),
sourceWidth,
sourceDepth,
quarterTurns);
IrisObjectRotation inverseRotation = IrisObjectRotation.of(0, 90.0D * quarterTurns, 0);
IrisJigsawConnector sourceOriented = CapturedConnector.from(captured).toConnector()
.setPosition(new IrisPosition(position.x(), position.y(), position.z()))
.setDirection(inverseRotation.rotate(captured.getDirection()))
.setTop(inverseRotation.rotate(captured.getTop()));
restoreCapturedMetadata(sourceOriented, sourcePiece);
captured.setName(sourceOriented.getName());
captured.setTargetName(sourceOriented.getTargetName());
captured.setChannel(sourceOriented.getChannel());
}
private static IrisJigsawConnector connectorAt(IrisJigsawPiece piece, IrisPosition position) {
if (piece == null || piece.getConnectors() == null || position == null) {
return null;
}
for (IrisJigsawConnector connector : piece.getConnectors()) {
if (connector != null && position.equals(connector.getPosition())) {
return connector;
}
}
return null;
}
private static String restoreIdentifier(String captured, String original) {
if (original != null && studioIdentifier(original).equals(captured)) {
return original;
}
return captured;
}
private static String studioIdentifier(String value) {
String normalized = Objects.requireNonNull(value, "Jigsaw Studio marker identifier").trim();
return normalized.indexOf(':') < 0 ? "iris:" + normalized : normalized;
}
static final class BayPopulation {
private final Set<Long> requiredChunks;
private final Set<Long> generatedChunks = ConcurrentHashMap.newKeySet();
private final Set<Long> appliedChunks = ConcurrentHashMap.newKeySet();
private final Set<Long> hydratedChunks = ConcurrentHashMap.newKeySet();
private volatile String failure;
BayPopulation(Set<Long> requiredChunks, String failure) {
this.requiredChunks = Set.copyOf(requiredChunks);
this.failure = failure == null ? "" : failure;
}
boolean markGenerated(long chunk) {
if (!requiredChunks.contains(chunk)) {
return false;
}
generatedChunks.add(chunk);
return !hydratedChunks.contains(chunk) && failure.isEmpty();
}
boolean needsApplication(long chunk) {
return failure.isEmpty()
&& requiredChunks.contains(chunk)
&& generatedChunks.contains(chunk)
&& !appliedChunks.contains(chunk);
}
boolean needsVerification(long chunk) {
return failure.isEmpty()
&& requiredChunks.contains(chunk)
&& generatedChunks.contains(chunk)
&& appliedChunks.contains(chunk)
&& !hydratedChunks.contains(chunk);
}
void markApplied(long chunk) {
if (requiredChunks.contains(chunk)) {
appliedChunks.add(chunk);
}
}
void markHydrated(long chunk) {
if (requiredChunks.contains(chunk)) {
hydratedChunks.add(chunk);
}
}
void markFullyReady() {
generatedChunks.addAll(requiredChunks);
appliedChunks.addAll(requiredChunks);
hydratedChunks.addAll(requiredChunks);
}
synchronized boolean fail(String reason) {
if (!failure.isEmpty()) {
return false;
}
failure = reason == null || reason.isBlank() ? "unknown hydration failure" : reason;
return true;
}
BayReadiness readiness() {
boolean ready = failure.isEmpty()
&& !requiredChunks.isEmpty()
&& generatedChunks.containsAll(requiredChunks)
&& hydratedChunks.containsAll(requiredChunks);
return new BayReadiness(
ready,
failure,
requiredChunks.size(),
generatedChunks.size(),
hydratedChunks.size());
}
}
}
@@ -0,0 +1,118 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.inventory.Inventory;
import java.util.List;
import java.util.Objects;
import static art.arcane.iris.core.service.JigsawStudioAutosaveScheduler.AUTOSAVE_DEBOUNCE_TICKS;
import static art.arcane.iris.core.service.JigsawStudioProtection.inventoryBlocks;
final class JigsawStudioDirtyTracker {
private final JigsawStudioService service;
JigsawStudioDirtyTracker(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean markDirty(World world, int worldX, int worldY, int worldZ) {
if (world == null) {
return false;
}
ActiveStudio studio = service.studios.get(world.getUID());
if (studio == null) {
return false;
}
if (service.saveLifecycle.reopenRequiredRequests.contains(studio.generator().getRequest().requestId())) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay bay = session.layout().findAt(worldX, worldY, worldZ);
if (bay == null) {
return false;
}
JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null);
if (activeVariant == null || !activeVariant.owned()) {
return false;
}
JigsawStudioSession.DirtyMark dirtyMark = session.markWorkcellDirty(bay.stableId());
if (dirtyMark.status() != JigsawStudioSession.DirtyStatus.MARKED) {
return false;
}
service.autosaveScheduler.scheduleAutosave(
studio,
bay,
dirtyMark.identity().orElseThrow(),
AUTOSAVE_DEBOUNCE_TICKS);
service.markEvaluationStale(studio);
if (dirtyMark.newlyDirty()) {
service.playerContext.refreshWorkcellContext(studio.worldId(), bay.stableId());
}
return true;
}
int markAllDirty(World world) {
if (world == null) {
return 0;
}
ActiveStudio studio = service.studios.get(world.getUID());
return studio == null ? 0 : markAllDirty(studio);
}
void markDirty(Block block) {
if (block != null) {
markDirty(block.getWorld(), block.getX(), block.getY(), block.getZ());
}
}
void markDirty(List<Block> blocks) {
for (Block block : blocks) {
markDirty(block);
}
}
void markDirty(Inventory inventory) {
for (Block block : inventoryBlocks(inventory)) {
markDirty(block);
}
}
void markAllDirty() {
for (ActiveStudio studio : service.studios.values()) {
markAllDirty(studio);
}
}
int markAllDirty(ActiveStudio studio) {
int changed = 0;
JigsawStudioSession session = studio.generator().getSession();
for (JigsawStudioBay bay : session.layout().bays()) {
JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null);
if (activeVariant == null || !activeVariant.owned()) {
continue;
}
JigsawStudioSession.DirtyMark dirtyMark = session.markWorkcellDirty(bay.stableId());
if (dirtyMark.status() == JigsawStudioSession.DirtyStatus.MARKED) {
service.autosaveScheduler.scheduleAutosave(
studio,
bay,
dirtyMark.identity().orElseThrow(),
AUTOSAVE_DEBOUNCE_TICKS);
changed++;
}
}
if (changed > 0) {
service.markEvaluationStale(studio);
for (JigsawStudioBay bay : session.layout().bays()) {
service.playerContext.refreshWorkcellContext(studio.worldId(), bay.stableId());
}
}
return changed;
}
}
@@ -0,0 +1,308 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.framework.StructureAssembler;
import art.arcane.iris.engine.framework.structure.StructureAssemblyResult;
import art.arcane.iris.engine.framework.structure.StructureGraphCompilation;
import art.arcane.iris.engine.framework.structure.StructureGraphCompiler;
import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic;
import art.arcane.iris.engine.framework.structure.StructureGraphResolver;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.math.RNG;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
final class JigsawStudioEvaluator {
private static final long PREVIEW_SEED = 1337L;
private static final int SPATIAL_PREVIEW_BASE_Y = JigsawStudioLayout.FLOOR_Y + 48;
private final JigsawStudioService service;
final Map<UUID, JigsawStudioGraphEvaluation> evaluations = new ConcurrentHashMap<>();
JigsawStudioEvaluator(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
void scheduleInitialEvaluation(ActiveStudio studio) {
if (studio == null) {
return;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
UUID requestId = request.requestId();
if (!claimInitialEvaluation(
studio.evaluationGeneration(),
service.isCurrentRequest(studio, requestId))) {
return;
}
publishEvaluation(studio, requestId, 1L);
}
static boolean claimInitialEvaluation(AtomicLong generation, boolean currentRequest) {
return currentRequest
&& Objects.requireNonNull(generation, "Jigsaw Studio evaluation generation")
.compareAndSet(0L, 1L);
}
void scheduleEvaluation(ActiveStudio studio) {
if (studio == null) {
return;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
UUID requestId = request.requestId();
if (!service.isCurrentRequest(studio, requestId)) {
return;
}
publishEvaluation(studio, requestId, studio.evaluationGeneration().incrementAndGet());
}
private void publishEvaluation(ActiveStudio studio, UUID requestId, long generation) {
JigsawStudioGraphEvaluation previous = evaluations.get(requestId);
JigsawStudioPreviewRenderer.PreviewBounds previousBounds = previous == null
? JigsawStudioPreviewRenderer.PreviewBounds.empty()
: previous.previewBounds();
evaluations.put(requestId, new JigsawStudioGraphEvaluation(
requestId,
generation,
PREVIEW_SEED,
JigsawStudioEvaluationState.PENDING,
previous == null ? "" : previous.selectedTheme(),
previous == null ? 0 : previous.pieceCount(),
"Compiling the committed graph and assembling seed 1337",
previousBounds));
service.playerContext.refreshAllWorkcellContexts(studio);
J.a(() -> evaluateGraph(studio, requestId, generation));
}
private void evaluateGraph(ActiveStudio studio, UUID requestId, long generation) {
EvaluationComputation computation;
try {
JigsawStudioActivation.Request request = studio.generator().getRequest();
IrisStructure structure = request.source().load(
IrisStructure.class,
request.structureKey(),
false);
if (structure == null) {
throw new IOException("The active structure resource no longer exists");
}
StructureGraphCompilation compilation = StructureGraphCompiler.compile(
structure,
StructureGraphResolver.forData(request.source()));
StructureGraphDiagnostic firstError = firstDiagnostic(
compilation,
StructureGraphDiagnostic.Severity.ERROR);
if (firstError != null) {
computation = invalidEvaluation(
requestId,
generation,
firstError.code() + ": " + firstError.message());
} else {
IrisPosition origin = previewOrigin(studio.generator().getLayout(), structure);
StructureAssembler assembler = StructureAssembler.forCompilation(compilation, origin);
StructureAssemblyResult assembly = assembler.assemble(new RNG(PREVIEW_SEED));
computation = evaluationForAssembly(
requestId,
generation,
compilation,
assembly,
studio.generator().getLayout().mode());
}
} catch (Throwable exception) {
IrisLogging.reportError(exception);
computation = invalidEvaluation(requestId, generation, failureMessage(exception));
}
completeEvaluation(studio, computation);
}
private EvaluationComputation evaluationForAssembly(
UUID requestId,
long generation,
StructureGraphCompilation compilation,
StructureAssemblyResult assembly,
JigsawStudioMode mode
) throws IOException {
if (assembly.status().isFailure()) {
return invalidEvaluation(
requestId,
generation,
assembly.status().name().toLowerCase(Locale.ROOT) + ": " + assembly.detail());
}
if (!assembly.hasOutput()) {
return new EvaluationComputation(
new JigsawStudioGraphEvaluation(
requestId,
generation,
PREVIEW_SEED,
JigsawStudioEvaluationState.WARNING,
assembly.selectedTheme(),
0,
assembly.detail().isEmpty()
? "Seed 1337 intentionally generated no structure"
: assembly.detail(),
JigsawStudioPreviewRenderer.PreviewBounds.empty()),
JigsawStudioPreviewRenderer.PreviewPlan.empty());
}
List<PlacedStructurePiece> aligned = alignPreviewPieces(assembly.pieces(), mode);
JigsawStudioPreviewRenderer.PreviewPlan plan = JigsawStudioPreviewRenderer.plan(aligned);
StructureGraphDiagnostic firstWarning = firstDiagnostic(
compilation,
StructureGraphDiagnostic.Severity.WARNING);
JigsawStudioEvaluationState state = firstWarning == null
? JigsawStudioEvaluationState.VALID
: JigsawStudioEvaluationState.WARNING;
String detail = firstWarning == null
? "Seed 1337 assembled " + aligned.size() + " piece(s)"
: firstWarning.code() + ": " + firstWarning.message();
return new EvaluationComputation(
new JigsawStudioGraphEvaluation(
requestId,
generation,
PREVIEW_SEED,
state,
assembly.selectedTheme(),
aligned.size(),
detail,
plan.bounds()),
plan);
}
private void completeEvaluation(ActiveStudio studio, EvaluationComputation computation) {
JigsawStudioGraphEvaluation evaluated = computation.evaluation();
UUID requestId = evaluated.requestId();
if (!isCurrentEvaluation(studio, requestId, evaluated.generation())) {
return;
}
service.previewRenderer.render(
studio.world(),
requestId,
evaluated.generation(),
computation.plan(),
result -> {
if (!isCurrentEvaluation(studio, requestId, evaluated.generation())) {
return;
}
JigsawStudioGraphEvaluation completed = result.successful()
? evaluated
: new JigsawStudioGraphEvaluation(
requestId,
evaluated.generation(),
PREVIEW_SEED,
JigsawStudioEvaluationState.WARNING,
evaluated.selectedTheme(),
evaluated.pieceCount(),
evaluated.detail() + "; " + result.failure(),
evaluated.previewBounds());
evaluations.put(requestId, completed);
service.playerContext.refreshAllWorkcellContexts(studio);
});
}
private boolean isCurrentEvaluation(ActiveStudio studio, UUID requestId, long generation) {
if (!service.isCurrentRequest(studio, requestId)
|| studio.evaluationGeneration().get() != generation) {
return false;
}
JigsawStudioGraphEvaluation current = evaluations.get(requestId);
return current != null && current.generation() == generation;
}
private static EvaluationComputation invalidEvaluation(
UUID requestId,
long generation,
String detail
) {
return new EvaluationComputation(
new JigsawStudioGraphEvaluation(
requestId,
generation,
PREVIEW_SEED,
JigsawStudioEvaluationState.INVALID,
"",
0,
detail,
JigsawStudioPreviewRenderer.PreviewBounds.empty()),
JigsawStudioPreviewRenderer.PreviewPlan.empty());
}
private static StructureGraphDiagnostic firstDiagnostic(
StructureGraphCompilation compilation,
StructureGraphDiagnostic.Severity severity
) {
for (StructureGraphDiagnostic diagnostic : compilation.getDiagnostics()) {
if (diagnostic.severity() == severity) {
return diagnostic;
}
}
return null;
}
private static IrisPosition previewOrigin(JigsawStudioLayout layout, IrisStructure structure) {
int radius = Math.max(1, structure.getMaxSizeChunks()) * 16;
return new IrisPosition(
-radius - 32,
0,
Math.max(16, layout.extentZ() / 2));
}
static List<PlacedStructurePiece> alignPreviewPieces(
List<PlacedStructurePiece> pieces,
JigsawStudioMode mode
) {
int minimumY = Integer.MAX_VALUE;
for (PlacedStructurePiece piece : pieces) {
minimumY = Math.min(minimumY, piece.getMinY());
}
int baseY = mode == JigsawStudioMode.SPATIAL_JIGSAW
? SPATIAL_PREVIEW_BASE_Y
: JigsawStudioLayout.FLOOR_Y + 1;
int shiftY = baseY - minimumY;
List<PlacedStructurePiece> aligned = new ArrayList<>(pieces.size());
for (PlacedStructurePiece piece : pieces) {
aligned.add(new PlacedStructurePiece(
piece.getPiece(),
piece.getObject(),
piece.getX(),
piece.getY() + shiftY,
piece.getZ(),
piece.getRotation(),
piece.getMinX(),
piece.getMinY() + shiftY,
piece.getMinZ(),
piece.getMaxX(),
piece.getMaxY() + shiftY,
piece.getMaxZ()));
}
return List.copyOf(aligned);
}
static IrisStructure loadStudioStructure(ActiveStudio studio) {
JigsawStudioActivation.Request request = studio.generator().getRequest();
return request.source().load(IrisStructure.class, request.structureKey(), false);
}
private record EvaluationComputation(
JigsawStudioGraphEvaluation evaluation,
JigsawStudioPreviewRenderer.PreviewPlan plan
) {
private EvaluationComputation {
Objects.requireNonNull(evaluation, "Jigsaw Studio graph evaluation");
Objects.requireNonNull(plan, "Jigsaw Studio evaluation preview plan");
}
}
}
@@ -0,0 +1,417 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.runtime.InPlaceChunkRegenerator;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
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.ActiveStudio;
import art.arcane.iris.core.service.JigsawStudioService.CommandGraphMutation;
import art.arcane.iris.core.service.JigsawStudioService.CommandGraphMutationResult;
import art.arcane.iris.engine.data.chunk.TerrainChunk;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Chunk;
import org.bukkit.World;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.applyRenderedBayChunk;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.verifyRenderedBayChunk;
import static art.arcane.iris.core.service.JigsawStudioProtection.authorizeOwner;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
import static art.arcane.iris.core.service.JigsawStudioService.message;
final class JigsawStudioGraphMutations {
private final JigsawStudioService service;
final Set<UUID> graphMutationsInProgress = new HashSet<>();
JigsawStudioGraphMutations(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
String beginGraphMutation(
ActiveStudio studio,
JigsawStudioSession session
) {
UUID requestId = studio.generator().getRequest().requestId();
service.tileWatcher.finalizeJigsawTileWatches(requestId);
synchronized (service.saveLifecycleLock) {
if (service.saveLifecycle.closingRequests.contains(requestId)) {
return "This Jigsaw Studio is closing and cannot update its graph.";
}
if (service.saveLifecycle.savesInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio save to finish.";
}
if (graphMutationsInProgress.contains(requestId)) {
return "A Jigsaw Studio graph update is already running.";
}
if (service.saveLifecycle.exportsInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio export to finish.";
}
if (service.materializer.materializationsInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio variant load or rollback to finish.";
}
if (session.operationInProgress()) {
return "Wait for the current workcell operation to finish.";
}
if (service.saveLifecycle.reopenRequiredRequests.contains(requestId)) {
return "Close and reopen Jigsaw Studio before making another graph change.";
}
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
return "Finish or close the open vanilla jigsaw-block editor before changing graph metadata.";
}
if (session.isDirty()) {
return "Wait for every dirty workcell to finish autosaving before changing graph metadata.";
}
graphMutationsInProgress.add(requestId);
return "";
}
}
boolean runCommandGraphMutation(
Player player,
UUID expectedRequestId,
CommandGraphMutation task
) {
Objects.requireNonNull(expectedRequestId, "Jigsaw Studio command graph request ID");
Objects.requireNonNull(task, "Jigsaw Studio command graph mutation");
if (player == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> runCommandGraphMutation(player, expectedRequestId, task));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null
|| !studio.generator().getRequest().requestId().equals(expectedRequestId)) {
message(player, "This Jigsaw Studio session is no longer active.");
return false;
}
if (!authorizeOwner(player, studio)) {
return false;
}
String reservationFailure = beginGraphMutation(studio, studio.generator().getSession());
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
return scheduleGraphMutation(player, studio, task);
}
boolean scheduleGraphMutation(
Player player,
ActiveStudio studio,
CommandGraphMutation task
) {
UUID requestId = studio.generator().getRequest().requestId();
J.a(() -> {
CommandGraphMutationResult result = null;
Throwable failure = null;
try {
result = task.run();
} catch (Throwable exception) {
failure = exception;
}
CommandGraphMutationResult completedResult = result;
Throwable completedFailure = failure;
boolean scheduled = J.runEntity(
player,
() -> completeGraphMutation(
player,
studio,
requestId,
completedResult,
completedFailure),
0,
() -> finishGraphMutation(requestId));
if (!scheduled) {
finishGraphMutation(requestId);
if (completedFailure != null) {
IrisLogging.reportError(completedFailure);
}
}
});
return true;
}
private void completeGraphMutation(
Player player,
ActiveStudio studio,
UUID requestId,
CommandGraphMutationResult result,
Throwable failure
) {
try {
if (failure != null) {
IrisLogging.reportError(failure);
message(player, "Graph update failed: " + failureMessage(failure));
finishGraphMutation(requestId);
return;
}
if (result == null) {
message(player, "Graph update completed without a result; no Studio state changed.");
finishGraphMutation(requestId);
return;
}
if (!service.isCurrentRequest(studio, requestId)) {
message(player, "The graph updated on disk after this Studio session changed; reopen it to continue.");
finishGraphMutation(requestId);
return;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioLayout previousLayout = session.layout();
if (result.rebindActiveVariants().isEmpty()) {
session.replaceLayout(result.layout());
} else {
session.replaceLayoutAndRebind(result.layout(), result.rebindActiveVariants());
}
for (JigsawStudioBay workcell : result.layout().bays()) {
studio.generator().invalidateRender(workcell.stableId());
}
if (layoutGeometryChanged(previousLayout, result.layout())) {
scheduleLiveRelayout(player, studio, requestId, previousLayout, result);
return;
}
finishGraphMutationSuccess(player, studio, requestId, result);
} catch (Throwable exception) {
IrisLogging.reportError(exception);
message(player, "The graph updated on disk, but Studio could not refresh: "
+ failureMessage(exception) + ". Reopen this project before editing further.");
service.saveLifecycle.reopenRequiredRequests.add(requestId);
finishGraphMutation(requestId);
}
}
private void finishGraphMutationSuccess(
Player player,
ActiveStudio studio,
UUID requestId,
CommandGraphMutationResult result
) {
for (JigsawStudioBay workcell : result.layout().bays()) {
service.playerContext.refreshWorkcellContext(studio.worldId(), workcell.stableId());
}
service.evaluator.scheduleEvaluation(studio);
message(player, result.message());
finishGraphMutation(requestId);
if (!result.activatePieceKey().isEmpty()) {
service.variantEditor.switchVariant(
player,
result.activateWorkcellId(),
result.activatePieceKey(),
false);
} else if (result.reload().isPresent()) {
service.variantEditor.reloadActiveVariant(player, studio, result.reload().orElseThrow());
}
}
private void scheduleLiveRelayout(
Player player,
ActiveStudio studio,
UUID requestId,
JigsawStudioLayout previousLayout,
CommandGraphMutationResult result
) {
Set<Long> chunks = relayoutChunks(previousLayout, result.layout());
AtomicInteger remaining = new AtomicInteger(chunks.size());
AtomicReference<String> failure = new AtomicReference<>("");
AtomicReference<Throwable> cause = new AtomicReference<>();
if (chunks.isEmpty()) {
completeLiveRelayout(player, studio, requestId, result, "", null);
return;
}
for (long chunkKey : chunks) {
int chunkX = (int) (chunkKey >> 32);
int chunkZ = (int) chunkKey;
boolean scheduled = J.runRegion(studio.world(), chunkX, chunkZ, () -> {
try {
repaintStudioChunk(studio, chunkX, chunkZ);
} catch (Throwable exception) {
failure.compareAndSet("", "chunk " + chunkX + "," + chunkZ
+ " could not regenerate: " + failureMessage(exception));
cause.compareAndSet(null, exception);
}
if (remaining.decrementAndGet() == 0) {
scheduleLiveRelayoutCompletion(
player,
studio,
requestId,
result,
failure.get(),
cause.get());
}
});
if (!scheduled) {
failure.compareAndSet("", "chunk " + chunkX + "," + chunkZ
+ " could not be scheduled on its owning region");
if (remaining.decrementAndGet() == 0) {
scheduleLiveRelayoutCompletion(
player,
studio,
requestId,
result,
failure.get(),
cause.get());
}
}
}
}
private void scheduleLiveRelayoutCompletion(
Player player,
ActiveStudio studio,
UUID requestId,
CommandGraphMutationResult result,
String failure,
Throwable cause
) {
boolean scheduled = J.runEntity(
player,
() -> completeLiveRelayout(player, studio, requestId, result, failure, cause));
if (!scheduled) {
service.saveLifecycle.reopenRequiredRequests.add(requestId);
finishGraphMutation(requestId);
if (cause != null) {
IrisLogging.reportError(cause);
}
}
}
private void completeLiveRelayout(
Player player,
ActiveStudio studio,
UUID requestId,
CommandGraphMutationResult result,
String failure,
Throwable cause
) {
if (!failure.isEmpty() || !service.isCurrentRequest(studio, requestId)) {
service.saveLifecycle.reopenRequiredRequests.add(requestId);
studio.populations().clear();
if (cause != null) {
IrisLogging.reportError(cause);
}
message(player, "The workcell size was saved, but live regeneration failed"
+ (failure.isEmpty() ? "." : ": " + failure + ".")
+ " Close and reopen this project before editing further.");
finishGraphMutation(requestId);
return;
}
studio.populations().clear();
for (JigsawStudioBay workcell : result.layout().bays()) {
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(workcell);
studio.replacePopulation(
workcell,
rendered,
rendered.valid() ? "" : rendered.failure(),
rendered.valid());
}
service.saveLifecycle.reopenRequiredRequests.remove(requestId);
finishGraphMutationSuccess(player, studio, requestId, result);
studio.generator().getSession().selectedBayId().ifPresent(workcellId -> service.teleportTo(player, workcellId));
}
static boolean layoutGeometryChanged(
JigsawStudioLayout previous,
JigsawStudioLayout current
) {
if (previous.bays().size() != current.bays().size()) {
return true;
}
for (JigsawStudioBay previousBay : previous.bays()) {
JigsawStudioBay currentBay = current.get(previousBay.stableId());
if (currentBay == null || !previousBay.bounds().equals(currentBay.bounds())) {
return true;
}
}
return false;
}
static Set<Long> relayoutChunks(
JigsawStudioLayout previous,
JigsawStudioLayout current
) {
Set<Long> chunks = new HashSet<>();
addRelayoutChunks(chunks, previous);
addRelayoutChunks(chunks, current);
return Set.copyOf(chunks);
}
private static void addRelayoutChunks(Set<Long> chunks, JigsawStudioLayout layout) {
for (JigsawStudioBay workcell : layout.bays()) {
JigsawStudioBounds bounds = workcell.bounds();
int minimumChunkX = (bounds.originX() - 1) >> 4;
int maximumChunkX = (bounds.maxX() + 1) >> 4;
int minimumChunkZ = (bounds.originZ() - 1) >> 4;
int maximumChunkZ = (bounds.maxZ() + 1) >> 4;
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
chunks.add(JigsawStudioService.chunkKey(chunkX, chunkZ));
}
}
}
}
private static void repaintStudioChunk(ActiveStudio studio, int chunkX, int chunkZ) throws IOException {
World world = studio.world();
Chunk chunk = world.getChunkAt(chunkX, chunkZ);
TerrainChunk generated = TerrainChunk.create(world);
studio.generator().paintChunk(generated, chunkX, chunkZ);
if (!INMS.get().applyChunkBlocks(chunk, generated)) {
InPlaceChunkRegenerator.applyBlockDiffs(
chunk,
generated.getChunkData(),
world.getMinHeight(),
world.getMaxHeight());
}
for (JigsawStudioBay workcell : studio.generator().getLayout().bays()) {
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(workcell);
if (!rendered.valid()) {
continue;
}
boolean connectorsVisible = studio.generator().getSession()
.workcellSnapshot(workcell.stableId())
.connectorsVisible();
applyRenderedBayChunk(world, workcell, rendered, chunkX, chunkZ, connectorsVisible);
verifyRenderedBayChunk(world, workcell, rendered, chunkX, chunkZ, connectorsVisible);
}
world.refreshChunk(chunkX, chunkZ);
}
boolean graphMutationInProgress(UUID requestId) {
synchronized (service.saveLifecycleLock) {
return graphMutationsInProgress.contains(requestId);
}
}
private void finishGraphMutation(UUID requestId) {
synchronized (service.saveLifecycleLock) {
graphMutationsInProgress.remove(requestId);
}
}
static JigsawStudioLayout loadMappedLayout(ActiveStudio studio) throws IOException {
JigsawStudioActivation.Request request = studio.generator().getRequest();
IrisData source = request.source();
source.invalidateStructureResources();
IrisStructure structure = source.load(IrisStructure.class, request.structureKey(), false);
if (structure == null) {
throw new IOException("The structure could not be reloaded after the graph transaction");
}
return JigsawStudioGraphMapper.map(source, structure);
}
}
@@ -0,0 +1,632 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.service.JigsawStudioCapture.ChunkCaptureArea;
import art.arcane.iris.core.service.JigsawStudioCapture.LocalPosition;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.World;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static art.arcane.iris.core.service.JigsawStudioCapture.chunkIntersections;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.hydrateChunk;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.restoreConnectorChunk;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.validateMaterialization;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.writeMaterializedChunk;
import static art.arcane.iris.core.service.JigsawStudioService.chunkKey;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
import static art.arcane.iris.core.service.JigsawStudioService.message;
final class JigsawStudioMaterializer {
private static final int HYDRATION_RETRY_TICKS = 2;
private static final int MAX_HYDRATION_ATTEMPTS = 40;
private final JigsawStudioService service;
final Set<UUID> materializationsInProgress = new HashSet<>();
JigsawStudioMaterializer(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean scheduleMaterialization(MaterializationWork work) {
String validationFailure = validateMaterialization(work.target());
if (!validationFailure.isEmpty()) {
work.studio().generator().getSession().abortVariantSwitch(work.token());
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), "Variant '" + work.token().targetVariant().pieceKey()
+ "' cannot load: " + validationFailure);
return false;
}
String reservationFailure = beginMaterialization(work);
if (!reservationFailure.isEmpty()) {
work.studio().generator().getSession().abortVariantSwitch(work.token());
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), reservationFailure);
return false;
}
List<ChunkCaptureArea> areas = chunkIntersections(work.workcell().bounds());
MaterializationCoordinator coordinator = new MaterializationCoordinator(work, areas);
for (ChunkCaptureArea area : areas) {
boolean scheduled = J.runRegion(
work.world(),
area.chunkX(),
area.chunkZ(),
() -> materializeCandidateChunk(coordinator, area));
if (!scheduled) {
coordinator.candidateComplete(
area,
"Iris could not schedule workcell chunk "
+ area.chunkX() + "," + area.chunkZ() + " on its owning region.",
null);
} else {
coordinator.markScheduled();
}
}
return coordinator.scheduledAny();
}
private String beginMaterialization(MaterializationWork work) {
UUID requestId = work.studio().generator().getRequest().requestId();
synchronized (service.saveLifecycleLock) {
if (!isCurrentVariantSwitch(work)) {
return "The Jigsaw Studio changed before the variant load could start.";
}
if (service.saveLifecycle.closingRequests.contains(requestId)) {
return "This Jigsaw Studio is closing and cannot load another variant.";
}
if (service.saveLifecycle.savesInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio save to finish.";
}
if (service.graphMutations.graphMutationsInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio graph update to finish.";
}
if (service.saveLifecycle.exportsInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio export to finish.";
}
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
return "Finish or close the open vanilla jigsaw-block editor before loading another variant.";
}
if (!materializationsInProgress.add(requestId)) {
return "Another Jigsaw Studio variant load or rollback is already running.";
}
return "";
}
}
String beginConnectorRepair(ActiveStudio studio) {
UUID requestId = studio.generator().getRequest().requestId();
service.tileWatcher.finalizeJigsawTileWatches(requestId);
synchronized (service.saveLifecycleLock) {
if (!service.isCurrentRequest(studio, requestId)) {
return "This Jigsaw Studio session is no longer active.";
}
if (service.saveLifecycle.closingRequests.contains(requestId)) {
return "This Jigsaw Studio is closing and cannot reset connector blocks.";
}
if (service.saveLifecycle.savesInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio save to finish.";
}
if (service.graphMutations.graphMutationsInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio graph update to finish.";
}
if (service.saveLifecycle.exportsInProgress.contains(requestId)) {
return "Wait for the current Jigsaw Studio export to finish.";
}
if (studio.generator().getSession().operationInProgress()) {
return "Wait for the current workcell operation to finish.";
}
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
return "Finish or close the open vanilla jigsaw-block editor before resetting connectors.";
}
if (!materializationsInProgress.add(requestId)) {
return "Another Jigsaw Studio variant load or repair is already running.";
}
return "";
}
}
boolean scheduleConnectorRepair(
Player player,
ActiveStudio studio,
JigsawStudioBay workcell,
JigsawStudioGenerator.RenderedBay rendered,
boolean connectorsVisible
) {
Map<Long, List<JigsawStudioGenerator.RenderedConnector>> connectorsByChunk = new HashMap<>();
JigsawStudioBounds bounds = workcell.bounds();
for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) {
int worldX = bounds.originX() + connector.x();
int worldZ = bounds.originZ() + connector.z();
connectorsByChunk.computeIfAbsent(
chunkKey(worldX >> 4, worldZ >> 4),
ignored -> new ArrayList<>()).add(connector);
}
AtomicInteger remaining = new AtomicInteger(connectorsByChunk.size());
AtomicReference<String> failure = new AtomicReference<>("");
AtomicReference<Throwable> cause = new AtomicReference<>();
AtomicBoolean scheduledAny = new AtomicBoolean(false);
Map<LocalPosition, JigsawStudioGenerator.RenderedBlock> renderedBlocks = new HashMap<>();
for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) {
renderedBlocks.put(new LocalPosition(block.x(), block.y(), block.z()), block);
}
for (Map.Entry<Long, List<JigsawStudioGenerator.RenderedConnector>> entry
: connectorsByChunk.entrySet()) {
int chunkX = (int) (entry.getKey() >> 32);
int chunkZ = (int) entry.getKey().longValue();
boolean scheduled = J.runRegion(studio.world(), chunkX, chunkZ, () -> {
try {
if (!studio.world().isChunkLoaded(chunkX, chunkZ)) {
throw new IOException("connector chunk " + chunkX + "," + chunkZ
+ " is not loaded");
}
restoreConnectorChunk(
studio.world(),
workcell,
entry.getValue(),
renderedBlocks,
connectorsVisible);
} catch (Throwable exception) {
failure.compareAndSet("", failureMessage(exception));
cause.compareAndSet(null, exception);
}
if (remaining.decrementAndGet() == 0) {
completeConnectorRepair(
player,
studio,
rendered.connectors().size(),
failure.get(),
cause.get());
}
});
if (scheduled) {
scheduledAny.set(true);
} else {
failure.compareAndSet("", "connector chunk " + chunkX + "," + chunkZ
+ " could not be scheduled on its owning region");
if (remaining.decrementAndGet() == 0) {
completeConnectorRepair(
player,
studio,
rendered.connectors().size(),
failure.get(),
cause.get());
}
}
}
if (!scheduledAny.get()) {
finishMaterialization(studio.generator().getRequest().requestId());
}
return scheduledAny.get();
}
private void completeConnectorRepair(
Player player,
ActiveStudio studio,
int connectorCount,
String failure,
Throwable cause
) {
UUID requestId = studio.generator().getRequest().requestId();
boolean scheduled = J.runEntity(player, () -> {
finishMaterialization(requestId);
if (cause != null) {
IrisLogging.reportError(cause);
}
if (!failure.isEmpty()) {
message(player, "Connector reset was incomplete: " + failure + ". Retry after visiting the workcell.");
return;
}
message(player, "Restored " + connectorCount
+ " connector block(s) from the last saved iteration.");
});
if (!scheduled) {
finishMaterialization(requestId);
if (cause != null) {
IrisLogging.reportError(cause);
}
}
}
private void finishMaterialization(UUID requestId) {
synchronized (service.saveLifecycleLock) {
materializationsInProgress.remove(requestId);
}
}
private void materializeCandidateChunk(
MaterializationCoordinator coordinator,
ChunkCaptureArea area
) {
MaterializationWork work = coordinator.work();
if (!isCurrentVariantSwitch(work)) {
coordinator.candidateComplete(
area,
"Jigsaw Studio changed before the variant could finish loading.",
null);
return;
}
if (!work.world().isChunkLoaded(area.chunkX(), area.chunkZ())) {
coordinator.candidateComplete(
area,
"Workcell chunk " + area.chunkX() + "," + area.chunkZ()
+ " is not loaded. Visit the whole workcell and try again.",
null);
return;
}
try {
coordinator.markCandidateTouched(area);
writeMaterializedChunk(
work.world(),
work.workcell(),
work.target(),
area,
work.targetConnectorsVisible());
coordinator.candidateComplete(area, "", null);
} catch (Throwable exception) {
coordinator.candidateComplete(
area,
"Variant materialization failed in chunk "
+ area.chunkX() + "," + area.chunkZ() + ": " + failureMessage(exception),
exception);
}
}
private void materializeRollbackChunk(
MaterializationCoordinator coordinator,
ChunkCaptureArea area
) {
MaterializationWork work = coordinator.work();
if (!isRollbackVariantSwitch(work)) {
coordinator.rollbackComplete(
area,
"Jigsaw Studio changed before the previous variant could be restored.",
null);
return;
}
if (!work.world().isChunkLoaded(area.chunkX(), area.chunkZ())) {
coordinator.rollbackComplete(
area,
"Rollback chunk " + area.chunkX() + "," + area.chunkZ() + " is not loaded.",
null);
return;
}
try {
writeMaterializedChunk(
work.world(),
work.workcell(),
work.previous(),
area,
work.previousConnectorsVisible());
coordinator.rollbackComplete(area, "", null);
} catch (Throwable exception) {
coordinator.rollbackComplete(
area,
"Rollback failed in chunk " + area.chunkX() + "," + area.chunkZ()
+ ": " + failureMessage(exception),
exception);
}
}
private boolean isCurrentVariantSwitch(MaterializationWork work) {
return service.isCurrentRequest(
work.studio(),
work.studio().generator().getRequest().requestId())
&& work.studio().generator().getSession().isVariantSwitchCurrent(work.token());
}
private boolean isRollbackVariantSwitch(MaterializationWork work) {
return service.studios.get(work.studio().worldId()) == work.studio()
&& service.protection.materializationInProgress(work.studio());
}
void scheduleHydration(
ActiveStudio studio,
World world,
int chunkX,
int chunkZ,
int attempt
) {
if (!service.enabled || service.studios.get(world.getUID()) != studio) {
return;
}
long key = chunkKey(chunkX, chunkZ);
if (!studio.hydrationsInProgress().add(key)) {
return;
}
boolean scheduled = J.runRegion(
world,
chunkX,
chunkZ,
() -> runHydrationAttempt(studio, world, chunkX, chunkZ, attempt, key),
HYDRATION_RETRY_TICKS);
if (!scheduled) {
studio.hydrationsInProgress().remove(key);
}
}
private void runHydrationAttempt(
ActiveStudio studio,
World world,
int chunkX,
int chunkZ,
int attempt,
long key
) {
studio.hydrationsInProgress().remove(key);
if (!service.enabled || service.studios.get(world.getUID()) != studio) {
return;
}
if (!world.isChunkLoaded(chunkX, chunkZ)) {
if (attempt < MAX_HYDRATION_ATTEMPTS) {
scheduleHydration(studio, world, chunkX, chunkZ, attempt + 1);
}
return;
}
if (hydrateChunk(studio, world, chunkX, chunkZ)) {
scheduleHydration(studio, world, chunkX, chunkZ, 0);
}
}
private final class MaterializationCoordinator {
private final MaterializationWork work;
private final List<ChunkCaptureArea> areas;
private final Set<Long> candidateCompleted = new HashSet<>();
private final Set<Long> candidateTouched = new HashSet<>();
private final Set<Long> rollbackCompleted = new HashSet<>();
private int candidateRemaining;
private int rollbackRemaining;
private String candidateFailure = "";
private String rollbackFailure = "";
private boolean rollbackStarted;
private boolean finished;
private boolean leaseReleased;
private boolean scheduledAny;
private MaterializationCoordinator(
MaterializationWork work,
List<ChunkCaptureArea> areas
) {
this.work = Objects.requireNonNull(work, "Jigsaw Studio materialization work");
this.areas = List.copyOf(areas);
candidateRemaining = this.areas.size();
}
private MaterializationWork work() {
return work;
}
private synchronized void markScheduled() {
scheduledAny = true;
}
private synchronized boolean scheduledAny() {
return scheduledAny;
}
private synchronized void markCandidateTouched(ChunkCaptureArea area) {
candidateTouched.add(chunkKey(area.chunkX(), area.chunkZ()));
}
private void candidateComplete(
ChunkCaptureArea area,
String failure,
Throwable exception
) {
boolean finishSuccess = false;
boolean startRollback = false;
synchronized (this) {
if (finished || rollbackStarted
|| !candidateCompleted.add(chunkKey(area.chunkX(), area.chunkZ()))) {
return;
}
if (failure != null && !failure.isBlank() && candidateFailure.isEmpty()) {
candidateFailure = failure;
}
candidateRemaining--;
if (candidateRemaining == 0) {
if (candidateFailure.isEmpty()) {
finishSuccess = true;
} else {
rollbackStarted = true;
startRollback = true;
}
}
}
if (exception != null) {
IrisLogging.reportError(exception);
}
if (finishSuccess) {
finishSuccess();
} else if (startRollback) {
scheduleRollback();
}
}
private void finishSuccess() {
JigsawStudioSession session = work.studio().generator().getSession();
if (!isCurrentVariantSwitch(work) || !session.completeVariantSwitch(work.token())) {
beginLateRollback("Jigsaw Studio changed before the loaded variant could be activated.");
return;
}
if (work.connectorVisibilityChange()) {
session.setConnectorsVisible(work.workcell().stableId(), work.targetConnectorsVisible());
}
synchronized (this) {
if (finished) {
return;
}
finished = true;
}
try {
work.studio().generator().invalidateRender(work.workcell().stableId());
work.studio().replacePopulation(work.workcell(), work.target(), "", true);
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), work.connectorVisibilityChange()
? "Connector blocks are now "
+ (work.targetConnectorsVisible() ? "visible." : "hidden.")
: "Loaded variant '" + work.token().targetVariant().pieceKey()
+ "' into " + work.workcell().stableId() + ".");
} finally {
releaseLease();
}
}
private void beginLateRollback(String failure) {
synchronized (this) {
if (finished || rollbackStarted) {
return;
}
candidateFailure = failure;
rollbackStarted = true;
}
scheduleRollback();
}
private void scheduleRollback() {
List<ChunkCaptureArea> rollbackAreas = new ArrayList<>();
synchronized (this) {
for (ChunkCaptureArea area : areas) {
if (candidateTouched.contains(chunkKey(area.chunkX(), area.chunkZ()))) {
rollbackAreas.add(area);
}
}
rollbackRemaining = rollbackAreas.size();
if (rollbackRemaining == 0) {
finished = true;
}
}
if (rollbackAreas.isEmpty()) {
finishRollback();
return;
}
for (ChunkCaptureArea area : rollbackAreas) {
boolean scheduled = J.runRegion(
work.world(),
area.chunkX(),
area.chunkZ(),
() -> materializeRollbackChunk(this, area));
if (!scheduled) {
rollbackComplete(
area,
"Iris could not schedule rollback for workcell chunk "
+ area.chunkX() + "," + area.chunkZ() + ".",
null);
}
}
}
private void rollbackComplete(
ChunkCaptureArea area,
String failure,
Throwable exception
) {
boolean finalizeRollback = false;
synchronized (this) {
if (finished || !rollbackStarted
|| !rollbackCompleted.add(chunkKey(area.chunkX(), area.chunkZ()))) {
return;
}
if (failure != null && !failure.isBlank() && rollbackFailure.isEmpty()) {
rollbackFailure = failure;
}
rollbackRemaining--;
if (rollbackRemaining == 0) {
finished = true;
finalizeRollback = true;
}
}
if (exception != null) {
IrisLogging.reportError(exception);
}
if (finalizeRollback) {
finishRollback();
}
}
private void finishRollback() {
JigsawStudioSession session = work.studio().generator().getSession();
boolean released = session.abortVariantSwitch(work.token());
try {
work.studio().generator().invalidateRender(work.workcell().stableId());
if (rollbackFailure.isEmpty() && released) {
work.studio().replacePopulation(work.workcell(), work.previous(), "", true);
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), candidateFailure + " The previous variant was restored.");
return;
}
String failure = rollbackFailure.isEmpty()
? "The previous variant could not be restored because the Studio session changed."
: rollbackFailure;
work.studio().replacePopulation(work.workcell(), work.previous(), failure, false);
service.playerContext.refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId());
message(work.player(), candidateFailure + " Automatic rollback also failed: " + failure
+ " Do not save this workcell until it is reopened.");
} finally {
releaseLease();
}
}
private void releaseLease() {
synchronized (this) {
if (leaseReleased) {
return;
}
leaseReleased = true;
}
finishMaterialization(work.studio().generator().getRequest().requestId());
}
}
record MaterializationWork(
ActiveStudio studio,
World world,
Player player,
JigsawStudioBay workcell,
JigsawStudioSession.VariantSwitchToken token,
JigsawStudioGenerator.RenderedBay previous,
JigsawStudioGenerator.RenderedBay target,
boolean previousConnectorsVisible,
boolean targetConnectorsVisible,
boolean connectorVisibilityChange
) {
MaterializationWork {
Objects.requireNonNull(studio, "Jigsaw Studio materialization studio");
Objects.requireNonNull(world, "Jigsaw Studio materialization world");
Objects.requireNonNull(player, "Jigsaw Studio materialization player");
Objects.requireNonNull(workcell, "Jigsaw Studio materialization workcell");
Objects.requireNonNull(token, "Jigsaw Studio materialization token");
Objects.requireNonNull(previous, "Jigsaw Studio previous rendered variant");
Objects.requireNonNull(target, "Jigsaw Studio target rendered variant");
}
}
public record VariantReloadRequest(
String workcellId,
JigsawStudioGenerator.RenderedBay previous
) {
public VariantReloadRequest {
workcellId = workcellId == null ? "" : workcellId.trim();
if (workcellId.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio reload workcell ID cannot be blank");
}
Objects.requireNonNull(previous, "Jigsaw Studio previous rendered variant");
}
}
}
@@ -0,0 +1,191 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioSaveLifecycle.BayReadiness;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import static art.arcane.iris.core.service.JigsawStudioProtection.ownerMatches;
final class JigsawStudioPlayerContext {
private final JigsawStudioService service;
final Map<UUID, PlayerWorkcellContext> playerWorkcells = new ConcurrentHashMap<>();
JigsawStudioPlayerContext(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
void refreshAllWorkcellContexts(ActiveStudio studio) {
scheduleOnlinePlayers(studio.worldId());
}
void scheduleOnlinePlayers(UUID worldId) {
J.runGlobal(() -> {
for (Player player : Bukkit.getOnlinePlayers()) {
J.runEntity(player, () -> {
if (player.getWorld().getUID().equals(worldId)) {
service.visualization.ensureVisualizationLoop(player);
reconcilePlayerContext(player, player.getLocation());
}
});
}
});
}
void reconcilePlayerContext(Player player, Location location) {
if (player == null) {
return;
}
Location target = location == null ? player.getLocation() : location;
if (!J.isOwnedByCurrentRegion(player)
|| target.getWorld() == null
|| !target.getWorld().getUID().equals(player.getWorld().getUID())) {
J.runEntity(player, () -> reconcilePlayerContext(player, player.getLocation()), 1);
return;
}
ActiveStudio studio = service.studios.get(target.getWorld().getUID());
if (studio == null) {
clearPlayerContext(player);
return;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().findAt(
target.getBlockX(), target.getBlockY(), target.getBlockZ());
boolean owner = ownerMatches(player, studio);
selectEnteredWorkcell(session, workcell, owner);
String workcellId = workcell == null ? "" : workcell.stableId();
playerWorkcells.put(player.getUniqueId(), new PlayerWorkcellContext(
studio.worldId(),
studio.generator().getRequest().requestId(),
workcellId));
JigsawStudioVariant variant = workcell == null
? null
: session.activeVariant(workcell.stableId()).orElse(null);
JigsawStudioBoardState state = boardState(studio, session, workcell, variant);
String workcellRole = workcell == null ? "" : workcell.canonicalDisplayName();
String workcellName = workcell == null ? "" : workcell.displayName();
String variantName = variant == null ? "" : variant.resolvedDisplayName();
String hint = "Triple-sneak for controls";
boardService().applyJigsawContext(player, new JigsawStudioBoardContext(
studio.worldId(),
studio.generator().getRequest().requestId(),
studio.generator().getRequest().structureKey(),
session.layout().mode(),
workcellRole,
workcellName,
variantName,
state,
hint));
}
static boolean selectEnteredWorkcell(
JigsawStudioSession session,
JigsawStudioBay workcell,
boolean owner
) {
return owner && workcell != null && session.selectBay(workcell.stableId());
}
void refreshWorkcellContext(UUID worldId, String workcellId) {
if (worldId == null || workcellId == null) {
return;
}
J.runGlobal(() -> {
for (Player player : Bukkit.getOnlinePlayers()) {
J.runEntity(player, () -> {
if (!player.getWorld().getUID().equals(worldId)) {
return;
}
PlayerWorkcellContext context = playerWorkcells.get(player.getUniqueId());
if (context != null && workcellId.equals(context.workcellId())) {
reconcilePlayerContext(player, player.getLocation());
}
});
}
});
}
void clearWorldPlayerContexts(UUID worldId) {
J.runGlobal(() -> {
for (Player player : Bukkit.getOnlinePlayers()) {
J.runEntity(player, () -> {
PlayerWorkcellContext context = playerWorkcells.get(player.getUniqueId());
if (context != null && context.worldId().equals(worldId)) {
service.toolbelt.closeMenu(player);
clearPlayerContext(player);
}
});
}
});
}
private void clearPlayerContext(Player player) {
if (player == null) {
return;
}
playerWorkcells.remove(player.getUniqueId());
boardService().clearJigsawContext(player);
}
static BoardSVC boardService() {
return IrisServices.get(BoardSVC.class);
}
private static JigsawStudioBoardState boardState(
ActiveStudio studio,
JigsawStudioSession session,
JigsawStudioBay workcell,
JigsawStudioVariant variant
) {
if (workcell == null) {
return JigsawStudioBoardState.SAVED;
}
JigsawStudioSession.WorkcellSnapshot snapshot = session.workcellSnapshot(workcell.stableId());
if (snapshot.switchInProgress()) {
return JigsawStudioBoardState.LOADING;
}
if (snapshot.saveInProgress()) {
return JigsawStudioBoardState.SAVING;
}
if (!workcell.enabled()) {
return JigsawStudioBoardState.DISABLED;
}
if (variant != null && !variant.owned()) {
return JigsawStudioBoardState.READ_ONLY;
}
BayReadiness readiness = studio.population(workcell).readiness();
if (!readiness.failure().isEmpty()) {
return JigsawStudioBoardState.INVALID;
}
return snapshot.dirty() ? JigsawStudioBoardState.UNSAVED : JigsawStudioBoardState.SAVED;
}
private static String displayKey(String resourceKey) {
int separator = resourceKey.lastIndexOf('/');
return separator < 0 ? resourceKey : resourceKey.substring(separator + 1);
}
private record PlayerWorkcellContext(
UUID worldId,
UUID requestId,
String workcellId
) {
PlayerWorkcellContext {
Objects.requireNonNull(worldId, "Jigsaw Studio player-context world ID");
Objects.requireNonNull(requestId, "Jigsaw Studio player-context request ID");
workcellId = workcellId == null ? "" : workcellId;
}
}
}
@@ -0,0 +1,396 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioControlPosition;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockState;
import org.bukkit.block.DoubleChest;
import org.bukkit.entity.Player;
import org.bukkit.inventory.BlockInventoryHolder;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import static art.arcane.iris.core.service.JigsawStudioService.message;
final class JigsawStudioProtection {
private static final Set<String> MUTATING_COMMANDS = Set.of(
"biome", "brush", "chunk", "clone", "copy", "cut", "data", "deform", "drain",
"execute", "faces", "fill", "fillbiome", "fixlava", "fixwater", "flip", "flora",
"forest", "function", "gmask", "green", "hollow", "item", "load", "mask", "move",
"naturalize", "overlay", "paste", "place", "redo", "regen", "replace", "replacenear",
"restore", "rotate", "schedule", "schem", "schematic", "set", "setblock", "smooth",
"snow", "sphere", "stack", "thaw", "undo", "walls");
private static final Set<String> SAFE_NON_OWNER_COMMANDS = Set.of(
"help", "list", "me", "msg", "ping", "pl", "plugins", "r", "reply", "rules",
"say", "tell", "ver", "version", "w", "whisper");
private final JigsawStudioService service;
JigsawStudioProtection(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean blocksInventoryMutation(Inventory inventory, UUID actorId) {
for (Block block : inventoryBlocks(inventory)) {
StudioBlockMutationContext context = studioBlockMutationContext(block);
if (context != null && blocksStudioInventoryMutation(
true,
context.immutable(),
materializationInProgress(context.studio()),
service.saveLifecycle.reopenRequiredRequests.contains(context.requestId()),
context.ownerId(),
actorId)) {
return true;
}
}
return false;
}
boolean blocksMachineMutation(Block block) {
StudioBlockMutationContext context = studioBlockMutationContext(block);
return context != null && blocksStudioInventoryMutation(
true,
context.immutable(),
materializationInProgress(context.studio()),
service.saveLifecycle.reopenRequiredRequests.contains(context.requestId()),
context.ownerId(),
null);
}
private StudioBlockMutationContext studioBlockMutationContext(Block block) {
if (block == null) {
return null;
}
ActiveStudio studio = service.studios.get(block.getWorld().getUID());
if (studio == null) {
return null;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
boolean preview = service.previewRenderer.contains(
request.requestId(), block.getX(), block.getY(), block.getZ());
JigsawStudioControlPosition control = studio.generator().getLayout().controlPosition();
boolean controlChest = block.getType() == Material.CHEST
&& block.getX() == control.worldX()
&& block.getY() == control.worldY()
&& block.getZ() == control.worldZ();
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay bay = session.layout().findAt(
block.getX(), block.getY(), block.getZ());
boolean workcell = bay != null;
if (!preview && !controlChest && !workcell) {
return null;
}
JigsawStudioVariant variant = bay == null
? null
: session.activeVariant(bay.stableId()).orElse(null);
boolean nonEditableWorkcell = bay != null && (variant == null || !variant.owned());
return new StudioBlockMutationContext(
studio,
request.requestId(),
request.ownerId(),
preview || controlChest || nonEditableWorkcell);
}
static List<Block> inventoryBlocks(Inventory inventory) {
if (inventory == null) {
return List.of();
}
List<Block> blocks = new ArrayList<>(2);
InventoryHolder holder = inventory.getHolder();
if (holder instanceof BlockInventoryHolder blockInventoryHolder) {
blocks.add(blockInventoryHolder.getBlock());
} else if (holder instanceof DoubleChest doubleChest) {
addInventoryBlock(blocks, doubleChest.getLeftSide());
addInventoryBlock(blocks, doubleChest.getRightSide());
}
return blocks;
}
private static void addInventoryBlock(List<Block> blocks, InventoryHolder holder) {
if (holder instanceof BlockInventoryHolder blockInventoryHolder
&& !blocks.contains(blockInventoryHolder.getBlock())) {
blocks.add(blockInventoryHolder.getBlock());
}
}
static boolean isMutatingCommand(String commandLine) {
return MUTATING_COMMANDS.contains(commandLabel(commandLine));
}
static boolean blocksMutatingCommand(UUID ownerId, UUID actorId, String commandLine) {
return !ownerMatches(ownerId, actorId)
&& !isSafeNonOwnerCommand(commandLine);
}
static boolean blocksNonEditableWorkcellMutation(
boolean hasNonEditableWorkcell,
String commandLine
) {
return hasNonEditableWorkcell && isMutatingCommand(commandLine);
}
static boolean canToggleVariantRotation(
JigsawStudioCompatibilityTarget compatibilityTarget,
JigsawStudioVariant variant
) {
JigsawStudioCompatibilityTarget target = Objects.requireNonNull(
compatibilityTarget,
"Jigsaw Studio compatibility target");
JigsawStudioVariant activeVariant = Objects.requireNonNull(variant, "Jigsaw Studio variant");
return activeVariant.owned()
&& (target != JigsawStudioCompatibilityTarget.VANILLA_PORTABLE
|| !activeVariant.rotatable());
}
static boolean isSafeNonOwnerCommand(String commandLine) {
String normalized = normalizeCommandLine(commandLine);
String label = commandLabel(normalized);
if (SAFE_NON_OWNER_COMMANDS.contains(label)) {
return true;
}
int separator = normalized.indexOf(' ');
if (!label.equals("iris") || separator < 0) {
return false;
}
String arguments = normalized.substring(separator + 1).trim();
return arguments.equals("jigsaw status") || arguments.startsWith("jigsaw status ");
}
private static String commandLabel(String commandLine) {
String normalized = normalizeCommandLine(commandLine);
int separator = normalized.indexOf(' ');
String label = separator < 0 ? normalized : normalized.substring(0, separator);
int namespace = label.lastIndexOf(':');
return namespace < 0 ? label : label.substring(namespace + 1);
}
private static String normalizeCommandLine(String commandLine) {
if (commandLine == null) {
return "";
}
String normalized = commandLine.trim().toLowerCase(Locale.ROOT);
while (normalized.startsWith("/")) {
normalized = normalized.substring(1);
}
return normalized;
}
static boolean ownerMatches(Player player, ActiveStudio studio) {
if (player == null || studio == null) {
return false;
}
UUID ownerId = studio.generator().getRequest().ownerId();
return ownerMatches(ownerId, player.getUniqueId());
}
static boolean canCreateVariants(JigsawStudioLayout layout) {
return Objects.requireNonNull(layout, "Jigsaw Studio layout")
.variantCatalog()
.editableGraph();
}
static String variantCreationSourceFailure(
JigsawStudioVariant activeVariant,
boolean duplicateActive
) {
if (activeVariant == null) {
return duplicateActive
? "This workcell has no active variant to duplicate."
: "This workcell has no active variant whose pool role can be copied. Use "
+ "/iris jigsaw piece create <poolKey> <pieceKey> to choose an owned pool explicitly.";
}
if (!activeVariant.owned()) {
return "Adopt or clone this graph before creating from its read-only variant.";
}
if (activeVariant.memberships().isEmpty()) {
return "The active variant has no owned pool membership to copy. Use "
+ "/iris jigsaw piece create <poolKey> <pieceKey> to choose an owned pool explicitly.";
}
return "";
}
static boolean ownerMatches(UUID ownerId, UUID actorId) {
return ownerId == null || ownerId.equals(actorId);
}
static boolean authorizeOwner(Player player, ActiveStudio studio) {
if (ownerMatches(player, studio)) {
return true;
}
message(player, "This Jigsaw Studio is owned by another player session.");
return false;
}
boolean isUnauthorizedStudioEdit(Player player, Block block) {
if (player == null || block == null) {
return false;
}
ActiveStudio studio = service.studios.get(block.getWorld().getUID());
if (studio == null) {
return false;
}
UUID requestId = studio.generator().getRequest().requestId();
if (service.previewRenderer.contains(requestId, block.getX(), block.getY(), block.getZ())) {
message(player, "The seed-1337 Jigsaw preview is read-only and refreshes automatically.");
return true;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay bay = session.layout().findAt(block.getX(), block.getY(), block.getZ());
JigsawStudioVariant variant = bay == null
? null
: session.activeVariant(bay.stableId()).orElse(null);
if (bay != null && (variant == null || !variant.owned())) {
message(player, variant == null
? "This empty Jigsaw Studio workcell has no loaded editable variant. Load one before editing it."
: "This Jigsaw Studio variant is read-only. Adopt or clone its graph before editing it.");
return true;
}
if (service.saveLifecycle.reopenRequiredRequests.contains(requestId)) {
message(player, "Close and reopen Jigsaw Studio before editing the resized layout.");
return true;
}
if (materializationInProgress(studio)) {
message(player, "Wait for the current Jigsaw Studio variant load or rollback to finish.");
return true;
}
if (!blocksStudioEdit(studio.generator().getRequest().ownerId(), player.getUniqueId())) {
return false;
}
message(player, "This Jigsaw Studio is owned by another player session.");
return true;
}
boolean materializationInProgress(ActiveStudio studio) {
if (studio == null) {
return false;
}
UUID requestId = studio.generator().getRequest().requestId();
synchronized (service.saveLifecycleLock) {
return service.materializer.materializationsInProgress.contains(requestId);
}
}
boolean anyMaterializationInProgress() {
synchronized (service.saveLifecycleLock) {
return !service.materializer.materializationsInProgress.isEmpty();
}
}
boolean anyNonEditableWorkcell() {
for (ActiveStudio studio : service.studios.values()) {
if (hasNonEditableWorkcell(studio)) {
return true;
}
}
return false;
}
static boolean hasNonEditableWorkcell(ActiveStudio studio) {
JigsawStudioSession session = studio.generator().getSession();
for (JigsawStudioBay bay : session.layout().bays()) {
JigsawStudioVariant variant = session.activeVariant(bay.stableId()).orElse(null);
if (variant == null || !variant.owned()) {
return true;
}
}
return false;
}
static boolean blocksStudioEdit(UUID ownerId, UUID actorId) {
return !ownerMatches(ownerId, actorId);
}
static boolean blocksStudioInventoryMutation(
boolean studioBlockInventory,
boolean immutable,
boolean materializing,
boolean reopenRequired,
UUID ownerId,
UUID actorId
) {
return studioBlockInventory
&& (immutable
|| materializing
|| reopenRequired
|| actorId != null && blocksStudioEdit(ownerId, actorId));
}
boolean movesProtectedBlocks(List<Block> blocks, BlockFace direction) {
for (Block block : blocks) {
if (isImmutableStudioBlock(block)
|| isImmutableStudioBlock(block.getRelative(direction))
|| isImmutableStudioBlock(block.getRelative(direction.getOppositeFace()))) {
return true;
}
}
return false;
}
boolean containsImmutableStudioBlock(List<BlockState> states) {
for (BlockState state : states) {
if (isImmutableStudioBlock(state.getBlock())) {
return true;
}
}
return false;
}
boolean isImmutableStudioBlock(Block block) {
StudioBlockMutationContext context = studioBlockMutationContext(block);
return context != null && context.immutable();
}
private boolean isPreviewBlock(Block block) {
if (block == null) {
return false;
}
ActiveStudio studio = service.studios.get(block.getWorld().getUID());
if (studio == null) {
return false;
}
UUID requestId = studio.generator().getRequest().requestId();
return service.previewRenderer.contains(requestId, block.getX(), block.getY(), block.getZ());
}
boolean isControlChest(Block block) {
if (block == null || block.getType() != Material.CHEST) {
return false;
}
ActiveStudio studio = service.studios.get(block.getWorld().getUID());
if (studio == null) {
return false;
}
JigsawStudioControlPosition control = studio.generator().getLayout().controlPosition();
return block.getX() == control.worldX()
&& block.getY() == control.worldY()
&& block.getZ() == control.worldZ();
}
private record StudioBlockMutationContext(
ActiveStudio studio,
UUID requestId,
UUID ownerId,
boolean immutable
) {
private StudioBlockMutationContext {
Objects.requireNonNull(studio, "Jigsaw Studio block mutation studio");
Objects.requireNonNull(requestId, "Jigsaw Studio block mutation request ID");
}
}
}
@@ -0,0 +1,710 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioTripleSneakTracker;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.spi.IrisLogging;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockBurnEvent;
import org.bukkit.event.block.BlockCookEvent;
import org.bukkit.event.block.BlockDispenseEvent;
import org.bukkit.event.block.BlockExplodeEvent;
import org.bukkit.event.block.BlockFadeEvent;
import org.bukkit.event.block.BlockFormEvent;
import org.bukkit.event.block.BlockFromToEvent;
import org.bukkit.event.block.BlockGrowEvent;
import org.bukkit.event.block.BlockMultiPlaceEvent;
import org.bukkit.event.block.BlockPistonExtendEvent;
import org.bukkit.event.block.BlockPistonRetractEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.block.BlockRedstoneEvent;
import org.bukkit.event.block.BlockSpreadEvent;
import org.bukkit.event.block.BrewingStartEvent;
import org.bukkit.event.block.CrafterCraftEvent;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.event.entity.EntityChangeBlockEvent;
import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.event.inventory.BrewEvent;
import org.bukkit.event.inventory.BrewingStandFuelEvent;
import org.bukkit.event.inventory.FurnaceBurnEvent;
import org.bukkit.event.inventory.FurnaceStartSmeltEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.event.inventory.InventoryMoveItemEvent;
import org.bukkit.event.inventory.InventoryPickupItemEvent;
import org.bukkit.event.player.PlayerBucketEmptyEvent;
import org.bukkit.event.player.PlayerBucketFillEvent;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerPortalEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.player.PlayerToggleSneakEvent;
import org.bukkit.event.server.ServerCommandEvent;
import org.bukkit.event.world.ChunkLoadEvent;
import org.bukkit.event.world.StructureGrowEvent;
import org.bukkit.event.world.WorldUnloadEvent;
import org.bukkit.inventory.EquipmentSlot;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import static art.arcane.iris.core.service.JigsawStudioPlayerContext.boardService;
import static art.arcane.iris.core.service.JigsawStudioProtection.authorizeOwner;
import static art.arcane.iris.core.service.JigsawStudioProtection.blocksMutatingCommand;
import static art.arcane.iris.core.service.JigsawStudioProtection.blocksNonEditableWorkcellMutation;
import static art.arcane.iris.core.service.JigsawStudioProtection.hasNonEditableWorkcell;
import static art.arcane.iris.core.service.JigsawStudioProtection.isMutatingCommand;
import static art.arcane.iris.core.service.JigsawStudioProtection.isSafeNonOwnerCommand;
import static art.arcane.iris.core.service.JigsawStudioProtection.ownerMatches;
import static art.arcane.iris.core.service.JigsawStudioService.isNaturalStudioSpawn;
import static art.arcane.iris.core.service.JigsawStudioService.message;
import static art.arcane.iris.core.service.JigsawStudioService.sameBlockPosition;
final class JigsawStudioProtectionListener implements Listener {
private final JigsawStudioService service;
JigsawStudioProtectionListener(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onWorldUnload(WorldUnloadEvent event) {
ActiveStudio studio = service.studios.get(event.getWorld().getUID());
if (studio != null) {
UUID requestId = studio.generator().getRequest().requestId();
service.tileWatcher.finalizeJigsawTileWatches(requestId);
if (service.saveLifecycle.requiresLifecycleDrain(studio)
&& !service.saveLifecycle.discardingRequest(requestId)) {
event.setCancelled(true);
service.autosaveScheduler.expediteAutosaves(requestId);
IrisLogging.warn("Jigsaw Studio kept world %s loaded while pending edits finish autosaving",
event.getWorld().getName());
return;
}
}
service.registry.unregister(event.getWorld());
}
@EventHandler
public void onChunkLoad(ChunkLoadEvent event) {
ActiveStudio studio = service.studios.get(event.getWorld().getUID());
if (studio != null) {
service.registry.markChunkAvailable(studio, event.getChunk().getX(), event.getChunk().getZ());
}
}
@EventHandler
public void onPlayerJoin(PlayerJoinEvent event) {
service.visualization.ensureVisualizationLoop(event.getPlayer());
service.playerContext.reconcilePlayerContext(event.getPlayer(), event.getPlayer().getLocation());
}
@EventHandler
public void onPlayerChangedWorld(PlayerChangedWorldEvent event) {
service.toolbelt.closeMenu(event.getPlayer());
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
service.tripleSneakTracker.clearPlayer(event.getPlayer().getUniqueId());
service.visualization.visualizationLoops.remove(event.getPlayer().getUniqueId());
service.visualization.assemblyPreviews.remove(event.getPlayer().getUniqueId());
service.playerContext.playerWorkcells.remove(event.getPlayer().getUniqueId());
service.visualization.ensureVisualizationLoop(event.getPlayer());
service.playerContext.reconcilePlayerContext(event.getPlayer(), event.getPlayer().getLocation());
}
@EventHandler
public void onPlayerQuit(PlayerQuitEvent event) {
UUID playerId = event.getPlayer().getUniqueId();
service.toolbelt.closeMenu(event.getPlayer());
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(playerId);
service.tripleSneakTracker.clearPlayer(playerId);
service.toolbelt.toolConfirmations.remove(playerId);
service.visualization.visualizationLoops.remove(playerId);
service.particlesDisabled.remove(playerId);
service.visualization.assemblyPreviews.remove(playerId);
service.playerContext.playerWorkcells.remove(playerId);
service.toolbelt.deferredDuplications.entrySet().removeIf(
entry -> entry.getValue().playerId().equals(playerId));
boardService().clearJigsawContext(event.getPlayer());
}
@EventHandler
public void onTripleSneak(PlayerToggleSneakEvent event) {
if (!event.isSneaking()) {
return;
}
Player player = event.getPlayer();
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !ownerMatches(player, studio)) {
service.tripleSneakTracker.clearPlayer(player.getUniqueId());
return;
}
JigsawStudioTripleSneakTracker.Progress progress = service.tripleSneakTracker.recordSneak(
player.getUniqueId(),
studio.worldId(),
studio.generator().getRequest().requestId(),
System.nanoTime());
if (progress == JigsawStudioTripleSneakTracker.Progress.TRIGGERED) {
service.toolbelt.openControlMenu(player);
}
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
public void onUseTool(PlayerInteractEvent event) {
if (event.getHand() != EquipmentSlot.HAND
|| (event.getAction() != Action.RIGHT_CLICK_AIR
&& event.getAction() != Action.RIGHT_CLICK_BLOCK)) {
return;
}
Optional<JigsawStudioToolPayload> decoded = service.toolbelt.toolCodec.decode(event.getItem());
if (decoded.isEmpty()) {
return;
}
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
event.setCancelled(true);
service.toolbelt.useTool(event.getPlayer(), decoded.get(), event.getItem(), event.getPlayer().isSneaking());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlayerMove(PlayerMoveEvent event) {
if (sameBlockPosition(event.getFrom(), event.getTo())) {
return;
}
service.playerContext.reconcilePlayerContext(event.getPlayer(), event.getTo());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlayerTeleport(PlayerTeleportEvent event) {
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
service.playerContext.reconcilePlayerContext(event.getPlayer(), event.getTo());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlayerPortal(PlayerPortalEvent event) {
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
service.playerContext.reconcilePlayerContext(event.getPlayer(), event.getTo());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerRespawn(PlayerRespawnEvent event) {
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
service.playerContext.reconcilePlayerContext(event.getPlayer(), event.getRespawnLocation());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onNaturalCreatureSpawn(CreatureSpawnEvent event) {
if (service.studios.containsKey(event.getLocation().getWorld().getUID())
&& isNaturalStudioSpawn(event.getSpawnReason())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onUnauthorizedBlockPlace(BlockPlaceEvent event) {
if (service.protection.isUnauthorizedStudioEdit(event.getPlayer(), event.getBlockPlaced())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onUnauthorizedBlockMultiPlace(BlockMultiPlaceEvent event) {
for (BlockState state : event.getReplacedBlockStates()) {
if (service.protection.isUnauthorizedStudioEdit(event.getPlayer(), state.getBlock())) {
event.setCancelled(true);
return;
}
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBlockBreak(BlockBreakEvent event) {
if (service.protection.isControlChest(event.getBlock())) {
event.setCancelled(true);
message(event.getPlayer(), "The Jigsaw Studio control chest cannot be removed.");
return;
}
if (service.protection.isUnauthorizedStudioEdit(event.getPlayer(), event.getBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onUnauthorizedBucketEmpty(PlayerBucketEmptyEvent event) {
Block target = event.getBlockClicked().getRelative(event.getBlockFace());
if (service.protection.isUnauthorizedStudioEdit(event.getPlayer(), target)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onUnauthorizedBucketFill(PlayerBucketFillEvent event) {
if (service.protection.isUnauthorizedStudioEdit(event.getPlayer(), event.getBlockClicked())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onUnauthorizedInteract(PlayerInteractEvent event) {
if (event.getAction() == Action.RIGHT_CLICK_BLOCK
&& !service.protection.isControlChest(event.getClickedBlock())
&& service.protection.isUnauthorizedStudioEdit(event.getPlayer(), event.getClickedBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockPlace(BlockPlaceEvent event) {
service.dirtyTracker.markDirty(event.getBlockPlaced());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockMultiPlace(BlockMultiPlaceEvent event) {
for (BlockState state : event.getReplacedBlockStates()) {
service.dirtyTracker.markDirty(state.getBlock());
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBucketEmpty(PlayerBucketEmptyEvent event) {
service.dirtyTracker.markDirty(event.getBlockClicked().getRelative(event.getBlockFace()));
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBucketFill(PlayerBucketFillEvent event) {
service.dirtyTracker.markDirty(event.getBlockClicked());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onControlChest(PlayerInteractEvent event) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK
|| event.getHand() != EquipmentSlot.HAND
|| !service.protection.isControlChest(event.getClickedBlock())) {
return;
}
event.setCancelled(true);
ActiveStudio studio = service.studios.get(event.getPlayer().getWorld().getUID());
if (!authorizeOwner(event.getPlayer(), studio)) {
return;
}
service.toolbelt.openControlMenu(event.getPlayer());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBlockExplode(BlockExplodeEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID()))) {
event.blockList().clear();
return;
}
event.blockList().removeIf(service.protection::isImmutableStudioBlock);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedEntityExplode(EntityExplodeEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getEntity().getWorld().getUID()))) {
event.blockList().clear();
return;
}
event.blockList().removeIf(service.protection::isImmutableStudioBlock);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedPistonExtend(BlockPistonExtendEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID()))
|| service.protection.movesProtectedBlocks(event.getBlocks(), event.getDirection())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedPistonRetract(BlockPistonRetractEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID()))
|| service.protection.movesProtectedBlocks(event.getBlocks(), event.getDirection())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedEntityChangeBlock(EntityChangeBlockEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID()))
|| service.protection.isImmutableStudioBlock(event.getBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBlockFromTo(BlockFromToEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID()))
|| service.protection.isImmutableStudioBlock(event.getBlock())
|| service.protection.isImmutableStudioBlock(event.getToBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBlockForm(BlockFormEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID()))
|| service.protection.isImmutableStudioBlock(event.getBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBlockGrow(BlockGrowEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID()))
|| service.protection.isImmutableStudioBlock(event.getBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBlockSpread(BlockSpreadEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID()))
|| service.protection.isImmutableStudioBlock(event.getBlock())
|| service.protection.isImmutableStudioBlock(event.getSource())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBlockFade(BlockFadeEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID()))
|| service.protection.isImmutableStudioBlock(event.getBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBlockBurn(BlockBurnEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID()))
|| service.protection.isImmutableStudioBlock(event.getBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onProtectedBlockRedstone(BlockRedstoneEvent event) {
if (service.protection.materializationInProgress(service.studios.get(event.getBlock().getWorld().getUID()))
|| service.protection.isImmutableStudioBlock(event.getBlock())) {
event.setNewCurrent(event.getOldCurrent());
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedStructureGrow(StructureGrowEvent event) {
if (event.getLocation().getWorld() != null
&& (service.protection.materializationInProgress(
service.studios.get(event.getLocation().getWorld().getUID()))
|| service.protection.containsImmutableStudioBlock(event.getBlocks()))) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedInventoryClick(InventoryClickEvent event) {
if (!service.protection.blocksInventoryMutation(
event.getView().getTopInventory(),
event.getWhoClicked().getUniqueId())) {
return;
}
event.setCancelled(true);
if (event.getWhoClicked() instanceof Player player) {
message(player, "This Jigsaw Studio container is read-only during the current operation or owner session.");
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedInventoryDrag(InventoryDragEvent event) {
if (!service.protection.blocksInventoryMutation(
event.getView().getTopInventory(),
event.getWhoClicked().getUniqueId())) {
return;
}
event.setCancelled(true);
if (event.getWhoClicked() instanceof Player player) {
message(player, "This Jigsaw Studio container is read-only during the current operation or owner session.");
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedInventoryMove(InventoryMoveItemEvent event) {
if (service.protection.blocksInventoryMutation(event.getSource(), null)
|| service.protection.blocksInventoryMutation(event.getDestination(), null)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedInventoryPickup(InventoryPickupItemEvent event) {
if (service.protection.blocksInventoryMutation(event.getInventory(), null)) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBlockCook(BlockCookEvent event) {
if (service.protection.blocksMachineMutation(event.getBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedFurnaceBurn(FurnaceBurnEvent event) {
if (service.protection.blocksMachineMutation(event.getBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBrew(BrewEvent event) {
if (service.protection.blocksMachineMutation(event.getBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBrewingStandFuel(BrewingStandFuelEvent event) {
if (service.protection.blocksMachineMutation(event.getBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedBlockDispense(BlockDispenseEvent event) {
if (service.protection.blocksMachineMutation(event.getBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProtectedCrafterCraft(CrafterCraftEvent event) {
if (service.protection.blocksMachineMutation(event.getBlock())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlayerInteract(PlayerInteractEvent event) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK && event.getAction() != Action.PHYSICAL) {
return;
}
Block clicked = event.getClickedBlock();
if (clicked != null) {
if (event.getAction() == Action.RIGHT_CLICK_BLOCK) {
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
if (clicked.getType() == Material.JIGSAW) {
service.tileWatcher.startJigsawTileWatch(event.getPlayer(), clicked);
}
}
service.dirtyTracker.markDirty(clicked);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInventoryClick(InventoryClickEvent event) {
service.dirtyTracker.markDirty(event.getView().getTopInventory());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInventoryDrag(InventoryDragEvent event) {
service.dirtyTracker.markDirty(event.getView().getTopInventory());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onInventoryClose(InventoryCloseEvent event) {
service.dirtyTracker.markDirty(event.getView().getTopInventory());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInventoryMove(InventoryMoveItemEvent event) {
service.dirtyTracker.markDirty(event.getSource());
service.dirtyTracker.markDirty(event.getDestination());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInventoryPickup(InventoryPickupItemEvent event) {
service.dirtyTracker.markDirty(event.getInventory());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockCook(BlockCookEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onFurnaceBurn(FurnaceBurnEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onFurnaceStartSmelt(FurnaceStartSmeltEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBrew(BrewEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onBrewingStart(BrewingStartEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBrewingStandFuel(BrewingStandFuelEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockDispense(BlockDispenseEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onCrafterCraft(CrafterCraftEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockExplode(BlockExplodeEvent event) {
service.dirtyTracker.markDirty(event.blockList());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onEntityExplode(EntityExplodeEvent event) {
service.dirtyTracker.markDirty(event.blockList());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onEntityChangeBlock(EntityChangeBlockEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockFromTo(BlockFromToEvent event) {
service.dirtyTracker.markDirty(event.getToBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockForm(BlockFormEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockGrow(BlockGrowEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockSpread(BlockSpreadEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockFade(BlockFadeEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockBurn(BlockBurnEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockRedstone(BlockRedstoneEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPistonExtend(BlockPistonExtendEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
service.dirtyTracker.markDirty(event.getBlock().getRelative(event.getDirection()));
for (Block block : event.getBlocks()) {
service.dirtyTracker.markDirty(block);
service.dirtyTracker.markDirty(block.getRelative(event.getDirection()));
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPistonRetract(BlockPistonRetractEvent event) {
service.dirtyTracker.markDirty(event.getBlock());
service.dirtyTracker.markDirty(event.getBlock().getRelative(event.getDirection()));
for (Block block : event.getBlocks()) {
service.dirtyTracker.markDirty(block);
service.dirtyTracker.markDirty(block.getRelative(event.getDirection()));
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onStructureGrow(StructureGrowEvent event) {
for (BlockState state : event.getBlocks()) {
service.dirtyTracker.markDirty(state.getBlock());
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onUnauthorizedPlayerCommand(PlayerCommandPreprocessEvent event) {
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId());
ActiveStudio studio = service.studios.get(event.getPlayer().getWorld().getUID());
if (studio != null
&& service.protection.materializationInProgress(studio)
&& !isSafeNonOwnerCommand(event.getMessage())) {
event.setCancelled(true);
message(event.getPlayer(), "Wait for the current Jigsaw Studio variant load or rollback to finish.");
return;
}
if (studio != null && blocksNonEditableWorkcellMutation(
hasNonEditableWorkcell(studio), event.getMessage())) {
event.setCancelled(true);
message(event.getPlayer(), "Mutating commands are disabled while this Studio has an empty or read-only workcell.");
return;
}
UUID ownerId = studio == null ? null : studio.generator().getRequest().ownerId();
if (!blocksMutatingCommand(ownerId, event.getPlayer().getUniqueId(), event.getMessage())) {
return;
}
event.setCancelled(true);
message(event.getPlayer(), "This Jigsaw Studio is owned by another player session.");
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPlayerCommand(PlayerCommandPreprocessEvent event) {
if (isMutatingCommand(event.getMessage())) {
service.dirtyTracker.markAllDirty(event.getPlayer().getWorld());
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onServerCommand(ServerCommandEvent event) {
if (!isSafeNonOwnerCommand(event.getCommand()) && service.protection.anyMaterializationInProgress()) {
event.setCancelled(true);
return;
}
if (isMutatingCommand(event.getCommand()) && service.protection.anyNonEditableWorkcell()) {
event.setCancelled(true);
return;
}
if (isMutatingCommand(event.getCommand())) {
service.dirtyTracker.markAllDirty();
}
}
}
@@ -0,0 +1,271 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.service.JigsawStudioChunkWriter.BayPopulation;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.World;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import static art.arcane.iris.core.service.JigsawStudioService.AUTOSAVE_RETRY_TICKS;
import static art.arcane.iris.core.service.JigsawStudioService.INSTANCE;
import static art.arcane.iris.core.service.JigsawStudioService.disableNaturalStudioSpawning;
final class JigsawStudioRegistry {
private final JigsawStudioService service;
private final Set<UUID> unregisterRetries = ConcurrentHashMap.newKeySet();
private final Set<UUID> unregisterDrainWarnings = ConcurrentHashMap.newKeySet();
JigsawStudioRegistry(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
void quiesceForServerShutdown() {
if (!service.disableStarted.compareAndSet(false, true)) {
return;
}
try {
service.tileWatcher.finalizeAllJigsawTileWatches();
} catch (Throwable exception) {
IrisLogging.reportError("Failed to finalize Jigsaw Studio tile watches during shutdown.", exception);
}
try {
service.autosaveScheduler.drainAutosavesBeforeDisable();
} catch (Throwable exception) {
IrisLogging.reportError("Failed to drain Jigsaw Studio autosaves during shutdown.", exception);
}
service.enabled = false;
JigsawStudioMenuController activeMenuController = service.toolbelt.menuController;
service.toolbelt.menuController = null;
if (activeMenuController != null) {
try {
activeMenuController.closeAll();
} catch (Throwable exception) {
IrisLogging.reportError("Failed to close Jigsaw Studio menus during shutdown.", exception);
}
}
service.particlesDisabled.clear();
service.visualization.visualizationLoops.clear();
service.visualization.assemblyPreviews.clear();
service.playerContext.playerWorkcells.clear();
service.autosaveScheduler.autosaves.clear();
service.toolbelt.deferredDuplications.clear();
service.tileWatcher.jigsawTileWatches.clear();
service.toolbelt.toolConfirmations.clear();
service.tripleSneakTracker.clearAll();
service.evaluator.evaluations.clear();
try {
service.previewRenderer.removeAll();
} catch (Throwable exception) {
IrisLogging.reportError("Failed to remove Jigsaw Studio previews during shutdown.", exception);
}
service.saveLifecycle.reopenRequiredRequests.clear();
unregisterRetries.clear();
unregisterDrainWarnings.clear();
synchronized (service.saveLifecycleLock) {
service.studios.clear();
service.saveLifecycle.savesInProgress.clear();
service.graphMutations.graphMutationsInProgress.clear();
service.materializer.materializationsInProgress.clear();
service.saveLifecycle.exportsInProgress.clear();
service.saveLifecycle.closingRequests.clear();
service.saveLifecycle.discardingRequests.clear();
}
INSTANCE = null;
}
void register(Engine engine, JigsawStudioGenerator generator) {
if (!service.enabled || service.disableStarted.get()) {
return;
}
Engine activeEngine = Objects.requireNonNull(engine, "Jigsaw Studio engine");
JigsawStudioGenerator activeGenerator = Objects.requireNonNull(generator, "Jigsaw Studio generator");
World world = BukkitWorldBinding.world(activeEngine.getTarget().getWorld());
if (world == null) {
return;
}
ActiveStudio existing = service.studios.get(world.getUID());
if (existing != null && existing.generator() == activeGenerator) {
return;
}
if (existing != null) {
UUID existingRequestId = existing.generator().getRequest().requestId();
service.tileWatcher.finalizeJigsawTileWatches(existingRequestId);
service.autosaveScheduler.drainAutosavesBeforeRemoval(existing);
if (service.saveLifecycle.requiresLifecycleDrain(existing)
&& !service.saveLifecycle.discardingRequest(existingRequestId)) {
IrisLogging.warn("Jigsaw Studio registration deferred while request %s finishes its final autosave drain",
existingRequestId);
return;
}
}
ActiveStudio next = new ActiveStudio(
world.getUID(),
world,
activeEngine,
activeGenerator,
new ConcurrentHashMap<>(),
ConcurrentHashMap.newKeySet(),
new AtomicLong());
UUID displacedRequestId = null;
synchronized (service.saveLifecycleLock) {
if (!service.enabled || service.disableStarted.get()) {
return;
}
ActiveStudio previous = service.studios.get(world.getUID());
if (previous != null && previous.generator() == activeGenerator) {
return;
}
ActiveStudio displaced = service.studios.put(world.getUID(), next);
if (displaced != null) {
displacedRequestId = displaced.generator().getRequest().requestId();
service.saveLifecycle.savesInProgress.remove(displacedRequestId);
service.graphMutations.graphMutationsInProgress.remove(displacedRequestId);
service.materializer.materializationsInProgress.remove(displacedRequestId);
service.saveLifecycle.exportsInProgress.remove(displacedRequestId);
service.saveLifecycle.closingRequests.remove(displacedRequestId);
service.saveLifecycle.discardingRequests.remove(displacedRequestId);
service.saveLifecycle.reopenRequiredRequests.remove(displacedRequestId);
}
}
if (displacedRequestId != null) {
service.autosaveScheduler.clearAutosaves(displacedRequestId);
service.tileWatcher.clearJigsawTileWatches(displacedRequestId);
unregisterRetries.remove(displacedRequestId);
unregisterDrainWarnings.remove(displacedRequestId);
service.tripleSneakTracker.clearRequest(displacedRequestId);
service.evaluator.evaluations.remove(displacedRequestId);
service.previewRenderer.removeRequest(displacedRequestId);
}
IrisLogging.debug("Jigsaw Studio authoring registered: world=%s structure=%s bays=%d",
world.getName(), activeGenerator.getSession().structureKey(), activeGenerator.getLayout().bays().size());
service.evaluator.scheduleInitialEvaluation(next);
service.playerContext.scheduleOnlinePlayers(world.getUID());
}
void activationCommitted(World world, UUID requestId) {
if (!service.enabled || service.disableStarted.get() || world == null || requestId == null) {
return;
}
ActiveStudio studio = service.studios.get(world.getUID());
if (studio == null || !requestId.equals(studio.generator().getRequest().requestId())) {
return;
}
disableNaturalStudioSpawning(world);
service.evaluator.scheduleInitialEvaluation(studio);
}
void markChunkGenerated(
Engine engine,
JigsawStudioGenerator generator,
int chunkX,
int chunkZ
) {
if (!service.enabled || service.disableStarted.get() || engine == null || generator == null) {
return;
}
World world = BukkitWorldBinding.world(engine.getTarget().getWorld());
if (world == null) {
return;
}
ActiveStudio studio = service.studios.get(world.getUID());
if (!service.enabled || service.disableStarted.get()
|| studio == null || studio.engine() != engine || studio.generator() != generator) {
return;
}
markChunkAvailable(studio, chunkX, chunkZ);
}
void markChunkAvailable(ActiveStudio studio, int chunkX, int chunkZ) {
long chunkKey = JigsawStudioService.chunkKey(chunkX, chunkZ);
boolean relevant = false;
for (JigsawStudioBay bay : studio.generator().getLayout().bays()) {
BayPopulation population = studio.population(bay);
if (population.markGenerated(chunkKey)) {
relevant = true;
}
}
if (relevant) {
service.materializer.scheduleHydration(studio, studio.world(), chunkX, chunkZ, 0);
}
}
void unregister(World world) {
if (world == null) {
return;
}
ActiveStudio active = service.studios.get(world.getUID());
if (active != null) {
UUID activeRequestId = active.generator().getRequest().requestId();
service.tileWatcher.finalizeJigsawTileWatches(activeRequestId);
service.autosaveScheduler.drainAutosavesBeforeRemoval(active);
if (service.saveLifecycle.requiresLifecycleDrain(active)
&& !service.saveLifecycle.discardingRequest(activeRequestId)) {
if (unregisterDrainWarnings.add(activeRequestId)) {
IrisLogging.warn("Jigsaw Studio unregister deferred while request %s finishes its final autosave drain",
activeRequestId);
}
scheduleUnregisterRetry(world, activeRequestId);
return;
}
}
ActiveStudio removed;
JigsawStudioActivation.Request request;
synchronized (service.saveLifecycleLock) {
removed = service.studios.remove(world.getUID());
if (removed == null) {
return;
}
request = removed.generator().getRequest();
service.saveLifecycle.savesInProgress.remove(request.requestId());
service.graphMutations.graphMutationsInProgress.remove(request.requestId());
service.materializer.materializationsInProgress.remove(request.requestId());
service.saveLifecycle.exportsInProgress.remove(request.requestId());
service.saveLifecycle.closingRequests.remove(request.requestId());
service.saveLifecycle.discardingRequests.remove(request.requestId());
service.saveLifecycle.reopenRequiredRequests.remove(request.requestId());
}
service.autosaveScheduler.clearAutosaves(request.requestId());
service.toolbelt.deferredDuplications.remove(request.requestId());
service.tileWatcher.clearJigsawTileWatches(request.requestId());
unregisterRetries.remove(request.requestId());
unregisterDrainWarnings.remove(request.requestId());
service.toolbelt.toolConfirmations.entrySet().removeIf(
entry -> entry.getValue().payload().requestId().equals(request.requestId()));
service.tripleSneakTracker.clearRequest(request.requestId());
service.evaluator.evaluations.remove(request.requestId());
service.previewRenderer.forgetRequest(request.requestId());
JigsawStudioActivation.deactivate(request.packKey(), request.requestId());
service.playerContext.clearWorldPlayerContexts(world.getUID());
IrisLogging.debug("Jigsaw Studio authoring unregistered: world=%s", world.getName());
}
private void scheduleUnregisterRetry(World world, UUID requestId) {
if (!service.enabled || !unregisterRetries.add(requestId)) {
return;
}
try {
J.s(() -> {
unregisterRetries.remove(requestId);
ActiveStudio current = service.studios.get(world.getUID());
if (current != null
&& current.generator().getRequest().requestId().equals(requestId)) {
unregister(world);
}
}, AUTOSAVE_RETRY_TICKS);
} catch (Throwable exception) {
unregisterRetries.remove(requestId);
IrisLogging.reportError(exception);
}
}
}
@@ -0,0 +1,512 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioAutosaveScheduler.AutosaveKey;
import art.arcane.iris.core.service.JigsawStudioAutosaveScheduler.AutosavePersistentFailure;
import art.arcane.iris.core.service.JigsawStudioCaptureScheduler.CaptureTarget;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.core.service.JigsawStudioService.CloseStart;
import art.arcane.iris.core.service.JigsawStudioService.ExportStart;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import static art.arcane.iris.core.service.JigsawStudioCaptureScheduler.resolveCaptureTarget;
import static art.arcane.iris.core.service.JigsawStudioProtection.authorizeOwner;
import static art.arcane.iris.core.service.JigsawStudioService.AUTOSAVE_RETRY_TICKS;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
import static art.arcane.iris.core.service.JigsawStudioService.findBay;
import static art.arcane.iris.core.service.JigsawStudioService.message;
final class JigsawStudioSaveLifecycle {
private static final int REPLACEMENT_CLOSE_WAIT_TICKS = 2_400;
private final JigsawStudioService service;
final Set<UUID> savesInProgress = new HashSet<>();
final Set<UUID> exportsInProgress = new HashSet<>();
final Set<UUID> closingRequests = new HashSet<>();
final Set<UUID> discardingRequests = new HashSet<>();
final Set<UUID> reopenRequiredRequests = ConcurrentHashMap.newKeySet();
JigsawStudioSaveLifecycle(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
CloseStart tryBeginClose(UUID requestId, UUID ownerId, boolean discard) {
if (requestId == null) {
return CloseStart.NOT_ACTIVE;
}
service.tileWatcher.finalizeJigsawTileWatches(requestId);
synchronized (service.saveLifecycleLock) {
JigsawStudioActivation.Request request = JigsawStudioActivation.getRequest(requestId);
JigsawStudioSession session = JigsawStudioActivation.getSession(requestId);
if (request == null || session == null) {
return CloseStart.NOT_ACTIVE;
}
if (request.ownerId() != null && !request.ownerId().equals(ownerId)) {
return CloseStart.NOT_OWNER;
}
if (savesInProgress.contains(requestId)) {
return CloseStart.SAVE_IN_PROGRESS;
}
if (service.graphMutations.graphMutationsInProgress.contains(requestId)) {
return CloseStart.OPERATION_IN_PROGRESS;
}
if (exportsInProgress.contains(requestId)) {
return CloseStart.OPERATION_IN_PROGRESS;
}
if (service.materializer.materializationsInProgress.contains(requestId)) {
return CloseStart.OPERATION_IN_PROGRESS;
}
if (session.operationInProgress()) {
return CloseStart.OPERATION_IN_PROGRESS;
}
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
return CloseStart.OPERATION_IN_PROGRESS;
}
if (session.isDirty() && !discard) {
return CloseStart.DIRTY;
}
closingRequests.add(requestId);
if (discard) {
discardingRequests.add(requestId);
} else {
discardingRequests.remove(requestId);
}
return CloseStart.STARTED;
}
}
String closeProtectionFailure(UUID requestId) {
if (requestId == null) {
return null;
}
synchronized (service.saveLifecycleLock) {
if (closingRequests.contains(requestId) && !savesInProgress.contains(requestId)) {
return null;
}
if (savesInProgress.contains(requestId)) {
return "The active Jigsaw Studio is saving and cannot be closed or replaced yet.";
}
if (service.graphMutations.graphMutationsInProgress.contains(requestId)) {
return "The active Jigsaw Studio is updating its graph and cannot be closed or replaced yet.";
}
if (exportsInProgress.contains(requestId)) {
return "The active Jigsaw Studio is exporting and cannot be closed or replaced yet.";
}
if (service.materializer.materializationsInProgress.contains(requestId)) {
return "The active Jigsaw Studio is loading or restoring a variant and cannot be closed yet.";
}
JigsawStudioSession session = JigsawStudioActivation.getSession(requestId);
if (session != null && session.operationInProgress()) {
return "The active Jigsaw Studio is loading a variant and cannot be closed or replaced yet.";
}
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
return "The active Jigsaw Studio is finalizing an open vanilla jigsaw-block editor.";
}
if (session != null && session.isDirty()) {
return "The active Jigsaw Studio is waiting for autosave. Let it finish before closing.";
}
return "The active Jigsaw Studio is owner-controlled. Close it with /iris jigsaw close.";
}
}
CompletableFuture<Void> awaitCloseForReplacement(UUID requestId, UUID ownerId) {
CompletableFuture<Void> readiness = new CompletableFuture<>();
awaitCloseForReplacement(requestId, ownerId, readiness, 0);
return readiness;
}
private void awaitCloseForReplacement(
UUID requestId,
UUID ownerId,
CompletableFuture<Void> readiness,
int waitedTicks
) {
if (readiness.isDone()) {
return;
}
CloseStart closeStart = tryBeginClose(requestId, ownerId, false);
switch (closeStart) {
case STARTED, NOT_ACTIVE -> readiness.complete(null);
case NOT_OWNER -> readiness.completeExceptionally(new IllegalStateException(
"The active Jigsaw Studio is owned by another player session."));
case DIRTY, SAVE_IN_PROGRESS, OPERATION_IN_PROGRESS -> {
if (waitedTicks == 0) {
service.autosaveScheduler.expediteAutosaves(requestId);
}
if (waitedTicks >= REPLACEMENT_CLOSE_WAIT_TICKS) {
String failure = closeProtectionFailure(requestId);
readiness.completeExceptionally(new IllegalStateException(
failure == null
? "The active Jigsaw Studio did not become ready for replacement."
: failure));
return;
}
try {
J.s(() -> awaitCloseForReplacement(
requestId,
ownerId,
readiness,
waitedTicks + AUTOSAVE_RETRY_TICKS), AUTOSAVE_RETRY_TICKS);
} catch (Throwable exception) {
readiness.completeExceptionally(exception);
}
}
}
}
void cancelClose(UUID requestId) {
if (requestId == null) {
return;
}
synchronized (service.saveLifecycleLock) {
closingRequests.remove(requestId);
discardingRequests.remove(requestId);
}
}
SaveStart tryBeginSave(UUID requestId) {
Objects.requireNonNull(requestId, "Jigsaw Studio save request ID");
synchronized (service.saveLifecycleLock) {
if (closingRequests.contains(requestId)) {
return SaveStart.CLOSING;
}
if (service.graphMutations.graphMutationsInProgress.contains(requestId)) {
return SaveStart.GRAPH_OPERATION;
}
if (exportsInProgress.contains(requestId)) {
return SaveStart.EXPORT_OPERATION;
}
if (service.materializer.materializationsInProgress.contains(requestId)) {
return SaveStart.VARIANT_OPERATION;
}
if (!savesInProgress.add(requestId)) {
return SaveStart.IN_PROGRESS;
}
return SaveStart.STARTED;
}
}
void finishSave(UUID requestId) {
if (requestId == null) {
return;
}
synchronized (service.saveLifecycleLock) {
savesInProgress.remove(requestId);
}
}
ExportStart tryBeginExport(UUID requestId, UUID ownerId) {
Objects.requireNonNull(requestId, "Jigsaw Studio export request ID");
Objects.requireNonNull(ownerId, "Jigsaw Studio export owner ID");
service.tileWatcher.finalizeJigsawTileWatches(requestId);
synchronized (service.saveLifecycleLock) {
JigsawStudioActivation.Request request = JigsawStudioActivation.getRequest(requestId);
JigsawStudioSession session = JigsawStudioActivation.getSession(requestId);
if (request == null || session == null) {
return ExportStart.NOT_ACTIVE;
}
if (request.ownerId() != null && !request.ownerId().equals(ownerId)) {
return ExportStart.NOT_OWNER;
}
if (closingRequests.contains(requestId)) {
return ExportStart.CLOSING;
}
if (savesInProgress.contains(requestId)) {
return ExportStart.SAVE_IN_PROGRESS;
}
if (service.graphMutations.graphMutationsInProgress.contains(requestId)) {
return ExportStart.OPERATION_IN_PROGRESS;
}
if (service.materializer.materializationsInProgress.contains(requestId) || session.operationInProgress()) {
return ExportStart.OPERATION_IN_PROGRESS;
}
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
return ExportStart.OPERATION_IN_PROGRESS;
}
if (session.isDirty()) {
return ExportStart.DIRTY;
}
if (!exportsInProgress.add(requestId)) {
return ExportStart.IN_PROGRESS;
}
return ExportStart.STARTED;
}
}
void finishExport(UUID requestId) {
if (requestId == null) {
return;
}
synchronized (service.saveLifecycleLock) {
exportsInProgress.remove(requestId);
}
}
boolean saveSelected(Player player) {
if (player == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> saveSelected(player));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null) {
message(player, "Iris Jigsaw Studio is not active in this world.");
return false;
}
if (!authorizeOwner(player, studio)) {
return false;
}
if (reopenRequiredRequests.contains(studio.generator().getRequest().requestId())) {
message(player, "Close and reopen Jigsaw Studio before loading variants in the resized layout.");
return false;
}
if (service.graphMutations.graphMutationInProgress(studio.generator().getRequest().requestId())) {
message(player, "Wait for the current Jigsaw Studio graph update to finish.");
return false;
}
UUID requestId = studio.generator().getRequest().requestId();
service.tileWatcher.finalizeJigsawTileWatches(requestId);
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
message(player, "Wait for the open vanilla jigsaw-block editor to finish its final snapshot.");
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String selected = session.selectedBayId().orElse(null);
if (selected == null) {
Location location = player.getLocation();
JigsawStudioBay underPlayer = session.layout().findAt(
location.getBlockX(), location.getBlockY(), location.getBlockZ());
if (underPlayer != null) {
selected = underPlayer.stableId();
session.selectBay(selected);
}
}
if (selected == null) {
message(player, "Stand inside or select a Jigsaw Studio workcell before saving.");
return false;
}
return saveBay(player, selected);
}
boolean saveBay(Player player, String bayId) {
if (player == null || bayId == null || bayId.isBlank()) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> saveBay(player, bayId));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null) {
message(player, "Iris Jigsaw Studio is not active in this world.");
return false;
}
if (!authorizeOwner(player, studio)) {
return false;
}
JigsawStudioBay bay = findBay(studio.generator().getSession().layout(), bayId);
if (bay == null) {
message(player, "No Jigsaw Studio bay matches '" + bayId + "'.");
return false;
}
return startSave(studio, player.getWorld(), bay, player, true) == SaveAttempt.STARTED;
}
SaveAttempt startSave(
ActiveStudio studio,
World world,
JigsawStudioBay bay,
Player player,
boolean report
) {
UUID requestId = studio.generator().getRequest().requestId();
if (reopenRequiredRequests.contains(requestId)) {
JigsawStudioService.report(
player, report, "Close and reopen Jigsaw Studio before saving the resized layout.");
return SaveAttempt.DEFERRED;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null);
if (activeVariant == null) {
JigsawStudioService.report(
player, report, "Workcell '" + bay.stableId() + "' has no active variant to save.");
return SaveAttempt.DEFERRED;
}
if (!activeVariant.owned()) {
JigsawStudioService.report(player, report, "Variant '" + activeVariant.pieceKey()
+ "' is read-only. Adopt or clone its graph before editing it.");
return SaveAttempt.DEFERRED;
}
BayReadiness readiness = studio.population(bay).readiness();
if (!readiness.ready()) {
JigsawStudioService.report(player, report, readinessMessage(bay, readiness));
return readiness.failure().isEmpty() ? SaveAttempt.RETRY : SaveAttempt.DEFERRED;
}
CaptureTarget captureTarget;
try {
captureTarget = resolveCaptureTarget(studio, bay, activeVariant);
} catch (IOException exception) {
String failure = "Jigsaw Studio cannot capture this workcell: " + failureMessage(exception);
JigsawStudioService.report(player, report, failure);
return service.autosaveScheduler.retainCurrentAutosaveFailure(
studio,
bay.stableId(),
new AutosavePersistentFailure(failure, exception))
? SaveAttempt.PERSISTENT_FAILURE
: SaveAttempt.DEFERRED;
}
JigsawStudioSession.SaveStart sessionSave = session.beginSave(bay.stableId());
if (sessionSave.status() != JigsawStudioSession.SaveStatus.STARTED) {
JigsawStudioService.report(player, report, switch (sessionSave.status()) {
case SWITCH_IN_PROGRESS -> "This workcell is loading another variant.";
case SAVE_IN_PROGRESS -> "This workcell is already saving.";
case NO_ACTIVE_VARIANT -> "This workcell has no active variant.";
case UNKNOWN_WORKCELL -> "This workcell is no longer active.";
case STARTED -> "The workcell save could not start.";
});
return sessionSave.status() == JigsawStudioSession.SaveStatus.SAVE_IN_PROGRESS
|| sessionSave.status() == JigsawStudioSession.SaveStatus.SWITCH_IN_PROGRESS
? SaveAttempt.RETRY
: SaveAttempt.DEFERRED;
}
JigsawStudioSession.SaveIdentity saveIdentity = sessionSave.identity().orElseThrow();
SaveStart saveStart = tryBeginSave(requestId);
if (saveStart == SaveStart.CLOSING) {
session.abortSave(saveIdentity);
JigsawStudioService.report(player, report, "This Jigsaw Studio is closing and cannot start another save.");
return SaveAttempt.DEFERRED;
}
if (saveStart == SaveStart.IN_PROGRESS) {
session.abortSave(saveIdentity);
JigsawStudioService.report(player, report, "A save is already running for this Jigsaw Studio.");
return SaveAttempt.RETRY;
}
if (saveStart == SaveStart.GRAPH_OPERATION) {
session.abortSave(saveIdentity);
JigsawStudioService.report(player, report, "Wait for the current Jigsaw Studio graph update to finish.");
return SaveAttempt.RETRY;
}
if (saveStart == SaveStart.EXPORT_OPERATION) {
session.abortSave(saveIdentity);
JigsawStudioService.report(player, report, "Wait for the current Jigsaw Studio export to finish.");
return SaveAttempt.RETRY;
}
if (saveStart == SaveStart.VARIANT_OPERATION) {
session.abortSave(saveIdentity);
JigsawStudioService.report(
player, report, "Wait for the current Jigsaw Studio variant load or rollback to finish.");
return SaveAttempt.RETRY;
}
if (report) {
session.selectBay(bay.stableId());
}
service.playerContext.refreshWorkcellContext(studio.worldId(), bay.stableId());
JigsawStudioService.report(player, report, "Capturing variant '" + activeVariant.pieceKey() + "' from "
+ bay.stableId() + "...");
try {
boolean scheduled = service.captureScheduler.scheduleCapture(
studio,
world,
bay,
captureTarget,
player,
requestId,
saveIdentity,
service.autosaveScheduler.autosaveFailureState(studio, saveIdentity));
return scheduled ? SaveAttempt.STARTED : SaveAttempt.DEFERRED;
} catch (Throwable exception) {
session.abortSave(saveIdentity);
finishSave(requestId);
service.playerContext.refreshWorkcellContext(studio.worldId(), bay.stableId());
IrisLogging.reportError(exception);
JigsawStudioService.report(player, report, "Jigsaw Studio could not schedule the workcell capture: "
+ failureMessage(exception));
return SaveAttempt.DEFERRED;
}
}
boolean requiresLifecycleDrain(ActiveStudio studio) {
UUID requestId = studio.generator().getRequest().requestId();
if (service.tileWatcher.hasJigsawTileWatch(requestId)
|| studio.generator().getSession().isDirty()
|| studio.generator().getSession().operationInProgress()) {
return true;
}
for (AutosaveKey key : service.autosaveScheduler.autosaves.keySet()) {
if (requestId.equals(key.requestId())) {
return true;
}
}
synchronized (service.saveLifecycleLock) {
return savesInProgress.contains(requestId)
|| service.graphMutations.graphMutationsInProgress.contains(requestId)
|| service.materializer.materializationsInProgress.contains(requestId)
|| exportsInProgress.contains(requestId);
}
}
boolean discardingRequest(UUID requestId) {
synchronized (service.saveLifecycleLock) {
return discardingRequests.contains(requestId);
}
}
boolean isCurrentSave(
ActiveStudio studio,
UUID requestId,
JigsawStudioSession.SaveIdentity identity
) {
return service.isCurrentRequest(studio, requestId)
&& studio.generator().getSession().isSaveCurrent(identity);
}
private static String readinessMessage(JigsawStudioBay bay, BayReadiness readiness) {
if (!readiness.failure().isEmpty()) {
return "Bay '" + bay.stableId() + "' is invalid and cannot be saved: " + readiness.failure();
}
return "Bay '" + bay.stableId() + "' is not ready to save: "
+ readiness.generatedChunks() + "/" + readiness.requiredChunks() + " chunk(s) populated, "
+ readiness.hydratedChunks() + "/" + readiness.requiredChunks()
+ " chunk(s) hydrated. Visit the whole bay and wait for its markers to finish loading.";
}
record BayReadiness(
boolean ready,
String failure,
int requiredChunks,
int generatedChunks,
int hydratedChunks
) {
}
enum SaveStart {
STARTED,
IN_PROGRESS,
CLOSING,
GRAPH_OPERATION,
EXPORT_OPERATION,
VARIANT_OPERATION
}
enum SaveAttempt {
STARTED,
RETRY,
DEFERRED,
PERSISTENT_FAILURE
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,340 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.collection.KMap;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import static art.arcane.iris.core.service.JigsawStudioService.AUTOSAVE_RETRY_TICKS;
final class JigsawStudioTileWatcher {
private static final int JIGSAW_TILE_WATCH_INTERVAL_TICKS = 5;
private final JigsawStudioService service;
final Map<JigsawTileWatchKey, JigsawTileWatch> jigsawTileWatches = new ConcurrentHashMap<>();
JigsawStudioTileWatcher(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
void startJigsawTileWatch(Player player, Block block) {
if (player == null || block == null || block.getType() != Material.JIGSAW) {
return;
}
World world = block.getWorld();
int worldX = block.getX();
int worldY = block.getY();
int worldZ = block.getZ();
UUID playerId = player.getUniqueId();
if (J.isOwnedByCurrentRegion(world, worldX >> 4, worldZ >> 4)) {
initializeJigsawTileWatch(playerId, world, worldX, worldY, worldZ);
return;
}
boolean scheduled = J.runRegion(
world,
worldX >> 4,
worldZ >> 4,
() -> initializeJigsawTileWatch(playerId, world, worldX, worldY, worldZ));
if (!scheduled) {
IrisLogging.warn("Jigsaw Studio could not watch jigsaw marker NBT at %d,%d,%d in %s",
worldX, worldY, worldZ, world.getName());
}
}
private void initializeJigsawTileWatch(
UUID playerId,
World world,
int worldX,
int worldY,
int worldZ
) {
ActiveStudio studio = service.studios.get(world.getUID());
if (!service.enabled || studio == null) {
return;
}
Block block = world.getBlockAt(worldX, worldY, worldZ);
if (block.getType() != Material.JIGSAW) {
return;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay bay = session.layout().findAt(worldX, worldY, worldZ);
JigsawStudioVariant variant = bay == null
? null
: session.activeVariant(bay.stableId()).orElse(null);
if (bay == null || variant == null || !variant.owned()) {
return;
}
KMap<String, Object> snapshot;
try {
snapshot = BukkitPlatform.serializeTile(block.getLocation());
} catch (Throwable exception) {
IrisLogging.reportError(exception);
return;
}
if (snapshot == null) {
IrisLogging.warn("Jigsaw Studio could not read jigsaw marker NBT at %d,%d,%d in %s",
worldX, worldY, worldZ, world.getName());
return;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
JigsawTileWatchKey key = new JigsawTileWatchKey(
request.requestId(), world.getUID(), worldX, worldY, worldZ);
JigsawTileWatch watch = new JigsawTileWatch(
key,
studio,
playerId,
bay.stableId(),
snapshot,
new AtomicBoolean(false),
new AtomicBoolean(false));
jigsawTileWatches.put(key, watch);
scheduleJigsawTileWatch(watch);
}
private void scheduleJigsawTileWatch(JigsawTileWatch watch) {
boolean scheduled = J.runRegion(
watch.studio().world(),
watch.key().worldX() >> 4,
watch.key().worldZ() >> 4,
() -> runJigsawTileWatch(watch, false),
JIGSAW_TILE_WATCH_INTERVAL_TICKS);
if (!scheduled && jigsawTileWatches.get(watch.key()) == watch) {
warnJigsawTileWatchScheduleFailure(watch);
scheduleJigsawTileWatchReconciliation(watch, false);
}
}
private void runJigsawTileWatch(JigsawTileWatch watch, boolean release) {
if (jigsawTileWatches.get(watch.key()) != watch) {
return;
}
ActiveStudio studio = service.studios.get(watch.key().worldId());
if (!service.enabled || studio != watch.studio()
|| !service.isCurrentRequest(studio, watch.key().requestId())) {
jigsawTileWatches.remove(watch.key(), watch);
return;
}
Block block = studio.world().getBlockAt(
watch.key().worldX(), watch.key().worldY(), watch.key().worldZ());
JigsawStudioBay bay = studio.generator().getSession().layout().findAt(
watch.key().worldX(), watch.key().worldY(), watch.key().worldZ());
if (block.getType() != Material.JIGSAW
|| bay == null
|| !bay.stableId().equals(watch.workcellId())) {
jigsawTileWatches.remove(watch.key(), watch);
return;
}
KMap<String, Object> snapshot;
try {
snapshot = BukkitPlatform.serializeTile(block.getLocation());
} catch (Throwable exception) {
jigsawTileWatches.remove(watch.key(), watch);
IrisLogging.reportError(exception);
return;
}
if (snapshot == null) {
jigsawTileWatches.remove(watch.key(), watch);
IrisLogging.warn("Jigsaw Studio stopped watching unreadable jigsaw marker NBT at %d,%d,%d in %s",
watch.key().worldX(),
watch.key().worldY(),
watch.key().worldZ(),
studio.world().getName());
return;
}
JigsawTilePollDecision decision = jigsawTilePollDecision(
watch.snapshot(), snapshot, release);
if (decision.changed()) {
service.dirtyTracker.markDirty(
studio.world(),
watch.key().worldX(),
watch.key().worldY(),
watch.key().worldZ());
}
if (!decision.continueWatching()) {
jigsawTileWatches.remove(watch.key(), watch);
return;
}
JigsawTileWatch next = new JigsawTileWatch(
watch.key(),
watch.studio(),
watch.playerId(),
watch.workcellId(),
snapshot,
watch.scheduleFailureLogged(),
new AtomicBoolean(false));
if (jigsawTileWatches.replace(watch.key(), watch, next)) {
scheduleJigsawTileWatch(next);
}
}
boolean hasJigsawTileWatch(UUID requestId) {
if (requestId == null) {
return false;
}
for (JigsawTileWatchKey key : jigsawTileWatches.keySet()) {
if (requestId.equals(key.requestId())) {
return true;
}
}
return false;
}
void clearJigsawTileWatches(UUID requestId) {
if (requestId != null) {
jigsawTileWatches.keySet().removeIf(key -> requestId.equals(key.requestId()));
}
}
void finalizeJigsawTileWatchesForPlayer(UUID playerId) {
if (playerId == null) {
return;
}
for (JigsawTileWatch watch : List.copyOf(jigsawTileWatches.values())) {
if (playerId.equals(watch.playerId())) {
finalizeJigsawTileWatch(watch);
}
}
}
void finalizeJigsawTileWatches(UUID requestId) {
if (requestId == null) {
return;
}
for (JigsawTileWatch watch : List.copyOf(jigsawTileWatches.values())) {
if (requestId.equals(watch.key().requestId())) {
finalizeJigsawTileWatch(watch);
}
}
}
void finalizeAllJigsawTileWatches() {
for (JigsawTileWatch watch : List.copyOf(jigsawTileWatches.values())) {
try {
finalizeJigsawTileWatch(watch);
} catch (Throwable exception) {
IrisLogging.reportError("Failed to finalize a Jigsaw Studio tile watch during shutdown.", exception);
}
}
}
private void finalizeJigsawTileWatch(JigsawTileWatch watch) {
if (jigsawTileWatches.get(watch.key()) != watch) {
return;
}
World world = watch.studio().world();
int chunkX = watch.key().worldX() >> 4;
int chunkZ = watch.key().worldZ() >> 4;
if (J.isOwnedByCurrentRegion(world, chunkX, chunkZ)) {
runJigsawTileWatch(watch, true);
return;
}
boolean scheduled = J.runRegion(
world,
chunkX,
chunkZ,
() -> runJigsawTileWatch(watch, true));
if (!scheduled) {
warnJigsawTileWatchScheduleFailure(watch);
scheduleJigsawTileWatchReconciliation(watch, true);
}
}
private void warnJigsawTileWatchScheduleFailure(JigsawTileWatch watch) {
if (watch.scheduleFailureLogged().compareAndSet(false, true)) {
IrisLogging.warn("Jigsaw Studio could not schedule jigsaw marker NBT at %d,%d,%d in %s; Iris will keep retrying",
watch.key().worldX(),
watch.key().worldY(),
watch.key().worldZ(),
watch.studio().world().getName());
}
}
private void scheduleJigsawTileWatchReconciliation(JigsawTileWatch watch, boolean release) {
if (!watch.reconciliationScheduled().compareAndSet(false, true)) {
return;
}
try {
J.s(() -> {
watch.reconciliationScheduled().set(false);
if (jigsawTileWatches.get(watch.key()) != watch) {
return;
}
if (release) {
finalizeJigsawTileWatch(watch);
} else {
scheduleJigsawTileWatch(watch);
}
}, AUTOSAVE_RETRY_TICKS);
} catch (Throwable exception) {
watch.reconciliationScheduled().set(false);
IrisLogging.reportError(exception);
}
}
static boolean tileSnapshotChanged(
KMap<String, Object> previous,
KMap<String, Object> current
) {
return !Objects.equals(previous, current);
}
static JigsawTilePollDecision jigsawTilePollDecision(
KMap<String, Object> previous,
KMap<String, Object> current,
boolean release
) {
return new JigsawTilePollDecision(tileSnapshotChanged(previous, current), !release);
}
private record JigsawTileWatchKey(
UUID requestId,
UUID worldId,
int worldX,
int worldY,
int worldZ
) {
private JigsawTileWatchKey {
Objects.requireNonNull(requestId, "Jigsaw Studio tile watch request ID");
Objects.requireNonNull(worldId, "Jigsaw Studio tile watch world ID");
}
}
record JigsawTilePollDecision(boolean changed, boolean continueWatching) {
}
private record JigsawTileWatch(
JigsawTileWatchKey key,
ActiveStudio studio,
UUID playerId,
String workcellId,
KMap<String, Object> snapshot,
AtomicBoolean scheduleFailureLogged,
AtomicBoolean reconciliationScheduled
) {
private JigsawTileWatch {
Objects.requireNonNull(key, "Jigsaw Studio tile watch key");
Objects.requireNonNull(studio, "Jigsaw Studio tile watch studio");
Objects.requireNonNull(playerId, "Jigsaw Studio tile watch player ID");
Objects.requireNonNull(workcellId, "Jigsaw Studio tile watch workcell ID");
Objects.requireNonNull(snapshot, "Jigsaw Studio tile watch snapshot");
Objects.requireNonNull(scheduleFailureLogged, "Jigsaw Studio tile watch schedule warning state");
Objects.requireNonNull(reconciliationScheduled, "Jigsaw Studio tile watch retry schedule state");
}
}
}
@@ -0,0 +1,692 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolMembership;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.engine.object.IrisJigsawThemeSet;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import static art.arcane.iris.core.service.JigsawStudioEvaluator.loadStudioStructure;
import static art.arcane.iris.core.service.JigsawStudioProtection.authorizeOwner;
import static art.arcane.iris.core.service.JigsawStudioProtection.canToggleVariantRotation;
import static art.arcane.iris.core.service.JigsawStudioProtection.ownerMatches;
import static art.arcane.iris.core.service.JigsawStudioService.AUTOSAVE_RETRY_TICKS;
import static art.arcane.iris.core.service.JigsawStudioService.message;
import static art.arcane.iris.core.service.JigsawStudioVariantProperties.canResizeVariantToCapacity;
final class JigsawStudioToolbelt {
private static final long TOOL_CONFIRM_NANOS = 10_000_000_000L;
private final JigsawStudioService service;
final Map<UUID, DeferredDuplication> deferredDuplications = new ConcurrentHashMap<>();
final Map<UUID, ToolConfirmation> toolConfirmations = new ConcurrentHashMap<>();
final JigsawStudioToolCodec toolCodec = new JigsawStudioToolCodec();
volatile JigsawStudioMenuController menuController;
JigsawStudioToolbelt(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
Optional<JigsawStudioMenuState> menuState(Player player) {
if (player == null || !J.isOwnedByCurrentRegion(player)) {
return Optional.empty();
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null) {
return Optional.empty();
}
if (!ownerMatches(player, studio)) {
return Optional.empty();
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioLayout layout = session.layout();
IrisStructure structure = loadStudioStructure(studio);
List<JigsawStudioMenuState.ThemeSet> themeSets = new ArrayList<>();
if (structure != null && structure.getThemeSets() != null) {
for (IrisJigsawThemeSet themeSet : structure.getThemeSets()) {
if (themeSet != null) {
themeSets.add(new JigsawStudioMenuState.ThemeSet(
themeSet.getKey(),
themeSet.getWeight()));
}
}
}
List<JigsawStudioMenuState.Workcell> workcells = new ArrayList<>(layout.bays().size());
for (JigsawStudioBay workcell : layout.bays()) {
JigsawStudioSession.WorkcellSnapshot snapshot = session.workcellSnapshot(workcell.stableId());
List<JigsawStudioMenuState.Variant> variants = new ArrayList<>();
for (JigsawStudioVariant variant : layout.variants(workcell)) {
boolean active = variant.pieceKey().equals(snapshot.activeVariantKey());
List<JigsawStudioMenuState.Membership> memberships = new ArrayList<>(
variant.memberships().size());
for (JigsawStudioPoolMembership membership : variant.memberships()) {
memberships.add(new JigsawStudioMenuState.Membership(
membership.poolKey(),
membership.entryIndex(),
membership.weight(),
membership.chance()));
}
variants.add(new JigsawStudioMenuState.Variant(
variant.pieceKey(),
variant.resolvedDisplayName(),
variant.dimensions(),
active,
variant.owned(),
variant.rotatable(),
canToggleVariantRotation(request.compatibilityTarget(), variant),
canResizeVariantToCapacity(workcell, variant, active),
variant.themes(),
variant.rules(),
memberships));
}
workcells.add(new JigsawStudioMenuState.Workcell(
workcell.stableId(),
workcell.canonicalDisplayName(),
workcell.displayName(),
workcell.capacity(),
workcell.enabled(),
snapshot.activeVariantKey(),
snapshot.dirty(),
snapshot.saveInProgress(),
snapshot.switchInProgress(),
snapshot.connectorsVisible(),
variants));
}
return Optional.of(new JigsawStudioMenuState(
studio.worldId(),
request.requestId(),
request.structureKey(),
layout.mode(),
request.compatibilityTarget(),
structure != null && structure.isRequireCaps(),
themeSets,
session.selectedBayId().orElse(""),
Optional.ofNullable(service.evaluator.evaluations.get(request.requestId()))
.map(JigsawStudioMenuState.Evaluation::from)
.orElseGet(JigsawStudioMenuState.Evaluation::pending),
workcells));
}
boolean selectWorkcell(Player player, String workcellId) {
if (player == null || workcellId == null || workcellId.isBlank()) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> selectWorkcell(player, workcellId));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null) {
message(player, "Iris Jigsaw Studio is not active in this world.");
return false;
}
if (!authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
if (!session.selectBay(workcellId)) {
message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'.");
return false;
}
service.visualization.ensureVisualizationLoop(player);
return true;
}
boolean openControlMenu(Player player) {
if (player != null && J.isOwnedByCurrentRegion(player)) {
service.tileWatcher.finalizeJigsawTileWatchesForPlayer(player.getUniqueId());
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio != null
&& service.saveLifecycle.reopenRequiredRequests.contains(
studio.generator().getRequest().requestId())) {
message(player, "Close and reopen Jigsaw Studio to apply the resized workcell layout.");
return false;
}
}
JigsawStudioMenuController activeMenuController = menuController;
return activeMenuController != null && activeMenuController.open(player);
}
void closeMenu(Player player) {
JigsawStudioMenuController activeMenuController = menuController;
if (activeMenuController != null) {
activeMenuController.close(player);
}
}
boolean useTool(
Player player,
JigsawStudioToolPayload payload,
ItemStack tool,
boolean resetLabel
) {
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> useTool(player, payload, tool, resetLabel));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null
|| !studio.generator().getRequest().requestId().equals(payload.requestId())
|| !authorizeOwner(player, studio)) {
message(player, "This Jigsaw Studio tool is stale or belongs to another session.");
return false;
}
if (payload.action().destructive() && !confirmTool(player, payload)) {
message(player, "Right-click the same tool again within 10 seconds to confirm "
+ payload.action().displayName() + ".");
return true;
}
return switch (payload.action()) {
case OPEN_MENU -> openControlMenu(player);
case SELECT_WORKCELL -> selectWorkcell(player, payload.workcellId());
case LOAD_VARIANT -> service.variantEditor.switchVariant(
player,
payload.workcellId(),
payload.pieceKey(),
false);
case CREATE_VARIANT -> service.variantEditor.createVariant(player, payload.workcellId(), false);
case DUPLICATE_VARIANT -> service.variantEditor.createVariant(player, payload.workcellId(), true);
case DUPLICATE_FAMILY -> duplicateFamilyFromTool(player);
case PREVIEW_GRAPH -> service.goToPreview(player);
case FLUSH_AUTOSAVE -> service.autosaveScheduler.flushAutosave(player, payload.workcellId());
case TOGGLE_ROTATION -> service.variantProperties.toggleVariantRotatable(player, payload.workcellId());
case EXPAND_TO_CELL -> service.variantProperties.expandVariantToCell(player, payload.workcellId());
case RESIZE_VARIANT -> openVariantSizeSettingsFromTool(player, payload);
case RENAME_VARIANT -> renameVariantFromTool(player, payload, tool, resetLabel);
case ADJUST_VARIANT_WEIGHT -> service.variantProperties.adjustVariantWeight(
player,
payload.workcellId(),
payload.pieceKey(),
payload.poolKey(),
payload.entryIndex(),
payload.amount());
case ADJUST_VARIANT_CHANCE -> service.variantProperties.adjustVariantChance(
player,
payload.workcellId(),
payload.pieceKey(),
payload.poolKey(),
payload.entryIndex(),
payload.amount());
case UNLINK_MEMBERSHIP -> service.variantEditor.unlinkVariantMembership(
player,
payload.workcellId(),
payload.pieceKey(),
payload.poolKey(),
payload.entryIndex());
case TOGGLE_WORKCELL -> toggleWorkcellFromTool(player, payload.workcellId());
case RESIZE_WORKCELL -> openWorkcellSettingsFromTool(player, payload.workcellId());
case RENAME_WORKCELL -> renameWorkcellFromTool(player, payload, tool, resetLabel);
case SET_THEME -> useThemeTool(player, payload);
case SET_PIECE_RULES -> openVariantSettingsFromTool(player, payload);
case DELETE_VARIANT -> service.variantEditor.deleteVariant(
player, payload.workcellId(), payload.pieceKey());
case DELETE_PROJECT -> service.variantEditor.deleteProject(player);
case TOGGLE_REQUIRE_CAPS -> toggleRequireCapsFromTool(player);
};
}
private boolean toggleWorkcellFromTool(Player player, String workcellId) {
Optional<JigsawStudioMenuState> state = menuState(player);
JigsawStudioMenuState.Workcell workcell = state
.map(menu -> menu.workcell(workcellId))
.orElse(null);
if (workcell == null) {
message(player, "This bound workcell no longer exists.");
return false;
}
return service.workcellEditor.setWorkcellEnabled(player, workcellId, !workcell.enabled());
}
private boolean openWorkcellSettingsFromTool(Player player, String workcellId) {
JigsawStudioMenuController activeMenuController = menuController;
return activeMenuController != null
&& activeMenuController.openWorkcellSettings(player, workcellId);
}
private boolean openVariantSettingsFromTool(Player player, JigsawStudioToolPayload payload) {
JigsawStudioMenuController activeMenuController = menuController;
return activeMenuController != null
&& activeMenuController.openVariantSettings(
player,
payload.workcellId(),
payload.pieceKey());
}
private boolean openVariantSizeSettingsFromTool(Player player, JigsawStudioToolPayload payload) {
JigsawStudioMenuController activeMenuController = menuController;
return activeMenuController != null
&& activeMenuController.openVariantSizeSettings(
player,
payload.workcellId(),
payload.pieceKey());
}
private boolean renameVariantFromTool(
Player player,
JigsawStudioToolPayload payload,
ItemStack tool,
boolean resetLabel
) {
Optional<String> displayName = toolDisplayName(player, payload, tool, resetLabel);
return displayName.isPresent() && service.variantProperties.updateVariantDisplayName(
player,
payload.workcellId(),
payload.pieceKey(),
displayName.get());
}
private boolean renameWorkcellFromTool(
Player player,
JigsawStudioToolPayload payload,
ItemStack tool,
boolean resetLabel
) {
Optional<String> displayName = toolDisplayName(player, payload, tool, resetLabel);
return displayName.isPresent() && service.workcellEditor.updateWorkcellDisplayName(
player,
payload.workcellId(),
displayName.get());
}
private static Optional<String> toolDisplayName(
Player player,
JigsawStudioToolPayload payload,
ItemStack tool,
boolean resetLabel
) {
if (resetLabel) {
return Optional.of("");
}
String displayName = tool == null || !tool.hasItemMeta() || tool.getItemMeta() == null
? ""
: tool.getItemMeta().getDisplayName();
String normalized = ChatColor.stripColor(displayName);
normalized = normalized == null ? "" : normalized.trim();
String defaultName = "Jigsaw Studio: " + payload.action().displayName();
if (normalized.isEmpty() || normalized.equals(defaultName)) {
message(player, "Rename this bound stick in an anvil to the desired label, then right-click it. "
+ "Sneak-right-click resets the label.");
return Optional.empty();
}
return Optional.of(normalized);
}
private boolean useThemeTool(Player player, JigsawStudioToolPayload payload) {
if (!payload.pieceKey().isBlank()) {
return openVariantSettingsFromTool(player, payload);
}
Optional<JigsawStudioMenuState> state = menuState(player);
if (state.isEmpty()) {
return false;
}
return service.variantEditor.duplicateActiveFamily(
player,
JigsawStudioMenuFormat.nextThemeSetKey(state.get().themeSets()));
}
private boolean duplicateFamilyFromTool(Player player) {
Optional<JigsawStudioMenuState> state = menuState(player);
return state.isPresent() && service.variantEditor.duplicateActiveFamily(
player,
JigsawStudioMenuFormat.nextThemeSetKey(state.get().themeSets()));
}
private boolean toggleRequireCapsFromTool(Player player) {
Optional<JigsawStudioMenuState> state = menuState(player);
return state.isPresent() && service.workcellEditor.setRequireCaps(player, !state.get().requireCaps());
}
private boolean confirmTool(Player player, JigsawStudioToolPayload payload) {
long now = System.nanoTime();
ToolConfirmation previous = toolConfirmations.get(player.getUniqueId());
if (previous != null
&& previous.expiresAtNanos() >= now
&& previous.payload().equals(payload)) {
toolConfirmations.remove(player.getUniqueId(), previous);
return true;
}
toolConfirmations.put(
player.getUniqueId(),
new ToolConfirmation(payload, now + TOOL_CONFIRM_NANOS));
return false;
}
JigsawStudioVariant activeOwnedVariant(
Player player,
ActiveStudio studio,
String workcellId
) {
if (studio == null) {
message(player, "Iris Jigsaw Studio is not active in this world.");
return null;
}
JigsawStudioSession session = studio.generator().getSession();
if (session.layout().get(workcellId) == null) {
message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'.");
return null;
}
JigsawStudioVariant variant = session.activeVariant(workcellId).orElse(null);
if (variant == null) {
message(player, "This workcell has no active variant.");
return null;
}
if (!variant.owned()) {
message(player, "Variant '" + variant.pieceKey()
+ "' is read-only. Adopt or clone its graph before editing it.");
return null;
}
return variant;
}
JigsawStudioVariant activeOwnedVariant(
Player player,
ActiveStudio studio,
String workcellId,
String expectedPieceKey
) {
JigsawStudioVariant variant = activeOwnedVariant(player, studio, workcellId);
if (variant == null) {
return null;
}
if (!variant.pieceKey().equals(expectedPieceKey)) {
message(player, "The bound variant changed. Open the control menu or request a fresh tool.");
return null;
}
return variant;
}
static JigsawStudioPoolMembership findMembership(
JigsawStudioVariant variant,
String poolKey,
int entryIndex
) {
for (JigsawStudioPoolMembership membership : variant.memberships()) {
if (membership.poolKey().equals(poolKey) && membership.entryIndex() == entryIndex) {
return membership;
}
}
return null;
}
boolean deferDuplicationUntilAutosaved(
Player player,
ActiveStudio studio,
DeferredDuplication requested
) {
JigsawStudioSession session = studio.generator().getSession();
UUID requestId = studio.generator().getRequest().requestId();
boolean autosavePending;
synchronized (service.saveLifecycleLock) {
autosavePending = session.isDirty() || service.saveLifecycle.savesInProgress.contains(requestId);
}
if (!autosavePending) {
return false;
}
DeferredDuplication existing = deferredDuplications.putIfAbsent(requestId, requested);
if (existing == null) {
message(player, requested.kind() == DeferredDuplicationKind.SINGLE
? "Autosave is finishing first; this cell's variant will duplicate automatically."
: "Autosave is finishing every edited cell first; the coherent family will duplicate automatically.");
service.autosaveScheduler.expediteAutosaves(requestId);
scheduleDeferredDuplication(requested);
return true;
}
message(player, existing.sameIntent(requested)
? "That duplicate action is already queued behind autosave."
: "Another duplicate action is already queued behind autosave for this Studio.");
return true;
}
private void scheduleDeferredDuplication(DeferredDuplication deferred) {
if (deferredDuplications.get(deferred.requestId()) != deferred
|| !deferred.scheduled().compareAndSet(false, true)) {
return;
}
try {
J.s(() -> {
deferred.scheduled().set(false);
if (deferredDuplications.get(deferred.requestId()) != deferred) {
return;
}
Player player = Bukkit.getPlayer(deferred.playerId());
if (player == null) {
deferredDuplications.remove(deferred.requestId(), deferred);
return;
}
boolean scheduled = J.runEntity(
player,
() -> resumeDeferredDuplication(player, deferred),
0,
() -> deferredDuplications.remove(deferred.requestId(), deferred));
if (!scheduled) {
deferredDuplications.remove(deferred.requestId(), deferred);
}
}, AUTOSAVE_RETRY_TICKS);
} catch (Throwable exception) {
deferred.scheduled().set(false);
deferredDuplications.remove(deferred.requestId(), deferred);
IrisLogging.reportError(exception);
}
}
private void resumeDeferredDuplication(Player player, DeferredDuplication deferred) {
if (deferredDuplications.get(deferred.requestId()) != deferred) {
return;
}
ActiveStudio studio = service.studios.get(deferred.worldId());
JigsawStudioSession session = studio == null ? null : studio.generator().getSession();
boolean currentRequest = studio != null
&& studio.generator().getRequest().requestId().equals(deferred.requestId())
&& session.sessionId().equals(deferred.sessionId())
&& player.getWorld().getUID().equals(deferred.worldId());
boolean sourcesCurrent = currentRequest && sourceBindingsCurrent(session, deferred.sourcePieces());
boolean autosavePending;
boolean operationPending;
boolean terminal;
synchronized (service.saveLifecycleLock) {
autosavePending = currentRequest
&& (session.isDirty() || service.saveLifecycle.savesInProgress.contains(deferred.requestId()));
operationPending = currentRequest
&& (service.graphMutations.graphMutationsInProgress.contains(deferred.requestId())
|| service.materializer.materializationsInProgress.contains(deferred.requestId())
|| service.saveLifecycle.exportsInProgress.contains(deferred.requestId())
|| session.operationInProgress()
|| service.tileWatcher.hasJigsawTileWatch(deferred.requestId()));
terminal = !currentRequest
|| service.saveLifecycle.closingRequests.contains(deferred.requestId())
|| service.saveLifecycle.reopenRequiredRequests.contains(deferred.requestId());
}
DeferredDuplicationReadiness readiness = deferredDuplicationReadiness(
currentRequest,
sourcesCurrent,
autosavePending,
operationPending,
terminal);
if (readiness == DeferredDuplicationReadiness.STALE) {
deferredDuplications.remove(deferred.requestId(), deferred);
message(player, "The queued duplicate was cancelled because its Studio or source variant changed.");
return;
}
if (readiness == DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE) {
service.autosaveScheduler.expediteAutosaves(deferred.requestId());
scheduleDeferredDuplication(deferred);
return;
}
if (readiness == DeferredDuplicationReadiness.WAITING_FOR_OPERATION) {
scheduleDeferredDuplication(deferred);
return;
}
boolean started = deferred.kind() == DeferredDuplicationKind.SINGLE
? service.variantEditor.createVariant(player, deferred.workcellId(), true)
: service.variantEditor.duplicateActiveFamily(player, deferred.themeKey());
if (started) {
deferredDuplications.remove(deferred.requestId(), deferred);
return;
}
if (service.isCurrentRequest(studio, deferred.requestId())
&& sourceBindingsCurrent(session, deferred.sourcePieces())) {
scheduleDeferredDuplication(deferred);
} else {
deferredDuplications.remove(deferred.requestId(), deferred);
}
}
static DeferredDuplicationReadiness deferredDuplicationReadiness(
boolean currentRequest,
boolean sourcesCurrent,
boolean autosavePending,
boolean operationPending,
boolean terminal
) {
if (!currentRequest || !sourcesCurrent || terminal) {
return DeferredDuplicationReadiness.STALE;
}
if (autosavePending) {
return DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE;
}
if (operationPending) {
return DeferredDuplicationReadiness.WAITING_FOR_OPERATION;
}
return DeferredDuplicationReadiness.READY;
}
private static boolean sourceBindingsCurrent(
JigsawStudioSession session,
Map<String, String> expectedSources
) {
if (session == null) {
return false;
}
for (Map.Entry<String, String> source : expectedSources.entrySet()) {
if (session.activeVariant(source.getKey())
.map(JigsawStudioVariant::pieceKey)
.filter(source.getValue()::equals)
.isEmpty()) {
return false;
}
}
return true;
}
enum DeferredDuplicationReadiness {
READY,
WAITING_FOR_AUTOSAVE,
WAITING_FOR_OPERATION,
STALE
}
private enum DeferredDuplicationKind {
SINGLE,
FAMILY
}
record DeferredDuplication(
UUID requestId,
UUID sessionId,
UUID playerId,
UUID worldId,
DeferredDuplicationKind kind,
String workcellId,
Map<String, String> sourcePieces,
String themeKey,
AtomicBoolean scheduled
) {
DeferredDuplication {
Objects.requireNonNull(requestId, "Jigsaw Studio deferred duplicate request ID");
Objects.requireNonNull(sessionId, "Jigsaw Studio deferred duplicate session ID");
Objects.requireNonNull(playerId, "Jigsaw Studio deferred duplicate player ID");
Objects.requireNonNull(worldId, "Jigsaw Studio deferred duplicate world ID");
Objects.requireNonNull(kind, "Jigsaw Studio deferred duplicate kind");
workcellId = workcellId == null ? "" : workcellId;
sourcePieces = Map.copyOf(Objects.requireNonNull(
sourcePieces,
"Jigsaw Studio deferred duplicate source pieces"));
themeKey = themeKey == null ? "" : themeKey;
Objects.requireNonNull(scheduled, "Jigsaw Studio deferred duplicate schedule state");
if (sourcePieces.isEmpty()) {
throw new IllegalArgumentException("A deferred duplicate requires at least one source variant");
}
if (kind == DeferredDuplicationKind.SINGLE && workcellId.isBlank()) {
throw new IllegalArgumentException("A deferred single duplicate requires a workcell");
}
if (kind == DeferredDuplicationKind.FAMILY && themeKey.isBlank()) {
throw new IllegalArgumentException("A deferred family duplicate requires a theme key");
}
}
static DeferredDuplication single(
UUID requestId,
UUID sessionId,
Player player,
UUID worldId,
String workcellId,
String sourcePiece
) {
return new DeferredDuplication(
requestId,
sessionId,
player.getUniqueId(),
worldId,
DeferredDuplicationKind.SINGLE,
workcellId,
Map.of(workcellId, sourcePiece),
"",
new AtomicBoolean());
}
static DeferredDuplication family(
UUID requestId,
UUID sessionId,
Player player,
UUID worldId,
Map<String, String> sourcePieces,
String themeKey
) {
return new DeferredDuplication(
requestId,
sessionId,
player.getUniqueId(),
worldId,
DeferredDuplicationKind.FAMILY,
"",
sourcePieces,
themeKey,
new AtomicBoolean());
}
private boolean sameIntent(DeferredDuplication other) {
return other != null
&& playerId.equals(other.playerId)
&& kind == other.kind
&& workcellId.equals(other.workcellId)
&& sourcePieces.equals(other.sourcePieces)
&& themeKey.equals(other.themeKey);
}
}
record ToolConfirmation(JigsawStudioToolPayload payload, long expiresAtNanos) {
ToolConfirmation {
Objects.requireNonNull(payload, "Jigsaw Studio tool confirmation payload");
}
}
}
@@ -0,0 +1,590 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
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.JigsawStudioPoolMembership;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioProjectDeletionService;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioMaterializer.MaterializationWork;
import art.arcane.iris.core.service.JigsawStudioMaterializer.VariantReloadRequest;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.core.service.JigsawStudioService.CloseStart;
import art.arcane.iris.core.service.JigsawStudioService.CommandGraphMutationResult;
import art.arcane.iris.core.service.JigsawStudioToolbelt.DeferredDuplication;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.validateMaterialization;
import static art.arcane.iris.core.service.JigsawStudioGraphMutations.loadMappedLayout;
import static art.arcane.iris.core.service.JigsawStudioProtection.authorizeOwner;
import static art.arcane.iris.core.service.JigsawStudioProtection.canCreateVariants;
import static art.arcane.iris.core.service.JigsawStudioProtection.variantCreationSourceFailure;
import static art.arcane.iris.core.service.JigsawStudioService.clearAutosaveHistory;
import static art.arcane.iris.core.service.JigsawStudioService.failureMessage;
import static art.arcane.iris.core.service.JigsawStudioService.message;
import static art.arcane.iris.core.service.JigsawStudioToolbelt.findMembership;
final class JigsawStudioVariantEditor {
private final JigsawStudioService service;
JigsawStudioVariantEditor(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean switchVariant(
Player player,
String workcellId,
String targetPieceKey,
boolean discardDirty
) {
if (player == null || workcellId == null || targetPieceKey == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> switchVariant(
player, workcellId, targetPieceKey, discardDirty));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null) {
message(player, "Iris Jigsaw Studio is not active in this world.");
return false;
}
if (!authorizeOwner(player, studio)) {
return false;
}
if (service.graphMutations.graphMutationInProgress(studio.generator().getRequest().requestId())) {
message(player, "Wait for the current Jigsaw Studio graph update to finish.");
return false;
}
UUID requestId = studio.generator().getRequest().requestId();
service.tileWatcher.finalizeJigsawTileWatches(requestId);
if (service.tileWatcher.hasJigsawTileWatch(requestId)) {
message(player, "Wait for the open vanilla jigsaw-block editor to finish its final snapshot.");
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().get(workcellId);
if (workcell == null) {
message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'.");
return false;
}
JigsawStudioSession.SwitchStart start = session.beginVariantSwitch(
workcell.stableId(), targetPieceKey, discardDirty);
if (start.status() != JigsawStudioSession.SwitchStatus.STARTED) {
message(player, switch (start.status()) {
case DIRTY -> "Wait for the current variant to finish autosaving before switching.";
case SAVE_IN_PROGRESS -> "Wait for the current variant save to finish.";
case SWITCH_IN_PROGRESS -> "This workcell is already loading another variant.";
case ALREADY_ACTIVE -> "Variant '" + targetPieceKey + "' is already active.";
case WRONG_WORKCELL -> "Variant '" + targetPieceKey + "' belongs to another workcell.";
case UNKNOWN_VARIANT -> "No variant '" + targetPieceKey + "' exists in this Studio catalog.";
case UNKNOWN_WORKCELL -> "The selected workcell no longer exists.";
case STARTED -> "The variant switch could not start.";
});
return false;
}
return materializeVariant(player, studio, workcell, start, null);
}
void reloadActiveVariant(
Player player,
ActiveStudio studio,
VariantReloadRequest reload
) {
if (!service.isCurrentRequest(studio, studio.generator().getRequest().requestId())) {
return;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().get(reload.workcellId());
if (workcell == null) {
message(player, "The resized variant was saved, but its workcell is no longer active. Reopen Studio.");
return;
}
JigsawStudioSession.SwitchStart start = session.beginVariantReload(workcell.stableId());
if (start.status() != JigsawStudioSession.SwitchStatus.STARTED) {
service.saveLifecycle.reopenRequiredRequests.add(studio.generator().getRequest().requestId());
message(player, "The resized variant was saved, but Studio could not reload it: "
+ start.status() + ". Close and reopen this project before editing that workcell.");
return;
}
materializeVariant(player, studio, workcell, start, reload.previous());
}
private boolean materializeVariant(
Player player,
ActiveStudio studio,
JigsawStudioBay workcell,
JigsawStudioSession.SwitchStart start,
JigsawStudioGenerator.RenderedBay previousOverride
) {
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioSession.VariantSwitchToken token = start.token().orElseThrow();
String targetPieceKey = token.targetVariant().pieceKey();
JigsawStudioGenerator.RenderedBay target = studio.generator().renderVariant(
workcell,
token.targetVariant());
if (!target.valid()) {
session.abortVariantSwitch(token);
service.playerContext.refreshWorkcellContext(studio.worldId(), workcell.stableId());
message(player, "Variant '" + targetPieceKey + "' cannot load: " + target.failure());
return false;
}
JigsawStudioGenerator.RenderedBay previous = previousOverride == null
? token.previousVariant()
.map(variant -> studio.generator().renderVariant(workcell, variant))
.orElseGet(() -> JigsawStudioGenerator.RenderedBay.empty(workcell.bounds().dimensions()))
: previousOverride;
if (!previous.valid()) {
session.abortVariantSwitch(token);
service.playerContext.refreshWorkcellContext(studio.worldId(), workcell.stableId());
message(player, "The current variant cannot be retained for rollback: " + previous.failure());
return false;
}
JigsawStudioVariant previousVariant = token.previousVariant().orElse(null);
if (previousVariant != null) {
String rollbackFailure = validateMaterialization(previous);
if (!rollbackFailure.isEmpty()) {
session.abortVariantSwitch(token);
service.playerContext.refreshWorkcellContext(studio.worldId(), workcell.stableId());
message(player, "The current variant cannot be retained for rollback: " + rollbackFailure);
return false;
}
}
session.selectBay(workcell.stableId());
service.playerContext.refreshWorkcellContext(studio.worldId(), workcell.stableId());
message(player, "Loading variant '" + targetPieceKey + "' into " + workcell.stableId() + "...");
return service.materializer.scheduleMaterialization(new MaterializationWork(
studio,
player.getWorld(),
player,
workcell,
token,
previous,
target,
session.workcellSnapshot(workcell.stableId()).connectorsVisible(),
session.workcellSnapshot(workcell.stableId()).connectorsVisible(),
false));
}
boolean createVariant(
Player player,
String workcellId,
boolean duplicateActive
) {
if (player == null || workcellId == null || workcellId.isBlank()) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> createVariant(player, workcellId, duplicateActive));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null) {
message(player, "Iris Jigsaw Studio is not active in this world.");
return false;
}
if (!authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().get(workcellId);
if (workcell == null) {
message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'.");
return false;
}
if (!canCreateVariants(session.layout())) {
message(player, "This Jigsaw Studio graph is read-only. "
+ "Adopt or clone it before creating variants.");
return false;
}
JigsawStudioVariant activeVariant = session.activeVariant(workcellId).orElse(null);
String sourceFailure = variantCreationSourceFailure(activeVariant, duplicateActive);
if (!sourceFailure.isEmpty()) {
message(player, sourceFailure);
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
if (duplicateActive && service.toolbelt.deferDuplicationUntilAutosaved(
player,
studio,
DeferredDuplication.single(
request.requestId(),
session.sessionId(),
player,
studio.worldId(),
workcell.stableId(),
activeVariant.pieceKey()))) {
return true;
}
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
String archetypeKey = workcell.archetype()
.map(archetype -> archetype.name().toLowerCase(Locale.ROOT))
.orElse("spatial");
String sourcePieceKey = activeVariant.pieceKey();
message(player, duplicateActive ? "Duplicating the active variant..." : "Creating a new variant...");
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
Path packRoot = request.source().getDataFolder().toPath();
String pieceKey = JigsawStudioGraphEditor.nextVariantKey(
packRoot, request.structureKey(), archetypeKey);
if (duplicateActive) {
JigsawStudioGraphEditor.duplicatePiece(
packRoot,
request.structureKey(),
sourcePieceKey,
pieceKey);
} else {
JigsawStudioGraphEditor.createBlankVariant(
packRoot,
request.structureKey(),
sourcePieceKey,
pieceKey);
}
JigsawStudioLayout updatedLayout = loadMappedLayout(studio);
String targetWorkcellId = updatedLayout.workcellForVariant(pieceKey)
.map(JigsawStudioBay::stableId)
.orElseThrow(() -> new IOException(
"Created variant '" + pieceKey + "' has no Studio workcell"));
if (updatedLayout.mode() == JigsawStudioMode.SPATIAL_JIGSAW) {
return new CommandGraphMutationResult(
updatedLayout,
"",
"",
(duplicateActive ? "Duplicated" : "Created") + " variant '" + pieceKey
+ "' in " + targetWorkcellId + ".");
}
return new CommandGraphMutationResult(
updatedLayout,
targetWorkcellId,
pieceKey,
(duplicateActive ? "Duplicated" : "Created") + " variant '" + pieceKey + "'.");
});
}
boolean duplicateActiveFamily(Player player, String themeKey) {
if (player == null || themeKey == null || themeKey.isBlank()) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> duplicateActiveFamily(player, themeKey));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
if (request.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE) {
message(player, "Coherent variant families are Iris-only and cannot be added to a vanilla-portable graph.");
return false;
}
JigsawStudioSession session = studio.generator().getSession();
Map<String, String> sourcePieces = new LinkedHashMap<>();
for (JigsawStudioBay workcell : session.layout().bays()) {
if (!workcell.enabled()) {
continue;
}
JigsawStudioVariant active = session.activeVariant(workcell.stableId()).orElse(null);
if (active == null || !active.owned()) {
message(player, "Load an owned source variant in every enabled workcell before duplicating a family.");
return false;
}
sourcePieces.put(workcell.stableId(), active.pieceKey());
}
if (service.toolbelt.deferDuplicationUntilAutosaved(
player,
studio,
DeferredDuplication.family(
request.requestId(),
session.sessionId(),
player,
studio.worldId(),
sourcePieces,
themeKey))) {
return true;
}
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioGraphEditor.VariantFamilyCreation creation =
JigsawStudioGraphEditor.duplicateActiveFamily(
request.source().getDataFolder().toPath(),
request.structureKey(),
sourcePieces,
themeKey);
if (!creation.writeResult().successful()) {
throw new IOException("Variant-family transaction failed with "
+ creation.writeResult().status());
}
JigsawStudioLayout updatedLayout = loadMappedLayout(studio);
Map<String, String> rebinds = updatedLayout.mode() == JigsawStudioMode.SPATIAL_JIGSAW
? Map.of()
: creation.pieceKeysByWorkcell();
return new CommandGraphMutationResult(
updatedLayout,
"",
"",
rebinds,
Optional.empty(),
"Duplicated every enabled workcell as coherent family '" + themeKey + "' with "
+ creation.pieceKeysByWorkcell().size() + " variant(s).");
});
}
boolean deleteVariant(Player player, String workcellId, String pieceKey) {
if (player == null || workcellId == null || workcellId.isBlank()
|| pieceKey == null || pieceKey.isBlank()) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> deleteVariant(player, workcellId, pieceKey));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().get(workcellId);
JigsawStudioVariant variant = session.layout().variantCatalog().find(pieceKey).orElse(null);
if (workcell == null || variant == null || !session.layout().accepts(workcell, variant)) {
message(player, "Variant '" + pieceKey + "' no longer belongs to workcell '" + workcellId + "'.");
return false;
}
if (!variant.owned()) {
message(player, "Variant '" + pieceKey + "' is read-only. Adopt or clone it before deletion.");
return false;
}
String activePieceKey = session.activeVariant(workcellId)
.map(JigsawStudioVariant::pieceKey)
.orElse("");
if (session.layout().mode() == JigsawStudioMode.PLANAR_JIGSAW
&& activePieceKey.equals(pieceKey)) {
message(player, "Load another variant in this workcell before deleting '" + pieceKey + "'.");
return false;
}
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioGraphEditor.PieceDeletionResult deletion =
JigsawStudioGraphEditor.deletePieceVariant(
request.source().getDataFolder().toPath(),
request.structureKey(),
pieceKey);
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
"Deleted variant '" + pieceKey + "', removed "
+ deletion.removedPoolMemberships() + " pool membership(s), and removed "
+ deletion.removedObjectResources() + " unshared object(s).");
});
}
boolean deleteProject(Player player) {
if (player == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> deleteProject(player));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
CloseStart closeStart = service.saveLifecycle.tryBeginClose(request.requestId(), player.getUniqueId(), false);
if (closeStart != CloseStart.STARTED) {
message(player, switch (closeStart) {
case DIRTY -> "Wait for autosave to finish before deleting this project.";
case SAVE_IN_PROGRESS -> "Wait for the active autosave to finish before deleting this project.";
case OPERATION_IN_PROGRESS -> "Wait for the active Jigsaw Studio operation to finish.";
case NOT_OWNER -> "This Jigsaw Studio is owned by another player session.";
case NOT_ACTIVE -> "This Jigsaw Studio session is no longer active.";
case STARTED -> "Project deletion could not start.";
});
return false;
}
Path packRoot = request.source().getDataFolder().toPath();
message(player, "Inspecting owned resources and reverse references before project deletion...");
J.a(() -> inspectProjectDeletion(player, studio, request, packRoot));
return true;
}
private void inspectProjectDeletion(
Player player,
ActiveStudio studio,
JigsawStudioActivation.Request request,
Path packRoot
) {
JigsawStudioProjectDeletionService.DeletionPlan plan;
try {
plan = JigsawStudioProjectDeletionService.inspect(packRoot, request.structureKey());
} catch (Throwable exception) {
service.saveLifecycle.cancelClose(request.requestId());
IrisLogging.reportError(exception);
message(player, "Project deletion inspection failed: " + failureMessage(exception));
return;
}
if (!plan.deletable()) {
service.saveLifecycle.cancelClose(request.requestId());
message(player, "Project deletion is blocked by " + plan.blockers().size()
+ " external reference(s). The first is "
+ plan.blockers().getFirst().ownerPath() + " at "
+ plan.blockers().getFirst().location() + ".");
return;
}
boolean scheduled = J.runEntity(
player,
() -> closeAndDeleteProject(player, studio, request, plan),
0,
() -> service.saveLifecycle.cancelClose(request.requestId()));
if (!scheduled) {
service.saveLifecycle.cancelClose(request.requestId());
message(player, "Project deletion stopped because the owner session ended.");
}
}
private void closeAndDeleteProject(
Player player,
ActiveStudio studio,
JigsawStudioActivation.Request request,
JigsawStudioProjectDeletionService.DeletionPlan plan
) {
if (!service.isCurrentRequest(studio, request.requestId())) {
service.saveLifecycle.cancelClose(request.requestId());
message(player, "Project deletion stopped because the Jigsaw Studio session changed.");
return;
}
IrisServices.get(StudioSVC.class).close().whenComplete((result, throwable) -> {
Throwable failure = throwable != null
? throwable
: result == null ? new IllegalStateException("Studio close completed without a result")
: result.failureCause();
if (failure != null) {
service.saveLifecycle.cancelClose(request.requestId());
IrisLogging.reportError(failure);
message(player, "Project deletion stopped because Studio could not close: "
+ failureMessage(failure));
return;
}
J.a(() -> applyProjectDeletion(player, request, plan));
});
}
private void applyProjectDeletion(
Player player,
JigsawStudioActivation.Request request,
JigsawStudioProjectDeletionService.DeletionPlan plan
) {
try {
JigsawStudioProjectDeletionService.ProjectDeletionResult result =
JigsawStudioProjectDeletionService.delete(plan);
try {
clearAutosaveHistory(
request.source().getDataFolder().toPath(),
request.structureKey());
} catch (IOException historyFailure) {
IrisLogging.reportError(historyFailure);
message(player, "The project graph was deleted, but its autosave history could not be removed: "
+ failureMessage(historyFailure));
}
request.source().invalidateStructureResources();
message(player, "Deleted Jigsaw project '" + request.structureKey() + "' and "
+ result.removedResourceCount() + " owned resource(s).");
IrisLogging.debug("Jigsaw Studio project deleted: structure=%s resources=%d",
request.structureKey(), result.removedResourceCount());
} catch (Throwable exception) {
IrisLogging.reportError(exception);
message(player, "Studio closed, but the project was not deleted: "
+ failureMessage(exception) + ". Its files remain recoverable on disk.");
}
}
boolean unlinkVariantMembership(
Player player,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex
) {
if (player == null || workcellId == null || pieceKey == null || poolKey == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> unlinkVariantMembership(
player, workcellId, pieceKey, poolKey, entryIndex));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio != null && !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioVariant variant = service.toolbelt.activeOwnedVariant(player, studio, workcellId, pieceKey);
if (variant == null) {
return false;
}
JigsawStudioPoolMembership membership = findMembership(variant, poolKey, entryIndex);
if (membership == null) {
message(player, "That exact pool membership is no longer present.");
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioPoolEditor.removeEntry(
request.source().getDataFolder().toPath(),
request.structureKey(),
membership.poolKey(),
membership.entryIndex(),
variant.pieceKey());
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
"Unlinked '" + variant.pieceKey() + "' from pool '"
+ membership.poolKey() + "'.");
});
}
}
@@ -0,0 +1,550 @@
package art.arcane.iris.core.service;
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.JigsawStudioGraphEditor;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolMembership;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant;
import art.arcane.iris.core.service.JigsawStudioMaterializer.VariantReloadRequest;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.core.service.JigsawStudioService.CommandGraphMutationResult;
import art.arcane.iris.engine.object.IrisJigsawThemeSet;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.entity.Player;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import static art.arcane.iris.core.service.JigsawStudioEvaluator.loadStudioStructure;
import static art.arcane.iris.core.service.JigsawStudioGraphMutations.loadMappedLayout;
import static art.arcane.iris.core.service.JigsawStudioProtection.authorizeOwner;
import static art.arcane.iris.core.service.JigsawStudioProtection.canToggleVariantRotation;
import static art.arcane.iris.core.service.JigsawStudioService.DEFAULT_PIECE_RULES;
import static art.arcane.iris.core.service.JigsawStudioService.message;
import static art.arcane.iris.core.service.JigsawStudioToolbelt.findMembership;
final class JigsawStudioVariantProperties {
private final JigsawStudioService service;
JigsawStudioVariantProperties(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean updateVariantThemes(
Player player,
String workcellId,
String pieceKey,
List<String> themes
) {
if (player == null || pieceKey == null || themes == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> updateVariantThemes(
player, workcellId, pieceKey, themes));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioVariant variant = service.toolbelt.activeOwnedVariant(player, studio, workcellId, pieceKey);
if (variant == null) {
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
List<String> targetThemes = List.copyOf(themes);
if (request.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE
&& !targetThemes.isEmpty()) {
message(player, "Piece themes are Iris-only and cannot be added to a vanilla-portable graph.");
return false;
}
IrisStructure structure = loadStudioStructure(studio);
Set<String> declaredThemes = new HashSet<>();
if (structure != null && structure.getThemeSets() != null) {
for (IrisJigsawThemeSet theme : structure.getThemeSets()) {
if (theme != null) {
declaredThemes.add(theme.getKey());
}
}
}
if (!declaredThemes.containsAll(targetThemes)) {
message(player, "One or more selected theme sets no longer exist.");
return false;
}
if (variant.themes().equals(targetThemes)) {
message(player, "Variant theme membership is unchanged.");
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioGraphEditor.updatePieceThemes(
request.source().getDataFolder().toPath(),
request.structureKey(),
variant.pieceKey(),
targetThemes);
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
"Updated theme membership for '" + variant.pieceKey() + "'.");
});
}
boolean updateVariantRules(
Player player,
String workcellId,
String pieceKey,
JigsawStudioPieceRules rules
) {
if (player == null || pieceKey == null || rules == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> updateVariantRules(
player, workcellId, pieceKey, rules));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioVariant variant = service.toolbelt.activeOwnedVariant(player, studio, workcellId, pieceKey);
if (variant == null) {
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
if (request.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE
&& !DEFAULT_PIECE_RULES.equals(rules)) {
message(player, "Piece placement rules are Iris-only for a vanilla-portable graph.");
return false;
}
if (variant.rules().equals(rules)) {
message(player, "Variant piece rules are unchanged.");
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioGraphEditor.updatePieceRules(
request.source().getDataFolder().toPath(),
request.structureKey(),
variant.pieceKey(),
rules);
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
"Updated placement rules for '" + variant.pieceKey() + "'.");
});
}
boolean toggleVariantRotatable(Player player, String workcellId) {
if (player == null || workcellId == null || workcellId.isBlank()) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> toggleVariantRotatable(player, workcellId));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio != null && !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioVariant variant = service.toolbelt.activeOwnedVariant(player, studio, workcellId);
if (variant == null) {
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
if (!canToggleVariantRotation(request.compatibilityTarget(), variant)) {
message(player, "Vanilla-portable variants must remain rotatable.");
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
boolean rotatable = !variant.rotatable();
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioGraphEditor.updateRotatable(
request.source().getDataFolder().toPath(),
request.structureKey(),
variant.pieceKey(),
rotatable);
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
"Variant rotation is now " + (rotatable ? "enabled." : "disabled."));
});
}
boolean expandVariantToCell(Player player, String workcellId) {
if (player == null || workcellId == null || workcellId.isBlank()) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> expandVariantToCell(player, workcellId));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio != null && !authorizeOwner(player, studio)) {
return false;
}
if (studio == null) {
message(player, "Iris Jigsaw Studio is not active in this world.");
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioVariant variant = service.toolbelt.activeOwnedVariant(player, studio, workcellId);
JigsawStudioBay workcell = session.layout().get(workcellId);
if (variant == null || workcell == null) {
return false;
}
return resizeVariant(player, workcellId, variant.pieceKey(), workcell.capacity());
}
boolean resizeVariant(
Player player,
String workcellId,
String pieceKey,
JigsawStudioCellDimensions dimensions
) {
if (player == null || workcellId == null || workcellId.isBlank()
|| pieceKey == null || pieceKey.isBlank() || dimensions == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> resizeVariant(player, workcellId, pieceKey, dimensions));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().get(workcellId);
JigsawStudioVariant variant = session.layout().variantCatalog().find(pieceKey).orElse(null);
if (workcell == null || variant == null || !session.layout().accepts(workcell, variant)) {
message(player, "Variant '" + pieceKey + "' does not belong to workcell '" + workcellId + "'.");
return false;
}
if (!variant.owned()) {
message(player, "Variant '" + pieceKey + "' is read-only. Duplicate it before resizing.");
return false;
}
if (dimensions.width() > workcell.capacity().width()
|| dimensions.height() > workcell.capacity().height()
|| dimensions.depth() > workcell.capacity().depth()) {
message(player, "Requested variant size " + dimensions.width() + "x" + dimensions.height() + "x"
+ dimensions.depth() + " exceeds workcell capacity " + workcell.capacity().width() + "x"
+ workcell.capacity().height() + "x" + workcell.capacity().depth()
+ ". Increase the workcell capacity first.");
return false;
}
if (variant.mode() == JigsawStudioMode.PLANAR_JIGSAW
&& (dimensions.width() < 3 || dimensions.depth() < 3)) {
message(player, "Planar variant width and depth must each be at least 3 blocks.");
return false;
}
if (variant.dimensions().filter(dimensions::equals).isPresent()) {
message(player, "Variant '" + variant.resolvedDisplayName() + "' already uses "
+ dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth() + ".");
return false;
}
boolean active = session.activeVariant(workcellId)
.map(activeVariant -> activeVariant.pieceKey().equals(pieceKey))
.orElse(false);
JigsawStudioGenerator.RenderedBay previous = active
? studio.generator().renderVariant(workcell, variant)
: null;
if (previous != null && !previous.valid()) {
message(player, "The active variant cannot be retained for live resize rollback: " + previous.failure());
return false;
}
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
message(player, "Resizing variant '" + variant.resolvedDisplayName() + "' to "
+ dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth() + "...");
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioGraphEditor.VariantResizeResult resize =
JigsawStudioGraphEditor.resizePieceObject(
request.source().getDataFolder().toPath(),
request.structureKey(),
variant.pieceKey(),
dimensions);
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
Map.of(),
active ? Optional.of(new VariantReloadRequest(workcellId, previous)) : Optional.empty(),
"Resized variant '" + variant.resolvedDisplayName() + "' from "
+ resize.previousDimensions().width() + "x"
+ resize.previousDimensions().height() + "x"
+ resize.previousDimensions().depth() + " to "
+ dimensions.width() + "x" + dimensions.height() + "x"
+ dimensions.depth() + ". " + resize.relocatedConnectors()
+ " connector(s) moved with the new bounds.");
});
}
boolean updateVariantDisplayName(
Player player,
String workcellId,
String pieceKey,
String displayName
) {
if (player == null || workcellId == null || pieceKey == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> updateVariantDisplayName(
player, workcellId, pieceKey, displayName));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().get(workcellId);
JigsawStudioVariant variant = session.layout().variantCatalog().find(pieceKey).orElse(null);
if (workcell == null || variant == null || !session.layout().accepts(workcell, variant)) {
message(player, "Variant '" + pieceKey + "' does not belong to workcell '" + workcellId + "'.");
return false;
}
if (!variant.owned()) {
message(player, "Variant '" + pieceKey + "' is read-only. Duplicate it before renaming.");
return false;
}
String normalizedName;
try {
normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName);
} catch (IllegalArgumentException exception) {
message(player, exception.getMessage());
return false;
}
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioGraphEditor.updatePieceDisplayName(
request.source().getDataFolder().toPath(),
request.structureKey(),
pieceKey,
normalizedName);
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
normalizedName.isEmpty()
? "Reset the variant label to its resource-key fallback."
: "Renamed the variant to '" + normalizedName + "'.");
});
}
boolean adjustVariantWeight(
Player player,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex,
int delta
) {
if (delta == 0) {
return false;
}
if (player == null || workcellId == null || pieceKey == null || poolKey == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> adjustVariantWeight(
player, workcellId, pieceKey, poolKey, entryIndex, delta));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio != null && !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioVariant variant = service.toolbelt.activeOwnedVariant(player, studio, workcellId, pieceKey);
if (variant == null) {
return false;
}
JigsawStudioPoolMembership membership = findMembership(
variant, poolKey, entryIndex);
if (membership == null) {
message(player, "That exact pool membership is no longer present.");
return false;
}
int weight;
try {
weight = Math.addExact(membership.weight(), delta);
} catch (ArithmeticException exception) {
message(player, "The requested variant weight is outside the supported integer range.");
return false;
}
if (weight < 1) {
message(player, "Variant weights cannot be lower than 1; unlink the membership instead.");
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioPoolEditor.updateWeightAtIndex(
request.source().getDataFolder().toPath(),
request.structureKey(),
membership.poolKey(),
membership.entryIndex(),
variant.pieceKey(),
weight);
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
"Set '" + variant.pieceKey() + "' weight to " + weight
+ " in pool '" + membership.poolKey() + "'.");
});
}
boolean adjustVariantChance(
Player player,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex,
int deltaPercentagePoints
) {
if (deltaPercentagePoints == 0 || player == null || workcellId == null
|| pieceKey == null || poolKey == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> adjustVariantChance(
player,
workcellId,
pieceKey,
poolKey,
entryIndex,
deltaPercentagePoints));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio != null && !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioVariant variant = service.toolbelt.activeOwnedVariant(player, studio, workcellId, pieceKey);
if (variant == null) {
return false;
}
JigsawStudioPoolMembership membership = findMembership(variant, poolKey, entryIndex);
if (membership == null) {
message(player, "That exact pool membership is no longer present.");
return false;
}
double chance = membership.chance() + deltaPercentagePoints / 100.0D;
if (!Double.isFinite(chance) || chance < 0.0D || chance > 1.0D) {
message(player, "Variant chance must stay between 0% and 100%.");
return false;
}
chance = Math.round(chance * 1_000_000.0D) / 1_000_000.0D;
JigsawStudioActivation.Request request = studio.generator().getRequest();
if (request.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE
&& chance != 1.0D) {
message(player, "Chance gates are Iris-only and cannot be added to a vanilla-portable graph.");
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
double targetChance = chance;
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioPoolEditor.updateChanceAtIndex(
request.source().getDataFolder().toPath(),
request.structureKey(),
membership.poolKey(),
membership.entryIndex(),
variant.pieceKey(),
targetChance);
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
"Set '" + variant.pieceKey() + "' chance to "
+ Math.round(targetChance * 100.0D) + "% in pool '"
+ membership.poolKey() + "'.");
});
}
static boolean canResizeVariantToCapacity(
JigsawStudioBay workcell,
JigsawStudioVariant variant,
boolean active
) {
if (!active || !variant.owned()) {
return false;
}
JigsawStudioCellDimensions canonical = variant.dimensions().orElse(null);
if (canonical == null) {
return false;
}
JigsawStudioCellDimensions target = workcell.capacity();
return canonical.width() <= target.width()
&& canonical.height() <= target.height()
&& canonical.depth() <= target.depth()
&& (canonical.width() < target.width()
|| canonical.height() < target.height()
|| canonical.depth() < target.depth());
}
}
@@ -0,0 +1,404 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.engine.framework.PlacedStructurePiece;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import static art.arcane.iris.core.service.JigsawStudioService.message;
final class JigsawStudioVisualization {
private static final int VISUAL_INTERVAL_TICKS = 8;
private static final int PARTICLE_BUDGET = 384;
private static final double VISUAL_RANGE = 96.0D;
private static final double VISUAL_RANGE_SQUARED = VISUAL_RANGE * VISUAL_RANGE;
private static final Color SELECTED_COLOR = Color.AQUA;
private static final Color NEARBY_COLOR = Color.fromRGB(92, 102, 118);
private static final Color INVALID_BAY_COLOR = Color.RED;
private static final Color INVALID_CONNECTOR_COLOR = Color.RED;
private static final Color VALID_CONNECTOR_COLOR = Color.LIME;
private static final Color ASSEMBLY_PREVIEW_COLOR = Color.fromRGB(180, 90, 255);
private static final Color LIVE_PREVIEW_WARNING_COLOR = Color.fromRGB(255, 180, 55);
private static final long ASSEMBLY_PREVIEW_MILLIS = 10_000L;
private final JigsawStudioService service;
final Set<UUID> visualizationLoops = ConcurrentHashMap.newKeySet();
final Map<UUID, AssemblyPreview> assemblyPreviews = new ConcurrentHashMap<>();
JigsawStudioVisualization(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean showAssemblyPreview(Player player, List<PlacedStructurePiece> pieces) {
if (player == null || pieces == null || pieces.isEmpty()) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> showAssemblyPreview(player, pieces));
}
if (!service.studios.containsKey(player.getWorld().getUID())) {
message(player, "Iris Jigsaw Studio is not active in this world.");
return false;
}
List<JigsawStudioPreviewRenderer.PreviewBounds> bounds = new ArrayList<>(pieces.size());
for (PlacedStructurePiece piece : pieces) {
bounds.add(new JigsawStudioPreviewRenderer.PreviewBounds(
piece.getMinX(),
piece.getMinY(),
piece.getMinZ(),
piece.getMaxX(),
piece.getMaxY(),
piece.getMaxZ()));
}
assemblyPreviews.put(player.getUniqueId(), new AssemblyPreview(
player.getWorld().getUID(),
System.currentTimeMillis() + ASSEMBLY_PREVIEW_MILLIS,
List.copyOf(bounds)));
service.particlesDisabled.remove(player.getUniqueId());
ensureVisualizationLoop(player);
return true;
}
void ensureVisualizationLoop(Player player) {
if (player == null) {
return;
}
if (!J.isOwnedByCurrentRegion(player)) {
J.runEntity(player, () -> ensureVisualizationLoop(player));
return;
}
UUID playerId = player.getUniqueId();
if (!service.enabled || service.particlesDisabled.contains(playerId)
|| !service.studios.containsKey(player.getWorld().getUID())
|| !visualizationLoops.add(playerId)) {
return;
}
visualizationTick(player);
}
private void visualizationTick(Player player) {
UUID playerId = player.getUniqueId();
if (!service.enabled || service.particlesDisabled.contains(playerId)) {
visualizationLoops.remove(playerId);
return;
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null) {
visualizationLoops.remove(playerId);
return;
}
try {
renderVisualization(player, studio);
} catch (Throwable exception) {
visualizationLoops.remove(playerId);
IrisLogging.reportError(exception);
return;
}
boolean scheduled = J.runEntity(
player,
() -> visualizationTick(player),
VISUAL_INTERVAL_TICKS,
() -> visualizationLoops.remove(playerId));
if (!scheduled) {
visualizationLoops.remove(playerId);
}
}
private void renderVisualization(Player player, ActiveStudio studio) {
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioLayout layout = session.layout();
Location playerLocation = player.getLocation();
JigsawStudioBay focused = layout.findAt(
playerLocation.getBlockX(),
playerLocation.getBlockY(),
playerLocation.getBlockZ());
ParticleBudget budget = new ParticleBudget(PARTICLE_BUDGET);
if (focused != null) {
Color focusedColor = bayValid(studio, focused) ? SELECTED_COLOR : INVALID_BAY_COLOR;
drawConnectors(player, playerLocation, studio, focused, budget);
drawBounds(player, playerLocation, focused.bounds(), focusedColor, 1.15F, 1.0D, budget);
}
drawLivePreview(player, playerLocation, studio, budget);
drawAssemblyPreview(player, playerLocation, budget);
for (JigsawStudioBay bay : layout.bays()) {
if (bay == focused || !isNearby(playerLocation, bay.bounds())) {
continue;
}
Color nearbyColor = bayValid(studio, bay) ? NEARBY_COLOR : INVALID_BAY_COLOR;
drawBounds(player, playerLocation, bay.bounds(), nearbyColor, 0.55F, 4.0D, budget);
if (budget.empty()) {
return;
}
}
}
private void drawLivePreview(
Player player,
Location playerLocation,
ActiveStudio studio,
ParticleBudget budget
) {
UUID requestId = studio.generator().getRequest().requestId();
JigsawStudioGraphEvaluation evaluation = service.evaluator.evaluations.get(requestId);
if (evaluation == null || evaluation.previewBounds().isEmpty()) {
return;
}
JigsawStudioPreviewRenderer.PreviewBounds preview = evaluation.previewBounds();
Color color = switch (evaluation.state()) {
case VALID -> ASSEMBLY_PREVIEW_COLOR;
case PENDING, WARNING, STALE -> LIVE_PREVIEW_WARNING_COLOR;
case INVALID -> INVALID_BAY_COLOR;
};
drawBounds(
player,
playerLocation,
preview,
color,
0.85F,
2.0D,
budget);
}
private void drawAssemblyPreview(
Player player,
Location playerLocation,
ParticleBudget budget
) {
AssemblyPreview preview = assemblyPreviews.get(player.getUniqueId());
if (preview == null) {
return;
}
if (!preview.worldId().equals(player.getWorld().getUID())
|| preview.expiresAtMillis() < System.currentTimeMillis()) {
assemblyPreviews.remove(player.getUniqueId(), preview);
return;
}
for (JigsawStudioPreviewRenderer.PreviewBounds bounds : preview.bounds()) {
drawBounds(player, playerLocation, bounds, ASSEMBLY_PREVIEW_COLOR, 0.85F, 2.0D, budget);
if (budget.empty()) {
return;
}
}
}
private static void drawConnectors(
Player player,
Location playerLocation,
ActiveStudio studio,
JigsawStudioBay bay,
ParticleBudget budget
) {
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(bay);
if (!rendered.valid()) {
return;
}
for (JigsawStudioGenerator.RenderedConnector renderedConnector : rendered.connectors()) {
IrisJigsawConnector connector = renderedConnector.connector();
IrisDirection direction = connector.getDirection();
drawConnectorLine(
player,
playerLocation,
bay.bounds().originX() + renderedConnector.x() + 0.5D,
bay.bounds().originY() + renderedConnector.y() + 0.5D,
bay.bounds().originZ() + renderedConnector.z() + 0.5D,
direction,
connectorColor(connector),
budget);
if (budget.empty()) {
return;
}
}
}
private static void drawConnectorLine(
Player player,
Location playerLocation,
double startX,
double startY,
double startZ,
IrisDirection direction,
Color color,
ParticleBudget budget
) {
drawLine(
player,
playerLocation,
startX,
startY,
startZ,
startX + direction.x() * 1.75D,
startY + direction.y() * 1.75D,
startZ + direction.z() * 1.75D,
color,
1.05F,
0.35D,
budget);
}
private static boolean bayValid(ActiveStudio studio, JigsawStudioBay bay) {
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(bay);
return rendered.valid() && studio.population(bay).readiness().failure().isEmpty();
}
private static Color connectorColor(IrisJigsawConnector connector) {
if (connector.getPool() == null || connector.getPool().isBlank()
|| connector.getName() == null || connector.getName().isBlank()
|| connector.getTargetName() == null || connector.getTargetName().isBlank()) {
return INVALID_CONNECTOR_COLOR;
}
String channel = connector.getChannel();
if (channel == null || channel.isBlank()) {
return VALID_CONNECTOR_COLOR;
}
int hash = channel.toLowerCase(Locale.ROOT).hashCode();
int red = 80 + (hash & 127);
int green = 80 + ((hash >>> 8) & 127);
int blue = 80 + ((hash >>> 16) & 127);
return Color.fromRGB(red, green, blue);
}
private static void drawBounds(
Player player,
Location playerLocation,
JigsawStudioBounds bounds,
Color color,
float size,
double step,
ParticleBudget budget
) {
drawBounds(player, playerLocation, new JigsawStudioPreviewRenderer.PreviewBounds(
bounds.originX(), bounds.originY(), bounds.originZ(), bounds.maxX(), bounds.maxY(), bounds.maxZ()),
color, size, step, budget);
}
private static void drawBounds(
Player player,
Location playerLocation,
JigsawStudioPreviewRenderer.PreviewBounds bounds,
Color color,
float size,
double step,
ParticleBudget budget
) {
double minX = bounds.minimumX();
double minY = bounds.minimumY();
double minZ = bounds.minimumZ();
double maxX = bounds.maximumX() + 1.0D;
double maxY = bounds.maximumY() + 1.0D;
double maxZ = bounds.maximumZ() + 1.0D;
drawLine(player, playerLocation, minX, minY, minZ, maxX, minY, minZ, color, size, step, budget);
drawLine(player, playerLocation, minX, minY, maxZ, maxX, minY, maxZ, color, size, step, budget);
drawLine(player, playerLocation, minX, maxY, minZ, maxX, maxY, minZ, color, size, step, budget);
drawLine(player, playerLocation, minX, maxY, maxZ, maxX, maxY, maxZ, color, size, step, budget);
drawLine(player, playerLocation, minX, minY, minZ, minX, maxY, minZ, color, size, step, budget);
drawLine(player, playerLocation, maxX, minY, minZ, maxX, maxY, minZ, color, size, step, budget);
drawLine(player, playerLocation, minX, minY, maxZ, minX, maxY, maxZ, color, size, step, budget);
drawLine(player, playerLocation, maxX, minY, maxZ, maxX, maxY, maxZ, color, size, step, budget);
drawLine(player, playerLocation, minX, minY, minZ, minX, minY, maxZ, color, size, step, budget);
drawLine(player, playerLocation, maxX, minY, minZ, maxX, minY, maxZ, color, size, step, budget);
drawLine(player, playerLocation, minX, maxY, minZ, minX, maxY, maxZ, color, size, step, budget);
drawLine(player, playerLocation, maxX, maxY, minZ, maxX, maxY, maxZ, color, size, step, budget);
}
private static void drawLine(
Player player,
Location playerLocation,
double startX,
double startY,
double startZ,
double endX,
double endY,
double endZ,
Color color,
float size,
double step,
ParticleBudget budget
) {
if (budget.empty()) {
return;
}
double deltaX = endX - startX;
double deltaY = endY - startY;
double deltaZ = endZ - startZ;
double length = Math.sqrt(deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ);
int samples = Math.max(1, (int) Math.ceil(length / step));
Particle.DustOptions dust = new Particle.DustOptions(color, size);
for (int index = 0; index <= samples; index++) {
double progress = (double) index / samples;
double x = startX + deltaX * progress;
double y = startY + deltaY * progress;
double z = startZ + deltaZ * progress;
double distanceX = playerLocation.getX() - x;
double distanceY = playerLocation.getY() - y;
double distanceZ = playerLocation.getZ() - z;
if (distanceX * distanceX + distanceY * distanceY + distanceZ * distanceZ > VISUAL_RANGE_SQUARED) {
continue;
}
if (!budget.consume()) {
return;
}
player.spawnParticle(Particle.DUST, x, y, z, 1, 0.0D, 0.0D, 0.0D, 0.0D, dust);
}
}
private static boolean isNearby(Location location, JigsawStudioBounds bounds) {
double centerX = bounds.originX() + bounds.dimensions().width() / 2.0D;
double centerY = bounds.originY() + bounds.dimensions().height() / 2.0D;
double centerZ = bounds.originZ() + bounds.dimensions().depth() / 2.0D;
double deltaX = location.getX() - centerX;
double deltaY = location.getY() - centerY;
double deltaZ = location.getZ() - centerZ;
return deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= VISUAL_RANGE_SQUARED;
}
private record AssemblyPreview(
UUID worldId,
long expiresAtMillis,
List<JigsawStudioPreviewRenderer.PreviewBounds> bounds
) {
AssemblyPreview {
Objects.requireNonNull(worldId, "Jigsaw Studio preview world");
bounds = List.copyOf(bounds);
}
}
private static final class ParticleBudget {
private int remaining;
private ParticleBudget(int remaining) {
this.remaining = remaining;
}
private boolean consume() {
if (remaining < 1) {
return false;
}
remaining--;
return true;
}
private boolean empty() {
return remaining < 1;
}
}
}
@@ -0,0 +1,434 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype;
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.JigsawStudioGraphEditor;
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.JigsawStudioMaterializer.MaterializationWork;
import art.arcane.iris.core.service.JigsawStudioService.ActiveStudio;
import art.arcane.iris.core.service.JigsawStudioService.CommandGraphMutationResult;
import art.arcane.iris.engine.object.IrisJigsawThemeSet;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import static art.arcane.iris.core.service.JigsawStudioChunkWriter.validateMaterialization;
import static art.arcane.iris.core.service.JigsawStudioEvaluator.loadStudioStructure;
import static art.arcane.iris.core.service.JigsawStudioGraphMutations.loadMappedLayout;
import static art.arcane.iris.core.service.JigsawStudioProtection.authorizeOwner;
import static art.arcane.iris.core.service.JigsawStudioService.message;
final class JigsawStudioWorkcellEditor {
private final JigsawStudioService service;
JigsawStudioWorkcellEditor(JigsawStudioService service) {
this.service = Objects.requireNonNull(service, "Jigsaw Studio service");
}
boolean setConnectorBlocksVisible(Player player, String workcellId, boolean visible) {
if (player == null || workcellId == null || workcellId.isBlank()) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> setConnectorBlocksVisible(player, workcellId, visible));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().get(workcellId);
if (workcell == null) {
message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'.");
return false;
}
JigsawStudioSession.WorkcellSnapshot snapshot = session.workcellSnapshot(workcellId);
if (snapshot.connectorsVisible() == visible) {
message(player, "Connector blocks are already " + (visible ? "visible." : "hidden."));
return false;
}
JigsawStudioSession.SwitchStart start = session.beginVariantReload(workcellId);
if (start.status() != JigsawStudioSession.SwitchStatus.STARTED) {
message(player, switch (start.status()) {
case DIRTY -> "Wait for this workcell to finish autosaving before changing connector visibility.";
case SAVE_IN_PROGRESS -> "Wait for the current workcell save to finish.";
case SWITCH_IN_PROGRESS -> "This workcell is already loading another view.";
case UNKNOWN_WORKCELL -> "The selected workcell no longer exists.";
case UNKNOWN_VARIANT -> "This workcell has no active variant.";
case ALREADY_ACTIVE, WRONG_WORKCELL, STARTED ->
"Connector visibility could not change: " + start.status() + ".";
});
return false;
}
JigsawStudioSession.VariantSwitchToken token = start.token().orElseThrow();
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderVariant(
workcell,
token.targetVariant());
if (!rendered.valid()) {
session.abortVariantSwitch(token);
message(player, "Connector visibility cannot change: " + rendered.failure());
return false;
}
message(player, (visible ? "Showing" : "Hiding") + " connector blocks in " + workcellId + "...");
return service.materializer.scheduleMaterialization(new MaterializationWork(
studio,
player.getWorld(),
player,
workcell,
token,
rendered,
rendered,
snapshot.connectorsVisible(),
visible,
true));
}
boolean resetConnectorBlocks(Player player, String workcellId) {
if (player == null || workcellId == null || workcellId.isBlank()) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> resetConnectorBlocks(player, workcellId));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().get(workcellId);
if (workcell == null) {
message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'.");
return false;
}
JigsawStudioVariant activeVariant = session.activeVariant(workcellId).orElse(null);
if (activeVariant == null || !activeVariant.owned()) {
message(player, "Load an owned variant before resetting its connector blocks.");
return false;
}
JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderVariant(
workcell,
activeVariant);
String validationFailure = validateMaterialization(rendered);
if (!validationFailure.isEmpty()) {
message(player, "Connector blocks cannot reset: " + validationFailure);
return false;
}
if (rendered.connectors().isEmpty()) {
message(player, "The active variant has no saved connectors to reset.");
return false;
}
String reservationFailure = service.materializer.beginConnectorRepair(studio);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
boolean connectorsVisible = session.workcellSnapshot(workcellId).connectorsVisible();
message(player, "Resetting " + rendered.connectors().size()
+ " connector block(s) from the last saved iteration...");
return service.materializer.scheduleConnectorRepair(
player,
studio,
workcell,
rendered,
connectorsVisible);
}
boolean setWorkcellEnabled(Player player, String workcellId, boolean enabled) {
if (player == null || workcellId == null || workcellId.isBlank()) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> setWorkcellEnabled(player, workcellId, enabled));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().get(workcellId);
if (workcell == null || workcell.archetype().isEmpty()) {
message(player, "Only planar Jigsaw Studio workcells can be enabled or disabled.");
return false;
}
if (workcell.enabled() == enabled) {
message(player, "Workcell '" + workcellId + "' is already "
+ (enabled ? "enabled." : "disabled."));
return false;
}
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
JigsawPlanarArchetype archetype = workcell.archetype().orElseThrow();
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioStructureEditor.updateWorkcellEnabled(
request.source().getDataFolder().toPath(),
request.structureKey(),
archetype,
enabled);
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
"Workcell '" + workcellId + "' is now "
+ (enabled ? "enabled." : "disabled for assembly and export."));
});
}
boolean updateWorkcellDimensions(
Player player,
String workcellId,
JigsawStudioCellDimensions dimensions
) {
if (player == null || workcellId == null || workcellId.isBlank() || dimensions == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> updateWorkcellDimensions(player, workcellId, dimensions));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().get(workcellId);
if (workcell == null) {
message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'.");
return false;
}
if (workcell.capacity().equals(dimensions)) {
message(player, "Workcell '" + workcellId + "' already has capacity "
+ dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth() + ".");
return false;
}
if (workcell.archetype().isPresent()
&& (dimensions.width() < 3 || dimensions.depth() < 3)) {
message(player, "Planar workcell width and depth must each be at least 3 blocks.");
return false;
}
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
JigsawPlanarArchetype archetype = workcell.archetype().orElse(null);
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioGraphEditor.WorkcellCapacityResult capacityResult = null;
if (archetype == null) {
JigsawStudioStructureEditor.updateCellSize(
request.source().getDataFolder().toPath(),
request.structureKey(),
dimensions);
} else {
capacityResult = JigsawStudioGraphEditor.updatePlanarWorkcellCapacity(
request.source().getDataFolder().toPath(),
request.structureKey(),
archetype,
dimensions);
}
request.source().invalidateStructureResources();
String resizeSummary = capacityResult == null
? ""
: " Verified " + capacityResult.checkedVariants() + " existing variant"
+ (capacityResult.checkedVariants() == 1 ? "" : "s") + "; no variant object was resized.";
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
"Updated '" + workcellId + "' capacity to "
+ dimensions.width() + "x" + dimensions.height() + "x"
+ dimensions.depth() + "." + resizeSummary
+ " The live workcell layout was regenerated.");
});
}
boolean setRequireCaps(Player player, boolean requireCaps) {
if (player == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> setRequireCaps(player, requireCaps));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
if (request.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE
&& requireCaps) {
message(player, "Mandatory caps are Iris-only and cannot be enabled for a vanilla-portable graph.");
return false;
}
IrisStructure structure = loadStudioStructure(studio);
if (structure == null) {
message(player, "The active jigsaw structure could not be loaded.");
return false;
}
if (structure.isRequireCaps() == requireCaps) {
message(player, "Mandatory caps are already " + (requireCaps ? "enabled." : "disabled."));
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioStructureEditor.updateRequireCaps(
request.source().getDataFolder().toPath(),
request.structureKey(),
requireCaps);
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
"Mandatory physical caps are now "
+ (requireCaps ? "enabled." : "disabled."));
});
}
boolean updateThemeSetWeight(Player player, String themeKey, int weight) {
if (player == null || themeKey == null || themeKey.isBlank() || weight < 1) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> updateThemeSetWeight(player, themeKey, weight));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
if (request.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE) {
message(player, "Coherent theme sets are Iris-only.");
return false;
}
IrisStructure structure = loadStudioStructure(studio);
if (structure == null || structure.getThemeSets() == null) {
message(player, "The active jigsaw structure has no theme sets.");
return false;
}
List<IrisJigsawThemeSet> updated = new ArrayList<>(structure.getThemeSets().size());
boolean found = false;
for (IrisJigsawThemeSet theme : structure.getThemeSets()) {
if (theme != null && themeKey.equals(theme.getKey())) {
if (theme.getWeight() == weight) {
message(player, "Theme '" + themeKey + "' already has weight " + weight + ".");
return false;
}
updated.add(new IrisJigsawThemeSet(themeKey, weight));
found = true;
} else if (theme != null) {
updated.add(new IrisJigsawThemeSet(theme.getKey(), theme.getWeight()));
}
}
if (!found) {
message(player, "Theme set '" + themeKey + "' no longer exists.");
return false;
}
JigsawStudioSession session = studio.generator().getSession();
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
JigsawStudioStructureEditor.updateThemeSets(
request.source().getDataFolder().toPath(),
request.structureKey(),
updated);
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
"Set theme '" + themeKey + "' weight to " + weight + ".");
});
}
boolean updateWorkcellDisplayName(
Player player,
String workcellId,
String displayName
) {
if (player == null || workcellId == null) {
return false;
}
if (!J.isOwnedByCurrentRegion(player)) {
return J.runEntity(player, () -> updateWorkcellDisplayName(player, workcellId, displayName));
}
ActiveStudio studio = service.studios.get(player.getWorld().getUID());
if (studio == null || !authorizeOwner(player, studio)) {
return false;
}
JigsawStudioSession session = studio.generator().getSession();
JigsawStudioBay workcell = session.layout().get(workcellId);
if (workcell == null) {
message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'.");
return false;
}
String normalizedName;
try {
normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName);
} catch (IllegalArgumentException exception) {
message(player, exception.getMessage());
return false;
}
String reservationFailure = service.graphMutations.beginGraphMutation(studio, session);
if (!reservationFailure.isEmpty()) {
message(player, reservationFailure);
return false;
}
JigsawStudioActivation.Request request = studio.generator().getRequest();
JigsawPlanarArchetype archetype = workcell.archetype().orElse(null);
return service.graphMutations.scheduleGraphMutation(
player,
studio,
() -> {
if (archetype == null) {
JigsawStudioStructureEditor.updateSpatialWorkcellDisplayName(
request.source().getDataFolder().toPath(),
request.structureKey(),
normalizedName);
} else {
JigsawStudioStructureEditor.updateWorkcellDisplayName(
request.source().getDataFolder().toPath(),
request.structureKey(),
archetype,
normalizedName);
}
return new CommandGraphMutationResult(
loadMappedLayout(studio),
"",
"",
normalizedName.isEmpty()
? "Reset the workcell label to '" + workcell.canonicalDisplayName() + "'."
: "Renamed the workcell to '" + normalizedName + "'.");
});
}
}
@@ -112,18 +112,18 @@ public class JigsawStudioLifecycleTest {
assertTrue(service.closeProtectionFailure(request.requestId()).contains("loading a variant")); assertTrue(service.closeProtectionFailure(request.requestId()).contains("loading a variant"));
assertTrue(session.abortVariantSwitch(switchToken)); assertTrue(session.abortVariantSwitch(switchToken));
assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(request.requestId())); assertEquals(JigsawStudioSaveLifecycle.SaveStart.STARTED, service.saveLifecycle.tryBeginSave(request.requestId()));
assertEquals( assertEquals(
JigsawStudioService.CloseStart.SAVE_IN_PROGRESS, JigsawStudioService.CloseStart.SAVE_IN_PROGRESS,
service.tryBeginClose(request.requestId(), OWNER, true)); service.tryBeginClose(request.requestId(), OWNER, true));
assertTrue(service.closeProtectionFailure(request.requestId()).contains("saving")); assertTrue(service.closeProtectionFailure(request.requestId()).contains("saving"));
service.finishSave(request.requestId()); service.saveLifecycle.finishSave(request.requestId());
assertEquals( assertEquals(
JigsawStudioService.CloseStart.STARTED, JigsawStudioService.CloseStart.STARTED,
service.tryBeginClose(request.requestId(), OWNER, true)); service.tryBeginClose(request.requestId(), OWNER, true));
assertNull(service.closeProtectionFailure(request.requestId())); assertNull(service.closeProtectionFailure(request.requestId()));
assertEquals(JigsawStudioService.SaveStart.CLOSING, service.tryBeginSave(request.requestId())); assertEquals(JigsawStudioSaveLifecycle.SaveStart.CLOSING, service.saveLifecycle.tryBeginSave(request.requestId()));
} }
@Test @Test
@@ -143,7 +143,7 @@ public class JigsawStudioLifecycleTest {
retry.set(invocation.getArgument(0)); retry.set(invocation.getArgument(0));
return null; return null;
}); });
CompletableFuture<Void> readiness = service.awaitCloseForReplacement( CompletableFuture<Void> readiness = service.saveLifecycle.awaitCloseForReplacement(
request.requestId(), OWNER); request.requestId(), OWNER);
assertFalse(readiness.isDone()); assertFalse(readiness.isDone());
@@ -167,7 +167,7 @@ public class JigsawStudioLifecycleTest {
JigsawStudioService service = new JigsawStudioService(); JigsawStudioService service = new JigsawStudioService();
try (MockedStatic<J> scheduling = mockStatic(J.class)) { try (MockedStatic<J> scheduling = mockStatic(J.class)) {
CompletableFuture<Void> readiness = service.awaitCloseForReplacement( CompletableFuture<Void> readiness = service.saveLifecycle.awaitCloseForReplacement(
request.requestId(), OTHER_OWNER); request.requestId(), OTHER_OWNER);
assertTrue(readiness.isCompletedExceptionally()); assertTrue(readiness.isCompletedExceptionally());
@@ -187,9 +187,9 @@ public class JigsawStudioLifecycleTest {
baseline.put("name", "iris:start"); baseline.put("name", "iris:start");
KMap<String, Object> updated = new KMap<>(); KMap<String, Object> updated = new KMap<>();
updated.put("name", "iris:hall"); updated.put("name", "iris:hall");
assertTrue(JigsawStudioService.tileSnapshotChanged(baseline, updated)); assertTrue(JigsawStudioTileWatcher.tileSnapshotChanged(baseline, updated));
Class<?> keyType = Class.forName(JigsawStudioService.class.getName() + "$JigsawTileWatchKey"); Class<?> keyType = Class.forName(JigsawStudioTileWatcher.class.getName() + "$JigsawTileWatchKey");
Constructor<?> constructor = keyType.getDeclaredConstructor( Constructor<?> constructor = keyType.getDeclaredConstructor(
UUID.class, UUID.class, int.class, int.class, int.class); UUID.class, UUID.class, int.class, int.class, int.class);
constructor.setAccessible(true); constructor.setAccessible(true);
@@ -213,7 +213,7 @@ public class JigsawStudioLifecycleTest {
new ConcurrentHashMap<>(), new ConcurrentHashMap<>(),
ConcurrentHashMap.newKeySet(), ConcurrentHashMap.newKeySet(),
new AtomicLong()); new AtomicLong());
Class<?> watchType = Class.forName(JigsawStudioService.class.getName() + "$JigsawTileWatch"); Class<?> watchType = Class.forName(JigsawStudioTileWatcher.class.getName() + "$JigsawTileWatch");
Constructor<?> watchConstructor = watchType.getDeclaredConstructor( Constructor<?> watchConstructor = watchType.getDeclaredConstructor(
keyType, keyType,
studioType, studioType,
@@ -231,10 +231,10 @@ public class JigsawStudioLifecycleTest {
baseline, baseline,
new AtomicBoolean(), new AtomicBoolean(),
new AtomicBoolean()); new AtomicBoolean());
Field watchesField = JigsawStudioService.class.getDeclaredField("jigsawTileWatches"); Field watchesField = JigsawStudioTileWatcher.class.getDeclaredField("jigsawTileWatches");
watchesField.setAccessible(true); watchesField.setAccessible(true);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<Object, Object> watches = (Map<Object, Object>) watchesField.get(service); Map<Object, Object> watches = (Map<Object, Object>) watchesField.get(service.tileWatcher);
watches.put(key, watch); watches.put(key, watch);
try (MockedStatic<J> scheduling = mockStatic(J.class)) { try (MockedStatic<J> scheduling = mockStatic(J.class)) {
@@ -277,55 +277,55 @@ public class JigsawStudioLifecycleTest {
JigsawStudioService.ExportStart.IN_PROGRESS, JigsawStudioService.ExportStart.IN_PROGRESS,
service.tryBeginExport(request.requestId(), OWNER)); service.tryBeginExport(request.requestId(), OWNER));
assertEquals( assertEquals(
JigsawStudioService.SaveStart.EXPORT_OPERATION, JigsawStudioSaveLifecycle.SaveStart.EXPORT_OPERATION,
service.tryBeginSave(request.requestId())); service.saveLifecycle.tryBeginSave(request.requestId()));
assertEquals( assertEquals(
JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS, JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS,
service.tryBeginClose(request.requestId(), OWNER, false)); service.tryBeginClose(request.requestId(), OWNER, false));
assertTrue(service.closeProtectionFailure(request.requestId()).contains("exporting")); assertTrue(service.closeProtectionFailure(request.requestId()).contains("exporting"));
service.finishExport(request.requestId()); service.finishExport(request.requestId());
assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(request.requestId())); assertEquals(JigsawStudioSaveLifecycle.SaveStart.STARTED, service.saveLifecycle.tryBeginSave(request.requestId()));
service.finishSave(request.requestId()); service.saveLifecycle.finishSave(request.requestId());
} }
@Test @Test
public void immediateSingleAndFamilyDuplicatesWaitForAutosaveThenRunExactlyOnce() { public void immediateSingleAndFamilyDuplicatesWaitForAutosaveThenRunExactlyOnce() {
assertEquals( assertEquals(
JigsawStudioService.DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE, JigsawStudioToolbelt.DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE,
JigsawStudioService.deferredDuplicationReadiness( JigsawStudioToolbelt.deferredDuplicationReadiness(
true, true, true, false, false)); true, true, true, false, false));
assertEquals( assertEquals(
JigsawStudioService.DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE, JigsawStudioToolbelt.DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE,
JigsawStudioService.deferredDuplicationReadiness( JigsawStudioToolbelt.deferredDuplicationReadiness(
true, true, true, true, false)); true, true, true, true, false));
assertEquals( assertEquals(
JigsawStudioService.DeferredDuplicationReadiness.WAITING_FOR_OPERATION, JigsawStudioToolbelt.DeferredDuplicationReadiness.WAITING_FOR_OPERATION,
JigsawStudioService.deferredDuplicationReadiness( JigsawStudioToolbelt.deferredDuplicationReadiness(
true, true, false, true, false)); true, true, false, true, false));
assertEquals( assertEquals(
JigsawStudioService.DeferredDuplicationReadiness.READY, JigsawStudioToolbelt.DeferredDuplicationReadiness.READY,
JigsawStudioService.deferredDuplicationReadiness( JigsawStudioToolbelt.deferredDuplicationReadiness(
true, true, false, false, false)); true, true, false, false, false));
assertEquals( assertEquals(
JigsawStudioService.DeferredDuplicationReadiness.STALE, JigsawStudioToolbelt.DeferredDuplicationReadiness.STALE,
JigsawStudioService.deferredDuplicationReadiness( JigsawStudioToolbelt.deferredDuplicationReadiness(
true, false, false, false, false)); true, false, false, false, false));
} }
@Test @Test
public void initialEvaluationWaitsForCommitAndSchedulesExactlyOnce() { public void initialEvaluationWaitsForCommitAndSchedulesExactlyOnce() {
AtomicLong registrationBeforeCommit = new AtomicLong(); AtomicLong registrationBeforeCommit = new AtomicLong();
assertFalse(JigsawStudioService.claimInitialEvaluation(registrationBeforeCommit, false)); assertFalse(JigsawStudioEvaluator.claimInitialEvaluation(registrationBeforeCommit, false));
assertEquals(0L, registrationBeforeCommit.get()); assertEquals(0L, registrationBeforeCommit.get());
assertTrue(JigsawStudioService.claimInitialEvaluation(registrationBeforeCommit, true)); assertTrue(JigsawStudioEvaluator.claimInitialEvaluation(registrationBeforeCommit, true));
assertEquals(1L, registrationBeforeCommit.get()); assertEquals(1L, registrationBeforeCommit.get());
assertFalse(JigsawStudioService.claimInitialEvaluation(registrationBeforeCommit, true)); assertFalse(JigsawStudioEvaluator.claimInitialEvaluation(registrationBeforeCommit, true));
AtomicLong registrationAfterCommit = new AtomicLong(); AtomicLong registrationAfterCommit = new AtomicLong();
assertTrue(JigsawStudioService.claimInitialEvaluation(registrationAfterCommit, true)); assertTrue(JigsawStudioEvaluator.claimInitialEvaluation(registrationAfterCommit, true));
assertEquals(1L, registrationAfterCommit.get()); assertEquals(1L, registrationAfterCommit.get());
assertFalse(JigsawStudioService.claimInitialEvaluation(registrationAfterCommit, true)); assertFalse(JigsawStudioEvaluator.claimInitialEvaluation(registrationAfterCommit, true));
} }
@Test @Test
@@ -339,9 +339,9 @@ public class JigsawStudioLifecycleTest {
JigsawStudioBay blank = layout.get("workcell/blank"); JigsawStudioBay blank = layout.get("workcell/blank");
assertTrue(session.selectedBayId().isEmpty()); assertTrue(session.selectedBayId().isEmpty());
assertTrue(JigsawStudioService.selectEnteredWorkcell(session, end, true)); assertTrue(JigsawStudioPlayerContext.selectEnteredWorkcell(session, end, true));
assertEquals(end.stableId(), session.selectedBayId().orElseThrow()); assertEquals(end.stableId(), session.selectedBayId().orElseThrow());
assertFalse(JigsawStudioService.selectEnteredWorkcell(session, blank, false)); assertFalse(JigsawStudioPlayerContext.selectEnteredWorkcell(session, blank, false));
assertEquals(end.stableId(), session.selectedBayId().orElseThrow()); assertEquals(end.stableId(), session.selectedBayId().orElseThrow());
} }
@@ -571,14 +571,14 @@ public class JigsawStudioMenuControllerTest {
when(world.getUID()).thenReturn(worldId); when(world.getUID()).thenReturn(worldId);
when(location.getWorld()).thenReturn(world); when(location.getWorld()).thenReturn(world);
Field menuField = JigsawStudioService.class.getDeclaredField("menuController"); Field menuField = JigsawStudioToolbelt.class.getDeclaredField("menuController");
menuField.setAccessible(true); menuField.setAccessible(true);
menuField.set(service, menu); menuField.set(service.toolbelt, menu);
IrisServices.register(BoardSVC.class, board); IrisServices.register(BoardSVC.class, board);
try (MockedStatic<J> scheduling = mockStatic(J.class)) { try (MockedStatic<J> scheduling = mockStatic(J.class)) {
scheduling.when(() -> J.isOwnedByCurrentRegion(player)).thenReturn(true); scheduling.when(() -> J.isOwnedByCurrentRegion(player)).thenReturn(true);
service.onPlayerChangedWorld(event); service.protectionListener.onPlayerChangedWorld(event);
verify(menu).close(player); verify(menu).close(player);
verify(board).clearJigsawContext(player); verify(board).clearJigsawContext(player);
@@ -79,10 +79,10 @@ public class JigsawStudioPreviewRendererTest {
10, 10,
IrisObjectRotation.of(0, 0, 0)); IrisObjectRotation.of(0, 0, 0));
PlacedStructurePiece planar = JigsawStudioService.alignPreviewPieces( PlacedStructurePiece planar = JigsawStudioEvaluator.alignPreviewPieces(
List.of(source), List.of(source),
JigsawStudioMode.PLANAR_JIGSAW).getFirst(); JigsawStudioMode.PLANAR_JIGSAW).getFirst();
PlacedStructurePiece spatial = JigsawStudioService.alignPreviewPieces( PlacedStructurePiece spatial = JigsawStudioEvaluator.alignPreviewPieces(
List.of(source), List.of(source),
JigsawStudioMode.SPATIAL_JIGSAW).getFirst(); JigsawStudioMode.SPATIAL_JIGSAW).getFirst();
@@ -197,11 +197,11 @@ public class JigsawStudioServiceCaptureTest {
when(state.isCustom()).thenReturn(false); when(state.isCustom()).thenReturn(false);
when(state.nativeHandle()).thenReturn(blockData); when(state.nativeHandle()).thenReturn(blockData);
JigsawStudioService.restoreConnectorChunk( JigsawStudioChunkWriter.restoreConnectorChunk(
world, world,
workcell, workcell,
List.of(renderedConnector), List.of(renderedConnector),
Map.of(new JigsawStudioService.LocalPosition(1, 2, 3), renderedBlock), Map.of(new JigsawStudioCapture.LocalPosition(1, 2, 3), renderedBlock),
false); false);
verify(world).getBlockAt(11, 22, 33); verify(world).getBlockAt(11, 22, 33);
@@ -229,9 +229,9 @@ public class JigsawStudioServiceCaptureTest {
expandedSpecs, expandedSpecs,
JigsawStudioVariantCatalog.empty()); JigsawStudioVariantCatalog.empty());
assertFalse(JigsawStudioService.layoutGeometryChanged(original, original)); assertFalse(JigsawStudioGraphMutations.layoutGeometryChanged(original, original));
assertTrue(JigsawStudioService.layoutGeometryChanged(original, expanded)); assertTrue(JigsawStudioGraphMutations.layoutGeometryChanged(original, expanded));
Set<Long> chunks = JigsawStudioService.relayoutChunks(original, expanded); Set<Long> chunks = JigsawStudioGraphMutations.relayoutChunks(original, expanded);
assertTrue(chunks.contains(0L)); assertTrue(chunks.contains(0L));
assertTrue(chunks.contains(((long) 4 << 32))); assertTrue(chunks.contains(((long) 4 << 32)));
} }
@@ -248,8 +248,8 @@ public class JigsawStudioServiceCaptureTest {
dimensions, dimensions,
new JigsawStudioVariantCatalog(List.of(), false)); new JigsawStudioVariantCatalog(List.of(), false));
assertTrue(JigsawStudioService.canCreateVariants(editable)); assertTrue(JigsawStudioProtection.canCreateVariants(editable));
assertFalse(JigsawStudioService.canCreateVariants(managed)); assertFalse(JigsawStudioProtection.canCreateVariants(managed));
} }
@Test @Test
@@ -269,27 +269,27 @@ public class JigsawStudioServiceCaptureTest {
new JigsawStudioPoolMembership("test/pieces", 0, 4, 0.35D), new JigsawStudioPoolMembership("test/pieces", 0, 4, 0.35D),
new JigsawStudioPoolMembership("test/caps", 0, 7, 0.8D))); new JigsawStudioPoolMembership("test/caps", 0, 7, 0.8D)));
assertEquals("", JigsawStudioService.variantCreationSourceFailure(assigned, false)); assertEquals("", JigsawStudioProtection.variantCreationSourceFailure(assigned, false));
assertEquals("", JigsawStudioService.variantCreationSourceFailure(assigned, true)); assertEquals("", JigsawStudioProtection.variantCreationSourceFailure(assigned, true));
assertTrue(JigsawStudioService.variantCreationSourceFailure(null, false) assertTrue(JigsawStudioProtection.variantCreationSourceFailure(null, false)
.contains("piece create <poolKey> <pieceKey>")); .contains("piece create <poolKey> <pieceKey>"));
assertTrue(JigsawStudioService.variantCreationSourceFailure(planarVariant(true, true), false) assertTrue(JigsawStudioProtection.variantCreationSourceFailure(planarVariant(true, true), false)
.contains("no owned pool membership")); .contains("no owned pool membership"));
assertTrue(JigsawStudioService.variantCreationSourceFailure(planarVariant(true, false), true) assertTrue(JigsawStudioProtection.variantCreationSourceFailure(planarVariant(true, false), true)
.contains("read-only variant")); .contains("read-only variant"));
} }
@Test @Test
public void recognizesVanillaNamespacedAndWorldEditMutationCommands() { public void recognizesVanillaNamespacedAndWorldEditMutationCommands() {
assertTrue(JigsawStudioService.isMutatingCommand("/fill 0 0 0 1 1 1 stone")); assertTrue(JigsawStudioProtection.isMutatingCommand("/fill 0 0 0 1 1 1 stone"));
assertTrue(JigsawStudioService.isMutatingCommand("minecraft:setblock 0 0 0 air")); assertTrue(JigsawStudioProtection.isMutatingCommand("minecraft:setblock 0 0 0 air"));
assertTrue(JigsawStudioService.isMutatingCommand("//paste -a")); assertTrue(JigsawStudioProtection.isMutatingCommand("//paste -a"));
assertTrue(JigsawStudioService.isMutatingCommand("/execute as @s run setblock 0 0 0 stone")); assertTrue(JigsawStudioProtection.isMutatingCommand("/execute as @s run setblock 0 0 0 stone"));
assertTrue(JigsawStudioService.isMutatingCommand("/function test:build")); assertTrue(JigsawStudioProtection.isMutatingCommand("/function test:build"));
assertTrue(JigsawStudioService.isMutatingCommand("/data merge block 0 0 0 {}")); assertTrue(JigsawStudioProtection.isMutatingCommand("/data merge block 0 0 0 {}"));
assertTrue(JigsawStudioService.isMutatingCommand("/item replace block 0 0 0 container.0 with stone")); assertTrue(JigsawStudioProtection.isMutatingCommand("/item replace block 0 0 0 container.0 with stone"));
assertFalse(JigsawStudioService.isMutatingCommand("/iris jigsaw status")); assertFalse(JigsawStudioProtection.isMutatingCommand("/iris jigsaw status"));
assertFalse(JigsawStudioService.isMutatingCommand("/tp 0 80 0")); assertFalse(JigsawStudioProtection.isMutatingCommand("/tp 0 80 0"));
} }
@Test @Test
@@ -297,28 +297,28 @@ public class JigsawStudioServiceCaptureTest {
UUID ownerId = UUID.fromString("11111111-1111-1111-1111-111111111111"); UUID ownerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID otherId = UUID.fromString("22222222-2222-2222-2222-222222222222"); UUID otherId = UUID.fromString("22222222-2222-2222-2222-222222222222");
assertTrue(JigsawStudioService.ownerMatches(ownerId, ownerId)); assertTrue(JigsawStudioProtection.ownerMatches(ownerId, ownerId));
assertTrue(JigsawStudioService.ownerMatches(null, otherId)); assertTrue(JigsawStudioProtection.ownerMatches(null, otherId));
assertFalse(JigsawStudioService.ownerMatches(ownerId, otherId)); assertFalse(JigsawStudioProtection.ownerMatches(ownerId, otherId));
assertTrue(JigsawStudioService.blocksStudioEdit(ownerId, otherId)); assertTrue(JigsawStudioProtection.blocksStudioEdit(ownerId, otherId));
assertFalse(JigsawStudioService.blocksStudioEdit(ownerId, ownerId)); assertFalse(JigsawStudioProtection.blocksStudioEdit(ownerId, ownerId));
assertFalse(JigsawStudioService.blocksStudioEdit(null, otherId)); assertFalse(JigsawStudioProtection.blocksStudioEdit(null, otherId));
assertTrue(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "//paste -a")); assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "//paste -a"));
assertFalse(JigsawStudioService.blocksMutatingCommand(ownerId, ownerId, "//paste -a")); assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, ownerId, "//paste -a"));
assertFalse(JigsawStudioService.blocksMutatingCommand(null, otherId, "//paste -a")); assertFalse(JigsawStudioProtection.blocksMutatingCommand(null, otherId, "//paste -a"));
assertFalse(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "/iris jigsaw status")); assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/iris jigsaw status"));
assertFalse(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "/msg owner hello")); assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/msg owner hello"));
assertTrue(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "/execute run say bypass")); assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/execute run say bypass"));
assertTrue(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "/unknownplugin mutate")); assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/unknownplugin mutate"));
assertTrue(JigsawStudioService.blocksNonEditableWorkcellMutation( assertTrue(JigsawStudioProtection.blocksNonEditableWorkcellMutation(
true, "/fill 0 0 0 1 1 1 stone")); true, "/fill 0 0 0 1 1 1 stone"));
assertTrue(JigsawStudioService.blocksNonEditableWorkcellMutation(true, "//paste -a")); assertTrue(JigsawStudioProtection.blocksNonEditableWorkcellMutation(true, "//paste -a"));
assertFalse(JigsawStudioService.blocksNonEditableWorkcellMutation( assertFalse(JigsawStudioProtection.blocksNonEditableWorkcellMutation(
true, "/iris jigsaw variant load test/end")); true, "/iris jigsaw variant load test/end"));
assertFalse(JigsawStudioService.blocksNonEditableWorkcellMutation( assertFalse(JigsawStudioProtection.blocksNonEditableWorkcellMutation(
false, "/fill 0 0 0 1 1 1 stone")); false, "/fill 0 0 0 1 1 1 stone"));
Method method = JigsawStudioService.class.getMethod( Method method = JigsawStudioProtectionListener.class.getMethod(
"onUnauthorizedPlayerCommand", "onUnauthorizedPlayerCommand",
PlayerCommandPreprocessEvent.class); PlayerCommandPreprocessEvent.class);
EventHandler handler = method.getAnnotation(EventHandler.class); EventHandler handler = method.getAnnotation(EventHandler.class);
@@ -340,21 +340,21 @@ public class JigsawStudioServiceCaptureTest {
UUID ownerId = UUID.fromString("11111111-1111-1111-1111-111111111111"); UUID ownerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID otherId = UUID.fromString("22222222-2222-2222-2222-222222222222"); UUID otherId = UUID.fromString("22222222-2222-2222-2222-222222222222");
assertFalse(JigsawStudioService.blocksStudioInventoryMutation( assertFalse(JigsawStudioProtection.blocksStudioInventoryMutation(
false, false, true, true, ownerId, otherId)); false, false, true, true, ownerId, otherId));
assertFalse(JigsawStudioService.blocksStudioInventoryMutation( assertFalse(JigsawStudioProtection.blocksStudioInventoryMutation(
true, false, false, false, ownerId, ownerId)); true, false, false, false, ownerId, ownerId));
assertTrue(JigsawStudioService.blocksStudioInventoryMutation( assertTrue(JigsawStudioProtection.blocksStudioInventoryMutation(
true, false, true, false, ownerId, ownerId)); true, false, true, false, ownerId, ownerId));
assertTrue(JigsawStudioService.blocksStudioInventoryMutation( assertTrue(JigsawStudioProtection.blocksStudioInventoryMutation(
true, false, false, true, ownerId, ownerId)); true, false, false, true, ownerId, ownerId));
assertTrue(JigsawStudioService.blocksStudioInventoryMutation( assertTrue(JigsawStudioProtection.blocksStudioInventoryMutation(
true, false, false, false, ownerId, otherId)); true, false, false, false, ownerId, otherId));
assertFalse(JigsawStudioService.blocksStudioInventoryMutation( assertFalse(JigsawStudioProtection.blocksStudioInventoryMutation(
true, false, false, false, ownerId, null)); true, false, false, false, ownerId, null));
assertTrue(JigsawStudioService.blocksStudioInventoryMutation( assertTrue(JigsawStudioProtection.blocksStudioInventoryMutation(
true, true, false, false, ownerId, null)); true, true, false, false, ownerId, null));
assertTrue(JigsawStudioService.blocksStudioInventoryMutation( assertTrue(JigsawStudioProtection.blocksStudioInventoryMutation(
true, true, false, false, ownerId, ownerId)); true, true, false, false, ownerId, ownerId));
} }
@@ -394,17 +394,17 @@ public class JigsawStudioServiceCaptureTest {
KMap<String, Object> changedAgain = new KMap<>(); KMap<String, Object> changedAgain = new KMap<>();
changedAgain.put("name", "iris:cap"); changedAgain.put("name", "iris:cap");
assertFalse(JigsawStudioService.tileSnapshotChanged(baseline, unchanged)); assertFalse(JigsawStudioTileWatcher.tileSnapshotChanged(baseline, unchanged));
assertTrue(JigsawStudioService.tileSnapshotChanged(baseline, changed)); assertTrue(JigsawStudioTileWatcher.tileSnapshotChanged(baseline, changed));
assertEquals( assertEquals(
new JigsawStudioService.JigsawTilePollDecision(true, true), new JigsawStudioTileWatcher.JigsawTilePollDecision(true, true),
JigsawStudioService.jigsawTilePollDecision(baseline, changed, false)); JigsawStudioTileWatcher.jigsawTilePollDecision(baseline, changed, false));
assertEquals( assertEquals(
new JigsawStudioService.JigsawTilePollDecision(true, true), new JigsawStudioTileWatcher.JigsawTilePollDecision(true, true),
JigsawStudioService.jigsawTilePollDecision(changed, changedAgain, false)); JigsawStudioTileWatcher.jigsawTilePollDecision(changed, changedAgain, false));
assertEquals( assertEquals(
new JigsawStudioService.JigsawTilePollDecision(false, false), new JigsawStudioTileWatcher.JigsawTilePollDecision(false, false),
JigsawStudioService.jigsawTilePollDecision(changedAgain, changedAgain, true)); JigsawStudioTileWatcher.jigsawTilePollDecision(changedAgain, changedAgain, true));
} }
@Test @Test
@@ -412,14 +412,14 @@ public class JigsawStudioServiceCaptureTest {
UUID requestId = UUID.fromString("33333333-3333-3333-3333-333333333333"); UUID requestId = UUID.fromString("33333333-3333-3333-3333-333333333333");
IOException validationFailure = new IOException("closure rejected an empty piece entry"); IOException validationFailure = new IOException("closure rejected an empty piece entry");
String validationDetail = "Jigsaw Studio save failed: closure rejected an empty piece entry"; String validationDetail = "Jigsaw Studio save failed: closure rejected an empty piece entry";
String validationContext = JigsawStudioService.autosaveFailureContext( String validationContext = JigsawStudioAutosaveScheduler.autosaveFailureContext(
requestId, requestId,
"qa/profile_final", "qa/profile_final",
"workcell/end", "workcell/end",
"qa/profile_final/end", "qa/profile_final/end",
validationDetail); validationDetail);
JigsawStudioService.AutosaveFailureState validationState = JigsawStudioAutosaveScheduler.AutosaveFailureState validationState =
new JigsawStudioService.AutosaveFailureState(); new JigsawStudioAutosaveScheduler.AutosaveFailureState();
StructureWriteResult writerResult = new StructureWriteResult( StructureWriteResult writerResult = new StructureWriteResult(
StructureWriteResult.Status.OWNERSHIP_CONFLICT, StructureWriteResult.Status.OWNERSHIP_CONFLICT,
StructureWriteResult.Action.NONE, StructureWriteResult.Action.NONE,
@@ -430,18 +430,18 @@ public class JigsawStudioServiceCaptureTest {
"", "",
Optional.empty()); Optional.empty());
String writerDetail = JigsawStudioService.writeFailure(writerResult); String writerDetail = JigsawStudioService.writeFailure(writerResult);
String writerContext = JigsawStudioService.autosaveFailureContext( String writerContext = JigsawStudioAutosaveScheduler.autosaveFailureContext(
requestId, requestId,
"qa/profile_final", "qa/profile_final",
"workcell/end", "workcell/end",
"qa/profile_final/end", "qa/profile_final/end",
writerDetail); writerDetail);
JigsawStudioService.AutosaveFailureState writerState = JigsawStudioAutosaveScheduler.AutosaveFailureState writerState =
new JigsawStudioService.AutosaveFailureState(); new JigsawStudioAutosaveScheduler.AutosaveFailureState();
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) { try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
JigsawStudioService.AutosaveFailureDecision firstValidation = JigsawStudioAutosaveScheduler.AutosaveFailureDecision firstValidation =
JigsawStudioService.recordPersistentAutosaveFailure( JigsawStudioAutosaveScheduler.recordPersistentAutosaveFailure(
validationState, validationState,
requestId, requestId,
"qa/profile_final", "qa/profile_final",
@@ -449,8 +449,8 @@ public class JigsawStudioServiceCaptureTest {
"qa/profile_final/end", "qa/profile_final/end",
validationDetail, validationDetail,
validationFailure); validationFailure);
JigsawStudioService.AutosaveFailureDecision secondValidation = JigsawStudioAutosaveScheduler.AutosaveFailureDecision secondValidation =
JigsawStudioService.recordPersistentAutosaveFailure( JigsawStudioAutosaveScheduler.recordPersistentAutosaveFailure(
validationState, validationState,
requestId, requestId,
"qa/profile_final", "qa/profile_final",
@@ -460,7 +460,7 @@ public class JigsawStudioServiceCaptureTest {
validationFailure); validationFailure);
List<Integer> writerDelays = new ArrayList<>(); List<Integer> writerDelays = new ArrayList<>();
for (int attempt = 0; attempt < 7; attempt++) { for (int attempt = 0; attempt < 7; attempt++) {
writerDelays.add(JigsawStudioService.recordPersistentAutosaveFailure( writerDelays.add(JigsawStudioAutosaveScheduler.recordPersistentAutosaveFailure(
writerState, writerState,
requestId, requestId,
"qa/profile_final", "qa/profile_final",
@@ -481,8 +481,8 @@ public class JigsawStudioServiceCaptureTest {
logging.verify(() -> IrisLogging.warn("%s", writerContext), times(1)); logging.verify(() -> IrisLogging.warn("%s", writerContext), times(1));
} }
JigsawStudioService.AutosaveFailureDecision newIdentity = JigsawStudioAutosaveScheduler.AutosaveFailureDecision newIdentity =
new JigsawStudioService.AutosaveFailureState().recordPersistentFailure(); new JigsawStudioAutosaveScheduler.AutosaveFailureState().recordPersistentFailure();
assertEquals(40, newIdentity.retryTicks()); assertEquals(40, newIdentity.retryTicks());
assertTrue(newIdentity.logFailure()); assertTrue(newIdentity.logFailure());
} }
@@ -545,11 +545,11 @@ public class JigsawStudioServiceCaptureTest {
when(generator.getLayout()).thenAnswer(invocation -> session.layout()); when(generator.getLayout()).thenAnswer(invocation -> session.layout());
World world = mock(World.class); World world = mock(World.class);
when(world.getUID()).thenReturn(worldId); when(world.getUID()).thenReturn(worldId);
ConcurrentHashMap<String, JigsawStudioService.BayPopulation> populations = new ConcurrentHashMap<>(); ConcurrentHashMap<String, JigsawStudioChunkWriter.BayPopulation> populations = new ConcurrentHashMap<>();
JigsawStudioService.BayPopulation endPopulation = JigsawStudioChunkWriter.BayPopulation endPopulation =
new JigsawStudioService.BayPopulation(Set.of(0L), ""); new JigsawStudioChunkWriter.BayPopulation(Set.of(0L), "");
JigsawStudioService.BayPopulation straightPopulation = JigsawStudioChunkWriter.BayPopulation straightPopulation =
new JigsawStudioService.BayPopulation(Set.of(0L), ""); new JigsawStudioChunkWriter.BayPopulation(Set.of(0L), "");
endPopulation.markFullyReady(); endPopulation.markFullyReady();
straightPopulation.markFullyReady(); straightPopulation.markFullyReady();
populations.put(initialEnd.stableId(), endPopulation); populations.put(initialEnd.stableId(), endPopulation);
@@ -572,7 +572,7 @@ public class JigsawStudioServiceCaptureTest {
populations, populations,
ConcurrentHashMap.newKeySet(), ConcurrentHashMap.newKeySet(),
new AtomicLong()); new AtomicLong());
Method scheduleAutosave = JigsawStudioService.class.getDeclaredMethod( Method scheduleAutosave = JigsawStudioAutosaveScheduler.class.getDeclaredMethod(
"scheduleAutosave", "scheduleAutosave",
studioType, studioType,
JigsawStudioBay.class, JigsawStudioBay.class,
@@ -606,8 +606,8 @@ public class JigsawStudioServiceCaptureTest {
anyInt(), anyInt(),
any(Runnable.class))) any(Runnable.class)))
.thenReturn(true); .thenReturn(true);
scheduleAutosave.invoke(service, studio, initialEnd, endDirty, 40); scheduleAutosave.invoke(service.autosaveScheduler, studio, initialEnd, endDirty, 40);
scheduleAutosave.invoke(service, studio, initialStraight, straightDirty, 40); scheduleAutosave.invoke(service.autosaveScheduler, studio, initialStraight, straightDirty, 40);
assertEquals(2, delayedAutosaves.size()); assertEquals(2, delayedAutosaves.size());
JigsawStudioSession.SaveIdentity committedEnd = session.beginSave(initialEnd.stableId()) JigsawStudioSession.SaveIdentity committedEnd = session.beginSave(initialEnd.stableId())
@@ -619,7 +619,7 @@ public class JigsawStudioServiceCaptureTest {
dimensions, dimensions,
catalog); catalog);
assertTrue(session.replaceLayout(replacementLayout)); assertTrue(session.replaceLayout(replacementLayout));
JigsawStudioBay currentStraight = JigsawStudioService.resolveCurrentAutosaveBay( JigsawStudioBay currentStraight = JigsawStudioAutosaveScheduler.resolveCurrentAutosaveBay(
session, session,
initialStraight.stableId()); initialStraight.stableId());
assertNotSame(initialStraight, currentStraight); assertNotSame(initialStraight, currentStraight);
@@ -676,10 +676,10 @@ public class JigsawStudioServiceCaptureTest {
when(player.getWorld()).thenReturn(world); when(player.getWorld()).thenReturn(world);
JigsawStudioService service = new JigsawStudioService(); JigsawStudioService service = new JigsawStudioService();
ConcurrentHashMap<String, JigsawStudioService.BayPopulation> populations = new ConcurrentHashMap<>(); ConcurrentHashMap<String, JigsawStudioChunkWriter.BayPopulation> populations = new ConcurrentHashMap<>();
populations.put( populations.put(
bay.stableId(), bay.stableId(),
new JigsawStudioService.BayPopulation(Set.of(0L), "workcell hydration failed")); new JigsawStudioChunkWriter.BayPopulation(Set.of(0L), "workcell hydration failed"));
Class<?> studioType = Class.forName(JigsawStudioService.class.getName() + "$ActiveStudio"); Class<?> studioType = Class.forName(JigsawStudioService.class.getName() + "$ActiveStudio");
Constructor<?> studioConstructor = studioType.getDeclaredConstructor( Constructor<?> studioConstructor = studioType.getDeclaredConstructor(
UUID.class, UUID.class,
@@ -704,11 +704,11 @@ public class JigsawStudioServiceCaptureTest {
Map<UUID, Object> studios = (Map<UUID, Object>) studiosField.get(service); Map<UUID, Object> studios = (Map<UUID, Object>) studiosField.get(service);
studios.put(worldId, studio); studios.put(worldId, studio);
Class<?> keyType = Class.forName(JigsawStudioService.class.getName() + "$AutosaveKey"); Class<?> keyType = Class.forName(JigsawStudioAutosaveScheduler.class.getName() + "$AutosaveKey");
Constructor<?> keyConstructor = keyType.getDeclaredConstructor(UUID.class, String.class); Constructor<?> keyConstructor = keyType.getDeclaredConstructor(UUID.class, String.class);
keyConstructor.setAccessible(true); keyConstructor.setAccessible(true);
Object key = keyConstructor.newInstance(requestId, bay.stableId()); Object key = keyConstructor.newInstance(requestId, bay.stableId());
Class<?> ticketType = Class.forName(JigsawStudioService.class.getName() + "$AutosaveTicket"); Class<?> ticketType = Class.forName(JigsawStudioAutosaveScheduler.class.getName() + "$AutosaveTicket");
Constructor<?> ticketConstructor = ticketType.getDeclaredConstructor( Constructor<?> ticketConstructor = ticketType.getDeclaredConstructor(
keyType, keyType,
studioType, studioType,
@@ -722,15 +722,15 @@ public class JigsawStudioServiceCaptureTest {
identity, identity,
new AtomicBoolean(false), new AtomicBoolean(false),
new AtomicBoolean(false)); new AtomicBoolean(false));
Field autosavesField = JigsawStudioService.class.getDeclaredField("autosaves"); Field autosavesField = JigsawStudioAutosaveScheduler.class.getDeclaredField("autosaves");
autosavesField.setAccessible(true); autosavesField.setAccessible(true);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<Object, Object> autosaves = (Map<Object, Object>) autosavesField.get(service); Map<Object, Object> autosaves = (Map<Object, Object>) autosavesField.get(service.autosaveScheduler);
autosaves.put(key, ticket); autosaves.put(key, ticket);
Method failureStateAccessor = ticketType.getDeclaredMethod("failureState"); Method failureStateAccessor = ticketType.getDeclaredMethod("failureState");
failureStateAccessor.setAccessible(true); failureStateAccessor.setAccessible(true);
JigsawStudioService.AutosaveFailureState failureState = JigsawStudioAutosaveScheduler.AutosaveFailureState failureState =
(JigsawStudioService.AutosaveFailureState) failureStateAccessor.invoke(ticket); (JigsawStudioAutosaveScheduler.AutosaveFailureState) failureStateAccessor.invoke(ticket);
assertEquals(40, failureState.recordPersistentFailure().retryTicks()); assertEquals(40, failureState.recordPersistentFailure().retryTicks());
try (MockedStatic<J> scheduling = mockStatic(J.class)) { try (MockedStatic<J> scheduling = mockStatic(J.class)) {
@@ -745,13 +745,13 @@ public class JigsawStudioServiceCaptureTest {
populations.put( populations.put(
bay.stableId(), bay.stableId(),
new JigsawStudioService.BayPopulation(Set.of(0L), "")); new JigsawStudioChunkWriter.BayPopulation(Set.of(0L), ""));
assertFalse(service.flushAutosave(player, bay.stableId())); assertFalse(service.flushAutosave(player, bay.stableId()));
assertEquals(1, autosaves.size()); assertEquals(1, autosaves.size());
assertTrue(session.isDirtyCurrent(identity)); assertTrue(session.isDirtyCurrent(identity));
JigsawStudioService.BayPopulation ready = JigsawStudioChunkWriter.BayPopulation ready =
new JigsawStudioService.BayPopulation(Set.of(0L), ""); new JigsawStudioChunkWriter.BayPopulation(Set.of(0L), "");
ready.markFullyReady(); ready.markFullyReady();
populations.put(bay.stableId(), ready); populations.put(bay.stableId(), ready);
JigsawStudioSession.SaveIdentity inProgress = session.beginSave(bay.stableId()) JigsawStudioSession.SaveIdentity inProgress = session.beginSave(bay.stableId())
@@ -773,13 +773,13 @@ public class JigsawStudioServiceCaptureTest {
JigsawStudioVariant fixed = planarVariant(false, true); JigsawStudioVariant fixed = planarVariant(false, true);
JigsawStudioVariant readOnly = planarVariant(true, false); JigsawStudioVariant readOnly = planarVariant(true, false);
assertFalse(JigsawStudioService.canToggleVariantRotation( assertFalse(JigsawStudioProtection.canToggleVariantRotation(
JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, rotatable)); JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, rotatable));
assertTrue(JigsawStudioService.canToggleVariantRotation( assertTrue(JigsawStudioProtection.canToggleVariantRotation(
JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, fixed)); JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, fixed));
assertTrue(JigsawStudioService.canToggleVariantRotation( assertTrue(JigsawStudioProtection.canToggleVariantRotation(
JigsawStudioCompatibilityTarget.IRIS_EXTENDED, rotatable)); JigsawStudioCompatibilityTarget.IRIS_EXTENDED, rotatable));
assertFalse(JigsawStudioService.canToggleVariantRotation( assertFalse(JigsawStudioProtection.canToggleVariantRotation(
JigsawStudioCompatibilityTarget.IRIS_EXTENDED, readOnly)); JigsawStudioCompatibilityTarget.IRIS_EXTENDED, readOnly));
} }
@@ -793,22 +793,22 @@ public class JigsawStudioServiceCaptureTest {
IrisJigsawConnector eastSource = connector().setDirection(IrisDirection.EAST_POSITIVE_X); IrisJigsawConnector eastSource = connector().setDirection(IrisDirection.EAST_POSITIVE_X);
IrisJigsawConnector southDisplayed = connector().setDirection(IrisDirection.SOUTH_POSITIVE_Z); IrisJigsawConnector southDisplayed = connector().setDirection(IrisDirection.SOUTH_POSITIVE_Z);
JigsawStudioService.requireWorkcellTopology( JigsawStudioCapture.requireWorkcellTopology(
layout.get("workcell/end"), layout.get("workcell/end"),
List.of(eastSource), List.of(eastSource),
3); 3);
JigsawStudioService.WorkcellTopologyException wrongDirection = assertThrows( JigsawStudioCapture.WorkcellTopologyException wrongDirection = assertThrows(
JigsawStudioService.WorkcellTopologyException.class, JigsawStudioCapture.WorkcellTopologyException.class,
() -> JigsawStudioService.requireWorkcellTopology( () -> JigsawStudioCapture.requireWorkcellTopology(
layout.get("workcell/end"), layout.get("workcell/end"),
List.of(southDisplayed), List.of(southDisplayed),
0)); 0));
assertTrue(wrongDirection.getMessage().contains("south end (1 horizontal connector)")); assertTrue(wrongDirection.getMessage().contains("south end (1 horizontal connector)"));
assertTrue(wrongDirection.getMessage().contains("Reset Connector Blocks")); assertTrue(wrongDirection.getMessage().contains("Reset Connector Blocks"));
JigsawStudioService.WorkcellTopologyException missingTee = assertThrows( JigsawStudioCapture.WorkcellTopologyException missingTee = assertThrows(
JigsawStudioService.WorkcellTopologyException.class, JigsawStudioCapture.WorkcellTopologyException.class,
() -> JigsawStudioService.requireWorkcellTopology( () -> JigsawStudioCapture.requireWorkcellTopology(
layout.get("workcell/tee"), layout.get("workcell/tee"),
List.of(), List.of(),
0)); 0));
@@ -823,13 +823,13 @@ public class JigsawStudioServiceCaptureTest {
64, 64,
0, 0,
new JigsawStudioCellDimensions(3, 1, 3)); new JigsawStudioCellDimensions(3, 1, 3));
List<JigsawStudioService.ChunkCaptureArea> areas = JigsawStudioService.chunkIntersections(bounds); List<JigsawStudioCapture.ChunkCaptureArea> areas = JigsawStudioCapture.chunkIntersections(bounds);
PlatformBlockState sourceState = BukkitBlockState.of(directionalBlockData(BlockFace.NORTH)); PlatformBlockState sourceState = BukkitBlockState.of(directionalBlockData(BlockFace.NORTH));
for (int quarterTurns = 1; quarterTurns <= 3; quarterTurns++) { for (int quarterTurns = 1; quarterTurns <= 3; quarterTurns++) {
IrisObjectRotation displayRotation = IrisObjectRotation.of(0, -90.0D * quarterTurns, 0); IrisObjectRotation displayRotation = IrisObjectRotation.of(0, -90.0D * quarterTurns, 0);
PlatformBlockState displayedState = displayRotation.rotate(sourceState, 0, 0, 0); PlatformBlockState displayedState = displayRotation.rotate(sourceState, 0, 0, 0);
IrisDirection displayedDirection = displayRotation.rotate(IrisDirection.NORTH_NEGATIVE_Z); IrisDirection displayedDirection = displayRotation.rotate(IrisDirection.NORTH_NEGATIVE_Z);
JigsawStudioService.CapturedConnector connector = new JigsawStudioService.CapturedConnector( JigsawStudioCapture.CapturedConnector connector = new JigsawStudioCapture.CapturedConnector(
1, 1,
0, 0,
1, 1,
@@ -843,14 +843,14 @@ public class JigsawStudioServiceCaptureTest {
displayedState.key(), displayedState.key(),
-3, -3,
7); 7);
JigsawStudioService.ChunkSnapshot snapshot = new JigsawStudioService.ChunkSnapshot( JigsawStudioCapture.ChunkSnapshot snapshot = new JigsawStudioCapture.ChunkSnapshot(
areas.getFirst(), areas.getFirst(),
List.of(), List.of(),
List.of(connector)); List.of(connector));
try (MockedStatic<B> blocks = mockStatic(B.class)) { try (MockedStatic<B> blocks = mockStatic(B.class)) {
blocks.when(() -> B.getStateOrNull( blocks.when(() -> B.getStateOrNull(
displayedState.key(), false)).thenReturn(displayedState); displayedState.key(), false)).thenReturn(displayedState);
JigsawStudioService.Capture capture = JigsawStudioService.aggregateSnapshots( JigsawStudioCapture.Capture capture = JigsawStudioCapture.aggregateSnapshots(
bounds, bounds,
areas, areas,
List.of(snapshot), List.of(snapshot),
@@ -888,12 +888,12 @@ public class JigsawStudioServiceCaptureTest {
when(block.getBlockData()).thenReturn(chestData); when(block.getBlockData()).thenReturn(chestData);
World world = mock(World.class); World world = mock(World.class);
when(world.getBlockAt(0, 64, 0)).thenReturn(block); when(world.getBlockAt(0, 64, 0)).thenReturn(block);
JigsawStudioService.ChunkCaptureArea area = JigsawStudioService.chunkIntersections(bounds).getFirst(); JigsawStudioCapture.ChunkCaptureArea area = JigsawStudioCapture.chunkIntersections(bounds).getFirst();
JigsawStudioService.ChunkSnapshot snapshot; JigsawStudioCapture.ChunkSnapshot snapshot;
try (MockedStatic<TileData> tiles = mockStatic(TileData.class)) { try (MockedStatic<TileData> tiles = mockStatic(TileData.class)) {
tiles.when(() -> TileData.getTileState(block, false)).thenReturn(tileData); tiles.when(() -> TileData.getTileState(block, false)).thenReturn(tileData);
snapshot = JigsawStudioService.captureChunkIntersection( snapshot = JigsawStudioCapture.captureChunkIntersection(
world, world,
bounds, bounds,
piece, piece,
@@ -902,7 +902,7 @@ public class JigsawStudioServiceCaptureTest {
0, 0,
false); false);
} }
JigsawStudioService.Capture capture = JigsawStudioService.aggregateSnapshots( JigsawStudioCapture.Capture capture = JigsawStudioCapture.aggregateSnapshots(
bounds, bounds,
List.of(area), List.of(area),
List.of(snapshot)); List.of(snapshot));
@@ -945,7 +945,7 @@ public class JigsawStudioServiceCaptureTest {
IrisJigsawPiece source = pieceWithPlanarConnectors(dimensions, authoredOrders.get(index)); IrisJigsawPiece source = pieceWithPlanarConnectors(dimensions, authoredOrders.get(index));
List<IrisJigsawConnector> captured = planarConnectors(dimensions, captureOrders.get(index)); List<IrisJigsawConnector> captured = planarConnectors(dimensions, captureOrders.get(index));
List<IrisJigsawConnector> ordered = JigsawStudioService.preserveCapturedConnectorOrder( List<IrisJigsawConnector> ordered = JigsawStudioCapture.preserveCapturedConnectorOrder(
source, source,
captured); captured);
@@ -974,7 +974,7 @@ public class JigsawStudioServiceCaptureTest {
dimensions, dimensions,
IrisDirection.EAST_POSITIVE_X); IrisDirection.EAST_POSITIVE_X);
List<IrisJigsawConnector> ordered = JigsawStudioService.preserveCapturedConnectorOrder( List<IrisJigsawConnector> ordered = JigsawStudioCapture.preserveCapturedConnectorOrder(
source, source,
List.of(capturedEast, capturedNorth)); List.of(capturedEast, capturedNorth));
@@ -998,7 +998,7 @@ public class JigsawStudioServiceCaptureTest {
dimensions, dimensions,
IrisDirection.NORTH_NEGATIVE_Z); IrisDirection.NORTH_NEGATIVE_Z);
List<IrisJigsawConnector> ordered = JigsawStudioService.preserveCapturedConnectorOrder( List<IrisJigsawConnector> ordered = JigsawStudioCapture.preserveCapturedConnectorOrder(
source, source,
List.of(capturedWest, capturedNorth)); List.of(capturedWest, capturedNorth));
@@ -1020,7 +1020,7 @@ public class JigsawStudioServiceCaptureTest {
IrisJigsawConnector highZ = connectorAt(2, 3, 10); IrisJigsawConnector highZ = connectorAt(2, 3, 10);
IrisJigsawConnector lowZ = connectorAt(2, 3, 4); IrisJigsawConnector lowZ = connectorAt(2, 3, 4);
List<IrisJigsawConnector> ordered = JigsawStudioService.preserveCapturedConnectorOrder( List<IrisJigsawConnector> ordered = JigsawStudioCapture.preserveCapturedConnectorOrder(
source, source,
List.of(highY, capturedNorth, highZ, lowZ)); List.of(highY, capturedNorth, highZ, lowZ));
@@ -1040,10 +1040,10 @@ public class JigsawStudioServiceCaptureTest {
duplicateSource.getConnectors().add(second); duplicateSource.getConnectors().add(second);
IrisJigsawPiece emptySource = new IrisJigsawPiece().setConnectors(new KList<>()); IrisJigsawPiece emptySource = new IrisJigsawPiece().setConnectors(new KList<>());
assertThrows(IOException.class, () -> JigsawStudioService.preserveCapturedConnectorOrder( assertThrows(IOException.class, () -> JigsawStudioCapture.preserveCapturedConnectorOrder(
duplicateSource, duplicateSource,
List.of(first))); List.of(first)));
assertThrows(IOException.class, () -> JigsawStudioService.preserveCapturedConnectorOrder( assertThrows(IOException.class, () -> JigsawStudioCapture.preserveCapturedConnectorOrder(
emptySource, emptySource,
List.of(first, second))); List.of(first, second)));
} }
@@ -1055,7 +1055,7 @@ public class JigsawStudioServiceCaptureTest {
64, 64,
0, 0,
new JigsawStudioCellDimensions(5, 1, 3)); new JigsawStudioCellDimensions(5, 1, 3));
List<JigsawStudioService.ChunkCaptureArea> areas = JigsawStudioService.chunkIntersections(bounds); List<JigsawStudioCapture.ChunkCaptureArea> areas = JigsawStudioCapture.chunkIntersections(bounds);
IrisJigsawConnector sourceFirst = connectorAt(0, 0, 1) IrisJigsawConnector sourceFirst = connectorAt(0, 0, 1)
.setDirection(IrisDirection.NORTH_NEGATIVE_Z); .setDirection(IrisDirection.NORTH_NEGATIVE_Z);
IrisJigsawConnector sourceSecond = connectorAt(2, 0, 2) IrisJigsawConnector sourceSecond = connectorAt(2, 0, 2)
@@ -1069,31 +1069,31 @@ public class JigsawStudioServiceCaptureTest {
for (int quarterTurns = 1; quarterTurns <= 3; quarterTurns++) { for (int quarterTurns = 1; quarterTurns <= 3; quarterTurns++) {
IrisObjectRotation displayRotation = IrisObjectRotation.of(0, -90.0D * quarterTurns, 0); IrisObjectRotation displayRotation = IrisObjectRotation.of(0, -90.0D * quarterTurns, 0);
PlatformBlockState displayedState = displayRotation.rotate(sourceState, 0, 0, 0); PlatformBlockState displayedState = displayRotation.rotate(sourceState, 0, 0, 0);
JigsawStudioService.CapturedConnector displayedFirst = displayedConnector( JigsawStudioCapture.CapturedConnector displayedFirst = displayedConnector(
sourceFirst, sourceFirst,
bounds.dimensions(), bounds.dimensions(),
quarterTurns, quarterTurns,
displayRotation, displayRotation,
displayedState.key()); displayedState.key());
JigsawStudioService.CapturedConnector displayedSecond = displayedConnector( JigsawStudioCapture.CapturedConnector displayedSecond = displayedConnector(
sourceSecond, sourceSecond,
bounds.dimensions(), bounds.dimensions(),
quarterTurns, quarterTurns,
displayRotation, displayRotation,
displayedState.key()); displayedState.key());
JigsawStudioService.ChunkSnapshot snapshot = new JigsawStudioService.ChunkSnapshot( JigsawStudioCapture.ChunkSnapshot snapshot = new JigsawStudioCapture.ChunkSnapshot(
areas.getFirst(), areas.getFirst(),
List.of(), List.of(),
List.of(displayedSecond, displayedFirst)); List.of(displayedSecond, displayedFirst));
try (MockedStatic<B> blocks = mockStatic(B.class)) { try (MockedStatic<B> blocks = mockStatic(B.class)) {
blocks.when(() -> B.getStateOrNull(displayedState.key(), false)).thenReturn(displayedState); blocks.when(() -> B.getStateOrNull(displayedState.key(), false)).thenReturn(displayedState);
JigsawStudioService.Capture capture = JigsawStudioService.aggregateSnapshots( JigsawStudioCapture.Capture capture = JigsawStudioCapture.aggregateSnapshots(
bounds, bounds,
areas, areas,
List.of(snapshot), List.of(snapshot),
quarterTurns); quarterTurns);
List<IrisJigsawConnector> ordered = JigsawStudioService.preserveCapturedConnectorOrder( List<IrisJigsawConnector> ordered = JigsawStudioCapture.preserveCapturedConnectorOrder(
source, source,
capture.connectors()); capture.connectors());
@@ -1110,7 +1110,7 @@ public class JigsawStudioServiceCaptureTest {
64, 64,
0, 0,
new JigsawStudioCellDimensions(5, 2, 3)); new JigsawStudioCellDimensions(5, 2, 3));
List<JigsawStudioService.ChunkCaptureArea> areas = JigsawStudioService.chunkIntersections(bounds); List<JigsawStudioCapture.ChunkCaptureArea> areas = JigsawStudioCapture.chunkIntersections(bounds);
KMap<String, Object> properties = new KMap<>(); KMap<String, Object> properties = new KMap<>();
properties.put("CustomName", "QA Chest"); properties.put("CustomName", "QA Chest");
properties.put("Lock", "iris:test"); properties.put("Lock", "iris:test");
@@ -1124,18 +1124,18 @@ public class JigsawStudioServiceCaptureTest {
for (int quarterTurns = 1; quarterTurns <= 3; quarterTurns++) { for (int quarterTurns = 1; quarterTurns <= 3; quarterTurns++) {
BlockData displayedData = directionalBlockData(Material.CHEST, BlockFace.EAST); BlockData displayedData = directionalBlockData(Material.CHEST, BlockFace.EAST);
PlatformBlockState displayedState = BukkitBlockState.of(displayedData); PlatformBlockState displayedState = BukkitBlockState.of(displayedData);
JigsawStudioService.CapturedBlock capturedBlock = new JigsawStudioService.CapturedBlock( JigsawStudioCapture.CapturedBlock capturedBlock = new JigsawStudioCapture.CapturedBlock(
1, 1,
1, 1,
1, 1,
displayedState, displayedState,
tileData); tileData);
JigsawStudioService.ChunkSnapshot snapshot = new JigsawStudioService.ChunkSnapshot( JigsawStudioCapture.ChunkSnapshot snapshot = new JigsawStudioCapture.ChunkSnapshot(
areas.getFirst(), areas.getFirst(),
List.of(capturedBlock), List.of(capturedBlock),
List.of()); List.of());
JigsawStudioService.Capture capture = JigsawStudioService.aggregateSnapshots( JigsawStudioCapture.Capture capture = JigsawStudioCapture.aggregateSnapshots(
bounds, bounds,
areas, areas,
List.of(snapshot), List.of(snapshot),
@@ -1163,7 +1163,7 @@ public class JigsawStudioServiceCaptureTest {
List.of(new JigsawStudioGenerator.RenderedBlock(0, 0, 0, state, tileData)), List.of(new JigsawStudioGenerator.RenderedBlock(0, 0, 0, state, tileData)),
List.of()); List.of());
assertEquals("", JigsawStudioService.validateMaterialization(rendered)); assertEquals("", JigsawStudioChunkWriter.validateMaterialization(rendered));
} }
@Test @Test
@@ -1172,8 +1172,8 @@ public class JigsawStudioServiceCaptureTest {
BlockData air = blockData(Material.AIR, "minecraft:air"); BlockData air = blockData(Material.AIR, "minecraft:air");
BlockData structureVoid = blockData(Material.STRUCTURE_VOID, "minecraft:structure_void"); BlockData structureVoid = blockData(Material.STRUCTURE_VOID, "minecraft:structure_void");
JigsawStudioService.storeConnectorFinalState(object, 0, 0, 0, air); JigsawStudioCapture.storeConnectorFinalState(object, 0, 0, 0, air);
JigsawStudioService.storeConnectorFinalState(object, 1, 0, 0, structureVoid); JigsawStudioCapture.storeConnectorFinalState(object, 1, 0, 0, structureVoid);
IrisBlockVector airPosition = object.getSigned(0, 0, 0); IrisBlockVector airPosition = object.getSigned(0, 0, 0);
IrisBlockVector structureVoidPosition = object.getSigned(1, 0, 0); IrisBlockVector structureVoidPosition = object.getSigned(1, 0, 0);
@@ -1194,10 +1194,10 @@ public class JigsawStudioServiceCaptureTest {
when(sourceAir.key()).thenReturn("minecraft:air"); when(sourceAir.key()).thenReturn("minecraft:air");
source.setUnsigned(0, 0, 0, sourceAir); source.setUnsigned(0, 0, 0, sourceAir);
PlatformBlockState retained = JigsawStudioService.retainedSourceAir(source, 0, 0, 0, air); PlatformBlockState retained = JigsawStudioCapture.retainedSourceAir(source, 0, 0, 0, air);
assertNotNull(retained); assertNotNull(retained);
assertNull(JigsawStudioService.retainedSourceAir(source, 1, 0, 0, air)); assertNull(JigsawStudioCapture.retainedSourceAir(source, 1, 0, 0, air));
assertNull(JigsawStudioService.retainedSourceAir(source, 0, 0, 0, structureVoid)); assertNull(JigsawStudioCapture.retainedSourceAir(source, 0, 0, 0, structureVoid));
IrisObject captured = new IrisObject(2, 2, 2); IrisObject captured = new IrisObject(2, 2, 2);
captured.setUnsigned(0, 0, 0, retained); captured.setUnsigned(0, 0, 0, retained);
@@ -1239,7 +1239,7 @@ public class JigsawStudioServiceCaptureTest {
IrisJigsawPiece piece = new IrisJigsawPiece().setConnectors(new KList<>()); IrisJigsawPiece piece = new IrisJigsawPiece().setConnectors(new KList<>());
piece.getConnectors().add(source); piece.getConnectors().add(source);
KMap<String, Object> nbt = JigsawStudioService.markerNbt(source); KMap<String, Object> nbt = JigsawStudioChunkWriter.markerNbt(source);
assertEquals("iris:fort/start", nbt.get("pool")); assertEquals("iris:fort/start", nbt.get("pool"));
assertEquals("iris:door", nbt.get("name")); assertEquals("iris:door", nbt.get("name"));
assertEquals(-4, nbt.get("selection_priority")); assertEquals(-4, nbt.get("selection_priority"));
@@ -1250,7 +1250,7 @@ public class JigsawStudioServiceCaptureTest {
.setPool("fort/start") .setPool("fort/start")
.setName("iris:door") .setName("iris:door")
.setTargetName("iris:door"); .setTargetName("iris:door");
JigsawStudioService.restoreCapturedMetadata(captured, piece); JigsawStudioChunkWriter.restoreCapturedMetadata(captured, piece);
assertEquals("fort/start", captured.getPool()); assertEquals("fort/start", captured.getPool());
assertEquals("door", captured.getName()); assertEquals("door", captured.getName());
@@ -1260,7 +1260,7 @@ public class JigsawStudioServiceCaptureTest {
@Test @Test
public void authoredBayReadinessRequiresEveryChunkToPopulateAndHydrate() { public void authoredBayReadinessRequiresEveryChunkToPopulateAndHydrate() {
JigsawStudioService.BayPopulation population = new JigsawStudioService.BayPopulation( JigsawStudioChunkWriter.BayPopulation population = new JigsawStudioChunkWriter.BayPopulation(
Set.of(11L, 12L), ""); Set.of(11L, 12L), "");
assertFalse(population.readiness().ready()); assertFalse(population.readiness().ready());
@@ -1275,7 +1275,7 @@ public class JigsawStudioServiceCaptureTest {
population.markApplied(12L); population.markApplied(12L);
population.markHydrated(12L); population.markHydrated(12L);
JigsawStudioService.BayReadiness readiness = population.readiness(); JigsawStudioSaveLifecycle.BayReadiness readiness = population.readiness();
assertTrue(readiness.ready()); assertTrue(readiness.ready());
assertEquals(2, readiness.requiredChunks()); assertEquals(2, readiness.requiredChunks());
assertEquals(2, readiness.generatedChunks()); assertEquals(2, readiness.generatedChunks());
@@ -1293,9 +1293,9 @@ public class JigsawStudioServiceCaptureTest {
new JigsawStudioVariantCatalog(List.of(planarVariant(true, true)))); new JigsawStudioVariantCatalog(List.of(planarVariant(true, true))));
JigsawStudioBay blank = layout.get("workcell/blank"); JigsawStudioBay blank = layout.get("workcell/blank");
long chunkKey = ((long) 1 << 32) ^ 1L; long chunkKey = ((long) 1 << 32) ^ 1L;
JigsawStudioService.BayPopulation population = JigsawStudioChunkWriter.BayPopulation population =
new JigsawStudioService.BayPopulation(Set.of(chunkKey), ""); new JigsawStudioChunkWriter.BayPopulation(Set.of(chunkKey), "");
ConcurrentHashMap<String, JigsawStudioService.BayPopulation> populations = new ConcurrentHashMap<>(); ConcurrentHashMap<String, JigsawStudioChunkWriter.BayPopulation> populations = new ConcurrentHashMap<>();
populations.put(blank.stableId(), population); populations.put(blank.stableId(), population);
World world = mock(World.class); World world = mock(World.class);
@@ -1339,7 +1339,7 @@ public class JigsawStudioServiceCaptureTest {
when(event.getWorld()).thenReturn(world); when(event.getWorld()).thenReturn(world);
when(event.getChunk()).thenReturn(chunk); when(event.getChunk()).thenReturn(chunk);
service.onChunkLoad(event); service.protectionListener.onChunkLoad(event);
assertTrue(population.needsApplication(chunkKey)); assertTrue(population.needsApplication(chunkKey));
assertEquals(1, population.readiness().generatedChunks()); assertEquals(1, population.readiness().generatedChunks());
@@ -1348,7 +1348,7 @@ public class JigsawStudioServiceCaptureTest {
@Test @Test
public void hydrationFailurePermanentlyBlocksSaveReadiness() { public void hydrationFailurePermanentlyBlocksSaveReadiness() {
JigsawStudioService.BayPopulation population = new JigsawStudioService.BayPopulation( JigsawStudioChunkWriter.BayPopulation population = new JigsawStudioChunkWriter.BayPopulation(
Set.of(27L), ""); Set.of(27L), "");
population.markGenerated(27L); population.markGenerated(27L);
population.markApplied(27L); population.markApplied(27L);
@@ -1369,20 +1369,20 @@ public class JigsawStudioServiceCaptureTest {
9, 9,
new JigsawStudioCellDimensions(25, 3, 20)); new JigsawStudioCellDimensions(25, 3, 20));
List<JigsawStudioService.ChunkCaptureArea> areas = JigsawStudioService.chunkIntersections(bounds); List<JigsawStudioCapture.ChunkCaptureArea> areas = JigsawStudioCapture.chunkIntersections(bounds);
assertEquals(List.of( assertEquals(List.of(
new JigsawStudioService.ChunkCaptureArea(-1, 0, 0, 5, 0, 7), new JigsawStudioCapture.ChunkCaptureArea(-1, 0, 0, 5, 0, 7),
new JigsawStudioService.ChunkCaptureArea(-1, 1, 0, 5, 7, 20), new JigsawStudioCapture.ChunkCaptureArea(-1, 1, 0, 5, 7, 20),
new JigsawStudioService.ChunkCaptureArea(0, 0, 5, 21, 0, 7), new JigsawStudioCapture.ChunkCaptureArea(0, 0, 5, 21, 0, 7),
new JigsawStudioService.ChunkCaptureArea(0, 1, 5, 21, 7, 20), new JigsawStudioCapture.ChunkCaptureArea(0, 1, 5, 21, 7, 20),
new JigsawStudioService.ChunkCaptureArea(1, 0, 21, 25, 0, 7), new JigsawStudioCapture.ChunkCaptureArea(1, 0, 21, 25, 0, 7),
new JigsawStudioService.ChunkCaptureArea(1, 1, 21, 25, 7, 20) new JigsawStudioCapture.ChunkCaptureArea(1, 1, 21, 25, 7, 20)
), areas); ), areas);
for (int x = 0; x < bounds.dimensions().width(); x++) { for (int x = 0; x < bounds.dimensions().width(); x++) {
for (int z = 0; z < bounds.dimensions().depth(); z++) { for (int z = 0; z < bounds.dimensions().depth(); z++) {
int matches = 0; int matches = 0;
for (JigsawStudioService.ChunkCaptureArea area : areas) { for (JigsawStudioCapture.ChunkCaptureArea area : areas) {
if (area.contains(x, z)) { if (area.contains(x, z)) {
matches++; matches++;
} }
@@ -1399,28 +1399,28 @@ public class JigsawStudioServiceCaptureTest {
64, 64,
0, 0,
new JigsawStudioCellDimensions(20, 2, 1)); new JigsawStudioCellDimensions(20, 2, 1));
List<JigsawStudioService.ChunkCaptureArea> areas = JigsawStudioService.chunkIntersections(bounds); List<JigsawStudioCapture.ChunkCaptureArea> areas = JigsawStudioCapture.chunkIntersections(bounds);
PlatformBlockState stone = mock(PlatformBlockState.class); PlatformBlockState stone = mock(PlatformBlockState.class);
when(stone.key()).thenReturn("minecraft:stone"); when(stone.key()).thenReturn("minecraft:stone");
JigsawStudioService.ChunkSnapshot first = new JigsawStudioService.ChunkSnapshot( JigsawStudioCapture.ChunkSnapshot first = new JigsawStudioCapture.ChunkSnapshot(
areas.getFirst(), areas.getFirst(),
List.of(new JigsawStudioService.CapturedBlock(0, 0, 0, stone, null)), List.of(new JigsawStudioCapture.CapturedBlock(0, 0, 0, stone, null)),
List.of()); List.of());
JigsawStudioService.ChunkSnapshot second = new JigsawStudioService.ChunkSnapshot( JigsawStudioCapture.ChunkSnapshot second = new JigsawStudioCapture.ChunkSnapshot(
areas.getLast(), areas.getLast(),
List.of(new JigsawStudioService.CapturedBlock(8, 1, 0, stone, null)), List.of(new JigsawStudioCapture.CapturedBlock(8, 1, 0, stone, null)),
List.of()); List.of());
JigsawStudioService.Capture forward = JigsawStudioService.aggregateSnapshots( JigsawStudioCapture.Capture forward = JigsawStudioCapture.aggregateSnapshots(
bounds, areas, List.of(first, second)); bounds, areas, List.of(first, second));
JigsawStudioService.Capture reverse = JigsawStudioService.aggregateSnapshots( JigsawStudioCapture.Capture reverse = JigsawStudioCapture.aggregateSnapshots(
bounds, areas, List.of(second, first)); bounds, areas, List.of(second, first));
assertArrayEquals(forward.objectContent(), reverse.objectContent()); assertArrayEquals(forward.objectContent(), reverse.objectContent());
assertFalse(forward.hasBlockEntities()); assertFalse(forward.hasBlockEntities());
assertTrue(forward.connectors().isEmpty()); assertTrue(forward.connectors().isEmpty());
assertThrows(IOException.class, assertThrows(IOException.class,
() -> JigsawStudioService.aggregateSnapshots(bounds, areas, List.of(first))); () -> JigsawStudioCapture.aggregateSnapshots(bounds, areas, List.of(first)));
} }
@Test @Test
@@ -1429,14 +1429,14 @@ public class JigsawStudioServiceCaptureTest {
UUID firstRequest = UUID.fromString("44444444-4444-4444-4444-444444444444"); UUID firstRequest = UUID.fromString("44444444-4444-4444-4444-444444444444");
UUID secondRequest = UUID.fromString("55555555-5555-5555-5555-555555555555"); UUID secondRequest = UUID.fromString("55555555-5555-5555-5555-555555555555");
assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(firstRequest)); assertEquals(JigsawStudioSaveLifecycle.SaveStart.STARTED, service.saveLifecycle.tryBeginSave(firstRequest));
assertEquals(JigsawStudioService.SaveStart.IN_PROGRESS, service.tryBeginSave(firstRequest)); assertEquals(JigsawStudioSaveLifecycle.SaveStart.IN_PROGRESS, service.saveLifecycle.tryBeginSave(firstRequest));
assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(secondRequest)); assertEquals(JigsawStudioSaveLifecycle.SaveStart.STARTED, service.saveLifecycle.tryBeginSave(secondRequest));
service.finishSave(firstRequest); service.saveLifecycle.finishSave(firstRequest);
assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(firstRequest)); assertEquals(JigsawStudioSaveLifecycle.SaveStart.STARTED, service.saveLifecycle.tryBeginSave(firstRequest));
service.finishSave(firstRequest); service.saveLifecycle.finishSave(firstRequest);
service.finishSave(secondRequest); service.saveLifecycle.finishSave(secondRequest);
} }
private static IrisJigsawConnector connector() { private static IrisJigsawConnector connector() {
@@ -1496,7 +1496,7 @@ public class JigsawStudioServiceCaptureTest {
return directions; return directions;
} }
private static JigsawStudioService.CapturedConnector displayedConnector( private static JigsawStudioCapture.CapturedConnector displayedConnector(
IrisJigsawConnector source, IrisJigsawConnector source,
JigsawStudioCellDimensions displayDimensions, JigsawStudioCellDimensions displayDimensions,
int displayRotationQuarterTurns, int displayRotationQuarterTurns,
@@ -1507,7 +1507,7 @@ public class JigsawStudioServiceCaptureTest {
source.getPosition(), source.getPosition(),
displayDimensions, displayDimensions,
displayRotationQuarterTurns); displayRotationQuarterTurns);
return new JigsawStudioService.CapturedConnector( return new JigsawStudioCapture.CapturedConnector(
displayedPosition.getX(), displayedPosition.getX(),
displayedPosition.getY(), displayedPosition.getY(),
displayedPosition.getZ(), displayedPosition.getZ(),
@@ -1629,7 +1629,7 @@ public class JigsawStudioServiceCaptureTest {
EventPriority priority, EventPriority priority,
boolean ignoreCancelled boolean ignoreCancelled
) throws NoSuchMethodException { ) throws NoSuchMethodException {
Method method = JigsawStudioService.class.getMethod(methodName, eventType); Method method = JigsawStudioProtectionListener.class.getMethod(methodName, eventType);
EventHandler annotation = method.getAnnotation(EventHandler.class); EventHandler annotation = method.getAnnotation(EventHandler.class);
assertNotNull(annotation); assertNotNull(annotation);
assertEquals(priority, annotation.priority()); assertEquals(priority, annotation.priority());
@@ -81,10 +81,10 @@ public class JigsawStudioVisualizationBoundsTest {
List<Emission> preview = emissions(fixture.player()); List<Emission> preview = emissions(fixture.player());
clearInvocations(fixture.player()); clearInvocations(fixture.player());
Method draw = JigsawStudioService.class.getDeclaredMethod("drawBounds", Player.class, Location.class, Method draw = JigsawStudioVisualization.class.getDeclaredMethod("drawBounds", Player.class, Location.class,
JigsawStudioBounds.class, Color.class, float.class, double.class, nested("ParticleBudget")); JigsawStudioBounds.class, Color.class, float.class, double.class, visualizationNested("ParticleBudget"));
draw.setAccessible(true); draw.setAccessible(true);
Field color = JigsawStudioService.class.getDeclaredField("ASSEMBLY_PREVIEW_COLOR"); Field color = JigsawStudioVisualization.class.getDeclaredField("ASSEMBLY_PREVIEW_COLOR");
color.setAccessible(true); color.setAccessible(true);
invoke(draw, null, fixture.player(), viewer, bounds, color.get(null), 0.85F, 2.0D, budget(384)); invoke(draw, null, fixture.player(), viewer, bounds, color.get(null), 0.85F, 2.0D, budget(384));
@@ -122,10 +122,10 @@ public class JigsawStudioVisualizationBoundsTest {
assertTrue(fixture.service().showAssemblyPreview(fixture.player(), List.of(piece))); assertTrue(fixture.service().showAssemblyPreview(fixture.player(), List.of(piece)));
} }
Method draw = JigsawStudioService.class.getDeclaredMethod("drawAssemblyPreview", Player.class, Method draw = JigsawStudioVisualization.class.getDeclaredMethod("drawAssemblyPreview", Player.class,
Location.class, nested("ParticleBudget")); Location.class, visualizationNested("ParticleBudget"));
draw.setAccessible(true); draw.setAccessible(true);
invoke(draw, fixture.service(), fixture.player(), new Location(fixture.world(), 201, 65, 201), budget(384)); invoke(draw, fixture.service().visualization, fixture.player(), new Location(fixture.world(), 201, 65, 201), budget(384));
assertTrue(emissions(fixture.player()).stream().anyMatch(emission -> assertTrue(emissions(fixture.player()).stream().anyMatch(emission ->
emission.x() == 201 && emission.y() == 65 && emission.z() == 201)); emission.x() == 201 && emission.y() == 65 && emission.z() == 201));
@@ -157,24 +157,24 @@ public class JigsawStudioVisualizationBoundsTest {
Object studio = constructor.newInstance(worldId, world, mock(Engine.class), generator, Object studio = constructor.newInstance(worldId, world, mock(Engine.class), generator,
new ConcurrentHashMap<>(), ConcurrentHashMap.newKeySet(), new AtomicLong()); new ConcurrentHashMap<>(), ConcurrentHashMap.newKeySet(), new AtomicLong());
JigsawStudioService service = new JigsawStudioService(); JigsawStudioService service = new JigsawStudioService();
map(service, "studios").put(worldId, studio); map(service, JigsawStudioService.class, "studios").put(worldId, studio);
return new Fixture(service, player, world, requestId, studio); return new Fixture(service, player, world, requestId, studio);
} }
private static void evaluate(Fixture fixture, JigsawStudioPreviewRenderer.PreviewBounds bounds) throws Exception { private static void evaluate(Fixture fixture, JigsawStudioPreviewRenderer.PreviewBounds bounds) throws Exception {
map(fixture.service(), "evaluations").put(fixture.requestId(), new JigsawStudioGraphEvaluation( map(fixture.service().evaluator, JigsawStudioEvaluator.class, "evaluations").put(fixture.requestId(), new JigsawStudioGraphEvaluation(
fixture.requestId(), 1, 1337, JigsawStudioEvaluationState.VALID, "", 2, "", bounds)); fixture.requestId(), 1, 1337, JigsawStudioEvaluationState.VALID, "", 2, "", bounds));
} }
private static void drawLive(Fixture fixture, Location viewer, int remaining) throws Exception { private static void drawLive(Fixture fixture, Location viewer, int remaining) throws Exception {
Method draw = JigsawStudioService.class.getDeclaredMethod("drawLivePreview", Player.class, Location.class, Method draw = JigsawStudioVisualization.class.getDeclaredMethod("drawLivePreview", Player.class, Location.class,
nested("ActiveStudio"), nested("ParticleBudget")); nested("ActiveStudio"), visualizationNested("ParticleBudget"));
draw.setAccessible(true); draw.setAccessible(true);
invoke(draw, fixture.service(), fixture.player(), viewer, fixture.studio(), budget(remaining)); invoke(draw, fixture.service().visualization, fixture.player(), viewer, fixture.studio(), budget(remaining));
} }
private static Object budget(int remaining) throws Exception { private static Object budget(int remaining) throws Exception {
Constructor<?> constructor = nested("ParticleBudget").getDeclaredConstructor(int.class); Constructor<?> constructor = visualizationNested("ParticleBudget").getDeclaredConstructor(int.class);
constructor.setAccessible(true); constructor.setAccessible(true);
return constructor.newInstance(remaining); return constructor.newInstance(remaining);
} }
@@ -184,10 +184,14 @@ public class JigsawStudioVisualizationBoundsTest {
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private static Map<UUID, Object> map(JigsawStudioService service, String name) throws Exception { private static Map<UUID, Object> map(Object target, Class<?> owner, String name) throws Exception {
Field field = JigsawStudioService.class.getDeclaredField(name); Field field = owner.getDeclaredField(name);
field.setAccessible(true); field.setAccessible(true);
return (Map<UUID, Object>) field.get(service); return (Map<UUID, Object>) field.get(target);
}
private static Class<?> visualizationNested(String name) throws ClassNotFoundException {
return Class.forName(JigsawStudioVisualization.class.getName() + "$" + name);
} }
private static void invoke(Method method, Object target, Object... arguments) throws Exception { private static void invoke(Method method, Object target, Object... arguments) throws Exception {