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
2026-09-08 06:20:54 -04:00
parent 8b9d1fb2fb
commit 2451c89cee
26 changed files with 9617 additions and 8574 deletions
+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/ExternalDataSVC.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/JigsawStudioMaterializer.java
art/arcane/iris/core/service/JigsawStudioMenuActions.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/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/JigsawStudioStructureMenu.java
art/arcane/iris/core/service/JigsawStudioTileWatcher.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/ObjectSVC.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(session.abortVariantSwitch(switchToken));
assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(request.requestId()));
assertEquals(JigsawStudioSaveLifecycle.SaveStart.STARTED, service.saveLifecycle.tryBeginSave(request.requestId()));
assertEquals(
JigsawStudioService.CloseStart.SAVE_IN_PROGRESS,
service.tryBeginClose(request.requestId(), OWNER, true));
assertTrue(service.closeProtectionFailure(request.requestId()).contains("saving"));
service.finishSave(request.requestId());
service.saveLifecycle.finishSave(request.requestId());
assertEquals(
JigsawStudioService.CloseStart.STARTED,
service.tryBeginClose(request.requestId(), OWNER, true));
assertNull(service.closeProtectionFailure(request.requestId()));
assertEquals(JigsawStudioService.SaveStart.CLOSING, service.tryBeginSave(request.requestId()));
assertEquals(JigsawStudioSaveLifecycle.SaveStart.CLOSING, service.saveLifecycle.tryBeginSave(request.requestId()));
}
@Test
@@ -143,7 +143,7 @@ public class JigsawStudioLifecycleTest {
retry.set(invocation.getArgument(0));
return null;
});
CompletableFuture<Void> readiness = service.awaitCloseForReplacement(
CompletableFuture<Void> readiness = service.saveLifecycle.awaitCloseForReplacement(
request.requestId(), OWNER);
assertFalse(readiness.isDone());
@@ -167,7 +167,7 @@ public class JigsawStudioLifecycleTest {
JigsawStudioService service = new JigsawStudioService();
try (MockedStatic<J> scheduling = mockStatic(J.class)) {
CompletableFuture<Void> readiness = service.awaitCloseForReplacement(
CompletableFuture<Void> readiness = service.saveLifecycle.awaitCloseForReplacement(
request.requestId(), OTHER_OWNER);
assertTrue(readiness.isCompletedExceptionally());
@@ -187,9 +187,9 @@ public class JigsawStudioLifecycleTest {
baseline.put("name", "iris:start");
KMap<String, Object> updated = new KMap<>();
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(
UUID.class, UUID.class, int.class, int.class, int.class);
constructor.setAccessible(true);
@@ -213,7 +213,7 @@ public class JigsawStudioLifecycleTest {
new ConcurrentHashMap<>(),
ConcurrentHashMap.newKeySet(),
new AtomicLong());
Class<?> watchType = Class.forName(JigsawStudioService.class.getName() + "$JigsawTileWatch");
Class<?> watchType = Class.forName(JigsawStudioTileWatcher.class.getName() + "$JigsawTileWatch");
Constructor<?> watchConstructor = watchType.getDeclaredConstructor(
keyType,
studioType,
@@ -231,10 +231,10 @@ public class JigsawStudioLifecycleTest {
baseline,
new AtomicBoolean(),
new AtomicBoolean());
Field watchesField = JigsawStudioService.class.getDeclaredField("jigsawTileWatches");
Field watchesField = JigsawStudioTileWatcher.class.getDeclaredField("jigsawTileWatches");
watchesField.setAccessible(true);
@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);
try (MockedStatic<J> scheduling = mockStatic(J.class)) {
@@ -277,55 +277,55 @@ public class JigsawStudioLifecycleTest {
JigsawStudioService.ExportStart.IN_PROGRESS,
service.tryBeginExport(request.requestId(), OWNER));
assertEquals(
JigsawStudioService.SaveStart.EXPORT_OPERATION,
service.tryBeginSave(request.requestId()));
JigsawStudioSaveLifecycle.SaveStart.EXPORT_OPERATION,
service.saveLifecycle.tryBeginSave(request.requestId()));
assertEquals(
JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS,
service.tryBeginClose(request.requestId(), OWNER, false));
assertTrue(service.closeProtectionFailure(request.requestId()).contains("exporting"));
service.finishExport(request.requestId());
assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(request.requestId()));
service.finishSave(request.requestId());
assertEquals(JigsawStudioSaveLifecycle.SaveStart.STARTED, service.saveLifecycle.tryBeginSave(request.requestId()));
service.saveLifecycle.finishSave(request.requestId());
}
@Test
public void immediateSingleAndFamilyDuplicatesWaitForAutosaveThenRunExactlyOnce() {
assertEquals(
JigsawStudioService.DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE,
JigsawStudioService.deferredDuplicationReadiness(
JigsawStudioToolbelt.DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE,
JigsawStudioToolbelt.deferredDuplicationReadiness(
true, true, true, false, false));
assertEquals(
JigsawStudioService.DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE,
JigsawStudioService.deferredDuplicationReadiness(
JigsawStudioToolbelt.DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE,
JigsawStudioToolbelt.deferredDuplicationReadiness(
true, true, true, true, false));
assertEquals(
JigsawStudioService.DeferredDuplicationReadiness.WAITING_FOR_OPERATION,
JigsawStudioService.deferredDuplicationReadiness(
JigsawStudioToolbelt.DeferredDuplicationReadiness.WAITING_FOR_OPERATION,
JigsawStudioToolbelt.deferredDuplicationReadiness(
true, true, false, true, false));
assertEquals(
JigsawStudioService.DeferredDuplicationReadiness.READY,
JigsawStudioService.deferredDuplicationReadiness(
JigsawStudioToolbelt.DeferredDuplicationReadiness.READY,
JigsawStudioToolbelt.deferredDuplicationReadiness(
true, true, false, false, false));
assertEquals(
JigsawStudioService.DeferredDuplicationReadiness.STALE,
JigsawStudioService.deferredDuplicationReadiness(
JigsawStudioToolbelt.DeferredDuplicationReadiness.STALE,
JigsawStudioToolbelt.deferredDuplicationReadiness(
true, false, false, false, false));
}
@Test
public void initialEvaluationWaitsForCommitAndSchedulesExactlyOnce() {
AtomicLong registrationBeforeCommit = new AtomicLong();
assertFalse(JigsawStudioService.claimInitialEvaluation(registrationBeforeCommit, false));
assertFalse(JigsawStudioEvaluator.claimInitialEvaluation(registrationBeforeCommit, false));
assertEquals(0L, registrationBeforeCommit.get());
assertTrue(JigsawStudioService.claimInitialEvaluation(registrationBeforeCommit, true));
assertTrue(JigsawStudioEvaluator.claimInitialEvaluation(registrationBeforeCommit, true));
assertEquals(1L, registrationBeforeCommit.get());
assertFalse(JigsawStudioService.claimInitialEvaluation(registrationBeforeCommit, true));
assertFalse(JigsawStudioEvaluator.claimInitialEvaluation(registrationBeforeCommit, true));
AtomicLong registrationAfterCommit = new AtomicLong();
assertTrue(JigsawStudioService.claimInitialEvaluation(registrationAfterCommit, true));
assertTrue(JigsawStudioEvaluator.claimInitialEvaluation(registrationAfterCommit, true));
assertEquals(1L, registrationAfterCommit.get());
assertFalse(JigsawStudioService.claimInitialEvaluation(registrationAfterCommit, true));
assertFalse(JigsawStudioEvaluator.claimInitialEvaluation(registrationAfterCommit, true));
}
@Test
@@ -339,9 +339,9 @@ public class JigsawStudioLifecycleTest {
JigsawStudioBay blank = layout.get("workcell/blank");
assertTrue(session.selectedBayId().isEmpty());
assertTrue(JigsawStudioService.selectEnteredWorkcell(session, end, true));
assertTrue(JigsawStudioPlayerContext.selectEnteredWorkcell(session, end, true));
assertEquals(end.stableId(), session.selectedBayId().orElseThrow());
assertFalse(JigsawStudioService.selectEnteredWorkcell(session, blank, false));
assertFalse(JigsawStudioPlayerContext.selectEnteredWorkcell(session, blank, false));
assertEquals(end.stableId(), session.selectedBayId().orElseThrow());
}
@@ -571,14 +571,14 @@ public class JigsawStudioMenuControllerTest {
when(world.getUID()).thenReturn(worldId);
when(location.getWorld()).thenReturn(world);
Field menuField = JigsawStudioService.class.getDeclaredField("menuController");
Field menuField = JigsawStudioToolbelt.class.getDeclaredField("menuController");
menuField.setAccessible(true);
menuField.set(service, menu);
menuField.set(service.toolbelt, menu);
IrisServices.register(BoardSVC.class, board);
try (MockedStatic<J> scheduling = mockStatic(J.class)) {
scheduling.when(() -> J.isOwnedByCurrentRegion(player)).thenReturn(true);
service.onPlayerChangedWorld(event);
service.protectionListener.onPlayerChangedWorld(event);
verify(menu).close(player);
verify(board).clearJigsawContext(player);
@@ -79,10 +79,10 @@ public class JigsawStudioPreviewRendererTest {
10,
IrisObjectRotation.of(0, 0, 0));
PlacedStructurePiece planar = JigsawStudioService.alignPreviewPieces(
PlacedStructurePiece planar = JigsawStudioEvaluator.alignPreviewPieces(
List.of(source),
JigsawStudioMode.PLANAR_JIGSAW).getFirst();
PlacedStructurePiece spatial = JigsawStudioService.alignPreviewPieces(
PlacedStructurePiece spatial = JigsawStudioEvaluator.alignPreviewPieces(
List.of(source),
JigsawStudioMode.SPATIAL_JIGSAW).getFirst();
@@ -197,11 +197,11 @@ public class JigsawStudioServiceCaptureTest {
when(state.isCustom()).thenReturn(false);
when(state.nativeHandle()).thenReturn(blockData);
JigsawStudioService.restoreConnectorChunk(
JigsawStudioChunkWriter.restoreConnectorChunk(
world,
workcell,
List.of(renderedConnector),
Map.of(new JigsawStudioService.LocalPosition(1, 2, 3), renderedBlock),
Map.of(new JigsawStudioCapture.LocalPosition(1, 2, 3), renderedBlock),
false);
verify(world).getBlockAt(11, 22, 33);
@@ -229,9 +229,9 @@ public class JigsawStudioServiceCaptureTest {
expandedSpecs,
JigsawStudioVariantCatalog.empty());
assertFalse(JigsawStudioService.layoutGeometryChanged(original, original));
assertTrue(JigsawStudioService.layoutGeometryChanged(original, expanded));
Set<Long> chunks = JigsawStudioService.relayoutChunks(original, expanded);
assertFalse(JigsawStudioGraphMutations.layoutGeometryChanged(original, original));
assertTrue(JigsawStudioGraphMutations.layoutGeometryChanged(original, expanded));
Set<Long> chunks = JigsawStudioGraphMutations.relayoutChunks(original, expanded);
assertTrue(chunks.contains(0L));
assertTrue(chunks.contains(((long) 4 << 32)));
}
@@ -248,8 +248,8 @@ public class JigsawStudioServiceCaptureTest {
dimensions,
new JigsawStudioVariantCatalog(List.of(), false));
assertTrue(JigsawStudioService.canCreateVariants(editable));
assertFalse(JigsawStudioService.canCreateVariants(managed));
assertTrue(JigsawStudioProtection.canCreateVariants(editable));
assertFalse(JigsawStudioProtection.canCreateVariants(managed));
}
@Test
@@ -269,27 +269,27 @@ public class JigsawStudioServiceCaptureTest {
new JigsawStudioPoolMembership("test/pieces", 0, 4, 0.35D),
new JigsawStudioPoolMembership("test/caps", 0, 7, 0.8D)));
assertEquals("", JigsawStudioService.variantCreationSourceFailure(assigned, false));
assertEquals("", JigsawStudioService.variantCreationSourceFailure(assigned, true));
assertTrue(JigsawStudioService.variantCreationSourceFailure(null, false)
assertEquals("", JigsawStudioProtection.variantCreationSourceFailure(assigned, false));
assertEquals("", JigsawStudioProtection.variantCreationSourceFailure(assigned, true));
assertTrue(JigsawStudioProtection.variantCreationSourceFailure(null, false)
.contains("piece create <poolKey> <pieceKey>"));
assertTrue(JigsawStudioService.variantCreationSourceFailure(planarVariant(true, true), false)
assertTrue(JigsawStudioProtection.variantCreationSourceFailure(planarVariant(true, true), false)
.contains("no owned pool membership"));
assertTrue(JigsawStudioService.variantCreationSourceFailure(planarVariant(true, false), true)
assertTrue(JigsawStudioProtection.variantCreationSourceFailure(planarVariant(true, false), true)
.contains("read-only variant"));
}
@Test
public void recognizesVanillaNamespacedAndWorldEditMutationCommands() {
assertTrue(JigsawStudioService.isMutatingCommand("/fill 0 0 0 1 1 1 stone"));
assertTrue(JigsawStudioService.isMutatingCommand("minecraft:setblock 0 0 0 air"));
assertTrue(JigsawStudioService.isMutatingCommand("//paste -a"));
assertTrue(JigsawStudioService.isMutatingCommand("/execute as @s run setblock 0 0 0 stone"));
assertTrue(JigsawStudioService.isMutatingCommand("/function test:build"));
assertTrue(JigsawStudioService.isMutatingCommand("/data merge block 0 0 0 {}"));
assertTrue(JigsawStudioService.isMutatingCommand("/item replace block 0 0 0 container.0 with stone"));
assertFalse(JigsawStudioService.isMutatingCommand("/iris jigsaw status"));
assertFalse(JigsawStudioService.isMutatingCommand("/tp 0 80 0"));
assertTrue(JigsawStudioProtection.isMutatingCommand("/fill 0 0 0 1 1 1 stone"));
assertTrue(JigsawStudioProtection.isMutatingCommand("minecraft:setblock 0 0 0 air"));
assertTrue(JigsawStudioProtection.isMutatingCommand("//paste -a"));
assertTrue(JigsawStudioProtection.isMutatingCommand("/execute as @s run setblock 0 0 0 stone"));
assertTrue(JigsawStudioProtection.isMutatingCommand("/function test:build"));
assertTrue(JigsawStudioProtection.isMutatingCommand("/data merge block 0 0 0 {}"));
assertTrue(JigsawStudioProtection.isMutatingCommand("/item replace block 0 0 0 container.0 with stone"));
assertFalse(JigsawStudioProtection.isMutatingCommand("/iris jigsaw status"));
assertFalse(JigsawStudioProtection.isMutatingCommand("/tp 0 80 0"));
}
@Test
@@ -297,28 +297,28 @@ public class JigsawStudioServiceCaptureTest {
UUID ownerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID otherId = UUID.fromString("22222222-2222-2222-2222-222222222222");
assertTrue(JigsawStudioService.ownerMatches(ownerId, ownerId));
assertTrue(JigsawStudioService.ownerMatches(null, otherId));
assertFalse(JigsawStudioService.ownerMatches(ownerId, otherId));
assertTrue(JigsawStudioService.blocksStudioEdit(ownerId, otherId));
assertFalse(JigsawStudioService.blocksStudioEdit(ownerId, ownerId));
assertFalse(JigsawStudioService.blocksStudioEdit(null, otherId));
assertTrue(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "//paste -a"));
assertFalse(JigsawStudioService.blocksMutatingCommand(ownerId, ownerId, "//paste -a"));
assertFalse(JigsawStudioService.blocksMutatingCommand(null, otherId, "//paste -a"));
assertFalse(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "/iris jigsaw status"));
assertFalse(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "/msg owner hello"));
assertTrue(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "/execute run say bypass"));
assertTrue(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "/unknownplugin mutate"));
assertTrue(JigsawStudioService.blocksNonEditableWorkcellMutation(
assertTrue(JigsawStudioProtection.ownerMatches(ownerId, ownerId));
assertTrue(JigsawStudioProtection.ownerMatches(null, otherId));
assertFalse(JigsawStudioProtection.ownerMatches(ownerId, otherId));
assertTrue(JigsawStudioProtection.blocksStudioEdit(ownerId, otherId));
assertFalse(JigsawStudioProtection.blocksStudioEdit(ownerId, ownerId));
assertFalse(JigsawStudioProtection.blocksStudioEdit(null, otherId));
assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "//paste -a"));
assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, ownerId, "//paste -a"));
assertFalse(JigsawStudioProtection.blocksMutatingCommand(null, otherId, "//paste -a"));
assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/iris jigsaw status"));
assertFalse(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/msg owner hello"));
assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/execute run say bypass"));
assertTrue(JigsawStudioProtection.blocksMutatingCommand(ownerId, otherId, "/unknownplugin mutate"));
assertTrue(JigsawStudioProtection.blocksNonEditableWorkcellMutation(
true, "/fill 0 0 0 1 1 1 stone"));
assertTrue(JigsawStudioService.blocksNonEditableWorkcellMutation(true, "//paste -a"));
assertFalse(JigsawStudioService.blocksNonEditableWorkcellMutation(
assertTrue(JigsawStudioProtection.blocksNonEditableWorkcellMutation(true, "//paste -a"));
assertFalse(JigsawStudioProtection.blocksNonEditableWorkcellMutation(
true, "/iris jigsaw variant load test/end"));
assertFalse(JigsawStudioService.blocksNonEditableWorkcellMutation(
assertFalse(JigsawStudioProtection.blocksNonEditableWorkcellMutation(
false, "/fill 0 0 0 1 1 1 stone"));
Method method = JigsawStudioService.class.getMethod(
Method method = JigsawStudioProtectionListener.class.getMethod(
"onUnauthorizedPlayerCommand",
PlayerCommandPreprocessEvent.class);
EventHandler handler = method.getAnnotation(EventHandler.class);
@@ -340,21 +340,21 @@ public class JigsawStudioServiceCaptureTest {
UUID ownerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID otherId = UUID.fromString("22222222-2222-2222-2222-222222222222");
assertFalse(JigsawStudioService.blocksStudioInventoryMutation(
assertFalse(JigsawStudioProtection.blocksStudioInventoryMutation(
false, false, true, true, ownerId, otherId));
assertFalse(JigsawStudioService.blocksStudioInventoryMutation(
assertFalse(JigsawStudioProtection.blocksStudioInventoryMutation(
true, false, false, false, ownerId, ownerId));
assertTrue(JigsawStudioService.blocksStudioInventoryMutation(
assertTrue(JigsawStudioProtection.blocksStudioInventoryMutation(
true, false, true, false, ownerId, ownerId));
assertTrue(JigsawStudioService.blocksStudioInventoryMutation(
assertTrue(JigsawStudioProtection.blocksStudioInventoryMutation(
true, false, false, true, ownerId, ownerId));
assertTrue(JigsawStudioService.blocksStudioInventoryMutation(
assertTrue(JigsawStudioProtection.blocksStudioInventoryMutation(
true, false, false, false, ownerId, otherId));
assertFalse(JigsawStudioService.blocksStudioInventoryMutation(
assertFalse(JigsawStudioProtection.blocksStudioInventoryMutation(
true, false, false, false, ownerId, null));
assertTrue(JigsawStudioService.blocksStudioInventoryMutation(
assertTrue(JigsawStudioProtection.blocksStudioInventoryMutation(
true, true, false, false, ownerId, null));
assertTrue(JigsawStudioService.blocksStudioInventoryMutation(
assertTrue(JigsawStudioProtection.blocksStudioInventoryMutation(
true, true, false, false, ownerId, ownerId));
}
@@ -394,17 +394,17 @@ public class JigsawStudioServiceCaptureTest {
KMap<String, Object> changedAgain = new KMap<>();
changedAgain.put("name", "iris:cap");
assertFalse(JigsawStudioService.tileSnapshotChanged(baseline, unchanged));
assertTrue(JigsawStudioService.tileSnapshotChanged(baseline, changed));
assertFalse(JigsawStudioTileWatcher.tileSnapshotChanged(baseline, unchanged));
assertTrue(JigsawStudioTileWatcher.tileSnapshotChanged(baseline, changed));
assertEquals(
new JigsawStudioService.JigsawTilePollDecision(true, true),
JigsawStudioService.jigsawTilePollDecision(baseline, changed, false));
new JigsawStudioTileWatcher.JigsawTilePollDecision(true, true),
JigsawStudioTileWatcher.jigsawTilePollDecision(baseline, changed, false));
assertEquals(
new JigsawStudioService.JigsawTilePollDecision(true, true),
JigsawStudioService.jigsawTilePollDecision(changed, changedAgain, false));
new JigsawStudioTileWatcher.JigsawTilePollDecision(true, true),
JigsawStudioTileWatcher.jigsawTilePollDecision(changed, changedAgain, false));
assertEquals(
new JigsawStudioService.JigsawTilePollDecision(false, false),
JigsawStudioService.jigsawTilePollDecision(changedAgain, changedAgain, true));
new JigsawStudioTileWatcher.JigsawTilePollDecision(false, false),
JigsawStudioTileWatcher.jigsawTilePollDecision(changedAgain, changedAgain, true));
}
@Test
@@ -412,14 +412,14 @@ public class JigsawStudioServiceCaptureTest {
UUID requestId = UUID.fromString("33333333-3333-3333-3333-333333333333");
IOException validationFailure = new IOException("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,
"qa/profile_final",
"workcell/end",
"qa/profile_final/end",
validationDetail);
JigsawStudioService.AutosaveFailureState validationState =
new JigsawStudioService.AutosaveFailureState();
JigsawStudioAutosaveScheduler.AutosaveFailureState validationState =
new JigsawStudioAutosaveScheduler.AutosaveFailureState();
StructureWriteResult writerResult = new StructureWriteResult(
StructureWriteResult.Status.OWNERSHIP_CONFLICT,
StructureWriteResult.Action.NONE,
@@ -430,18 +430,18 @@ public class JigsawStudioServiceCaptureTest {
"",
Optional.empty());
String writerDetail = JigsawStudioService.writeFailure(writerResult);
String writerContext = JigsawStudioService.autosaveFailureContext(
String writerContext = JigsawStudioAutosaveScheduler.autosaveFailureContext(
requestId,
"qa/profile_final",
"workcell/end",
"qa/profile_final/end",
writerDetail);
JigsawStudioService.AutosaveFailureState writerState =
new JigsawStudioService.AutosaveFailureState();
JigsawStudioAutosaveScheduler.AutosaveFailureState writerState =
new JigsawStudioAutosaveScheduler.AutosaveFailureState();
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
JigsawStudioService.AutosaveFailureDecision firstValidation =
JigsawStudioService.recordPersistentAutosaveFailure(
JigsawStudioAutosaveScheduler.AutosaveFailureDecision firstValidation =
JigsawStudioAutosaveScheduler.recordPersistentAutosaveFailure(
validationState,
requestId,
"qa/profile_final",
@@ -449,8 +449,8 @@ public class JigsawStudioServiceCaptureTest {
"qa/profile_final/end",
validationDetail,
validationFailure);
JigsawStudioService.AutosaveFailureDecision secondValidation =
JigsawStudioService.recordPersistentAutosaveFailure(
JigsawStudioAutosaveScheduler.AutosaveFailureDecision secondValidation =
JigsawStudioAutosaveScheduler.recordPersistentAutosaveFailure(
validationState,
requestId,
"qa/profile_final",
@@ -460,7 +460,7 @@ public class JigsawStudioServiceCaptureTest {
validationFailure);
List<Integer> writerDelays = new ArrayList<>();
for (int attempt = 0; attempt < 7; attempt++) {
writerDelays.add(JigsawStudioService.recordPersistentAutosaveFailure(
writerDelays.add(JigsawStudioAutosaveScheduler.recordPersistentAutosaveFailure(
writerState,
requestId,
"qa/profile_final",
@@ -481,8 +481,8 @@ public class JigsawStudioServiceCaptureTest {
logging.verify(() -> IrisLogging.warn("%s", writerContext), times(1));
}
JigsawStudioService.AutosaveFailureDecision newIdentity =
new JigsawStudioService.AutosaveFailureState().recordPersistentFailure();
JigsawStudioAutosaveScheduler.AutosaveFailureDecision newIdentity =
new JigsawStudioAutosaveScheduler.AutosaveFailureState().recordPersistentFailure();
assertEquals(40, newIdentity.retryTicks());
assertTrue(newIdentity.logFailure());
}
@@ -545,11 +545,11 @@ public class JigsawStudioServiceCaptureTest {
when(generator.getLayout()).thenAnswer(invocation -> session.layout());
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
ConcurrentHashMap<String, JigsawStudioService.BayPopulation> populations = new ConcurrentHashMap<>();
JigsawStudioService.BayPopulation endPopulation =
new JigsawStudioService.BayPopulation(Set.of(0L), "");
JigsawStudioService.BayPopulation straightPopulation =
new JigsawStudioService.BayPopulation(Set.of(0L), "");
ConcurrentHashMap<String, JigsawStudioChunkWriter.BayPopulation> populations = new ConcurrentHashMap<>();
JigsawStudioChunkWriter.BayPopulation endPopulation =
new JigsawStudioChunkWriter.BayPopulation(Set.of(0L), "");
JigsawStudioChunkWriter.BayPopulation straightPopulation =
new JigsawStudioChunkWriter.BayPopulation(Set.of(0L), "");
endPopulation.markFullyReady();
straightPopulation.markFullyReady();
populations.put(initialEnd.stableId(), endPopulation);
@@ -572,7 +572,7 @@ public class JigsawStudioServiceCaptureTest {
populations,
ConcurrentHashMap.newKeySet(),
new AtomicLong());
Method scheduleAutosave = JigsawStudioService.class.getDeclaredMethod(
Method scheduleAutosave = JigsawStudioAutosaveScheduler.class.getDeclaredMethod(
"scheduleAutosave",
studioType,
JigsawStudioBay.class,
@@ -606,8 +606,8 @@ public class JigsawStudioServiceCaptureTest {
anyInt(),
any(Runnable.class)))
.thenReturn(true);
scheduleAutosave.invoke(service, studio, initialEnd, endDirty, 40);
scheduleAutosave.invoke(service, studio, initialStraight, straightDirty, 40);
scheduleAutosave.invoke(service.autosaveScheduler, studio, initialEnd, endDirty, 40);
scheduleAutosave.invoke(service.autosaveScheduler, studio, initialStraight, straightDirty, 40);
assertEquals(2, delayedAutosaves.size());
JigsawStudioSession.SaveIdentity committedEnd = session.beginSave(initialEnd.stableId())
@@ -619,7 +619,7 @@ public class JigsawStudioServiceCaptureTest {
dimensions,
catalog);
assertTrue(session.replaceLayout(replacementLayout));
JigsawStudioBay currentStraight = JigsawStudioService.resolveCurrentAutosaveBay(
JigsawStudioBay currentStraight = JigsawStudioAutosaveScheduler.resolveCurrentAutosaveBay(
session,
initialStraight.stableId());
assertNotSame(initialStraight, currentStraight);
@@ -676,10 +676,10 @@ public class JigsawStudioServiceCaptureTest {
when(player.getWorld()).thenReturn(world);
JigsawStudioService service = new JigsawStudioService();
ConcurrentHashMap<String, JigsawStudioService.BayPopulation> populations = new ConcurrentHashMap<>();
ConcurrentHashMap<String, JigsawStudioChunkWriter.BayPopulation> populations = new ConcurrentHashMap<>();
populations.put(
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");
Constructor<?> studioConstructor = studioType.getDeclaredConstructor(
UUID.class,
@@ -704,11 +704,11 @@ public class JigsawStudioServiceCaptureTest {
Map<UUID, Object> studios = (Map<UUID, Object>) studiosField.get(service);
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);
keyConstructor.setAccessible(true);
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(
keyType,
studioType,
@@ -722,15 +722,15 @@ public class JigsawStudioServiceCaptureTest {
identity,
new AtomicBoolean(false),
new AtomicBoolean(false));
Field autosavesField = JigsawStudioService.class.getDeclaredField("autosaves");
Field autosavesField = JigsawStudioAutosaveScheduler.class.getDeclaredField("autosaves");
autosavesField.setAccessible(true);
@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);
Method failureStateAccessor = ticketType.getDeclaredMethod("failureState");
failureStateAccessor.setAccessible(true);
JigsawStudioService.AutosaveFailureState failureState =
(JigsawStudioService.AutosaveFailureState) failureStateAccessor.invoke(ticket);
JigsawStudioAutosaveScheduler.AutosaveFailureState failureState =
(JigsawStudioAutosaveScheduler.AutosaveFailureState) failureStateAccessor.invoke(ticket);
assertEquals(40, failureState.recordPersistentFailure().retryTicks());
try (MockedStatic<J> scheduling = mockStatic(J.class)) {
@@ -745,13 +745,13 @@ public class JigsawStudioServiceCaptureTest {
populations.put(
bay.stableId(),
new JigsawStudioService.BayPopulation(Set.of(0L), ""));
new JigsawStudioChunkWriter.BayPopulation(Set.of(0L), ""));
assertFalse(service.flushAutosave(player, bay.stableId()));
assertEquals(1, autosaves.size());
assertTrue(session.isDirtyCurrent(identity));
JigsawStudioService.BayPopulation ready =
new JigsawStudioService.BayPopulation(Set.of(0L), "");
JigsawStudioChunkWriter.BayPopulation ready =
new JigsawStudioChunkWriter.BayPopulation(Set.of(0L), "");
ready.markFullyReady();
populations.put(bay.stableId(), ready);
JigsawStudioSession.SaveIdentity inProgress = session.beginSave(bay.stableId())
@@ -773,13 +773,13 @@ public class JigsawStudioServiceCaptureTest {
JigsawStudioVariant fixed = planarVariant(false, true);
JigsawStudioVariant readOnly = planarVariant(true, false);
assertFalse(JigsawStudioService.canToggleVariantRotation(
assertFalse(JigsawStudioProtection.canToggleVariantRotation(
JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, rotatable));
assertTrue(JigsawStudioService.canToggleVariantRotation(
assertTrue(JigsawStudioProtection.canToggleVariantRotation(
JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, fixed));
assertTrue(JigsawStudioService.canToggleVariantRotation(
assertTrue(JigsawStudioProtection.canToggleVariantRotation(
JigsawStudioCompatibilityTarget.IRIS_EXTENDED, rotatable));
assertFalse(JigsawStudioService.canToggleVariantRotation(
assertFalse(JigsawStudioProtection.canToggleVariantRotation(
JigsawStudioCompatibilityTarget.IRIS_EXTENDED, readOnly));
}
@@ -793,22 +793,22 @@ public class JigsawStudioServiceCaptureTest {
IrisJigsawConnector eastSource = connector().setDirection(IrisDirection.EAST_POSITIVE_X);
IrisJigsawConnector southDisplayed = connector().setDirection(IrisDirection.SOUTH_POSITIVE_Z);
JigsawStudioService.requireWorkcellTopology(
JigsawStudioCapture.requireWorkcellTopology(
layout.get("workcell/end"),
List.of(eastSource),
3);
JigsawStudioService.WorkcellTopologyException wrongDirection = assertThrows(
JigsawStudioService.WorkcellTopologyException.class,
() -> JigsawStudioService.requireWorkcellTopology(
JigsawStudioCapture.WorkcellTopologyException wrongDirection = assertThrows(
JigsawStudioCapture.WorkcellTopologyException.class,
() -> JigsawStudioCapture.requireWorkcellTopology(
layout.get("workcell/end"),
List.of(southDisplayed),
0));
assertTrue(wrongDirection.getMessage().contains("south end (1 horizontal connector)"));
assertTrue(wrongDirection.getMessage().contains("Reset Connector Blocks"));
JigsawStudioService.WorkcellTopologyException missingTee = assertThrows(
JigsawStudioService.WorkcellTopologyException.class,
() -> JigsawStudioService.requireWorkcellTopology(
JigsawStudioCapture.WorkcellTopologyException missingTee = assertThrows(
JigsawStudioCapture.WorkcellTopologyException.class,
() -> JigsawStudioCapture.requireWorkcellTopology(
layout.get("workcell/tee"),
List.of(),
0));
@@ -823,13 +823,13 @@ public class JigsawStudioServiceCaptureTest {
64,
0,
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));
for (int quarterTurns = 1; quarterTurns <= 3; quarterTurns++) {
IrisObjectRotation displayRotation = IrisObjectRotation.of(0, -90.0D * quarterTurns, 0);
PlatformBlockState displayedState = displayRotation.rotate(sourceState, 0, 0, 0);
IrisDirection displayedDirection = displayRotation.rotate(IrisDirection.NORTH_NEGATIVE_Z);
JigsawStudioService.CapturedConnector connector = new JigsawStudioService.CapturedConnector(
JigsawStudioCapture.CapturedConnector connector = new JigsawStudioCapture.CapturedConnector(
1,
0,
1,
@@ -843,14 +843,14 @@ public class JigsawStudioServiceCaptureTest {
displayedState.key(),
-3,
7);
JigsawStudioService.ChunkSnapshot snapshot = new JigsawStudioService.ChunkSnapshot(
JigsawStudioCapture.ChunkSnapshot snapshot = new JigsawStudioCapture.ChunkSnapshot(
areas.getFirst(),
List.of(),
List.of(connector));
try (MockedStatic<B> blocks = mockStatic(B.class)) {
blocks.when(() -> B.getStateOrNull(
displayedState.key(), false)).thenReturn(displayedState);
JigsawStudioService.Capture capture = JigsawStudioService.aggregateSnapshots(
JigsawStudioCapture.Capture capture = JigsawStudioCapture.aggregateSnapshots(
bounds,
areas,
List.of(snapshot),
@@ -888,12 +888,12 @@ public class JigsawStudioServiceCaptureTest {
when(block.getBlockData()).thenReturn(chestData);
World world = mock(World.class);
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)) {
tiles.when(() -> TileData.getTileState(block, false)).thenReturn(tileData);
snapshot = JigsawStudioService.captureChunkIntersection(
snapshot = JigsawStudioCapture.captureChunkIntersection(
world,
bounds,
piece,
@@ -902,7 +902,7 @@ public class JigsawStudioServiceCaptureTest {
0,
false);
}
JigsawStudioService.Capture capture = JigsawStudioService.aggregateSnapshots(
JigsawStudioCapture.Capture capture = JigsawStudioCapture.aggregateSnapshots(
bounds,
List.of(area),
List.of(snapshot));
@@ -945,7 +945,7 @@ public class JigsawStudioServiceCaptureTest {
IrisJigsawPiece source = pieceWithPlanarConnectors(dimensions, authoredOrders.get(index));
List<IrisJigsawConnector> captured = planarConnectors(dimensions, captureOrders.get(index));
List<IrisJigsawConnector> ordered = JigsawStudioService.preserveCapturedConnectorOrder(
List<IrisJigsawConnector> ordered = JigsawStudioCapture.preserveCapturedConnectorOrder(
source,
captured);
@@ -974,7 +974,7 @@ public class JigsawStudioServiceCaptureTest {
dimensions,
IrisDirection.EAST_POSITIVE_X);
List<IrisJigsawConnector> ordered = JigsawStudioService.preserveCapturedConnectorOrder(
List<IrisJigsawConnector> ordered = JigsawStudioCapture.preserveCapturedConnectorOrder(
source,
List.of(capturedEast, capturedNorth));
@@ -998,7 +998,7 @@ public class JigsawStudioServiceCaptureTest {
dimensions,
IrisDirection.NORTH_NEGATIVE_Z);
List<IrisJigsawConnector> ordered = JigsawStudioService.preserveCapturedConnectorOrder(
List<IrisJigsawConnector> ordered = JigsawStudioCapture.preserveCapturedConnectorOrder(
source,
List.of(capturedWest, capturedNorth));
@@ -1020,7 +1020,7 @@ public class JigsawStudioServiceCaptureTest {
IrisJigsawConnector highZ = connectorAt(2, 3, 10);
IrisJigsawConnector lowZ = connectorAt(2, 3, 4);
List<IrisJigsawConnector> ordered = JigsawStudioService.preserveCapturedConnectorOrder(
List<IrisJigsawConnector> ordered = JigsawStudioCapture.preserveCapturedConnectorOrder(
source,
List.of(highY, capturedNorth, highZ, lowZ));
@@ -1040,10 +1040,10 @@ public class JigsawStudioServiceCaptureTest {
duplicateSource.getConnectors().add(second);
IrisJigsawPiece emptySource = new IrisJigsawPiece().setConnectors(new KList<>());
assertThrows(IOException.class, () -> JigsawStudioService.preserveCapturedConnectorOrder(
assertThrows(IOException.class, () -> JigsawStudioCapture.preserveCapturedConnectorOrder(
duplicateSource,
List.of(first)));
assertThrows(IOException.class, () -> JigsawStudioService.preserveCapturedConnectorOrder(
assertThrows(IOException.class, () -> JigsawStudioCapture.preserveCapturedConnectorOrder(
emptySource,
List.of(first, second)));
}
@@ -1055,7 +1055,7 @@ public class JigsawStudioServiceCaptureTest {
64,
0,
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)
.setDirection(IrisDirection.NORTH_NEGATIVE_Z);
IrisJigsawConnector sourceSecond = connectorAt(2, 0, 2)
@@ -1069,31 +1069,31 @@ public class JigsawStudioServiceCaptureTest {
for (int quarterTurns = 1; quarterTurns <= 3; quarterTurns++) {
IrisObjectRotation displayRotation = IrisObjectRotation.of(0, -90.0D * quarterTurns, 0);
PlatformBlockState displayedState = displayRotation.rotate(sourceState, 0, 0, 0);
JigsawStudioService.CapturedConnector displayedFirst = displayedConnector(
JigsawStudioCapture.CapturedConnector displayedFirst = displayedConnector(
sourceFirst,
bounds.dimensions(),
quarterTurns,
displayRotation,
displayedState.key());
JigsawStudioService.CapturedConnector displayedSecond = displayedConnector(
JigsawStudioCapture.CapturedConnector displayedSecond = displayedConnector(
sourceSecond,
bounds.dimensions(),
quarterTurns,
displayRotation,
displayedState.key());
JigsawStudioService.ChunkSnapshot snapshot = new JigsawStudioService.ChunkSnapshot(
JigsawStudioCapture.ChunkSnapshot snapshot = new JigsawStudioCapture.ChunkSnapshot(
areas.getFirst(),
List.of(),
List.of(displayedSecond, displayedFirst));
try (MockedStatic<B> blocks = mockStatic(B.class)) {
blocks.when(() -> B.getStateOrNull(displayedState.key(), false)).thenReturn(displayedState);
JigsawStudioService.Capture capture = JigsawStudioService.aggregateSnapshots(
JigsawStudioCapture.Capture capture = JigsawStudioCapture.aggregateSnapshots(
bounds,
areas,
List.of(snapshot),
quarterTurns);
List<IrisJigsawConnector> ordered = JigsawStudioService.preserveCapturedConnectorOrder(
List<IrisJigsawConnector> ordered = JigsawStudioCapture.preserveCapturedConnectorOrder(
source,
capture.connectors());
@@ -1110,7 +1110,7 @@ public class JigsawStudioServiceCaptureTest {
64,
0,
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<>();
properties.put("CustomName", "QA Chest");
properties.put("Lock", "iris:test");
@@ -1124,18 +1124,18 @@ public class JigsawStudioServiceCaptureTest {
for (int quarterTurns = 1; quarterTurns <= 3; quarterTurns++) {
BlockData displayedData = directionalBlockData(Material.CHEST, BlockFace.EAST);
PlatformBlockState displayedState = BukkitBlockState.of(displayedData);
JigsawStudioService.CapturedBlock capturedBlock = new JigsawStudioService.CapturedBlock(
JigsawStudioCapture.CapturedBlock capturedBlock = new JigsawStudioCapture.CapturedBlock(
1,
1,
1,
displayedState,
tileData);
JigsawStudioService.ChunkSnapshot snapshot = new JigsawStudioService.ChunkSnapshot(
JigsawStudioCapture.ChunkSnapshot snapshot = new JigsawStudioCapture.ChunkSnapshot(
areas.getFirst(),
List.of(capturedBlock),
List.of());
JigsawStudioService.Capture capture = JigsawStudioService.aggregateSnapshots(
JigsawStudioCapture.Capture capture = JigsawStudioCapture.aggregateSnapshots(
bounds,
areas,
List.of(snapshot),
@@ -1163,7 +1163,7 @@ public class JigsawStudioServiceCaptureTest {
List.of(new JigsawStudioGenerator.RenderedBlock(0, 0, 0, state, tileData)),
List.of());
assertEquals("", JigsawStudioService.validateMaterialization(rendered));
assertEquals("", JigsawStudioChunkWriter.validateMaterialization(rendered));
}
@Test
@@ -1172,8 +1172,8 @@ public class JigsawStudioServiceCaptureTest {
BlockData air = blockData(Material.AIR, "minecraft:air");
BlockData structureVoid = blockData(Material.STRUCTURE_VOID, "minecraft:structure_void");
JigsawStudioService.storeConnectorFinalState(object, 0, 0, 0, air);
JigsawStudioService.storeConnectorFinalState(object, 1, 0, 0, structureVoid);
JigsawStudioCapture.storeConnectorFinalState(object, 0, 0, 0, air);
JigsawStudioCapture.storeConnectorFinalState(object, 1, 0, 0, structureVoid);
IrisBlockVector airPosition = object.getSigned(0, 0, 0);
IrisBlockVector structureVoidPosition = object.getSigned(1, 0, 0);
@@ -1194,10 +1194,10 @@ public class JigsawStudioServiceCaptureTest {
when(sourceAir.key()).thenReturn("minecraft:air");
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);
assertNull(JigsawStudioService.retainedSourceAir(source, 1, 0, 0, air));
assertNull(JigsawStudioService.retainedSourceAir(source, 0, 0, 0, structureVoid));
assertNull(JigsawStudioCapture.retainedSourceAir(source, 1, 0, 0, air));
assertNull(JigsawStudioCapture.retainedSourceAir(source, 0, 0, 0, structureVoid));
IrisObject captured = new IrisObject(2, 2, 2);
captured.setUnsigned(0, 0, 0, retained);
@@ -1239,7 +1239,7 @@ public class JigsawStudioServiceCaptureTest {
IrisJigsawPiece piece = new IrisJigsawPiece().setConnectors(new KList<>());
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:door", nbt.get("name"));
assertEquals(-4, nbt.get("selection_priority"));
@@ -1250,7 +1250,7 @@ public class JigsawStudioServiceCaptureTest {
.setPool("fort/start")
.setName("iris:door")
.setTargetName("iris:door");
JigsawStudioService.restoreCapturedMetadata(captured, piece);
JigsawStudioChunkWriter.restoreCapturedMetadata(captured, piece);
assertEquals("fort/start", captured.getPool());
assertEquals("door", captured.getName());
@@ -1260,7 +1260,7 @@ public class JigsawStudioServiceCaptureTest {
@Test
public void authoredBayReadinessRequiresEveryChunkToPopulateAndHydrate() {
JigsawStudioService.BayPopulation population = new JigsawStudioService.BayPopulation(
JigsawStudioChunkWriter.BayPopulation population = new JigsawStudioChunkWriter.BayPopulation(
Set.of(11L, 12L), "");
assertFalse(population.readiness().ready());
@@ -1275,7 +1275,7 @@ public class JigsawStudioServiceCaptureTest {
population.markApplied(12L);
population.markHydrated(12L);
JigsawStudioService.BayReadiness readiness = population.readiness();
JigsawStudioSaveLifecycle.BayReadiness readiness = population.readiness();
assertTrue(readiness.ready());
assertEquals(2, readiness.requiredChunks());
assertEquals(2, readiness.generatedChunks());
@@ -1293,9 +1293,9 @@ public class JigsawStudioServiceCaptureTest {
new JigsawStudioVariantCatalog(List.of(planarVariant(true, true))));
JigsawStudioBay blank = layout.get("workcell/blank");
long chunkKey = ((long) 1 << 32) ^ 1L;
JigsawStudioService.BayPopulation population =
new JigsawStudioService.BayPopulation(Set.of(chunkKey), "");
ConcurrentHashMap<String, JigsawStudioService.BayPopulation> populations = new ConcurrentHashMap<>();
JigsawStudioChunkWriter.BayPopulation population =
new JigsawStudioChunkWriter.BayPopulation(Set.of(chunkKey), "");
ConcurrentHashMap<String, JigsawStudioChunkWriter.BayPopulation> populations = new ConcurrentHashMap<>();
populations.put(blank.stableId(), population);
World world = mock(World.class);
@@ -1339,7 +1339,7 @@ public class JigsawStudioServiceCaptureTest {
when(event.getWorld()).thenReturn(world);
when(event.getChunk()).thenReturn(chunk);
service.onChunkLoad(event);
service.protectionListener.onChunkLoad(event);
assertTrue(population.needsApplication(chunkKey));
assertEquals(1, population.readiness().generatedChunks());
@@ -1348,7 +1348,7 @@ public class JigsawStudioServiceCaptureTest {
@Test
public void hydrationFailurePermanentlyBlocksSaveReadiness() {
JigsawStudioService.BayPopulation population = new JigsawStudioService.BayPopulation(
JigsawStudioChunkWriter.BayPopulation population = new JigsawStudioChunkWriter.BayPopulation(
Set.of(27L), "");
population.markGenerated(27L);
population.markApplied(27L);
@@ -1369,20 +1369,20 @@ public class JigsawStudioServiceCaptureTest {
9,
new JigsawStudioCellDimensions(25, 3, 20));
List<JigsawStudioService.ChunkCaptureArea> areas = JigsawStudioService.chunkIntersections(bounds);
List<JigsawStudioCapture.ChunkCaptureArea> areas = JigsawStudioCapture.chunkIntersections(bounds);
assertEquals(List.of(
new JigsawStudioService.ChunkCaptureArea(-1, 0, 0, 5, 0, 7),
new JigsawStudioService.ChunkCaptureArea(-1, 1, 0, 5, 7, 20),
new JigsawStudioService.ChunkCaptureArea(0, 0, 5, 21, 0, 7),
new JigsawStudioService.ChunkCaptureArea(0, 1, 5, 21, 7, 20),
new JigsawStudioService.ChunkCaptureArea(1, 0, 21, 25, 0, 7),
new JigsawStudioService.ChunkCaptureArea(1, 1, 21, 25, 7, 20)
new JigsawStudioCapture.ChunkCaptureArea(-1, 0, 0, 5, 0, 7),
new JigsawStudioCapture.ChunkCaptureArea(-1, 1, 0, 5, 7, 20),
new JigsawStudioCapture.ChunkCaptureArea(0, 0, 5, 21, 0, 7),
new JigsawStudioCapture.ChunkCaptureArea(0, 1, 5, 21, 7, 20),
new JigsawStudioCapture.ChunkCaptureArea(1, 0, 21, 25, 0, 7),
new JigsawStudioCapture.ChunkCaptureArea(1, 1, 21, 25, 7, 20)
), areas);
for (int x = 0; x < bounds.dimensions().width(); x++) {
for (int z = 0; z < bounds.dimensions().depth(); z++) {
int matches = 0;
for (JigsawStudioService.ChunkCaptureArea area : areas) {
for (JigsawStudioCapture.ChunkCaptureArea area : areas) {
if (area.contains(x, z)) {
matches++;
}
@@ -1399,28 +1399,28 @@ public class JigsawStudioServiceCaptureTest {
64,
0,
new JigsawStudioCellDimensions(20, 2, 1));
List<JigsawStudioService.ChunkCaptureArea> areas = JigsawStudioService.chunkIntersections(bounds);
List<JigsawStudioCapture.ChunkCaptureArea> areas = JigsawStudioCapture.chunkIntersections(bounds);
PlatformBlockState stone = mock(PlatformBlockState.class);
when(stone.key()).thenReturn("minecraft:stone");
JigsawStudioService.ChunkSnapshot first = new JigsawStudioService.ChunkSnapshot(
JigsawStudioCapture.ChunkSnapshot first = new JigsawStudioCapture.ChunkSnapshot(
areas.getFirst(),
List.of(new JigsawStudioService.CapturedBlock(0, 0, 0, stone, null)),
List.of(new JigsawStudioCapture.CapturedBlock(0, 0, 0, stone, null)),
List.of());
JigsawStudioService.ChunkSnapshot second = new JigsawStudioService.ChunkSnapshot(
JigsawStudioCapture.ChunkSnapshot second = new JigsawStudioCapture.ChunkSnapshot(
areas.getLast(),
List.of(new JigsawStudioService.CapturedBlock(8, 1, 0, stone, null)),
List.of(new JigsawStudioCapture.CapturedBlock(8, 1, 0, stone, null)),
List.of());
JigsawStudioService.Capture forward = JigsawStudioService.aggregateSnapshots(
JigsawStudioCapture.Capture forward = JigsawStudioCapture.aggregateSnapshots(
bounds, areas, List.of(first, second));
JigsawStudioService.Capture reverse = JigsawStudioService.aggregateSnapshots(
JigsawStudioCapture.Capture reverse = JigsawStudioCapture.aggregateSnapshots(
bounds, areas, List.of(second, first));
assertArrayEquals(forward.objectContent(), reverse.objectContent());
assertFalse(forward.hasBlockEntities());
assertTrue(forward.connectors().isEmpty());
assertThrows(IOException.class,
() -> JigsawStudioService.aggregateSnapshots(bounds, areas, List.of(first)));
() -> JigsawStudioCapture.aggregateSnapshots(bounds, areas, List.of(first)));
}
@Test
@@ -1429,14 +1429,14 @@ public class JigsawStudioServiceCaptureTest {
UUID firstRequest = UUID.fromString("44444444-4444-4444-4444-444444444444");
UUID secondRequest = UUID.fromString("55555555-5555-5555-5555-555555555555");
assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(firstRequest));
assertEquals(JigsawStudioService.SaveStart.IN_PROGRESS, service.tryBeginSave(firstRequest));
assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(secondRequest));
assertEquals(JigsawStudioSaveLifecycle.SaveStart.STARTED, service.saveLifecycle.tryBeginSave(firstRequest));
assertEquals(JigsawStudioSaveLifecycle.SaveStart.IN_PROGRESS, service.saveLifecycle.tryBeginSave(firstRequest));
assertEquals(JigsawStudioSaveLifecycle.SaveStart.STARTED, service.saveLifecycle.tryBeginSave(secondRequest));
service.finishSave(firstRequest);
assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(firstRequest));
service.finishSave(firstRequest);
service.finishSave(secondRequest);
service.saveLifecycle.finishSave(firstRequest);
assertEquals(JigsawStudioSaveLifecycle.SaveStart.STARTED, service.saveLifecycle.tryBeginSave(firstRequest));
service.saveLifecycle.finishSave(firstRequest);
service.saveLifecycle.finishSave(secondRequest);
}
private static IrisJigsawConnector connector() {
@@ -1496,7 +1496,7 @@ public class JigsawStudioServiceCaptureTest {
return directions;
}
private static JigsawStudioService.CapturedConnector displayedConnector(
private static JigsawStudioCapture.CapturedConnector displayedConnector(
IrisJigsawConnector source,
JigsawStudioCellDimensions displayDimensions,
int displayRotationQuarterTurns,
@@ -1507,7 +1507,7 @@ public class JigsawStudioServiceCaptureTest {
source.getPosition(),
displayDimensions,
displayRotationQuarterTurns);
return new JigsawStudioService.CapturedConnector(
return new JigsawStudioCapture.CapturedConnector(
displayedPosition.getX(),
displayedPosition.getY(),
displayedPosition.getZ(),
@@ -1629,7 +1629,7 @@ public class JigsawStudioServiceCaptureTest {
EventPriority priority,
boolean ignoreCancelled
) throws NoSuchMethodException {
Method method = JigsawStudioService.class.getMethod(methodName, eventType);
Method method = JigsawStudioProtectionListener.class.getMethod(methodName, eventType);
EventHandler annotation = method.getAnnotation(EventHandler.class);
assertNotNull(annotation);
assertEquals(priority, annotation.priority());
@@ -81,10 +81,10 @@ public class JigsawStudioVisualizationBoundsTest {
List<Emission> preview = emissions(fixture.player());
clearInvocations(fixture.player());
Method draw = JigsawStudioService.class.getDeclaredMethod("drawBounds", Player.class, Location.class,
JigsawStudioBounds.class, Color.class, float.class, double.class, nested("ParticleBudget"));
Method draw = JigsawStudioVisualization.class.getDeclaredMethod("drawBounds", Player.class, Location.class,
JigsawStudioBounds.class, Color.class, float.class, double.class, visualizationNested("ParticleBudget"));
draw.setAccessible(true);
Field color = JigsawStudioService.class.getDeclaredField("ASSEMBLY_PREVIEW_COLOR");
Field color = JigsawStudioVisualization.class.getDeclaredField("ASSEMBLY_PREVIEW_COLOR");
color.setAccessible(true);
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)));
}
Method draw = JigsawStudioService.class.getDeclaredMethod("drawAssemblyPreview", Player.class,
Location.class, nested("ParticleBudget"));
Method draw = JigsawStudioVisualization.class.getDeclaredMethod("drawAssemblyPreview", Player.class,
Location.class, visualizationNested("ParticleBudget"));
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 ->
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,
new ConcurrentHashMap<>(), ConcurrentHashMap.newKeySet(), new AtomicLong());
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);
}
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));
}
private static void drawLive(Fixture fixture, Location viewer, int remaining) throws Exception {
Method draw = JigsawStudioService.class.getDeclaredMethod("drawLivePreview", Player.class, Location.class,
nested("ActiveStudio"), nested("ParticleBudget"));
Method draw = JigsawStudioVisualization.class.getDeclaredMethod("drawLivePreview", Player.class, Location.class,
nested("ActiveStudio"), visualizationNested("ParticleBudget"));
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 {
Constructor<?> constructor = nested("ParticleBudget").getDeclaredConstructor(int.class);
Constructor<?> constructor = visualizationNested("ParticleBudget").getDeclaredConstructor(int.class);
constructor.setAccessible(true);
return constructor.newInstance(remaining);
}
@@ -184,10 +184,14 @@ public class JigsawStudioVisualizationBoundsTest {
}
@SuppressWarnings("unchecked")
private static Map<UUID, Object> map(JigsawStudioService service, String name) throws Exception {
Field field = JigsawStudioService.class.getDeclaredField(name);
private static Map<UUID, Object> map(Object target, Class<?> owner, String name) throws Exception {
Field field = owner.getDeclaredField(name);
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 {