Split jigsaw studio menus

- Move each jigsaw menu kind into its own renderer class
- Move workcell capacity staging into JigsawStudioWorkcellMenu
- Move menu presentation helpers and adjustment math into JigsawStudioMenuFormat
- Promote the menu action contract to JigsawStudioMenuActions
- Point the menu controller test at the relocated menu types
This commit is contained in:
Brian Neumann-Fopiano
2026-09-08 06:20:54 -04:00
parent e7c6b6e08e
commit 8b9d1fb2fb
11 changed files with 2668 additions and 2411 deletions
@@ -0,0 +1,325 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolAction;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload;
import art.arcane.volmlib.util.inventorygui.UIElement;
import art.arcane.volmlib.util.inventorygui.UIWindow;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import java.util.List;
import java.util.Objects;
import static art.arcane.iris.core.service.JigsawStudioMenuController.CHANCE_STEP_PERCENTAGE_POINTS;
import static art.arcane.iris.core.service.JigsawStudioMenuController.GRID_POSITIONS;
import static art.arcane.iris.core.service.JigsawStudioMenuController.MEMBERSHIPS_PER_PAGE;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.chance;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.clampPage;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.dimensions;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.displayKey;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.element;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.evaluationElement;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.maximumPlacements;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.page;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.pageCount;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.safe;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.themes;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.yesNo;
final class JigsawStudioDetailsMenu {
private final JigsawStudioMenuController controller;
JigsawStudioDetailsMenu(JigsawStudioMenuController controller) {
this.controller = Objects.requireNonNull(controller, "Jigsaw Studio menu controller");
}
void render(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
int requestedPage
) {
int page = clampPage(requestedPage, variant.memberships().size(), MEMBERSHIPS_PER_PAGE);
controller.purgeExpiredConfirmations(window.getViewer().getUniqueId());
window.batch(() -> {
window.clearElements();
addDetailsHeader(window, state, workcell, variant, page);
addMemberships(window, state, workcell, variant, page);
addDetailsFooter(window, state, workcell, variant, page);
});
}
private void addDetailsHeader(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
int page
) {
UIElement back = element("back", Material.ARROW, ChatColor.YELLOW + "Back to Variants");
back.onLeftClick(clicked -> controller.refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.setElement(-4, 0, back);
UIElement identity = element("identity", Material.NAME_TAG, ChatColor.AQUA + safe(variant.displayName()));
identity.addLore(ChatColor.DARK_GRAY + safe(variant.pieceKey()));
identity.addLore(variant.owned()
? ChatColor.GREEN + "Owned variant"
: ChatColor.GRAY + "Read-only variant");
identity.addLore(variant.active()
? ChatColor.GREEN + "Loaded in this workcell"
: ChatColor.GRAY + "Not currently loaded");
identity.addLore(ChatColor.GRAY + "Size: " + variant.dimensions()
.map(JigsawStudioMenuFormat::dimensions)
.orElse("Object missing"));
identity.addLore(ChatColor.GRAY + "Themes: " + themes(variant.themes()));
identity.addLore(ChatColor.GRAY + "Depth: " + variant.rules().minimumDepth()
+ "-" + variant.rules().maximumDepth());
identity.addLore(ChatColor.GRAY + "Placements: " + variant.rules().minimumPlacements()
+ "-" + maximumPlacements(variant.rules().maximumPlacements()));
identity.addLore(ChatColor.GRAY + "Terminal: " + yesNo(variant.rules().terminal()));
identity.addLore(ChatColor.GRAY + "Pool entries: " + variant.memberships().size());
if (variant.owned()) {
identity.addLore(ChatColor.YELLOW + "Left-click for a rename stick");
identity.addLore(ChatColor.GRAY + "Rename that stick in an anvil, then right-click it.");
identity.addLore(ChatColor.GRAY + "Sneak-right-click the stick to reset this label.");
identity.onLeftClick(clicked -> controller.actions.giveTool(
window.getViewer(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.RENAME_VARIANT,
state.requestId(),
workcell.stableId(),
variant.pieceKey())));
}
window.setElement(0, 0, identity);
boolean activeOwned = variant.active() && variant.owned();
boolean irisRuleEditing = activeOwned && state.irisExtended();
UIElement settings = element(
"variant-settings",
irisRuleEditing ? Material.COMPARATOR : Material.GRAY_DYE,
irisRuleEditing
? ChatColor.GOLD + "Themes & Piece Rules"
: ChatColor.GRAY + "Themes & Rules Unavailable");
settings.addLore(irisRuleEditing
? ChatColor.GRAY + "Edit this loaded variant's theme membership and placement rules."
: state.irisExtended()
? ChatColor.GRAY + "Load an owned variant before editing its rules."
: ChatColor.GRAY + "Vanilla-portable pieces cannot encode Iris theme or rule metadata.");
if (irisRuleEditing) {
settings.addLore(ChatColor.YELLOW + "Left-click to edit");
settings.onLeftClick(clicked -> controller.openVariantSettings(
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), 0));
}
window.setElement(-2, 0, settings);
boolean rotationEditable = variant.active() && variant.rotationEditable();
UIElement rotation = element(
"rotation",
rotationEditable ? Material.REPEATER : Material.BARRIER,
rotationEditable
? ChatColor.LIGHT_PURPLE + "Toggle Rotation"
: ChatColor.GRAY + "Rotation is Read-only");
rotation.addLore(ChatColor.GRAY + "Currently: " + (variant.rotatable() ? "Enabled" : "Disabled"));
if (rotationEditable) {
rotation.addLore(ChatColor.YELLOW + "Left-click to toggle");
rotation.onLeftClick(clicked -> controller.toggleRotation(
window.getViewer(), state.requestId(), workcell.stableId()));
} else if (!variant.active()) {
rotation.addLore(ChatColor.GRAY + "Load this variant before editing rotation.");
} else if (variant.owned() && variant.rotatable()) {
rotation.addLore(ChatColor.GRAY + "Vanilla-portable variants must remain rotatable.");
}
window.setElement(3, 0, rotation);
boolean sizeEditable = variant.owned() && variant.dimensions().isPresent();
UIElement size = element(
"variant-size",
sizeEditable ? Material.SCAFFOLDING : Material.BARRIER,
sizeEditable
? ChatColor.GREEN + "Edit This Variant's Size"
: ChatColor.GRAY + "Variant Size is Read-only");
size.addLore(ChatColor.GRAY + "Current: " + variant.dimensions()
.map(JigsawStudioMenuFormat::dimensions)
.orElse("Object missing"));
size.addLore(ChatColor.GRAY + "Workcell capacity: " + dimensions(workcell.capacity()));
if (sizeEditable) {
size.addLore(ChatColor.YELLOW + "Left-click for width, height, and depth controls");
size.onLeftClick(clicked -> controller.openVariantSizeSettings(
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey()));
if (variant.resizableToCapacity()) {
size.addLore(ChatColor.YELLOW + "Shift-left: resize this variant to capacity");
size.onShiftLeftClick(clicked -> controller.resizeVariant(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
workcell.capacity()));
}
}
window.setElement(2, 0, size);
boolean lastVariant = workcell.variants().size() <= 1;
boolean deletionAvailable = variant.owned() && !variant.active() && !lastVariant;
boolean confirmed = deletionAvailable && controller.isDeleteConfirmed(
window.getViewer().getUniqueId(),
state.requestId(),
workcell.stableId(),
variant.pieceKey());
UIElement delete = element(
"delete-variant",
deletionAvailable ? confirmed ? Material.REDSTONE : Material.LAVA_BUCKET : Material.BARRIER,
deletionAvailable
? confirmed ? ChatColor.RED + "Confirm Delete Variant" : ChatColor.RED + "Delete Variant"
: variant.active()
? ChatColor.GRAY + "Loaded Variant Cannot Be Deleted"
: lastVariant
? ChatColor.GRAY + "Last Variant Cannot Be Deleted"
: ChatColor.GRAY + "Deletion is Read-only")
.setEnchanted(confirmed);
if (deletionAvailable) {
delete.addLore(confirmed
? ChatColor.RED + "Click again to permanently delete this owned variant."
: ChatColor.RED + "Click twice within 10 seconds to confirm.");
delete.onLeftClick(clicked -> controller.confirmOrDeleteVariant(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
page));
} else if (variant.active()) {
delete.addLore(ChatColor.GRAY + "Load another variant in this workcell first.");
} else if (lastVariant) {
delete.addLore(ChatColor.GRAY + "Create another variant before deleting this one.");
}
window.setElement(4, 0, delete);
}
private void addMemberships(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
int page
) {
List<JigsawStudioMenuState.Membership> memberships = page(
variant.memberships(), page, MEMBERSHIPS_PER_PAGE);
if (memberships.isEmpty()) {
UIElement empty = element("no-memberships", Material.BARRIER, ChatColor.RED + "No Pool Entries");
empty.addLore(ChatColor.GRAY + "This variant is not linked from an owned pool.");
window.setElement(0, 2, empty);
return;
}
for (int index = 0; index < memberships.size(); index++) {
JigsawStudioMenuState.Membership membership = memberships.get(index);
int position = GRID_POSITIONS[index % GRID_POSITIONS.length];
int row = 1 + index / GRID_POSITIONS.length;
boolean confirmed = variant.active() && variant.owned() && controller.isUnlinkConfirmed(
window.getViewer().getUniqueId(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
membership);
UIElement element = element(
"membership-" + index,
confirmed ? Material.REDSTONE : Material.GOLD_NUGGET,
(confirmed ? ChatColor.RED : ChatColor.GOLD)
+ safe(displayKey(membership.poolKey()))
+ " [" + membership.entryIndex() + "]")
.setEnchanted(confirmed);
element.addLore(ChatColor.DARK_GRAY + safe(membership.poolKey()));
element.addLore(ChatColor.GRAY + "Exact entry: " + membership.entryIndex());
element.addLore(ChatColor.WHITE + "Weight: " + membership.weight());
element.addLore((state.irisExtended() ? ChatColor.WHITE : ChatColor.GRAY)
+ "Chance: " + chance(membership.chance())
+ (state.irisExtended() ? "" : " (Iris only)"));
if (variant.active() && variant.owned()) {
element.addLore(ChatColor.GREEN + "Left-click: weight +1");
element.addLore(ChatColor.YELLOW + "Right-click: weight -1");
if (state.irisExtended()) {
element.addLore(ChatColor.GREEN + "Shift-left: chance +"
+ CHANCE_STEP_PERCENTAGE_POINTS + "%");
element.addLore(ChatColor.YELLOW + "Shift-right: chance -"
+ CHANCE_STEP_PERCENTAGE_POINTS + "%");
} else {
element.addLore(ChatColor.GRAY + "Vanilla-portable pool entries always have 100% chance.");
}
element.addLore(confirmed
? ChatColor.RED + "Middle-click again to unlink"
: ChatColor.RED + "Middle-click twice to unlink");
element.onLeftClick(clicked -> controller.adjustMembershipWeight(
window.getViewer(), state.requestId(), workcell.stableId(), membership, 1));
element.onRightClick(clicked -> controller.adjustMembershipWeight(
window.getViewer(), state.requestId(), workcell.stableId(), membership, -1));
if (state.irisExtended()) {
element.onShiftLeftClick(clicked -> controller.adjustMembershipChance(
window.getViewer(),
state.requestId(),
workcell.stableId(),
membership,
CHANCE_STEP_PERCENTAGE_POINTS));
element.onShiftRightClick(clicked -> controller.adjustMembershipChance(
window.getViewer(),
state.requestId(),
workcell.stableId(),
membership,
-CHANCE_STEP_PERCENTAGE_POINTS));
}
element.onMiddleClick(clicked -> controller.confirmOrUnlink(
window.getViewer(), state.requestId(), workcell.stableId(), membership, page));
} else {
element.addLore(variant.active()
? ChatColor.GRAY + "Read-only pool entry"
: ChatColor.GRAY + "Load this variant to edit its pool entry");
}
window.setElement(position, row, element);
}
}
private void addDetailsFooter(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
int page
) {
UIElement back = element("footer-back", Material.ARROW, ChatColor.YELLOW + "Back");
back.onLeftClick(clicked -> controller.refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.setElement(-4, 5, back);
int pageCount = pageCount(variant.memberships().size(), MEMBERSHIPS_PER_PAGE);
if (page > 0) {
UIElement previous = element("membership-previous", Material.ARROW, ChatColor.YELLOW + "Previous Page");
previous.onLeftClick(clicked -> controller.refreshDetails(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
page - 1));
window.setElement(-1, 5, previous);
}
UIElement indicator = element("membership-page", Material.BOOK, ChatColor.WHITE + "Page "
+ (page + 1) + "/" + pageCount);
indicator.addLore(ChatColor.GRAY + "" + variant.memberships().size() + " exact pool entries");
window.setElement(0, 5, indicator);
if (page + 1 < pageCount) {
UIElement next = element("membership-next", Material.ARROW, ChatColor.YELLOW + "Next Page");
next.onLeftClick(clicked -> controller.refreshDetails(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
page + 1));
window.setElement(1, 5, next);
}
window.setElement(2, 5, evaluationElement(state.evaluation()));
UIElement toolbox = element("details-toolbox", Material.STICK, ChatColor.AQUA + "Toolbox");
toolbox.onLeftClick(clicked -> controller.openToolbox(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.setElement(4, 5, toolbox);
}
}
@@ -0,0 +1,286 @@
package art.arcane.iris.core.service;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.inventorygui.UIElement;
import art.arcane.volmlib.util.inventorygui.UIWindow;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import java.util.List;
import java.util.Objects;
import static art.arcane.iris.core.service.JigsawStudioMenuController.GRID_POSITIONS;
import static art.arcane.iris.core.service.JigsawStudioMenuController.VARIANTS_PER_PAGE;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.clampPage;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.compatibilityName;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.dimensions;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.element;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.evaluationElement;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.nextThemeSetKey;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.page;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.pageCount;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.safe;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.themes;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.variantMaterial;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.workcellMaterial;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.workcellStatus;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.yesNo;
final class JigsawStudioMainMenu {
private final JigsawStudioMenuController controller;
JigsawStudioMainMenu(JigsawStudioMenuController controller) {
this.controller = Objects.requireNonNull(controller, "Jigsaw Studio menu controller");
}
void render(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell selected,
int requestedPage
) {
int page = clampPage(requestedPage, selected.variants().size(), VARIANTS_PER_PAGE);
window.batch(() -> {
window.clearElements();
addWorkcells(window, state, selected.stableId());
addFamilyControl(window, state);
addStructureControl(window, state, selected.stableId());
addVariants(window, state, selected, page);
addMainActions(window, state, selected, page);
});
}
private void addFamilyControl(UIWindow window, JigsawStudioMenuState state) {
String nextThemeKey = nextThemeSetKey(state.themeSets());
boolean available = state.irisExtended() && state.workcells().stream()
.filter(JigsawStudioMenuState.Workcell::enabled)
.allMatch(workcell -> workcell.activeVariant() != null && workcell.activeVariant().owned());
UIElement family = element(
"duplicate-family",
available ? Material.PURPLE_DYE : Material.GRAY_DYE,
available
? ChatColor.LIGHT_PURPLE + "Duplicate All Enabled Cells as Family"
: ChatColor.GRAY + "Duplicate All Enabled Cells as Family");
family.addLore(ChatColor.GRAY + "Copies each enabled cell's loaded variant into one coherent family.");
family.addLore(ChatColor.GRAY + "New family: " + safe(nextThemeKey));
if (available) {
family.addLore(ChatColor.YELLOW + "Left-click to duplicate the complete family atomically");
family.onLeftClick(clicked -> controller.duplicateActiveFamily(
window.getViewer(), state.requestId(), nextThemeKey));
} else if (!state.irisExtended()) {
family.addLore(ChatColor.GRAY + "Coherent families require Iris compatibility.");
} else {
family.addLore(ChatColor.GRAY + "Load an owned variant in every enabled workcell first.");
}
window.setElement(3, 0, family);
}
private void addStructureControl(
UIWindow window,
JigsawStudioMenuState state,
String selectedWorkcellId
) {
UIElement structure = element(
"structure-rules",
state.irisExtended()
? state.requireCaps() ? Material.IRON_BARS : Material.TRIPWIRE_HOOK
: Material.BARRIER,
ChatColor.LIGHT_PURPLE + "Structure Rules");
structure.addLore(ChatColor.GRAY + "Mandatory caps: " + yesNo(state.requireCaps()));
structure.addLore(ChatColor.GRAY + "Theme sets: " + state.themeSets().size());
structure.addLore(ChatColor.GRAY + "Compatibility: " + compatibilityName(state.compatibilityTarget()));
structure.addLore(ChatColor.YELLOW + "Left-click for themes and rules");
structure.addLore(state.irisExtended()
? ChatColor.YELLOW + "Right-click to toggle mandatory caps"
: ChatColor.GRAY + "Mandatory caps and themes require Iris compatibility");
structure.onLeftClick(clicked -> controller.openStructureSettings(
window.getViewer(), state.requestId(), selectedWorkcellId, 0));
if (state.irisExtended()) {
structure.onRightClick(clicked -> controller.setRequireCaps(
window.getViewer(), state.requestId(), !state.requireCaps()));
}
window.setElement(4, 0, structure);
}
private void addWorkcells(UIWindow window, JigsawStudioMenuState state, String selectedWorkcellId) {
int count = state.workcells().size();
int start = count == 1 ? 0 : -(count / 2);
for (int index = 0; index < count; index++) {
JigsawStudioMenuState.Workcell workcell = state.workcells().get(index);
boolean selected = workcell.stableId().equals(selectedWorkcellId);
UIElement element = element(
"workcell-" + index,
workcellMaterial(workcell, selected),
(selected ? ChatColor.AQUA : workcell.enabled() ? ChatColor.WHITE : ChatColor.GRAY)
+ safe(workcell.displayName()))
.setEnchanted(selected);
element.addLore(workcell.enabled()
? ChatColor.GREEN + "Enabled"
: ChatColor.RED + "Disabled for assembly and export");
if (!workcell.canonicalName().equals(workcell.displayName())) {
element.addLore(ChatColor.GRAY + "Solver role: " + safe(workcell.canonicalName()));
}
element.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity()));
element.addLore(ChatColor.GRAY + "Variants: " + workcell.variants().size());
JigsawStudioMenuState.Variant active = workcell.activeVariant();
element.addLore(ChatColor.GRAY + "Loaded: "
+ (active == null ? "None" : safe(active.displayName())));
element.addLore(workcellStatus(workcell));
element.addLore(ChatColor.GRAY + "Connector blocks: "
+ (workcell.connectorsVisible() ? "Visible" : "Hidden"));
element.addLore(ChatColor.YELLOW + "Left-click to select and teleport");
element.addLore(ChatColor.YELLOW + "Right-click for workcell settings");
if (workcell.dirty() && !workcell.saving()) {
element.addLore(ChatColor.GOLD + "Shift-left: Flush Autosave Now");
element.onShiftLeftClick(clicked -> controller.flushNow(
window.getViewer(), state.requestId(), workcell.stableId()));
}
element.onLeftClick(clicked -> controller.selectWorkcell(
window.getViewer(),
state.requestId(),
workcell.stableId()));
element.onRightClick(clicked -> J.runEntity(
window.getViewer(),
() -> controller.openWorkcellSettings(
window.getViewer(),
state.requestId(),
workcell.stableId()),
1));
window.setElement(start + index, 0, element);
}
}
private void addVariants(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
int page
) {
List<JigsawStudioMenuState.Variant> variants = page(
workcell.variants(), page, VARIANTS_PER_PAGE);
if (variants.isEmpty()) {
UIElement empty = element("no-variants", Material.BARRIER, ChatColor.RED + "No variants");
empty.addLore(ChatColor.GRAY + "Create a variant to begin authoring this workcell.");
window.setElement(0, 2, empty);
return;
}
for (int index = 0; index < variants.size(); index++) {
JigsawStudioMenuState.Variant variant = variants.get(index);
int position = GRID_POSITIONS[index % GRID_POSITIONS.length];
int row = 1 + index / GRID_POSITIONS.length;
UIElement element = element(
"variant-" + index,
variantMaterial(variant),
(variant.active() ? ChatColor.GREEN : ChatColor.WHITE)
+ safe(variant.displayName())
+ (variant.active() ? ChatColor.AQUA + " [Loaded]" : ""))
.setEnchanted(variant.active());
element.addLore(ChatColor.DARK_GRAY + safe(variant.pieceKey()));
element.addLore(variant.owned()
? ChatColor.GREEN + "Owned"
: ChatColor.GRAY + "Read-only");
element.addLore(ChatColor.GRAY + "Rotation: " + (variant.rotatable() ? "Enabled" : "Disabled"));
element.addLore(ChatColor.GRAY + "Size: " + variant.dimensions()
.map(JigsawStudioMenuFormat::dimensions)
.orElse("Object missing"));
element.addLore(ChatColor.GRAY + "Themes: " + themes(variant.themes()));
element.addLore(ChatColor.GRAY + "Pool entries: " + variant.memberships().size());
element.addLore(variant.active()
? ChatColor.YELLOW + "Loaded; right-click for details"
: ChatColor.YELLOW + "Left-click to load");
if (!variant.active()) {
element.addLore(ChatColor.YELLOW + "Right-click for details or deletion");
}
element.onLeftClick(clicked -> controller.switchVariant(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey()));
element.onRightClick(clicked -> J.runEntity(
window.getViewer(),
() -> controller.openDetails(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
0),
1));
window.setElement(position, row, element);
}
}
private void addMainActions(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
int page
) {
UIElement create = element("create", Material.NETHER_STAR, ChatColor.GREEN + "New Blank Variant");
create.addLore(ChatColor.GRAY + "Create an empty owned variant with this cell's current size.");
create.onLeftClick(clicked -> controller.createVariant(
window.getViewer(), state.requestId(), workcell.stableId(), false));
window.setElement(-4, 5, create);
JigsawStudioMenuState.Variant active = workcell.activeVariant();
boolean duplicateAvailable = active != null && active.owned();
UIElement duplicate = element(
"duplicate",
duplicateAvailable ? Material.PAPER : Material.GRAY_DYE,
duplicateAvailable
? ChatColor.AQUA + "Duplicate This Cell's Variant"
: active == null
? ChatColor.GRAY + "Duplicate This Cell's Variant"
: ChatColor.GRAY + "Duplicate This Cell's Variant");
duplicate.addLore(duplicateAvailable
? ChatColor.GRAY + "Clone the loaded variant into an owned variant."
: active == null
? ChatColor.GRAY + "Load a variant before duplicating it."
: ChatColor.GRAY + "Adopt the read-only graph before duplicating it.");
if (duplicateAvailable) {
duplicate.onLeftClick(clicked -> controller.createVariant(
window.getViewer(), state.requestId(), workcell.stableId(), true));
}
window.setElement(-3, 5, duplicate);
UIElement settings = element("settings", Material.CRAFTING_TABLE, ChatColor.GOLD + "Workcell Settings");
settings.addLore(ChatColor.GRAY + "Enabled: " + yesNo(workcell.enabled()));
settings.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity()));
settings.onLeftClick(clicked -> controller.openWorkcellSettings(
window.getViewer(), state.requestId(), workcell.stableId()));
window.setElement(-2, 5, settings);
int pageCount = pageCount(workcell.variants().size(), VARIANTS_PER_PAGE);
if (page > 0) {
UIElement previous = element("previous", Material.ARROW, ChatColor.YELLOW + "Previous Page");
previous.onLeftClick(clicked -> controller.refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), page - 1));
window.setElement(-1, 5, previous);
}
UIElement pageIndicator = element("page", Material.BOOK, ChatColor.WHITE + "Page "
+ (page + 1) + "/" + pageCount);
pageIndicator.addLore(ChatColor.GRAY + "" + workcell.variants().size() + " variants");
window.setElement(0, 5, pageIndicator);
if (page + 1 < pageCount) {
UIElement next = element("next", Material.ARROW, ChatColor.YELLOW + "Next Page");
next.onLeftClick(clicked -> controller.refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), page + 1));
window.setElement(1, 5, next);
}
window.setElement(2, 5, evaluationElement(state.evaluation()));
UIElement preview = element("preview", Material.ENDER_EYE, ChatColor.LIGHT_PURPLE + "Go to Preview");
preview.addLore(ChatColor.GRAY + "Open the deterministic assembly preview.");
preview.onLeftClick(clicked -> controller.goToPreview(window.getViewer(), state.requestId()));
window.setElement(3, 5, preview);
UIElement toolbox = element("toolbox", Material.STICK, ChatColor.AQUA + "Toolbox");
toolbox.addLore(ChatColor.GRAY + "Take named sticks bound to this Studio session.");
toolbox.onLeftClick(clicked -> controller.openToolbox(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.setElement(4, 5, toolbox);
}
}
@@ -0,0 +1,111 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload;
import org.bukkit.entity.Player;
import java.util.List;
import java.util.Optional;
public interface JigsawStudioMenuActions {
Optional<JigsawStudioMenuState> menuState(Player player);
boolean selectWorkcell(Player player, String workcellId);
boolean teleportToWorkcell(Player player, String workcellId);
boolean setConnectorBlocksVisible(Player player, String workcellId, boolean visible);
boolean resetConnectorBlocks(Player player, String workcellId);
boolean undoAutosave(Player player);
boolean switchVariant(Player player, String workcellId, String pieceKey, boolean discardDirty);
boolean createVariant(Player player, String workcellId, boolean duplicateActive);
boolean setWorkcellEnabled(Player player, String workcellId, boolean enabled);
boolean updateWorkcellDimensions(
Player player,
String workcellId,
JigsawStudioCellDimensions dimensions
);
boolean updateWorkcellDisplayName(Player player, String workcellId, String displayName);
boolean setRequireCaps(Player player, boolean requireCaps);
boolean duplicateActiveFamily(Player player, String themeKey);
boolean updateThemeSetWeight(Player player, String themeKey, int weight);
boolean flushAutosave(Player player, String workcellId);
boolean goToPreview(Player player);
boolean toggleVariantRotatable(Player player, String workcellId);
boolean expandVariantToCell(Player player, String workcellId);
boolean resizeVariant(
Player player,
String workcellId,
String pieceKey,
JigsawStudioCellDimensions dimensions
);
boolean updateVariantDisplayName(
Player player,
String workcellId,
String pieceKey,
String displayName
);
boolean updateVariantThemes(
Player player,
String workcellId,
String pieceKey,
List<String> themes
);
boolean updateVariantRules(
Player player,
String workcellId,
String pieceKey,
JigsawStudioPieceRules rules
);
boolean adjustVariantWeight(
Player player,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex,
int delta
);
boolean adjustVariantChance(
Player player,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex,
int deltaPercentagePoints
);
boolean unlinkVariantMembership(
Player player,
String workcellId,
String pieceKey,
String poolKey,
int entryIndex
);
boolean deleteVariant(Player player, String workcellId, String pieceKey);
boolean deleteProject(Player player);
boolean giveTool(Player player, JigsawStudioToolPayload payload);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,477 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules;
import art.arcane.volmlib.util.data.MaterialBlock;
import art.arcane.volmlib.util.inventorygui.UIElement;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import static art.arcane.iris.core.service.JigsawStudioMenuController.PLACEMENT_RULE_SHIFT_STEP;
import static art.arcane.iris.core.service.JigsawStudioMenuController.RULE_SHIFT_STEP;
final class JigsawStudioMenuFormat {
private JigsawStudioMenuFormat() {
}
static boolean hasMembership(
JigsawStudioMenuState state,
String workcellId,
JigsawStudioMenuState.Membership expected
) {
JigsawStudioMenuState.Variant active = activeVariant(state, workcellId);
if (active == null) {
return false;
}
for (JigsawStudioMenuState.Membership membership : active.memberships()) {
if (membership.poolKey().equals(expected.poolKey())
&& membership.entryIndex() == expected.entryIndex()) {
return true;
}
}
return false;
}
static JigsawStudioMenuState.Variant activeVariant(
JigsawStudioMenuState state,
String workcellId
) {
JigsawStudioMenuState.Workcell workcell = state.workcell(workcellId);
return workcell == null ? null : workcell.activeVariant();
}
static JigsawStudioMenuState.Variant variant(
JigsawStudioMenuState.Workcell workcell,
String pieceKey
) {
if (workcell == null || pieceKey == null) {
return null;
}
for (JigsawStudioMenuState.Variant variant : workcell.variants()) {
if (variant.pieceKey().equals(pieceKey)) {
return variant;
}
}
return null;
}
static String nextThemeSetKey(List<JigsawStudioMenuState.ThemeSet> themeSets) {
List<JigsawStudioMenuState.ThemeSet> values = List.copyOf(Objects.requireNonNull(
themeSets,
"Jigsaw Studio theme sets"));
int candidate = 1;
while (candidate > 0) {
String key = "variant-" + candidate;
boolean present = false;
for (JigsawStudioMenuState.ThemeSet themeSet : values) {
if (Objects.requireNonNull(themeSet, "Jigsaw Studio theme set").key().equals(key)) {
present = true;
break;
}
}
if (!present) {
return key;
}
candidate = Math.incrementExact(candidate);
}
throw new IllegalStateException("Jigsaw Studio cannot allocate another numbered theme set");
}
static String themeSelectionPercent(
List<JigsawStudioMenuState.ThemeSet> themeSets,
JigsawStudioMenuState.ThemeSet target
) {
List<JigsawStudioMenuState.ThemeSet> activeThemeSets = Objects.requireNonNull(
themeSets,
"Jigsaw Studio theme sets");
JigsawStudioMenuState.ThemeSet activeTarget = Objects.requireNonNull(
target,
"Jigsaw Studio target theme set");
int totalWeight = 0;
for (JigsawStudioMenuState.ThemeSet themeSet : activeThemeSets) {
totalWeight = Math.addExact(totalWeight, Objects.requireNonNull(
themeSet,
"Jigsaw Studio theme set").weight());
}
if (totalWeight < 1) {
return "0.0%";
}
return String.format(
Locale.ROOT,
"%.1f%%",
activeTarget.weight() * 100.0D / totalWeight);
}
static Optional<Integer> adjustedPositiveValue(int value, int delta) {
if (value < 1 || delta == 0) {
throw new IllegalArgumentException("Jigsaw Studio positive value adjustment is invalid");
}
try {
int adjusted = Math.addExact(value, delta);
return adjusted < 1 ? Optional.empty() : Optional.of(adjusted);
} catch (ArithmeticException exception) {
return Optional.empty();
}
}
static Optional<JigsawStudioPieceRules> adjustedRules(
JigsawStudioPieceRules rules,
RuleField field,
int delta
) {
JigsawStudioPieceRules current = Objects.requireNonNull(rules, "Jigsaw Studio piece rules");
RuleField target = Objects.requireNonNull(field, "Jigsaw Studio piece rule field");
if (delta == 0) {
throw new IllegalArgumentException("Jigsaw Studio piece rule delta cannot be zero");
}
try {
int minimumDepth = current.minimumDepth();
int maximumDepth = current.maximumDepth();
int minimumPlacements = current.minimumPlacements();
int maximumPlacements = current.maximumPlacements();
switch (target) {
case MINIMUM_DEPTH -> minimumDepth = Math.addExact(minimumDepth, delta);
case MAXIMUM_DEPTH -> maximumDepth = Math.addExact(maximumDepth, delta);
case MINIMUM_PLACEMENTS -> minimumPlacements = Math.addExact(minimumPlacements, delta);
case MAXIMUM_PLACEMENTS -> {
Optional<Integer> adjustedMaximum = adjustedMaximumPlacements(maximumPlacements, delta);
if (adjustedMaximum.isEmpty()) {
return Optional.empty();
}
maximumPlacements = adjustedMaximum.get();
}
}
return Optional.of(new JigsawStudioPieceRules(
minimumDepth,
maximumDepth,
minimumPlacements,
maximumPlacements,
current.terminal()));
} catch (ArithmeticException | IllegalArgumentException exception) {
return Optional.empty();
}
}
static JigsawStudioPieceRules withTerminal(JigsawStudioPieceRules rules, boolean terminal) {
JigsawStudioPieceRules current = Objects.requireNonNull(rules, "Jigsaw Studio piece rules");
return new JigsawStudioPieceRules(
current.minimumDepth(),
current.maximumDepth(),
current.minimumPlacements(),
current.maximumPlacements(),
terminal);
}
private static Optional<Integer> adjustedMaximumPlacements(int value, int delta) {
if (value == 0) {
if (delta > 0) {
return Optional.empty();
}
int adjusted = Math.addExact(513, delta);
return adjusted < 1 ? Optional.empty() : Optional.of(adjusted);
}
int adjusted = Math.addExact(value, delta);
if (adjusted > 512) {
return Optional.of(0);
}
return adjusted < 1 ? Optional.empty() : Optional.of(adjusted);
}
static Optional<JigsawStudioCellDimensions> adjustedDimensions(
JigsawStudioCellDimensions dimensions,
DimensionAxis axis,
int delta
) {
JigsawStudioCellDimensions current = Objects.requireNonNull(
dimensions,
"Jigsaw Studio workcell dimensions");
DimensionAxis target = Objects.requireNonNull(axis, "Jigsaw Studio workcell dimension axis");
if (delta == 0) {
throw new IllegalArgumentException("Jigsaw Studio workcell dimension delta cannot be zero");
}
try {
int width = target == DimensionAxis.WIDTH
? Math.addExact(current.width(), delta)
: current.width();
int height = target == DimensionAxis.HEIGHT
? Math.addExact(current.height(), delta)
: current.height();
int depth = target == DimensionAxis.DEPTH
? Math.addExact(current.depth(), delta)
: current.depth();
return Optional.of(new JigsawStudioCellDimensions(width, height, depth));
} catch (ArithmeticException | IllegalArgumentException exception) {
return Optional.empty();
}
}
static JigsawStudioMenuState.Workcell withCapacity(
JigsawStudioMenuState.Workcell workcell,
JigsawStudioCellDimensions capacity
) {
JigsawStudioMenuState.Workcell source = Objects.requireNonNull(
workcell,
"Jigsaw Studio menu workcell");
return new JigsawStudioMenuState.Workcell(
source.stableId(),
source.canonicalName(),
source.displayName(),
Objects.requireNonNull(capacity, "Jigsaw Studio staged workcell capacity"),
source.enabled(),
source.activeVariantKey(),
source.dirty(),
source.saving(),
source.loading(),
source.connectorsVisible(),
source.variants());
}
static int pageCount(int itemCount, int pageSize) {
if (itemCount < 0 || pageSize < 1) {
throw new IllegalArgumentException("Jigsaw Studio menu page bounds are invalid");
}
return Math.max(1, (itemCount + pageSize - 1) / pageSize);
}
static int clampPage(int requestedPage, int itemCount, int pageSize) {
int maximum = pageCount(itemCount, pageSize) - 1;
return Math.max(0, Math.min(requestedPage, maximum));
}
static <T> List<T> page(List<T> items, int requestedPage, int pageSize) {
List<T> values = List.copyOf(Objects.requireNonNull(items, "Jigsaw Studio menu page items"));
int page = clampPage(requestedPage, values.size(), pageSize);
int start = page * pageSize;
int end = Math.min(values.size(), start + pageSize);
return values.subList(start, end);
}
static UIElement evaluationElement(JigsawStudioMenuState.Evaluation evaluation) {
UIElement element = element(
"evaluation",
evaluationMaterial(evaluation.state()),
evaluationColor(evaluation.state()) + evaluationName(evaluation.state()));
element.addLore(ChatColor.GRAY + "Evaluation updates automatically.");
element.addLore(ChatColor.GRAY + "Seed: " + evaluation.seed());
if (evaluation.generation() > 0L) {
element.addLore(ChatColor.GRAY + "Generation: " + evaluation.generation());
}
if (!evaluation.selectedTheme().isEmpty()) {
element.addLore(ChatColor.GRAY + "Theme: " + safe(evaluation.selectedTheme()));
}
element.addLore(ChatColor.GRAY + "Pieces: " + evaluation.pieceCount());
if (!evaluation.detail().isEmpty()) {
element.addLore(ChatColor.GRAY + safe(evaluation.detail()));
}
return element;
}
static Material evaluationMaterial(JigsawStudioEvaluationState state) {
return switch (state) {
case PENDING -> Material.CLOCK;
case VALID -> Material.EMERALD;
case WARNING -> Material.YELLOW_DYE;
case INVALID -> Material.RED_DYE;
case STALE -> Material.GRAY_DYE;
};
}
private static ChatColor evaluationColor(JigsawStudioEvaluationState state) {
return switch (state) {
case PENDING -> ChatColor.AQUA;
case VALID -> ChatColor.GREEN;
case WARNING -> ChatColor.YELLOW;
case INVALID -> ChatColor.RED;
case STALE -> ChatColor.GRAY;
};
}
private static String evaluationName(JigsawStudioEvaluationState state) {
return switch (state) {
case PENDING -> "Evaluation Pending";
case VALID -> "Ready to Assemble";
case WARNING -> "Ready with Warnings";
case INVALID -> "Assembly Blocked";
case STALE -> "Evaluation Stale";
};
}
static Material workcellMaterial(JigsawStudioMenuState.Workcell workcell, boolean selected) {
if (!workcell.enabled()) {
return Material.GRAY_WOOL;
}
if (workcell.loading() || workcell.saving()) {
return Material.YELLOW_WOOL;
}
if (workcell.dirty()) {
return Material.ORANGE_WOOL;
}
return selected ? Material.LIME_WOOL : Material.LIGHT_GRAY_WOOL;
}
static Material variantMaterial(JigsawStudioMenuState.Variant variant) {
if (variant.active()) {
return Material.JIGSAW;
}
return variant.owned() ? Material.PAPER : Material.GRAY_DYE;
}
static String workcellStatus(JigsawStudioMenuState.Workcell workcell) {
if (workcell.loading()) {
return ChatColor.YELLOW + "Loading variant";
}
if (workcell.saving()) {
return ChatColor.YELLOW + "Autosave in progress";
}
if (workcell.dirty()) {
return ChatColor.GOLD + "Autosave pending";
}
return ChatColor.GREEN + "Autosaved";
}
static String dimensions(JigsawStudioCellDimensions dimensions) {
return dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth();
}
static String themes(List<String> themes) {
return themes.isEmpty() ? "None" : safe(String.join(", ", themes));
}
static String maximumPlacements(int maximum) {
return maximum == 0 ? "Unlimited" : Integer.toString(maximum);
}
static String chance(double chance) {
return String.format(Locale.ROOT, "%.1f%%", chance * 100D);
}
static String yesNo(boolean value) {
return value ? "Yes" : "No";
}
static String compatibilityName(JigsawStudioCompatibilityTarget target) {
return switch (target) {
case IRIS_EXTENDED -> "Iris Extended";
case VANILLA_PORTABLE -> "Vanilla Portable";
};
}
static UIElement element(String id, Material material, String name) {
return new UIElement(id)
.setMaterial(new MaterialBlock(material))
.setName(name);
}
static String title(String structureKey) {
String value = "Iris Jigsaw: " + safe(displayKey(structureKey));
return value.length() > 32 ? value.substring(0, 32) : value;
}
static String displayKey(String resourceKey) {
int separator = resourceKey.lastIndexOf('/');
return separator < 0 ? resourceKey : resourceKey.substring(separator + 1);
}
static String safe(String value) {
return BoardSVC.untrustedBoardValue(value);
}
enum RuleField {
MINIMUM_DEPTH("Minimum Depth", Material.LIGHT_BLUE_DYE, RULE_SHIFT_STEP) {
@Override
int value(JigsawStudioPieceRules rules) {
return rules.minimumDepth();
}
},
MAXIMUM_DEPTH("Maximum Depth", Material.BLUE_DYE, RULE_SHIFT_STEP) {
@Override
int value(JigsawStudioPieceRules rules) {
return rules.maximumDepth();
}
},
MINIMUM_PLACEMENTS("Minimum Placements", Material.TARGET, PLACEMENT_RULE_SHIFT_STEP) {
@Override
int value(JigsawStudioPieceRules rules) {
return rules.minimumPlacements();
}
},
MAXIMUM_PLACEMENTS("Maximum Placements", Material.GREEN_DYE, PLACEMENT_RULE_SHIFT_STEP) {
@Override
int value(JigsawStudioPieceRules rules) {
return rules.maximumPlacements();
}
@Override
String displayValue(int value) {
return maximumPlacements(value);
}
};
private final String displayName;
private final Material material;
private final int shiftStep;
RuleField(String displayName, Material material, int shiftStep) {
this.displayName = displayName;
this.material = material;
this.shiftStep = shiftStep;
}
String displayName() {
return displayName;
}
Material material() {
return material;
}
int shiftStep() {
return shiftStep;
}
String displayValue(int value) {
return Integer.toString(value);
}
abstract int value(JigsawStudioPieceRules rules);
}
enum DimensionAxis {
WIDTH("Width") {
@Override
int value(JigsawStudioCellDimensions dimensions) {
return dimensions.width();
}
},
HEIGHT("Height") {
@Override
int value(JigsawStudioCellDimensions dimensions) {
return dimensions.height();
}
},
DEPTH("Depth") {
@Override
int value(JigsawStudioCellDimensions dimensions) {
return dimensions.depth();
}
};
private final String displayName;
DimensionAxis(String displayName) {
this.displayName = displayName;
}
String displayName() {
return displayName;
}
abstract int value(JigsawStudioCellDimensions dimensions);
}
}
@@ -156,7 +156,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
public final class JigsawStudioService implements IrisService, JigsawStudioMenuController.Actions {
public final class JigsawStudioService implements IrisService, JigsawStudioMenuActions {
private static final int AUTOSAVE_DEBOUNCE_TICKS = 40;
private static final int AUTOSAVE_RETRY_TICKS = 5;
private static final int REPLACEMENT_CLOSE_WAIT_TICKS = 2_400;
@@ -3185,14 +3185,14 @@ public final class JigsawStudioService implements IrisService, JigsawStudioMenuC
}
return duplicateActiveFamily(
player,
JigsawStudioMenuController.nextThemeSetKey(state.get().themeSets()));
JigsawStudioMenuFormat.nextThemeSetKey(state.get().themeSets()));
}
private boolean duplicateFamilyFromTool(Player player) {
Optional<JigsawStudioMenuState> state = menuState(player);
return state.isPresent() && duplicateActiveFamily(
player,
JigsawStudioMenuController.nextThemeSetKey(state.get().themeSets()));
JigsawStudioMenuFormat.nextThemeSetKey(state.get().themeSets()));
}
private boolean toggleRequireCapsFromTool(Player player) {
@@ -0,0 +1,194 @@
package art.arcane.iris.core.service;
import art.arcane.volmlib.util.inventorygui.UIElement;
import art.arcane.volmlib.util.inventorygui.UIWindow;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import java.util.List;
import java.util.Objects;
import static art.arcane.iris.core.service.JigsawStudioMenuController.GRID_POSITIONS;
import static art.arcane.iris.core.service.JigsawStudioMenuController.THEME_SETS_PER_PAGE;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.clampPage;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.compatibilityName;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.element;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.evaluationElement;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.nextThemeSetKey;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.page;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.pageCount;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.safe;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.themeSelectionPercent;
final class JigsawStudioStructureMenu {
private final JigsawStudioMenuController controller;
JigsawStudioStructureMenu(JigsawStudioMenuController controller) {
this.controller = Objects.requireNonNull(controller, "Jigsaw Studio menu controller");
}
void render(
UIWindow window,
JigsawStudioMenuState state,
String selectedWorkcellId,
int requestedPage
) {
int page = clampPage(requestedPage, state.themeSets().size(), THEME_SETS_PER_PAGE);
controller.purgeExpiredConfirmations(window.getViewer().getUniqueId());
window.batch(() -> {
window.clearElements();
UIElement back = element("structure-back", Material.ARROW, ChatColor.YELLOW + "Back to Variants");
back.onLeftClick(clicked -> controller.refreshMain(
window.getViewer(), state.requestId(), selectedWorkcellId, 0));
window.setElement(-4, 0, back);
UIElement identity = element(
"structure-identity",
Material.JIGSAW,
ChatColor.LIGHT_PURPLE + "Structure Themes & Caps");
identity.addLore(ChatColor.DARK_GRAY + safe(state.structureKey()));
identity.addLore(ChatColor.GRAY + "Compatibility: " + compatibilityName(state.compatibilityTarget()));
identity.addLore(ChatColor.GRAY + "Theme sets select one coherent variant family per assembly.");
identity.addLore(ChatColor.GRAY + "Empty piece themes remain available to every family.");
window.setElement(0, 0, identity);
window.setElement(4, 0, evaluationElement(state.evaluation()));
UIElement requireCaps = element(
"require-caps",
state.irisExtended()
? state.requireCaps() ? Material.IRON_BARS : Material.TRIPWIRE_HOOK
: Material.BARRIER,
!state.irisExtended()
? ChatColor.GRAY + "Mandatory Caps Require Iris Compatibility"
: state.requireCaps()
? ChatColor.GREEN + "Mandatory Caps Enabled"
: ChatColor.YELLOW + "Mandatory Caps Disabled");
requireCaps.addLore(!state.irisExtended()
? ChatColor.GRAY + "Vanilla-portable export rejects Iris mandatory-cap metadata."
: state.requireCaps()
? ChatColor.GRAY + "Every open connector must close with a terminal piece."
: ChatColor.GRAY + "Assembly may leave an open connector when no cap can fit.");
if (state.irisExtended()) {
requireCaps.addLore(ChatColor.YELLOW + "Left-click to toggle");
requireCaps.onLeftClick(clicked -> controller.setRequireCaps(
window.getViewer(), state.requestId(), !state.requireCaps()));
}
window.setElement(-3, 1, requireCaps);
String nextThemeKey = nextThemeSetKey(state.themeSets());
boolean themeEditingAvailable = state.irisExtended();
UIElement createTheme = element(
"new-theme-set",
themeEditingAvailable ? Material.NETHER_STAR : Material.BARRIER,
themeEditingAvailable
? ChatColor.GREEN + "Duplicate All Enabled Cells as Family: " + safe(nextThemeKey)
: ChatColor.GRAY + "Theme Sets Require Iris Compatibility");
if (themeEditingAvailable) {
createTheme.addLore(ChatColor.GRAY + "Creates one new owned variant for every enabled workcell.");
createTheme.addLore(ChatColor.GRAY + "All created variants join " + safe(nextThemeKey) + ".");
createTheme.addLore(ChatColor.GRAY + "One family is selected for the complete assembly.");
createTheme.addLore(ChatColor.YELLOW + "Left-click to create the complete set");
createTheme.onLeftClick(clicked -> controller.duplicateActiveFamily(
window.getViewer(), state.requestId(), nextThemeKey));
} else {
createTheme.addLore(ChatColor.GRAY + "Vanilla jigsaw resources cannot encode Iris theme metadata.");
}
window.setElement(0, 1, createTheme);
boolean deleteConfirmed = controller.isProjectDeleteConfirmed(
window.getViewer().getUniqueId(), state.requestId());
UIElement deleteProject = element(
"delete-project",
deleteConfirmed ? Material.REDSTONE : Material.LAVA_BUCKET,
deleteConfirmed
? ChatColor.RED + "Confirm Delete Jigsaw"
: ChatColor.RED + "Delete Jigsaw")
.setEnchanted(deleteConfirmed);
deleteProject.addLore(deleteConfirmed
? ChatColor.RED + "Click again to delete this entire managed project."
: ChatColor.RED + "Click twice within 10 seconds to confirm.");
deleteProject.onLeftClick(clicked -> controller.confirmOrDeleteProject(
window.getViewer(), state.requestId(), selectedWorkcellId, page));
window.setElement(3, 1, deleteProject);
addThemeSets(window, state, page);
UIElement footerBack = element("structure-footer-back", Material.ARROW, ChatColor.YELLOW + "Back");
footerBack.onLeftClick(clicked -> controller.refreshMain(
window.getViewer(), state.requestId(), selectedWorkcellId, 0));
window.setElement(-4, 5, footerBack);
int pageCount = pageCount(state.themeSets().size(), THEME_SETS_PER_PAGE);
if (page > 0) {
UIElement previous = element(
"theme-previous",
Material.ARROW,
ChatColor.YELLOW + "Previous Page");
previous.onLeftClick(clicked -> controller.refreshStructureSettings(
window.getViewer(), state.requestId(), selectedWorkcellId, page - 1));
window.setElement(-1, 5, previous);
}
UIElement indicator = element(
"theme-page",
Material.BOOK,
ChatColor.WHITE + "Page " + (page + 1) + "/" + pageCount);
indicator.addLore(ChatColor.GRAY + "" + state.themeSets().size() + " theme sets");
window.setElement(0, 5, indicator);
if (page + 1 < pageCount) {
UIElement next = element("theme-next", Material.ARROW, ChatColor.YELLOW + "Next Page");
next.onLeftClick(clicked -> controller.refreshStructureSettings(
window.getViewer(), state.requestId(), selectedWorkcellId, page + 1));
window.setElement(1, 5, next);
}
window.setElement(4, 5, evaluationElement(state.evaluation()));
});
}
private void addThemeSets(
UIWindow window,
JigsawStudioMenuState state,
int page
) {
List<JigsawStudioMenuState.ThemeSet> themeSets = page(
state.themeSets(), page, THEME_SETS_PER_PAGE);
if (themeSets.isEmpty()) {
UIElement empty = element(
"no-theme-sets",
Material.GRAY_DYE,
ChatColor.GRAY + "Implicit Unthemed Assembly");
empty.addLore(ChatColor.GRAY + "Create variant-1 to begin coherent theme selection.");
window.setElement(0, 3, empty);
return;
}
for (int index = 0; index < themeSets.size(); index++) {
JigsawStudioMenuState.ThemeSet themeSet = themeSets.get(index);
int position = GRID_POSITIONS[index % GRID_POSITIONS.length];
int row = 2 + index / GRID_POSITIONS.length;
UIElement element = element(
"theme-set-" + index,
state.irisExtended() ? Material.PURPLE_DYE : Material.GRAY_DYE,
(state.irisExtended() ? ChatColor.LIGHT_PURPLE : ChatColor.GRAY) + safe(themeSet.key()));
element.addLore(ChatColor.WHITE + "Selection weight: " + themeSet.weight());
element.addLore(ChatColor.WHITE + "Whole-assembly chance: "
+ themeSelectionPercent(state.themeSets(), themeSet));
if (state.irisExtended()) {
element.addLore(ChatColor.GREEN + "Left-click: weight +1");
element.addLore(ChatColor.YELLOW + "Right-click: weight -1");
element.addLore(ChatColor.GREEN + "Shift-left: weight +8");
element.addLore(ChatColor.YELLOW + "Shift-right: weight -8");
element.onLeftClick(clicked -> controller.adjustThemeSetWeight(
window.getViewer(), state.requestId(), themeSet, 1));
element.onRightClick(clicked -> controller.adjustThemeSetWeight(
window.getViewer(), state.requestId(), themeSet, -1));
element.onShiftLeftClick(clicked -> controller.adjustThemeSetWeight(
window.getViewer(), state.requestId(), themeSet, 8));
element.onShiftRightClick(clicked -> controller.adjustThemeSetWeight(
window.getViewer(), state.requestId(), themeSet, -8));
} else {
element.addLore(ChatColor.GRAY + "Theme metadata is unavailable for vanilla-portable graphs.");
}
window.setElement(position, row, element);
}
}
}
@@ -0,0 +1,370 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolAction;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload;
import art.arcane.volmlib.util.inventorygui.UIElement;
import art.arcane.volmlib.util.inventorygui.UIWindow;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import static art.arcane.iris.core.service.JigsawStudioMenuController.CHANCE_STEP_PERCENTAGE_POINTS;
import static art.arcane.iris.core.service.JigsawStudioMenuController.GRID_POSITIONS;
import static art.arcane.iris.core.service.JigsawStudioMenuController.TOOLS_PER_PAGE;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.clampPage;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.displayKey;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.element;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.evaluationElement;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.nextThemeSetKey;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.page;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.pageCount;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.safe;
final class JigsawStudioToolboxMenu {
private final JigsawStudioMenuController controller;
JigsawStudioToolboxMenu(JigsawStudioMenuController controller) {
this.controller = Objects.requireNonNull(controller, "Jigsaw Studio menu controller");
}
void render(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
int requestedPage
) {
List<ToolboxTool> allTools = toolboxTools(state, workcell);
int page = clampPage(requestedPage, allTools.size(), TOOLS_PER_PAGE);
List<ToolboxTool> tools = page(allTools, page, TOOLS_PER_PAGE);
window.batch(() -> {
window.clearElements();
UIElement back = element("toolbox-back", Material.ARROW, ChatColor.YELLOW + "Back to Variants");
back.onLeftClick(clicked -> controller.refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.setElement(-4, 0, back);
UIElement heading = element("toolbox-heading", Material.CHEST, ChatColor.AQUA + "Bound Tool Sticks");
heading.addLore(ChatColor.GRAY + "Every stick is bound to request " + state.requestId());
heading.addLore(ChatColor.GRAY + "Stale sticks stop working when this Studio closes.");
window.setElement(0, 0, heading);
window.setElement(4, 0, evaluationElement(state.evaluation()));
for (int index = 0; index < tools.size(); index++) {
ToolboxTool tool = tools.get(index);
int position = GRID_POSITIONS[index % GRID_POSITIONS.length];
int row = 1 + index / GRID_POSITIONS.length;
UIElement element = element(
"tool-" + index,
Material.STICK,
ChatColor.AQUA + safe(tool.displayName()));
element.addLore(ChatColor.GRAY + "Action: " + tool.payload().action().displayName());
addToolBindingLore(element, tool.payload());
element.addLore(tool.payload().action().destructive()
? ChatColor.RED + "Bound tools require confirmation when used."
: ChatColor.YELLOW + "Left-click to receive this named stick.");
element.onLeftClick(clicked -> controller.giveTool(
window.getViewer(), state.requestId(), tool.payload()));
window.setElement(position, row, element);
}
UIElement footerBack = element("toolbox-footer-back", Material.ARROW, ChatColor.YELLOW + "Back");
footerBack.onLeftClick(clicked -> controller.refreshMain(
window.getViewer(), state.requestId(), workcell.stableId(), 0));
window.setElement(-4, 5, footerBack);
int pageCount = pageCount(allTools.size(), TOOLS_PER_PAGE);
if (page > 0) {
UIElement previous = element("toolbox-previous", Material.ARROW, ChatColor.YELLOW + "Previous Page");
previous.onLeftClick(clicked -> controller.refreshToolbox(
window.getViewer(), state.requestId(), workcell.stableId(), page - 1));
window.setElement(-1, 5, previous);
}
UIElement indicator = element("toolbox-page", Material.BOOK, ChatColor.WHITE + "Page "
+ (page + 1) + "/" + pageCount);
indicator.addLore(ChatColor.GRAY + "" + allTools.size() + " bound tools");
window.setElement(0, 5, indicator);
if (page + 1 < pageCount) {
UIElement next = element("toolbox-next", Material.ARROW, ChatColor.YELLOW + "Next Page");
next.onLeftClick(clicked -> controller.refreshToolbox(
window.getViewer(), state.requestId(), workcell.stableId(), page + 1));
window.setElement(1, 5, next);
}
});
}
static List<ToolboxTool> toolboxTools(
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell
) {
JigsawStudioMenuState menu = Objects.requireNonNull(state, "Jigsaw Studio toolbox state");
JigsawStudioMenuState.Workcell selected = Objects.requireNonNull(
workcell,
"Jigsaw Studio toolbox workcell");
UUID requestId = menu.requestId();
List<ToolboxTool> tools = new ArrayList<>();
tools.add(new ToolboxTool(
"Open Control Menu",
JigsawStudioToolPayload.request(JigsawStudioToolAction.OPEN_MENU, requestId)));
tools.add(new ToolboxTool(
"Select " + selected.displayName(),
JigsawStudioToolPayload.workcell(
JigsawStudioToolAction.SELECT_WORKCELL,
requestId,
selected.stableId())));
if (menu.mode() == JigsawStudioMode.PLANAR_JIGSAW) {
tools.add(new ToolboxTool(
"Toggle " + selected.displayName(),
JigsawStudioToolPayload.workcell(
JigsawStudioToolAction.TOGGLE_WORKCELL,
requestId,
selected.stableId())));
}
tools.add(new ToolboxTool(
"Resize " + selected.displayName() + " Capacity",
JigsawStudioToolPayload.workcell(
JigsawStudioToolAction.RESIZE_WORKCELL,
requestId,
selected.stableId())));
tools.add(new ToolboxTool(
"Rename " + selected.displayName(),
JigsawStudioToolPayload.workcell(
JigsawStudioToolAction.RENAME_WORKCELL,
requestId,
selected.stableId())));
tools.add(new ToolboxTool(
"New Blank " + selected.displayName() + " Variant",
JigsawStudioToolPayload.workcell(
JigsawStudioToolAction.CREATE_VARIANT,
requestId,
selected.stableId())));
tools.add(new ToolboxTool(
"Go to Preview",
JigsawStudioToolPayload.request(JigsawStudioToolAction.PREVIEW_GRAPH, requestId)));
tools.add(new ToolboxTool(
"Flush " + selected.displayName() + " Autosave",
JigsawStudioToolPayload.workcell(
JigsawStudioToolAction.FLUSH_AUTOSAVE,
requestId,
selected.stableId())));
if (menu.irisExtended()) {
tools.add(new ToolboxTool(
"Duplicate All Enabled Cells as Family: " + nextThemeSetKey(menu.themeSets()),
JigsawStudioToolPayload.request(JigsawStudioToolAction.DUPLICATE_FAMILY, requestId)));
}
JigsawStudioMenuState.Variant active = selected.activeVariant();
if (active != null) {
if (active.owned()) {
tools.add(new ToolboxTool(
"Duplicate This Cell's Variant: " + active.displayName(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.DUPLICATE_VARIANT,
requestId,
selected.stableId(),
active.pieceKey())));
}
if (active.owned()) {
tools.add(new ToolboxTool(
"Rename This Variant: " + active.displayName(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.RENAME_VARIANT,
requestId,
selected.stableId(),
active.pieceKey())));
tools.add(new ToolboxTool(
"Resize This Variant: " + active.displayName(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.RESIZE_VARIANT,
requestId,
selected.stableId(),
active.pieceKey())));
}
if (active.rotationEditable()) {
tools.add(new ToolboxTool(
"Toggle Rotation: " + active.displayName(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.TOGGLE_ROTATION,
requestId,
selected.stableId(),
active.pieceKey())));
}
if (active.owned() && active.resizableToCapacity()) {
tools.add(new ToolboxTool(
"Resize " + active.displayName() + " to Capacity",
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.EXPAND_TO_CELL,
requestId,
selected.stableId(),
active.pieceKey())));
}
if (active.owned() && menu.irisExtended()) {
tools.add(new ToolboxTool(
"Open Theme Memberships: " + active.displayName(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.SET_THEME,
requestId,
selected.stableId(),
active.pieceKey())));
tools.add(new ToolboxTool(
"Open Piece Rules: " + active.displayName(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.SET_PIECE_RULES,
requestId,
selected.stableId(),
active.pieceKey())));
}
if (active.owned()) {
for (JigsawStudioMenuState.Membership membership : active.memberships()) {
addMembershipTools(
tools,
requestId,
selected,
active,
membership,
menu.irisExtended());
}
}
}
for (JigsawStudioMenuState.Variant variant : selected.variants()) {
if (variant.active()) {
continue;
}
tools.add(new ToolboxTool(
"Load " + variant.displayName(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.LOAD_VARIANT,
requestId,
selected.stableId(),
variant.pieceKey())));
if (variant.owned()) {
tools.add(new ToolboxTool(
"Rename " + variant.displayName(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.RENAME_VARIANT,
requestId,
selected.stableId(),
variant.pieceKey())));
tools.add(new ToolboxTool(
"Resize " + variant.displayName(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.RESIZE_VARIANT,
requestId,
selected.stableId(),
variant.pieceKey())));
}
if (variant.owned() && selected.variants().size() > 1) {
tools.add(new ToolboxTool(
"Delete " + variant.displayName(),
JigsawStudioToolPayload.variant(
JigsawStudioToolAction.DELETE_VARIANT,
requestId,
selected.stableId(),
variant.pieceKey())));
}
}
if (menu.irisExtended()) {
tools.add(new ToolboxTool(
menu.requireCaps() ? "Disable Mandatory Caps" : "Enable Mandatory Caps",
JigsawStudioToolPayload.request(JigsawStudioToolAction.TOGGLE_REQUIRE_CAPS, requestId)));
}
tools.add(new ToolboxTool(
"Delete Project",
JigsawStudioToolPayload.request(JigsawStudioToolAction.DELETE_PROJECT, requestId)));
return List.copyOf(tools);
}
private static void addMembershipTools(
List<ToolboxTool> tools,
UUID requestId,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
JigsawStudioMenuState.Membership membership,
boolean chanceEditable
) {
String membershipName = displayKey(membership.poolKey()) + " [" + membership.entryIndex() + "]";
tools.add(new ToolboxTool(
"Weight +1: " + membershipName,
JigsawStudioToolPayload.membership(
JigsawStudioToolAction.ADJUST_VARIANT_WEIGHT,
requestId,
workcell.stableId(),
variant.pieceKey(),
membership.poolKey(),
membership.entryIndex(),
1)));
tools.add(new ToolboxTool(
"Weight -1: " + membershipName,
JigsawStudioToolPayload.membership(
JigsawStudioToolAction.ADJUST_VARIANT_WEIGHT,
requestId,
workcell.stableId(),
variant.pieceKey(),
membership.poolKey(),
membership.entryIndex(),
-1)));
if (chanceEditable) {
tools.add(new ToolboxTool(
"Chance +" + CHANCE_STEP_PERCENTAGE_POINTS + "%: " + membershipName,
JigsawStudioToolPayload.membership(
JigsawStudioToolAction.ADJUST_VARIANT_CHANCE,
requestId,
workcell.stableId(),
variant.pieceKey(),
membership.poolKey(),
membership.entryIndex(),
CHANCE_STEP_PERCENTAGE_POINTS)));
tools.add(new ToolboxTool(
"Chance -" + CHANCE_STEP_PERCENTAGE_POINTS + "%: " + membershipName,
JigsawStudioToolPayload.membership(
JigsawStudioToolAction.ADJUST_VARIANT_CHANCE,
requestId,
workcell.stableId(),
variant.pieceKey(),
membership.poolKey(),
membership.entryIndex(),
-CHANCE_STEP_PERCENTAGE_POINTS)));
}
tools.add(new ToolboxTool(
"Unlink " + membershipName,
JigsawStudioToolPayload.membership(
JigsawStudioToolAction.UNLINK_MEMBERSHIP,
requestId,
workcell.stableId(),
variant.pieceKey(),
membership.poolKey(),
membership.entryIndex(),
0)));
}
private static void addToolBindingLore(UIElement element, JigsawStudioToolPayload payload) {
if (!payload.workcellId().isEmpty()) {
element.addLore(ChatColor.GRAY + "Workcell: " + safe(payload.workcellId()));
}
if (!payload.pieceKey().isEmpty()) {
element.addLore(ChatColor.GRAY + "Variant: " + safe(payload.pieceKey()));
}
if (!payload.poolKey().isEmpty()) {
element.addLore(ChatColor.GRAY + "Pool: " + safe(payload.poolKey())
+ (payload.entryIndex() < 0 ? "" : " [" + payload.entryIndex() + "]"));
}
if (payload.amount() != 0) {
element.addLore(ChatColor.GRAY + "Amount: " + payload.amount());
}
}
record ToolboxTool(String displayName, JigsawStudioToolPayload payload) {
ToolboxTool {
displayName = Objects.requireNonNull(displayName, "Jigsaw Studio toolbox display name").trim();
if (displayName.isEmpty()) {
throw new IllegalArgumentException("Jigsaw Studio toolbox display name cannot be blank");
}
payload = Objects.requireNonNull(payload, "Jigsaw Studio toolbox payload");
}
}
}
@@ -0,0 +1,213 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.service.JigsawStudioMenuFormat.RuleField;
import art.arcane.volmlib.util.inventorygui.UIElement;
import art.arcane.volmlib.util.inventorygui.UIWindow;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import static art.arcane.iris.core.service.JigsawStudioMenuController.GRID_POSITIONS;
import static art.arcane.iris.core.service.JigsawStudioMenuController.THEME_SETS_PER_PAGE;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.clampPage;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.element;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.evaluationElement;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.page;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.pageCount;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.safe;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.themes;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.withTerminal;
final class JigsawStudioVariantMenu {
private final JigsawStudioMenuController controller;
JigsawStudioVariantMenu(JigsawStudioMenuController controller) {
this.controller = Objects.requireNonNull(controller, "Jigsaw Studio menu controller");
}
void render(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
int requestedPage
) {
int page = clampPage(requestedPage, state.themeSets().size(), THEME_SETS_PER_PAGE);
window.batch(() -> {
window.clearElements();
UIElement back = element("variant-settings-back", Material.ARROW, ChatColor.YELLOW + "Back to Details");
back.onLeftClick(clicked -> controller.openDetails(
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), 0));
window.setElement(-4, 0, back);
UIElement identity = element(
"variant-settings-identity",
Material.COMPARATOR,
ChatColor.GOLD + safe(variant.displayName()) + " Rules");
identity.addLore(ChatColor.DARK_GRAY + safe(variant.pieceKey()));
identity.addLore(ChatColor.GRAY + "Theme memberships: " + themes(variant.themes()));
identity.addLore(ChatColor.GRAY + "Use Duplicate All Enabled Cells as Family for coherent sets.");
window.setElement(0, 0, identity);
window.setElement(4, 0, evaluationElement(state.evaluation()));
if (!variant.active() || !variant.owned() || !state.irisExtended()) {
UIElement unavailable = element(
"variant-settings-unavailable",
Material.BARRIER,
state.irisExtended()
? ChatColor.RED + "Load an Owned Variant First"
: ChatColor.GRAY + "Iris Metadata Unavailable");
unavailable.addLore(state.irisExtended()
? ChatColor.GRAY + "Rules and themes can only change for the loaded owned variant."
: ChatColor.GRAY + "Vanilla-portable pieces use vanilla placement behavior.");
window.setElement(0, 2, unavailable);
} else {
window.setElement(-4, 1, ruleElement(
window, state, workcell, variant, RuleField.MINIMUM_DEPTH));
window.setElement(-2, 1, ruleElement(
window, state, workcell, variant, RuleField.MAXIMUM_DEPTH));
window.setElement(0, 1, ruleElement(
window, state, workcell, variant, RuleField.MINIMUM_PLACEMENTS));
window.setElement(2, 1, ruleElement(
window, state, workcell, variant, RuleField.MAXIMUM_PLACEMENTS));
UIElement terminal = element(
"rule-terminal",
variant.rules().terminal() ? Material.REDSTONE_TORCH : Material.LEVER,
variant.rules().terminal()
? ChatColor.GREEN + "Terminal Piece: Yes"
: ChatColor.YELLOW + "Terminal Piece: No");
terminal.addLore(ChatColor.GRAY + "Terminal pieces consume a connector without expanding.");
terminal.addLore(ChatColor.YELLOW + "Left-click to toggle");
terminal.onLeftClick(clicked -> controller.updateVariantRules(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
withTerminal(variant.rules(), !variant.rules().terminal())));
window.setElement(4, 1, terminal);
addVariantThemeMemberships(window, state, workcell, variant, page);
}
UIElement footerBack = element("variant-rules-footer-back", Material.ARROW, ChatColor.YELLOW + "Back");
footerBack.onLeftClick(clicked -> controller.openDetails(
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), 0));
window.setElement(-4, 5, footerBack);
int pageCount = pageCount(state.themeSets().size(), THEME_SETS_PER_PAGE);
if (page > 0) {
UIElement previous = element(
"variant-theme-previous",
Material.ARROW,
ChatColor.YELLOW + "Previous Page");
previous.onLeftClick(clicked -> controller.refreshVariantSettings(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
page - 1));
window.setElement(-1, 5, previous);
}
UIElement indicator = element(
"variant-theme-page",
Material.BOOK,
ChatColor.WHITE + "Theme Page " + (page + 1) + "/" + pageCount);
indicator.addLore(ChatColor.GRAY + "" + state.themeSets().size() + " declared themes");
window.setElement(0, 5, indicator);
if (page + 1 < pageCount) {
UIElement next = element(
"variant-theme-next",
Material.ARROW,
ChatColor.YELLOW + "Next Page");
next.onLeftClick(clicked -> controller.refreshVariantSettings(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
page + 1));
window.setElement(1, 5, next);
}
window.setElement(4, 5, evaluationElement(state.evaluation()));
});
}
private UIElement ruleElement(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
RuleField field
) {
int value = field.value(variant.rules());
UIElement element = element(
"rule-" + field.name().toLowerCase(Locale.ROOT),
field.material(),
ChatColor.AQUA + field.displayName() + ": " + field.displayValue(value));
boolean unlimitedMaximum = field == RuleField.MAXIMUM_PLACEMENTS && value == 0;
if (unlimitedMaximum) {
element.addLore(ChatColor.GRAY + "Increase is already unlimited.");
} else {
element.addLore(ChatColor.GREEN + "Left-click: +1");
element.addLore(ChatColor.GREEN + "Shift-left: +" + field.shiftStep());
element.onLeftClick(clicked -> controller.adjustVariantRule(
window.getViewer(), state.requestId(), workcell, variant, field, 1));
element.onShiftLeftClick(clicked -> controller.adjustVariantRule(
window.getViewer(), state.requestId(), workcell, variant, field, field.shiftStep()));
}
element.addLore(unlimitedMaximum
? ChatColor.YELLOW + "Right-click: set 512"
: ChatColor.YELLOW + "Right-click: -1");
element.addLore(unlimitedMaximum
? ChatColor.YELLOW + "Shift-right: set 497"
: ChatColor.YELLOW + "Shift-right: -" + field.shiftStep());
element.onRightClick(clicked -> controller.adjustVariantRule(
window.getViewer(), state.requestId(), workcell, variant, field, -1));
element.onShiftRightClick(clicked -> controller.adjustVariantRule(
window.getViewer(), state.requestId(), workcell, variant, field, -field.shiftStep()));
return element;
}
private void addVariantThemeMemberships(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
int page
) {
List<JigsawStudioMenuState.ThemeSet> themeSets = page(
state.themeSets(), page, THEME_SETS_PER_PAGE);
if (themeSets.isEmpty()) {
UIElement empty = element(
"variant-no-themes",
Material.GRAY_DYE,
ChatColor.GRAY + "Available to Every Theme");
empty.addLore(ChatColor.GRAY + "Create a coherent theme set from Structure Rules first.");
window.setElement(0, 3, empty);
return;
}
for (int index = 0; index < themeSets.size(); index++) {
JigsawStudioMenuState.ThemeSet themeSet = themeSets.get(index);
boolean member = variant.themes().contains(themeSet.key());
int position = GRID_POSITIONS[index % GRID_POSITIONS.length];
int row = 2 + index / GRID_POSITIONS.length;
UIElement element = element(
"variant-theme-" + index,
member ? Material.LIME_DYE : Material.GRAY_DYE,
(member ? ChatColor.GREEN : ChatColor.GRAY) + safe(themeSet.key()))
.setEnchanted(member);
element.addLore(member
? ChatColor.GREEN + "This variant belongs to the theme."
: ChatColor.GRAY + "This variant does not belong to the theme.");
element.addLore(ChatColor.GRAY + "Only variants in the selected family are eligible.");
element.addLore(ChatColor.YELLOW + "Left-click to toggle membership");
element.onLeftClick(clicked -> controller.toggleVariantTheme(
window.getViewer(), state.requestId(), workcell, variant, themeSet.key()));
window.setElement(position, row, element);
}
}
}
@@ -0,0 +1,536 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.service.JigsawStudioMenuFormat.DimensionAxis;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolAction;
import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.inventorygui.UIElement;
import art.arcane.volmlib.util.inventorygui.UIWindow;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import static art.arcane.iris.core.service.JigsawStudioMenuController.send;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.adjustedDimensions;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.dimensions;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.element;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.evaluationElement;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.safe;
import static art.arcane.iris.core.service.JigsawStudioMenuFormat.withCapacity;
final class JigsawStudioWorkcellMenu {
private static final int WORKCELL_RESIZE_REFRESH_TICKS = 2;
private static final int WORKCELL_RESIZE_REFRESH_ATTEMPTS = 200;
private final Map<UUID, PendingWorkcellResize> pendingWorkcellResizes = new ConcurrentHashMap<>();
private final JigsawStudioMenuController controller;
JigsawStudioWorkcellMenu(JigsawStudioMenuController controller) {
this.controller = Objects.requireNonNull(controller, "Jigsaw Studio menu controller");
}
void render(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell
) {
PendingWorkcellResize pendingResize = pendingWorkcellResize(
window.getViewer().getUniqueId(),
state.requestId(),
workcell.stableId());
JigsawStudioMenuState.Workcell renderedWorkcell = pendingResize == null
? workcell
: withCapacity(workcell, pendingResize.dimensions());
window.batch(() -> {
window.clearElements();
UIElement back = element("settings-back", Material.ARROW, ChatColor.YELLOW + "Back to Variants");
back.onLeftClick(clicked -> leaveWorkcellSettings(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
window.setElement(-4, 0, back);
UIElement identity = element(
"settings-identity",
renderedWorkcell.enabled() ? Material.LIME_WOOL : Material.GRAY_WOOL,
ChatColor.AQUA + safe(renderedWorkcell.displayName()));
identity.addLore(ChatColor.DARK_GRAY + safe(renderedWorkcell.stableId()));
identity.addLore(renderedWorkcell.enabled()
? ChatColor.GREEN + "Enabled"
: ChatColor.RED + "Disabled for assembly and export");
if (!renderedWorkcell.canonicalName().equals(renderedWorkcell.displayName())) {
identity.addLore(ChatColor.GRAY + "Solver role: " + safe(renderedWorkcell.canonicalName()));
}
identity.addLore(ChatColor.GRAY + "Capacity: " + dimensions(renderedWorkcell.capacity()));
if (pendingResize != null) {
identity.addLore(pendingResize.applying()
? ChatColor.YELLOW + "Applying one live relayout"
: ChatColor.GOLD + "Pending; apply when all dimensions are ready");
}
identity.addLore(ChatColor.YELLOW + "Left-click for a rename stick");
identity.addLore(ChatColor.GRAY + "Rename that stick in an anvil, then right-click it.");
identity.addLore(ChatColor.GRAY + "Sneak-right-click the stick to reset this label.");
identity.onLeftClick(clicked -> controller.actions.giveTool(
window.getViewer(),
JigsawStudioToolPayload.workcell(
JigsawStudioToolAction.RENAME_WORKCELL,
state.requestId(),
renderedWorkcell.stableId())));
window.setElement(0, 0, identity);
window.setElement(4, 0, evaluationElement(state.evaluation()));
if (state.mode() == JigsawStudioMode.PLANAR_JIGSAW) {
UIElement enabled = element(
"settings-enabled",
renderedWorkcell.enabled() ? Material.LEVER : Material.REDSTONE_TORCH,
renderedWorkcell.enabled()
? ChatColor.GREEN + "Workcell Enabled"
: ChatColor.RED + "Workcell Disabled");
enabled.addLore(ChatColor.GRAY + "Disabled workcells remain editable and keep their size.");
enabled.addLore(ChatColor.YELLOW + "Left-click to "
+ (renderedWorkcell.enabled() ? "disable" : "enable"));
enabled.onLeftClick(clicked -> controller.setWorkcellEnabled(
window.getViewer(),
state.requestId(),
renderedWorkcell.stableId(),
!renderedWorkcell.enabled()));
window.setElement(0, 1, enabled);
} else {
UIElement spatial = element(
"spatial-bounds",
Material.SCAFFOLDING,
ChatColor.AQUA + "Spatial Workcell Bounds");
spatial.addLore(ChatColor.GRAY + "Spatial project bounds are shared by the project.");
spatial.addLore(ChatColor.GRAY + "Changing them regenerates the Studio layout.");
window.setElement(0, 1, spatial);
}
UIElement connectors = element(
"settings-connectors",
renderedWorkcell.connectorsVisible() ? Material.JIGSAW : Material.STRUCTURE_VOID,
renderedWorkcell.connectorsVisible()
? ChatColor.GREEN + "Connector Blocks Visible"
: ChatColor.YELLOW + "Connector Blocks Hidden");
connectors.addLore(ChatColor.GRAY + "Hidden connectors retain their metadata and final block state.");
connectors.addLore(ChatColor.YELLOW + "Left-click to "
+ (renderedWorkcell.connectorsVisible() ? "hide" : "show") + " connector blocks");
connectors.onLeftClick(clicked -> controller.toggleConnectorBlocks(
window.getViewer(),
state.requestId(),
renderedWorkcell.stableId(),
!renderedWorkcell.connectorsVisible()));
window.setElement(2, 1, connectors);
UIElement resetConnectors = element(
"settings-reset-connectors",
Material.RECOVERY_COMPASS,
ChatColor.AQUA + "Reset Connector Blocks");
resetConnectors.addLore(ChatColor.GRAY + "Restore every connector in this workcell from disk.");
resetConnectors.addLore(ChatColor.GRAY + "Other edited blocks are left unchanged.");
resetConnectors.onLeftClick(clicked -> controller.resetConnectorBlocks(
window.getViewer(),
state.requestId(),
renderedWorkcell.stableId()));
window.setElement(4, 1, resetConnectors);
window.setElement(-2, 2, axisElement(
window,
state,
renderedWorkcell,
DimensionAxis.WIDTH,
Material.IRON_INGOT));
window.setElement(0, 2, axisElement(
window,
state,
renderedWorkcell,
DimensionAxis.HEIGHT,
Material.GOLD_INGOT));
window.setElement(2, 2, axisElement(
window,
state,
renderedWorkcell,
DimensionAxis.DEPTH,
Material.COPPER_INGOT));
if (pendingResize != null) {
UIElement apply = element(
"settings-apply-capacity",
pendingResize.applying() ? Material.CLOCK : Material.EMERALD_BLOCK,
pendingResize.applying()
? ChatColor.YELLOW + "Applying Cell Size"
: ChatColor.GREEN + "Apply Cell Size");
apply.addLore(ChatColor.WHITE + dimensions(pendingResize.dimensions()));
apply.addLore(ChatColor.GRAY + "Regenerates the layout once after all size edits.");
if (!pendingResize.applying()) {
apply.onLeftClick(clicked -> applyWorkcellResize(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
}
window.setElement(0, 3, apply);
if (!pendingResize.applying()) {
UIElement discard = element(
"settings-discard-capacity",
Material.BARRIER,
ChatColor.RED + "Discard Size Changes");
discard.onLeftClick(clicked -> discardWorkcellResize(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
window.setElement(2, 3, discard);
}
}
UIElement footerBack = element("settings-footer-back", Material.ARROW, ChatColor.YELLOW + "Back");
footerBack.onLeftClick(clicked -> leaveWorkcellSettings(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
window.setElement(-4, 5, footerBack);
UIElement undo = element(
"settings-undo",
Material.CLOCK,
ChatColor.LIGHT_PURPLE + "Undo Last Autosave");
undo.addLore(ChatColor.GRAY + "Restore the previous owned graph iteration.");
undo.addLore(ChatColor.GRAY + "Up to five autosave iterations are retained on disk.");
undo.onLeftClick(clicked -> controller.undoAutosave(
window.getViewer(),
state.requestId()));
window.setElement(0, 5, undo);
if (renderedWorkcell.dirty() && !renderedWorkcell.saving()) {
UIElement saveNow = element(
"save-now",
Material.EMERALD,
ChatColor.GOLD + "Flush Autosave Now");
saveNow.addLore(ChatColor.GRAY + "Autosave is automatic.");
saveNow.addLore(ChatColor.GRAY + "Use this only to flush pending work or recover immediately.");
saveNow.onLeftClick(clicked -> controller.flushNow(
window.getViewer(), state.requestId(), renderedWorkcell.stableId()));
window.setElement(2, 5, saveNow);
}
UIElement preview = element(
"settings-preview",
Material.ENDER_EYE,
ChatColor.LIGHT_PURPLE + "Go to Preview");
preview.onLeftClick(clicked -> controller.goToPreview(window.getViewer(), state.requestId()));
window.setElement(3, 5, preview);
UIElement toolbox = element("settings-toolbox", Material.STICK, ChatColor.AQUA + "Toolbox");
toolbox.onLeftClick(clicked -> controller.openToolbox(
window.getViewer(), state.requestId(), renderedWorkcell.stableId(), 0));
window.setElement(4, 5, toolbox);
});
}
void renderSize(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant
) {
JigsawStudioCellDimensions current = variant.dimensions().orElseThrow();
window.batch(() -> {
window.clearElements();
UIElement back = element("variant-size-back", Material.ARROW, ChatColor.YELLOW + "Back to Details");
back.onLeftClick(clicked -> controller.openDetails(
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), 0));
window.setElement(-4, 0, back);
UIElement identity = element(
"variant-size-identity",
Material.SCAFFOLDING,
ChatColor.AQUA + safe(variant.displayName()) + " Size");
identity.addLore(ChatColor.DARK_GRAY + safe(variant.pieceKey()));
identity.addLore(ChatColor.WHITE + "Current: " + dimensions(current));
identity.addLore(ChatColor.GRAY + "Workcell capacity: " + dimensions(workcell.capacity()));
identity.addLore(ChatColor.GRAY + "Only this variant object and its edge connectors change.");
window.setElement(0, 0, identity);
window.setElement(4, 0, evaluationElement(state.evaluation()));
window.setElement(-2, 2, variantAxisElement(
window, state, workcell, variant, DimensionAxis.WIDTH, Material.IRON_INGOT));
window.setElement(0, 2, variantAxisElement(
window, state, workcell, variant, DimensionAxis.HEIGHT, Material.GOLD_INGOT));
window.setElement(2, 2, variantAxisElement(
window, state, workcell, variant, DimensionAxis.DEPTH, Material.COPPER_INGOT));
UIElement capacity = element(
"variant-size-capacity",
variant.resizableToCapacity() ? Material.NETHER_STAR : Material.GRAY_DYE,
variant.resizableToCapacity()
? ChatColor.GREEN + "Resize This Variant to Capacity"
: ChatColor.GRAY + "Variant Already Matches Capacity");
capacity.addLore(ChatColor.GRAY + dimensions(workcell.capacity()));
if (variant.resizableToCapacity()) {
capacity.addLore(ChatColor.YELLOW + "Left-click to resize only this variant");
capacity.onLeftClick(clicked -> controller.resizeVariant(
window.getViewer(),
state.requestId(),
workcell.stableId(),
variant.pieceKey(),
workcell.capacity()));
}
window.setElement(0, 3, capacity);
UIElement footerBack = element("variant-size-footer-back", Material.ARROW, ChatColor.YELLOW + "Back");
footerBack.onLeftClick(clicked -> controller.openDetails(
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), 0));
window.setElement(-4, 5, footerBack);
});
}
private UIElement variantAxisElement(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
JigsawStudioMenuState.Variant variant,
DimensionAxis axis,
Material material
) {
JigsawStudioCellDimensions current = variant.dimensions().orElseThrow();
UIElement element = element(
"variant-axis-" + axis.name().toLowerCase(Locale.ROOT),
material,
ChatColor.AQUA + axis.displayName() + ": " + axis.value(current));
element.addLore(ChatColor.GREEN + "Left-click: +1");
element.addLore(ChatColor.YELLOW + "Right-click: -1");
element.addLore(ChatColor.GREEN + "Shift-left: +8");
element.addLore(ChatColor.YELLOW + "Shift-right: -8");
element.addLore(ChatColor.GRAY + "Maximum: " + axis.value(workcell.capacity()));
element.onLeftClick(clicked -> controller.resizeVariantAxis(
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), axis, 1));
element.onRightClick(clicked -> controller.resizeVariantAxis(
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), axis, -1));
element.onShiftLeftClick(clicked -> controller.resizeVariantAxis(
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), axis, 8));
element.onShiftRightClick(clicked -> controller.resizeVariantAxis(
window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), axis, -8));
return element;
}
private UIElement axisElement(
UIWindow window,
JigsawStudioMenuState state,
JigsawStudioMenuState.Workcell workcell,
DimensionAxis axis,
Material material
) {
UIElement element = element(
"axis-" + axis.name().toLowerCase(Locale.ROOT),
material,
ChatColor.AQUA + axis.displayName() + " Capacity: " + axis.value(workcell.capacity()));
element.addLore(ChatColor.GREEN + "Left-click: +1");
element.addLore(ChatColor.YELLOW + "Right-click: -1");
element.addLore(ChatColor.GREEN + "Shift-left: +8");
element.addLore(ChatColor.YELLOW + "Shift-right: -8");
element.addLore(ChatColor.GRAY + "Changes stay in this menu until Apply Cell Size.");
element.onLeftClick(clicked -> resizeWorkcell(
window.getViewer(), state.requestId(), workcell.stableId(), axis, 1));
element.onRightClick(clicked -> resizeWorkcell(
window.getViewer(), state.requestId(), workcell.stableId(), axis, -1));
element.onShiftLeftClick(clicked -> resizeWorkcell(
window.getViewer(), state.requestId(), workcell.stableId(), axis, 8));
element.onShiftRightClick(clicked -> resizeWorkcell(
window.getViewer(), state.requestId(), workcell.stableId(), axis, -8));
return element;
}
private void resizeWorkcell(
Player player,
UUID requestId,
String workcellId,
DimensionAxis axis,
int delta
) {
Optional<JigsawStudioMenuState> current = controller.matchingState(player, requestId, true);
if (current.isEmpty()) {
return;
}
JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId);
if (workcell == null) {
controller.stale(player);
return;
}
UUID playerId = player.getUniqueId();
PendingWorkcellResize existing = pendingWorkcellResize(playerId, requestId, workcellId);
if (existing != null && existing.applying()) {
send(player, ChatColor.YELLOW + "That cell size is already being applied.");
return;
}
JigsawStudioCellDimensions base = existing == null
? workcell.capacity()
: existing.dimensions();
Optional<JigsawStudioCellDimensions> adjusted = adjustedDimensions(
base, axis, delta);
if (adjusted.isEmpty()) {
send(player, ChatColor.RED + "That workcell size is outside Iris limits.");
return;
}
PendingWorkcellResize pending = new PendingWorkcellResize(
requestId,
workcellId,
adjusted.get(),
false);
pendingWorkcellResizes.put(playerId, pending);
UIWindow window = controller.windows.get(playerId);
if (window != null) {
render(window, current.get(), withCapacity(workcell, pending.dimensions()));
}
}
private void applyWorkcellResize(Player player, UUID requestId, String workcellId) {
Optional<JigsawStudioMenuState> current = controller.matchingState(player, requestId, true);
if (current.isEmpty()) {
return;
}
UUID playerId = player.getUniqueId();
PendingWorkcellResize pending = pendingWorkcellResize(playerId, requestId, workcellId);
JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId);
if (pending == null || pending.applying() || workcell == null) {
return;
}
if (workcell.capacity().equals(pending.dimensions())) {
pendingWorkcellResizes.remove(playerId, pending);
refreshWorkcellSettings(player, requestId, workcellId);
return;
}
PendingWorkcellResize applying = new PendingWorkcellResize(
requestId,
workcellId,
pending.dimensions(),
true);
pendingWorkcellResizes.put(playerId, applying);
UIWindow window = controller.windows.get(playerId);
if (window != null) {
render(window, current.get(), withCapacity(workcell, applying.dimensions()));
}
if (!controller.actions.updateWorkcellDimensions(player, workcellId, applying.dimensions())) {
pendingWorkcellResizes.replace(playerId, applying, pending);
refreshWorkcellSettings(player, requestId, workcellId);
return;
}
scheduleWorkcellResizeRefresh(player, applying, WORKCELL_RESIZE_REFRESH_ATTEMPTS);
}
private void scheduleWorkcellResizeRefresh(
Player player,
PendingWorkcellResize pending,
int attemptsRemaining
) {
boolean scheduled = J.runEntity(
player,
() -> refreshAppliedWorkcellResize(player, pending, attemptsRemaining),
WORKCELL_RESIZE_REFRESH_TICKS);
if (!scheduled) {
pendingWorkcellResizes.remove(player.getUniqueId(), pending);
}
}
private void refreshAppliedWorkcellResize(
Player player,
PendingWorkcellResize pending,
int attemptsRemaining
) {
UUID playerId = player.getUniqueId();
if (!pending.equals(pendingWorkcellResizes.get(playerId))) {
return;
}
Optional<JigsawStudioMenuState> current = controller.matchingState(player, pending.requestId(), false);
JigsawStudioMenuState.Workcell workcell = current
.map(state -> state.workcell(pending.workcellId()))
.orElse(null);
if (workcell == null) {
pendingWorkcellResizes.remove(playerId, pending);
return;
}
if (workcell.capacity().equals(pending.dimensions())) {
pendingWorkcellResizes.remove(playerId, pending);
UIWindow window = controller.windows.get(playerId);
if (window != null) {
render(window, current.orElseThrow(), workcell);
}
return;
}
if (attemptsRemaining > 0) {
scheduleWorkcellResizeRefresh(player, pending, attemptsRemaining - 1);
return;
}
PendingWorkcellResize retry = new PendingWorkcellResize(
pending.requestId(),
pending.workcellId(),
pending.dimensions(),
false);
pendingWorkcellResizes.replace(playerId, pending, retry);
UIWindow window = controller.windows.get(playerId);
if (window != null) {
render(window, current.orElseThrow(), withCapacity(workcell, retry.dimensions()));
}
send(player, ChatColor.YELLOW
+ "Cell resizing is still pending; use Apply Cell Size to retry after the current operation settles.");
}
private void discardWorkcellResize(Player player, UUID requestId, String workcellId) {
PendingWorkcellResize pending = pendingWorkcellResize(player.getUniqueId(), requestId, workcellId);
if (pending != null && !pending.applying()) {
pendingWorkcellResizes.remove(player.getUniqueId(), pending);
}
refreshWorkcellSettings(player, requestId, workcellId);
}
private void leaveWorkcellSettings(Player player, UUID requestId, String workcellId) {
PendingWorkcellResize pending = pendingWorkcellResize(player.getUniqueId(), requestId, workcellId);
if (pending != null && !pending.applying()) {
pendingWorkcellResizes.remove(player.getUniqueId(), pending);
}
controller.refreshMain(player, requestId, workcellId, 0);
}
private void refreshWorkcellSettings(Player player, UUID requestId, String workcellId) {
Optional<JigsawStudioMenuState> current = controller.matchingState(player, requestId, true);
UIWindow window = controller.windows.get(player.getUniqueId());
JigsawStudioMenuState.Workcell workcell = current
.map(state -> state.workcell(workcellId))
.orElse(null);
if (window == null || workcell == null) {
return;
}
render(window, current.orElseThrow(), workcell);
}
private PendingWorkcellResize pendingWorkcellResize(UUID playerId, UUID requestId, String workcellId) {
PendingWorkcellResize pending = pendingWorkcellResizes.get(playerId);
if (pending == null
|| !pending.requestId().equals(requestId)
|| !pending.workcellId().equals(workcellId)) {
return null;
}
return pending;
}
private record PendingWorkcellResize(
UUID requestId,
String workcellId,
JigsawStudioCellDimensions dimensions,
boolean applying
) {
private PendingWorkcellResize {
requestId = Objects.requireNonNull(requestId, "Jigsaw Studio resize request ID");
workcellId = Objects.requireNonNull(workcellId, "Jigsaw Studio resize workcell ID");
dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio resize dimensions");
}
}
void clearStagedResize(UUID playerId) {
pendingWorkcellResizes.remove(playerId);
}
void clearStagedResizes() {
pendingWorkcellResizes.clear();
}
}
@@ -57,13 +57,13 @@ public class JigsawStudioMenuControllerTest {
values.add(index);
}
assertEquals(1, JigsawStudioMenuController.pageCount(0, 28));
assertEquals(1, JigsawStudioMenuController.pageCount(28, 28));
assertEquals(2, JigsawStudioMenuController.pageCount(29, 28));
assertEquals(List.of(0, 1, 2), JigsawStudioMenuController.page(values, -10, 3));
assertEquals(List.of(54, 55, 56), JigsawStudioMenuController.page(values, 999, 3));
assertEquals(1, JigsawStudioMenuFormat.pageCount(0, 28));
assertEquals(1, JigsawStudioMenuFormat.pageCount(28, 28));
assertEquals(2, JigsawStudioMenuFormat.pageCount(29, 28));
assertEquals(List.of(0, 1, 2), JigsawStudioMenuFormat.page(values, -10, 3));
assertEquals(List.of(54, 55, 56), JigsawStudioMenuFormat.page(values, 999, 3));
assertThrows(IllegalArgumentException.class,
() -> JigsawStudioMenuController.pageCount(1, 0));
() -> JigsawStudioMenuFormat.pageCount(1, 0));
}
@Test
@@ -86,7 +86,7 @@ public class JigsawStudioMenuControllerTest {
false,
List.of(active));
assertEquals(ChatColor.GREEN + "Autosaved", JigsawStudioMenuController.workcellStatus(fresh));
assertEquals(ChatColor.GREEN + "Autosaved", JigsawStudioMenuFormat.workcellStatus(fresh));
}
@Test
@@ -97,11 +97,11 @@ public class JigsawStudioMenuControllerTest {
List.of(),
List.of());
assertEquals(Material.JIGSAW, JigsawStudioMenuController.variantMaterial(active));
assertEquals(Material.JIGSAW, JigsawStudioMenuFormat.variantMaterial(active));
assertEquals(Material.EMERALD,
JigsawStudioMenuController.evaluationMaterial(JigsawStudioEvaluationState.VALID));
JigsawStudioMenuFormat.evaluationMaterial(JigsawStudioEvaluationState.VALID));
assertEquals(Material.TARGET,
JigsawStudioMenuController.RuleField.MINIMUM_PLACEMENTS.material());
JigsawStudioMenuFormat.RuleField.MINIMUM_PLACEMENTS.material());
}
@Test
@@ -217,33 +217,33 @@ public class JigsawStudioMenuControllerTest {
assertEquals(
new JigsawStudioCellDimensions(25, 12, 18),
JigsawStudioMenuController.adjustedDimensions(
JigsawStudioMenuFormat.adjustedDimensions(
dimensions,
JigsawStudioMenuController.DimensionAxis.WIDTH,
JigsawStudioMenuFormat.DimensionAxis.WIDTH,
1).orElseThrow());
assertEquals(
new JigsawStudioCellDimensions(24, 20, 18),
JigsawStudioMenuController.adjustedDimensions(
JigsawStudioMenuFormat.adjustedDimensions(
dimensions,
JigsawStudioMenuController.DimensionAxis.HEIGHT,
JigsawStudioMenuFormat.DimensionAxis.HEIGHT,
8).orElseThrow());
assertEquals(
new JigsawStudioCellDimensions(24, 12, 10),
JigsawStudioMenuController.adjustedDimensions(
JigsawStudioMenuFormat.adjustedDimensions(
dimensions,
JigsawStudioMenuController.DimensionAxis.DEPTH,
JigsawStudioMenuFormat.DimensionAxis.DEPTH,
-8).orElseThrow());
assertTrue(JigsawStudioMenuController.adjustedDimensions(
assertTrue(JigsawStudioMenuFormat.adjustedDimensions(
new JigsawStudioCellDimensions(1, 1, 1),
JigsawStudioMenuController.DimensionAxis.WIDTH,
JigsawStudioMenuFormat.DimensionAxis.WIDTH,
-1).isEmpty());
assertTrue(JigsawStudioMenuController.adjustedDimensions(
assertTrue(JigsawStudioMenuFormat.adjustedDimensions(
new JigsawStudioCellDimensions(128, 1, 1),
JigsawStudioMenuController.DimensionAxis.WIDTH,
JigsawStudioMenuFormat.DimensionAxis.WIDTH,
1).isEmpty());
assertThrows(IllegalArgumentException.class, () -> JigsawStudioMenuController.adjustedDimensions(
assertThrows(IllegalArgumentException.class, () -> JigsawStudioMenuFormat.adjustedDimensions(
dimensions,
JigsawStudioMenuController.DimensionAxis.WIDTH,
JigsawStudioMenuFormat.DimensionAxis.WIDTH,
0));
}
@@ -251,21 +251,22 @@ public class JigsawStudioMenuControllerTest {
public void stagesMultipleCellSizeEditsAndAppliesOneRelayoutWithoutClosing() throws Exception {
UUID playerId = UUID.fromString("55555555-5555-5555-5555-555555555555");
Player player = mock(Player.class);
JigsawStudioMenuController.Actions actions = mock(JigsawStudioMenuController.Actions.class);
JigsawStudioMenuActions actions = mock(JigsawStudioMenuActions.class);
JigsawStudioMenuState.Variant active = variant("pieces/corner", true, List.of(), List.of());
JigsawStudioMenuState.Workcell workcell = workcell(active);
JigsawStudioMenuState state = state(JigsawStudioMenuState.Evaluation.pending(), workcell);
JigsawStudioMenuController controller = new JigsawStudioMenuController(
mock(JavaPlugin.class),
actions);
Method resize = JigsawStudioMenuController.class.getDeclaredMethod(
JigsawStudioWorkcellMenu workcellMenu = new JigsawStudioWorkcellMenu(controller);
Method resize = JigsawStudioWorkcellMenu.class.getDeclaredMethod(
"resizeWorkcell",
Player.class,
UUID.class,
String.class,
JigsawStudioMenuController.DimensionAxis.class,
JigsawStudioMenuFormat.DimensionAxis.class,
int.class);
Method apply = JigsawStudioMenuController.class.getDeclaredMethod(
Method apply = JigsawStudioWorkcellMenu.class.getDeclaredMethod(
"applyWorkcellResize",
Player.class,
UUID.class,
@@ -280,18 +281,18 @@ public class JigsawStudioMenuControllerTest {
new JigsawStudioCellDimensions(25, 20, 18))).thenReturn(true);
resize.invoke(
controller,
workcellMenu,
player,
REQUEST_ID,
workcell.stableId(),
JigsawStudioMenuController.DimensionAxis.WIDTH,
JigsawStudioMenuFormat.DimensionAxis.WIDTH,
1);
resize.invoke(
controller,
workcellMenu,
player,
REQUEST_ID,
workcell.stableId(),
JigsawStudioMenuController.DimensionAxis.HEIGHT,
JigsawStudioMenuFormat.DimensionAxis.HEIGHT,
8);
verify(actions, never()).updateWorkcellDimensions(
@@ -304,7 +305,7 @@ public class JigsawStudioMenuControllerTest {
any(Runnable.class),
eq(2))).thenReturn(true);
apply.invoke(controller, player, REQUEST_ID, workcell.stableId());
apply.invoke(workcellMenu, player, REQUEST_ID, workcell.stableId());
verify(actions).updateWorkcellDimensions(
player,
@@ -319,7 +320,7 @@ public class JigsawStudioMenuControllerTest {
JigsawStudioMenuState.Workcell source = workcell(active);
JigsawStudioCellDimensions capacity = new JigsawStudioCellDimensions(31, 19, 27);
JigsawStudioMenuState.Workcell staged = JigsawStudioMenuController.withCapacity(source, capacity);
JigsawStudioMenuState.Workcell staged = JigsawStudioMenuFormat.withCapacity(source, capacity);
assertEquals(capacity, staged.capacity());
assertEquals(source.stableId(), staged.stableId());
@@ -335,12 +336,12 @@ public class JigsawStudioMenuControllerTest {
new JigsawStudioMenuState.ThemeSet("seasonal", 4),
new JigsawStudioMenuState.ThemeSet("variant-2", 2));
assertEquals("variant-3", JigsawStudioMenuController.nextThemeSetKey(themeSets));
assertEquals("variant-1", JigsawStudioMenuController.nextThemeSetKey(List.of()));
assertEquals(Integer.valueOf(12), JigsawStudioMenuController.adjustedPositiveValue(4, 8).orElseThrow());
assertTrue(JigsawStudioMenuController.adjustedPositiveValue(4, -8).isEmpty());
assertEquals("variant-3", JigsawStudioMenuFormat.nextThemeSetKey(themeSets));
assertEquals("variant-1", JigsawStudioMenuFormat.nextThemeSetKey(List.of()));
assertEquals(Integer.valueOf(12), JigsawStudioMenuFormat.adjustedPositiveValue(4, 8).orElseThrow());
assertTrue(JigsawStudioMenuFormat.adjustedPositiveValue(4, -8).isEmpty());
assertThrows(IllegalArgumentException.class,
() -> JigsawStudioMenuController.adjustedPositiveValue(4, 0));
() -> JigsawStudioMenuFormat.adjustedPositiveValue(4, 0));
}
@Test
@@ -349,10 +350,10 @@ public class JigsawStudioMenuControllerTest {
new JigsawStudioMenuState.ThemeSet("variant-1", 3),
new JigsawStudioMenuState.ThemeSet("variant-2", 1));
assertEquals("75.0%", JigsawStudioMenuController.themeSelectionPercent(
assertEquals("75.0%", JigsawStudioMenuFormat.themeSelectionPercent(
themes,
themes.getFirst()));
assertEquals("25.0%", JigsawStudioMenuController.themeSelectionPercent(
assertEquals("25.0%", JigsawStudioMenuFormat.themeSelectionPercent(
themes,
themes.getLast()));
}
@@ -361,47 +362,47 @@ public class JigsawStudioMenuControllerTest {
public void editsPieceRuleFieldsWithinRuntimeBounds() {
assertEquals(
new JigsawStudioPieceRules(2, 12, 2, 8, false),
JigsawStudioMenuController.adjustedRules(
JigsawStudioMenuFormat.adjustedRules(
RULES,
JigsawStudioMenuController.RuleField.MINIMUM_DEPTH,
JigsawStudioMenuFormat.RuleField.MINIMUM_DEPTH,
1).orElseThrow());
assertEquals(
new JigsawStudioPieceRules(1, 17, 2, 8, false),
JigsawStudioMenuController.adjustedRules(
JigsawStudioMenuFormat.adjustedRules(
RULES,
JigsawStudioMenuController.RuleField.MAXIMUM_DEPTH,
JigsawStudioMenuFormat.RuleField.MAXIMUM_DEPTH,
5).orElseThrow());
assertEquals(
new JigsawStudioPieceRules(1, 12, 3, 8, false),
JigsawStudioMenuController.adjustedRules(
JigsawStudioMenuFormat.adjustedRules(
RULES,
JigsawStudioMenuController.RuleField.MINIMUM_PLACEMENTS,
JigsawStudioMenuFormat.RuleField.MINIMUM_PLACEMENTS,
1).orElseThrow());
assertEquals(
new JigsawStudioPieceRules(1, 12, 2, 24, false),
JigsawStudioMenuController.adjustedRules(
JigsawStudioMenuFormat.adjustedRules(
RULES,
JigsawStudioMenuController.RuleField.MAXIMUM_PLACEMENTS,
JigsawStudioMenuFormat.RuleField.MAXIMUM_PLACEMENTS,
16).orElseThrow());
assertEquals(
new JigsawStudioPieceRules(0, 30, 0, 0, false),
JigsawStudioMenuController.adjustedRules(
JigsawStudioMenuFormat.adjustedRules(
new JigsawStudioPieceRules(0, 30, 0, 512, false),
JigsawStudioMenuController.RuleField.MAXIMUM_PLACEMENTS,
JigsawStudioMenuFormat.RuleField.MAXIMUM_PLACEMENTS,
1).orElseThrow());
assertEquals(
new JigsawStudioPieceRules(0, 30, 0, 512, false),
JigsawStudioMenuController.adjustedRules(
JigsawStudioMenuFormat.adjustedRules(
new JigsawStudioPieceRules(0, 30, 0, 0, false),
JigsawStudioMenuController.RuleField.MAXIMUM_PLACEMENTS,
JigsawStudioMenuFormat.RuleField.MAXIMUM_PLACEMENTS,
-1).orElseThrow());
assertTrue(JigsawStudioMenuController.adjustedRules(
assertTrue(JigsawStudioMenuFormat.adjustedRules(
RULES,
JigsawStudioMenuController.RuleField.MINIMUM_DEPTH,
JigsawStudioMenuFormat.RuleField.MINIMUM_DEPTH,
12).isEmpty());
assertEquals(
new JigsawStudioPieceRules(1, 12, 2, 8, true),
JigsawStudioMenuController.withTerminal(RULES, true));
JigsawStudioMenuFormat.withTerminal(RULES, true));
}
@Test
@@ -424,23 +425,23 @@ public class JigsawStudioMenuControllerTest {
JigsawStudioMenuState.Workcell corner = workcell(active, List.of(active, inactive));
JigsawStudioMenuState state = state(JigsawStudioMenuState.Evaluation.pending(), corner);
List<JigsawStudioMenuController.ToolboxTool> tools = JigsawStudioMenuController.toolboxTools(
List<JigsawStudioToolboxMenu.ToolboxTool> tools = JigsawStudioToolboxMenu.toolboxTools(
state,
corner);
JigsawStudioMenuController.ToolboxTool preview = tool(tools, JigsawStudioToolAction.PREVIEW_GRAPH, 0);
JigsawStudioMenuController.ToolboxTool chanceIncrease = tool(
JigsawStudioToolboxMenu.ToolboxTool preview = tool(tools, JigsawStudioToolAction.PREVIEW_GRAPH, 0);
JigsawStudioToolboxMenu.ToolboxTool chanceIncrease = tool(
tools,
JigsawStudioToolAction.ADJUST_VARIANT_CHANCE,
JigsawStudioMenuController.CHANCE_STEP_PERCENTAGE_POINTS);
JigsawStudioMenuController.ToolboxTool delete = tool(
JigsawStudioToolboxMenu.ToolboxTool delete = tool(
tools,
JigsawStudioToolAction.DELETE_VARIANT,
0);
JigsawStudioMenuController.ToolboxTool newThemeSet = tools.stream()
JigsawStudioToolboxMenu.ToolboxTool newThemeSet = tools.stream()
.filter(tool -> tool.payload().action() == JigsawStudioToolAction.DUPLICATE_FAMILY)
.findFirst()
.orElseThrow();
JigsawStudioMenuController.ToolboxTool pieceRules = tools.stream()
JigsawStudioToolboxMenu.ToolboxTool pieceRules = tools.stream()
.filter(tool -> tool.payload().action() == JigsawStudioToolAction.SET_PIECE_RULES)
.findFirst()
.orElseThrow();
@@ -489,7 +490,7 @@ public class JigsawStudioMenuControllerTest {
JigsawStudioMenuState.Evaluation.pending(),
List.of(room));
List<JigsawStudioMenuController.ToolboxTool> tools = JigsawStudioMenuController.toolboxTools(state, room);
List<JigsawStudioToolboxMenu.ToolboxTool> tools = JigsawStudioToolboxMenu.toolboxTools(state, room);
assertTrue(tools.stream().anyMatch(tool -> tool.payload().action()
== JigsawStudioToolAction.RESIZE_WORKCELL));
@@ -519,7 +520,7 @@ public class JigsawStudioMenuControllerTest {
JigsawStudioMenuState.Evaluation.pending(),
List.of(corner));
List<JigsawStudioMenuController.ToolboxTool> tools = JigsawStudioMenuController.toolboxTools(state, corner);
List<JigsawStudioToolboxMenu.ToolboxTool> tools = JigsawStudioToolboxMenu.toolboxTools(state, corner);
assertFalse(state.irisExtended());
assertTrue(tools.stream().noneMatch(tool -> tool.payload().action() == JigsawStudioToolAction.SET_THEME));
@@ -536,7 +537,7 @@ public class JigsawStudioMenuControllerTest {
@Test
public void openMarshalsToThePlayerRegionBeforeReadingStudioState() {
Player player = mock(Player.class);
JigsawStudioMenuController.Actions actions = mock(JigsawStudioMenuController.Actions.class);
JigsawStudioMenuActions actions = mock(JigsawStudioMenuActions.class);
JigsawStudioMenuController controller = new JigsawStudioMenuController(
mock(JavaPlugin.class),
actions);
@@ -649,12 +650,12 @@ public class JigsawStudioMenuControllerTest {
memberships);
}
private JigsawStudioMenuController.ToolboxTool tool(
List<JigsawStudioMenuController.ToolboxTool> tools,
private JigsawStudioToolboxMenu.ToolboxTool tool(
List<JigsawStudioToolboxMenu.ToolboxTool> tools,
JigsawStudioToolAction action,
int amount
) {
Optional<JigsawStudioMenuController.ToolboxTool> match = tools.stream()
Optional<JigsawStudioToolboxMenu.ToolboxTool> match = tools.stream()
.filter(tool -> tool.payload().action() == action && tool.payload().amount() == amount)
.findFirst();
return match.orElseThrow();