package com.xfestudio.xfeservermanager.core.menu;
import com.xfestudio.xfeservermanager.core.trigger.TriggerEvaluator;
import java.time.Instant;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/**
* Version-neutral menu document edited by the Web console and rendered by a compatible client.
* Coordinates are logical pixels in the configured canvas and are scaled as one unit.
*/
public record MenuDefinition(
UUID id,
String name,
String description,
int width,
int height,
String popupPosition,
int offsetX,
int offsetY,
boolean pauseGame,
String backgroundColor,
List<Control> controls,
long revision,
String createdBy,
Instant createdAt,
Instant updatedAt) {
public static final int MIN_WIDTH = 120;
public static final int MAX_WIDTH = 1_920;
public static final int MIN_HEIGHT = 80;
public static final int MAX_HEIGHT = 1_080;
public static final int MAX_CONTROLS = 128;
public static final Set<String> POSITIONS = Set.of(
"center", "top", "bottom", "left", "right",
"top_left", "top_right", "bottom_left", "bottom_right", "custom");
public static final Set<String> CONTROL_TYPES = Set.of(
"text", "rich_text", "button", "icon_button", "image", "panel", "border",
"progress", "input", "checkbox", "slider", "item_slot", "separator", "spacer");
public static final Set<String> CONTROL_EVENTS = Set.of(
"click", "double_click", "change", "submit", "toggle", "hover", "focus", "blur");
public MenuDefinition {
Objects.requireNonNull(id, "id");
name = bounded(name, "menu name", 1, 96);
description = bounded(description, "menu description", 0, 1_024);
if (width < MIN_WIDTH || width > MAX_WIDTH) {
throw new IllegalArgumentException("menu width must be between " + MIN_WIDTH + " and " + MAX_WIDTH);
}
if (height < MIN_HEIGHT || height > MAX_HEIGHT) {
throw new IllegalArgumentException("menu height must be between " + MIN_HEIGHT + " and " + MAX_HEIGHT);
}
popupPosition = Objects.toString(popupPosition, "center").strip().toLowerCase(java.util.Locale.ROOT);
if (!POSITIONS.contains(popupPosition)) throw new IllegalArgumentException("unsupported menu popup position");
if (Math.abs(offsetX) > 8_192 || Math.abs(offsetY) > 8_192) {
throw new IllegalArgumentException("menu offsets must be between -8192 and 8192");
}
backgroundColor = color(backgroundColor, "menu backgroundColor");
controls = controls == null ? List.of() : List.copyOf(controls);
if (controls.size() > MAX_CONTROLS) {
throw new IllegalArgumentException("a menu cannot contain more than " + MAX_CONTROLS + " controls");
}
Set<String> ids = new HashSet<>();
for (Control control : controls) {
Objects.requireNonNull(control, "menu control");
if (!ids.add(control.id())) throw new IllegalArgumentException("duplicate menu control id " + control.id());
if ((long) control.x() + control.width() > width || (long) control.y() + control.height() > height) {
throw new IllegalArgumentException("menu control lies outside the canvas: " + control.id());
}
}
if (revision < 0) throw new IllegalArgumentException("menu revision must not be negative");
createdBy = bounded(createdBy, "menu creator", 1, 128);
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(updatedAt, "updatedAt");
}
public record Control(
String id,
String type,
int x,
int y,
int width,
int height,
int zIndex,
String visibleWhen,
Map<String, String> properties,
List<EventBinding> events) {
public Control {
id = bounded(id, "control id", 1, 64);
if (!id.matches("[A-Za-z_][A-Za-z0-9_.-]{0,63}")) {
throw new IllegalArgumentException("control id must be an identifier");
}
if (id.equals("__xfesm_root__")) throw new IllegalArgumentException("control id is reserved");
type = Objects.toString(type, "").strip().toLowerCase(java.util.Locale.ROOT);
if (!CONTROL_TYPES.contains(type)) throw new IllegalArgumentException("unsupported menu control type " + type);
if (x < 0 || y < 0 || width < 1 || height < 1 || width > MAX_WIDTH || height > MAX_HEIGHT) {
throw new IllegalArgumentException("menu control geometry is invalid: " + id);
}
if (zIndex < -1_000 || zIndex > 1_000) throw new IllegalArgumentException("control zIndex is out of range");
visibleWhen = bounded(visibleWhen, "control visibleWhen", 0, 512);
if (!visibleWhen.isEmpty()) TriggerEvaluator.validateTemplateVariables(visibleWhen);
Map<String, String> safeProperties = new LinkedHashMap<>();
if (properties != null) {
if (properties.size() > 48) throw new IllegalArgumentException("a control has too many properties");
properties.forEach((key, value) -> {
String safeKey = bounded(key, "control property name", 1, 64);
if (!safeKey.matches("[A-Za-z][A-Za-z0-9_.-]{0,63}")) {
throw new IllegalArgumentException("invalid control property name " + safeKey);
}
String safeValue = propertyValue(value);
TriggerEvaluator.validateTemplateVariables(safeValue);
safeProperties.put(safeKey, safeValue);
});
}
properties = Map.copyOf(safeProperties);
events = events == null ? List.of() : List.copyOf(events);
if (events.size() > CONTROL_EVENTS.size()) throw new IllegalArgumentException("a control has too many events");
Set<String> eventNames = new HashSet<>();
for (EventBinding event : events) {
Objects.requireNonNull(event, "control event");
if (!eventNames.add(event.event())) {
throw new IllegalArgumentException("duplicate " + event.event() + " binding on " + id);
}
}
}
}
/** A control event may open a submenu, invoke a menu.control trigger, close, or combine them. */
public record EventBinding(String event, String triggerId, String submenuId, boolean closeMenu) {
public EventBinding {
event = Objects.toString(event, "click").strip().toLowerCase(java.util.Locale.ROOT);
if (!CONTROL_EVENTS.contains(event)) throw new IllegalArgumentException("unsupported control event " + event);
triggerId = optionalUuid(triggerId, "triggerId");
submenuId = optionalUuid(submenuId, "submenuId");
if (triggerId.isEmpty() && submenuId.isEmpty() && !closeMenu) {
throw new IllegalArgumentException("a control event needs a trigger, submenu, or close action");
}
}
}
private static String optionalUuid(String value, String name) {
String normalized = Objects.toString(value, "").strip();
if (normalized.isEmpty()) return "";
try {
return UUID.fromString(normalized).toString();
} catch (IllegalArgumentException invalid) {
throw new IllegalArgumentException(name + " must be a UUID", invalid);
}
}
private static String color(String value, String name) {
String normalized = Objects.toString(value, "#101713e8").strip().toLowerCase(java.util.Locale.ROOT);
if (!normalized.matches("#[0-9a-f]{6}(?:[0-9a-f]{2})?")) {
throw new IllegalArgumentException(name + " must be #RRGGBB or #RRGGBBAA");
}
return normalized;
}
private static String bounded(String value, String name, int minimum, int maximum) {
String safe = Objects.toString(value, "").strip();
if (safe.length() < minimum || safe.length() > maximum || safe.codePoints().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(name + " must contain " + minimum + "-" + maximum + " printable characters");
}
return safe;
}
private static String propertyValue(String value) {
String safe = Objects.toString(value, "");
if (safe.length() > 16_384 || safe.codePoints().anyMatch(codePoint ->
Character.isISOControl(codePoint) && codePoint != '\n' && codePoint != '\r' && codePoint != '\t')) {
throw new IllegalArgumentException("control property value is too long or contains control characters");
}
return safe;
}
}
package com.xfestudio.xfeservermanager.core.menu;
import com.xfestudio.xfeservermanager.core.trigger.TriggerEvaluator;
import java.time.Instant;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/**
* Version-neutral menu document edited by the Web console and rendered by a compatible client.
* Coordinates are logical pixels in the configured canvas and are scaled as one unit.
*/
public record MenuDefinition(
UUID id,
String name,
String description,
int width,
int height,
String popupPosition,
int offsetX,
int offsetY,
boolean pauseGame,
String backgroundColor,
List<Control> controls,
long revision,
String createdBy,
Instant createdAt,
Instant updatedAt) {
public static final int MIN_WIDTH = 120;
public static final int MAX_WIDTH = 1_920;
public static final int MIN_HEIGHT = 80;
public static final int MAX_HEIGHT = 1_080;
public static final int MAX_CONTROLS = 128;
public static final Set<String> POSITIONS = Set.of(
"center", "top", "bottom", "left", "right",
"top_left", "top_right", "bottom_left", "bottom_right", "custom");
public static final Set<String> CONTROL_TYPES = Set.of(
"text", "rich_text", "button", "icon_button", "image", "panel", "border",
"progress", "input", "checkbox", "slider", "item_slot", "separator", "spacer");
public static final Set<String> CONTROL_EVENTS = Set.of(
"click", "double_click", "change", "submit", "toggle", "hover", "focus", "blur");
public MenuDefinition {
Objects.requireNonNull(id, "id");
name = bounded(name, "menu name", 1, 96);
description = bounded(description, "menu description", 0, 1_024);
if (width < MIN_WIDTH || width > MAX_WIDTH) {
throw new IllegalArgumentException("menu width must be between " + MIN_WIDTH + " and " + MAX_WIDTH);
}
if (height < MIN_HEIGHT || height > MAX_HEIGHT) {
throw new IllegalArgumentException("menu height must be between " + MIN_HEIGHT + " and " + MAX_HEIGHT);
}
popupPosition = Objects.toString(popupPosition, "center").strip().toLowerCase(java.util.Locale.ROOT);
if (!POSITIONS.contains(popupPosition)) throw new IllegalArgumentException("unsupported menu popup position");
if (Math.abs(offsetX) > 8_192 || Math.abs(offsetY) > 8_192) {
throw new IllegalArgumentException("menu offsets must be between -8192 and 8192");
}
backgroundColor = color(backgroundColor, "menu backgroundColor");
controls = controls == null ? List.of() : List.copyOf(controls);
if (controls.size() > MAX_CONTROLS) {
throw new IllegalArgumentException("a menu cannot contain more than " + MAX_CONTROLS + " controls");
}
Set<String> ids = new HashSet<>();
for (Control control : controls) {
Objects.requireNonNull(control, "menu control");
if (!ids.add(control.id())) throw new IllegalArgumentException("duplicate menu control id " + control.id());
if ((long) control.x() + control.width() > width || (long) control.y() + control.height() > height) {
throw new IllegalArgumentException("menu control lies outside the canvas: " + control.id());
}
}
if (revision < 0) throw new IllegalArgumentException("menu revision must not be negative");
createdBy = bounded(createdBy, "menu creator", 1, 128);
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(updatedAt, "updatedAt");
}
public record Control(
String id,
String type,
int x,
int y,
int width,
int height,
int zIndex,
String visibleWhen,
Map<String, String> properties,
List<EventBinding> events) {
public Control {
id = bounded(id, "control id", 1, 64);
if (!id.matches("[A-Za-z_][A-Za-z0-9_.-]{0,63}")) {
throw new IllegalArgumentException("control id must be an identifier");
}
if (id.equals("__xfesm_root__")) throw new IllegalArgumentException("control id is reserved");
type = Objects.toString(type, "").strip().toLowerCase(java.util.Locale.ROOT);
if (!CONTROL_TYPES.contains(type)) throw new IllegalArgumentException("unsupported menu control type " + type);
if (x < 0 || y < 0 || width < 1 || height < 1 || width > MAX_WIDTH || height > MAX_HEIGHT) {
throw new IllegalArgumentException("menu control geometry is invalid: " + id);
}
if (zIndex < -1_000 || zIndex > 1_000) throw new IllegalArgumentException("control zIndex is out of range");
visibleWhen = bounded(visibleWhen, "control visibleWhen", 0, 512);
if (!visibleWhen.isEmpty()) TriggerEvaluator.validateTemplateVariables(visibleWhen);
Map<String, String> safeProperties = new LinkedHashMap<>();
if (properties != null) {
if (properties.size() > 48) throw new IllegalArgumentException("a control has too many properties");
properties.forEach((key, value) -> {
String safeKey = bounded(key, "control property name", 1, 64);
if (!safeKey.matches("[A-Za-z][A-Za-z0-9_.-]{0,63}")) {
throw new IllegalArgumentException("invalid control property name " + safeKey);
}
String safeValue = propertyValue(value);
TriggerEvaluator.validateTemplateVariables(safeValue);
safeProperties.put(safeKey, safeValue);
});
}
properties = Map.copyOf(safeProperties);
events = events == null ? List.of() : List.copyOf(events);
if (events.size() > CONTROL_EVENTS.size()) throw new IllegalArgumentException("a control has too many events");
Set<String> eventNames = new HashSet<>();
for (EventBinding event : events) {
Objects.requireNonNull(event, "control event");
if (!eventNames.add(event.event())) {
throw new IllegalArgumentException("duplicate " + event.event() + " binding on " + id);
}
}
}
}
/** A control event may open a submenu, invoke a menu.control trigger, close, or combine them. */
public record EventBinding(String event, String triggerId, String submenuId, boolean closeMenu) {
public EventBinding {
event = Objects.toString(event, "click").strip().toLowerCase(java.util.Locale.ROOT);
if (!CONTROL_EVENTS.contains(event)) throw new IllegalArgumentException("unsupported control event " + event);
triggerId = optionalUuid(triggerId, "triggerId");
submenuId = optionalUuid(submenuId, "submenuId");
if (triggerId.isEmpty() && submenuId.isEmpty() && !closeMenu) {
throw new IllegalArgumentException("a control event needs a trigger, submenu, or close action");
}
}
}
private static String optionalUuid(String value, String name) {
String normalized = Objects.toString(value, "").strip();
if (normalized.isEmpty()) return "";
try {
return UUID.fromString(normalized).toString();
} catch (IllegalArgumentException invalid) {
throw new IllegalArgumentException(name + " must be a UUID", invalid);
}
}
private static String color(String value, String name) {
String normalized = Objects.toString(value, "#101713e8").strip().toLowerCase(java.util.Locale.ROOT);
if (!normalized.matches("#[0-9a-f]{6}(?:[0-9a-f]{2})?")) {
throw new IllegalArgumentException(name + " must be #RRGGBB or #RRGGBBAA");
}
return normalized;
}
private static String bounded(String value, String name, int minimum, int maximum) {
String safe = Objects.toString(value, "").strip();
if (safe.length() < minimum || safe.length() > maximum || safe.codePoints().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException(name + " must contain " + minimum + "-" + maximum + " printable characters");
}
return safe;
}
private static String propertyValue(String value) {
String safe = Objects.toString(value, "");
if (safe.length() > 16_384 || safe.codePoints().anyMatch(codePoint ->
Character.isISOControl(codePoint) && codePoint != '\n' && codePoint != '\r' && codePoint != '\t')) {
throw new IllegalArgumentException("control property value is too long or contains control characters");
}
return safe;
}
}